@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.
Files changed (118) hide show
  1. package/README.md +55 -5
  2. package/package.json +1 -1
  3. package/skills/agentforce-offline-experimentation-run/SKILL.md +14 -22
  4. package/skills/agentforce-offline-experimentation-run/scripts/bt_compare.py +81 -0
  5. package/skills/agentforce-offline-experimentation-run/scripts/bt_fetch.py +358 -0
  6. package/skills/agentforce-offline-experimentation-run/scripts/bt_patch_agent.py +110 -0
  7. package/skills/agentforce-offline-experimentation-run/scripts/btlib/__init__.py +34 -0
  8. package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentpatch.py +483 -0
  9. package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentsrc.py +565 -0
  10. package/skills/agentforce-offline-experimentation-run/scripts/btlib/cache.py +95 -0
  11. package/skills/agentforce-offline-experimentation-run/scripts/btlib/compare.py +617 -0
  12. package/skills/agentforce-offline-experimentation-run/scripts/btlib/fetch.py +1012 -0
  13. package/skills/agentforce-offline-experimentation-run/scripts/btlib/judge.py +148 -0
  14. package/skills/agentforce-offline-experimentation-run/scripts/btlib/messages.py +92 -0
  15. package/skills/agentforce-offline-experimentation-run/scripts/btlib/patch.py +327 -0
  16. package/skills/agentforce-offline-experimentation-run/scripts/btlib/redact.py +40 -0
  17. package/skills/agentforce-offline-experimentation-run/scripts/btlib/results.py +288 -0
  18. package/skills/agentforce-offline-experimentation-run/scripts/btlib/scoring.py +340 -0
  19. package/skills/agentforce-offline-experimentation-run/scripts/btlib/sfap.py +238 -0
  20. package/skills/agentforce-offline-experimentation-run/scripts/btlib/splits.py +136 -0
  21. package/skills/agentforce-offline-experimentation-run/scripts/btlib/stdm.py +98 -0
  22. package/skills/agentforce-offline-experimentation-run/scripts/btlib/tokens.py +173 -0
  23. package/skills/agentforce-offline-experimentation-run/scripts/btlib/units.py +199 -0
  24. package/skills/agentforce-offline-experimentation-run/scripts/btlib/versions.py +194 -0
  25. package/skills/agentforce-offline-experimentation-run/scripts/build_report.py +366 -378
  26. package/skills/agentforce-offline-experimentation-run/scripts/resolve-token.sh +14 -4
  27. package/skills/agentforce-offline-experimentation-run/scripts/sfap_replay.py +472 -796
  28. package/skills/dx-lifecycle-center/SKILL.md +5 -2
  29. package/skills/dx-lifecycle-center/assets/widgets/connect-connected/connect-connected.json +65 -0
  30. package/skills/dx-lifecycle-center/assets/widgets/connect-connected/schema.json +32 -0
  31. package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/connect-sign-in.json +57 -0
  32. package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/schema.json +21 -0
  33. package/skills/dx-lifecycle-center/examples/workflows.md +20 -6
  34. package/skills/dx-lifecycle-center/references/connect-org.md +21 -2
  35. package/skills/dx-lifecycle-center/references/deploy.md +2 -2
  36. package/skills/dx-lifecycle-center/references/list-orgs.md +1 -1
  37. package/skills/dx-lifecycle-center/scripts/card-policy.jq +71 -0
  38. package/skills/dx-lifecycle-center/scripts/fill-widget.jq +29 -0
  39. package/skills/dx-lifecycle-center/scripts/widget.sh +28 -0
  40. package/skills/dx-org-devhub-configure/SKILL.md +85 -84
  41. package/skills/dx-org-devhub-configure/examples/status-output.md +25 -2
  42. package/skills/dx-org-devhub-configure/scripts/devhub.sh +265 -140
  43. package/skills/dx-org-devhub-configure/scripts/test-devhub.sh +100 -15
  44. package/skills/dx-packaging-move-to-2gp-migrate/SKILL.md +89 -0
  45. package/skills/dx-packaging-move-to-2gp-migrate/references/api-operations.md +164 -0
  46. package/skills/dx-scratch-def-configure/SKILL.md +163 -0
  47. package/skills/dx-scratch-def-configure/examples/confirm-card.md +53 -0
  48. package/skills/dx-scratch-def-configure/examples/report-recipes.md +100 -0
  49. package/skills/dx-scratch-def-configure/references/features.json +3270 -0
  50. package/skills/dx-scratch-def-configure/references/project-scratch-def.schema.json +1841 -0
  51. package/skills/dx-scratch-def-configure/references/settings-map.json +682 -0
  52. package/skills/dx-scratch-def-configure/scripts/lib_scratch_def.py +1306 -0
  53. package/skills/dx-scratch-def-configure/scripts/requirements.txt +3 -0
  54. package/skills/dx-scratch-def-configure/scripts/scrape_config_values.py +848 -0
  55. package/skills/education-cloud-domain-configure/SKILL.md +1 -1
  56. package/skills/education-cloud-multi-campus-configure/SKILL.md +1 -1
  57. package/skills/education-cloud-student-recruitment-agent-configure/SKILL.md +1 -1
  58. package/skills/experience-design-validate/SKILL.md +1 -1
  59. package/skills/experience-search-coordinate/SKILL.md +18 -12
  60. package/skills/experience-search-coordinate/references/content-route.md +79 -1
  61. package/skills/field-service-data-capture-migrate/SKILL.md +1 -1
  62. package/skills/platform-apex-generate/SKILL.md +14 -11
  63. package/skills/platform-apex-test-generate/SKILL.md +2 -1
  64. package/skills/platform-datamask-run/SKILL.md +52 -71
  65. package/skills/platform-datamask-run/references/api-surface.md +10 -4
  66. package/skills/platform-datamask-run/references/policy-authoring.md +56 -7
  67. package/skills/platform-datamask-run/references/run-and-abort.md +17 -10
  68. package/skills/platform-datamask-run/scripts/poll-job.sh +15 -25
  69. package/skills/platform-dataspace-access-configure/SKILL.md +19 -1
  70. package/skills/platform-metadata-discovery-get/SKILL.md +185 -0
  71. package/skills/platform-metadata-discovery-get/references/api-map.md +25 -0
  72. package/skills/platform-metadata-discovery-get/references/h360-routes.md +30 -0
  73. package/skills/platform-metadata-discovery-get/references/scoped-soql.md +52 -0
  74. package/skills/platform-metadata-grounding-get/SKILL.md +170 -0
  75. package/skills/platform-policy-rule-generate/SKILL.md +84 -232
  76. package/skills/platform-policy-rule-generate/references/condition-model.md +47 -0
  77. package/skills/platform-policy-rule-generate/references/condition-templates.md +89 -0
  78. package/skills/platform-policy-rule-generate/references/conjunction-compiler.md +25 -0
  79. package/skills/platform-policy-rule-generate/references/conjunction-expression.md +22 -0
  80. package/skills/platform-policy-rule-generate/references/consumer-all.md +9 -0
  81. package/skills/platform-policy-rule-generate/references/consumer-datacloud-ui.md +91 -0
  82. package/skills/platform-policy-rule-generate/references/deploy-errors.md +24 -36
  83. package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/package.xml +12 -0
  84. package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitionSets/ExistsJoinRLS.policyRuleDefinitionSet +6 -0
  85. package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitions/ExistsJoinRLS_Rule_1.policyRuleDefinition +91 -0
  86. package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/package.xml +12 -0
  87. package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitionSets/FieldReadDeny.policyRuleDefinitionSet +6 -0
  88. package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitions/FieldReadDeny_Rule_1.policyRuleDefinition +72 -0
  89. package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/package.xml +12 -0
  90. package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitionSets/LastCharactersFieldMasking.policyRuleDefinitionSet +6 -0
  91. package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitions/LastCharactersFieldMasking_Rule_1.policyRuleDefinition +77 -0
  92. package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/package.xml +12 -0
  93. package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitionSets/PrincipalFieldMatchRLS.policyRuleDefinitionSet +6 -0
  94. package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitions/PrincipalFieldMatchRLS_Rule_1.policyRuleDefinition +77 -0
  95. package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/package.xml +12 -0
  96. package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitionSets/TaggedObjectAccess.policyRuleDefinitionSet +4 -0
  97. package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitions/TaggedObjectAccess_Rule_1.policyRuleDefinition +65 -0
  98. package/skills/platform-policy-rule-generate/references/headless-transport.md +87 -0
  99. package/skills/platform-policy-rule-generate/references/policy-schema-full.md +17 -10
  100. package/skills/platform-policy-rule-generate/references/policy-shapes.md +74 -0
  101. package/skills/platform-policy-rule-generate/references/principal-expression.md +31 -0
  102. package/skills/platform-policy-rule-generate/references/reference-resolution.md +51 -0
  103. package/skills/platform-policy-rule-generate/references/resource-expression.md +48 -0
  104. package/skills/platform-policy-rule-generate/references/runtime-context.md +9 -0
  105. package/skills/platform-policy-rule-generate/references/templates-access.md +11 -9
  106. package/skills/platform-policy-rule-generate/references/templates-advanced.md +4 -2
  107. package/skills/platform-policy-rule-generate/references/templates-record.md +37 -22
  108. package/skills/platform-policy-rule-generate/references/templates-transform.md +10 -8
  109. package/skills/platform-policy-rule-generate/references/templates.md +2 -2
  110. package/skills/platform-policy-rule-generate/references/ui-compatibility.md +35 -17
  111. package/skills/platform-report-generate/SKILL.md +9 -4
  112. package/skills/platform-report-generate/references/chart-types.md +26 -2
  113. package/skills/platform-report-generate/references/column-names.md +43 -0
  114. package/skills/platform-report-generate/references/errors-and-troubleshooting.md +1 -1
  115. package/skills/platform-report-generate/references/verification-checklist.md +10 -2
  116. package/skills/service-digital-engagement-channel-configure/SKILL.md +7 -5
  117. package/skills/service-digital-engagement-channel-configure/assets/messaging_channel_template.xml +33 -0
  118. 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
- Generate a self-contained HTML offline-analysis report from replay results JSON.
4
- Adapted from hawking/ai-agent-backtesting PR #53 (build_failure_report.py).
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
- Usage:
7
- python build_report.py [replay_results.json] [output.html]
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
- import json, sys, html as _html, textwrap
10
- from pathlib import Path
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
- def _calls(ans_json):
18
- """Extract [(name, args_dict), ...] from a structured answer JSON string."""
19
- try:
20
- ans = json.loads(ans_json) if isinstance(ans_json, str) else ans_json
21
- except (json.JSONDecodeError, TypeError):
22
- return []
23
- tcs = ans.get("tool_calls") or []
24
- out = []
25
- for tc in tcs:
26
- name = tc.get("name", "")
27
- args = tc.get("arguments", {})
28
- if isinstance(args, str):
29
- try:
30
- args = json.loads(args)
31
- except Exception:
32
- args = {"raw": args}
33
- out.append((name, args))
34
- return out
35
-
36
-
37
- def _is_router(d):
38
- golden = _calls(d.get("GoldenAnswer", "{}"))
39
- if d.get("GoldenType") == "topic":
40
- return True
41
- for name, _ in golden:
42
- if any(name.lower().startswith(p) for p in ("route_to", "go_to", "transfer_to")):
43
- return True
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 _esc(text):
99
- return _html.escape(str(text)) if text else ""
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
- def _card(d, rec, idx):
116
- """Render an expandable card for a single failing row."""
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
- flags = []
126
- if d.get("ScoringError"):
127
- flags.append(f'<span class="flag flag-error">{_esc(d["ScoringError"][:80])}</span>')
128
- if d.get("ToolAltReason"):
129
- flags.append(f'<span class="flag flag-warn">{_esc(d["ToolAltReason"])}</span>')
130
- if d.get("FunctionArgsScoreReason"):
131
- flags.append(f'<span class="flag flag-info">Args: {_esc(str(d["FunctionArgsScoreReason"])[:120])}</span>')
132
-
133
- # Status badge
134
- if d.get("PassStrict") or d.get("FunctionArgsAgreement"):
135
- status_cls = "badge-pass"
136
- status_txt = "PASS"
137
- elif d.get("FunctionNameAgreementLenient"):
138
- status_cls = "badge-warn"
139
- status_txt = "PARTIAL"
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
- status_cls = "badge-fail"
142
- status_txt = "FAIL"
143
-
144
- # Golden vs Predicted comparison
145
- golden_html = ""
146
- for name, args in golden:
147
- golden_html += f'<div class="tool-call"><span class="tool-name">{_esc(name)}</span>'
148
- golden_html += f'<pre class="tool-args">{_json_pretty(args)}</pre></div>'
149
- if not golden:
150
- try:
151
- ga = json.loads(d.get("GoldenAnswer", "{}"))
152
- if ga.get("content"):
153
- golden_html = f'<div class="text-resp">{_esc(ga["content"][:300])}</div>'
154
- except Exception:
155
- pass
156
-
157
- predicted_html = ""
158
- for name, args in predicted:
159
- predicted_html += f'<div class="tool-call"><span class="tool-name">{_esc(name)}</span>'
160
- predicted_html += f'<pre class="tool-args">{_json_pretty(args)}</pre></div>'
161
- if not predicted:
162
- try:
163
- pa = json.loads(d.get("PredictedAnswer", "{}"))
164
- if pa.get("content"):
165
- predicted_html = f'<div class="text-resp">{_esc(pa["content"][:300])}</div>'
166
- except Exception:
167
- pass
168
-
169
- # Tools offered
170
- tools_offered = rec.get("tools", []) if rec else []
171
- tools_html = ", ".join(f'<code>{_esc(t)}</code>' for t in tools_offered) if tools_offered else "<em>none</em>"
172
-
173
- # Transcript (all messages, full content)
174
- messages = rec.get("messages", []) if rec else []
175
- transcript_html = ""
176
- for msg in messages:
177
- role = msg.get("role", "unknown")
178
- content = str(msg.get("content", ""))
179
- transcript_html += f'<div class="msg msg-{_esc(role)}"><strong>{_esc(role)}</strong>: {_esc(content)}</div>'
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="{status_cls}">{status_txt}</span>
186
- <span class="card-bucket">{_esc(bucket)}</span>
187
- <span class="card-utterance">{_esc(d.get("Utterance", "")[:80])}</span>
188
- <span class="card-session">{_esc(d.get("AiAgentSessionId", "")[:12])}...</span>
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
- <h4>Golden (Expected)</h4>
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
- <details class="transcript-toggle">
221
- <summary>Transcript ({len(messages)} messages)</summary>
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
- # Main HTML builder
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 &middot; end type: {end_types} &middot; with action errors: '
244
+ f'{p["sessions_with_action_errors"]} &middot; 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 &middot; {a["match"]} match &middot; 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"]} &middot; 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 &middot; {o["match"]} match &middot; 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
- # A row passes if strict pass, args agreement, OR lenient name match (equivalent tool)
635
- def _row_passes(d):
636
- return d.get("PassStrict") or d.get("FunctionArgsAgreement") or d.get("FunctionNameAgreementLenient")
637
-
638
- failing = [d for d in rows if not _row_passes(d)]
639
- bucket_counts = Counter()
640
- for d in failing:
641
- name_hit = d.get("FunctionNameAgreement", False)
642
- bucket_counts[_bucket(d, name_hit)] += 1
643
- max_bucket = max(bucket_counts.values()) if bucket_counts else 1
644
-
645
- passing = [d for d in rows if _row_passes(d)]
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 &middot; {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 &middot; {a_verdict}</div>
703
- <div class="scorecard-detail">{name_lenient}/{len(actions)} tool match (lenient) &middot; {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 &middot; {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 &middot; {len(passing)} pass &middot; {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
- rows = data.get("rows", [])
789
- passing = sum(1 for d in rows
790
- if d.get("PassStrict") or d.get("FunctionArgsAgreement") or d.get("FunctionNameAgreementLenient"))
791
- print(f"Report generated: {output_path}")
792
- print(f" {len(rows)} rows, {passing} passing, {len(rows) - passing} failing")
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())