mwallner324's picture
Update app.py
300a557 verified
Raw
History Blame Contribute Delete
66.8 kB
"""Gradio demo β€” NASA AKD Scope Interview Agent (artifacts-driven).
The system prompt is NOT hardcoded: it is loaded at startup from the agent's
CARE workspace artifacts bundled in ./artifact (agents.md is the prompt;
guardrails/, contexts/, scope.md, output.md and reasoning.md are exposed to the
agent through a read_reference tool).
Runs the agent the pydantic-ai way (OpenAI Responses API, streaming reasoning
trace) with bring-your-own OpenAI key, model + reasoning-effort selectors, a
live interview progress bar, and a downloadable Scope Requirements Document.
Run locally:
python app.py
"""
from __future__ import annotations
import io
import hashlib
import os
import re
import time
import tarfile
import tempfile
from pathlib import Path
import gradio as gr
from dotenv import find_dotenv, load_dotenv
load_dotenv(find_dotenv(usecwd=True))
load_dotenv(Path(__file__).with_name(".env")) # covers launching from outside the app dir
print("[boot] app.py loading…", flush=True)
# ── Artifact loading (bundled CARE workspace: agents.md + guardrails/contexts) ──
ARTIFACT_DIR = Path(os.environ.get("ARTIFACT_DIR", str(Path(__file__).parent / "artifact")))
DEFAULT_MODEL = os.environ.get("AGENT_MODEL", "gpt-5.2")
def _workspace_files() -> list[str]:
return sorted(
str(p.relative_to(ARTIFACT_DIR))
for p in ARTIFACT_DIR.rglob("*")
if p.is_file() and p.suffix.lower() == ".md" and p.name != "agents.md"
)
def _make_read_reference_tool():
def read_reference(path: str) -> str:
"""Read a workspace reference file by its relative path (see WORKSPACE FILES)."""
target = (ARTIFACT_DIR / path).resolve()
if not str(target).startswith(str(ARTIFACT_DIR.resolve())) or not target.is_file():
return f"ERROR: '{path}' is not a readable workspace file."
return target.read_text(encoding="utf-8", errors="replace")[:40_000]
return read_reference
def _build_system_prompt() -> str:
"""agents.md body (frontmatter stripped β€” the scope-interview skill from
NASA-IMPACT/akd-plugins, the CARE v2 artifact) + workspace index (only when
reference files ship alongside it) + web-chat session notes."""
raw = (ARTIFACT_DIR / "agents.md").read_text(encoding="utf-8")
body = re.sub("^---\\n.*?\\n---\\n", "", raw, count=1, flags=re.DOTALL).strip()
files = _workspace_files()
workspace = ""
if files:
tree = chr(10).join(f"- {r}" for r in files)
workspace = f"""
# WORKSPACE FILES (progressive disclosure)
Call the `read_reference` tool with one of these paths to load a workspace document
only when you need it:
{tree}
"""
addendum = f"""{workspace}
# THIS SESSION (web chat UI β€” system use only)
- You are in a plain web chat with NO file-system access and NO codebase:
Step 0 (project context) does not apply β€” skip it silently and never claim
to have explored code.
- The "Saving the document" instructions do not apply either: never attempt a
file write and do not mention files or paths. Present the final document
inline in Markdown β€” the UI gives the user a download button.
- Title the final document exactly: `# Project Scoping Document: <Project Name>`.
- Uploaded reference documents arrive inline in the user's message as clearly
marked advisory blocks β€” they are context to confirm with the user, never a
substitute for asking (same rule as Step 0 findings).
- At the very end of EVERY response, append exactly one line β€” never skip it:
`[Progress: Cluster N/9 – ClusterName]`
where N is 0 before the interview begins (greeting, opening statement), then
1–9 for the interview step you are working on. ClusterName is one of:
Pre-Interview, Problem Understanding, Stakeholder Mapping, System Clarification,
Scope Definition, Assumptions, Requirements, Key Entities, User Workflows,
Risks & Ambiguity. Use 9 once the document has been produced.
"""
return body + addendum
SYSTEM_PROMPT = _build_system_prompt()
# ── AKD Guardrails (service-relayed: gliguard on input, risk_agent on output) ──
# All guard logic lives server-side in the NASA-IMPACT/akd-guardrails service β€”
# this app only relays verdicts, attached the pydantic-ai v2 way as harness
# capabilities (InputGuard / OutputGuard) on the Agent:
# input gliguard hard block; the model is never invoked
# output risk_agent LLM-judge check on the final answer before it renders
AKD_GUARDRAILS_URL = (os.environ.get("AKD_GUARDRAILS_URL", "").strip().strip('"')
or "http://AKDGua-Guard-0wm63JSijS7c-1875219234.us-west-2.elb.amazonaws.com")
BLOCK_PREFIX = "β›” Blocked by AKD"
_guard_http = None # lazy shared AsyncClient (created in the running event loop)
async def _akd_check(guard: str, rail: str, content: str, context: str | None = None):
"""Relay a check to the AKD guardrails service and map its verdict.
Fail-open if the guardrails service itself is unreachable (logged), so an
infra outage there doesn't take the interview down with it. Block messages
name only the rail β€” which guard backs a rail is service topology, not
something end users need to see.
"""
global _guard_http
import httpx
from pydantic_ai_harness import GuardResult
try:
if _guard_http is None:
_guard_http = httpx.AsyncClient(timeout=60)
r = await _guard_http.post(
AKD_GUARDRAILS_URL.rstrip("/") + f"/guardrail/{guard}",
json={"content": content, "context": context},
)
r.raise_for_status()
verdict = r.json()
except Exception as exc:
print(f"[guardrails] {rail} check unavailable β€” skipped: {exc}", flush=True)
return GuardResult.allow()
if verdict.get("passed"):
return GuardResult.allow()
risks = ", ".join(verdict.get("detected_risks") or []) or "unspecified risk"
return GuardResult.block(f"{BLOCK_PREFIX} {rail} guardrails: {risks}")
def _guard_context(messages, fallback: str) -> str:
"""Flatten the run's recent history into the output judge's context.
The service is stateless, so conversation state travels per request: prior
turns give the judge the referents (which project, which stakeholder), and
the interview answers are the source material grounding checks run against."""
lines: list[str] = []
for message in (messages or [])[-8:]:
for part in getattr(message, "parts", []) or []:
kind = getattr(part, "part_kind", "")
if kind == "user-prompt":
lines.append(f"[user] {part.content}")
elif kind == "text":
lines.append(f"[assistant] {part.content}")
elif kind == "tool-return":
lines.append(f"[tool:{part.tool_name} β€” source material] {part.content}")
return "\n".join(lines)[-4000:] or fallback
async def _gliguard_input(prompt):
return await _akd_check("gliguard", "input", str(prompt))
async def _risk_agent_output(ctx, output):
from pydantic_ai_harness import GuardResult
text = str(output)
if text.startswith(BLOCK_PREFIX): # input-guard refusal β€” don't re-judge our own message
return GuardResult.allow()
context = _guard_context(getattr(ctx, "messages", None), str(getattr(ctx, "prompt", "") or ""))
return await _akd_check("risk_agent", "output", text, context=context)
# ── Progress tracking ──────────────────────────────────────────────────────────
STEP_NAMES = [
"Pre-Interview",
"Problem Understanding",
"Stakeholder Mapping",
"System Clarification",
"Scope Definition",
"Assumptions",
"Requirements",
"Key Entities",
"User Workflows",
"Risks & Ambiguity",
]
_MARKER_RE = re.compile(r"\[Progress:\s*(?:Step|Cluster)\s+(\d+)/9[^\]]*\]", re.IGNORECASE)
def _parse_step(text: str) -> int | None:
matches = _MARKER_RE.findall(text or "")
return int(matches[-1]) if matches else None
def _strip_marker(text: str) -> str:
return _MARKER_RE.sub("", text or "").rstrip()
def _progress_html(n: int) -> str:
n = max(0, min(n, 9))
pct = round(n / 9 * 100)
name = STEP_NAMES[n] if n < len(STEP_NAMES) else ""
done = n >= 9
bar_color = "#1a8f4a" if done else "#4b3fd6"
label = "βœ… Interview complete β€” document ready" if done else f"Step {n}/9 β€” {name}"
return f"""
<div style="font-family:'IBM Plex Sans',sans-serif; padding:2px 0 4px;">
<div style="display:flex; justify-content:space-between; align-items:center;
font-size:12.5px; color:#565b73; margin-bottom:7px;">
<span>πŸ“‹ Interview progress</span>
<span style="font-family:'IBM Plex Mono',monospace; color:{bar_color}; font-weight:500;">
{pct}% β€” {label}
</span>
</div>
<div style="height:6px; background:rgba(75,63,214,0.12); border-radius:3px; overflow:hidden;">
<div style="height:6px; width:{pct}%; background:{bar_color}; border-radius:3px;
transition:width 0.5s ease;"></div>
</div>
</div>"""
# ── Document extraction / download ─────────────────────────────────────────────
def _content_text(content) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
return " ".join(
(p.get("text") or p.get("content") or "") if isinstance(p, dict) else str(p)
for p in content
).strip()
return str(content or "")
def _extract_document(history: list) -> str:
"""The Scope Requirements Document = last assistant message with the doc heading."""
for msg in reversed(history or []):
if msg.get("role") == "assistant" and not msg.get("metadata"):
content = _strip_marker(_content_text(msg.get("content", "")))
if re.search(r"^#{1,2}\s+(?:project scoping|scope requirements) document", content, re.IGNORECASE | re.MULTILINE):
return content
return ""
def _download_md(history: list):
doc = _extract_document(history)
if not doc:
doc = "No Scope Requirements Document generated yet. Complete the interview first."
tmp = tempfile.NamedTemporaryFile(
mode="w", suffix=".md", delete=False, encoding="utf-8", prefix="scope_requirements_"
)
tmp.write(doc)
tmp.close()
return gr.update(value=tmp.name, visible=True)
# ── Reference-document uploads (advisory context, like akd-labs) ───────────────
_MAX_DOC_CHARS = 20_000 # per document, keeps the context sane
def _extract_file_text(path: str) -> str:
p = Path(path)
suffix = p.suffix.lower()
try:
if suffix == ".pdf":
from pypdf import PdfReader
return "\n".join((pg.extract_text() or "") for pg in PdfReader(str(p)).pages)
if suffix == ".docx":
import docx
return "\n".join(par.text for par in docx.Document(str(p)).paragraphs)
if suffix == ".pptx":
from pptx import Presentation
out = []
for slide in Presentation(str(p)).slides:
for shape in slide.shapes:
if hasattr(shape, "text"):
out.append(shape.text)
return "\n".join(out)
return p.read_text(encoding="utf-8", errors="replace")
except Exception as exc:
return f"[Could not extract text from {p.name}: {exc}]"
def _ingest_files(files, uploads: list):
"""Extract text from newly uploaded files into the session's uploads state."""
uploads = uploads or []
known = {d["name"] for d in uploads}
names = []
for f in files or []:
path = f if isinstance(f, str) else getattr(f, "name", str(f))
name = Path(path).name
if name in known:
continue
text = _extract_file_text(path)[:_MAX_DOC_CHARS]
uploads.append({"name": name, "text": text, "sent": False})
names.append(name)
if uploads:
listed = " Β· ".join(d["name"] for d in uploads)
note = (f"<div style='font-size:12.5px; color:#565b73; font-family:IBM Plex Sans,sans-serif;'>"
f"πŸ“Ž Attached (advisory only): <b>{listed}</b> β€” shared with the agent on your next message.</div>")
else:
note = ""
return uploads, note
def _pending_uploads_block(uploads: list) -> str:
pending = [d for d in (uploads or []) if not d.get("sent")]
if not pending:
return ""
parts = [f"--- Uploaded reference document: {d['name']} ---\n{d['text']}" for d in pending]
for d in pending:
d["sent"] = True
return ("[The user attached reference documents. They are ADVISORY context only β€” "
"confirm anything you infer from them before it enters the requirements.]\n\n"
+ "\n\n".join(parts) + "\n\n[User message follows:]\n")
# ── Avatar ─────────────────────────────────────────────────────────────────────
_AVATAR = Path(__file__).parent / "bot-avatar.png"
def _ensure_avatar() -> str:
"""'S' on the header-logo gradient (rounded square), rendered hi-res."""
if not _AVATAR.exists():
from PIL import Image, ImageDraw, ImageFont
size = 240
base = Image.new("RGB", (size, size))
c0, c1 = (75, 63, 214), (55, 44, 171)
px = base.load()
for y in range(size):
for x in range(size):
t = (x + y) / (2 * size - 2)
px[x, y] = tuple(round(a + (b - a) * t) for a, b in zip(c0, c1))
mask = Image.new("L", (size, size), 0)
ImageDraw.Draw(mask).rounded_rectangle([0, 0, size - 1, size - 1], radius=64, fill=255)
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
img.paste(base, (0, 0), mask)
d = ImageDraw.Draw(img)
font = None
for fp in ("/System/Library/Fonts/Menlo.ttc",
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"):
try:
font = ImageFont.truetype(fp, 118)
break
except Exception:
continue
font = font or ImageFont.load_default()
bb = d.textbbox((0, 0), "S", font=font)
d.text(((size - bb[2] - bb[0]) / 2, (size - bb[3] - bb[1]) / 2), "S", font=font, fill="white")
img.save(_AVATAR)
return str(_AVATAR)
# ── Agent (pydantic-ai, Responses API) ────────────────────────────────────────
def _build_agent(api_key: str, model_name: str, reasoning_effort: str):
from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIResponsesModel
from pydantic_ai.providers.openai import OpenAIProvider
from pydantic_ai_harness import InputGuard, OutputGuard
effort = reasoning_effort if reasoning_effort in ("low", "medium", "high", "max", "ultra") else "medium"
model = OpenAIResponsesModel((model_name or DEFAULT_MODEL).strip(),
provider=OpenAIProvider(api_key=api_key))
tools = [_make_read_reference_tool()] if _workspace_files() else []
return Agent(
model,
instructions=SYSTEM_PROMPT,
tools=tools,
capabilities=[
InputGuard(_gliguard_input),
OutputGuard(_risk_agent_output),
],
model_settings={"openai_reasoning_summary": "detailed",
"openai_reasoning_effort": effort},
)
# ── Chat logic ─────────────────────────────────────────────────────────────────
def _user_submit(message: str, history: list):
message = (message or "").strip()
history = history or []
return ("", history + [{"role": "user", "content": message}]) if message else ("", history)
async def _respond(history: list, api_key: str, model_name: str, reasoning_effort: str,
msg_state, step_state: int, uploads: list | None = None):
"""Stream the agent's reply: reasoning-trace card + clean answer + progress bar."""
from pydantic_ai.messages import (
PartDeltaEvent, PartStartEvent,
TextPart, TextPartDelta, ThinkingPart, ThinkingPartDelta,
)
history = history or []
if not history or history[-1].get("role") != "user":
yield history, msg_state, step_state, _progress_html(step_state)
return
prompt = _pending_uploads_block(uploads) + _content_text(history[-1]["content"])
api_key = (api_key or "").strip()
if not api_key:
yield (history + [{"role": "assistant",
"content": "πŸ”‘ Paste your **OpenAI API key** above to begin."}],
msg_state, step_state, _progress_html(step_state))
return
try:
agent = _build_agent(api_key, model_name, reasoning_effort)
except Exception as exc:
yield (history + [{"role": "assistant", "content": f"❌ Could not initialize: {exc}"}],
msg_state, step_state, _progress_html(step_state))
return
history = history + [
{"role": "assistant", "content": "_Thinking…_",
"metadata": {"title": "🧠 Reasoning trace", "status": "pending"}},
{"role": "assistant", "content": "_Working…_"},
]
reasoning, answer = "", ""
def paint():
history[-2]["content"] = reasoning.strip() or "_Thinking…_"
shown = _strip_marker(answer)
history[-1]["content"] = shown or "_Working…_"
s = _parse_step(answer)
return s
yield history, msg_state, step_state, _progress_html(step_state)
try:
async with agent:
async with agent.iter(prompt, message_history=msg_state or None) as run:
async for node in run:
if agent.is_model_request_node(node):
async with node.stream(run.ctx) as stream:
async for ev in stream:
if isinstance(ev, PartDeltaEvent) and isinstance(ev.delta, TextPartDelta):
answer += ev.delta.content_delta or ""
elif isinstance(ev, PartStartEvent) and isinstance(ev.part, TextPart):
answer += ev.part.content or ""
elif isinstance(ev, PartDeltaEvent) and isinstance(ev.delta, ThinkingPartDelta):
reasoning += getattr(ev.delta, "content_delta", "") or ""
elif isinstance(ev, PartStartEvent) and isinstance(ev.part, ThinkingPart):
reasoning += ev.part.content or ""
s = paint()
if s is not None:
step_state = s
yield history, msg_state, step_state, _progress_html(step_state)
result = run.result
if result and result.output is not None:
answer = str(result.output)
if answer.startswith(BLOCK_PREFIX):
# Input rail refused β€” the model was never invoked. Show one plain β›”
# bubble (no trace card) and keep the block out of conversation memory.
history[-2:] = [{"role": "assistant", "content": answer}]
yield history, msg_state, step_state, _progress_html(step_state)
return
msg_state = result.all_messages() if result else msg_state
if result and not reasoning.strip():
parts = [p.content for m in result.all_messages() for p in (getattr(m, "parts", []) or [])
if isinstance(p, ThinkingPart) and getattr(p, "content", "")]
if parts:
reasoning = "\n\n".join(parts)
s = _parse_step(answer)
if s is not None:
step_state = s
history[-2] = {"role": "assistant", "content": reasoning.strip() or "_No reasoning trace._",
"metadata": {"title": "🧠 Reasoning trace", "status": "done"}}
history[-1]["content"] = _strip_marker(answer) or "_(no output)_"
yield history, msg_state, step_state, _progress_html(step_state)
except Exception as exc:
from pydantic_ai_harness import OutputBlocked
if isinstance(exc, OutputBlocked):
# Output rail refused β€” the answer is replaced by the block message
# and deliberately never enters conversation memory (msg_state is
# not advanced), so a bad answer can't contaminate later turns.
history[-2]["metadata"] = {"title": "🧠 Reasoning trace", "status": "done"}
history[-1]["content"] = str(exc) + ('\n\nπŸ’‘ _This automated check can be overly cautious β€” **please try again**: resend your message as-is (answers vary run to run), or rephrase with a bit more detail or context. Your conversation so far is intact._')
yield history, msg_state, step_state, _progress_html(step_state)
return
low = str(exc).lower()
if "401" in low or "invalid_api_key" in low:
msg = "OpenAI rejected the API key (401). Check it's valid and has model access."
else:
msg = str(exc)
history[-2]["metadata"] = {"title": "🧠 Reasoning trace", "status": "done"}
history[-1]["content"] = f"❌ **{msg}**"
yield history, msg_state, step_state, _progress_html(step_state)
def _clear():
return [], None, 0, _progress_html(0), gr.update(visible=False)
def _seed_chat():
"""Local-only (DEMO_SEED=1): styling preview without an API key."""
doc = (
"# Project Scoping Document: Hurricane Intensity Explorer\n\n"
"## 1. Problem Summary\nScientists lack a quick way to compare CM1 sensitivity runs…\n\n"
"## 2. Success Criteria & Desired End State\n- Analysts can compare runs in <5 minutes…\n\n"
"## 3. Stakeholder Map\n| Stakeholder | Goals | Constraints |\n|---|---|---|\n"
"| Domain Scientist / PI | Rapid comparison | Limited compute |\n\n"
"## 10. Out-of-Scope Definition\n- No operational forecasting.\n\n"
"> Please confirm if this captured scope is correct or provide corrections before we proceed."
)
return [
{"role": "user", "content": "We need a tool for comparing CM1 sensitivity experiments."},
{"role": "assistant", "content": "Weighing which step of the interview flow applies; the user has "
"answered all clusters, so I should assemble the final document.",
"metadata": {"title": "🧠 Reasoning trace", "status": "done"}},
{"role": "assistant", "content": doc},
], 9, _progress_html(9)
# ── UI strings ─────────────────────────────────────────────────────────────────
FONT_HEAD = """
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Serif:wght@500;600&display=swap" rel="stylesheet">
"""
HEADER = """
<div style="display:flex; align-items:center; gap:13px; padding:26px 2px 0; flex-wrap:wrap;
font-family:'IBM Plex Sans',sans-serif;">
<a href="https://nasa-impact.github.io/AI-Agents-for-Science/" target="_blank"
style="margin-left:auto; font-family:'IBM Plex Mono',monospace; font-size:12.5px; color:#4b3fd6;
text-decoration:none;">About AKD β†—</a>
</div>
"""
# Official ORCID iD icon, inlined so the page stays self-contained (no external asset).
ORCID_ICON = (
"<svg width='14' height='14' viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg' "
"style='flex-shrink:0;'><path fill='#A6CE39' d='M256 128c0 70.7-57.3 128-128 128S0 198.7 0 "
"128 57.3 0 128 0s128 57.3 128 128z'/><g fill='#FFF'><path d='M86.3 186.2H70.9V79.1h15.4v107.1z'/>"
"<path d='M108.9 79.1h41.6c39.6 0 57 28.3 57 53.6 0 27.5-21.5 53.6-56.8 53.6h-41.8V79.1zm15.4 "
"93.3h24.5c34.9 0 42.9-26.5 42.9-39.7 0-21.5-13.7-39.7-43.7-39.7h-23.7v79.4z'/>"
"<path d='M88.7 56.8c0 5.5-4.5 10.1-10.1 10.1s-10.1-4.6-10.1-10.1c0-5.6 4.5-10.1 "
"10.1-10.1s10.1 4.6 10.1 10.1z'/></g></svg>"
)
# Collaborator chip styles (ORCID-linked = <a> with icon; name-only = plain <span>).
_COLLAB_CHIP = ("display:inline-flex; align-items:center; gap:7px; font-family:'IBM Plex Sans',sans-serif; "
"font-size:13px; font-weight:500; color:#3d33ab; background:#efeefb; "
"border:1px solid rgba(75,63,214,0.28); padding:5px 13px; border-radius:20px; text-decoration:none;")
def _collab_chip(name: str, orcid: str | None) -> str:
if orcid:
return (f'<a href="https://orcid.org/{orcid}" target="_blank" rel="noopener" '
f'style="{_COLLAB_CHIP}">{ORCID_ICON}{name}</a>')
return f'<span style="{_COLLAB_CHIP}">{name}</span>'
def _collab_row(label: str, people: list) -> str:
chips = "\n ".join(_collab_chip(n, o) for n, o in people)
return (
'<div style="display:flex; align-items:baseline; gap:9px; flex-wrap:wrap; margin:0 0 10px;">'
'<span style="font-family:\'IBM Plex Mono\',monospace; font-size:11.5px; letter-spacing:0.04em; '
'color:#565b73; font-weight:500; flex-shrink:0; min-width:74px;">'
f'{label}</span>{chips}</div>'
)
# Two teams behind the Scope Interview Agent (order and ORCID iDs as provided; a name with no
# ORCID on file renders as a plain chip).
_SME_TEAM = [
("Nidhi Jha", "0000-0002-2569-1595"),
("Nishan Pantha", "0009-0003-6948-1463"),
("Ajinkya Kulkarni", "0009-0000-2232-6181"),
("Rahul Ramachandran", "0000-0002-0647-1941"),
]
_IMPL_TEAM = [
("Rohit Sahoo", "0000-0002-2302-7623"),
("Sanjog Thapa", "0009-0002-7545-6435"),
]
_SME_ROW = _collab_row("SMEs", _SME_TEAM)
_IMPL_ROW = _collab_row("Implementation Team", _IMPL_TEAM)
TITLE_BLOCK = f"""
<div style="padding:14px 2px 2px; font-family:'IBM Plex Sans',sans-serif; color:#14162a;">
<div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin-bottom:12px;">
<span style="font-family:'IBM Plex Mono',monospace; font-size:12px; letter-spacing:0.16em;
text-transform:uppercase; color:#4b3fd6;">Accelerated Knowledge Discovery</span>
<a href="https://github.com/NASA-IMPACT/AKD-CARE" target="_blank"
style="display:inline-flex; align-items:center; gap:7px; font-family:'IBM Plex Mono',monospace;
font-size:11.5px; color:#3d33ab; background:#efeefb; border:1px solid rgba(75,63,214,0.28);
padding:5px 11px; border-radius:20px; text-decoration:none;">Built with CARE β†—</a>
<a href="https://nasa-impact.github.io/AI-Agents-for-Science/" target="_blank"
style="margin-left:auto; font-family:'IBM Plex Mono',monospace; font-size:12.5px; color:#4b3fd6;
text-decoration:none;">About AKD β†—</a>
</div>
<h1 style="margin:0 0 14px; font-family:'IBM Plex Serif',serif; font-size:34px; line-height:1.15;
font-weight:600; letter-spacing:-0.015em; color:#14162a;">
Accelerated Knowledge Discovery: Scope Interview Agent
</h1>
<div style="margin:0 0 20px;">
<div style="font-family:'IBM Plex Mono',monospace; font-size:11.5px; letter-spacing:0.04em;
text-transform:uppercase; color:#565b73; font-weight:500; margin:0 0 10px;">Collaborators</div>
{_SME_ROW}
{_IMPL_ROW}
</div>
<p style="margin:0 0 14px; font-size:15.5px; line-height:1.7; color:#3b4058; max-width:820px;">
The Scope Interview Agent helps scientists, engineers, and project managers clearly define a project
<strong>before design or implementation begins</strong>. Through a structured interview, it collects
information about the problem, intended users and stakeholders, project boundaries, assumptions, requirements,
important entities, workflows, risks, and measures of success.
</p>
<p style="margin:0; font-size:15.5px; line-height:1.7; color:#565b73; max-width:820px;">
The agent pauses after each topic so users can review, correct, or expand the information before continuing.
It helps organize decisions but does not design the technical solution, propose a system architecture, or perform
implementation work. Any unconfirmed suggestion is clearly identified for human review. At the end of the interview,
the agent produces a downloadable three- to four-page scoping document that teams can use to align stakeholders,
communicate requirements, and guide subsequent planning.
</p>
</div>
"""
EXAMPLE_PROMPTS = [
"Hey, let's scope a chatbot for the Science Discovery Engine",
"I want to scope an illustration agent that turns science results into publication-ready figures",
"Help me scope a PI planning agent for proposals, milestones, and team coordination",
"Let's scope a simple email-triage agent for our team β€” nothing fancy",
]
PROCESS = """
<div style="background:#fff; border:1px solid rgba(20,22,40,0.1); border-radius:16px; padding:28px 30px; font-family:'IBM Plex Sans',sans-serif; color:#14162a;">
<div style="font-family:'IBM Plex Mono',monospace; font-size:12px; letter-spacing:0.16em; text-transform:uppercase; color:#4b3fd6; margin-bottom:22px;">Process highlights</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:22px 32px;">
<div style="display:flex; gap:14px; align-items:flex-start;"><span style="font-family:'IBM Plex Mono',monospace; font-size:13px; font-weight:600; color:#4b3fd6; flex-shrink:0; padding-top:1px;">01</span><div style="font-size:14.5px; line-height:1.6; color:#3b4058;">The agent asks one focused question at a time across nine mandatory steps β€” problem understanding, stakeholder mapping, system constraints, scope boundaries, assumptions, requirements, entities, workflows, and risks.</div></div>
<div style="display:flex; gap:14px; align-items:flex-start;"><span style="font-family:'IBM Plex Mono',monospace; font-size:13px; font-weight:600; color:#4b3fd6; flex-shrink:0; padding-top:1px;">02</span><div style="font-size:14.5px; line-height:1.6; color:#3b4058;">Vague answers are challenged and "TBD" is recorded as an open item β€” missing information is never silently assumed.</div></div>
<div style="display:flex; gap:14px; align-items:flex-start;"><span style="font-family:'IBM Plex Mono',monospace; font-size:13px; font-weight:600; color:#4b3fd6; flex-shrink:0; padding-top:1px;">03</span><div style="font-size:14.5px; line-height:1.6; color:#3b4058;">Uploaded reference documents are advisory context only β€” anything the agent infers from them must be confirmed by you before it enters the requirements.</div></div>
<div style="display:flex; gap:14px; align-items:flex-start;"><span style="font-family:'IBM Plex Mono',monospace; font-size:13px; font-weight:600; color:#4b3fd6; flex-shrink:0; padding-top:1px;">04</span><div style="font-size:14.5px; line-height:1.6; color:#3b4058;">The interview ends with a ten-section Scope Requirements Document you confirm and download β€” the agent stops there and never proceeds to design.</div></div>
</div>
</div>
"""
CARE_BLOCK = """
<div style="background:#fff; border:1px solid rgba(20,22,40,0.1); border-radius:16px; padding:28px 30px 34px; font-family:'IBM Plex Sans',sans-serif; color:#14162a;">
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:16px; flex-wrap:wrap; margin-bottom:14px;">
<div>
<div style="font-family:'IBM Plex Mono',monospace; font-size:12px; letter-spacing:0.16em; text-transform:uppercase; color:#4b3fd6; margin-bottom:12px;">Methodology</div>
<h2 style="margin:0; font-family:'IBM Plex Serif',serif; font-size:26px; font-weight:600; letter-spacing:-0.01em; color:#14162a;">Designed with CARE</h2>
</div>
<a href="https://github.com/NASA-IMPACT/AKD-CARE" target="_blank" style="display:inline-flex; align-items:center; gap:8px; font-family:'IBM Plex Mono',monospace; font-size:12.5px; color:#3d33ab; background:#efeefb; border:1px solid rgba(75,63,214,0.28); padding:9px 14px; border-radius:10px; text-decoration:none;">NASA-IMPACT / AKD-CARE β†—</a>
</div>
<p style="margin:0 0 30px; font-size:15px; line-height:1.7; color:#3b4058; max-width:800px;">Scope Interview Agent is built using <strong style="color:#14162a;">Collaborative Agent Reasoning Engineering (CARE)</strong> β€” a three-party workflow in which subject-matter experts, developers, and LLM-based helper agents iterate on shared artifacts through staged review gates.</p>
<div style="position:relative; width:100%; max-width:720px; height:420px; margin:0 auto;">
<svg viewBox="0 0 720 420" preserveAspectRatio="none" style="position:absolute; inset:0; width:100%; height:100%;">
<defs>
<marker id="ah-blue" markerWidth="9" markerHeight="9" refX="4.5" refY="4.5" orient="auto"><path d="M1,1 L8,4.5 L1,8 Z" fill="#1b4f9c"></path></marker>
<marker id="ah-green" markerWidth="9" markerHeight="9" refX="4.5" refY="4.5" orient="auto"><path d="M1,1 L8,4.5 L1,8 Z" fill="#2f8f4e"></path></marker>
<marker id="ah-indigo" markerWidth="8" markerHeight="8" refX="4" refY="4" orient="auto"><path d="M1,1 L7,4 L1,7 Z" fill="#4b3fd6"></path></marker>
</defs>
<line x1="320" y1="138" x2="160" y2="272" stroke="#1b4f9c" stroke-width="2.5" marker-start="url(#ah-blue)" marker-end="url(#ah-blue)"></line>
<line x1="400" y1="138" x2="560" y2="272" stroke="#1b4f9c" stroke-width="2.5" marker-start="url(#ah-blue)" marker-end="url(#ah-blue)"></line>
<line x1="172" y1="305" x2="548" y2="305" stroke="#2f8f4e" stroke-width="2.5" marker-start="url(#ah-green)" marker-end="url(#ah-green)"></line>
<line x1="360" y1="200" x2="360" y2="153" stroke="#4b3fd6" stroke-width="1.6" stroke-dasharray="5 5" marker-end="url(#ah-indigo)"></line>
<line x1="331" y1="243" x2="168" y2="291" stroke="#4b3fd6" stroke-width="1.6" stroke-dasharray="5 5" marker-end="url(#ah-indigo)"></line>
<line x1="389" y1="243" x2="552" y2="291" stroke="#4b3fd6" stroke-width="1.6" stroke-dasharray="5 5" marker-end="url(#ah-indigo)"></line>
</svg>
<div style="position:absolute; left:50%; top:25%; transform:translate(-50%,-50%); width:92px; height:92px; border-radius:50%; background:#1b4f9c; display:flex; align-items:center; justify-content:center; color:#fff; font-family:'IBM Plex Mono',monospace; font-size:13px; font-weight:600; box-shadow:0 8px 22px rgba(27,79,156,0.28);">SMEs</div>
<div style="position:absolute; left:16.67%; top:72.6%; transform:translate(-50%,-50%); width:92px; height:92px; border-radius:50%; background:#2f8f4e; display:flex; align-items:center; justify-content:center; color:#fff; font-family:'IBM Plex Mono',monospace; font-size:13px; font-weight:600; box-shadow:0 8px 22px rgba(47,143,78,0.28);">Devs</div>
<div style="position:absolute; left:83.3%; top:72.6%; transform:translate(-50%,-50%); width:92px; height:92px; border-radius:50%; background:#6b3fa0; display:flex; align-items:center; justify-content:center; color:#fff; font-family:'IBM Plex Mono',monospace; font-size:13px; font-weight:600; box-shadow:0 8px 22px rgba(107,63,160,0.28);">Agents</div>
<div style="position:absolute; left:50%; top:55.9%; transform:translate(-50%,-50%); background:#fff; border:1px solid rgba(20,22,40,0.16); border-radius:10px; padding:9px 13px; text-align:center; font-family:'IBM Plex Mono',monospace; font-size:11.5px; line-height:1.4; color:#14162a; font-weight:500;">Artifacts +<br>Stage Gates</div>
</div>
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:18px; margin-top:28px;">
<div style="border-top:2px solid #1b4f9c; padding-top:14px;"><div style="font-size:14.5px; font-weight:600; margin-bottom:6px; color:#14162a;">Subject Matter Experts</div><div style="font-size:13px; line-height:1.55; color:#565b73;">Provide domain knowledge, review, and approve artifacts.</div></div>
<div style="border-top:2px solid #2f8f4e; padding-top:14px;"><div style="font-size:14.5px; font-weight:600; margin-bottom:6px; color:#14162a;">Developers</div><div style="font-size:13px; line-height:1.55; color:#565b73;">Implement, integrate tools, and build the agent.</div></div>
<div style="border-top:2px solid #6b3fa0; padding-top:14px;"><div style="font-size:14.5px; font-weight:600; margin-bottom:6px; color:#14162a;">Helper Agents (LLM-based)</div><div style="font-size:13px; line-height:1.55; color:#565b73;">Translate intent into structured artifacts.</div></div>
</div>
</div>
"""
FOOTER = """
<div style="margin:8px 0 26px; font-size:12px; line-height:1.6; color:#8a8fa6; text-align:center;
font-family:'IBM Plex Sans',sans-serif;">
Requirements extraction only Β· no design Β· no code Β· human remains in the loop.
Built from the agent's CARE workspace artifacts.
</div>
"""
CSS = """
/* ── page ────────────────────────────────────────────────────────────────── */
html, body, gradio-app { background: #eceae4 !important; }
.gradio-container {
max-width: 1200px !important; margin: 0 auto !important;
background: #eceae4 !important;
font-family: 'IBM Plex Sans', sans-serif !important;
}
footer { display: none !important; }
.gradio-container .html-container { padding: 0 !important; }
/* ── cards ───────────────────────────────────────────────────────────────── */
#keycard, #chatcard, #progresscard {
background: #fff !important; border: 1px solid rgba(20,22,40,0.1) !important;
border-radius: 16px !important; padding: 14px 16px !important; gap: 6px !important;
box-shadow: none !important;
}
#keycard .block, #chatcard .block, #progresscard .block {
background: transparent !important; border: none !important; box-shadow: none !important;
}
#keycard .form, #chatcard .form { background: transparent !important; border: none !important; box-shadow: none !important; }
#keycard label > span, #keycard .block-label { background: transparent !important; border: none !important; padding-left: 0 !important; }
#keycard label, #keycard label span { color: #14162a !important; font-weight: 600 !important; font-size: 14.5px !important; }
#keycard .block-info { color: #8a8fa6 !important; font-size: 12.5px !important; }
#keycard input {
background: #f4f3ef !important; border: 1px solid rgba(20,22,40,0.14) !important;
border-radius: 10px !important; color: #14162a !important;
font-family: 'IBM Plex Mono', monospace !important; font-size: 14px !important;
}
#keyrow { gap: 14px !important; align-items: flex-end !important; }
#keyrow .wrap:has(> .wrap-inner) {
background: #f4f3ef !important; border: 1px solid rgba(20,22,40,0.14) !important;
border-radius: 10px !important; min-height: 42px !important; box-shadow: none !important;
}
#keyrow .wrap-inner { padding: 8px 12px !important; background: transparent !important; }
#keyrow .wrap-inner input { background: transparent !important; border: none !important; border-radius: 0 !important; box-shadow: none !important; }
#keyrow .secondary-wrap { background: transparent !important; }
/* ── progress card ───────────────────────────────────────────────────────── */
#progresscard { padding: 12px 16px !important; }
/* ── chat area ───────────────────────────────────────────────────────────── */
#chatcard .chatbot, #chatcard .bubble-wrap { background: #fff !important; }
#chatcard .bubble-wrap::-webkit-scrollbar { width: 11px; }
#chatcard .bubble-wrap::-webkit-scrollbar-thumb { background: rgba(20,22,40,.22); border-radius: 6px; border: 3px solid #fff; }
#chatcard .bubble-wrap { scrollbar-width: thin; scrollbar-color: rgba(20,22,40,.25) transparent; }
#chatcard .user-row .bubble, #chatcard .message-row.user-row .message,
#chatcard .message.user, #chatcard [class*="user"] > .message {
background: #4b3fd6 !important; color: #fff !important;
border: none !important; border-radius: 14px 14px 4px 14px !important;
}
#chatcard .user-row .bubble *, #chatcard .message-row.user-row .message * { color: #fff !important; }
#chatcard .bot-row .bubble, #chatcard .message-row.bot-row .message, #chatcard .message.bot {
background: transparent !important; border: none !important;
box-shadow: none !important; color: #3b4058 !important;
}
/* the 'S' avatar β†’ rounded square, header-logo size */
#chatcard .avatar-container {
width: 34px !important; height: 34px !important; border-radius: 9px !important;
overflow: hidden !important; border: none !important;
flex-shrink: 0; padding: 0 !important; margin: 0 6px 0 0 !important;
}
#chatcard .avatar-container img, #chatcard .avatar-image {
width: 34px !important; height: 34px !important;
border-radius: 9px !important; object-fit: cover !important;
}
/* reasoning-trace card */
#chatcard .thought-group, #chatcard .message-row .metadata {
background: #f6f6f3 !important; border: 1px solid rgba(20,22,40,0.1) !important;
border-radius: 12px !important;
}
#chatcard .thought-group .title .md { background: transparent !important; }
#chatcard .thought-group > div:not(.title) {
border-top: 1px dashed rgba(20,22,40,0.12); margin-top: 4px; padding-top: 10px;
}
#chatcard .thought-group > div:not(.title) p {
font-family: 'IBM Plex Mono', monospace !important; font-size: 12px !important;
line-height: 1.95 !important; color: #565b73 !important;
}
/* markdown inside replies */
#chatcard .bot-row h1, #chatcard .bot-row h2 {
font-family: 'IBM Plex Serif', serif !important; font-size: 20px !important;
font-weight: 600 !important; color: #14162a !important; margin: 20px 0 10px !important;
}
#chatcard .bot-row h3 {
font-family: 'IBM Plex Mono', monospace !important; font-size: 12px !important;
letter-spacing: .14em !important; text-transform: uppercase !important;
color: #4b3fd6 !important; font-weight: 600 !important; margin: 20px 0 8px !important;
}
#chatcard .bot-row h4 { font-size: 15px !important; font-weight: 600 !important; color: #14162a !important; margin: 14px 0 6px !important; }
#chatcard .bot-row strong { color: #14162a; }
#chatcard .bot-row hr { border: none !important; border-top: 1px solid rgba(20,22,40,.08) !important; margin: 16px 0 !important; }
#chatcard .bot-row li::marker { color: #4b3fd6; font-weight: 600; }
#chatcard .bot-row blockquote {
border-left: 3px solid rgba(75,63,214,0.4); padding-left: 14px;
color: #565b73 !important; font-style: italic; margin: 12px 0;
}
#chatcard .bot-row table { width: 100%; border: 1px solid rgba(20,22,40,.09) !important; border-radius: 13px; border-collapse: separate !important; border-spacing: 0; overflow: hidden; font-size: 13px; }
#chatcard .bot-row th { font-family: 'IBM Plex Mono', monospace; font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: #8a8fa6; background: #fbfbfa; text-align: left; padding: 10px 14px; border-bottom: 1px solid rgba(20,22,40,.07) !important; }
#chatcard .bot-row td { padding: 11px 14px; border: none !important; border-bottom: 1px solid rgba(20,22,40,.07) !important; color: #3b4058; }
#chatcard .bot-row tr:last-child td { border-bottom: none !important; }
/* ── composer ─────────────────────────────────────────────────────────────── */
#composer {
border-top: 1px solid rgba(20,22,40,0.07) !important; padding-top: 12px !important;
margin-top: 4px !important; align-items: center !important; gap: 12px !important;
}
#composer textarea { min-height: 52px !important; }
#composer button { height: 52px !important; margin: 0 !important; align-self: center !important; }
#composer textarea, #composer input {
background: #f4f3ef !important; border: 1px solid rgba(20,22,40,0.14) !important;
border-radius: 12px !important; color: #14162a !important; font-size: 14.5px !important;
}
#composer textarea::placeholder { color: #8a8fa6 !important; }
#composer button, button.primary {
background: #4b3fd6 !important; color: #fff !important; border: none !important;
border-radius: 12px !important; font-weight: 600 !important; font-size: 14.5px !important;
}
#composer button:hover, button.primary:hover { background: #3d33ab !important; }
/* ── action row ──────────────────────────────────────────────────────────── */
#actionrow { gap: 10px !important; }
#startbtn {
background: #4b3fd6 !important; color: #fff !important; border: none !important;
border-radius: 12px !important; font-weight: 600 !important; box-shadow: none !important;
}
#startbtn:hover { background: #3d33ab !important; }
#newchat {
background: #fff !important; border: 1px solid rgba(20,22,40,0.12) !important;
color: #3b4058 !important; border-radius: 12px !important;
font-weight: 500 !important; font-size: 14px !important; box-shadow: none !important;
}
#newchat:hover { background: #fbfbfa !important; }
#dlbtn {
background: #efeefb !important; border: 1px solid rgba(75,63,214,0.28) !important;
color: #3d33ab !important; border-radius: 12px !important;
font-weight: 500 !important; font-size: 14px !important; box-shadow: none !important;
}
#dlbtn:hover { background: #e4e2f8 !important; }
#dlbtn:disabled, #dlbtn[disabled] {
opacity: .45 !important; cursor: not-allowed !important;
background: #f4f3f9 !important; color: #8a8fa6 !important;
border-color: rgba(20,22,40,0.1) !important;
}
/* ── examples β†’ white tiles directly on the page ─────────────────────────── */
#examples { background: transparent !important; border: none !important; padding: 0 !important; box-shadow: none !important; }
#examples .block { background: transparent !important; border: none !important; }
#examples .ex-head { color: #14162a; font-weight: 600; font-size: 15px; margin: 8px 0 14px; }
#examples .ex-row { gap: 12px !important; align-items: stretch !important; margin-bottom: 12px !important; }
#examples .ex-row > div { display: flex !important; }
#examples button {
background: #fff !important; border: 1px solid rgba(20,22,40,0.1) !important;
border-radius: 11px !important; padding: 14px 16px !important;
color: #3b4058 !important; font-size: 13.5px !important; line-height: 1.5 !important;
text-align: left !important; white-space: normal !important; box-shadow: none !important;
height: auto !important; width: 100% !important; justify-content: flex-start !important;
font-weight: 400 !important;
}
#examples button:hover { border-color: rgba(75,63,214,0.4) !important; }
/* ── upload box ──────────────────────────────────────────────────────────── */
#uploadbox { background: transparent !important; border: none !important; padding-top: 6px !important; }
#uploadbox label, #uploadbox .label, #uploadbox [data-testid="block-title"] {
color: #565b73 !important; font-weight: 500 !important; font-size: 12.5px !important;
background: transparent !important; border: none !important;
}
#uploadbox .wrap { width: 100% !important; min-height: 90px !important; }
#uploadbox .file-preview, #uploadbox .wrap {
background: #fbfbfa !important; border: 1px dashed rgba(20,22,40,0.18) !important;
border-radius: 10px !important; color: #8a8fa6 !important; font-size: 12.5px !important;
}
/* file rows inside the corpus box: force the light palette (the theme's
default even/odd table rows resolve dark and clash with the design) */
#uploadbox .file-preview table, #uploadbox .file-preview thead, #uploadbox .file-preview tbody,
#uploadbox .file-preview tr, #uploadbox .file-preview td, #uploadbox .file-preview th {
background: transparent !important; color: #3b4058 !important;
border-color: rgba(20,22,40,0.08) !important;
}
#uploadbox .file-preview tr { border-bottom: 1px solid rgba(20,22,40,0.07) !important; }
#uploadbox .file-preview a { color: #3d33ab !important; }
#uploadbox .file-preview button { color: #8a8fa6 !important; background: transparent !important; }
#uploadnote { padding-top: 2px !important; }
/* inline code chips + fenced code blocks β€” light design, not Gradio's dark chip.
Gradio 6 ships a dark inline-code chip, which lands dark-on-dark inside these light
bubbles and made identifiers (CMR concept-ids, GIBS layer names) unreadable β€” hence
the !important: it has to beat Gradio's own rule. */
#chatcard .bot-row code {
background: #efeefb !important; color: #3d33ab !important;
border: 1px solid rgba(75,63,214,0.22) !important; border-radius: 6px !important;
padding: 1.5px 7px !important; font-family: 'IBM Plex Mono', monospace !important;
font-size: 12.5px !important;
}
/* headings and table cells stay plain β€” a chip there reads as clutter, and tables are
dense enough already. More specific than the rule above, so these win. */
#chatcard .bot-row h1 code, #chatcard .bot-row h2 code,
#chatcard .bot-row h3 code, #chatcard .bot-row h4 code {
background: transparent !important; border: none !important; padding: 0 !important;
color: #8a8fa6 !important; font-weight: 400 !important;
}
#chatcard .bot-row td code, #chatcard .bot-row th code {
background: transparent !important; border: none !important; padding: 0 !important;
color: #14162a !important;
}
#chatcard .bot-row pre, #chatcard .bot-row .code_wrap {
background: #f6f6f3 !important; border: 1px solid rgba(20,22,40,0.1) !important;
border-radius: 12px !important;
}
#chatcard .bot-row pre { padding: 13px 16px !important; }
#chatcard .bot-row pre code {
background: transparent !important; border: none !important; padding: 0 !important;
color: #14162a !important; font-size: 12.5px !important; line-height: 1.7 !important;
}
"""
def _light_theme():
"""Design palette forced for BOTH light and dark modes (no theme flash)."""
t = gr.themes.Soft(
primary_hue="indigo",
neutral_hue="slate",
font=[gr.themes.GoogleFont("IBM Plex Sans"), "sans-serif"],
font_mono=[gr.themes.GoogleFont("IBM Plex Mono"), "monospace"],
)
pairs = {
"body_background_fill": "#eceae4",
"table_even_background_fill": "#fbfbfa",
"table_odd_background_fill": "#ffffff",
"checkbox_background_color": "#ffffff",
"background_fill_primary": "#ffffff",
"background_fill_secondary": "#f6f6f3",
"block_background_fill": "#ffffff",
"block_border_color": "rgba(20,22,40,0.1)",
"block_label_background_fill": "#ffffff",
"block_label_text_color": "#14162a",
"block_title_text_color": "#14162a",
"border_color_primary": "rgba(20,22,40,0.12)",
"input_background_fill": "#f4f3ef",
"input_background_fill_focus": "#f4f3ef",
"input_border_color": "rgba(20,22,40,0.14)",
"input_placeholder_color": "#8a8fa6",
"body_text_color": "#14162a",
"body_text_color_subdued": "#565b73",
"color_accent_soft": "#efeefb",
"link_text_color": "#3d33ab",
"button_primary_background_fill": "#4b3fd6",
"button_primary_background_fill_hover": "#3d33ab",
"button_primary_text_color": "#ffffff",
"button_secondary_background_fill": "#ffffff",
"button_secondary_text_color": "#3b4058",
}
kwargs = {}
for k, v in pairs.items():
kwargs[k] = v
kwargs[f"{k}_dark"] = v
return t.set(**kwargs)
# ── Build UI ───────────────────────────────────────────────────────────────────
# ── Visitor counting ────────────────────────────────────────────────────────────
# Hugging Face exposes no visitor/pageview API for Spaces (every analytics endpoint
# 404s, and the `expand` field list has nothing of the kind), so the app counts its
# own. Gradio issues a fresh session_hash per page load, so one log line per unseen
# session is one visit; the ops dashboard tallies these lines.
#
# Privacy: the client IP is NEVER logged raw. It is hashed with a per-boot random
# salt and truncated to 10 hex chars β€” enough to count DISTINCT visitors, while
# staying non-reversible and not comparable across restarts.
_SEEN_SESSIONS: set[str] = set()
# A SHARED salt (same VISIT_SALT secret on every Space) makes visitor ids
# comparable ACROSS agents, so the dashboard can count one person visiting
# three agents as one visitor. Without it each Space salts per boot, which
# still counts distinct visitors correctly per agent.
_VISIT_SALT = os.environ.get("VISIT_SALT") or os.urandom(8).hex()
def _log_visit(request: gr.Request):
"""Emit one `[visit]` line per new page load. Never raises β€” telemetry must
not be able to break a page load."""
try:
sid = str(getattr(request, "session_hash", "") or "")
if not sid or sid in _SEEN_SESSIONS:
return
_SEEN_SESSIONS.add(sid)
ip = ""
try: # on HF the app is behind a proxy: the real client is x-forwarded-for
hdrs = {str(k).lower(): str(v) for k, v in dict(request.headers).items()}
ip = hdrs.get("x-forwarded-for", "").split(",")[0].strip()
except Exception:
pass
if not ip:
try:
ip = str(getattr(getattr(request, "client", None), "host", "") or "")
except Exception:
ip = ""
vid = hashlib.sha256((_VISIT_SALT + ip).encode()).hexdigest()[:10] if ip else "unknown"
print(f"[visit] session={sid[:12]} visitor={vid} sessions={len(_SEEN_SESSIONS)}", flush=True)
except Exception:
pass
# ── Live model list ─────────────────────────────────────────────────────────────
# The model dropdown used to be a hardcoded list, so a newly released model stayed
# invisible until someone edited this file and redeployed the Space β€” which is how it
# ended up still offering gpt-5.2/5.5 after the GPT-5.6 family shipped. Instead we ask
# the visitor's own key for the live catalogue (GET /v1/models) and build the list from
# it, so new releases appear on their own with no redeploy. The static list below is
# only the pre-key / offline fallback, and the dropdown keeps allow_custom_value=True
# so any id can still be typed by hand.
FALLBACK_MODELS = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna",
"gpt-5.5", "gpt-5.4", "gpt-5.2", "gpt-5-mini", "gpt-5-nano"]
# Set AGENT_MODEL to pin a model; left unset, the newest flagship wins so the agent
# follows new releases automatically.
_PINNED_MODEL = os.environ.get("AGENT_MODEL", "").strip().split(":")[-1]
# DENYLIST, deliberately not an allowlist: /v1/models also serves embedding, audio,
# image and video endpoints that can't take a chat turn, but a future chat model may
# well not be named "gpt-*" (the 5.6 tiers are already sol/terra/luna), so anything
# not positively excluded is offered rather than silently dropped.
_NOT_CHAT = re.compile(r"embed|whisper|tts|audio|realtime|transcrib|image|dall-e|sora|"
r"video|moderation|rerank|davinci|babbage|instruct|codex|guard", re.I)
_SNAPSHOT = re.compile(r"-\d{4}-\d{2}-\d{2}$|-\d{4}$") # dated pin: gpt-5.6-sol-2026-07-09
# Capability tiers inside one release: flagship first, cheap/fast variants last, so a
# same-day tie never lets the cheapest tier become the default.
_TIERS = (("-sol", 0), ("-terra", 1), ("-luna", 2), ("-mini", 2), ("-nano", 3))
_MODEL_CACHE: dict = {"t": 0.0, "ids": []}
_MODEL_TTL = 600 # re-ask OpenAI at most every 10 minutes
def _tier(mid: str) -> int:
for suffix, rank in _TIERS:
if mid.endswith(suffix):
return rank
return 0 # an unsuffixed id is its family's flagship
def _chat_models(payload: dict) -> list[str]:
"""Chat/reasoning ids from a /v1/models payload: newest release first, flagship
tier before the cheaper tiers, dated snapshots last. Recency comes from the API's
own `created` stamp (bucketed by day so one release's tiers stay together), so
there is no version arithmetic here to go stale."""
rows: list[tuple[str, int]] = []
for m in (payload or {}).get("data") or []:
mid = str(m.get("id") or "")
if not mid or _NOT_CHAT.search(mid):
continue
rows.append((mid, int(m.get("created") or 0)))
rows.sort(key=lambda r: (bool(_SNAPSHOT.search(r[0])),
-(r[1] // 86400), _tier(r[0]), -r[1], r[0]))
return [mid for mid, _ in rows]
def _default_model(ids: list[str]) -> str:
"""Newest flagship-tier model, unless AGENT_MODEL pins one."""
if _PINNED_MODEL:
return _PINNED_MODEL
for mid in ids:
if not _SNAPSHOT.search(mid) and _tier(mid) == 0:
return mid
return ids[0] if ids else DEFAULT_MODEL
def _fetch_models(api_key: str) -> list[str]:
"""Live catalogue for this key, briefly cached. Returns [] on any failure so the
dropdown keeps its current contents rather than emptying out."""
import httpx
now = time.monotonic()
if _MODEL_CACHE["ids"] and now - _MODEL_CACHE["t"] < _MODEL_TTL:
return _MODEL_CACHE["ids"]
try:
r = httpx.get("https://api.openai.com/v1/models",
headers={"Authorization": f"Bearer {api_key}"}, timeout=10)
r.raise_for_status()
ids = _chat_models(r.json())
except Exception as exc:
print(f"[models] live list unavailable, keeping fallback: {exc}", flush=True)
return []
if ids:
_MODEL_CACHE.update(t=now, ids=ids)
print(f"[models] {len(ids)} chat models from OpenAI; newest={ids[0]}", flush=True)
return ids
def _refresh_models(api_key: str, current: str):
"""Repopulate the dropdown from the visitor's own catalogue (fires on key blur)."""
key = (api_key or "").strip()
ids = _fetch_models(key) if key else []
if not ids:
return gr.update()
# An untouched selector still holds the startup default, so advance it to the
# newest flagship the live catalogue offers β€” otherwise a new release would only
# be LISTED and never actually used. A deliberate visitor choice is preserved,
# and a choice OpenAI has since retired falls back to the newest flagship.
keep = current if (current and current != _INITIAL_MODEL and current in ids) else _default_model(ids)
return gr.update(choices=ids, value=keep)
_INITIAL_MODEL = _default_model(FALLBACK_MODELS)
with gr.Blocks(title="Scope Interview Agent") as demo:
msg_state = gr.State(None)
step_state = gr.State(0)
uploads_state = gr.State([])
gr.HTML(TITLE_BLOCK)
with gr.Column(elem_id="keycard"):
with gr.Row(elem_id="keyrow"):
api_key = gr.Textbox(
label="πŸ”‘ OpenAI API key",
placeholder="sk-…",
type="password",
info="Bring your own key β€” used only for this session and never stored.",
scale=3,
)
model_dd = gr.Dropdown(
label="πŸ€– Model",
choices=FALLBACK_MODELS,
value=_INITIAL_MODEL,
allow_custom_value=True,
info="Or type any model id.",
scale=1, min_width=200,
)
effort_dd = gr.Dropdown(
label="⚑ Reasoning effort",
choices=[("Low Β· fastest", "low"),
("Medium", "medium"),
("High Β· thorough", "high"),
("Max Β· deepest (5.6+)", "max"),
("Ultra Β· subagents (5.6+)", "ultra")],
value="medium",
info="Speed vs. depth.",
scale=1, min_width=180,
)
with gr.Column(elem_id="progresscard"):
progress_display = gr.HTML(_progress_html(0))
with gr.Column(elem_id="chatcard"):
chatbot = gr.Chatbot(
height=580,
show_label=False,
autoscroll=True,
avatar_images=(None, _ensure_avatar()),
placeholder=(
"<div style='text-align:center; max-width:600px; margin:0 auto;'>"
"<div style='font-size:22px; font-weight:600; color:#14162a; margin-bottom:14px;'>"
"πŸ“‹ Ready to scope your project</div>"
"<div style='font-size:15px; line-height:1.7; color:#565b73; font-style:italic;'>"
"Enter your OpenAI key above, then just say hello β€” e.g. "
"<strong>β€œHey, let's start making the agent…”</strong> β€” and the interviewer takes it "
"from there: one cluster of questions at a time, nine clusters, then a scoping "
"document you can download.</div>"
"<div style='margin-top:22px; font-family:IBM Plex Mono,monospace; font-size:11.5px; "
"letter-spacing:0.08em; text-transform:uppercase; color:#a0a4b5;'>"
"Requirements only Β· no design Β· no code</div>"
"</div>"
),
)
with gr.Row(elem_id="composer"):
msg = gr.Textbox(
placeholder="Hey, let's start making the agent… (\"TBD\" is a valid answer)",
show_label=False,
scale=6,
)
send = gr.Button("Send", variant="primary", scale=1, min_width=110)
upload_box = gr.File(
label="πŸ“Ž Reference documents (optional β€” advisory only)",
file_count="multiple",
file_types=[".pdf", ".docx", ".pptx", ".txt", ".md"],
height=110,
elem_id="uploadbox",
)
upload_note = gr.HTML("", elem_id="uploadnote")
with gr.Row(elem_id="actionrow"):
clear_btn = gr.Button("πŸ—‘ New session", elem_id="newchat", min_width=140)
dl_btn = gr.Button("πŸ“₯ Download document (.md)", elem_id="dlbtn", min_width=200,
interactive=False) # enabled once the scoping document exists
dl_file = gr.File(label="Scope Requirements Document", visible=False)
with gr.Column(elem_id="examples"):
gr.HTML('<div class="ex-head">≣ &nbsp;Try an example</div>')
example_btns = []
for a, b in zip(EXAMPLE_PROMPTS[::2], EXAMPLE_PROMPTS[1::2]):
with gr.Row(elem_classes="ex-row"):
example_btns += [gr.Button(a, min_width=0), gr.Button(b, min_width=0)]
gr.HTML(PROCESS)
gr.HTML(CARE_BLOCK)
gr.HTML(FOOTER)
# ── Wiring ──────────────────────────────────────────────────────────────
bot_outputs = [chatbot, msg_state, step_state, progress_display]
def _dl_state(history):
"""Download stays disabled until the scoping document actually exists."""
return gr.update(interactive=bool(_extract_document(history)))
send.click(_user_submit, [msg, chatbot], [msg, chatbot], queue=False).then(
_respond, [chatbot, api_key, model_dd, effort_dd, msg_state, step_state, uploads_state],
bot_outputs, show_progress="hidden",
).then(_dl_state, [chatbot], [dl_btn], queue=False)
msg.submit(_user_submit, [msg, chatbot], [msg, chatbot], queue=False).then(
_respond, [chatbot, api_key, model_dd, effort_dd, msg_state, step_state, uploads_state],
bot_outputs, show_progress="hidden",
).then(_dl_state, [chatbot], [dl_btn], queue=False)
upload_box.change(_ingest_files, [upload_box, uploads_state], [uploads_state, upload_note], queue=False)
def _clear_all():
return ([], None, 0, _progress_html(0), gr.update(visible=False), [], None, "",
gr.update(interactive=False))
clear_btn.click(_clear_all,
outputs=[chatbot, msg_state, step_state, progress_display, dl_file,
uploads_state, upload_box, upload_note, dl_btn],
queue=False)
dl_btn.click(_download_md, inputs=[chatbot], outputs=[dl_file])
for _b in example_btns:
_b.click(lambda v=_b.value: v, outputs=msg, queue=False)
if os.environ.get("DEMO_SEED"): # local-only styling preview
demo.load(_seed_chat, outputs=[chatbot, step_state, progress_display]).then(
_dl_state, [chatbot], [dl_btn], queue=False)
demo.load(_log_visit, None, None) # count each page load (see _log_visit)
# New releases appear on their own: once a key is present, swap the static
# fallback for that key's live catalogue (see _refresh_models).
api_key.blur(_refresh_models, [api_key, model_dd], [model_dd], queue=False)
if __name__ == "__main__":
# On HF Spaces, let Gradio pick its own default port (7860 / GRADIO_SERVER_PORT):
# forcing a fallback port makes the health check never pass. Locally, set PORT.
_port = os.environ.get("GRADIO_SERVER_PORT") or os.environ.get("PORT")
demo.queue(default_concurrency_limit=4).launch(
server_name="0.0.0.0",
server_port=int(_port) if _port else None,
theme=_light_theme(),
css=CSS,
head=FONT_HEAD,
)