mondalsou/lead_optimization_agent
0
1"""2Lead Optimization Agent — Streamlit UI3Run: streamlit run app.py4"""5import html6import json7import os8import re9import sys10import time11from datetime import datetime12from pathlib import Path13import streamlit as st14import pandas as pd15import matplotlib; matplotlib.use("Agg")16import matplotlib.pyplot as plt17import plotly.graph_objects as go18import anthropic19 20sys.path.insert(0, os.path.dirname(__file__))21from agent_utils import tool_executor, is_valid_smiles22 23# ─── Page config ──────────────────────────────────────────────────────────────24st.set_page_config(25 page_title="Lead Optimization Agent",26 page_icon="🧬",27 layout="wide",28 initial_sidebar_state="expanded",29)30 31# ─── Custom CSS ───────────────────────────────────────────────────────────────32st.markdown("""33<style>34@import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Space+Grotesk:wght@400;500;700&display=swap');35 36:root {37 --bg: #f7f0e8;38 --bg-soft: #fffaf5;39 --panel: rgba(255, 251, 247, 0.84);40 --panel-strong: #fffdf9;41 --ink: #19212a;42 --muted: #5f6b76;43 --line: rgba(25, 33, 42, 0.10);44 --teal: #0f766e;45 --teal-soft: #d7f3ee;46 --sand: #f2e3cf;47 --coral: #d97757;48 --blue: #2563eb;49 --gold: #b7791f;50 --green: #15803d;51 --red: #b42318;52 --shadow: 0 24px 55px rgba(49, 38, 30, 0.10);53}54 55html, body, [class*="css"] {56 font-family: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;57 color: var(--ink);58}59 60[data-testid="stAppViewContainer"] {61 background:62 radial-gradient(circle at top left, rgba(215, 243, 238, 0.7), transparent 28%),63 radial-gradient(circle at top right, rgba(242, 227, 207, 0.95), transparent 34%),64 linear-gradient(180deg, #fffaf5 0%, #f8efe6 48%, #f5ebe2 100%);65}66 67[data-testid="stHeader"] {68 background: transparent;69}70 71.block-container {72 padding-top: 2rem;73 padding-bottom: 4rem;74 max-width: 1320px;75}76 77[data-testid="stSidebar"] {78 background: linear-gradient(180deg, #193a3b 0%, #224c4d 100%);79 border-right: 1px solid rgba(255, 255, 255, 0.10);80}81 82[data-testid="stSidebar"] * {83 color: #edf7f5;84}85 86[data-testid="stSidebar"] [data-testid="stMarkdownContainer"] p {87 color: rgba(237, 247, 245, 0.78);88}89 90[data-testid="stSidebar"] .stSelectbox label,91[data-testid="stSidebar"] .stTextInput label,92[data-testid="stSidebar"] .stTextArea label,93[data-testid="stSidebar"] .stSlider label {94 color: #f7fbfa;95 font-weight: 600;96}97 98[data-testid="stSidebar"] .stTextInput input,99[data-testid="stSidebar"] .stTextArea textarea,100[data-testid="stSidebar"] .stSelectbox [data-baseweb="select"] > div {101 background: #fffaf7 !important;102 border: 1px solid rgba(255, 255, 255, 0.18);103 border-radius: 16px;104 color: #19212a !important;105 caret-color: #19212a;106}107 108[data-testid="stSidebar"] .stTextInput input,109[data-testid="stSidebar"] .stTextArea textarea,110[data-testid="stSidebar"] .stSelectbox [data-baseweb="select"] * {111 color: #19212a !important;112}113 114[data-testid="stSidebar"] .stTextInput input::placeholder,115[data-testid="stSidebar"] .stTextArea textarea::placeholder {116 color: #7c8b99 !important;117}118 119[data-testid="stSidebar"] .stTextInput > div,120[data-testid="stSidebar"] .stTextArea > div,121[data-testid="stSidebar"] .stSelectbox > div {122 background: transparent;123}124 125[data-testid="stSidebar"] .stButton > button,126[data-testid="stSidebar"] .stDownloadButton > button {127 background: #fffaf7 !important;128 color: #19212a !important;129 border: 1px solid rgba(255, 255, 255, 0.18) !important;130}131 132[data-testid="stSidebar"] .stButton > button *,133[data-testid="stSidebar"] .stDownloadButton > button * {134 color: #19212a !important;135}136 137[data-testid="stSidebar"] .stButton > button:hover,138[data-testid="stSidebar"] .stDownloadButton > button:hover {139 background: #fff5ef !important;140 border-color: rgba(255, 255, 255, 0.28) !important;141}142 143[data-testid="stSidebar"] .stButton > button:disabled,144[data-testid="stSidebar"] .stDownloadButton > button:disabled {145 background: rgba(255, 250, 247, 0.82) !important;146 color: #64748b !important;147 border-color: rgba(255, 255, 255, 0.12) !important;148 opacity: 1 !important;149 box-shadow: none;150}151 152[data-testid="stSidebar"] .stButton > button:disabled *,153[data-testid="stSidebar"] .stDownloadButton > button:disabled * {154 color: #64748b !important;155}156 157[data-testid="stSidebar"] [data-testid="stFileUploaderDropzone"] {158 background: #fffaf7 !important;159 border: 1px solid rgba(255, 255, 255, 0.18) !important;160}161 162[data-testid="stSidebar"] [data-testid="stFileUploaderDropzone"] * {163 color: #19212a !important;164}165 166[data-testid="stSidebar"] [data-testid="stFileUploaderDropzone"] small {167 color: #7c8b99 !important;168}169 170[data-testid="stSidebar"] [data-testid="stFileUploaderDropzone"] button {171 background: #f7f0e8 !important;172 color: #19212a !important;173 border: 1px solid rgba(25, 33, 42, 0.12) !important;174 box-shadow: none;175}176 177[data-testid="stSidebar"] [data-testid="stFileUploaderDropzone"] button * {178 color: #19212a !important;179}180 181[data-testid="stSidebar"] .stSlider [data-baseweb="slider"] > div > div {182 background: rgba(255, 255, 255, 0.20);183}184 185[data-testid="stSidebar"] .stSlider [role="slider"] {186 background: #f7c59f;187 border-color: #f7c59f;188}189 190.stButton > button {191 border-radius: 999px;192 padding: 0.78rem 1.15rem;193 font-weight: 700;194 letter-spacing: 0.01em;195 border: 1px solid rgba(25, 33, 42, 0.10);196 box-shadow: 0 10px 24px rgba(25, 33, 42, 0.08);197 transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;198}199 200.stButton > button:hover {201 transform: translateY(-1px);202 box-shadow: 0 14px 28px rgba(25, 33, 42, 0.12);203}204 205.stButton > button[kind="primary"] {206 background: linear-gradient(135deg, #1d847c 0%, #0f766e 100%);207 color: #ffffff;208 border-color: transparent;209}210 211.stButton > button[kind="secondary"] {212 background: rgba(255, 253, 249, 0.76);213 color: var(--ink);214}215 216.stTabs [data-baseweb="tab-list"] {217 gap: 0.45rem;218 background: rgba(255, 255, 255, 0.52);219 padding: 0.4rem;220 border: 1px solid var(--line);221 border-radius: 999px;222 width: fit-content;223 box-shadow: 0 10px 24px rgba(25, 33, 42, 0.05);224}225 226.stTabs [data-baseweb="tab"] {227 border-radius: 999px;228 padding: 0.8rem 1.1rem;229 color: var(--muted);230 font-weight: 600;231 height: auto;232}233 234.stTabs [aria-selected="true"] {235 background: var(--panel-strong);236 color: var(--ink);237}238 239.hero-shell {240 position: relative;241 overflow: hidden;242 background:243 radial-gradient(circle at top right, rgba(255, 255, 255, 0.78), transparent 28%),244 linear-gradient(135deg, rgba(215, 243, 238, 0.96) 0%, rgba(255, 250, 245, 0.98) 46%, rgba(242, 227, 207, 0.88) 100%);245 border: 1px solid rgba(25, 33, 42, 0.08);246 border-radius: 32px;247 padding: 2rem;248 box-shadow: var(--shadow);249 margin-bottom: 1.15rem;250}251 252.hero-shell::after {253 content: "";254 position: absolute;255 width: 240px;256 height: 240px;257 top: -85px;258 right: -60px;259 border-radius: 50%;260 background: rgba(15, 118, 110, 0.08);261}262 263.hero-grid {264 position: relative;265 z-index: 1;266 display: grid;267 grid-template-columns: minmax(0, 1.4fr) minmax(290px, 0.8fr);268 gap: 1.4rem;269 align-items: start;270}271 272.hero-eyebrow,273.section-kicker {274 display: inline-flex;275 align-items: center;276 gap: 0.35rem;277 padding: 0.35rem 0.75rem;278 border-radius: 999px;279 background: rgba(255, 255, 255, 0.58);280 border: 1px solid rgba(25, 33, 42, 0.08);281 color: var(--teal);282 font-size: 0.76rem;283 font-weight: 700;284 text-transform: uppercase;285 letter-spacing: 0.08em;286}287 288.hero-title {289 margin: 0.8rem 0 0.7rem;290 font-size: clamp(2.3rem, 4vw, 4.3rem);291 line-height: 0.95;292 letter-spacing: -0.04em;293 color: var(--ink);294}295 296.hero-title .accent {297 display: block;298 font-family: "Instrument Serif", Georgia, serif;299 font-style: italic;300 font-weight: 400;301 letter-spacing: -0.02em;302}303 304.hero-text {305 max-width: 48rem;306 font-size: 0.98rem;307 line-height: 1.6;308 color: var(--muted);309 margin: 0;310}311 312.hero-pill-row {313 display: flex;314 flex-wrap: wrap;315 gap: 0.55rem;316 margin-top: 1rem;317}318 319.hero-pill {320 background: rgba(255, 255, 255, 0.66);321 border: 1px solid rgba(25, 33, 42, 0.08);322 border-radius: 999px;323 padding: 0.5rem 0.8rem;324 font-size: 0.88rem;325 color: var(--ink);326}327 328.hero-panel,329.overview-card,330.metric-card,331.mol-card,332.best-banner,333.empty-card {334 background: var(--panel);335 backdrop-filter: blur(14px);336 border: 1px solid rgba(25, 33, 42, 0.08);337 box-shadow: var(--shadow);338}339 340.hero-panel {341 border-radius: 24px;342 padding: 1.35rem;343}344 345.hero-panel-title {346 font-size: 0.78rem;347 color: var(--muted);348 text-transform: uppercase;349 letter-spacing: 0.08em;350 font-weight: 700;351}352 353.hero-stat-grid {354 display: grid;355 gap: 0.85rem;356 margin-top: 1rem;357}358 359.hero-stat {360 display: grid;361 gap: 0.18rem;362 padding-bottom: 0.8rem;363 border-bottom: 1px solid rgba(25, 33, 42, 0.08);364}365 366.hero-stat:last-child {367 border-bottom: 0;368 padding-bottom: 0;369}370 371.hero-stat-label {372 font-size: 0.8rem;373 text-transform: uppercase;374 letter-spacing: 0.08em;375 color: var(--muted);376}377 378.hero-stat-value {379 font-size: 1.2rem;380 font-weight: 700;381 color: var(--ink);382}383 384.overview-card,385.empty-card {386 border-radius: 24px;387 padding: 1.3rem 1.35rem;388 min-height: 100%;389}390 391.overview-label {392 font-size: 0.8rem;393 color: var(--muted);394 text-transform: uppercase;395 letter-spacing: 0.08em;396 font-weight: 700;397}398 399.overview-title {400 font-size: 1.25rem;401 font-weight: 700;402 color: var(--ink);403 margin: 0.45rem 0 0.65rem;404}405 406.overview-text,407.small-note,408.check-copy {409 color: var(--muted);410 line-height: 1.55;411 font-size: 0.9rem;412}413 414.mono-preview {415 margin-top: 0.9rem;416 padding: 0.8rem 0.95rem;417 border-radius: 18px;418 background: rgba(25, 33, 42, 0.04);419 border: 1px solid rgba(25, 33, 42, 0.08);420 font-family: "SFMono-Regular", ui-monospace, monospace;421 font-size: 0.82rem;422 overflow-wrap: anywhere;423}424 425.brief-lead {426 color: var(--muted);427 line-height: 1.6;428 font-size: 0.95rem;429 margin: 0.15rem 0 0;430}431 432.brief-targets {433 margin-top: 0.9rem;434 display: grid;435 gap: 0.5rem;436}437 438.brief-target {439 display: flex;440 gap: 0.6rem;441 align-items: flex-start;442 color: var(--ink);443 font-size: 0.93rem;444 line-height: 1.45;445}446 447.brief-target::before {448 content: "";449 width: 8px;450 height: 8px;451 border-radius: 999px;452 margin-top: 0.38rem;453 flex-shrink: 0;454 background: linear-gradient(135deg, #0f766e 0%, #d97757 100%);455}456 457.brief-meta {458 margin-top: 0.9rem;459 font-size: 0.82rem;460 color: #7c8b99;461 text-transform: uppercase;462 letter-spacing: 0.06em;463 font-weight: 700;464}465 466.readiness-list {467 display: grid;468 gap: 0.9rem;469 margin-top: 1rem;470}471 472.readiness-item {473 display: grid;474 grid-template-columns: auto 1fr;475 gap: 0.75rem;476 align-items: start;477}478 479.readiness-chip {480 min-width: 78px;481 text-align: center;482 border-radius: 999px;483 padding: 0.42rem 0.65rem;484 font-size: 0.72rem;485 font-weight: 700;486 text-transform: uppercase;487 letter-spacing: 0.08em;488}489 490.chip-ready { background: rgba(21, 128, 61, 0.12); color: var(--green); }491.chip-warn { background: rgba(217, 119, 87, 0.14); color: var(--coral); }492 493.section-heading {494 margin: 1.8rem 0 1rem;495}496 497.section-title {498 margin: 0.5rem 0 0;499 font-size: 1.55rem;500 line-height: 1.1;501 letter-spacing: -0.03em;502 color: var(--ink);503}504 505.section-copy {506 color: var(--muted);507 margin: 0.35rem 0 0;508 max-width: 42rem;509 font-size: 0.92rem;510}511 512.metric-card {513 border-radius: 24px;514 padding: 1.35rem 1.2rem;515 text-align: center;516}517 518.metric-label {519 font-size: 0.76rem;520 color: var(--muted);521 text-transform: uppercase;522 letter-spacing: 0.08em;523 margin-bottom: 0.45rem;524 font-weight: 700;525}526 527.metric-before {528 font-size: 0.88rem;529 color: var(--muted);530}531 532.metric-after {533 font-size: 2rem;534 line-height: 1;535 font-weight: 700;536 margin: 0.4rem 0 0.3rem;537 letter-spacing: -0.04em;538}539 540.metric-delta {541 font-size: 0.9rem;542 font-weight: 700;543}544 545.best-banner {546 border-radius: 26px;547 padding: 1.25rem 1.35rem;548 margin-top: 1rem;549}550 551.best-banner-label {552 display: inline-flex;553 align-items: center;554 gap: 0.35rem;555 padding: 0.35rem 0.75rem;556 border-radius: 999px;557 background: rgba(21, 128, 61, 0.12);558 color: var(--green);559 font-size: 0.76rem;560 text-transform: uppercase;561 letter-spacing: 0.08em;562 font-weight: 700;563}564 565.best-banner-title {566 font-size: 1.4rem;567 line-height: 1.1;568 margin: 0.75rem 0 0.45rem;569 color: var(--ink);570}571 572.best-banner-copy {573 color: var(--muted);574 margin: 0;575 font-size: 0.93rem;576 line-height: 1.55;577}578 579.best-banner-code {580 margin-top: 0.9rem;581 padding: 0.9rem 1rem;582 border-radius: 18px;583 background: rgba(25, 33, 42, 0.05);584 border: 1px solid rgba(25, 33, 42, 0.08);585 font-family: "SFMono-Regular", ui-monospace, monospace;586 overflow-wrap: anywhere;587}588 589.result-pill-row {590 display: flex;591 flex-wrap: wrap;592 gap: 0.55rem;593 margin-top: 0.9rem;594}595 596.result-pill {597 border-radius: 999px;598 padding: 0.45rem 0.75rem;599 background: rgba(255, 255, 255, 0.7);600 border: 1px solid rgba(25, 33, 42, 0.08);601 color: var(--ink);602 font-size: 0.87rem;603}604 605.mol-card {606 border-radius: 28px;607 padding: 1.45rem;608 margin-bottom: 1.25rem;609 border-left: 5px solid rgba(25, 33, 42, 0.14);610}611 612.mol-card.best { border-left-color: var(--green); }613.mol-card.good { border-left-color: var(--blue); }614.mol-card.start { border-left-color: #7c8b99; }615 616.mol-structure-panel {617 display: flex;618 align-items: center;619 justify-content: center;620 min-height: 320px;621 padding: 1rem;622 border-radius: 24px;623 background: linear-gradient(180deg, #fbfcfe 0%, #eef3f8 100%);624 border: 1px solid rgba(71, 85, 105, 0.16);625 overflow: hidden;626}627 628.mol-structure-panel svg {629 width: 100%;630 height: auto;631 max-height: 300px;632}633 634.mol-structure-fallback {635 width: 100%;636 padding: 1rem;637 border-radius: 18px;638 background: rgba(255, 255, 255, 0.7);639 border: 1px dashed rgba(25, 33, 42, 0.14);640}641 642.change-note {643 margin-top: 0.55rem;644 font-size: 0.78rem;645 color: #b45309;646 font-weight: 700;647 letter-spacing: 0.03em;648}649 650.round-badge {651 display: inline-block;652 border-radius: 999px;653 padding: 0.42rem 0.78rem;654 font-size: 0.76rem;655 font-weight: 700;656 letter-spacing: 0.08em;657 text-transform: uppercase;658 margin-bottom: 0.35rem;659}660 661.badge-best { background: rgba(21, 128, 61, 0.12); color: var(--green); }662.badge-start { background: rgba(25, 33, 42, 0.07); color: #596674; }663.badge-attempt { background: rgba(37, 99, 235, 0.10); color: #1d4ed8; }664 665.score-section { margin: 0.75rem 0 0.1rem; }666 667.score-row {668 display: flex;669 align-items: center;670 margin: 0.72rem 0;671 gap: 0.7rem;672}673 674.score-label {675 font-size: 0.86rem;676 color: var(--muted);677 width: 170px;678 flex-shrink: 0;679}680 681.score-bar-bg {682 flex: 1;683 background: rgba(25, 33, 42, 0.08);684 border-radius: 999px;685 height: 10px;686 overflow: hidden;687}688 689.score-bar {690 height: 100%;691 border-radius: 999px;692 transition: width 0.3s ease;693}694 695.score-value {696 font-size: 0.88rem;697 font-weight: 700;698 width: 58px;699 text-align: right;700 flex-shrink: 0;701}702 703.score-delta {704 font-size: 0.76rem;705 width: 52px;706 text-align: right;707 flex-shrink: 0;708}709 710.green { color: var(--green); }711.red { color: var(--red); }712.grey { color: #8c99a5; }713 714.alert-pill {715 display: inline-block;716 border-radius: 999px;717 padding: 0.42rem 0.72rem;718 font-size: 0.76rem;719 font-weight: 700;720 margin-top: 0.35rem;721}722 723.pill-green { background: rgba(21, 128, 61, 0.12); color: var(--green); }724.pill-yellow { background: rgba(183, 121, 31, 0.14); color: var(--gold); }725.pill-red { background: rgba(180, 35, 24, 0.12); color: var(--red); }726 727.reasoning-box {728 background: linear-gradient(180deg, rgba(255, 255, 255, 0.68), rgba(255, 255, 255, 0.82));729 border-radius: 22px;730 padding: 0.9rem 1rem;731 margin-top: 1rem;732 border: 1px solid rgba(25, 33, 42, 0.08);733}734 735.reasoning-title {736 font-size: 0.76rem;737 font-weight: 700;738 letter-spacing: 0.08em;739 text-transform: uppercase;740 color: var(--teal);741 margin-bottom: 0.45rem;742}743 744.reasoning-text {745 font-size: 0.92rem;746 color: #32404d;747 line-height: 1.65;748}749 750.page-subtitle {751 font-size: 0.98rem;752 color: var(--muted);753 margin-top: -0.2rem;754 margin-bottom: 0;755}756 757.empty-grid {758 display: grid;759 grid-template-columns: repeat(3, minmax(0, 1fr));760 gap: 1rem;761 margin-top: 0.8rem;762}763 764.empty-step {765 font-size: 0.76rem;766 font-weight: 700;767 letter-spacing: 0.08em;768 text-transform: uppercase;769 color: var(--teal);770}771 772.empty-title {773 font-size: 1.1rem;774 font-weight: 700;775 margin: 0.5rem 0 0.45rem;776 color: var(--ink);777}778 779.empty-copy {780 margin: 0;781 color: var(--muted);782 line-height: 1.55;783 font-size: 0.9rem;784}785 786div[data-testid="stAlert"] {787 border-radius: 20px;788 border: 1px solid rgba(25, 33, 42, 0.08);789}790 791@media (max-width: 980px) {792 .hero-grid,793 .empty-grid {794 grid-template-columns: 1fr;795 }796 797 .score-label {798 width: 120px;799 }800}801</style>802""", unsafe_allow_html=True)803 804 805# ─── Helpers ──────────────────────────────────────────────────────────────────806def score_color(val, low, mid, high, invert=False):807 """Return hex color based on thresholds."""808 if invert:809 val = -val; low, mid, high = -high, -mid, -low810 if val >= high: return "#16a34a"811 if val >= mid: return "#f59e0b"812 return "#dc2626"813 814def bar_html(label, value, display, bar_pct, color, delta_str=""):815 return f"""816<div class="score-row">817 <span class="score-label">{label}</span>818 <div class="score-bar-bg">819 <div class="score-bar" style="width:{bar_pct:.0f}%;background:{color}"></div>820 </div>821 <span class="score-value" style="color:{color}">{display}</span>822 <span class="score-delta {'green' if delta_str.startswith('+') else 'red' if delta_str.startswith('-') else 'grey'}">{delta_str}</span>823</div>"""824 825def mol_scores_html(c, start=None):826 """Render score bars for one candidate."""827 def delta(key, fmt="+.2f"):828 if start is None: return ""829 d = (c.get(key) or 0) - (start.get(key) or 0)830 return f"{d:{fmt}}" if d != 0 else ""831 832 bbb = (c.get("bbb_probability") or 0) * 100833 cns = c.get("cns_mpo_score") or 0834 qed = (c.get("qed_score") or 0) * 100835 flex = c.get("rotatable_bonds") or 0836 alerts= c.get("num_alerts") or 0837 838 out = '<div class="score-section">'839 out += bar_html("Brain Penetration", bbb, f"{bbb:.0f}%", bbb,840 score_color(bbb, 50, 70, 80), delta("bbb_probability", "+.0%").replace("%","pp") if start else "")841 out += bar_html("CNS Activity Score", cns, f"{cns:.1f}/5", cns/5*100,842 score_color(cns, 3, 4, 4.5), delta("cns_mpo_score") if start else "")843 out += bar_html("Drug Likeness", qed, f"{qed:.0f}%", qed,844 score_color(qed, 49, 60, 67), delta("qed_score", "+.0%").replace("%","pp") if start else "")845 out += bar_html("Flexibility", flex, f"{flex} bonds", max(0, 100-flex*10),846 score_color(flex, 9, 7, 5, invert=True), delta("rotatable_bonds", "+d") if start else "")847 848 # Safety pill849 alert_color = "pill-green" if alerts == 0 else ("pill-yellow" if alerts <= 2 else "pill-red")850 alert_text = "No safety alerts" if alerts == 0 else f"{alerts} safety alert{'s' if alerts>1 else ''}"851 out += f'<div style="margin-top:10px"><span class="alert-pill {alert_color}">{alert_text}</span></div>'852 out += "</div>"853 return out854 855def escape_html(text):856 return html.escape(str(text or "")).replace("\n", "<br>")857 858def truncate_text(text, limit=220):859 clean = " ".join(str(text or "").split())860 if len(clean) <= limit:861 return clean862 return clean[: limit - 1].rstrip() + "…"863 864def render_section_heading(title, subtitle="", kicker="Overview"):865 st.markdown(866 f"""867<div class="section-heading">868 <span class="section-kicker">{escape_html(kicker)}</span>869 <h2 class="section-title">{escape_html(title)}</h2>870 {f'<p class="section-copy">{escape_html(subtitle)}</p>' if subtitle else ''}871</div>872""",873 unsafe_allow_html=True,874 )875 876def readiness_item(label, detail, ready):877 chip_class = "chip-ready" if ready else "chip-warn"878 chip_text = "Ready" if ready else "Needed"879 return f"""880<div class="readiness-item">881 <div class="readiness-chip {chip_class}">{chip_text}</div>882 <div>883 <div class="overview-label">{escape_html(label)}</div>884 <div class="check-copy">{escape_html(detail)}</div>885 </div>886</div>887"""888 889def summarize_reasoning(text, is_start=False, limit=190):890 clean = str(text or "")891 clean = re.sub(r"```.*?```", " ", clean, flags=re.S)892 clean = re.sub(r"^#{1,6}\s*", "", clean, flags=re.M)893 clean = clean.replace("---", " ")894 clean = clean.replace("**", "").replace("`", "")895 clean = re.sub(r"\s+", " ", clean).strip()896 897 if not clean:898 return ""899 900 if "Rationale:" in clean:901 rationale = clean.split("Rationale:", 1)[1].strip()902 else:903 rationale = clean904 905 sentences = [s.strip() for s in re.split(r"(?<=[.!?])\s+", rationale) if s.strip()]906 if not sentences:907 return truncate_text(clean, limit)908 909 if is_start:910 return truncate_text(sentences[0], limit)911 912 keywords = (913 "replace",914 "switch",915 "move",916 "moving",917 "add",918 "adding",919 "remove",920 "reduce",921 "shift",922 "place",923 "placing",924 "orientation",925 "scaffold",926 )927 selected = next((s for s in sentences if any(k in s.lower() for k in keywords)), sentences[0])928 selected = re.sub(r"^(let's|let me)\s+", "", selected, flags=re.I)929 selected = re.sub(r"^very close!?[\s,:-]*", "", selected, flags=re.I)930 selected = re.sub(r"^rationale:\s*", "", selected, flags=re.I)931 return truncate_text(selected, limit)932 933def _metric_priority_phrase(current, previous):934 if previous is None:935 return ""936 937 improvements = []938 bbb_delta = (current.get("bbb_probability") or 0) - (previous.get("bbb_probability") or 0)939 cns_delta = (current.get("cns_mpo_score") or 0) - (previous.get("cns_mpo_score") or 0)940 qed_delta = (current.get("qed_score") or 0) - (previous.get("qed_score") or 0)941 flex_delta = (previous.get("rotatable_bonds") or 0) - (current.get("rotatable_bonds") or 0)942 alert_delta = (previous.get("num_alerts") or 0) - (current.get("num_alerts") or 0)943 944 if bbb_delta > 0.015:945 improvements.append("improve brain penetration")946 if cns_delta > 0.05:947 improvements.append("lift CNS MPO")948 if qed_delta > 0.015:949 improvements.append("improve overall drug-likeness")950 if flex_delta > 0:951 improvements.append("reduce conformational flexibility")952 if alert_delta > 0:953 improvements.append("reduce structural risk")954 955 if not improvements:956 return "fine-tune the CNS profile"957 if len(improvements) == 1:958 return improvements[0]959 return f"{improvements[0]} and {improvements[1]}"960 961def _structural_change_phrase(reasoning):962 text = str(reasoning or "").lower()963 964 checks = [965 (("amide", "nitrile"), "replaced the amide with a nitrile handle"),966 (("n-methyl",), "N-methylated the side-chain amine"),967 (("fluoro",), "introduced a fluorine on the side chain"),968 (("fluorine",), "introduced a fluorine on the side chain"),969 (("methyl",), "added a small methyl substituent"),970 (("pyridine",), "swapped the phenyl ring for a pyridine"),971 (("rigid",), "rigidified the scaffold"),972 (("fused ring",), "rigidified the aryl region with a fused ring"),973 (("naphthal",), "expanded the aryl system to a fused bicyclic ring"),974 (("bioisostere",), "made a bioisosteric swap"),975 ]976 for needles, phrase in checks:977 if all(needle in text for needle in needles):978 return phrase979 980 match = re.search(r"replace(?:d)?\s+(.+?)\s+with\s+(.+?)(?:[.,;]|$)", text)981 if match:982 before = truncate_text(match.group(1), 35)983 after = truncate_text(match.group(2), 35)984 return f"replaced {before} with {after}"985 986 return "changed the highlighted region"987 988def summarize_candidate_change(current, previous=None):989 reasoning = current.get("reasoning") or ""990 if previous is None:991 qed = (current.get("qed_score") or 0) * 100992 cns = current.get("cns_mpo_score") or 0993 bbb = (current.get("bbb_probability") or 0) * 100994 logs = current.get("log_s") or 0995 flex = current.get("rotatable_bonds") or 0996 return truncate_text(997 f"Baseline readout: drug-likeness {qed:.0f}%, CNS score {cns:.2f}/5, "998 f"brain penetration {bbb:.0f}%, solubility logS {logs:.2f}, "999 f"{flex} rotatable bonds. This is the starting point for optimization.",1000 220,1001 )1002 1003 change_phrase = _structural_change_phrase(reasoning)1004 why_phrase = _metric_priority_phrase(current, previous)1005 return truncate_text(1006 f"We {change_phrase} to {why_phrase}.",1007 210,1008 )1009 1010def brief_title_from_name(name):1011 text = str(name or "").strip()1012 if not text or text == "Custom molecule":1013 return "Custom lead brief"1014 return text.replace("→", "to")1015 1016def brief_body_html(goal_text):1017 text = str(goal_text or "").strip()1018 if not text:1019 return '<p class="brief-lead">Add the target profile you want the agent to optimise toward.</p>'1020 1021 sections = [part.strip() for part in text.split("\n\n") if part.strip()]1022 intro = sections[0] if sections else text1023 intro = intro.replace("Targets:", "").strip()1024 intro_lines = [line.strip(" -") for line in intro.splitlines() if line.strip()]1025 intro_text = " ".join(intro_lines[:2]).strip()1026 1027 targets = []1028 for section in sections[1:]:1029 for line in section.splitlines():1030 stripped = line.strip()1031 if stripped.startswith("-"):1032 targets.append(stripped.lstrip("- ").strip())1033 1034 if not targets:1035 for line in text.splitlines():1036 stripped = line.strip()1037 if stripped.startswith("-"):1038 targets.append(stripped.lstrip("- ").strip())1039 1040 html_parts = [f'<p class="brief-lead">{escape_html(truncate_text(intro_text, 260))}</p>']1041 if targets:1042 targets_html = "".join(1043 f'<div class="brief-target">{escape_html(truncate_text(item, 110))}</div>'1044 for item in targets[:4]1045 )1046 html_parts.append(f'<div class="brief-targets">{targets_html}</div>')1047 return "".join(html_parts)1048 1049def normalize_candidates(raw_candidates):1050 if not isinstance(raw_candidates, list):1051 raise ValueError("Saved run must contain a list of candidates.")1052 1053 normalized = []1054 for idx, item in enumerate(raw_candidates):1055 if not isinstance(item, dict):1056 continue1057 row = dict(item)1058 row["mol_index"] = row.get("mol_index", row.get("round", idx))1059 row["input_smiles"] = row.get("input_smiles") or row.get("canonical_smiles") or ""1060 row["reasoning"] = row.get("reasoning") or ""1061 normalized.append(row)1062 1063 if not normalized:1064 raise ValueError("No valid candidates found in saved run.")1065 return normalized1066 1067def build_run_payload(smiles, goal, candidates, preset_name, source="live"):1068 return {1069 "version": 1,1070 "saved_at": datetime.now().isoformat(timespec="seconds"),1071 "source": source,1072 "preset_name": preset_name,1073 "starting_smiles": smiles,1074 "goal": goal,1075 "candidate_count": len(candidates),1076 "candidates": candidates,1077 }1078 1079def load_run_payload(payload):1080 if isinstance(payload, list):1081 candidates = normalize_candidates(payload)1082 meta = {1083 "preset_name": "Saved run",1084 "starting_smiles": candidates[0].get("input_smiles", ""),1085 "goal": "",1086 "saved_at": "",1087 "source": "upload",1088 }1089 return candidates, meta1090 1091 if not isinstance(payload, dict):1092 raise ValueError("Unsupported saved run format.")1093 1094 candidates = normalize_candidates(payload.get("candidates") or payload.get("results") or [])1095 meta = {1096 "preset_name": payload.get("preset_name") or "Saved run",1097 "starting_smiles": payload.get("starting_smiles") or payload.get("smiles") or candidates[0].get("input_smiles", ""),1098 "goal": payload.get("goal") or "",1099 "saved_at": payload.get("saved_at") or "",1100 "source": payload.get("source") or "upload",1101 }1102 return candidates, meta1103 1104def apply_loaded_run(payload, source_label):1105 candidates, meta = load_run_payload(payload)1106 meta["source_label"] = source_label1107 st.session_state.candidates = candidates1108 st.session_state.completed = True1109 st.session_state.run_meta = meta1110 st.session_state.run_notice = f"Loaded {len(candidates)} candidates from {source_label}."1111 1112def save_run_snapshot(payload, runs_dir, latest_path):1113 runs_dir.mkdir(parents=True, exist_ok=True)1114 stamp = datetime.now().strftime("%Y%m%d_%H%M%S")1115 run_path = runs_dir / f"run_{stamp}.json"1116 text = json.dumps(payload, indent=2)1117 latest_path.write_text(text, encoding="utf-8")1118 run_path.write_text(text, encoding="utf-8")1119 return run_path1120 1121def mol_svg_markup(smiles, previous_smiles=None):1122 """Return inline SVG markup for a SMILES string, optionally highlighting the changed region."""1123 try:1124 from rdkit import Chem1125 from rdkit.Chem import rdFMCS1126 from rdkit.Chem.Draw import rdMolDraw2D1127 mol = Chem.MolFromSmiles(smiles)1128 if mol is None:1129 return None, False1130 1131 highlight_atoms = []1132 highlight_bonds = []1133 highlight_atom_colors = {}1134 highlight_bond_colors = {}1135 highlight_atom_radii = {}1136 1137 if previous_smiles:1138 previous = Chem.MolFromSmiles(previous_smiles)1139 if previous is not None:1140 mcs = rdFMCS.FindMCS(1141 [previous, mol],1142 ringMatchesRingOnly=True,1143 completeRingsOnly=True,1144 atomCompare=rdFMCS.AtomCompare.CompareElements,1145 bondCompare=rdFMCS.BondCompare.CompareOrder,1146 timeout=2,1147 )1148 if mcs and mcs.smartsString:1149 patt = Chem.MolFromSmarts(mcs.smartsString)1150 if patt is not None:1151 match = mol.GetSubstructMatch(patt)1152 if match:1153 core_atoms = set(match)1154 highlight_atoms = [a.GetIdx() for a in mol.GetAtoms() if a.GetIdx() not in core_atoms]1155 highlight_bonds = [1156 b.GetIdx()1157 for b in mol.GetBonds()1158 if b.GetBeginAtomIdx() in highlight_atoms or b.GetEndAtomIdx() in highlight_atoms1159 ]1160 highlight_atom_colors = {idx: (0.98, 0.83, 0.18) for idx in highlight_atoms}1161 highlight_bond_colors = {idx: (0.98, 0.83, 0.18) for idx in highlight_bonds}1162 highlight_atom_radii = {idx: 0.5 for idx in highlight_atoms}1163 1164 Chem.rdDepictor.Compute2DCoords(mol)1165 drawer = rdMolDraw2D.MolDraw2DSVG(420, 300)1166 opts = drawer.drawOptions()1167 opts.bondLineWidth = 21168 opts.padding = 0.051169 opts.multipleBondOffset = 0.181170 opts.fixedBondLength = 421171 opts.clearBackground = False1172 opts.fillHighlights = False1173 opts.highlightRadius = 0.31174 opts.highlightBondWidthMultiplier = 181175 opts.atomHighlightsAreCircles = True1176 opts.useDefaultAtomPalette()1177 opts.updateAtomPalette({1178 6: (0.11, 0.16, 0.23), # carbon1179 7: (0.10, 0.35, 0.82), # nitrogen1180 8: (0.84, 0.15, 0.16), # oxygen1181 9: (0.00, 0.62, 0.69), # fluorine1182 15: (0.58, 0.20, 0.78), # phosphorus1183 16: (0.84, 0.45, 0.08), # sulfur1184 17: (0.09, 0.55, 0.35), # chlorine1185 35: (0.65, 0.25, 0.16), # bromine1186 53: (0.43, 0.34, 0.78), # iodine1187 })1188 opts.setHighlightColour((0.95, 0.69, 0.12))1189 opts.setBackgroundColour((1.0, 1.0, 1.0, 0.0))1190 rdMolDraw2D.PrepareAndDrawMolecule(1191 drawer,1192 mol,1193 highlightAtoms=highlight_atoms,1194 highlightAtomColors=highlight_atom_colors,1195 highlightAtomRadii=highlight_atom_radii,1196 highlightBonds=highlight_bonds,1197 highlightBondColors=highlight_bond_colors,1198 )1199 drawer.FinishDrawing()1200 svg = drawer.GetDrawingText()