302 lines
11 KiB
Python
302 lines
11 KiB
Python
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
|