bluenevus commited on
Commit
24742cb
·
1 Parent(s): 01d9e10

Update app.py via AI Editor

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