@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
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Run-dir artifacts — the stored traces of one replay run.
|
|
2
|
+
|
|
3
|
+
units.jsonl replayed units: request + golden (bt.unit/1); replay again with --from-units
|
|
4
|
+
results.jsonl one line per unit x repeat: response, scores, status (bt.result/1)
|
|
5
|
+
summary.json aggregates, run config, code fingerprint (bt.summary/1)
|
|
6
|
+
replay_results_sfap.csv the legacy CSV columns, one row per unit x repeat, plus unit columns
|
|
7
|
+
|
|
8
|
+
Run dirs hold user utterances: keep them out of git. Every writer goes through redact_obj and
|
|
9
|
+
creates its file readable by its owner only.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import csv
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import statistics
|
|
17
|
+
import subprocess
|
|
18
|
+
from collections import Counter, defaultdict
|
|
19
|
+
|
|
20
|
+
from . import SCHEMA_RESULT, SCHEMA_SUMMARY
|
|
21
|
+
from .redact import redact_obj
|
|
22
|
+
from .scoring import MATCH_BUCKETS
|
|
23
|
+
from .sfap import TIMEOUT_KINDS
|
|
24
|
+
|
|
25
|
+
UNIT_FIELDS = ("unit_id", "session_id", "turn", "step", "step_id", "interaction_id", "org", "agent",
|
|
26
|
+
"agent_version", "compatible_versions", "node", "step_kind", "split", "channel", "topic",
|
|
27
|
+
"user_utterance", "production")
|
|
28
|
+
SCORE_FIELDS = ("decision", "names", "names_strict", "args", "text", "score", "strict", "bucket",
|
|
29
|
+
"detail", "args_detail", "judge", "schema_violations", "topic_match", "confusion",
|
|
30
|
+
"proxies")
|
|
31
|
+
LEGACY_CSV_FIELDS = ["session_id", "utterance", "original_model", "replay_model", "actual_topic",
|
|
32
|
+
"replayed_topic", "topic_match", "actual_action", "replayed_action",
|
|
33
|
+
"function_match_score", "function_args_score", "actual_outcome",
|
|
34
|
+
"replayed_outcome", "text_resp_score"]
|
|
35
|
+
CSV_FIELDS = LEGACY_CSV_FIELDS + ["unit_id", "node", "step_kind", "repeat", "status", "bucket",
|
|
36
|
+
"unit_score"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def unit_kind(unit):
|
|
40
|
+
"""router_tool | router_text | subagent_tool | subagent_text."""
|
|
41
|
+
return f"{unit.get('step_kind')}_{unit['golden']['kind']}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def make_record(unit, repeat, model, seed, request_sha, call, scores=None, patch=None):
|
|
45
|
+
"""One results.jsonl line. A failed judge call turns the record into an infra error.
|
|
46
|
+
patch: {"base_request_sha", "ops"} of a patched run (--patch): the unpatched request's
|
|
47
|
+
sha and the ops that matched this unit."""
|
|
48
|
+
rec = {"schema": SCHEMA_RESULT}
|
|
49
|
+
rec.update({k: unit.get(k) for k in UNIT_FIELDS})
|
|
50
|
+
patch = patch or {}
|
|
51
|
+
rec.update(kind=unit_kind(unit), repeat=repeat, model=model,
|
|
52
|
+
golden_model=unit["request"].get("model"), seed=seed, request_sha=request_sha,
|
|
53
|
+
base_request_sha=patch.get("base_request_sha"), patch_ops=patch.get("ops"),
|
|
54
|
+
status=call["status"], error=call.get("error"), error_kind=call.get("error_kind"),
|
|
55
|
+
http_status=call.get("http_status"), attempts=call.get("attempts"),
|
|
56
|
+
latency_ms=call.get("latency_ms"), cached=bool(call.get("cached")))
|
|
57
|
+
resp = call.get("response")
|
|
58
|
+
rec["response"] = {k: resp.get(k) for k in ("text", "tool_calls", "finish_reason")} if resp else None
|
|
59
|
+
rec["usage"] = resp.get("usage") if resp else None
|
|
60
|
+
rec["golden"] = unit["golden"]
|
|
61
|
+
rec.update({k: None for k in SCORE_FIELDS})
|
|
62
|
+
if scores is not None:
|
|
63
|
+
if scores.get("judge_error"):
|
|
64
|
+
rec.update(status="infra_error", error_kind="judge", error=scores["judge_error"])
|
|
65
|
+
else:
|
|
66
|
+
rec.update({k: scores.get(k) for k in SCORE_FIELDS})
|
|
67
|
+
return rec
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def open_private(path, **kwargs):
|
|
71
|
+
"""A file opened for writing that only its owner can read (mode 0600), before anything is written."""
|
|
72
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
73
|
+
try:
|
|
74
|
+
os.chmod(path, 0o600) # an existing file keeps its mode through os.open
|
|
75
|
+
return os.fdopen(fd, "w", encoding="utf-8", **kwargs)
|
|
76
|
+
except BaseException:
|
|
77
|
+
os.close(fd)
|
|
78
|
+
raise
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def write_jsonl(path, rows):
|
|
82
|
+
with open_private(path) as f:
|
|
83
|
+
for row in rows:
|
|
84
|
+
f.write(json.dumps(redact_obj(row), ensure_ascii=False) + "\n")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def append_jsonl(f, row):
|
|
88
|
+
f.write(json.dumps(redact_obj(row), ensure_ascii=False) + "\n")
|
|
89
|
+
f.flush()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def read_jsonl(path):
|
|
93
|
+
with open(path, encoding="utf-8") as f:
|
|
94
|
+
return [json.loads(line) for line in f if line.strip()]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def write_json(path, obj):
|
|
98
|
+
with open_private(path) as f:
|
|
99
|
+
json.dump(redact_obj(obj), f, indent=2, ensure_ascii=False)
|
|
100
|
+
f.write("\n")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# Modules that neither replay nor score: editing them must not make two runs incomparable.
|
|
104
|
+
NOT_FINGERPRINTED = ("agentpatch.py", "compare.py", "fetch.py")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def code_fingerprint():
|
|
108
|
+
"""Hash of the btlib sources that replay and score, plus the git commit, so runs scored by
|
|
109
|
+
different code are told apart."""
|
|
110
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
111
|
+
h = hashlib.sha256()
|
|
112
|
+
for name in sorted(os.listdir(here)):
|
|
113
|
+
if name.endswith(".py") and name not in NOT_FINGERPRINTED:
|
|
114
|
+
with open(os.path.join(here, name), "rb") as f:
|
|
115
|
+
h.update(name.encode() + b"\0" + f.read() + b"\0")
|
|
116
|
+
out = {"btlib_sha256": h.hexdigest()[:16], "git": None, "git_dirty": None}
|
|
117
|
+
try:
|
|
118
|
+
git = subprocess.run(["git", "-C", here, "rev-parse", "--short=12", "HEAD"],
|
|
119
|
+
capture_output=True, text=True, timeout=10)
|
|
120
|
+
if git.returncode == 0:
|
|
121
|
+
out["git"] = git.stdout.strip()
|
|
122
|
+
dirty = subprocess.run(["git", "-C", here, "status", "--porcelain", "--", "."],
|
|
123
|
+
capture_output=True, text=True, timeout=10)
|
|
124
|
+
out["git_dirty"] = bool(dirty.stdout.strip())
|
|
125
|
+
except (OSError, subprocess.SubprocessError):
|
|
126
|
+
pass
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def aggregate(rows):
|
|
131
|
+
n = len(rows)
|
|
132
|
+
if not n:
|
|
133
|
+
return {"n": 0, "agreement": None, "strict": None, "match": 0}
|
|
134
|
+
return {"n": n,
|
|
135
|
+
"agreement": round(sum(r["score"] for r in rows) / n, 4),
|
|
136
|
+
"strict": round(sum(r["strict"] for r in rows) / n, 4),
|
|
137
|
+
"match": sum(1 for r in rows if r["bucket"] in MATCH_BUCKETS)}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _group(rows, keys_of):
|
|
141
|
+
groups = defaultdict(list)
|
|
142
|
+
for r in rows:
|
|
143
|
+
for k in keys_of(r):
|
|
144
|
+
groups[k].append(r)
|
|
145
|
+
out = {}
|
|
146
|
+
for k in sorted(groups):
|
|
147
|
+
agg = aggregate(groups[k])
|
|
148
|
+
agg["buckets"] = dict(Counter(r["bucket"] for r in groups[k]).most_common())
|
|
149
|
+
out[k] = agg
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def golden_tools(rec):
|
|
154
|
+
"""Tools the golden called; '(text)' for a text golden."""
|
|
155
|
+
return sorted({c["name"] for c in rec["golden"]["tool_calls"]}) or ["(text)"]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def groundedness(rec):
|
|
159
|
+
"""The category production's groundedness check gave the record's turn; '(none)' without one."""
|
|
160
|
+
return (rec.get("production") or {}).get("groundedness") or "(none)"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def production_summary(records):
|
|
164
|
+
"""Production's own checks over the turns and sessions of these records (report-only)."""
|
|
165
|
+
turns, sessions = {}, {}
|
|
166
|
+
for r in records:
|
|
167
|
+
p = r.get("production")
|
|
168
|
+
if p:
|
|
169
|
+
turns.setdefault((r["session_id"], r["turn"]), p)
|
|
170
|
+
sessions.setdefault(r["session_id"], p)
|
|
171
|
+
guardrails = defaultdict(Counter)
|
|
172
|
+
for p in turns.values():
|
|
173
|
+
for name, value in (p.get("guardrails") or {}).items():
|
|
174
|
+
guardrails[name][value] += 1
|
|
175
|
+
return {"turns": len(turns),
|
|
176
|
+
"groundedness": dict(Counter(p.get("groundedness") or "(none)" for p in turns.values()).most_common()),
|
|
177
|
+
"guardrails": {name: dict(c.most_common()) for name, c in sorted(guardrails.items())},
|
|
178
|
+
"turns_with_step_errors": sum(1 for p in turns.values() if p.get("step_errors")),
|
|
179
|
+
"sessions": len(sessions),
|
|
180
|
+
"end_type": dict(Counter(p.get("session_end_type") or "(none)" for p in sessions.values()).most_common()),
|
|
181
|
+
"sessions_with_action_errors": sum(1 for p in sessions.values() if p.get("session_action_errors"))}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def summarize(records, config=None, skipped=None, unit_count=None):
|
|
185
|
+
ok = [r for r in records if r["status"] == "ok"]
|
|
186
|
+
infra = [r for r in records if r["status"] != "ok"]
|
|
187
|
+
n = len(records)
|
|
188
|
+
routed = [r for r in ok if r["topic_match"] is not None]
|
|
189
|
+
texts = [r for r in ok if r["proxies"]]
|
|
190
|
+
confusion = Counter(tuple(r["confusion"]) for r in ok if r["confusion"])
|
|
191
|
+
violations = Counter(v for r in ok for v in r["schema_violations"] or [])
|
|
192
|
+
return {
|
|
193
|
+
"schema": SCHEMA_SUMMARY,
|
|
194
|
+
"config": config or {},
|
|
195
|
+
"counts": {
|
|
196
|
+
"units": unit_count if unit_count is not None else len({r["unit_id"] for r in records}),
|
|
197
|
+
"records": n, "scored": len(ok), "infra_error": len(infra),
|
|
198
|
+
"cached": sum(1 for r in records if r.get("cached")),
|
|
199
|
+
"infra_rate": round(len(infra) / n, 4) if n else 0.0,
|
|
200
|
+
"infra_by_kind": dict(Counter(r["error_kind"] for r in infra).most_common()),
|
|
201
|
+
"timed_out_units": timed_out_units(records),
|
|
202
|
+
"skipped_steps": dict(skipped or {}),
|
|
203
|
+
},
|
|
204
|
+
"overall": aggregate(ok),
|
|
205
|
+
"by_kind": _group(ok, lambda r: [r["kind"]]),
|
|
206
|
+
"by_node": _group(ok, lambda r: [r["node"]]),
|
|
207
|
+
"by_tool": _group(ok, golden_tools),
|
|
208
|
+
"by_bucket": dict(Counter(r["bucket"] for r in ok).most_common()),
|
|
209
|
+
"by_split": _group(ok, lambda r: [r["split"]] if r.get("split") else []),
|
|
210
|
+
"by_groundedness": _group(ok, lambda r: [groundedness(r)]),
|
|
211
|
+
"production": production_summary(records),
|
|
212
|
+
"topic": {"n": len(routed), "match": sum(1 for r in routed if r["topic_match"]),
|
|
213
|
+
"rate": round(sum(1 for r in routed if r["topic_match"]) / len(routed), 4) if routed else None},
|
|
214
|
+
"confusion": [{"golden": g, "candidate": c, "n": k} for (g, c), k in confusion.most_common()],
|
|
215
|
+
"schema_violations": {"records": sum(1 for r in ok if r["schema_violations"]),
|
|
216
|
+
"notes": dict(violations.most_common())},
|
|
217
|
+
"text": {
|
|
218
|
+
"n": len(texts),
|
|
219
|
+
"median_len_ratio": round(statistics.median(r["proxies"]["len_ratio"] for r in texts), 3) if texts else None,
|
|
220
|
+
"question_ending_agrees": sum(1 for r in texts if r["proxies"]["golden_ends_with_question"]
|
|
221
|
+
== r["proxies"]["candidate_ends_with_question"]),
|
|
222
|
+
"raw_id_leaks": sum(1 for r in texts if r["proxies"]["candidate_raw_ids"] > r["proxies"]["golden_raw_ids"]),
|
|
223
|
+
},
|
|
224
|
+
"per_repeat": {str(rep): aggregate([r for r in ok if r["repeat"] == rep])
|
|
225
|
+
for rep in sorted({r["repeat"] for r in records})},
|
|
226
|
+
"judge_calls": sum(len(r["judge"] or []) for r in ok),
|
|
227
|
+
"usage": _usage_totals(records),
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def timed_out_units(records):
|
|
232
|
+
"""The units whose every record timed out: the model may not finish them (a runaway answer)."""
|
|
233
|
+
timed_out = defaultdict(list)
|
|
234
|
+
for r in records:
|
|
235
|
+
timed_out[r["unit_id"]].append(r["status"] != "ok" and r.get("error_kind") in TIMEOUT_KINDS)
|
|
236
|
+
return sum(1 for flags in timed_out.values() if all(flags))
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _usage_totals(records):
|
|
240
|
+
totals = Counter()
|
|
241
|
+
for r in records:
|
|
242
|
+
for k, v in (r.get("usage") or {}).items():
|
|
243
|
+
if isinstance(v, (int, float)) and not isinstance(v, bool):
|
|
244
|
+
totals[k] += v
|
|
245
|
+
return dict(totals)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _names(calls):
|
|
249
|
+
return "+".join(c["name"] or "" for c in calls or [])
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def csv_row(rec):
|
|
253
|
+
"""Legacy CSV columns for one record (topic columns only on router steps)."""
|
|
254
|
+
g_calls = rec["golden"]["tool_calls"]
|
|
255
|
+
resp = rec["response"] or {}
|
|
256
|
+
c_calls = resp.get("tool_calls") or []
|
|
257
|
+
ok = rec["status"] == "ok"
|
|
258
|
+
router = rec["step_kind"] == "router"
|
|
259
|
+
row = {
|
|
260
|
+
"session_id": rec["session_id"], "utterance": rec["user_utterance"],
|
|
261
|
+
"original_model": rec["golden_model"], "replay_model": rec["model"],
|
|
262
|
+
"actual_topic": _names(g_calls) if router else (rec["topic"] or ""),
|
|
263
|
+
"replayed_topic": _names(c_calls) if router and ok else "",
|
|
264
|
+
"topic_match": int(bool(rec["topic_match"])) if router and ok else "",
|
|
265
|
+
"actual_action": _names(g_calls), "replayed_action": _names(c_calls),
|
|
266
|
+
"function_match_score": "", "function_args_score": "",
|
|
267
|
+
"actual_outcome": rec["golden"]["text"], "replayed_outcome": resp.get("text") or "",
|
|
268
|
+
"text_resp_score": "",
|
|
269
|
+
"unit_id": rec["unit_id"], "node": rec["node"], "step_kind": rec["step_kind"],
|
|
270
|
+
"repeat": rec["repeat"], "status": rec["status"], "bucket": rec["bucket"] or "",
|
|
271
|
+
"unit_score": rec["score"] if ok else "",
|
|
272
|
+
}
|
|
273
|
+
if ok:
|
|
274
|
+
if g_calls or c_calls:
|
|
275
|
+
row["function_match_score"] = int(bool(g_calls and c_calls and rec["names_strict"]))
|
|
276
|
+
if rec["names"]:
|
|
277
|
+
row["function_args_score"] = int(bool(rec["args"]))
|
|
278
|
+
if not g_calls and not c_calls:
|
|
279
|
+
row["text_resp_score"] = int(bool(rec["text"]))
|
|
280
|
+
return row
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def write_csv(path, records):
|
|
284
|
+
with open_private(path, newline="") as f:
|
|
285
|
+
w = csv.DictWriter(f, fieldnames=CSV_FIELDS)
|
|
286
|
+
w.writeheader()
|
|
287
|
+
for rec in records:
|
|
288
|
+
w.writerow(redact_obj(csv_row(rec)))
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Score one replayed step against its golden (the production answer to the same request).
|
|
2
|
+
|
|
3
|
+
Golden is a tool call:
|
|
4
|
+
decision candidate also called a tool (not text)
|
|
5
|
+
names same multiset of tool names (optionally lenient via configured equivalence groups)
|
|
6
|
+
args every argument the same: IDs, codes, enums and numbers compared exactly,
|
|
7
|
+
missing == empty, free-text strings sent to the judge. A key one call leaves out
|
|
8
|
+
takes its default (the agent config's arg_defaults, else the schema's "default").
|
|
9
|
+
Not compared on a transition that declares no parameters (it takes none)
|
|
10
|
+
unit score 1 (all match) | 0.5 (decision + names, args differ) | 0
|
|
11
|
+
Golden is text:
|
|
12
|
+
candidate must reply with text the judge finds equivalent: 1 | 0. A tool call scores 0.
|
|
13
|
+
Strict score: 1 only for exact names (no equivalence groups), matching args with no default
|
|
14
|
+
taken, or equivalent text.
|
|
15
|
+
|
|
16
|
+
Buckets: match, transition_args (a match only because arguments on such a transition were not
|
|
17
|
+
compared), wrong_args, wrong_tool, missed_call, extra_call, tool_to_text, text_to_tool,
|
|
18
|
+
text_divergence.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
from collections import Counter
|
|
24
|
+
|
|
25
|
+
from .agentsrc import TRANSITION_SUFFIX
|
|
26
|
+
|
|
27
|
+
MATCH_BUCKETS = ("match", "transition_args") # the buckets that score 1
|
|
28
|
+
# Last word of a parameter name that marks an identifier (productCode, order_id, SKU, email).
|
|
29
|
+
_ID_WORDS = {"id", "ids", "code", "codes", "number", "num", "sku", "email", "phone", "uuid", "url"}
|
|
30
|
+
_KEY_WORDS = re.compile(r"[A-Z]?[a-z0-9]+|[A-Z]+(?![a-z])")
|
|
31
|
+
_ID_VALUE = re.compile(r"^(?=\S*\d)\S+$") # one token containing a digit: SHOE-M-088, 006Rt00000AbCdE
|
|
32
|
+
_SF_ID = re.compile(r"\b(?=[A-Za-z0-9]*\d)(?=[A-Za-z0-9]*[A-Za-z])[A-Za-z0-9]{15}(?:[A-Za-z0-9]{3})?\b")
|
|
33
|
+
_SF_ID_FULL = re.compile(r"^(?=[A-Za-z0-9]*\d)(?=[A-Za-z0-9]*[A-Za-z])[A-Za-z0-9]{15}(?:[A-Za-z0-9]{3})?$")
|
|
34
|
+
_UUID = re.compile(r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _id_key(key):
|
|
38
|
+
words = _KEY_WORDS.findall(key or "")
|
|
39
|
+
return bool(words) and words[-1].lower() in _ID_WORDS
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _empty(v):
|
|
43
|
+
return v is None or v == "" or v == [] or v == {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _num(v):
|
|
47
|
+
if isinstance(v, bool):
|
|
48
|
+
return None
|
|
49
|
+
if isinstance(v, (int, float)):
|
|
50
|
+
return float(v)
|
|
51
|
+
if isinstance(v, str):
|
|
52
|
+
try:
|
|
53
|
+
return float(v.strip())
|
|
54
|
+
except ValueError:
|
|
55
|
+
return None
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _bool(v):
|
|
60
|
+
if isinstance(v, bool):
|
|
61
|
+
return v
|
|
62
|
+
if isinstance(v, str) and v.strip().lower() in ("true", "false"):
|
|
63
|
+
return v.strip().lower() == "true"
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def values_equal(a, b):
|
|
68
|
+
"""Deterministic equality with light normalisation (case/space for strings, '2' == 2).
|
|
69
|
+
|
|
70
|
+
Salesforce record ids stay case-sensitive: 15-character ids differing only in case
|
|
71
|
+
are different records.
|
|
72
|
+
"""
|
|
73
|
+
if _empty(a) and _empty(b):
|
|
74
|
+
return True
|
|
75
|
+
if isinstance(a, str) and isinstance(b, str):
|
|
76
|
+
a, b = a.strip(), b.strip()
|
|
77
|
+
if _SF_ID_FULL.match(a) or _SF_ID_FULL.match(b):
|
|
78
|
+
return a == b
|
|
79
|
+
return " ".join(a.split()).lower() == " ".join(b.split()).lower()
|
|
80
|
+
na, nb = _num(a), _num(b)
|
|
81
|
+
if na is not None and nb is not None:
|
|
82
|
+
return na == nb
|
|
83
|
+
ba, bb = _bool(a), _bool(b)
|
|
84
|
+
if ba is not None and bb is not None:
|
|
85
|
+
return ba == bb
|
|
86
|
+
return json.dumps(a, sort_keys=True) == json.dumps(b, sort_keys=True)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def is_free_text(key, golden_value, candidate_value, prop_schema):
|
|
90
|
+
"""Free text may be equivalent without being equal (a search phrase); IDs and enums may not."""
|
|
91
|
+
if not (isinstance(golden_value, str) and isinstance(candidate_value, str)):
|
|
92
|
+
return False
|
|
93
|
+
if (prop_schema or {}).get("enum") or (prop_schema or {}).get("pattern"):
|
|
94
|
+
return False
|
|
95
|
+
if _id_key(key):
|
|
96
|
+
return False
|
|
97
|
+
if _ID_VALUE.match(golden_value.strip()) or _ID_VALUE.match(candidate_value.strip()):
|
|
98
|
+
return False
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def compare_args(golden_args, candidate_args, schema=None, defaults=None):
|
|
103
|
+
"""{"equal": [...], "differ": [...], "free_text": [...], "defaulted": [...], "omitted": {...}}
|
|
104
|
+
over the union of keys.
|
|
105
|
+
|
|
106
|
+
A key one call leaves out (or sends empty) takes its default: defaults[key] (the agent
|
|
107
|
+
config's arg_defaults for the tool), else the schema's "default". "defaulted" lists the
|
|
108
|
+
equal keys that are equal only so; "omitted" maps each differing key one call left out to
|
|
109
|
+
that call, "golden" or "candidate".
|
|
110
|
+
"""
|
|
111
|
+
props = (schema or {}).get("properties") or {}
|
|
112
|
+
known = {k: p["default"] for k, p in props.items() if isinstance(p, dict) and "default" in p}
|
|
113
|
+
known.update(defaults or {})
|
|
114
|
+
out = {"equal": [], "differ": [], "free_text": [], "defaulted": [], "omitted": {}}
|
|
115
|
+
for key in sorted(set(golden_args) | set(candidate_args)):
|
|
116
|
+
g, c = golden_args.get(key), candidate_args.get(key)
|
|
117
|
+
left_out = "golden" if _empty(g) else "candidate" if _empty(c) else None
|
|
118
|
+
if values_equal(g, c):
|
|
119
|
+
out["equal"].append(key)
|
|
120
|
+
elif left_out and key in known and values_equal(known[key], c if left_out == "golden" else g):
|
|
121
|
+
out["equal"].append(key)
|
|
122
|
+
out["defaulted"].append(key)
|
|
123
|
+
elif is_free_text(key, g, c, props.get(key)):
|
|
124
|
+
out["free_text"].append(key)
|
|
125
|
+
else:
|
|
126
|
+
out["differ"].append(key)
|
|
127
|
+
if left_out:
|
|
128
|
+
out["omitted"][key] = left_out
|
|
129
|
+
return out
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def schema_violations(calls, schemas):
|
|
133
|
+
"""Problems of candidate calls against the offered tools' parameter schemas."""
|
|
134
|
+
notes = []
|
|
135
|
+
types = {"string": str, "boolean": bool, "object": dict, "array": list}
|
|
136
|
+
for call in calls:
|
|
137
|
+
name, args = call.get("name"), call.get("args") or {}
|
|
138
|
+
if "args_raw" in call:
|
|
139
|
+
notes.append(f"{name}: arguments are not a JSON object")
|
|
140
|
+
if name not in schemas:
|
|
141
|
+
notes.append(f"{name}: tool was not offered")
|
|
142
|
+
continue
|
|
143
|
+
schema = schemas[name] or {}
|
|
144
|
+
props = schema.get("properties") or {}
|
|
145
|
+
for req in schema.get("required") or []:
|
|
146
|
+
if _empty(args.get(req)):
|
|
147
|
+
notes.append(f"{name}: missing required '{req}'")
|
|
148
|
+
for key, val in args.items():
|
|
149
|
+
if props and key not in props:
|
|
150
|
+
notes.append(f"{name}: unknown parameter '{key}'")
|
|
151
|
+
continue
|
|
152
|
+
ps = props.get(key) or {}
|
|
153
|
+
t = ps.get("type")
|
|
154
|
+
if t in ("number", "integer"):
|
|
155
|
+
n = _num(val)
|
|
156
|
+
if n is None or (t == "integer" and not n.is_integer()):
|
|
157
|
+
notes.append(f"{name}.{key}: expected {t}")
|
|
158
|
+
elif t in types and val is not None and not isinstance(val, types[t]):
|
|
159
|
+
notes.append(f"{name}.{key}: expected {t}")
|
|
160
|
+
if ps.get("enum") and val not in ps["enum"]:
|
|
161
|
+
notes.append(f"{name}.{key}: not one of the allowed values")
|
|
162
|
+
if isinstance(val, str):
|
|
163
|
+
if ps.get("pattern"):
|
|
164
|
+
try:
|
|
165
|
+
if not re.search(ps["pattern"], val):
|
|
166
|
+
notes.append(f"{name}.{key}: does not match the pattern")
|
|
167
|
+
except re.error:
|
|
168
|
+
pass
|
|
169
|
+
if ps.get("maxLength") is not None and len(val) > ps["maxLength"]:
|
|
170
|
+
notes.append(f"{name}.{key}: longer than maxLength {ps['maxLength']}")
|
|
171
|
+
if ps.get("minLength") is not None and len(val) < ps["minLength"]:
|
|
172
|
+
notes.append(f"{name}.{key}: shorter than minLength {ps['minLength']}")
|
|
173
|
+
return notes
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def text_proxies(golden_text, candidate_text):
|
|
177
|
+
"""Report-only text signals; not part of the unit score."""
|
|
178
|
+
def ids(t):
|
|
179
|
+
return len(_SF_ID.findall(t or "")) + len(_UUID.findall(t or ""))
|
|
180
|
+
g, c = golden_text or "", candidate_text or ""
|
|
181
|
+
return {
|
|
182
|
+
"len_ratio": round(len(c) / max(1, len(g)), 3),
|
|
183
|
+
"golden_ends_with_question": g.rstrip().endswith("?"),
|
|
184
|
+
"candidate_ends_with_question": c.rstrip().endswith("?"),
|
|
185
|
+
"golden_raw_ids": ids(g),
|
|
186
|
+
"candidate_raw_ids": ids(c),
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class Equivalence:
|
|
191
|
+
"""Tool-name equivalence groups from the agent config; empty (exact names) by default."""
|
|
192
|
+
|
|
193
|
+
def __init__(self, groups=None):
|
|
194
|
+
self.canon = {}
|
|
195
|
+
for i, group in enumerate(groups or []):
|
|
196
|
+
for name in group:
|
|
197
|
+
self.canon[name] = f"group{i}:" + "|".join(sorted(group))
|
|
198
|
+
|
|
199
|
+
def key(self, name):
|
|
200
|
+
return self.canon.get(name, name)
|
|
201
|
+
|
|
202
|
+
def same(self, a, b):
|
|
203
|
+
return a == b or self.key(a) == self.key(b)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def takes_no_args(fn):
|
|
207
|
+
"""Is this offered function a transition (the platform's suffix ends its description) that
|
|
208
|
+
declares no parameters?"""
|
|
209
|
+
fn = fn or {}
|
|
210
|
+
return ((fn.get("description") or "").endswith(TRANSITION_SUFFIX)
|
|
211
|
+
and not (fn.get("parameters") or {}).get("properties"))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _pair_calls(golden_calls, candidate_calls, eq):
|
|
215
|
+
"""Pair golden and candidate calls with equivalent names; prefer pairs whose args are equal."""
|
|
216
|
+
pairs, used = [], set()
|
|
217
|
+
for g in golden_calls:
|
|
218
|
+
options = [i for i, c in enumerate(candidate_calls)
|
|
219
|
+
if i not in used and eq.same(g["name"], c["name"])]
|
|
220
|
+
if not options:
|
|
221
|
+
continue
|
|
222
|
+
best = next((i for i in options if values_equal(g.get("args") or {}, candidate_calls[i].get("args") or {})),
|
|
223
|
+
options[0])
|
|
224
|
+
used.add(best)
|
|
225
|
+
pairs.append((g, candidate_calls[best]))
|
|
226
|
+
return pairs
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def score_unit(unit, response, judge=None, equivalence=None, arg_defaults=None):
|
|
230
|
+
"""Score one ok response. Returns a dict; 'judge_error' is set when a needed judge call failed.
|
|
231
|
+
arg_defaults: {tool: {param: value}}, the agent config's defaults of the params a call may leave out."""
|
|
232
|
+
eq = equivalence or Equivalence()
|
|
233
|
+
arg_defaults = arg_defaults or {}
|
|
234
|
+
golden = unit["golden"]
|
|
235
|
+
msgs = unit["request"]["messages"]
|
|
236
|
+
offered = {(t.get("function") or {}).get("name"): t.get("function") or {} for t in unit["request"]["tools"]}
|
|
237
|
+
schemas = {name: fn.get("parameters") or {} for name, fn in offered.items()}
|
|
238
|
+
g_calls = golden.get("tool_calls") or []
|
|
239
|
+
c_calls = response.get("tool_calls") or []
|
|
240
|
+
c_text = response.get("text") or ""
|
|
241
|
+
out = {"golden_kind": golden["kind"], "decision": None, "names": None, "names_strict": None,
|
|
242
|
+
"args": None, "text": None, "score": 0.0, "strict": 0.0, "bucket": None,
|
|
243
|
+
"detail": "", "args_detail": [], "judge": [], "judge_error": None,
|
|
244
|
+
"schema_violations": schema_violations(c_calls, schemas) if c_calls else [],
|
|
245
|
+
"topic_match": None, "confusion": None, "proxies": None}
|
|
246
|
+
|
|
247
|
+
if unit.get("step_kind") == "router":
|
|
248
|
+
# Topic = where the router sent the turn (its go_to_* calls); text = stayed put.
|
|
249
|
+
out["topic_match"] = sorted(c["name"] for c in g_calls) == sorted(c["name"] for c in c_calls)
|
|
250
|
+
|
|
251
|
+
if golden["kind"] == "tool":
|
|
252
|
+
out["decision"] = bool(c_calls)
|
|
253
|
+
if not c_calls:
|
|
254
|
+
out["bucket"] = "tool_to_text"
|
|
255
|
+
out["detail"] = f"expected {', '.join(c['name'] for c in g_calls)}, replied with text"
|
|
256
|
+
out["confusion"] = ["+".join(sorted(c["name"] for c in g_calls)), "(text)"]
|
|
257
|
+
return out
|
|
258
|
+
g_names = Counter(eq.key(c["name"]) for c in g_calls)
|
|
259
|
+
c_names = Counter(eq.key(c["name"]) for c in c_calls)
|
|
260
|
+
out["names"] = g_names == c_names
|
|
261
|
+
out["names_strict"] = Counter(c["name"] for c in g_calls) == Counter(c["name"] for c in c_calls)
|
|
262
|
+
if not out["names"]:
|
|
263
|
+
if not (g_names - c_names) and (c_names - g_names):
|
|
264
|
+
out["bucket"] = "extra_call"
|
|
265
|
+
elif (g_names - c_names) and not (c_names - g_names):
|
|
266
|
+
out["bucket"] = "missed_call"
|
|
267
|
+
else:
|
|
268
|
+
out["bucket"] = "wrong_tool"
|
|
269
|
+
g_list = "+".join(sorted(c["name"] for c in g_calls))
|
|
270
|
+
c_list = "+".join(sorted(c["name"] for c in c_calls))
|
|
271
|
+
out["confusion"] = [g_list, c_list]
|
|
272
|
+
out["detail"] = f"expected {g_list}, got {c_list}"
|
|
273
|
+
return out
|
|
274
|
+
all_equal, not_compared, defaulted = True, [], False
|
|
275
|
+
for g, c in _pair_calls(g_calls, c_calls, eq):
|
|
276
|
+
cmp = compare_args(g.get("args") or {}, c.get("args") or {}, schemas.get(g["name"]),
|
|
277
|
+
arg_defaults.get(g["name"]))
|
|
278
|
+
entry = {"name": g["name"], "candidate_name": c["name"], **cmp, "equivalent": None}
|
|
279
|
+
if ((cmp["differ"] or cmp["free_text"]) and "args_raw" not in c
|
|
280
|
+
and takes_no_args(offered.get(g["name"])) and takes_no_args(offered.get(c["name"]))):
|
|
281
|
+
entry["equivalent"], entry["not_compared"] = True, True
|
|
282
|
+
not_compared += [f"{g['name']}.{k}" for k in cmp["differ"] + cmp["free_text"]]
|
|
283
|
+
elif cmp["differ"] or "args_raw" in c:
|
|
284
|
+
entry["equivalent"] = False
|
|
285
|
+
elif cmp["free_text"]:
|
|
286
|
+
if judge is None:
|
|
287
|
+
entry["equivalent"] = False
|
|
288
|
+
entry["judge_skipped"] = True
|
|
289
|
+
else:
|
|
290
|
+
verdict, err = judge.args(g["name"], schemas.get(g["name"]), g.get("args") or {},
|
|
291
|
+
c.get("args") or {}, msgs)
|
|
292
|
+
if verdict is None:
|
|
293
|
+
out["judge_error"] = err
|
|
294
|
+
return out
|
|
295
|
+
out["judge"].append({"for": f"args:{g['name']}", **verdict})
|
|
296
|
+
entry["equivalent"] = verdict["equivalent"]
|
|
297
|
+
else:
|
|
298
|
+
entry["equivalent"] = True
|
|
299
|
+
all_equal = all_equal and entry["equivalent"]
|
|
300
|
+
defaulted = defaulted or bool(cmp["defaulted"])
|
|
301
|
+
out["args_detail"].append(entry)
|
|
302
|
+
out["args"] = all_equal
|
|
303
|
+
if all_equal and not_compared:
|
|
304
|
+
out["bucket"], out["score"] = "transition_args", 1.0
|
|
305
|
+
out["detail"] = "arguments on a transition that takes none, not compared: " + ", ".join(not_compared)
|
|
306
|
+
elif all_equal:
|
|
307
|
+
out["bucket"], out["score"] = "match", 1.0
|
|
308
|
+
out["strict"] = 1.0 if out["names_strict"] and not defaulted else 0.0
|
|
309
|
+
else:
|
|
310
|
+
out["bucket"], out["score"] = "wrong_args", 0.5
|
|
311
|
+
differ = [f"{e['name']}.{k}" + (f" (left out by the {e['omitted'][k]})" if k in e["omitted"] else "")
|
|
312
|
+
for e in out["args_detail"] if not e["equivalent"]
|
|
313
|
+
for k in (e["differ"] + e["free_text"])]
|
|
314
|
+
out["detail"] = "arguments differ: " + ", ".join(differ) if differ else "arguments differ"
|
|
315
|
+
return out
|
|
316
|
+
|
|
317
|
+
# golden is text
|
|
318
|
+
out["decision"] = not c_calls
|
|
319
|
+
if c_calls:
|
|
320
|
+
out["bucket"] = "text_to_tool"
|
|
321
|
+
out["detail"] = f"expected a text reply, called {', '.join(c['name'] for c in c_calls)}"
|
|
322
|
+
out["confusion"] = ["(text)", "+".join(sorted(c["name"] for c in c_calls))]
|
|
323
|
+
return out
|
|
324
|
+
out["proxies"] = text_proxies(golden.get("text"), c_text)
|
|
325
|
+
if " ".join((golden.get("text") or "").split()) == " ".join(c_text.split()):
|
|
326
|
+
out["text"] = True
|
|
327
|
+
elif judge is None:
|
|
328
|
+
out["text"] = False
|
|
329
|
+
out["detail"] = "no judge configured"
|
|
330
|
+
else:
|
|
331
|
+
verdict, err = judge.text(golden.get("text") or "", c_text, msgs)
|
|
332
|
+
if verdict is None:
|
|
333
|
+
out["judge_error"] = err
|
|
334
|
+
return out
|
|
335
|
+
out["judge"].append({"for": "text", **verdict})
|
|
336
|
+
out["text"] = verdict["equivalent"]
|
|
337
|
+
out["detail"] = verdict.get("reasoning", "")
|
|
338
|
+
out["bucket"] = "match" if out["text"] else "text_divergence"
|
|
339
|
+
out["score"] = out["strict"] = 1.0 if out["text"] else 0.0
|
|
340
|
+
return out
|