| """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")) |
| print("[boot] app.py loadingβ¦", flush=True) |
|
|
| |
|
|
| 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_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 |
|
|
|
|
| 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): |
| 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) |
|
|
|
|
| |
|
|
| 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>""" |
|
|
|
|
| |
|
|
| 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) |
|
|
|
|
| |
|
|
| _MAX_DOC_CHARS = 20_000 |
|
|
|
|
| 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 = 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) |
|
|
|
|
| |
|
|
| 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}, |
| ) |
|
|
|
|
| |
|
|
| 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): |
| |
| |
| 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): |
| |
| |
| |
| 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) |
|
|
|
|
| |
|
|
| 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> |
| """ |
|
|
| |
| 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>" |
| ) |
|
|
| |
| _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>' |
| ) |
|
|
|
|
| |
| |
| _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) |
|
|
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _SEEN_SESSIONS: set[str] = set() |
| |
| |
| |
| |
| _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: |
| 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 |
|
|
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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"] |
|
|
| |
| |
| _PINNED_MODEL = os.environ.get("AGENT_MODEL", "").strip().split(":")[-1] |
|
|
| |
| |
| |
| |
| _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}$") |
| |
| |
| _TIERS = (("-sol", 0), ("-terra", 1), ("-luna", 2), ("-mini", 2), ("-nano", 3)) |
| _MODEL_CACHE: dict = {"t": 0.0, "ids": []} |
| _MODEL_TTL = 600 |
|
|
|
|
| def _tier(mid: str) -> int: |
| for suffix, rank in _TIERS: |
| if mid.endswith(suffix): |
| return rank |
| return 0 |
|
|
|
|
| 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() |
| |
| |
| |
| |
| 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) |
| dl_file = gr.File(label="Scope Requirements Document", visible=False) |
|
|
| with gr.Column(elem_id="examples"): |
| gr.HTML('<div class="ex-head">β£ 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) |
|
|
| |
| 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"): |
| demo.load(_seed_chat, outputs=[chatbot, step_state, progress_display]).then( |
| _dl_state, [chatbot], [dl_btn], queue=False) |
|
|
| demo.load(_log_visit, None, None) |
|
|
| |
| |
| api_key.blur(_refresh_models, [api_key, model_dd], [model_dd], queue=False) |
|
|
| if __name__ == "__main__": |
| |
| |
| _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, |
| ) |
|
|