import json import pytest from test_evidence_local_archive import read_active, write_unit from tht.evidence.adapters import FilesystemEvidenceSource from tht.evidence.authoring import RestructureCandidate from tht.evidence.canonical import EvidenceProvenance, ManualEvidenceProvenance from tht.evidence.contracts import AcquiredDocument, SourceObject from tht.evidence.imports import decide, refresh, reviews from tht.evidence.local_archive import ArchiveConflict, LocalEvidenceArchive, _digest class Refiner: def __init__(self): self.calls = [] def restructure(self, request): self.calls.append(request) return (RestructureCandidate(schema_version=1, existing_id=request.previous_units[0].id if request.previous_units else None, title="Order key", kind="domain", language="en", purposes=("sql_generation",), payload={"rule": request.normalized_text.strip()}, supporting_excerpts=(request.normalized_text.strip(),)),) class Remote: def __init__(self, uri="https://docs.example.test/rule.md"): self.uri = uri self.text = "Use order ID and year." self.calls = 0 self.fail = False self.absent = False def discover(self): self.calls += 1 if self.fail: raise RuntimeError("private access credential must not leak") if not self.absent: yield SourceObject(source_id="test:rule", uri=self.uri, fingerprint="sha256:" + _digest(self.text.encode())) def acquire(self, item): self.calls += 1 return AcquiredDocument(source=item, content=self.text.encode(), media_type="text/markdown") def empty_archive(tmp_path): (tmp_path / "evidence/curated").mkdir(parents=True) archive = LocalEvidenceArchive(tmp_path) archive.initialize() return archive def choose(archive, choice="replace", activate=lambda _: None): row = next(r for r in reviews(archive) if r["status"] in {"review", "applying"}) return decide(archive, source_id=row["id"], revision=row["revision"], decision=choice, actor="Curator", activate=activate) def test_local_source_refresh_preserves_manual_correction_and_explicit_choices(tmp_path): path = write_unit(tmp_path, source=True) archive = LocalEvidenceArchive(tmp_path) archive.consolidate(actor="Curator", activate=lambda _: None) source = FilesystemEvidenceSource(archive.evidence, patterns=("source/*.md",)) refiner = Refiner() assert refresh(archive, [source], refiner)["counts"]["unchanged"] == 1 assert not refiner.calls path.write_text(path.read_text().replace("## Rule\n\nUse order ID and year.", "## Rule\n\nUse company as well.")) archive.consolidate(actor="Curator", activate=lambda _: None) original = archive.active_snapshot() (archive.evidence / "source/orders.md").write_text("The new source says use ID only.") refresh(archive, [source], refiner) assert archive.active_snapshot() == original assert reviews(archive)[0]["current"][0]["payload"]["rule"] == "Use company as well." choose(archive, "keep") unit = read_active(archive) assert unit.payload.rule == "Use company as well." assert isinstance(unit.provenance, ManualEvidenceProvenance) assert unit.provenance.original.supporting_excerpts == ("Use order ID and year.",) assert refresh(archive, [source], refiner)["counts"]["unchanged"] == 1 (archive.evidence / "source/orders.md").write_text("Now use ID, year and company.") refresh(archive, [source], refiner) choose(archive) unit = read_active(archive) assert unit.payload.rule == "Now use ID, year and company." assert isinstance(unit.provenance, EvidenceProvenance) assert (archive.active_snapshot() / unit.provenance.source_file).read_text().strip() == unit.payload.rule assert (original / "curated/domain/order-key.md").exists() @pytest.mark.parametrize("uri", ["https://docs.example.test/rule.md", "s3://documents/rule.md"]) def test_read_only_remote_import_preserves_bytes_and_never_refreshes_implicitly(tmp_path, uri): archive = empty_archive(tmp_path) remote, refiner = Remote(uri), Refiner() refresh(archive, [remote], refiner) assert archive.active_snapshot() is None choose(archive) assert read_active(archive).payload.rule == remote.text assert remote.calls == 2 archive.consolidate(actor="Curator", activate=lambda _: None) assert remote.calls == 2 assert refresh(archive, [remote], refiner)["counts"]["unchanged"] == 1 assert len(refiner.calls) == 1 saved = next((archive.metadata / "acquisitions").rglob("*.json")) assert json.loads(saved.read_text())["source"]["uri"] == uri assert json.loads(saved.read_text())["raw_base64"] def test_access_failure_and_missing_source_do_not_remove_or_replace_curated_content(tmp_path): archive, remote, refiner = empty_archive(tmp_path), Remote(), Refiner() refresh(archive, [remote], refiner) choose(archive) before = (archive.metadata / "sources.json").read_bytes() active = archive.active_snapshot() remote.fail = True with pytest.raises(RuntimeError): refresh(archive, [remote], refiner) assert (archive.metadata / "sources.json").read_bytes() == before remote.fail, remote.absent = False, True refresh(archive, [remote], refiner) assert reviews(archive)[0]["availability"] == "missing" assert archive.active_snapshot() == active def test_refresh_does_not_resurrect_deleted_units_with_new_model_ids(tmp_path): archive, remote, refiner = empty_archive(tmp_path), Remote(), Refiner() refresh(archive, [remote], refiner) choose(archive) (archive.evidence / "curated/domain/order-key.md").unlink() archive.consolidate(actor="Curator", activate=lambda _: None) remote.text = "Changed source could regenerate the deleted rule." refresh(archive, [remote], refiner) row = reviews(archive)[0] assert row["suppressed"] and row["proposed"] == [] choose(archive) assert archive._files(archive.active_snapshot()) == {} def test_failed_activation_retries_saved_source_decision_without_reacquisition(tmp_path): archive, remote, refiner = empty_archive(tmp_path), Remote(), Refiner() refresh(archive, [remote], refiner) def fail(_): raise RuntimeError("offline index") with pytest.raises(RuntimeError, match="offline index"): choose(archive, activate=fail) assert reviews(archive)[0]["status"] == "applying" assert archive.active_snapshot() is None choose(archive) assert read_active(archive).payload.rule == remote.text assert remote.calls == 2 def test_source_retirement_decision_also_suppresses_future_regeneration(tmp_path): archive, remote, refiner = empty_archive(tmp_path), Remote(), Refiner() refresh(archive, [remote], refiner) choose(archive) remote.text = "The source no longer supports the old rule." class EmptyRefiner: def restructure(self, request): return () refresh(archive, [remote], EmptyRefiner()) assert reviews(archive)[0]["removed_ids"] == ["evidence:order-key"] choose(archive) remote.text = "A newly worded source could restore the same rule." refresh(archive, [remote], refiner) assert reviews(archive)[0]["proposed"] == [] def test_retry_never_overwrites_an_intervening_external_edit(tmp_path): archive, remote, refiner = empty_archive(tmp_path), Remote(), Refiner() refresh(archive, [remote], refiner) def fail(_): raise RuntimeError("offline index") with pytest.raises(RuntimeError): choose(archive, activate=fail) path = archive.evidence / "curated/domain/order-key.md" edited = path.read_text().replace("Use order ID and year.", "External correction.") path.write_text(edited) with pytest.raises(ArchiveConflict, match="changed during"): choose(archive) assert path.read_text() == edited assert archive.active_snapshot() is None def test_external_edit_invalidates_comparison_and_same_source_can_be_reviewed_again(tmp_path): archive, remote, refiner = empty_archive(tmp_path), Remote(), Refiner() refresh(archive, [remote], refiner) choose(archive) remote.text = "New source." refresh(archive, [remote], refiner) old_revision = reviews(archive)[0]["revision"] path = archive.evidence / "curated/domain/order-key.md" path.write_text(path.read_text().replace("## Rule\n\nUse order ID and year.", "## Rule\n\nManual correction.")) with pytest.raises(ArchiveConflict, match="changed since"): choose(archive) refresh(archive, [remote], refiner) assert reviews(archive)[0]["revision"] != old_revision choose(archive, "keep") assert read_active(archive).payload.rule == "Manual correction." def test_decision_recovers_interruption_between_journal_and_archive_write(monkeypatch, tmp_path): archive, remote, refiner = empty_archive(tmp_path), Remote(), Refiner() refresh(archive, [remote], refiner) write = archive._write_state monkeypatch.setattr(archive, "_write_state", lambda _: (_ for _ in ()).throw(OSError("interrupted"))) with pytest.raises(OSError): choose(archive) monkeypatch.setattr(archive, "_write_state", write) choose(archive) assert read_active(archive).payload.rule == remote.text def test_local_incoming_draft_needs_no_database_or_source_server(tmp_path): archive = empty_archive(tmp_path) (archive.evidence / "incoming").mkdir() (archive.evidence / "incoming/draft.md").write_text("A new domain rule.") refresh(archive, [FilesystemEvidenceSource(archive.evidence, patterns=("incoming/*.md",))], Refiner()) choose(archive) assert read_active(archive).payload.rule == "A new domain rule." def test_source_cli_access_error_is_sanitized_json(monkeypatch, tmp_path): from typer.testing import CliRunner from tht.cli import app def fail(*a, **kw): raise RuntimeError("private credential") monkeypatch.setattr("tht.evidence.administration.source_action", fail) result = CliRunner().invoke(app, ["evidence", "sources", "refresh", "--json", "-c", str(tmp_path / "cfg")]) assert result.exit_code == 1 assert json.loads(result.stdout)["status"] == "failed" assert "private credential" not in result.stdout def test_installed_refiner_uses_deployment_resources_outside_the_python_wheel(monkeypatch, tmp_path): from tht.evidence.authoring import authoring_skill_path monkeypatch.setenv("THT_HARNESS_DIR", str(tmp_path / "deployment")) assert authoring_skill_path() == tmp_path / "deployment/.pi/skills/tht-evidence-authoring/SKILL.md"