File size: 35,810 Bytes
9f8ef60
6a3eec9
 
9f8ef60
 
 
6a3eec9
 
 
 
 
 
9f8ef60
 
6a3eec9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9f8ef60
 
6a3eec9
 
 
 
 
 
 
492e029
 
 
 
6862340
 
 
1781e32
 
 
 
6862340
 
 
1781e32
9f8ef60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1781e32
 
6862340
 
 
 
1781e32
6862340
 
 
1781e32
 
 
6862340
1781e32
 
 
 
6862340
 
 
1781e32
 
 
492e029
6862340
 
 
 
 
 
 
 
 
492e029
6a3eec9
 
 
6862340
 
492e029
6862340
6a3eec9
6862340
6a3eec9
6862340
6a3eec9
 
 
 
 
 
 
 
 
 
 
 
7607008
6a3eec9
7607008
6a3eec9
7607008
 
 
6862340
 
 
 
 
 
7607008
56cc958
6a3eec9
1781e32
6a3eec9
 
 
 
 
 
 
 
 
 
 
 
bf3e7ab
 
6862340
7607008
6a3eec9
 
 
 
6862340
 
7607008
6a3eec9
1781e32
 
 
6a3eec9
 
 
 
 
44b9b11
6862340
 
 
44b9b11
6862340
 
 
44b9b11
6862340
 
 
44b9b11
 
 
9f8ef60
6a3eec9
9f8ef60
 
6a3eec9
 
 
 
 
492e029
 
6862340
492e029
 
6862340
 
 
492e029
6862340
 
44b9b11
6a3eec9
492e029
 
 
6862340
6a3eec9
 
492e029
6a3eec9
 
492e029
6862340
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
492e029
6862340
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
492e029
7607008
6a3eec9
56cc958
 
343ee62
 
 
 
 
56cc958
 
 
 
7607008
56cc958
6a3eec9
343ee62
 
 
 
 
6a3eec9
 
 
 
 
 
 
 
 
7607008
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6a3eec9
 
492e029
7607008
6a3eec9
 
 
 
7607008
6a3eec9
 
9f8ef60
6a3eec9
 
 
9f8ef60
6a3eec9
 
 
 
 
 
 
6862340
6a3eec9
 
 
6862340
 
 
 
 
6a3eec9
 
 
 
 
6862340
 
 
 
 
6a3eec9
 
 
9f8ef60
6a3eec9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9f8ef60
6a3eec9
 
 
 
 
 
 
 
 
 
 
 
 
 
9f8ef60
6862340
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6a3eec9
 
 
 
 
 
 
 
 
 
 
 
 
 
9f8ef60
6a3eec9
 
 
 
9f8ef60
6a3eec9
 
 
6862340
6a3eec9
 
 
 
 
 
 
6862340
6a3eec9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6862340
6a3eec9
 
 
 
9f8ef60
6862340
 
 
 
 
 
 
 
 
 
 
6a3eec9
 
 
 
 
 
 
 
 
 
 
 
 
6862340
 
6a3eec9
 
 
 
 
9f8ef60
6a3eec9
 
 
 
 
 
 
 
 
6862340
 
6a3eec9
 
 
 
 
9f8ef60
6a3eec9
 
 
 
 
 
 
 
 
6862340
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6a3eec9
6862340
6a3eec9
 
 
 
 
 
 
6862340
6a3eec9
 
6862340
6a3eec9
 
6862340
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6a3eec9
9f8ef60
 
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
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
import json
import httpx
import traceback
import gradio as gr
from typing import Any, Dict

# Charcoal Black Theme Palette
BG_COLOR = "#121212"
TEXT_PRIMARY = "#E0E0E0"
TEXT_SECONDARY = "#B0B0B0"
BORDER_COLOR = "#444444"
ACCENT_COLOR = "#888888"

custom_theme = gr.themes.Base().set(
    body_background_fill=BG_COLOR,
    body_background_fill_dark=BG_COLOR,
    body_text_color=TEXT_PRIMARY,
    body_text_color_dark=TEXT_PRIMARY,
    background_fill_primary="#181818",
    background_fill_primary_dark="#181818",
    background_fill_secondary=BG_COLOR,
    background_fill_secondary_dark=BG_COLOR,
    border_color_primary=BORDER_COLOR,
    border_color_primary_dark=BORDER_COLOR,
    block_background_fill="#181818",
    block_background_fill_dark="#181818",
    button_primary_background_fill=ACCENT_COLOR,
    button_primary_background_fill_dark=ACCENT_COLOR,
    button_primary_background_fill_hover="#aaaaaa",
    button_primary_background_fill_hover_dark="#aaaaaa",
    button_primary_text_color=BG_COLOR,
    button_primary_text_color_dark=BG_COLOR,
    button_primary_text_color_hover=BG_COLOR,
    button_primary_text_color_hover_dark=BG_COLOR,
    slider_color=ACCENT_COLOR,
    slider_color_dark=ACCENT_COLOR,
)

custom_css = f"""
.gradio-container {{ font-family: 'Inter', sans-serif; }}
.stat-box {{ background-color: #181818; border: 1px solid {BORDER_COLOR}; border-radius: 8px; padding: 15px; margin-bottom: 10px; text-align: center; }}
.stat-value {{ font-size: 24px; font-weight: bold; color: {TEXT_PRIMARY}; }}
.stat-label {{ font-size: 12px; text-transform: uppercase; color: {TEXT_SECONDARY}; letter-spacing: 1px; }}
.header-title {{ color: {TEXT_PRIMARY} !important; text-align: center; border-bottom: 2px solid {BORDER_COLOR}; padding-bottom: 10px; margin-bottom: 20px; }}
textarea, input, select {{ background-color: {BG_COLOR} !important; color: {TEXT_PRIMARY} !important; border: 1px solid {BORDER_COLOR} !important; }}

/* Tier Cards */
.tier-card {{ justify-content: flex-start !important; text-align: left !important; padding: 15px !important; border-radius: 8px !important; border: 2px solid #333 !important; background-color: #181818 !important; white-space: pre-wrap !important; line-height: 1.4 !important; transition: all 0.2s !important; }}
.tier-card:hover {{ border-color: #666 !important; background-color: #222 !important; }}
.tier-selected-image {{ border-color: #22c55e !important; background-color: rgba(34, 197, 94, 0.05) !important; }}
.tier-selected-clip {{ border-color: #f59e0b !important; background-color: rgba(245, 158, 11, 0.05) !important; }}
.tier-selected-weight {{ border-color: #ef4444 !important; background-color: rgba(239, 68, 68, 0.05) !important; }}

/* Subenv Coverage Badges */
.coverage-row {{ display: flex; gap: 10px; margin-bottom: 15px; font-family: 'Inter', sans-serif; }}
.cov-badge {{ padding: 4px 12px; border-radius: 16px; font-size: 0.85em; font-weight: bold; cursor: help; }}
.cov-active-image {{ background-color: #22c55e; color: #fff; border: 1px solid #22c55e; }}
.cov-active-clip {{ background-color: #f59e0b; color: #fff; border: 1px solid #f59e0b; }}
.cov-active-weight {{ background-color: #ef4444; color: #fff; border: 1px solid #ef4444; }}
.cov-inactive {{ background-color: transparent; color: #666; border: 1px solid #444; cursor: default; }}
"""

def format_observation(obs: Dict[str, Any]) -> str:
    """Formats the current observation dict to a readable markdown format."""
    if not obs:
        return "Not initialized. Please click **Initialize Scenario**."
    
    node = obs.get("node", "Unknown")
    step = obs.get("step", 0)
    instr = obs.get("instruction", "No instructions")
    signals = obs.get("signals", {})
    scores = obs.get("scores", None)
    
    md = f"### Step {step}: {node}\n\n**Instructions:** {instr}\n\n"
    
    if signals:
        md += "#### πŸ“‘ Available Signals\n```json\n" + json.dumps(signals, indent=2) + "\n```\n"
    
    if scores:
        md += "#### πŸ† Final Score Report\n"
        for k, v in scores.items():
            if isinstance(v, float):
                md += f"- **{k}**: {v:.3f}\n"
            else:
                md += f"- **{k}**: {v}\n"
                
    return md

def generate_badges(tier: str) -> str:
    color_class = {
        "image_audit": "cov-active-image",
        "clip_audit": "cov-active-clip",
        "weight_audit": "cov-active-weight"
    }.get(tier, "cov-active-image")
    
    b1_active = tier == "image_audit"
    b2_active = tier == "clip_audit"
    b3_active = tier == "weight_audit"
    
    def render_badge(name, active, weight):
        if active:
            return f'<div class="cov-badge {color_class}" title="active in selected audit mode">{name} βœ“</div>'
        else:
            return f'<div class="cov-badge cov-inactive">{name} &mdash;</div>'

    html = '<div class="coverage-row">'
    html += render_badge("Image Audit", b1_active, "n/a")
    html += render_badge("Clip Audit", b2_active, "n/a")
    html += render_badge("Weight Audit", b3_active, "n/a")
    html += '</div>'
    return html

async def handle_ingestion(ref_img, clip_vids, lora, tok, prompt, param_json, tier):
    if tier == "image_audit":
        if not ref_img:
            return "Error: reference_image is required for image_audit.", "unknown"
        if not str(prompt or "").strip():
            return "Error: prompt is required for image_audit.", "unknown"
    if tier == "clip_audit" and not clip_vids:
        return "Error: clips (1-12 .mp4) are required for clip_audit.", "unknown"
    if tier == "weight_audit" and not lora:
        return "Error: lora_weights (.safetensors) are required for weight_audit.", "unknown"
        
    try:
        url = "http://127.0.0.1:8000/ingest-artifacts"
        files_data = []
        if ref_img is not None and tier == "image_audit":
            files_data.append(("reference_image", open(ref_img.name, "rb")))
        
        if lora is not None and tier == "weight_audit":
            files_data.append(("lora_weights", open(lora.name, "rb")))
        if tok is not None and tier == "weight_audit":
            files_data.append(("tokenizer_config", open(tok.name, "rb")))
        if clip_vids is not None and tier == "clip_audit":
            for clip in clip_vids:
                files_data.append(("clips", open(clip.name, "rb")))
                
        data = {
            "prompt": str(prompt or ""),
            "param_config_json": str(param_json or "")
        }
        
        async with httpx.AsyncClient() as client:
            resp = await client.post(url, data=data, files=files_data, timeout=60.0)
            
        if resp.status_code == 200:
            return resp.json().get("ingestion_id", "Error: No ID returned"), tier
        else:
            return f"Error {resp.status_code}: {resp.text}", "unknown"
    except Exception as e:
        return f"Exception: {str(e)}\n{traceback.format_exc()}", "unknown"
        
def format_tier_label(tier: str) -> str:
    if tier == "image_audit":
        return '<div style="margin-top: 28px; font-size: 0.9em; color: #E0E0E0; white-space: nowrap;">[Task: Image Audit <span style="color:#22c55e;">●</span>]</div>'
    elif tier == "clip_audit":
        return '<div style="margin-top: 28px; font-size: 0.9em; color: #E0E0E0; white-space: nowrap;">[Task: Clip Audit <span style="color:#f59e0b;">●</span>]</div>'
    elif tier == "weight_audit":
        return '<div style="margin-top: 28px; font-size: 0.9em; color: #E0E0E0; white-space: nowrap;">[Task: Weight Audit <span style="color:#ef4444;">●</span>]</div>'
    else:
        return '<div style="margin-top: 28px; font-size: 0.9em; color: #888; white-space: nowrap;">[Task: Unknown <span style="color:#666;">β—‹</span>]</div>'

async def handle_analysis(ingestion_id, model_id, provider, api_key, max_tokens, temp, tier):
    if not ingestion_id:
        return "Error: Provide an Ingestion ID first."
    try:
        url = "http://127.0.0.1:8000/analyze-ingestion"
        data = {
            "ingestion_id": str(ingestion_id),
            "provider": str(provider),
            "max_tokens": int(max_tokens),
            "temperature": float(temp)
        }
        if model_id:
            data["model_id"] = str(model_id)
        if api_key:
            data["api_key"] = str(api_key)
        if tier in ["image_audit", "clip_audit", "weight_audit"]:
            data["task_tier"] = str(tier)
            
        async with httpx.AsyncClient() as client:
            resp = await client.post(url, json=data, timeout=120.0)
            
        if tier not in ["image_audit", "clip_audit", "weight_audit"]:
            tier = "weight_audit" # Backend default
            
        if resp.status_code == 200:
            report = resp.json().get("report", "Error: No report returned")
            badges = generate_badges(tier)
            return badges + "\n\n" + report
        else:
            return f"Error {resp.status_code}: {resp.text}"
    except Exception as e:
         return f"Exception: {str(e)}"

TIER_INFO = {
    "image_audit": {
        "title": "Image Audit β€” Node 1 + Node 2",
        "body": "The agent receives image quality signals and prompt/config context. It must:\n(1) classify image regime,\n(2) identify image/prompt risks,\n(3) detect parameter anomalies with directional fixes.\nEpisode completes after Node 2 with a standalone Sub-env 1 score."
    },
    "clip_audit": {
        "title": "Clip Audit β€” Node 5",
        "body": "The agent receives clip evidence + dataset context and must recommend\naccept/reject/fix/defer with clear impact reasoning. Episode completes after\nclip disposition with a standalone Sub-env 2 score."
    },
    "weight_audit": {
        "title": "Weight Audit β€” Node 8",
        "body": "The agent receives weight-derived phoneme risk signals and must rank\nrisky phonemes, predict behavior triggers, identify risky clusters, and\nrecommend mitigations. Episode completes after Node 8 with a standalone\nSub-env 3 score."
    }
}

def build_custom_ui(web_manager, action_fields, metadata, is_chat_env, title, quick_start_md):
    with gr.Blocks(theme=custom_theme, css=custom_css, title="TalkingHeadBench Console") as demo:
        gr.Markdown(f"# {title} πŸ“‘", elem_classes=["header-title"])
        
        with gr.Tabs():
            # TAB 1: INGESTION & AUTOMATED ANALYSIS
            with gr.Tab("Workspace Initialization & LLM Analysis"):
                with gr.Row():
                    with gr.Column(scale=1):
                        gr.Markdown("### 1. Select Audit Tier & Ingest")
                        
                        task_tier = gr.Textbox(value="image_audit", visible=False)
                        
                        with gr.Row():
                            btn_image = gr.Button("🟒 IMAGE AUDIT\nReference + Prompt\nNode 1+2", elem_classes=["tier-card", "tier-selected-image"])
                            btn_clip = gr.Button("🟑 CLIP AUDIT\nDataset Clips Only\nNode 5", elem_classes=["tier-card"])
                            btn_weight = gr.Button("πŸ”΄ WEIGHT AUDIT\nLoRA Weights Only\nNode 8", elem_classes=["tier-card"])

                        with gr.Accordion(label=TIER_INFO["image_audit"]["title"], open=False) as eval_accordion:
                            eval_markdown = gr.Markdown(value=TIER_INFO["image_audit"]["body"])

                        ref_file = gr.File(label="Reference Image (.jpg / .png)")
                        clip_files = gr.File(label="Video Clips (.mp4)", file_count="multiple", visible=False)
                        lora_file = gr.File(label="LoRA Weights (.safetensors)", visible=False)
                        tok_file = gr.File(label="Tokenizer Config (.json)", visible=False)
                        prompt_text = gr.Textbox(label="Text Prompt", value="A man speaking directly to the camera.")
                        param_text = gr.Textbox(label="Config JSON (Optional)", value='{"cfg": 7.0}')
                        
                        ingest_btn = gr.Button("Ingest Reference Image", variant="primary")
                        ingestion_id_out = gr.Textbox(label="Generated Ingestion ID", interactive=False)
                        
                        # Tier Selection Logic
                        def set_image_audit():
                            return [
                                "image_audit",
                                gr.update(visible=True),
                                gr.update(visible=True),
                                gr.update(visible=False),
                                gr.update(visible=False),
                                gr.update(visible=False),
                                "Ingest Image Audit Artifacts",
                                gr.update(elem_classes=["tier-card", "tier-selected-image"]),
                                gr.update(elem_classes=["tier-card"]),
                                gr.update(elem_classes=["tier-card"]),
                                gr.update(label=TIER_INFO["image_audit"]["title"], open=True),
                                TIER_INFO["image_audit"]["body"],
                            ]

                        def set_clip_audit():
                            return [
                                "clip_audit",
                                gr.update(visible=False),
                                gr.update(visible=False),
                                gr.update(visible=True),
                                gr.update(visible=False),
                                gr.update(visible=False),
                                "Ingest Clip Audit Artifacts",
                                gr.update(elem_classes=["tier-card"]),
                                gr.update(elem_classes=["tier-card", "tier-selected-clip"]),
                                gr.update(elem_classes=["tier-card"]),
                                gr.update(label=TIER_INFO["clip_audit"]["title"], open=True),
                                TIER_INFO["clip_audit"]["body"],
                            ]

                        def set_weight_audit():
                            return [
                                "weight_audit",
                                gr.update(visible=False),
                                gr.update(visible=False),
                                gr.update(visible=False),
                                gr.update(visible=True),
                                gr.update(visible=True),
                                "Ingest Weight Audit Artifacts",
                                gr.update(elem_classes=["tier-card"]),
                                gr.update(elem_classes=["tier-card"]),
                                gr.update(elem_classes=["tier-card", "tier-selected-weight"]),
                                gr.update(label=TIER_INFO["weight_audit"]["title"], open=True),
                                TIER_INFO["weight_audit"]["body"],
                            ]
                            
                        tier_outputs = [
                            task_tier,
                            ref_file,
                            prompt_text,
                            clip_files,
                            lora_file,
                            tok_file,
                            ingest_btn,
                            btn_image,
                            btn_clip,
                            btn_weight,
                            eval_accordion,
                            eval_markdown,
                        ]
                        btn_image.click(set_image_audit, inputs=[], outputs=tier_outputs)
                        btn_clip.click(set_clip_audit, inputs=[], outputs=tier_outputs)
                        btn_weight.click(set_weight_audit, inputs=[], outputs=tier_outputs)
                        
                        current_task_tier = gr.Textbox(value="unknown", visible=False)
                        
                    with gr.Column(scale=1):
                        gr.Markdown("### 2. Automated LLM Analysis")
                        gr.Markdown(
                            "Use an LLM Diagnostician to automatically analyze the ingested bundle. "
                            "If credentials are configured on the server (HF_TOKEN / API_BASE_URL / MODEL_NAME), "
                            "leave model and API key blank to use those defaults."
                        )
                        
                        with gr.Row():
                            analysis_ingestion_id = gr.Textbox(label="Ingestion ID (auto-filled from step 1)", scale=4)
                            active_tier_label = gr.HTML(value='<div style="margin-top: 28px; font-size: 0.9em; color: #888; white-space: nowrap;">[Task: Unknown <span style="color:#666;">β—‹</span>]</div>', scale=1)
                            
                        analysis_provider = gr.Dropdown(choices=["auto", "openai", "anthropic", "huggingface", "local"], value="auto", label="LLM Provider")
                        analysis_model = gr.Textbox(label="Model ID (Optional formatting)", placeholder="e.g. meta-llama/Llama-3.1-70B-Instruct")
                        analysis_api_key = gr.Textbox(
                            label="API Key (Optional override)",
                            type="password",
                            placeholder="Leave blank to use server-side HF_TOKEN",
                        )
                        analysis_max_tokens = gr.Slider(minimum=64, maximum=4096, value=700, step=1, label="Max Tokens")
                        analysis_temp = gr.Slider(minimum=0.0, maximum=1.0, value=0.2, step=0.01, label="Temperature")
                        
                        analyze_btn = gr.Button("Run Diagnostic Analysis", variant="primary")
                        analysis_report_out = gr.Markdown(label="LLM Final Report")
                        
                        # Link step 1 output to step 2 input
                        ingestion_id_out.change(fn=lambda x: x, inputs=ingestion_id_out, outputs=analysis_ingestion_id)
                        
                        def handle_ingestion_id_change(user_typed_id, last_ingested_id, current_state_tier):
                            if user_typed_id != last_ingested_id and user_typed_id:
                                return "unknown"
                            return current_state_tier
                            
                        analysis_ingestion_id.change(
                            fn=handle_ingestion_id_change,
                            inputs=[analysis_ingestion_id, ingestion_id_out, current_task_tier],
                            outputs=[current_task_tier]
                        )
                        
                        current_task_tier.change(
                            fn=format_tier_label,
                            inputs=[current_task_tier],
                            outputs=[active_tier_label]
                        )
                        
                        ingest_btn.click(
                            handle_ingestion, 
                            inputs=[ref_file, clip_files, lora_file, tok_file, prompt_text, param_text, task_tier],
                            outputs=[ingestion_id_out, current_task_tier]
                        )
                        
                        analyze_btn.click(
                            handle_analysis,
                            inputs=[analysis_ingestion_id, analysis_model, analysis_provider, analysis_api_key, analysis_max_tokens, analysis_temp, current_task_tier],
                            outputs=[analysis_report_out]
                        )

            # TAB 2: MANUAL EVALUATION CONSOLE
            with gr.Tab("Manual Evaluation Console"):
                current_obs = gr.State({})

                with gr.Row():
                    # LEFT SIDEBAR - Environment Status & Actions
                    with gr.Column(scale=1):
                        with gr.Group():
                            gr.Markdown("### Dashboard")
                            with gr.Row():
                                with gr.Column(elem_classes=["stat-box"]):
                                    step_counter = gr.Markdown("<div class='stat-value'>0/2</div><div class='stat-label'>Step</div>")
                                with gr.Column(elem_classes=["stat-box"]):
                                    node_display = gr.Markdown("<div class='stat-value'>Idle</div><div class='stat-label'>Node</div>")
                                    
                        manual_task_tier = gr.Dropdown(
                            choices=["image_audit", "clip_audit", "weight_audit"],
                            value="image_audit",
                            label="Manual Audit Mode",
                        )
                        manual_ingestion_id = gr.Textbox(label="Enter Ingestion ID to load session")
                        reset_btn = gr.Button("Initialize Scenario", variant="primary")
                        
                        # Auto-fill manual ingestion id if one was generated in Tab 1
                        ingestion_id_out.change(fn=lambda x: x, inputs=ingestion_id_out, outputs=manual_ingestion_id)
                        current_task_tier.change(
                            fn=lambda x: x if x in ["image_audit", "clip_audit", "weight_audit"] else "image_audit",
                            inputs=[current_task_tier],
                            outputs=[manual_task_tier],
                        )
                        
                        with gr.Accordion("Raw Observation Payload", open=False):
                            raw_obs_view = gr.JSON(label="Current Context")

                    # RIGHT MAIN PANEL - Interactive Data
                    with gr.Column(scale=2):
                        obs_markdown = gr.Markdown(value=format_observation({}))
                        
                        # STEP 1: ImageDiagnosticsAction
                        with gr.Group(visible=False) as grp_step1:
                            gr.Markdown("## Action 1: Image Diagnostics")
                            s1_regime = gr.Dropdown(
                                choices=["frontal_simple", "non_frontal", "complex_background", "occluded", "low_quality"],
                                label="Regime Classification", value="frontal_simple"
                            )
                            s1_risk_factors = gr.Textbox(label="Identified Risk Factors (JSON list)", value='["lateral_pose_risk"]')
                            s1_usability = gr.Slider(0.0, 1.0, value=0.5, label="Image Usability Score")
                            s1_reasoning = gr.Textbox(label="Reasoning", value="Image analysis completed.")
                            s1_submit = gr.Button("Execute Diagnostics", variant="primary")

                        # STEP 2: ParamAnomalyAction
                        with gr.Group(visible=False) as grp_step2:
                            gr.Markdown("## Action 2: Parameter Anomaly Detection")
                            s2_risk = gr.Dropdown(
                                choices=["safe", "marginal", "risky", "dangerous"],
                                label="Config Risk Level", value="marginal"
                            )
                            s2_anomalies = gr.Code(
                                label="Anomalies (JSON array of ParameterAnomaly dicts)",
                                language="json",
                                value='[]'
                            )
                            s2_summary = gr.Textbox(label="Risk Summary", value="Parameter analysis completed.")
                            s2_submit = gr.Button("Execute Parameter Check", variant="primary")

                        # CLIP STEP: ClipDispositionAction
                        with gr.Group(visible=False) as grp_clip:
                            gr.Markdown("## Action: Clip Disposition")
                            c_disposition = gr.Dropdown(
                                choices=["accept", "reject", "fix", "defer"],
                                label="Disposition",
                                value="accept",
                            )
                            c_confidence = gr.Slider(0.0, 1.0, value=0.6, label="Confidence")
                            c_rejection = gr.Code(
                                label="Rejection Reasons (JSON array)",
                                language="json",
                                value="[]",
                            )
                            c_fixes = gr.Code(
                                label="Fix Instructions (JSON array)",
                                language="json",
                                value="[]",
                            )
                            c_fix_effort = gr.Dropdown(
                                choices=["", "trivial", "moderate", "high"],
                                value="",
                                label="Estimated Fix Effort (optional)",
                            )
                            c_defer = gr.Textbox(label="Defer Reason (optional)", value="")
                            c_reasoning = gr.Textbox(
                                label="Dataset Impact Reasoning",
                                value="Disposition reasoning based on dossier quality and coverage impact.",
                            )
                            c_override = gr.Dropdown(
                                choices=["not_applicable", "declined", "applied"],
                                value="not_applicable",
                                label="Override Decision",
                            )
                            c_override_just = gr.Textbox(
                                label="Override Justification (optional)",
                                value="",
                            )
                            c_submit = gr.Button("Execute Clip Disposition", variant="primary")

                        # STEP 3: PhonemeRiskAction
                        with gr.Group(visible=False) as grp_step3:
                            gr.Markdown("## Action 3: Phoneme Behavioral Risk Assessment")
                            s3_safety = gr.Dropdown(
                                choices=["safe", "minor_concerns", "moderate_risk", "high_risk", "unsafe"],
                                label="Model Behavioral Safety", value="minor_concerns"
                            )
                            s3_risks = gr.Code(
                                label="Phoneme Risk Ranking (JSON array)",
                                language="json",
                                value='[]'
                            )
                            s3_summary = gr.Textbox(label="Overall Behavioral Summary", value="Behavioral evaluate completed.")
                            s3_submit = gr.Button("Execute Final Assessment", variant="primary")

                        # DONE VIEW
                        with gr.Group(visible=False) as grp_done:
                            gr.Markdown("## 🏁 Episode Complete")
                            score_markdown = gr.Markdown()

                # State update flow
                def update_ui_state(obs: Dict[str, Any]):
                    if not obs: 
                        return [{}, format_observation({}), "<div class='stat-value'>0/2</div><div class='stat-label'>Step</div>", "<div class='stat-value'>Error</div><div class='stat-label'>Node</div>", False, False, False, False, False, ""]
                        
                    step = obs.get("step", 0)
                    node = obs.get("node", "Unknown")
                    is_done = getattr(obs, "done", obs.get("done", False)) # Handle both object getattr and dict get
                    schema = obs.get("expected_action_schema", "")
                    scores = obs.get("scores", None)
                    
                    step_str = f"<div class='stat-value'>{step}/2</div><div class='stat-label'>Step</div>"
                    node_short = node.split(" ")[0] if " " in node else node
                    node_str = f"<div class='stat-value'>{node_short}</div><div class='stat-label'>Node</div>"
                    
                    score_md = ""
                    if is_done and scores:
                        html_scores = ""
                        for k, v in scores.items():
                            val = f"{v:.3f}" if isinstance(v, float) else str(v)
                            html_scores += f"<li><strong>{k}:</strong> <span style='color: {ACCENT_COLOR};'>{val}</span></li>"
                        score_md = f"<ul style='font-size: 1.2rem;'>{html_scores}</ul><hr/><p>Evaluation finished. Change ingestion ID and Reset to run new scenario.</p>"
                    
                    return [
                        obs,
                        format_observation(obs),
                        step_str,
                        node_str,
                        gr.update(visible=(not is_done and schema == "ImageDiagnosticsAction")),
                        gr.update(visible=(not is_done and schema == "ParamAnomalyAction")),
                        gr.update(visible=(not is_done and schema == "ClipDispositionAction")),
                        gr.update(visible=(not is_done and schema == "PhonemeRiskAction")),
                        gr.update(visible=is_done),
                        gr.update(value=score_md)
                    ]

                async def do_reset(ingestion_id, selected_tier):
                    mode_map = {
                        "image_audit": "image",
                        "clip_audit": "clips",
                        "weight_audit": "weights",
                    }
                    payload = {"mode": mode_map.get(selected_tier, "image")}
                    if str(ingestion_id or "").strip():
                        payload["ingestion_id"] = str(ingestion_id)

                    res = await web_manager.reset_environment(payload)
                    obs = res.get("observation", {})
                    return update_ui_state(obs)
                    
                async def do_step1(regime, risks_str, usability, reasoning):
                    try:
                        risks = json.loads(risks_str)
                    except:
                        risks = []
                    action_payload = {
                        "regime_classification": regime,
                        "identified_risk_factors": risks,
                        "image_usability_score": float(usability),
                        "reasoning": reasoning,
                        "prompt_issues": [],
                        "recommended_prompt_modifications": [],
                    }
                    res = await web_manager.step_environment(action_payload)
                    obs = res.get("observation", {})
                    obs["done"] = res.get("done", False) 
                    return update_ui_state(obs)

                async def do_step2(risk_level, anomalies_str, summary):
                    try:
                        anoms = json.loads(anomalies_str)
                    except:
                        anoms = []
                    action_payload = {
                        "config_risk_level": risk_level,
                        "anomalies": anoms,
                        "summary": summary,
                        "predicted_failure_modes": [],
                        "directional_fixes": [],
                    }
                    res = await web_manager.step_environment(action_payload)
                    obs = res.get("observation", {})
                    obs["done"] = res.get("done", False)
                    return update_ui_state(obs)

                async def do_step3(safety_level, risks_str, summary):
                    try:
                        risks = json.loads(risks_str)
                    except:
                        risks = []
                    action_payload = {
                        "model_behavioral_safety": safety_level,
                        "phoneme_risk_ranking": risks,
                        "summary": summary,
                        "predicted_behavior_triggers": [],
                        "risky_phoneme_clusters": [],
                        "mitigation_recommendations": [],
                    }
                    res = await web_manager.step_environment(action_payload)
                    obs = res.get("observation", {})
                    obs["done"] = res.get("done", False)
                    return update_ui_state(obs)

                async def do_clip_step(
                    disposition,
                    confidence,
                    rejection_str,
                    fixes_str,
                    fix_effort,
                    defer_reason,
                    reasoning,
                    override_decision,
                    override_justification,
                ):
                    try:
                        rejection_reasons = json.loads(rejection_str)
                        if not isinstance(rejection_reasons, list):
                            rejection_reasons = []
                    except:
                        rejection_reasons = []

                    try:
                        fix_instructions = json.loads(fixes_str)
                        if not isinstance(fix_instructions, list):
                            fix_instructions = []
                    except:
                        fix_instructions = []

                    action_payload = {
                        "disposition": disposition,
                        "confidence": float(confidence),
                        "rejection_reasons": rejection_reasons or None,
                        "fix_instructions": fix_instructions or None,
                        "estimated_fix_effort": fix_effort or None,
                        "defer_reason": (defer_reason or "").strip() or None,
                        "dataset_impact_reasoning": reasoning,
                        "override_decision": override_decision,
                        "override_justification": (override_justification or "").strip() or None,
                    }

                    res = await web_manager.step_environment(action_payload)
                    obs = res.get("observation", {})
                    obs["done"] = res.get("done", False)
                    return update_ui_state(obs)

                outputs = [
                    raw_obs_view, obs_markdown, step_counter, node_display,
                    grp_step1, grp_step2, grp_clip, grp_step3, grp_done, score_markdown
                ]
                
                reset_btn.click(do_reset, inputs=[manual_ingestion_id, manual_task_tier], outputs=outputs)
                s1_submit.click(do_step1, inputs=[s1_regime, s1_risk_factors, s1_usability, s1_reasoning], outputs=outputs)
                s2_submit.click(do_step2, inputs=[s2_risk, s2_anomalies, s2_summary], outputs=outputs)
                c_submit.click(
                    do_clip_step,
                    inputs=[
                        c_disposition,
                        c_confidence,
                        c_rejection,
                        c_fixes,
                        c_fix_effort,
                        c_defer,
                        c_reasoning,
                        c_override,
                        c_override_just,
                    ],
                    outputs=outputs,
                )
                s3_submit.click(do_step3, inputs=[s3_safety, s3_risks, s3_summary], outputs=outputs)

    return demo