Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 39 additions & 15 deletions training/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
prediction core doesn't change.
"""

from functools import lru_cache
from pathlib import Path

from fastapi import FastAPI
Expand Down Expand Up @@ -45,6 +46,37 @@ def _resolve(text: str):
return None


def _svg(smi, w=320, h=220):
"""2D structure SVG; None if drawing is unavailable (headless box w/o libXrender)."""
mol = Chem.MolFromSmiles(smi) if smi else None
if mol is None:
return None
try:
from rdkit.Chem.Draw import rdMolDraw2D
d = rdMolDraw2D.MolDraw2DSVG(w, h)
d.DrawMolecule(mol)
d.FinishDrawing()
return d.GetDrawingText()
except Exception: # noqa: BLE001 — missing X11 libs etc.; degrade gracefully
return None


@lru_cache(maxsize=8192)
def _name(smi):
"""PubChem common name (Title) for a SMILES — cached, best-effort, short timeout."""
import json
import urllib.parse
import urllib.request
url = ("https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/smiles/"
f"{urllib.parse.quote(smi)}/property/Title/JSON")
try:
with urllib.request.urlopen(url, timeout=4) as r:
d = json.load(r)
return d["PropertyTable"]["Properties"][0].get("Title")
except Exception: # noqa: BLE001 — not found / timeout / throttled
return None


class Query(BaseModel):
smiles: str
k: int = 8
Expand All @@ -64,25 +96,17 @@ def api_neighbors(q: Query):
smi = _resolve(q.smiles)
if not smi:
return {"neighbors": []}
return P.substitute(smi, k=q.k)
res = P.substitute(smi, k=q.k)
for n in res.get("neighbors", []): # enrich each candidate with a structure + a name
n["svg"] = _svg(n["smiles"], 132, 96)
n["name"] = _name(n["smiles"])
return res


@app.post("/api/structure")
def api_structure(q: Query):
"""2D structure depiction (SVG). Degrades to {svg: None} if RDKit's drawing module
can't load (e.g. a headless box missing libXrender) — never 500s the demo."""
smi = _resolve(q.smiles)
mol = Chem.MolFromSmiles(smi) if smi else None
if mol is None:
return {"svg": None}
try:
from rdkit.Chem.Draw import rdMolDraw2D
d = rdMolDraw2D.MolDraw2DSVG(320, 220)
d.DrawMolecule(mol)
d.FinishDrawing()
return {"svg": d.GetDrawingText()}
except Exception: # noqa: BLE001 — missing X11 libs etc.; degrade gracefully
return {"svg": None}
"""2D structure depiction (SVG); {svg: None} if drawing is unavailable."""
return {"svg": _svg(_resolve(q.smiles))}


@app.get("/", response_class=HTMLResponse)
Expand Down
27 changes: 19 additions & 8 deletions training/workbench.html
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,14 @@
.chip{font-family:var(--mono);font-size:12px;padding:5px 11px;border-radius:20px;
border:1px solid var(--line);background:var(--surface)}
.chip.on{color:#fff;border-color:transparent}
.neighbor{display:flex;justify-content:space-between;align-items:center;gap:12px;
.neighbor{display:flex;align-items:center;gap:12px;
padding:11px 0;border-bottom:1px solid var(--line)}
.neighbor:last-child{border-bottom:none}
.neighbor .s{font-family:var(--mono);font-size:12.5px;word-break:break-all}
.neighbor .nb-struct{flex:0 0 auto;width:64px;height:48px;background:#fff;border:1px solid var(--line);border-radius:6px}
.neighbor .nb-struct svg{width:64px;height:48px}
.neighbor .nb-info{flex:1;min-width:0}
.neighbor .nb-name{font-weight:600;font-size:13px;text-transform:capitalize;color:var(--ink)}
.neighbor .s{font-family:var(--mono);font-size:12px;color:var(--muted);word-break:break-all}
.neighbor .sim{font-family:var(--mono);font-size:12px;color:var(--accent);white-space:nowrap}
.neighbor .kt{font-family:var(--mono);font-size:11px;color:var(--muted)}
.empty{color:var(--muted);font-size:14px;padding:8px 0}
Expand Down Expand Up @@ -127,10 +131,13 @@ <h2>Aroma</h2>

<p class="note" id="footnote" style="display:none">
Bars are model predictions; a <b>measured</b> tag means the molecule is in the
curated data (ground truth, not a guess). Salty is lookup-only and sour is a
rule. Substitutions rank by structural similarity (Tanimoto over molecular
fingerprints). Aroma isn't in the public demo — it trains on your licensed or
in-house odor data, on-prem. Results are leads for the bench, not verdicts.
curated data (ground truth, not a guess). The trained taste/tox heads are fit on
<b>organic</b> molecules — carbon-free inputs are flagged out-of-domain. Salty is
lookup-only, sour is a rule. Tox-assay flags are <b>indicative, caution-only</b>
(Tox21 in-vitro models) — never a determination. Quantitative dosing / odor-activity
needs odor-threshold tables (licensed or customer data), so it stays qualitative here.
Substitutions rank by structural similarity (Tanimoto). Aroma isn't in the public
demo — it trains on your data, on-prem. Results are leads for the bench, not verdicts.
</p>
</main>

Expand Down Expand Up @@ -215,8 +222,12 @@ <h2>Aroma</h2>
}else{
$('neighbors').innerHTML = neighbors.map(x=>`
<div class="neighbor">
<div><div class="s">${x.smiles}</div>
<div class="kt">${x.known_tastes.length?x.known_tastes.join(' · '):'—'}</div></div>
<div class="nb-struct">${x.svg||''}</div>
<div class="nb-info">
<div class="nb-name">${x.name||'—'}</div>
<div class="s">${x.smiles}</div>
<div class="kt">${x.known_tastes.length?x.known_tastes.join(' · '):'—'}</div>
</div>
<div class="sim">${Math.round(x.similarity*100)}% match</div>
</div>`).join('');
}
Expand Down
Loading