CoolFace
Apppublic

PatSnap/Pharma-Intelligence

sourceHugging Facemitupdated 4mo agoView on Hugging Face
1likes
app.py2069 linesDownload Raw Back to root
1#!/usr/bin/env python32"""3PatSnap Pharma Intelligence — Hugging Face Space Demo4Product-grade multi-module AI agent for life science intelligence.5 6Modules:7  - Agent Chat (natural language → tool orchestration → report)8  - Target Intelligence    (靶点全景)9  - Drug Exploration       (药物管线)10  - Disease Investigation  (疾病格局)11  - Company Profiling      (公司分析)12  - Clinical Trials        (临床试验)13"""14 15import os, json, asyncio, time, re16from collections import Counter17from datetime import datetime18from typing import Optional, Dict, List, Tuple19 20import gradio as gr21 22# =============================================================================23# CONFIGURATION24# =============================================================================25 26API_KEY = os.getenv("PATSNAP_API_KEY", "")27HF_TOKEN = os.getenv("HF_TOKEN", "")  # optional: enable LLM agent mode28SERVER_URL = f"https://connect.patsnap.com/096456/logic-mcp?apikey={API_KEY}"29 30# Module definitions31MODULES = {32    "target":  {"icon": "🎯", "label": "Target Intelligence", "label_cn": "靶点全景",33                "tool": "ls_drug_search", "entity": "target",34                "desc": "Analyze any biomedical target — biology, drugs, pipeline, trials."},35    "drug":    {"icon": "💊", "label": "Drug Exploration", "label_cn": "药物管线",36                "tool": "ls_drug_search", "entity": "drug",37                "desc": "Search drugs by target, disease, mechanism, or company."},38    "disease": {"icon": "🏥", "label": "Disease Investigation", "label_cn": "疾病格局",39                "tool": "ls_drug_search", "entity": "disease",40                "desc": "Understand disease landscape — epidemiology, treatments, pipeline."},41    "company": {"icon": "🏢", "label": "Company Profiling", "label_cn": "公司分析",42                "tool": "ls_organization_pipeline_fetch", "entity": "company",43                "desc": "Profile pharma companies — pipeline, deals, therapeutic focus."},44    "trial":   {"icon": "🧪", "label": "Clinical Trials", "label_cn": "临床试验",45                "tool": "ls_clinical_trial_search", "entity": "trial",46                "desc": "Explore clinical trials by target, disease, phase, or sponsor."},47}48 49# Language: 'en' or 'zh'50DEFAULT_LANG = "en"51 52# =============================================================================53# MOCK DATA — Comprehensive, module-specific54# =============================================================================55 56MOCK_TARGETS = {57    "EGFR": {58        "name": "EGFR", "full_name": "Epidermal Growth Factor Receptor",59        "family": "ErbB/HER receptor tyrosine kinase",60        "class": "Kinase", "indication_count": 12, "drug_count": 28,61        "pdb_ids": ["1M17", "1XKK", "2J5F"],62        "pathways": ["RAS/RAF/MEK/ERK", "PI3K/AKT/mTOR", "JAK/STAT"],63        "mutation_hotspots": ["L858R (exon 21)", "exon 19 deletion", "T790M (exon 20)", "C797S (exon 20)"],64        "approved_drugs": [65            {"name": "Osimertinib", "type": "Small molecule", "gen": "3rd-gen TKI",66             "year": 2015, "indications": ["NSCLC (T790M+)", "NSCLC (1L EGFRm)", "NSCLC (adjuvant)"],67             "company": "AstraZeneca"},68            {"name": "Gefitinib", "type": "Small molecule", "gen": "1st-gen TKI",69             "year": 2003, "indications": ["NSCLC (EGFRm)"], "company": "AstraZeneca"},70            {"name": "Erlotinib", "type": "Small molecule", "gen": "1st-gen TKI",71             "year": 2004, "indications": ["NSCLC", "Pancreatic"], "company": "Roche/Genentech"},72            {"name": "Afatinib", "type": "Small molecule", "gen": "2nd-gen TKI",73             "year": 2013, "indications": ["NSCLC (EGFRm)"], "company": "Boehringer Ingelheim"},74            {"name": "Dacomitinib", "type": "Small molecule", "gen": "2nd-gen TKI",75             "year": 2018, "indications": ["NSCLC (EGFRm)"], "company": "Pfizer"},76            {"name": "Cetuximab", "type": "Monoclonal antibody", "gen": "mAb",77             "year": 2004, "indications": ["CRC", "HNSCC"], "company": "Merck KGaA / BMS"},78            {"name": "Panitumumab", "type": "Monoclonal antibody", "gen": "mAb",79             "year": 2006, "indications": ["CRC"], "company": "Amgen"},80            {"name": "Amivantamab", "type": "Bispecific antibody", "gen": "BsAb",81             "year": 2021, "indications": ["NSCLC (ex20ins)"], "company": "Janssen"},82            {"name": "Patritumab deruxtecan", "type": "ADC", "gen": "ADC (HER3)",83             "year": 2024, "indications": ["NSCLC (post-TKI)"], "company": "Daiichi Sankyo / Merck"},84        ],85        "pipeline_summary": {86            "phase_3": 45, "phase_2": 120, "phase_1": 85, "preclinical": 200,87            "hot_topics": ["4th-gen TKIs (C797S)", "Bispecific ADCs", "PROTAC degraders",88                          "Combination with immunotherapy", "Brain-penetrant TKIs"],89        },90        "competitive_landscape": "Highly competitive — every major pharma has an EGFR asset. "91                                 "Innovation is focused on resistance mechanisms and next-gen modalities.",92    },93    "HER2": {94        "name": "HER2", "full_name": "Human Epidermal Growth Factor Receptor 2",95        "family": "ErbB/HER receptor tyrosine kinase",96        "class": "Kinase", "indication_count": 5, "drug_count": 15,97        "pdb_ids": ["1N8Z", "3PP0"],98        "pathways": ["RAS/RAF/MEK/ERK", "PI3K/AKT/mTOR"],99        "mutation_hotspots": ["Amplification (breast/gastric)", "Exon 20 mutations (NSCLC)"],100        "approved_drugs": [101            {"name": "Trastuzumab", "type": "Monoclonal antibody", "gen": "mAb",102             "year": 1998, "indications": ["HER2+ Breast Cancer", "HER2+ Gastric Cancer"], "company": "Roche"},103            {"name": "Trastuzumab deruxtecan", "type": "ADC", "gen": "ADC",104             "year": 2019, "indications": ["HER2+ Breast Cancer", "HER2-low Breast Cancer", "HER2+ Gastric Cancer",105                                          "HER2-mutant NSCLC"], "company": "Daiichi Sankyo / AstraZeneca"},106            {"name": "Pertuzumab", "type": "Monoclonal antibody", "gen": "mAb",107             "year": 2012, "indications": ["HER2+ Breast Cancer"], "company": "Roche"},108            {"name": "Lapatinib", "type": "Small molecule", "gen": "TKI",109             "year": 2007, "indications": ["HER2+ Breast Cancer"], "company": "Novartis"},110            {"name": "Tucatinib", "type": "Small molecule", "gen": "TKI",111             "year": 2020, "indications": ["HER2+ Breast Cancer (CNS mets)"], "company": "Seagen / Merck"},112        ],113        "pipeline_summary": {114            "phase_3": 25, "phase_2": 80, "phase_1": 55, "preclinical": 130,115            "hot_topics": ["HER2-low targeting", "Bispecific ADCs", "Brain metastasis"],116        },117        "competitive_landscape": "HER2 ADC space is the current battleground. Enhertu dominates; "118                                 "competitors focus on differentiation via payload, DAR, or epitope.",119    },120    "PD-L1": {121        "name": "PD-L1", "full_name": "Programmed Death-Ligand 1",122        "family": "B7 immune checkpoint",123        "class": "Immune checkpoint ligand", "indication_count": 20, "drug_count": 20,124        "approved_drugs": [125            {"name": "Atezolizumab", "type": "Monoclonal antibody", "gen": "Anti-PD-L1 mAb",126             "year": 2016, "indications": ["NSCLC", "SCLC", "Urothelial", "HCC"], "company": "Roche"},127            {"name": "Durvalumab", "type": "Monoclonal antibody", "gen": "Anti-PD-L1 mAb",128             "year": 2017, "indications": ["NSCLC (Stage III)", "SCLC", "Biliary Tract"], "company": "AstraZeneca"},129            {"name": "Avelumab", "type": "Monoclonal antibody", "gen": "Anti-PD-L1 mAb",130             "year": 2017, "indications": ["Merkel Cell", "Urothelial", "RCC"], "company": "Merck KGaA / Pfizer"},131        ],132        "pipeline_summary": {"phase_3": 35, "phase_2": 60, "phase_1": 40, "preclinical": 100},133        "competitive_landscape": "PD-L1 is a companion to PD-1 — the focus is on combination strategies "134                                 "and predictive biomarker development.",135    },136}137 138MOCK_COMPANIES = {139    "Roche": {140        "name": "Roche", "ticker": "ROG.SW",141        "headquarters": "Basel, Switzerland",142        "employees": "~100,000",143        "2024_revenue": "$65.4B",144        "therapeutic_areas": ["Oncology", "Neuroscience", "Ophthalmology", "Immunology", "Infectious Disease"],145        "flagship_drugs": [146            {"name": "Trastuzumab (Herceptin)", "target": "HER2", "sales": "$3.2B", "phase": "Approved"},147            {"name": "Atezolizumab (Tecentriq)", "target": "PD-L1", "sales": "$4.8B", "phase": "Approved"},148            {"name": "Bevacizumab (Avastin)", "target": "VEGF", "sales": "$2.1B", "phase": "Approved"},149            {"name": "Trastuzumab deruxtecan (co-developed)", "target": "HER2", "sales": "$3.5B", "phase": "Approved"},150        ],151        "pipeline_count": {"approved": 28, "phase_3": 15, "phase_2": 35, "phase_1": 22},152        "recent_deals": [153            "Acquired Carmot Therapeutics (obesity) — $2.7B upfront (2023)",154            "Acquired Telavant (IBD) — $7.1B (2023)",155        ],156        "strategy": "Roche combines strong internal R&D with strategic bolt-on acquisitions. "157                     "Oncology remains the core, with growing investment in immunology and metabolic disease.",158    },159    "AstraZeneca": {160        "name": "AstraZeneca", "ticker": "AZN.L",161        "headquarters": "Cambridge, UK",162        "employees": "~90,000",163        "2024_revenue": "$54.1B",164        "therapeutic_areas": ["Oncology", "CVRM", "Respiratory & Immunology", "Rare Disease"],165        "flagship_drugs": [166            {"name": "Osimertinib (Tagrisso)", "target": "EGFR", "sales": "$6.8B", "phase": "Approved"},167            {"name": "Durvalumab (Imfinzi)", "target": "PD-L1", "sales": "$4.5B", "phase": "Approved"},168            {"name": "Trastuzumab deruxtecan (co-developed)", "target": "HER2", "sales": "$3.5B", "phase": "Approved"},169            {"name": "Dapagliflozin (Farxiga)", "target": "SGLT2", "sales": "$7.1B", "phase": "Approved"},170        ],171        "pipeline_count": {"approved": 22, "phase_3": 12, "phase_2": 28, "phase_1": 18},172        "recent_deals": [173            "Acquired Gracell Biotechnologies (CAR-T) — $1.2B (2023)",174            "Acquired Icosavax (RSV/hMPV vaccine) — $1.1B (2023)",175            "Acquired Fusion Pharmaceuticals (radiopharma) — $2.4B (2024)",176        ],177        "strategy": "AZ's oncology portfolio is anchored by Tagrisso, Imfinzi, and Enhertu. "178                     "Actively expanding into cell therapy, radiopharmaceuticals, and ADCs.",179    },180}181 182MOCK_DISEASES = {183    "NSCLC": {184        "name": "Non-Small Cell Lung Cancer",185        "global_incidence": "~2.2M new cases/year (2024)",186        "mortality": "~1.8M deaths/year",187        "5yr_survival": "Stage I: 65%, Stage IV: 8%",188        "major_mutations": ["EGFR (15-20%)", "KRAS (25-30%)", "ALK (3-5%)",189                           "ROS1 (1-2%)", "BRAF (1-3%)", "MET exon 14 (3%)", "RET (1-2%)"],190        "approved_drugs_count": 45,191        "drug_classes": ["TKIs (1st/2nd/3rd gen)", "Immune checkpoint inhibitors",192                        "ADCs", "Bispecific antibodies", "Chemotherapy"],193        "key_drugs": [194            {"name": "Osimertinib", "target": "EGFR", "setting": "1L EGFRm ± adjuvant"},195            {"name": "Pembrolizumab", "target": "PD-1", "setting": "1L PD-L1 ≥50% ± chemo"},196            {"name": "Amivantamab", "target": "EGFR/MET", "setting": "ex20ins"},197            {"name": "Sotorasib", "target": "KRAS G12C", "setting": "2L+"},198            {"name": "Lorlatinib", "target": "ALK", "setting": "1L ALK+"},199        ],200        "market_size": "$32B (2024), projected $48B by 2030",201        "pipeline": {"phase_3": 85, "phase_2": 150, "phase_1": 100},202        "key_trends": ["Perioperative immunotherapy", "MRD-guided adjuvant therapy",203                       "Antibody-drug conjugates expanding", "Bispecifics entering 1L"],204    },205    "Breast Cancer": {206        "name": "Breast Cancer",207        "global_incidence": "~2.3M new cases/year (2024)",208        "mortality": "~685K deaths/year",209        "subtypes": ["HR+/HER2- (70%)", "HER2+ (15-20%)", "TNBC (10-15%)"],210        "approved_drugs_count": 52,211        "key_drugs": [212            {"name": "Trastuzumab deruxtecan", "target": "HER2", "setting": "HER2+ and HER2-low"},213            {"name": "Sacituzumab govitecan", "target": "Trop-2", "setting": "TNBC, HR+/HER2-"},214            {"name": "Palbociclib", "target": "CDK4/6", "setting": "HR+/HER2- 1L"},215            {"name": "Olaparib", "target": "PARP", "setting": "BRCA1/2-mutated"},216        ],217        "market_size": "$28B (2024)",218        "key_trends": ["CDK4/6 moving to adjuvant", "ADCs dominating HER2 space",219                       "Immunotherapy for TNBC", "Oral SERDs entering market"],220    },221}222 223MOCK_DRUG_SEARCH = {224    # Returned by any drug search; keyed by target/disease225    "default": [226        {"name": "Osimertinib", "target": "EGFR", "type": "Small molecule",227         "highest_phase": "Approved", "first_approved": "2015-11-13",228         "indications": ["NSCLC"], "company": "AstraZeneca"},229        {"name": "Gefitinib", "target": "EGFR", "type": "Small molecule",230         "highest_phase": "Approved", "first_approved": "2003-05-05",231         "indications": ["NSCLC"], "company": "AstraZeneca"},232        {"name": "Cetuximab", "target": "EGFR", "type": "Monoclonal antibody",233         "highest_phase": "Approved", "first_approved": "2004-02-12",234         "indications": ["CRC", "HNSCC"], "company": "Merck KGaA / BMS"},235        {"name": "Trastuzumab deruxtecan", "target": "HER2", "type": "ADC",236         "highest_phase": "Approved", "first_approved": "2019-12-20",237         "indications": ["Breast Cancer", "Gastric Cancer", "NSCLC"],238         "company": "Daiichi Sankyo / AstraZeneca"},239        {"name": "Pembrolizumab", "target": "PD-1", "type": "Monoclonal antibody",240         "highest_phase": "Approved", "first_approved": "2014-09-04",241         "indications": ["Melanoma", "NSCLC", "HNSCC", "cHL"], "company": "Merck (MSD)"},242        {"name": "Amivantamab", "target": "EGFR/MET", "type": "Bispecific antibody",243         "highest_phase": "Approved", "first_approved": "2021-05-21",244         "indications": ["NSCLC (ex20ins)"], "company": "Janssen"},245        {"name": "Sotorasib", "target": "KRAS G12C", "type": "Small molecule",246         "highest_phase": "Approved", "first_approved": "2021-05-28",247         "indications": ["NSCLC (KRAS G12C)"], "company": "Amgen"},248        {"name": "Sacituzumab govitecan", "target": "Trop-2", "type": "ADC",249         "highest_phase": "Approved", "first_approved": "2020-04-22",250         "indications": ["TNBC", "HR+/HER2- Breast Cancer"], "company": "Gilead"},251    ]252}253 254# =============================================================================255# CSS — Product-grade styling256# =============================================================================257 258CUSTOM_CSS = """259/* ===== Design Tokens ===== */260:root {261    --navy-950: #020617;262    --navy-900: #0a1628;263    --navy-800: #122540;264    --navy-700: #1a3050;265    --navy-600: #1e3a5f;266    --teal-500: #0891b2;267    --teal-400: #06b6d4;268    --teal-50: #ecfeff;269    --emerald-500: #10b981;270    --emerald-50: #ecfdf5;271    --surface: #ffffff;272    --bg: #f8fafc;273    --bg-alt: #f1f5f9;274    --text: #1e293b;275    --text-secondary: #64748b;276    --text-tertiary: #94a3b8;277    --border: #e2e8f0;278    --border-light: #f1f5f9;279    --radius-sm: 6px;280    --radius: 10px;281    --radius-lg: 14px;282    --radius-xl: 20px;283    --shadow-xs: 0 1px 2px rgba(15,23,42,0.04);284    --shadow-sm: 0 1px 3px rgba(15,23,42,0.06);285    --shadow: 0 1px 3px rgba(15,23,42,0.08), 0 1px 2px rgba(15,23,42,0.04);286    --shadow-md: 0 4px 6px -1px rgba(15,23,42,0.08), 0 2px 4px -2px rgba(15,23,42,0.04);287    --font: 'Inter', -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', system-ui, sans-serif;288    --font-mono: 'SF Mono', 'JetBrains Mono', 'Fira Code', monospace;289}290 291@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');292 293.gradio-container {294    max-width: 100% !important;295    padding: 0 32px !important;296    margin: 0 auto !important;297    font-family: var(--font) !important;298    background: var(--bg) !important;299    -webkit-font-smoothing: antialiased;300    -moz-osx-font-smoothing: grayscale;301}302 303/* Force ALL text dark — override Gradio Soft theme everywhere */304.gradio-container .prose,305.gradio-container .prose *,306.gradio-container .md,307.gradio-container .md *,308.gradio-container [class*="markdown"],309.gradio-container [class*="markdown"] * {310    color: var(--text) !important;311}312.gradio-container .prose h2, .gradio-container .md h2,313.gradio-container [class*="markdown"] h2 {314    color: var(--navy-950) !important;315    font-weight: 700 !important;316}317.gradio-container .prose strong, .gradio-container .md strong,318.gradio-container [class*="markdown"] strong {319    color: var(--navy-900) !important;320}321.gradio-container label, .gradio-container .label-text,322.gradio-container .label-container span {323    color: var(--text) !important;324    font-weight: 500 !important;325}326.gradio-container td { color: var(--text) !important; }327.gradio-container th { color: var(--text-secondary) !important; }328 329/* Ensure header text is always white and visible */330.header-container,331.header-container * {332    color: #ffffff !important;333}334.header-title {335    color: #ffffff !important;336}337.header-subtitle {338    color: rgba(255, 255, 255, 0.85) !important;339}340 341/* ===== Header ===== */342.header-container {343    background: linear-gradient(135deg, var(--navy-950) 0%, var(--navy-900) 30%, var(--navy-700) 70%, var(--navy-600) 100%);344    padding: 40px 48px 34px;345    margin-bottom: 28px;346    color: white;347    position: relative;348    overflow: hidden;349    border-bottom: 1px solid rgba(255,255,255,0.06);350}351.header-container::before {352    content: '';353    position: absolute;354    top: -120px;355    right: -80px;356    width: 420px;357    height: 420px;358    background: radial-gradient(circle, rgba(6,182,212,0.13) 0%, rgba(6,182,212,0.04) 40%, transparent 70%);359    border-radius: 50%;360    pointer-events: none;361}362.header-container::after {363    content: '';364    position: absolute;365    bottom: -60px;366    left: 30%;367    width: 300px;368    height: 300px;369    background: radial-gradient(circle, rgba(16,185,129,0.08) 0%, transparent 70%);370    border-radius: 50%;371    pointer-events: none;372}373.header-row {374    display: flex;375    align-items: flex-start;376    justify-content: space-between;377    position: relative;378    z-index: 1;379}380.header-title {381    font-size: 26px;382    font-weight: 700;383    margin: 0 0 10px 0;384    letter-spacing: -0.4px;385    line-height: 1.2;386    color: #ffffff;387}388.header-subtitle {389    font-size: 14px;390    opacity: 0.68;391    margin: 0;392    font-weight: 400;393    line-height: 1.6;394    max-width: 520px;395    color: rgba(255,255,255,0.85);396}397.header-badges {398    display: flex;399    gap: 8px;400    margin-top: 16px;401    flex-wrap: wrap;402}403.header-badge {404    display: inline-flex;405    align-items: center;406    gap: 5px;407    background: rgba(255,255,255,0.08);408    backdrop-filter: blur(12px);409    -webkit-backdrop-filter: blur(12px);410    padding: 5px 16px;411    border-radius: 20px;412    font-size: 11.5px;413    font-weight: 500;414    letter-spacing: 0.02em;415    border: 1px solid rgba(255,255,255,0.10);416    color: rgba(255,255,255,0.8);417}418 419/* ===== Tabs ===== */420.tabs { border: none !important; }421.tab-nav {422    background: transparent !important;423    border-bottom: 1px solid var(--border) !important;424    border-radius: 0 !important;425    padding: 0 4px !important;426    box-shadow: none !important;427    margin-bottom: 24px !important;428    gap: 4px !important;429}430.tab-nav button {431    border-radius: 8px 8px 0 0 !important;432    padding: 10px 20px !important;433    font-size: 13.5px !important;434    font-weight: 500 !important;435    border: none !important;436    color: #475569 !important;437    transition: color 0.15s ease, background 0.15s ease !important;438    background: transparent !important;439    border-bottom: 2px solid transparent !important;440    margin-bottom: -1px !important;441    cursor: pointer !important;442}443.tab-nav button:hover {444    color: var(--text) !important;445    background: var(--bg-alt) !important;446}447.tab-nav button.selected {448    background: transparent !important;449    color: #020617 !important;450    border-bottom: 2px solid #020617 !important;451    box-shadow: none !important;452    font-weight: 600 !important;453}454.tab-nav button:focus-visible {455    outline: 2px solid var(--navy-600) !important;456    outline-offset: -2px !important;457    border-radius: 8px 8px 0 0 !important;458}459 460/* Ensure all text elements are readable */461.gradio-container * {462    color: inherit !important;463}464.gradio-container button:not(.btn-primary):not(.example-chip) {465    color: #475569 !important;466}467.gradio-container button:not(.btn-primary):not(.example-chip):hover {468    color: #1e293b !important;469}470 471/* ===== Input ===== */472.agent-input textarea, .agent-input input {473    border-radius: var(--radius) !important;474    border: 1.5px solid var(--border) !important;475    padding: 14px 16px !important;476    font-size: 14.5px !important;477    line-height: 1.6 !important;478    transition: border-color 0.15s ease, box-shadow 0.15s ease !important;479    resize: none !important;480    background: var(--surface) !important;481    color: var(--text) !important;482}483.agent-input textarea:focus, .agent-input input:focus {484    border-color: var(--navy-800) !important;485    box-shadow: 0 0 0 3px rgba(18,37,64,0.08) !important;486    outline: none !important;487}488 489/* Example chips */490.example-chip {491    border-radius: 20px !important;492    padding: 6px 16px !important;493    font-size: 12.5px !important;494    font-weight: 500 !important;495    border: 1px solid var(--border) !important;496    background: var(--surface) !important;497    color: var(--text-secondary) !important;498    cursor: pointer !important;499    transition: all 0.15s ease !important;500    white-space: nowrap !important;501    min-width: unset !important;502    height: auto !important;503    line-height: 1.4 !important;504}505.example-chip:hover {506    border-color: var(--teal-400) !important;507    color: var(--teal-500) !important;508    background: var(--teal-50) !important;509}510.example-chip:focus-visible {511    outline: 2px solid var(--teal-400) !important;512    outline-offset: 1px !important;513}514 515/* Primary button */516.btn-primary {517    background: var(--navy-950) !important;518    color: white !important;519    border: none !important;520    border-radius: var(--radius) !important;521    padding: 11px 32px !important;522    font-size: 14px !important;523    font-weight: 600 !important;524    cursor: pointer !important;525    transition: background 0.15s ease, box-shadow 0.15s ease !important;526    letter-spacing: 0.01em;527}528.btn-primary:hover {529    background: var(--navy-800) !important;530    box-shadow: var(--shadow-md);531}532.btn-primary:focus-visible {533    outline: 2px solid var(--navy-600) !important;534    outline-offset: 2px !important;535}536 537/* Secondary button */538.btn-secondary {539    background: var(--bg-alt) !important;540    color: #475569 !important;541    border: 1px solid var(--border) !important;542    border-radius: var(--radius) !important;543    padding: 10px 24px !important;544    font-size: 14px !important;545    font-weight: 500 !important;546    cursor: pointer !important;547    transition: all 0.15s ease !important;548}549.btn-secondary:hover {550    background: var(--border) !important;551    color: #1e293b !important;552}553.btn-secondary:focus-visible {554    outline: 2px solid var(--navy-600) !important;555    outline-offset: 2px !important;556}557 558/* ===== Thinking Steps ===== */559.thinking-steps {560    background: linear-gradient(135deg, #f8faff, #f0fdfa);561    border: 1px solid #ccfbf1;562    border-radius: var(--radius);563    padding: 14px 18px;564    margin: 16px 0;565}566.thinking-step {567    padding: 3px 0;568    color: #115e59;569    font-size: 13px;570    line-height: 1.6;571}572 573/* ===== Cards ===== */574.card {575    background: var(--surface);576    border-radius: var(--radius-lg);577    padding: 24px;578    box-shadow: var(--shadow-xs);579    border: 1px solid var(--border);580    margin-bottom: 16px;581    transition: box-shadow 0.2s ease;582}583.card:hover { box-shadow: var(--shadow-sm); }584 585/* ===== Report Content ===== */586.report { color: var(--text); font-size: 14.5px; line-height: 1.75; }587.report h2 {588    font-size: 20px;589    font-weight: 700;590    margin-top: 28px;591    margin-bottom: 12px;592    color: var(--navy-950);593    letter-spacing: -0.3px;594}595.report h3 {596    font-size: 15px;597    font-weight: 600;598    margin-top: 20px;599    margin-bottom: 8px;600    color: var(--text);601}602.report table {603    width: 100%;604    border-collapse: collapse;605    margin: 14px 0;606    font-size: 13.5px;607    border-radius: var(--radius-sm);608    overflow: hidden;609    border: 1px solid var(--border);610}611.report th {612    background: var(--bg-alt);613    padding: 10px 14px;614    text-align: left;615    font-weight: 600;616    font-size: 11.5px;617    color: var(--text-secondary);618    text-transform: uppercase;619    letter-spacing: 0.05em;620    border-bottom: 1px solid var(--border);621}622.report td {623    padding: 10px 14px;624    border-bottom: 1px solid var(--border-light);625    color: var(--text);626}627.report tr:last-child td { border-bottom: none; }628.report tr:hover td { background: #fafcff; }629 630/* ===== Status Badge ===== */631.badge {632    display: inline-block;633    padding: 2px 10px;634    border-radius: 10px;635    font-size: 11px;636    font-weight: 600;637    letter-spacing: 0.02em;638}639.badge-approved { background: #dcfce7; color: #166534; }640.badge-phase3  { background: #dbeafe; color: #1e40af; }641.badge-phase2  { background: #fef3c7; color: #92400e; }642.badge-phase1  { background: #fce7f3; color: #9d174d; }643 644/* ===== Footer ===== */645.footer {646    text-align: center;647    padding: 28px 16px;648    color: var(--text-tertiary);649    font-size: 12px;650    border-top: 1px solid var(--border-light);651    margin-top: 48px;652    letter-spacing: 0.02em;653}654.footer strong { color: var(--text-secondary); font-weight: 600; }655 656/* ===== Animations ===== */657@keyframes fadeIn {658    from { opacity: 0; transform: translateY(8px); }659    to { opacity: 1; transform: translateY(0); }660}661.fade-in { animation: fadeIn 0.35s ease-out; }662 663@media (prefers-reduced-motion: reduce) {664    *, *::before, *::after {665        animation-duration: 0.01ms !important;666        animation-iteration-count: 1 !important;667        transition-duration: 0.01ms !important;668    }669    .fade-in { animation: none; }670}671 672/* Misc */673footer { display: none !important; }674::-webkit-scrollbar { width: 6px; }675::-webkit-scrollbar-track { background: transparent; }676::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }677"""678 679# =============================================================================680# MCP CLIENT681# =============================================================================682 683_mcp_tools_cache: Optional[List[str]] = None684 685async def get_mcp_tools() -> List[str]:686    """Fetch available MCP tool names with caching."""687    global _mcp_tools_cache688    if _mcp_tools_cache is not None:689        return _mcp_tools_cache690    if not API_KEY:691        return []692    try:693        from mcp import ClientSession694        from mcp.client.streamable_http import streamablehttp_client695        async with streamablehttp_client(SERVER_URL, timeout=15, sse_read_timeout=15) as (read, write, _):696            async with ClientSession(read, write) as session:697                await session.initialize()698                result = await session.list_tools()699                _mcp_tools_cache = [t.name for t in result.tools]700                return _mcp_tools_cache701    except Exception as e:702        print(f"[MCP] Tool discovery failed: {e}")703        return []704 705 706async def call_mcp_tool(tool_name: str, args: dict) -> Optional[dict]:707    """Call a specific MCP tool. Returns parsed JSON or None on failure."""708    if not API_KEY:709        return None710    try:711        from mcp import ClientSession712        from mcp.client.streamable_http import streamablehttp_client713        async with streamablehttp_client(SERVER_URL, timeout=30, sse_read_timeout=30) as (read, write, _):714            async with ClientSession(read, write) as session:715                await session.initialize()716                result = await session.call_tool(tool_name, arguments=args)717                if result.content:718                    text = result.content[0].text719                    return json.loads(text) if isinstance(text, str) else text720    except Exception as e:721        print(f"[MCP] {tool_name} failed: {e}")722    return None723 724 725# =============================================================================726# AGENT ENGINE — Intent Parsing & Tool Routing727# =============================================================================728 729# Entity extraction patterns730TARGET_KEYWORDS = {731    "egfr": "EGFR", "her2": "HER2", "her-2": "HER2", "erbb2": "HER2",732    "pd-l1": "PD-L1", "pdl1": "PD-L1", "pd-1": "PD-1", "pd1": "PD-1",733    "braf": "BRAF", "alk": "ALK", "ros1": "ROS1", "kras": "KRAS",734    "vegf": "VEGF", "vegfr": "VEGFR", "ctla-4": "CTLA-4", "ctla4": "CTLA-4",735    "stat3": "STAT3", "met": "c-MET", "c-met": "c-MET", "ret": "RET",736    "ntrk": "NTRK", "fgfr": "FGFR", "parp": "PARP", "cdk4": "CDK4/6",737    "cdk4/6": "CDK4/6", "btk": "BTK", "jak": "JAK", "flt3": "FLT3",738    "idh1": "IDH1", "idh2": "IDH2", "tp53": "TP53", "p53": "TP53",739    "brca1": "BRCA1", "brca2": "BRCA2", "trop-2": "Trop-2", "trop2": "Trop-2",740    "claudin": "Claudin 18.2", "cldn18": "Claudin 18.2",741}742 743DISEASE_KEYWORDS = {744    "non-small cell lung cancer": "NSCLC", "nsclc": "NSCLC", "lung cancer": "Lung Cancer",745    "breast cancer": "Breast Cancer", "triple-negative breast": "Triple-Negative Breast Cancer",746    "tnbc": "Triple-Negative Breast Cancer",747    "colorectal": "Colorectal Cancer", "crc": "Colorectal Cancer",748    "melanoma": "Melanoma",749    "pancreatic": "Pancreatic Cancer",750    "hepatocellular": "Hepatocellular Carcinoma", "hcc": "Hepatocellular Carcinoma",751    "liver cancer": "Hepatocellular Carcinoma",752    "gastric": "Gastric Cancer", "stomach cancer": "Gastric Cancer",753    "leukemia": "Leukemia", "lymphoma": "Lymphoma",754    "ovarian": "Ovarian Cancer", "prostate": "Prostate Cancer",755    "multiple myeloma": "Multiple Myeloma", "mm": "Multiple Myeloma",756    "renal cell": "Renal Cell Carcinoma", "rcc": "Renal Cell Carcinoma",757    "bladder": "Urothelial Carcinoma", "urothelial": "Urothelial Carcinoma",758    "head and neck": "Head and Neck Cancer", "hnscc": "Head and Neck Cancer",759    "glioblastoma": "Glioblastoma", "gbm": "Glioblastoma",760    "alzheimer": "Alzheimer's Disease", "parkinson": "Parkinson's Disease",761    "diabetes": "Diabetes", "obesity": "Obesity",762}763 764COMPANY_KEYWORDS = {765    "roche": "Roche", "genentech": "Roche",766    "novartis": "Novartis",767    "pfizer": "Pfizer",768    "merck": "Merck (MSD)", "msd": "Merck (MSD)",769    "bristol-myers": "Bristol-Myers Squibb", "bms": "Bristol-Myers Squibb",770    "astrazeneca": "AstraZeneca", "az": "AstraZeneca",771    "johnson": "Johnson & Johnson", "jnj": "Johnson & Johnson", "janssen": "Johnson & Johnson",772    "sanofi": "Sanofi",773    "gsk": "GlaxoSmithKline",774    "abbvie": "AbbVie",775    "amgen": "Amgen",776    "gilead": "Gilead",777    "lilly": "Eli Lilly", "eli lilly": "Eli Lilly",778    "moderna": "Moderna",779    "biontech": "BioNTech",780    "daiichi": "Daiichi Sankyo",781    "beigene": "BeiGene", "百济": "BeiGene",782    "innovent": "Innovent", "信达": "Innovent",783    "hengrui": "Hengrui", "恒瑞": "Hengrui",784    "akebia": "Akebia",785}786 787PHASE_KEYWORDS = {788    "phase 3": "phase_3", "phase iii": "phase_3", "phase iii": "phase_3", "pivotal": "phase_3",789    "phase 2": "phase_2", "phase ii": "phase_2",790    "phase 1": "phase_1", "phase i": "phase_1", "first-in-human": "phase_1", "fih": "phase_1",791    "approved": "approved", "marketed": "approved", "launched": "approved",792    "preclinical": "preclinical",793}794 795MODULE_KEYWORDS = {796    "target": ["target", "靶点", "receptor", "kinase", "protein", "gene", "mutation", "pathway",797               "inhibitor target", "antibody target", "drug target"],798    "drug": ["drug", "药物", "medicine", "inhibitor", "antibody", "therapy", "treatment regimen",799             "approved drug", "pipeline drug", "molecule", "compound", "modality"],800    "disease": ["disease", "疾病", "cancer", "tumor", "indication", "适应症", "epidemiology",801                "patients", "prevalence", "incidence", "mortality"],802    "company": ["company", "公司", "pharma", "biotech", "pipeline of", "portfolio",803                "acquisition", "merger", "partner", "revenue"],804    "trial": ["trial", "试验", "clinical", "nct", "enrollment", "endpoint", "randomized",805              "double-blind", "phase 3 trial", "phase 2 trial", "phase 1 trial"],806}807 808 809def parse_intent(query: str) -> Dict:810    """811    Parse a natural language query to determine:812    - module (target/drug/disease/company/trial)813    - entities (targets, diseases, companies, phases)814    - mcp_args (for direct MCP call)815    - confidence816    """817    lower = query.lower()818    result = {819        "module": "target",  # default820        "entities": {"targets": [], "diseases": [], "companies": [], "phases": []},821        "mcp_args": {"limit": 10},822        "confidence": 0.0,823        "thinking": [],824    }825 826    # Extract entities827    for kw, val in TARGET_KEYWORDS.items():828        if kw in lower and val not in result["entities"]["targets"]:829            result["entities"]["targets"].append(val)830 831    for kw, val in DISEASE_KEYWORDS.items():832        if kw in lower and val not in result["entities"]["diseases"]:833            result["entities"]["diseases"].append(val)834 835    for kw, val in COMPANY_KEYWORDS.items():836        if kw in lower and val not in result["entities"]["companies"]:837            result["entities"]["companies"].append(val)838 839    for kw, val in PHASE_KEYWORDS.items():840        if kw in lower:841            result["entities"]["phases"].append(val)842 843    # Determine module by scoring keyword matches844    scores = {m: 0 for m in MODULE_KEYWORDS}845    for module, keywords in MODULE_KEYWORDS.items():846        for kw in keywords:847            if kw in lower:848                scores[module] += 1849 850    best_module = max(scores, key=scores.get)851    max_score = scores[best_module]852 853    # Heuristic overrides based on entities found854    if result["entities"]["targets"] and not result["entities"]["diseases"] and not result["entities"]["companies"]:855        if max_score == 0 or best_module == "drug":856            result["module"] = "target" if scores["target"] >= scores["drug"] else "drug"857        else:858            result["module"] = best_module859    elif result["entities"]["diseases"] and not result["entities"]["targets"]:860        result["module"] = "disease"861    elif result["entities"]["companies"] and not result["entities"]["targets"]:862        result["module"] = "company"863    elif "trial" in lower or "clinical" in lower or "enrollment" in lower:864        result["module"] = "trial"865    else:866        result["module"] = best_module if max_score > 0 else "target"867 868    # Build MCP args based on module869    mod = result["module"]870    if mod == "target" and result["entities"]["targets"]:871        result["mcp_args"] = {"target": result["entities"]["targets"][:3], "limit": 10}872        result["thinking"].append(f"🔍 Detected target query → searching for: {', '.join(result['entities']['targets'][:3])}")873    elif mod == "drug":874        args = {"limit": 10}875        if result["entities"]["targets"]:876            args["target"] = result["entities"]["targets"][:3]877        if result["entities"]["diseases"]:878            args["disease"] = result["entities"]["diseases"][:3]879        if result["entities"]["phases"]:880            args["highest_phase"] = result["entities"]["phases"][:3]881        result["mcp_args"] = args882        result["thinking"].append(f"🔍 Detected drug query → searching with {json.dumps(args)}")883    elif mod == "disease" and result["entities"]["diseases"]:884        result["mcp_args"] = {"disease": result["entities"]["diseases"][:3], "limit": 10}885        result["thinking"].append(f"🔍 Detected disease query → searching for: {', '.join(result['entities']['diseases'][:3])}")886    elif mod == "company" and result["entities"]["companies"]:887        result["mcp_args"] = {"company": result["entities"]["companies"][:3], "limit": 10}888        result["thinking"].append(f"🔍 Detected company query → searching for: {', '.join(result['entities']['companies'][:3])}")889    elif mod == "trial":890        args = {"limit": 10}891        if result["entities"]["targets"]:892            args["target"] = result["entities"]["targets"][:3]893        if result["entities"]["diseases"]:894            args["disease"] = result["entities"]["diseases"][:3]895        if result["entities"]["phases"]:896            args["phase"] = result["entities"]["phases"][:3]897        result["mcp_args"] = args898        result["thinking"].append(f"🔍 Detected trial query → searching with {json.dumps(args)}")899 900    # Confidence901    entity_count = sum(len(v) for v in result["entities"].values())902    result["confidence"] = min(entity_count * 0.25 + scores[result["module"]] * 0.15, 0.95)903 904    return result905 906 907# =============================================================================908# REPORT BUILDERS909# =============================================================================910 911def _badge(phase: str) -> str:912    """Generate an HTML badge for a drug phase."""913    phase_lower = phase.lower()914    if "approved" in phase_lower:915        cls = "badge-approved"916    elif "phase 3" in phase_lower or "phase iii" in phase_lower:917        cls = "badge-phase3"918    elif "phase 2" in phase_lower or "phase ii" in phase_lower:919        cls = "badge-phase2"920    elif "phase 1" in phase_lower or "phase i" in phase_lower:921        cls = "badge-phase1"922    else:923        cls = "badge-phase2"924    return f'<span class="badge {cls}">{phase}</span>'925 926 927def build_target_report(target_data: Dict) -> str:928    """Build a structured target intelligence report."""929    name = target_data.get("name", "Unknown")930    full = target_data.get("full_name", "")931    family = target_data.get("family", "N/A")932    cls = target_data.get("class", "N/A")933    drugs = target_data.get("approved_drugs", [])934    pipeline = target_data.get("pipeline_summary", {})935    pathways = target_data.get("pathways", [])936    mutations = target_data.get("mutation_hotspots", [])937    landscape = target_data.get("competitive_landscape", "")938 939    report = []940    report.append(f"## 🎯 {name} — Target Intelligence Report")941    report.append("")942 943    # Overview card944    report.append("### 📋 Overview")945    report.append(f"| Property | Value |")946    report.append(f"|----------|-------|")947    report.append(f"| **Full Name** | {full} |")948    report.append(f"| **Family** | {family} |")949    report.append(f"| **Class** | {cls} |")950    report.append(f"| **Approved Drugs** | {len(drugs)} |")951    report.append("")952 953    # Pathways954    if pathways:955        report.append("### 🧬 Signaling Pathways")956        for p in pathways:957            report.append(f"- {p}")958        report.append("")959 960    # Mutations961    if mutations:962        report.append("### 🔬 Key Mutations / Variants")963        for m in mutations:964            report.append(f"- {m}")965        report.append("")966 967    # Approved Drugs Table968    if drugs:969        report.append(f"### 💊 Approved Drugs ({len(drugs)})")970        report.append("| Drug | Type | Generation | Year | Indications | Company |")971        report.append("|------|------|-----------|------|-------------|---------|")972        for d in drugs:973            inds = ", ".join(d.get("indications", [])[:2])974            if len(d.get("indications", [])) > 2:975                inds += f" +{len(d['indications']) - 2} more"976            report.append(f"| {d['name']} | {d['type']} | {d.get('gen', '-')} | "977                         f"{d['year']} | {inds} | {d.get('company', '-')} |")978        report.append("")979 980    # Pipeline981    if pipeline:982        report.append("### 🔬 Pipeline Overview")983        report.append(f"| Phase | Count |")984        report.append(f"|-------|-------|")985        for phase in ["phase_3", "phase_2", "phase_1", "preclinical"]:986            label = phase.replace("_", " ").title()987            report.append(f"| {label} | {pipeline.get(phase, 'N/A')} |")988        report.append("")989        hot = pipeline.get("hot_topics", [])990        if hot:991            report.append("**🔥 Hot Topics:**")992            for t in hot:993                report.append(f"- {t}")994            report.append("")995 996    # Competitive landscape997    if landscape:998        report.append("### 🏔️ Competitive Landscape")999        report.append(landscape)1000        report.append("")1001 1002    report.append("---")1003    report.append(f"*Report generated by PatSnap Pharma Intelligence Agent*")1004    return "\n".join(report)1005 1006 1007def _normalize_drug_item(item: Dict) -> Dict:1008    """Normalize server response fields to internal schema."""1009    if "display_name_en" in item or "drug_type_view" in item:1010        drug_types = item.get("drug_type_view", [])1011        type_str = ", ".join(d.get("display_name_en", "") for d in drug_types if d.get("display_name_en")) or "N/A"1012 1013        # Indications from research_disease_view (fetch) or indication_view (search)1014        indications = item.get("research_disease_view", []) or item.get("indication_view", [])1015        ind_list = [d.get("display_name_en", "") for d in indications if d.get("display_name_en")] or []1016 1017        # Company from originator_org_master_entity_id_view (fetch) or originator_view (search)1018        orgs = item.get("originator_org_master_entity_id_view", []) or item.get("originator_view", []) or item.get("organization_view", [])1019        company = ", ".join(d.get("display_name_en", "") for d in orgs if d.get("display_name_en")) or "N/A"1020 1021        # Phase from global_highest_dev_status_view (fetch) or highest_phase_view (search)1022        phase = item.get("global_highest_dev_status_view") or item.get("highest_phase_view", {})1023        phase_str = phase.get("display_name_en", "") if isinstance(phase, dict) else str(phase) if phase else "N/A"1024 1025        return {1026            "name": item.get("display_name_en") or item.get("display_name_cn") or "N/A",1027            "type": type_str,1028            "highest_phase": phase_str,1029            "first_approved": item.get("first_approved_date", ""),1030            "indications": ind_list,1031            "company": company,1032        }1033    return item1034 1035 1036def _normalize_items(items: List[Dict]) -> List[Dict]:1037    """Normalize a list of server response items."""1038    return [_normalize_drug_item(i) for i in items]1039 1040 1041# =============================================================================1042# ENTITY EXTRACTORS — Shape live MCP data into report-friendly dicts1043# =============================================================================1044 1045def _profile_text(item: Dict) -> str:1046    """Extract a profile description string from profile/profile_v2 fields."""1047    for key in ("profile_v2", "profile"):1048        val = item.get(key)1049        if isinstance(val, list) and val:1050            content = val[0].get("content", "")1051            if content:1052                return content1053    return ""1054 1055 1056def _aliases(item: Dict, max_n: int = 6) -> List[str]:1057    """Extract English aliases from a target/disease item."""1058    aliases = item.get("alias", []) or []1059    out, seen = [], set()1060    for a in aliases:1061        if isinstance(a, dict) and a.get("lang") == "EN":1062            n = a.get("name", "").strip()1063            if n and n not in seen:1064                seen.add(n)1065                out.append(n)1066                if len(out) >= max_n:1067                    break1068    return out1069 1070 1071def extract_target(item: Dict) -> Dict:1072    """Extract structured target info from ls_target_fetch result."""1073    return {1074        "name": item.get("display_name_en") or item.get("display_name_cn", "N/A"),1075        "aliases": _aliases(item),1076        "profile": _profile_text(item),1077        "drug_count": item.get("drug_count_roll_up") or item.get("drug_count", 0),1078        "dev_drug_count": item.get("dev_drug_count_roll_up") or item.get("dev_drug_count", 0),1079        "disease_count": item.get("disease_count_roll_up") or item.get("disease_count", 0),1080        "uniprot_id": (item.get("uniprot_id") or [None])[0],1081        "hgnc_id": (item.get("hgnc_id") or [None])[0],1082        "chembl_id": (item.get("chembl_id") or [None])[0],1083        "organism": item.get("organisms") or item.get("source", ""),1084    }1085 1086 1087def extract_disease(item: Dict) -> Dict:1088    """Extract structured disease info from ls_disease_fetch result."""1089    return {1090        "name": item.get("display_name_en") or item.get("display_name_cn", "N/A"),1091        "aliases": _aliases(item),1092        "profile": _profile_text(item),1093        "dev_drug_count": item.get("dev_drug_count_roll_up") or item.get("dev_drug_count", 0),1094        "mesh_id": item.get("mesh_id"),1095        "umls_cui": (item.get("umls_cui") or [None])[0],1096    }1097 1098 1099def extract_organization(item: Dict) -> Dict:1100    """Extract structured org info from ls_organization_fetch result."""1101    country = item.get("country_id_view") or {}1102    fdate = item.get("founded_date")1103    founded_year = None1104    if fdate and isinstance(fdate, int):1105        s = str(fdate)1106        if len(s) >= 4:1107            founded_year = s[:4]1108    return {1109        "name": item.get("display_name_en") or item.get("name_en", "N/A"),1110        "description": item.get("short_description_en") or (item.get("long_description_en", "") or "")[:400],1111        "website": item.get("website", ""),1112        "country": country.get("display_name_en", "") if isinstance(country, dict) else "",1113        "founded": founded_year,1114        "employees": item.get("employee_number"),1115        "stock_exchange": item.get("stock_exchange_code", ""),1116        "stock_symbol": item.get("stock_symbol", ""),1117        "ownership": ", ".join(item.get("ownership_type", []) or []),1118        "drug_count": item.get("drug_count_roll_up") or item.get("drug_count", 0),1119        "dev_drug_count": item.get("dev_drug_count_roll_up") or item.get("dev_drug_count", 0),1120        "patent_count": item.get("patent_phs_count_roll_up") or item.get("patent_phs_count", 0),1121    }1122 1123 1124def extract_pipeline_drug(item: Dict) -> Dict:1125    """Extract a drug record from ls_organization_pipeline_fetch."""1126    targets = item.get("targets", []) or []1127    target_str = ", ".join(t.get("display_name_en", "") for t in targets if t.get("display_name_en")) or "—"1128    statuses = item.get("status_tables", []) or []1129    indications = []1130    phases_seen = set()1131    best_phase = ""1132    for s in statuses:1133        d = s.get("disease_id_view", {}) or {}1134        name = d.get("display_name_en", "")1135        if name and name not in indications:1136            indications.append(name)1137        ds = s.get("dev_status")1138        if isinstance(ds, list):1139            for entry in ds:1140                view = entry.get("dev_status_id_view", {}) or {}1141                ph = view.get("display_name_en", "")1142                if ph and ph not in phases_seen:1143                    phases_seen.add(ph)1144                    if not best_phase:1145                        best_phase = ph1146        elif isinstance(ds, dict):1147            ph = ds.get("display_name_en", "")1148            if ph and ph not in phases_seen:1149                phases_seen.add(ph)1150                if not best_phase:1151                    best_phase = ph1152    return {1153        "name": item.get("display_name_en") or item.get("display_name_cn", "N/A"),1154        "targets": target_str,1155        "indications": indications[:3],1156        "phase": best_phase or "—",1157    }1158 1159 1160def extract_trial(item: Dict) -> Dict:1161    """Extract structured trial info from ls_clinical_trial_fetch result."""1162    phase = item.get("clinical_phase") or {}1163    phase_str = phase.get("display_name_en", "") if isinstance(phase, dict) else str(phase)1164    sponsors = item.get("sponsor_organization", []) or []1165    sponsor_str = ", ".join(s.get("display_name_en", "") for s in sponsors if s.get("display_name_en"))1166    diseases = item.get("disease", []) or []1167    disease_str = ", ".join(d.get("display_name_en", "") for d in diseases if d.get("display_name_en"))1168    drugs = item.get("experiment_drug", []) or []1169    drug_str = ", ".join(d.get("display_name_en", "") for d in drugs if d.get("display_name_en"))1170    return {1171        "nct": item.get("registration_number", ""),1172        "title": item.get("trial_title", ""),1173        "status": item.get("trial_status", ""),1174        "phase": phase_str,1175        "sponsor": sponsor_str,1176        "disease": disease_str,1177        "drugs": drug_str,1178    }1179 1180 1181def build_drug_report(items: List[Dict], query_summary: str, total: int = 0) -> str:1182    """Build a drug pipeline report from search results."""1183    if not items:1184        return f"## 💊 Drug Search: {query_summary}\n\n📭 No results found. Try a different query."1185 1186    drug_types = Counter()1187    companies = Counter()1188    years = []1189    phases = Counter()1190 1191    for item in items:1192        drug_types[item.get("type", "Unknown")] += 11193        companies[item.get("company", "Unknown")] += 11194        phases[item.get("highest_phase", "Unknown")] += 11195        date = item.get("first_approved", "")1196        if date and date != "N/A":1197            try:1198                years.append(int(date.split("-")[0]))1199            except (ValueError, IndexError):1200                pass

Showing the first 1,200 of 2069 lines. Download the file for the rest.