Repository navigation
Expand file tree
/
Copy pathevaluate_api_model.py
More file actions
1785 lines (1592 loc) · 73.3 KB
/
Copy pathevaluate_api_model.py
File metadata and controls
1785 lines (1592 loc) · 73.3 KB
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
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""Evaluate API models on A2UI tasks with L1/L2/L3 scenario-dimension metrics.
Usage:
python evaluate_api_model.py \
--task-dir ./data/eval_300 \
--models openai/gpt-4o-mini deepseek/deepseek-chat-v3.1 openai/gpt-5.4 \
--judge-model openai/gpt-5.4 \
--max-per-scenario 10
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import random
import re
import sys
import time
from collections import defaultdict
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
_EVAL_ROOT = Path(__file__).resolve().parent
if str(_EVAL_ROOT) not in sys.path:
sys.path.insert(0, str(_EVAL_ROOT))
def _iter_search_roots() -> list[Path]:
roots: list[Path] = []
seen: set[Path] = set()
for base in (_EVAL_ROOT, *_EVAL_ROOT.parents):
for candidate in (base, base.parent):
candidate = candidate.resolve()
if candidate not in seen:
seen.add(candidate)
roots.append(candidate)
return roots
def _find_nearby_dir(name: str) -> Path | None:
for root in _iter_search_roots():
candidate = root / name
if candidate.exists():
return candidate
return None
from openai import AsyncOpenAI
from render_check import render_check as _render_check
DEFAULT_MODELS = [
"openai/gpt-4o-mini",
"deepseek/deepseek-chat-v3.1",
"openai/gpt-5.4",
"qwen/qwen3-30b-a3b-instruct-2507",
"google/gemini-3-flash-preview"
]
DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
DEFAULT_TASK_SOURCES = ["multiwoz", "sgd", "esconv", "annomi"]
L1_DIMS = {
"L1-1": "JSON Parse Correctness",
"L1-2": "Schema Compliance",
"L1-3": "Reference Integrity",
"L1-4": "Required Fields Completeness",
"L1-5": "Value Format Correctness",
}
L2_DIMS = {
"D2-1": "Trigger Appropriateness",
"D2-2": "Component-Intent Alignment",
"D2-3": "Text-UI Grounding",
"D2-4": "Data Model Utilization",
"D2-5": "Action Completeness",
}
L3_DIMS = {
"U3-A": "Value-Add over Text",
"U3-B": "Conversational Naturalness",
"U3-C": "Cognitive Load",
}
SCENARIO_DEFS = {
"S1": "Emotional support & strategy branching (primarily esconv/annomi)",
"S2": "Behavior change & commitment facilitation (primarily annomi/esconv)",
"S3": "Slot filling & process advancement (primarily multiwoz/sgd)",
"S4": "Candidate result display & comparison decision (primarily multiwoz/sgd)",
"S5": "Transaction closure & failure recovery (primarily multiwoz/sgd)",
}
L2_RUBRIC_HINTS = {
"S1": {
"D2-1": "Good: user expresses ambivalence/emotion, model presents parallel options via selection UI; Bad: model has converged on advice yet still pops a selection card.",
"D2-2": "Good: emotion branching uses clearly selectable, submittable interactive components; Bad: only static Text wrapping text to mimic interaction.",
"D2-3": "Good: option labels strictly come from choices in text_response; Bad: UI invents options not mentioned in text.",
"D2-4": "Good: dataModelUpdate records current emotional state; Bad: no state management at all.",
"D2-5": "Good: interactive controls have action.name and context passes back user selection; Bad: actionable elements lack action.",
},
"S2": {
"D2-1": "Good: user expresses willingness to act, model triggers commitment/assessment UI; Bad: still in exploratory discussion yet pushes commitment card.",
"D2-2": "Good: uses scalar assessment or commitment-type interactive components; Bad: commitment scenario only has static display without interactive components.",
"D2-3": "Good: quantified ranges or commitment options match dimensions built in text; Bad: endpoints/options are fabricated.",
"D2-4": "Good: dataModelUpdate records commitment content and timing; Bad: commitment flow has no state record.",
"D2-5": "Good: submit action contains commitment payload in context; Bad: only 'submitted' with no concrete data passed back.",
},
"S3": {
"D2-1": "Good: triggers form UI when user needs to fill in information; Bad: collects slots via text Q&A without providing input components.",
"D2-2": "Good: uses editable input components to collect parameters; Bad: uses plain Text to display questions but gives no input entry.",
"D2-3": "Good: form fields match information requested in text; Bad: form includes extra fields not mentioned in text.",
"D2-4": "Good: dataModelUpdate records collected parameters; Bad: multi-turn collection but no state accumulation.",
"D2-5": "Good: submit action contains complete input values in context; Bad: cannot track input values after submission.",
},
"S4": {
"D2-1": "Good: triggers result display UI when search results exist; Bad: has results but doesn't trigger structured display.",
"D2-2": "Good: candidates displayed in structured, comparable, actionable UI; Bad: only fragmented text.",
"D2-3": "Good: displayed candidates match count and content described in text; Bad: text says 3 candidates but UI shows only 2.",
"D2-4": "Good: dataModelUpdate records search criteria and results; Bad: candidate data only in text with no structured record.",
"D2-5": "Good: selecting a candidate's interactive element passes back candidate ID or equivalent; Bad: selection leads to flow stall.",
},
"S5": {
"D2-1": "Good: triggers confirm/cancel UI during transaction closure; Bad: closure phase has no interactive components.",
"D2-2": "Good: confirmation page has clear confirm/cancel/modify actions; Bad: only text summary without executable operations.",
"D2-3": "Good: confirmation info matches booking details in text; Bad: confirmation page contradicts text.",
"D2-4": "Good: dataModelUpdate records booking status; Bad: no state update after confirmation.",
"D2-5": "Good: confirm action contains complete booking info in context; Bad: critical operation has no action.",
},
}
L3_RUBRIC_HINTS = {
"S1": {
"U3-A": "Good: selection card lets user click instead of manually describing emotional direction; Bad: only Text wrapping 'you could try A or B'.",
"U3-B": "Good: acknowledges user emotion first, then naturally introduces interaction; Bad: cold-starts UI push while user emotion is intense.",
"U3-C": "Good: only one core interaction per turn, 2-4 options; Bad: stacks multiple components or too many options at once.",
},
"S2": {
"U3-A": "Good: quantified/commitment interaction converts abstract intent into actionable steps; Bad: UI merely restates text content.",
"U3-B": "Good: uses supportive framing like 'let's assess your readiness' before pushing interaction; Bad: abruptly demands 'please rate yourself'.",
"U3-C": "Good: assess first, then commit — step by step; Bad: asks for assessment + plan + commitment all at once.",
},
"S3": {
"U3-A": "Good: form collection lets user fill in rather than Q&A one by one; Bad: form only displays known info without editable fields.",
"U3-B": "Good: 'to find the best option for you, I need a few details' naturally leads to form; Bad: blank form pops up without context.",
"U3-C": "Good: collects core info step by step; Bad: pops a 10+ field form all at once.",
},
"S4": {
"U3-A": "Good: structured display lets user compare and select; Bad: candidate info only listed as plain Text.",
"U3-B": "Good: 'based on your requirements, here are the options' naturally introduces results; Bad: jumps straight to results without transition.",
"U3-C": "Good: balanced candidate count and density, grouped when necessary; Bad: displays 20+ candidates at once without grouping.",
},
"S5": {
"U3-A": "Good: confirmation page lets user confirm/cancel in one click; Bad: confirmation page only shows text without action buttons.",
"U3-B": "Good: 'here is your booking summary, please confirm' naturally leads in; Bad: demands confirmation without transition.",
"U3-C": "Good: confirmation page focuses on key info and core actions; Bad: piles up extra options during confirmation.",
},
}
OUTPUT_SCHEMA_HINT = """You must output ONLY a JSON object (no markdown):
{
"text_response": "string",
"a2ui": [ ... ]
}
"""
A2UI_MINIMAL_GUIDE = """You are a conversational AI assistant. Reply with natural language text and optional A2UI messages.
Always output valid JSON:
{"text_response": "...", "a2ui": [...]}
# A2UI Protocol
Allowed message types (each message must contain exactly one action key):
- beginRendering: create/start rendering a surface
- surfaceUpdate: define/update the component tree on a surface
- dataModelUpdate: write/update data model values
- deleteSurface: remove a surface
Component item format:
{"id": "x", "component": {"TypeName": {...}}}
Use ONLY these component names:
- Button: Clickable action trigger. Usually include `action.name` and connect visible content via `child`.
- Card: Single-card container shell. Use `child` as the main root content of this card.
- Column: Vertical layout container. Arrange child component IDs in `children`.
- DateTimeInput: Date/time picker input. Main binding field is `value`.
- Divider: Visual separator line. Use `axis` to choose horizontal or vertical.
- FullScreenModal: Full-screen modal container. Wire `entryPointChild` and `contentChild`.
- Icon: Icon display component. Field `name` is `{literalString: "icon-name"}`, `style` is `line` or `filled`.
Valid icon names (use ONLY these): star, home, search, time, like, dislike, thumbs-up, thumbs-down,
success, tips, fire, lightning, protection, alarm, alarm-clock, calendar-thirty, stopwatch, hourglass-null,
arrow-left, arrow-right, arrow-circle-up, arrow-circle-down, arrow-circle-left, arrow-circle-right,
book, book-one, book-open, notes, copy, link, share, share-two, rss, history, refresh,
phone-telephone, mail-open, camera, pic-one, local-two, shopping-bag-one,
knife-fork, chef-hat-one, cook, bowl, pot, platte, goblet, tea-drink, avocado-one, cheese, refrigerator,
birthday-cake, leaves-two, sleep, abdominal, afferent,
smiling-face-with-squinting-eyes, grinning-face-with-tightly-closed-eyes-open-mouth,
anguished-face, disappointed-face, emotion-unhappy,
more, more-one, hamburger-button, all-application, setting-three, equalizer, application-effect,
preview-open, preview-close-one, left-c, right-c.
- Image: Image display component. Main field is `url`.
- Label: Plain text display component. Main field is `text`, optional `variant`.
- MarkdownView: Rich text/markdown display. Main field is `text`.
- PasswordKeypad: Secure keypad input. Provide `value.path` and submission `action`.
- Row: Horizontal layout container. Arrange child component IDs in `children`.
- SelectionList: Option selection list. Core fields are `selection` and `items`.
- Tabs: Tabbed content switcher. Define tab entries in `tabItems`.
- TickSlider: Discrete slider input. Core fields are `value` and `max`."""
A2UI_CONCISE_GUIDE = """A2UI Key Points (concise):
1) a2ui is a message array; each message object must contain exactly one action key:
beginRendering | surfaceUpdate | dataModelUpdate | deleteSurface
2) beginRendering:
{"beginRendering":{"surfaceId":"...","root":"...","styles":{"primaryColor":"#1976D2"}}}
3) surfaceUpdate:
{"surfaceUpdate":{"surfaceId":"...","components":[{"id":"c1","component":{"Label":{"text":{"literalString":"..."}}}}]}}
4) dataModelUpdate:
{"dataModelUpdate":{"surfaceId":"...","path":"/","contents":[{"key":"k","valueString":"v"}]}}
5) deleteSurface:
{"deleteSurface":{"surfaceId":"..."}}
6) component wrapper must have exactly one component type key. Common components:
Layout: Card, Column, Row
Display: Label, MarkdownView, Icon, Image, Divider
Input: SelectionList, SelectionWrap, TickSlider, DateTimeInput, Button
Other: Tabs, FullScreenModal, PasswordKeypad
7) Only return an empty array [] for no_ui_chat or when expected_pattern is none/delete; do not fabricate UI unrelated to the task.
"""
# Message protocol and key rules (aligned with data_generation_v2/rewrite_pipeline + repair_pipeline)
# to reduce STRUCT_INVALID_ACTION_KEY: a2ui must be an array of MESSAGES, not raw component items.
A2UI_MESSAGE_PROTOCOL_AND_RULES = """
## A2UI Message Protocol (CRITICAL)
The `a2ui` array is a list of **messages**. Each element must be a message object with **exactly ONE** action key (do NOT put raw component items like {"id": "...", "component": {...}} at the top level).
- `beginRendering`: {surfaceId: string, root: string} — create surface; root is the ID of the top-level component.
- `surfaceUpdate`: {surfaceId: string, components: array} — define the component tree. Each item in components: {"id": "unique_id", "component": {"TypeName": {...props}}}.
- `dataModelUpdate`: {surfaceId: string, path: "/", contents: array} — write data. Each entry: {key: "name", valueString: "text"} or valueNumber / valueBoolean.
- `deleteSurface`: {surfaceId: string} — remove surface.
Correct pattern: use ONE surfaceId; include beginRendering (root pointing to top component), then surfaceUpdate (with components array), then dataModelUpdate if needed. Components go **inside** surfaceUpdate.components, not directly in a2ui.
## Key Rules
1. Every surfaceUpdate must have a matching beginRendering with root pointing to the top-level component ID.
2. All component IDs referenced as children must exist in the same surfaceUpdate.components.
3. Use only ONE surface (one surfaceId) per reply. Do not create multiple surfaces.
4. If using SelectionList/SelectionWrap, selection must include "literalArray": [] alongside "path"; do not add the selection key to dataModelUpdate.
5. If using interactive components (SelectionList, TickSlider, DateTimeInput), include a Button for confirm/submit.
6. dataModelUpdate valueString must not be empty "".
"""
_A2UI_DEMO_ROOT = Path(
os.environ.get("A2UI_DEMO_ROOT")
or ((_EVAL_ROOT / "vendor" / "a2ui_demo") if (_EVAL_ROOT / "vendor" / "a2ui_demo").exists() else _find_nearby_dir("a2ui_demo"))
)
_COMPONENT_SCHEMA_DIR = _A2UI_DEMO_ROOT / "resources" / "components" / "schemas"
_COMPONENT_CATALOG_CACHE: tuple[str, str] | None = None
VALUE_FORMAT_CODES = {
"STRUCT_WRONG_TYPE",
"STRUCT_INVALID_ENUM",
"STRUCT_MULTIPLE_VALUE_TYPES",
"STRUCT_NO_VALUE_TYPE",
}
@dataclass
class TaskSample:
task_id: str
source: str
scenario_id: str
intent_type: str
difficulty_level: str
task_description: str
user_message: str
dialogue_context: list[dict[str, str]]
expected_pattern: str
gt_a2ui: list[dict] = field(default_factory=list) # legacy reference, not used directly in L2/L3 prompts
gt_assistant_text: str = ""
completion_criteria: list[str] = field(default_factory=list)
raw_context: dict[str, Any] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
episode_turns: list[dict[str, Any]] = field(default_factory=list)
subgoals: list[str] = field(default_factory=list)
domains: list[str] = field(default_factory=list)
def _component_name_from_schema_file(path: Path) -> str:
stem = path.stem.removesuffix("_schema")
return "".join(part[:1].upper() + part[1:] for part in stem.split("_") if part)
def _format_component_prop_list(props: list[str]) -> str:
if not props:
return "(none)"
return ", ".join(props[:6])
def _load_component_catalog_and_summary() -> tuple[str, str]:
global _COMPONENT_CATALOG_CACHE
if _COMPONENT_CATALOG_CACHE is not None:
return _COMPONENT_CATALOG_CACHE
if not _COMPONENT_SCHEMA_DIR.exists():
fallback = (
"## Available Components\n"
"Label, MarkdownView, Icon, Image, Button, SelectionList, SelectionWrap, "
"TickSlider, DateTimeInput, Card, Column, Row, Tabs, Divider, FullScreenModal, "
"PasswordKeypad"
)
_COMPONENT_CATALOG_CACHE = (fallback, fallback)
return _COMPONENT_CATALOG_CACHE
names: list[str] = []
summary_lines: list[str] = []
for path in sorted(_COMPONENT_SCHEMA_DIR.glob("*_schema.json")):
if path.name in {"catalog_schema.json", "surface_update_schema.json"}:
continue
try:
obj = json.loads(path.read_text(encoding="utf-8"))
except Exception:
continue
comp_name = str(obj.get("title") or _component_name_from_schema_file(path))
desc = " ".join(str(obj.get("description", "")).strip().split()) or "A2UI component."
props = list((obj.get("properties") or {}).keys())
names.append(comp_name)
summary_lines.append(
f"- {comp_name}: {desc} Key props: {_format_component_prop_list(props)}"
)
catalog = "## Available Components\n" + ", ".join(names)
summary = (
catalog
+ "\n\n## Component Quick Reference\n"
+ "\n".join(summary_lines)
+ "\n\nUse any component from this catalog if it helps the task and remains schema-valid."
)
_COMPONENT_CATALOG_CACHE = (catalog, summary)
return _COMPONENT_CATALOG_CACHE
def _build_generation_guide(prompt_mode: str) -> str:
component_catalog, component_summary = _load_component_catalog_and_summary()
if prompt_mode in ("minimal", "sft"):
return A2UI_MINIMAL_GUIDE
# full mode: load from pre-built txt file
full_prompt_path = Path(__file__).parent / "system_prompt_full.txt"
if full_prompt_path.exists():
return full_prompt_path.read_text(encoding="utf-8")
# fallback: dynamic build (legacy)
try:
a2ui_demo_parent = _A2UI_DEMO_ROOT.parent
if str(a2ui_demo_parent) not in sys.path:
sys.path.insert(0, str(a2ui_demo_parent))
from a2ui_demo.server.a2ui_prompt import build_component_catalog # type: ignore
from a2ui_demo.server.a2ui_schema_registry import build_a2ui_message_schema_text # type: ignore
component_catalog = build_component_catalog()
a2ui_schema = build_a2ui_message_schema_text()
return (
"A2UI Key Points (full version, from a2ui_demo):\n"
"You must strictly follow schema field names; do not use onClick/onPress/content or other undefined names. "
"Common components: Label, MarkdownView, Button, SelectionList, SelectionWrap, "
"TickSlider, DateTimeInput, Card, Column, Row, Divider, Icon, Image, Tabs.\n\n"
f"{A2UI_MESSAGE_PROTOCOL_AND_RULES}\n\n"
f"{component_catalog}\n\n"
"## A2UI JSON Schema\n"
f"{a2ui_schema}\n"
)
except Exception as e:
print(f"[warn] failed to load full A2UI prompt, fallback to concise: {e}")
return (
A2UI_CONCISE_GUIDE + "\n\n"
+ A2UI_MESSAGE_PROTOCOL_AND_RULES + "\n\n"
+ component_catalog
)
def _load_env_file(path: Path) -> None:
if not path.exists():
return
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
def _setup_env() -> None:
_load_env_file(_EVAL_ROOT / ".env")
for root in _iter_search_roots()[:4]:
_load_env_file(root / ".env")
def _ensure_a2ui_lint_import():
if not (_A2UI_DEMO_ROOT / "server" / "a2ui_lint" / "__init__.py").exists():
raise RuntimeError(
"Missing bundled a2ui_demo dependency. Restore vendor/a2ui_demo or set A2UI_DEMO_ROOT to a compatible checkout."
)
a2ui_demo_parent = _A2UI_DEMO_ROOT.parent
if str(a2ui_demo_parent) not in sys.path:
sys.path.insert(0, str(a2ui_demo_parent))
from a2ui_demo.server.a2ui_lint import validate # type: ignore
return validate
def _extract_json_object(text: str) -> dict[str, Any] | None:
def _is_model_output_dict(obj: dict[str, Any]) -> bool:
return any(key in obj for key in ("text_response", "a2ui", "a2ui_messages"))
def _model_output_priority(obj: dict[str, Any]) -> tuple[int, int, int]:
return (
1 if ("a2ui" in obj or "a2ui_messages" in obj) else 0,
1 if "text_response" in obj else 0,
len(obj),
)
content = text.strip()
if content.startswith("```"):
content = content.strip("`")
if content.startswith("json"):
content = content[4:].strip()
try:
obj = json.loads(content)
if isinstance(obj, dict):
return obj
except Exception:
pass
# Find ALL valid JSON objects by brace matching.
# For model outputs, strongly prefer objects that contain `a2ui` /
# `a2ui_messages` / `text_response` instead of arbitrarily selecting an
# inner component dict with more top-level keys.
candidates: list[tuple[int, dict[str, Any]]] = []
start = content.find("{")
while start >= 0:
depth = 0
in_str = False
esc = False
for i in range(start, len(content)):
ch = content[i]
if in_str:
if esc:
esc = False
elif ch == "\\":
esc = True
elif ch == '"':
in_str = False
continue
if ch == '"':
in_str = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
cand = content[start : i + 1]
try:
obj = json.loads(cand)
if isinstance(obj, dict):
candidates.append((start, obj))
except Exception:
pass
break
start = content.find("{", start + 1)
if not candidates:
return None
output_candidates = [(start, obj) for start, obj in candidates if _is_model_output_dict(obj)]
if output_candidates:
# Prefer objects that actually look like the target output schema,
# then prefer earlier matches in the text.
return max(
output_candidates,
key=lambda item: (*_model_output_priority(item[1]), -item[0]),
)[1]
# Fallback for judge outputs and other generic JSON payloads: preserve the
# previous "outer object" heuristic by taking the dict with the most keys.
return max(candidates, key=lambda item: len(item[1]))[1]
def _coerce_output(raw_output: str) -> tuple[str, list[dict], bool, str]:
"""Return (assistant_text, a2ui_messages, parse_error, parse_note)."""
obj = _extract_json_object(raw_output)
if obj is None:
return "", [], True, "json_object_parse_failed"
text = str(obj.get("text_response", "")).strip()
a2ui = obj.get("a2ui_messages", obj.get("a2ui", []))
if not isinstance(a2ui, list):
return text, [], True, "a2ui_messages_not_list"
fixed_messages: list[dict] = []
for m in a2ui:
if isinstance(m, dict):
fixed_messages.append(m)
else:
return text, [], True, "a2ui_message_not_dict"
return text, fixed_messages, False, ""
def _infer_scenario_id(task: dict) -> str:
sid = str(task.get("metadata", {}).get("scenario_id", "")).strip()
if sid in SCENARIO_DEFS:
return sid
source = task.get("source", "")
intent = task.get("intent_type", "")
if source in ("multiwoz", "sgd"):
if intent == "information_grounding":
return "S4"
if intent == "booking_confirmation":
return "S5"
return "S3"
if intent in ("continuous_self_assessment", "action_commitment"):
return "S2"
return "S1"
def load_task_samples(task_dir: Path) -> list[TaskSample]:
tasks: list[TaskSample] = []
merged_path = task_dir / "all_tasks.json"
if merged_path.exists():
datasets = [json.loads(merged_path.read_text(encoding="utf-8"))]
else:
datasets = [
json.loads(path.read_text(encoding="utf-8"))
for path in sorted(task_dir.glob("*_tasks.json"))
]
for data in datasets:
for t in data:
source = t.get("source", "")
ctx = t.get("context", {}) or {}
tasks.append(
TaskSample(
task_id=t.get("task_id", ""),
source=source,
scenario_id=_infer_scenario_id(t),
intent_type=t.get("intent_type", ""),
difficulty_level=t.get("difficulty_level", "atomic"),
task_description=t.get("task_description", ""),
user_message=ctx.get("user_message", ""),
dialogue_context=ctx.get("dialogue_context", [])[-6:],
expected_pattern=t.get("expected_pattern", ""),
gt_a2ui=t.get("gt_a2ui", []),
gt_assistant_text=t.get("gt_assistant_text", ""),
completion_criteria=t.get("completion_criteria", []),
raw_context=ctx,
metadata=t.get("metadata", {}) or {},
episode_turns=ctx.get("episode_turns", []) or [],
subgoals=ctx.get("subgoals", []) or [],
domains=ctx.get("domains", []) or [],
)
)
return tasks
def sample_balanced(tasks: list[TaskSample], max_per_scenario: int, seed: int) -> list[TaskSample]:
if max_per_scenario <= 0:
return sorted(tasks, key=lambda x: (x.scenario_id, x.source, x.task_id))
random.seed(seed)
by_s: dict[str, list[TaskSample]] = defaultdict(list)
for t in tasks:
by_s[t.scenario_id].append(t)
sampled: list[TaskSample] = []
for sid in sorted(SCENARIO_DEFS.keys()):
bucket = by_s.get(sid, [])
random.shuffle(bucket)
sampled.extend(bucket[:max_per_scenario])
# If some scenarios are sparse, keep deterministic order.
sampled.sort(key=lambda x: (x.scenario_id, x.source, x.task_id))
return sampled
def _format_context(dialogue_context: list[dict[str, str]]) -> str:
if not dialogue_context:
return "(conversation start)"
lines = []
for m in dialogue_context[-6:]:
role = str(m.get("role", "user")).title()
content = str(m.get("content", "")).strip()
lines.append(f"{role}: {content}")
return "\n".join(lines)
def _format_bullets(items: list[str]) -> str:
cleaned = [str(x).strip() for x in items if str(x).strip()]
if not cleaned:
return "(none)"
return "\n".join(f"- {x}" for x in cleaned)
def _build_task_addendum(task: TaskSample, step_idx: int | None = None, total_steps: int | None = None) -> str:
blocks: list[str] = []
if task.difficulty_level == "depth" and task.episode_turns:
label = ""
if step_idx is not None and total_steps is not None:
label = f" (current turn {step_idx + 1}/{total_steps})"
blocks.append(
"[Replay Task]\n"
f"This is a multi-turn sequential task{label}. Generate only the current turn's assistant output, "
"and stay consistent with your previous replies while continuing to advance the overall task."
)
deps = task.raw_context.get("cross_turn_dependencies", []) or []
if deps:
blocks.append("[Cross-turn Dependencies]\n" + _format_bullets(deps))
pre_summary = str(task.raw_context.get("pre_episode_summary", "")).strip()
if pre_summary:
blocks.append("[Pre-Episode Summary]\n" + pre_summary)
pre_state = task.raw_context.get("pre_episode_state", {}) or {}
state_lines: list[str] = []
if pre_state.get("domain"):
state_lines.append(f"- domain: {pre_state['domain']}")
active_surfaces = pre_state.get("active_surfaces_at_start", []) or []
if active_surfaces:
state_lines.append(f"- active_surfaces_at_start: {active_surfaces}")
if pre_state.get("search_context"):
state_lines.append(f"- search_context: {pre_state['search_context']}")
known_facts = task.raw_context.get("known_facts", []) or []
for fact in known_facts[:6]:
state_lines.append(f"- known_fact: {fact}")
if task.raw_context.get("stage_at_start"):
state_lines.append(f"- stage_at_start: {task.raw_context['stage_at_start']}")
if state_lines:
blocks.append("[Known State At Start]\n" + "\n".join(state_lines))
if task.difficulty_level == "width":
if task.domains:
blocks.append("[Composite Domains]\n" + ", ".join(task.domains))
if task.subgoals:
blocks.append("[Composite Subgoals]\n" + _format_bullets(task.subgoals))
if task.intent_type == "no_ui_chat" or task.expected_pattern == "none":
blocks.append(
"[Width Requirement]\n"
"This is a single-turn composite request, but the best strategy is to use one clear text-only reply "
"to coordinate all subgoals; do not force-generate A2UI just to appear complex."
)
else:
blocks.append(
"[Width Requirement]\n"
"This is a single-turn composite coordination task. You should try to cover all subgoals in one reply; "
"if necessary, you may output multiple surfaces or multiple A2UI updates in the same reply."
)
if task.completion_criteria:
blocks.append("[Success Criteria]\n" + _format_bullets(task.completion_criteria))
return "\n\n".join(blocks)
def _build_component_schema_context() -> str:
_, summary = _load_component_catalog_and_summary()
return summary
def build_generation_messages(
task: TaskSample,
generation_guide: str,
*,
dialogue_context: list[dict[str, str]] | None = None,
user_message: str | None = None,
expected_pattern: str | None = None,
intent_type: str | None = None,
step_idx: int | None = None,
total_steps: int | None = None,
) -> list[dict[str, str]]:
dialogue_context = dialogue_context if dialogue_context is not None else task.dialogue_context
user_message = user_message if user_message is not None else task.user_message
expected_pattern = expected_pattern if expected_pattern is not None else task.expected_pattern
intent_type = intent_type if intent_type is not None else task.intent_type
requirement_line = (
"Based on the task description and dialogue context, generate a natural language reply and appropriate A2UI interactive components. "
"You must output a strict JSON object in the format "
'{"text_response":"...","a2ui":[...]}.'
)
system = (
"You are an A2UI task assistant. You must output both a natural language reply and A2UI messages.\n"
+ generation_guide
+ "\n"
+ requirement_line
+ "\n"
+ OUTPUT_SCHEMA_HINT
)
addendum = _build_task_addendum(task, step_idx=step_idx, total_steps=total_steps)
user = (
f"[Scenario] {task.scenario_id}: {SCENARIO_DEFS[task.scenario_id]}\n"
f"[Intent] {intent_type}\n"
f"[Task] {task.task_description}\n"
+ (addendum + "\n\n" if addendum else "")
+ f"[Dialogue Context]\n{_format_context(dialogue_context)}\n\n"
+ f"[Current User Message]\n{user_message}\n\n"
"Generate the best assistant output for the current turn."
)
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
def _history_turn_to_plain_text(role: str, content: str) -> str:
if role != "assistant":
return content
try:
parsed = json.loads(content)
except (json.JSONDecodeError, TypeError):
return content
if not isinstance(parsed, dict):
return content
text_response = parsed.get("text_response")
if isinstance(text_response, str) and text_response.strip():
return text_response.strip()
return content
def build_sft_messages(
task: TaskSample,
system_prompt: str,
) -> list[dict[str, str]]:
"""Build prompt messages as plain dialogue history with no historical A2UI."""
messages: list[dict[str, str]] = [{"role": "system", "content": system_prompt}]
# Replay dialogue_context as multi-turn conversation
for turn in task.dialogue_context:
role = turn.get("role", "user")
content = str(turn.get("content", "")).strip()
if not content:
continue
content = _history_turn_to_plain_text(role, content)
messages.append({"role": role, "content": content})
# Current user message
user_message = task.user_message.strip()
if user_message:
messages.append({"role": "user", "content": user_message})
return messages
def evaluate_l1_scores(
parse_error: bool,
messages: list[dict],
validate_fn,
) -> tuple[dict[str, float], bool, dict[str, Any]]:
if parse_error:
scores = {k: 0.0 for k in L1_DIMS}
return scores, False, {
"error_count": 1,
"warning_count": 0,
"diagnostic_codes": ["JSON_PARSE_FAILED"],
"warning_codes": [],
"policy_error_codes": [],
}
try:
validation = validate_fn(messages, levels={1, 2, 3, 4})
except Exception:
scores = {k: 0.0 for k in L1_DIMS}
return scores, False, {
"error_count": 1,
"warning_count": 0,
"diagnostic_codes": ["VALIDATOR_CRASH"],
"warning_codes": [],
"policy_error_codes": [],
}
error_codes = [d.code.value for d in validation.errors]
warning_codes = [d.code.value for d in validation.warnings]
all_codes = error_codes + warning_codes
error_count = len(validation.errors)
warning_count = len(validation.warnings)
# Map diagnostic codes to L1 dimensions by prefix:
# L1-2 Schema Compliance: STRUCT_*
# L1-3 Reference Integrity: REF_*
# L1-4 Required Fields / Data: DATA_*
# L1-5 Semantic Lint: LINT_*
dim_issue_count: dict[str, int] = {"L1-2": 0, "L1-3": 0, "L1-4": 0, "L1-5": 0}
dim_has_error: dict[str, bool] = {"L1-2": False, "L1-3": False, "L1-4": False, "L1-5": False}
_prefix_to_dim = {
"STRUCT_": "L1-2",
"REF_": "L1-3",
"DATA_": "L1-4",
"LINT_": "L1-5",
}
for d in validation.errors:
code = d.code.value
for prefix, dim in _prefix_to_dim.items():
if code.startswith(prefix):
dim_issue_count[dim] += 1
dim_has_error[dim] = True
break
for d in validation.warnings:
code = d.code.value
for prefix, dim in _prefix_to_dim.items():
if code.startswith(prefix):
dim_issue_count[dim] += 1
break
# Overall L1 pass = no errors at all
l1_pass = (error_count == 0)
# Per-dimension scoring:
# - If L1 fails (any error anywhere) => ALL dimensions get 0.
# Rationale: when output has structural errors, the validator may not
# reach deeper checks (REF/DATA/LINT), so "no error reported" in those
# dims is unreliable — it means "not checked", not "passed".
# - If L1 passes (no errors) => per-dim score = 5 - warning_count, floor 0
l1 = {"L1-1": 5.0} # JSON parse OK
if not l1_pass:
for dim in ["L1-2", "L1-3", "L1-4", "L1-5"]:
l1[dim] = 0.0
else:
for dim in ["L1-2", "L1-3", "L1-4", "L1-5"]:
l1[dim] = float(max(0, 5 - dim_issue_count[dim]))
codes = error_codes
details = {
"is_valid": bool(validation.is_valid),
"error_count": error_count,
"warning_count": warning_count,
"diagnostic_codes": codes,
"warning_codes": warning_codes,
"policy_error_codes": [],
}
return l1, l1_pass, details
def _a2ui_summary(messages: list[dict]) -> str:
if not messages:
return "(no A2UI messages)"
lines = []
for msg in messages:
if "surfaceUpdate" in msg:
su = msg["surfaceUpdate"]
sid = su.get("surfaceId", "?")
c_types = []
for c in su.get("components", []):
c_types.extend(list(c.get("component", {}).keys()))
lines.append(f"surfaceUpdate({sid}): {', '.join(c_types)}")
elif "dataModelUpdate" in msg:
dm = msg["dataModelUpdate"]
sid = dm.get("surfaceId", "?")
keys = [x.get("key", "") for x in dm.get("contents", [])]
lines.append(f"dataModelUpdate({sid}): keys={keys}")
elif "beginRendering" in msg:
lines.append(f"beginRendering({msg['beginRendering'].get('surfaceId', '?')})")
elif "deleteSurface" in msg:
lines.append(f"deleteSurface({msg['deleteSurface'].get('surfaceId', '?')})")
return "\n".join(lines)[:2000]
def _truncate_json_for_prompt(payload: Any, *, max_chars: int) -> str:
"""Serialize JSON for judge prompts with a simple head-tail truncation."""
raw = json.dumps(payload, ensure_ascii=False, indent=1)
if len(raw) <= max_chars:
return raw
head = max_chars // 2
tail = max_chars - head
return raw[:head] + "\n... (truncated) ...\n" + raw[-tail:]
def _build_l2_rubric_hints(scenario_id: str) -> str:
hints = L2_RUBRIC_HINTS[scenario_id]
lines = []
for k in ["D2-1", "D2-2", "D2-3", "D2-4", "D2-5"]:
lines.append(f"- {k} ({L2_DIMS[k]}): {hints[k]}")
return "\n".join(lines)
def _build_l3_rubric_hints(scenario_id: str) -> str:
hints = L3_RUBRIC_HINTS[scenario_id]
lines = []
for k in ["U3-A", "U3-B", "U3-C"]:
lines.append(f"- {k} ({L3_DIMS[k]}): {hints[k]}")
return "\n".join(lines)
# Load prompts from files
_PROMPTS_DIR = Path(__file__).resolve().parent / "prompts"
_L2_PROMPT_TEMPLATE: str | None = None
_L3_PROMPT_TEMPLATE: str | None = None
def _get_l2_prompt_template() -> str:
global _L2_PROMPT_TEMPLATE
if _L2_PROMPT_TEMPLATE is None:
_L2_PROMPT_TEMPLATE = (_PROMPTS_DIR / "l2_judge.txt").read_text(encoding="utf-8")
return _L2_PROMPT_TEMPLATE
def _get_l3_prompt_template() -> str:
global _L3_PROMPT_TEMPLATE
if _L3_PROMPT_TEMPLATE is None:
_L3_PROMPT_TEMPLATE = (_PROMPTS_DIR / "l3_judge.txt").read_text(encoding="utf-8")
return _L3_PROMPT_TEMPLATE
def build_l2_judge_messages(
task: TaskSample,
text_response: str,
a2ui_messages: list[dict],
*,
dialogue_context: list[dict[str, str]] | None = None,
user_message: str | None = None,
) -> list[dict[str, str]]:
"""Build L2 judge prompt with rich principle-grounded rubrics."""
dialogue_context = dialogue_context if dialogue_context is not None else task.dialogue_context
user_message = user_message if user_message is not None else task.user_message
a2ui_raw = _truncate_json_for_prompt(a2ui_messages, max_chars=3000)
addendum = _build_task_addendum(task)
prompt = _get_l2_prompt_template().format(
rubric_hints=_build_l2_rubric_hints(task.scenario_id),
scenario_id=task.scenario_id,
scenario_def=SCENARIO_DEFS[task.scenario_id],
task_description=(task.task_description + ("\n\n" + addendum if addendum else "")),
dialogue_context=_format_context(dialogue_context),
user_message=user_message,
text_response=text_response,
a2ui_summary=_a2ui_summary(a2ui_messages),
a2ui_raw_json=a2ui_raw,
component_schema_context=_build_component_schema_context(),
)
return [{"role": "user", "content": prompt}]
def build_l3_judge_messages(
task: TaskSample,
text_response: str,
a2ui_messages: list[dict],
*,
dialogue_context: list[dict[str, str]] | None = None,
user_message: str | None = None,
) -> list[dict[str, str]]:
"""Build L3 judge prompt with rich principle-grounded rubrics."""
dialogue_context = dialogue_context if dialogue_context is not None else task.dialogue_context
user_message = user_message if user_message is not None else task.user_message
addendum = _build_task_addendum(task)
model_output_raw = _truncate_json_for_prompt(
{
"text_response": text_response,
"a2ui": a2ui_messages,
},
max_chars=8000,
)
prompt = _get_l3_prompt_template().format(
rubric_hints=_build_l3_rubric_hints(task.scenario_id),
scenario_id=task.scenario_id,
scenario_def=SCENARIO_DEFS[task.scenario_id],
task_description=(task.task_description + ("\n\n" + addendum if addendum else "")),
dialogue_context=_format_context(dialogue_context),
user_message=user_message,
text_response=text_response,
a2ui_summary=_a2ui_summary(a2ui_messages),
model_output_raw_json=model_output_raw,
component_schema_context=_build_component_schema_context(),
)
return [{"role": "user", "content": prompt}]
def _strict_dim_scores(raw: Any, dims: dict[str, str]) -> tuple[dict[str, float], bool, str]:
"""Strict judge parser: no fallback score and no missing-dimension tolerance."""
zeros = {did: 0.0 for did in dims}
if not isinstance(raw, dict):
return zeros, False, "judge_invalid_format"
out: dict[str, float] = {}
for did in dims:
if did not in raw:
return zeros, False, f"judge_missing_dim:{did}"
cell = raw[did]
if not isinstance(cell, dict) or "score" not in cell:
return zeros, False, f"judge_missing_score:{did}"
try:
score_f = float(cell["score"])
except Exception:
return zeros, False, f"judge_non_numeric_score:{did}"
if score_f < 0.0 or score_f > 5.0:
return zeros, False, f"judge_score_out_of_range:{did}"
out[did] = score_f
return out, True, ""
async def _chat_completion_json(
client: AsyncOpenAI,
model: str,
messages: list[dict[str, str]],
temperature: float = 0.7,
max_tokens: int = 2200,
retries: int = 3,