master-skill 0.10.1 → 0.11.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/GEMINI.md +1 -1
- package/README.md +52 -297
- package/README_EN.md +52 -278
- package/bin/cli.mjs +237 -2
- package/gemini-extension.json +1 -1
- package/hooks/session-start +4 -1
- package/package.json +3 -2
- package/prebuilt/master-curriculum/SKILL.md +1 -1
- package/prebuilt/master-debate/SKILL.md +1 -1
- package/prebuilt/master-help/SKILL.md +86 -0
- package/prebuilt/master-help/tests/fidelity.jsonl +10 -0
- package/prebuilt/master-kumarajiva/meta.json +14 -3
- package/prebuilt/master-nagarjuna/meta.json +19 -4
- package/prebuilt/master-tsongkhapa/meta.json +26 -5
- package/references/teaching-modes.md +8 -1
- package/routing.json +209 -0
- package/scripts/check-gate-liveness.py +222 -0
- package/scripts/test-fidelity.py +320 -49
- package/scripts/tests/test_check_gate_liveness.py +232 -0
- package/scripts/tests/test_check_response.py +190 -0
- package/scripts/tests/test_fidelity_providers.py +202 -0
- package/scripts/tests/test_select_fidelity_smoke.py +2 -2
- package/scripts/tests/test_validate.py +145 -0
- package/scripts/tests/test_validate_citation_contract.py +1 -1
- package/scripts/tests/test_validate_fidelity.py +2 -2
- package/scripts/tests/test_validate_workflow.py +21 -2
- package/scripts/validate-fidelity.py +6 -1
- package/scripts/validate-routing.py +254 -0
- package/scripts/validate.py +63 -36
- package/skill-catalog.json +83 -20
- /package/prebuilt/{compare → compare-masters}/SKILL.md +0 -0
- /package/prebuilt/{compare → compare-masters}/tests/fidelity.jsonl +0 -0
package/scripts/test-fidelity.py
CHANGED
|
@@ -31,6 +31,161 @@ from pathlib import Path
|
|
|
31
31
|
from verify_citations import audit_answer, load_declared_ids
|
|
32
32
|
|
|
33
33
|
PREBUILT_DIR = Path(__file__).resolve().parent.parent / "prebuilt"
|
|
34
|
+
SCHEMA_VERSION = 1
|
|
35
|
+
|
|
36
|
+
# This project ships one prebuilt/ to five hosts (Claude Code, Cursor, Codex
|
|
37
|
+
# CLI, OpenCode, Gemini CLI), but every fidelity number it has produced came
|
|
38
|
+
# from one Anthropic model. A fixture measures whether the prompt induces the
|
|
39
|
+
# right behaviour, and that is a property of the prompt-and-model pair — so
|
|
40
|
+
# provider is an axis of the eval matrix, not a way to spend less.
|
|
41
|
+
#
|
|
42
|
+
# `api` selects the request/response shape. DeepSeek and Gemini both expose
|
|
43
|
+
# OpenAI-compatible endpoints, so one adapter covers them.
|
|
44
|
+
PROVIDERS: dict[str, dict] = {
|
|
45
|
+
"anthropic": {
|
|
46
|
+
"env": "ANTHROPIC_API_KEY",
|
|
47
|
+
"api": "anthropic",
|
|
48
|
+
"base_url": None,
|
|
49
|
+
"default_model": "claude-sonnet-4-6",
|
|
50
|
+
"models_url": "https://docs.claude.com/en/docs/about-claude/models",
|
|
51
|
+
},
|
|
52
|
+
"deepseek": {
|
|
53
|
+
"env": "DEEPSEEK_API_KEY",
|
|
54
|
+
"api": "openai",
|
|
55
|
+
"base_url": "https://api.deepseek.com/v1",
|
|
56
|
+
"default_model": None,
|
|
57
|
+
"models_url": "https://api-docs.deepseek.com/quick_start/pricing",
|
|
58
|
+
},
|
|
59
|
+
"gemini": {
|
|
60
|
+
"env": "GEMINI_API_KEY",
|
|
61
|
+
"api": "openai",
|
|
62
|
+
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai/",
|
|
63
|
+
"default_model": None,
|
|
64
|
+
"models_url": "https://ai.google.dev/gemini-api/docs/models",
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
DEFAULT_PROVIDER = "anthropic"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def resolve_provider(name: str) -> dict:
|
|
72
|
+
"""Look up a provider spec, failing with the list of what is available."""
|
|
73
|
+
try:
|
|
74
|
+
return PROVIDERS[name]
|
|
75
|
+
except KeyError:
|
|
76
|
+
known = ", ".join(sorted(PROVIDERS))
|
|
77
|
+
raise ValueError(f"unknown provider {name!r} — known providers: {known}") from None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def resolve_model(provider: str, explicit: str | None) -> str:
|
|
81
|
+
"""Pick the model id for a run.
|
|
82
|
+
|
|
83
|
+
Anthropic keeps a default so existing invocations are unchanged. Every other
|
|
84
|
+
provider must be named explicitly: a guessed model id committed to this repo
|
|
85
|
+
would rot silently, and a run that cannot say which model produced it is not
|
|
86
|
+
a reproducible measurement.
|
|
87
|
+
"""
|
|
88
|
+
if explicit:
|
|
89
|
+
return explicit
|
|
90
|
+
spec = resolve_provider(provider)
|
|
91
|
+
if spec["default_model"]:
|
|
92
|
+
return spec["default_model"]
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"provider {provider!r} has no default model — pass --model explicitly. "
|
|
95
|
+
f"Current model ids: {spec['models_url']}"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def build_request(
|
|
100
|
+
provider: str, model: str, system_prompt: str, question: str, max_tokens: int
|
|
101
|
+
) -> dict:
|
|
102
|
+
"""Build the request body for this provider's API shape."""
|
|
103
|
+
spec = resolve_provider(provider)
|
|
104
|
+
if spec["api"] == "anthropic":
|
|
105
|
+
return {
|
|
106
|
+
"model": model,
|
|
107
|
+
"max_tokens": max_tokens,
|
|
108
|
+
"system": system_prompt,
|
|
109
|
+
"messages": [{"role": "user", "content": question}],
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
"model": model,
|
|
113
|
+
"max_tokens": max_tokens,
|
|
114
|
+
"messages": [
|
|
115
|
+
{"role": "system", "content": system_prompt},
|
|
116
|
+
{"role": "user", "content": question},
|
|
117
|
+
],
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def extract_text(provider: str, response: object) -> str:
|
|
122
|
+
"""Pull the answer text out of this provider's response object.
|
|
123
|
+
|
|
124
|
+
Raises rather than returning "" on an empty response: a blank answer scored
|
|
125
|
+
as a normal case fails every must_mention and would be recorded as a persona
|
|
126
|
+
defect when it is really a transport problem.
|
|
127
|
+
"""
|
|
128
|
+
spec = resolve_provider(provider)
|
|
129
|
+
if spec["api"] == "anthropic":
|
|
130
|
+
blocks = getattr(response, "content", None) or []
|
|
131
|
+
for block in blocks:
|
|
132
|
+
if getattr(block, "type", None) == "text":
|
|
133
|
+
return block.text
|
|
134
|
+
raise ValueError(f"{provider}: response carried no text block")
|
|
135
|
+
|
|
136
|
+
choices = getattr(response, "choices", None) or []
|
|
137
|
+
if not choices:
|
|
138
|
+
raise ValueError(f"{provider}: response carried no choices")
|
|
139
|
+
content = getattr(choices[0].message, "content", None)
|
|
140
|
+
if content is None:
|
|
141
|
+
raise ValueError(f"{provider}: response message had no content")
|
|
142
|
+
return content
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def aggregation_conflicts(suites: list[dict]) -> list[str]:
|
|
146
|
+
"""Report why a set of suites must not be pooled into one number.
|
|
147
|
+
|
|
148
|
+
Two models are two instruments. Averaging a Sonnet run with a DeepSeek run
|
|
149
|
+
— or a Sonnet run with an Opus run — produces a figure that describes
|
|
150
|
+
neither. Report per model instead.
|
|
151
|
+
"""
|
|
152
|
+
seen = {
|
|
153
|
+
(s.get("provider", DEFAULT_PROVIDER), s.get("model"))
|
|
154
|
+
for s in suites
|
|
155
|
+
if s.get("model")
|
|
156
|
+
}
|
|
157
|
+
if len(seen) <= 1:
|
|
158
|
+
return []
|
|
159
|
+
listed = ", ".join(f"{p}/{m}" for p, m in sorted(seen))
|
|
160
|
+
return [
|
|
161
|
+
"refusing to aggregate across instruments — these suites came from "
|
|
162
|
+
f"different models ({listed}). Report a row per model."
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def suite_common(
|
|
167
|
+
master_name: str, dry_run: bool, outcome: str, provider: str = DEFAULT_PROVIDER
|
|
168
|
+
) -> dict:
|
|
169
|
+
"""Return fields shared by every fidelity JSON v1 suite."""
|
|
170
|
+
return {
|
|
171
|
+
"schema_version": SCHEMA_VERSION,
|
|
172
|
+
"master": master_name,
|
|
173
|
+
"provider": provider,
|
|
174
|
+
"mode": "dry_run" if dry_run else "graded",
|
|
175
|
+
"outcome": outcome,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def suite_error(
|
|
180
|
+
master_name: str, dry_run: bool, message: str, provider: str = DEFAULT_PROVIDER
|
|
181
|
+
) -> dict:
|
|
182
|
+
"""Return a fidelity JSON v1 suite for a precondition or execution error."""
|
|
183
|
+
return {
|
|
184
|
+
**suite_common(master_name, dry_run, "error", provider),
|
|
185
|
+
"total": 0,
|
|
186
|
+
"results": [],
|
|
187
|
+
"error": message,
|
|
188
|
+
}
|
|
34
189
|
|
|
35
190
|
|
|
36
191
|
def load_skill_context(master_dir: Path) -> str:
|
|
@@ -64,12 +219,56 @@ def load_tests(master_dir: Path) -> list[dict]:
|
|
|
64
219
|
if not fidelity_path.exists():
|
|
65
220
|
return []
|
|
66
221
|
tests = []
|
|
67
|
-
for line in
|
|
222
|
+
for line_number, line in enumerate(
|
|
223
|
+
fidelity_path.read_text(encoding="utf-8").splitlines(),
|
|
224
|
+
start=1,
|
|
225
|
+
):
|
|
68
226
|
if line.strip():
|
|
69
|
-
|
|
227
|
+
try:
|
|
228
|
+
test = json.loads(line)
|
|
229
|
+
except json.JSONDecodeError as error:
|
|
230
|
+
raise ValueError(
|
|
231
|
+
f"Invalid fidelity.jsonl line {line_number}: {error.msg}"
|
|
232
|
+
) from error
|
|
233
|
+
if not isinstance(test, dict):
|
|
234
|
+
raise ValueError(
|
|
235
|
+
f"Invalid fidelity.jsonl line {line_number}: expected a JSON object"
|
|
236
|
+
)
|
|
237
|
+
question = test.get("q")
|
|
238
|
+
if not isinstance(question, str) or not question.strip():
|
|
239
|
+
raise ValueError(
|
|
240
|
+
f"Invalid fidelity.jsonl line {line_number}: "
|
|
241
|
+
"expected a non-empty string q"
|
|
242
|
+
)
|
|
243
|
+
tests.append(test)
|
|
70
244
|
return tests
|
|
71
245
|
|
|
72
246
|
|
|
247
|
+
def _split_echoes(
|
|
248
|
+
terms: list[str], response: str, question: str
|
|
249
|
+
) -> tuple[list[str], list[str]]:
|
|
250
|
+
"""Split forbidden-term hits into genuine violations and question echoes.
|
|
251
|
+
|
|
252
|
+
``must_not_contain`` is a substring match against the response, and the
|
|
253
|
+
boundary fixtures are baited questions that carry the loaded term
|
|
254
|
+
themselves ("华严宗是不是佛教最高的宗派?" forbids 最高). A response that
|
|
255
|
+
quotes the bait in order to refuse it — "你问是不是最高,佛法无高下" —
|
|
256
|
+
trips the match exactly as hard as an actual ranking does.
|
|
257
|
+
|
|
258
|
+
Substring matching cannot tell those apart, so a hit whose term already
|
|
259
|
+
appears in the fixture's own question is undecidable here. It is returned
|
|
260
|
+
separately and does not fail the case; it flags the case for human review
|
|
261
|
+
instead. A hit on a term the question never used is a real violation.
|
|
262
|
+
"""
|
|
263
|
+
found: list[str] = []
|
|
264
|
+
echoed: list[str] = []
|
|
265
|
+
for term in terms:
|
|
266
|
+
if term not in response:
|
|
267
|
+
continue
|
|
268
|
+
(echoed if term in question else found).append(term)
|
|
269
|
+
return found, echoed
|
|
270
|
+
|
|
271
|
+
|
|
73
272
|
def check_response(
|
|
74
273
|
response: str,
|
|
75
274
|
test_case: dict,
|
|
@@ -79,8 +278,13 @@ def check_response(
|
|
|
79
278
|
"""Check a response against expected citations, mentions, and boundaries.
|
|
80
279
|
|
|
81
280
|
Returns {passed: bool, missing_cites: [...], missing_mentions: [...],
|
|
82
|
-
forbidden_found: [...],
|
|
83
|
-
|
|
281
|
+
forbidden_found: [...], forbidden_echoed: [...],
|
|
282
|
+
boundary_violations: [...], boundary_echoed: [...],
|
|
283
|
+
needs_review: bool, fabricated_cites: [...]}.
|
|
284
|
+
|
|
285
|
+
``*_echoed`` holds forbidden terms that the fixture's own question already
|
|
286
|
+
contains — see ``_split_echoes``. They do not fail the case; they set
|
|
287
|
+
``needs_review`` so a human can look at the stored response and decide.
|
|
84
288
|
|
|
85
289
|
When the test sets ``must_cite_only_existing_sources`` and ``declared_ids``
|
|
86
290
|
is supplied, every citation in the response must be either a declared
|
|
@@ -97,18 +301,20 @@ def check_response(
|
|
|
97
301
|
if mention not in response:
|
|
98
302
|
missing_mentions.append(mention)
|
|
99
303
|
|
|
304
|
+
question = test_case.get("q", "")
|
|
305
|
+
|
|
100
306
|
# Boundary tests: must_not_contain
|
|
101
|
-
forbidden_found =
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
forbidden_found.append(forbidden)
|
|
307
|
+
forbidden_found, forbidden_echoed = _split_echoes(
|
|
308
|
+
test_case.get("must_not_contain", []), response, question
|
|
309
|
+
)
|
|
105
310
|
|
|
106
311
|
# First-turn boundary: must_not_contain_first_turn
|
|
107
|
-
boundary_violations = []
|
|
312
|
+
boundary_violations: list[str] = []
|
|
313
|
+
boundary_echoed: list[str] = []
|
|
108
314
|
if is_first_turn:
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
315
|
+
boundary_violations, boundary_echoed = _split_echoes(
|
|
316
|
+
test_case.get("must_not_contain_first_turn", []), response, question
|
|
317
|
+
)
|
|
112
318
|
|
|
113
319
|
# B1: must_cite_only_existing_sources — no hallucinated citations
|
|
114
320
|
fabricated_cites = []
|
|
@@ -128,26 +334,68 @@ def check_response(
|
|
|
128
334
|
"missing_cites": missing_cites,
|
|
129
335
|
"missing_mentions": missing_mentions,
|
|
130
336
|
"forbidden_found": forbidden_found,
|
|
337
|
+
"forbidden_echoed": forbidden_echoed,
|
|
131
338
|
"boundary_violations": boundary_violations,
|
|
339
|
+
"boundary_echoed": boundary_echoed,
|
|
340
|
+
"needs_review": bool(forbidden_echoed or boundary_echoed),
|
|
132
341
|
"fabricated_cites": fabricated_cites,
|
|
133
342
|
}
|
|
134
343
|
|
|
135
344
|
|
|
345
|
+
def result_entry(
|
|
346
|
+
index: int, test: dict, check: dict, response_text: str
|
|
347
|
+
) -> dict:
|
|
348
|
+
"""Build one graded result record.
|
|
349
|
+
|
|
350
|
+
Carries the response itself, not just its length. The first baseline
|
|
351
|
+
stored only ``response_length``, which left every failure unadjudicable
|
|
352
|
+
after the fact — there was no way to revisit a case and see what the
|
|
353
|
+
persona actually said.
|
|
354
|
+
"""
|
|
355
|
+
return {
|
|
356
|
+
"index": index,
|
|
357
|
+
"question": test["q"],
|
|
358
|
+
"difficulty": test.get("difficulty", "unknown"),
|
|
359
|
+
"test_type": test.get("test_type", "fidelity"),
|
|
360
|
+
"status": "PASS" if check["passed"] else "FAIL",
|
|
361
|
+
"missing_cites": check["missing_cites"],
|
|
362
|
+
"missing_mentions": check["missing_mentions"],
|
|
363
|
+
"forbidden_found": check["forbidden_found"],
|
|
364
|
+
"forbidden_echoed": check["forbidden_echoed"],
|
|
365
|
+
"boundary_violations": check["boundary_violations"],
|
|
366
|
+
"boundary_echoed": check["boundary_echoed"],
|
|
367
|
+
"fabricated_cites": check["fabricated_cites"],
|
|
368
|
+
"needs_review": check["needs_review"],
|
|
369
|
+
"response": response_text,
|
|
370
|
+
"response_length": len(response_text),
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
|
|
136
374
|
def run_tests(
|
|
137
375
|
master_name: str,
|
|
138
376
|
dry_run: bool = False,
|
|
139
|
-
model: str =
|
|
377
|
+
model: str | None = None,
|
|
140
378
|
max_tests: int | None = None,
|
|
141
379
|
quiet: bool = False,
|
|
380
|
+
provider: str = DEFAULT_PROVIDER,
|
|
142
381
|
) -> dict:
|
|
143
382
|
"""Run fidelity tests for a master. Returns summary."""
|
|
144
383
|
master_dir = PREBUILT_DIR / master_name
|
|
145
384
|
if not master_dir.exists():
|
|
146
|
-
return
|
|
385
|
+
return suite_error(
|
|
386
|
+
master_name, dry_run, f"Master '{master_name}' not found", provider
|
|
387
|
+
)
|
|
147
388
|
|
|
148
|
-
|
|
389
|
+
try:
|
|
390
|
+
tests = load_tests(master_dir)
|
|
391
|
+
except (OSError, ValueError) as error:
|
|
392
|
+
return suite_error(
|
|
393
|
+
master_name, dry_run, f"Unable to load fidelity suite: {error}", provider
|
|
394
|
+
)
|
|
149
395
|
if not tests:
|
|
150
|
-
return
|
|
396
|
+
return suite_error(
|
|
397
|
+
master_name, dry_run, f"No fidelity.jsonl found for '{master_name}'", provider
|
|
398
|
+
)
|
|
151
399
|
|
|
152
400
|
if max_tests is not None and max_tests > 0:
|
|
153
401
|
# Prefer easier/basic tests when capping — smoke suite should hit
|
|
@@ -171,22 +419,49 @@ def run_tests(
|
|
|
171
419
|
"difficulty": test.get("difficulty", "unknown"),
|
|
172
420
|
"status": "dry_run",
|
|
173
421
|
})
|
|
174
|
-
return {
|
|
422
|
+
return {
|
|
423
|
+
**suite_common(master_name, dry_run, "completed", provider),
|
|
424
|
+
"total": len(tests),
|
|
425
|
+
"results": results,
|
|
426
|
+
}
|
|
175
427
|
|
|
176
428
|
# Load skill context
|
|
177
429
|
system_prompt = load_skill_context(master_dir)
|
|
178
430
|
|
|
179
|
-
# Import anthropic
|
|
180
431
|
try:
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
432
|
+
spec = resolve_provider(provider)
|
|
433
|
+
model = resolve_model(provider, model)
|
|
434
|
+
except ValueError as error:
|
|
435
|
+
return suite_error(master_name, dry_run, str(error), provider)
|
|
184
436
|
|
|
185
|
-
api_key = os.environ.get("
|
|
437
|
+
api_key = os.environ.get(spec["env"])
|
|
186
438
|
if not api_key:
|
|
187
|
-
return
|
|
439
|
+
return suite_error(
|
|
440
|
+
master_name, dry_run, f"{spec['env']} environment variable not set", provider
|
|
441
|
+
)
|
|
188
442
|
|
|
189
|
-
|
|
443
|
+
if spec["api"] == "anthropic":
|
|
444
|
+
try:
|
|
445
|
+
import anthropic
|
|
446
|
+
except ImportError:
|
|
447
|
+
return suite_error(
|
|
448
|
+
master_name, dry_run,
|
|
449
|
+
"anthropic package not installed. Run: pip install anthropic",
|
|
450
|
+
provider,
|
|
451
|
+
)
|
|
452
|
+
client = anthropic.Anthropic(api_key=api_key)
|
|
453
|
+
send = lambda body: client.messages.create(**body) # noqa: E731
|
|
454
|
+
else:
|
|
455
|
+
try:
|
|
456
|
+
import openai
|
|
457
|
+
except ImportError:
|
|
458
|
+
return suite_error(
|
|
459
|
+
master_name, dry_run,
|
|
460
|
+
"openai package not installed. Run: pip install openai",
|
|
461
|
+
provider,
|
|
462
|
+
)
|
|
463
|
+
client = openai.OpenAI(api_key=api_key, base_url=spec["base_url"])
|
|
464
|
+
send = lambda body: client.chat.completions.create(**body) # noqa: E731
|
|
190
465
|
|
|
191
466
|
# Declared offline sources, for the must_cite_only_existing_sources B1 check.
|
|
192
467
|
try:
|
|
@@ -202,13 +477,10 @@ def run_tests(
|
|
|
202
477
|
print(f" [{i+1}/{len(tests)}] {test['q'][:50]}...", end=" ", flush=True)
|
|
203
478
|
|
|
204
479
|
try:
|
|
205
|
-
|
|
206
|
-
model
|
|
207
|
-
max_tokens=2048,
|
|
208
|
-
system=system_prompt,
|
|
209
|
-
messages=[{"role": "user", "content": test["q"]}],
|
|
480
|
+
response = send(
|
|
481
|
+
build_request(provider, model, system_prompt, test["q"], 2048)
|
|
210
482
|
)
|
|
211
|
-
response_text =
|
|
483
|
+
response_text = extract_text(provider, response)
|
|
212
484
|
except Exception as e:
|
|
213
485
|
results.append({
|
|
214
486
|
"index": i,
|
|
@@ -224,27 +496,12 @@ def run_tests(
|
|
|
224
496
|
check = check_response(
|
|
225
497
|
response_text, test, is_first_turn=True, declared_ids=declared_ids
|
|
226
498
|
)
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
result_entry = {
|
|
230
|
-
"index": i,
|
|
231
|
-
"question": test["q"],
|
|
232
|
-
"difficulty": test.get("difficulty", "unknown"),
|
|
233
|
-
"test_type": test.get("test_type", "fidelity"),
|
|
234
|
-
"status": status,
|
|
235
|
-
"missing_cites": check["missing_cites"],
|
|
236
|
-
"missing_mentions": check["missing_mentions"],
|
|
237
|
-
"forbidden_found": check["forbidden_found"],
|
|
238
|
-
"boundary_violations": check["boundary_violations"],
|
|
239
|
-
"fabricated_cites": check["fabricated_cites"],
|
|
240
|
-
"response_length": len(response_text),
|
|
241
|
-
}
|
|
242
|
-
results.append(result_entry)
|
|
499
|
+
results.append(result_entry(i, test, check, response_text))
|
|
243
500
|
|
|
244
501
|
if check["passed"]:
|
|
245
502
|
passed += 1
|
|
246
503
|
if not quiet:
|
|
247
|
-
print("PASS")
|
|
504
|
+
print("PASS (review)" if check["needs_review"] else "PASS")
|
|
248
505
|
else:
|
|
249
506
|
failed += 1
|
|
250
507
|
failures = (check["missing_cites"] + check["missing_mentions"]
|
|
@@ -253,7 +510,7 @@ def run_tests(
|
|
|
253
510
|
print(f"FAIL ({failures})")
|
|
254
511
|
|
|
255
512
|
return {
|
|
256
|
-
"
|
|
513
|
+
**suite_common(master_name, dry_run, "completed", provider),
|
|
257
514
|
"model": model,
|
|
258
515
|
"total": len(tests),
|
|
259
516
|
"passed": passed,
|
|
@@ -283,7 +540,16 @@ def main() -> int:
|
|
|
283
540
|
parser.add_argument("--master", type=str, help="Test a specific master")
|
|
284
541
|
parser.add_argument("--all", action="store_true", help="Test all masters with fidelity.jsonl")
|
|
285
542
|
parser.add_argument("--dry-run", action="store_true", help="Show test cases without calling API")
|
|
286
|
-
parser.add_argument(
|
|
543
|
+
parser.add_argument(
|
|
544
|
+
"--provider", type=str, default=DEFAULT_PROVIDER, choices=sorted(PROVIDERS),
|
|
545
|
+
help="Which API to grade against (default: anthropic). Non-anthropic "
|
|
546
|
+
"providers require --model.",
|
|
547
|
+
)
|
|
548
|
+
parser.add_argument(
|
|
549
|
+
"--model", type=str, default=None,
|
|
550
|
+
help="Model id. Defaults to claude-sonnet-4-6 for --provider anthropic; "
|
|
551
|
+
"required for every other provider.",
|
|
552
|
+
)
|
|
287
553
|
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
288
554
|
parser.add_argument(
|
|
289
555
|
"--max-tests",
|
|
@@ -317,6 +583,7 @@ def main() -> int:
|
|
|
317
583
|
master,
|
|
318
584
|
dry_run=args.dry_run,
|
|
319
585
|
model=args.model,
|
|
586
|
+
provider=args.provider,
|
|
320
587
|
max_tests=args.max_tests,
|
|
321
588
|
quiet=args.json,
|
|
322
589
|
)
|
|
@@ -329,6 +596,10 @@ def main() -> int:
|
|
|
329
596
|
if args.json:
|
|
330
597
|
print(json.dumps(all_results, indent=2, ensure_ascii=False))
|
|
331
598
|
elif len(masters) > 1:
|
|
599
|
+
conflicts = aggregation_conflicts(all_results)
|
|
600
|
+
for conflict in conflicts:
|
|
601
|
+
print(f"\n::warning:: {conflict}")
|
|
602
|
+
|
|
332
603
|
print(f"\n{'='*50}")
|
|
333
604
|
print("Overall Summary:")
|
|
334
605
|
for r in all_results:
|