premanmcp 1.1.11 → 1.1.13
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/bin/account.js +48 -0
- package/bin/cli.js +73 -14
- package/bin/connect/configs.js +20 -6
- package/bin/desktop.js +557 -79
- package/bin/eval.js +328 -50
- package/bin/eval_entry.js +9 -2
- package/bin/eval_generation_contract.py +72 -0
- package/bin/eval_generation_debug.py +279 -0
- package/bin/eval_generation_status.py +284 -0
- package/bin/eval_harness.py +198 -4
- package/bin/eval_start.js +203 -0
- package/bin/eval_target.js +20 -1
- package/bin/eval_templates.js +447 -21
- package/bin/hook.js +21 -1
- package/bin/integrations.js +20 -4
- package/bin/probe.js +323 -0
- package/bin/runner.js +23 -0
- package/bin/shared.js +331 -14
- package/package.json +2 -2
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Opt-in evidence for ASSERT test-set generation; offline replay and bounded retry.
|
|
2
|
+
|
|
3
|
+
Enable through run_assert.py with PREMAN_EVAL_DEBUG_DIR. Captures stay local;
|
|
4
|
+
stdout/stderr contain summaries and paths, never request or response bodies.
|
|
5
|
+
Keep preman-mcp/bin/eval_generation_debug.py byte-identical to this file.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import asyncio
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from functools import wraps
|
|
14
|
+
import importlib.metadata
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
import re
|
|
20
|
+
import runpy
|
|
21
|
+
import sys
|
|
22
|
+
import uuid
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger("preman.eval_generation")
|
|
25
|
+
# Share names across structured fields and malformed/embedded JSON strings.
|
|
26
|
+
_SECRET_NAMES = (
|
|
27
|
+
r"(?:authorization|proxy[-_]authorization|[\w-]*api[-_]?key|"
|
|
28
|
+
r"[\w-]*password[\w-]*|[\w-]*secret[\w-]*|[\w-]*credential[\w-]*|"
|
|
29
|
+
r"[\w-]*token|cookie|set[-_]cookie)"
|
|
30
|
+
)
|
|
31
|
+
_SECRET_FIELD = re.compile(_SECRET_NAMES, re.I)
|
|
32
|
+
_TEXT_SECRET = re.compile(
|
|
33
|
+
r"""(\b""" + _SECRET_NAMES + r"""["']?\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;}]+)""", re.I
|
|
34
|
+
)
|
|
35
|
+
_HEADER_SECRET = re.compile(
|
|
36
|
+
r"(?i)(\b(?:authorization|proxy-authorization|cookie|set-cookie)\s*:\s*)[^\r\n]+"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _diagnose(parsed, schema):
|
|
41
|
+
"""Mirror the pinned stage's acceptance rules; reuse its case normalizer."""
|
|
42
|
+
from assert_ai.stages.test_set import normalize_generated_test_case
|
|
43
|
+
|
|
44
|
+
array = schema["properties"]["test_set"]
|
|
45
|
+
count = array.get("minItems", 1)
|
|
46
|
+
result = {"valid": False, "reason": "invalid_test_set_payload", "expected": count}
|
|
47
|
+
if not isinstance(parsed, dict) or not isinstance(parsed.get("test_set"), list):
|
|
48
|
+
return result
|
|
49
|
+
cases = parsed["test_set"]
|
|
50
|
+
result["received"] = len(cases)
|
|
51
|
+
if len(cases) < count:
|
|
52
|
+
return {**result, "reason": "too_few_cases"}
|
|
53
|
+
tool_source = "per_test_case" if "tools" in array["items"]["properties"] else "runtime"
|
|
54
|
+
for index, case in enumerate(cases[:count]):
|
|
55
|
+
try:
|
|
56
|
+
if not isinstance(case, dict):
|
|
57
|
+
raise ValueError("case is not an object")
|
|
58
|
+
normalize_generated_test_case(case, tool_source=tool_source, fixed_system_prompt=None)
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
# A malformed tool can make the upstream normalizer raise AttributeError.
|
|
61
|
+
# That rejected reply is evidence too; never lose it during diagnosis.
|
|
62
|
+
return {**result, "reason": "invalid_case", "case_index": index, "error_type": type(exc).__name__}
|
|
63
|
+
return {**result, "valid": True, "reason": "accepted"}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _redact(value):
|
|
67
|
+
"""Remove credential fields/patterns and literal credentials from the environment."""
|
|
68
|
+
from assert_ai.core.session import _sanitize_response_text
|
|
69
|
+
|
|
70
|
+
secrets = sorted({
|
|
71
|
+
value for key, value in os.environ.items()
|
|
72
|
+
if len(value) >= 6 and re.search(r"KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL", key, re.I)
|
|
73
|
+
}, key=len, reverse=True)
|
|
74
|
+
changed = False
|
|
75
|
+
|
|
76
|
+
def visit(item):
|
|
77
|
+
nonlocal changed
|
|
78
|
+
if isinstance(item, dict):
|
|
79
|
+
out = {}
|
|
80
|
+
for key, val in item.items():
|
|
81
|
+
if _SECRET_FIELD.fullmatch(key):
|
|
82
|
+
out[key] = "[REDACTED]"
|
|
83
|
+
changed = True
|
|
84
|
+
else:
|
|
85
|
+
out[key] = visit(val)
|
|
86
|
+
return out
|
|
87
|
+
if isinstance(item, list):
|
|
88
|
+
return [visit(val) for val in item]
|
|
89
|
+
if isinstance(item, str):
|
|
90
|
+
clean = item
|
|
91
|
+
# Provider content often contains serialized JSON, so dict-only
|
|
92
|
+
# redaction cannot see its password/api_key fields.
|
|
93
|
+
try:
|
|
94
|
+
embedded = json.loads(clean)
|
|
95
|
+
except (ValueError, TypeError):
|
|
96
|
+
embedded = None
|
|
97
|
+
if isinstance(embedded, (dict, list)):
|
|
98
|
+
sanitized = visit(embedded)
|
|
99
|
+
if sanitized != embedded:
|
|
100
|
+
clean = json.dumps(sanitized, ensure_ascii=False)
|
|
101
|
+
for secret in secrets:
|
|
102
|
+
clean = clean.replace(secret, "[REDACTED]")
|
|
103
|
+
clean = _TEXT_SECRET.sub(r'\1"[REDACTED]"', clean)
|
|
104
|
+
clean = _HEADER_SECRET.sub(r'\1[REDACTED]', clean)
|
|
105
|
+
clean = _sanitize_response_text(clean)
|
|
106
|
+
changed |= clean != item
|
|
107
|
+
return clean
|
|
108
|
+
return item
|
|
109
|
+
|
|
110
|
+
clean = visit(value)
|
|
111
|
+
return clean, changed
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _capture(directory, model, messages, schema_name, schema, options, response):
|
|
115
|
+
from assert_ai.core.model_client import to_jsonable
|
|
116
|
+
|
|
117
|
+
result = _diagnose(response.parsed, schema)
|
|
118
|
+
dist = importlib.metadata.distribution("assert-ai")
|
|
119
|
+
source = json.loads(dist.read_text("direct_url.json") or "{}")
|
|
120
|
+
evidence = {
|
|
121
|
+
"format": "preman.eval-generation/v1",
|
|
122
|
+
"captured_at": datetime.now(timezone.utc).isoformat(),
|
|
123
|
+
"runtime": {"assert_ai_version": dist.version, "assert_ai_commit": source.get("vcs_info", {}).get("commit_id")},
|
|
124
|
+
"request": {"model": model, "messages": messages, "schema_name": schema_name, "json_schema": schema, "options": options},
|
|
125
|
+
"response": {name: getattr(response, name, None) for name in (
|
|
126
|
+
"text", "content", "parsed", "finish_reason", "status", "incomplete_details",
|
|
127
|
+
"model", "response_id", "usage", "api_mode", "raw",
|
|
128
|
+
)},
|
|
129
|
+
"result": result,
|
|
130
|
+
}
|
|
131
|
+
_, request_redacted = _redact(to_jsonable(evidence["request"]))
|
|
132
|
+
evidence, was_redacted = _redact(to_jsonable(evidence))
|
|
133
|
+
evidence["request_redacted"] = request_redacted
|
|
134
|
+
evidence["redacted"] = was_redacted
|
|
135
|
+
directory = Path(directory).expanduser().resolve()
|
|
136
|
+
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
137
|
+
status = "valid" if result["valid"] else "invalid"
|
|
138
|
+
path = directory / f"{status}-{uuid.uuid4().hex}.json"
|
|
139
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
140
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
141
|
+
json.dump(evidence, stream, ensure_ascii=False, indent=2)
|
|
142
|
+
stream.write("\n")
|
|
143
|
+
label = evidence["request"]["options"].get("call_label") if options is not None else None
|
|
144
|
+
(log.info if result["valid"] else log.warning)(
|
|
145
|
+
"generation status=%s batch=%s reason=%s capture=%s", status, label, result["reason"], path,
|
|
146
|
+
)
|
|
147
|
+
return path
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def install():
|
|
151
|
+
"""Observe only test-set calls, without retrying or changing their responses."""
|
|
152
|
+
if not os.environ.get("PREMAN_EVAL_DEBUG_DIR"):
|
|
153
|
+
return
|
|
154
|
+
from assert_ai.stages import test_set
|
|
155
|
+
|
|
156
|
+
original = test_set.generate_structured
|
|
157
|
+
if getattr(original, "_preman_debug_capture", False):
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
@wraps(original)
|
|
161
|
+
async def observed(model, messages, *, schema_name, json_schema, options=None):
|
|
162
|
+
response = await original(model, messages, schema_name=schema_name, json_schema=json_schema, options=options)
|
|
163
|
+
directory = os.environ.get("PREMAN_EVAL_DEBUG_DIR")
|
|
164
|
+
if directory:
|
|
165
|
+
try:
|
|
166
|
+
_capture(directory, model, messages, schema_name, json_schema, options, response)
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
# Evidence storage must never turn an accepted reply into a lost batch.
|
|
169
|
+
# Exception text can contain body data; log only its type.
|
|
170
|
+
log.warning("generation capture_failed error_type=%s", type(exc).__name__)
|
|
171
|
+
return response
|
|
172
|
+
|
|
173
|
+
observed._preman_debug_capture = True
|
|
174
|
+
test_set.generate_structured = observed
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def replay(capture):
|
|
178
|
+
"""Reparse saved text and apply generation validation, with no model calls."""
|
|
179
|
+
from assert_ai.core.model_client import _maybe_parse_json
|
|
180
|
+
|
|
181
|
+
parsed = _maybe_parse_json(capture["response"]["text"] or "")
|
|
182
|
+
return {
|
|
183
|
+
**_diagnose(parsed, capture["request"]["json_schema"]),
|
|
184
|
+
"parser_matches_capture": parsed == capture["response"]["parsed"],
|
|
185
|
+
"redacted": capture["redacted"],
|
|
186
|
+
"finish_reason": capture["response"].get("finish_reason"),
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def _retry(capture, attempts):
|
|
191
|
+
"""Repeat a request sequentially; one provider attempt, no error-body logs."""
|
|
192
|
+
from assert_ai.core import model_client
|
|
193
|
+
from assert_ai.core.model_client import GenerateOptions
|
|
194
|
+
from assert_ai.stages import test_set
|
|
195
|
+
|
|
196
|
+
if capture.get("request_redacted", capture["redacted"]):
|
|
197
|
+
raise ValueError("Capture was redacted; refusing to send an altered request.")
|
|
198
|
+
request = capture["request"]
|
|
199
|
+
options = GenerateOptions(**(request["options"] or {}))
|
|
200
|
+
options.timeout_s = min(options.timeout_s or 120, 120)
|
|
201
|
+
directory = os.environ.get("PREMAN_EVAL_DEBUG_DIR", "artifacts/eval-generation-debug")
|
|
202
|
+
generate = test_set.generate_structured
|
|
203
|
+
# Other harness observers use wraps(), which copies this marker too.
|
|
204
|
+
# Remove the entire marked chain so this command captures exactly once.
|
|
205
|
+
while getattr(generate, "_preman_debug_capture", False):
|
|
206
|
+
generate = generate.__wrapped__
|
|
207
|
+
|
|
208
|
+
async def once(call_fn, **kwargs):
|
|
209
|
+
return await call_fn()
|
|
210
|
+
|
|
211
|
+
for attempt in range(1, attempts + 1):
|
|
212
|
+
original_retries = model_client._with_retries
|
|
213
|
+
disabled_level = logging.root.manager.disable
|
|
214
|
+
try:
|
|
215
|
+
# This standalone diagnostic command is sequential. Disable the
|
|
216
|
+
# upstream loop only during its request: it otherwise retries six
|
|
217
|
+
# times and logs provider exceptions, which can echo private bodies.
|
|
218
|
+
model_client._with_retries = once
|
|
219
|
+
logging.disable(logging.CRITICAL)
|
|
220
|
+
response = await asyncio.wait_for(generate(
|
|
221
|
+
request["model"], request["messages"], schema_name=request["schema_name"],
|
|
222
|
+
json_schema=request["json_schema"], options=options,
|
|
223
|
+
), timeout=120)
|
|
224
|
+
except Exception as exc:
|
|
225
|
+
print(f"Live request failed ({type(exc).__name__}); no invalid payload reproduced.", file=sys.stderr)
|
|
226
|
+
return 2
|
|
227
|
+
finally:
|
|
228
|
+
model_client._with_retries = original_retries
|
|
229
|
+
logging.disable(disabled_level)
|
|
230
|
+
try:
|
|
231
|
+
path = _capture(directory, request["model"], request["messages"], request["schema_name"], request["json_schema"], options, response)
|
|
232
|
+
except Exception as exc:
|
|
233
|
+
print(f"Capture failed ({type(exc).__name__}); stopping the probe.", file=sys.stderr)
|
|
234
|
+
return 2
|
|
235
|
+
result = _diagnose(response.parsed, request["json_schema"])
|
|
236
|
+
print(json.dumps({"attempt": attempt, "capture": str(path), **result}), flush=True)
|
|
237
|
+
if not result["valid"]:
|
|
238
|
+
return 1
|
|
239
|
+
print("No invalid payload reproduced within the attempt limit.", flush=True)
|
|
240
|
+
return 0
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def main(argv=None):
|
|
244
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
245
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
246
|
+
for command in ("inspect", "replay", "retry"):
|
|
247
|
+
child = sub.add_parser(command)
|
|
248
|
+
child.add_argument("capture", type=Path)
|
|
249
|
+
if command == "retry":
|
|
250
|
+
child.add_argument("--attempts", type=int, default=1)
|
|
251
|
+
child.add_argument("--output-dir", type=Path)
|
|
252
|
+
args = parser.parse_args(argv)
|
|
253
|
+
if args.command == "retry" and not 1 <= args.attempts <= 20:
|
|
254
|
+
parser.error("--attempts must be from 1 to 20")
|
|
255
|
+
try:
|
|
256
|
+
capture = json.loads(args.capture.read_text(encoding="utf-8"))
|
|
257
|
+
if capture.get("format") != "preman.eval-generation/v1":
|
|
258
|
+
raise ValueError("Unrecognized generation capture format")
|
|
259
|
+
if args.command == "inspect":
|
|
260
|
+
print(json.dumps(capture, indent=2, ensure_ascii=False))
|
|
261
|
+
return 0
|
|
262
|
+
if args.command == "replay":
|
|
263
|
+
result = replay(capture)
|
|
264
|
+
print(json.dumps(result, indent=2))
|
|
265
|
+
return 0 if result["valid"] else 1
|
|
266
|
+
directory = args.output_dir or args.capture.parent
|
|
267
|
+
os.environ["PREMAN_EVAL_DEBUG_DIR"] = str(directory.resolve())
|
|
268
|
+
# Reuse the same provider workarounds as a normal generation run.
|
|
269
|
+
parent = Path(__file__).resolve().parent
|
|
270
|
+
harness = parent / ("run_assert.py" if (parent / "run_assert.py").exists() else "eval_harness.py")
|
|
271
|
+
runpy.run_path(str(harness))
|
|
272
|
+
logging.basicConfig(level=logging.INFO)
|
|
273
|
+
return asyncio.run(_retry(capture, args.attempts))
|
|
274
|
+
except (OSError, ValueError, KeyError, TypeError) as exc:
|
|
275
|
+
parser.error(str(exc))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
if __name__ == "__main__":
|
|
279
|
+
sys.exit(main())
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Publish usable ASSERT cases and a separate, bounded generation error report.
|
|
2
|
+
|
|
3
|
+
Keep the npm copy byte-identical. This adapter observes the pinned stage's actual
|
|
4
|
+
job results, preserving its validation, retries, case IDs, and incomplete-cache
|
|
5
|
+
signal. It does not synthesize missing cases or repeat any model requests.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from contextvars import ContextVar
|
|
10
|
+
from functools import wraps
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
import tempfile
|
|
18
|
+
|
|
19
|
+
from eval_generation_contract import generation_spec_sha256
|
|
20
|
+
|
|
21
|
+
from eval_generation_debug import _diagnose, _redact
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger('preman.eval_generation')
|
|
24
|
+
_suite_root = ContextVar('preman_generation_suite', default=None)
|
|
25
|
+
_spec_hash = ContextVar('preman_generation_spec_hash', default=None)
|
|
26
|
+
_active = ContextVar('preman_generation', default=None)
|
|
27
|
+
_kind = ContextVar('preman_generation_kind', default=None)
|
|
28
|
+
_job = ContextVar('preman_generation_job', default=None)
|
|
29
|
+
_MAX_FAILURES = 100
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _failure(job, result):
|
|
33
|
+
"""Use the stage's error verdict, with only safe response diagnostics."""
|
|
34
|
+
response = job.get('response')
|
|
35
|
+
reason = 'generation_error'
|
|
36
|
+
message = f"Generation failed ({type(result['error']).__name__})."
|
|
37
|
+
detail = {}
|
|
38
|
+
if response is not None:
|
|
39
|
+
diagnostic = _diagnose(response.parsed, job['schema'])
|
|
40
|
+
reason = diagnostic['reason'] if not diagnostic['valid'] else 'invalid_case'
|
|
41
|
+
message = {
|
|
42
|
+
'invalid_test_set_payload': 'The model did not return a valid test-case payload.',
|
|
43
|
+
'too_few_cases': 'The model returned fewer cases than this job requested.',
|
|
44
|
+
'invalid_case': 'The generated case did not pass validation.',
|
|
45
|
+
}.get(reason, message)
|
|
46
|
+
if response.finish_reason == 'length':
|
|
47
|
+
reason = 'output_token_limit'
|
|
48
|
+
message = 'The model reached its output token limit before returning valid cases.'
|
|
49
|
+
detail['finish_reason'] = response.finish_reason
|
|
50
|
+
# Redact before clipping so a cut credential cannot evade sanitization.
|
|
51
|
+
detail['response_text'] = _redact(response.text or '')[0][:8000]
|
|
52
|
+
usage = response.usage
|
|
53
|
+
if usage is not None:
|
|
54
|
+
detail['completion_tokens'] = usage.completion_tokens
|
|
55
|
+
raw = usage.raw if isinstance(usage.raw, dict) else {}
|
|
56
|
+
tokens = raw.get('completion_tokens_details') or raw.get('output_tokens_details') or {}
|
|
57
|
+
if isinstance(tokens, dict):
|
|
58
|
+
detail['reasoning_tokens'] = tokens.get('reasoning_tokens')
|
|
59
|
+
detail['max_tokens'] = job.get('max_tokens')
|
|
60
|
+
identity = _redact(job['identity'])[0]
|
|
61
|
+
identity['behavior'] = identity['behavior'][:256]
|
|
62
|
+
identity['dimensions'] = {str(k)[:128]: str(v)[:1024] for k, v in list(identity['dimensions'].items())[:32]}
|
|
63
|
+
return {**identity, 'reason': reason, 'message': message, **detail}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _atomic_write(path, content):
|
|
67
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
fd, temporary = tempfile.mkstemp(prefix=f'.{path.name}-', dir=path.parent)
|
|
69
|
+
try:
|
|
70
|
+
with os.fdopen(fd, 'wb') as stream:
|
|
71
|
+
stream.write(content)
|
|
72
|
+
os.replace(temporary, path)
|
|
73
|
+
finally:
|
|
74
|
+
if os.path.exists(temporary):
|
|
75
|
+
os.unlink(temporary)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _publish(state, result):
|
|
79
|
+
from assert_ai.core.artifact_cache import _is_local_edit
|
|
80
|
+
|
|
81
|
+
version_bank = Path(result['test_set_path'])
|
|
82
|
+
suite = Path(_suite_root.get() or version_bank.parent)
|
|
83
|
+
bank = version_bank.read_bytes()
|
|
84
|
+
jobs = sorted(state['jobs'], key=lambda job: (job['identity']['kind'], job['order']))
|
|
85
|
+
failed = [job for job in jobs if job.get('error') is not None]
|
|
86
|
+
generated = int(result['saved_count'])
|
|
87
|
+
report = {
|
|
88
|
+
'format': 'preman.eval-generation-status/v1',
|
|
89
|
+
'status': 'partial' if failed and generated else 'failed' if failed else 'complete',
|
|
90
|
+
'expected_count': sum(job['identity']['case_count'] for job in jobs),
|
|
91
|
+
'generated_count': generated,
|
|
92
|
+
'failed_count': sum(job['identity']['case_count'] for job in failed),
|
|
93
|
+
'failure_job_count': len(failed),
|
|
94
|
+
'failures_truncated': len(failed) > _MAX_FAILURES,
|
|
95
|
+
'failures': [job['failure'] for job in failed[:_MAX_FAILURES]],
|
|
96
|
+
'bank_sha256': hashlib.sha256(bank).hexdigest(),
|
|
97
|
+
'generation_spec_sha256': _spec_hash.get(),
|
|
98
|
+
}
|
|
99
|
+
try:
|
|
100
|
+
report['taxonomy_sha256'] = hashlib.sha256(Path(state['taxonomy_path']).read_bytes()).hexdigest()
|
|
101
|
+
except OSError:
|
|
102
|
+
# Without a matching rubric the API declines reuse; ready cases and
|
|
103
|
+
# generation errors must still remain visible.
|
|
104
|
+
report['taxonomy_sha256'] = None
|
|
105
|
+
payload = (json.dumps(report, ensure_ascii=False, indent=2) + '\n').encode('utf-8')
|
|
106
|
+
_atomic_write(version_bank.parent / 'generation.json', payload)
|
|
107
|
+
if version_bank.resolve() != (suite / 'test_set.jsonl').resolve():
|
|
108
|
+
# Retain ASSERT's protection for edited/published banks. A partial
|
|
109
|
+
# bank has no complete-cache sidecar, so future generation cannot
|
|
110
|
+
# silently renumber its cases under the same historical identities.
|
|
111
|
+
if _is_local_edit(suite, 'test_set', suite / 'test_set.jsonl', version_bank):
|
|
112
|
+
log.warning('generation kept the existing bank and report; new output is at %s; use a new suite to publish different cases', version_bank)
|
|
113
|
+
return
|
|
114
|
+
_atomic_write(suite / 'test_set.jsonl', bank)
|
|
115
|
+
_atomic_write(suite / 'generation.json', payload)
|
|
116
|
+
log.info('generation status=%s generated=%d failed=%d report=%s', report['status'], generated, report['failed_count'], suite / 'generation.json')
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _select_published_partial(ctx):
|
|
121
|
+
"""Evaluate the bank offered to users, even beside an older complete cache."""
|
|
122
|
+
suite = Path(ctx['suite_root'])
|
|
123
|
+
bank = suite / 'test_set.jsonl'
|
|
124
|
+
try:
|
|
125
|
+
report = json.loads((suite / 'generation.json').read_text(encoding='utf-8'))
|
|
126
|
+
if not isinstance(report, dict) or report.get('status') != 'partial':
|
|
127
|
+
return
|
|
128
|
+
if report.get('bank_sha256') != hashlib.sha256(bank.read_bytes()).hexdigest():
|
|
129
|
+
return
|
|
130
|
+
except (OSError, ValueError):
|
|
131
|
+
return
|
|
132
|
+
ctx['test_set_path'] = str(bank)
|
|
133
|
+
# This is a usable partial bank, not the old cache version or a newly
|
|
134
|
+
# finalized artifact. Do not attribute its cases to that older version.
|
|
135
|
+
ctx.setdefault('artifact_versions', {}).pop('test_set', None)
|
|
136
|
+
ctx.pop('test_set_artifact_dir', None)
|
|
137
|
+
ctx.pop('stratification_path', None)
|
|
138
|
+
log.info('generation evaluating published partial bank %s', bank)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def install():
|
|
142
|
+
"""Adapt the pinned stage without modifying the installed ASSERT package."""
|
|
143
|
+
from assert_ai.stages import test_set
|
|
144
|
+
from assert_ai.core import artifact_cache
|
|
145
|
+
from assert_ai import config
|
|
146
|
+
|
|
147
|
+
original_context = config.load_runtime_context
|
|
148
|
+
if not getattr(original_context, '_preman_generation_status', False):
|
|
149
|
+
@wraps(original_context)
|
|
150
|
+
def load_context(raw, cfg_path, **kwargs):
|
|
151
|
+
# The runner applies CLI overrides before this call. Hash before
|
|
152
|
+
# ASSERT injects model defaults into raw's nested stage mappings.
|
|
153
|
+
fingerprint = generation_spec_sha256(raw, config_dir=Path(cfg_path).parent)
|
|
154
|
+
ctx = original_context(raw, cfg_path, **kwargs)
|
|
155
|
+
ctx['_preman_generation_spec_sha256'] = fingerprint
|
|
156
|
+
return ctx
|
|
157
|
+
|
|
158
|
+
load_context._preman_generation_status = True
|
|
159
|
+
config.load_runtime_context = load_context
|
|
160
|
+
|
|
161
|
+
original_activate = artifact_cache.activate_latest_artifacts
|
|
162
|
+
if not getattr(original_activate, '_preman_generation_status', False):
|
|
163
|
+
@wraps(original_activate)
|
|
164
|
+
def activate_latest(ctx):
|
|
165
|
+
original_activate(ctx)
|
|
166
|
+
_select_published_partial(ctx)
|
|
167
|
+
|
|
168
|
+
activate_latest._preman_generation_status = True
|
|
169
|
+
artifact_cache.activate_latest_artifacts = activate_latest
|
|
170
|
+
# runner imports this by name; support installation both before and after
|
|
171
|
+
# that import without importing runner (and loading dotenv) just to patch.
|
|
172
|
+
runner = sys.modules.get('assert_ai.runner')
|
|
173
|
+
if runner is not None:
|
|
174
|
+
runner.activate_latest_artifacts = artifact_cache.activate_latest_artifacts
|
|
175
|
+
runner.load_runtime_context = config.load_runtime_context
|
|
176
|
+
|
|
177
|
+
def patch(name, factory):
|
|
178
|
+
original = getattr(test_set, name)
|
|
179
|
+
if getattr(original, '_preman_generation_status', False):
|
|
180
|
+
return
|
|
181
|
+
wrapped = factory(original)
|
|
182
|
+
wrapped._preman_generation_status = True
|
|
183
|
+
setattr(test_set, name, wrapped)
|
|
184
|
+
|
|
185
|
+
def stage_wrapper(original):
|
|
186
|
+
@wraps(original)
|
|
187
|
+
async def run(ctx, raw_cfg):
|
|
188
|
+
token = _suite_root.set(ctx['suite_root'])
|
|
189
|
+
spec_token = _spec_hash.set(ctx.get('_preman_generation_spec_sha256'))
|
|
190
|
+
try:
|
|
191
|
+
return await original(ctx, raw_cfg)
|
|
192
|
+
finally:
|
|
193
|
+
_spec_hash.reset(spec_token)
|
|
194
|
+
_suite_root.reset(token)
|
|
195
|
+
return run
|
|
196
|
+
|
|
197
|
+
def orchestration_wrapper(original):
|
|
198
|
+
@wraps(original)
|
|
199
|
+
async def run_test_set(**kwargs):
|
|
200
|
+
state = {'jobs': [], 'taxonomy_path': kwargs['taxonomy_path']}
|
|
201
|
+
token = _active.set(state)
|
|
202
|
+
try:
|
|
203
|
+
result = await original(**kwargs)
|
|
204
|
+
_publish(state, result)
|
|
205
|
+
if not result['saved_count'] and result['errored_count']:
|
|
206
|
+
raise ValueError('No test cases were generated; see generation.json for failed jobs')
|
|
207
|
+
return result
|
|
208
|
+
finally:
|
|
209
|
+
_active.reset(token)
|
|
210
|
+
return run_test_set
|
|
211
|
+
|
|
212
|
+
def records_wrapper(original):
|
|
213
|
+
@wraps(original)
|
|
214
|
+
async def generate_records(**kwargs):
|
|
215
|
+
token = _kind.set(kwargs['kind'])
|
|
216
|
+
try:
|
|
217
|
+
return await original(**kwargs)
|
|
218
|
+
except test_set.LLMAuthError:
|
|
219
|
+
raise
|
|
220
|
+
except Exception as exc:
|
|
221
|
+
state = _active.get()
|
|
222
|
+
jobs = [j for j in state['jobs'] if j['identity']['kind'] == kwargs['kind']] if state else []
|
|
223
|
+
# ASSERT raises the first batch error when a whole kind fails.
|
|
224
|
+
# Let the other kind finish, then decide failure for the bank.
|
|
225
|
+
# Planning/configuration errors do not satisfy this condition.
|
|
226
|
+
if jobs and all(j.get('error') is not None for j in jobs) and any(j['error'] is exc for j in jobs):
|
|
227
|
+
return {'records': [], 'errored_count': len(jobs)}
|
|
228
|
+
raise
|
|
229
|
+
finally:
|
|
230
|
+
_kind.reset(token)
|
|
231
|
+
return generate_records
|
|
232
|
+
|
|
233
|
+
def gather_wrapper(original):
|
|
234
|
+
@wraps(original)
|
|
235
|
+
async def gather(items, *, limit, worker):
|
|
236
|
+
state, kind = _active.get(), _kind.get()
|
|
237
|
+
if state is None or kind is None:
|
|
238
|
+
return await original(items, limit=limit, worker=worker)
|
|
239
|
+
|
|
240
|
+
async def observed(item):
|
|
241
|
+
behavior = str(item.behavior.get('name') or '')
|
|
242
|
+
dimensions = {'behavior': behavior}
|
|
243
|
+
dimensions.update({name: str(level['name']) for name, level in (item.tuple_spec or {}).items()})
|
|
244
|
+
job = {
|
|
245
|
+
'order': item.order,
|
|
246
|
+
'identity': {
|
|
247
|
+
'id': f'{kind}-generation-{item.order:06d}', 'kind': kind,
|
|
248
|
+
'behavior': behavior, 'dimensions': dimensions, 'case_count': item.count,
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
state['jobs'].append(job)
|
|
252
|
+
token = _job.set(job)
|
|
253
|
+
try:
|
|
254
|
+
result = await worker(item)
|
|
255
|
+
if 'error' in result:
|
|
256
|
+
job['error'] = result['error']
|
|
257
|
+
job['failure'] = _failure(job, result)
|
|
258
|
+
return result
|
|
259
|
+
finally:
|
|
260
|
+
# Keep bounded diagnostics only, not entire provider bodies.
|
|
261
|
+
job.pop('response', None)
|
|
262
|
+
job.pop('schema', None)
|
|
263
|
+
_job.reset(token)
|
|
264
|
+
return await original(items, limit=limit, worker=observed)
|
|
265
|
+
return gather
|
|
266
|
+
|
|
267
|
+
def response_wrapper(original):
|
|
268
|
+
@wraps(original)
|
|
269
|
+
async def generate_structured(*args, **kwargs):
|
|
270
|
+
job = _job.get()
|
|
271
|
+
if job is not None:
|
|
272
|
+
job['schema'] = kwargs['json_schema']
|
|
273
|
+
job['max_tokens'] = getattr(kwargs.get('options'), 'max_tokens', None)
|
|
274
|
+
response = await original(*args, **kwargs)
|
|
275
|
+
if job is not None:
|
|
276
|
+
job['response'] = response
|
|
277
|
+
return response
|
|
278
|
+
return generate_structured
|
|
279
|
+
|
|
280
|
+
patch('run', stage_wrapper)
|
|
281
|
+
patch('run_test_set', orchestration_wrapper)
|
|
282
|
+
patch('_generate_records', records_wrapper)
|
|
283
|
+
patch('gather_limited', gather_wrapper)
|
|
284
|
+
patch('generate_structured', response_wrapper)
|