Download app.py from tirtho149/HealthBot: direct link, hf CLI and curl.
- Browser
- Download file 26.8 kB
-
https://huggingface.co/spaces/tirtho149/HealthBot/resolve/main/app.py
- Command line
-
hf download hf://spaces/tirtho149/HealthBot/app.py
-
curl -L -o app.py https://huggingface.co/spaces/tirtho149/HealthBot/resolve/main/app.py
26.8 kB
| """ | |
| SyndemicGPS / HealthBot — geography-conditioned syndemic health reasoning. | |
| A multi-panel conversational prototype: | |
| centre the conversation | |
| right context evidence, uncertainty decomposition, contextual pressure | |
| The language model parses the request and writes the prose. Every number it is | |
| allowed to say is computed by `syndemic/engine.py` and handed to it in a FACTS | |
| block. With no API key configured the app still runs, in deterministic mode. | |
| """ | |
| from __future__ import annotations | |
| import base64 | |
| import copy | |
| import os | |
| from pathlib import Path | |
| # `spaces` must be imported before gradio on ZeroGPU hardware. | |
| try: # pragma: no cover - only present on HF Spaces | |
| import spaces # noqa: F401 | |
| _HAS_SPACES = True | |
| except Exception: | |
| _HAS_SPACES = False | |
| import gradio as gr | |
| from syndemic import engine as E | |
| from syndemic import ops, panels | |
| from syndemic.llm import LLM, BY_ID, CATALOG, choices as model_choices | |
| LLM_CLIENT = LLM() | |
| if _HAS_SPACES: # ZeroGPU requires at least one GPU-decorated entry point. | |
| def _gpu_probe() -> str: | |
| return "ok" | |
| def _logo_uri(name: str) -> str: | |
| """Inline a logo as a base64 data URI — no static-path serving needed on Spaces.""" | |
| try: | |
| raw = (Path(__file__).parent / "assets" / "logos" / name).read_bytes() | |
| mime = "image/svg+xml" if name.endswith(".svg") else "image/png" | |
| return f"data:{mime};base64," + base64.b64encode(raw).decode() | |
| except Exception: | |
| return "" | |
| # Institutional strip at the very top. The cards stay white in both themes — | |
| # both marks are drawn for a light backing. | |
| LOGO_STRIP = f""" | |
| <div class="sg-logos"> | |
| <div class="sg-logo"><img src="{_logo_uri('ihe-du.png')}" | |
| alt="Institute of Health Economics, University of Dhaka"/></div> | |
| <div class="sg-logo"><img src="{_logo_uri('isu.svg')}" alt="Iowa State University"/></div> | |
| </div> | |
| """ | |
| CSS = """ | |
| :root{ | |
| --sg-accent:#1D4E89; --sg-geo:#6A4C93; --sg-crit:#AE382C; --sg-warn:#B0701A; --sg-ok:#2F7D53; | |
| --sg-off:#94A1AD; --sg-ink:#131F2B; --sg-ink2:#475663; --sg-ink3:#6E7C8A; | |
| --sg-line:#E3E7EC; --sg-panel:#F7F8FA; --sg-bubble:#F0F2F5; | |
| } | |
| .dark{ | |
| --sg-accent:#6FA6E9; --sg-geo:#B896E9; --sg-crit:#E8776A; --sg-warn:#DFA445; --sg-ok:#5FBF8B; | |
| --sg-off:#5B6875; --sg-ink:#E7EDF3; --sg-ink2:#A5B2BE; --sg-ink3:#7C8A97; | |
| --sg-line:#232C35; --sg-panel:#171E26; --sg-bubble:#1D262F; | |
| } | |
| .gradio-container{max-width:1580px!important} | |
| .sg-rail{background:var(--sg-panel);border-radius:10px;padding:10px 12px!important} | |
| .sg-rail .gap{gap:8px!important} | |
| #sg-head{padding:0 0 6px} | |
| #sg-head h1{font-size:17px} | |
| #sg-head p{font-size:11.5px;max-width:70ch} | |
| footer{display:none!important} | |
| /* --- institutional logo strip --- */ | |
| .sg-logos{display:flex;gap:14px;align-items:stretch;justify-content:center;padding:2px 0 12px;flex-wrap:wrap} | |
| .sg-logo{display:flex;align-items:center;justify-content:center;background:#fff;border-radius:10px; | |
| padding:11px 20px;box-shadow:0 1px 4px rgba(16,24,32,.12)} | |
| .sg-logo img{max-height:46px;max-width:100%;height:auto;width:auto;display:block} | |
| @media (max-width:700px){.sg-logo img{max-height:34px}} | |
| /* --- masthead --- */ | |
| #sg-head{display:flex;align-items:center;gap:12px;padding:2px 4px 8px} | |
| #sg-head .sg-mark{width:34px;height:34px;border-radius:9px;display:grid;place-items:center;flex:none; | |
| background:var(--sg-accent);color:#fff;font-weight:600;font-size:15px;letter-spacing:.02em} | |
| #sg-head h1{margin:0;font-size:19px;font-weight:700;line-height:1.15;color:var(--sg-ink)} | |
| #sg-head p{margin:0;font-size:12px;color:var(--sg-ink3);max-width:78ch} | |
| /* --- shared atoms --- */ | |
| .sg-eyebrow{font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--sg-ink3); | |
| display:block;margin-bottom:6px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace} | |
| .sg-pill{display:inline-flex;align-items:center;font-family:ui-monospace,SFMono-Regular,Menlo,monospace; | |
| font-size:9.5px;letter-spacing:.07em;text-transform:uppercase;padding:2px 7px;border-radius:3px; | |
| border:1px solid var(--sg-line);color:var(--sg-ink2);white-space:nowrap} | |
| .sg-pill.sg-acc{color:var(--sg-accent);border-color:var(--sg-accent)} | |
| .sg-pill.sg-geo{color:var(--sg-geo);border-color:var(--sg-geo)} | |
| .sg-pill.sg-amber{color:var(--sg-warn);border-color:var(--sg-warn)} | |
| .sg-num{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-variant-numeric:tabular-nums; | |
| font-size:11px;color:var(--sg-ink2)} | |
| .sg-dim{color:var(--sg-ink3)} | |
| .sg-note{font-size:10.5px;color:var(--sg-ink3);margin:6px 0 0;line-height:1.45} | |
| .sg-panel{padding:2px 0 8px} | |
| /* --- status strip --- */ | |
| .sg-status{display:flex;flex-wrap:wrap;gap:7px;align-items:center;padding:5px 0} | |
| .sg-status .sg-arrow{color:var(--sg-ink3);font-size:12px} | |
| /* --- safety --- */ | |
| .sg-safety{border:1px solid;border-radius:6px;padding:8px 10px;font-size:11.5px;display:block} | |
| .sg-safety b{display:block;font-size:12px;margin:2px 0 2px;color:var(--sg-ink)} | |
| .sg-safety span:last-child{color:var(--sg-ink2)} | |
| .sg-green{border-color:var(--sg-ok);background:color-mix(in srgb,var(--sg-ok) 8%,transparent);color:var(--sg-ok)} | |
| .sg-amber{border-color:var(--sg-warn);background:color-mix(in srgb,var(--sg-warn) 8%,transparent);color:var(--sg-warn)} | |
| .sg-red{border-color:var(--sg-crit);background:color-mix(in srgb,var(--sg-crit) 10%,transparent);color:var(--sg-crit)} | |
| /* --- canvas --- */ | |
| .sg-graph{display:block;width:100%;height:auto} | |
| .sg-graph text{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;fill:var(--sg-ink2)} | |
| .sg-graph .sg-lab{font-size:10px} | |
| .sg-graph .sg-val{font-size:9.5px;font-weight:500;fill:var(--sg-ink2)} | |
| .sg-graph .sg-layer{font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;fill:var(--sg-ink3)} | |
| .sg-graph .sg-dom{font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;fill:var(--sg-ink3)} | |
| /* --- evidence table --- */ | |
| .sg-table{border-collapse:collapse;width:100%;font-size:11.5px} | |
| .sg-table th{text-align:left;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:500; | |
| font-size:8.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--sg-ink3); | |
| padding:0 6px 4px 0;border-bottom:1px solid var(--sg-line)} | |
| .sg-table td{padding:4px 6px 4px 0;border-bottom:1px dotted var(--sg-line);vertical-align:top;color:var(--sg-ink2)} | |
| .sg-table tr.sg-out td{color:var(--sg-off)} | |
| .sg-lvl{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:8.5px;letter-spacing:.06em; | |
| text-transform:uppercase;color:var(--sg-ink3)} | |
| .sg-lvl.sg-back{color:var(--sg-warn)} | |
| .sg-bar{height:3px;border-radius:2px;background:var(--sg-line);position:relative;margin-top:3px;width:38px} | |
| .sg-bar i{position:absolute;inset:0 auto 0 0;border-radius:2px;background:var(--sg-geo)} | |
| .sg-out .sg-bar i{background:var(--sg-off)} | |
| /* --- uncertainty + pressure --- */ | |
| .sg-ubar{display:grid;grid-template-columns:136px 1fr 36px;align-items:center;gap:8px;margin-bottom:5px} | |
| .sg-ulab{font-size:11.5px;color:var(--sg-ink2)} | |
| .sg-ulab code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:10.5px;color:var(--sg-ink3)} | |
| .sg-utrack{height:6px;background:var(--sg-line);border-radius:3px;overflow:hidden} | |
| .sg-utrack i{display:block;height:100%;background:linear-gradient(90deg,var(--sg-accent),var(--sg-geo))} | |
| .sg-uval{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;text-align:right;color:var(--sg-ink2)} | |
| .sg-prow{display:grid;grid-template-columns:92px 1fr 28px;align-items:center;gap:7px;font-size:11.5px;margin-bottom:4px} | |
| .sg-pnm{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--sg-ink2)} | |
| .sg-ptrack{height:11px;background:var(--sg-line);border-radius:2px;overflow:hidden} | |
| .sg-ptrack i{display:block;height:100%;background:var(--sg-geo);opacity:.55} | |
| .sg-isa .sg-ptrack i{background:var(--sg-accent);opacity:.92} | |
| .sg-isb .sg-ptrack i{opacity:.92} | |
| .sg-pval{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;text-align:right;color:var(--sg-ink2)} | |
| /* --- chat --- */ | |
| #sg-chat{border-radius:10px} | |
| #sg-chat details{border:1px solid var(--sg-line);border-radius:6px;padding:5px 9px;margin:6px 0;font-size:12px} | |
| #sg-chat details summary{cursor:pointer;color:var(--sg-ink3);font-size:11.5px} | |
| #sg-chat table{font-size:11px} | |
| #sg-chat sub{color:var(--sg-ink3);font-size:10.5px} | |
| .sg-chip button{font-size:11.5px!important;padding:4px 10px!important;min-width:0!important} | |
| /* --- the case (built from the description, not hard-coded) --- */ | |
| .sg-case-h{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px} | |
| .sg-summary{font-size:12.5px;color:var(--sg-ink);margin:0 0 11px;line-height:1.45} | |
| .sg-cgroup{margin-bottom:10px} | |
| .sg-clab{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:8.5px;letter-spacing:.12em; | |
| text-transform:uppercase;color:var(--sg-ink3)} | |
| .sg-cgroup ul{list-style:none!important;margin:5px 0 0!important;padding:0!important; | |
| display:flex;flex-direction:column;gap:4px} | |
| .sg-cgroup li{margin:0!important} | |
| .sg-cn{display:grid;grid-template-columns:26px 1fr;gap:9px;align-items:baseline} | |
| .sg-dots{display:inline-flex;gap:2px;padding-top:5px} | |
| .sg-dots i{width:5px;height:5px;border-radius:50%;background:var(--sg-line);display:block} | |
| .sg-dots i.on{background:var(--sg-accent)} | |
| .sg-cl{font-size:11.5px;color:var(--sg-ink2);line-height:1.35} | |
| .sg-cl em{display:block;font-style:normal;font-size:10px;color:var(--sg-ink3);margin-top:1px} | |
| /* --- ChatGPT-like conversation column --- */ | |
| #sg-chat{border:none!important;background:transparent!important} | |
| #sg-chat .message{border:none!important;box-shadow:none!important;font-size:14px!important; | |
| line-height:1.62!important} | |
| #sg-chat .user-row .message,#sg-chat .user .message{background:var(--sg-bubble)!important; | |
| border-radius:18px!important;padding:10px 15px!important} | |
| #sg-chat .bot-row .message,#sg-chat .bot .message{background:transparent!important;padding:2px 0 8px!important} | |
| #sg-chat p{margin:0 0 9px} | |
| #sg-chat h5{font-size:12.5px;margin:12px 0 4px} | |
| #sg-chat hr{border:none;border-top:1px solid var(--sg-line);margin:14px 0} | |
| .sg-composer{align-items:flex-end} | |
| .sg-composer textarea,.sg-composer input{border-radius:22px!important;padding:12px 17px!important; | |
| font-size:14px!important;min-height:46px!important;line-height:1.4!important} | |
| .sg-composer button{min-height:46px!important} | |
| .sg-composer button{border-radius:20px!important} | |
| .sg-col{max-width:820px;margin:0 auto;width:100%} | |
| /* --- live reasoning --- */ | |
| .sg-think-h{display:flex;align-items:baseline;justify-content:space-between;gap:8px;margin-bottom:8px} | |
| .sg-think-note{font-size:10.5px;color:var(--sg-ink3);font-style:italic} | |
| ol.sg-think{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:1px} | |
| .sg-step{display:grid;grid-template-columns:16px 1fr;gap:8px;align-items:baseline; | |
| padding:5px 0;border-top:1px dotted var(--sg-line)} | |
| .sg-step:first-child{border-top:none} | |
| .sg-mk{font-size:11px;color:var(--sg-ink3);text-align:center;line-height:1.3} | |
| .sg-sn{font-size:11.5px;color:var(--sg-ink2)} | |
| .sg-sr{grid-column:2;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:10px; | |
| color:var(--sg-ink3);word-break:break-word} | |
| .sg-done .sg-mk{color:var(--sg-ok)} | |
| .sg-done .sg-sn{color:var(--sg-ink)} | |
| .sg-run .sg-sn{color:var(--sg-geo);font-weight:600} | |
| .sg-wait{opacity:.42} | |
| .sg-skip{opacity:.42} | |
| .sg-skip .sg-sn{text-decoration:line-through} | |
| .sg-spin{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--sg-geo); | |
| animation:sgpulse 1s ease-in-out infinite} | |
| @keyframes sgpulse{0%,100%{opacity:.3;transform:scale(.75)}50%{opacity:1;transform:scale(1)}} | |
| @media (prefers-reduced-motion:reduce){.sg-spin{animation:none;opacity:.9}} | |
| /* --- rail --- */ | |
| .sg-rail .html-container{overflow:visible!important;padding:0!important} | |
| .sg-rail .block{border:none!important;background:transparent!important} | |
| """ | |
| INTRO = ("Tell me about a person — yourself or someone you are thinking about. Their situation, " | |
| "sleep, money, housing, symptoms. I build the picture from what you say.") | |
| CHIPS = [ | |
| ("Try a different person", | |
| "A 34-year-old nurse on night shifts, exhausted, lonely since moving city, sleeps 5 hours."), | |
| ("Move them to Sweden", "Move this person to Sweden."), | |
| ("Why did that change?", "Why did your answer change?"), | |
| ("Drop geography", "Answer without using geography."), | |
| ("What don't you know?", "What do you not know?"), | |
| ] | |
| DISCLAIMER = ("Research prototype. Support and hypothesis generation only — not diagnosis or treatment. " | |
| "Place data is retrieved live from the World Bank, Open-Meteo and Wikipedia; where no source " | |
| "exists the system abstains rather than estimating.") | |
| # --------------------------------------------------------------------------- # | |
| # Panel rendering | |
| # --------------------------------------------------------------------------- # | |
| def render(st: E.State): | |
| """The live status strip above the conversation. | |
| `hydrate` is idempotent and cached per place, so the first paint fetches the | |
| place's context and every later call is free — without this the opening | |
| screen reported far fewer factors than the place actually has. | |
| """ | |
| E.hydrate(st.a) | |
| ctx = E.resolve(st.a, st.tau, st.cf) | |
| return (panels.status_html(st, ctx, LLM_CLIENT.status()),) | |
| def sync_places(st: E.State): | |
| """Refresh both place pickers — the registry grows as new places resolve.""" | |
| choices = E.place_choices() | |
| return gr.update(choices=choices, value=st.a), gr.update(choices=choices, value=st.b) | |
| def sync_controls(st: E.State): | |
| pa, pb = sync_places(st) | |
| return (pa, pb, gr.update(value=st.tau), gr.update(value=st.geo_off)) | |
| # --------------------------------------------------------------------------- # | |
| # Turn handling | |
| # --------------------------------------------------------------------------- # | |
| def user_turn(message: str, history: list): | |
| message = (message or "").strip() | |
| if not message: | |
| return "", history | |
| return "", history + [{"role": "user", "content": message}] | |
| def bot_turn(history: list, st: E.State, writer: str, parser: str, | |
| compare_on: bool, compare_models: list): | |
| """Parse -> execute tools -> verbalise, reporting each stage as it happens. | |
| Yields (chat, status, thinking) so the right-hand panel shows | |
| the §15 pipeline advancing in real time rather than after the fact. | |
| """ | |
| rows = [dict(s=x, r="", state="wait") for x in panels.STAGES] | |
| def think(note=""): | |
| return panels.thinking_html(rows, note=note) | |
| if not history or history[-1]["role"] != "user": | |
| yield history, *render(st), think() | |
| return | |
| question = history[-1]["content"] | |
| # ---- stage 1: parse ------------------------------------------------- | |
| rows[0]["state"] = "run" | |
| history = history + [{"role": "assistant", "content": "_working…_"}] | |
| yield history, *render(st), think("reading your question") | |
| rx = ops.regex_intent(question, has_case=bool(st.case)) | |
| # The model is only asked to classify what the deterministic router could not, | |
| # which also saves a round trip on most turns. | |
| parsed = (LLM_CLIENT.parse_intent(question, model=parser) | |
| if LLM_CLIENT.available and rx["op"] == "chat" else None) | |
| intent = ops.merge_intent(parsed, rx, question) | |
| parser_used = (parsed or {}).get("_parser_model") or "rule router" | |
| if intent["op"] == "set_case" and LLM_CLIENT.available: | |
| intent["_case"] = LLM_CLIENT.extract_case(question, model=parser) | |
| rows[0].update(state="done", r=f"op {intent['op']} · {parser_used}") | |
| # ---- stages 2-7: the engine ---------------------------------------- | |
| for i in range(1, 7): | |
| rows[i]["state"] = "run" | |
| yield history, *render(st), think("running the tools") | |
| result = ops.execute(st, intent, question) | |
| for i, tr in enumerate(result.trace[1:7], start=1): | |
| rows[i].update(state="skip" if tr["skip"] else "done", r=tr["r"]) | |
| rows[6].update(state="done", r=st.tier().upper()) | |
| header = f"**{result.title}**\n\n" if result.title else "" | |
| # The prose is the answer. The structured listing is the working — kept for | |
| # inspection, folded away so it stops competing with what the person asked. | |
| working = (f"<details><summary>Show the working</summary>\n\n{result.body}\n</details>" | |
| if result.body else "") | |
| tail = "\n\n".join(x for x in [working, ops._md_sources(result.passages), | |
| panels.trace_md(result.trace)] if x) | |
| if result.passages: | |
| rows[2]["r"] = (rows[2]["r"] or "") + f" + {len(result.passages)} passages" | |
| # ---- stage 8: write -------------------------------------------------- | |
| picks = [m for m in (compare_models or []) if m] | |
| multi = compare_on and LLM_CLIENT.available and len(picks) >= 2 and not result.safety | |
| rows[7].update(state="run", r=(f"{len(picks)} models in parallel" if multi else (writer or LLM_CLIENT.model))) | |
| yield history, *render(st), think("writing the answer") | |
| if multi: | |
| out = LLM_CLIENT.compare(result.facts, question, picks, safety=result.safety) | |
| blocks = [] | |
| for mid, text, usage in out: | |
| m = BY_ID.get(mid) | |
| name = m.label if m else mid | |
| blocks.append(f"##### {name}\n`{usage.badge()}`\n\n{text.strip() or '_' + (usage.error or 'no output') + '_'}") | |
| history[-1]["content"] = (header + "<sub>Same FACTS block — only the writer model differs.</sub>\n\n" | |
| + "\n\n---\n\n".join(blocks) + "\n\n" + tail) | |
| rows[7].update(state="done", r=f"{len(picks)} models compared") | |
| yield history, *render(st), think("done") | |
| return | |
| prose, usage = "", None | |
| if LLM_CLIENT.available: | |
| for chunk, u in LLM_CLIENT.stream_answer(result.facts, question, | |
| safety=result.safety, model=writer): | |
| prose, usage = chunk, u | |
| if prose: | |
| rows[7]["r"] = f"{u.model} · {u.seconds:.0f}s · {len(prose)} chars" | |
| history[-1]["content"] = header + prose | |
| yield history, *render(st), think("writing the answer") | |
| if not prose: | |
| prose = _fallback_prose(result, usage) | |
| rows[7].update(state="skip", r=(usage.error if usage and usage.error else "no model")) | |
| badge = "" | |
| else: | |
| rows[7].update(state="done", r=usage.badge() if usage else "") | |
| badge = f"\n\n<sub>`{usage.badge()}`</sub>" if usage else "" | |
| history[-1]["content"] = header + prose + badge + "\n\n" + tail | |
| yield history, *render(st), think("done") | |
| def _fallback_prose(result: ops.Result, usage=None) -> str: | |
| if not LLM_CLIENT.api_key: | |
| return ("_Deterministic mode — no language model is configured, so this answer is the engine's " | |
| "structured output without a written summary. The reasoning below is unaffected._") | |
| err = (usage.error if usage and usage.error else LLM_CLIENT.last_error) or "unknown error" | |
| return f"_Every model in the fallback chain failed ({err}). Structured output only._" | |
| # --------------------------------------------------------------------------- # | |
| # Control handlers | |
| # --------------------------------------------------------------------------- # | |
| def set_places(a, b, st: E.State): | |
| """Accepts a registry id or any free-text place; resolves and fetches on demand.""" | |
| st.a = E.ensure_place(a) or st.a | |
| st.b = E.ensure_place(b) or st.b | |
| st.cf, st.cf_label = None, "" | |
| return (st, *render(st), *sync_places(st)) | |
| def set_tau(tau, st: E.State): | |
| st.tau = float(tau) | |
| return (st, *render(st)) | |
| def set_ablate(flag, st: E.State): | |
| st.geo_off = bool(flag) | |
| return (st, *render(st)) | |
| def clear_cf(st: E.State): | |
| st.cf, st.cf_label = None, "" | |
| return (st, *render(st)) | |
| def load_scenario(name: str, st: E.State): | |
| sc = next((s for s in E.SCENARIOS if s["n"] == name), None) | |
| if not sc: | |
| return (st, [], *render(st), *sync_controls(st)) | |
| st.a, st.b, st.tau = sc["a"], sc["b"], sc["tau"] | |
| st.cf, st.cf_label, st.geo_off = None, "", False | |
| st.set_case(copy.deepcopy(E.EXAMPLE_CASE["nodes"]), E.EXAMPLE_CASE["summary"]) | |
| if sc["si"]: | |
| st.set_sev("si_flag", 1) | |
| hist = [{"role": "assistant", | |
| "content": f"**Scenario loaded — {sc['n']}**\n\n{sc['d']}. The person is unchanged; " | |
| f"the evidence gate is at τ = {sc['tau']:.2f}.\n\nAsk me: _{sc['q']}_"}] | |
| return (st, hist, *render(st), *sync_controls(st)) # 2 panels + 4 controls | |
| # --------------------------------------------------------------------------- # | |
| # UI | |
| # --------------------------------------------------------------------------- # | |
| _INIT = render(E.State()) # first paint is correct before any event fires | |
| MASTHEAD = ('<div id="sg-head"><div class="sg-mark">S</div><div>' | |
| '<h1>SyndemicGPS</h1>' | |
| '<p>Geography-conditioned syndemic health reasoning</p></div></div>') | |
| with gr.Blocks(title="SyndemicGPS", css=CSS, fill_height=True, | |
| theme=gr.themes.Soft(primary_hue="slate", secondary_hue="indigo", neutral_hue="slate", | |
| font=["IBM Plex Sans", "system-ui", "sans-serif"], | |
| font_mono=["IBM Plex Mono", "ui-monospace", "monospace"])) as demo: | |
| state = gr.State(E.State()) | |
| gr.HTML(LOGO_STRIP) | |
| with gr.Row(equal_height=False): | |
| # ======================= PANEL 1 — the conversation ======================= | |
| with gr.Column(scale=7, min_width=430, elem_classes="sg-col"): | |
| gr.HTML(MASTHEAD) | |
| status = gr.HTML(_INIT[0]) | |
| chat = gr.Chatbot(type="messages", height=560, elem_id="sg-chat", show_label=False, | |
| value=[{"role": "assistant", "content": INTRO}], | |
| avatar_images=(None, None), sanitize_html=False) | |
| with gr.Row(elem_classes="sg-composer"): | |
| box = gr.Textbox(placeholder="Describe a person, or ask a question", | |
| show_label=False, scale=8, container=False, lines=1, max_lines=5) | |
| btn_send = gr.Button("Send", scale=1, variant="primary") | |
| btn_clear = gr.Button("Clear", scale=1, variant="secondary") | |
| with gr.Row(elem_classes="sg-chip"): | |
| chips = [gr.Button(lab, size="sm", variant="secondary") for lab, _ in CHIPS] | |
| with gr.Accordion("Settings", open=False): | |
| with gr.Row(): | |
| dd_a = gr.Dropdown(E.place_choices(), value="dhaka", label="Place A", | |
| allow_custom_value=True, filterable=True, | |
| info="type any country or city") | |
| dd_b = gr.Dropdown(E.place_choices(), value="stockholm", label="Place B", | |
| allow_custom_value=True, filterable=True, | |
| info="type any country or city") | |
| with gr.Row(): | |
| sl_tau = gr.Slider(0.30, 0.90, value=0.55, step=0.01, label="Evidence gate τ") | |
| cb_geo = gr.Checkbox(value=False, label="Ablate geography") | |
| with gr.Row(): | |
| rd_scen = gr.Radio([s["n"] for s in E.SCENARIOS], value=E.SCENARIOS[0]["n"], | |
| label="Example scenario") | |
| with gr.Row(): | |
| dd_cf = gr.Dropdown([(c["lab"], c["k"]) for c in E.CFS], | |
| label="Structural counterfactual", value=None, scale=3) | |
| btn_cf = gr.Button("Apply", size="sm", variant="primary", scale=1) | |
| btn_cf_clear = gr.Button("Clear", size="sm", scale=1) | |
| with gr.Row(): | |
| dd_model = gr.Dropdown(model_choices(), value=LLM_CLIENT.model, label="Writer model", | |
| allow_custom_value=True, filterable=True) | |
| dd_parser = gr.Dropdown(model_choices(), value=LLM_CLIENT.parser_model, | |
| label="Parser model", allow_custom_value=True, filterable=True) | |
| with gr.Row(): | |
| cb_cmp = gr.Checkbox(value=False, label="Compare models side by side") | |
| dd_cmp = gr.Dropdown(model_choices(), value=["qwen-3.7-max", "deepseek-v4"], | |
| label="Models to compare", multiselect=True, | |
| allow_custom_value=True, filterable=True) | |
| gr.Markdown(f"<sub>{DISCLAIMER}</sub>") | |
| # ====================== PANEL 2 — live reasoning ====================== | |
| with gr.Column(scale=3, min_width=250, elem_classes="sg-rail"): | |
| thinking = gr.HTML(panels.thinking_idle()) | |
| PANELS = [status] | |
| OUT = [chat, *PANELS, thinking] | |
| BOT_IN = [chat, state, dd_model, dd_parser, cb_cmp, dd_cmp] | |
| # ----------------------------------------------------------------- events | |
| box.submit(user_turn, [box, chat], [box, chat]).then( | |
| bot_turn, BOT_IN, OUT).then(sync_places, [state], [dd_a, dd_b]) | |
| btn_send.click(user_turn, [box, chat], [box, chat]).then( | |
| bot_turn, BOT_IN, OUT).then(sync_places, [state], [dd_a, dd_b]) | |
| def _chip(label): | |
| def go(history): | |
| return "", history + [{"role": "user", "content": label}] | |
| return go | |
| for c, (_lab, msg) in zip(chips, CHIPS): | |
| c.click(_chip(msg), [chat], [box, chat]).then( | |
| bot_turn, BOT_IN, OUT).then(sync_places, [state], [dd_a, dd_b]) | |
| btn_clear.click(lambda: ([{"role": "assistant", "content": INTRO}], panels.thinking_idle()), | |
| None, [chat, thinking]) | |
| dd_a.change(set_places, [dd_a, dd_b, state], [state, *PANELS, dd_a, dd_b]) | |
| dd_b.change(set_places, [dd_a, dd_b, state], [state, *PANELS, dd_a, dd_b]) | |
| sl_tau.change(set_tau, [sl_tau, state], [state, *PANELS]) | |
| cb_geo.change(set_ablate, [cb_geo, state], [state, *PANELS]) | |
| rd_scen.change(load_scenario, [rd_scen, state], [state, chat, *PANELS, dd_a, dd_b, sl_tau, cb_geo]) | |
| def cf_click(factor, history): | |
| lab = next((c["lab"] for c in E.CFS if c["k"] == factor), None) | |
| if not lab: | |
| return "", history | |
| return "", history + [{"role": "user", | |
| "content": f"Keep them where they are but {lab[0].lower()}{lab[1:]}."}] | |
| btn_cf.click(cf_click, [dd_cf, chat], [box, chat]).then(bot_turn, BOT_IN, OUT) | |
| btn_cf_clear.click(clear_cf, [state], [state, *PANELS]) | |
| if __name__ == "__main__": | |
| demo.queue(default_concurrency_limit=8).launch( | |
| server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)), show_api=False) | |