Spaces:
Paused
Paused
Update app.py via AI Editor
Browse files
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
|
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 |
-
# 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,
|
29 |
meta_tags=[{"name": "viewport", "content": "width=device-width, initial-scale=1"}])
|
30 |
-
server = app.server
|
31 |
|
32 |
# --- Configure Gemini AI ---
|
33 |
-
# IMPORTANT: Set the GEMINI_API_KEY environment variable
|
34 |
try:
|
35 |
-
# Prefer direct CUDA GPU configuration in app.py - Note:
|
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 |
-
#
|
43 |
-
|
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 |
-
#
|
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
|
58 |
-
pink_review_document = None
|
59 |
-
red_review_document = None
|
60 |
-
gold_review_document = None
|
61 |
-
loe_document = None
|
62 |
-
virtual_board_document = None
|
63 |
|
64 |
# Stores the *generated* proposal drafts
|
65 |
-
pink_document = None
|
66 |
-
red_document = None
|
67 |
-
gold_document = None
|
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
|
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
|
102 |
dbc.Row(
|
103 |
dbc.Col(
|
104 |
-
# Blinking triple dot for progress
|
105 |
dcc.Loading(
|
106 |
id="loading-indicator",
|
107 |
-
type="dots",
|
108 |
-
children=[html.Div(id="loading-output", style={'height': '10px'})],
|
109 |
-
overlay_style={"visibility":"hidden", "opacity": 0},
|
110 |
-
style={'visibility':'hidden', 'height': '30px'},
|
111 |
-
fullscreen=False,
|
112 |
className="justify-content-center"
|
113 |
),
|
114 |
width=12,
|
115 |
-
className="text-center mb-3"
|
116 |
)
|
117 |
),
|
118 |
|
119 |
-
# Main Content Row
|
120 |
dbc.Row([
|
121 |
-
# Left Column (
|
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'
|
133 |
},
|
134 |
-
multiple=True
|
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},
|
151 |
-
color="primary",
|
152 |
-
className="mb-2 w-100 d-block",
|
153 |
-
style={'textAlign': 'left', 'whiteSpace': 'normal', 'height': 'auto', 'wordWrap': 'break-word'}
|
154 |
) for doc_type in document_types.keys()]
|
155 |
])
|
156 |
)
|
157 |
])
|
158 |
-
, color="light"),
|
159 |
-
width=12, lg=4,
|
160 |
-
className="mb-3 mb-lg-0",
|
161 |
style={'padding': '15px'}
|
162 |
),
|
163 |
|
164 |
-
# Right Column (Status
|
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",
|
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'}),
|
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'}),
|
201 |
-
# Button Group for Send and Clear Chat
|
202 |
dbc.ButtonGroup([
|
203 |
-
dbc.Button("Send Chat", id="btn-send-chat", color="secondary"),
|
204 |
-
dbc.Button("Clear Chat", id="btn-clear-chat", color="tertiary")
|
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'})
|
207 |
]
|
208 |
)
|
209 |
]), className="mb-3"
|
210 |
)
|
211 |
])
|
212 |
),
|
213 |
-
width=12, lg=8,
|
214 |
-
style={'backgroundColor': '#ffffff', 'padding': '15px'}
|
215 |
)
|
216 |
])
|
217 |
-
], style={'maxWidth': '100%', 'padding': '0 15px'})
|
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)
|
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
|
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
|
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):
|
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}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|