bluenevus commited on
Commit
e34874a
·
1 Parent(s): b640a17

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +1063 -267
app.py CHANGED
@@ -3,339 +3,1135 @@ import io
3
  import os
4
  import pandas as pd
5
  from docx import Document
6
- from io import BytesIO
7
- import dash
8
- import dash_bootstrap_components as dbc
9
- from dash import html, dcc, Input, Output, State, callback_context
10
  import google.generativeai as genai
11
  from docx.shared import Pt
12
  from docx.enum.style import WD_STYLE_TYPE
13
  from PyPDF2 import PdfReader
14
- from io import StringIO
 
15
 
16
- # Initialize Dash app
17
- app = dash.Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP])
18
 
19
- # Configure Gemini AI
20
- genai.configure(api_key=os.environ["GEMINI_API_KEY"])
21
- model = genai.GenerativeModel('gemini-2.5-pro-preview-03-25')
 
 
 
 
 
 
22
 
23
- # Global variables
24
- uploaded_files = {}
25
- current_document = None
26
- document_type = None
27
- shredded_document = None
28
- pink_review_document = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
- # Document types and their descriptions
 
 
 
 
 
 
 
 
 
 
31
  document_types = {
32
- "Shred": "Generate a requirements spreadsheet of the Project Work Statement (PWS) identified by action words like shall, will, perform etc. by pws section, requirement. Do not write as if you're responding to the proposal. Its a spreadsheet to distill the requirements, not microhealth's approach",
33
- "Pink": "Create a Pink Team document based on the PWS outline. Your goal is to be compliant and compelling.",
34
- "Pink Review": "Evaluate compliance of the Pink Team document against the requirements and output a spreadsheet of non compliant findings by pws number, the goal of that pws section, what made it non compliant and your recommendations for recovery",
35
- "Red": "Produce a Red Team document based on the Pink Review by pws sections. Your goal is to be compliant and compelling by recovering all the findings in Pink Review",
36
- "Red Review": "Evaluate compliance of the Red Team document against the requirements and output a spreadsheet of non compliant findings by pws number, the goal of that pws section, what made it non compliant and your recommendations for recovery",
37
- "Gold": "Create a Pink Team document based on the PWS response by pws sections. Your goal is to be compliant and compelling by recovering all the findings in Red Review",
38
- "Gold Review": "Perform a final compliance review against the requirements and output a spreadsheet of non compliant findings by pws number, the goal of that pws section, what made it non compliant and your recommendations for recovery",
39
- "Virtual Board": "Based on the requirements and in particular the evaulation criteria, you will evaluate the proposal as if you were a contracting office and provide section by section evaluation as unsatisfactory, satisfactory, good, very good, excellent and why in a spreadsheet",
40
- "LOE": "Generate a Level of Effort (LOE) breakdown as a spreadsheet"
41
  }
42
 
43
- app.layout = dbc.Container([
44
- dbc.Row([
45
- dbc.Col([
46
- html.H4("Proposal Documents", className="mt-3 mb-4"),
47
- dcc.Upload(
48
- id='upload-document',
49
- children=html.Div([
50
- 'Drag and Drop or ',
51
- html.A('Select Files')
52
- ]),
53
- style={
54
- 'width': '100%',
55
- 'height': '60px',
56
- 'lineHeight': '60px',
57
- 'borderWidth': '1px',
58
- 'borderStyle': 'dashed',
59
- 'borderRadius': '5px',
60
- 'textAlign': 'center',
61
- 'margin': '10px 0'
62
- },
63
- multiple=True
64
- ),
65
- html.Div(id='file-list'),
66
- html.Hr(),
67
- html.Div([
68
- dbc.Button(
69
- doc_type,
70
- id=f'btn-{doc_type.lower().replace("_", "-")}',
71
- color="link",
72
- className="mb-2 w-100 text-left custom-button",
73
- style={'overflow': 'hidden', 'text-overflow': 'ellipsis', 'white-space': 'nowrap'}
74
- ) for doc_type in document_types.keys()
75
- ])
76
- ], width=3),
77
- dbc.Col([
78
- html.Div(id='status-bar', className="alert alert-info", style={'marginBottom': '20px'}),
79
  dcc.Loading(
80
  id="loading-indicator",
81
- type="dot",
82
- children=[html.Div(id="loading-output")]
 
 
 
 
83
  ),
84
- html.Div(id='document-preview', className="border p-3 mb-3"),
85
- dbc.Button("Download Document", id="btn-download", color="success", className="mt-3"),
86
- dcc.Download(id="download-document"),
87
- html.Hr(),
88
- html.Div(id='pink-review-upload', style={'display': 'none'}, children=[
89
- dcc.Upload(
90
- id='upload-pink-review',
91
- children=html.Div(['Drag and Drop or ', html.A('Select Pink Review File')]),
92
- style={
93
- 'width': '100%',
94
- 'height': '60px',
95
- 'lineHeight': '60px',
96
- 'borderWidth': '1px',
97
- 'borderStyle': 'dashed',
98
- 'borderRadius': '5px',
99
- 'textAlign': 'center',
100
- 'margin': '10px 0'
101
- },
102
- multiple=False
103
- ),
104
- html.Div(id='pink-review-file-name')
105
- ]),
106
- dcc.Loading(
107
- id="chat-loading",
108
- type="dot",
109
- children=[
110
- dbc.Input(id="chat-input", type="text", placeholder="Chat with AI to update document...", className="mb-2"),
111
- dbc.Button("Send", id="btn-send-chat", color="primary", className="mb-3"),
112
- html.Div(id="chat-output")
113
- ]
114
- )
115
- ], width=9)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  ])
117
- ], fluid=True)
 
 
 
118
 
119
  def process_document(contents, filename):
120
- content_type, content_string = contents.split(',')
121
- decoded = base64.b64decode(content_string)
 
 
 
122
  try:
 
 
 
 
 
 
123
  if filename.lower().endswith('.docx'):
124
- doc = Document(BytesIO(decoded))
125
- text = "\n".join([para.text for para in doc.paragraphs])
126
- return text
 
127
  elif filename.lower().endswith('.pdf'):
128
- pdf = PdfReader(BytesIO(decoded))
129
- text = ""
130
- for page in pdf.pages:
131
- text += page.extract_text()
132
- return text
 
 
 
 
 
 
 
 
 
 
133
  else:
134
- return f"Unsupported file format: {filename}. Please upload a PDF or DOCX file."
 
 
 
135
  except Exception as e:
136
- return f"Error processing document: {str(e)}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
 
 
138
  @app.callback(
139
  Output('file-list', 'children'),
140
- Output('status-bar', 'children'),
141
  Input('upload-document', 'contents'),
142
  State('upload-document', 'filename'),
143
- State('file-list', 'children')
 
144
  )
145
- def update_output(list_of_contents, list_of_names, existing_files):
146
- global uploaded_files, shredded_document
147
- if list_of_contents is not None:
148
- new_files = []
149
- for i, (content, name) in enumerate(zip(list_of_contents, list_of_names)):
150
- file_content = process_document(content, name)
151
- uploaded_files[name] = file_content
152
- new_files.append(html.Div([
153
- html.Button('×', id={'type': 'remove-file', 'index': name}, style={'marginRight': '5px', 'fontSize': '10px'}),
154
- html.Span(name)
155
- ]))
156
- if existing_files is None:
157
- existing_files = []
158
- shredded_document = None # Reset shredded document when new files are uploaded
159
- return existing_files + new_files, "Document uploaded. Please click 'Shred' to proceed."
160
- return existing_files, "Please upload a document and click 'Shred' to begin."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  @app.callback(
163
  Output('file-list', 'children', allow_duplicate=True),
164
  Output('status-bar', 'children', allow_duplicate=True),
165
- Input({'type': 'remove-file', 'index': dash.ALL}, 'n_clicks'),
 
166
  State('file-list', 'children'),
167
  prevent_initial_call=True
168
  )
169
- def remove_file(n_clicks, existing_files):
170
- global uploaded_files, shredded_document
171
- ctx = dash.callback_context
172
- if not ctx.triggered:
173
- raise dash.exceptions.PreventUpdate
174
- removed_file = ctx.triggered[0]['prop_id'].split(',')[0].split(':')[-1].strip('}')
175
- uploaded_files.pop(removed_file, None)
176
- shredded_document = None # Reset shredded document when a file is removed
177
- return [file for file in existing_files if file['props']['children'][1]['props']['children'] != removed_file], "Document removed. Please upload a document and click 'Shred' to begin."
178
-
179
- def generate_document(document_type, file_contents):
180
- prompt = f"""Generate a {document_type} based on the following project artifacts:
181
- {' '.join(file_contents)}
182
- Instructions:
183
- 1. Create the {document_type} as a detailed document.
184
- 2. Use proper formatting and structure.
185
- 3. Include all necessary sections and details.
186
- 4. Start the output immediately with the document content.
187
- 5. IMPORTANT: If the document type is Pink, Red, Gold and not review type, loe or board
188
- then your goal is to be compliant and compelling based on the
189
- requrements, write in paragraph in active voice as
190
- MicroHealth, limit bullets, answer the
191
- requirement with what MicroHealth will do
192
- to satisfy the requirement, the technical
193
- approach with innovation for efficiency,
194
- productivity, quality and measurable
195
- outcomes, the industry standard that
196
- methodology is based on if applicable,
197
- detail the workflow or steps to accomplish
198
- the requirement with labor categories that
199
- will do those tasks in that workflow,
200
- reference reputable research like gartner,
201
- forrester, IDC, Deloitte, Accenture etc
202
- with measures of success and substantiation
203
- of MicroHealth's approach. Never use soft words
204
- like maybe, could be, should, possible be definitive in your language and confident.
205
- 6. you must also take into account section L&M of the document which is the evaluation criteria
206
- to be sure we address them.
207
- Now, generate the {document_type}:
208
- """
209
 
210
- response = model.generate_content(prompt)
211
- return response.text
 
 
 
 
 
 
 
 
 
 
 
212
 
 
 
 
 
 
 
 
 
 
 
 
213
  @app.callback(
214
- Output('document-preview', 'children'),
215
- Output('loading-output', 'children'),
216
  Output('status-bar', 'children', allow_duplicate=True),
217
- Output('pink-review-upload', 'style'),
218
- [Input(f'btn-{doc_type.lower().replace("_", "-")}', 'n_clicks') for doc_type in document_types.keys()],
219
- State('pink-review-file-name', 'children'),
 
 
220
  prevent_initial_call=True
221
  )
222
- def generate_document_preview(*args):
223
- global current_document, document_type, shredded_document, pink_review_document
224
- ctx = dash.callback_context
225
- if not ctx.triggered:
226
- raise dash.exceptions.PreventUpdate
227
- button_id = ctx.triggered[0]['prop_id'].split('.')[0]
228
- document_type = button_id.replace('btn-', '').replace('-', '_').title()
229
- pink_review_file = args[-1]
230
-
231
- if not uploaded_files and document_type != "Shred":
232
- return html.Div("Please upload and shred a document first."), "", "Please upload and shred a document first.", {'display': 'none'}
233
-
234
- if document_type == "Shred":
235
- if not uploaded_files:
236
- return html.Div("Please upload a document before shredding."), "", "Please upload a document before shredding.", {'display': 'none'}
237
- file_contents = list(uploaded_files.values())
238
- try:
239
- shredded_document = generate_document(document_type, file_contents)
240
- return dcc.Markdown(shredded_document), f"{document_type} generated", "Document shredded. You can now proceed with other operations.", {'display': 'none'}
241
- except Exception as e:
242
- print(f"Error generating document: {str(e)}")
243
- return html.Div(f"Error generating document: {str(e)}"), "Error", "An error occurred while shredding the document.", {'display': 'none'}
244
 
245
- if shredded_document is None:
246
- return html.Div("Please shred a document first."), "", "Please shred a document first.", {'display': 'none'}
 
247
 
248
- if document_type == "Pink Review":
249
- return html.Div("Please upload a Pink Team document or use the generated one."), "", "Please upload a Pink Team document or use the generated one.", {'display': 'block'}
 
250
 
251
- if document_type in ["Red", "Red Review"] and pink_review_document is None:
252
- return html.Div("Please complete Pink Review first."), "", "Please complete Pink Review first.", {'display': 'none'}
253
 
254
- try:
255
- if document_type == "Pink Review" and pink_review_file:
256
- current_document = generate_document(document_type, [pink_review_file, shredded_document])
257
- elif document_type in ["Red", "Red Review"]:
258
- current_document = generate_document(document_type, [pink_review_document, shredded_document])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  else:
260
- current_document = generate_document(document_type, [shredded_document])
 
 
261
 
262
- if document_type == "Pink Review":
263
- pink_review_document = current_document
264
 
265
- return dcc.Markdown(current_document), f"{document_type} generated", f"{document_type} document generated successfully.", {'display': 'none'}
266
- except Exception as e:
267
- print(f"Error generating document: {str(e)}")
268
- return html.Div(f"Error generating document: {str(e)}"), "Error", "An error occurred while generating the document.", {'display': 'none'}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  @app.callback(
271
- Output('pink-review-file-name', 'children'),
272
- Input('upload-pink-review', 'contents'),
273
- State('upload-pink-review', 'filename')
 
274
  )
275
- def update_pink_review_filename(contents, filename):
276
- if contents is not None:
277
- return filename
278
- return ""
279
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  @app.callback(
281
- Output('chat-output', 'children'),
282
  Output('document-preview', 'children', allow_duplicate=True),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  Input('btn-send-chat', 'n_clicks'),
284
- State('chat-input', 'value'),
285
  prevent_initial_call=True
286
  )
287
- def update_document_via_chat(n_clicks, chat_input):
288
- global current_document, document_type
289
- if not chat_input or current_document is None:
290
- raise dash.exceptions.PreventUpdate
291
-
292
- prompt = f"""Update the following {document_type} based on this instruction: {chat_input}
293
- Current document:
294
- {current_document}
295
- Instructions:
296
- 1. Provide the updated document content.
297
- 2. Maintain proper formatting and structure.
298
- 3. Incorporate the requested changes seamlessly.
299
- Now, provide the updated {document_type}:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  """
301
-
302
- response = model.generate_content(prompt)
303
- current_document = response.text
304
-
305
- return f"Document updated based on: {chat_input}", dcc.Markdown(current_document)
306
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  @app.callback(
308
  Output("download-document", "data"),
309
  Input("btn-download", "n_clicks"),
310
  prevent_initial_call=True
311
  )
312
- def download_document(n_clicks):
313
- global current_document, document_type
314
- if current_document is None:
315
- raise dash.exceptions.PreventUpdate
316
-
317
- if document_type == "LOE":
318
- # Create a pandas DataFrame for LOE
319
- df = pd.read_csv(StringIO(current_document))
320
-
321
- # Save the DataFrame to an Excel file
322
- output = BytesIO()
323
- with pd.ExcelWriter(output, engine='xlsxwriter') as writer:
324
- df.to_excel(writer, sheet_name='LOE', index=False)
325
-
326
- return dcc.send_bytes(output.getvalue(), f"{document_type}.xlsx")
327
- else:
328
- # Create an in-memory Word document
329
- doc = Document()
330
- doc.add_paragraph(current_document)
331
-
332
- # Save the document to a BytesIO object
333
- output = BytesIO()
334
- doc.save(output)
335
-
336
- return dcc.send_bytes(output.getvalue(), f"{document_type}.docx")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
 
 
 
338
  if __name__ == '__main__':
339
  print("Starting the Dash application...")
 
 
 
 
 
340
  app.run(debug=False, host='0.0.0.0', port=7860)
341
  print("Dash application has finished running.")
 
3
  import os
4
  import pandas as pd
5
  from docx import Document
6
+ from io import BytesIO, StringIO
7
+ import dash # Version 3.0.3
8
+ import dash_bootstrap_components as dbc # Version 2.0.2
9
+ from dash import html, dcc, Input, Output, State, callback_context, PreventUpdate, ALL
10
  import google.generativeai as genai
11
  from docx.shared import Pt
12
  from docx.enum.style import WD_STYLE_TYPE
13
  from PyPDF2 import PdfReader
14
+ import logging
15
+ import uuid # For unique IDs if needed with pattern matching
16
 
17
+ # --- Logging Configuration ---
18
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
19
 
20
+ # --- Initialize Dash app ---
21
+ # Using Bootstrap for layout and styling. Added meta tags for responsiveness.
22
+ # dash==3.0.3
23
+ # dash-bootstrap-components==2.0.2
24
+ app = dash.Dash(__name__,
25
+ external_stylesheets=[dbc.themes.BOOTSTRAP],
26
+ suppress_callback_exceptions=True, # Needed because controls are dynamically added
27
+ meta_tags=[{"name": "viewport", "content": "width=device-width, initial-scale=1"}])
28
+ server = app.server # Expose server for Gunicorn
29
 
30
+ # --- Configure Gemini AI ---
31
+ # IMPORTANT: Set the GEMINI_API_KEY environment variable before running the app.
32
+ try:
33
+ # Prefer direct CUDA GPU configuration in app.py - Note: This is not applicable for cloud-based APIs like Gemini. Configuration happens via API key.
34
+ api_key = os.environ.get("GEMINI_API_KEY")
35
+ if not api_key:
36
+ logging.warning("GEMINI_API_KEY environment variable not found. AI features will be disabled.")
37
+ model = None
38
+ else:
39
+ genai.configure(api_key=api_key)
40
+ # Specify a model compatible with function calling or more advanced generation if needed.
41
+ # Using 'gemini-pro' as a generally available and capable model.
42
+ model = genai.GenerativeModel('gemini-pro')
43
+ logging.info("Gemini AI configured successfully using 'gemini-pro'.")
44
+ except Exception as e:
45
+ logging.error(f"Error configuring Gemini AI: {e}", exc_info=True)
46
+ model = None
47
+
48
+ # --- Global Variables ---
49
+ # Using dictionaries to store session-specific data might be better for multi-user,
50
+ # but for simplicity with current constraints, we use global vars.
51
+ # Consider using dcc.Store for better state management in complex scenarios.
52
+ uploaded_files = {} # {filename: content_text}
53
+
54
+ # Stores the *results* of generation/review steps
55
+ shredded_document = None # Text content of the shredded PWS/requirements
56
+ pink_review_document = None # Text content of the generated Pink Review
57
+ red_review_document = None # Text content of the generated Red Review
58
+ gold_review_document = None # Text content of the generated Gold Review
59
+ loe_document = None # Text content of the generated LOE
60
+ virtual_board_document = None # Text content of the generated Virtual Board
61
+
62
+ # Stores the *generated* proposal drafts
63
+ pink_document = None # Text content of the generated Pink Team document
64
+ red_document = None # Text content of the generated Red Team document
65
+ gold_document = None # Text content of the generated Gold Team document
66
 
67
+ # Store uploaded content specifically for review inputs
68
+ uploaded_pink_content = None
69
+ uploaded_red_content = None
70
+ uploaded_gold_content = None
71
+
72
+ # Store the currently displayed document and its type for download/chat
73
+ current_display_document = None
74
+ current_display_type = None
75
+
76
+ # --- Document Types ---
77
+ # Descriptions adjusted slightly for clarity
78
  document_types = {
79
+ "Shred": "Generate a requirements spreadsheet from the PWS/Source Docs, identifying action words (shall, will, perform, etc.) by section.",
80
+ "Pink": "Create a compliant and compelling Pink Team proposal draft based on the Shredded requirements.",
81
+ "Pink Review": "Evaluate a Pink Team draft against Shredded requirements. Output findings (compliance, gaps, recommendations) in a spreadsheet.",
82
+ "Red": "Create a Red Team proposal draft, addressing feedback from the Pink Review and enhancing compliance/compellingness.",
83
+ "Red Review": "Evaluate a Red Team draft against Shredded requirements and Pink Review findings. Output findings in a spreadsheet.",
84
+ "Gold": "Create a Gold Team proposal draft, addressing feedback from the Red Review for final compliance and polish.",
85
+ "Gold Review": "Perform a final compliance review of the Gold Team draft against Shredded requirements and Red Review findings. Output findings.",
86
+ "Virtual Board": "Simulate a source selection board evaluation of the final proposal against PWS/Shred requirements and evaluation criteria (Sec L/M). Output evaluation.",
87
+ "LOE": "Generate a Level of Effort (LOE) estimate spreadsheet based on the Shredded requirements."
88
  }
89
 
90
+ # --- Layout Definition ---
91
+ # Using Dash Bootstrap Components for layout and Cards for logical separation.
92
+ # Single form layout functions for modern design.
93
+ app.layout = dbc.Container(fluid=True, className="dbc", children=[
94
+ # Title Row - Full Width
95
+ dbc.Row(
96
+ dbc.Col(html.H1("Proposal AI Assistant", className="text-center my-4", style={'color': '#1C304A'}), width=12)
97
+ ),
98
+
99
+ # Progress Indicator Row (Initially Hidden) - Full Width below title, above columns
100
+ dbc.Row(
101
+ dbc.Col(
102
+ # Blinking triple dot for progress
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  dcc.Loading(
104
  id="loading-indicator",
105
+ type="dots", # Changed type to dots as requested
106
+ children=[html.Div(id="loading-output", style={'height': '10px'})], # Placeholder content
107
+ overlay_style={"visibility":"hidden", "opacity": 0}, # Make overlay invisible
108
+ style={'visibility':'hidden', 'height': '30px'}, # Hide initially via style, give some height
109
+ fullscreen=False, # Keep it contained
110
+ className="justify-content-center"
111
  ),
112
+ width=12,
113
+ className="text-center mb-3" # Center the dots
114
+ )
115
+ ),
116
+
117
+ # Main Content Row (Two Columns)
118
+ dbc.Row([
119
+ # Left Column (Navigation / Upload) - 30% width, light gray background
120
+ dbc.Col(
121
+ dbc.Card(
122
+ dbc.CardBody([
123
+ html.H4("1. Upload Source Documents", className="card-title"),
124
+ dcc.Upload(
125
+ id='upload-document',
126
+ children=html.Div(['Drag and Drop or ', html.A('Select PWS/Source Files (.docx, .pdf)')]),
127
+ style={
128
+ 'width': '100%', 'height': '60px', 'lineHeight': '60px',
129
+ 'borderWidth': '1px', 'borderStyle': 'dashed', 'borderRadius': '5px',
130
+ 'textAlign': 'center', 'margin': '10px 0', 'backgroundColor': '#ffffff' # White background for contrast
131
+ },
132
+ multiple=True # Allow multiple source files
133
+ ),
134
+ # Use Card for file list for better visual grouping
135
+ dbc.Card(
136
+ dbc.CardBody(
137
+ html.Div(id='file-list', style={'maxHeight': '150px', 'overflowY': 'auto', 'fontSize': '0.9em'})
138
+ ), className="mb-3" , style={'backgroundColor': '#ffffff'}
139
+ ),
140
+ html.Hr(),
141
+ html.H4("2. Select Action", className="card-title mt-3"),
142
+ # Buttons for actions - Use Card for button group
143
+ dbc.Card(
144
+ dbc.CardBody([
145
+ # Use primary button style defined in CSS request (implicitly via dbc class)
146
+ *[dbc.Button(
147
+ doc_type,
148
+ id={'type': 'action-button', 'index': doc_type}, # Use pattern-matching ID
149
+ color="primary", # Primary style
150
+ className="mb-2 w-100 d-block", # d-block for full width buttons
151
+ style={'textAlign': 'left', 'whiteSpace': 'normal', 'height': 'auto', 'wordWrap': 'break-word'} # Allow wrap
152
+ ) for doc_type in document_types.keys()]
153
+ ])
154
+ )
155
+ ])
156
+ , color="light"), # Use Bootstrap 'light' color for card background -> light gray
157
+ width=12, lg=4, # Full width on small screens, 30% (4/12) on large
158
+ className="mb-3 mb-lg-0", # Margin bottom on small screens
159
+ style={'padding': '15px'}
160
+ ),
161
+
162
+ # Right Column (Status / Preview / Controls / Chat) - 70% width, white background
163
+ dbc.Col(
164
+ dbc.Card(
165
+ dbc.CardBody([
166
+ # Status Bar
167
+ dbc.Alert(id='status-bar', children="Upload source documents and select an action.", color="info"),
168
+
169
+ # Dynamic Controls for Reviews - Use Card for visual separation
170
+ dbc.Card(id='review-controls-card', children=[dbc.CardBody(id='review-controls')], className="mb-3", style={'display': 'none'}), # Hidden initially
171
+
172
+ # Document Preview Area - Use Card
173
+ dbc.Card(
174
+ dbc.CardBody([
175
+ html.H5("Document Preview / Output", className="card-title"),
176
+ # Wrap preview in Loading
177
+ dcc.Loading(
178
+ id="loading-preview", # Separate loading for preview
179
+ type="circle",
180
+ children=[html.Div(id='document-preview', style={'whiteSpace': 'pre-wrap', 'maxHeight': '400px', 'overflowY': 'auto', 'border': '1px solid #ccc', 'padding': '10px', 'borderRadius': '5px', 'background': '#f8f9fa'})]
181
+ )
182
+ ]), className="mb-3"
183
+ ),
184
+ dbc.Button("Download Output", id="btn-download", color="success", className="mt-3", style={'display': 'none'}), # Hidden initially
185
+ dcc.Download(id="download-document"),
186
+
187
+ html.Hr(),
188
+
189
+ # Chat Section - Use Card
190
+ dbc.Card(
191
+ dbc.CardBody([
192
+ html.H5("Refine Output (Chat)", className="card-title"),
193
+ # Wrap chat in loading
194
+ dcc.Loading(
195
+ id="chat-loading",
196
+ type="circle",
197
+ children=[
198
+ dbc.Textarea(id="chat-input", placeholder="Enter instructions to refine the document shown above...", className="mb-2", style={'whiteSpace': 'normal', 'wordWrap': 'break-word'}), # Ensure word wrap
199
+ dbc.Button("Send Chat", id="btn-send-chat", color="secondary", className="mb-3"), # Use secondary style
200
+ html.Div(id="chat-output", style={'whiteSpace': 'pre-wrap', 'marginTop': '10px', 'border': '1px solid #eee', 'padding': '10px', 'borderRadius': '5px', 'minHeight': '50px'}) # Add border/padding
201
+ ]
202
+ )
203
+ ]), className="mb-3"
204
+ )
205
+ ])
206
+ ),
207
+ width=12, lg=8, # Full width on small screens, 70% (8/12) on large
208
+ style={'backgroundColor': '#ffffff', 'padding': '15px'} # White background
209
+ )
210
  ])
211
+ ], style={'maxWidth': '100%', 'padding': '0 15px'}) # Max width and padding for container
212
+
213
+
214
+ # --- Helper Functions ---
215
 
216
  def process_document(contents, filename):
217
+ """Processes uploaded file content (PDF or DOCX) and returns text, or None and error message."""
218
+ if contents is None:
219
+ logging.warning(f"process_document called with None contents for {filename}")
220
+ return None, f"Error: No content provided for {filename}."
221
+
222
  try:
223
+ content_type, content_string = contents.split(',')
224
+ decoded = base64.b64decode(content_string)
225
+ logging.info(f"Processing file: {filename}")
226
+ text = None
227
+ error_message = None
228
+
229
  if filename.lower().endswith('.docx'):
230
+ doc = Document(io.BytesIO(decoded))
231
+ # Extract text, ensuring paragraphs are separated and empty ones are skipped
232
+ text = "\n".join([para.text for para in doc.paragraphs if para.text.strip()])
233
+ logging.info(f"Successfully processed DOCX: {filename}")
234
  elif filename.lower().endswith('.pdf'):
235
+ pdf = PdfReader(io.BytesIO(decoded))
236
+ extracted_pages = []
237
+ for i, page in enumerate(pdf.pages):
238
+ try:
239
+ page_text = page.extract_text()
240
+ if page_text:
241
+ extracted_pages.append(page_text)
242
+ except Exception as page_e:
243
+ logging.warning(f"Could not extract text from page {i+1} of {filename}: {page_e}")
244
+ text = "\n\n".join(extracted_pages) # Separate pages clearly
245
+ if not text:
246
+ logging.warning(f"No text extracted from PDF: {filename}. It might be image-based or corrupted.")
247
+ error_message = f"Error: No text could be extracted from PDF {filename}. It might be image-based or require OCR."
248
+ else:
249
+ logging.info(f"Successfully processed PDF: {filename}")
250
  else:
251
+ logging.warning(f"Unsupported file format: {filename}")
252
+ error_message = f"Unsupported file format: {filename}. Please upload PDF or DOCX."
253
+
254
+ return text, error_message
255
  except Exception as e:
256
+ logging.error(f"Error processing document {filename}: {e}", exc_info=True)
257
+ return None, f"Error processing file {filename}: {str(e)}"
258
+
259
+ def get_combined_uploaded_text():
260
+ """Combines text content of all successfully uploaded files, separated clearly."""
261
+ if not uploaded_files:
262
+ return ""
263
+ # Join contents with a separator indicating file breaks
264
+ return "\n\n--- FILE BREAK ---\n\n".join(uploaded_files.values())
265
+
266
+ def generate_ai_document(doc_type, input_docs, context_docs=None):
267
+ """Generates document using Gemini AI. Updates current_display."""
268
+ global current_display_document, current_display_type # Allow modification
269
+
270
+ if not model:
271
+ logging.error("Gemini AI model not initialized.")
272
+ return "Error: AI Model not configured. Please check API Key."
273
+ if not input_docs or not any(input_docs): # Check if list exists and has content
274
+ logging.warning(f"generate_ai_document called for {doc_type} with no input documents.")
275
+ return f"Error: Missing required input document(s) for {doc_type} generation."
276
+
277
+ # Combine input documents into a single string
278
+ combined_input = "\n\n---\n\n".join(filter(None, input_docs))
279
+ combined_context = "\n\n---\n\n".join(filter(None, context_docs)) if context_docs else ""
280
+
281
+ # Enhanced prompt structure based on user feedback and best practices
282
+ prompt = f"""**Objective:** Generate the '{doc_type}' document.
283
+
284
+ **Your Role:** Act as an expert proposal writer/analyst.
285
+
286
+ **Core Instructions:**
287
+ 1. **Adhere Strictly to the Task:** Generate *only* the content for the '{doc_type}'. Do not add introductions, summaries, or conversational filler unless it's part of the requested document format itself.
288
+ 2. **Follow Format Guidelines:**
289
+ * **Spreadsheet Types (Shred, Reviews, LOE, Board):** Structure output clearly. Use Markdown tables or a delimited format (like CSV) suitable for parsing. Define clear columns (e.g., `PWS_Section | Requirement | Finding | Recommendation` for reviews; `Section | Task | Estimated_Hours | Resource_Type` for LOE).
290
+ * **Proposal Sections (Pink, Red, Gold):** Write professional, compelling prose. Use active voice ("MicroHealth will..."). Directly address requirements from context (Shredded PWS). Detail the 'how' (technical approach, methodology, workflow, tools). Incorporate innovation and benefits (efficiency, quality, outcomes). Substantiate claims (e.g., cite Gartner, Forrester if applicable). Clearly state roles/responsibilities (labor categories). Ensure compliance with Section L/M (Evaluation Criteria) from context. Avoid vague terms ('might', 'could', 'potentially'); be assertive and confident. Use paragraphs primarily; limit bullet points to lists where essential.
291
+ 3. **Utilize Provided Documents:**
292
+ * **Context Document(s):** Use these as the primary reference or baseline (e.g., Shredded Requirements are the basis for compliance).
293
+ * **Primary Input Document(s):** This is the main subject of the task (e.g., the PWS to be Shredded, the Pink draft to be Reviewed, the Review findings to incorporate into the next draft).
294
+
295
+ **Provided Documents:**
296
+
297
+ **Context Document(s) (e.g., Shredded Requirements, PWS Section L/M):**
298
+ ```text
299
+ {combined_context if combined_context else "N/A"}
300
+ ```
301
+
302
+ **Primary Input Document(s) (e.g., PWS text, Pink Draft text, Review Findings text):**
303
+ ```text
304
+ {combined_input}
305
+ ```
306
+
307
+ **Detailed Instructions for '{doc_type}':**
308
+ {document_types.get(doc_type, "Generate the requested document based on the inputs and context.")}
309
+
310
+ **Begin '{doc_type}' Output:**
311
+ """
312
+
313
+ logging.info(f"Generating AI document for: {doc_type}")
314
+ # logging.debug(f"Prompt for {doc_type}: {prompt[:500]}...") # Uncomment for debugging prompt starts
315
+
316
+ try:
317
+ # Increased timeout might be needed for complex generations
318
+ response = model.generate_content(prompt) # Consider adding request_options={'timeout': 300} if needed
319
+
320
+ # Handle potential safety blocks or empty responses
321
+ if not response.parts:
322
+ logging.warning(f"Gemini AI returned no parts for {doc_type}. Potential safety block or empty response.")
323
+ generated_text = f"Error: AI returned no content for {doc_type}. This might be due to safety filters or an issue with the prompt/input."
324
+ else:
325
+ generated_text = response.text
326
+
327
+ logging.info(f"Successfully generated document for: {doc_type}")
328
+ # Update global state for download/chat *only if successful*
329
+ if not generated_text.startswith("Error:"):
330
+ current_display_document = generated_text
331
+ current_display_type = doc_type
332
+ else:
333
+ # Ensure error message is displayed if AI returns an error internally
334
+ current_display_document = generated_text
335
+ current_display_type = doc_type # Still set type so user knows what failed
336
+
337
+ return generated_text
338
+ except Exception as e:
339
+ logging.error(f"Error during Gemini AI call for {doc_type}: {e}", exc_info=True)
340
+ # Update display with error message
341
+ current_display_document = f"Error generating document via AI for {doc_type}: {str(e)}"
342
+ current_display_type = doc_type
343
+ return current_display_document
344
+
345
+
346
+ # --- Callbacks ---
347
 
348
+ # 1. Handle File Uploads (Source Documents)
349
  @app.callback(
350
  Output('file-list', 'children'),
351
+ Output('status-bar', 'children', allow_duplicate=True),
352
  Input('upload-document', 'contents'),
353
  State('upload-document', 'filename'),
354
+ State('file-list', 'children'),
355
+ prevent_initial_call=True
356
  )
357
+ def handle_file_upload(list_of_contents, list_of_names, existing_files_display):
358
+ global uploaded_files
359
+ # Reset downstream data when new source files are uploaded, as context changes
360
+ global shredded_document, pink_document, pink_review_document, red_document, red_review_document, gold_document, gold_review_document, loe_document, virtual_board_document, current_display_document, current_display_type, uploaded_pink_content, uploaded_red_content, uploaded_gold_content
361
+
362
+ status_message = "Please upload source documents (.pdf, .docx) and select an action."
363
+ if list_of_contents is None:
364
+ raise PreventUpdate
365
+
366
+ new_files_display = []
367
+ processed_count = 0
368
+ error_count = 0
369
+ reset_needed = False
370
+
371
+ if existing_files_display is None:
372
+ existing_files_display = []
373
+
374
+ # Get current filenames from the display to avoid duplicates
375
+ current_filenames = set()
376
+ if existing_files_display:
377
+ # Handle potential list vs single item
378
+ file_list_items = existing_files_display if isinstance(existing_files_display, list) else [existing_files_display]
379
+ for item in file_list_items:
380
+ # Check structure carefully based on Div/Button/Span
381
+ if isinstance(item, html.Div) and len(item.children) > 1 and isinstance(item.children[1], html.Span):
382
+ current_filenames.add(item.children[1].children)
383
+
384
+
385
+ for i, (content, name) in enumerate(zip(list_of_contents, list_of_names)):
386
+ if name in current_filenames:
387
+ logging.warning(f"Skipping duplicate upload attempt for source file: {name}")
388
+ continue # Avoid processing duplicates
389
+
390
+ file_content_text, error = process_document(content, name)
391
+
392
+ if error:
393
+ logging.error(f"Failed to process source file {name}: {error}")
394
+ error_count += 1
395
+ status_message = f"Error processing {name}. {error}" # Show last error
396
+ continue # Skip adding failed files
397
+
398
+ if file_content_text is not None: # Allow empty files if processing is successful
399
+ uploaded_files[name] = file_content_text
400
+ # Use dbc.Button for remove, styled small
401
+ new_files_display.append(html.Div([
402
+ dbc.Button('X', id={'type': 'remove-file', 'index': name}, size="sm", color="danger", className="me-2 py-0 px-1", n_clicks=0),
403
+ html.Span(name, title=name) # Add tooltip with full name
404
+ ], className="d-flex align-items-center mb-1"))
405
+ processed_count += 1
406
+ current_filenames.add(name) # Add to tracking set
407
+ reset_needed = True # Mark that downstream docs should be cleared
408
+
409
+ if reset_needed:
410
+ logging.info("New source files uploaded, resetting downstream generated documents.")
411
+ shredded_document = None
412
+ pink_document = None
413
+ pink_review_document = None
414
+ red_document = None
415
+ red_review_document = None
416
+ gold_document = None
417
+ gold_review_document = None
418
+ loe_document = None
419
+ virtual_board_document = None
420
+ current_display_document = None # Clear preview
421
+ current_display_type = None
422
+ uploaded_pink_content = None # Also clear review uploads if source changes
423
+ uploaded_red_content = None
424
+ uploaded_gold_content = None
425
 
426
+
427
+ if processed_count > 0:
428
+ status_message = f"Successfully uploaded {processed_count} source file(s). Ready for 'Shred' or other actions."
429
+ elif error_count > 0 and processed_count == 0:
430
+ status_message = "Failed to process uploaded file(s). Check logs. Ensure they are valid PDF/DOCX with extractable text."
431
+ elif not new_files_display: # Means only duplicates were uploaded or upload was empty
432
+ status_message = "No new valid source files were added."
433
+
434
+
435
+ # Combine existing and new display items
436
+ final_display_list = (existing_files_display if isinstance(existing_files_display, list) else [existing_files_display] if existing_files_display else []) + new_files_display
437
+
438
+ return final_display_list, status_message
439
+
440
+ # 2. Handle File Removal (Source Documents)
441
  @app.callback(
442
  Output('file-list', 'children', allow_duplicate=True),
443
  Output('status-bar', 'children', allow_duplicate=True),
444
+ # Use pattern-matching ID for the Input
445
+ Input({'type': 'remove-file', 'index': ALL}, 'n_clicks'),
446
  State('file-list', 'children'),
447
  prevent_initial_call=True
448
  )
449
+ def handle_file_remove(n_clicks, current_file_list_display):
450
+ global uploaded_files
451
+ # Reset downstream data when a source file is removed
452
+ global shredded_document, pink_document, pink_review_document, red_document, red_review_document, gold_document, gold_review_document, loe_document, virtual_board_document, current_display_document, current_display_type, uploaded_pink_content, uploaded_red_content, uploaded_gold_content
453
+
454
+ triggered_id_dict = callback_context.triggered_id
455
+ # Check if the callback was triggered by a pattern-matching ID and n_clicks increased
456
+ if not triggered_id_dict or not isinstance(triggered_id_dict, dict) or 'index' not in triggered_id_dict:
457
+ raise PreventUpdate
458
+
459
+ # Check if any click count is > 0 (or just check the specific one that triggered)
460
+ if not any(nc > 0 for nc in n_clicks if nc is not None): # Ensure click happened
461
+ raise PreventUpdate
462
+
463
+
464
+ file_to_remove = triggered_id_dict['index']
465
+ logging.info(f"Attempting to remove source file: {file_to_remove}")
466
+
467
+ if file_to_remove in uploaded_files:
468
+ del uploaded_files[file_to_remove]
469
+ logging.info(f"Removed {file_to_remove} from uploaded_files dictionary.")
470
+
471
+ # Reset downstream docs since context changed
472
+ logging.info("Source file removed, resetting downstream generated documents.")
473
+ shredded_document = None
474
+ pink_document = None
475
+ pink_review_document = None
476
+ red_document = None
477
+ red_review_document = None
478
+ gold_document = None
479
+ gold_review_document = None
480
+ loe_document = None
481
+ virtual_board_document = None
482
+ current_display_document = None # Clear preview
483
+ current_display_type = None
484
+ uploaded_pink_content = None # Also clear review uploads
485
+ uploaded_red_content = None
486
+ uploaded_gold_content = None
 
 
487
 
488
+ # Filter the display list
489
+ updated_file_list_display = []
490
+ if current_file_list_display:
491
+ # Handle potential list vs single item
492
+ file_list_items = current_file_list_display if isinstance(current_file_list_display, list) else [current_file_list_display]
493
+ updated_file_list_display = [
494
+ item for item in file_list_items
495
+ # Check structure carefully based on Div/Button/Span
496
+ if not (isinstance(item, html.Div) and
497
+ item.children and isinstance(item.children[0], dbc.Button) and
498
+ isinstance(item.children[0].id, dict) and
499
+ item.children[0].id.get('index') == file_to_remove)
500
+ ]
501
 
502
+
503
+ status_message = f"Removed source file: {file_to_remove}. "
504
+ if not uploaded_files:
505
+ status_message += "No source files remaining. Please upload documents."
506
+ else:
507
+ status_message += "Ready for 'Shred' or other actions."
508
+
509
+ return updated_file_list_display, status_message
510
+
511
+
512
+ # 3. Handle Action Button Clicks (Show Controls or Trigger Generation)
513
  @app.callback(
514
+ Output('review-controls-card', 'style'), # Show/hide the whole card
515
+ Output('review-controls', 'children'), # Content of the card body
516
  Output('status-bar', 'children', allow_duplicate=True),
517
+ Output('document-preview', 'children', allow_duplicate=True),
518
+ Output('loading-indicator', 'style'), # Show/hide main loading indicator (dots)
519
+ Output('btn-download', 'style', allow_duplicate=True), # Show/hide download button
520
+ # Use pattern-matching ID for the Input
521
+ Input({'type': 'action-button', 'index': ALL}, 'n_clicks'),
522
  prevent_initial_call=True
523
  )
524
+ def handle_action_button(n_clicks):
525
+ global shredded_document, pink_document, red_document, gold_document, pink_review_document, red_review_document, gold_review_document, loe_document, virtual_board_document
526
+ # Reset potentially uploaded review files when a *new* main action is selected from the left nav
527
+ global uploaded_pink_content, uploaded_red_content, uploaded_gold_content
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
528
 
529
+ triggered_id_dict = callback_context.triggered_id
530
+ if not triggered_id_dict or not isinstance(triggered_id_dict, dict) or 'index' not in triggered_id_dict:
531
+ raise PreventUpdate
532
 
533
+ # Check if any click count is > 0
534
+ if not any(nc > 0 for nc in n_clicks if nc is not None):
535
+ raise PreventUpdate
536
 
537
+ action_type = triggered_id_dict['index']
538
+ logging.info(f"Action button clicked: {action_type}")
539
 
540
+ # Default states
541
+ review_controls_style = {'display': 'none'} # Hide review controls by default
542
+ review_controls_children = []
543
+ status_message = f"Selected action: {action_type}"
544
+ doc_preview_children = dash.no_update # Avoid clearing preview unless needed
545
+ loading_style = {'visibility':'hidden'} # Hide loading dots by default
546
+ download_style = {'display': 'none'} # Hide download button by default
547
+
548
+
549
+ # Reset previously uploaded review files content when a *new* action is selected.
550
+ # This prevents using an old uploaded file for a new review type accidentally.
551
+ uploaded_pink_content = None
552
+ uploaded_red_content = None
553
+ uploaded_gold_content = None
554
+ logging.debug("Cleared any previously uploaded review document content.")
555
+
556
+ # --- Actions Requiring Review Controls (Pink/Red/Gold Review) ---
557
+ if action_type in ["Pink Review", "Red Review", "Gold Review"]:
558
+ review_controls_style = {'display': 'block'} # Show the review controls card
559
+ base_doc_type = action_type.split(" ")[0] # Pink, Red, or Gold
560
+ prereq_doc = None
561
+ prereq_doc_name = ""
562
+ generated_doc_to_review = None
563
+ generated_doc_name = f"Generated {base_doc_type} Document"
564
+ upload_file_prompt = f"Select {base_doc_type} File"
565
+
566
+ # Check common prerequisite: Shredded document
567
+ if not shredded_document:
568
+ status_message = "Error: Please 'Shred' the source documents first."
569
+ doc_preview_children = html.Div(status_message, className="text-danger")
570
+ return review_controls_style, [], status_message, doc_preview_children, loading_style, download_style
571
+
572
+ # Check specific prerequisites and get the document to review
573
+ if action_type == "Pink Review":
574
+ prereq_doc = shredded_document # Base requirement
575
+ prereq_doc_name = "Shredded Document"
576
+ generated_doc_to_review = pink_document # Generated Pink to review
577
+ elif action_type == "Red Review":
578
+ prereq_doc = pink_review_document # Need Pink review results
579
+ prereq_doc_name = "Pink Review Document"
580
+ generated_doc_to_review = red_document # Generated Red to review
581
+ elif action_type == "Gold Review":
582
+ prereq_doc = red_review_document # Need Red review results
583
+ prereq_doc_name = "Red Review Document"
584
+ generated_doc_to_review = gold_document # Generated Gold to review
585
+
586
+ # Check if the specific prerequisite (like Pink Review for Red Review) exists
587
+ if prereq_doc is None and action_type != "Pink Review": # Shred is checked above
588
+ status_message = f"Error: Please complete '{prereq_doc_name.replace(' Document','')}' first."
589
+ doc_preview_children = html.Div(status_message, className="text-danger")
590
+ return review_controls_style, [], status_message, doc_preview_children, loading_style, download_style
591
+
592
+
593
+ # Configure Radio Items based on whether the generated version exists
594
+ radio_options = []
595
+ default_value = 'upload' # Default to upload as requested
596
+ if generated_doc_to_review:
597
+ radio_options.append({'label': f'Use {generated_doc_name}', 'value': 'generated'})
598
+ radio_options.append({'label': f'Upload {base_doc_type} Document', 'value': 'upload'})
599
+ # Keep default 'upload' unless generated is the *only* option (which shouldn't happen here)
600
  else:
601
+ # If generated doesn't exist, only allow upload
602
+ radio_options.append({'label': f'Upload {base_doc_type} Document', 'value': 'upload'})
603
+ status_message = f"Warning: No '{base_doc_type}' document was generated in this session. You must upload one to proceed with {action_type}."
604
 
 
 
605
 
606
+ # Build the controls
607
+ review_controls_children = [
608
+ html.H5(f"Configure Input for {action_type}"),
609
+ dbc.Label(f"Select {base_doc_type} document source:"),
610
+ dbc.RadioItems(
611
+ id='review-source-radio', # Single ID for the radio group
612
+ options=radio_options,
613
+ value=default_value, # Default to 'upload'
614
+ inline=True,
615
+ className='mb-2'
616
+ ),
617
+ # Single Upload component, dynamically shown/hidden by radio button callback
618
+ dcc.Upload(
619
+ id='upload-review-doc', # Single ID for the upload component
620
+ children=html.Div(['Drag and Drop or ', html.A(upload_file_prompt)]),
621
+ style={'display': 'block' if default_value == 'upload' else 'none', # Show/hide based on default value
622
+ 'width': '100%', 'height': '60px', 'lineHeight': '60px', 'borderWidth': '1px',
623
+ 'borderStyle': 'dashed', 'borderRadius': '5px', 'textAlign': 'center', 'margin': '10px 0',
624
+ 'backgroundColor': '#f8f9fa'},
625
+ multiple=False
626
+ ),
627
+ html.Div(id='review-upload-status', className='mb-2 text-muted small'), # For upload confirmation/error
628
+ # Generate Review button with pattern-matching ID
629
+ dbc.Button(f"Generate {action_type}", id={'type': 'generate-review-button', 'index': action_type}, color="primary")
630
+ ]
631
+ # Clear preview when showing controls, provide instruction
632
+ doc_preview_children = html.Div(f"Configure input source for {base_doc_type} document and click 'Generate {action_type}'.", style={'padding':'10px'})
633
+ status_message = f"Ready to configure input for {action_type}."
634
+
635
+
636
+ # --- Actions Triggering Direct Generation (Shred, Pink, Red, Gold, LOE, Virtual Board) ---
637
+ else:
638
+ review_controls_style = {'display': 'none'} # Hide review controls
639
+ review_controls_children = []
640
+ loading_style = {'visibility':'visible'} # Show loading dots
641
+ doc_preview_children = "" # Clear preview while loading/generating
642
+ status_message = f"Generating {action_type}..."
643
+
644
+ # Determine inputs based on action type
645
+ input_docs = []
646
+ context_docs = []
647
+ generation_possible = True
648
 
649
+ if action_type == "Shred":
650
+ source_docs_text = get_combined_uploaded_text()
651
+ if not source_docs_text:
652
+ status_message = "Error: Please upload source document(s) first."
653
+ generation_possible = False
654
+ else:
655
+ input_docs = [source_docs_text]
656
+ elif action_type == "Pink":
657
+ if not shredded_document:
658
+ status_message = "Error: Please 'Shred' the source documents first."
659
+ generation_possible = False
660
+ else:
661
+ input_docs = [get_combined_uploaded_text()] # Pink is based on source docs
662
+ context_docs = [shredded_document] # With context of shredded requirements
663
+ elif action_type == "Red":
664
+ if not shredded_document or not pink_review_document:
665
+ status_message = "Error: Please complete 'Shred' and 'Pink Review' first."
666
+ generation_possible = False
667
+ else:
668
+ # Red uses Pink Review findings as primary input to address them
669
+ input_docs = [pink_review_document]
670
+ # Context includes Shredded requirements and maybe original Pink? Let's stick to Shred+Review for now.
671
+ context_docs = [shredded_document]
672
+ elif action_type == "Gold":
673
+ if not shredded_document or not red_review_document:
674
+ status_message = "Error: Please complete 'Shred' and 'Red Review' first."
675
+ generation_possible = False
676
+ else:
677
+ # Gold uses Red Review findings as primary input
678
+ input_docs = [red_review_document]
679
+ context_docs = [shredded_document]
680
+ elif action_type in ["LOE", "Virtual Board"]:
681
+ if not shredded_document:
682
+ status_message = f"Error: Please 'Shred' the source documents first before generating {action_type}."
683
+ generation_possible = False
684
+ else:
685
+ # These likely only need the shredded requirements as input
686
+ input_docs = [shredded_document]
687
+ else:
688
+ status_message = f"Action '{action_type}' is not recognized for direct generation."
689
+ generation_possible = False
690
+
691
+ # Perform generation if possible
692
+ if generation_possible:
693
+ result_doc = generate_ai_document(action_type, input_docs, context_docs)
694
+
695
+ # Store result in the correct global variable
696
+ if result_doc and not result_doc.startswith("Error:"):
697
+ if action_type == "Shred": shredded_document = result_doc
698
+ elif action_type == "Pink": pink_document = result_doc
699
+ elif action_type == "Red": red_document = result_doc
700
+ elif action_type == "Gold": gold_document = result_doc
701
+ elif action_type == "LOE": loe_document = result_doc
702
+ elif action_type == "Virtual Board": virtual_board_document = result_doc
703
+ # Reviews are handled separately
704
+
705
+ doc_preview_children = dcc.Markdown(result_doc, style={'wordWrap': 'break-word'})
706
+ status_message = f"{action_type} generated successfully."
707
+ download_style = {'display': 'inline-block'} # Show download button on success
708
+ else:
709
+ # If generation failed, result_doc contains the error message from generate_ai_document
710
+ doc_preview_children = html.Div(result_doc, className="text-danger") # Display error in preview
711
+ status_message = f"Error generating {action_type}. See preview for details."
712
+ download_style = {'display': 'none'} # Hide download button on error
713
+
714
+ else:
715
+ # Generation not possible due to prerequisites
716
+ doc_preview_children = html.Div(status_message, className="text-danger")
717
+
718
+ loading_style = {'visibility':'hidden'} # Hide loading dots when finished/failed
719
+
720
+
721
+ return review_controls_style, review_controls_children, status_message, doc_preview_children, loading_style, download_style
722
+
723
+
724
+ # 4. Toggle Review Upload Component Visibility based on Radio Button
725
  @app.callback(
726
+ Output('upload-review-doc', 'style'),
727
+ Input('review-source-radio', 'value'),
728
+ State('upload-review-doc', 'style'), # Get current style to prevent unnecessary updates
729
+ prevent_initial_call=True
730
  )
731
+ def toggle_review_upload_visibility(radio_value, current_style):
732
+ # Preserves existing style attributes while toggling 'display'
733
+ new_style = current_style.copy() if current_style else {}
734
+ should_display = (radio_value == 'upload')
735
 
736
+ if should_display:
737
+ new_style['display'] = 'block'
738
+ else:
739
+ new_style['display'] = 'none'
740
+
741
+ # Prevent update if display style is already correct
742
+ if ('display' in current_style and current_style['display'] == new_style['display']):
743
+ raise PreventUpdate
744
+ else:
745
+ logging.debug(f"Toggling review upload visibility. Radio: {radio_value}, New Style: {new_style}")
746
+ return new_style
747
+
748
+
749
+ # 5. Handle Upload of Document for Review Input
750
+ @app.callback(
751
+ Output('review-upload-status', 'children'),
752
+ Output('status-bar', 'children', allow_duplicate=True),
753
+ Input('upload-review-doc', 'contents'),
754
+ State('upload-review-doc', 'filename'),
755
+ # Get the current review type from the button ID that generated the controls
756
+ State({'type': 'generate-review-button', 'index': ALL}, 'id'),
757
+ prevent_initial_call=True
758
+ )
759
+ def handle_review_upload(contents, filename, button_ids):
760
+ global uploaded_pink_content, uploaded_red_content, uploaded_gold_content
761
+
762
+ if contents is None or filename is None or not button_ids:
763
+ # No file uploaded or controls not fully rendered yet
764
+ raise PreventUpdate
765
+
766
+ # Determine which review type this upload is for based on the visible button ID
767
+ # Assumes only one set of review controls is visible, thus only one button ID in the list
768
+ if not isinstance(button_ids, list) or len(button_ids) == 0:
769
+ logging.warning("handle_review_upload: Could not determine review type from button ID state.")
770
+ raise PreventUpdate
771
+
772
+ review_type = button_ids[0]['index'] # e.g., "Pink Review"
773
+ base_type = review_type.split(" ")[0] # e.g., "Pink"
774
+
775
+ logging.info(f"Handling upload of file '{filename}' for {review_type} input.")
776
+
777
+ file_content_text, error = process_document(contents, filename)
778
+
779
+ upload_status_display = ""
780
+ status_bar_message = ""
781
+
782
+ # Clear previous uploads for this type before storing new one
783
+ if base_type == "Pink": uploaded_pink_content = None
784
+ elif base_type == "Red": uploaded_red_content = None
785
+ elif base_type == "Gold": uploaded_gold_content = None
786
+
787
+ if error:
788
+ status_bar_message = f"Error processing uploaded {base_type} file: {error}"
789
+ upload_status_display = html.Div(f"Failed to load {filename}: {error}", className="text-danger small")
790
+ else:
791
+ status_bar_message = f"Uploaded '{filename}' successfully for {review_type} input."
792
+ upload_status_display = html.Div(f"Using uploaded file: {filename}", className="text-success small")
793
+ # Store the content in the correct variable
794
+ if base_type == "Pink": uploaded_pink_content = file_content_text
795
+ elif base_type == "Red": uploaded_red_content = file_content_text
796
+ elif base_type == "Gold": uploaded_gold_content = file_content_text
797
+ logging.info(f"Stored uploaded content for {base_type} review input.")
798
+
799
+ return upload_status_display, status_bar_message
800
+
801
+
802
+ # 6. Generate Review Document on Button Click
803
  @app.callback(
 
804
  Output('document-preview', 'children', allow_duplicate=True),
805
+ Output('status-bar', 'children', allow_duplicate=True),
806
+ Output('loading-indicator', 'style', allow_duplicate=True), # Show/hide main loading dots
807
+ Output('btn-download', 'style', allow_duplicate=True),
808
+ # Use pattern-matching ID for the Input trigger
809
+ Input({'type': 'generate-review-button', 'index': ALL}, 'n_clicks'),
810
+ State('review-source-radio', 'value'), # State of the radio button choice
811
+ # Get the button ID again to know which review type triggered it
812
+ State({'type': 'generate-review-button', 'index': ALL}, 'id'),
813
+ prevent_initial_call=True
814
+ )
815
+ def generate_review_document(n_clicks, source_option, button_ids):
816
+ global shredded_document, pink_document, red_document, gold_document
817
+ global pink_review_document, red_review_document, gold_review_document
818
+ global uploaded_pink_content, uploaded_red_content, uploaded_gold_content
819
+
820
+ triggered_id_dict = callback_context.triggered_id
821
+ if not triggered_id_dict or not isinstance(triggered_id_dict, dict) or 'index' not in triggered_id_dict:
822
+ raise PreventUpdate
823
+
824
+ # Check if any click count is > 0
825
+ if not any(nc > 0 for nc in n_clicks if nc is not None):
826
+ raise PreventUpdate
827
+
828
+ review_type = triggered_id_dict['index'] # e.g., "Pink Review"
829
+ base_type = review_type.split(" ")[0] # e.g., "Pink"
830
+ logging.info(f"Generate button clicked for: {review_type}, Source option chosen: {source_option}")
831
+
832
+ doc_preview_children = "" # Clear preview
833
+ status_message = f"Generating {review_type}..."
834
+ loading_style = {'visibility':'visible'} # Show loading dots
835
+ download_style = {'display': 'none'} # Hide download initially
836
+
837
+
838
+ # --- Prerequisite Check ---
839
+ if not shredded_document:
840
+ status_message = "Error: 'Shredded' document is missing. Please perform 'Shred' first."
841
+ loading_style = {'visibility':'hidden'}
842
+ doc_preview_children = html.Div(status_message, className="text-danger")
843
+ return doc_preview_children, status_message, loading_style, download_style
844
+
845
+ # --- Determine Input Document based on Radio Choice ---
846
+ input_document_content = None
847
+ input_doc_source_name = "" # For logging/status messages
848
+
849
+ if source_option == 'generated':
850
+ input_doc_source_name = f"Generated {base_type} Document"
851
+ if base_type == "Pink": input_document_content = pink_document
852
+ elif base_type == "Red": input_document_content = red_document
853
+ elif base_type == "Gold": input_document_content = gold_document
854
+
855
+ if not input_document_content:
856
+ status_message = f"Error: Cannot use 'generated' option. The {input_doc_source_name} was not found (was it generated successfully?)."
857
+ loading_style = {'visibility':'hidden'}
858
+ doc_preview_children = html.Div(status_message, className="text-danger")
859
+ return doc_preview_children, status_message, loading_style, download_style
860
+
861
+ elif source_option == 'upload':
862
+ input_doc_source_name = f"Uploaded {base_type} Document"
863
+ if base_type == "Pink": input_document_content = uploaded_pink_content
864
+ elif base_type == "Red": input_document_content = uploaded_red_content
865
+ elif base_type == "Gold": input_document_content = uploaded_gold_content
866
+
867
+ if not input_document_content:
868
+ # Check if the upload component has children (file name displayed)
869
+ status_message = f"Error: Cannot use 'upload' option. No {base_type} document was successfully uploaded and processed for this review step."
870
+ loading_style = {'visibility':'hidden'}
871
+ doc_preview_children = html.Div(status_message, className="text-danger")
872
+ return doc_preview_children, status_message, loading_style, download_style
873
+ else:
874
+ status_message = f"Error: Invalid source option '{source_option}' selected."
875
+ loading_style = {'visibility':'hidden'}
876
+ doc_preview_children = html.Div(status_message, className="text-danger")
877
+ return doc_preview_children, status_message, loading_style, download_style
878
+
879
+
880
+ # --- Generate Review Document ---
881
+ logging.info(f"Generating {review_type} using '{input_doc_source_name}' as input and Shredded document as context.")
882
+
883
+ # Reviews need the document being reviewed (Pink/Red/Gold) as primary input
884
+ # and the Shredded PWS as context/requirements basis.
885
+ review_result = generate_ai_document(review_type, [input_document_content], context_docs=[shredded_document])
886
+
887
+ if review_result and not review_result.startswith("Error:"):
888
+ doc_preview_children = dcc.Markdown(review_result, style={'wordWrap': 'break-word'})
889
+ status_message = f"{review_type} generated successfully using {input_doc_source_name}."
890
+ # Store the result in the correct global variable
891
+ if review_type == "Pink Review": pink_review_document = review_result
892
+ elif review_type == "Red Review": red_review_document = review_result
893
+ elif review_type == "Gold Review": gold_review_document = review_result
894
+ download_style = {'display': 'inline-block'} # Show download button
895
+ else:
896
+ # review_result contains the error message
897
+ doc_preview_children = html.Div(f"Error generating {review_type}: {review_result}", className="text-danger")
898
+ status_message = f"Failed to generate {review_type}. See preview for details."
899
+ download_style = {'display': 'none'}
900
+
901
+ loading_style = {'visibility':'hidden'} # Hide loading dots
902
+ return doc_preview_children, status_message, loading_style, download_style
903
+
904
+
905
+ # 7. Handle Chat Interaction to Refine Displayed Document
906
+ @app.callback(
907
+ Output('chat-output', 'children'), # Display chat confirmation/error
908
+ Output('document-preview', 'children', allow_duplicate=True), # Update the preview
909
+ Output('status-bar', 'children', allow_duplicate=True), # Update main status
910
  Input('btn-send-chat', 'n_clicks'),
911
+ State('chat-input', 'value'), # Get the chat instruction
912
  prevent_initial_call=True
913
  )
914
+ def handle_chat(n_clicks, chat_input):
915
+ global current_display_document, current_display_type
916
+ # Also need to update the specific underlying document variable (e.g., pink_document)
917
+ # so the chat changes persist if that document is used later.
918
+ global shredded_document, pink_document, red_document, gold_document, pink_review_document, red_review_document, gold_review_document, loe_document, virtual_board_document
919
+
920
+ if not n_clicks or not chat_input or not chat_input.strip():
921
+ # No click or empty input
922
+ raise PreventUpdate
923
+ if not current_display_document or not current_display_type:
924
+ # No document currently loaded in the preview to refine
925
+ return html.Div("Error: No document is currently displayed to refine.", className="text-warning"), dash.no_update, "Cannot refine: No document loaded in preview."
926
+
927
+ logging.info(f"Chat refinement requested for displayed document type: {current_display_type}. Instruction: '{chat_input[:100]}...'")
928
+
929
+ # Construct prompt for refinement
930
+ prompt = f"""**Objective:** Refine the following '{current_display_type}' document based *only* on the user's instruction below.
931
+
932
+ **Your Role:** Act as an editor making precise changes.
933
+
934
+ **Core Instructions:**
935
+ 1. **Apply Instruction:** Modify the 'Original Document' solely based on the 'User Instruction'.
936
+ 2. **Maintain Context:** Preserve the overall structure, tone, and format of the original document unless the instruction explicitly directs otherwise.
937
+ 3. **Output Only Updated Document:** Provide *only* the complete, updated '{current_display_type}' document. Do not add any conversational text, preamble, or explanation of changes.
938
+
939
+ **Original Document:**
940
+ ```text
941
+ {current_display_document}
942
+ ```
943
+
944
+ **User Instruction:**
945
+ ```text
946
+ {chat_input}
947
+ ```
948
+
949
+ **Begin Updated '{current_display_type}' Output:**
950
  """
 
 
 
 
 
951
 
952
+ try:
953
+ # Show loading indicator for chat refinement? (Optional, maybe use inner loading)
954
+ status_message = f"Refining {current_display_type} based on chat instruction..."
955
+ # Note: Consider adding a loading state specifically for the chat output area.
956
+
957
+ response = model.generate_content(prompt)
958
+
959
+ # Handle potential safety blocks or empty responses
960
+ if not response.parts:
961
+ logging.warning(f"Gemini AI returned no parts for chat refinement of {current_display_type}.")
962
+ updated_document = f"Error: AI returned no content during refinement. This might be due to safety filters or an issue with the instruction."
963
+ chat_response_display = html.Div(updated_document, className="text-danger")
964
+ status_message = f"Error refining {current_display_type} via chat."
965
+ return chat_response_display, dash.no_update, status_message # Don't update preview if refinement failed
966
+ else:
967
+ updated_document = response.text
968
+ logging.info(f"Successfully refined {current_display_type} via chat.")
969
+
970
+ # --- CRITICAL: Update the correct underlying global variable ---
971
+ # This ensures the refined document is used in subsequent steps if needed.
972
+ original_doc_updated = False
973
+ if current_display_type == "Shred": shredded_document = updated_document; original_doc_updated = True
974
+ elif current_display_type == "Pink": pink_document = updated_document; original_doc_updated = True
975
+ elif current_display_type == "Pink Review": pink_review_document = updated_document; original_doc_updated = True
976
+ elif current_display_type == "Red": red_document = updated_document; original_doc_updated = True
977
+ elif current_display_type == "Red Review": red_review_document = updated_document; original_doc_updated = True
978
+ elif current_display_type == "Gold": gold_document = updated_document; original_doc_updated = True
979
+ elif current_display_type == "Gold Review": gold_review_document = updated_document; original_doc_updated = True
980
+ elif current_display_type == "LOE": loe_document = updated_document; original_doc_updated = True
981
+ elif current_display_type == "Virtual Board": virtual_board_document = updated_document; original_doc_updated = True
982
+
983
+ if original_doc_updated:
984
+ logging.info(f"Updated the underlying global variable for {current_display_type} with chat refinement.")
985
+ else:
986
+ logging.warning(f"Could not map displayed type '{current_display_type}' to a specific global variable for persistent update after chat.")
987
+
988
+
989
+ # Update the preview display immediately
990
+ current_display_document = updated_document # Keep preview consistent
991
+
992
+ # Display confirmation in chat output area
993
+ chat_response_display = html.Div([
994
+ html.Strong("Refinement applied successfully."),
995
+ #html.P(f'Instruction: "{chat_input}"'), # Optional: Echo instruction
996
+ html.Hr(),
997
+ html.Em("Preview above has been updated. The changes will be used if this document is input for subsequent steps.")
998
+ ])
999
+ status_message = f"{current_display_type} updated via chat instruction."
1000
+ # Update the document preview itself
1001
+ doc_preview_update = dcc.Markdown(updated_document, style={'wordWrap': 'break-word'})
1002
+
1003
+ return chat_response_display, doc_preview_update, status_message
1004
+
1005
+ except Exception as e:
1006
+ logging.error(f"Error during chat refinement call for {current_display_type}: {e}", exc_info=True)
1007
+ chat_response_display = html.Div(f"Error refining document via chat: {str(e)}", className="text-danger")
1008
+ status_message = f"Error refining {current_display_type} via chat."
1009
+ # Do not update the main document preview if chat refinement fails
1010
+ return chat_response_display, dash.no_update, status_message
1011
+
1012
+
1013
+ # 8. Handle Download Button Click
1014
  @app.callback(
1015
  Output("download-document", "data"),
1016
  Input("btn-download", "n_clicks"),
1017
  prevent_initial_call=True
1018
  )
1019
+ def download_generated_document(n_clicks):
1020
+ """Prepares the currently displayed document for download."""
1021
+ global current_display_document, current_display_type
1022
+
1023
+ if not n_clicks or current_display_document is None or current_display_type is None:
1024
+ # No clicks or nothing to download
1025
+ raise PreventUpdate
1026
+
1027
+ logging.info(f"Download requested for displayed document: {current_display_type}")
1028
+
1029
+ # Sanitize filename
1030
+ safe_filename_base = "".join(c if c.isalnum() else "_" for c in current_display_type)
1031
+
1032
+ # Determine if output should be spreadsheet (Excel) or document (Word)
1033
+ is_spreadsheet_type = current_display_type in ["Shred", "Pink Review", "Red Review", "Gold Review", "Virtual Board", "LOE"]
1034
+
1035
+ if is_spreadsheet_type:
1036
+ filename = f"{safe_filename_base}.xlsx"
1037
+ logging.info(f"Attempting to format {current_display_type} as Excel.")
1038
+ try:
1039
+ # Use StringIO to treat the string data as a file for pandas
1040
+ data_io = StringIO(current_display_document)
1041
+ df = None
1042
+
1043
+ # Attempt 1: Try parsing as CSV (or detect delimiter)
1044
+ try:
1045
+ # Read a sample to sniff delimiter
1046
+ sniffer_sample = data_io.read(2048) # Read more data for better sniffing
1047
+ data_io.seek(0) # Reset pointer after reading sample
1048
+ dialect = pd.io.parsers.readers.csv.Sniffer().sniff(sniffer_sample, delimiters=',|\t') # Sniff common delimiters
1049
+ df = pd.read_csv(data_io, sep=dialect.delimiter)
1050
+ logging.info(f"Successfully parsed {current_display_type} using detected delimiter '{dialect.delimiter}'.")
1051
+ except Exception as e_csv:
1052
+ logging.warning(f"Could not parse {current_display_type} as standard CSV/TSV ({e_csv}). Trying Markdown Table parsing.")
1053
+ data_io.seek(0) # Reset pointer
1054
+
1055
+ # Attempt 2: Try parsing as a Markdown table (simple version)
1056
+ lines = [line.strip() for line in data_io.readlines() if line.strip()]
1057
+ header = []
1058
+ data = []
1059
+ header_found = False
1060
+ separator_found = False
1061
+
1062
+ for line in lines:
1063
+ if line.startswith('|') and line.endswith('|'):
1064
+ parts = [p.strip() for p in line.strip('|').split('|')]
1065
+ if not header_found:
1066
+ header = parts
1067
+ header_found = True
1068
+ elif '---' in line: # Detect separator line
1069
+ separator_found = True
1070
+ # Optional: Check if separator alignment matches header count
1071
+ if len(line.strip('|').split('|')) != len(header):
1072
+ logging.warning("Markdown table header/separator mismatch detected.")
1073
+ # Decide whether to proceed or fail parsing
1074
+ elif header_found and separator_found: # Only add data lines after header and separator
1075
+ if len(parts) == len(header):
1076
+ data.append(parts)
1077
+ else:
1078
+ logging.warning(f"Markdown table row data mismatch (expected {len(header)}, got {len(parts)}): {line}")
1079
+
1080
+
1081
+ if header and data:
1082
+ df = pd.DataFrame(data, columns=header)
1083
+ logging.info(f"Successfully parsed {current_display_type} as Markdown Table.")
1084
+ else:
1085
+ logging.warning(f"Could not parse {current_display_type} as Markdown Table after CSV attempt failed.")
1086
+ # Fallback: If no DataFrame could be created, send as text
1087
+ return dict(content=current_display_document, filename=f"{safe_filename_base}.txt")
1088
+
1089
+ # If DataFrame was created, save to Excel
1090
+ output = BytesIO()
1091
+ # Use xlsxwriter engine for better compatibility/features if needed
1092
+ with pd.ExcelWriter(output, engine='xlsxwriter') as writer:
1093
+ df.to_excel(writer, sheet_name=current_display_type[:31], index=False) # Sheet name limit is 31 chars
1094
+ logging.info(f"Sending {filename} (Excel format)")
1095
+ return dcc.send_bytes(output.getvalue(), filename)
1096
+
1097
+ except Exception as e_excel:
1098
+ logging.error(f"Error creating Excel file for {current_display_type}: {e_excel}. Sending as text.", exc_info=True)
1099
+ # Fallback to sending as a text file if any DataFrame/Excel processing fails
1100
+ return dict(content=current_display_document, filename=f"{safe_filename_base}.txt")
1101
+
1102
+ else: # Assume DOCX for Pink, Red, Gold
1103
+ filename = f"{safe_filename_base}.docx"
1104
+ logging.info(f"Formatting {current_display_type} as DOCX.")
1105
+ try:
1106
+ doc = Document()
1107
+ # Add paragraph by paragraph to potentially preserve some structure like line breaks
1108
+ for paragraph_text in current_display_document.split('\n'):
1109
+ # Add paragraph only if it contains non-whitespace characters
1110
+ if paragraph_text.strip():
1111
+ doc.add_paragraph(paragraph_text)
1112
+ else:
1113
+ # Optionally add an empty paragraph to represent blank lines, or skip
1114
+ doc.add_paragraph() # Add blank lines if they were intentional
1115
+
1116
+ # Save the document to an in-memory BytesIO object
1117
+ output = BytesIO()
1118
+ doc.save(output)
1119
+ logging.info(f"Sending {filename} (DOCX format)")
1120
+ return dcc.send_bytes(output.getvalue(), filename) # Use dcc.send_bytes for BytesIO
1121
+ except Exception as e_docx:
1122
+ logging.error(f"Error creating DOCX file for {current_display_type}: {e_docx}. Sending as text.", exc_info=True)
1123
+ # Fallback to sending as a text file
1124
+ return dict(content=current_display_document, filename=f"{safe_filename_base}.txt")
1125
+
1126
 
1127
+ # --- Main Execution ---
1128
+ # Always use this structure for running the app
1129
  if __name__ == '__main__':
1130
  print("Starting the Dash application...")
1131
+ # Set debug=False for production/deployment environments like Hugging Face Spaces
1132
+ # Set host='0.0.0.0' to make the app accessible on the network (required for Docker/Spaces)
1133
+ # Default port 8050, using 7860 as often used for ML demos/Spaces
1134
+ # Use server=app.server for Gunicorn compatibility (multi-threading via workers)
1135
+ # Multi-threading for multiple simultaneous user support is handled by the deployment server (e.g., Gunicorn with workers), not directly in app.run for production.
1136
  app.run(debug=False, host='0.0.0.0', port=7860)
1137
  print("Dash application has finished running.")