Kevinshh commited on
Commit
366270f
·
verified ·
1 Parent(s): b67f7b6

Upload streamlit_app.py

Browse files
Files changed (1) hide show
  1. streamlit_app.py +769 -0
streamlit_app.py ADDED
@@ -0,0 +1,769 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Streamlit Application - Drug Stability Intelligence Platform
3
+
4
+ This is the main entry point for the three-layer architecture:
5
+ - Layer 1: IntentParser (LLM semantic understanding)
6
+ - Layer 2: RegulatoryDecisionEngine (Rule-based calculations)
7
+ - Layer 3: ExplanationGenerator + Plotly charts + Report generation
8
+
9
+ Features:
10
+ - Interactive stability analysis
11
+ - Plotly charts with confidence intervals
12
+ - Dual output: Streamlit interactive + HTML/PDF archive
13
+ """
14
+
15
+ import streamlit as st
16
+ import plotly.graph_objects as go
17
+ import plotly.express as px
18
+ from plotly.subplots import make_subplots
19
+ import json
20
+ import tempfile
21
+ from datetime import datetime
22
+ from typing import Dict, Any, Optional, List
23
+ from pathlib import Path
24
+
25
+ # Local imports
26
+ from schemas.analysis_intent import (
27
+ AnalysisIntent,
28
+ AnalysisType,
29
+ AnalysisPurpose,
30
+ ExtractedDataSummary,
31
+ )
32
+ from schemas.decision_result import (
33
+ RegulatoryDecisionResult,
34
+ RefusalSeverity,
35
+ )
36
+ from layers.intent_parser import IntentParser
37
+ from layers.regulatory_decision_engine import RegulatoryDecisionEngine
38
+ from layers.explanation_generator import ExplanationGenerator
39
+ from layers.model_invoker import ModelInvoker
40
+ from utils.file_parsers import parse_file
41
+ from utils.stability_data_extractor import StabilityDataExtractor
42
+ from utils.stability_report_formatter import StabilityReportFormatter
43
+
44
+
45
+ # =============================================================================
46
+ # App Configuration
47
+ # =============================================================================
48
+
49
+ st.set_page_config(
50
+ page_title="Drug Stability Intelligence Platform",
51
+ page_icon="🧪",
52
+ layout="wide",
53
+ initial_sidebar_state="expanded"
54
+ )
55
+
56
+ # Custom CSS
57
+ st.markdown("""
58
+ <style>
59
+ .main-header {
60
+ font-size: 2.5rem;
61
+ font-weight: 700;
62
+ background: linear-gradient(90deg, #1e3a5f, #2e7d32);
63
+ -webkit-background-clip: text;
64
+ -webkit-text-fill-color: transparent;
65
+ text-align: center;
66
+ margin-bottom: 1rem;
67
+ }
68
+ .sub-header {
69
+ font-size: 1.1rem;
70
+ color: #666;
71
+ text-align: center;
72
+ margin-bottom: 2rem;
73
+ }
74
+ .metric-card {
75
+ background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
76
+ border-radius: 10px;
77
+ padding: 1rem;
78
+ margin: 0.5rem 0;
79
+ }
80
+ .warning-box {
81
+ background-color: #fff3cd;
82
+ border: 1px solid #ffc107;
83
+ border-radius: 5px;
84
+ padding: 1rem;
85
+ margin: 1rem 0;
86
+ }
87
+ .success-box {
88
+ background-color: #d4edda;
89
+ border: 1px solid #28a745;
90
+ border-radius: 5px;
91
+ padding: 1rem;
92
+ margin: 1rem 0;
93
+ }
94
+ .refusal-box {
95
+ background-color: #f8d7da;
96
+ border: 1px solid #dc3545;
97
+ border-radius: 5px;
98
+ padding: 1rem;
99
+ margin: 1rem 0;
100
+ }
101
+ </style>
102
+ """, unsafe_allow_html=True)
103
+
104
+
105
+ # =============================================================================
106
+ # Chart Generation (Plotly)
107
+ # =============================================================================
108
+
109
+ def create_prediction_chart(
110
+ kinetic_fits: Dict,
111
+ predictions: Dict,
112
+ specification_limit: float
113
+ ) -> go.Figure:
114
+ """Create prediction chart with confidence intervals."""
115
+
116
+ fig = go.Figure()
117
+
118
+ # Colors
119
+ colors = px.colors.qualitative.Set2
120
+
121
+ # Plot each condition
122
+ for i, (cond_id, fit) in enumerate(kinetic_fits.items()):
123
+ color = colors[i % len(colors)]
124
+
125
+ # Generate fitted line
126
+ if hasattr(fit, 'k') and hasattr(fit, 'y0'):
127
+ t_line = list(range(0, 37, 3))
128
+ y_line = [fit.y0 + fit.k * t for t in t_line]
129
+
130
+ fig.add_trace(go.Scatter(
131
+ x=t_line,
132
+ y=y_line,
133
+ mode='lines',
134
+ name=f'{cond_id} (拟合线)',
135
+ line=dict(color=color, width=2)
136
+ ))
137
+
138
+ pred_y = []
139
+ ci_lower = []
140
+ ci_upper = []
141
+
142
+ for tp_key, pred in predictions.items():
143
+ if hasattr(pred, 'timepoint_months'):
144
+ pred_x.append(pred.timepoint_months)
145
+ pred_y.append(pred.point_estimate)
146
+ ci_lower.append(pred.CI_lower)
147
+ ci_upper.append(pred.CI_upper)
148
+
149
+ if pred_x:
150
+ # CI band
151
+ fig.add_trace(go.Scatter(
152
+ x=pred_x + pred_x[::-1],
153
+ y=ci_upper + ci_lower[::-1],
154
+ fill='toself',
155
+ fillcolor='rgba(40, 167, 69, 0.2)',
156
+ line=dict(color='rgba(255,255,255,0)'),
157
+ name='95% 置信区间',
158
+ showlegend=True
159
+ ))
160
+
161
+ # Prediction points
162
+ fig.add_trace(go.Scatter(
163
+ x=pred_x,
164
+ y=pred_y,
165
+ mode='markers',
166
+ name='预测值',
167
+ marker=dict(color='#28a745', size=12, symbol='diamond')
168
+ ))
169
+
170
+ # Specification limit
171
+ fig.add_hline(
172
+ y=specification_limit,
173
+ line_dash="dash",
174
+ line_color="#dc3545",
175
+ annotation_text=f"规格限度 ({specification_limit}%)"
176
+ )
177
+
178
+ fig.update_layout(
179
+ title="稳定性预测曲线 (含95%置信区间)",
180
+ xaxis_title="时间 (月)",
181
+ yaxis_title="杂质含量 (%)",
182
+ legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01),
183
+ template="plotly_white",
184
+ height=500
185
+ )
186
+
187
+ return fig
188
+
189
+
190
+ def create_batch_comparison_chart(
191
+ batch_ranking: List,
192
+ kinetic_fits: Dict
193
+ ) -> go.Figure:
194
+ """Create batch comparison bar chart."""
195
+
196
+ if not batch_ranking:
197
+ return go.Figure()
198
+
199
+ # Handle both dataclass objects and dicts
200
+ batch_names = []
201
+ scores = []
202
+ for r in batch_ranking:
203
+ if hasattr(r, 'batch_name'):
204
+ # It's a dataclass
205
+ batch_names.append(r.batch_name or r.batch_id or 'Unknown')
206
+ scores.append(r.score if r.score is not None else 0)
207
+ else:
208
+ # It's a dict
209
+ batch_names.append(r.get('batch_name', r.get('batch_id', 'Unknown')))
210
+ scores.append(r.get('score', 0))
211
+
212
+ # Color based on score
213
+ colors = ['#28a745' if s >= 80 else '#ffc107' if s >= 60 else '#dc3545' for s in scores]
214
+
215
+ fig = go.Figure(data=[
216
+ go.Bar(
217
+ x=batch_names,
218
+ y=scores,
219
+ marker_color=colors,
220
+ text=scores,
221
+ textposition='auto'
222
+ )
223
+ ])
224
+
225
+ fig.update_layout(
226
+ title="批次稳定性评分对比",
227
+ xaxis_title="批次",
228
+ yaxis_title="评分",
229
+ yaxis_range=[0, 105],
230
+ template="plotly_white",
231
+ height=400
232
+ )
233
+
234
+ return fig
235
+
236
+
237
+ def create_kinetics_scatter(kinetic_fits: Dict) -> go.Figure:
238
+ """Create kinetics comparison scatter plot."""
239
+
240
+ if not kinetic_fits:
241
+ return go.Figure()
242
+
243
+ conditions = list(kinetic_fits.keys())
244
+ k_values = [fit.k if hasattr(fit, 'k') else 0 for fit in kinetic_fits.values()]
245
+ r2_values = [fit.R2 if hasattr(fit, 'R2') else 0 for fit in kinetic_fits.values()]
246
+
247
+ fig = go.Figure(data=[
248
+ go.Scatter(
249
+ x=k_values,
250
+ y=r2_values,
251
+ mode='markers+text',
252
+ text=conditions,
253
+ textposition='top center',
254
+ marker=dict(
255
+ size=20,
256
+ color=r2_values,
257
+ colorscale='RdYlGn',
258
+ showscale=True,
259
+ colorbar=dict(title="R²")
260
+ )
261
+ )
262
+ ])
263
+
264
+ fig.add_hline(y=0.9, line_dash="dash", line_color="green",
265
+ annotation_text="R² = 0.9 (高质量)")
266
+ fig.add_hline(y=0.8, line_dash="dash", line_color="orange",
267
+ annotation_text="R² = 0.8 (最低要求)")
268
+
269
+ fig.update_layout(
270
+ title="动力学拟合质量分布",
271
+ xaxis_title="降解速率 k (%/月)",
272
+ yaxis_title="决定系数 R²",
273
+ template="plotly_white",
274
+ height=400
275
+ )
276
+
277
+ return fig
278
+
279
+
280
+ # =============================================================================
281
+ # Core Analysis Pipeline
282
+ # =============================================================================
283
+
284
+ @st.cache_resource
285
+ def get_engine():
286
+ """Get cached engine instances."""
287
+ return {
288
+ "intent_parser": IntentParser(),
289
+ "decision_engine": RegulatoryDecisionEngine(),
290
+ "explanation_generator": ExplanationGenerator()
291
+ }
292
+
293
+
294
+ def run_analysis(
295
+ goal: str,
296
+ uploaded_files: List,
297
+ purpose: str = "rd_reference",
298
+ specification_limit: Optional[float] = None,
299
+ target_timepoints: Optional[List[int]] = None
300
+ ) -> tuple:
301
+ """
302
+ Run the full three-layer analysis pipeline.
303
+
304
+ Parameters are now optional - system will infer from data/goal if not provided.
305
+
306
+ Returns:
307
+ Tuple of (intent, result, explanations)
308
+ """
309
+ engines = get_engine()
310
+
311
+ # ==== PHASE 1: Parse files and extract structured data ====
312
+ all_text = ""
313
+ temp_paths = []
314
+
315
+ for uploaded_file in uploaded_files:
316
+ try:
317
+ # Save to temp file for parsing
318
+ suffix = Path(uploaded_file.name).suffix
319
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
320
+ tmp.write(uploaded_file.getvalue())
321
+ tmp_path = tmp.name
322
+ temp_paths.append(tmp_path)
323
+
324
+ # Parse file to get raw text
325
+ content = parse_file(tmp_path)
326
+ if content:
327
+ all_text += f"\n=== File: {uploaded_file.name} ===\n{content}\n"
328
+
329
+ except Exception as e:
330
+ st.warning(f"文件解析警告: {uploaded_file.name} - {str(e)}")
331
+
332
+ # ==== PHASE 2: Extract structured data using StabilityDataExtractor ====
333
+ extractor = StabilityDataExtractor()
334
+ raw_extracted = extractor.extract_from_text(all_text, goal)
335
+
336
+ # Use extracted spec limit if user didn't provide one
337
+ if specification_limit is None:
338
+ specification_limit = raw_extracted.get("specification_limit", 0.5)
339
+
340
+ # Use extracted target timepoints if user didn't provide
341
+ if target_timepoints is None or len(target_timepoints) == 0:
342
+ target_timepoints = raw_extracted.get("target_timepoints", [24, 36])
343
+
344
+ # ==== PHASE 3: Convert to batches format for RegulatoryDecisionEngine ====
345
+ extracted_data = _convert_to_batches_format(raw_extracted)
346
+
347
+ # Build data summary for intent parser
348
+ data_summary = _build_data_summary(extracted_data)
349
+
350
+ # ==== Layer 1: Parse Intent ====
351
+ intent = engines["intent_parser"].parse(goal, data_summary)
352
+
353
+ # Apply user selections (or extracted defaults)
354
+ intent.preferences.target_timepoints = target_timepoints
355
+ intent.constraints.specification_limit = specification_limit
356
+ try:
357
+ intent.constraints.purpose = AnalysisPurpose(purpose)
358
+ except ValueError:
359
+ intent.constraints.purpose = AnalysisPurpose.RD_REFERENCE
360
+
361
+ # ==== Layer 2: Execute Regulatory Decision ====
362
+ result = engines["decision_engine"].execute(intent, extracted_data)
363
+
364
+ # ==== Layer 3: Generate Explanations ====
365
+ explanations = engines["explanation_generator"].generate(
366
+ result=result,
367
+ purpose=purpose,
368
+ specification_limit=specification_limit,
369
+ confidence_level=intent.preferences.required_confidence
370
+ )
371
+
372
+ return intent, result, explanations
373
+
374
+
375
+ def _convert_to_batches_format(raw_extracted: Dict) -> Dict[str, Any]:
376
+ """
377
+ Convert StabilityDataExtractor output to RegulatoryDecisionEngine expected format.
378
+
379
+ Input format (from extractor):
380
+ {\"demo_longterm\": {times: [...], values: [...]}, ...}
381
+ OR
382
+ {"batches": [...]} (from generic extraction)
383
+
384
+ Output format (for engine):
385
+ {\"batches\": [{batch_id, conditions: [{condition_id, timepoints, cqa_data}]}]}
386
+ """
387
+ # If generic extraction already provided batches, use them directly
388
+ if raw_extracted.get("batches"):
389
+ return {"batches": raw_extracted["batches"]}
390
+
391
+ batches = []
392
+ cqa_name = raw_extracted.get("cqa", "总杂质")
393
+
394
+ # Demo Batch
395
+ demo_conditions = []
396
+ if raw_extracted.get("demo_longterm"):
397
+ demo_lt = raw_extracted["demo_longterm"]
398
+ demo_conditions.append({
399
+ "condition_id": "Demo_25C_LongTerm",
400
+ "timepoints": demo_lt.get("times", []),
401
+ "cqa_data": [{
402
+ "cqa_name": cqa_name,
403
+ "values": demo_lt.get("values", [])
404
+ }]
405
+ })
406
+ if raw_extracted.get("demo_accelerated"):
407
+ demo_acc = raw_extracted["demo_accelerated"]
408
+ demo_conditions.append({
409
+ "condition_id": "Demo_40C_Accelerated",
410
+ "timepoints": demo_acc.get("times", []),
411
+ "cqa_data": [{
412
+ "cqa_name": cqa_name,
413
+ "values": demo_acc.get("values", [])
414
+ }]
415
+ })
416
+
417
+ if demo_conditions:
418
+ batches.append({
419
+ "batch_id": "Demo",
420
+ "batch_name": "Demo批次",
421
+ "batch_type": "reference",
422
+ "conditions": demo_conditions
423
+ })
424
+
425
+ # Target Batch
426
+ target_conditions = []
427
+ if raw_extracted.get("target_accelerated"):
428
+ target_acc = raw_extracted["target_accelerated"]
429
+ target_conditions.append({
430
+ "condition_id": "Target_40C_Accelerated",
431
+ "timepoints": target_acc.get("times", []),
432
+ "cqa_data": [{
433
+ "cqa_name": cqa_name,
434
+ "values": target_acc.get("values", [])
435
+ }]
436
+ })
437
+ if raw_extracted.get("target_destructive"):
438
+ target_dest = raw_extracted["target_destructive"]
439
+ target_conditions.append({
440
+ "condition_id": "Target_60C_Destructive",
441
+ "timepoints": target_dest.get("times", []),
442
+ "cqa_data": [{
443
+ "cqa_name": cqa_name,
444
+ "values": target_dest.get("values", [])
445
+ }]
446
+ })
447
+
448
+ if target_conditions:
449
+ batches.append({
450
+ "batch_id": "Target",
451
+ "batch_name": "处方1",
452
+ "batch_type": "target",
453
+ "conditions": target_conditions
454
+ })
455
+
456
+ return {"batches": batches}
457
+
458
+
459
+ def _build_data_summary(extracted_data: Dict) -> ExtractedDataSummary:
460
+ """Build ExtractedDataSummary from extracted batches data."""
461
+ data_summary = ExtractedDataSummary()
462
+
463
+ batches = extracted_data.get("batches", [])
464
+ if not batches:
465
+ return data_summary
466
+
467
+ data_summary.batch_ids = [b.get("batch_id", "") for b in batches]
468
+ all_conditions = []
469
+ all_cqas = []
470
+ all_timepoints = []
471
+
472
+ for batch in batches:
473
+ for cond in batch.get("conditions", []):
474
+ all_conditions.append(cond.get("condition_id", ""))
475
+ tps = cond.get("timepoints", [])
476
+ all_timepoints.extend([t for t in tps if t is not None])
477
+ for cqa in cond.get("cqa_data", []):
478
+ all_cqas.append(cqa.get("cqa_name", ""))
479
+
480
+ data_summary.conditions = list(set(all_conditions))
481
+ data_summary.cqa_list = list(set(all_cqas))
482
+ data_summary.available_timepoints = sorted(set(all_timepoints))
483
+
484
+ return data_summary
485
+
486
+
487
+ # =============================================================================
488
+ # Main Application
489
+ # =============================================================================
490
+
491
+ def main():
492
+ """Main Streamlit application."""
493
+
494
+ # Header
495
+ st.markdown('<h1 class="main-header">🧪 Drug Stability Intelligence Platform</h1>',
496
+ unsafe_allow_html=True)
497
+ st.markdown('<p class="sub-header">ICH/FDA/EMA合规的智能稳定性分析系统</p>',
498
+ unsafe_allow_html=True)
499
+
500
+ # Sidebar
501
+ with st.sidebar:
502
+ st.header("⚙️ 分析设置")
503
+
504
+ # LLM Provider
505
+ st.subheader("🔑 LLM 配置")
506
+ provider = st.selectbox(
507
+ "选择提供商",
508
+ ["Moonshot Kimi", "Google Gemini", "OpenAI", "Deepseek"],
509
+ index=0
510
+ )
511
+ api_key = st.text_input("API Key", type="password")
512
+
513
+ st.divider()
514
+
515
+ # Analysis Mode - NEW: Let user choose analysis type
516
+ st.subheader("📊 分析模式")
517
+ analysis_mode = st.selectbox(
518
+ "选择分析模式",
519
+ [
520
+ ("🤖 智能分析 (自动识别)", "auto"),
521
+ ("📈 稳定性预测", "prediction"),
522
+ ("🏷️ 批次筛选/对比", "batch_comparison"),
523
+ ("📊 趋势评估", "trend")
524
+ ],
525
+ format_func=lambda x: x[0],
526
+ help="系统将根据您的分析目标自动调整参数,您也可以在下方手动设置"
527
+ )[1]
528
+
529
+ purpose = st.selectbox(
530
+ "分析目的",
531
+ [
532
+ ("研发参考", "rd_reference"),
533
+ ("法规申报", "regulatory_submission"),
534
+ ("内部决策", "internal_decision")
535
+ ],
536
+ format_func=lambda x: x[0]
537
+ )[1]
538
+
539
+ # Optional Advanced Settings - in expander
540
+ with st.expander("⚙️ 高级参数 (可选)", expanded=False):
541
+ st.caption("💡 不设置时,系统将从数据或分析目标中自动推断")
542
+
543
+ use_custom_spec = st.checkbox("手动设置规格限度", value=False)
544
+ if use_custom_spec:
545
+ spec_limit = st.number_input(
546
+ "规格限度 (%)",
547
+ min_value=0.1,
548
+ max_value=10.0,
549
+ value=0.5,
550
+ step=0.1
551
+ )
552
+ else:
553
+ spec_limit = None # Will be inferred from data
554
+
555
+ use_custom_tp = st.checkbox("手动设置预测时间点", value=False)
556
+ if use_custom_tp:
557
+ target_tp = st.multiselect(
558
+ "目标预测时间点 (月)",
559
+ [6, 12, 18, 24, 30, 36, 48],
560
+ default=[24, 36]
561
+ )
562
+ else:
563
+ target_tp = None # Will be inferred from goal
564
+
565
+ st.divider()
566
+
567
+ st.subheader("ℹ️ 系统信息")
568
+ st.info("""
569
+ **三层架构**
570
+ - Layer 1: 意图理解 (LLM)
571
+ - Layer 2: 科学决策 (规则)
572
+ - Layer 3: 呈现报告 (LLM+Plotly)
573
+ """)
574
+
575
+ # Main content
576
+ col1, col2 = st.columns([1, 2])
577
+
578
+ with col1:
579
+ st.header("📁 数据输入")
580
+
581
+ # File upload
582
+ uploaded_files = st.file_uploader(
583
+ "上传稳定性数据文件",
584
+ type=["xlsx", "xls", "docx", "doc", "pdf", "csv"],
585
+ accept_multiple_files=True
586
+ )
587
+
588
+ # Analysis goal
589
+ goal = st.text_area(
590
+ "🎯 分析目标",
591
+ placeholder="例如:请预测SF-0047批次在24个月和36个月时的总杂质含量",
592
+ height=100
593
+ )
594
+
595
+ # Analyze button
596
+ analyze_clicked = st.button("🚀 开始分析", type="primary", use_container_width=True)
597
+
598
+ with col2:
599
+ st.header("📈 分析结果")
600
+
601
+ if analyze_clicked:
602
+ if not uploaded_files:
603
+ st.error("请上传稳定性数据文件")
604
+ elif not goal:
605
+ st.error("请输入分析目标")
606
+ else:
607
+ with st.spinner("正在执行三层分析..."):
608
+ try:
609
+ intent, result, explanations = run_analysis(
610
+ goal=goal,
611
+ uploaded_files=uploaded_files,
612
+ purpose=purpose,
613
+ specification_limit=spec_limit,
614
+ target_timepoints=target_tp
615
+ )
616
+
617
+ # Store in session state
618
+ st.session_state['intent'] = intent
619
+ st.session_state['result'] = result
620
+ st.session_state['explanations'] = explanations
621
+
622
+ except Exception as e:
623
+ st.error(f"分析过程发生错误: {str(e)}")
624
+
625
+ # Display results
626
+ if 'result' in st.session_state:
627
+ result = st.session_state['result']
628
+ explanations = st.session_state.get('explanations', {})
629
+
630
+ # Check if refused
631
+ if not result.can_proceed and result.refusal:
632
+ st.markdown(f"""
633
+ <div class="refusal-box">
634
+ <h3>⚠️ 分析无法完成</h3>
635
+ <p><strong>原因:</strong> {result.refusal.reason}</p>
636
+ <p><strong>法规依据:</strong> {result.refusal.regulatory_reference}</p>
637
+ <p><strong>建议:</strong></p>
638
+ <ul>
639
+ {"".join(f"<li>{s}</li>" for s in result.refusal.suggestions)}
640
+ </ul>
641
+ </div>
642
+ """, unsafe_allow_html=True)
643
+
644
+ else:
645
+ # Executive Summary
646
+ st.subheader("📋 执行摘要")
647
+ st.success(explanations.get("executive_summary", result.get_executive_summary()))
648
+
649
+ # Tabs for different views
650
+ tabs = st.tabs(["📊 可视化", "📈 动力学结果", "🔮 预测结果", "📝 完整报告"])
651
+
652
+ with tabs[0]:
653
+ # Charts
654
+ if result.predictions:
655
+ fig = create_prediction_chart(
656
+ result.kinetic_fits,
657
+ result.predictions,
658
+ spec_limit
659
+ )
660
+ st.plotly_chart(fig, use_container_width=True)
661
+
662
+ if result.batch_ranking:
663
+ fig = create_batch_comparison_chart(
664
+ result.batch_ranking,
665
+ result.kinetic_fits
666
+ )
667
+ st.plotly_chart(fig, use_container_width=True)
668
+
669
+ if result.kinetic_fits:
670
+ fig = create_kinetics_scatter(result.kinetic_fits)
671
+ st.plotly_chart(fig, use_container_width=True)
672
+
673
+ with tabs[1]:
674
+ st.subheader("动力学拟合结果")
675
+
676
+ if result.kinetic_fits:
677
+ for cond_id, fit in result.kinetic_fits.items():
678
+ with st.expander(f"📌 {cond_id}", expanded=True):
679
+ col_a, col_b, col_c = st.columns(3)
680
+ col_a.metric("k (%/月)", f"{fit.k:.4f}")
681
+ col_b.metric("R²", f"{fit.R2:.4f}")
682
+ col_c.metric("SE(k)", f"{fit.SE_k:.4f}")
683
+ st.code(fit.equation)
684
+ else:
685
+ st.info("无动力学拟合结果")
686
+
687
+ with tabs[2]:
688
+ st.subheader("预测结果")
689
+
690
+ if result.predictions:
691
+ for tp, pred in result.predictions.items():
692
+ status_color = "🟢" if pred.is_compliant() else "🔴"
693
+ with st.expander(f"{status_color} {tp}", expanded=True):
694
+ col_a, col_b, col_c = st.columns(3)
695
+ col_a.metric("点预测", f"{pred.point_estimate:.2f}%")
696
+ col_b.metric("95% CI", f"{pred.CI_lower:.2f}% - {pred.CI_upper:.2f}%")
697
+ col_c.metric("距规格余量", f"{pred.margin_to_limit:.2f}%")
698
+ elif result.batch_ranking:
699
+ st.subheader("批次排名")
700
+ for r in result.batch_ranking:
701
+ medal = "🥇" if r.rank == 1 else "🥈" if r.rank == 2 else "🥉" if r.rank == 3 else "📍"
702
+ st.markdown(f"{medal} **{r.batch_name}** - 评分: {r.score} - {r.reason}")
703
+ else:
704
+ st.info("无预测结果")
705
+
706
+ with tabs[3]:
707
+ st.subheader("完整分析报告")
708
+
709
+ # Display all explanation sections
710
+ for section, content in explanations.items():
711
+ if content:
712
+ st.markdown(f"**{section.replace('_', ' ').title()}**")
713
+ st.markdown(content)
714
+ st.divider()
715
+
716
+ # Download buttons
717
+ st.subheader("📥 下载报告")
718
+ col_dl1, col_dl2 = st.columns(2)
719
+
720
+ with col_dl1:
721
+ # Generate HTML report
722
+ html_content = generate_html_report(result, explanations, spec_limit)
723
+ st.download_button(
724
+ "📄 下载 HTML 报告",
725
+ data=html_content,
726
+ file_name=f"stability_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.html",
727
+ mime="text/html",
728
+ use_container_width=True
729
+ )
730
+
731
+ with col_dl2:
732
+ st.button(
733
+ "📑 下载 PDF 报告 (需安装wkhtmltopdf)",
734
+ disabled=True,
735
+ use_container_width=True
736
+ )
737
+
738
+
739
+ def generate_html_report(
740
+ result: RegulatoryDecisionResult,
741
+ explanations: Dict[str, str],
742
+ spec_limit: float
743
+ ) -> str:
744
+ """Generate downloadable HTML report using dynamic orchestration."""
745
+
746
+ # Use the new ReportOrchestrator
747
+ from layers.report_orchestrator import ReportOrchestrator
748
+
749
+ # We need the intent - try to get it from session state
750
+ intent = st.session_state.get('intent')
751
+
752
+ if intent is None:
753
+ # Fallback: create minimal intent from available info
754
+ from schemas.analysis_intent import AnalysisIntent, AnalysisType
755
+ intent = AnalysisIntent(
756
+ user_question_raw="生成报告",
757
+ analysis_type=AnalysisType.BATCH_SCREENING if result.batch_ranking else AnalysisType.SHELF_LIFE_PREDICTION
758
+ )
759
+
760
+ # Generate report via orchestrator
761
+ orchestrator = ReportOrchestrator()
762
+ html_content = orchestrator.generate(intent, result, explanations)
763
+
764
+ return html_content
765
+
766
+
767
+
768
+ if __name__ == "__main__":
769
+ main()