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