@salesforce/afv-skills 1.67.0 → 1.68.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -5
- package/package.json +1 -1
- package/skills/agentforce-offline-experimentation-run/SKILL.md +14 -22
- package/skills/agentforce-offline-experimentation-run/scripts/bt_compare.py +81 -0
- package/skills/agentforce-offline-experimentation-run/scripts/bt_fetch.py +358 -0
- package/skills/agentforce-offline-experimentation-run/scripts/bt_patch_agent.py +110 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/__init__.py +34 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentpatch.py +483 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentsrc.py +565 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/cache.py +95 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/compare.py +617 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/fetch.py +1012 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/judge.py +148 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/messages.py +92 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/patch.py +327 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/redact.py +40 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/results.py +288 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/scoring.py +340 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/sfap.py +238 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/splits.py +136 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/stdm.py +98 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/tokens.py +173 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/units.py +199 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/versions.py +194 -0
- package/skills/agentforce-offline-experimentation-run/scripts/build_report.py +366 -378
- package/skills/agentforce-offline-experimentation-run/scripts/resolve-token.sh +14 -4
- package/skills/agentforce-offline-experimentation-run/scripts/sfap_replay.py +472 -796
- package/skills/dx-lifecycle-center/SKILL.md +5 -2
- package/skills/dx-lifecycle-center/assets/widgets/connect-connected/connect-connected.json +65 -0
- package/skills/dx-lifecycle-center/assets/widgets/connect-connected/schema.json +32 -0
- package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/connect-sign-in.json +57 -0
- package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/schema.json +21 -0
- package/skills/dx-lifecycle-center/examples/workflows.md +20 -6
- package/skills/dx-lifecycle-center/references/connect-org.md +21 -2
- package/skills/dx-lifecycle-center/references/deploy.md +2 -2
- package/skills/dx-lifecycle-center/references/list-orgs.md +1 -1
- package/skills/dx-lifecycle-center/scripts/card-policy.jq +71 -0
- package/skills/dx-lifecycle-center/scripts/fill-widget.jq +29 -0
- package/skills/dx-lifecycle-center/scripts/widget.sh +28 -0
- package/skills/dx-org-devhub-configure/SKILL.md +85 -84
- package/skills/dx-org-devhub-configure/examples/status-output.md +25 -2
- package/skills/dx-org-devhub-configure/scripts/devhub.sh +265 -140
- package/skills/dx-org-devhub-configure/scripts/test-devhub.sh +100 -15
- package/skills/dx-packaging-move-to-2gp-migrate/SKILL.md +89 -0
- package/skills/dx-packaging-move-to-2gp-migrate/references/api-operations.md +164 -0
- package/skills/dx-scratch-def-configure/SKILL.md +163 -0
- package/skills/dx-scratch-def-configure/examples/confirm-card.md +53 -0
- package/skills/dx-scratch-def-configure/examples/report-recipes.md +100 -0
- package/skills/dx-scratch-def-configure/references/features.json +3270 -0
- package/skills/dx-scratch-def-configure/references/project-scratch-def.schema.json +1841 -0
- package/skills/dx-scratch-def-configure/references/settings-map.json +682 -0
- package/skills/dx-scratch-def-configure/scripts/lib_scratch_def.py +1306 -0
- package/skills/dx-scratch-def-configure/scripts/requirements.txt +3 -0
- package/skills/dx-scratch-def-configure/scripts/scrape_config_values.py +848 -0
- package/skills/education-cloud-domain-configure/SKILL.md +1 -1
- package/skills/education-cloud-multi-campus-configure/SKILL.md +1 -1
- package/skills/education-cloud-student-recruitment-agent-configure/SKILL.md +1 -1
- package/skills/experience-design-validate/SKILL.md +1 -1
- package/skills/experience-search-coordinate/SKILL.md +18 -12
- package/skills/experience-search-coordinate/references/content-route.md +79 -1
- package/skills/field-service-data-capture-migrate/SKILL.md +1 -1
- package/skills/platform-apex-generate/SKILL.md +14 -11
- package/skills/platform-apex-test-generate/SKILL.md +2 -1
- package/skills/platform-datamask-run/SKILL.md +52 -71
- package/skills/platform-datamask-run/references/api-surface.md +10 -4
- package/skills/platform-datamask-run/references/policy-authoring.md +56 -7
- package/skills/platform-datamask-run/references/run-and-abort.md +17 -10
- package/skills/platform-datamask-run/scripts/poll-job.sh +15 -25
- package/skills/platform-dataspace-access-configure/SKILL.md +19 -1
- package/skills/platform-metadata-discovery-get/SKILL.md +185 -0
- package/skills/platform-metadata-discovery-get/references/api-map.md +25 -0
- package/skills/platform-metadata-discovery-get/references/h360-routes.md +30 -0
- package/skills/platform-metadata-discovery-get/references/scoped-soql.md +52 -0
- package/skills/platform-metadata-grounding-get/SKILL.md +170 -0
- package/skills/platform-policy-rule-generate/SKILL.md +84 -232
- package/skills/platform-policy-rule-generate/references/condition-model.md +47 -0
- package/skills/platform-policy-rule-generate/references/condition-templates.md +89 -0
- package/skills/platform-policy-rule-generate/references/conjunction-compiler.md +25 -0
- package/skills/platform-policy-rule-generate/references/conjunction-expression.md +22 -0
- package/skills/platform-policy-rule-generate/references/consumer-all.md +9 -0
- package/skills/platform-policy-rule-generate/references/consumer-datacloud-ui.md +91 -0
- package/skills/platform-policy-rule-generate/references/deploy-errors.md +24 -36
- package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitionSets/ExistsJoinRLS.policyRuleDefinitionSet +6 -0
- package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitions/ExistsJoinRLS_Rule_1.policyRuleDefinition +91 -0
- package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitionSets/FieldReadDeny.policyRuleDefinitionSet +6 -0
- package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitions/FieldReadDeny_Rule_1.policyRuleDefinition +72 -0
- package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitionSets/LastCharactersFieldMasking.policyRuleDefinitionSet +6 -0
- package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitions/LastCharactersFieldMasking_Rule_1.policyRuleDefinition +77 -0
- package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitionSets/PrincipalFieldMatchRLS.policyRuleDefinitionSet +6 -0
- package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitions/PrincipalFieldMatchRLS_Rule_1.policyRuleDefinition +77 -0
- package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitionSets/TaggedObjectAccess.policyRuleDefinitionSet +4 -0
- package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitions/TaggedObjectAccess_Rule_1.policyRuleDefinition +65 -0
- package/skills/platform-policy-rule-generate/references/headless-transport.md +87 -0
- package/skills/platform-policy-rule-generate/references/policy-schema-full.md +17 -10
- package/skills/platform-policy-rule-generate/references/policy-shapes.md +74 -0
- package/skills/platform-policy-rule-generate/references/principal-expression.md +31 -0
- package/skills/platform-policy-rule-generate/references/reference-resolution.md +51 -0
- package/skills/platform-policy-rule-generate/references/resource-expression.md +48 -0
- package/skills/platform-policy-rule-generate/references/runtime-context.md +9 -0
- package/skills/platform-policy-rule-generate/references/templates-access.md +11 -9
- package/skills/platform-policy-rule-generate/references/templates-advanced.md +4 -2
- package/skills/platform-policy-rule-generate/references/templates-record.md +37 -22
- package/skills/platform-policy-rule-generate/references/templates-transform.md +10 -8
- package/skills/platform-policy-rule-generate/references/templates.md +2 -2
- package/skills/platform-policy-rule-generate/references/ui-compatibility.md +35 -17
- package/skills/platform-report-generate/SKILL.md +9 -4
- package/skills/platform-report-generate/references/chart-types.md +26 -2
- package/skills/platform-report-generate/references/column-names.md +43 -0
- package/skills/platform-report-generate/references/errors-and-troubleshooting.md +1 -1
- package/skills/platform-report-generate/references/verification-checklist.md +10 -2
- package/skills/service-digital-engagement-channel-configure/SKILL.md +7 -5
- package/skills/service-digital-engagement-channel-configure/assets/messaging_channel_template.xml +33 -0
- package/skills/service-digital-engagement-channel-configure/references/channel_settings.md +110 -14
|
@@ -1,88 +1,53 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
"""Self-contained HTML report for one replay run.
|
|
3
|
+
|
|
4
|
+
python3 scripts/build_report.py <run dir | results.jsonl> [report.html]
|
|
5
|
+
|
|
6
|
+
Reads results.jsonl, plus units.jsonl (transcripts, offered tools) and summary.json (run
|
|
7
|
+
config) when they sit next to it. Writes <run dir>/report.html by default. Every value is
|
|
8
|
+
HTML-escaped and redacted; the report shows user utterances, so share it like the run dir.
|
|
5
9
|
|
|
6
|
-
|
|
7
|
-
|
|
10
|
+
Scorecards follow the step kinds: router decisions (topic = the go_to_* call), sub-agent
|
|
11
|
+
tool calls, sub-agent text replies, and overall agreement (mean unit score: 1 match,
|
|
12
|
+
0.5 right tools with different arguments, 0 otherwise) next to the strict score. Infra
|
|
13
|
+
errors are counted on their own card and never scored.
|
|
8
14
|
"""
|
|
9
|
-
|
|
10
|
-
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
import html as _html
|
|
11
20
|
from collections import Counter
|
|
12
21
|
|
|
13
|
-
|
|
14
|
-
# Helpers
|
|
15
|
-
# ---------------------------------------------------------------------------
|
|
22
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
16
23
|
|
|
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
|
-
return False
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def _bucket(d, name_hit):
|
|
48
|
-
"""Categorize a failing row into a failure bucket."""
|
|
49
|
-
if d.get("ScoringError"):
|
|
50
|
-
err = d["ScoringError"].lower()
|
|
51
|
-
if "expected tool call" in err and "got text" in err:
|
|
52
|
-
return "no tool call emitted"
|
|
53
|
-
if "expected text" in err and "got tool call" in err:
|
|
54
|
-
return "unexpected tool call"
|
|
55
|
-
return "infra error"
|
|
56
|
-
if d.get("ToolAltReason"):
|
|
57
|
-
return "used functionally similar tool"
|
|
58
|
-
if not name_hit:
|
|
59
|
-
return "called a different tool"
|
|
60
|
-
if d.get("SchemaViolationNotes"):
|
|
61
|
-
return "args violate tool schema"
|
|
62
|
-
return "args mismatch (values differ)"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def _score_topic(routers):
|
|
66
|
-
if not routers:
|
|
67
|
-
return None
|
|
68
|
-
hits = sum(1 for d in routers if d.get("FunctionNameAgreementLenient"))
|
|
69
|
-
return round(100 * hits / len(routers), 1)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def _score_action_strict(actions):
|
|
73
|
-
if not actions:
|
|
74
|
-
return None
|
|
75
|
-
hits = sum(1 for d in actions if d.get("PassStrict") or d.get("FunctionArgsAgreement"))
|
|
76
|
-
return round(100 * hits / len(actions), 1)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def _score_action_lenient(actions):
|
|
80
|
-
"""Count a row as passing if it passes strict OR used a functionally equivalent tool."""
|
|
81
|
-
if not actions:
|
|
82
|
-
return None
|
|
83
|
-
hits = sum(1 for d in actions
|
|
84
|
-
if d.get("PassStrict") or d.get("FunctionArgsAgreement") or d.get("FunctionNameAgreementLenient"))
|
|
85
|
-
return round(100 * hits / len(actions), 1)
|
|
24
|
+
from btlib import SCHEMA_RESULT # noqa: E402
|
|
25
|
+
from btlib import results as resultslib # noqa: E402
|
|
26
|
+
from btlib.redact import redact # noqa: E402
|
|
27
|
+
from btlib.scoring import MATCH_BUCKETS # noqa: E402
|
|
28
|
+
|
|
29
|
+
BUCKET_LABELS = {
|
|
30
|
+
"wrong_args": "same tool, different arguments",
|
|
31
|
+
"wrong_tool": "called a different tool",
|
|
32
|
+
"missed_call": "missed a tool call",
|
|
33
|
+
"extra_call": "extra tool call",
|
|
34
|
+
"tool_to_text": "replied with text instead of a tool call",
|
|
35
|
+
"text_to_tool": "called a tool instead of replying",
|
|
36
|
+
"text_divergence": "text reply not equivalent",
|
|
37
|
+
"equivalent_tool": "equivalent tool (lenient match only)",
|
|
38
|
+
"transition_args": "arguments on a transition that takes none (lenient match only)",
|
|
39
|
+
}
|
|
40
|
+
KIND_TITLES = [("router", "Routing", "router steps; topic = the go_to_* call"),
|
|
41
|
+
("subagent_tool", "Sub-agent tool calls", "steps where production called a tool"),
|
|
42
|
+
("subagent_text", "Sub-agent text replies", "steps where production replied with text")]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _esc(text):
|
|
46
|
+
return _html.escape(redact(text)) if text not in (None, "") else ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _json_pretty(obj):
|
|
50
|
+
return _esc(json.dumps(obj, indent=2, ensure_ascii=False))
|
|
86
51
|
|
|
87
52
|
|
|
88
53
|
def _rating(score, safe_min=85, review_min=60):
|
|
@@ -95,139 +60,320 @@ def _rating(score, safe_min=85, review_min=60):
|
|
|
95
60
|
return "UNSAFE", "#dc2626"
|
|
96
61
|
|
|
97
62
|
|
|
98
|
-
def
|
|
99
|
-
return
|
|
100
|
-
|
|
63
|
+
def _pct(x):
|
|
64
|
+
return None if x is None else round(100 * x, 1)
|
|
101
65
|
|
|
102
|
-
def _json_pretty(obj):
|
|
103
|
-
if isinstance(obj, str):
|
|
104
|
-
try:
|
|
105
|
-
obj = json.loads(obj)
|
|
106
|
-
except Exception:
|
|
107
|
-
return _esc(obj)
|
|
108
|
-
return _esc(json.dumps(obj, indent=2))
|
|
109
66
|
|
|
67
|
+
def _fmt_pct(x):
|
|
68
|
+
return "N/A" if x is None else f"{x}%"
|
|
110
69
|
|
|
111
|
-
# ---------------------------------------------------------------------------
|
|
112
|
-
# Card rendering
|
|
113
|
-
# ---------------------------------------------------------------------------
|
|
114
70
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
golden = _calls(d.get("GoldenAnswer", "{}"))
|
|
118
|
-
predicted = _calls(d.get("PredictedAnswer", "{}"))
|
|
119
|
-
g_names = [n for n, _ in golden]
|
|
120
|
-
p_names = [n for n, _ in predicted]
|
|
71
|
+
LEGACY_HINT = ("This report reads the run dir of the step-level replay (sfap_replay.py --out-dir); "
|
|
72
|
+
"a replay_results.json from the old replay is not comparable: re-run the replay.")
|
|
121
73
|
|
|
122
|
-
name_hit = d.get("FunctionNameAgreement", False)
|
|
123
|
-
bucket = _bucket(d, name_hit)
|
|
124
74
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
#
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
75
|
+
def load_run(path):
|
|
76
|
+
"""(records, units_by_id, summary or None, run_dir) from a run dir or a results.jsonl path."""
|
|
77
|
+
run_dir = path if os.path.isdir(path) else os.path.dirname(os.path.abspath(path))
|
|
78
|
+
results_path = os.path.join(path, "results.jsonl") if os.path.isdir(path) else path
|
|
79
|
+
if not os.path.exists(results_path):
|
|
80
|
+
raise SystemExit(f"{results_path} not found. {LEGACY_HINT}")
|
|
81
|
+
try:
|
|
82
|
+
records = resultslib.read_jsonl(results_path)
|
|
83
|
+
except (ValueError, UnicodeDecodeError): # JSONDecodeError is a ValueError
|
|
84
|
+
records = None
|
|
85
|
+
if records is None or not all(isinstance(r, dict) and r.get("schema") == SCHEMA_RESULT for r in records):
|
|
86
|
+
raise SystemExit(f"{results_path} is not a results.jsonl ({SCHEMA_RESULT}). {LEGACY_HINT}")
|
|
87
|
+
units_path = os.path.join(run_dir, "units.jsonl")
|
|
88
|
+
units = {u["unit_id"]: u for u in resultslib.read_jsonl(units_path)} if os.path.exists(units_path) else {}
|
|
89
|
+
summary_path = os.path.join(run_dir, "summary.json")
|
|
90
|
+
summary = None
|
|
91
|
+
if os.path.exists(summary_path):
|
|
92
|
+
with open(summary_path, encoding="utf-8") as f:
|
|
93
|
+
summary = json.load(f)
|
|
94
|
+
return records, units, summary, run_dir
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def failure_category(rec):
|
|
98
|
+
"""Bucket for the failure breakdown; None for a strict match."""
|
|
99
|
+
if rec["bucket"] == "match":
|
|
100
|
+
return "equivalent_tool" if rec["strict"] < 1.0 else None
|
|
101
|
+
return rec["bucket"]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _calls_html(calls):
|
|
105
|
+
return "".join(f'<div class="tool-call"><span class="tool-name">{_esc(c.get("name"))}</span>'
|
|
106
|
+
f'<pre class="tool-args">{_json_pretty(c.get("args") if "args_raw" not in c else c["args_raw"])}</pre></div>'
|
|
107
|
+
for c in calls)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _answer_html(calls, text):
|
|
111
|
+
if calls:
|
|
112
|
+
return _calls_html(calls) + (f'<div class="text-resp">{_esc(text)}</div>' if text else "")
|
|
113
|
+
return f'<div class="text-resp">{_esc(text)}</div>' if text else "<em>empty</em>"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _transcript_html(unit):
|
|
117
|
+
if not unit:
|
|
118
|
+
return "<em>no units.jsonl next to results.jsonl</em>", 0
|
|
119
|
+
msgs = unit["request"]["messages"]
|
|
120
|
+
parts = []
|
|
121
|
+
for m in msgs:
|
|
122
|
+
role = m.get("role", "unknown")
|
|
123
|
+
if m.get("tool_invocations"):
|
|
124
|
+
body = "; ".join(f'{(c.get("function") or {}).get("name")}({(c.get("function") or {}).get("arguments")})'
|
|
125
|
+
for c in m["tool_invocations"])
|
|
126
|
+
said = f'{_esc(m.get("content"))}<br>' if m.get("content") else ""
|
|
127
|
+
parts.append(f'<div class="msg msg-assistant"><strong>assistant → tool</strong>: {said}<code>{_esc(body)}</code></div>')
|
|
128
|
+
elif role == "tool":
|
|
129
|
+
parts.append(f'<div class="msg msg-info"><strong>tool result ({_esc(m.get("tool_call_name") or "")})</strong>: '
|
|
130
|
+
f'{_esc(m.get("content"))}</div>')
|
|
131
|
+
elif role == "system":
|
|
132
|
+
content = m.get("content") or ""
|
|
133
|
+
parts.append(f'<details class="msg msg-system"><summary><strong>system</strong> ({len(content)} chars)'
|
|
134
|
+
f'</summary><pre class="tool-args">{_esc(content)}</pre></details>')
|
|
135
|
+
else:
|
|
136
|
+
parts.append(f'<div class="msg msg-{_esc(role)}"><strong>{_esc(role)}</strong>: {_esc(m.get("content"))}</div>')
|
|
137
|
+
return "".join(parts), len(msgs)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _card(rec, unit, idx, transcript=True):
|
|
141
|
+
ok = rec["status"] == "ok"
|
|
142
|
+
if not ok:
|
|
143
|
+
badge, badge_txt = "badge-fail", "INFRA"
|
|
144
|
+
elif rec["bucket"] == "match" and rec["strict"] == 1.0:
|
|
145
|
+
badge, badge_txt = "badge-pass", "PASS"
|
|
146
|
+
elif rec["score"] and rec["score"] > 0:
|
|
147
|
+
badge, badge_txt = "badge-warn", "PARTIAL"
|
|
140
148
|
else:
|
|
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
|
-
|
|
149
|
+
badge, badge_txt = "badge-fail", "FAIL"
|
|
150
|
+
category = (failure_category(rec) if ok else rec["error_kind"]) or "match"
|
|
151
|
+
flags = []
|
|
152
|
+
if not ok:
|
|
153
|
+
flags.append(f'<span class="flag flag-error">{_esc(rec["error"])}</span>')
|
|
154
|
+
if ok and rec["detail"]:
|
|
155
|
+
flags.append(f'<span class="flag flag-warn">{_esc(rec["detail"])}</span>')
|
|
156
|
+
for note in rec.get("schema_violations") or []:
|
|
157
|
+
flags.append(f'<span class="flag flag-error">schema: {_esc(note)}</span>')
|
|
158
|
+
for j in rec.get("judge") or []:
|
|
159
|
+
flags.append(f'<span class="flag flag-info">judge ({_esc(j.get("for"))}): '
|
|
160
|
+
f'{"equivalent" if j.get("equivalent") else "not equivalent"}: {_esc(j.get("reasoning"))}</span>')
|
|
161
|
+
g = rec["golden"]
|
|
162
|
+
resp = rec["response"] or {}
|
|
163
|
+
|
|
164
|
+
def yn(v):
|
|
165
|
+
return "N/A" if v is None else ("Yes" if v else "No")
|
|
166
|
+
|
|
167
|
+
rows = [("Decision (tool vs text)", yn(rec["decision"])), ("Tool names (lenient)", yn(rec["names"])),
|
|
168
|
+
("Tool names (exact)", yn(rec["names_strict"])), ("Arguments", yn(rec["args"])),
|
|
169
|
+
("Text equivalent", yn(rec["text"])), ("Unit score", "N/A" if rec["score"] is None else rec["score"]),
|
|
170
|
+
("Strict", "N/A" if rec["strict"] is None else rec["strict"])]
|
|
171
|
+
if rec["step_kind"] == "router":
|
|
172
|
+
rows.append(("Topic (go_to) match", yn(rec["topic_match"])))
|
|
173
|
+
if rec.get("proxies"):
|
|
174
|
+
p = rec["proxies"]
|
|
175
|
+
rows.append(("Length ratio (candidate/golden)", p["len_ratio"]))
|
|
176
|
+
rows.append(("Ends with a question (golden / candidate)",
|
|
177
|
+
f'{yn(p["golden_ends_with_question"])} / {yn(p["candidate_ends_with_question"])}'))
|
|
178
|
+
if p["candidate_raw_ids"] > p["golden_raw_ids"]:
|
|
179
|
+
rows.append(("Raw record ids in reply", p["candidate_raw_ids"]))
|
|
180
|
+
score_rows = "".join(f"<tr><td>{_esc(k)}</td><td>{_esc(str(v))}</td></tr>" for k, v in rows)
|
|
181
|
+
tools = [(t.get("function") or {}).get("name") for t in unit["request"]["tools"]] if unit else []
|
|
182
|
+
tools_html = ", ".join(f"<code>{_esc(t)}</code>" for t in tools) or "<em>none</em>"
|
|
183
|
+
if transcript:
|
|
184
|
+
body, n_msgs = _transcript_html(unit)
|
|
185
|
+
request_html = f'''<details class="transcript-toggle">
|
|
186
|
+
<summary>Request sent ({n_msgs} messages)</summary>
|
|
187
|
+
<div class="transcript">{body}</div>
|
|
188
|
+
</details>'''
|
|
189
|
+
else: # matching steps: keep the report small; the request is in units.jsonl
|
|
190
|
+
request_html = f'<div class="scorecard-detail">request: units.jsonl, unit_id <code>{_esc(rec["unit_id"])}</code></div>'
|
|
181
191
|
return f'''
|
|
182
192
|
<details class="card">
|
|
183
193
|
<summary class="card-header">
|
|
184
194
|
<span class="card-idx">#{idx}</span>
|
|
185
|
-
<span class="{
|
|
186
|
-
<span class="card-bucket">{_esc(
|
|
187
|
-
<span class="card-
|
|
188
|
-
<span class="card-
|
|
195
|
+
<span class="{badge}">{badge_txt}</span>
|
|
196
|
+
<span class="card-bucket">{_esc(BUCKET_LABELS.get(category, category))}</span>
|
|
197
|
+
<span class="card-bucket">{_esc(rec["node"])}</span>
|
|
198
|
+
<span class="card-utterance">{_esc((rec.get("user_utterance") or "")[:100])}</span>
|
|
199
|
+
<span class="card-session">r{rec["repeat"]} · {_esc(rec["unit_id"][-24:])}</span>
|
|
189
200
|
</summary>
|
|
190
201
|
<div class="card-body">
|
|
191
202
|
<div class="flags">{" ".join(flags)}</div>
|
|
192
|
-
|
|
193
203
|
<div class="comparison">
|
|
194
|
-
<div class="comp-col">
|
|
195
|
-
|
|
196
|
-
{golden_html or "<em>none</em>"}
|
|
197
|
-
</div>
|
|
198
|
-
<div class="comp-col">
|
|
199
|
-
<h4>Predicted (Replay)</h4>
|
|
200
|
-
{predicted_html or "<em>none</em>"}
|
|
201
|
-
</div>
|
|
202
|
-
</div>
|
|
203
|
-
|
|
204
|
-
<div class="section">
|
|
205
|
-
<h4>Scores</h4>
|
|
206
|
-
<table class="score-table">
|
|
207
|
-
<tr><td>Function Name Match</td><td>{"Yes" if d.get("FunctionNameAgreement") else "No"}</td></tr>
|
|
208
|
-
<tr><td>Function Name Match (Lenient)</td><td>{"Yes" if d.get("FunctionNameAgreementLenient") else "No"}</td></tr>
|
|
209
|
-
<tr><td>Args Agreement</td><td>{"Yes" if d.get("FunctionArgsAgreement") else "No"}</td></tr>
|
|
210
|
-
<tr><td>Args Score</td><td>{d.get("FunctionArgsScore", "N/A")}</td></tr>
|
|
211
|
-
<tr><td>Pass Strict</td><td>{"Yes" if d.get("PassStrict") else "No"}</td></tr>
|
|
212
|
-
</table>
|
|
213
|
-
</div>
|
|
214
|
-
|
|
215
|
-
<div class="section">
|
|
216
|
-
<h4>Tools Offered</h4>
|
|
217
|
-
<div>{tools_html}</div>
|
|
204
|
+
<div class="comp-col"><h4>Golden ({_esc(rec.get("golden_model"))})</h4>{_answer_html(g["tool_calls"], g["text"])}</div>
|
|
205
|
+
<div class="comp-col"><h4>Candidate ({_esc(rec.get("model"))})</h4>{_answer_html(resp.get("tool_calls") or [], resp.get("text")) if ok else "<em>no answer (infra error)</em>"}</div>
|
|
218
206
|
</div>
|
|
219
|
-
|
|
220
|
-
<
|
|
221
|
-
|
|
222
|
-
<div class="transcript">{transcript_html or "<em>no transcript captured</em>"}</div>
|
|
223
|
-
</details>
|
|
207
|
+
<div class="section"><h4>Scores</h4><table class="score-table">{score_rows}</table></div>
|
|
208
|
+
<div class="section"><h4>Tools offered</h4><div>{tools_html}</div></div>
|
|
209
|
+
{request_html}
|
|
224
210
|
</div>
|
|
225
211
|
</details>'''
|
|
226
212
|
|
|
227
213
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
214
|
+
def _scorecard(title, value_pct, label, details):
|
|
215
|
+
verdict, color = _rating(value_pct)
|
|
216
|
+
detail_html = "".join(f'<div class="scorecard-detail">{d}</div>' for d in details)
|
|
217
|
+
return f'''
|
|
218
|
+
<div class="scorecard">
|
|
219
|
+
<h3>{_esc(title)}</h3>
|
|
220
|
+
<div class="scorecard-value" style="color:{color}">{_fmt_pct(value_pct)}</div>
|
|
221
|
+
<div class="scorecard-label">{_esc(label)}</div>
|
|
222
|
+
{detail_html}
|
|
223
|
+
<div class="scorecard-detail">{verdict}</div>
|
|
224
|
+
</div>'''
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _production_html(summary):
|
|
228
|
+
"""What production's own checks said of the replayed turns; '' when STDM recorded none."""
|
|
229
|
+
p = summary["production"]
|
|
230
|
+
if not (set(p["groundedness"]) - {"(none)"} or p["guardrails"] or p["turns_with_step_errors"]
|
|
231
|
+
or set(p["end_type"]) - {"(none)"} or p["sessions_with_action_errors"]):
|
|
232
|
+
return ""
|
|
233
|
+
by_cat = summary["by_groundedness"]
|
|
234
|
+
rows = "".join(
|
|
235
|
+
f'<tr><td>{"no check recorded" if cat == "(none)" else f"<code>{_esc(cat)}</code>"}</td>'
|
|
236
|
+
f'<td>{p["groundedness"].get(cat, 0)}</td><td>{by_cat.get(cat, {}).get("n", 0)}</td>'
|
|
237
|
+
f'<td>{_fmt_pct(_pct(by_cat.get(cat, {}).get("agreement")))}</td>'
|
|
238
|
+
f'<td>{_fmt_pct(_pct(by_cat.get(cat, {}).get("strict")))}</td></tr>'
|
|
239
|
+
for cat in list(p["groundedness"]) + [c for c in by_cat if c not in p["groundedness"]])
|
|
240
|
+
lines = [f'<code>{_esc(name)}</code>: {_esc(", ".join(f"{v} {n}" for v, n in values.items()))}'
|
|
241
|
+
for name, values in p["guardrails"].items()]
|
|
242
|
+
end_types = ", ".join(f'{"not recorded" if t == "(none)" else _esc(t)} {n}' for t, n in p["end_type"].items())
|
|
243
|
+
lines.append(f'{p["sessions"]} sessions · end type: {end_types} · with action errors: '
|
|
244
|
+
f'{p["sessions_with_action_errors"]} · turns with a failed step: {p["turns_with_step_errors"]}')
|
|
245
|
+
details = "".join(f'<div class="scorecard-detail">{line}</div>' for line in lines)
|
|
246
|
+
return f'''<div class="buckets"><h3>Production's own checks (report only)</h3>
|
|
247
|
+
<div class="scorecard-detail">What production's groundedness check and trust guardrails said of its own replies. They never change a score: agreement with production is not correctness.</div>
|
|
248
|
+
<table class="score-table node-table">
|
|
249
|
+
<tr><th>groundedness of the turn</th><th>turns</th><th>steps</th><th>agreement</th><th>strict</th></tr>{rows}</table>
|
|
250
|
+
{details}</div>'''
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def build(records, units, summary=None):
|
|
254
|
+
saved = summary or {}
|
|
255
|
+
config = saved.get("config") or {}
|
|
256
|
+
counts = saved.get("counts") or {}
|
|
257
|
+
summary = resultslib.summarize(records, config, counts.get("skipped_steps"), counts.get("units"))
|
|
258
|
+
ok = [r for r in records if r["status"] == "ok"]
|
|
259
|
+
infra = [r for r in records if r["status"] != "ok"]
|
|
260
|
+
overall = _pct(summary["overall"]["agreement"])
|
|
261
|
+
verdict, verdict_color = _rating(overall)
|
|
262
|
+
|
|
263
|
+
def meta(label, value):
|
|
264
|
+
return f'<div><dt>{_esc(label)}</dt><dd>{_esc(str(value)) if value not in (None, "", []) else "N/A"}</dd></div>'
|
|
265
|
+
|
|
266
|
+
code = config.get("code") or {}
|
|
267
|
+
head = [
|
|
268
|
+
meta("Candidate model", ", ".join(sorted({r["model"] for r in records if r.get("model")}))),
|
|
269
|
+
meta("Golden (production) model", ", ".join(sorted({r["golden_model"] for r in records if r.get("golden_model")}))),
|
|
270
|
+
meta("Judge model", config.get("judge_model")),
|
|
271
|
+
meta("Org", config.get("org") or next((r["org"] for r in records if r.get("org")), None)),
|
|
272
|
+
meta("Agent", config.get("agent") or next((r["agent"] for r in records if r.get("agent")), None)),
|
|
273
|
+
meta("Units x repeats", f'{summary["counts"]["units"]} x {config.get("k", 1)}'),
|
|
274
|
+
meta("Finished", config.get("finished")),
|
|
275
|
+
meta("Code", f'{code.get("git") or "?"}{"+dirty" if code.get("git_dirty") else ""} / btlib {code.get("btlib_sha256", "?")}'),
|
|
276
|
+
]
|
|
277
|
+
parts = [f'''<!DOCTYPE html>
|
|
278
|
+
<html lang="en">
|
|
279
|
+
<head>
|
|
280
|
+
<meta charset="utf-8">
|
|
281
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
282
|
+
<title>Offline Analysis Report</title>
|
|
283
|
+
<style>{CSS}</style>
|
|
284
|
+
</head>
|
|
285
|
+
<body>
|
|
286
|
+
<div class="container">
|
|
287
|
+
<div class="header">
|
|
288
|
+
<h1>Offline Analysis Report</h1>
|
|
289
|
+
<div class="verdict" style="background:{verdict_color}">{verdict}</div>
|
|
290
|
+
<dl class="header-meta">{"".join(head)}</dl>
|
|
291
|
+
</div>
|
|
292
|
+
<div class="scorecards">''']
|
|
293
|
+
|
|
294
|
+
for prefix, title, label in KIND_TITLES:
|
|
295
|
+
a = resultslib.aggregate([r for r in ok if r["kind"] == prefix or r["kind"].startswith(prefix + "_")])
|
|
296
|
+
if not a["n"]:
|
|
297
|
+
continue
|
|
298
|
+
details = [f'{a["n"]} steps · {a["match"]} match · strict {_fmt_pct(_pct(a["strict"]))}']
|
|
299
|
+
if prefix == "router" and summary["topic"]["n"]:
|
|
300
|
+
details.append(f'topic {summary["topic"]["match"]}/{summary["topic"]["n"]}')
|
|
301
|
+
if prefix == "subagent_text" and summary["text"]["n"]:
|
|
302
|
+
t = summary["text"]
|
|
303
|
+
details.append(f'median length ratio {t["median_len_ratio"]} · raw-id leaks {t["raw_id_leaks"]}')
|
|
304
|
+
parts.append(_scorecard(title, _pct(a["agreement"]), label, details))
|
|
305
|
+
o = summary["overall"]
|
|
306
|
+
parts.append(_scorecard("Overall", overall, "Agreement with production (mean unit score)",
|
|
307
|
+
[f'{o["n"]} scored · {o["match"]} match · strict {_fmt_pct(_pct(o["strict"]))}']))
|
|
308
|
+
c = summary["counts"]
|
|
309
|
+
infra_kinds = ", ".join(f"{_esc(k)} {v}" for k, v in c["infra_by_kind"].items()) or "none"
|
|
310
|
+
skipped = ", ".join(f"{_esc(k)} {v}" for k, v in c["skipped_steps"].items()) or "none"
|
|
311
|
+
timed_out = (f'\n <div class="scorecard-detail">{c["timed_out_units"]} units timed out on every repeat: the model '
|
|
312
|
+
f'may not finish them</div>' if c.get("timed_out_units") else "")
|
|
313
|
+
parts.append(f'''
|
|
314
|
+
<div class="scorecard">
|
|
315
|
+
<h3>Infra errors (not scored)</h3>
|
|
316
|
+
<div class="scorecard-value" style="color:{"#dc2626" if c["infra_rate"] > 0.1 else "#475569"}">{c["infra_error"]}</div>
|
|
317
|
+
<div class="scorecard-label">{100 * c["infra_rate"]:.1f}% of {c["records"]} calls</div>
|
|
318
|
+
<div class="scorecard-detail">by kind: {infra_kinds}</div>{timed_out}
|
|
319
|
+
<div class="scorecard-detail">steps not replayed: {skipped}</div>
|
|
320
|
+
</div>''')
|
|
321
|
+
parts.append("</div>")
|
|
322
|
+
|
|
323
|
+
# per-node table
|
|
324
|
+
node_rows = "".join(
|
|
325
|
+
f'<tr><td><code>{_esc(node)}</code></td><td>{a["n"]}</td><td>{_fmt_pct(_pct(a["agreement"]))}</td>'
|
|
326
|
+
f'<td>{_fmt_pct(_pct(a["strict"]))}</td><td>{_esc(", ".join(f"{BUCKET_LABELS.get(b, b)} {k}" for b, k in a["buckets"].items() if b not in MATCH_BUCKETS))}</td></tr>'
|
|
327
|
+
for node, a in summary["by_node"].items())
|
|
328
|
+
if node_rows:
|
|
329
|
+
parts.append(f'''<div class="buckets"><h3>By node</h3><table class="score-table node-table">
|
|
330
|
+
<tr><th>node</th><th>steps</th><th>agreement</th><th>strict</th><th>non-matches</th></tr>{node_rows}</table></div>''')
|
|
331
|
+
parts.append(_production_html(summary))
|
|
332
|
+
|
|
333
|
+
# failure breakdown
|
|
334
|
+
cats = Counter(cat for cat in (failure_category(r) for r in ok) if cat)
|
|
335
|
+
if cats:
|
|
336
|
+
top = max(cats.values())
|
|
337
|
+
parts.append('<div class="buckets"><h3>Failure breakdown</h3>')
|
|
338
|
+
for cat, n in cats.most_common():
|
|
339
|
+
parts.append(f'''
|
|
340
|
+
<div class="bucket-row">
|
|
341
|
+
<span class="bucket-name">{_esc(BUCKET_LABELS.get(cat, cat))}</span>
|
|
342
|
+
<div class="bucket-bar-wrap"><div class="bucket-bar" style="width:{round(100 * n / top)}%"></div></div>
|
|
343
|
+
<span class="bucket-count">{n}</span>
|
|
344
|
+
</div>''')
|
|
345
|
+
violations = summary["schema_violations"]["records"]
|
|
346
|
+
if violations:
|
|
347
|
+
parts.append(f'<div class="scorecard-detail">{violations} candidate answer(s) violate the offered tool schema</div>')
|
|
348
|
+
parts.append("</div>")
|
|
349
|
+
if summary["confusion"]:
|
|
350
|
+
conf_rows = "".join(f'<tr><td><code>{_esc(x["golden"])}</code></td><td><code>{_esc(x["candidate"])}</code></td><td>{x["n"]}</td></tr>'
|
|
351
|
+
for x in summary["confusion"][:15])
|
|
352
|
+
parts.append(f'''<div class="buckets"><h3>Tool confusions (golden → candidate)</h3><table class="score-table node-table">
|
|
353
|
+
<tr><th>golden</th><th>candidate</th><th>n</th></tr>{conf_rows}</table></div>''')
|
|
354
|
+
|
|
355
|
+
failing = [(i, r) for i, r in enumerate(records) if r["status"] == "ok" and failure_category(r)]
|
|
356
|
+
passing = [(i, r) for i, r in enumerate(records) if r["status"] == "ok" and not failure_category(r)]
|
|
357
|
+
if passing:
|
|
358
|
+
parts.append(f'<div class="pass-summary">{len(passing)} step(s) matched production exactly</div>')
|
|
359
|
+
if failing:
|
|
360
|
+
parts.append(f"<h2>Not an exact match ({len(failing)})</h2>")
|
|
361
|
+
parts.extend(_card(r, units.get(r["unit_id"]), i) for i, r in failing)
|
|
362
|
+
if infra:
|
|
363
|
+
parts.append(f"<h2>Infra errors ({len(infra)})</h2>")
|
|
364
|
+
parts.extend(_card(r, units.get(r["unit_id"]), i) for i, r in enumerate(records) if r["status"] != "ok")
|
|
365
|
+
if passing:
|
|
366
|
+
parts.append(f'''
|
|
367
|
+
<details style="margin-top:1.5rem">
|
|
368
|
+
<summary style="cursor:pointer;font-family:'Space Grotesk',sans-serif;font-weight:600;color:var(--slate-500);font-size:1.1rem;padding:0.5rem 0">
|
|
369
|
+
Matching ({len(passing)})
|
|
370
|
+
</summary>
|
|
371
|
+
<div style="margin-top:0.5rem">''')
|
|
372
|
+
parts.extend(_card(r, units.get(r["unit_id"]), i, transcript=False) for i, r in passing)
|
|
373
|
+
parts.append("</div></details>")
|
|
374
|
+
parts.append("\n</div>\n</body>\n</html>")
|
|
375
|
+
return "\n".join(parts)
|
|
376
|
+
|
|
231
377
|
|
|
232
378
|
CSS = '''
|
|
233
379
|
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&family=Inter:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap');
|
|
@@ -602,195 +748,37 @@ code {
|
|
|
602
748
|
color: var(--safe);
|
|
603
749
|
font-weight: 500;
|
|
604
750
|
}
|
|
605
|
-
'''
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
def build(data):
|
|
609
|
-
"""Build HTML report from replay results JSON data."""
|
|
610
|
-
meta = data.get("metadata", {})
|
|
611
|
-
rows = data.get("rows", [])
|
|
612
|
-
recs = data.get("recs", {})
|
|
613
|
-
|
|
614
|
-
routers = [d for d in rows if _is_router(d)]
|
|
615
|
-
actions = [d for d in rows if not _is_router(d)]
|
|
616
|
-
|
|
617
|
-
topic_score = _score_topic(routers)
|
|
618
|
-
action_strict = _score_action_strict(actions)
|
|
619
|
-
action_lenient = _score_action_lenient(actions)
|
|
620
|
-
|
|
621
|
-
# Overall verdict uses lenient action score (functionally equivalent tools count)
|
|
622
|
-
action_score = action_lenient
|
|
623
|
-
if topic_score is not None and action_score is not None:
|
|
624
|
-
overall = round(0.3 * topic_score + 0.7 * action_score, 1)
|
|
625
|
-
elif action_score is not None:
|
|
626
|
-
overall = action_score
|
|
627
|
-
elif topic_score is not None:
|
|
628
|
-
overall = topic_score
|
|
629
|
-
else:
|
|
630
|
-
overall = None
|
|
631
|
-
|
|
632
|
-
verdict_text, verdict_color = _rating(overall)
|
|
633
751
|
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
# Build HTML
|
|
648
|
-
parts = []
|
|
649
|
-
parts.append(f'''<!DOCTYPE html>
|
|
650
|
-
<html lang="en">
|
|
651
|
-
<head>
|
|
652
|
-
<meta charset="utf-8">
|
|
653
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
654
|
-
<title>Offline Analysis Report</title>
|
|
655
|
-
<style>{CSS}</style>
|
|
656
|
-
</head>
|
|
657
|
-
<body>
|
|
658
|
-
<div class="container">
|
|
659
|
-
''')
|
|
660
|
-
|
|
661
|
-
# Header
|
|
662
|
-
parts.append(f'''
|
|
663
|
-
<div class="header">
|
|
664
|
-
<h1>Offline Analysis Report</h1>
|
|
665
|
-
<div class="verdict" style="background:{verdict_color}">{verdict_text}</div>
|
|
666
|
-
<dl class="header-meta">
|
|
667
|
-
<div><dt>Baseline Model</dt><dd>{_esc(meta.get("baseline_model", "N/A"))}</dd></div>
|
|
668
|
-
<div><dt>Replay Model</dt><dd>{_esc(meta.get("replay_model", "N/A"))}</dd></div>
|
|
669
|
-
<div><dt>Org ID</dt><dd>{_esc(meta.get("org_id", "N/A"))}</dd></div>
|
|
670
|
-
<div><dt>Agent</dt><dd>{_esc(meta.get("agent_api_name", "N/A") or "N/A")}</dd></div>
|
|
671
|
-
<div><dt>Total Rows</dt><dd>{meta.get("total_rows", len(rows))}</dd></div>
|
|
672
|
-
<div><dt>Timestamp</dt><dd>{_esc(meta.get("timestamp", ""))}</dd></div>
|
|
673
|
-
</dl>
|
|
674
|
-
</div>
|
|
675
|
-
''')
|
|
676
|
-
|
|
677
|
-
# Scorecards
|
|
678
|
-
parts.append('<div class="scorecards">')
|
|
679
|
-
|
|
680
|
-
if routers:
|
|
681
|
-
t_verdict, t_color = _rating(topic_score)
|
|
682
|
-
parts.append(f'''
|
|
683
|
-
<div class="scorecard">
|
|
684
|
-
<h3>Routing</h3>
|
|
685
|
-
<div class="scorecard-value" style="color:{t_color}">{topic_score}%</div>
|
|
686
|
-
<div class="scorecard-label">Topic Agreement (Lenient)</div>
|
|
687
|
-
<div class="scorecard-detail">{len(routers)} routing rows · {t_verdict}</div>
|
|
688
|
-
</div>''')
|
|
689
|
-
|
|
690
|
-
if actions:
|
|
691
|
-
a_verdict, a_color = _rating(action_lenient)
|
|
692
|
-
as_verdict, as_color = _rating(action_strict)
|
|
693
|
-
strict_pass = sum(1 for d in actions if d.get("PassStrict"))
|
|
694
|
-
args_agree = sum(1 for d in actions if d.get("FunctionArgsAgreement"))
|
|
695
|
-
name_exact = sum(1 for d in actions if d.get("FunctionNameAgreement"))
|
|
696
|
-
name_lenient = sum(1 for d in actions if d.get("FunctionNameAgreementLenient"))
|
|
697
|
-
parts.append(f'''
|
|
698
|
-
<div class="scorecard">
|
|
699
|
-
<h3>Action Tools (Lenient)</h3>
|
|
700
|
-
<div class="scorecard-value" style="color:{a_color}">{action_lenient}%</div>
|
|
701
|
-
<div class="scorecard-label">Includes functionally equivalent tools</div>
|
|
702
|
-
<div class="scorecard-detail">{len(actions)} action rows · {a_verdict}</div>
|
|
703
|
-
<div class="scorecard-detail">{name_lenient}/{len(actions)} tool match (lenient) · {name_exact}/{len(actions)} exact</div>
|
|
704
|
-
</div>''')
|
|
705
|
-
parts.append(f'''
|
|
706
|
-
<div class="scorecard">
|
|
707
|
-
<h3>Action Tools (Strict)</h3>
|
|
708
|
-
<div class="scorecard-value" style="color:{as_color}">{action_strict}%</div>
|
|
709
|
-
<div class="scorecard-label">Exact name + args agreement</div>
|
|
710
|
-
<div class="scorecard-detail">{strict_pass} strict pass · {args_agree} args agree</div>
|
|
711
|
-
</div>''')
|
|
712
|
-
|
|
713
|
-
# Overall scorecard
|
|
714
|
-
o_verdict, o_color = _rating(overall)
|
|
715
|
-
parts.append(f'''
|
|
716
|
-
<div class="scorecard">
|
|
717
|
-
<h3>Overall</h3>
|
|
718
|
-
<div class="scorecard-value" style="color:{o_color}">{overall if overall is not None else "N/A"}%</div>
|
|
719
|
-
<div class="scorecard-label">Weighted Score</div>
|
|
720
|
-
<div class="scorecard-detail">{len(rows)} total rows · {len(passing)} pass · {len(failing)} fail</div>
|
|
721
|
-
</div>''')
|
|
722
|
-
|
|
723
|
-
parts.append('</div>') # end scorecards
|
|
724
|
-
|
|
725
|
-
# Failure buckets
|
|
726
|
-
if bucket_counts:
|
|
727
|
-
parts.append('<div class="buckets"><h3>Failure Breakdown</h3>')
|
|
728
|
-
for bucket_name, count in bucket_counts.most_common():
|
|
729
|
-
pct = round(100 * count / max_bucket)
|
|
730
|
-
parts.append(f'''
|
|
731
|
-
<div class="bucket-row">
|
|
732
|
-
<span class="bucket-name">{_esc(bucket_name)}</span>
|
|
733
|
-
<div class="bucket-bar-wrap"><div class="bucket-bar" style="width:{pct}%"></div></div>
|
|
734
|
-
<span class="bucket-count">{count}</span>
|
|
735
|
-
</div>''')
|
|
736
|
-
parts.append('</div>')
|
|
737
|
-
|
|
738
|
-
# Passing rows summary
|
|
739
|
-
if passing:
|
|
740
|
-
parts.append(f'<div class="pass-summary">{len(passing)} row(s) passed (strict pass or args agreement)</div>')
|
|
741
|
-
|
|
742
|
-
# Failing row cards
|
|
743
|
-
if failing:
|
|
744
|
-
parts.append(f'<h2>Failing Rows ({len(failing)})</h2>')
|
|
745
|
-
for d in failing:
|
|
746
|
-
idx = d.get("SessionRowIndex", "?")
|
|
747
|
-
rec = recs.get(str(idx), {})
|
|
748
|
-
parts.append(_card(d, rec, idx))
|
|
749
|
-
|
|
750
|
-
# All passing row cards (collapsed section)
|
|
751
|
-
if passing:
|
|
752
|
-
parts.append(f'''
|
|
753
|
-
<details style="margin-top:1.5rem">
|
|
754
|
-
<summary style="cursor:pointer;font-family:'Space Grotesk',sans-serif;font-weight:600;color:var(--slate-500);font-size:1.1rem;padding:0.5rem 0">
|
|
755
|
-
Passing Rows ({len(passing)})
|
|
756
|
-
</summary>
|
|
757
|
-
<div style="margin-top:0.5rem">''')
|
|
758
|
-
for d in passing:
|
|
759
|
-
idx = d.get("SessionRowIndex", "?")
|
|
760
|
-
rec = recs.get(str(idx), {})
|
|
761
|
-
parts.append(_card(d, rec, idx))
|
|
762
|
-
parts.append('</div></details>')
|
|
763
|
-
|
|
764
|
-
parts.append('''
|
|
765
|
-
</div>
|
|
766
|
-
</body>
|
|
767
|
-
</html>''')
|
|
768
|
-
|
|
769
|
-
return "\n".join(parts)
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
# ---------------------------------------------------------------------------
|
|
773
|
-
# CLI
|
|
774
|
-
# ---------------------------------------------------------------------------
|
|
775
|
-
|
|
776
|
-
def main():
|
|
777
|
-
input_path = sys.argv[1] if len(sys.argv) > 1 else "replay_results.json"
|
|
778
|
-
output_path = sys.argv[2] if len(sys.argv) > 2 else "offline_analysis_report.html"
|
|
779
|
-
|
|
780
|
-
with open(input_path) as f:
|
|
781
|
-
data = json.load(f)
|
|
782
|
-
|
|
783
|
-
html = build(data)
|
|
752
|
+
/* Multi-row tables (by node, confusions) */
|
|
753
|
+
.node-table td:first-child { width: auto; color: var(--slate-700); }
|
|
754
|
+
.node-table th {
|
|
755
|
+
text-align: left;
|
|
756
|
+
padding: 0.3rem 0.5rem;
|
|
757
|
+
font-weight: 600;
|
|
758
|
+
color: var(--slate-600);
|
|
759
|
+
border-bottom: 2px solid var(--slate-200);
|
|
760
|
+
}
|
|
761
|
+
.node-table td:last-child { font-family: inherit; font-weight: 400; color: var(--slate-600); }
|
|
762
|
+
details.msg > summary { cursor: pointer; }
|
|
763
|
+
.msg pre.tool-args { white-space: pre-wrap; margin-top: 0.4rem; }
|
|
764
|
+
'''
|
|
784
765
|
|
|
785
|
-
with open(output_path, "w") as f:
|
|
786
|
-
f.write(html)
|
|
787
766
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
767
|
+
def main(argv=None):
|
|
768
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
769
|
+
if not argv or argv[0] in ("-h", "--help"):
|
|
770
|
+
print(__doc__)
|
|
771
|
+
return 0 if argv else 2
|
|
772
|
+
records, units, summary, run_dir = load_run(argv[0])
|
|
773
|
+
out = argv[1] if len(argv) > 1 else os.path.join(run_dir, "report.html")
|
|
774
|
+
with resultslib.open_private(out) as f: # the report quotes user utterances
|
|
775
|
+
f.write(build(records, units, summary))
|
|
776
|
+
ok = sum(1 for r in records if r["status"] == "ok")
|
|
777
|
+
matched = sum(1 for r in records if r["status"] == "ok" and not failure_category(r))
|
|
778
|
+
print(f"Report generated: {out}")
|
|
779
|
+
print(f" {len(records)} results: {matched} match, {ok - matched} differ, {len(records) - ok} infra errors")
|
|
780
|
+
return 0
|
|
793
781
|
|
|
794
782
|
|
|
795
783
|
if __name__ == "__main__":
|
|
796
|
-
main()
|
|
784
|
+
sys.exit(main())
|