Sync from GitHub via hub-sync
Browse files- app.py +113 -356
- tests/test_app.py +33 -0
app.py
CHANGED
|
@@ -1,18 +1,27 @@
|
|
| 1 |
-
"""Gradio demo for Agent 9 — Agentic Workflow System.
|
| 2 |
-
|
| 3 |
-
The UI presents a fictional wealth-management household-onboarding workflow at two
|
| 4 |
-
levels: a plain-English business story for non-technical visitors and detailed
|
| 5 |
-
workflow evidence for engineers. Workflow-control logic remains in the runtime,
|
| 6 |
-
not in Gradio callbacks. All households and onboarding data are synthetic.
|
| 7 |
-
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
import os
|
|
|
|
| 12 |
from typing import Any
|
| 13 |
|
| 14 |
import gradio as gr
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
from src.demo_workflow import build_onboarding_registry, build_onboarding_workflow
|
| 17 |
from src.evaluation import run_evaluation_suite
|
| 18 |
from src.executor import (
|
|
@@ -44,22 +53,17 @@ from src.ui_presenters import (
|
|
| 44 |
workflow_path_html,
|
| 45 |
)
|
| 46 |
|
| 47 |
-
|
| 48 |
WORKFLOW = build_onboarding_workflow()
|
| 49 |
DATABASE_PATH = os.getenv("WORKFLOW_DB_PATH", "workflow_runs.db")
|
| 50 |
STATE_STORE = SQLiteStateStore(DATABASE_PATH)
|
| 51 |
LIVE_MODEL_ID = configured_model_id()
|
| 52 |
LIVE_ONBOARDING_MODEL = build_hf_onboarding_model_from_env()
|
| 53 |
|
| 54 |
-
|
| 55 |
STORY_SPECS: dict[str, dict[str, Any]] = {
|
| 56 |
"Harbor Family — straightforward household": {
|
| 57 |
"household_id": "HH-HARBOR-STANDARD",
|
| 58 |
"household_type": "JOINT",
|
| 59 |
-
"onboarding_notes":
|
| 60 |
-
"The fictional Harbor family is beginning a standard advisory relationship. "
|
| 61 |
-
"The household has two adults, straightforward planning needs, and a complete synthetic intake."
|
| 62 |
-
),
|
| 63 |
"documents_complete": True,
|
| 64 |
"identity_status": "VERIFIED",
|
| 65 |
"relationship_complexity": "STANDARD",
|
|
@@ -68,9 +72,7 @@ STORY_SPECS: dict[str, dict[str, Any]] = {
|
|
| 68 |
"Harbor Family — temporary onboarding-service issue": {
|
| 69 |
"household_id": "HH-HARBOR-RETRY",
|
| 70 |
"household_type": "JOINT",
|
| 71 |
-
"onboarding_notes":
|
| 72 |
-
"The fictional Harbor family has a straightforward synthetic intake, but the demo will simulate one temporary package-preparation service interruption."
|
| 73 |
-
),
|
| 74 |
"documents_complete": True,
|
| 75 |
"identity_status": "VERIFIED",
|
| 76 |
"relationship_complexity": "STANDARD",
|
|
@@ -79,9 +81,7 @@ STORY_SPECS: dict[str, dict[str, Any]] = {
|
|
| 79 |
"Redwood Family Trust — human review required": {
|
| 80 |
"household_id": "HH-REDWOOD-TRUST",
|
| 81 |
"household_type": "TRUST",
|
| 82 |
-
"onboarding_notes":
|
| 83 |
-
"The fictional Redwood Family Trust has multiple interested parties and a more complex planning relationship. The synthetic intake is complete but should demonstrate an exception-review path."
|
| 84 |
-
),
|
| 85 |
"documents_complete": True,
|
| 86 |
"identity_status": "VERIFIED",
|
| 87 |
"relationship_complexity": "COMPLEX",
|
|
@@ -90,9 +90,7 @@ STORY_SPECS: dict[str, dict[str, Any]] = {
|
|
| 90 |
"Cedar Household — permanent dependency failure": {
|
| 91 |
"household_id": "HH-CEDAR-FAILURE",
|
| 92 |
"household_type": "JOINT",
|
| 93 |
-
"onboarding_notes":
|
| 94 |
-
"The fictional Cedar household has a standard synthetic intake, but the demo will simulate a permanent downstream package-preparation failure."
|
| 95 |
-
),
|
| 96 |
"documents_complete": True,
|
| 97 |
"identity_status": "VERIFIED",
|
| 98 |
"relationship_complexity": "STANDARD",
|
|
@@ -100,16 +98,7 @@ STORY_SPECS: dict[str, dict[str, Any]] = {
|
|
| 100 |
},
|
| 101 |
}
|
| 102 |
|
| 103 |
-
|
| 104 |
-
RunBundle = tuple[
|
| 105 |
-
str,
|
| 106 |
-
str,
|
| 107 |
-
list[list[Any]],
|
| 108 |
-
list[list[str]],
|
| 109 |
-
list[list[str]],
|
| 110 |
-
dict[str, Any],
|
| 111 |
-
]
|
| 112 |
-
|
| 113 |
AppBundle = tuple[
|
| 114 |
str,
|
| 115 |
str,
|
|
@@ -125,155 +114,6 @@ AppBundle = tuple[
|
|
| 125 |
]
|
| 126 |
|
| 127 |
|
| 128 |
-
APP_CSS = """
|
| 129 |
-
.agent-hero {
|
| 130 |
-
padding: 1.35rem 1.45rem;
|
| 131 |
-
border: 1px solid var(--border-color-primary, rgba(127,127,127,.25));
|
| 132 |
-
border-radius: 16px;
|
| 133 |
-
background: linear-gradient(135deg, rgba(249,115,22,.12), rgba(99,102,241,.08));
|
| 134 |
-
margin-bottom: 1rem;
|
| 135 |
-
}
|
| 136 |
-
.agent-eyebrow, .outcome-eyebrow, .panel-eyebrow {
|
| 137 |
-
font-size: .76rem;
|
| 138 |
-
font-weight: 800;
|
| 139 |
-
letter-spacing: .12em;
|
| 140 |
-
opacity: .72;
|
| 141 |
-
margin-bottom: .35rem;
|
| 142 |
-
}
|
| 143 |
-
.agent-hero h1 { margin: .1rem 0 .45rem 0; font-size: 2rem; }
|
| 144 |
-
.agent-hero p { margin: 0; max-width: 1000px; font-size: 1.03rem; line-height: 1.55; }
|
| 145 |
-
.pattern-grid {
|
| 146 |
-
display: grid;
|
| 147 |
-
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
| 148 |
-
gap: .8rem;
|
| 149 |
-
margin: 1rem 0 1.15rem 0;
|
| 150 |
-
}
|
| 151 |
-
.pattern-card, .ai-result, .why-panel {
|
| 152 |
-
padding: 1rem;
|
| 153 |
-
border-radius: 14px;
|
| 154 |
-
border: 1px solid var(--border-color-primary, rgba(127,127,127,.25));
|
| 155 |
-
background: var(--block-background-fill, rgba(127,127,127,.06));
|
| 156 |
-
}
|
| 157 |
-
.pattern-card strong { display: block; margin-bottom: .3rem; font-size: 1rem; }
|
| 158 |
-
.pattern-card span { opacity: .78; line-height: 1.45; font-size: .92rem; }
|
| 159 |
-
.ai-card {
|
| 160 |
-
border-color: rgba(59,130,246,.55);
|
| 161 |
-
background: linear-gradient(135deg, rgba(59,130,246,.14), rgba(99,102,241,.09));
|
| 162 |
-
}
|
| 163 |
-
.ai-explainer, .ai-runtime {
|
| 164 |
-
padding: 1rem 1.1rem;
|
| 165 |
-
border-radius: 14px;
|
| 166 |
-
border: 1px solid rgba(59,130,246,.38);
|
| 167 |
-
background: rgba(59,130,246,.08);
|
| 168 |
-
margin: .7rem 0 1rem 0;
|
| 169 |
-
line-height: 1.5;
|
| 170 |
-
}
|
| 171 |
-
.ai-runtime-live { border-color: rgba(34,197,94,.45); background: rgba(34,197,94,.08); }
|
| 172 |
-
.ai-runtime-waiting { border-color: rgba(245,158,11,.45); background: rgba(245,158,11,.08); }
|
| 173 |
-
.pattern-flow { display: flex; flex-wrap: wrap; gap: .55rem; align-items: center; margin-top: .65rem; }
|
| 174 |
-
.flow-pill {
|
| 175 |
-
padding: .48rem .7rem;
|
| 176 |
-
border-radius: 999px;
|
| 177 |
-
border: 1px solid var(--border-color-primary, rgba(127,127,127,.25));
|
| 178 |
-
background: var(--block-background-fill, rgba(127,127,127,.06));
|
| 179 |
-
font-weight: 650;
|
| 180 |
-
font-size: .9rem;
|
| 181 |
-
}
|
| 182 |
-
.ai-pill { border-color: rgba(59,130,246,.6); background: rgba(59,130,246,.12); }
|
| 183 |
-
.flow-arrow { opacity: .55; font-weight: 800; }
|
| 184 |
-
.business-outcome {
|
| 185 |
-
border-radius: 16px;
|
| 186 |
-
padding: 1.1rem 1.2rem;
|
| 187 |
-
border: 1px solid var(--border-color-primary, rgba(127,127,127,.25));
|
| 188 |
-
margin: .8rem 0;
|
| 189 |
-
}
|
| 190 |
-
.outcome-success { border-left: 6px solid #22c55e; background: rgba(34,197,94,.08); }
|
| 191 |
-
.outcome-waiting { border-left: 6px solid #f59e0b; background: rgba(245,158,11,.08); }
|
| 192 |
-
.outcome-rejected { border-left: 6px solid #f97316; background: rgba(249,115,22,.08); }
|
| 193 |
-
.outcome-failed { border-left: 6px solid #ef4444; background: rgba(239,68,68,.08); }
|
| 194 |
-
.outcome-neutral { border-left: 6px solid #64748b; background: rgba(100,116,139,.08); }
|
| 195 |
-
.outcome-title, .panel-title { font-size: 1.2rem; font-weight: 800; margin-bottom: .4rem; }
|
| 196 |
-
.outcome-body, .panel-body { line-height: 1.5; margin-bottom: .55rem; }
|
| 197 |
-
.outcome-takeaway { font-weight: 650; opacity: .83; }
|
| 198 |
-
.ai-result-live { border-color: rgba(59,130,246,.6); background: rgba(59,130,246,.09); }
|
| 199 |
-
.ai-result-fallback { border-color: rgba(100,116,139,.4); }
|
| 200 |
-
.ai-result-failed { border-color: rgba(239,68,68,.45); background: rgba(239,68,68,.07); }
|
| 201 |
-
.ai-mode { display: inline-block; padding: .25rem .5rem; border-radius: 999px; font-size: .76rem; font-weight: 800; background: rgba(59,130,246,.14); margin-bottom: .45rem; }
|
| 202 |
-
.ai-model { font-size: .82rem; opacity: .7; margin-bottom: .5rem; }
|
| 203 |
-
.ai-category { margin-bottom: .5rem; }
|
| 204 |
-
.ai-summary { line-height: 1.48; margin-bottom: .6rem; }
|
| 205 |
-
.ai-used { font-weight: 700; margin-bottom: .45rem; }
|
| 206 |
-
.ai-boundary, .why-proof { font-size: .88rem; opacity: .78; line-height: 1.4; }
|
| 207 |
-
.why-standard { border-left: 5px solid #22c55e; }
|
| 208 |
-
.why-review { border-left: 5px solid #f59e0b; }
|
| 209 |
-
.why-list { margin: .35rem 0 .7rem 1.15rem; }
|
| 210 |
-
.workflow-map {
|
| 211 |
-
margin: 1rem 0;
|
| 212 |
-
padding: 1rem 1.05rem;
|
| 213 |
-
border-radius: 16px;
|
| 214 |
-
border: 1px solid var(--border-color-primary, rgba(127,127,127,.25));
|
| 215 |
-
background: var(--block-background-fill, rgba(127,127,127,.04));
|
| 216 |
-
}
|
| 217 |
-
.map-heading { font-size: 1.15rem; font-weight: 800; }
|
| 218 |
-
.map-subheading { opacity: .7; margin: .2rem 0 .9rem 0; line-height: 1.4; }
|
| 219 |
-
.map-main { display: flex; flex-wrap: wrap; align-items: center; gap: .42rem; }
|
| 220 |
-
.map-split { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: .75rem; margin-top: .85rem; }
|
| 221 |
-
.map-branch { padding: .75rem; border-radius: 12px; border: 1px dashed var(--border-color-primary, rgba(127,127,127,.28)); }
|
| 222 |
-
.branch-label { font-size: .72rem; font-weight: 800; letter-spacing: .08em; opacity: .65; margin-bottom: .45rem; }
|
| 223 |
-
.map-node { display: inline-block; padding: .42rem .62rem; border-radius: 10px; border: 1px solid rgba(100,116,139,.35); background: rgba(100,116,139,.08); font-size: .84rem; font-weight: 700; }
|
| 224 |
-
.map-node-completed { border-color: rgba(34,197,94,.55); background: rgba(34,197,94,.12); }
|
| 225 |
-
.map-node-waiting-for-human, .map-node-retry-scheduled, .map-node-running { border-color: rgba(245,158,11,.6); background: rgba(245,158,11,.12); }
|
| 226 |
-
.map-node-failed { border-color: rgba(239,68,68,.6); background: rgba(239,68,68,.12); }
|
| 227 |
-
.map-node-skipped, .map-node-pending, .map-node-ready { opacity: .42; }
|
| 228 |
-
.map-node-ai { box-shadow: inset 0 0 0 2px rgba(59,130,246,.62); }
|
| 229 |
-
.map-arrow { opacity: .5; font-weight: 800; }
|
| 230 |
-
.map-legend { margin-top: .8rem; font-size: .8rem; opacity: .65; }
|
| 231 |
-
.journey-wrap { padding: 1rem 0 .2rem 0; }
|
| 232 |
-
.journey-heading { font-size: 1.15rem; font-weight: 800; }
|
| 233 |
-
.journey-subheading { opacity: .72; margin: .25rem 0 .8rem 0; line-height: 1.45; }
|
| 234 |
-
.journey-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(245px, 1fr)); gap: .65rem; }
|
| 235 |
-
.journey-step { display: flex; gap: .7rem; padding: .75rem .8rem; border-radius: 12px; border: 1px solid var(--border-color-primary, rgba(127,127,127,.22)); background: var(--block-background-fill, rgba(127,127,127,.04)); }
|
| 236 |
-
.journey-icon { width: 30px; height: 30px; min-width: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 900; }
|
| 237 |
-
.journey-done { background: rgba(34,197,94,.16); }
|
| 238 |
-
.journey-waiting { background: rgba(245,158,11,.18); }
|
| 239 |
-
.journey-rejected { background: rgba(249,115,22,.18); }
|
| 240 |
-
.journey-failed { background: rgba(239,68,68,.18); }
|
| 241 |
-
.journey-skipped { background: rgba(100,116,139,.16); }
|
| 242 |
-
.journey-pending { background: rgba(100,116,139,.10); }
|
| 243 |
-
.journey-title { font-weight: 750; }
|
| 244 |
-
.journey-state { font-size: .8rem; font-weight: 700; opacity: .78; margin: .1rem 0 .2rem 0; }
|
| 245 |
-
.journey-description { font-size: .86rem; opacity: .72; line-height: 1.38; }
|
| 246 |
-
.section-note { padding: .8rem 1rem; border-radius: 12px; background: rgba(99,102,241,.08); border: 1px solid rgba(99,102,241,.18); margin-bottom: .85rem; }
|
| 247 |
-
"""
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
HERO_HTML = """
|
| 251 |
-
<div class="agent-hero">
|
| 252 |
-
<div class="agent-eyebrow">WEALTH MANAGEMENT • OPERATIONS WORKFLOW DASHBOARD • SYNTHETIC DEMO</div>
|
| 253 |
-
<h1>Agent 9 — Agentic Workflow System</h1>
|
| 254 |
-
<p><strong>Think of this as an operations workflow dashboard for a fictional wealth-management firm.</strong> In this example, the dashboard follows a new household through onboarding and makes the AI step, deterministic controls, retries, routing, and human review visible in one place. AI organizes the unstructured intake into useful work product while application code governs what happens next. No real client, account, KYC/AML service, money movement, or trade is involved.</p>
|
| 255 |
-
</div>
|
| 256 |
-
"""
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
PATTERN_HTML = """
|
| 260 |
-
<div class="pattern-grid">
|
| 261 |
-
<div class="pattern-card ai-card"><strong>🤖 AI contributes work product</strong><span>The bounded AI node creates a profile category and concise intake summary that can be included in the simulated onboarding package.</span></div>
|
| 262 |
-
<div class="pattern-card"><strong>✓ Code governs consequences</strong><span>Document status, exception rules, routing, retries, persistence, and allowed next steps remain application-controlled.</span></div>
|
| 263 |
-
<div class="pattern-card"><strong>🧑 A person handles exceptions</strong><span>Trust, entity, complex, missing-document, or identity-review cases can pause before they are marked ready.</span></div>
|
| 264 |
-
<div class="pattern-card"><strong>↻ Operations recover safely</strong><span>Temporary service problems retry within a limit; permanent failures stop before uncertain downstream work.</span></div>
|
| 265 |
-
</div>
|
| 266 |
-
<div class="section-note"><strong>The pattern:</strong> AI produces useful, validated work. Deterministic software controls the business process. Humans retain authority over consequential exceptions.</div>
|
| 267 |
-
<div class="pattern-flow">
|
| 268 |
-
<span class="flow-pill">Household intake</span><span class="flow-arrow">→</span>
|
| 269 |
-
<span class="flow-pill ai-pill">🤖 AI organize</span><span class="flow-arrow">→</span>
|
| 270 |
-
<span class="flow-pill">Rules + package</span><span class="flow-arrow">→</span>
|
| 271 |
-
<span class="flow-pill">Verify</span><span class="flow-arrow">→</span>
|
| 272 |
-
<span class="flow-pill">Standard ready OR human review</span>
|
| 273 |
-
</div>
|
| 274 |
-
"""
|
| 275 |
-
|
| 276 |
-
|
| 277 |
def _ai_runtime_html() -> str:
|
| 278 |
if LIVE_ONBOARDING_MODEL is not None:
|
| 279 |
return (
|
|
@@ -292,32 +132,6 @@ def _ai_runtime_html() -> str:
|
|
| 292 |
)
|
| 293 |
|
| 294 |
|
| 295 |
-
EMPTY_OUTCOME_HTML = """
|
| 296 |
-
<div class="business-outcome outcome-neutral">
|
| 297 |
-
<div class="outcome-eyebrow">READY TO DEMONSTRATE</div>
|
| 298 |
-
<div class="outcome-title">Choose a fictional household story and run it</div>
|
| 299 |
-
<div class="outcome-body">The result will be explained in business language first. The technical tabs remain available underneath as evidence.</div>
|
| 300 |
-
<div class="outcome-takeaway">Start with the Harbor Family straightforward case.</div>
|
| 301 |
-
</div>
|
| 302 |
-
"""
|
| 303 |
-
|
| 304 |
-
EMPTY_PATH_HTML = """
|
| 305 |
-
<div class="workflow-map"><div class="map-heading">Live workflow path</div><div class="map-subheading">Run a household to light up the path it actually takes.</div></div>
|
| 306 |
-
"""
|
| 307 |
-
|
| 308 |
-
EMPTY_AI_HTML = """
|
| 309 |
-
<div class="ai-result"><div class="panel-eyebrow">🤖 BOUNDED AI WORK PRODUCT</div><div class="panel-title">Waiting for a household</div><div class="panel-body">The validated profile category and intake summary will appear here.</div></div>
|
| 310 |
-
"""
|
| 311 |
-
|
| 312 |
-
EMPTY_ROUTING_HTML = """
|
| 313 |
-
<div class="why-panel"><div class="panel-eyebrow">WHY DID IT ROUTE HERE?</div><div class="panel-title">Waiting for the review gate</div><div class="panel-body">After a run, this panel will show the exact deterministic reasons for the selected path.</div></div>
|
| 314 |
-
"""
|
| 315 |
-
|
| 316 |
-
EMPTY_JOURNEY_HTML = """
|
| 317 |
-
<div class="journey-wrap"><div class="journey-heading">What happened, step by step</div><div class="journey-subheading">After you run a story, this section will translate the workflow engine into an understandable onboarding journey.</div></div>
|
| 318 |
-
"""
|
| 319 |
-
|
| 320 |
-
|
| 321 |
def _build_registry():
|
| 322 |
return build_onboarding_registry(
|
| 323 |
onboarding_model=LIVE_ONBOARDING_MODEL,
|
|
@@ -334,8 +148,6 @@ def run_request_ui(
|
|
| 334 |
relationship_complexity: str,
|
| 335 |
simulation_mode: str,
|
| 336 |
) -> AppBundle:
|
| 337 |
-
"""Run one synthetic onboarding case and return business plus technical views."""
|
| 338 |
-
|
| 339 |
context = {
|
| 340 |
"household_id": household_id,
|
| 341 |
"household_type": household_type,
|
|
@@ -345,14 +157,8 @@ def run_request_ui(
|
|
| 345 |
"relationship_complexity": relationship_complexity,
|
| 346 |
"simulation_mode": simulation_mode,
|
| 347 |
}
|
| 348 |
-
|
| 349 |
try:
|
| 350 |
-
run = execute_workflow(
|
| 351 |
-
WORKFLOW,
|
| 352 |
-
_build_registry(),
|
| 353 |
-
context=context,
|
| 354 |
-
state_store=STATE_STORE,
|
| 355 |
-
)
|
| 356 |
return _app_bundle(run)
|
| 357 |
except WorkflowExecutionError as exc:
|
| 358 |
return _bundle_with_notice(exc.run, "The onboarding workflow stopped after a controlled failure.")
|
|
@@ -361,8 +167,6 @@ def run_request_ui(
|
|
| 361 |
|
| 362 |
|
| 363 |
def run_story_ui(story: str) -> AppBundle:
|
| 364 |
-
"""Run one prebuilt public-safe onboarding story for a non-technical visitor."""
|
| 365 |
-
|
| 366 |
spec = STORY_SPECS.get(story)
|
| 367 |
if spec is None:
|
| 368 |
return _empty_bundle("Choose one of the four onboarding stories before running the workflow.")
|
|
@@ -370,14 +174,10 @@ def run_story_ui(story: str) -> AppBundle:
|
|
| 370 |
|
| 371 |
|
| 372 |
def refresh_run_ui(run_id: str) -> AppBundle:
|
| 373 |
-
"""Reload one persisted workflow run by its random run identifier."""
|
| 374 |
-
|
| 375 |
if not run_id or not run_id.strip():
|
| 376 |
return _empty_bundle("Enter a run ID to load a persisted workflow.")
|
| 377 |
-
|
| 378 |
try:
|
| 379 |
-
|
| 380 |
-
return _app_bundle(run)
|
| 381 |
except RunNotFoundError:
|
| 382 |
return _empty_bundle("No persisted workflow run was found for that ID.", run_id.strip())
|
| 383 |
except Exception as exc:
|
|
@@ -385,31 +185,22 @@ def refresh_run_ui(run_id: str) -> AppBundle:
|
|
| 385 |
|
| 386 |
|
| 387 |
def submit_human_decision_ui(run_id: str, decision: str) -> AppBundle:
|
| 388 |
-
"""Apply one explicit decision to the currently open onboarding review."""
|
| 389 |
-
|
| 390 |
if not run_id or not run_id.strip():
|
| 391 |
return _empty_bundle("Enter a run ID before submitting a human decision.")
|
| 392 |
-
|
| 393 |
normalized_run_id = run_id.strip()
|
| 394 |
try:
|
| 395 |
run = STATE_STORE.load(normalized_run_id)
|
| 396 |
except RunNotFoundError:
|
| 397 |
return _empty_bundle("No persisted workflow run was found for that ID.", normalized_run_id)
|
| 398 |
-
|
| 399 |
open_reviews = [review for review in run.human_reviews if review.decision is None]
|
| 400 |
if len(open_reviews) != 1:
|
| 401 |
-
return _bundle_with_notice(
|
| 402 |
-
run,
|
| 403 |
-
"This run does not have exactly one open human review. Reload the run and inspect the Human Review table.",
|
| 404 |
-
)
|
| 405 |
-
|
| 406 |
-
review = open_reviews[0]
|
| 407 |
try:
|
| 408 |
updated = submit_human_decision(
|
| 409 |
WORKFLOW,
|
| 410 |
_build_registry(),
|
| 411 |
run_id=normalized_run_id,
|
| 412 |
-
review_id=
|
| 413 |
decision=HumanDecision(decision),
|
| 414 |
state_store=STATE_STORE,
|
| 415 |
)
|
|
@@ -423,8 +214,6 @@ def submit_human_decision_ui(run_id: str, decision: str) -> AppBundle:
|
|
| 423 |
|
| 424 |
|
| 425 |
def run_evaluation_ui() -> tuple[str, list[list[str]], list[list[str]]]:
|
| 426 |
-
"""Run the deterministic system evaluation and return compact display rows."""
|
| 427 |
-
|
| 428 |
report = run_evaluation_suite()
|
| 429 |
summary = (
|
| 430 |
"### Wealth Onboarding Workflow Evaluation\n"
|
|
@@ -463,173 +252,141 @@ def _bundle_with_notice(run: WorkflowRun, notice: str) -> AppBundle:
|
|
| 463 |
|
| 464 |
def _empty_bundle(message: str, run_id: str = "") -> AppBundle:
|
| 465 |
return (
|
| 466 |
-
f"### Workflow Status\n{message}",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 467 |
run_id,
|
| 468 |
-
[],
|
| 469 |
-
|
| 470 |
-
[],
|
| 471 |
-
{},
|
| 472 |
-
EMPTY_OUTCOME_HTML,
|
| 473 |
-
EMPTY_PATH_HTML,
|
| 474 |
-
EMPTY_AI_HTML,
|
| 475 |
-
EMPTY_ROUTING_HTML,
|
| 476 |
-
EMPTY_JOURNEY_HTML,
|
| 477 |
)
|
| 478 |
|
| 479 |
|
| 480 |
-
def
|
| 481 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
|
| 483 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 484 |
gr.HTML(HERO_HTML)
|
| 485 |
gr.HTML(PATTERN_HTML)
|
| 486 |
gr.HTML(_ai_runtime_html())
|
| 487 |
|
| 488 |
gr.Markdown("## Try the onboarding pattern in one click")
|
| 489 |
-
gr.Markdown(
|
| 490 |
-
|
| 491 |
-
)
|
| 492 |
-
|
| 493 |
-
choices=list(STORY_SPECS),
|
| 494 |
-
value="Harbor Family — straightforward household",
|
| 495 |
-
label="Choose an onboarding story",
|
| 496 |
-
)
|
| 497 |
-
story_button = gr.Button("Run This Onboarding Story", variant="primary")
|
| 498 |
|
| 499 |
business_outcome = gr.HTML(EMPTY_OUTCOME_HTML)
|
| 500 |
workflow_path = gr.HTML(EMPTY_PATH_HTML)
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
routing_explanation = gr.HTML(EMPTY_ROUTING_HTML)
|
| 504 |
business_journey = gr.HTML(EMPTY_JOURNEY_HTML)
|
| 505 |
|
| 506 |
with gr.Accordion("Reload a saved run", open=False):
|
| 507 |
-
gr.Markdown(
|
| 508 |
-
"Every run receives a random ID and is checkpointed in SQLite. Reloading a completed run shows the same state without replaying completed work."
|
| 509 |
-
)
|
| 510 |
with gr.Row():
|
| 511 |
current_run_id = gr.Textbox(label="Run ID", placeholder="A random run ID appears here after execution.")
|
| 512 |
refresh_button = gr.Button("Reload Saved Run")
|
| 513 |
|
| 514 |
with gr.Tabs():
|
| 515 |
-
with gr.Tab("Customize
|
| 516 |
gr.Markdown("### Build your own synthetic onboarding scenario\nThese inputs are fictional and exist only to demonstrate workflow behavior.")
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
"All information in this demo is synthetic."
|
| 530 |
-
),
|
| 531 |
-
lines=4,
|
| 532 |
-
)
|
| 533 |
-
with gr.Row():
|
| 534 |
-
identity_status = gr.Dropdown(
|
| 535 |
-
choices=["VERIFIED", "REVIEW_REQUIRED"], value="VERIFIED", label="Identity Check Status"
|
| 536 |
-
)
|
| 537 |
-
relationship_complexity = gr.Dropdown(
|
| 538 |
-
choices=["STANDARD", "COMPLEX"], value="STANDARD", label="Relationship Complexity"
|
| 539 |
-
)
|
| 540 |
-
simulation_mode = gr.Dropdown(
|
| 541 |
-
choices=["NONE", "TRANSIENT_ONCE", "PERMANENT"], value="NONE", label="Failure Simulation"
|
| 542 |
-
)
|
| 543 |
-
gr.Markdown(
|
| 544 |
-
"**Helpful shortcuts:** TRUST/ENTITY, COMPLEX, missing documents, or REVIEW_REQUIRED demonstrates human review. TRANSIENT_ONCE demonstrates bounded retry recovery."
|
| 545 |
-
)
|
| 546 |
-
run_button = gr.Button("Run Custom Household", variant="primary")
|
| 547 |
-
|
| 548 |
-
with gr.Tab("Technical Details"):
|
| 549 |
-
gr.Markdown(
|
| 550 |
-
"### Engineering view\nInspect exact workflow status, node state, attempt counts, errors, and structured outputs—including the AI Intake Organizer output and its source."
|
| 551 |
-
)
|
| 552 |
status = gr.Markdown("### Workflow Status\nRun a synthetic household to begin.")
|
| 553 |
node_table = gr.Dataframe(headers=NODE_HEADERS, interactive=False, label="Node Runtime State")
|
| 554 |
final_output = gr.JSON(label="Final Output")
|
| 555 |
|
| 556 |
with gr.Tab("Audit Trail"):
|
| 557 |
-
gr.Markdown(
|
| 558 |
-
"### Why the workflow is auditable\nEvery meaningful control transition is recorded. The audit timeline contains control metadata only; onboarding context is not copied into event details."
|
| 559 |
-
)
|
| 560 |
event_table = gr.Dataframe(headers=EVENT_HEADERS, interactive=False, label="Append-Only Workflow Events")
|
| 561 |
|
| 562 |
-
with gr.Tab("
|
| 563 |
-
gr.Markdown(
|
| 564 |
-
"### The human remains in control\nA fictional exception case pauses here. The workflow cannot mark the package ready until a person explicitly decides what should happen."
|
| 565 |
-
)
|
| 566 |
review_table = gr.Dataframe(headers=REVIEW_HEADERS, interactive=False, label="Human Review History")
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
)
|
| 571 |
-
decision_button = gr.Button("Submit Human Decision", variant="primary")
|
| 572 |
-
gr.Markdown(
|
| 573 |
-
"**APPROVE** continues the approved exception path. **REJECT** ends the fictional process as `REJECTED`, not `FAILED`. **RETRY** opens a fresh review without replaying completed upstream work. This is illustrative, not any firm's actual regulatory procedure."
|
| 574 |
-
)
|
| 575 |
|
| 576 |
with gr.Tab("Evaluation"):
|
| 577 |
-
gr.Markdown(
|
| 578 |
-
"### Reliability evidence\nThe same onboarding engine is exercised across success, retry, human review, persistence, failure, and adversarial-control scenarios."
|
| 579 |
-
)
|
| 580 |
evaluation_summary = gr.Markdown("Run the deterministic evaluation harness to inspect system-level metrics.")
|
| 581 |
evaluation_button = gr.Button("Run 10-Case Evaluation")
|
| 582 |
metric_table = gr.Dataframe(headers=METRIC_HEADERS, interactive=False, label="Evaluation Metrics")
|
| 583 |
case_table = gr.Dataframe(headers=CASE_HEADERS, interactive=False, label="Evaluation Cases")
|
| 584 |
|
| 585 |
run_outputs = [
|
| 586 |
-
status,
|
| 587 |
-
|
| 588 |
-
node_table,
|
| 589 |
-
event_table,
|
| 590 |
-
review_table,
|
| 591 |
-
final_output,
|
| 592 |
-
business_outcome,
|
| 593 |
-
workflow_path,
|
| 594 |
-
ai_insight,
|
| 595 |
-
routing_explanation,
|
| 596 |
-
business_journey,
|
| 597 |
]
|
|
|
|
| 598 |
|
| 599 |
-
story_button.click(fn=
|
| 600 |
run_button.click(
|
| 601 |
-
fn=
|
| 602 |
-
inputs=[
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
onboarding_notes,
|
| 606 |
-
documents_complete,
|
| 607 |
-
identity_status,
|
| 608 |
-
relationship_complexity,
|
| 609 |
-
simulation_mode,
|
| 610 |
-
],
|
| 611 |
-
outputs=run_outputs,
|
| 612 |
)
|
| 613 |
refresh_button.click(fn=refresh_run_ui, inputs=[current_run_id], outputs=run_outputs)
|
| 614 |
-
decision_button.click(
|
| 615 |
-
|
| 616 |
-
inputs=[current_run_id, human_decision],
|
| 617 |
-
outputs=run_outputs,
|
| 618 |
-
)
|
| 619 |
-
evaluation_button.click(
|
| 620 |
-
fn=run_evaluation_ui,
|
| 621 |
-
outputs=[evaluation_summary, metric_table, case_table],
|
| 622 |
-
)
|
| 623 |
|
| 624 |
return demo
|
| 625 |
|
| 626 |
|
| 627 |
demo = build_app()
|
| 628 |
|
| 629 |
-
|
| 630 |
if __name__ == "__main__":
|
| 631 |
-
demo.queue().launch(
|
| 632 |
-
server_name="0.0.0.0",
|
| 633 |
-
server_port=int(os.getenv("PORT", "7860")),
|
| 634 |
-
css=APP_CSS,
|
| 635 |
-
)
|
|
|
|
| 1 |
+
"""Gradio demo for Agent 9 — Agentic Workflow System."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import os
|
| 6 |
+
import time
|
| 7 |
from typing import Any
|
| 8 |
|
| 9 |
import gradio as gr
|
| 10 |
|
| 11 |
+
from src.demo_presentation import (
|
| 12 |
+
APP_CSS,
|
| 13 |
+
EMPTY_ACTIVITY_HTML,
|
| 14 |
+
EMPTY_AI_HTML,
|
| 15 |
+
EMPTY_JOURNEY_HTML,
|
| 16 |
+
EMPTY_OUTCOME_HTML,
|
| 17 |
+
EMPTY_PATH_HTML,
|
| 18 |
+
EMPTY_ROUTING_HTML,
|
| 19 |
+
HERO_HTML,
|
| 20 |
+
PATTERN_HTML,
|
| 21 |
+
activity_html,
|
| 22 |
+
activity_start_html,
|
| 23 |
+
playback_delay,
|
| 24 |
+
)
|
| 25 |
from src.demo_workflow import build_onboarding_registry, build_onboarding_workflow
|
| 26 |
from src.evaluation import run_evaluation_suite
|
| 27 |
from src.executor import (
|
|
|
|
| 53 |
workflow_path_html,
|
| 54 |
)
|
| 55 |
|
|
|
|
| 56 |
WORKFLOW = build_onboarding_workflow()
|
| 57 |
DATABASE_PATH = os.getenv("WORKFLOW_DB_PATH", "workflow_runs.db")
|
| 58 |
STATE_STORE = SQLiteStateStore(DATABASE_PATH)
|
| 59 |
LIVE_MODEL_ID = configured_model_id()
|
| 60 |
LIVE_ONBOARDING_MODEL = build_hf_onboarding_model_from_env()
|
| 61 |
|
|
|
|
| 62 |
STORY_SPECS: dict[str, dict[str, Any]] = {
|
| 63 |
"Harbor Family — straightforward household": {
|
| 64 |
"household_id": "HH-HARBOR-STANDARD",
|
| 65 |
"household_type": "JOINT",
|
| 66 |
+
"onboarding_notes": "The fictional Harbor family is beginning a standard advisory relationship. The household has two adults, straightforward planning needs, and a complete synthetic intake.",
|
|
|
|
|
|
|
|
|
|
| 67 |
"documents_complete": True,
|
| 68 |
"identity_status": "VERIFIED",
|
| 69 |
"relationship_complexity": "STANDARD",
|
|
|
|
| 72 |
"Harbor Family — temporary onboarding-service issue": {
|
| 73 |
"household_id": "HH-HARBOR-RETRY",
|
| 74 |
"household_type": "JOINT",
|
| 75 |
+
"onboarding_notes": "The fictional Harbor family has a straightforward synthetic intake, but the demo will simulate one temporary package-preparation service interruption.",
|
|
|
|
|
|
|
| 76 |
"documents_complete": True,
|
| 77 |
"identity_status": "VERIFIED",
|
| 78 |
"relationship_complexity": "STANDARD",
|
|
|
|
| 81 |
"Redwood Family Trust — human review required": {
|
| 82 |
"household_id": "HH-REDWOOD-TRUST",
|
| 83 |
"household_type": "TRUST",
|
| 84 |
+
"onboarding_notes": "The fictional Redwood Family Trust has multiple interested parties and a more complex planning relationship. The synthetic intake is complete but should demonstrate an exception-review path.",
|
|
|
|
|
|
|
| 85 |
"documents_complete": True,
|
| 86 |
"identity_status": "VERIFIED",
|
| 87 |
"relationship_complexity": "COMPLEX",
|
|
|
|
| 90 |
"Cedar Household — permanent dependency failure": {
|
| 91 |
"household_id": "HH-CEDAR-FAILURE",
|
| 92 |
"household_type": "JOINT",
|
| 93 |
+
"onboarding_notes": "The fictional Cedar household has a standard synthetic intake, but the demo will simulate a permanent downstream package-preparation failure.",
|
|
|
|
|
|
|
| 94 |
"documents_complete": True,
|
| 95 |
"identity_status": "VERIFIED",
|
| 96 |
"relationship_complexity": "STANDARD",
|
|
|
|
| 98 |
},
|
| 99 |
}
|
| 100 |
|
| 101 |
+
RunBundle = tuple[str, str, list[list[Any]], list[list[str]], list[list[str]], dict[str, Any]]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
AppBundle = tuple[
|
| 103 |
str,
|
| 104 |
str,
|
|
|
|
| 114 |
]
|
| 115 |
|
| 116 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
def _ai_runtime_html() -> str:
|
| 118 |
if LIVE_ONBOARDING_MODEL is not None:
|
| 119 |
return (
|
|
|
|
| 132 |
)
|
| 133 |
|
| 134 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
def _build_registry():
|
| 136 |
return build_onboarding_registry(
|
| 137 |
onboarding_model=LIVE_ONBOARDING_MODEL,
|
|
|
|
| 148 |
relationship_complexity: str,
|
| 149 |
simulation_mode: str,
|
| 150 |
) -> AppBundle:
|
|
|
|
|
|
|
| 151 |
context = {
|
| 152 |
"household_id": household_id,
|
| 153 |
"household_type": household_type,
|
|
|
|
| 157 |
"relationship_complexity": relationship_complexity,
|
| 158 |
"simulation_mode": simulation_mode,
|
| 159 |
}
|
|
|
|
| 160 |
try:
|
| 161 |
+
run = execute_workflow(WORKFLOW, _build_registry(), context=context, state_store=STATE_STORE)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
return _app_bundle(run)
|
| 163 |
except WorkflowExecutionError as exc:
|
| 164 |
return _bundle_with_notice(exc.run, "The onboarding workflow stopped after a controlled failure.")
|
|
|
|
| 167 |
|
| 168 |
|
| 169 |
def run_story_ui(story: str) -> AppBundle:
|
|
|
|
|
|
|
| 170 |
spec = STORY_SPECS.get(story)
|
| 171 |
if spec is None:
|
| 172 |
return _empty_bundle("Choose one of the four onboarding stories before running the workflow.")
|
|
|
|
| 174 |
|
| 175 |
|
| 176 |
def refresh_run_ui(run_id: str) -> AppBundle:
|
|
|
|
|
|
|
| 177 |
if not run_id or not run_id.strip():
|
| 178 |
return _empty_bundle("Enter a run ID to load a persisted workflow.")
|
|
|
|
| 179 |
try:
|
| 180 |
+
return _app_bundle(STATE_STORE.load(run_id.strip()))
|
|
|
|
| 181 |
except RunNotFoundError:
|
| 182 |
return _empty_bundle("No persisted workflow run was found for that ID.", run_id.strip())
|
| 183 |
except Exception as exc:
|
|
|
|
| 185 |
|
| 186 |
|
| 187 |
def submit_human_decision_ui(run_id: str, decision: str) -> AppBundle:
|
|
|
|
|
|
|
| 188 |
if not run_id or not run_id.strip():
|
| 189 |
return _empty_bundle("Enter a run ID before submitting a human decision.")
|
|
|
|
| 190 |
normalized_run_id = run_id.strip()
|
| 191 |
try:
|
| 192 |
run = STATE_STORE.load(normalized_run_id)
|
| 193 |
except RunNotFoundError:
|
| 194 |
return _empty_bundle("No persisted workflow run was found for that ID.", normalized_run_id)
|
|
|
|
| 195 |
open_reviews = [review for review in run.human_reviews if review.decision is None]
|
| 196 |
if len(open_reviews) != 1:
|
| 197 |
+
return _bundle_with_notice(run, "This run does not have exactly one open human review. Reload the run and inspect the Human Review table.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
try:
|
| 199 |
updated = submit_human_decision(
|
| 200 |
WORKFLOW,
|
| 201 |
_build_registry(),
|
| 202 |
run_id=normalized_run_id,
|
| 203 |
+
review_id=open_reviews[0].review_id,
|
| 204 |
decision=HumanDecision(decision),
|
| 205 |
state_store=STATE_STORE,
|
| 206 |
)
|
|
|
|
| 214 |
|
| 215 |
|
| 216 |
def run_evaluation_ui() -> tuple[str, list[list[str]], list[list[str]]]:
|
|
|
|
|
|
|
| 217 |
report = run_evaluation_suite()
|
| 218 |
summary = (
|
| 219 |
"### Wealth Onboarding Workflow Evaluation\n"
|
|
|
|
| 252 |
|
| 253 |
def _empty_bundle(message: str, run_id: str = "") -> AppBundle:
|
| 254 |
return (
|
| 255 |
+
f"### Workflow Status\n{message}", run_id, [], [], [], {},
|
| 256 |
+
EMPTY_OUTCOME_HTML, EMPTY_PATH_HTML, EMPTY_AI_HTML, EMPTY_ROUTING_HTML, EMPTY_JOURNEY_HTML,
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def _running_bundle(run_id: str = "") -> AppBundle:
|
| 261 |
+
return (
|
| 262 |
+
"### Workflow running…\nThe workflow engine is controlling the approved next steps.",
|
| 263 |
run_id,
|
| 264 |
+
[], [], [], {},
|
| 265 |
+
EMPTY_OUTCOME_HTML, EMPTY_PATH_HTML, EMPTY_AI_HTML, EMPTY_ROUTING_HTML, EMPTY_JOURNEY_HTML,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
)
|
| 267 |
|
| 268 |
|
| 269 |
+
def _stream_bundle(bundle: AppBundle):
|
| 270 |
+
run_id = bundle[1]
|
| 271 |
+
if not run_id:
|
| 272 |
+
yield ('<div class="activity-card"><div class="activity-badge">STOPPED</div><div class="activity-title">Workflow did not start</div></div>', *bundle)
|
| 273 |
+
return
|
| 274 |
+
try:
|
| 275 |
+
run = STATE_STORE.load(run_id)
|
| 276 |
+
except Exception:
|
| 277 |
+
yield ('<div class="activity-card"><div class="activity-badge">STOPPED</div><div class="activity-title">Workflow trace unavailable</div></div>', *bundle)
|
| 278 |
+
return
|
| 279 |
+
running = _running_bundle(run_id)
|
| 280 |
+
for index, event in enumerate(run.events, start=1):
|
| 281 |
+
yield (activity_html(run, index), *running)
|
| 282 |
+
time.sleep(playback_delay(event.event_type.value))
|
| 283 |
+
yield (activity_html(run, len(run.events), complete=True), *bundle)
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def stream_story_ui(story: str):
|
| 287 |
+
yield (activity_start_html(story), *_running_bundle())
|
| 288 |
+
yield from _stream_bundle(run_story_ui(story))
|
| 289 |
|
| 290 |
+
|
| 291 |
+
def stream_request_ui(
|
| 292 |
+
household_id: str,
|
| 293 |
+
household_type: str,
|
| 294 |
+
onboarding_notes: str,
|
| 295 |
+
documents_complete: bool,
|
| 296 |
+
identity_status: str,
|
| 297 |
+
relationship_complexity: str,
|
| 298 |
+
simulation_mode: str,
|
| 299 |
+
):
|
| 300 |
+
yield (activity_start_html("Custom synthetic household"), *_running_bundle())
|
| 301 |
+
bundle = run_request_ui(
|
| 302 |
+
household_id, household_type, onboarding_notes, documents_complete,
|
| 303 |
+
identity_status, relationship_complexity, simulation_mode,
|
| 304 |
+
)
|
| 305 |
+
yield from _stream_bundle(bundle)
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def build_app() -> gr.Blocks:
|
| 309 |
+
with gr.Blocks(title="Agent 9 — Wealth Onboarding Workflow", analytics_enabled=False) as demo:
|
| 310 |
gr.HTML(HERO_HTML)
|
| 311 |
gr.HTML(PATTERN_HTML)
|
| 312 |
gr.HTML(_ai_runtime_html())
|
| 313 |
|
| 314 |
gr.Markdown("## Try the onboarding pattern in one click")
|
| 315 |
+
gr.Markdown("Choose a fictional household and watch the workflow control plane move it through AI assistance, deterministic rules, retry/recovery, and human escalation.")
|
| 316 |
+
story = gr.Radio(choices=list(STORY_SPECS), value="Harbor Family — straightforward household", label="Choose an onboarding story")
|
| 317 |
+
story_button = gr.Button("Run this onboarding workflow", variant="primary")
|
| 318 |
+
live_activity = gr.HTML(EMPTY_ACTIVITY_HTML)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
|
| 320 |
business_outcome = gr.HTML(EMPTY_OUTCOME_HTML)
|
| 321 |
workflow_path = gr.HTML(EMPTY_PATH_HTML)
|
| 322 |
+
ai_insight = gr.HTML(EMPTY_AI_HTML)
|
| 323 |
+
routing_explanation = gr.HTML(EMPTY_ROUTING_HTML)
|
|
|
|
| 324 |
business_journey = gr.HTML(EMPTY_JOURNEY_HTML)
|
| 325 |
|
| 326 |
with gr.Accordion("Reload a saved run", open=False):
|
| 327 |
+
gr.Markdown("Every run receives a random ID and is checkpointed in SQLite. Reloading a completed run shows the same state without replaying completed work.")
|
|
|
|
|
|
|
| 328 |
with gr.Row():
|
| 329 |
current_run_id = gr.Textbox(label="Run ID", placeholder="A random run ID appears here after execution.")
|
| 330 |
refresh_button = gr.Button("Reload Saved Run")
|
| 331 |
|
| 332 |
with gr.Tabs():
|
| 333 |
+
with gr.Tab("Customize Scenario"):
|
| 334 |
gr.Markdown("### Build your own synthetic onboarding scenario\nThese inputs are fictional and exist only to demonstrate workflow behavior.")
|
| 335 |
+
household_id = gr.Textbox(label="Household ID", value="HH-DEMO-001")
|
| 336 |
+
household_type = gr.Dropdown(choices=["INDIVIDUAL", "JOINT", "TRUST", "ENTITY"], value="JOINT", label="Household Type")
|
| 337 |
+
documents_complete = gr.Checkbox(label="Onboarding documents complete", value=True)
|
| 338 |
+
onboarding_notes = gr.Textbox(label="Synthetic Onboarding Notes", value="Fictional household seeking a standard advisory relationship. All information in this demo is synthetic.", lines=4)
|
| 339 |
+
identity_status = gr.Dropdown(choices=["VERIFIED", "REVIEW_REQUIRED"], value="VERIFIED", label="Identity Check Status")
|
| 340 |
+
relationship_complexity = gr.Dropdown(choices=["STANDARD", "COMPLEX"], value="STANDARD", label="Relationship Complexity")
|
| 341 |
+
simulation_mode = gr.Dropdown(choices=["NONE", "TRANSIENT_ONCE", "PERMANENT"], value="NONE", label="Failure Simulation")
|
| 342 |
+
gr.Markdown("**Helpful shortcuts:** TRUST/ENTITY, COMPLEX, missing documents, or REVIEW_REQUIRED demonstrates human review. TRANSIENT_ONCE demonstrates bounded retry recovery.")
|
| 343 |
+
run_button = gr.Button("Run custom workflow", variant="primary")
|
| 344 |
+
|
| 345 |
+
with gr.Tab("Engineering State"):
|
| 346 |
+
gr.Markdown("### Engineering evidence\nInspect exact workflow status, node state, attempt counts, errors, and structured outputs—including the AI Intake Organizer output and its source.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
status = gr.Markdown("### Workflow Status\nRun a synthetic household to begin.")
|
| 348 |
node_table = gr.Dataframe(headers=NODE_HEADERS, interactive=False, label="Node Runtime State")
|
| 349 |
final_output = gr.JSON(label="Final Output")
|
| 350 |
|
| 351 |
with gr.Tab("Audit Trail"):
|
| 352 |
+
gr.Markdown("### Why the workflow is auditable\nEvery meaningful control transition is recorded. The audit timeline contains control metadata only; onboarding context is not copied into event details.")
|
|
|
|
|
|
|
| 353 |
event_table = gr.Dataframe(headers=EVENT_HEADERS, interactive=False, label="Append-Only Workflow Events")
|
| 354 |
|
| 355 |
+
with gr.Tab("Human Review"):
|
| 356 |
+
gr.Markdown("### The human remains in control\nA fictional exception case pauses here. The workflow cannot mark the package ready until a person explicitly decides what should happen.")
|
|
|
|
|
|
|
| 357 |
review_table = gr.Dataframe(headers=REVIEW_HEADERS, interactive=False, label="Human Review History")
|
| 358 |
+
human_decision = gr.Dropdown(choices=["APPROVE", "REJECT", "RETRY"], value="APPROVE", label="What should the reviewer do?")
|
| 359 |
+
decision_button = gr.Button("Submit Human Decision", variant="primary")
|
| 360 |
+
gr.Markdown("**APPROVE** continues the approved exception path. **REJECT** ends the fictional process as `REJECTED`, not `FAILED`. **RETRY** opens a fresh review without replaying completed upstream work. This is illustrative, not any firm's actual regulatory procedure.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
|
| 362 |
with gr.Tab("Evaluation"):
|
| 363 |
+
gr.Markdown("### Reliability evidence\nThe same onboarding engine is exercised across success, retry, human review, persistence, failure, and adversarial-control scenarios.")
|
|
|
|
|
|
|
| 364 |
evaluation_summary = gr.Markdown("Run the deterministic evaluation harness to inspect system-level metrics.")
|
| 365 |
evaluation_button = gr.Button("Run 10-Case Evaluation")
|
| 366 |
metric_table = gr.Dataframe(headers=METRIC_HEADERS, interactive=False, label="Evaluation Metrics")
|
| 367 |
case_table = gr.Dataframe(headers=CASE_HEADERS, interactive=False, label="Evaluation Cases")
|
| 368 |
|
| 369 |
run_outputs = [
|
| 370 |
+
status, current_run_id, node_table, event_table, review_table, final_output,
|
| 371 |
+
business_outcome, workflow_path, ai_insight, routing_explanation, business_journey,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
]
|
| 373 |
+
stream_outputs = [live_activity, *run_outputs]
|
| 374 |
|
| 375 |
+
story_button.click(fn=stream_story_ui, inputs=[story], outputs=stream_outputs, show_progress="hidden")
|
| 376 |
run_button.click(
|
| 377 |
+
fn=stream_request_ui,
|
| 378 |
+
inputs=[household_id, household_type, onboarding_notes, documents_complete, identity_status, relationship_complexity, simulation_mode],
|
| 379 |
+
outputs=stream_outputs,
|
| 380 |
+
show_progress="hidden",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
)
|
| 382 |
refresh_button.click(fn=refresh_run_ui, inputs=[current_run_id], outputs=run_outputs)
|
| 383 |
+
decision_button.click(fn=submit_human_decision_ui, inputs=[current_run_id, human_decision], outputs=run_outputs)
|
| 384 |
+
evaluation_button.click(fn=run_evaluation_ui, outputs=[evaluation_summary, metric_table, case_table])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
|
| 386 |
return demo
|
| 387 |
|
| 388 |
|
| 389 |
demo = build_app()
|
| 390 |
|
|
|
|
| 391 |
if __name__ == "__main__":
|
| 392 |
+
demo.queue().launch(server_name="0.0.0.0", server_port=int(os.getenv("PORT", "7860")), css=APP_CSS)
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_app.py
CHANGED
|
@@ -13,6 +13,7 @@ os.environ["LIVE_AI_ENABLED"] = "false"
|
|
| 13 |
|
| 14 |
import app # noqa: E402
|
| 15 |
|
|
|
|
| 16 |
from src.persistence import SQLiteStateStore # noqa: E402
|
| 17 |
from src.schemas import WorkflowStatus # noqa: E402
|
| 18 |
|
|
@@ -21,6 +22,38 @@ def test_gradio_app_builds_as_blocks() -> None:
|
|
| 21 |
assert isinstance(app.demo, gr.Blocks)
|
| 22 |
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
def test_one_click_standard_household_explains_ready_path(
|
| 25 |
tmp_path: Path,
|
| 26 |
monkeypatch,
|
|
|
|
| 13 |
|
| 14 |
import app # noqa: E402
|
| 15 |
|
| 16 |
+
from src.demo_presentation import APP_CSS, PATTERN_HTML # noqa: E402
|
| 17 |
from src.persistence import SQLiteStateStore # noqa: E402
|
| 18 |
from src.schemas import WorkflowStatus # noqa: E402
|
| 19 |
|
|
|
|
| 22 |
assert isinstance(app.demo, gr.Blocks)
|
| 23 |
|
| 24 |
|
| 25 |
+
def test_business_presentation_is_centered_and_vertical() -> None:
|
| 26 |
+
assert "max-width:980px" in APP_CSS
|
| 27 |
+
assert "grid-template-columns:1fr" in APP_CSS
|
| 28 |
+
assert "flex-direction:column" in APP_CSS
|
| 29 |
+
assert "Humans retain authority over consequential exceptions" in PATTERN_HTML
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_stream_story_yields_live_activity_and_full_completed_trace(
|
| 33 |
+
tmp_path: Path,
|
| 34 |
+
monkeypatch,
|
| 35 |
+
) -> None:
|
| 36 |
+
store = SQLiteStateStore(tmp_path / "stream-story.db")
|
| 37 |
+
monkeypatch.setattr(app, "STATE_STORE", store)
|
| 38 |
+
monkeypatch.setattr(app, "playback_delay", lambda _event_type: 0)
|
| 39 |
+
|
| 40 |
+
frames = list(app.stream_story_ui("Harbor Family — straightforward household"))
|
| 41 |
+
|
| 42 |
+
assert len(frames) > 3
|
| 43 |
+
assert "RUNNING" in frames[0][0]
|
| 44 |
+
assert "Workflow engine is running" in frames[0][0]
|
| 45 |
+
|
| 46 |
+
final = frames[-1]
|
| 47 |
+
run_id = final[2]
|
| 48 |
+
run = store.load(run_id)
|
| 49 |
+
|
| 50 |
+
assert run.status is WorkflowStatus.COMPLETED
|
| 51 |
+
assert "activity-scroll" in final[0]
|
| 52 |
+
assert "Workflow event trace complete" in final[0]
|
| 53 |
+
assert final[0].count("activity-event") >= len(run.events)
|
| 54 |
+
assert "COMPLETED" in final[1]
|
| 55 |
+
|
| 56 |
+
|
| 57 |
def test_one_click_standard_household_explains_ready_path(
|
| 58 |
tmp_path: Path,
|
| 59 |
monkeypatch,
|