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