| """ |
| Drug Stability Prompt Orchestrator - Professional Bilingual UI |
| |
| A beautiful, professional UI for drug-excipient compatibility analysis. |
| Features: |
| - Bilingual support (Chinese/English) |
| - Modern glassmorphism design |
| - Gradient backgrounds |
| - Smooth animations |
| - Professional pharmaceutical industry aesthetics |
| """ |
|
|
| import os |
| import sys |
| import tempfile |
| from pathlib import Path |
| from datetime import datetime |
| from typing import Optional, Tuple, List, Dict |
|
|
| import gradio as gr |
|
|
| |
| PROJECT_ROOT = Path(__file__).parent |
| sys.path.insert(0, str(PROJECT_ROOT)) |
|
|
| from schemas.canonical_schema import AnalysisRequest |
| from layers.input_normalizer import InputNormalizer |
| from layers.prompt_orchestrator import PromptOrchestrator |
| from layers.model_invoker import ModelInvoker |
| from layers.output_normalizer import OutputNormalizer |
| from layers.llm_providers import get_available_providers, LLMProvider |
| from report.generator import ReportGenerator |
| from utils.molecule_renderer import MoleculeRenderer |
| from utils.stability_report_formatter import StabilityReportFormatter |
|
|
|
|
| |
| |
| |
|
|
| TEXTS = { |
| "app_title": { |
| "cn": "🧪 药物-辅料相容性分析系统", |
| "en": "🧪 API-Excipient Compatibility Analyzer" |
| }, |
| "app_subtitle": { |
| "cn": "专业制剂研发智能预测平台", |
| "en": "Intelligent Formulation R&D Prediction Platform" |
| }, |
| "tab_analysis": { |
| "cn": "📊 相容性分析", |
| "en": "📊 Compatibility Analysis" |
| }, |
| "tab_settings": { |
| "cn": "⚙️ 系统设置", |
| "en": "⚙️ Settings" |
| }, |
| "tab_about": { |
| "cn": "ℹ️ 关于", |
| "en": "ℹ️ About" |
| }, |
| "input_section": { |
| "cn": "📋 输入信息", |
| "en": "📋 Input Information" |
| }, |
| "api_smiles_label": { |
| "cn": "API SMILES 结构式", |
| "en": "API SMILES Notation" |
| }, |
| "api_smiles_placeholder": { |
| "cn": "请输入药物分子的SMILES结构式...\n例如: CC(=O)Nc1ccc(O)cc1 (对乙酰氨基酚)", |
| "en": "Enter SMILES notation of the drug molecule...\nExample: CC(=O)Nc1ccc(O)cc1 (Acetaminophen)" |
| }, |
| "structure_preview": { |
| "cn": "分子结构预览", |
| "en": "Molecular Structure Preview" |
| }, |
| "properties": { |
| "cn": "理化性质", |
| "en": "Physicochemical Properties" |
| }, |
| "excipient_label": { |
| "cn": "辅料信息", |
| "en": "Excipient Information" |
| }, |
| "excipient_placeholder": { |
| "cn": "请输入辅料名称...\n例如: 乳糖, 微晶纤维素", |
| "en": "Enter excipient name...\nExample: Lactose, Microcrystalline Cellulose" |
| }, |
| "stability_label": { |
| "cn": "稳定性数据 (可选)", |
| "en": "Stability Data (Optional)" |
| }, |
| "stability_placeholder": { |
| "cn": "如有稳定性试验数据,请在此描述...", |
| "en": "Enter stability study data if available..." |
| }, |
| "file_upload": { |
| "cn": "上传文件 (可选)", |
| "en": "Upload Files (Optional)" |
| }, |
| "btn_clear": { |
| "cn": "🗑️ 清空", |
| "en": "🗑️ Clear" |
| }, |
| "btn_analyze": { |
| "cn": "🔬 开始分析", |
| "en": "🔬 Start Analysis" |
| }, |
| "btn_example": { |
| "cn": "📌 加载示例", |
| "en": "📌 Load Example" |
| }, |
| "output_section": { |
| "cn": "📊 分析报告", |
| "en": "📊 Analysis Report" |
| }, |
| "status_label": { |
| "cn": "状态", |
| "en": "Status" |
| }, |
| "status_waiting": { |
| "cn": "等待输入...", |
| "en": "Waiting for input..." |
| }, |
| "status_complete": { |
| "cn": "分析完成!", |
| "en": "Analysis complete!" |
| }, |
| "download_html": { |
| "cn": "下载 HTML 报告", |
| "en": "Download HTML Report" |
| }, |
| "download_pdf": { |
| "cn": "下载 PDF 报告", |
| "en": "Download PDF Report" |
| }, |
| "settings_title": { |
| "cn": "🔑 LLM 提供商设置", |
| "en": "🔑 LLM Provider Settings" |
| }, |
| "settings_desc": { |
| "cn": "选择您的LLM提供商并输入API密钥。如不配置,系统将以占位符模式运行。", |
| "en": "Select your LLM provider and enter API key. System runs in placeholder mode if not configured." |
| }, |
| "provider_label": { |
| "cn": "选择 LLM 提供商", |
| "en": "Select LLM Provider" |
| }, |
| "api_key_label": { |
| "cn": "API 密钥", |
| "en": "API Key" |
| }, |
| "api_key_placeholder": { |
| "cn": "请输入您的API密钥...", |
| "en": "Enter your API key..." |
| }, |
| "btn_apply": { |
| "cn": "✓ 应用设置", |
| "en": "✓ Apply Settings" |
| }, |
| "status_not_configured": { |
| "cn": "⚠️ 未配置LLM提供商(占位符模式)", |
| "en": "⚠️ LLM provider not configured (placeholder mode)" |
| }, |
| "status_configured": { |
| "cn": "✓ 已配置", |
| "en": "✓ Configured" |
| }, |
| "about_title": { |
| "cn": "关于本系统", |
| "en": "About This System" |
| }, |
| "report_placeholder": { |
| "cn": "请在左侧输入药物和辅料信息,然后点击\"开始分析\"按钮", |
| "en": "Enter drug and excipient information on the left, then click \"Start Analysis\"" |
| }, |
| "error_missing_input": { |
| "cn": "请填写API和辅料信息", |
| "en": "Please fill in API and excipient information" |
| }, |
| "smiles_invalid": { |
| "cn": "无效的SMILES结构式", |
| "en": "Invalid SMILES notation" |
| }, |
| "smiles_waiting": { |
| "cn": "输入SMILES后自动渲染", |
| "en": "Structure renders after SMILES input" |
| }, |
| "rdkit_not_installed": { |
| "cn": "RDKit未安装,无法渲染分子结构", |
| "en": "RDKit not installed, cannot render structure" |
| }, |
| "language": { |
| "cn": "English", |
| "en": "中文" |
| }, |
| "example_scenario": { |
| "cn": "**示例场景:** 对乙酰氨基酚 + 乳糖相容性分析", |
| "en": "**Example:** Acetaminophen + Lactose compatibility analysis" |
| }, |
| } |
|
|
| |
| LLM_PROVIDERS = [ |
| ("OpenAI (ChatGPT)", "openai"), |
| ("Google Gemini", "gemini"), |
| ("Anthropic Claude", "claude"), |
| ("xAI Grok", "grok"), |
| ("Moonshot Kimi", "kimi"), |
| ("Deepseek", "deepseek"), |
| ("智谱清言 (GLM)", "zhipu"), |
| ] |
|
|
|
|
| |
| |
| |
|
|
| mol_renderer = MoleculeRenderer() |
|
|
|
|
| |
| |
| |
|
|
| class CompatibilityAnalyzer: |
| """Main analyzer orchestrating the full analysis pipeline. |
| |
| Now uses the dual-phase ProfessionalAnalyzer for: |
| - Phase 1: Deep chemical reasoning |
| - Phase 2: Professional report writing |
| """ |
| |
| def __init__(self): |
| try: |
| from layers.professional_analyzer import ProfessionalAnalyzer |
| self.professional_analyzer = ProfessionalAnalyzer() |
| self.model_invoker = self.professional_analyzer.model_invoker |
| self._use_professional = True |
| except ImportError: |
| |
| self.model_invoker = ModelInvoker() |
| self.professional_analyzer = None |
| self._use_professional = False |
| print("Warning: ProfessionalAnalyzer not available, using legacy analyzer") |
| |
| def set_llm_provider(self, provider: str, api_key: str): |
| if provider and api_key: |
| self.model_invoker.set_provider(provider, api_key) |
| |
| def analyze( |
| self, |
| api_text: str, |
| excipient_text: str, |
| stability_text: str = "", |
| uploaded_files: List[str] = None, |
| progress_callback=None, |
| structure_image: str = None, |
| lang: str = "cn", |
| ) -> Tuple[str, Optional[str]]: |
| """Run the full analysis pipeline using dual-phase approach.""" |
| try: |
| progress_msgs = { |
| "cn": { |
| "parsing": "正在解析分子结构...", |
| "groups": "正在识别反应活性基团...", |
| "rendering": "正在生成分子结构图...", |
| "phase1": "Phase 1: 深度机理推理中...", |
| "phase2": "Phase 2: 专业报告撰写中...", |
| "complete": "分析完成!", |
| }, |
| "en": { |
| "parsing": "Parsing molecular structure...", |
| "groups": "Identifying reactive groups...", |
| "rendering": "Generating structure image...", |
| "phase1": "Phase 1: Deep reasoning analysis...", |
| "phase2": "Phase 2: Professional report writing...", |
| "complete": "Analysis complete!", |
| } |
| } |
| |
| |
| smiles = api_text.strip() |
| excipient_name = excipient_text.strip() |
| goal = stability_text.strip() |
| |
| |
| if not self._use_professional or self.professional_analyzer is None: |
| return '<div style="color:red;padding:20px;">ProfessionalAnalyzer not available. Please ensure professional_analyzer.py is deployed.</div>', None |
| |
| |
| |
| |
| |
| if not smiles and (uploaded_files and goal): |
| def stability_progress(progress: float, msg: str): |
| if progress_callback: |
| progress_callback(progress, msg) |
| |
| |
| result = self.professional_analyzer.analyze_stability_advanced( |
| goal=goal, |
| file_paths=uploaded_files, |
| api_info=f"API Info Provided: {bool(smiles)}", |
| excipient_info=excipient_name, |
| progress_callback=stability_progress |
| ) |
| |
| if not result["success"]: |
| return f'<div style="color:red;padding:20px;">稳定性分析失败: {result.get("error")}</div>', None |
| |
| |
| html_report = StabilityReportFormatter.format_professional_report( |
| analysis_result=result, |
| goal=goal |
| ) |
| |
| return html_report, None |
|
|
| |
| |
| |
| |
| elif smiles: |
| |
| api_name = smiles[:30] + "..." if len(smiles) > 30 else smiles |
| |
| |
| def wrapped_progress(progress: float, msg: str): |
| if progress_callback: |
| if progress <= 0.2: |
| display_msg = progress_msgs[lang]["parsing"] |
| elif progress <= 0.3: |
| display_msg = progress_msgs[lang]["phase1"] |
| elif progress <= 0.7: |
| display_msg = progress_msgs[lang]["phase1"] |
| elif progress < 1.0: |
| display_msg = progress_msgs[lang]["phase2"] |
| else: |
| display_msg = progress_msgs[lang]["complete"] |
| progress_callback(progress, display_msg) |
| |
| result = self.professional_analyzer.analyze( |
| smiles=smiles, |
| excipient_name=excipient_name, |
| api_name=api_name, |
| progress_callback=wrapped_progress, |
| ) |
| |
| if not result["success"]: |
| error_msg = result.get("error", "Unknown error") |
| return f'<div style="color:red;padding:20px;">相容性分析出错: {error_msg}</div>', None |
| |
| |
| html_report = self.professional_analyzer.format_html_report( |
| analysis_result=result, |
| api_name=api_name, |
| excipient_name=excipient_name, |
| ) |
| |
| else: |
| return '<div style="color:red;padding:20px;">请输入 API SMILES 结构(用于相容性分析)或 上传数据文件并填写分析目标(用于稳定性分析)。</div>', None |
| |
| |
| if result.get("structure_image") and "[结构图待生成]" in html_report: |
| html_report = html_report.replace( |
| "[结构图待生成]", |
| f'<img src="{result["structure_image"]}" alt="Structure" style="max-width:100%;max-height:180px;">' |
| ) |
| |
| |
| pdf_path = None |
| try: |
| from report.generator import ReportGenerator |
| from schemas.canonical_schema import AnalysisResult |
| |
| |
| |
| with tempfile.NamedTemporaryFile(suffix='.html', delete=False, mode='w', encoding='utf-8') as tmp_html: |
| tmp_html.write(html_report) |
| tmp_html_path = tmp_html.name |
| |
| |
| try: |
| from weasyprint import HTML |
| with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False, prefix=f"report_{result['report_id']}_") as tmp_pdf: |
| HTML(string=html_report).write_pdf(tmp_pdf.name) |
| pdf_path = tmp_pdf.name |
| except ImportError: |
| print("WeasyPrint not available, PDF generation skipped") |
| except Exception as pdf_err: |
| print(f"PDF generation failed: {pdf_err}") |
| |
| except Exception as e: |
| print(f"PDF preparation failed: {e}") |
| |
| if progress_callback: |
| progress_callback(1.0, progress_msgs[lang]["complete"]) |
| |
| return html_report, pdf_path |
| |
| except Exception as e: |
| import traceback |
| traceback.print_exc() |
| error_msg = "分析出错" if lang == "cn" else "Analysis error" |
| return f'<div style="color:red;padding:20px;">{error_msg}: {str(e)}</div>', None |
|
|
|
|
| |
| |
| |
|
|
| def render_molecule(smiles: str, lang: str) -> Tuple[str, str, str]: |
| """Render molecular structure.""" |
| if not smiles or not smiles.strip(): |
| msg = TEXTS["smiles_waiting"][lang] |
| return f"<div class='mol-placeholder'>{msg}</div>", "", "" |
| |
| smiles = smiles.strip() |
| |
| if not mol_renderer.is_available: |
| msg = TEXTS["rdkit_not_installed"][lang] |
| return f"<div class='mol-error'>{msg}</div>", "", "" |
| |
| svg = mol_renderer.render_2d_svg(smiles, 320, 240) |
| if not svg: |
| msg = TEXTS["smiles_invalid"][lang] |
| return f"<div class='mol-error'>{msg}</div>", "", "" |
| |
| props = mol_renderer.calculate_properties(smiles) |
| props_html = "" |
| if props: |
| labels = { |
| "cn": ["分子量", "LogP", "H-供体", "H-受体", "TPSA", "可旋转键"], |
| "en": ["MW", "LogP", "HBD", "HBA", "TPSA", "RotBonds"] |
| } |
| props_html = f""" |
| <div class="props-grid"> |
| <div class="prop-item"><span class="prop-label">{labels[lang][0]}</span><span class="prop-value">{props['molecular_weight']}</span></div> |
| <div class="prop-item"><span class="prop-label">{labels[lang][1]}</span><span class="prop-value">{props['logp']}</span></div> |
| <div class="prop-item"><span class="prop-label">{labels[lang][2]}</span><span class="prop-value">{props['hbd']}</span></div> |
| <div class="prop-item"><span class="prop-label">{labels[lang][3]}</span><span class="prop-value">{props['hba']}</span></div> |
| <div class="prop-item"><span class="prop-label">{labels[lang][4]}</span><span class="prop-value">{props['tpsa']}</span></div> |
| <div class="prop-item"><span class="prop-label">{labels[lang][5]}</span><span class="prop-value">{props['rotatable_bonds']}</span></div> |
| </div> |
| """ |
| |
| return f"<div class='mol-display'>{svg}</div>", props_html, mol_renderer.get_data_uri(smiles, 320, 240) or "" |
|
|
|
|
| def get_text(key: str, lang: str) -> str: |
| """Get localized text.""" |
| return TEXTS.get(key, {}).get(lang, key) |
|
|
|
|
| |
| |
| |
|
|
| CUSTOM_CSS = """ |
| /* ======================================== |
| Global Styles & Variables |
| ======================================== */ |
| :root { |
| --primary: #1a5f7a; |
| --primary-light: #2d8bb8; |
| --primary-dark: #134a5f; |
| --accent: #57c5b6; |
| --accent-light: #7dd3c8; |
| --bg-dark: #0f1419; |
| --bg-card: rgba(255, 255, 255, 0.95); |
| --text-dark: #1a1a2e; |
| --text-light: #6b7280; |
| --border-light: rgba(0, 0, 0, 0.08); |
| --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.08); |
| --shadow-md: 0 4px 20px rgba(0, 0, 0, 0.12); |
| --shadow-lg: 0 8px 40px rgba(0, 0, 0, 0.16); |
| --gradient-primary: linear-gradient(135deg, #1a5f7a 0%, #2d8bb8 50%, #57c5b6 100%); |
| --gradient-accent: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
| --radius-sm: 8px; |
| --radius-md: 12px; |
| --radius-lg: 20px; |
| } |
| |
| .gradio-container { |
| font-family: 'Inter', 'Microsoft YaHei', -apple-system, BlinkMacSystemFont, sans-serif !important; |
| background: linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%) !important; |
| min-height: 100vh; |
| } |
| |
| /* ======================================== |
| Header Styles |
| ======================================== */ |
| .app-header { |
| background: var(--gradient-primary); |
| padding: 40px 30px; |
| border-radius: var(--radius-lg); |
| margin-bottom: 24px; |
| text-align: center; |
| box-shadow: var(--shadow-lg); |
| position: relative; |
| overflow: hidden; |
| } |
| |
| .app-header::before { |
| content: ''; |
| position: absolute; |
| top: -50%; |
| left: -50%; |
| width: 200%; |
| height: 200%; |
| background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 60%); |
| animation: shimmer 15s infinite linear; |
| } |
| |
| @keyframes shimmer { |
| 0% { transform: rotate(0deg); } |
| 100% { transform: rotate(360deg); } |
| } |
| |
| .app-header h1 { |
| margin: 0; |
| font-size: 32px; |
| font-weight: 700; |
| color: white; |
| text-shadow: 0 2px 10px rgba(0,0,0,0.2); |
| position: relative; |
| z-index: 1; |
| } |
| |
| .app-header p { |
| margin: 12px 0 0; |
| font-size: 16px; |
| color: rgba(255,255,255,0.9); |
| font-weight: 400; |
| position: relative; |
| z-index: 1; |
| } |
| |
| .lang-toggle { |
| position: absolute; |
| top: 20px; |
| right: 20px; |
| z-index: 10; |
| } |
| |
| .lang-toggle button { |
| background: rgba(255,255,255,0.2) !important; |
| border: 1px solid rgba(255,255,255,0.3) !important; |
| color: white !important; |
| padding: 8px 16px !important; |
| border-radius: 20px !important; |
| font-weight: 500 !important; |
| backdrop-filter: blur(10px); |
| transition: all 0.3s ease !important; |
| } |
| |
| .lang-toggle button:hover { |
| background: rgba(255,255,255,0.3) !important; |
| transform: translateY(-2px); |
| } |
| |
| /* ======================================== |
| Card Styles |
| ======================================== */ |
| .card { |
| background: var(--bg-card); |
| border-radius: var(--radius-md); |
| padding: 24px; |
| box-shadow: var(--shadow-md); |
| border: 1px solid var(--border-light); |
| backdrop-filter: blur(10px); |
| } |
| |
| .card-header { |
| font-size: 18px; |
| font-weight: 600; |
| color: var(--primary); |
| margin-bottom: 20px; |
| padding-bottom: 12px; |
| border-bottom: 2px solid var(--accent); |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| } |
| |
| /* ======================================== |
| Molecule Display |
| ======================================== */ |
| .mol-display { |
| background: linear-gradient(135deg, #f8fafc 0%, #e8f4f8 100%); |
| border-radius: var(--radius-md); |
| padding: 20px; |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| min-height: 260px; |
| border: 1px solid var(--border-light); |
| } |
| |
| .mol-placeholder, .mol-error { |
| color: var(--text-light); |
| font-size: 14px; |
| text-align: center; |
| padding: 40px; |
| } |
| |
| .mol-error { |
| color: #dc3545; |
| } |
| |
| .props-grid { |
| display: grid; |
| grid-template-columns: repeat(3, 1fr); |
| gap: 12px; |
| margin-top: 16px; |
| } |
| |
| .prop-item { |
| background: linear-gradient(135deg, #f0f7ff 0%, #e8f4f8 100%); |
| padding: 12px; |
| border-radius: var(--radius-sm); |
| text-align: center; |
| border: 1px solid rgba(45, 139, 184, 0.1); |
| } |
| |
| .prop-label { |
| display: block; |
| font-size: 11px; |
| color: var(--text-light); |
| text-transform: uppercase; |
| letter-spacing: 0.5px; |
| margin-bottom: 4px; |
| } |
| |
| .prop-value { |
| display: block; |
| font-size: 16px; |
| font-weight: 600; |
| color: var(--primary); |
| } |
| |
| /* ======================================== |
| Button Styles |
| ======================================== */ |
| .btn-primary { |
| background: var(--gradient-primary) !important; |
| color: white !important; |
| border: none !important; |
| padding: 14px 28px !important; |
| border-radius: var(--radius-sm) !important; |
| font-weight: 600 !important; |
| font-size: 15px !important; |
| box-shadow: var(--shadow-sm) !important; |
| transition: all 0.3s ease !important; |
| } |
| |
| .btn-primary:hover { |
| transform: translateY(-3px) !important; |
| box-shadow: var(--shadow-md) !important; |
| } |
| |
| .btn-secondary { |
| background: #f1f5f9 !important; |
| color: var(--text-dark) !important; |
| border: 1px solid var(--border-light) !important; |
| padding: 14px 28px !important; |
| border-radius: var(--radius-sm) !important; |
| font-weight: 500 !important; |
| transition: all 0.3s ease !important; |
| } |
| |
| .btn-secondary:hover { |
| background: #e2e8f0 !important; |
| } |
| |
| .btn-example { |
| background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%) !important; |
| color: #92400e !important; |
| border: 1px solid #fcd34d !important; |
| padding: 10px 20px !important; |
| border-radius: var(--radius-sm) !important; |
| font-weight: 500 !important; |
| } |
| |
| /* ======================================== |
| Input Styles |
| ======================================== */ |
| .input-field textarea, .input-field input { |
| border-radius: var(--radius-sm) !important; |
| border: 1px solid #e2e8f0 !important; |
| padding: 14px !important; |
| font-size: 14px !important; |
| transition: all 0.3s ease !important; |
| } |
| |
| .input-field textarea:focus, .input-field input:focus { |
| border-color: var(--primary-light) !important; |
| box-shadow: 0 0 0 3px rgba(45, 139, 184, 0.1) !important; |
| } |
| |
| /* ======================================== |
| Report Display |
| ======================================== */ |
| .report-container { |
| background: white; |
| border-radius: var(--radius-md); |
| border: 1px solid var(--border-light); |
| min-height: 500px; |
| box-shadow: var(--shadow-sm); |
| overflow: hidden; |
| } |
| |
| .report-placeholder { |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| justify-content: center; |
| min-height: 400px; |
| color: var(--text-light); |
| text-align: center; |
| padding: 40px; |
| } |
| |
| .report-placeholder svg { |
| width: 80px; |
| height: 80px; |
| margin-bottom: 20px; |
| opacity: 0.5; |
| } |
| |
| /* ======================================== |
| Settings Panel |
| ======================================== */ |
| .settings-panel { |
| background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%); |
| border: 1px solid #fcd34d; |
| border-radius: var(--radius-md); |
| padding: 24px; |
| } |
| |
| .settings-panel .label { |
| color: #92400e; |
| font-weight: 600; |
| } |
| |
| /* ======================================== |
| Status Badge |
| ======================================== */ |
| .status-badge { |
| display: inline-flex; |
| align-items: center; |
| gap: 8px; |
| padding: 8px 16px; |
| border-radius: 20px; |
| font-size: 13px; |
| font-weight: 500; |
| } |
| |
| .status-configured { |
| background: #d1fae5; |
| color: #065f46; |
| } |
| |
| .status-pending { |
| background: #fef3c7; |
| color: #92400e; |
| } |
| |
| /* ======================================== |
| Animations |
| ======================================== */ |
| @keyframes fadeIn { |
| from { opacity: 0; transform: translateY(10px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| |
| .fade-in { |
| animation: fadeIn 0.5s ease forwards; |
| } |
| |
| /* ======================================== |
| Responsive Design |
| ======================================== */ |
| @media (max-width: 768px) { |
| .app-header h1 { |
| font-size: 24px; |
| } |
| |
| .props-grid { |
| grid-template-columns: repeat(2, 1fr); |
| } |
| } |
| """ |
|
|
|
|
| |
| |
| |
|
|
| def create_interface() -> gr.Blocks: |
| """Create the professional bilingual Gradio interface.""" |
| |
| analyzer = CompatibilityAnalyzer() |
| |
| with gr.Blocks( |
| title="Drug-Excipient Compatibility Analyzer", |
| css=CUSTOM_CSS, |
| theme=gr.themes.Soft( |
| primary_hue="blue", |
| secondary_hue="cyan", |
| font=["Inter", "Microsoft YaHei", "sans-serif"], |
| ) |
| ) as demo: |
| |
| |
| lang_state = gr.State("cn") |
| structure_state = gr.State("") |
| mode_state = gr.State("compatibility") |
| |
| |
| with gr.Row(): |
| with gr.Column(): |
| header_html = gr.HTML(""" |
| <div class="app-header"> |
| <h1 id="app-title">🧪 药物-辅料相容性分析系统</h1> |
| <p id="app-subtitle">专业制剂研发智能预测平台</p> |
| </div> |
| """) |
| |
| |
| with gr.Row(): |
| with gr.Column(scale=4): |
| pass |
| with gr.Column(scale=1): |
| lang_btn = gr.Button("English", size="sm", elem_classes=["lang-toggle"]) |
| |
| |
| with gr.Tabs() as main_tabs: |
| |
| |
| with gr.TabItem("📊 智能分析 (Analysis)", id="tab_analysis") as analysis_tab: |
| |
| with gr.Row(equal_height=False): |
| |
| with gr.Column(scale=2): |
| input_header = gr.Markdown("### 📋 输入信息") |
| |
| |
| with gr.Tabs() as input_tabs: |
| |
| |
| with gr.TabItem("相容性预测 (Compatibility)", id="input_tab_compat"): |
| gr.Markdown("##### 🧪 基于API结构预测与辅料的反应") |
| smiles_input = gr.Textbox( |
| label="API SMILES 结构式", |
| placeholder="请输入药物分子的SMILES结构式...\n例如: CC(=O)Nc1ccc(O)cc1", |
| lines=2, |
| elem_classes=["input-field"], |
| ) |
| structure_html = gr.HTML( |
| value="<div class='mol-placeholder'>输入SMILES后自动渲染</div>", |
| ) |
| props_html = gr.HTML(value="") |
| excipient_input = gr.Textbox( |
| label="辅料名称", |
| placeholder="请输入辅料名称...\n例如: 乳糖", |
| lines=1, |
| elem_classes=["input-field"], |
| ) |
| with gr.Row(): |
| example_compat_btn = gr.Button("📌 加载相容性示例", elem_classes=["btn-example"]) |
|
|
| |
| with gr.TabItem("稳定性分析 (Stability)", id="input_tab_stability"): |
| gr.Markdown("##### 📈 基于实验数据评估趋势与效期") |
| file_upload = gr.File( |
| label="上传稳定性数据 (Excel/CSV/PDF/Word)", |
| file_count="multiple", |
| file_types=[".xlsx", ".xls", ".csv", ".txt", ".pdf", ".docx", ".doc", ".pptx", ".ppt"], |
| ) |
| stability_goal_input = gr.Textbox( |
| label="分析目标与判断标准", |
| placeholder="例如:基于长期试验数据(0,3,6月),评估杂质A增长趋势,判断是否满足24个月货架期要求...", |
| lines=4, |
| elem_classes=["input-field"], |
| ) |
| with gr.Row(): |
| example_stab_btn = gr.Button("📌 加载稳定性示例", elem_classes=["btn-example"]) |
| |
| |
| with gr.Row(elem_classes=["action-bar"]): |
| clear_btn = gr.Button("🗑️ 清空重置", elem_classes=["btn-secondary"]) |
| analyze_btn = gr.Button("🔬 开始专业分析", elem_classes=["btn-primary"]) |
| |
| |
| with gr.Column(scale=3): |
| output_header = gr.Markdown("### 📊 分析报告") |
| |
| status_text = gr.Textbox( |
| label="当前状态", |
| value="系统就绪,等待下达指令...", |
| interactive=False, |
| ) |
| |
| report_output = gr.HTML( |
| value=""" |
| <div class="report-placeholder"> |
| <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> |
| </svg> |
| <p>请在左侧选择 [相容性] 或 [稳定性] 模式进行分析</p> |
| </div> |
| """, |
| elem_classes=["report-container"], |
| ) |
| |
| with gr.Row(): |
| download_html = gr.File(label="下载 HTML", visible=False) |
| download_pdf = gr.File(label="下载 PDF", visible=False) |
| |
| |
| with gr.TabItem("⚙️ 系统设置", id="tab_settings") as settings_tab: |
| |
| settings_title = gr.Markdown("### 🔑 LLM 提供商设置") |
| settings_desc = gr.Markdown("选择您的LLM提供商并输入API密钥。如不配置,系统将以占位符模式运行。") |
| |
| with gr.Group(elem_classes=["settings-panel"]): |
| provider_dropdown = gr.Dropdown( |
| label="选择 LLM 提供商", |
| choices=LLM_PROVIDERS, |
| value=None, |
| ) |
| |
| api_key_input = gr.Textbox( |
| label="API 密钥", |
| placeholder="请输入您的API密钥...", |
| type="password", |
| ) |
| |
| provider_status = gr.Textbox( |
| label="状态", |
| value="⚠️ 未配置LLM提供商(占位符模式)", |
| interactive=False, |
| ) |
| |
| apply_btn = gr.Button("✓ 应用设置", elem_classes=["btn-primary"]) |
| |
| gr.Markdown(""" |
| ### 📝 API密钥获取 |
| |
| | 提供商 | 网址 | |
| |--------|------| |
| | OpenAI | platform.openai.com | |
| | Gemini | aistudio.google.com | |
| | Claude | console.anthropic.com | |
| | Grok | console.x.ai | |
| | Kimi | platform.moonshot.cn | |
| | Deepseek | platform.deepseek.com | |
| | 智谱 | open.bigmodel.cn | |
| """) |
| |
| |
| with gr.TabItem("ℹ️ 关于", id="tab_about"): |
| gr.Markdown(""" |
| ## 关于本系统 | About This System |
| |
| 本系统是一个专业的药物-辅料相容性分析平台,旨在帮助制剂研发人员快速评估药物与辅料之间的潜在相互作用风险。 |
| |
| This is a professional API-Excipient compatibility analysis platform designed to help formulation R&D scientists quickly assess potential interaction risks between drugs and excipients. |
| |
| --- |
| |
| ### 主要功能 | Key Features |
| |
| - **相容性预测** | Compatibility Prediction: 基于SMILES结构自动识别活性基团和反应风险 |
| - **稳定性分析** | Stability Analysis: 基于实验数据(Excel/CSV)进行趋势分析与效期评估 |
| - **专业报告** | Professional Report: 生成双语专业研发报告 (QbD/ICH准则) |
| - **多模型支持** | Multi-LLM Support: 支持7种主流大语言模型 |
| |
| --- |
| |
| ### 技术栈 | Tech Stack |
| |
| - **Frontend**: Gradio with custom CSS |
| - **Backend**: Python with modular layer architecture |
| - **Chemistry**: RDKit for molecular rendering |
| - **AI**: Multi-provider LLM integration |
| |
| --- |
| |
| ### 免责声明 | Disclaimer |
| |
| 本系统生成的分析报告仅供研发参考。相容性结论需通过实验验证(如加速稳定性试验)进行最终确认。 |
| |
| Analysis reports are for R&D reference only. Compatibility conclusions should be confirmed through experimental verification (e.g., accelerated stability testing). |
| """) |
| |
| |
| |
| def toggle_language(current_lang): |
| new_lang = "en" if current_lang == "cn" else "cn" |
| |
| |
| header = f""" |
| <div class="app-header"> |
| <h1>{get_text('app_title', new_lang)}</h1> |
| <p>{get_text('app_subtitle', new_lang)}</p> |
| </div> |
| """ |
| |
| |
| return ( |
| new_lang, |
| header, |
| get_text('language', new_lang), |
| f"### {get_text('input_section', new_lang)}", |
| f"### {get_text('output_section', new_lang)}", |
| get_text('status_waiting', new_lang), |
| f"### {get_text('settings_title', new_lang)}", |
| get_text('settings_desc', new_lang), |
| get_text('status_not_configured', new_lang), |
| get_text('example_scenario', new_lang), |
| ) |
| |
| def update_structure(smiles, lang): |
| svg, props, uri = render_molecule(smiles, lang) |
| return svg, props, uri |
| |
| def apply_settings(provider, api_key, lang): |
| if provider and api_key: |
| analyzer.set_llm_provider(provider, api_key) |
| name = dict(LLM_PROVIDERS).get(provider, provider) |
| return f"✓ {get_text('status_configured', lang)} {name}" |
| return get_text('status_not_configured', lang) |
| |
| def run_analysis(smiles, excipient, stability_goal, files, struct_img, lang, progress=gr.Progress()): |
| |
| |
| has_compat_input = bool(smiles and excipient) |
| has_stability_input = bool(files and stability_goal) |
| |
| if not has_compat_input and not has_stability_input: |
| return ( |
| f"<div class='report-placeholder' style='color:red;'>⚠️ 请至少完善一种分析模式的输入信息:<br>1. 相容性预测:需填写 SMILES 和 辅料名称<br>2. 稳定性分析:需上传数据文件 并 填写分析目标</div>", |
| "输入不完整", |
| gr.update(visible=False), |
| gr.update(visible=False), |
| ) |
| |
| file_paths = [f.name for f in files] if files else [] |
| |
| html, pdf = analyzer.analyze( |
| api_text=smiles if smiles else "", |
| excipient_text=excipient if excipient else "", |
| stability_text=stability_goal if stability_goal else "", |
| uploaded_files=file_paths, |
| progress_callback=lambda v, m: progress(v, desc=m), |
| structure_image=struct_img, |
| lang=lang, |
| ) |
| |
| html_file = None |
| try: |
| with tempfile.NamedTemporaryFile(mode='w', suffix='.html', delete=False, encoding='utf-8') as f: |
| f.write(html) |
| html_file = f.name |
| except: |
| pass |
| |
| return ( |
| html, |
| get_text('status_complete', lang), |
| gr.update(value=html_file, visible=html_file is not None), |
| gr.update(value=pdf, visible=pdf is not None), |
| ) |
| |
| def load_example_compat(): |
| return "CC(=O)Nc1ccc(O)cc1", "乳糖", None, "" |
| |
| def load_example_stability(): |
| |
| import os |
| dummy_path = "example_stability_data.csv" |
| with open(dummy_path, "w", encoding="utf-8") as f: |
| f.write("Time(Month),Assay(%),Impurity A(%),pH\n0,99.8,0.05,6.5\n3,99.2,0.12,6.4\n6,98.5,0.25,6.2") |
| |
| goal = "评估杂质A增长趋势,判断是否满足24个月效期标准(限度<0.5%)" |
| return "", "", [dummy_path], goal |
| |
| def clear_all(lang): |
| placeholder = f"<div class='mol-placeholder'>{get_text('smiles_waiting', lang)}</div>" |
| return "", "", "", None, placeholder, "", "" |
| |
| |
| lang_btn.click( |
| fn=toggle_language, |
| inputs=[lang_state], |
| outputs=[ |
| lang_state, header_html, lang_btn, |
| input_header, output_header, status_text, |
| settings_title, settings_desc, provider_status, |
| gr.Markdown() |
| ], |
| ) |
| |
| smiles_input.change( |
| fn=update_structure, |
| inputs=[smiles_input, lang_state], |
| outputs=[structure_html, props_html, structure_state], |
| ) |
| |
| apply_btn.click( |
| fn=apply_settings, |
| inputs=[provider_dropdown, api_key_input, lang_state], |
| outputs=[provider_status], |
| ) |
| |
| analyze_btn.click( |
| fn=run_analysis, |
| inputs=[smiles_input, excipient_input, stability_goal_input, file_upload, structure_state, lang_state], |
| outputs=[report_output, status_text, download_html, download_pdf], |
| ) |
| |
| example_compat_btn.click( |
| fn=load_example_compat, |
| outputs=[smiles_input, excipient_input, file_upload, stability_goal_input], |
| ) |
| |
| example_stab_btn.click( |
| fn=load_example_stability, |
| outputs=[smiles_input, excipient_input, file_upload, stability_goal_input], |
| ) |
| |
| clear_btn.click( |
| fn=clear_all, |
| inputs=[lang_state], |
| outputs=[smiles_input, excipient_input, stability_goal_input, file_upload, structure_html, props_html, structure_state], |
| ) |
| |
| return demo |
|
|
|
|
| |
| |
| |
|
|
| def main(): |
| demo = create_interface() |
| demo.launch( |
| server_name="0.0.0.0", |
| server_port=7860, |
| share=False, |
| show_error=True, |
| ) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|