Spaces:
Running
Running
Download app.py from helmfridsson/qa-agent-langgraph: direct link, hf CLI and curl.
- Browser
- Download file 23.6 kB
-
https://huggingface.co/spaces/helmfridsson/qa-agent-langgraph/resolve/main/app.py
- Command line
-
hf download hf://spaces/helmfridsson/qa-agent-langgraph/app.py
-
curl -L -o app.py https://huggingface.co/spaces/helmfridsson/qa-agent-langgraph/resolve/main/app.py
23.6 kB
| from __future__ import annotations | |
| import json | |
| import os | |
| import re | |
| import sys | |
| import time | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| import streamlit as st | |
| ROOT = Path(__file__).resolve().parent | |
| SRC = ROOT / "src" | |
| if str(SRC) not in sys.path: | |
| sys.path.insert(0, str(SRC)) | |
| from qa_langgraph.export import build_run_export | |
| from qa_langgraph.graph import build_graph | |
| from qa_langgraph.registry import AGENTS, default_agent_configs | |
| from qa_langgraph.run_logger import read_stage_traces_jsonl, write_run_export | |
| from qa_langgraph.sample_scenarios import CUSTOM_SCENARIO, load_sample_scenario, scenario_options | |
| from qa_langgraph.service_client import DEFAULT_SERVICE_URL, normalize_service_url, validate_agent_service | |
| def init_session() -> None: | |
| default_service_url = normalize_service_url(os.getenv("QA_AGENT_SERVICE_URL", DEFAULT_SERVICE_URL)) | |
| st.session_state.setdefault("agent_configs", default_agent_configs()) | |
| st.session_state.setdefault("run_state", {}) | |
| st.session_state.setdefault("run_export", None) | |
| st.session_state.setdefault("run_status", "not run") | |
| st.session_state.setdefault("current_run_id", "") | |
| st.session_state.setdefault("stage_traces_jsonl", "") | |
| st.session_state.setdefault("selected_scenario", CUSTOM_SCENARIO) | |
| st.session_state.setdefault("title", "") | |
| st.session_state.setdefault("requirements_text", "") | |
| st.session_state.setdefault("qa_agent_service_url", default_service_url) | |
| def agent_status_rows(stage_traces: list[dict]) -> list[dict]: | |
| latest: dict[str, dict] = {} | |
| for trace in stage_traces: | |
| latest[trace.get("agent_key", "")] = trace | |
| rows = [] | |
| for key, agent in AGENTS.items(): | |
| trace = latest.get(key, {}) | |
| rows.append( | |
| { | |
| "agent": agent.name, | |
| "status": trace.get("status", "not started"), | |
| "iteration": trace.get("iteration", ""), | |
| "duration_ms": trace.get("duration_ms", ""), | |
| } | |
| ) | |
| return rows | |
| def safe_filename_part(value: str, fallback: str = "scenario") -> str: | |
| cleaned = re.sub(r"[^A-Za-z0-9]+", "_", value.strip()).strip("_") | |
| return cleaned or fallback | |
| def download_basename(export: dict) -> str: | |
| title = safe_filename_part(export.get("title", "scenario")) | |
| created_at = export.get("run_metadata", {}).get("created_at", "") | |
| try: | |
| timestamp = datetime.fromisoformat(created_at.replace("Z", "+00:00")).strftime("%Y%m%d-%H%M%S") | |
| except ValueError: | |
| timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") | |
| return f"LangGraph_{title}_{timestamp}" | |
| def _dot_label(label: str) -> str: | |
| return label.replace('"', '\\"') | |
| def runtime_path_edges(stage_traces: list[dict], orchestration_decision: dict) -> set[tuple[str, str]]: | |
| edges: set[tuple[str, str]] = set() | |
| ordered_nodes = [trace.get("agent_key") for trace in stage_traces if trace.get("agent_key")] | |
| if ordered_nodes: | |
| edges.add(("START", ordered_nodes[0])) | |
| for source, target in zip(ordered_nodes, ordered_nodes[1:]): | |
| edges.add((source, target)) | |
| decision = orchestration_decision.get("decision") | |
| target_agent = orchestration_decision.get("target_agent") | |
| if ordered_nodes and ordered_nodes[-1] == "orchestrator": | |
| if decision == "finish": | |
| edges.add(("orchestrator", "END")) | |
| elif decision == "fail": | |
| edges.add(("orchestrator", "END_FAIL")) | |
| elif decision == "backtrack" and target_agent in {"requirements_analyst", "test_designer", "reviewer"}: | |
| edges.add(("orchestrator", target_agent)) | |
| return edges | |
| def build_workflow_dot(stage_traces: list[dict] | None = None, orchestration_decision: dict | None = None) -> str: | |
| active_edges = runtime_path_edges(stage_traces or [], orchestration_decision or {}) | |
| completed_nodes = {trace.get("agent_key") for trace in (stage_traces or []) if trace.get("status") == "completed"} | |
| failed_nodes = {trace.get("agent_key") for trace in (stage_traces or []) if trace.get("status") == "failed"} | |
| nodes = { | |
| "START": {"label": "START", "shape": "circle", "fillcolor": "#1e293b", "fontcolor": "#f8fafc"}, | |
| "requirements_analyst": { | |
| "label": "Requirements Analyst\\n(service)", | |
| "shape": "box", | |
| "fillcolor": "#0b5fff" if "requirements_analyst" in completed_nodes else "#f8fafc", | |
| "fontcolor": "#ffffff" if "requirements_analyst" in completed_nodes else "#0f172a", | |
| }, | |
| "test_designer": { | |
| "label": "Test Designer\\n(service)", | |
| "shape": "box", | |
| "fillcolor": "#0b5fff" if "test_designer" in completed_nodes else "#f8fafc", | |
| "fontcolor": "#ffffff" if "test_designer" in completed_nodes else "#0f172a", | |
| }, | |
| "reviewer": { | |
| "label": "Reviewer\\n(service)", | |
| "shape": "box", | |
| "fillcolor": "#0b5fff" if "reviewer" in completed_nodes else "#f8fafc", | |
| "fontcolor": "#ffffff" if "reviewer" in completed_nodes else "#0f172a", | |
| }, | |
| "orchestrator": { | |
| "label": "Orchestrator\\n(local deterministic)", | |
| "shape": "diamond", | |
| "fillcolor": "#f59e0b" if "orchestrator" in completed_nodes else "#fef3c7", | |
| "fontcolor": "#0f172a", | |
| }, | |
| "END": {"label": "END\\nfinish", "shape": "doublecircle", "fillcolor": "#16a34a", "fontcolor": "#ffffff"}, | |
| "END_FAIL": {"label": "END\\nfail", "shape": "doublecircle", "fillcolor": "#dc2626", "fontcolor": "#ffffff"}, | |
| } | |
| for node_key in failed_nodes: | |
| if node_key in nodes: | |
| nodes[node_key]["fillcolor"] = "#dc2626" | |
| nodes[node_key]["fontcolor"] = "#ffffff" | |
| edges = [ | |
| ("START", "requirements_analyst", ""), | |
| ("requirements_analyst", "test_designer", ""), | |
| ("test_designer", "reviewer", ""), | |
| ("reviewer", "orchestrator", ""), | |
| ("orchestrator", "END", "finish"), | |
| ("orchestrator", "requirements_analyst", "backtrack"), | |
| ("orchestrator", "test_designer", "backtrack"), | |
| ("orchestrator", "reviewer", "backtrack"), | |
| ("orchestrator", "END_FAIL", "fail"), | |
| ] | |
| lines = [ | |
| "digraph LangGraphWorkflow {", | |
| ' graph [rankdir=LR, bgcolor="#f8fafc", fontname="Helvetica", nodesep="0.55", ranksep="0.75", pad="0.25"];', | |
| ' node [style="rounded,filled", color="#0f172a", penwidth=1.5, fontname="Helvetica"];', | |
| ' edge [color="#334155", penwidth=1.9, fontname="Helvetica", fontsize=11, fontcolor="#0f172a"];', | |
| ] | |
| for node_key, attrs in nodes.items(): | |
| lines.append( | |
| f' {node_key} [label="{_dot_label(attrs["label"])}", shape="{attrs["shape"]}", fillcolor="{attrs["fillcolor"]}", fontcolor="{attrs["fontcolor"]}"];' | |
| ) | |
| for source, target, label in edges: | |
| is_active = (source, target) in active_edges | |
| color = "#0b5fff" if is_active else "#334155" | |
| penwidth = "3.4" if is_active else "1.9" | |
| fontcolor = "#0b5fff" if is_active else "#0f172a" | |
| label_attr = f', label="{label}", fontcolor="{fontcolor}"' if label else "" | |
| lines.append(f' {source} -> {target} [color="{color}", penwidth={penwidth}{label_attr}];') | |
| lines.append("}") | |
| return "\n".join(lines) | |
| def runtime_path_summary(stage_traces: list[dict], orchestration_decision: dict) -> str: | |
| ordered_names = [AGENTS[trace["agent_key"]].name for trace in stage_traces if trace.get("agent_key") in AGENTS] | |
| if not ordered_names: | |
| return "No run has been executed yet." | |
| summary = " -> ".join(ordered_names) | |
| decision = orchestration_decision.get("decision") | |
| if decision == "finish": | |
| return f"{summary} -> END (finish)" | |
| if decision == "fail": | |
| return f"{summary} -> END (fail)" | |
| if decision == "backtrack" and orchestration_decision.get("target_agent") in AGENTS: | |
| target_name = AGENTS[orchestration_decision["target_agent"]].name | |
| return f"{summary} -> backtrack to {target_name}" | |
| return summary | |
| def render_service_help(service_url: str) -> None: | |
| rest_url = normalize_service_url(service_url) | |
| st.markdown("**Use REST here, not MCP.**") | |
| st.caption("LangGraph calls FastAPI REST endpoints. MCP on port 8008 is for MCP clients and does not expose Swagger/OpenAPI.") | |
| st.table( | |
| [ | |
| { | |
| "server": "Hosted REST/FastAPI", | |
| "default": "https://helmfridsson-qa-agent-service.hf.space/api/v1", | |
| "paths": "/health, /docs, /openapi.json, /agents/{agent_key}/invoke", | |
| }, | |
| { | |
| "server": "Local REST/FastAPI", | |
| "default": "http://127.0.0.1:8009/api/v1", | |
| "paths": "/health, /docs, /openapi.json, /agents/{agent_key}/invoke", | |
| }, | |
| {"server": "Local MCP", "default": "http://127.0.0.1:8008/mcp", "paths": "/mcp only, no Swagger/OpenAPI"}, | |
| ] | |
| ) | |
| st.caption("You can paste the full REST base URL, the service root, `/docs`, `/health`, or the Hugging Face Space page URL. The app normalizes it to `/api/v1`.") | |
| st.markdown( | |
| f""" | |
| REST links for the configured URL: | |
| - Health: [{rest_url}/health]({rest_url}/health) | |
| - Swagger: [{rest_url}/docs]({rest_url}/docs) | |
| - OpenAPI JSON: [{rest_url}/openapi.json]({rest_url}/openapi.json) | |
| """ | |
| ) | |
| st.code( | |
| """cd /Users/tomashelmfridsson/workspace/qa-agent-service | |
| PYTHONPATH=src uvicorn api:app --reload --port 8009""", | |
| language="bash", | |
| ) | |
| st.caption("The PYTHONPATH part fixes ModuleNotFoundError when qa-agent-service is not installed in the active Python environment.") | |
| st.caption("If the hosted /api/v1 health check returns Streamlit HTML, the Space is running the monitor UI but not exposing the REST API surface yet.") | |
| def run_workflow(title: str, requirements_text: str, global_config: dict, agent_configs: list[dict], max_rounds: int) -> None: | |
| if not title.strip(): | |
| st.error("Scenario title is required.") | |
| return | |
| if not requirements_text.strip(): | |
| st.error("Requirement text is required.") | |
| return | |
| if any(config.get("execution_mode") not in {"Deterministic", "Disabled"} for config in agent_configs): | |
| validate_agent_service(global_config) | |
| run_id = f"run_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}" | |
| state = { | |
| "run_metadata": { | |
| "run_id": run_id, | |
| "created_at": datetime.now(timezone.utc).isoformat(), | |
| "started_monotonic_ms": int(time.time() * 1000), | |
| "app_version": "langgraph-qa-compare-0.1.0", | |
| }, | |
| "title": title.strip(), | |
| "source_requirements": requirements_text.strip(), | |
| "requirements": [], | |
| "test_designs": [], | |
| "generated_artifacts": [], | |
| "iterations": 1, | |
| "run_controls": { | |
| "max_rounds": max_rounds, | |
| "max_feedback_messages": 0, | |
| "max_feedback_per_agent_pair": 0, | |
| }, | |
| "global_llm_config": global_config, | |
| "agent_configs": agent_configs, | |
| "stage_traces": [], | |
| "working_memory": {"shared": {}, "agent_private": {}, "timeline": []}, | |
| "errors": [], | |
| } | |
| st.session_state.current_run_id = run_id | |
| st.session_state.run_status = "running" | |
| st.session_state.run_state = state | |
| st.session_state.run_export = None | |
| st.session_state.stage_traces_jsonl = "" | |
| graph = build_graph() | |
| final_state = state | |
| status_slot = st.empty() | |
| traces_slot = st.empty() | |
| memory_slot = st.empty() | |
| for snapshot in graph.stream(state, {"recursion_limit": max(8, max_rounds * 6)}, stream_mode="values"): | |
| final_state = snapshot | |
| st.session_state.run_state = final_state | |
| st.session_state.run_status = "running" | |
| traces = final_state.get("stage_traces", []) | |
| memory = final_state.get("working_memory", {"shared": {}, "agent_private": {}, "timeline": []}) | |
| status_slot.info(f"Running... {len(traces)} stage trace(s), iteration {final_state.get('iterations', 1)}") | |
| traces_slot.dataframe(agent_status_rows(traces), use_container_width=True) | |
| memory_slot.json( | |
| { | |
| "shared": memory.get("shared", {}), | |
| "timeline": memory.get("timeline", []), | |
| } | |
| ) | |
| export = build_run_export(final_state) | |
| export_dict = export.model_dump(mode="json") | |
| write_run_export(run_id, export_dict) | |
| st.session_state.run_state = final_state | |
| st.session_state.run_export = export_dict | |
| st.session_state.run_status = export_dict.get("run_metadata", {}).get("status", "partial") | |
| st.session_state.stage_traces_jsonl = read_stage_traces_jsonl(run_id) | |
| init_session() | |
| st.set_page_config(page_title="LangGraph QA Compare", layout="wide") | |
| st.markdown( | |
| """ | |
| <style> | |
| .stApp { | |
| background-color: #0b1220; | |
| color: #e5e7eb; | |
| } | |
| .stApp h1, | |
| .stApp h2, | |
| .stApp h3, | |
| .stApp h4, | |
| .stApp h5, | |
| .stApp h6 { | |
| color: #f8fafc !important; | |
| } | |
| .stApp [data-testid="stHeadingWithActionElements"], | |
| .stApp [data-testid="stHeadingWithActionElements"] *, | |
| .stApp [data-testid="stCaptionContainer"], | |
| .stApp [data-testid="stAlertContent"], | |
| .stApp [data-testid="stJson"] { | |
| color: #dbe4f0 !important; | |
| opacity: 1 !important; | |
| } | |
| .stApp .block-container [data-testid="stMarkdownContainer"] p, | |
| .stApp .block-container [data-testid="stMarkdownContainer"] li, | |
| .stApp .block-container [data-testid="stMarkdownContainer"] span, | |
| .stApp .block-container [data-testid="stCaptionContainer"] p { | |
| color: #dbe4f0 !important; | |
| opacity: 1 !important; | |
| } | |
| .stApp [data-testid="stCodeBlock"], | |
| .stApp pre, | |
| .stApp code { | |
| color: #f8fafc !important; | |
| } | |
| .stApp [data-testid="stCodeBlock"] pre, | |
| .stApp pre { | |
| background-color: #111827 !important; | |
| } | |
| .stApp [data-testid="stMetricValue"], | |
| .stApp [data-testid="stMetricLabel"] { | |
| color: #f8fafc !important; | |
| } | |
| .stApp [data-testid="stWidgetLabel"], | |
| .stApp [data-testid="stExpander"] summary p, | |
| .stApp [data-testid="stTextInput"] label, | |
| .stApp [data-testid="stTextArea"] label, | |
| .stApp [data-testid="stSelectbox"] label, | |
| .stApp [data-testid="stNumberInput"] label { | |
| color: #e2e8f0 !important; | |
| opacity: 1 !important; | |
| } | |
| .stApp input, | |
| .stApp textarea, | |
| .stApp [data-baseweb="select"] > div { | |
| background-color: #111827 !important; | |
| border-color: #374151 !important; | |
| color: #f8fafc !important; | |
| } | |
| .stApp [data-baseweb="select"] input, | |
| .stApp input, | |
| .stApp textarea { | |
| color: #f8fafc !important; | |
| } | |
| .stApp input::placeholder, | |
| .stApp textarea::placeholder { | |
| color: #cbd5e1 !important; | |
| opacity: 1 !important; | |
| } | |
| .agent-directives { | |
| white-space: pre-wrap; | |
| color: #f8fafc; | |
| background: #111827; | |
| border: 1px solid #374151; | |
| border-radius: 0.5rem; | |
| padding: 0.75rem; | |
| line-height: 1.45; | |
| } | |
| </style> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| st.title("LangGraph QA Compare") | |
| with st.sidebar: | |
| st.header("QA Agent Service") | |
| service_url = st.text_input("REST API URL", value=st.session_state.get("qa_agent_service_url", DEFAULT_SERVICE_URL)) | |
| st.session_state.qa_agent_service_url = normalize_service_url(service_url) | |
| service_timeout_seconds = st.number_input("Service timeout seconds", min_value=10, max_value=600, value=150, step=10) | |
| max_rounds = st.number_input("Max rounds", min_value=1, max_value=8, value=2, step=1) | |
| global_config = { | |
| "qa_agent_service_url": st.session_state.qa_agent_service_url, | |
| "service_timeout_seconds": int(service_timeout_seconds), | |
| } | |
| if st.button("Validate QA Agent Service"): | |
| try: | |
| validate_agent_service(global_config) | |
| st.success("QA Agent Service is reachable.") | |
| except Exception as exc: | |
| st.error(str(exc)) | |
| with st.expander("REST vs MCP endpoints", expanded=False): | |
| render_service_help(service_url) | |
| if st.button("Clear session"): | |
| st.session_state.clear() | |
| st.rerun() | |
| tab_input, tab_agents, tab_flow, tab_runtime, tab_memory, tab_results, tab_downloads = st.tabs( | |
| ["Scenario Input", "Agent Configuration", "Flow Map", "Runtime Activity", "Working Memory", "Results", "Downloads"] | |
| ) | |
| with tab_input: | |
| selected_scenario = st.selectbox( | |
| "Predefined scenario", | |
| scenario_options(), | |
| index=scenario_options().index(st.session_state.get("selected_scenario", CUSTOM_SCENARIO)), | |
| ) | |
| if st.button("Load selected scenario"): | |
| title_value, requirements_value = load_sample_scenario(selected_scenario) | |
| st.session_state.selected_scenario = selected_scenario | |
| st.session_state.title = title_value | |
| st.session_state.requirements_text = requirements_value | |
| st.rerun() | |
| title = st.text_input("Scenario title", value=st.session_state.get("title", "")) | |
| requirements_text = st.text_area( | |
| "Raw requirements", | |
| value=st.session_state.get("requirements_text", ""), | |
| height=300, | |
| ) | |
| if st.button("Run workflow", type="primary"): | |
| st.session_state.title = title | |
| st.session_state.requirements_text = requirements_text | |
| try: | |
| run_workflow(title, requirements_text, global_config, st.session_state.agent_configs, int(max_rounds)) | |
| st.success("Workflow finished. Check Runtime Activity and Results.") | |
| except Exception as exc: | |
| st.session_state.run_status = "failed" | |
| st.error(str(exc)) | |
| with tab_agents: | |
| updated_configs = [] | |
| for config in st.session_state.agent_configs: | |
| key = config["agent_key"] | |
| agent_definition = AGENTS[key] | |
| with st.expander(config["agent_name"], expanded=False): | |
| mode_options = ["Deterministic", "Disabled"] if key == "orchestrator" else ["Service-backed", "Deterministic", "Disabled"] | |
| current_mode = config.get("execution_mode", "Deterministic" if key == "orchestrator" else "Service-backed") | |
| if current_mode == "LLM-backed": | |
| current_mode = "Service-backed" | |
| if current_mode not in mode_options: | |
| current_mode = mode_options[0] | |
| execution_mode = st.selectbox( | |
| "Execution mode", | |
| mode_options, | |
| index=mode_options.index(current_mode), | |
| key=f"{key}_mode", | |
| ) | |
| provider_strategy = st.selectbox( | |
| "Caller provider override", | |
| ["global", "ollama", "openai"], | |
| index=["global", "ollama", "openai"].index(config.get("provider_strategy", "global")), | |
| key=f"{key}_provider", | |
| ) | |
| model_override = st.text_input("Caller model override", value=config.get("model_override", ""), key=f"{key}_model") | |
| agent_timeout = st.number_input( | |
| "Service call timeout seconds", | |
| min_value=10, | |
| max_value=600, | |
| value=int(config.get("timeout_seconds", 150)), | |
| step=10, | |
| key=f"{key}_timeout", | |
| ) | |
| st.markdown("Default directives") | |
| st.markdown( | |
| f"<div class='agent-directives'>{agent_definition.default_directives}</div>", | |
| unsafe_allow_html=True, | |
| ) | |
| directives = st.text_area("Additional directives", value=config.get("directives", ""), key=f"{key}_directives") | |
| updated = dict(config) | |
| updated.update( | |
| { | |
| "execution_mode": execution_mode, | |
| "provider_strategy": provider_strategy, | |
| "model_override": model_override, | |
| "timeout_seconds": int(agent_timeout), | |
| "directives": directives, | |
| } | |
| ) | |
| updated_configs.append(updated) | |
| st.session_state.agent_configs = updated_configs | |
| run_state = st.session_state.get("run_state", {}) | |
| stage_traces = run_state.get("stage_traces", []) | |
| orchestration_decision = run_state.get("orchestration_decision", {}) | |
| with tab_flow: | |
| col_static, col_runtime = st.columns(2) | |
| with col_static: | |
| st.subheader("Workflow structure") | |
| st.caption("Static LangGraph routing, including the orchestrator's conditional backtracking paths.") | |
| st.graphviz_chart(build_workflow_dot(), use_container_width=True) | |
| with col_runtime: | |
| st.subheader("Latest run path") | |
| st.caption("Highlighted edges show the path taken by the current run state.") | |
| st.graphviz_chart(build_workflow_dot(stage_traces, orchestration_decision), use_container_width=True) | |
| st.code(runtime_path_summary(stage_traces, orchestration_decision), language="text") | |
| if orchestration_decision: | |
| st.json( | |
| { | |
| "decision": orchestration_decision.get("decision"), | |
| "target_agent": orchestration_decision.get("target_agent"), | |
| "reason": orchestration_decision.get("reason"), | |
| "feedback": orchestration_decision.get("feedback"), | |
| } | |
| ) | |
| with tab_runtime: | |
| export = st.session_state.get("run_export") | |
| status = export.get("run_metadata", {}).get("status") if export else st.session_state.get("run_status", "not run") | |
| st.metric("Current run status", status) | |
| if st.session_state.get("current_run_id"): | |
| st.caption(f"Run ID: {st.session_state.current_run_id}") | |
| st.metric("Iteration count", run_state.get("iterations", 0)) | |
| st.dataframe(agent_status_rows(stage_traces), use_container_width=True) | |
| st.caption(runtime_path_summary(stage_traces, orchestration_decision)) | |
| st.json(stage_traces) | |
| with tab_memory: | |
| memory = run_state.get("working_memory", {"shared": {}, "timeline": []}) | |
| st.metric("Current run status", st.session_state.get("run_status", "not run")) | |
| if st.session_state.get("current_run_id"): | |
| st.caption(f"Run ID: {st.session_state.current_run_id}") | |
| st.subheader("Shared") | |
| st.json(memory.get("shared", {})) | |
| st.subheader("Timeline") | |
| st.json(memory.get("timeline", [])) | |
| with tab_results: | |
| st.subheader("Requirements") | |
| st.dataframe(run_state.get("requirements", []), use_container_width=True) | |
| st.subheader("Test cases") | |
| st.dataframe(run_state.get("test_designs", []), use_container_width=True) | |
| st.subheader("Review") | |
| st.json(run_state.get("review", {})) | |
| st.subheader("Orchestration decision") | |
| st.json(orchestration_decision) | |
| st.subheader("Final JSON preview") | |
| st.json(st.session_state.get("run_export") or {}) | |
| with tab_downloads: | |
| export = st.session_state.get("run_export") | |
| if export: | |
| basename = download_basename(export) | |
| st.download_button( | |
| "Download run_export.json", | |
| data=json.dumps(export, indent=2, ensure_ascii=False), | |
| file_name=f"{basename}_run_export.json", | |
| mime="application/json", | |
| ) | |
| st.download_button( | |
| "Download stage_traces.jsonl", | |
| data=st.session_state.get("stage_traces_jsonl", ""), | |
| file_name=f"{basename}_stage_traces.jsonl", | |
| mime="application/x-ndjson", | |
| ) | |
| else: | |
| st.info("Run the workflow to generate downloads.") | |