Skip to content

Commit 054957b

Browse files
feat(demo): rich typeahead dropdown (structure+name+IUPAC+SMILES); fix limonene "sweet" (#70)
- Replace the bare <datalist> with a custom dropdown: each suggestion row shows a 2D structure thumbnail, common name, IUPAC name, and SMILES. Reusable attachRichSuggest() is wired to the main bar AND every mixture ingredient row. /api/suggest now returns svg (local) + iupac (background-precomputed from PubChem, cached). - Fix the "limonene = 195x as sweet as sucrose" embarrassment: the sweet classifier false-positives on lipophilic terpenes, and the intensity regressor (trained on hydrophilic sweeteners) then extrapolates. Gate the intensity chip on logP < 2 (a sweetener-applicability proxy) so it suppresses limonene/terpenes while keeping real sweeteners (sugars, aspartame are polar); also tag it "(est.)". Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
1 parent 260b888 commit 054957b

2 files changed

Lines changed: 54 additions & 19 deletions

File tree

training/app.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
prediction core doesn't change.
1919
"""
2020

21+
import threading
2122
from functools import lru_cache
2223
from pathlib import Path
2324

@@ -139,15 +140,29 @@ def _load_suggest():
139140

140141

141142
_SUGGEST = _load_suggest()
143+
_IUPAC = {} # smiles -> IUPAC name, filled in the background (PubChem, cached)
144+
145+
146+
def _precompute_iupac():
147+
for _, s in _SUGGEST:
148+
_IUPAC[s] = _names(s)[1]
149+
150+
151+
if _SUGGEST:
152+
threading.Thread(target=_precompute_iupac, daemon=True).start()
142153

143154

144155
@app.get("/api/suggest")
145156
def api_suggest(qs: str = ""):
146-
"""Name typeahead over the curated flavor-volatile list."""
157+
"""Rich typeahead over the curated flavor-volatile list — name + SMILES + structure + IUPAC."""
147158
t = qs.strip().lower()
148159
if len(t) < 2:
149160
return {"items": []}
150-
return {"items": [{"name": n, "smiles": s} for n, s in _SUGGEST if t in n.lower()][:8]}
161+
items = [{"name": n, "smiles": s} for n, s in _SUGGEST if t in n.lower()][:8]
162+
for it in items:
163+
it["svg"] = _svg(it["smiles"], 90, 64)
164+
it["iupac"] = _IUPAC.get(it["smiles"])
165+
return {"items": items}
151166

152167

153168
@app.get("/", response_class=HTMLResponse)

training/workbench.html

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,16 @@
9595
#mixResults{margin-top:14px}
9696
.hazard{border:1px solid #E7C4B8;background:#FBEDE9;color:#8A3A22;border-radius:8px;padding:10px 13px;margin-bottom:8px;font-size:13px;line-height:1.5}
9797
.hazard.cond{border-color:#E8D38A;background:#FFF6E6;color:#7A5A12}
98+
.suggest-dd{position:absolute;z-index:60;background:var(--panel);border:1px solid var(--line);border-radius:9px;box-shadow:0 8px 24px #00000022;max-height:340px;overflow:auto}
99+
.sg-row{display:flex;gap:10px;align-items:center;padding:8px 10px;cursor:pointer;border-bottom:1px solid var(--line)}
100+
.sg-row:last-child{border-bottom:none}
101+
.sg-row:hover{background:var(--accent-soft)}
102+
.sg-st{flex:0 0 auto;width:56px;height:42px;background:#fff;border:1px solid var(--line);border-radius:5px}
103+
.sg-st svg{width:56px;height:42px}
104+
.sg-info{min-width:0}
105+
.sg-name{font-weight:600;font-size:13px}
106+
.sg-iupac{font-family:var(--mono);font-size:10px;color:var(--muted);word-break:break-all}
107+
.sg-smi{font-family:var(--mono);font-size:11px;color:var(--accent);word-break:break-all}
98108
</style>
99109
</head>
100110
<body>
@@ -104,9 +114,8 @@ <h1>Flavor Workbench</h1>
104114
</header>
105115
<main>
106116
<div class="bar">
107-
<input id="q" list="suggest" placeholder="Compound name or SMILES — e.g. vanillin, or OC(=O)CC(O)(CC(=O)O)C(=O)O"
117+
<input id="q" placeholder="Compound name or SMILES — e.g. vanillin, or OC(=O)CC(O)(CC(=O)O)C(=O)O"
108118
autocomplete="off" spellcheck="false">
109-
<datalist id="suggest"></datalist>
110119
<button id="go">Read flavor</button>
111120
</div>
112121
<div class="hint">Type a <b>common or IUPAC name</b> (e.g. <code>vanillin</code>) or a
@@ -251,9 +260,10 @@ <h2>Aroma</h2>
251260
p.sour && p.sour_reason && p.sour_reason.length ? `sour · ${p.sour_reason[0]}` : 'not sour'));
252261
c.push(chip('salty', p.salty===true, p.salty===true?'var(--salty)':null,
253262
p.salty===true ? 'salty (known)' : 'salty — not in data'));
254-
if(inDomain && p.sweet >= 0.5 && typeof p.sweet_intensity === 'number'){
263+
const logP = ((p.physchem||{}).computed||{}).logP;
264+
if(inDomain && p.sweet >= 0.5 && typeof p.sweet_intensity === 'number' && typeof logP==='number' && logP < 2){
255265
const x = Math.pow(10, p.sweet_intensity);
256-
c.push(chip('intensity', true, 'var(--sweet)', `~${x>=10?Math.round(x):x.toFixed(1)}× as sweet as sucrose`));
266+
c.push(chip('intensity', true, 'var(--sweet)', `~${x>=10?Math.round(x):x.toFixed(1)}× as sweet as sucrose (est.)`));
257267
}
258268
if(inDomain && p.multitaste) c.push(chip('multi', true, 'var(--aroma)', 'multi-taste'));
259269
$('chips').innerHTML = c.join('');
@@ -340,30 +350,40 @@ <h2>Aroma</h2>
340350
if(nb && nb.dataset.smi){ q.value = nb.dataset.smi; window.scrollTo({top:0,behavior:'smooth'}); run(); }
341351
});
342352

343-
// --- name typeahead (datalist) over the curated flavor-volatile list, on any input ---
344-
let suggestT;
345-
function wireSuggest(input){
346-
input.setAttribute('list','suggest');
353+
// --- rich typeahead dropdown (structure + names + SMILES) on any input ---
354+
function attachRichSuggest(input, onPick){
355+
const dd=document.createElement('div'); dd.className='suggest-dd'; dd.style.display='none';
356+
document.body.appendChild(dd);
357+
let t;
358+
const close=()=>{ dd.style.display='none'; };
359+
const place=()=>{ const r=input.getBoundingClientRect(); dd.style.left=(r.left+window.scrollX)+'px'; dd.style.top=(r.bottom+window.scrollY+4)+'px'; dd.style.width=r.width+'px'; };
347360
input.addEventListener('input', ()=>{
348-
clearTimeout(suggestT);
349-
suggestT=setTimeout(async ()=>{
361+
clearTimeout(t);
362+
t=setTimeout(async ()=>{
350363
const v=input.value.trim();
351-
if(v.length<2){ $('suggest').innerHTML=''; return; }
364+
if(v.length<2){ close(); return; }
352365
try{
353366
const r=await fetch('/api/suggest?qs='+encodeURIComponent(v));
354-
const d=await r.json();
355-
$('suggest').innerHTML=(d.items||[]).map(it=>`<option value="${it.name}">`).join('');
356-
}catch(_){}
357-
}, 160);
367+
const items=(await r.json()).items||[];
368+
if(!items.length){ close(); return; }
369+
dd.innerHTML=items.map(it=>`<div class="sg-row" data-smi="${it.smiles}">
370+
<div class="sg-st">${it.svg||''}</div>
371+
<div class="sg-info"><div class="sg-name">${it.name}</div>${it.iupac?`<div class="sg-iupac">${it.iupac}</div>`:''}<div class="sg-smi">${it.smiles}</div></div></div>`).join('');
372+
place(); dd.style.display='block';
373+
}catch(_){ close(); }
374+
}, 150);
358375
});
376+
dd.addEventListener('mousedown', e=>{ const row=e.target.closest('.sg-row'); if(row){ e.preventDefault(); input.value=row.dataset.smi; close(); if(onPick) onPick(); } });
377+
input.addEventListener('blur', ()=>setTimeout(close, 150));
378+
window.addEventListener('scroll', ()=>{ if(dd.style.display!=='none') place(); }, true);
359379
}
360-
wireSuggest(q);
380+
attachRichSuggest(q, ()=>run());
361381

362382
// --- mixture mode ---
363383
function mixAddRow(val){
364384
const div=document.createElement('div'); div.className='mix-row';
365385
const inp=document.createElement('input'); inp.placeholder='name or SMILES'; inp.value=val||'';
366-
wireSuggest(inp);
386+
attachRichSuggest(inp);
367387
const rm=document.createElement('button'); rm.className='rm'; rm.textContent='×'; rm.title='remove';
368388
rm.onclick=()=>div.remove();
369389
div.append(inp, rm); $('mixRows').appendChild(div);

0 commit comments

Comments
 (0)