Spaces:
Sleeping
Sleeping
Simplify UI: 10 tabs -> 4 (Explore/Transform/Map/From text). Spectrum bar. Killer demo pre-rendered. Substring search defaults.
Browse files- __pycache__/app.cpython-310.pyc +0 -0
- app.py +464 -835
__pycache__/app.cpython-310.pyc
CHANGED
|
Binary files a/__pycache__/app.cpython-310.pyc and b/__pycache__/app.cpython-310.pyc differ
|
|
|
app.py
CHANGED
|
@@ -1,30 +1,17 @@
|
|
| 1 |
-
"""Epicure Explorer
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
-
|
| 5 |
-
-
|
| 6 |
-
-
|
| 7 |
-
-
|
| 8 |
-
- Mode atlas (click row -> highlight on UMAP)
|
| 9 |
-
- Compare siblings (one query, three columns)
|
| 10 |
-
- UMAP visualisation (2D / 3D)
|
| 11 |
-
- Parse my fridge (free-text -> canonical vocab via rapidfuzz)
|
| 12 |
-
- Recipe builder (hybrid retrieval: rapidfuzz + sentence-transformers over mode labels)
|
| 13 |
-
- Saved queries (per-browser persistence via gr.BrowserState)
|
| 14 |
-
- Public developer API (gr.api endpoints for neighbours / slerp / arithmetic / embed)
|
| 15 |
-
- Food-group filter on every ingredient dropdown
|
| 16 |
|
| 17 |
Paper: https://arxiv.org/abs/2605.22391
|
| 18 |
"""
|
| 19 |
|
| 20 |
from __future__ import annotations
|
| 21 |
|
| 22 |
-
import os
|
| 23 |
-
import re
|
| 24 |
-
import sys
|
| 25 |
-
import json
|
| 26 |
-
import uuid
|
| 27 |
-
from datetime import datetime, timezone
|
| 28 |
from functools import lru_cache
|
| 29 |
|
| 30 |
import numpy as np
|
|
@@ -45,25 +32,19 @@ except ImportError:
|
|
| 45 |
from rapidfuzz import process as fuzz_process, fuzz as fuzz_scorers
|
| 46 |
|
| 47 |
# ===== Kaikaku brand =====
|
| 48 |
-
KAIKAKU_DARK
|
| 49 |
-
KAIKAKU_DEEP
|
| 50 |
-
KAIKAKU_MID
|
| 51 |
-
KAIKAKU_EDGE
|
| 52 |
-
KAIKAKU_ACCENT
|
| 53 |
KAIKAKU_ACCENT_HOVER = "#1E6E5F"
|
| 54 |
KAIKAKU_ACCENT_LIGHT = "#A8D5CA"
|
| 55 |
-
KAIKAKU_TEXT = "#0F2D2F"
|
| 56 |
-
KAIKAKU_MUTED = "#5A7878"
|
| 57 |
|
| 58 |
plt.rcParams.update({
|
| 59 |
-
"figure.facecolor": "#ffffff",
|
| 60 |
-
"axes.
|
| 61 |
-
"
|
| 62 |
-
"
|
| 63 |
-
"xtick.color": "#333333",
|
| 64 |
-
"ytick.color": "#333333",
|
| 65 |
-
"text.color": "#111111",
|
| 66 |
-
"savefig.facecolor": "#ffffff",
|
| 67 |
})
|
| 68 |
|
| 69 |
MODELS = {
|
|
@@ -77,33 +58,25 @@ _HERE = os.path.dirname(os.path.abspath(__file__))
|
|
| 77 |
UMAP_DATA = np.load(os.path.join(_HERE, "umap_2d.npz"))
|
| 78 |
_lab = json.load(open(os.path.join(_HERE, "ingredient_labels.json")))
|
| 79 |
NAMES_BY_IDX: list[str] = _lab["names"]
|
| 80 |
-
FOOD_GROUPS:
|
| 81 |
|
| 82 |
FG_COLORS = {
|
| 83 |
-
"Vegetable":
|
| 84 |
-
"
|
| 85 |
-
"Grain": "#bcbd22",
|
| 86 |
-
"Dairy": "#17becf",
|
| 87 |
-
"Spice": "#d62728",
|
| 88 |
-
"Pantry": "#ff7f0e",
|
| 89 |
-
"Beverage": "#9467bd",
|
| 90 |
-
"Other": "#cccccc",
|
| 91 |
}
|
| 92 |
|
| 93 |
print(f"[epicure-explorer] models loaded: {list(MODELS)}", flush=True)
|
| 94 |
-
print(f"[epicure-explorer] food group labels: {len(FOOD_GROUPS)} ingredients", flush=True)
|
| 95 |
-
|
| 96 |
-
# ===== Feature 5: food-group filter helpers =====
|
| 97 |
|
| 98 |
-
|
| 99 |
-
|
|
|
|
| 100 |
|
| 101 |
-
def _choices_for_group(group
|
| 102 |
if not group or group == "All":
|
| 103 |
return ALL_INGREDIENTS
|
| 104 |
return sorted(n for n in ALL_INGREDIENTS if _NAME_TO_GROUP.get(n, "Other") == group)
|
| 105 |
|
| 106 |
-
def _filter_dropdown(group
|
| 107 |
new_choices = _choices_for_group(group)
|
| 108 |
allowed = set(new_choices)
|
| 109 |
cur = current_value or []
|
|
@@ -113,7 +86,7 @@ def _filter_dropdown(group: str, current_value):
|
|
| 113 |
kept = [v for v in cur if v in allowed]
|
| 114 |
return gr.Dropdown(choices=new_choices, value=kept)
|
| 115 |
|
| 116 |
-
# ===== math
|
| 117 |
|
| 118 |
def _unit(v, eps=1e-9):
|
| 119 |
n = np.linalg.norm(v); return v / max(n, eps)
|
|
@@ -155,87 +128,29 @@ def _slerp(v, d, theta_deg):
|
|
| 155 |
if n < 1e-9: return v
|
| 156 |
d_perp = d_perp / n
|
| 157 |
th = np.deg2rad(float(theta_deg))
|
| 158 |
-
return _unit(np.cos(th)*v + np.sin(th)*d_perp)
|
| 159 |
-
|
| 160 |
-
# ===== Feature 4: explainer helpers =====
|
| 161 |
-
|
| 162 |
-
def _fmt_nb_inline(pairs):
|
| 163 |
-
return ", ".join(f"{n} ({s:+.2f})" for n, s in pairs)
|
| 164 |
|
| 165 |
-
|
| 166 |
-
if v is None or d is None or q is None:
|
| 167 |
-
return "_(no rotation applied)_"
|
| 168 |
-
cos_theta = float(q @ v)
|
| 169 |
-
travelled = min(max(float(theta) / 90.0, 0.0), 1.0)
|
| 170 |
-
dir_nb = _topk(m, _unit(d), k=5, exclude=basket or [])
|
| 171 |
-
seed_nb = _topk(m, v, k=3, exclude=basket or [])
|
| 172 |
-
dir_names = ", ".join(n for n, _ in dir_nb[:3])
|
| 173 |
-
label = "direction pole" if kind == "supervised" else "factor-mode pole"
|
| 174 |
-
dirs_str = " + ".join(direction_keys) if direction_keys else "(none)"
|
| 175 |
-
return (
|
| 176 |
-
f"**Why these results** \n"
|
| 177 |
-
f"- Rotated query vs. seed centroid: cos = {cos_theta:.3f} (theta = {float(theta):.0f}掳; "
|
| 178 |
-
f"{travelled*100:.0f}% of the way to the {label}). \n"
|
| 179 |
-
f"- {label.capitalize()} ({dirs_str}) nearest in vocab: {_fmt_nb_inline(dir_nb)}. \n"
|
| 180 |
-
f"- Seed basket's own top-3 (baseline): {_fmt_nb_inline(seed_nb)}. \n"
|
| 181 |
-
f"- At {float(theta):.0f}掳 the query lands near: {dir_names}."
|
| 182 |
-
)
|
| 183 |
-
|
| 184 |
-
def _arithmetic_explainer(m, positives, negatives, q, pos_v, neg_v):
|
| 185 |
-
if q is None:
|
| 186 |
-
return "_(no result: missing positives)_"
|
| 187 |
-
pos_sims = [(n, float(_unit(m.E[m.vocab[n]]) @ q)) for n in (positives or []) if n in m.vocab]
|
| 188 |
-
neg_sims = [(n, float(_unit(m.E[m.vocab[n]]) @ q)) for n in (negatives or []) if n in m.vocab]
|
| 189 |
-
top = _topk(m, q, k=1, exclude=(positives or []) + (negatives or []))
|
| 190 |
-
top_name, top_sim = top[0] if top else ("(none)", 0.0)
|
| 191 |
-
pos_part = ", ".join(f"{n} ({s:+.2f})" for n, s in pos_sims) or "(none)"
|
| 192 |
-
neg_part = ", ".join(f"{n} ({s:+.2f})" for n, s in neg_sims) or "(none)"
|
| 193 |
-
input_max = max((s for _, s in pos_sims + neg_sims), default=0.0)
|
| 194 |
-
if pos_sims or neg_sims:
|
| 195 |
-
gap = top_sim - input_max
|
| 196 |
-
if gap > 0.05:
|
| 197 |
-
interp = (f"Result sits closer to **{top_name}** ({top_sim:+.2f}) "
|
| 198 |
-
f"than to any input (max {input_max:+.2f}); the embedding separates these concepts.")
|
| 199 |
-
else:
|
| 200 |
-
interp = (f"Result is dominated by the inputs themselves "
|
| 201 |
-
f"(top neighbour {top_name} only {gap:+.2f} above max input cosine).")
|
| 202 |
-
else:
|
| 203 |
-
interp = f"Result top neighbour: {top_name} ({top_sim:+.2f})."
|
| 204 |
-
return (
|
| 205 |
-
f"**Why these results** \n"
|
| 206 |
-
f"- Result vs. positives: {pos_part}. \n"
|
| 207 |
-
f"- Result vs. negatives: {neg_part}. \n"
|
| 208 |
-
f"- {interp}"
|
| 209 |
-
)
|
| 210 |
-
|
| 211 |
-
# ===== heatmap =====
|
| 212 |
|
| 213 |
def _basket_heatmap(m, basket):
|
| 214 |
valid = [n for n in (basket or []) if n in m.vocab]
|
| 215 |
-
fig, ax = plt.subplots(figsize=(
|
| 216 |
if len(valid) < 2:
|
| 217 |
ax.text(0.5, 0.5, "Add 2+ ingredients to see pairwise cosines",
|
| 218 |
-
ha="center", va="center", fontsize=
|
| 219 |
-
|
| 220 |
-
ax.axis("off")
|
| 221 |
-
plt.tight_layout()
|
| 222 |
-
return fig
|
| 223 |
idxs = [m.vocab[n] for n in valid]
|
| 224 |
sub = m.E[idxs]
|
| 225 |
sim = sub @ sub.T
|
| 226 |
im = ax.imshow(sim, cmap="viridis", vmin=-0.2, vmax=1.0, aspect="auto")
|
| 227 |
-
ax.set_xticks(range(len(valid)))
|
| 228 |
-
ax.
|
| 229 |
-
ax.set_xticklabels(valid, rotation=35, ha="right")
|
| 230 |
-
ax.set_yticklabels(valid)
|
| 231 |
for i in range(len(valid)):
|
| 232 |
for j in range(len(valid)):
|
| 233 |
v = float(sim[i, j])
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
cb = plt.colorbar(im, ax=ax)
|
| 237 |
-
cb.set_label("cosine")
|
| 238 |
-
ax.set_title("Pairwise cosine within the basket", fontsize=12)
|
| 239 |
plt.tight_layout()
|
| 240 |
return fig
|
| 241 |
|
|
@@ -248,7 +163,7 @@ def _umap_coords(sibling, three_d):
|
|
| 248 |
m = MODELS[sibling]
|
| 249 |
E = m.E - m.E.mean(axis=0, keepdims=True)
|
| 250 |
_, _, Vt = np.linalg.svd(E, full_matrices=False)
|
| 251 |
-
pc1 =
|
| 252 |
pc1 = (pc1 - pc1.mean()) / (pc1.std() + 1e-9)
|
| 253 |
scale = (base.max() - base.min()) * 0.25
|
| 254 |
return base, (pc1 * scale).astype(np.float32)
|
|
@@ -261,169 +176,224 @@ def umap_view(sibling, basket, show_neighbours, k, three_d=False):
|
|
| 261 |
hover_text = [f"{NAMES_BY_IDX[i]}<br>group: {FOOD_GROUPS[i]}" for i in range(n)]
|
| 262 |
basket_set = set(basket or [])
|
| 263 |
basket_idxs = [m.vocab[b] for b in (basket or []) if b in m.vocab]
|
| 264 |
-
neighbour_set
|
| 265 |
if show_neighbours and basket_idxs:
|
| 266 |
centroid = _basket_centroid(m, basket)
|
| 267 |
if centroid is not None:
|
| 268 |
-
|
| 269 |
-
neighbour_set = {nm for nm, _ in
|
| 270 |
-
|
| 271 |
-
bg_x = [float(coords2[i, 0]) for i in range(n) if
|
| 272 |
-
bg_y = [float(coords2[i, 1]) for i in range(n) if
|
| 273 |
-
bg_z = [float(z[i]) for i in range(n) if
|
| 274 |
-
bg_c = [colors[i] for i in range(n) if
|
| 275 |
-
bg_h = [hover_text[i] for i in range(n) if
|
| 276 |
fig = go.Figure()
|
| 277 |
if three_d:
|
| 278 |
-
fig.add_trace(go.Scatter3d(
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
text=bg_h, hovertemplate="%{text}<extra></extra>", name="ingredients", showlegend=False,
|
| 282 |
-
))
|
| 283 |
else:
|
| 284 |
-
fig.add_trace(go.Scattergl(
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
text=bg_h, hovertemplate="%{text}<extra></extra>", name="ingredients", showlegend=False,
|
| 288 |
-
))
|
| 289 |
if neighbour_set:
|
| 290 |
ni = [i for i in range(n) if NAMES_BY_IDX[i] in neighbour_set]
|
| 291 |
-
nx = [float(coords2[i, 0]) for i in ni]
|
| 292 |
-
ny = [float(coords2[i, 1]) for i in ni]
|
| 293 |
nz = [float(z[i]) for i in ni] if three_d else None
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
TR = go.Scatter3d if three_d else go.Scatter
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
fig.add_trace(TR(x=nx, y=ny, z=nz, **
|
| 303 |
if basket_idxs:
|
| 304 |
bx = [float(coords2[i, 0]) for i in basket_idxs]
|
| 305 |
by = [float(coords2[i, 1]) for i in basket_idxs]
|
| 306 |
bz = [float(z[i]) for i in basket_idxs] if three_d else None
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
TR = go.Scatter3d if three_d else go.Scatter
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
fig.add_trace(TR(x=bx, y=by, z=bz, **
|
| 316 |
-
title_suffix = " (3D)" if three_d else ""
|
| 317 |
fig.update_layout(
|
| 318 |
-
title=dict(text=f"UMAP
|
| 319 |
-
height=
|
| 320 |
paper_bgcolor="#ffffff", plot_bgcolor="#ffffff",
|
| 321 |
legend=dict(orientation="v", x=1.02, y=1, font=dict(size=11)),
|
| 322 |
)
|
| 323 |
if not three_d:
|
| 324 |
-
fig.update_xaxes(showgrid=True, gridcolor="#
|
| 325 |
-
fig.update_yaxes(showgrid=True, gridcolor="#
|
| 326 |
else:
|
| 327 |
fig.update_layout(scene=dict(xaxis=dict(title="UMAP 1"), yaxis=dict(title="UMAP 2"),
|
| 328 |
zaxis=dict(title="PC1 (z)"), bgcolor="#ffffff"))
|
| 329 |
return fig
|
| 330 |
|
| 331 |
-
# =====
|
| 332 |
|
| 333 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
m = MODELS[sibling]
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
return [],
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
return (
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
|
|
|
| 346 |
)
|
| 347 |
|
| 348 |
-
def
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
if
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
return [[n, f"{s:.4f}"] for n, s in _topk(m, v, k, basket)], "_(no direction selected)_"
|
| 356 |
-
q = _slerp(v, d, theta)
|
| 357 |
-
rows = [[n, f"{s:.4f}"] for n, s in _topk(m, q, k, basket)]
|
| 358 |
-
return rows, _slerp_explainer(m, basket, directions or [], theta, q, v, d, "supervised")
|
| 359 |
-
|
| 360 |
-
def emergent_slerp_multi(sibling, basket, mode_labels, theta, k):
|
| 361 |
-
m = MODELS[sibling]
|
| 362 |
-
label_to_id = {f"{mode.label} ({mode.mode_id})": mode.mode_id for mode in m.modes if mode.kind == "factor"}
|
| 363 |
-
mode_ids = [label_to_id[lab] for lab in (mode_labels or []) if lab in label_to_id]
|
| 364 |
-
v = _basket_centroid(m, basket)
|
| 365 |
-
if v is None:
|
| 366 |
-
return [], "_(empty basket)_"
|
| 367 |
-
d = _stack_directions(m, mode_ids, use_factor_pole=True)
|
| 368 |
-
if d is None:
|
| 369 |
-
return [[n, f"{s:.4f}"] for n, s in _topk(m, v, k, basket)], "_(no factor mode selected)_"
|
| 370 |
-
q = _slerp(v, d, theta)
|
| 371 |
-
rows = [[n, f"{s:.4f}"] for n, s in _topk(m, q, k, basket)]
|
| 372 |
-
return rows, _slerp_explainer(m, basket, mode_ids, theta, q, v, d, "emergent")
|
| 373 |
-
|
| 374 |
-
def arithmetic(sibling, positives, negatives, k):
|
| 375 |
-
m = MODELS[sibling]
|
| 376 |
-
pos = _basket_centroid(m, positives)
|
| 377 |
-
if pos is None:
|
| 378 |
-
return [], "_(no positives provided)_"
|
| 379 |
-
neg = _basket_centroid(m, negatives) if negatives else None
|
| 380 |
-
q = _unit(pos - neg) if neg is not None else pos
|
| 381 |
-
rows = [[n, f"{s:.4f}"] for n, s in _topk(m, q, k, (positives or []) + (negatives or []))]
|
| 382 |
-
return rows, _arithmetic_explainer(m, positives or [], negatives or [], q, pos, neg)
|
| 383 |
|
| 384 |
-
|
| 385 |
-
m = MODELS[sibling]
|
| 386 |
-
rows, q = [], (query or "").strip().lower()
|
| 387 |
-
for mode in m.modes:
|
| 388 |
-
if kind_filter != "all" and mode.kind != kind_filter:
|
| 389 |
-
continue
|
| 390 |
-
if q and q not in mode.label.lower() and q not in mode.property.lower():
|
| 391 |
-
continue
|
| 392 |
-
rows.append([mode.mode_id, mode.kind, mode.property, mode.label, mode.n_members,
|
| 393 |
-
", ".join(mode.members[:12])])
|
| 394 |
-
rows.sort(key=lambda r: (r[1], -r[4]))
|
| 395 |
-
return rows
|
| 396 |
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
|
| 413 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
|
| 415 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
_ST = None
|
| 417 |
def _get_st():
|
| 418 |
global _ST
|
| 419 |
if _ST is None:
|
| 420 |
-
print(f"[epicure-explorer] loading {_ST_MODEL_NAME} (first call, ~80MB)", flush=True)
|
| 421 |
from sentence_transformers import SentenceTransformer
|
| 422 |
-
_ST = SentenceTransformer(
|
| 423 |
return _ST
|
| 424 |
|
| 425 |
@lru_cache(maxsize=4)
|
| 426 |
-
def _mode_label_matrix(sibling
|
| 427 |
m = MODELS[sibling]
|
| 428 |
modes = [md for md in m.modes if md.kind == "factor"]
|
| 429 |
if not modes:
|
|
@@ -438,21 +408,18 @@ def _mode_quartile(mode):
|
|
| 438 |
n = max(4, min(12, (len(members) + 3) // 4))
|
| 439 |
return members[:n]
|
| 440 |
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
}
|
| 447 |
-
_TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z\-']{1,}")
|
| 448 |
|
| 449 |
-
def suggest_basket(prompt, sibling, k=10):
|
| 450 |
if not prompt or not prompt.strip():
|
| 451 |
-
return [], [], "
|
| 452 |
vocab = list(MODELS[sibling].vocab.keys())
|
| 453 |
-
vocab_sp = [v.replace("_",
|
| 454 |
-
|
| 455 |
-
tokens = [t for t in raw_tokens if t not in _PROMPT_STOPWORDS and len(t) > 2]
|
| 456 |
direct = {}
|
| 457 |
direct_evidence = []
|
| 458 |
for tok in tokens:
|
|
@@ -476,252 +443,92 @@ def suggest_basket(prompt, sibling, k=10):
|
|
| 476 |
for mid, lab, sim in picked:
|
| 477 |
for name in _mode_quartile(id_to_mode[mid]):
|
| 478 |
s_existing, _ = thematic.get(name, (0.0, ""))
|
| 479 |
-
|
| 480 |
-
thematic[name] = (s_new, lab)
|
| 481 |
combined = {}
|
| 482 |
-
for name, sc in direct.items():
|
| 483 |
-
combined[name] = (sc, "direct")
|
| 484 |
for name, (sc, lab) in thematic.items():
|
| 485 |
prev = combined.get(name)
|
| 486 |
if prev is None or sc > prev[0]:
|
| 487 |
-
|
| 488 |
-
combined[name] = (sc, tag)
|
| 489 |
ranked = sorted(combined.items(),
|
| 490 |
key=lambda kv: (-kv[1][0], 0 if kv[1][1] != "thematic" else 1, kv[0]))[:int(k)]
|
| 491 |
rows = [[name, src, round(score, 1)] for name, (score, src) in ranked]
|
| 492 |
names = [name for name, _ in ranked]
|
| 493 |
lines = []
|
| 494 |
if direct_evidence:
|
| 495 |
-
|
| 496 |
-
lines.append(f"**Direct mentions:** {dm}")
|
| 497 |
-
else:
|
| 498 |
-
lines.append("**Direct mentions:** _none cleared score threshold_")
|
| 499 |
if thematic_modes:
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
for mid, lab, sim in thematic_modes:
|
| 503 |
-
sample = ", ".join(id_to_mode[mid].members[:4])
|
| 504 |
-
bits.append(f"`{lab}` (cos {sim:.2f}; e.g. {sample})")
|
| 505 |
-
lines.append("**Matched factor modes:** " + "; ".join(bits))
|
| 506 |
-
else:
|
| 507 |
-
lines.append("**Matched factor modes:** _no mode label cleared cosine 0.25_")
|
| 508 |
-
return rows, names, "\n\n".join(lines)
|
| 509 |
-
|
| 510 |
-
# ===== fridge parser =====
|
| 511 |
-
|
| 512 |
-
_LINE_SPLIT = re.compile(r"[\n;]")
|
| 513 |
-
_BRACKET = re.compile(r"\([^)]*\)")
|
| 514 |
-
_QTY = (r"(?:\d+(?:[\.,/]\d+)?|a|an|one|two|three|four|five|six|seven|eight|nine|ten|half|quarter)")
|
| 515 |
-
_UNIT = (r"(?:cups?|tbsp\.?|tablespoons?|tsp\.?|teaspoons?|oz\.?|ounces?|lbs?\.?|pounds?|"
|
| 516 |
-
r"grams?|kgs?|kilos?|ml|liters?|litres?|cloves?|bunches?|sprigs?|pinch(?:es)?|"
|
| 517 |
-
r"slices?|pieces?|cans?|packets?|sticks?|leaves?|stalks?|heads?|inch(?:es)?|"
|
| 518 |
-
r"splash(?:es)?|dash(?:es)?|drops?|handfuls?|large|small|medium)")
|
| 519 |
-
_LEADING_QTY = re.compile(rf"^\s*{_QTY}\s+(?:{_UNIT}\b\s*)?(?:of\s+)?", re.IGNORECASE)
|
| 520 |
-
_LEADING_UNIT_ONLY = re.compile(rf"^\s*{_UNIT}\b\s*(?:of\s+)?", re.IGNORECASE)
|
| 521 |
-
_JUICE_OF = re.compile(rf"^\s*(?:juice|zest)\s+(?:of\s+)?(?:{_QTY}\s+)?", re.IGNORECASE)
|
| 522 |
-
_LEADING_PREP = re.compile(
|
| 523 |
-
r"^\s*(?:fresh|dried|cooked|frozen|raw|ripe|firm|boneless|skinless|smoked|low[- ]fat)\s+",
|
| 524 |
-
re.IGNORECASE)
|
| 525 |
-
_TRAILING_PREP = re.compile(
|
| 526 |
-
r"\s*,\s*(?:chopped|minced|diced|sliced|grated|crushed|whole|ground|peeled|"
|
| 527 |
-
r"to taste|optional|finely|coarsely|cubed|shredded|julienned|halved|quartered|warmed|"
|
| 528 |
-
r"toasted|roasted|bruised|melted|softened|cooked|drained|rinsed|patted dry|trimmed|"
|
| 529 |
-
r"deveined|seeded|stemmed|crumbled).*$", re.IGNORECASE)
|
| 530 |
-
_KNOWN_PLURALS = {"tortillas":"tortilla","thighs":"thigh","leaves":"leaf","onions":"onion",
|
| 531 |
-
"potatoes":"potato","tomatoes":"tomato","cloves":"clove"}
|
| 532 |
|
| 533 |
-
def
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
if
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
s = _LEADING_PREP.sub("", s)
|
| 542 |
-
s = _LEADING_PREP.sub("", s)
|
| 543 |
-
tokens = [_KNOWN_PLURALS.get(t, t) for t in s.split()]
|
| 544 |
-
return re.sub(r"\s+", " ", " ".join(tokens)).strip()
|
| 545 |
|
| 546 |
-
|
| 547 |
-
if not cleaned: return None, 0.0
|
| 548 |
-
candidates = []
|
| 549 |
-
for scorer in (fuzz_scorers.token_set_ratio, fuzz_scorers.WRatio, fuzz_scorers.partial_ratio):
|
| 550 |
-
hits = fuzz_process.extract(cleaned, vocab_sp, scorer=scorer, score_cutoff=min_score, limit=10)
|
| 551 |
-
for _name_sp, score, idx in hits:
|
| 552 |
-
candidates.append((vocab[idx], float(score)))
|
| 553 |
-
if not candidates: return None, 0.0
|
| 554 |
-
cleaned_tokens = set(cleaned.split())
|
| 555 |
-
def rank_key(c):
|
| 556 |
-
name, score = c
|
| 557 |
-
nt = set(name.replace("_"," ").split())
|
| 558 |
-
return (-score, 0 if nt.issubset(cleaned_tokens) else 1, -len(name))
|
| 559 |
-
candidates.sort(key=rank_key)
|
| 560 |
-
return candidates[0]
|
| 561 |
|
| 562 |
-
def
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
if not
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
tokens = cleaned.split()
|
| 575 |
-
if len(tokens) > 1:
|
| 576 |
-
match, score = _fuzzy_lookup(" ".join(tokens[:-1]), vocab, vocab_sp, int(min_score))
|
| 577 |
-
if match is None:
|
| 578 |
-
rows.append([line.strip(), "(no match)", 0.0, cleaned]); continue
|
| 579 |
-
rows.append([line.strip(), match, round(score, 1), cleaned])
|
| 580 |
-
matched.append(match)
|
| 581 |
-
seen, dedup = set(), []
|
| 582 |
-
for n in matched:
|
| 583 |
-
if n not in seen: seen.add(n); dedup.append(n)
|
| 584 |
-
return rows, dedup
|
| 585 |
|
| 586 |
-
# =====
|
| 587 |
|
| 588 |
-
def _suggest(name
|
| 589 |
vocab = list(MODELS[sibling].vocab.keys())
|
| 590 |
hits = fuzz_process.extract((name or "").lower().replace(" ", "_"),
|
| 591 |
vocab, scorer=fuzz_scorers.WRatio, limit=n)
|
| 592 |
return [h[0] for h in hits]
|
| 593 |
|
| 594 |
-
def _validate_sibling(sibling):
|
| 595 |
-
if sibling not in MODELS:
|
| 596 |
-
return {"error": f"sibling '{sibling}' not in {{cooc, core, chem}}",
|
| 597 |
-
"suggestions": ["cooc","core","chem"]}
|
| 598 |
-
return None
|
| 599 |
-
|
| 600 |
-
def _validate_ingredient(name, sibling, field="ingredient"):
|
| 601 |
-
if not isinstance(name, str) or not name:
|
| 602 |
-
return {"error": f"{field} must be a non-empty string"}
|
| 603 |
-
if name not in MODELS[sibling].vocab:
|
| 604 |
-
return {"error": f"{field} '{name}' not in vocab",
|
| 605 |
-
"suggestions": _suggest(name, sibling)}
|
| 606 |
-
return None
|
| 607 |
-
|
| 608 |
def api_neighbors(ingredient, sibling="chem", k=5):
|
| 609 |
-
|
| 610 |
-
if
|
| 611 |
m = MODELS[sibling]
|
| 612 |
q = _unit(m.E[m.vocab[ingredient]])
|
| 613 |
-
|
| 614 |
-
return [{"name": n, "cosine": round(float(s), 6)} for n, s in pairs]
|
| 615 |
|
| 616 |
def api_slerp(seed, direction, theta_deg=30, sibling="chem", k=5):
|
| 617 |
-
|
| 618 |
-
if err: return err
|
| 619 |
m = MODELS[sibling]
|
| 620 |
-
if
|
| 621 |
-
|
| 622 |
-
"suggestions": sorted(m.supervised_poles.keys())[:10]}
|
| 623 |
v = _unit(m.E[m.vocab[seed]])
|
| 624 |
d = _unit(m.supervised_poles[direction])
|
| 625 |
q = _slerp(v, d, float(theta_deg))
|
| 626 |
-
|
| 627 |
-
return [{"name": n, "cosine": round(float(s), 6)} for n, s in pairs]
|
| 628 |
|
| 629 |
def api_arithmetic(positives, negatives, sibling="chem", k=5):
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
positives
|
| 633 |
-
negatives = list(negatives or [])
|
| 634 |
-
if not positives:
|
| 635 |
-
return {"error": "positives must be a non-empty list"}
|
| 636 |
m = MODELS[sibling]
|
| 637 |
unknown = [x for x in positives + negatives if x not in m.vocab]
|
| 638 |
-
if unknown:
|
| 639 |
-
return {"error": f"unknown ingredients: {unknown}",
|
| 640 |
-
"suggestions": {x: _suggest(x, sibling) for x in unknown}}
|
| 641 |
pos = _basket_centroid(m, positives)
|
| 642 |
neg = _basket_centroid(m, negatives) if negatives else None
|
| 643 |
q = _unit(pos - neg) if neg is not None else pos
|
| 644 |
-
|
| 645 |
-
return [{"name": n, "cosine": round(float(s), 6)} for n, s in pairs]
|
| 646 |
|
| 647 |
def api_embed(ingredient, sibling="chem"):
|
| 648 |
-
|
| 649 |
-
if err: return err
|
| 650 |
m = MODELS[sibling]
|
| 651 |
-
|
| 652 |
-
return [float(x) for x in
|
| 653 |
|
| 654 |
-
|
| 655 |
-
err = _validate_sibling(sibling)
|
| 656 |
-
if err: return err
|
| 657 |
-
return sorted(MODELS[sibling].supervised_poles.keys())
|
| 658 |
-
|
| 659 |
-
def api_list_factor_modes(sibling="chem"):
|
| 660 |
-
err = _validate_sibling(sibling)
|
| 661 |
-
if err: return err
|
| 662 |
-
return [{"mode_id": mode.mode_id, "label": str(mode.label),
|
| 663 |
-
"kind": str(mode.kind), "property": str(mode.property),
|
| 664 |
-
"n_members": int(mode.n_members)}
|
| 665 |
-
for mode in MODELS[sibling].modes if mode.kind == "factor"]
|
| 666 |
-
|
| 667 |
-
# ===== Feature 9: saved queries helpers =====
|
| 668 |
-
|
| 669 |
-
TAB_IDS = {
|
| 670 |
-
"basket": "tab_basket",
|
| 671 |
-
"supervised_slerp": "tab_sup",
|
| 672 |
-
"emergent_slerp": "tab_em",
|
| 673 |
-
"arithmetic": "tab_ar",
|
| 674 |
-
"compare": "tab_cmp",
|
| 675 |
-
}
|
| 676 |
-
TAB_LABELS = {
|
| 677 |
-
"basket": "Basket pairings",
|
| 678 |
-
"supervised_slerp": "Supervised SLERP",
|
| 679 |
-
"emergent_slerp": "Emergent SLERP",
|
| 680 |
-
"arithmetic": "Arithmetic",
|
| 681 |
-
"compare": "Compare siblings",
|
| 682 |
-
}
|
| 683 |
-
|
| 684 |
-
def _summarise(tab, inputs):
|
| 685 |
-
sib = inputs.get("sibling", "")
|
| 686 |
-
if tab == "basket":
|
| 687 |
-
return f"[{sib}] basket: {', '.join(inputs.get('basket', [])[:3])} k={inputs.get('k')}"
|
| 688 |
-
if tab == "supervised_slerp":
|
| 689 |
-
b = ", ".join(inputs.get("basket", [])[:2])
|
| 690 |
-
d = ", ".join(inputs.get("directions", [])[:2])
|
| 691 |
-
return f"[{sib}] {b} +{inputs.get('theta')}掳 -> {d}"
|
| 692 |
-
if tab == "emergent_slerp":
|
| 693 |
-
b = ", ".join(inputs.get("basket", [])[:2])
|
| 694 |
-
return f"[{sib}] {b} +{inputs.get('theta')}掳 -> {len(inputs.get('modes', []))} factor modes"
|
| 695 |
-
if tab == "arithmetic":
|
| 696 |
-
p = " + ".join(inputs.get("positives", [])[:2])
|
| 697 |
-
n = " + ".join(inputs.get("negatives", [])[:2])
|
| 698 |
-
return f"[{sib}] {p}" + (f" - {n}" if n else "")
|
| 699 |
-
if tab == "compare":
|
| 700 |
-
return f"[3 siblings] {', '.join(inputs.get('basket', [])[:2])} +{inputs.get('theta')}掳"
|
| 701 |
-
return "(unknown)"
|
| 702 |
-
|
| 703 |
-
def save_query(saved, tab, inputs_dict):
|
| 704 |
-
saved = list(saved or [])
|
| 705 |
-
rec = {
|
| 706 |
-
"id": str(uuid.uuid4()),
|
| 707 |
-
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
| 708 |
-
"tab": tab,
|
| 709 |
-
"inputs": inputs_dict,
|
| 710 |
-
"summary": _summarise(tab, inputs_dict),
|
| 711 |
-
}
|
| 712 |
-
saved.insert(0, rec)
|
| 713 |
-
saved = saved[:200]
|
| 714 |
-
return saved, _render_saved(saved)
|
| 715 |
-
|
| 716 |
-
def delete_query(saved, qid):
|
| 717 |
-
saved = [q for q in (saved or []) if q.get("id") != qid]
|
| 718 |
-
return saved, _render_saved(saved)
|
| 719 |
-
|
| 720 |
-
def _render_saved(saved):
|
| 721 |
-
return [[q["created_at"], TAB_LABELS.get(q["tab"], q["tab"]), q["summary"], q["id"]]
|
| 722 |
-
for q in (saved or [])]
|
| 723 |
-
|
| 724 |
-
# ===== Theme + CSS =====
|
| 725 |
|
| 726 |
THEME = gr.themes.Soft(
|
| 727 |
primary_hue=gr.themes.Color(
|
|
@@ -733,18 +540,14 @@ THEME = gr.themes.Soft(
|
|
| 733 |
neutral_hue="slate",
|
| 734 |
font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
|
| 735 |
).set(
|
| 736 |
-
block_label_text_color="#1f2937",
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
block_title_text_weight="700",
|
| 740 |
-
body_text_color="#0f172a",
|
| 741 |
-
body_text_color_subdued="#475569",
|
| 742 |
button_primary_background_fill=KAIKAKU_ACCENT,
|
| 743 |
button_primary_background_fill_hover=KAIKAKU_ACCENT_HOVER,
|
| 744 |
button_primary_text_color="#ffffff",
|
| 745 |
button_primary_border_color=KAIKAKU_ACCENT,
|
| 746 |
button_secondary_background_fill="#f1f5f9",
|
| 747 |
-
button_secondary_background_fill_hover="#e2e8f0",
|
| 748 |
button_secondary_text_color=KAIKAKU_DARK,
|
| 749 |
slider_color=KAIKAKU_ACCENT,
|
| 750 |
color_accent=KAIKAKU_ACCENT,
|
|
@@ -754,8 +557,7 @@ CUSTOM_CSS = f"""
|
|
| 754 |
.gradio-container {{max-width: 1280px !important;}}
|
| 755 |
footer {{visibility: hidden;}}
|
| 756 |
.gradio-container label, .gradio-container .label,
|
| 757 |
-
.gradio-container [data-testid="block-label"],
|
| 758 |
-
.gradio-container .block-label, .gradio-container .gr-block-label {{
|
| 759 |
color: #0f172a !important; font-weight: 600 !important; background: transparent !important;
|
| 760 |
}}
|
| 761 |
.gradio-container button[role="tab"] {{ color: #334155 !important; font-weight: 500 !important; }}
|
|
@@ -766,428 +568,255 @@ footer {{visibility: hidden;}}
|
|
| 766 |
background: {KAIKAKU_ACCENT} !important; color: #ffffff !important;
|
| 767 |
border-color: {KAIKAKU_ACCENT} !important; font-weight: 600 !important;
|
| 768 |
}}
|
| 769 |
-
.gradio-container
|
| 770 |
-
.gradio-container table thead th, .gradio-container .gr-dataframe thead th {{
|
| 771 |
color: #0f172a !important; font-weight: 700 !important; background: #f8fafc !important;
|
| 772 |
}}
|
| 773 |
.gradio-container table tbody td {{ color: #0f172a !important; }}
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
|
|
|
|
|
|
|
|
|
| 777 |
}}
|
| 778 |
-
.
|
| 779 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 780 |
"""
|
| 781 |
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
<div class="
|
| 787 |
-
<
|
| 788 |
-
<div class="
|
| 789 |
-
<
|
| 790 |
-
<div class="
|
| 791 |
-
|
| 792 |
-
<
|
| 793 |
-
</div>
|
| 794 |
-
<div class="
|
| 795 |
-
|
| 796 |
-
|
|
|
|
| 797 |
</div>
|
| 798 |
"""
|
| 799 |
|
| 800 |
-
# =====
|
| 801 |
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
dd = gr.Dropdown(choices=ALL_INGREDIENTS, value=default_value, label=label,
|
| 806 |
-
multiselect=multiselect, max_choices=max_choices)
|
| 807 |
-
radio.change(_filter_dropdown, inputs=[radio, dd], outputs=dd, show_progress="hidden")
|
| 808 |
-
return radio, dd
|
| 809 |
|
| 810 |
# ===== UI =====
|
| 811 |
|
| 812 |
with gr.Blocks(title="Epicure Explorer", theme=THEME, css=CUSTOM_CSS) as demo:
|
| 813 |
|
| 814 |
-
saved_state = gr.BrowserState(default_value=[], storage_key="epicure_saved_queries_v1")
|
| 815 |
-
|
| 816 |
gr.Markdown(
|
| 817 |
"""# Epicure Explorer
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
)
|
|
|
|
| 822 |
|
| 823 |
-
gr.
|
| 824 |
-
|
| 825 |
-
sibling = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling embedding to query")
|
| 826 |
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
# ---------- Tab 1: Basket pairings ----------
|
| 832 |
-
with gr.Tab("Basket pairings", id="tab_basket"):
|
| 833 |
-
gr.Markdown("Pick one or more ingredients. The tool averages their unit vectors and returns nearest neighbours plus closest modes of that centroid.")
|
| 834 |
-
basket_radio, basket = _ingredient_picker("Ingredient basket (pick 1+)", ["chicken","lemon","garlic"])
|
| 835 |
-
k_pair = gr.Slider(1, 15, value=8, step=1, label="K")
|
| 836 |
with gr.Row():
|
| 837 |
-
|
| 838 |
-
|
|
|
|
|
|
|
| 839 |
with gr.Row():
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
gr.Examples(
|
| 846 |
examples=[
|
| 847 |
-
[
|
| 848 |
-
[
|
| 849 |
-
[
|
| 850 |
-
[
|
| 851 |
-
[
|
| 852 |
-
[
|
| 853 |
-
[
|
| 854 |
-
["core", ["coconut_milk","lemongrass","fish_sauce"], 8],
|
| 855 |
],
|
| 856 |
-
inputs=[
|
| 857 |
-
label="Try
|
| 858 |
)
|
| 859 |
|
| 860 |
-
# ---------- Tab 2: Supervised SLERP ----------
|
| 861 |
-
with gr.Tab("Supervised SLERP", id="tab_sup"):
|
| 862 |
-
gr.Markdown("Rotate the seed basket toward one or more supervised pole vectors.")
|
| 863 |
-
sup_radio, sup_basket = _ingredient_picker("Seed basket (pick 1+)", ["rice"])
|
| 864 |
-
sup_dirs = gr.Dropdown(choices=_supervised_choices("chem"), value=["cuisine:South_Asian"],
|
| 865 |
-
label="Supervised directions (pick 1+; summed)",
|
| 866 |
-
multiselect=True, max_choices=5)
|
| 867 |
-
sup_theta = gr.Slider(0, 90, value=30, step=5, label="Rotation angle (deg)")
|
| 868 |
-
sup_k = gr.Slider(1, 15, value=8, step=1, label="K")
|
| 869 |
with gr.Row():
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
["
|
| 882 |
-
|
| 883 |
-
["
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
|
| 888 |
-
|
| 889 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 890 |
)
|
| 891 |
|
| 892 |
-
# ---------- Tab
|
| 893 |
-
with gr.Tab("
|
| 894 |
-
gr.Markdown("Rotate the
|
| 895 |
-
|
| 896 |
-
|
| 897 |
-
|
| 898 |
-
|
| 899 |
-
|
| 900 |
-
|
| 901 |
-
em_k = gr.Slider(1, 15, value=8, step=1, label="K")
|
| 902 |
with gr.Row():
|
| 903 |
-
|
| 904 |
-
|
| 905 |
-
|
| 906 |
-
|
| 907 |
-
em_btn.click(emergent_slerp_multi,
|
| 908 |
-
inputs=[sibling, em_basket, em_modes, em_theta, em_k],
|
| 909 |
-
outputs=[em_table, em_explainer], show_progress="full")
|
| 910 |
-
sibling.change(lambda s: gr.Dropdown(choices=[label for label, _ in _factor_mode_choices(s)], value=[]),
|
| 911 |
-
inputs=sibling, outputs=em_modes)
|
| 912 |
-
|
| 913 |
-
# ---------- Tab 4: Arithmetic ----------
|
| 914 |
-
with gr.Tab("Arithmetic", id="tab_ar"):
|
| 915 |
-
gr.Markdown("Mikolov-style vector arithmetic: `centroid(positives) - centroid(negatives)`, then top-K neighbours. Killer demo: `miso - salt` on Core.")
|
| 916 |
-
pos_radio, pos_box = _ingredient_picker("Positives (added)", ["miso"])
|
| 917 |
-
neg_radio, neg_box = _ingredient_picker("Negatives (subtracted)", ["salt"])
|
| 918 |
-
ar_k = gr.Slider(1, 15, value=8, step=1, label="K")
|
| 919 |
with gr.Row():
|
| 920 |
-
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
|
| 925 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 926 |
gr.Examples(
|
| 927 |
examples=[
|
| 928 |
-
["core", ["miso"], ["salt"], 8],
|
| 929 |
-
["core",
|
| 930 |
-
["
|
| 931 |
-
["chem", ["
|
| 932 |
-
["chem", ["
|
| 933 |
-
["core", ["bread"], ["flour"], 8],
|
| 934 |
-
["core", ["coffee"], ["milk"], 8],
|
| 935 |
-
["chem", ["mozzarella_cheese"], ["milk"], 8],
|
| 936 |
],
|
| 937 |
-
inputs=[
|
| 938 |
-
label="Try one of these
|
| 939 |
)
|
| 940 |
|
| 941 |
-
# ---------- Tab
|
| 942 |
-
with gr.Tab("
|
| 943 |
-
gr.Markdown(
|
| 944 |
-
"Browse the GMM mode atlas. Cooc 150 / Core 193 / Chem 200 modes. "
|
| 945 |
-
"**Click any row** to send that mode's members to the UMAP tab as a basket."
|
| 946 |
-
)
|
| 947 |
-
atlas_kind = gr.Radio(choices=["all","factor","continuous","binary"], value="all", label="Mode kind")
|
| 948 |
-
atlas_search = gr.Textbox(label="Search labels / properties", placeholder="e.g. South Asian, baking, fiber", value="")
|
| 949 |
-
atlas_btn = gr.Button("Browse modes", variant="primary")
|
| 950 |
-
atlas_table = gr.Dataframe(
|
| 951 |
-
headers=["mode_id","kind","property","label","n_members","top members"],
|
| 952 |
-
label="Modes (click a row to highlight on UMAP)",
|
| 953 |
-
wrap=True, interactive=False,
|
| 954 |
-
)
|
| 955 |
-
atlas_btn.click(browse_modes, inputs=[sibling, atlas_kind, atlas_search], outputs=atlas_table, show_progress="full")
|
| 956 |
-
|
| 957 |
-
# ---------- Tab 6: Compare siblings ----------
|
| 958 |
-
with gr.Tab("Compare siblings", id="tab_cmp"):
|
| 959 |
-
gr.Markdown("Same query, three siblings, side by side.")
|
| 960 |
-
cmp_radio, cmp_basket = _ingredient_picker("Seed basket", ["chicken"])
|
| 961 |
-
cmp_dirs = gr.Dropdown(choices=_supervised_choices("chem"), value=[],
|
| 962 |
-
label="Optional directions (empty = pure pairings)",
|
| 963 |
-
multiselect=True, max_choices=5)
|
| 964 |
-
cmp_theta = gr.Slider(0, 90, value=30, step=5, label="Rotation angle (deg)")
|
| 965 |
-
cmp_k = gr.Slider(1, 15, value=8, step=1, label="K")
|
| 966 |
-
with gr.Row():
|
| 967 |
-
cmp_btn = gr.Button("Compare across siblings", variant="primary")
|
| 968 |
-
save_cmp_btn = gr.Button("Save this query", variant="secondary")
|
| 969 |
with gr.Row():
|
| 970 |
-
|
| 971 |
-
|
| 972 |
-
|
| 973 |
-
cmp_btn.click(compare_siblings, inputs=[cmp_basket, cmp_dirs, cmp_theta, cmp_k],
|
| 974 |
-
outputs=[cmp_cooc, cmp_core, cmp_chem], show_progress="full")
|
| 975 |
-
|
| 976 |
-
# ---------- Tab 7: UMAP ----------
|
| 977 |
-
with gr.Tab("UMAP visualisation", id="tab_umap"):
|
| 978 |
-
gr.Markdown(
|
| 979 |
-
"2-D UMAP of the 1,790-ingredient embedding (cosine, n_neighbors=30, min_dist=0.03). "
|
| 980 |
-
"Points coloured by food group. Basket members appear as accent stars; top-K neighbours as amber dots."
|
| 981 |
-
)
|
| 982 |
-
umap_radio, umap_basket = _ingredient_picker("Highlight these ingredients", ["chicken","lemon","garlic"])
|
| 983 |
with gr.Row():
|
| 984 |
-
|
| 985 |
-
|
| 986 |
-
|
| 987 |
-
|
| 988 |
-
|
| 989 |
-
|
| 990 |
-
|
| 991 |
-
|
| 992 |
-
|
| 993 |
-
|
| 994 |
-
|
| 995 |
-
|
| 996 |
-
|
| 997 |
-
|
| 998 |
-
"
|
| 999 |
-
"
|
|
|
|
| 1000 |
)
|
| 1001 |
-
|
| 1002 |
-
|
| 1003 |
-
|
| 1004 |
-
|
| 1005 |
-
"fresh lemongrass, bruised\n3 cloves garlic, minced\n1 inch fresh ginger\n"
|
| 1006 |
-
"juice of one lime\nsalt to taste"),
|
| 1007 |
)
|
| 1008 |
-
fridge_min = gr.Slider(40, 100, value=70, step=5, label="Min match score (rapidfuzz)")
|
| 1009 |
with gr.Row():
|
| 1010 |
-
|
| 1011 |
-
|
| 1012 |
-
|
| 1013 |
-
|
| 1014 |
-
|
| 1015 |
-
)
|
| 1016 |
-
|
| 1017 |
-
|
| 1018 |
-
|
| 1019 |
-
|
| 1020 |
-
fridge_btn.click(_parse, inputs=[fridge_text, sibling, fridge_min],
|
| 1021 |
-
outputs=[fridge_table, fridge_matched, shared_basket], show_progress="full")
|
| 1022 |
-
fridge_send.click(lambda matches: gr.Dropdown(value=matches[:10] if matches else []),
|
| 1023 |
-
inputs=[shared_basket], outputs=[basket])
|
| 1024 |
-
|
| 1025 |
-
# ---------- Tab 9: Recipe builder ----------
|
| 1026 |
-
with gr.Tab("Recipe builder", id="tab_recipe"):
|
| 1027 |
-
gr.Markdown(
|
| 1028 |
-
"Describe a dish in plain English. Hybrid retrieval: rapidfuzz token matching for direct "
|
| 1029 |
-
"ingredient mentions + sentence-transformer cosine against the sibling's factor-mode labels "
|
| 1030 |
-
"for thematic matches. First call after Space cold-start downloads ~80MB encoder (one-time)."
|
| 1031 |
-
)
|
| 1032 |
-
rb_prompt = gr.Textbox(label="Dish description", lines=3,
|
| 1033 |
-
value="I'm making Thai green curry for 4 people")
|
| 1034 |
-
rb_k = gr.Slider(4, 20, value=10, step=1, label="Suggestions (K)")
|
| 1035 |
-
rb_btn = gr.Button("Suggest starter basket", variant="primary")
|
| 1036 |
-
rb_table = gr.Dataframe(
|
| 1037 |
-
headers=["Ingredient", "Source", "Score"],
|
| 1038 |
-
label="Suggested basket (source = direct / thematic / both)", interactive=False,
|
| 1039 |
-
)
|
| 1040 |
-
rb_explainer = gr.Markdown()
|
| 1041 |
-
rb_matched = gr.State([])
|
| 1042 |
-
rb_send = gr.Button("Send to Basket tab", variant="secondary")
|
| 1043 |
-
def _rb(prompt, sib, k):
|
| 1044 |
-
rows, names, md = suggest_basket(prompt, sib, int(k))
|
| 1045 |
-
return rows, md, names
|
| 1046 |
-
rb_btn.click(_rb, inputs=[rb_prompt, sibling, rb_k],
|
| 1047 |
-
outputs=[rb_table, rb_explainer, rb_matched], show_progress="full")
|
| 1048 |
-
rb_send.click(lambda names: gr.Dropdown(value=(names or [])[:10]),
|
| 1049 |
-
inputs=[rb_matched], outputs=[basket])
|
| 1050 |
gr.Examples(
|
| 1051 |
examples=[
|
| 1052 |
-
["I'm making Thai green curry for 4 people",
|
| 1053 |
-
["spicy vegetarian taco filling",
|
| 1054 |
-
["
|
| 1055 |
-
["
|
| 1056 |
-
|
| 1057 |
],
|
| 1058 |
-
inputs=[
|
| 1059 |
-
label="Try one of these
|
| 1060 |
-
)
|
| 1061 |
-
|
| 1062 |
-
# ---------- Tab 10: Saved queries ----------
|
| 1063 |
-
with gr.Tab("Saved queries", id="tab_saved"):
|
| 1064 |
-
gr.Markdown(
|
| 1065 |
-
"Stored locally in your browser via `localStorage` (gr.BrowserState). "
|
| 1066 |
-
"~5 MB quota; per-browser, not per-account. Clearing browser data wipes them."
|
| 1067 |
-
)
|
| 1068 |
-
saved_table = gr.Dataframe(
|
| 1069 |
-
headers=["created_at", "tab", "summary", "id"],
|
| 1070 |
-
label="Your saved queries (newest first)", interactive=False, wrap=True,
|
| 1071 |
)
|
| 1072 |
-
with gr.Row():
|
| 1073 |
-
selected_id = gr.State("")
|
| 1074 |
-
del_btn = gr.Button("Delete selected", variant="secondary")
|
| 1075 |
-
def _on_select(saved, evt: gr.SelectData):
|
| 1076 |
-
if evt is None or evt.index is None:
|
| 1077 |
-
return ""
|
| 1078 |
-
row = evt.index[0] if isinstance(evt.index, (list, tuple)) else evt.index
|
| 1079 |
-
return (saved or [{}])[row].get("id", "") if row < len(saved or []) else ""
|
| 1080 |
-
saved_table.select(_on_select, inputs=[saved_state], outputs=selected_id)
|
| 1081 |
-
del_btn.click(delete_query, inputs=[saved_state, selected_id],
|
| 1082 |
-
outputs=[saved_state, saved_table])
|
| 1083 |
-
demo.load(lambda s: _render_saved(s), inputs=saved_state, outputs=saved_table)
|
| 1084 |
-
|
| 1085 |
-
# ---- Wire Save buttons (after all tabs exist so all components are in scope) ----
|
| 1086 |
-
save_basket_btn.click(
|
| 1087 |
-
lambda s, sib, b, k: save_query(s, "basket",
|
| 1088 |
-
{"sibling": sib, "basket": b or [], "k": int(k)}),
|
| 1089 |
-
inputs=[saved_state, sibling, basket, k_pair],
|
| 1090 |
-
outputs=[saved_state, saved_table],
|
| 1091 |
-
)
|
| 1092 |
-
save_sup_btn.click(
|
| 1093 |
-
lambda s, sib, b, d, th, k: save_query(s, "supervised_slerp",
|
| 1094 |
-
{"sibling": sib, "basket": b or [], "directions": d or [], "theta": float(th), "k": int(k)}),
|
| 1095 |
-
inputs=[saved_state, sibling, sup_basket, sup_dirs, sup_theta, sup_k],
|
| 1096 |
-
outputs=[saved_state, saved_table],
|
| 1097 |
-
)
|
| 1098 |
-
save_em_btn.click(
|
| 1099 |
-
lambda s, sib, b, m, th, k: save_query(s, "emergent_slerp",
|
| 1100 |
-
{"sibling": sib, "basket": b or [], "modes": m or [], "theta": float(th), "k": int(k)}),
|
| 1101 |
-
inputs=[saved_state, sibling, em_basket, em_modes, em_theta, em_k],
|
| 1102 |
-
outputs=[saved_state, saved_table],
|
| 1103 |
-
)
|
| 1104 |
-
save_ar_btn.click(
|
| 1105 |
-
lambda s, sib, p, n, k: save_query(s, "arithmetic",
|
| 1106 |
-
{"sibling": sib, "positives": p or [], "negatives": n or [], "k": int(k)}),
|
| 1107 |
-
inputs=[saved_state, sibling, pos_box, neg_box, ar_k],
|
| 1108 |
-
outputs=[saved_state, saved_table],
|
| 1109 |
-
)
|
| 1110 |
-
save_cmp_btn.click(
|
| 1111 |
-
lambda s, b, d, th, k: save_query(s, "compare",
|
| 1112 |
-
{"basket": b or [], "directions": d or [], "theta": float(th), "k": int(k)}),
|
| 1113 |
-
inputs=[saved_state, cmp_basket, cmp_dirs, cmp_theta, cmp_k],
|
| 1114 |
-
outputs=[saved_state, saved_table],
|
| 1115 |
-
)
|
| 1116 |
-
|
| 1117 |
-
# ---- Mode atlas row click -> UMAP highlight + jump to UMAP tab ----
|
| 1118 |
-
def atlas_row_to_umap(sibling_value, table_value, show_nb, k_value, three_d_value, evt: gr.SelectData):
|
| 1119 |
-
if evt is None or evt.index is None or table_value is None:
|
| 1120 |
-
return gr.update(), gr.update(), gr.update(), gr.update()
|
| 1121 |
-
row = evt.index[0] if isinstance(evt.index, (list, tuple)) else evt.index
|
| 1122 |
-
try:
|
| 1123 |
-
clicked_mode_id = (table_value.iloc[row, 0] if hasattr(table_value, "iloc")
|
| 1124 |
-
else table_value[row][0])
|
| 1125 |
-
except Exception:
|
| 1126 |
-
return gr.update(), gr.update(), gr.update(), gr.update()
|
| 1127 |
-
m = MODELS[sibling_value]
|
| 1128 |
-
mode = next((md for md in m.modes if md.mode_id == clicked_mode_id), None)
|
| 1129 |
-
if mode is None:
|
| 1130 |
-
return gr.update(), gr.update(), gr.update(), gr.update()
|
| 1131 |
-
members = [n for n in mode.members if n in m.vocab][:10]
|
| 1132 |
-
if not members:
|
| 1133 |
-
return gr.update(), gr.update(), gr.update(), gr.update()
|
| 1134 |
-
fig = umap_view(sibling_value, members, bool(show_nb), int(k_value), three_d=bool(three_d_value))
|
| 1135 |
-
return (
|
| 1136 |
-
gr.Dropdown(value=members),
|
| 1137 |
-
fig,
|
| 1138 |
-
members,
|
| 1139 |
-
gr.Tabs(selected="tab_umap"),
|
| 1140 |
-
)
|
| 1141 |
-
atlas_table.select(
|
| 1142 |
-
atlas_row_to_umap,
|
| 1143 |
-
inputs=[sibling, atlas_table, umap_show_nb, umap_k, umap_3d],
|
| 1144 |
-
outputs=[umap_basket, umap_plot, shared_basket, tabs],
|
| 1145 |
-
show_progress="hidden",
|
| 1146 |
-
)
|
| 1147 |
|
| 1148 |
-
# ----
|
| 1149 |
with gr.Group(visible=False):
|
| 1150 |
api_in_s1 = gr.Textbox(visible=False)
|
| 1151 |
api_in_s2 = gr.Textbox(visible=False)
|
| 1152 |
-
api_in_n
|
| 1153 |
api_in_n2 = gr.Number(visible=False, value=30)
|
| 1154 |
api_in_l1 = gr.JSON(visible=False, value=[])
|
| 1155 |
api_in_l2 = gr.JSON(visible=False, value=[])
|
| 1156 |
-
api_out
|
| 1157 |
-
|
| 1158 |
-
|
| 1159 |
-
|
| 1160 |
-
|
| 1161 |
-
api_btn_dirs = gr.Button(visible=False)
|
| 1162 |
-
api_btn_modes = gr.Button(visible=False)
|
| 1163 |
-
api_btn_neigh.click(api_neighbors, inputs=[api_in_s1, api_in_s2, api_in_n], outputs=api_out, api_name="neighbors")
|
| 1164 |
-
api_btn_slerp.click(api_slerp, inputs=[api_in_s1, api_in_s2, api_in_n2, gr.Textbox(visible=False, value="chem"), api_in_n], outputs=api_out, api_name="slerp")
|
| 1165 |
-
api_btn_arith.click(api_arithmetic, inputs=[api_in_l1, api_in_l2, api_in_s1, api_in_n], outputs=api_out, api_name="arithmetic")
|
| 1166 |
-
api_btn_embed.click(api_embed, inputs=[api_in_s1, api_in_s2], outputs=api_out, api_name="embed")
|
| 1167 |
-
api_btn_dirs.click(api_list_directions, inputs=[api_in_s1], outputs=api_out, api_name="list_directions")
|
| 1168 |
-
api_btn_modes.click(api_list_factor_modes, inputs=[api_in_s1], outputs=api_out, api_name="list_factor_modes")
|
| 1169 |
|
| 1170 |
gr.Markdown(
|
| 1171 |
"""---
|
| 1172 |
-
|
| 1173 |
-
|
| 1174 |
-
These operators are also exposed as JSON endpoints. See `/?view=api` for the auto-generated schema.
|
| 1175 |
-
|
| 1176 |
-
```python
|
| 1177 |
-
from gradio_client import Client
|
| 1178 |
-
c = Client("Kaikaku/epicure-explorer")
|
| 1179 |
-
c.predict("garlic", "chem", 5, api_name="/neighbors")
|
| 1180 |
-
c.predict("rice", "cuisine:South_Asian", 30, "chem", 5, api_name="/slerp")
|
| 1181 |
-
c.predict(["miso"], ["salt"], "core", 8, api_name="/arithmetic")
|
| 1182 |
-
c.predict("garlic", "chem", api_name="/embed") # 300-D L2-normalised vector
|
| 1183 |
-
c.predict("chem", api_name="/list_directions")
|
| 1184 |
-
c.predict("chem", api_name="/list_factor_modes")
|
| 1185 |
-
```
|
| 1186 |
-
|
| 1187 |
-
Endpoints validate inputs and return `{"error": "...", "suggestions": [...]}` on bad input. Free-tier limits: ~1-2 req/sec shared, no auth, Space sleeps after ~48h idle (cold start ~30-60s on next request).
|
| 1188 |
-
|
| 1189 |
-
---
|
| 1190 |
-
**Cite:** Radzikowski and Chen, 2026, *Epicure: Navigating the Emergent Geometry of Food Ingredient Embeddings*, [arXiv:2605.22391](https://arxiv.org/abs/2605.22391). Artefacts: [epicure-cooc](https://huggingface.co/Kaikaku/epicure-cooc) | [epicure-core](https://huggingface.co/Kaikaku/epicure-core) | [epicure-chem](https://huggingface.co/Kaikaku/epicure-chem) | [corpus dataset](https://huggingface.co/datasets/Kaikaku/epicure-corpus-resources)
|
| 1191 |
"""
|
| 1192 |
)
|
| 1193 |
|
|
|
|
| 1 |
+
"""Epicure Explorer - chef-facing operators over three sibling ingredient embeddings.
|
| 2 |
+
|
| 3 |
+
Simplified UI: 4 tabs.
|
| 4 |
+
- Explore : pick ingredients, see neighbours across all three siblings at once.
|
| 5 |
+
- Transform: rotate or do arithmetic on the basket (one tab for all three operators).
|
| 6 |
+
- Map : UMAP visualisation.
|
| 7 |
+
- From text: paste a recipe / dish description, fuzzy-match to canonical vocab.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
Paper: https://arxiv.org/abs/2605.22391
|
| 10 |
"""
|
| 11 |
|
| 12 |
from __future__ import annotations
|
| 13 |
|
| 14 |
+
import os, re, sys, json
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
from functools import lru_cache
|
| 16 |
|
| 17 |
import numpy as np
|
|
|
|
| 32 |
from rapidfuzz import process as fuzz_process, fuzz as fuzz_scorers
|
| 33 |
|
| 34 |
# ===== Kaikaku brand =====
|
| 35 |
+
KAIKAKU_DARK = "#0F2D2F"
|
| 36 |
+
KAIKAKU_DEEP = "#0A1F20"
|
| 37 |
+
KAIKAKU_MID = "#1A3D3F"
|
| 38 |
+
KAIKAKU_EDGE = "#2A4D4F"
|
| 39 |
+
KAIKAKU_ACCENT = "#288B79"
|
| 40 |
KAIKAKU_ACCENT_HOVER = "#1E6E5F"
|
| 41 |
KAIKAKU_ACCENT_LIGHT = "#A8D5CA"
|
|
|
|
|
|
|
| 42 |
|
| 43 |
plt.rcParams.update({
|
| 44 |
+
"figure.facecolor": "#ffffff", "axes.facecolor": "#ffffff",
|
| 45 |
+
"axes.edgecolor": "#cccccc", "axes.labelcolor": "#111111",
|
| 46 |
+
"xtick.color": "#333333", "ytick.color": "#333333",
|
| 47 |
+
"text.color": "#111111", "savefig.facecolor": "#ffffff",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
})
|
| 49 |
|
| 50 |
MODELS = {
|
|
|
|
| 58 |
UMAP_DATA = np.load(os.path.join(_HERE, "umap_2d.npz"))
|
| 59 |
_lab = json.load(open(os.path.join(_HERE, "ingredient_labels.json")))
|
| 60 |
NAMES_BY_IDX: list[str] = _lab["names"]
|
| 61 |
+
FOOD_GROUPS: list[str] = _lab["food_groups"]
|
| 62 |
|
| 63 |
FG_COLORS = {
|
| 64 |
+
"Vegetable":"#2ca02c","Fruit":"#e377c2","Grain":"#bcbd22","Dairy":"#17becf",
|
| 65 |
+
"Spice":"#d62728","Pantry":"#ff7f0e","Beverage":"#9467bd","Other":"#cccccc",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
}
|
| 67 |
|
| 68 |
print(f"[epicure-explorer] models loaded: {list(MODELS)}", flush=True)
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
+
# Food-group filter helpers
|
| 71 |
+
_NAME_TO_GROUP = {NAMES_BY_IDX[i]: FOOD_GROUPS[i] for i in range(len(NAMES_BY_IDX))}
|
| 72 |
+
FOOD_GROUP_CHOICES = ["All","Vegetable","Spice","Fruit","Dairy","Grain","Pantry","Beverage","Other"]
|
| 73 |
|
| 74 |
+
def _choices_for_group(group):
|
| 75 |
if not group or group == "All":
|
| 76 |
return ALL_INGREDIENTS
|
| 77 |
return sorted(n for n in ALL_INGREDIENTS if _NAME_TO_GROUP.get(n, "Other") == group)
|
| 78 |
|
| 79 |
+
def _filter_dropdown(group, current_value):
|
| 80 |
new_choices = _choices_for_group(group)
|
| 81 |
allowed = set(new_choices)
|
| 82 |
cur = current_value or []
|
|
|
|
| 86 |
kept = [v for v in cur if v in allowed]
|
| 87 |
return gr.Dropdown(choices=new_choices, value=kept)
|
| 88 |
|
| 89 |
+
# ===== math =====
|
| 90 |
|
| 91 |
def _unit(v, eps=1e-9):
|
| 92 |
n = np.linalg.norm(v); return v / max(n, eps)
|
|
|
|
| 128 |
if n < 1e-9: return v
|
| 129 |
d_perp = d_perp / n
|
| 130 |
th = np.deg2rad(float(theta_deg))
|
| 131 |
+
return _unit(np.cos(th) * v + np.sin(th) * d_perp)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
|
| 133 |
+
# ===== Heatmap =====
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
def _basket_heatmap(m, basket):
|
| 136 |
valid = [n for n in (basket or []) if n in m.vocab]
|
| 137 |
+
fig, ax = plt.subplots(figsize=(5.5, 4.5))
|
| 138 |
if len(valid) < 2:
|
| 139 |
ax.text(0.5, 0.5, "Add 2+ ingredients to see pairwise cosines",
|
| 140 |
+
ha="center", va="center", fontsize=12, color="#888", transform=ax.transAxes)
|
| 141 |
+
ax.axis("off"); plt.tight_layout(); return fig
|
|
|
|
|
|
|
|
|
|
| 142 |
idxs = [m.vocab[n] for n in valid]
|
| 143 |
sub = m.E[idxs]
|
| 144 |
sim = sub @ sub.T
|
| 145 |
im = ax.imshow(sim, cmap="viridis", vmin=-0.2, vmax=1.0, aspect="auto")
|
| 146 |
+
ax.set_xticks(range(len(valid))); ax.set_yticks(range(len(valid)))
|
| 147 |
+
ax.set_xticklabels(valid, rotation=35, ha="right"); ax.set_yticklabels(valid)
|
|
|
|
|
|
|
| 148 |
for i in range(len(valid)):
|
| 149 |
for j in range(len(valid)):
|
| 150 |
v = float(sim[i, j])
|
| 151 |
+
ax.text(j, i, f"{v:.2f}", ha="center", va="center", fontsize=9,
|
| 152 |
+
color=("white" if v < 0.55 else "black"))
|
| 153 |
+
cb = plt.colorbar(im, ax=ax); cb.set_label("cosine")
|
|
|
|
|
|
|
| 154 |
plt.tight_layout()
|
| 155 |
return fig
|
| 156 |
|
|
|
|
| 163 |
m = MODELS[sibling]
|
| 164 |
E = m.E - m.E.mean(axis=0, keepdims=True)
|
| 165 |
_, _, Vt = np.linalg.svd(E, full_matrices=False)
|
| 166 |
+
pc1 = E @ Vt[0]
|
| 167 |
pc1 = (pc1 - pc1.mean()) / (pc1.std() + 1e-9)
|
| 168 |
scale = (base.max() - base.min()) * 0.25
|
| 169 |
return base, (pc1 * scale).astype(np.float32)
|
|
|
|
| 176 |
hover_text = [f"{NAMES_BY_IDX[i]}<br>group: {FOOD_GROUPS[i]}" for i in range(n)]
|
| 177 |
basket_set = set(basket or [])
|
| 178 |
basket_idxs = [m.vocab[b] for b in (basket or []) if b in m.vocab]
|
| 179 |
+
neighbour_set = set()
|
| 180 |
if show_neighbours and basket_idxs:
|
| 181 |
centroid = _basket_centroid(m, basket)
|
| 182 |
if centroid is not None:
|
| 183 |
+
nb = _topk(m, centroid, k=int(k), exclude=basket)
|
| 184 |
+
neighbour_set = {nm for nm, _ in nb}
|
| 185 |
+
keep = lambda i: NAMES_BY_IDX[i] not in basket_set and NAMES_BY_IDX[i] not in neighbour_set
|
| 186 |
+
bg_x = [float(coords2[i, 0]) for i in range(n) if keep(i)]
|
| 187 |
+
bg_y = [float(coords2[i, 1]) for i in range(n) if keep(i)]
|
| 188 |
+
bg_z = [float(z[i]) for i in range(n) if keep(i)] if three_d else None
|
| 189 |
+
bg_c = [colors[i] for i in range(n) if keep(i)]
|
| 190 |
+
bg_h = [hover_text[i] for i in range(n) if keep(i)]
|
| 191 |
fig = go.Figure()
|
| 192 |
if three_d:
|
| 193 |
+
fig.add_trace(go.Scatter3d(x=bg_x, y=bg_y, z=bg_z, mode="markers",
|
| 194 |
+
marker=dict(size=3, color=bg_c, opacity=0.55), text=bg_h,
|
| 195 |
+
hovertemplate="%{text}<extra></extra>", name="ingredients", showlegend=False))
|
|
|
|
|
|
|
| 196 |
else:
|
| 197 |
+
fig.add_trace(go.Scattergl(x=bg_x, y=bg_y, mode="markers",
|
| 198 |
+
marker=dict(size=5, color=bg_c, opacity=0.65), text=bg_h,
|
| 199 |
+
hovertemplate="%{text}<extra></extra>", name="ingredients", showlegend=False))
|
|
|
|
|
|
|
| 200 |
if neighbour_set:
|
| 201 |
ni = [i for i in range(n) if NAMES_BY_IDX[i] in neighbour_set]
|
| 202 |
+
nx = [float(coords2[i, 0]) for i in ni]; ny = [float(coords2[i, 1]) for i in ni]
|
|
|
|
| 203 |
nz = [float(z[i]) for i in ni] if three_d else None
|
| 204 |
+
nl = [NAMES_BY_IDX[i] for i in ni]
|
| 205 |
+
mk = dict(size=11 if not three_d else 6, color="#ff8800", opacity=0.95,
|
| 206 |
+
line=dict(color="#ffffff", width=1.2))
|
| 207 |
TR = go.Scatter3d if three_d else go.Scatter
|
| 208 |
+
kw = dict(mode="markers+text", marker=mk, text=nl, textposition="top center",
|
| 209 |
+
textfont=dict(size=10),
|
| 210 |
+
hovertemplate="<b>%{text}</b> (neighbour)<extra></extra>",
|
| 211 |
+
name=f"top-{k} neighbours")
|
| 212 |
+
fig.add_trace(TR(x=nx, y=ny, z=nz, **kw) if three_d else TR(x=nx, y=ny, **kw))
|
| 213 |
if basket_idxs:
|
| 214 |
bx = [float(coords2[i, 0]) for i in basket_idxs]
|
| 215 |
by = [float(coords2[i, 1]) for i in basket_idxs]
|
| 216 |
bz = [float(z[i]) for i in basket_idxs] if three_d else None
|
| 217 |
+
bl = [NAMES_BY_IDX[i] for i in basket_idxs]
|
| 218 |
+
mk = dict(size=18 if not three_d else 9, color=KAIKAKU_ACCENT,
|
| 219 |
+
symbol="star" if not three_d else "diamond",
|
| 220 |
+
line=dict(color="#111111", width=1.5))
|
| 221 |
TR = go.Scatter3d if three_d else go.Scatter
|
| 222 |
+
kw = dict(mode="markers+text", marker=mk, text=bl, textposition="top center",
|
| 223 |
+
textfont=dict(size=13, color="#111111"),
|
| 224 |
+
hovertemplate="<b>%{text}</b> (basket)<extra></extra>", name="basket")
|
| 225 |
+
fig.add_trace(TR(x=bx, y=by, z=bz, **kw) if three_d else TR(x=bx, y=by, **kw))
|
|
|
|
| 226 |
fig.update_layout(
|
| 227 |
+
title=dict(text=f"UMAP - Epicure-{sibling.capitalize()}{' (3D)' if three_d else ''}", font=dict(size=14)),
|
| 228 |
+
height=620, margin=dict(l=40, r=40, t=50, b=40),
|
| 229 |
paper_bgcolor="#ffffff", plot_bgcolor="#ffffff",
|
| 230 |
legend=dict(orientation="v", x=1.02, y=1, font=dict(size=11)),
|
| 231 |
)
|
| 232 |
if not three_d:
|
| 233 |
+
fig.update_xaxes(showgrid=True, gridcolor="#eee", zeroline=False, title="UMAP 1")
|
| 234 |
+
fig.update_yaxes(showgrid=True, gridcolor="#eee", zeroline=False, title="UMAP 2")
|
| 235 |
else:
|
| 236 |
fig.update_layout(scene=dict(xaxis=dict(title="UMAP 1"), yaxis=dict(title="UMAP 2"),
|
| 237 |
zaxis=dict(title="PC1 (z)"), bgcolor="#ffffff"))
|
| 238 |
return fig
|
| 239 |
|
| 240 |
+
# ===== Explore: side-by-side neighbours across siblings =====
|
| 241 |
|
| 242 |
+
def explore_all_siblings(basket, k):
|
| 243 |
+
"""Returns 3 dataframes (Cooc/Core/Chem neighbours), heatmap, and mode tables per sibling."""
|
| 244 |
+
out_nb = []
|
| 245 |
+
out_modes = []
|
| 246 |
+
for sib in ["cooc","core","chem"]:
|
| 247 |
+
m = MODELS[sib]
|
| 248 |
+
c = _basket_centroid(m, basket)
|
| 249 |
+
if c is None:
|
| 250 |
+
out_nb.append([]); out_modes.append([]); continue
|
| 251 |
+
nb = _topk(m, c, int(k), exclude=basket or [])
|
| 252 |
+
out_nb.append([[n, f"{s:.4f}"] for n, s in nb])
|
| 253 |
+
scored = [(mode.mode_id, mode.label, mode.kind, float(_unit(mode.pole) @ c)) for mode in m.modes]
|
| 254 |
+
scored.sort(key=lambda x: -x[3])
|
| 255 |
+
out_modes.append([[mid, label, kind, f"{sim:.3f}"] for mid, label, kind, sim in scored[:5]])
|
| 256 |
+
heat = _basket_heatmap(MODELS["chem"], basket)
|
| 257 |
+
return out_nb[0], out_nb[1], out_nb[2], heat, out_modes[0], out_modes[1], out_modes[2]
|
| 258 |
+
|
| 259 |
+
# ===== Transform: unified operator =====
|
| 260 |
+
|
| 261 |
+
def transform(sibling, op, basket, directions, mode_labels, theta, negatives, k):
|
| 262 |
m = MODELS[sibling]
|
| 263 |
+
if op == "Rotate to supervised direction":
|
| 264 |
+
v = _basket_centroid(m, basket)
|
| 265 |
+
if v is None: return [], "_(empty basket)_"
|
| 266 |
+
d = _stack_directions(m, directions, use_factor_pole=False)
|
| 267 |
+
if d is None: return [[n, f"{s:.4f}"] for n, s in _topk(m, v, k, basket)], "_(no direction selected)_"
|
| 268 |
+
q = _slerp(v, d, theta)
|
| 269 |
+
rows = [[n, f"{s:.4f}"] for n, s in _topk(m, q, k, basket)]
|
| 270 |
+
return rows, _explain_slerp(m, basket, directions or [], theta, q, v, d)
|
| 271 |
+
if op == "Rotate to emergent mode":
|
| 272 |
+
label_to_id = {f"{md.label} ({md.mode_id})": md.mode_id for md in m.modes if md.kind == "factor"}
|
| 273 |
+
mode_ids = [label_to_id[lab] for lab in (mode_labels or []) if lab in label_to_id]
|
| 274 |
+
v = _basket_centroid(m, basket)
|
| 275 |
+
if v is None: return [], "_(empty basket)_"
|
| 276 |
+
d = _stack_directions(m, mode_ids, use_factor_pole=True)
|
| 277 |
+
if d is None: return [[n, f"{s:.4f}"] for n, s in _topk(m, v, k, basket)], "_(no mode selected)_"
|
| 278 |
+
q = _slerp(v, d, theta)
|
| 279 |
+
rows = [[n, f"{s:.4f}"] for n, s in _topk(m, q, k, basket)]
|
| 280 |
+
return rows, _explain_slerp(m, basket, mode_ids, theta, q, v, d)
|
| 281 |
+
# Arithmetic
|
| 282 |
+
pos = _basket_centroid(m, basket)
|
| 283 |
+
if pos is None: return [], "_(no positives)_"
|
| 284 |
+
neg = _basket_centroid(m, negatives) if negatives else None
|
| 285 |
+
q = _unit(pos - neg) if neg is not None else pos
|
| 286 |
+
rows = [[n, f"{s:.4f}"] for n, s in _topk(m, q, k, (basket or []) + (negatives or []))]
|
| 287 |
+
return rows, _explain_arithmetic(m, basket, negatives or [], q)
|
| 288 |
+
|
| 289 |
+
def _explain_slerp(m, basket, dir_keys, theta, q, v, d):
|
| 290 |
+
if q is None or v is None or d is None: return ""
|
| 291 |
+
cos_theta = float(q @ v)
|
| 292 |
+
travelled = min(max(float(theta) / 90.0, 0.0), 1.0)
|
| 293 |
+
dir_nb = _topk(m, _unit(d), 5, exclude=basket or [])
|
| 294 |
+
seed_nb = _topk(m, v, 3, exclude=basket or [])
|
| 295 |
+
dirs_str = " + ".join(dir_keys) if dir_keys else "(none)"
|
| 296 |
return (
|
| 297 |
+
f"**Why these results.** Rotated cos to seed = {cos_theta:.3f} "
|
| 298 |
+
f"({travelled*100:.0f}% of the way to {dirs_str}). "
|
| 299 |
+
f"Direction's own neighbourhood: {', '.join(n for n, _ in dir_nb[:5])}. "
|
| 300 |
+
f"Seed basket's own top-3: {', '.join(n for n, _ in seed_nb)}."
|
| 301 |
)
|
| 302 |
|
| 303 |
+
def _explain_arithmetic(m, positives, negatives, q):
|
| 304 |
+
if q is None: return ""
|
| 305 |
+
pos_sims = [(n, float(_unit(m.E[m.vocab[n]]) @ q)) for n in positives if n in m.vocab]
|
| 306 |
+
neg_sims = [(n, float(_unit(m.E[m.vocab[n]]) @ q)) for n in negatives if n in m.vocab]
|
| 307 |
+
pp = ", ".join(f"{n} ({s:+.2f})" for n, s in pos_sims) or "(none)"
|
| 308 |
+
np_ = ", ".join(f"{n} ({s:+.2f})" for n, s in neg_sims) or "(none)"
|
| 309 |
+
return f"**Why these results.** Result vs positives: {pp}. Result vs negatives: {np_}."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
|
| 311 |
+
# ===== From-text: combined fridge parser + recipe builder =====
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
|
| 313 |
+
_LINE_SPLIT = re.compile(r"[\n;]")
|
| 314 |
+
_BRACKET = re.compile(r"\([^)]*\)")
|
| 315 |
+
_QTY = r"(?:\d+(?:[\.,/]\d+)?|a|an|one|two|three|four|five|six|seven|eight|nine|ten|half|quarter)"
|
| 316 |
+
_UNIT = (r"(?:cups?|tbsp\.?|tablespoons?|tsp\.?|teaspoons?|oz\.?|ounces?|lbs?\.?|pounds?|"
|
| 317 |
+
r"grams?|kgs?|kilos?|ml|liters?|litres?|cloves?|bunches?|sprigs?|pinch(?:es)?|"
|
| 318 |
+
r"slices?|pieces?|cans?|packets?|sticks?|leaves?|stalks?|heads?|inch(?:es)?|"
|
| 319 |
+
r"splash(?:es)?|dash(?:es)?|drops?|handfuls?|large|small|medium)")
|
| 320 |
+
_LEADING_QTY = re.compile(rf"^\s*{_QTY}\s+(?:{_UNIT}\b\s*)?(?:of\s+)?", re.IGNORECASE)
|
| 321 |
+
_LEADING_UNIT_ONLY = re.compile(rf"^\s*{_UNIT}\b\s*(?:of\s+)?", re.IGNORECASE)
|
| 322 |
+
_JUICE_OF = re.compile(rf"^\s*(?:juice|zest)\s+(?:of\s+)?(?:{_QTY}\s+)?", re.IGNORECASE)
|
| 323 |
+
_LEADING_PREP = re.compile(
|
| 324 |
+
r"^\s*(?:fresh|dried|cooked|frozen|raw|ripe|firm|boneless|skinless|smoked|low[- ]fat)\s+", re.IGNORECASE)
|
| 325 |
+
_TRAILING_PREP = re.compile(
|
| 326 |
+
r"\s*,\s*(?:chopped|minced|diced|sliced|grated|crushed|whole|ground|peeled|"
|
| 327 |
+
r"to taste|optional|finely|coarsely|cubed|shredded|julienned|halved|quartered|warmed|"
|
| 328 |
+
r"toasted|roasted|bruised|melted|softened|cooked|drained|rinsed|patted dry|trimmed|"
|
| 329 |
+
r"deveined|seeded|stemmed|crumbled).*$", re.IGNORECASE)
|
| 330 |
+
_KNOWN_PLURALS = {"tortillas":"tortilla","thighs":"thigh","leaves":"leaf","onions":"onion",
|
| 331 |
+
"potatoes":"potato","tomatoes":"tomato","cloves":"clove"}
|
| 332 |
|
| 333 |
+
def _clean_line(line):
|
| 334 |
+
s = line.strip().lower()
|
| 335 |
+
s = _BRACKET.sub(" ", s)
|
| 336 |
+
if "juice" in s or "zest" in s:
|
| 337 |
+
s = _JUICE_OF.sub("", s)
|
| 338 |
+
s = _TRAILING_PREP.sub("", s)
|
| 339 |
+
s = _LEADING_QTY.sub("", s)
|
| 340 |
+
s = _LEADING_UNIT_ONLY.sub("", s)
|
| 341 |
+
s = _LEADING_PREP.sub("", s)
|
| 342 |
+
s = _LEADING_PREP.sub("", s)
|
| 343 |
+
tokens = [_KNOWN_PLURALS.get(t, t) for t in s.split()]
|
| 344 |
+
return re.sub(r"\s+", " ", " ".join(tokens)).strip()
|
| 345 |
|
| 346 |
+
def _fuzzy_lookup(cleaned, vocab, vocab_sp, min_score):
|
| 347 |
+
if not cleaned: return None, 0.0
|
| 348 |
+
candidates = []
|
| 349 |
+
for scorer in (fuzz_scorers.token_set_ratio, fuzz_scorers.WRatio, fuzz_scorers.partial_ratio):
|
| 350 |
+
hits = fuzz_process.extract(cleaned, vocab_sp, scorer=scorer, score_cutoff=min_score, limit=10)
|
| 351 |
+
for _sp, score, idx in hits:
|
| 352 |
+
candidates.append((vocab[idx], float(score)))
|
| 353 |
+
if not candidates: return None, 0.0
|
| 354 |
+
cleaned_tokens = set(cleaned.split())
|
| 355 |
+
def rank(c):
|
| 356 |
+
name, score = c
|
| 357 |
+
nt = set(name.replace("_", " ").split())
|
| 358 |
+
return (-score, 0 if nt.issubset(cleaned_tokens) else 1, -len(name))
|
| 359 |
+
candidates.sort(key=rank)
|
| 360 |
+
return candidates[0]
|
| 361 |
+
|
| 362 |
+
def parse_fridge(raw_text, sibling="chem", min_score=70):
|
| 363 |
+
if not raw_text or not raw_text.strip(): return [], []
|
| 364 |
+
vocab = list(MODELS[sibling].vocab.keys())
|
| 365 |
+
vocab_sp = [v.replace("_", " ") for v in vocab]
|
| 366 |
+
rows, matched = [], []
|
| 367 |
+
for line in _LINE_SPLIT.split(raw_text):
|
| 368 |
+
if not line.strip(): continue
|
| 369 |
+
cleaned = _clean_line(line)
|
| 370 |
+
if not cleaned:
|
| 371 |
+
rows.append([line.strip(), "(empty)", 0.0]); continue
|
| 372 |
+
match, score = _fuzzy_lookup(cleaned, vocab, vocab_sp, int(min_score))
|
| 373 |
+
if match is None:
|
| 374 |
+
tokens = cleaned.split()
|
| 375 |
+
if len(tokens) > 1:
|
| 376 |
+
match, score = _fuzzy_lookup(" ".join(tokens[:-1]), vocab, vocab_sp, int(min_score))
|
| 377 |
+
if match is None:
|
| 378 |
+
rows.append([line.strip(), "(no match)", 0.0]); continue
|
| 379 |
+
rows.append([line.strip(), match, round(score, 1)])
|
| 380 |
+
matched.append(match)
|
| 381 |
+
seen, dedup = set(), []
|
| 382 |
+
for n in matched:
|
| 383 |
+
if n not in seen: seen.add(n); dedup.append(n)
|
| 384 |
+
return rows, dedup
|
| 385 |
+
|
| 386 |
+
# Sentence-transformer for thematic queries
|
| 387 |
_ST = None
|
| 388 |
def _get_st():
|
| 389 |
global _ST
|
| 390 |
if _ST is None:
|
|
|
|
| 391 |
from sentence_transformers import SentenceTransformer
|
| 392 |
+
_ST = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2", device="cpu")
|
| 393 |
return _ST
|
| 394 |
|
| 395 |
@lru_cache(maxsize=4)
|
| 396 |
+
def _mode_label_matrix(sibling):
|
| 397 |
m = MODELS[sibling]
|
| 398 |
modes = [md for md in m.modes if md.kind == "factor"]
|
| 399 |
if not modes:
|
|
|
|
| 408 |
n = max(4, min(12, (len(members) + 3) // 4))
|
| 409 |
return members[:n]
|
| 410 |
|
| 411 |
+
_STOP = {"i","im","i'm","a","an","the","for","of","with","and","or","some","my","me","we",
|
| 412 |
+
"make","making","cook","cooking","prepare","preparing","want","need","to","tonight",
|
| 413 |
+
"people","person","servings","dinner","lunch","dish","recipe","quick","easy",
|
| 414 |
+
"tasty","yummy","good","great","food","meal","style"}
|
| 415 |
+
_TOK_RE = re.compile(r"[A-Za-z][A-Za-z\-']{1,}")
|
|
|
|
|
|
|
| 416 |
|
| 417 |
+
def suggest_basket(prompt, sibling="chem", k=10):
|
| 418 |
if not prompt or not prompt.strip():
|
| 419 |
+
return [], [], "_(empty prompt)_"
|
| 420 |
vocab = list(MODELS[sibling].vocab.keys())
|
| 421 |
+
vocab_sp = [v.replace("_"," ") for v in vocab]
|
| 422 |
+
tokens = [t for t in _TOK_RE.findall(prompt.lower()) if t not in _STOP and len(t) > 2]
|
|
|
|
| 423 |
direct = {}
|
| 424 |
direct_evidence = []
|
| 425 |
for tok in tokens:
|
|
|
|
| 443 |
for mid, lab, sim in picked:
|
| 444 |
for name in _mode_quartile(id_to_mode[mid]):
|
| 445 |
s_existing, _ = thematic.get(name, (0.0, ""))
|
| 446 |
+
thematic[name] = (max(s_existing, sim * 100.0), lab)
|
|
|
|
| 447 |
combined = {}
|
| 448 |
+
for name, sc in direct.items(): combined[name] = (sc, "direct")
|
|
|
|
| 449 |
for name, (sc, lab) in thematic.items():
|
| 450 |
prev = combined.get(name)
|
| 451 |
if prev is None or sc > prev[0]:
|
| 452 |
+
combined[name] = (sc, "both" if prev else "thematic")
|
|
|
|
| 453 |
ranked = sorted(combined.items(),
|
| 454 |
key=lambda kv: (-kv[1][0], 0 if kv[1][1] != "thematic" else 1, kv[0]))[:int(k)]
|
| 455 |
rows = [[name, src, round(score, 1)] for name, (score, src) in ranked]
|
| 456 |
names = [name for name, _ in ranked]
|
| 457 |
lines = []
|
| 458 |
if direct_evidence:
|
| 459 |
+
lines.append("**Direct mentions:** " + ", ".join(sorted({f"`{n}`" for _, n, _ in direct_evidence})))
|
|
|
|
|
|
|
|
|
|
| 460 |
if thematic_modes:
|
| 461 |
+
lines.append("**Matched modes:** " + "; ".join(f"`{lab}` (cos {sim:.2f})" for _, lab, sim in thematic_modes))
|
| 462 |
+
return rows, names, "\n\n".join(lines) if lines else "_(no matches)_"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
|
| 464 |
+
def parse_or_suggest(text, sibling, mode_choice):
|
| 465 |
+
"""Auto-detect: fridge-list if mostly short lines with units; recipe-prompt otherwise."""
|
| 466 |
+
if not text or not text.strip(): return [], "_(empty)_", []
|
| 467 |
+
if mode_choice == "Recipe / dish description":
|
| 468 |
+
rows, names, expl = suggest_basket(text, sibling, 10)
|
| 469 |
+
return rows, expl, names
|
| 470 |
+
rows, names = parse_fridge(text, sibling, 70)
|
| 471 |
+
return rows, f"Matched {len(names)} ingredients.", names
|
|
|
|
|
|
|
|
|
|
|
|
|
| 472 |
|
| 473 |
+
# ===== Mode atlas (used inside Explore Accordion) =====
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
|
| 475 |
+
def browse_modes(sibling, kind_filter, query):
|
| 476 |
+
m = MODELS[sibling]
|
| 477 |
+
rows, q = [], (query or "").strip().lower()
|
| 478 |
+
for mode in m.modes:
|
| 479 |
+
if kind_filter != "all" and mode.kind != kind_filter:
|
| 480 |
+
continue
|
| 481 |
+
if q and q not in mode.label.lower() and q not in mode.property.lower():
|
| 482 |
+
continue
|
| 483 |
+
rows.append([mode.mode_id, mode.kind, mode.property, mode.label, mode.n_members,
|
| 484 |
+
", ".join(mode.members[:10])])
|
| 485 |
+
rows.sort(key=lambda r: (r[1], -r[4]))
|
| 486 |
+
return rows
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 487 |
|
| 488 |
+
# ===== Public API endpoint helpers =====
|
| 489 |
|
| 490 |
+
def _suggest(name, sibling, n=5):
|
| 491 |
vocab = list(MODELS[sibling].vocab.keys())
|
| 492 |
hits = fuzz_process.extract((name or "").lower().replace(" ", "_"),
|
| 493 |
vocab, scorer=fuzz_scorers.WRatio, limit=n)
|
| 494 |
return [h[0] for h in hits]
|
| 495 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 496 |
def api_neighbors(ingredient, sibling="chem", k=5):
|
| 497 |
+
if sibling not in MODELS: return {"error": "bad sibling"}
|
| 498 |
+
if ingredient not in MODELS[sibling].vocab: return {"error": f"'{ingredient}' not in vocab", "suggestions": _suggest(ingredient, sibling)}
|
| 499 |
m = MODELS[sibling]
|
| 500 |
q = _unit(m.E[m.vocab[ingredient]])
|
| 501 |
+
return [{"name": n, "cosine": round(float(s), 6)} for n, s in _topk(m, q, int(k), [ingredient])]
|
|
|
|
| 502 |
|
| 503 |
def api_slerp(seed, direction, theta_deg=30, sibling="chem", k=5):
|
| 504 |
+
if sibling not in MODELS: return {"error": "bad sibling"}
|
|
|
|
| 505 |
m = MODELS[sibling]
|
| 506 |
+
if seed not in m.vocab: return {"error": f"'{seed}' not in vocab", "suggestions": _suggest(seed, sibling)}
|
| 507 |
+
if direction not in m.supervised_poles: return {"error": f"'{direction}' not a supervised pole"}
|
|
|
|
| 508 |
v = _unit(m.E[m.vocab[seed]])
|
| 509 |
d = _unit(m.supervised_poles[direction])
|
| 510 |
q = _slerp(v, d, float(theta_deg))
|
| 511 |
+
return [{"name": n, "cosine": round(float(s), 6)} for n, s in _topk(m, q, int(k), [seed])]
|
|
|
|
| 512 |
|
| 513 |
def api_arithmetic(positives, negatives, sibling="chem", k=5):
|
| 514 |
+
if sibling not in MODELS: return {"error": "bad sibling"}
|
| 515 |
+
positives = list(positives or []); negatives = list(negatives or [])
|
| 516 |
+
if not positives: return {"error": "positives must be non-empty"}
|
|
|
|
|
|
|
|
|
|
| 517 |
m = MODELS[sibling]
|
| 518 |
unknown = [x for x in positives + negatives if x not in m.vocab]
|
| 519 |
+
if unknown: return {"error": f"unknown: {unknown}"}
|
|
|
|
|
|
|
| 520 |
pos = _basket_centroid(m, positives)
|
| 521 |
neg = _basket_centroid(m, negatives) if negatives else None
|
| 522 |
q = _unit(pos - neg) if neg is not None else pos
|
| 523 |
+
return [{"name": n, "cosine": round(float(s), 6)} for n, s in _topk(m, q, int(k), positives + negatives)]
|
|
|
|
| 524 |
|
| 525 |
def api_embed(ingredient, sibling="chem"):
|
| 526 |
+
if sibling not in MODELS: return {"error": "bad sibling"}
|
|
|
|
| 527 |
m = MODELS[sibling]
|
| 528 |
+
if ingredient not in m.vocab: return {"error": f"'{ingredient}' not in vocab"}
|
| 529 |
+
return [float(x) for x in _unit(m.E[m.vocab[ingredient]]).tolist()]
|
| 530 |
|
| 531 |
+
# ===== Theme =====
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 532 |
|
| 533 |
THEME = gr.themes.Soft(
|
| 534 |
primary_hue=gr.themes.Color(
|
|
|
|
| 540 |
neutral_hue="slate",
|
| 541 |
font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
|
| 542 |
).set(
|
| 543 |
+
block_label_text_color="#1f2937", block_label_text_weight="600",
|
| 544 |
+
block_title_text_color="#0f172a", block_title_text_weight="700",
|
| 545 |
+
body_text_color="#0f172a", body_text_color_subdued="#475569",
|
|
|
|
|
|
|
|
|
|
| 546 |
button_primary_background_fill=KAIKAKU_ACCENT,
|
| 547 |
button_primary_background_fill_hover=KAIKAKU_ACCENT_HOVER,
|
| 548 |
button_primary_text_color="#ffffff",
|
| 549 |
button_primary_border_color=KAIKAKU_ACCENT,
|
| 550 |
button_secondary_background_fill="#f1f5f9",
|
|
|
|
| 551 |
button_secondary_text_color=KAIKAKU_DARK,
|
| 552 |
slider_color=KAIKAKU_ACCENT,
|
| 553 |
color_accent=KAIKAKU_ACCENT,
|
|
|
|
| 557 |
.gradio-container {{max-width: 1280px !important;}}
|
| 558 |
footer {{visibility: hidden;}}
|
| 559 |
.gradio-container label, .gradio-container .label,
|
| 560 |
+
.gradio-container [data-testid="block-label"], .gradio-container .block-label {{
|
|
|
|
| 561 |
color: #0f172a !important; font-weight: 600 !important; background: transparent !important;
|
| 562 |
}}
|
| 563 |
.gradio-container button[role="tab"] {{ color: #334155 !important; font-weight: 500 !important; }}
|
|
|
|
| 568 |
background: {KAIKAKU_ACCENT} !important; color: #ffffff !important;
|
| 569 |
border-color: {KAIKAKU_ACCENT} !important; font-weight: 600 !important;
|
| 570 |
}}
|
| 571 |
+
.gradio-container table thead th {{
|
|
|
|
| 572 |
color: #0f172a !important; font-weight: 700 !important; background: #f8fafc !important;
|
| 573 |
}}
|
| 574 |
.gradio-container table tbody td {{ color: #0f172a !important; }}
|
| 575 |
+
|
| 576 |
+
/* Spectrum bar */
|
| 577 |
+
.spectrum-bar {{
|
| 578 |
+
display: flex; align-items: stretch; margin: 12px 0 4px 0; height: 56px;
|
| 579 |
+
border-radius: 8px; overflow: hidden;
|
| 580 |
+
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
| 581 |
}}
|
| 582 |
+
.spectrum-cell {{
|
| 583 |
+
flex: 1; display: flex; flex-direction: column; justify-content: center;
|
| 584 |
+
padding: 6px 14px; color: #0f172a;
|
| 585 |
+
}}
|
| 586 |
+
.spectrum-cell-1 {{ background: #f0f9f6; }}
|
| 587 |
+
.spectrum-cell-2 {{ background: #d8efe7; }}
|
| 588 |
+
.spectrum-cell-3 {{ background: #b8dfd1; }}
|
| 589 |
+
.spectrum-name {{ font-weight: 700; font-size: 0.95em; }}
|
| 590 |
+
.spectrum-sub {{ font-size: 0.8em; color: #475569; }}
|
| 591 |
+
.spectrum-arrow {{ width: 16px; background: transparent; display:flex; align-items:center; justify-content:center; color: #94a3b8; }}
|
| 592 |
"""
|
| 593 |
|
| 594 |
+
SPECTRUM_BAR = """
|
| 595 |
+
<div class="spectrum-bar">
|
| 596 |
+
<div class="spectrum-cell spectrum-cell-1">
|
| 597 |
+
<div class="spectrum-name">Cooc</div>
|
| 598 |
+
<div class="spectrum-sub">recipe co-occurrence; neighbours = recipe companions</div>
|
| 599 |
+
</div>
|
| 600 |
+
<div class="spectrum-arrow">→</div>
|
| 601 |
+
<div class="spectrum-cell spectrum-cell-2">
|
| 602 |
+
<div class="spectrum-name">Core</div>
|
| 603 |
+
<div class="spectrum-sub">blended; concentrated geometry; tightest emergent modes</div>
|
| 604 |
+
</div>
|
| 605 |
+
<div class="spectrum-arrow">→</div>
|
| 606 |
+
<div class="spectrum-cell spectrum-cell-3">
|
| 607 |
+
<div class="spectrum-name">Chem</div>
|
| 608 |
+
<div class="spectrum-sub">FlavorDB compound metapaths; neighbours = flavour-profile peers</div>
|
| 609 |
+
</div>
|
| 610 |
</div>
|
| 611 |
"""
|
| 612 |
|
| 613 |
+
# ===== Pre-rendered killer demo on landing =====
|
| 614 |
|
| 615 |
+
_DEFAULT_BASKET = ["chicken","lemon","garlic"]
|
| 616 |
+
_INIT_NB_COOC, _INIT_NB_CORE, _INIT_NB_CHEM, _INIT_HEATMAP, _INIT_MD_COOC, _INIT_MD_CORE, _INIT_MD_CHEM = explore_all_siblings(_DEFAULT_BASKET, 8)
|
| 617 |
+
_INIT_UMAP = umap_view("chem", _DEFAULT_BASKET, True, 8)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 618 |
|
| 619 |
# ===== UI =====
|
| 620 |
|
| 621 |
with gr.Blocks(title="Epicure Explorer", theme=THEME, css=CUSTOM_CSS) as demo:
|
| 622 |
|
|
|
|
|
|
|
| 623 |
gr.Markdown(
|
| 624 |
"""# Epicure Explorer
|
| 625 |
+
Three sibling ingredient embeddings from [arXiv:2605.22391](https://arxiv.org/abs/2605.22391).
|
| 626 |
+
1,790 canonical ingredients across 7 languages; 300-D Metapath2Vec; controlled chemistry-vs-recipe-context spectrum.
|
| 627 |
+
"""
|
| 628 |
)
|
| 629 |
+
gr.HTML(SPECTRUM_BAR)
|
| 630 |
|
| 631 |
+
with gr.Tabs():
|
|
|
|
|
|
|
| 632 |
|
| 633 |
+
# ---------- Tab 1: EXPLORE ----------
|
| 634 |
+
with gr.Tab("Explore"):
|
| 635 |
+
gr.Markdown("Pick ingredients. See nearest neighbours in **all three siblings side-by-side** so the spectrum shows in one screen.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 636 |
with gr.Row():
|
| 637 |
+
ex_basket = gr.Dropdown(choices=ALL_INGREDIENTS, value=_DEFAULT_BASKET,
|
| 638 |
+
label="Ingredient basket", multiselect=True, max_choices=10,
|
| 639 |
+
scale=4)
|
| 640 |
+
ex_k = gr.Slider(3, 15, value=8, step=1, label="K", scale=1)
|
| 641 |
with gr.Row():
|
| 642 |
+
ex_fg = gr.Radio(choices=FOOD_GROUP_CHOICES, value="All",
|
| 643 |
+
label="Filter dropdown by food group", interactive=True, scale=3)
|
| 644 |
+
ex_btn = gr.Button("Find neighbours", variant="primary", scale=1)
|
| 645 |
+
ex_fg.change(_filter_dropdown, inputs=[ex_fg, ex_basket], outputs=ex_basket, show_progress="hidden")
|
| 646 |
+
|
| 647 |
gr.Examples(
|
| 648 |
examples=[
|
| 649 |
+
[["chicken","lemon","garlic"], 8],
|
| 650 |
+
[["miso","ginger","sesame_oil"], 8],
|
| 651 |
+
[["tomato","basil","mozzarella_cheese"], 8],
|
| 652 |
+
[["chocolate","strawberry","cream"], 8],
|
| 653 |
+
[["cumin","coriander","turmeric"], 8],
|
| 654 |
+
[["coconut_milk","lemongrass","fish_sauce"], 8],
|
| 655 |
+
[["red_wine","beef","rosemary"], 8],
|
|
|
|
| 656 |
],
|
| 657 |
+
inputs=[ex_basket, ex_k],
|
| 658 |
+
label="Try a basket (one click)",
|
| 659 |
)
|
| 660 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 661 |
with gr.Row():
|
| 662 |
+
ex_nb_cooc = gr.Dataframe(value=_INIT_NB_COOC, headers=["Cooc","cos"],
|
| 663 |
+
label="Cooc (recipe-context)", interactive=False)
|
| 664 |
+
ex_nb_core = gr.Dataframe(value=_INIT_NB_CORE, headers=["Core","cos"],
|
| 665 |
+
label="Core (blended)", interactive=False)
|
| 666 |
+
ex_nb_chem = gr.Dataframe(value=_INIT_NB_CHEM, headers=["Chem","cos"],
|
| 667 |
+
label="Chem (chemistry)", interactive=False)
|
| 668 |
+
|
| 669 |
+
with gr.Accordion("Closest modes (per sibling)", open=False):
|
| 670 |
+
with gr.Row():
|
| 671 |
+
ex_md_cooc = gr.Dataframe(value=_INIT_MD_COOC, headers=["id","label","kind","cos"],
|
| 672 |
+
label="Cooc top modes", interactive=False, wrap=True)
|
| 673 |
+
ex_md_core = gr.Dataframe(value=_INIT_MD_CORE, headers=["id","label","kind","cos"],
|
| 674 |
+
label="Core top modes", interactive=False, wrap=True)
|
| 675 |
+
ex_md_chem = gr.Dataframe(value=_INIT_MD_CHEM, headers=["id","label","kind","cos"],
|
| 676 |
+
label="Chem top modes", interactive=False, wrap=True)
|
| 677 |
+
|
| 678 |
+
with gr.Accordion("Pairwise coherence (basket members)", open=False):
|
| 679 |
+
ex_heat = gr.Plot(value=_INIT_HEATMAP, label="Heatmap")
|
| 680 |
+
|
| 681 |
+
with gr.Accordion("Browse the mode atlas (150-200 modes per sibling)", open=False):
|
| 682 |
+
with gr.Row():
|
| 683 |
+
atlas_sib = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling")
|
| 684 |
+
atlas_kind = gr.Radio(choices=["all","factor","continuous","binary"], value="all", label="Kind")
|
| 685 |
+
atlas_q = gr.Textbox(label="Search labels", placeholder="e.g. South Asian, baking", scale=2)
|
| 686 |
+
atlas_btn = gr.Button("Browse", variant="primary")
|
| 687 |
+
atlas_table = gr.Dataframe(
|
| 688 |
+
headers=["mode_id","kind","property","label","n_members","top members"],
|
| 689 |
+
interactive=False, wrap=True,
|
| 690 |
+
)
|
| 691 |
+
atlas_btn.click(browse_modes, inputs=[atlas_sib, atlas_kind, atlas_q], outputs=atlas_table)
|
| 692 |
+
|
| 693 |
+
ex_btn.click(
|
| 694 |
+
explore_all_siblings,
|
| 695 |
+
inputs=[ex_basket, ex_k],
|
| 696 |
+
outputs=[ex_nb_cooc, ex_nb_core, ex_nb_chem, ex_heat, ex_md_cooc, ex_md_core, ex_md_chem],
|
| 697 |
+
show_progress="minimal",
|
| 698 |
)
|
| 699 |
|
| 700 |
+
# ---------- Tab 2: TRANSFORM ----------
|
| 701 |
+
with gr.Tab("Transform"):
|
| 702 |
+
gr.Markdown("Rotate the basket toward a direction, an emergent mode, or compute `basket - negatives`. **All three operators on one form.**")
|
| 703 |
+
with gr.Row():
|
| 704 |
+
tx_sib = gr.Radio(choices=["cooc","core","chem"], value="core", label="Sibling")
|
| 705 |
+
tx_op = gr.Radio(
|
| 706 |
+
choices=["Rotate to supervised direction","Rotate to emergent mode","Arithmetic (basket - negatives)"],
|
| 707 |
+
value="Arithmetic (basket - negatives)", label="Operation",
|
| 708 |
+
)
|
|
|
|
| 709 |
with gr.Row():
|
| 710 |
+
tx_basket = gr.Dropdown(choices=ALL_INGREDIENTS, value=["miso"], label="Basket / positives",
|
| 711 |
+
multiselect=True, max_choices=10, scale=3)
|
| 712 |
+
tx_neg = gr.Dropdown(choices=ALL_INGREDIENTS, value=["salt"], label="Negatives (Arithmetic only)",
|
| 713 |
+
multiselect=True, max_choices=10, scale=2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 714 |
with gr.Row():
|
| 715 |
+
tx_dirs = gr.Dropdown(choices=_supervised_choices("core"), value=[],
|
| 716 |
+
label="Supervised directions (for 'Rotate to supervised')",
|
| 717 |
+
multiselect=True, max_choices=5, scale=3)
|
| 718 |
+
tx_modes = gr.Dropdown(choices=[lab for lab, _ in _factor_mode_choices("core")], value=[],
|
| 719 |
+
label="Factor modes (for 'Rotate to emergent')",
|
| 720 |
+
multiselect=True, max_choices=5, scale=3)
|
| 721 |
+
with gr.Row():
|
| 722 |
+
tx_theta = gr.Slider(0, 90, value=30, step=5, label="Rotation angle (deg, SLERP only)", scale=2)
|
| 723 |
+
tx_k = gr.Slider(3, 15, value=8, step=1, label="K", scale=1)
|
| 724 |
+
tx_btn = gr.Button("Run", variant="primary", scale=1)
|
| 725 |
+
tx_sib.change(lambda s: gr.Dropdown(choices=_supervised_choices(s), value=[]),
|
| 726 |
+
inputs=tx_sib, outputs=tx_dirs)
|
| 727 |
+
tx_sib.change(lambda s: gr.Dropdown(choices=[lab for lab, _ in _factor_mode_choices(s)], value=[]),
|
| 728 |
+
inputs=tx_sib, outputs=tx_modes)
|
| 729 |
+
tx_table = gr.Dataframe(headers=["Ingredient","cos"], label="Top-K result", interactive=False)
|
| 730 |
+
tx_why = gr.Markdown()
|
| 731 |
+
tx_btn.click(
|
| 732 |
+
transform,
|
| 733 |
+
inputs=[tx_sib, tx_op, tx_basket, tx_dirs, tx_modes, tx_theta, tx_neg, tx_k],
|
| 734 |
+
outputs=[tx_table, tx_why], show_progress="minimal",
|
| 735 |
+
)
|
| 736 |
gr.Examples(
|
| 737 |
examples=[
|
| 738 |
+
["core", "Arithmetic (basket - negatives)", ["miso"], [], [], 30, ["salt"], 8],
|
| 739 |
+
["core", "Arithmetic (basket - negatives)", ["coffee"], [], [], 30, ["milk"], 8],
|
| 740 |
+
["chem", "Arithmetic (basket - negatives)", ["chocolate"], [], [], 30, ["sugar"], 8],
|
| 741 |
+
["chem", "Rotate to supervised direction", ["rice"], ["cuisine:South_Asian"], [], 30, [], 8],
|
| 742 |
+
["chem", "Rotate to supervised direction", ["corn"], ["cuisine:Latin_American"], [], 30, [], 8],
|
|
|
|
|
|
|
|
|
|
| 743 |
],
|
| 744 |
+
inputs=[tx_sib, tx_op, tx_basket, tx_dirs, tx_modes, tx_theta, tx_neg, tx_k],
|
| 745 |
+
label="Try one of these",
|
| 746 |
)
|
| 747 |
|
| 748 |
+
# ---------- Tab 3: MAP ----------
|
| 749 |
+
with gr.Tab("Map"):
|
| 750 |
+
gr.Markdown("UMAP of the 1,790-ingredient embedding (cosine, n_neighbors=30, min_dist=0.03; paper Fig 1).")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 751 |
with gr.Row():
|
| 752 |
+
map_sib = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling", scale=1)
|
| 753 |
+
map_basket = gr.Dropdown(choices=ALL_INGREDIENTS, value=_DEFAULT_BASKET,
|
| 754 |
+
label="Highlight basket", multiselect=True, max_choices=10, scale=3)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 755 |
with gr.Row():
|
| 756 |
+
map_3d = gr.Checkbox(value=False, label="3-D")
|
| 757 |
+
map_nb = gr.Checkbox(value=True, label="Show top-K neighbours")
|
| 758 |
+
map_k = gr.Slider(3, 20, value=10, step=1, label="K", scale=1)
|
| 759 |
+
map_btn = gr.Button("Update", variant="primary", scale=1)
|
| 760 |
+
map_plot = gr.Plot(value=_INIT_UMAP, label="UMAP")
|
| 761 |
+
map_btn.click(umap_view, inputs=[map_sib, map_basket, map_nb, map_k, map_3d], outputs=map_plot,
|
| 762 |
+
show_progress="minimal")
|
| 763 |
+
|
| 764 |
+
# ---------- Tab 4: FROM TEXT ----------
|
| 765 |
+
with gr.Tab("From text"):
|
| 766 |
+
gr.Markdown("Paste a **shopping list / recipe ingredients** to get canonical matches, **or a dish description** to get thematic suggestions. Send the result into the Explore tab.")
|
| 767 |
+
ft_text = gr.Textbox(
|
| 768 |
+
label="Free text",
|
| 769 |
+
lines=6,
|
| 770 |
+
value="I'm making Thai green curry for 4 people",
|
| 771 |
+
placeholder=("Either a dish description ('I'm making Thai green curry for 4'), or "
|
| 772 |
+
"an ingredient list ('2 chicken thighs / 1 cup coconut milk / fish sauce / ...')"),
|
| 773 |
)
|
| 774 |
+
ft_mode = gr.Radio(
|
| 775 |
+
choices=["Recipe / dish description", "Ingredient list (shopping list / fridge)"],
|
| 776 |
+
value="Recipe / dish description",
|
| 777 |
+
label="Treat as",
|
|
|
|
|
|
|
| 778 |
)
|
|
|
|
| 779 |
with gr.Row():
|
| 780 |
+
ft_sib = gr.Radio(choices=["cooc","core","chem"], value="chem", label="Sibling")
|
| 781 |
+
ft_btn = gr.Button("Match", variant="primary")
|
| 782 |
+
ft_send = gr.Button("Send to Explore", variant="secondary")
|
| 783 |
+
ft_table = gr.Dataframe(headers=["Input","Match","Score"], interactive=False, label="Matched ingredients")
|
| 784 |
+
ft_expl = gr.Markdown()
|
| 785 |
+
ft_matched = gr.State([])
|
| 786 |
+
ft_btn.click(parse_or_suggest, inputs=[ft_text, ft_sib, ft_mode],
|
| 787 |
+
outputs=[ft_table, ft_expl, ft_matched], show_progress="full")
|
| 788 |
+
ft_send.click(lambda names: gr.Dropdown(value=(names or [])[:10]),
|
| 789 |
+
inputs=[ft_matched], outputs=[ex_basket])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 790 |
gr.Examples(
|
| 791 |
examples=[
|
| 792 |
+
["I'm making Thai green curry for 4 people", "Recipe / dish description"],
|
| 793 |
+
["spicy vegetarian taco filling", "Recipe / dish description"],
|
| 794 |
+
["Japanese miso-glazed salmon and greens", "Recipe / dish description"],
|
| 795 |
+
["2 boneless chicken thighs\n1 cup coconut milk\n1 tbsp fish sauce\nfresh lemongrass\n3 cloves garlic\njuice of one lime",
|
| 796 |
+
"Ingredient list (shopping list / fridge)"],
|
| 797 |
],
|
| 798 |
+
inputs=[ft_text, ft_mode],
|
| 799 |
+
label="Try one of these",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 800 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 801 |
|
| 802 |
+
# ---- Hidden API endpoints ----
|
| 803 |
with gr.Group(visible=False):
|
| 804 |
api_in_s1 = gr.Textbox(visible=False)
|
| 805 |
api_in_s2 = gr.Textbox(visible=False)
|
| 806 |
+
api_in_n = gr.Number(visible=False, value=5)
|
| 807 |
api_in_n2 = gr.Number(visible=False, value=30)
|
| 808 |
api_in_l1 = gr.JSON(visible=False, value=[])
|
| 809 |
api_in_l2 = gr.JSON(visible=False, value=[])
|
| 810 |
+
api_out = gr.JSON(visible=False)
|
| 811 |
+
gr.Button(visible=False).click(api_neighbors, inputs=[api_in_s1, api_in_s2, api_in_n], outputs=api_out, api_name="neighbors")
|
| 812 |
+
gr.Button(visible=False).click(api_slerp, inputs=[api_in_s1, api_in_s2, api_in_n2, gr.Textbox(visible=False, value="chem"), api_in_n], outputs=api_out, api_name="slerp")
|
| 813 |
+
gr.Button(visible=False).click(api_arithmetic, inputs=[api_in_l1, api_in_l2, api_in_s1, api_in_n], outputs=api_out, api_name="arithmetic")
|
| 814 |
+
gr.Button(visible=False).click(api_embed, inputs=[api_in_s1, api_in_s2], outputs=api_out, api_name="embed")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 815 |
|
| 816 |
gr.Markdown(
|
| 817 |
"""---
|
| 818 |
+
**Cite:** Radzikowski and Chen, 2026, *Epicure: Navigating the Emergent Geometry of Food Ingredient Embeddings*, [arXiv:2605.22391](https://arxiv.org/abs/2605.22391).
|
| 819 |
+
Models: [epicure-cooc](https://huggingface.co/Kaikaku/epicure-cooc) 路 [epicure-core](https://huggingface.co/Kaikaku/epicure-core) 路 [epicure-chem](https://huggingface.co/Kaikaku/epicure-chem) 路 [dataset](https://huggingface.co/datasets/Kaikaku/epicure-corpus-resources) 路 [API](/?view=api)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 820 |
"""
|
| 821 |
)
|
| 822 |
|