206 lines
7.8 KiB
Python
206 lines
7.8 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import stat
|
|
import sys
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
|
|
|
import prepare_kb_source_manifest as preparer # noqa: E402
|
|
|
|
SOURCE_TEXT = (
|
|
"Source preparation note.\n\n"
|
|
"A staged proposal remains non-canonical until review and guarded apply both succeed.\n\n"
|
|
"Every prepared claim must remain bound to an exact source quote.\n"
|
|
)
|
|
CLAIM_QUOTE = "Every prepared claim must remain bound to an exact source quote."
|
|
DUPLICATE_QUOTE = "A staged proposal remains non-canonical until review and guarded apply both succeed."
|
|
CANDIDATE_ID = "11111111-1111-4111-8111-111111111111"
|
|
|
|
|
|
def _args(tmp_path: Path, artifact: Path, context: Path) -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
artifact=artifact,
|
|
text=None,
|
|
kb_context=context,
|
|
identity="document:source-preparation-test-v1",
|
|
source_key="source_preparation_test_v1",
|
|
source_type="article",
|
|
title="Source preparation test",
|
|
locator="artifact://source-preparation/test-v1",
|
|
output_dir=tmp_path / "private-output",
|
|
key_file=tmp_path / "openrouter-key",
|
|
model="fixture/model",
|
|
openrouter_url="https://openrouter.invalid/v1/chat/completions",
|
|
timeout_seconds=30,
|
|
)
|
|
|
|
|
|
def _inputs(tmp_path: Path) -> tuple[Path, Path]:
|
|
artifact = tmp_path / "source.md"
|
|
artifact.write_text(SOURCE_TEXT, encoding="utf-8")
|
|
context = tmp_path / "context.json"
|
|
context.write_text(
|
|
json.dumps(
|
|
{
|
|
"database_search_query": "staged proposal exact source quote",
|
|
"candidate_claims": [
|
|
{
|
|
"id": CANDIDATE_ID,
|
|
"text": "A staged proposal is not canonical before guarded apply.",
|
|
"score": 3,
|
|
"evidence_count": 2,
|
|
"edge_count": 1,
|
|
}
|
|
],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
(tmp_path / "openrouter-key").write_text("fixture-secret", encoding="utf-8")
|
|
return artifact, context
|
|
|
|
|
|
def _model_output(*, claims: bool = True, bad_reference: bool = False) -> str:
|
|
claim_rows = []
|
|
if claims:
|
|
quote_ref = "S99999" if bad_reference else "S00003"
|
|
claim_rows = [
|
|
{
|
|
"claim_key": "exact_quotes_bind_prepared_claims",
|
|
"type": "structural",
|
|
"text": "Prepared claims remain auditable when their evidence is bound to exact source quotes.",
|
|
"quote_ref": quote_ref,
|
|
"confidence": 0.7,
|
|
"tags": ["provenance", "review"],
|
|
"evidence": [{"quote_ref": quote_ref, "role": "grounds"}],
|
|
}
|
|
]
|
|
return json.dumps(
|
|
{
|
|
"schema": preparer.MODEL_OUTPUT_SCHEMA,
|
|
"claims": claim_rows,
|
|
"duplicates": [
|
|
{
|
|
"claim_id": CANDIDATE_ID,
|
|
"source_quote_ref": "S00002",
|
|
"reason": "The canonical candidate already captures the review-before-apply argument.",
|
|
}
|
|
],
|
|
"extraction_notes": "One duplicate argument and one novel exact-quote provenance claim.",
|
|
}
|
|
)
|
|
|
|
|
|
def test_prepare_builds_private_compiler_validated_manifest_without_db_write(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
artifact, context = _inputs(tmp_path)
|
|
monkeypatch.setattr(
|
|
preparer,
|
|
"call_openrouter",
|
|
lambda *_args, **_kwargs: (_model_output(), {"prompt_tokens": 100, "completion_tokens": 50}),
|
|
)
|
|
|
|
receipt = preparer.prepare(_args(tmp_path, artifact, context))
|
|
|
|
assert receipt["status"] == "prepared"
|
|
assert receipt["database_write_performed"] is False
|
|
assert receipt["canonical_apply_performed"] is False
|
|
assert receipt["retrieval"]["canonical_candidate_ids"] == [CANDIDATE_ID]
|
|
assert receipt["extraction"]["claim_count"] == 1
|
|
assert receipt["compiler"]["validated"] is True
|
|
assert receipt["compiler"]["strict_child_proposal_id"]
|
|
output_dir = Path(receipt["outputs"]["output_dir"])
|
|
manifest = json.loads(Path(receipt["outputs"]["manifest"]).read_text(encoding="utf-8"))
|
|
assert manifest["schema"] == preparer.compiler.MANIFEST_SCHEMA_V2
|
|
assert manifest["extractor"]["version"] == preparer.EXTRACTOR_VERSION == "2"
|
|
assert manifest["dedupe"]["duplicates"][0]["claim_id"] == CANDIDATE_ID
|
|
assert manifest["claims"][0]["quote"] == CLAIM_QUOTE
|
|
assert manifest["dedupe"]["duplicates"][0]["source_quote"] == DUPLICATE_QUOTE
|
|
assert receipt["extraction"]["selected_source_references"] == [
|
|
{"kind": "claim", "reference": "S00003"},
|
|
{"kind": "evidence", "reference": "S00003"},
|
|
{"kind": "duplicate", "reference": "S00002"},
|
|
]
|
|
assert stat.S_IMODE(output_dir.stat().st_mode) == 0o700
|
|
for path in output_dir.iterdir():
|
|
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
|
|
|
|
|
def test_prepare_retries_once_when_model_invents_a_source_reference(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
artifact, context = _inputs(tmp_path)
|
|
outputs = iter((_model_output(bad_reference=True), _model_output()))
|
|
prompts: list[str] = []
|
|
|
|
def fake_call(prompt: str, **_kwargs):
|
|
prompts.append(prompt)
|
|
return next(outputs), {}
|
|
|
|
monkeypatch.setattr(preparer, "call_openrouter", fake_call)
|
|
|
|
receipt = preparer.prepare(_args(tmp_path, artifact, context))
|
|
|
|
assert receipt["status"] == "prepared"
|
|
assert len(receipt["extraction"]["attempts"]) == 2
|
|
assert "previous extraction failed deterministic validation" in prompts[1]
|
|
assert "must select one of the bracketed SOURCE fragment IDs" in prompts[1]
|
|
|
|
|
|
def test_source_fragments_preserve_exact_unicode_and_punctuation() -> None:
|
|
text = "Heading\n\nIdentity pins use an exact version - never a floating alias.\nCurly: \u201creviewed\u201d.\n"
|
|
|
|
fragments = preparer.build_source_fragments(text)
|
|
|
|
assert fragments == {
|
|
"S00001": "Heading",
|
|
"S00002": "Identity pins use an exact version - never a floating alias.",
|
|
"S00003": "Curly: \u201creviewed\u201d.",
|
|
}
|
|
assert all(value in text for value in fragments.values())
|
|
|
|
|
|
def test_source_fragment_ids_are_dense_across_blank_lines() -> None:
|
|
fragments = preparer.build_source_fragments("first\n\n\nsecond\n")
|
|
|
|
assert fragments == {"S00001": "first", "S00002": "second"}
|
|
|
|
|
|
def test_prepare_returns_no_novel_claims_without_manifest_or_stageable_packet(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
artifact, context = _inputs(tmp_path)
|
|
monkeypatch.setattr(preparer, "call_openrouter", lambda *_args, **_kwargs: (_model_output(claims=False), {}))
|
|
|
|
receipt = preparer.prepare(_args(tmp_path, artifact, context))
|
|
|
|
assert receipt["status"] == "no_novel_claims"
|
|
assert receipt["outputs"]["manifest"] is None
|
|
assert receipt["compiler"]["validated"] is False
|
|
assert "do not stage an empty proposal" in receipt["next_action"]
|
|
|
|
|
|
def test_binary_artifact_requires_explicit_utf8_extraction(tmp_path: Path) -> None:
|
|
artifact = tmp_path / "source.pdf"
|
|
artifact.write_bytes(b"%PDF-1.7\x00binary")
|
|
|
|
with pytest.raises(preparer.PreparationError, match=r"supply --text for \.pdf"):
|
|
preparer.extract_utf8_text(artifact, None)
|
|
|
|
|
|
def test_openrouter_key_cannot_be_redirected_to_an_unapproved_endpoint(tmp_path: Path) -> None:
|
|
with pytest.raises(preparer.PreparationError, match=r"must equal https://openrouter\.ai"):
|
|
preparer.call_openrouter(
|
|
"prompt",
|
|
model="fixture/model",
|
|
key_file=tmp_path / "private-file",
|
|
url="https://example.invalid/collect",
|
|
timeout_seconds=1,
|
|
)
|