| """ |
| Deep Research in Supply Chain — Gradio UI (ChatGPT-inspired skeleton). |
| |
| Multi-channel conversation interface that will wrap the Helicase |
| autonomous research agent. Research step is mocked for now so the |
| UX can be validated before Helicase + DashScope are wired in. |
| |
| Runs on Hugging Face Spaces (free tier) behind an <iframe> on |
| supplychaindatahub.org/deep_research. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import base64 |
| import json |
| import os |
| import time |
| import uuid |
| import zlib |
| from dataclasses import dataclass, field |
| from datetime import datetime |
| from typing import Any, Generator |
| from urllib.parse import quote |
|
|
| import gradio as gr |
| from fastapi import FastAPI |
| from fastapi.responses import HTMLResponse |
|
|
| from research_engine import run_research, _keys_configured, generate_kg_answer |
|
|
| |
| KG_VIEWER_BASE = os.getenv("KG_VIEWER_URL", "/kg") |
|
|
| _HERE = os.path.dirname(os.path.abspath(__file__)) |
| with open(os.path.join(_HERE, "kg_viewer.html"), "r", encoding="utf-8") as _f: |
| _KG_VIEWER_HTML = _f.read() |
|
|
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| def _new_channel() -> dict: |
| return { |
| "id": str(uuid.uuid4())[:8], |
| "title": "New chat", |
| "created_at": datetime.now().isoformat(), |
| "messages": [], |
| "kg_nodes": [], |
| "kg_edges": [], |
| "kg_ref2url": {}, |
| } |
|
|
|
|
| def _empty_state() -> dict: |
| ch = _new_channel() |
| return {"channels": {ch["id"]: ch}, "active": ch["id"]} |
|
|
|
|
| def _hydrate(state) -> dict: |
| """Restore state from BrowserState (may arrive as None or malformed).""" |
| if not isinstance(state, dict) or not state.get("channels"): |
| return _empty_state() |
| |
| if state.get("active") not in state["channels"]: |
| state["active"] = next(iter(state["channels"])) |
| return state |
|
|
|
|
| def _get_channel(state: dict, channel_id: str) -> dict: |
| return state["channels"][channel_id] |
|
|
|
|
| def _sidebar_choices(state: dict) -> list[tuple[str, str]]: |
| items = [] |
| channels = sorted( |
| state["channels"].values(), key=lambda c: c.get("created_at", ""), reverse=True |
| ) |
| for ch in channels: |
| label = ch["title"] if ch["title"] != "New chat" else "New chat" |
| items.append((label, ch["id"])) |
| return items |
|
|
|
|
| |
|
|
| NODE_COLORS = { |
| "company": {"bg": "#4f46e5", "border": "#3730a3"}, |
| "material": {"bg": "#10b981", "border": "#047857"}, |
| "product": {"bg": "#f59e0b", "border": "#b45309"}, |
| "process": {"bg": "#ec4899", "border": "#9d174d"}, |
| "region": {"bg": "#3b82f6", "border": "#1d4ed8"}, |
| "risk": {"bg": "#ef4444", "border": "#991b1b"}, |
| } |
|
|
|
|
| def _render_kg_html(nodes: list[dict], edges: list[dict], kg_id: str, status: str = "", progress: float = 0.0) -> str: |
| """Render an interactive vis-network KG as a self-contained HTML snippet.""" |
| vis_nodes = [] |
| for n in nodes: |
| c = NODE_COLORS.get(n["type"], {"bg": "#64748b", "border": "#334155"}) |
| vis_nodes.append({ |
| "id": n["id"], |
| "label": n["label"], |
| "title": f"{n['type'].title()} · confidence {n.get('confidence', 0.8):.2f}", |
| "group": n["type"], |
| "color": {"background": c["bg"], "border": c["border"]}, |
| "font": {"color": "#fff", "size": 13}, |
| "shape": "dot", |
| "size": 20, |
| }) |
| vis_edges = [{ |
| "from": e["from"], "to": e["to"], "label": e["label"], |
| "font": {"size": 10, "color": "#64748b", "strokeWidth": 0}, |
| "color": {"color": "#cbd5e1"}, |
| "arrows": "to", |
| } for e in edges] |
|
|
| nodes_json = json.dumps(vis_nodes) |
| edges_json = json.dumps(vis_edges) |
|
|
| progress_bar = ( |
| f'<div style="height:4px;background:#e5e7eb;border-radius:999px;overflow:hidden;margin:6px 0 10px 0;">' |
| f'<div style="height:100%;width:{progress*100:.0f}%;background:linear-gradient(90deg,#4f46e5,#ec4899);transition:width 0.6s ease;"></div>' |
| f'</div>' |
| ) if progress < 1.0 else "" |
|
|
| status_badge = f'<span style="font-size:12px;color:#4f46e5;font-weight:600;">{status}</span>' if status else '' |
|
|
| return f""" |
| <div style="border:1px solid #e5e7eb;border-radius:12px;padding:14px;background:#ffffff;margin-top:10px;box-shadow:0 2px 8px rgba(15,23,42,0.04);"> |
| <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;"> |
| <div style="font-weight:600;font-size:14px;color:#0f172a;">🕸️ Knowledge Graph · <span style="color:#64748b;font-weight:500;">{len(nodes)} nodes · {len(edges)} edges</span></div> |
| {status_badge} |
| </div> |
| {progress_bar} |
| <div id="{kg_id}" style="height:360px;border:1px solid #f1f5f9;border-radius:8px;background:#fafbfc;"></div> |
| <div style="display:flex;gap:14px;flex-wrap:wrap;margin-top:8px;font-size:11px;color:#64748b;"> |
| <span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#4f46e5;margin-right:4px;"></span>Company</span> |
| <span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#10b981;margin-right:4px;"></span>Material</span> |
| <span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#f59e0b;margin-right:4px;"></span>Product</span> |
| <span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#3b82f6;margin-right:4px;"></span>Region</span> |
| <span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#ef4444;margin-right:4px;"></span>Risk</span> |
| </div> |
| </div> |
| <script> |
| (function() {{ |
| function init() {{ |
| if (typeof vis === 'undefined') {{ setTimeout(init, 200); return; }} |
| var el = document.getElementById("{kg_id}"); |
| if (!el) return; |
| var nodes = new vis.DataSet({nodes_json}); |
| var edges = new vis.DataSet({edges_json}); |
| var net = new vis.Network(el, {{nodes:nodes, edges:edges}}, {{ |
| physics: {{ stabilization:{{iterations:80}}, barnesHut:{{gravitationalConstant:-2800, springLength:130}} }}, |
| interaction: {{ hover:true, tooltipDelay:120 }}, |
| nodes:{{ borderWidth: 2 }}, |
| edges:{{ smooth:{{type:'dynamic'}} }} |
| }}); |
| }} |
| if (typeof vis === 'undefined') {{ |
| var s = document.createElement('script'); |
| s.src = 'https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js'; |
| s.onload = init; |
| document.head.appendChild(s); |
| }} else {{ |
| init(); |
| }} |
| }})(); |
| </script> |
| """.strip() |
|
|
|
|
| |
| _MOCK_STEPS = [ |
| { |
| "status": "🔍 Iteration 1 · searching the web…", |
| "nodes": [ |
| {"id": "tesla", "label": "Tesla", "type": "company", "confidence": 0.98}, |
| {"id": "cell_4680", "label": "4680 Cell", "type": "product", "confidence": 0.95}, |
| ], |
| "edges": [{"from": "tesla", "to": "cell_4680", "label": "manufactures"}], |
| }, |
| { |
| "status": "🧠 Iteration 2 · extracting materials…", |
| "nodes": [ |
| {"id": "nmc", "label": "NMC Cathode", "type": "material", "confidence": 0.92}, |
| {"id": "anode", "label": "Silicon-Graphite Anode", "type": "material", "confidence": 0.88}, |
| ], |
| "edges": [ |
| {"from": "cell_4680", "to": "nmc", "label": "contains"}, |
| {"from": "cell_4680", "to": "anode", "label": "contains"}, |
| ], |
| }, |
| { |
| "status": "🔎 Iteration 3 · tracing cathode metals…", |
| "nodes": [ |
| {"id": "cobalt", "label": "Cobalt", "type": "material", "confidence": 0.90}, |
| {"id": "nickel", "label": "Nickel", "type": "material", "confidence": 0.91}, |
| {"id": "lithium", "label": "Lithium", "type": "material", "confidence": 0.93}, |
| ], |
| "edges": [ |
| {"from": "nmc", "to": "cobalt", "label": "contains"}, |
| {"from": "nmc", "to": "nickel", "label": "contains"}, |
| {"from": "nmc", "to": "lithium", "label": "contains"}, |
| ], |
| }, |
| { |
| "status": "🧭 Iteration 4 · identifying suppliers…", |
| "nodes": [ |
| {"id": "glencore", "label": "Glencore", "type": "company", "confidence": 0.87}, |
| {"id": "bhp", "label": "BHP", "type": "company", "confidence": 0.86}, |
| {"id": "albemarle", "label": "Albemarle", "type": "company", "confidence": 0.84}, |
| ], |
| "edges": [ |
| {"from": "glencore", "to": "cobalt", "label": "supplies"}, |
| {"from": "bhp", "to": "nickel", "label": "supplies"}, |
| {"from": "albemarle", "to": "lithium", "label": "supplies"}, |
| ], |
| }, |
| { |
| "status": "🌍 Iteration 5 · mapping geographies & risks…", |
| "nodes": [ |
| {"id": "drc", "label": "DR Congo", "type": "region", "confidence": 0.95}, |
| {"id": "australia", "label": "Australia", "type": "region", "confidence": 0.94}, |
| {"id": "risk_esg", "label": "ESG / child-labour risk", "type": "risk", "confidence": 0.80}, |
| ], |
| "edges": [ |
| {"from": "cobalt", "to": "drc", "label": "mined in"}, |
| {"from": "lithium", "to": "australia", "label": "mined in"}, |
| {"from": "cobalt", "to": "risk_esg", "label": "associated with"}, |
| ], |
| }, |
| ] |
|
|
|
|
| def _encode_kg_url(question: str, nodes: list[dict], edges: list[dict], ref2url: dict | None = None) -> str: |
| """Compress the KG into a URL-safe base64 payload for the viewer.""" |
| payload = json.dumps( |
| {"prompt": question, "nodes": nodes, "edges": edges, "ref2url": ref2url or {}}, |
| ensure_ascii=False, separators=(",", ":"), |
| ) |
| compressed = zlib.compress(payload.encode("utf-8"), level=9) |
| b64 = base64.urlsafe_b64encode(compressed).decode("ascii").rstrip("=") |
| return f"{KG_VIEWER_BASE}#data={b64}" |
|
|
|
|
| def _resolve_inline_citations(text: str, ref2url: dict) -> str: |
| """Turn inline [n] citations in the NL answer into clickable links.""" |
| import re |
| def replace(match): |
| idx = match.group(1) |
| ref = (ref2url or {}).get(str(idx)) or {} |
| url = ref.get("url", "") |
| title = (ref.get("title") or "").replace('"', "'") |
| if url: |
| return f"<a href=\"{url}\" target=\"_blank\" rel=\"noopener\" title=\"{title}\" style=\"color:#4f46e5;font-weight:600;text-decoration:none;\">[{idx}]</a>" |
| return f"[{idx}]" |
| return re.sub(r"\[(\d+)\]", replace, text) |
|
|
|
|
| def _summary(question: str, nodes: list[dict], edges: list[dict], iterations: int, converged: bool, reason: str, final_u: float, ref2url: dict | None = None) -> str: |
| type_counts: dict[str, int] = {} |
| for n in nodes: |
| t = n.get("type", "entity") |
| type_counts[t] = type_counts.get(t, 0) + 1 |
| bits = ", ".join(f"**{c}** {t}{'s' if c != 1 else ''}" for t, c in sorted(type_counts.items(), key=lambda x: -x[1])) |
| viewer = _encode_kg_url(question, nodes, edges, ref2url) |
| status = ( |
| f"🧬 **Converged** after {iterations} helical iteration{'s' if iterations != 1 else ''} — {reason}." |
| if converged |
| else f"⏹️ Reached max iterations ({iterations}) without full convergence — {reason}." |
| ) |
|
|
| |
| nl_answer = "" |
| try: |
| raw = generate_kg_answer(question, nodes, edges, ref2url or {}) |
| if raw: |
| nl_answer = _resolve_inline_citations(raw, ref2url or {}) |
| except Exception: |
| nl_answer = "" |
|
|
| answer_block = f"{nl_answer}\n\n---\n\n" if nl_answer else "" |
|
|
| return ( |
| f"{answer_block}" |
| f"**Knowledge graph built**: {len(nodes)} nodes · {len(edges)} edges — {bits}. " |
| f"{status}\n\n" |
| f"<a href=\"{viewer}\" target=\"_blank\" rel=\"noopener\" " |
| "style=\"display:inline-block;background:#4f46e5;color:#fff;padding:10px 18px;" |
| "border-radius:10px;font-weight:600;text-decoration:none;margin-top:4px;\">" |
| "📊 Open Knowledge Graph →</a>\n\n" |
| "<sub>Ask a follow-up to extend the graph — it uses the same KG as context.</sub>" |
| ) |
|
|
|
|
| def _run_research_streaming(question: str, prior_kg_nodes: list[dict] | None = None, prior_kg_edges: list[dict] | None = None, prior_ref2url: dict | None = None |
| ) -> Generator[tuple[str, list[dict], list[dict], dict], None, None]: |
| """Yield ``(chat_text, nodes, edges, ref2url)`` — chat text updates as |
| research proceeds; final yield carries the summary with a link to the |
| /kg/ viewer page plus the full citation map.""" |
| nodes: list[dict] = list(prior_kg_nodes or []) |
| edges: list[dict] = list(prior_kg_edges or []) |
| ref2url: dict = dict(prior_ref2url or {}) |
| seen_ids = {n["id"] for n in nodes} |
|
|
| if _keys_configured(): |
| |
| |
| |
| |
| |
| max_iterations = int(os.getenv("MAX_HELIX_ITERATIONS", "10")) |
| uncertainty_threshold = float(os.getenv("UNCERTAINTY_THRESHOLD", "0.3")) |
| convergence_delta = float(os.getenv("CONVERGENCE_DELTA", "0.05")) |
| convergence_patience = int(os.getenv("CONVERGENCE_PATIENCE", "3")) |
|
|
| yield "🔬 Starting helical research loop…", nodes, edges, ref2url |
|
|
| u_memory_history: list[float] = [] |
| converge_count = 0 |
| stagnant_count = 0 |
| converged = False |
| reason = "max iterations reached" |
| iteration = 0 |
|
|
| for iteration in range(1, max_iterations + 1): |
| |
| for status, n, e in run_research(question, nodes, edges, ref2url=ref2url): |
| if status == "keys_missing": |
| break |
| nodes, edges = n, e |
| yield f"{status} · Helix {iteration}/{max_iterations}", nodes, edges, ref2url |
|
|
| |
| if nodes: |
| avg_conf = sum(n.get("confidence", 0.75) for n in nodes) / len(nodes) |
| u_memory = max(0.0, min(1.0, 1.0 - avg_conf)) |
| else: |
| u_memory = 1.0 |
|
|
| |
| if u_memory_history: |
| prev_u = u_memory_history[-1] |
| relative_change = abs(prev_u - u_memory) / prev_u if prev_u > 0 else 0.0 |
| if relative_change < convergence_delta: |
| stagnant_count += 1 |
| else: |
| stagnant_count = 0 |
| u_memory_history.append(u_memory) |
|
|
| |
| if u_memory < uncertainty_threshold: |
| converge_count += 1 |
| if converge_count >= convergence_patience: |
| converged = True |
| reason = f"confident (U={u_memory:.3f} < τ_U={uncertainty_threshold:.2f} for {converge_count} rounds)" |
| else: |
| converge_count = 0 |
|
|
| |
| if not converged and stagnant_count >= convergence_patience and iteration > 2: |
| converged = True |
| reason = f"stagnant (|ΔU|<{convergence_delta*100:.0f}% for {stagnant_count} rounds)" |
|
|
| yield ( |
| f"🌀 Helix {iteration}: U_mem={u_memory:.3f} · " |
| f"stagnant={stagnant_count}/{convergence_patience} · " |
| f"confident={converge_count}/{convergence_patience}", |
| nodes, edges, ref2url, |
| ) |
|
|
| if converged: |
| yield f"🧬 CONVERGED — {reason}", nodes, edges, ref2url |
| break |
|
|
| final_u = u_memory_history[-1] if u_memory_history else 1.0 |
| yield _summary(question, nodes, edges, iterations=iteration, converged=converged, reason=reason, final_u=final_u, ref2url=ref2url), nodes, edges, ref2url |
| return |
|
|
| |
| for i, step in enumerate(_MOCK_STEPS): |
| for n in step["nodes"]: |
| if n["id"] not in seen_ids: |
| nodes.append(n) |
| seen_ids.add(n["id"]) |
| edges.extend(step["edges"]) |
| yield step["status"], nodes, edges, ref2url |
| time.sleep(1.2) |
| avg_conf = sum(n.get("confidence", 0.75) for n in nodes) / max(1, len(nodes)) |
| yield _summary( |
| question, nodes, edges, |
| iterations=len(_MOCK_STEPS), converged=True, |
| reason="mock run complete", final_u=max(0.0, 1.0 - avg_conf), |
| ref2url=ref2url, |
| ), nodes, edges, ref2url |
|
|
|
|
| |
|
|
| _IN_PROGRESS_MARKERS = ("🔬 Starting", "🔬 Planning", "🔍 Running", "🧠 Extracting", |
| "✨ Added", "🌀 Helix", "🔁 Round", "💤 Round", "🔬 Starting helical") |
|
|
|
|
| def on_load(state): |
| """Restore from BrowserState. Replace any stale in-progress assistant |
| message (left over from a refresh mid-stream) with a clean note.""" |
| state = _hydrate(state) |
| for ch in state["channels"].values(): |
| msgs = ch.get("messages") or [] |
| if msgs and msgs[-1].get("role") == "assistant": |
| content = msgs[-1].get("content", "") |
| if any(m in content for m in _IN_PROGRESS_MARKERS) and "📊" not in content and "CONVERGED" not in content: |
| msgs[-1] = {"role": "assistant", |
| "content": "⏸️ *Research was interrupted (page refreshed). Ask again to continue, or start a new chat.*"} |
| ch = _get_channel(state, state["active"]) |
| welcome_visible = len(ch["messages"]) == 0 |
| return ( |
| state, |
| gr.update(choices=_sidebar_choices(state), value=state["active"]), |
| list(ch["messages"]), |
| gr.update(visible=welcome_visible), |
| ) |
|
|
|
|
| def on_new_channel(state): |
| state = _hydrate(state) |
| ch = _new_channel() |
| state["channels"][ch["id"]] = ch |
| state["active"] = ch["id"] |
| return ( |
| state, |
| gr.update(choices=_sidebar_choices(state), value=ch["id"]), |
| [], |
| "", |
| gr.update(visible=True), |
| ) |
|
|
|
|
| def on_switch_channel(state, channel_id): |
| state = _hydrate(state) |
| if channel_id and channel_id in state["channels"]: |
| state["active"] = channel_id |
| ch = _get_channel(state, state["active"]) |
| welcome_visible = len(ch["messages"]) == 0 |
| return state, list(ch["messages"]), gr.update(visible=welcome_visible) |
|
|
|
|
| def on_ask(state, question): |
| """Streaming handler — chat text only; KG opens in /kg viewer via link.""" |
| state = _hydrate(state) |
| question = (question or "").strip() |
| if not question: |
| ch = _get_channel(state, state["active"]) |
| yield state, list(ch["messages"]), "", gr.update(), gr.update() |
| return |
|
|
| ch = _get_channel(state, state["active"]) |
| ch["messages"].append({"role": "user", "content": question}) |
| if ch["title"] == "New chat": |
| ch["title"] = question[:42] + ("…" if len(question) > 42 else "") |
| ch["messages"].append({"role": "assistant", "content": "🔬 Starting research…"}) |
|
|
| sidebar = gr.update(choices=_sidebar_choices(state), value=state["active"]) |
| hide_welcome = gr.update(visible=False) |
|
|
| yield state, list(ch["messages"]), "", sidebar, hide_welcome |
|
|
| for chat_text, nodes, edges, ref2url in _run_research_streaming( |
| question, ch["kg_nodes"], ch["kg_edges"], ch.get("kg_ref2url", {}) |
| ): |
| ch["messages"][-1] = {"role": "assistant", "content": chat_text} |
| ch["kg_nodes"] = list(nodes) |
| ch["kg_edges"] = list(edges) |
| ch["kg_ref2url"] = dict(ref2url) |
| yield state, list(ch["messages"]), "", sidebar, hide_welcome |
|
|
|
|
| |
|
|
| |
| CSS = """ |
| /* Hide Gradio chrome. */ |
| footer { display: none !important; } |
| #sdr-root .gradio-container { max-width: 100% !important; padding: 0 !important; } |
| |
| /* Google-font fallback — use system first, then Inter. */ |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); |
| |
| /* Global frame — dark blue ambient background + auto-height window. */ |
| html, body, gradio-app { |
| background: radial-gradient(1200px 600px at 20% 10%, #1e3a8a 0%, transparent 55%), |
| radial-gradient(1000px 700px at 85% 90%, #312e81 0%, transparent 55%), |
| linear-gradient(135deg, #0b1020 0%, #0f172a 55%, #0b1a34 100%) !important; |
| min-height: 100vh; |
| margin: 0; |
| padding: 0; |
| } |
| |
| #sdr-root { |
| background: transparent; |
| color: #111827; |
| font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; |
| min-height: 100vh; |
| padding: 20px !important; |
| box-sizing: border-box !important; |
| letter-spacing: -0.005em; |
| } |
| |
| /* Floating "window" — at least a full viewport tall, scroll-friendly. */ |
| #sdr-root > div:first-child { |
| max-width: 1280px; |
| min-height: calc(100vh - 40px); |
| margin: 0 auto !important; |
| background: #ffffff; |
| border-radius: 16px; |
| overflow: hidden; |
| box-shadow: |
| 0 30px 80px rgba(2, 6, 23, 0.45), |
| 0 10px 30px rgba(2, 6, 23, 0.30), |
| 0 0 0 1px rgba(255,255,255,0.06); |
| display: flex; |
| } |
| #sdr-root > div:first-child > div[class*="row"] { flex: 1; display: flex; width: 100%; min-height: 0; } |
| |
| /* ============ Sidebar — full height flex ============ */ |
| #sdr-sidebar { |
| background: linear-gradient(180deg, #0f172a 0%, #1e293b 100%); |
| color: #e5e7eb; |
| border-right: 1px solid #1f2937; |
| padding: 18px 14px !important; |
| display: flex !important; |
| flex-direction: column !important; |
| gap: 8px; |
| height: 100%; |
| min-height: 0; |
| overflow-y: auto; |
| } |
| |
| /* Brand block at top. */ |
| #sdr-brand { |
| display: flex; align-items: center; gap: 10px; |
| padding: 4px 6px 14px 6px; |
| border-bottom: 1px solid rgba(255,255,255,0.08); |
| margin-bottom: 10px; |
| } |
| #sdr-brand .sdr-logo { |
| width: 32px; height: 32px; border-radius: 8px; |
| background: linear-gradient(135deg,#6366f1,#ec4899); |
| display: grid; place-items: center; font-size: 16px; |
| box-shadow: 0 2px 8px rgba(99,102,241,0.35); |
| } |
| #sdr-brand .sdr-title { font-weight: 700; font-size: 14px; color:#fff; line-height:1.1; } |
| #sdr-brand .sdr-sub { font-size: 11px; color:#94a3b8; margin-top:2px; } |
| |
| /* Section heading. */ |
| #sdr-sidebar .prose h3, #sdr-section-head { |
| margin: 10px 6px 6px 6px; font-size: 11px; color: #94a3b8; |
| text-transform: uppercase; letter-spacing: 0.6px; font-weight: 600; |
| } |
| |
| /* New chat button — crisp white-on-dark pill, ChatGPT style. */ |
| #sdr-new-btn { margin-bottom: 2px; } |
| #sdr-new-btn button { |
| background: #ffffff !important; |
| color: #0f172a !important; |
| border: 1px solid rgba(255,255,255,0.9) !important; |
| border-radius: 12px !important; |
| font-weight: 600 !important; |
| font-size: 14px !important; |
| padding: 10px 14px !important; |
| width: 100% !important; |
| box-shadow: 0 2px 10px rgba(0,0,0,0.18); |
| transition: all 0.15s ease; |
| display: flex !important; |
| align-items: center !important; |
| justify-content: center !important; |
| gap: 8px !important; |
| } |
| #sdr-new-btn button:hover { |
| background: #f1f5f9 !important; |
| box-shadow: 0 6px 18px rgba(0,0,0,0.28); |
| transform: translateY(-1px); |
| } |
| |
| /* Channel list — pills with brighter colour for inactive items. */ |
| #sdr-channels label { |
| border-radius: 10px !important; |
| padding: 9px 12px !important; |
| font-size: 13.5px !important; |
| color: #e2e8f0 !important; |
| background: rgba(255,255,255,0.04) !important; |
| border: 1px solid rgba(255,255,255,0.06) !important; |
| transition: all 0.15s ease; |
| margin: 3px 0 !important; |
| cursor: pointer; |
| display: block; |
| } |
| #sdr-channels label:hover { |
| background: rgba(255,255,255,0.10) !important; |
| border-color: rgba(255,255,255,0.15) !important; |
| color: #f8fafc !important; |
| transform: translateX(2px); |
| } |
| #sdr-channels label:has(input:checked) { |
| background: linear-gradient(135deg, rgba(99,102,241,0.28), rgba(167,81,247,0.22)) !important; |
| border-color: #818cf8 !important; |
| color: #ffffff !important; |
| box-shadow: 0 2px 10px rgba(99,102,241,0.18); |
| font-weight: 600 !important; |
| } |
| #sdr-channels input[type="radio"] { display:none; } |
| #sdr-channels span { color: inherit !important; } |
| |
| /* Sidebar footer. */ |
| #sdr-footer { |
| margin-top: auto; |
| padding: 12px 8px 0 8px; |
| border-top: 1px solid rgba(255,255,255,0.08); |
| font-size: 11px; |
| color: #94a3b8; |
| line-height: 1.5; |
| } |
| #sdr-footer .sdr-user { |
| display:flex; align-items:center; gap: 10px; padding:8px 4px; |
| border-radius: 8px; |
| } |
| #sdr-footer .sdr-avatar { |
| width: 28px; height: 28px; border-radius: 50%; |
| background: linear-gradient(135deg,#6366f1,#ec4899); |
| display:grid; place-items:center; font-size: 12px; color:#fff; font-weight:600; |
| } |
| #sdr-footer .sdr-org { color:#cbd5e1; font-weight:500; font-size:12px; } |
| #sdr-signout-btn { margin-top: 6px; } |
| #sdr-signout-btn button { |
| background: rgba(239,68,68,0.10) !important; |
| color: #fca5a5 !important; |
| border: 1px solid rgba(239,68,68,0.30) !important; |
| border-radius: 8px !important; |
| font-size: 12px !important; |
| padding: 7px 10px !important; |
| width: 100% !important; |
| } |
| #sdr-signout-btn button:hover { |
| background: rgba(239,68,68,0.18) !important; |
| color: #fecaca !important; |
| } |
| |
| /* ============ Main area — full height flex ============ */ |
| #sdr-main { |
| padding: 0 !important; |
| background: #ffffff; |
| display: flex !important; |
| flex-direction: column !important; |
| min-height: calc(100vh - 40px); |
| overflow-y: auto; |
| } |
| #sdr-header { |
| padding: 14px 28px 12px 28px; |
| border-bottom: 1px solid #f1f5f9; |
| background: rgba(255,255,255,0.9); |
| backdrop-filter: saturate(180%) blur(10px); |
| display:flex; align-items:center; justify-content:space-between; gap:14px; |
| position: sticky; top: 0; z-index: 2; |
| } |
| #sdr-header .sdr-badge { |
| background: linear-gradient(135deg,#eef2ff,#fdf4ff); |
| color: #4338ca; font-size: 11px; font-weight: 600; |
| padding: 3px 10px; border-radius: 999px; |
| border: 1px solid #e0e7ff; |
| } |
| #sdr-header h2 { margin: 0; font-size: 17px; font-weight: 700; color:#0f172a; } |
| #sdr-header p { margin: 2px 0 0 0; font-size: 12.5px; color: #64748b; } |
| |
| /* Welcome hero + 2×2 sample prompt grid. */ |
| #sdr-welcome { padding: 18px 32px 4px 32px; flex: 0 0 auto; } |
| #sdr-welcome-hero { text-align:center; padding: 4px 0 14px 0; } |
| #sdr-welcome-hero .sdr-hero-icon { font-size: 36px; margin-bottom: 4px; } |
| #sdr-welcome-hero .sdr-hero-title { font-size: 20px; font-weight: 700; color:#0f172a; margin-bottom: 4px; } |
| #sdr-welcome-hero .sdr-hero-sub { color:#64748b; font-size: 13px; max-width: 580px; margin: 0 auto; line-height:1.5; } |
| |
| #sdr-card-grid { |
| display: grid; |
| grid-template-columns: 1fr 1fr; |
| gap: 10px; |
| max-width: 720px; |
| margin: 6px auto 0 auto; |
| } |
| .sdr-card { |
| background: #ffffff; |
| border: 1px solid #e5e7eb; |
| border-radius: 14px; |
| padding: 14px 16px; |
| cursor: pointer; |
| box-shadow: 0 1px 2px rgba(15,23,42,0.03); |
| transition: all 0.15s ease; |
| display: flex; |
| flex-direction: column; |
| gap: 4px; |
| user-select: none; |
| } |
| .sdr-card:hover { |
| border-color: #a5b4fc; |
| box-shadow: 0 6px 18px rgba(99,102,241,0.14), 0 2px 6px rgba(15,23,42,0.06); |
| transform: translateY(-2px); |
| background: linear-gradient(135deg,#fdfdff,#faf5ff); |
| } |
| .sdr-card-emoji { font-size: 22px; line-height: 1; } |
| .sdr-card-title { font-weight: 700; color:#0f172a; font-size: 14.5px; } |
| .sdr-card-desc { color:#64748b; font-size: 12.5px; line-height: 1.45; } |
| |
| /* Chatbot — compact status area above the KG panel. */ |
| #sdr-chat { |
| border: none !important; |
| background: transparent !important; |
| padding: 8px 16px 0 16px !important; |
| flex: 0 0 auto !important; |
| overflow-y: auto !important; |
| } |
| |
| /* KG panel — the main artifact. */ |
| #sdr-kg-panel { |
| flex: 1 1 auto !important; |
| padding: 8px 20px 4px 20px !important; |
| min-height: 340px !important; |
| overflow: hidden; |
| } |
| #sdr-kg-panel > div { width: 100%; height: 100%; } |
| #sdr-kg-panel > div > div:first-child, |
| #sdr-kg-panel > div > div:last-child { |
| width: 100%; |
| height: 100%; |
| } |
| #sdr-chat .message { font-size: 15px !important; line-height: 1.68 !important; color:#1f2937 !important; } |
| #sdr-chat .bubble, #sdr-chat .user-row, #sdr-chat .bot-row { background: transparent !important; } |
| #sdr-chat .message-wrap { max-width: 820px !important; margin: 0 auto !important; } |
| /* User messages — subtle indigo-tinted bubble on the right. */ |
| #sdr-chat .user > div:first-child { |
| background: #f0f2ff !important; |
| border-radius: 18px 18px 4px 18px !important; |
| padding: 10px 14px !important; |
| color: #1e1b4b !important; |
| } |
| /* Assistant messages — no bubble, pure text. */ |
| #sdr-chat .bot > div:first-child { background: transparent !important; padding: 4px 2px !important; } |
| |
| /* Input area — pinned near bottom. */ |
| #sdr-input-row { |
| max-width: 820px; |
| margin: 8px auto 4px auto; |
| background: #fff; |
| border: 1px solid #e5e7eb; |
| border-radius: 18px; |
| padding: 6px 8px; |
| box-shadow: 0 4px 16px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04); |
| transition: all 0.2s ease; |
| flex: 0 0 auto !important; |
| } |
| #sdr-input-row:focus-within { |
| border-color: #a5b4fc; |
| box-shadow: 0 4px 20px rgba(99, 102, 241, 0.12), 0 0 0 3px rgba(99, 102, 241, 0.08); |
| } |
| #sdr-input-row textarea { |
| border: none !important; |
| background: transparent !important; |
| font-size: 15px !important; |
| padding: 10px 12px !important; |
| resize: none !important; |
| color: #111827 !important; |
| } |
| #sdr-input-row textarea::placeholder { color: #9ca3af !important; } |
| |
| #sdr-send-btn button { |
| background: linear-gradient(135deg,#4f46e5,#7c3aed) !important; |
| color: #fff !important; |
| border: none !important; |
| border-radius: 12px !important; |
| min-width: 60px !important; |
| height: 44px !important; |
| padding: 0 14px !important; |
| font-weight: 600 !important; |
| box-shadow: 0 2px 8px rgba(79, 70, 229, 0.28); |
| transition: all 0.15s ease; |
| } |
| #sdr-send-btn button:hover { transform: translateY(-1px); box-shadow: 0 4px 14px rgba(79, 70, 229, 0.4); } |
| |
| /* Login screen. */ |
| #sdr-login { max-width: 420px; margin: 0 auto; padding: 40px 24px; } |
| #sdr-login-card { background: #ffffff; padding: 24px; border-radius: 14px; box-shadow: 0 20px 50px rgba(2,6,23,0.35); border: 1px solid #e5e7eb; } |
| #sdr-login-card input { border-radius: 10px !important; } |
| #sdr-login-btn button { |
| width: 100% !important; background: linear-gradient(135deg,#4f46e5,#7c3aed) !important; |
| color: #fff !important; border: none !important; border-radius: 12px !important; |
| padding: 12px 16px !important; font-weight: 600 !important; margin-top: 8px !important; |
| box-shadow: 0 2px 10px rgba(79,70,229,0.28); |
| } |
| #sdr-login-btn button:hover { transform: translateY(-1px); box-shadow: 0 4px 16px rgba(79,70,229,0.4); } |
| #sdr-login-msg { text-align: center; } |
| |
| #sdr-note { |
| text-align: center; |
| font-size: 10.5px; |
| color: #b3b7c2; |
| letter-spacing: 0.1px; |
| padding: 4px 16px 16px 16px; |
| max-width: 760px; |
| margin: 0 auto; |
| line-height: 1.45; |
| } |
| #sdr-note::before { |
| content: "ⓘ "; |
| color: #cbd5e1; |
| font-size: 11px; |
| } |
| """ |
|
|
| EMPTY_PLACEHOLDER = "" |
|
|
| SAMPLE_PROMPTS = [ |
| ("🔋", "Lithium-ion batteries", |
| "Map tier-1 and tier-2 suppliers of cobalt, lithium and nickel used in the cathode of Tesla's 4680 battery cell, including country of origin, refining facilities, alternative materials and known ESG risks."), |
| ("🧲", "EV rare-earth magnets", |
| "Identify the mining locations, refining and separation facilities, and top manufacturers of NdFeB rare-earth magnets used in permanent-magnet synchronous motors for electric vehicles, including geopolitical risks and substitution efforts."), |
| ("💊", "Pharmaceutical APIs", |
| "Trace the tier-1 and tier-2 active pharmaceutical ingredient (API) suppliers for a representative generic cardiovascular drug, focusing on India and China manufacturers and regulatory/FDA inspection history."), |
| ("🌾", "Cocoa supply chain", |
| "Map the supply chain of cocoa for the global chocolate industry — origin countries, major cooperatives, traders, processors, and known deforestation or child-labour risks."), |
| ] |
|
|
|
|
| with gr.Blocks(title="Deep Research — Supply Chain AI", elem_id="sdr-root", css=CSS) as demo: |
| |
| state = gr.BrowserState(default_value={}) |
| auth_state = gr.BrowserState(default_value=False) |
|
|
| |
| with gr.Column(visible=False, elem_id="sdr-login") as login_col: |
| gr.HTML( |
| "<div style='text-align:center;padding:60px 20px 24px;'>" |
| "<div style='font-size:48px;text-shadow:0 2px 10px rgba(99,102,241,0.4);'>🔬</div>" |
| "<h2 style='color:#f8fafc;margin:10px 0 6px;font-weight:700;letter-spacing:-0.3px;text-shadow:0 2px 12px rgba(0,0,0,0.3);'>Deep Research — Supply Chain AI</h2>" |
| "<p style='color:#cbd5e1;margin:0 auto 28px;max-width:480px;line-height:1.55;'>" |
| "Public beta from the <b style='color:#e0e7ff;'>Supply Chain AI Lab (SCAIL)</b>, University of Cambridge. " |
| "Please sign in to continue." |
| "</p>" |
| "</div>" |
| ) |
| with gr.Column(elem_id="sdr-login-card"): |
| login_user = gr.Textbox(label="Username", placeholder="username", max_lines=1, elem_id="sdr-login-user") |
| login_pass = gr.Textbox(label="Password", placeholder="password", type="password", max_lines=1, elem_id="sdr-login-pass") |
| login_btn = gr.Button("Sign in", variant="primary", elem_id="sdr-login-btn") |
| login_msg = gr.HTML("", elem_id="sdr-login-msg") |
|
|
| |
| with gr.Row(equal_height=False, visible=False) as main_row: |
| |
| with gr.Column(scale=1, min_width=260, elem_id="sdr-sidebar"): |
| gr.HTML( |
| "<div id='sdr-brand'>" |
| "<div class='sdr-logo'>🔬</div>" |
| "<div>" |
| "<div class='sdr-title'>Deep Research</div>" |
| "<div class='sdr-sub'>Supply Chain AI · SCAIL</div>" |
| "</div>" |
| "</div>" |
| ) |
| with gr.Group(elem_id="sdr-new-btn"): |
| new_btn = gr.Button("+ New chat", variant="primary", size="md") |
| gr.HTML("<div id='sdr-section-head'>Recent</div>") |
| channel_list = gr.Radio( |
| choices=[], |
| value=None, |
| label="", |
| interactive=True, |
| container=False, |
| elem_id="sdr-channels", |
| ) |
| gr.HTML( |
| "<div id='sdr-footer'>" |
| "<div class='sdr-user'>" |
| "<div class='sdr-avatar'>S</div>" |
| "<div>" |
| "<div class='sdr-org'>SCAIL · Cambridge</div>" |
| "<div>Public beta</div>" |
| "</div>" |
| "</div>" |
| "</div>" |
| ) |
| with gr.Group(elem_id="sdr-signout-btn"): |
| signout_btn = gr.Button("↩ Sign out", size="sm") |
|
|
| |
| with gr.Column(scale=4, elem_id="sdr-main"): |
| gr.HTML( |
| "<div id='sdr-header'>" |
| "<div>" |
| "<h2>Deep Research — Supply Chain AI</h2>" |
| "<p>A public beta from the Supply Chain AI Lab (SCAIL), University of Cambridge.</p>" |
| "</div>" |
| "<span class='sdr-badge'>BETA</span>" |
| "</div>" |
| ) |
|
|
| |
| cards_html = "<div id='sdr-welcome-hero'>" \ |
| "<div class='sdr-hero-icon'>🔬</div>" \ |
| "<div class='sdr-hero-title'>Deep Research — Supply Chain AI</div>" \ |
| "<div class='sdr-hero-sub'>Ask a detailed supply chain question. The agent will search the web, reason across evidence, and build an interactive knowledge graph.</div>" \ |
| "</div><div id='sdr-card-grid'>" |
| for i, (emoji, title, _) in enumerate(SAMPLE_PROMPTS): |
| desc_short = SAMPLE_PROMPTS[i][2] |
| desc_short = desc_short[:95] + "…" if len(desc_short) > 95 else desc_short |
| cards_html += ( |
| f"<div class='sdr-card' onclick=\"document.querySelector('#sdr-hidden-{i} button').click()\">" |
| f"<div class='sdr-card-emoji'>{emoji}</div>" |
| f"<div class='sdr-card-title'>{title}</div>" |
| f"<div class='sdr-card-desc'>{desc_short}</div>" |
| f"</div>" |
| ) |
| cards_html += "</div>" |
| welcome_block = gr.HTML(cards_html, elem_id="sdr-welcome") |
|
|
| |
| sample_btns = [] |
| with gr.Row(visible=False): |
| for i in range(len(SAMPLE_PROMPTS)): |
| sample_btns.append(gr.Button("", elem_id=f"sdr-hidden-{i}")) |
|
|
| |
| chat = gr.Chatbot( |
| label="", |
| elem_id="sdr-chat", |
| height=520, |
| show_label=False, |
| render_markdown=True, |
| sanitize_html=False, |
| type="messages", |
| ) |
|
|
| with gr.Row(elem_id="sdr-input-row"): |
| question_box = gr.Textbox( |
| label="", |
| placeholder="Message Deep Research… (e.g. map tier-1 & tier-2 suppliers of cobalt in Tesla 4680 cells)", |
| lines=2, |
| max_lines=8, |
| scale=9, |
| container=False, |
| show_label=False, |
| ) |
| with gr.Column(scale=1, min_width=70, elem_id="sdr-send-btn"): |
| ask_btn = gr.Button("↑", variant="primary") |
|
|
| footer_note = gr.HTML( |
| "<div id='sdr-note'>" |
| "Deep Research can make mistakes. Verify important findings against primary sources." |
| " · <span style='color:#818cf8;font-weight:500;'>Public beta</span>" |
| "</div>", |
| visible=False, |
| ) |
|
|
| |
| def do_login(u, p, auth_st): |
| if _auth((u or "").strip(), (p or "").strip()): |
| return ( |
| True, |
| gr.update(visible=False), |
| gr.update(visible=True), |
| gr.update(visible=True), |
| "", |
| ) |
| return ( |
| False, |
| gr.update(visible=True), |
| gr.update(visible=False), |
| gr.update(visible=False), |
| "<div style='color:#dc2626;margin-top:10px;font-size:13px;'>Invalid credentials. Please try again.</div>", |
| ) |
|
|
| def do_signout(state): |
| return ( |
| False, |
| {}, |
| gr.update(visible=True), |
| gr.update(visible=False), |
| gr.update(visible=False), |
| "", |
| "", |
| "", |
| ) |
|
|
| signout_btn.click( |
| do_signout, |
| inputs=[state], |
| outputs=[auth_state, state, login_col, main_row, footer_note, login_msg, login_user, login_pass], |
| api_name=False, |
| ) |
|
|
| login_btn.click( |
| do_login, |
| inputs=[login_user, login_pass, auth_state], |
| outputs=[auth_state, login_col, main_row, footer_note, login_msg], |
| api_name=False, |
| ) |
| login_pass.submit( |
| do_login, |
| inputs=[login_user, login_pass, auth_state], |
| outputs=[auth_state, login_col, main_row, footer_note, login_msg], |
| api_name=False, |
| ) |
|
|
| |
| |
| |
| def _initial_load(state, auth_st): |
| state = _hydrate(state) |
| ch = _get_channel(state, state["active"]) |
| welcome_visible = bool(auth_st) and len(ch["messages"]) == 0 |
| return ( |
| state, |
| gr.update(choices=_sidebar_choices(state), value=state["active"]), |
| list(ch["messages"]), |
| gr.update(visible=welcome_visible), |
| gr.update(visible=not bool(auth_st)), |
| gr.update(visible=bool(auth_st)), |
| gr.update(visible=bool(auth_st)), |
| ) |
|
|
| demo.load( |
| _initial_load, |
| inputs=[state, auth_state], |
| outputs=[state, channel_list, chat, welcome_block, login_col, main_row, footer_note], |
| api_name=False, |
| ) |
|
|
| new_btn.click( |
| on_new_channel, |
| inputs=state, |
| outputs=[state, channel_list, chat, question_box, welcome_block], |
| api_name=False, |
| ) |
| channel_list.change( |
| on_switch_channel, |
| inputs=[state, channel_list], |
| outputs=[state, chat, welcome_block], |
| api_name=False, |
| ) |
| ask_btn.click( |
| on_ask, |
| inputs=[state, question_box], |
| outputs=[state, chat, question_box, channel_list, welcome_block], |
| api_name=False, |
| ) |
| question_box.submit( |
| on_ask, |
| inputs=[state, question_box], |
| outputs=[state, chat, question_box, channel_list, welcome_block], |
| api_name=False, |
| ) |
|
|
| |
| for btn, (_emoji, _title, full_prompt) in zip(sample_btns, SAMPLE_PROMPTS): |
| btn.click(lambda p=full_prompt: p, inputs=None, outputs=question_box, api_name=False) |
|
|
|
|
| demo.queue() |
|
|
|
|
| |
| |
| def _parse_users(s: str) -> list[tuple[str, str]]: |
| out: list[tuple[str, str]] = [] |
| for pair in (s or "").split(","): |
| pair = pair.strip() |
| if ":" in pair: |
| u, p = pair.split(":", 1) |
| if u and p: |
| out.append((u.strip(), p.strip())) |
| return out |
|
|
|
|
| USERS = _parse_users(os.getenv("APP_USERS", "scail:deepresearch2026")) |
|
|
|
|
| def _auth(username: str, password: str) -> bool: |
| return any(u == username and p == password for u, p in USERS) |
|
|
|
|
| |
| app = FastAPI() |
|
|
|
|
| @app.get("/kg", response_class=HTMLResponse) |
| def kg_viewer(): |
| return HTMLResponse(content=_KG_VIEWER_HTML) |
|
|
|
|
| app = gr.mount_gradio_app(app, demo, path="/", ssr_mode=False) |
|
|
|
|
| if __name__ == "__main__": |
| import uvicorn |
| uvicorn.run(app, host="0.0.0.0", port=7860) |
|
|