Spaces:
Paused
Paused
Update app.py via AI Editor
Browse files
app.py
CHANGED
@@ -6,7 +6,7 @@ from docx import Document
|
|
6 |
from io import BytesIO, StringIO
|
7 |
import dash
|
8 |
import dash_bootstrap_components as dbc
|
9 |
-
from dash import html, dcc, Input, Output, State, callback_context
|
10 |
from docx.shared import Pt
|
11 |
from docx.enum.style import WD_STYLE_TYPE
|
12 |
from PyPDF2 import PdfReader
|
@@ -14,23 +14,19 @@ import openai
|
|
14 |
import logging
|
15 |
import threading
|
16 |
|
17 |
-
# Logging configuration
|
18 |
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
|
19 |
|
20 |
-
# Initialize Dash app
|
21 |
app = dash.Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP])
|
22 |
|
23 |
-
# Configure OpenAI
|
24 |
openai.api_key = os.environ.get("OPENAI_API_KEY", "")
|
25 |
|
26 |
-
# Global variables
|
27 |
uploaded_files = {}
|
28 |
current_document = None
|
29 |
document_type = None
|
30 |
shredded_document = None
|
31 |
pink_review_document = None
|
|
|
32 |
|
33 |
-
# Document types and their descriptions
|
34 |
document_types = {
|
35 |
"Shred": "Generate a requirements spreadsheet of the Project Work Statement (PWS) identified by action words like shall, will, perform etc. by pws section, requirement. Do not write as if you're responding to the proposal. Its a spreadsheet to distill the requirements, not microhealth's approach",
|
36 |
"Pink": "Create a Pink Team document based on the PWS outline. Your goal is to be compliant and compelling.",
|
@@ -43,44 +39,34 @@ document_types = {
|
|
43 |
"LOE": "Generate a Level of Effort (LOE) breakdown as a spreadsheet"
|
44 |
}
|
45 |
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
html.H4("Proposal Documents", className="mt-3 mb-4"),
|
50 |
html.Div([
|
51 |
html.Div(className="blinking-dot", style={'margin':'0 auto','width':'16px','height':'16px'}),
|
52 |
], style={'textAlign':'center', 'marginBottom':'10px'}),
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
]),
|
59 |
-
style={
|
60 |
-
'width': '100%',
|
61 |
-
'height': '60px',
|
62 |
-
'lineHeight': '60px',
|
63 |
-
'borderWidth': '1px',
|
64 |
-
'borderStyle': 'dashed',
|
65 |
-
'borderRadius': '5px',
|
66 |
-
'textAlign': 'center',
|
67 |
-
'margin': '10px 0'
|
68 |
-
},
|
69 |
-
multiple=True
|
70 |
),
|
71 |
-
html.Div(id='
|
|
|
|
|
72 |
html.Hr(),
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
]
|
83 |
-
|
|
|
84 |
html.Div([
|
85 |
html.Div(className="blinking-dot", style={'margin':'0 auto','width':'16px','height':'16px'}),
|
86 |
], style={'textAlign':'center', 'marginBottom':'10px'}),
|
@@ -94,10 +80,11 @@ app.layout = dbc.Container([
|
|
94 |
dbc.Button("Download Document", id="btn-download", color="success", className="mt-3"),
|
95 |
dcc.Download(id="download-document"),
|
96 |
html.Hr(),
|
97 |
-
html.Div(
|
|
|
98 |
dcc.Upload(
|
99 |
-
id='upload-
|
100 |
-
children=html.Div(['Drag and Drop or ', html.A('Select
|
101 |
style={
|
102 |
'width': '100%',
|
103 |
'height': '60px',
|
@@ -110,8 +97,19 @@ app.layout = dbc.Container([
|
|
110 |
},
|
111 |
multiple=False
|
112 |
),
|
113 |
-
html.Div(id='
|
114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
115 |
dcc.Loading(
|
116 |
id="chat-loading",
|
117 |
type="dot",
|
@@ -121,6 +119,47 @@ app.layout = dbc.Container([
|
|
121 |
html.Div(id="chat-output")
|
122 |
]
|
123 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
124 |
], width=9)
|
125 |
])
|
126 |
], fluid=True)
|
@@ -175,7 +214,7 @@ def update_output(list_of_contents, list_of_names, existing_files):
|
|
175 |
@app.callback(
|
176 |
Output('file-list', 'children', allow_duplicate=True),
|
177 |
Output('status-bar', 'children', allow_duplicate=True),
|
178 |
-
Input({'type': 'remove-file', 'index':
|
179 |
State('file-list', 'children'),
|
180 |
prevent_initial_call=True
|
181 |
)
|
@@ -190,6 +229,23 @@ def remove_file(n_clicks, existing_files):
|
|
190 |
logging.info(f"Removed file: {removed_file}")
|
191 |
return [file for file in existing_files if file['props']['children'][1]['props']['children'] != removed_file], "Document removed. Please upload a document and click 'Shred' to begin."
|
192 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
193 |
def generate_document(document_type, file_contents):
|
194 |
prompt = f"""Generate a {document_type} based on the following project artifacts:
|
195 |
{' '.join(file_contents)}
|
@@ -243,70 +299,106 @@ Now, generate the {document_type}:
|
|
243 |
Output('document-preview', 'children'),
|
244 |
Output('loading-output', 'children'),
|
245 |
Output('status-bar', 'children', allow_duplicate=True),
|
246 |
-
|
247 |
-
|
248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
249 |
prevent_initial_call=True
|
250 |
)
|
251 |
-
def
|
252 |
global current_document, document_type, shredded_document, pink_review_document
|
253 |
-
ctx =
|
254 |
if not ctx.triggered:
|
255 |
raise dash.exceptions.PreventUpdate
|
256 |
-
|
257 |
-
|
258 |
-
|
259 |
-
|
260 |
-
|
261 |
-
|
262 |
|
263 |
-
if
|
264 |
-
|
265 |
-
return html.Div("Please upload a document before shredding."), "", "Please upload a document before shredding.", {'display': 'none'}
|
266 |
-
file_contents = list(uploaded_files.values())
|
267 |
-
try:
|
268 |
-
shredded_document = generate_document(document_type, file_contents)
|
269 |
-
return dcc.Markdown(shredded_document), f"{document_type} generated", "Document shredded. You can now proceed with other operations.", {'display': 'none'}
|
270 |
-
except Exception as e:
|
271 |
-
logging.error(f"Error generating document: {str(e)}")
|
272 |
-
return html.Div(f"Error generating document: {str(e)}"), "Error", "An error occurred while shredding the document.", {'display': 'none'}
|
273 |
|
274 |
if shredded_document is None:
|
275 |
-
return html.Div("Please shred a document first."), "", "Please shred a document first."
|
276 |
|
277 |
-
if
|
278 |
-
|
279 |
|
280 |
-
if
|
281 |
-
|
282 |
-
|
283 |
-
|
284 |
-
|
285 |
-
|
286 |
-
|
287 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
288 |
else:
|
289 |
-
|
290 |
|
291 |
-
|
|
|
|
|
292 |
pink_review_document = current_document
|
293 |
-
|
294 |
-
|
295 |
-
|
|
|
|
|
|
|
296 |
except Exception as e:
|
297 |
logging.error(f"Error generating document: {str(e)}")
|
298 |
-
return html.Div(f"Error generating document: {str(e)}"), "Error", "An error occurred while generating the document."
|
299 |
-
|
300 |
-
@app.callback(
|
301 |
-
Output('pink-review-file-name', 'children'),
|
302 |
-
Input('upload-pink-review', 'contents'),
|
303 |
-
State('upload-pink-review', 'filename')
|
304 |
-
)
|
305 |
-
def update_pink_review_filename(contents, filename):
|
306 |
-
if contents is not None:
|
307 |
-
logging.info(f"Pink Review file uploaded: {filename}")
|
308 |
-
return filename
|
309 |
-
return ""
|
310 |
|
311 |
@app.callback(
|
312 |
Output('chat-output', 'children'),
|
|
|
6 |
from io import BytesIO, StringIO
|
7 |
import dash
|
8 |
import dash_bootstrap_components as dbc
|
9 |
+
from dash import html, dcc, Input, Output, State, callback_context, MATCH, ALL
|
10 |
from docx.shared import Pt
|
11 |
from docx.enum.style import WD_STYLE_TYPE
|
12 |
from PyPDF2 import PdfReader
|
|
|
14 |
import logging
|
15 |
import threading
|
16 |
|
|
|
17 |
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
|
18 |
|
|
|
19 |
app = dash.Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP])
|
20 |
|
|
|
21 |
openai.api_key = os.environ.get("OPENAI_API_KEY", "")
|
22 |
|
|
|
23 |
uploaded_files = {}
|
24 |
current_document = None
|
25 |
document_type = None
|
26 |
shredded_document = None
|
27 |
pink_review_document = None
|
28 |
+
uploaded_doc_contents = {}
|
29 |
|
|
|
30 |
document_types = {
|
31 |
"Shred": "Generate a requirements spreadsheet of the Project Work Statement (PWS) identified by action words like shall, will, perform etc. by pws section, requirement. Do not write as if you're responding to the proposal. Its a spreadsheet to distill the requirements, not microhealth's approach",
|
32 |
"Pink": "Create a Pink Team document based on the PWS outline. Your goal is to be compliant and compelling.",
|
|
|
39 |
"LOE": "Generate a Level of Effort (LOE) breakdown as a spreadsheet"
|
40 |
}
|
41 |
|
42 |
+
def get_right_col_content(selected_type):
|
43 |
+
if selected_type == "Shred":
|
44 |
+
return [
|
|
|
45 |
html.Div([
|
46 |
html.Div(className="blinking-dot", style={'margin':'0 auto','width':'16px','height':'16px'}),
|
47 |
], style={'textAlign':'center', 'marginBottom':'10px'}),
|
48 |
+
html.Div(id='status-bar', className="alert alert-info", style={'marginBottom': '20px'}),
|
49 |
+
dcc.Loading(
|
50 |
+
id="loading-indicator",
|
51 |
+
type="dot",
|
52 |
+
children=[html.Div(id="loading-output")]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
53 |
),
|
54 |
+
html.Div(id='document-preview', className="border p-3 mb-3"),
|
55 |
+
dbc.Button("Download Document", id="btn-download", color="success", className="mt-3"),
|
56 |
+
dcc.Download(id="download-document"),
|
57 |
html.Hr(),
|
58 |
+
dcc.Loading(
|
59 |
+
id="chat-loading",
|
60 |
+
type="dot",
|
61 |
+
children=[
|
62 |
+
dbc.Input(id="chat-input", type="text", placeholder="Chat with AI to update document...", className="mb-2", style={'whiteSpace':'pre-wrap'}),
|
63 |
+
dbc.Button("Send", id="btn-send-chat", color="primary", className="mb-3"),
|
64 |
+
html.Div(id="chat-output")
|
65 |
+
]
|
66 |
+
)
|
67 |
+
]
|
68 |
+
else:
|
69 |
+
return [
|
70 |
html.Div([
|
71 |
html.Div(className="blinking-dot", style={'margin':'0 auto','width':'16px','height':'16px'}),
|
72 |
], style={'textAlign':'center', 'marginBottom':'10px'}),
|
|
|
80 |
dbc.Button("Download Document", id="btn-download", color="success", className="mt-3"),
|
81 |
dcc.Download(id="download-document"),
|
82 |
html.Hr(),
|
83 |
+
html.Div([
|
84 |
+
html.Label(f"Upload {selected_type} Document"),
|
85 |
dcc.Upload(
|
86 |
+
id={'type': 'upload-doc-type', 'index': selected_type},
|
87 |
+
children=html.Div(['Drag and Drop or ', html.A('Select File')]),
|
88 |
style={
|
89 |
'width': '100%',
|
90 |
'height': '60px',
|
|
|
97 |
},
|
98 |
multiple=False
|
99 |
),
|
100 |
+
html.Div(id={'type': 'uploaded-doc-name', 'index': selected_type}),
|
101 |
+
dbc.RadioItems(
|
102 |
+
id={'type': 'radio-doc-source', 'index': selected_type},
|
103 |
+
options=[
|
104 |
+
{'label': 'Loaded Document', 'value': 'loaded'},
|
105 |
+
{'label': 'Uploaded Document', 'value': 'uploaded'}
|
106 |
+
],
|
107 |
+
value='loaded',
|
108 |
+
inline=True,
|
109 |
+
className="mb-2"
|
110 |
+
),
|
111 |
+
dbc.Button("Generate Document", id={'type': 'btn-generate-doc', 'index': selected_type}, color="primary", className="mb-3"),
|
112 |
+
], id={'type': 'doc-type-controls', 'index': selected_type}),
|
113 |
dcc.Loading(
|
114 |
id="chat-loading",
|
115 |
type="dot",
|
|
|
119 |
html.Div(id="chat-output")
|
120 |
]
|
121 |
)
|
122 |
+
]
|
123 |
+
|
124 |
+
app.layout = dbc.Container([
|
125 |
+
dbc.Row([
|
126 |
+
dbc.Col([
|
127 |
+
html.H4("Proposal Documents", className="mt-3 mb-4"),
|
128 |
+
html.Div([
|
129 |
+
html.Div(className="blinking-dot", style={'margin':'0 auto','width':'16px','height':'16px'}),
|
130 |
+
], style={'textAlign':'center', 'marginBottom':'10px'}),
|
131 |
+
dcc.Upload(
|
132 |
+
id='upload-document',
|
133 |
+
children=html.Div([
|
134 |
+
'Drag and Drop or ',
|
135 |
+
html.A('Select Files')
|
136 |
+
]),
|
137 |
+
style={
|
138 |
+
'width': '100%',
|
139 |
+
'height': '60px',
|
140 |
+
'lineHeight': '60px',
|
141 |
+
'borderWidth': '1px',
|
142 |
+
'borderStyle': 'dashed',
|
143 |
+
'borderRadius': '5px',
|
144 |
+
'textAlign': 'center',
|
145 |
+
'margin': '10px 0'
|
146 |
+
},
|
147 |
+
multiple=True
|
148 |
+
),
|
149 |
+
html.Div(id='file-list'),
|
150 |
+
html.Hr(),
|
151 |
+
html.Div([
|
152 |
+
dbc.Button(
|
153 |
+
doc_type,
|
154 |
+
id={'type': 'btn-doc-type', 'index': doc_type},
|
155 |
+
color="link",
|
156 |
+
className="mb-2 w-100 text-left custom-button",
|
157 |
+
style={'overflow': 'hidden', 'text-overflow': 'ellipsis', 'white-space': 'nowrap'}
|
158 |
+
) for doc_type in document_types.keys()
|
159 |
+
])
|
160 |
+
], width=3),
|
161 |
+
dbc.Col([
|
162 |
+
html.Div(id='right-col-content')
|
163 |
], width=9)
|
164 |
])
|
165 |
], fluid=True)
|
|
|
214 |
@app.callback(
|
215 |
Output('file-list', 'children', allow_duplicate=True),
|
216 |
Output('status-bar', 'children', allow_duplicate=True),
|
217 |
+
Input({'type': 'remove-file', 'index': ALL}, 'n_clicks'),
|
218 |
State('file-list', 'children'),
|
219 |
prevent_initial_call=True
|
220 |
)
|
|
|
229 |
logging.info(f"Removed file: {removed_file}")
|
230 |
return [file for file in existing_files if file['props']['children'][1]['props']['children'] != removed_file], "Document removed. Please upload a document and click 'Shred' to begin."
|
231 |
|
232 |
+
@app.callback(
|
233 |
+
Output('right-col-content', 'children'),
|
234 |
+
[Input({'type': 'btn-doc-type', 'index': ALL}, 'n_clicks')],
|
235 |
+
[State({'type': 'btn-doc-type', 'index': ALL}, 'id')]
|
236 |
+
)
|
237 |
+
def update_right_col(n_clicks_list, btn_ids):
|
238 |
+
triggered = callback_context.triggered
|
239 |
+
if not triggered or all(x is None for x in n_clicks_list):
|
240 |
+
selected_type = "Shred"
|
241 |
+
else:
|
242 |
+
idx = [i for i, x in enumerate(n_clicks_list) if x]
|
243 |
+
if idx:
|
244 |
+
selected_type = btn_ids[idx[-1]]['index']
|
245 |
+
else:
|
246 |
+
selected_type = "Shred"
|
247 |
+
return get_right_col_content(selected_type)
|
248 |
+
|
249 |
def generate_document(document_type, file_contents):
|
250 |
prompt = f"""Generate a {document_type} based on the following project artifacts:
|
251 |
{' '.join(file_contents)}
|
|
|
299 |
Output('document-preview', 'children'),
|
300 |
Output('loading-output', 'children'),
|
301 |
Output('status-bar', 'children', allow_duplicate=True),
|
302 |
+
Input({'type': 'btn-doc-type', 'index': 'Shred'}, 'n_clicks'),
|
303 |
+
prevent_initial_call=True
|
304 |
+
)
|
305 |
+
def generate_shred_doc(n_clicks):
|
306 |
+
global current_document, document_type, shredded_document
|
307 |
+
if not uploaded_files:
|
308 |
+
return html.Div("Please upload a document before shredding."), "", "Please upload a document before shredding."
|
309 |
+
file_contents = list(uploaded_files.values())
|
310 |
+
try:
|
311 |
+
shredded_document = generate_document("Shred", file_contents)
|
312 |
+
current_document = shredded_document
|
313 |
+
return dcc.Markdown(shredded_document), "Shred generated", "Document shredded. You can now proceed with other operations."
|
314 |
+
except Exception as e:
|
315 |
+
logging.error(f"Error generating document: {str(e)}")
|
316 |
+
return html.Div(f"Error generating document: {str(e)}"), "Error", "An error occurred while shredding the document."
|
317 |
+
|
318 |
+
@app.callback(
|
319 |
+
Output({'type': 'uploaded-doc-name', 'index': MATCH}, 'children'),
|
320 |
+
Output({'type': 'upload-doc-type', 'index': MATCH}, 'contents'),
|
321 |
+
Input({'type': 'upload-doc-type', 'index': MATCH}, 'contents'),
|
322 |
+
State({'type': 'upload-doc-type', 'index': MATCH}, 'filename'),
|
323 |
+
State({'type': 'upload-doc-type', 'index': MATCH}, 'id')
|
324 |
+
)
|
325 |
+
def update_uploaded_doc_name(contents, filename, id_dict):
|
326 |
+
if contents is not None:
|
327 |
+
uploaded_doc_contents[id_dict['index']] = (contents, filename)
|
328 |
+
logging.info(f"{id_dict['index']} file uploaded: {filename}")
|
329 |
+
return filename, contents
|
330 |
+
return "", None
|
331 |
+
|
332 |
+
@app.callback(
|
333 |
+
Output('document-preview', 'children', allow_duplicate=True),
|
334 |
+
Output('loading-output', 'children', allow_duplicate=True),
|
335 |
+
Output('status-bar', 'children', allow_duplicate=True),
|
336 |
+
Input({'type': 'btn-generate-doc', 'index': ALL}, 'n_clicks'),
|
337 |
+
State({'type': 'btn-generate-doc', 'index': ALL}, 'id'),
|
338 |
+
State({'type': 'radio-doc-source', 'index': ALL}, 'value'),
|
339 |
+
State({'type': 'upload-doc-type', 'index': ALL}, 'contents'),
|
340 |
+
State({'type': 'upload-doc-type', 'index': ALL}, 'filename'),
|
341 |
prevent_initial_call=True
|
342 |
)
|
343 |
+
def generate_other_doc(n_clicks_list, btn_ids, radio_values, upload_contents, upload_filenames):
|
344 |
global current_document, document_type, shredded_document, pink_review_document
|
345 |
+
ctx = callback_context
|
346 |
if not ctx.triggered:
|
347 |
raise dash.exceptions.PreventUpdate
|
348 |
+
idx = [i for i, x in enumerate(n_clicks_list) if x]
|
349 |
+
if not idx:
|
350 |
+
raise dash.exceptions.PreventUpdate
|
351 |
+
idx = idx[-1]
|
352 |
+
doc_type = btn_ids[idx]['index']
|
353 |
+
document_type = doc_type
|
354 |
|
355 |
+
if doc_type == "Shred":
|
356 |
+
raise dash.exceptions.PreventUpdate
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
357 |
|
358 |
if shredded_document is None:
|
359 |
+
return html.Div("Please shred a document first."), "", "Please shred a document first."
|
360 |
|
361 |
+
source = radio_values[idx] if radio_values and len(radio_values) > idx else 'loaded'
|
362 |
+
doc_content = None
|
363 |
|
364 |
+
if source == 'uploaded':
|
365 |
+
if upload_contents and len(upload_contents) > idx and upload_contents[idx] and upload_filenames and len(upload_filenames) > idx and upload_filenames[idx]:
|
366 |
+
doc_content = process_document(upload_contents[idx], upload_filenames[idx])
|
367 |
+
else:
|
368 |
+
return html.Div("Please upload a document to use as source."), "", "Please upload a document to use as source."
|
369 |
+
else:
|
370 |
+
if doc_type == "Pink":
|
371 |
+
doc_content = shredded_document
|
372 |
+
elif doc_type == "Pink Review":
|
373 |
+
doc_content = pink_review_document if pink_review_document else ""
|
374 |
+
elif doc_type == "Red":
|
375 |
+
doc_content = pink_review_document if pink_review_document else ""
|
376 |
+
elif doc_type == "Red Review":
|
377 |
+
doc_content = pink_review_document if pink_review_document else ""
|
378 |
+
elif doc_type == "Gold":
|
379 |
+
doc_content = shredded_document
|
380 |
+
elif doc_type == "Gold Review":
|
381 |
+
doc_content = shredded_document
|
382 |
+
elif doc_type == "Virtual Board":
|
383 |
+
doc_content = shredded_document
|
384 |
+
elif doc_type == "LOE":
|
385 |
+
doc_content = shredded_document
|
386 |
else:
|
387 |
+
doc_content = shredded_document
|
388 |
|
389 |
+
try:
|
390 |
+
if doc_type == "Pink Review":
|
391 |
+
current_document = generate_document(doc_type, [doc_content, shredded_document])
|
392 |
pink_review_document = current_document
|
393 |
+
elif doc_type in ["Red", "Red Review"]:
|
394 |
+
current_document = generate_document(doc_type, [doc_content, shredded_document])
|
395 |
+
else:
|
396 |
+
current_document = generate_document(doc_type, [doc_content])
|
397 |
+
logging.info(f"{doc_type} document generated successfully.")
|
398 |
+
return dcc.Markdown(current_document), f"{doc_type} generated", f"{doc_type} document generated successfully."
|
399 |
except Exception as e:
|
400 |
logging.error(f"Error generating document: {str(e)}")
|
401 |
+
return html.Div(f"Error generating document: {str(e)}"), "Error", "An error occurred while generating the document."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
402 |
|
403 |
@app.callback(
|
404 |
Output('chat-output', 'children'),
|