Merge remote-tracking branch 'origin/main' into codex/leo-vps-gcp-drift-w2-20260715

This commit is contained in:
twentyOne2x 2026-07-16 13:00:22 +02:00
commit 9cd658f3b1
9 changed files with 1579 additions and 118 deletions

View file

@ -33,7 +33,9 @@ SOURCE_INBOX_ROOT = Path("/home/teleo/.hermes/profiles/leoclean/state/kb-source-
SOURCE_PREPARATION_ROOT = Path("/home/teleo/.hermes/profiles/leoclean/state/kb-source-preparation") SOURCE_PREPARATION_ROOT = Path("/home/teleo/.hermes/profiles/leoclean/state/kb-source-preparation")
SOURCE_PROPOSAL_RECEIPT_SCHEMA = "livingip.teleoKbSourceProposalReceipt.v1" SOURCE_PROPOSAL_RECEIPT_SCHEMA = "livingip.teleoKbSourceProposalReceipt.v1"
SOURCE_PREPARATION_RECEIPT_SCHEMA = "livingip.kbSourcePreparationReceipt.v1" SOURCE_PREPARATION_RECEIPT_SCHEMA = "livingip.kbSourcePreparationReceipt.v1"
SOURCE_PREPARATION_STATUS_SCHEMA = "livingip.kbSourcePreparationStatus.v1"
SOURCE_COMPILER_BUNDLE_SCHEMA = "livingip.kbSourceProposalBundle.v1" SOURCE_COMPILER_BUNDLE_SCHEMA = "livingip.kbSourceProposalBundle.v1"
SOURCE_COMPILER_STATUS_SCHEMA = "livingip.kbSourceCompilerStatus.v1"
RETRIEVAL_RECEIPT_SCHEMA = "livingip.teleoKbRetrievalReceipt.v1" RETRIEVAL_RECEIPT_SCHEMA = "livingip.teleoKbRetrievalReceipt.v1"
CANONICAL_COUNT_LABELS = ("claims", "sources", "claim_edges", "claim_evidence") CANONICAL_COUNT_LABELS = ("claims", "sources", "claim_edges", "claim_evidence")
SHA256_RE = re.compile(r"^[0-9a-f]{64}$") SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
@ -2362,27 +2364,47 @@ def _validated_source_bundle(bundle: Any) -> tuple[dict[str, Any], dict[str, Any
def compile_source_bundle(args: argparse.Namespace) -> dict[str, Any]: def compile_source_bundle(args: argparse.Namespace) -> dict[str, Any]:
if not SOURCE_COMPILER_PATH.is_file(): if not SOURCE_COMPILER_PATH.is_file():
raise SystemExit(f"source compiler is not deployed: {SOURCE_COMPILER_PATH}") raise SystemExit(f"source compiler is not deployed: {SOURCE_COMPILER_PATH}")
command = [ with tempfile.TemporaryDirectory(prefix="teleo-source-compiler-") as raw_private_dir:
sys.executable, private_dir = Path(raw_private_dir)
str(SOURCE_COMPILER_PATH), os.chmod(private_dir, 0o700)
"--artifact", bundle_path = private_dir / "source-bundle.json"
str(args.artifact), command = [
"--text", sys.executable,
str(args.text), str(SOURCE_COMPILER_PATH),
"--manifest", "--artifact",
str(args.manifest), str(args.artifact),
] "--text",
try: str(args.text),
completed = subprocess.run(command, capture_output=True, text=True, timeout=120, check=False) "--manifest",
except (OSError, subprocess.TimeoutExpired) as exc: str(args.manifest),
raise SystemExit(f"source compiler could not run: {exc}") from exc "--output",
if completed.returncode != 0: str(bundle_path),
detail = completed.stdout.strip() or completed.stderr.strip() or f"exit {completed.returncode}" ]
raise SystemExit(f"source compiler rejected the artifact: {detail}") try:
try: completed = subprocess.run(command, capture_output=True, text=True, timeout=120, check=False)
bundle = json.loads(completed.stdout) except subprocess.TimeoutExpired:
except json.JSONDecodeError as exc: raise SystemExit("source compiler timed out; private diagnostic detail redacted") from None
raise SystemExit(f"source compiler emitted invalid JSON: {exc}") from exc except OSError:
raise SystemExit("source compiler could not start; private diagnostic detail redacted") from None
if completed.returncode != 0:
raise SystemExit("source compiler failed; private diagnostic detail redacted")
try:
status = json.loads(completed.stdout)
except json.JSONDecodeError as exc:
raise SystemExit("source compiler emitted an invalid public status") from exc
if (
not isinstance(status, dict)
or status.get("schema") != SOURCE_COMPILER_STATUS_SCHEMA
or status.get("status") != "pending_review"
or status.get("database_write_performed") is not False
or status.get("canonical_apply_performed") is not False
or status.get("private_bundle_written") is not True
):
raise SystemExit("source compiler violated its private-output status contract")
try:
bundle = json.loads(bundle_path.read_text(encoding="utf-8", errors="strict"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise SystemExit("source compiler did not write a valid private bundle") from exc
_validated_source_bundle(bundle) _validated_source_bundle(bundle)
return bundle return bundle
@ -2589,23 +2611,38 @@ def prepare_source(args: argparse.Namespace) -> dict[str, Any]:
command.extend(("--text", str(args.text))) command.extend(("--text", str(args.text)))
try: try:
completed = subprocess.run(command, capture_output=True, text=True, timeout=420, check=False) completed = subprocess.run(command, capture_output=True, text=True, timeout=420, check=False)
except (OSError, subprocess.TimeoutExpired) as exc: except subprocess.TimeoutExpired:
raise SystemExit(f"source preparer could not run: {exc}") from exc raise SystemExit("source preparer timed out; private diagnostic detail redacted") from None
except OSError:
raise SystemExit("source preparer could not start; private diagnostic detail redacted") from None
finally: finally:
if context_path.exists(): if context_path.exists():
context_path.unlink() context_path.unlink()
if completed.returncode != 0: if completed.returncode != 0:
detail = completed.stdout.strip() or completed.stderr.strip() or f"exit {completed.returncode}" raise SystemExit("source preparer failed; private diagnostic detail redacted")
raise SystemExit(f"source preparer rejected the artifact: {detail}")
try: try:
receipt = json.loads(completed.stdout) status = json.loads(completed.stdout)
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
raise SystemExit(f"source preparer emitted invalid JSON: {exc}") from exc raise SystemExit("source preparer emitted an invalid public status") from exc
if (
not isinstance(status, dict)
or status.get("schema") != SOURCE_PREPARATION_STATUS_SCHEMA
or status.get("status") not in {"prepared", "no_novel_claims"}
or status.get("database_write_performed") is not False
or status.get("canonical_apply_performed") is not False
or status.get("private_outputs_written") is not True
):
raise SystemExit("source preparer violated its private-output status contract")
receipt_path = args.output_dir / "preparation-receipt.json"
try:
receipt = json.loads(receipt_path.read_text(encoding="utf-8", errors="strict"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise SystemExit("source preparer did not write a valid private receipt") from exc
if ( if (
not isinstance(receipt, dict) not isinstance(receipt, dict)
or receipt.get("schema") != SOURCE_PREPARATION_RECEIPT_SCHEMA or receipt.get("schema") != SOURCE_PREPARATION_RECEIPT_SCHEMA
or receipt.get("status") not in {"prepared", "no_novel_claims"} or receipt.get("status") != status["status"]
or receipt.get("database_write_performed") is not False or receipt.get("database_write_performed") is not False
or receipt.get("canonical_apply_performed") is not False or receipt.get("canonical_apply_performed") is not False
): ):
@ -2614,7 +2651,13 @@ def prepare_source(args: argparse.Namespace) -> dict[str, Any]:
observed_ids = (receipt.get("retrieval") or {}).get("canonical_candidate_ids") observed_ids = (receipt.get("retrieval") or {}).get("canonical_candidate_ids")
if observed_ids != expected_ids: if observed_ids != expected_ids:
raise SystemExit("source preparer receipt does not match the canonical retrieval candidates") raise SystemExit("source preparer receipt does not match the canonical retrieval candidates")
return receipt return {
"schema": SOURCE_PREPARATION_STATUS_SCHEMA,
"status": receipt["status"],
"database_write_performed": False,
"canonical_apply_performed": False,
"private_outputs_written": True,
}
def propose_source(args: argparse.Namespace) -> dict[str, Any]: def propose_source(args: argparse.Namespace) -> dict[str, Any]:
@ -3537,19 +3580,10 @@ def print_source_proposal_receipt(data: dict[str, Any]) -> None:
def print_source_preparation_receipt(data: dict[str, Any]) -> None: def print_source_preparation_receipt(data: dict[str, Any]) -> None:
retrieval = data["retrieval"] print("# Source Preparation Status\n")
extraction = data["extraction"]
outputs = data["outputs"]
print("# Source Preparation Receipt\n")
print(f"- status: `{data['status']}`") print(f"- status: `{data['status']}`")
print(f"- model: `{extraction['model']}`; claims prepared: `{extraction['claim_count']}`")
print(f"- canonical candidates checked: `{retrieval['canonical_candidate_count']}`")
print(f"- duplicate judgments: `{len(retrieval['duplicate_judgments'])}`")
print("- database write performed: `false`; canonical apply performed: `false`") print("- database write performed: `false`; canonical apply performed: `false`")
print(f"- extracted text: `{outputs['extracted_text']}`") print("- private outputs written: `true`; inspect the requested output directory locally")
print(f"- manifest: `{outputs['manifest'] or '-'}`")
print(f"- receipt: `{outputs['receipt']}`")
print(f"- next: {data['next_action']}")
def main() -> int: def main() -> int:

View file

@ -13,8 +13,10 @@ from __future__ import annotations
import argparse import argparse
import hashlib import hashlib
import json import json
import os
import re import re
import sys import sys
import tempfile
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -30,6 +32,7 @@ MANIFEST_SCHEMA = "livingip.kbSourceExtractionManifest.v1"
MANIFEST_SCHEMA_V2 = "livingip.kbSourceExtractionManifest.v2" MANIFEST_SCHEMA_V2 = "livingip.kbSourceExtractionManifest.v2"
MANIFEST_SCHEMAS = {MANIFEST_SCHEMA, MANIFEST_SCHEMA_V2} MANIFEST_SCHEMAS = {MANIFEST_SCHEMA, MANIFEST_SCHEMA_V2}
BUNDLE_SCHEMA = "livingip.kbSourceProposalBundle.v1" BUNDLE_SCHEMA = "livingip.kbSourceProposalBundle.v1"
STATUS_SCHEMA = "livingip.kbSourceCompilerStatus.v1"
SHA256_RE = re.compile(r"^[0-9a-f]{64}$") SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
KEY_RE = re.compile(r"^[a-z0-9][a-z0-9._:-]{0,127}$") KEY_RE = re.compile(r"^[a-z0-9][a-z0-9._:-]{0,127}$")
URI_SCHEME_RE = re.compile(r"^[a-z][a-z0-9+.-]*:", re.IGNORECASE) URI_SCHEME_RE = re.compile(r"^[a-z][a-z0-9+.-]*:", re.IGNORECASE)
@ -570,52 +573,59 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser.add_argument("--artifact", required=True, type=Path, help="raw source artifact; any binary format") parser.add_argument("--artifact", required=True, type=Path, help="raw source artifact; any binary format")
parser.add_argument("--text", required=True, type=Path, help="strict UTF-8 extraction of the artifact") parser.add_argument("--text", required=True, type=Path, help="strict UTF-8 extraction of the artifact")
parser.add_argument("--manifest", required=True, type=Path, help="extraction manifest JSON") parser.add_argument("--manifest", required=True, type=Path, help="extraction manifest JSON")
parser.add_argument("--output", type=Path, help="optional path for the same JSON emitted to stdout") parser.add_argument("--output", required=True, type=Path, help="private path for the compiled proposal bundle")
return parser.parse_args(argv) return parser.parse_args(argv)
def _write_private_output(path: Path, rendered: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
fd, raw_temp = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
temp = Path(raw_temp)
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(rendered)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp, path)
os.chmod(path, 0o600)
finally:
if temp.exists():
temp.unlink()
def _print_status(status: str, *, reason: str | None = None) -> None:
payload: dict[str, Any] = {
"schema": STATUS_SCHEMA,
"status": status,
"database_write_performed": False,
"canonical_apply_performed": False,
"private_bundle_written": status == "pending_review",
}
if reason is not None:
payload["reason"] = reason
print(json.dumps(payload, sort_keys=True))
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
args = parse_args(argv) args = parse_args(argv)
try: try:
if args.output: output_path = args.output.resolve()
output_path = args.output.resolve() input_paths = {args.artifact.resolve(), args.text.resolve(), args.manifest.resolve()}
input_paths = {args.artifact.resolve(), args.text.resolve(), args.manifest.resolve()} if output_path in input_paths:
if output_path in input_paths: raise CompilerError("--output must not overwrite an input file")
raise CompilerError("--output must not overwrite an input file")
bundle = compile_source_packet(args.artifact, args.text, args.manifest) bundle = compile_source_packet(args.artifact, args.text, args.manifest)
except (CompilerError, OSError, ValueError) as exc: except (CompilerError, OSError, ValueError):
print( _print_status("rejected", reason="input_validation_failed")
json.dumps(
{
"schema": BUNDLE_SCHEMA,
"status": "rejected",
"database_write_performed": False,
"error": str(exc),
},
sort_keys=True,
)
)
return 2 return 2
rendered = json.dumps(bundle, indent=2, sort_keys=True, ensure_ascii=True) + "\n" rendered = json.dumps(bundle, indent=2, sort_keys=True, ensure_ascii=True) + "\n"
if args.output: try:
try: _write_private_output(args.output, rendered)
args.output.parent.mkdir(parents=True, exist_ok=True) except OSError:
args.output.write_text(rendered, encoding="utf-8") _print_status("rejected", reason="output_write_failed")
except OSError as exc: return 2
print( _print_status("pending_review")
json.dumps(
{
"schema": BUNDLE_SCHEMA,
"status": "rejected",
"database_write_performed": False,
"error": f"could not write output: {exc}",
},
sort_keys=True,
)
)
return 2
print(rendered, end="")
return 0 return 0

View file

@ -0,0 +1,420 @@
#!/usr/bin/env python3
"""Build source-specific canonical claim context from a Postgres dump without a database write."""
from __future__ import annotations
import argparse
import codecs
import hashlib
import importlib.util
import json
import os
import re
import shutil
import subprocess
import tempfile
from collections import Counter
from decimal import Decimal, InvalidOperation
from pathlib import Path
from types import ModuleType
from typing import Any
SCHEMA = "livingip.kbSourceDumpContextReceipt.v1"
STATUS_SCHEMA = "livingip.kbSourceDumpContextStatus.v1"
DEFAULT_DOCKER_IMAGE = "postgres:16-alpine"
DEFAULT_LIMIT = 12
DEFAULT_COPY_ENCODING = "utf-8"
MAX_PREVIEW_BYTES = 16_000
TABLES = ("claims", "claim_evidence", "claim_edges")
class ContextError(RuntimeError):
"""Raised when a dump cannot safely produce canonical retrieval context."""
def sha256_bytes(value: bytes) -> str:
return hashlib.sha256(value).hexdigest()
def _read_file(path: Path, label: str) -> bytes:
try:
if not path.is_file():
raise ContextError(f"{label} is not a regular file: {path}")
return path.read_bytes()
except OSError as exc:
raise ContextError(f"could not read {label}: {exc}") from exc
def _load_kb_tool(repo_root: Path) -> ModuleType:
path = repo_root / "hermes-agent" / "leoclean-bin" / "kb_tool.py"
spec = importlib.util.spec_from_file_location("teleo_kb_tool_context", path)
if spec is None or spec.loader is None:
raise ContextError(f"could not load retrieval semantics from {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _copy_encoding(value: str) -> str:
try:
return codecs.lookup(value).name
except LookupError as exc:
raise ContextError("COPY encoding is not supported") from exc
def _decode_copy_field(value: str, *, encoding: str = DEFAULT_COPY_ENCODING) -> str | None:
if value == r"\N":
return None
encoding = _copy_encoding(encoding)
result = bytearray()
index = 0
escapes = {"b": 8, "f": 12, "n": 10, "r": 13, "t": 9, "v": 11, "\\": 92}
def append_text(text: str) -> None:
try:
result.extend(text.encode(encoding, errors="strict"))
except UnicodeEncodeError as exc:
raise ContextError(f"COPY field cannot be encoded as {encoding}") from exc
while index < len(value):
char = value[index]
if char != "\\":
append_text(char)
index += 1
continue
if index + 1 >= len(value):
raise ContextError("COPY field contains a dangling backslash escape")
index += 1
escaped = value[index]
if escaped in escapes:
result.append(escapes[escaped])
index += 1
continue
if escaped == "x":
match = re.match(r"[0-9A-Fa-f]{1,2}", value[index + 1 :])
if match is not None:
result.append(int(match.group(0), 16))
index += 1 + len(match.group(0))
continue
if escaped in "01234567":
match = re.match(r"[0-7]{1,3}", value[index:])
if match is None:
raise ContextError("COPY field contains a malformed octal byte escape")
byte_value = int(match.group(0), 8)
if byte_value > 0xFF:
raise ContextError("COPY field contains an out-of-range octal byte escape")
result.append(byte_value)
index += len(match.group(0))
continue
append_text(escaped)
index += 1
if 0 in result:
raise ContextError("COPY field contains a zero byte")
try:
return result.decode(encoding, errors="strict")
except UnicodeDecodeError as exc:
raise ContextError(f"COPY byte escapes do not form valid {encoding} text") from exc
def parse_copy_table(
sql: str, table: str, *, encoding: str = DEFAULT_COPY_ENCODING
) -> list[dict[str, str | None]]:
encoding = _copy_encoding(encoding)
prefix = f"COPY public.{table} ("
lines = iter(line[:-1] if line.endswith("\r") else line for line in sql.split("\n"))
for line in lines:
if line.startswith(prefix) and line.endswith(") FROM stdin;"):
columns = [item.strip() for item in line[len(prefix) : -len(") FROM stdin;")].split(",")]
break
else:
raise ContextError(f"pg_restore output has no COPY block for public.{table}")
rows: list[dict[str, str | None]] = []
for line in lines:
if line == r"\.":
return rows
fields = line.split("\t")
if len(fields) != len(columns):
raise ContextError(f"public.{table} COPY row has {len(fields)} fields; expected {len(columns)}")
rows.append(
dict(zip(columns, (_decode_copy_field(field, encoding=encoding) for field in fields), strict=True))
)
raise ContextError(f"public.{table} COPY block is unterminated")
def restore_table(
dump_path: Path, table: str, docker_image: str, *, encoding: str = DEFAULT_COPY_ENCODING
) -> str:
if table not in TABLES:
raise ContextError(f"unsupported canonical table: {table}")
if shutil.which("docker") is None:
raise ContextError("docker is not installed or not available on PATH")
command = [
"docker",
"run",
"--rm",
"--network=none",
"--mount",
f"type=bind,src={dump_path.resolve()},dst=/canonical.dump,readonly",
docker_image,
"pg_restore",
"--data-only",
"-t",
table,
"-f",
"-",
"/canonical.dump",
]
encoding = _copy_encoding(encoding)
try:
completed = subprocess.run(
command,
capture_output=True,
text=True,
encoding=encoding,
errors="strict",
timeout=180,
check=False,
)
except (OSError, subprocess.TimeoutExpired, UnicodeError) as exc:
raise ContextError(f"pg_restore failed ({type(exc).__name__}); diagnostic detail redacted") from exc
if completed.returncode != 0:
raise ContextError(f"pg_restore returned exit {completed.returncode}; diagnostic detail redacted")
return completed.stdout
def _decimal(value: str | None) -> Decimal:
if value is None:
return Decimal(0)
try:
return Decimal(value)
except InvalidOperation as exc:
raise ContextError("canonical claim confidence is not numeric") from exc
def build_context(
*,
dump_path: Path,
artifact_path: Path,
title: str,
limit: int,
docker_image: str,
repo_root: Path,
copy_encoding: str = DEFAULT_COPY_ENCODING,
) -> tuple[dict[str, Any], dict[str, Any]]:
if limit < 1 or limit > 20:
raise ContextError("limit must be between 1 and 20")
dump_bytes = _read_file(dump_path, "canonical dump")
artifact_bytes = _read_file(artifact_path, "source artifact")
try:
preview = artifact_bytes[:MAX_PREVIEW_BYTES].decode("utf-8", errors="strict")
except UnicodeDecodeError as exc:
raise ContextError(f"source artifact preview must be strict UTF-8: {exc}") from exc
kb_tool = _load_kb_tool(repo_root)
query = " ".join(kb_tool.query_terms(f"{title} {preview}"))
terms = kb_tool.query_terms(query)
min_score = 2 if len(terms) >= 3 else 1
copy_encoding = _copy_encoding(copy_encoding)
tables = {
table: parse_copy_table(
restore_table(dump_path, table, docker_image, encoding=copy_encoding),
table,
encoding=copy_encoding,
)
for table in TABLES
}
evidence_counts = Counter(row["claim_id"] for row in tables["claim_evidence"] if row["claim_id"])
edge_counts: Counter[str] = Counter()
for row in tables["claim_edges"]:
if row["from_claim"]:
edge_counts[row["from_claim"]] += 1
if row["to_claim"]:
edge_counts[row["to_claim"]] += 1
ranked: list[dict[str, Any]] = []
for row in tables["claims"]:
if row["status"] != "open" or not row["id"] or not row["text"]:
continue
text = row["text"]
searchable = f"{text}\n{row['tags'] or ''}".lower()
score = sum(term in searchable for term in terms)
if score < min_score:
continue
ranked.append(
{
"id": row["id"],
"text": text,
"score": score,
"evidence_count": evidence_counts[row["id"]],
"edge_count": edge_counts[row["id"]],
"_confidence": _decimal(row["confidence"]),
}
)
ranked.sort(
key=lambda row: (
-row["score"],
-row["evidence_count"],
-row["edge_count"],
-row["_confidence"],
len(row["text"]),
row["text"],
row["id"],
)
)
candidates = [{key: value for key, value in row.items() if key != "_confidence"} for row in ranked[:limit]]
context = {"database_search_query": query, "candidate_claims": candidates}
receipt = {
"schema": SCHEMA,
"status": "prepared",
"database_write_performed": False,
"model_call_performed": False,
"canonical_dump": {
"path": str(dump_path.resolve()),
"sha256": sha256_bytes(dump_bytes),
},
"source": {
"path": str(artifact_path.resolve()),
"sha256": sha256_bytes(artifact_bytes),
},
"retrieval": {
"query_sha256": sha256_bytes(query.encode("utf-8")),
"term_count": len(terms),
"minimum_score": min_score,
"canonical_open_claim_count": sum(row["status"] == "open" for row in tables["claims"]),
"candidate_count": len(candidates),
"candidate_ids": [row["id"] for row in candidates],
},
"runtime": {
"docker_image": docker_image,
"network_disabled": True,
"pg_restore_output_retained": False,
"copy_encoding": copy_encoding,
"copy_decode_errors": "strict",
},
}
return context, receipt
def _stage_private_json(path: Path, value: Any) -> Path:
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
os.chmod(path.parent, 0o700)
fd, raw_temp = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
temp = Path(raw_temp)
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(value, handle, indent=2, sort_keys=True, ensure_ascii=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
return temp
except BaseException:
if temp.exists():
temp.unlink()
raise
def _private_json(path: Path, value: Any) -> None:
if path.exists():
raise ContextError(f"refusing to overwrite existing output: {path}")
temp = _stage_private_json(path, value)
try:
os.replace(temp, path)
os.chmod(path, 0o600)
finally:
if temp.exists():
temp.unlink()
def _publish_private_json_outputs(outputs: tuple[tuple[Path, Any], ...]) -> None:
for path, _value in outputs:
if path.exists():
raise ContextError(f"refusing to overwrite existing output: {path}")
staged: list[tuple[Path, Path]] = []
try:
for path, value in outputs:
staged.append((path, _stage_private_json(path, value)))
for path, temp in staged:
os.replace(temp, path)
os.chmod(path, 0o600)
finally:
for _path, temp in staged:
if temp.exists():
temp.unlink()
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dump", required=True, type=Path)
parser.add_argument("--artifact", required=True, type=Path)
parser.add_argument("--title", required=True)
parser.add_argument("--context-output", required=True, type=Path)
parser.add_argument("--receipt-output", required=True, type=Path)
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
parser.add_argument("--docker-image", default=DEFAULT_DOCKER_IMAGE)
parser.add_argument("--copy-encoding", default=DEFAULT_COPY_ENCODING)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
repo_root = Path(__file__).resolve().parents[1]
context_existed = args.context_output.is_file()
receipt_existed = args.receipt_output.is_file()
try:
if args.context_output.resolve() == args.receipt_output.resolve():
raise ContextError("context and receipt outputs must be different files")
context, receipt = build_context(
dump_path=args.dump,
artifact_path=args.artifact,
title=args.title,
limit=args.limit,
docker_image=args.docker_image,
repo_root=repo_root,
copy_encoding=args.copy_encoding,
)
receipt["outputs"] = {
"context": str(args.context_output.resolve()),
"receipt": str(args.receipt_output.resolve()),
}
_publish_private_json_outputs(
((args.context_output, context), (args.receipt_output, receipt))
)
except (ContextError, OSError, ValueError):
context_written = not context_existed and args.context_output.is_file()
receipt_written = not receipt_existed and args.receipt_output.is_file()
print(
json.dumps(
{
"schema": STATUS_SCHEMA,
"status": "rejected",
"database_write_performed": False,
"model_call_performed": False,
"private_context_written": context_written,
"private_receipt_written": receipt_written,
"reason": "context_preparation_failed",
},
sort_keys=True,
)
)
return 2
print(
json.dumps(
{
"schema": STATUS_SCHEMA,
"status": "prepared",
"database_write_performed": False,
"model_call_performed": False,
"private_context_written": True,
"private_receipt_written": True,
},
indent=2,
sort_keys=True,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -15,6 +15,8 @@ import hashlib
import json import json
import os import os
import re import re
import shutil
import subprocess
import sys import sys
import tempfile import tempfile
import urllib.error import urllib.error
@ -28,12 +30,15 @@ sys.path.insert(0, str(HERE))
import compile_kb_source_packet as compiler # noqa: E402 import compile_kb_source_packet as compiler # noqa: E402
RECEIPT_SCHEMA = "livingip.kbSourcePreparationReceipt.v1" RECEIPT_SCHEMA = "livingip.kbSourcePreparationReceipt.v1"
STATUS_SCHEMA = "livingip.kbSourcePreparationStatus.v1"
MODEL_OUTPUT_SCHEMA = "livingip.kbSourceModelExtraction.v2" MODEL_OUTPUT_SCHEMA = "livingip.kbSourceModelExtraction.v2"
RESOLVED_OUTPUT_SCHEMA = "livingip.kbSourceResolvedExtraction.v1" RESOLVED_OUTPUT_SCHEMA = "livingip.kbSourceResolvedExtraction.v1"
DEFAULT_MODEL = "anthropic/claude-sonnet-4.5" DEFAULT_MODEL = "anthropic/claude-sonnet-4.5"
EXTRACTOR_VERSION = "2" EXTRACTOR_VERSION = "2"
DEFAULT_OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" DEFAULT_OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
DEFAULT_KEY_FILE = Path("/opt/teleo-eval/secrets/openrouter-key") DEFAULT_KEY_FILE = Path("/opt/teleo-eval/secrets/openrouter-key")
DEFAULT_CLAUDE_MAX_BUDGET_USD = 0.10
MODEL_PROVIDERS = {"openrouter", "claude-cli"}
TEXT_SUFFIXES = {".csv", ".htm", ".html", ".json", ".jsonl", ".md", ".rst", ".txt", ".xml"} TEXT_SUFFIXES = {".csv", ".htm", ".html", ".json", ".jsonl", ".md", ".rst", ".txt", ".xml"}
MAX_SOURCE_CHARS = 120_000 MAX_SOURCE_CHARS = 120_000
MAX_CANDIDATES = 20 MAX_CANDIDATES = 20
@ -248,10 +253,10 @@ def call_openrouter(prompt: str, *, model: str, key_file: Path, url: str, timeou
with urllib.request.urlopen(request, timeout=timeout_seconds) as response: with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
body = response.read() body = response.read()
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
detail = exc.read(600).decode("utf-8", errors="replace") exc.read(600)
raise PreparationError(f"OpenRouter returned HTTP {exc.code}: {detail}") from exc raise PreparationError(f"OpenRouter returned HTTP {exc.code}; provider detail redacted") from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc: except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise PreparationError(f"OpenRouter request failed: {exc}") from exc raise PreparationError(f"OpenRouter request failed ({type(exc).__name__}); provider detail redacted") from exc
try: try:
envelope = json.loads(body.decode("utf-8", errors="strict")) envelope = json.loads(body.decode("utf-8", errors="strict"))
content = envelope["choices"][0]["message"]["content"] content = envelope["choices"][0]["message"]["content"]
@ -263,6 +268,100 @@ def call_openrouter(prompt: str, *, model: str, key_file: Path, url: str, timeou
return content, usage if isinstance(usage, dict) else {} return content, usage if isinstance(usage, dict) else {}
def call_claude_cli(
prompt: str,
*,
model: str,
timeout_seconds: int,
max_budget_usd: float,
) -> tuple[str, dict[str, Any]]:
"""Run a non-persistent, tool-free Claude extraction with a cost cap.
The private prompt is supplied on stdin rather than in the process argv.
Provider diagnostics are never copied into exceptions because they may
contain source fragments or credential-adjacent values.
"""
executable = shutil.which("claude")
if executable is None:
raise PreparationError("claude CLI is not installed or not available on PATH")
if isinstance(max_budget_usd, bool) or not isinstance(max_budget_usd, (int, float)) or max_budget_usd <= 0:
raise PreparationError("Claude CLI max budget must be a positive number")
command = [
executable,
"-p",
"--no-session-persistence",
"--tools",
"",
"--model",
model,
"--output-format",
"json",
"--max-budget-usd",
str(max_budget_usd),
]
try:
completed = subprocess.run(
command,
input=prompt,
capture_output=True,
text=True,
timeout=timeout_seconds,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise PreparationError(f"Claude CLI request failed ({type(exc).__name__}); provider detail redacted") from exc
if completed.returncode != 0:
raise PreparationError(f"Claude CLI returned exit {completed.returncode}; provider detail redacted")
try:
envelope = json.loads(completed.stdout)
except json.JSONDecodeError as exc:
raise PreparationError("Claude CLI returned an invalid JSON envelope; provider detail redacted") from exc
if not isinstance(envelope, dict) or envelope.get("is_error") is True or envelope.get("subtype") != "success":
raise PreparationError("Claude CLI returned an unsuccessful envelope; provider detail redacted")
content = envelope.get("result")
if not isinstance(content, str) or not content.strip():
raise PreparationError("Claude CLI returned no extraction content")
usage = {
"total_cost_usd": envelope.get("total_cost_usd"),
"usage": envelope.get("usage") if isinstance(envelope.get("usage"), dict) else {},
"model_usage": envelope.get("modelUsage") if isinstance(envelope.get("modelUsage"), dict) else {},
"max_budget_usd": max_budget_usd,
"session_persistence": False,
"tools_enabled": False,
}
return content, usage
def call_model(
prompt: str, args: argparse.Namespace, *, max_budget_usd: float | None = None
) -> tuple[str, dict[str, Any]]:
provider = getattr(args, "provider", "openrouter")
if provider == "openrouter":
return call_openrouter(
prompt,
model=args.model,
key_file=args.key_file,
url=args.openrouter_url,
timeout_seconds=args.timeout_seconds,
)
if provider == "claude-cli":
return call_claude_cli(
prompt,
model=args.model,
timeout_seconds=args.timeout_seconds,
max_budget_usd=(
max_budget_usd
if max_budget_usd is not None
else getattr(args, "max_budget_usd", DEFAULT_CLAUDE_MAX_BUDGET_USD)
),
)
raise PreparationError(f"model provider must be one of {sorted(MODEL_PROVIDERS)}")
def extractor_name(args: argparse.Namespace) -> str:
return f"{getattr(args, 'provider', 'openrouter')}:{args.model}"
def parse_model_output(content: str) -> dict[str, Any]: def parse_model_output(content: str) -> dict[str, Any]:
stripped = content.strip() stripped = content.strip()
if stripped.startswith("```"): if stripped.startswith("```"):
@ -447,7 +546,7 @@ def build_manifest(
"schema": compiler.MANIFEST_SCHEMA_V2, "schema": compiler.MANIFEST_SCHEMA_V2,
"artifact_sha256": artifact_sha256, "artifact_sha256": artifact_sha256,
"extracted_text_sha256": text_sha256, "extracted_text_sha256": text_sha256,
"extractor": {"name": f"openrouter:{args.model}", "version": EXTRACTOR_VERSION}, "extractor": {"name": extractor_name(args), "version": EXTRACTOR_VERSION},
"source": { "source": {
"identity": args.identity, "identity": args.identity,
"source_key": args.source_key, "source_key": args.source_key,
@ -495,7 +594,7 @@ def _validate_metadata(args: argparse.Namespace) -> None:
raise PreparationError(f"--source-type must be one of {sorted(compiler.ap.SOURCE_TYPES)}") raise PreparationError(f"--source-type must be one of {sorted(compiler.ap.SOURCE_TYPES)}")
def prepare(args: argparse.Namespace) -> dict[str, Any]: def _prepare_staged(args: argparse.Namespace, published_output_dir: Path) -> dict[str, Any]:
_validate_metadata(args) _validate_metadata(args)
artifact_bytes = _read_nonempty(args.artifact, "artifact") artifact_bytes = _read_nonempty(args.artifact, "artifact")
text_bytes = extract_utf8_text(args.artifact, args.text) text_bytes = extract_utf8_text(args.artifact, args.text)
@ -526,14 +625,23 @@ def prepare(args: argparse.Namespace) -> dict[str, Any]:
extraction: dict[str, Any] | None = None extraction: dict[str, Any] | None = None
bundle: dict[str, Any] | None = None bundle: dict[str, Any] | None = None
manifest: dict[str, Any] | None = None manifest: dict[str, Any] | None = None
reported_total_cost_usd = 0.0
for attempt in range(1, MAX_ATTEMPTS + 1): for attempt in range(1, MAX_ATTEMPTS + 1):
raw, usage = call_openrouter( provider = getattr(args, "provider", "openrouter")
max_total_budget_usd = getattr(args, "max_budget_usd", DEFAULT_CLAUDE_MAX_BUDGET_USD)
remaining_budget_usd = max_total_budget_usd - reported_total_cost_usd if provider == "claude-cli" else None
raw, usage = call_model(
build_prompt(fragments, context, repair_error), build_prompt(fragments, context, repair_error),
model=args.model, args,
key_file=args.key_file, max_budget_usd=remaining_budget_usd,
url=args.openrouter_url,
timeout_seconds=args.timeout_seconds,
) )
if provider == "claude-cli":
reported_cost = usage.get("total_cost_usd")
if isinstance(reported_cost, bool) or not isinstance(reported_cost, (int, float)) or reported_cost < 0:
raise PreparationError("Claude CLI did not report a valid non-negative model cost")
reported_total_cost_usd += float(reported_cost)
if reported_total_cost_usd > max_total_budget_usd + 1e-9:
raise PreparationError("Claude CLI reported model cost above the configured total budget")
attempts.append({"attempt": attempt, "response_sha256": sha256_bytes(raw.encode("utf-8")), "usage": usage}) attempts.append({"attempt": attempt, "response_sha256": sha256_bytes(raw.encode("utf-8")), "usage": usage})
try: try:
selection = parse_model_output(raw) selection = parse_model_output(raw)
@ -616,9 +724,17 @@ def prepare(args: argparse.Namespace) -> dict[str, Any]:
"duplicate_judgments": extraction["duplicates"], "duplicate_judgments": extraction["duplicates"],
}, },
"extraction": { "extraction": {
"provider": "openrouter", "provider": getattr(args, "provider", "openrouter"),
"model": args.model, "model": args.model,
"attempts": attempts, "attempts": attempts,
"cost_control": {
"max_total_budget_usd": (
getattr(args, "max_budget_usd", DEFAULT_CLAUDE_MAX_BUDGET_USD)
if getattr(args, "provider", "openrouter") == "claude-cli"
else None
),
"reported_total_cost_usd": reported_total_cost_usd,
},
"claim_count": len(extraction["claims"]), "claim_count": len(extraction["claims"]),
"evidence_count": sum(len(claim["evidence"]) for claim in extraction["claims"]), "evidence_count": sum(len(claim["evidence"]) for claim in extraction["claims"]),
"duplicate_judgment_count": len(extraction["duplicates"]), "duplicate_judgment_count": len(extraction["duplicates"]),
@ -631,14 +747,14 @@ def prepare(args: argparse.Namespace) -> dict[str, Any]:
"extracted_text_sha256": text_sha256, "extracted_text_sha256": text_sha256,
"kb_context_sha256": sha256_bytes(_json_bytes(context)), "kb_context_sha256": sha256_bytes(_json_bytes(context)),
"fragment_index_sha256": sha256_bytes(_json_bytes(fragment_locations)), "fragment_index_sha256": sha256_bytes(_json_bytes(fragment_locations)),
"extractor": {"name": f"openrouter:{args.model}", "version": EXTRACTOR_VERSION}, "extractor": {"name": extractor_name(args), "version": EXTRACTOR_VERSION},
"exact_selected_locations_validated": bundle is not None, "exact_selected_locations_validated": bundle is not None,
}, },
"outputs": { "outputs": {
"output_dir": str(output_dir), "output_dir": str(published_output_dir),
"extracted_text": str(text_path), "extracted_text": str(published_output_dir / text_path.name),
"extraction_result": str(extraction_path), "extraction_result": str(published_output_dir / extraction_path.name),
"manifest": str(manifest_path) if status == "prepared" else None, "manifest": str(published_output_dir / manifest_path.name) if status == "prepared" else None,
}, },
"compiler": { "compiler": {
"validated": bundle is not None, "validated": bundle is not None,
@ -651,11 +767,43 @@ def prepare(args: argparse.Namespace) -> dict[str, Any]:
else "No novel claim was prepared; inspect duplicate judgments and do not stage an empty proposal." else "No novel claim was prepared; inspect duplicate judgments and do not stage an empty proposal."
), ),
} }
receipt["outputs"]["receipt"] = str(receipt_path) receipt["outputs"]["receipt"] = str(published_output_dir / receipt_path.name)
_write_private(receipt_path, _json_bytes(receipt)) _write_private(receipt_path, _json_bytes(receipt))
return receipt return receipt
def _validate_output_target(path: Path) -> None:
if path.is_symlink():
raise PreparationError("--output-dir must not be a symlink")
if path.exists() and (not path.is_dir() or any(path.iterdir())):
raise PreparationError("--output-dir must be absent or an empty dedicated directory")
def prepare(args: argparse.Namespace) -> dict[str, Any]:
requested_output_dir = args.output_dir.expanduser()
_validate_output_target(requested_output_dir)
output_dir = requested_output_dir.resolve()
output_dir.parent.mkdir(parents=True, exist_ok=True)
staging_dir = Path(
tempfile.mkdtemp(
prefix=f".{output_dir.name}.",
suffix=".staging",
dir=output_dir.parent,
)
)
os.chmod(staging_dir, 0o700)
staged_args = argparse.Namespace(**vars(args))
staged_args.output_dir = staging_dir
try:
receipt = _prepare_staged(staged_args, output_dir)
_validate_output_target(output_dir)
os.replace(staging_dir, output_dir)
return receipt
finally:
if staging_dir.exists():
shutil.rmtree(staging_dir)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace: def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--artifact", required=True, type=Path) parser.add_argument("--artifact", required=True, type=Path)
@ -668,31 +816,65 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser.add_argument("--locator", required=True) parser.add_argument("--locator", required=True)
parser.add_argument("--output-dir", required=True, type=Path) parser.add_argument("--output-dir", required=True, type=Path)
parser.add_argument("--model", default=DEFAULT_MODEL) parser.add_argument("--model", default=DEFAULT_MODEL)
parser.add_argument("--provider", choices=sorted(MODEL_PROVIDERS), default="openrouter")
parser.add_argument(
"--max-budget-usd",
type=float,
default=DEFAULT_CLAUDE_MAX_BUDGET_USD,
help="hard total Claude CLI budget cap across validation retries; ignored by the OpenRouter provider",
)
parser.add_argument("--openrouter-url", default=DEFAULT_OPENROUTER_URL) parser.add_argument("--openrouter-url", default=DEFAULT_OPENROUTER_URL)
parser.add_argument("--timeout-seconds", type=int, default=180) parser.add_argument("--timeout-seconds", type=int, default=180)
parser.set_defaults(key_file=DEFAULT_KEY_FILE) parser.set_defaults(key_file=DEFAULT_KEY_FILE)
return parser.parse_args(argv) return parser.parse_args(argv)
def _private_output_state(output_dir: Path) -> dict[str, bool]:
return {
"extracted_text": (output_dir / "extracted-text.txt").is_file(),
"extraction_result": (output_dir / "extraction-result.json").is_file(),
"manifest": (output_dir / "source-manifest.json").is_file(),
"receipt": (output_dir / "preparation-receipt.json").is_file(),
}
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
args = parse_args(argv) args = parse_args(argv)
initial_output_state = _private_output_state(args.output_dir)
try: try:
receipt = prepare(args) receipt = prepare(args)
except (PreparationError, compiler.CompilerError, OSError, ValueError) as exc: except (PreparationError, compiler.CompilerError, OSError, ValueError):
current_output_state = _private_output_state(args.output_dir)
written_output_state = {
name: present and not initial_output_state[name] for name, present in current_output_state.items()
}
print( print(
json.dumps( json.dumps(
{ {
"schema": RECEIPT_SCHEMA, "schema": STATUS_SCHEMA,
"status": "rejected", "status": "rejected",
"database_write_performed": False, "database_write_performed": False,
"canonical_apply_performed": False, "canonical_apply_performed": False,
"error": str(exc), "private_outputs_written": written_output_state["receipt"],
"private_output_files": written_output_state,
"reason": "source_preparation_failed",
}, },
sort_keys=True, sort_keys=True,
) )
) )
return 2 return 2
print(json.dumps(receipt, indent=2, sort_keys=True, ensure_ascii=True)) print(
json.dumps(
{
"schema": STATUS_SCHEMA,
"status": receipt["status"],
"database_write_performed": False,
"canonical_apply_performed": False,
"private_outputs_written": True,
},
sort_keys=True,
)
)
return 0 return 0

View file

@ -3,6 +3,8 @@ from __future__ import annotations
import copy import copy
import hashlib import hashlib
import json import json
import stat
import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
@ -207,12 +209,15 @@ def test_v2_manifest_rejects_unretrieved_or_unbound_duplicate_judgments(tmp_path
_compile(tmp_path / "unbound", manifest) _compile(tmp_path / "unbound", manifest)
def test_cli_emits_structured_json_to_stdout_and_optional_output(tmp_path: Path, capsys: pytest.CaptureFixture) -> None: def test_cli_writes_private_bundle_and_emits_only_public_status_bytes(tmp_path: Path) -> None:
artifact, text, manifest_path = _write_inputs(tmp_path) private_root = tmp_path / "private-source-sentinel"
output = tmp_path / "compiled" / "packet.json" artifact, text, manifest_path = _write_inputs(private_root)
output = private_root / "compiled" / "packet.json"
status = compiler.main( completed = subprocess.run(
[ [
sys.executable,
str(REPO_ROOT / "scripts" / "compile_kb_source_packet.py"),
"--artifact", "--artifact",
str(artifact), str(artifact),
"--text", "--text",
@ -221,13 +226,34 @@ def test_cli_emits_structured_json_to_stdout_and_optional_output(tmp_path: Path,
str(manifest_path), str(manifest_path),
"--output", "--output",
str(output), str(output),
] ],
capture_output=True,
check=False,
) )
stdout = capsys.readouterr().out stdout = completed.stdout
assert status == 0 public_status = json.loads(stdout.decode("utf-8", errors="strict"))
assert output.read_text(encoding="utf-8") == stdout bundle = json.loads(output.read_text(encoding="utf-8"))
assert json.loads(stdout)["status"] == "pending_review" assert completed.returncode == 0
assert completed.stderr == b""
assert public_status == {
"schema": compiler.STATUS_SCHEMA,
"status": "pending_review",
"database_write_performed": False,
"canonical_apply_performed": False,
"private_bundle_written": True,
}
assert bundle["status"] == "pending_review"
assert output.read_bytes() != stdout
for private_value in (
str(private_root),
str(artifact),
str(output),
EXTRACTED_TEXT,
bundle["validated_manifest"]["source"]["title"],
):
assert private_value.encode("utf-8") not in stdout
assert stat.S_IMODE(output.stat().st_mode) == 0o600
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -355,7 +381,8 @@ def test_rejects_path_gaps_invalid_utf8_and_output_overwrite(tmp_path: Path, cap
assert status == 2 assert status == 2
assert rejection["status"] == "rejected" assert rejection["status"] == "rejected"
assert rejection["database_write_performed"] is False assert rejection["database_write_performed"] is False
assert "must not overwrite" in rejection["error"] assert rejection["reason"] == "input_validation_failed"
assert str(artifact) not in json.dumps(rejection)
def test_cli_reports_output_write_failure_as_structured_rejection( def test_cli_reports_output_write_failure_as_structured_rejection(
@ -363,14 +390,11 @@ def test_cli_reports_output_write_failure_as_structured_rejection(
) -> None: ) -> None:
artifact, text, manifest_path = _write_inputs(tmp_path) artifact, text, manifest_path = _write_inputs(tmp_path)
output = tmp_path / "packet.json" output = tmp_path / "packet.json"
original_write_text = Path.write_text
def guarded_write_text(path: Path, data: str, *args, **kwargs): def fail_write(_path: Path, _rendered: str) -> None:
if path == output: raise PermissionError("fixture output denied")
raise PermissionError("fixture output denied")
return original_write_text(path, data, *args, **kwargs)
monkeypatch.setattr(Path, "write_text", guarded_write_text) monkeypatch.setattr(compiler, "_write_private_output", fail_write)
status = compiler.main( status = compiler.main(
[ [
"--artifact", "--artifact",
@ -388,7 +412,8 @@ def test_cli_reports_output_write_failure_as_structured_rejection(
assert status == 2 assert status == 2
assert rejection["status"] == "rejected" assert rejection["status"] == "rejected"
assert rejection["database_write_performed"] is False assert rejection["database_write_performed"] is False
assert "could not write output" in rejection["error"] assert rejection["reason"] == "output_write_failed"
assert "fixture output denied" not in json.dumps(rejection)
def test_rejects_attempt_to_invent_source_from_chat_label(tmp_path: Path) -> None: def test_rejects_attempt_to_invent_source_from_chat_label(tmp_path: Path) -> None:

View file

@ -1623,7 +1623,9 @@ def test_vps_bridge_binds_claim_challenge_to_exact_retrieved_rows() -> None:
assert "no database write was made" in unbound_response assert "no database write was made" in unbound_response
def test_vps_prepare_source_retrieves_canonical_candidates_before_model_extraction(tmp_path: Path, monkeypatch) -> None: def test_vps_prepare_source_retrieves_canonical_candidates_before_model_extraction(
tmp_path: Path, monkeypatch, capsys
) -> None:
module = _load_module(BRIDGE_DIR / "kb_tool.py") module = _load_module(BRIDGE_DIR / "kb_tool.py")
preparer = tmp_path / "prepare_kb_source_manifest.py" preparer = tmp_path / "prepare_kb_source_manifest.py"
preparer.write_text("# fixture\n", encoding="utf-8") preparer.write_text("# fixture\n", encoding="utf-8")
@ -1658,16 +1660,29 @@ def test_vps_prepare_source_retrieves_canonical_candidates_before_model_extracti
context_path = Path(command[command.index("--kb-context") + 1]) context_path = Path(command[command.index("--kb-context") + 1])
captured["context"] = json.loads(context_path.read_text(encoding="utf-8")) captured["context"] = json.loads(context_path.read_text(encoding="utf-8"))
captured["command"] = command captured["command"] = command
receipt = { private_output_dir = Path(command[command.index("--output-dir") + 1])
private_output_dir.mkdir(parents=True)
private_receipt = {
"schema": module.SOURCE_PREPARATION_RECEIPT_SCHEMA, "schema": module.SOURCE_PREPARATION_RECEIPT_SCHEMA,
"status": "prepared", "status": "prepared",
"database_write_performed": False, "database_write_performed": False,
"canonical_apply_performed": False, "canonical_apply_performed": False,
"retrieval": {"canonical_candidate_ids": [candidate_id]}, "retrieval": {"canonical_candidate_ids": [candidate_id]},
"extraction": {"model": "fixture/model", "claim_count": 1}, "extraction": {"model": "fixture/model", "claim_count": 1},
"outputs": {}, "source": {"title": "PRIVATE-SOURCE-TITLE-SENTINEL"},
"outputs": {"receipt": str(private_output_dir / "preparation-receipt.json")},
} }
return subprocess.CompletedProcess(command, 0, stdout=json.dumps(receipt), stderr="") (private_output_dir / "preparation-receipt.json").write_text(
json.dumps(private_receipt), encoding="utf-8"
)
public_status = {
"schema": module.SOURCE_PREPARATION_STATUS_SCHEMA,
"status": "prepared",
"database_write_performed": False,
"canonical_apply_performed": False,
"private_outputs_written": True,
}
return subprocess.CompletedProcess(command, 0, stdout=json.dumps(public_status), stderr="")
monkeypatch.setattr(module.subprocess, "run", fake_run) monkeypatch.setattr(module.subprocess, "run", fake_run)
args = SimpleNamespace( args = SimpleNamespace(
@ -1690,6 +1705,77 @@ def test_vps_prepare_source_retrieves_canonical_candidates_before_model_extracti
assert captured["context"]["database_search_query"] == args.query assert captured["context"]["database_search_query"] == args.query
assert captured["context"]["candidate_claims"][0]["id"] == candidate_id assert captured["context"]["candidate_claims"][0]["id"] == candidate_id
assert "--text" not in captured["command"] assert "--text" not in captured["command"]
assert "source" not in receipt
assert "outputs" not in receipt
module.print_source_preparation_receipt(receipt)
stdout = capsys.readouterr().out
assert "PRIVATE-SOURCE-TITLE-SENTINEL" not in stdout
assert str(output_dir) not in stdout
assert candidate_id not in stdout
@pytest.mark.parametrize(
("failure_kind", "expected_error"),
[
("timeout", "source preparer timed out; private diagnostic detail redacted"),
("launch", "source preparer could not start; private diagnostic detail redacted"),
("crash", "source preparer failed; private diagnostic detail redacted"),
],
)
def test_vps_prepare_source_wrapper_redacts_commands_and_child_output(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
failure_kind: str,
expected_error: str,
) -> None:
module = _load_module(BRIDGE_DIR / "kb_tool.py")
sentinel = "PREPARER-WRAPPER-PRIVATE-CANARY"
preparer = tmp_path / "prepare_kb_source_manifest.py"
preparer.write_text("# fixture\n", encoding="utf-8")
inbox = tmp_path / "source-inbox"
preparation_root = tmp_path / "source-preparation"
private_inbox = inbox / sentinel
private_inbox.mkdir(parents=True)
preparation_root.mkdir()
artifact = private_inbox / "source.md"
artifact.write_text("private", encoding="utf-8")
monkeypatch.setattr(module, "SOURCE_PREPARER_PATH", preparer)
monkeypatch.setattr(module, "SOURCE_INBOX_ROOT", inbox)
monkeypatch.setattr(module, "SOURCE_PREPARATION_ROOT", preparation_root)
monkeypatch.setattr(module, "find_claims", lambda *_args, **_kwargs: [])
def fail(command, **_kwargs):
if failure_kind == "timeout":
raise subprocess.TimeoutExpired(command, 420, output=sentinel, stderr=sentinel)
if failure_kind == "launch":
raise OSError(f"could not launch {sentinel}")
return subprocess.CompletedProcess(command, 19, stdout="", stderr=sentinel)
monkeypatch.setattr(module.subprocess, "run", fail)
args = SimpleNamespace(
local=True,
query=sentinel,
artifact=artifact,
text=None,
identity=f"document:{sentinel}",
source_key="private_source",
source_type="article",
title=sentinel,
locator=f"artifact://{sentinel}",
output_dir=preparation_root / "private-output",
model=sentinel,
)
with pytest.raises(SystemExit) as caught:
module.prepare_source(args)
rendered = str(caught.value)
assert rendered == expected_error
assert sentinel not in rendered
assert str(artifact) not in rendered
assert args.identity not in rendered
assert args.locator not in rendered
def test_vps_prepare_source_rejects_files_and_outputs_outside_private_roots(tmp_path: Path) -> None: def test_vps_prepare_source_rejects_files_and_outputs_outside_private_roots(tmp_path: Path) -> None:

View file

@ -5,6 +5,7 @@ import importlib.util
import json import json
import re import re
import stat import stat
import subprocess
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
@ -219,6 +220,51 @@ def test_deployed_bridge_accepts_real_source_compiler_bundle(tmp_path: Path, mon
assert validated_manifest["artifact_sha256"] == artifact_sha256 assert validated_manifest["artifact_sha256"] == artifact_sha256
@pytest.mark.parametrize(
("failure_kind", "expected_error"),
[
("timeout", "source compiler timed out; private diagnostic detail redacted"),
("launch", "source compiler could not start; private diagnostic detail redacted"),
("crash", "source compiler failed; private diagnostic detail redacted"),
],
)
def test_source_compiler_wrapper_redacts_commands_timeouts_and_child_output(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
failure_kind: str,
expected_error: str,
) -> None:
module = load_module()
sentinel = "COMPILER-WRAPPER-PRIVATE-CANARY"
compiler = tmp_path / "compiler.py"
compiler.write_text("# fixture\n", encoding="utf-8")
args = SimpleNamespace(
artifact=tmp_path / sentinel / "artifact.pdf",
text=tmp_path / sentinel / "extracted.txt",
manifest=tmp_path / sentinel / "manifest.json",
)
monkeypatch.setattr(module, "SOURCE_COMPILER_PATH", compiler)
def fail(command, **_kwargs):
if failure_kind == "timeout":
raise subprocess.TimeoutExpired(command, 120, output=sentinel, stderr=sentinel)
if failure_kind == "launch":
raise OSError(f"could not launch {sentinel}")
return subprocess.CompletedProcess(command, 17, stdout="", stderr=sentinel)
monkeypatch.setattr(module.subprocess, "run", fail)
with pytest.raises(SystemExit) as caught:
module.compile_source_bundle(args)
rendered = str(caught.value)
assert rendered == expected_error
assert sentinel not in rendered
assert str(args.artifact) not in rendered
assert str(args.text) not in rendered
assert str(args.manifest) not in rendered
def test_propose_source_refuses_nonlocal_database_route(tmp_path: Path) -> None: def test_propose_source_refuses_nonlocal_database_route(tmp_path: Path) -> None:
module = load_module() module = load_module()
args = SimpleNamespace( args = SimpleNamespace(

View file

@ -0,0 +1,302 @@
from __future__ import annotations
import importlib.util
import json
import stat
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPT_PATH = REPO_ROOT / "scripts" / "prepare_kb_context_from_canonical_dump.py"
SPEC = importlib.util.spec_from_file_location("prepare_kb_context_from_canonical_dump", SCRIPT_PATH)
assert SPEC is not None and SPEC.loader is not None
context_builder = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = context_builder
SPEC.loader.exec_module(context_builder)
CLAIM_A = "11111111-1111-4111-8111-111111111111"
CLAIM_B = "22222222-2222-4222-8222-222222222222"
SOURCE_ID = "33333333-3333-4333-8333-333333333333"
def _copy(table: str, columns: list[str], rows: list[list[str]]) -> str:
rendered = [f"COPY public.{table} ({', '.join(columns)}) FROM stdin;"]
rendered.extend("\t".join(row) for row in rows)
rendered.append(r"\.")
return "\n".join(rendered) + "\n"
def test_build_context_matches_readonly_claim_ranking_and_counts(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
dump = tmp_path / "canonical.dump"
dump.write_bytes(b"fixture dump")
artifact = tmp_path / "segment.txt"
artifact.write_text("Exact source quotes keep staged proposals reviewable.\n", encoding="utf-8")
sql = {
"claims": _copy(
"claims",
[
"id",
"type",
"text",
"status",
"confidence",
"tags",
"created_by",
"superseded_by",
"created_at",
"updated_at",
],
[
[
CLAIM_A,
"structural",
"Exact source quotes keep proposals reviewable.",
"open",
"0.8",
"{provenance}",
r"\N",
r"\N",
"2026-01-01",
"2026-01-01",
],
[
CLAIM_B,
"structural",
"Staged proposals need guarded apply.",
"open",
"0.9",
"{review}",
r"\N",
r"\N",
"2026-01-01",
"2026-01-01",
],
],
),
"claim_evidence": _copy(
"claim_evidence",
["claim_id", "source_id", "role", "weight", "created_by", "created_at"],
[[CLAIM_A, SOURCE_ID, "grounds", "1", r"\N", "2026-01-01"]],
),
"claim_edges": _copy(
"claim_edges",
["id", "from_claim", "to_claim", "edge_type", "weight", "created_by", "created_at"],
[["44444444-4444-4444-8444-444444444444", CLAIM_A, CLAIM_B, "supports", "1", r"\N", "2026-01-01"]],
),
}
monkeypatch.setattr(context_builder, "restore_table", lambda _dump, table, _image, **_kwargs: sql[table])
context, receipt = context_builder.build_context(
dump_path=dump,
artifact_path=artifact,
title="Telegram segment",
limit=12,
docker_image="fixture-postgres",
repo_root=REPO_ROOT,
)
assert context["candidate_claims"][0]["id"] == CLAIM_A
assert context["candidate_claims"][0]["evidence_count"] == 1
assert context["candidate_claims"][0]["edge_count"] == 1
assert receipt["database_write_performed"] is False
assert receipt["model_call_performed"] is False
assert receipt["runtime"]["network_disabled"] is True
assert receipt["runtime"]["pg_restore_output_retained"] is False
assert receipt["runtime"]["copy_encoding"] == "utf-8"
assert receipt["runtime"]["copy_decode_errors"] == "strict"
def test_copy_parser_preserves_escaped_source_text() -> None:
sql = _copy("claims", ["id", "text"], [[CLAIM_A, r"line one\nline two\tvalue\\tail"]])
rows = context_builder.parse_copy_table(sql, "claims")
assert rows == [{"id": CLAIM_A, "text": "line one\nline two\tvalue\\tail"}]
@pytest.mark.parametrize(
("encoded", "decoded"),
[
(r"caf\303\251", "caf\N{LATIN SMALL LETTER E WITH ACUTE}"),
(r"caf\xc3\xa9", "caf\N{LATIN SMALL LETTER E WITH ACUTE}"),
(r"price \342\202\2545", "price \N{EURO SIGN}5"),
(r"literal\qescape", "literalqescape"),
(r"literal\x", "literalx"),
(r"literal\xg1", "literalxg1"),
],
)
def test_copy_parser_decodes_octal_and_hex_as_encoded_bytes(encoded: str, decoded: str) -> None:
assert context_builder._decode_copy_field(encoded) == decoded
def test_copy_parser_uses_explicit_encoding() -> None:
assert context_builder._decode_copy_field(r"caf\351", encoding="latin-1") == "caf\N{LATIN SMALL LETTER E WITH ACUTE}"
with pytest.raises(context_builder.ContextError, match="valid utf-8"):
context_builder._decode_copy_field(r"caf\351", encoding="utf-8")
@pytest.mark.parametrize("encoded", [r"\xff", r"\303", r"\400", r"\000", "dangling\\"])
def test_copy_parser_fails_closed_on_invalid_byte_escapes(encoded: str) -> None:
with pytest.raises(context_builder.ContextError):
context_builder._decode_copy_field(encoded)
@pytest.mark.parametrize("line_ending", ["\n", "\r\n"])
def test_copy_parser_splits_only_copy_row_boundaries(line_ending: str) -> None:
source_text = "before\N{LINE SEPARATOR}middle\N{PARAGRAPH SEPARATOR}after"
sql = line_ending.join(
[
"COPY public.claims (id, text) FROM stdin;",
f"{CLAIM_A}\t{source_text}",
r"\.",
"",
]
)
rows = context_builder.parse_copy_table(sql, "claims")
assert rows == [{"id": CLAIM_A, "text": source_text}]
def test_private_json_is_owner_only_and_refuses_overwrite(tmp_path: Path) -> None:
output = tmp_path / "private" / "context.json"
context_builder._private_json(output, {"safe": True})
assert json.loads(output.read_text(encoding="utf-8")) == {"safe": True}
assert stat.S_IMODE(output.parent.stat().st_mode) == 0o700
assert stat.S_IMODE(output.stat().st_mode) == 0o600
with pytest.raises(context_builder.ContextError, match="refusing to overwrite"):
context_builder._private_json(output, {"safe": False})
def test_cli_keeps_private_context_receipt_paths_and_details_off_stdout(
tmp_path: Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
sentinel = "PRIVATE-CANONICAL-DETAIL-SENTINEL"
dump = tmp_path / "private-dump-sentinel.dump"
artifact = tmp_path / "private-source-sentinel.txt"
context_output = tmp_path / "private-output" / "context.json"
receipt_output = tmp_path / "private-output" / "receipt.json"
context = {"database_search_query": sentinel, "candidate_claims": [{"id": CLAIM_A, "text": sentinel}]}
receipt = {"source": {"path": str(artifact), "private_detail": sentinel}}
monkeypatch.setattr(context_builder, "build_context", lambda **_kwargs: (context, receipt))
status = context_builder.main(
[
"--dump",
str(dump),
"--artifact",
str(artifact),
"--title",
sentinel,
"--context-output",
str(context_output),
"--receipt-output",
str(receipt_output),
]
)
stdout = capsys.readouterr().out
public_status = json.loads(stdout)
assert status == 0
assert public_status == {
"schema": context_builder.STATUS_SCHEMA,
"status": "prepared",
"database_write_performed": False,
"model_call_performed": False,
"private_context_written": True,
"private_receipt_written": True,
}
assert sentinel in context_output.read_text(encoding="utf-8")
assert sentinel in receipt_output.read_text(encoding="utf-8")
for private_value in (sentinel, str(dump), str(artifact), str(context_output), str(receipt_output)):
assert private_value not in stdout
def test_cli_redacts_private_failure_details_from_stdout(
tmp_path: Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
sentinel = "PRIVATE-CONTEXT-FAILURE-SENTINEL"
private_path = tmp_path / sentinel / "source.txt"
def fail(**_kwargs):
raise context_builder.ContextError(f"{sentinel}: {private_path}")
monkeypatch.setattr(context_builder, "build_context", fail)
status = context_builder.main(
[
"--dump",
str(tmp_path / "dump"),
"--artifact",
str(private_path),
"--title",
sentinel,
"--context-output",
str(tmp_path / "context.json"),
"--receipt-output",
str(tmp_path / "receipt.json"),
]
)
stdout = capsys.readouterr().out
public_status = json.loads(stdout)
assert status == 2
assert public_status["reason"] == "context_preparation_failed"
assert sentinel not in stdout
assert str(private_path) not in stdout
@pytest.mark.parametrize(
("fail_on_replace", "expected_context", "expected_receipt"),
[(1, False, False), (2, True, False)],
)
def test_cli_reports_exact_state_after_each_context_publication_boundary(
tmp_path: Path,
capsys: pytest.CaptureFixture,
monkeypatch: pytest.MonkeyPatch,
fail_on_replace: int,
expected_context: bool,
expected_receipt: bool,
) -> None:
context_output = tmp_path / "private-output" / "context.json"
receipt_output = tmp_path / "private-output" / "receipt.json"
context = {"database_search_query": "private", "candidate_claims": []}
receipt = {"retrieval": {"candidate_count": 0}}
monkeypatch.setattr(context_builder, "build_context", lambda **_kwargs: (context, receipt))
real_replace = context_builder.os.replace
replace_count = 0
def faulted_replace(source: Path, destination: Path) -> None:
nonlocal replace_count
replace_count += 1
if replace_count == fail_on_replace:
raise OSError("publication fault detail must stay private")
real_replace(source, destination)
monkeypatch.setattr(context_builder.os, "replace", faulted_replace)
status = context_builder.main(
[
"--dump",
str(tmp_path / "dump"),
"--artifact",
str(tmp_path / "artifact"),
"--title",
"private",
"--context-output",
str(context_output),
"--receipt-output",
str(receipt_output),
]
)
public_status = json.loads(capsys.readouterr().out)
assert status == 2
assert public_status["private_context_written"] is expected_context
assert public_status["private_receipt_written"] is expected_receipt
assert context_output.is_file() is expected_context
assert receipt_output.is_file() is expected_receipt

View file

@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
import io
import json import json
import stat import stat
import sys import sys
import urllib.error
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
@ -35,9 +37,11 @@ def _args(tmp_path: Path, artifact: Path, context: Path) -> SimpleNamespace:
locator="artifact://source-preparation/test-v1", locator="artifact://source-preparation/test-v1",
output_dir=tmp_path / "private-output", output_dir=tmp_path / "private-output",
key_file=tmp_path / "openrouter-key", key_file=tmp_path / "openrouter-key",
provider="openrouter",
model="fixture/model", model="fixture/model",
openrouter_url="https://openrouter.invalid/v1/chat/completions", openrouter_url="https://openrouter.invalid/v1/chat/completions",
timeout_seconds=30, timeout_seconds=30,
max_budget_usd=0.10,
) )
@ -274,3 +278,355 @@ def test_openrouter_key_cannot_be_redirected_to_an_unapproved_endpoint(tmp_path:
url="https://example.invalid/collect", url="https://example.invalid/collect",
timeout_seconds=1, timeout_seconds=1,
) )
def test_claude_cli_uses_stdin_without_tools_or_session_persistence_and_retains_cost(
monkeypatch: pytest.MonkeyPatch,
) -> None:
prompt = "private source prompt"
observed: dict[str, object] = {}
def fake_run(command, **kwargs):
observed["command"] = command
observed.update(kwargs)
return SimpleNamespace(
returncode=0,
stderr="",
stdout=json.dumps(
{
"subtype": "success",
"is_error": False,
"result": _model_output(),
"total_cost_usd": 0.0123,
"usage": {"input_tokens": 321, "output_tokens": 123},
"modelUsage": {"claude-sonnet-test": {"costUSD": 0.0123}},
}
),
)
monkeypatch.setattr(preparer.shutil, "which", lambda _name: "/usr/local/bin/claude")
monkeypatch.setattr(preparer.subprocess, "run", fake_run)
content, usage = preparer.call_claude_cli(
prompt,
model="sonnet",
timeout_seconds=30,
max_budget_usd=0.10,
)
command = observed["command"]
assert prompt not in command
assert observed["input"] == prompt
assert "--no-session-persistence" in command
assert command[command.index("--tools") + 1] == ""
assert command[command.index("--max-budget-usd") + 1] == "0.1"
assert json.loads(content)["schema"] == preparer.MODEL_OUTPUT_SCHEMA
assert usage["total_cost_usd"] == 0.0123
assert usage["session_persistence"] is False
assert usage["tools_enabled"] is False
def test_prepare_routes_claude_cli_and_records_provider_cost(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
artifact, context = _inputs(tmp_path)
args = _args(tmp_path, artifact, context)
args.provider = "claude-cli"
args.model = "sonnet"
monkeypatch.setattr(
preparer,
"call_openrouter",
lambda *_args, **_kwargs: pytest.fail("OpenRouter must not be called for the Claude CLI provider"),
)
monkeypatch.setattr(
preparer,
"call_claude_cli",
lambda *_args, **_kwargs: (_model_output(), {"total_cost_usd": 0.0123}),
)
receipt = preparer.prepare(args)
assert receipt["extraction"]["provider"] == "claude-cli"
assert receipt["extraction"]["attempts"][0]["usage"]["total_cost_usd"] == 0.0123
assert receipt["extraction"]["cost_control"] == {
"max_total_budget_usd": 0.10,
"reported_total_cost_usd": 0.0123,
}
assert receipt["replay"]["extractor"]["name"] == "claude-cli:sonnet"
manifest = json.loads(Path(receipt["outputs"]["manifest"]).read_text(encoding="utf-8"))
assert manifest["extractor"]["name"] == "claude-cli:sonnet"
def test_claude_retry_budget_is_reduced_by_reported_first_attempt_cost(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
artifact, context = _inputs(tmp_path)
args = _args(tmp_path, artifact, context)
args.provider = "claude-cli"
args.model = "sonnet"
outputs = iter(((_model_output(bad_reference=True), 0.04), (_model_output(), 0.03)))
budgets: list[float] = []
def fake_claude(_prompt: str, *, model: str, timeout_seconds: int, max_budget_usd: float):
assert model == "sonnet"
assert timeout_seconds == 30
budgets.append(max_budget_usd)
output, cost = next(outputs)
return output, {"total_cost_usd": cost}
monkeypatch.setattr(preparer, "call_claude_cli", fake_claude)
receipt = preparer.prepare(args)
assert budgets == pytest.approx([0.10, 0.06])
assert receipt["extraction"]["cost_control"]["reported_total_cost_usd"] == pytest.approx(0.07)
def test_provider_errors_redact_secret_and_private_detail(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
key_file = tmp_path / "key"
key_file.write_text("fixture-secret", encoding="utf-8")
error = urllib.error.HTTPError(
preparer.DEFAULT_OPENROUTER_URL,
401,
"unauthorized",
{},
io.BytesIO(b"fixture-secret and private source quote"),
)
def fail_urlopen(*_args, **_kwargs):
raise error
monkeypatch.setattr(preparer.urllib.request, "urlopen", fail_urlopen)
with pytest.raises(preparer.PreparationError) as caught:
preparer.call_openrouter(
"private source quote",
model="fixture/model",
key_file=key_file,
url=preparer.DEFAULT_OPENROUTER_URL,
timeout_seconds=1,
)
rendered = str(caught.value)
assert "fixture-secret" not in rendered
assert "private source quote" not in rendered
assert "provider detail redacted" in rendered
def test_cli_emits_only_public_status_for_private_preparation_receipt(
tmp_path: Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
sentinel = "PRIVATE-SOURCE-RECEIPT-SENTINEL"
private_path = tmp_path / sentinel / "preparation-receipt.json"
private_receipt = {
"status": "prepared",
"source": {"title": sentinel, "artifact_path": str(private_path)},
"retrieval": {"database_search_query": sentinel},
"outputs": {"receipt": str(private_path)},
}
monkeypatch.setattr(preparer, "prepare", lambda _args: private_receipt)
status = preparer.main(
[
"--artifact",
str(tmp_path / sentinel / "source.md"),
"--kb-context",
str(tmp_path / sentinel / "context.json"),
"--identity",
f"document:{sentinel}",
"--source-key",
"private_source",
"--source-type",
"article",
"--title",
sentinel,
"--locator",
f"artifact://{sentinel}",
"--output-dir",
str(private_path.parent),
]
)
stdout = capsys.readouterr().out
assert status == 0
assert json.loads(stdout) == {
"schema": preparer.STATUS_SCHEMA,
"status": "prepared",
"database_write_performed": False,
"canonical_apply_performed": False,
"private_outputs_written": True,
}
assert sentinel not in stdout
assert str(private_path) not in stdout
def test_cli_redacts_private_preparation_failure_from_stdout(
tmp_path: Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
sentinel = "PRIVATE-PREPARATION-FAILURE-SENTINEL"
def fail(_args):
raise preparer.PreparationError(f"{sentinel}: {tmp_path / sentinel}")
monkeypatch.setattr(preparer, "prepare", fail)
status = preparer.main(
[
"--artifact",
str(tmp_path / sentinel / "source.md"),
"--kb-context",
str(tmp_path / sentinel / "context.json"),
"--identity",
f"document:{sentinel}",
"--source-key",
"private_source",
"--source-type",
"article",
"--title",
sentinel,
"--locator",
f"artifact://{sentinel}",
"--output-dir",
str(tmp_path / sentinel / "output"),
]
)
stdout = capsys.readouterr().out
public_status = json.loads(stdout)
assert status == 2
assert public_status["reason"] == "source_preparation_failed"
assert sentinel not in stdout
assert str(tmp_path) not in stdout
@pytest.mark.parametrize("fail_after_write", [1, 2, 3, 4])
def test_prepare_never_publishes_partially_staged_output_set(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
fail_after_write: int,
) -> None:
artifact, context = _inputs(tmp_path)
args = _args(tmp_path, artifact, context)
monkeypatch.setattr(
preparer,
"call_openrouter",
lambda *_args, **_kwargs: (_model_output(), {"prompt_tokens": 100, "completion_tokens": 50}),
)
real_write = preparer._write_private
write_count = 0
def faulted_write(path: Path, value: bytes) -> None:
nonlocal write_count
real_write(path, value)
write_count += 1
if write_count == fail_after_write:
raise OSError("staging fault detail must stay private")
monkeypatch.setattr(preparer, "_write_private", faulted_write)
with pytest.raises(OSError):
preparer.prepare(args)
assert not args.output_dir.exists() or not any(args.output_dir.iterdir())
assert not list(args.output_dir.parent.glob(f".{args.output_dir.name}.*.staging"))
@pytest.mark.parametrize(
("fault_after_publication", "expected_published"),
[(False, False), (True, True)],
)
def test_prepare_atomic_publication_boundary_is_all_or_nothing(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
fault_after_publication: bool,
expected_published: bool,
) -> None:
artifact, context = _inputs(tmp_path)
args = _args(tmp_path, artifact, context)
monkeypatch.setattr(
preparer,
"call_openrouter",
lambda *_args, **_kwargs: (_model_output(), {"prompt_tokens": 100, "completion_tokens": 50}),
)
real_replace = preparer.os.replace
def faulted_replace(source: Path, destination: Path) -> None:
if Path(destination) != args.output_dir:
real_replace(source, destination)
return
if fault_after_publication:
real_replace(source, destination)
raise OSError("publication fault detail must stay private")
monkeypatch.setattr(preparer.os, "replace", faulted_replace)
with pytest.raises(OSError):
preparer.prepare(args)
expected_state = {
"extracted_text": expected_published,
"extraction_result": expected_published,
"manifest": expected_published,
"receipt": expected_published,
}
assert preparer._private_output_state(args.output_dir) == expected_state
assert not list(args.output_dir.parent.glob(f".{args.output_dir.name}.*.staging"))
@pytest.mark.parametrize(
"published_names",
[
("extracted-text.txt",),
("extracted-text.txt", "extraction-result.json"),
("extracted-text.txt", "extraction-result.json", "source-manifest.json"),
(
"extracted-text.txt",
"extraction-result.json",
"source-manifest.json",
"preparation-receipt.json",
),
],
)
def test_cli_failure_reports_actual_state_after_each_private_publication_boundary(
tmp_path: Path,
capsys: pytest.CaptureFixture,
monkeypatch: pytest.MonkeyPatch,
published_names: tuple[str, ...],
) -> None:
output_dir = tmp_path / "private-output"
def fail(args) -> None:
args.output_dir.mkdir(parents=True)
for name in published_names:
(args.output_dir / name).write_text("private", encoding="utf-8")
raise preparer.PreparationError("private publication fault")
monkeypatch.setattr(preparer, "prepare", fail)
status = preparer.main(
[
"--artifact",
str(tmp_path / "source.md"),
"--kb-context",
str(tmp_path / "context.json"),
"--identity",
"document:private",
"--source-key",
"private_source",
"--source-type",
"article",
"--title",
"private",
"--locator",
"artifact://private",
"--output-dir",
str(output_dir),
]
)
public_status = json.loads(capsys.readouterr().out)
expected_state = {
"extracted_text": "extracted-text.txt" in published_names,
"extraction_result": "extraction-result.json" in published_names,
"manifest": "source-manifest.json" in published_names,
"receipt": "preparation-receipt.json" in published_names,
}
assert status == 2
assert public_status["private_output_files"] == expected_state
assert public_status["private_outputs_written"] is expected_state["receipt"]