ai: keep invalid planning results out of reusable cache

This commit is contained in:
jenkins 2026-09-28 18:10:16 -05:00
parent e9c901f177
commit 8a3f53082f
3 changed files with 37 additions and 9 deletions

View File

@ -91,7 +91,7 @@ class BatchClient:
raise ValueError("approved runtime/model configuration mismatch") raise ValueError("approved runtime/model configuration mismatch")
return result return result
def run(self, envelope): def run(self, envelope, validator=None):
"""Cache a FA01/synthetic request by source, prompt, schema and model identity. """Cache a FA01/synthetic request by source, prompt, schema and model identity.
The roster application must filter source rows and permitted fields before The roster application must filter source rows and permitted fields before
@ -118,6 +118,7 @@ class BatchClient:
self._verify(record["result"], payload) self._verify(record["result"], payload)
if record.get("cache_key") != key: if record.get("cache_key") != key:
raise ValueError("cache identity mismatch") raise ValueError("cache identity mismatch")
self._validate_output(destination, record, validator)
return {"cache_hit": True, "path": str(destination), "record": record} return {"cache_hit": True, "path": str(destination), "record": record}
self.catalog() self.catalog()
started = time.monotonic() started = time.monotonic()
@ -128,9 +129,21 @@ class BatchClient:
"request_sha256": hashlib.sha256(canonical(payload)).hexdigest(), "request_sha256": hashlib.sha256(canonical(payload)).hexdigest(),
"campaign_id": envelope["campaign_id"], "suite_id": envelope["suite_id"], "campaign_id": envelope["campaign_id"], "suite_id": envelope["suite_id"],
"client_wall_seconds": round(time.monotonic() - started, 3), "result": result} "client_wall_seconds": round(time.monotonic() - started, 3), "result": result}
self._validate_output(destination, record, validator)
self._save(destination, record) self._save(destination, record)
return {"cache_hit": False, "path": str(destination), "record": record} return {"cache_hit": False, "path": str(destination), "record": record}
def _validate_output(self, destination, record, validator):
"""Keep failed application contracts as diagnostics, outside reusable cache."""
if validator is None:
return
try:
validator(json.loads(record["result"]["response"]))
except (ValueError, KeyError, TypeError):
self._save(destination.with_suffix(".rejected.json"), record)
destination.unlink(missing_ok=True)
raise ValueError("application validation failed; local diagnostic retained") from None
@staticmethod @staticmethod
def _verify(result, payload): def _verify(result, payload):
"""Reject changed models, settings and incomplete results before caching.""" """Reject changed models, settings and incomplete results before caching."""

View File

@ -64,16 +64,17 @@ def main():
parser.error("case-id must select one bundled synthetic case") parser.error("case-id must select one bundled synthetic case")
request = envelope(args.model, args.mode, sources, args.think) request = envelope(args.model, args.mode, sources, args.think)
client = BatchClient(args.token_file, args.cache_dir) client = BatchClient(args.token_file, args.cache_dir)
outcome = client.run(request) def validate(content):
if args.mode == "profile":
validate_profile(content, sources[0])
elif args.mode == "group":
validate_families(content, sources)
elif content != {"status": "LOCAL_OK"}:
raise ValueError("synthetic transport check failed")
outcome = client.run(request, validator=validate)
record = outcome["record"] record = outcome["record"]
result = record["result"] result = record["result"]
content = json.loads(result["response"])
if args.mode == "profile":
validate_profile(content, sources[0])
elif args.mode == "group":
validate_families(content, sources)
elif content != {"status": "LOCAL_OK"}:
raise ValueError("synthetic transport check failed")
print(json.dumps({"synthetic": True, "mode": args.mode, "model": args.model, print(json.dumps({"synthetic": True, "mode": args.mode, "model": args.model,
"cache_hit": outcome["cache_hit"], "result_file": outcome["path"], "cache_hit": outcome["cache_hit"], "result_file": outcome["path"],
"wall_seconds": record["client_wall_seconds"], "wall_seconds": record["client_wall_seconds"],

View File

@ -128,3 +128,17 @@ def test_redirects_and_shared_token_files_are_rejected(tmp_path):
token.chmod(0o644) token.chmod(0o644)
with pytest.raises(ValueError): with pytest.raises(ValueError):
client.BatchClient(token, tmp_path / "cache") client.BatchClient(token, tmp_path / "cache")
def test_application_validation_failure_keeps_diagnostics_but_is_not_reused(transport, envelope):
instance, calls = transport
def reject(content):
raise ValueError("missing case IDs")
for _ in range(2):
with pytest.raises(ValueError, match="application validation failed"):
instance.run(envelope, validator=reject)
assert calls.count("/generate") == 2
assert len(list(instance.cache_dir.glob("*.rejected.json"))) == 1
assert len(list(instance.cache_dir.glob("*.json"))) == 1