FlyingNunchucks commited on
Commit
0553ea7
·
verified ·
1 Parent(s): fc82b09

Sync from GitHub via hub-sync

Browse files
Files changed (2) hide show
  1. app.py +113 -356
  2. 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
- run = STATE_STORE.load(run_id.strip())
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=review.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 build_app() -> gr.Blocks:
481
- """Construct the public Gradio application."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
482
 
483
- with gr.Blocks(title="Agent 9 — Wealth Onboarding Workflow") as demo:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- "Pick a fictional household. The app shows the route, AI work product, and routing reasons before the engineering details."
491
- )
492
- story = gr.Radio(
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
- with gr.Row():
502
- ai_insight = gr.HTML(EMPTY_AI_HTML)
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 Household"):
516
  gr.Markdown("### Build your own synthetic onboarding scenario\nThese inputs are fictional and exist only to demonstrate workflow behavior.")
517
- with gr.Row():
518
- household_id = gr.Textbox(label="Household ID", value="HH-DEMO-001")
519
- household_type = gr.Dropdown(
520
- choices=["INDIVIDUAL", "JOINT", "TRUST", "ENTITY"],
521
- value="JOINT",
522
- label="Household Type",
523
- )
524
- documents_complete = gr.Checkbox(label="Onboarding documents complete", value=True)
525
- onboarding_notes = gr.Textbox(
526
- label="Synthetic Onboarding Notes",
527
- value=(
528
- "Fictional household seeking a standard advisory relationship. "
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("Operations / Compliance Review"):
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
- with gr.Row():
568
- human_decision = gr.Dropdown(
569
- choices=["APPROVE", "REJECT", "RETRY"], value="APPROVE", label="What should the reviewer do?"
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
- current_run_id,
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=run_story_ui, inputs=[story], outputs=run_outputs)
600
  run_button.click(
601
- fn=run_request_ui,
602
- inputs=[
603
- household_id,
604
- household_type,
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
- fn=submit_human_decision_ui,
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,