File size: 17,916 Bytes
0553ea7
152be26
 
 
 
0553ea7
152be26
 
 
 
0553ea7
 
 
 
 
 
 
 
 
 
 
 
 
 
ef4748f
152be26
 
 
 
 
 
 
5806fe4
 
 
 
 
152be26
 
 
 
 
 
 
 
5806fe4
08e4d77
 
152be26
 
5806fe4
152be26
5806fe4
152be26
 
ef4748f
152be26
 
5806fe4
 
152be26
08e4d77
5806fe4
 
ef4748f
0553ea7
ef4748f
 
 
08e4d77
 
5806fe4
 
ef4748f
0553ea7
ef4748f
 
 
08e4d77
 
5806fe4
 
ef4748f
0553ea7
ef4748f
 
 
08e4d77
 
5806fe4
 
ef4748f
0553ea7
ef4748f
 
 
08e4d77
 
 
 
0553ea7
08e4d77
 
 
 
 
 
 
 
 
5806fe4
 
 
08e4d77
 
 
5806fe4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152be26
ef4748f
 
 
 
 
 
152be26
08e4d77
152be26
ef4748f
 
 
 
 
 
152be26
 
 
0553ea7
08e4d77
152be26
ef4748f
5806fe4
ef4748f
152be26
 
08e4d77
 
 
ef4748f
08e4d77
 
 
 
152be26
 
 
0553ea7
152be26
 
 
 
 
 
08e4d77
152be26
 
 
 
 
 
 
 
 
0553ea7
152be26
 
 
5806fe4
152be26
0553ea7
152be26
 
 
08e4d77
152be26
 
 
 
 
 
 
 
 
 
 
ef4748f
152be26
 
 
 
 
 
 
 
08e4d77
 
5806fe4
 
 
 
 
 
 
 
08e4d77
 
 
5806fe4
152be26
5806fe4
 
08e4d77
5806fe4
 
 
08e4d77
152be26
 
 
08e4d77
 
0553ea7
 
 
 
 
 
 
 
08e4d77
0553ea7
 
08e4d77
152be26
 
0553ea7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152be26
0553ea7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
08e4d77
 
5806fe4
08e4d77
ef4748f
0553ea7
 
 
 
152be26
08e4d77
5806fe4
0553ea7
 
08e4d77
152be26
08e4d77
0553ea7
08e4d77
5806fe4
08e4d77
152be26
 
0553ea7
5806fe4
0553ea7
 
 
 
 
 
 
 
 
 
 
 
ef4748f
5806fe4
152be26
 
08e4d77
0553ea7
5806fe4
152be26
0553ea7
 
5806fe4
0553ea7
 
 
152be26
08e4d77
0553ea7
5806fe4
152be26
5806fe4
 
152be26
 
0553ea7
 
152be26
0553ea7
152be26
0553ea7
152be26
0553ea7
 
 
 
152be26
5806fe4
0553ea7
 
152be26
 
 
 
 
 
 
0553ea7
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
"""Gradio demo for Agent 9 — Agentic Workflow System."""

from __future__ import annotations

import os
import time
from typing import Any

import gradio as gr

from src.demo_presentation import (
    APP_CSS,
    EMPTY_ACTIVITY_HTML,
    EMPTY_AI_HTML,
    EMPTY_JOURNEY_HTML,
    EMPTY_OUTCOME_HTML,
    EMPTY_PATH_HTML,
    EMPTY_ROUTING_HTML,
    HERO_HTML,
    PATTERN_HTML,
    activity_html,
    activity_start_html,
    playback_delay,
)
from src.demo_workflow import build_onboarding_registry, build_onboarding_workflow
from src.evaluation import run_evaluation_suite
from src.executor import (
    WorkflowExecutionError,
    WorkflowHumanDecisionError,
    execute_workflow,
    submit_human_decision,
)
from src.hf_provider import (
    build_hf_onboarding_model_from_env,
    configured_model_id,
    live_ai_enabled_from_env,
)
from src.persistence import RunNotFoundError, SQLiteStateStore
from src.schemas import HumanDecision, WorkflowRun
from src.ui_presenters import (
    CASE_HEADERS,
    EVENT_HEADERS,
    METRIC_HEADERS,
    NODE_HEADERS,
    REVIEW_HEADERS,
    ai_insight_html,
    business_journey_html,
    business_outcome_html,
    evaluation_case_rows,
    evaluation_metric_rows,
    routing_explanation_html,
    run_bundle,
    workflow_path_html,
)

WORKFLOW = build_onboarding_workflow()
DATABASE_PATH = os.getenv("WORKFLOW_DB_PATH", "workflow_runs.db")
STATE_STORE = SQLiteStateStore(DATABASE_PATH)
LIVE_MODEL_ID = configured_model_id()
LIVE_ONBOARDING_MODEL = build_hf_onboarding_model_from_env()

STORY_SPECS: dict[str, dict[str, Any]] = {
    "Harbor Family — straightforward household": {
        "household_id": "HH-HARBOR-STANDARD",
        "household_type": "JOINT",
        "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.",
        "documents_complete": True,
        "identity_status": "VERIFIED",
        "relationship_complexity": "STANDARD",
        "simulation_mode": "NONE",
    },
    "Harbor Family — temporary onboarding-service issue": {
        "household_id": "HH-HARBOR-RETRY",
        "household_type": "JOINT",
        "onboarding_notes": "The fictional Harbor family has a straightforward synthetic intake, but the demo will simulate one temporary package-preparation service interruption.",
        "documents_complete": True,
        "identity_status": "VERIFIED",
        "relationship_complexity": "STANDARD",
        "simulation_mode": "TRANSIENT_ONCE",
    },
    "Redwood Family Trust — human review required": {
        "household_id": "HH-REDWOOD-TRUST",
        "household_type": "TRUST",
        "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.",
        "documents_complete": True,
        "identity_status": "VERIFIED",
        "relationship_complexity": "COMPLEX",
        "simulation_mode": "NONE",
    },
    "Cedar Household — permanent dependency failure": {
        "household_id": "HH-CEDAR-FAILURE",
        "household_type": "JOINT",
        "onboarding_notes": "The fictional Cedar household has a standard synthetic intake, but the demo will simulate a permanent downstream package-preparation failure.",
        "documents_complete": True,
        "identity_status": "VERIFIED",
        "relationship_complexity": "STANDARD",
        "simulation_mode": "PERMANENT",
    },
}

RunBundle = tuple[str, str, list[list[Any]], list[list[str]], list[list[str]], dict[str, Any]]
AppBundle = tuple[
    str,
    str,
    list[list[Any]],
    list[list[str]],
    list[list[str]],
    dict[str, Any],
    str,
    str,
    str,
    str,
    str,
]


def _ai_runtime_html() -> str:
    if LIVE_ONBOARDING_MODEL is not None:
        return (
            '<div class="ai-runtime ai-runtime-live"><strong>● Live AI connected</strong><br>'
            f'The AI Intake Organizer is using Hugging Face Inference Providers with <code>{LIVE_MODEL_ID}</code>. '
            'Its output still must pass the same strict Pydantic schema before the workflow can use it.</div>'
        )
    if live_ai_enabled_from_env():
        return (
            '<div class="ai-runtime ai-runtime-waiting"><strong>◐ Live AI requested but not connected</strong><br>'
            'LIVE_AI_ENABLED is on, but no usable HF_TOKEN was found. The demo is using its explicit deterministic fallback.</div>'
        )
    return (
        '<div class="ai-runtime"><strong>○ AI boundary ready; deterministic fallback active</strong><br>'
        'Live inference is deliberately opt-in. The same bounded node can use Hugging Face Inference Providers when the deployment sets <code>LIVE_AI_ENABLED=true</code> and supplies <code>HF_TOKEN</code>.</div>'
    )


def _build_registry():
    return build_onboarding_registry(
        onboarding_model=LIVE_ONBOARDING_MODEL,
        model_label=LIVE_MODEL_ID if LIVE_ONBOARDING_MODEL is not None else None,
    )


def run_request_ui(
    household_id: str,
    household_type: str,
    onboarding_notes: str,
    documents_complete: bool,
    identity_status: str,
    relationship_complexity: str,
    simulation_mode: str,
) -> AppBundle:
    context = {
        "household_id": household_id,
        "household_type": household_type,
        "onboarding_notes": onboarding_notes,
        "documents_complete": documents_complete,
        "identity_status": identity_status,
        "relationship_complexity": relationship_complexity,
        "simulation_mode": simulation_mode,
    }
    try:
        run = execute_workflow(WORKFLOW, _build_registry(), context=context, state_store=STATE_STORE)
        return _app_bundle(run)
    except WorkflowExecutionError as exc:
        return _bundle_with_notice(exc.run, "The onboarding workflow stopped after a controlled failure.")
    except Exception as exc:
        return _empty_bundle(f"Unable to start onboarding workflow: {type(exc).__name__}")


def run_story_ui(story: str) -> AppBundle:
    spec = STORY_SPECS.get(story)
    if spec is None:
        return _empty_bundle("Choose one of the four onboarding stories before running the workflow.")
    return run_request_ui(**spec)


def refresh_run_ui(run_id: str) -> AppBundle:
    if not run_id or not run_id.strip():
        return _empty_bundle("Enter a run ID to load a persisted workflow.")
    try:
        return _app_bundle(STATE_STORE.load(run_id.strip()))
    except RunNotFoundError:
        return _empty_bundle("No persisted workflow run was found for that ID.", run_id.strip())
    except Exception as exc:
        return _empty_bundle(f"Unable to load workflow: {type(exc).__name__}", run_id.strip())


def submit_human_decision_ui(run_id: str, decision: str) -> AppBundle:
    if not run_id or not run_id.strip():
        return _empty_bundle("Enter a run ID before submitting a human decision.")
    normalized_run_id = run_id.strip()
    try:
        run = STATE_STORE.load(normalized_run_id)
    except RunNotFoundError:
        return _empty_bundle("No persisted workflow run was found for that ID.", normalized_run_id)
    open_reviews = [review for review in run.human_reviews if review.decision is None]
    if len(open_reviews) != 1:
        return _bundle_with_notice(run, "This run does not have exactly one open human review. Reload the run and inspect the Human Review table.")
    try:
        updated = submit_human_decision(
            WORKFLOW,
            _build_registry(),
            run_id=normalized_run_id,
            review_id=open_reviews[0].review_id,
            decision=HumanDecision(decision),
            state_store=STATE_STORE,
        )
        return _app_bundle(updated)
    except (WorkflowHumanDecisionError, WorkflowExecutionError) as exc:
        return _bundle_with_notice(exc.run, str(exc))
    except ValueError:
        return _bundle_with_notice(run, "Unknown human decision.")
    except Exception as exc:
        return _bundle_with_notice(run, f"Unable to apply decision: {type(exc).__name__}")


def run_evaluation_ui() -> tuple[str, list[list[str]], list[list[str]]]:
    report = run_evaluation_suite()
    summary = (
        "### Wealth Onboarding Workflow Evaluation\n"
        f"**Cases:** {report.passed_cases}/{report.total_cases} passed  \n"
        f"**Pass rate:** {report.case_pass_rate:.2f}  \n"
        f"**Duplicate executions:** {report.metrics['duplicate_execution_count']}  \n"
        f"**Invalid transitions:** {report.metrics['invalid_transition_count']}"
    )
    return summary, evaluation_metric_rows(report), evaluation_case_rows(report)


def _app_bundle(run: WorkflowRun) -> AppBundle:
    technical: RunBundle = run_bundle(run)
    return (
        *technical,
        business_outcome_html(run),
        workflow_path_html(run),
        ai_insight_html(run),
        routing_explanation_html(run),
        business_journey_html(run),
    )


def _bundle_with_notice(run: WorkflowRun, notice: str) -> AppBundle:
    technical = run_bundle(run)
    return (
        f"> {notice}\n\n{technical[0]}",
        *technical[1:],
        business_outcome_html(run),
        workflow_path_html(run),
        ai_insight_html(run),
        routing_explanation_html(run),
        business_journey_html(run),
    )


def _empty_bundle(message: str, run_id: str = "") -> AppBundle:
    return (
        f"### Workflow Status\n{message}", run_id, [], [], [], {},
        EMPTY_OUTCOME_HTML, EMPTY_PATH_HTML, EMPTY_AI_HTML, EMPTY_ROUTING_HTML, EMPTY_JOURNEY_HTML,
    )


def _running_bundle(run_id: str = "") -> AppBundle:
    return (
        "### Workflow running…\nThe workflow engine is controlling the approved next steps.",
        run_id,
        [], [], [], {},
        EMPTY_OUTCOME_HTML, EMPTY_PATH_HTML, EMPTY_AI_HTML, EMPTY_ROUTING_HTML, EMPTY_JOURNEY_HTML,
    )


def _stream_bundle(bundle: AppBundle):
    run_id = bundle[1]
    if not run_id:
        yield ('<div class="activity-card"><div class="activity-badge">STOPPED</div><div class="activity-title">Workflow did not start</div></div>', *bundle)
        return
    try:
        run = STATE_STORE.load(run_id)
    except Exception:
        yield ('<div class="activity-card"><div class="activity-badge">STOPPED</div><div class="activity-title">Workflow trace unavailable</div></div>', *bundle)
        return
    running = _running_bundle(run_id)
    for index, event in enumerate(run.events, start=1):
        yield (activity_html(run, index), *running)
        time.sleep(playback_delay(event.event_type.value))
    yield (activity_html(run, len(run.events), complete=True), *bundle)


def stream_story_ui(story: str):
    yield (activity_start_html(story), *_running_bundle())
    yield from _stream_bundle(run_story_ui(story))


def stream_request_ui(
    household_id: str,
    household_type: str,
    onboarding_notes: str,
    documents_complete: bool,
    identity_status: str,
    relationship_complexity: str,
    simulation_mode: str,
):
    yield (activity_start_html("Custom synthetic household"), *_running_bundle())
    bundle = run_request_ui(
        household_id, household_type, onboarding_notes, documents_complete,
        identity_status, relationship_complexity, simulation_mode,
    )
    yield from _stream_bundle(bundle)


def build_app() -> gr.Blocks:
    with gr.Blocks(title="Agent 9 — Wealth Onboarding Workflow", analytics_enabled=False) as demo:
        gr.HTML(HERO_HTML)
        gr.HTML(PATTERN_HTML)
        gr.HTML(_ai_runtime_html())

        gr.Markdown("## Try the onboarding pattern in one click")
        gr.Markdown("Choose a fictional household and watch the workflow control plane move it through AI assistance, deterministic rules, retry/recovery, and human escalation.")
        story = gr.Radio(choices=list(STORY_SPECS), value="Harbor Family — straightforward household", label="Choose an onboarding story")
        story_button = gr.Button("Run this onboarding workflow", variant="primary")
        live_activity = gr.HTML(EMPTY_ACTIVITY_HTML)

        business_outcome = gr.HTML(EMPTY_OUTCOME_HTML)
        workflow_path = gr.HTML(EMPTY_PATH_HTML)
        ai_insight = gr.HTML(EMPTY_AI_HTML)
        routing_explanation = gr.HTML(EMPTY_ROUTING_HTML)
        business_journey = gr.HTML(EMPTY_JOURNEY_HTML)

        with gr.Accordion("Reload a saved run", open=False):
            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.")
            with gr.Row():
                current_run_id = gr.Textbox(label="Run ID", placeholder="A random run ID appears here after execution.")
                refresh_button = gr.Button("Reload Saved Run")

        with gr.Tabs():
            with gr.Tab("Customize Scenario"):
                gr.Markdown("### Build your own synthetic onboarding scenario\nThese inputs are fictional and exist only to demonstrate workflow behavior.")
                household_id = gr.Textbox(label="Household ID", value="HH-DEMO-001")
                household_type = gr.Dropdown(choices=["INDIVIDUAL", "JOINT", "TRUST", "ENTITY"], value="JOINT", label="Household Type")
                documents_complete = gr.Checkbox(label="Onboarding documents complete", value=True)
                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)
                identity_status = gr.Dropdown(choices=["VERIFIED", "REVIEW_REQUIRED"], value="VERIFIED", label="Identity Check Status")
                relationship_complexity = gr.Dropdown(choices=["STANDARD", "COMPLEX"], value="STANDARD", label="Relationship Complexity")
                simulation_mode = gr.Dropdown(choices=["NONE", "TRANSIENT_ONCE", "PERMANENT"], value="NONE", label="Failure Simulation")
                gr.Markdown("**Helpful shortcuts:** TRUST/ENTITY, COMPLEX, missing documents, or REVIEW_REQUIRED demonstrates human review. TRANSIENT_ONCE demonstrates bounded retry recovery.")
                run_button = gr.Button("Run custom workflow", variant="primary")

            with gr.Tab("Engineering State"):
                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.")
                status = gr.Markdown("### Workflow Status\nRun a synthetic household to begin.")
                node_table = gr.Dataframe(headers=NODE_HEADERS, interactive=False, label="Node Runtime State")
                final_output = gr.JSON(label="Final Output")

            with gr.Tab("Audit Trail"):
                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.")
                event_table = gr.Dataframe(headers=EVENT_HEADERS, interactive=False, label="Append-Only Workflow Events")

            with gr.Tab("Human Review"):
                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.")
                review_table = gr.Dataframe(headers=REVIEW_HEADERS, interactive=False, label="Human Review History")
                human_decision = gr.Dropdown(choices=["APPROVE", "REJECT", "RETRY"], value="APPROVE", label="What should the reviewer do?")
                decision_button = gr.Button("Submit Human Decision", variant="primary")
                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.")

            with gr.Tab("Evaluation"):
                gr.Markdown("### Reliability evidence\nThe same onboarding engine is exercised across success, retry, human review, persistence, failure, and adversarial-control scenarios.")
                evaluation_summary = gr.Markdown("Run the deterministic evaluation harness to inspect system-level metrics.")
                evaluation_button = gr.Button("Run 10-Case Evaluation")
                metric_table = gr.Dataframe(headers=METRIC_HEADERS, interactive=False, label="Evaluation Metrics")
                case_table = gr.Dataframe(headers=CASE_HEADERS, interactive=False, label="Evaluation Cases")

        run_outputs = [
            status, current_run_id, node_table, event_table, review_table, final_output,
            business_outcome, workflow_path, ai_insight, routing_explanation, business_journey,
        ]
        stream_outputs = [live_activity, *run_outputs]

        story_button.click(fn=stream_story_ui, inputs=[story], outputs=stream_outputs, show_progress="hidden")
        run_button.click(
            fn=stream_request_ui,
            inputs=[household_id, household_type, onboarding_notes, documents_complete, identity_status, relationship_complexity, simulation_mode],
            outputs=stream_outputs,
            show_progress="hidden",
        )
        refresh_button.click(fn=refresh_run_ui, inputs=[current_run_id], outputs=run_outputs)
        decision_button.click(fn=submit_human_decision_ui, inputs=[current_run_id, human_decision], outputs=run_outputs)
        evaluation_button.click(fn=run_evaluation_ui, outputs=[evaluation_summary, metric_table, case_table])

    return demo


demo = build_app()

if __name__ == "__main__":
    demo.queue().launch(server_name="0.0.0.0", server_port=int(os.getenv("PORT", "7860")), css=APP_CSS)