feat(evidence): resolve curated evidence explicitly

This commit is contained in:
2026-08-24 20:52:56 +02:00
parent f5c7cc6198
commit 8adc085746
7 changed files with 442 additions and 4 deletions
+1
View File
@@ -11,6 +11,7 @@
"decision add-batch",
"decision add-join-set",
"evidence prepare",
"evidence resolve",
"evidence validate",
"memory promote",
"memory save-one",
+1 -1
View File
@@ -32,7 +32,7 @@ def test_typer_tree_matches_the_approved_command_surface():
approved = _approved_surface()
expected = set(approved["maintained"]) | set(approved["enhanced"])
assert len(approved["maintained"]) == 57
assert len(approved["maintained"]) == 58
assert len(approved["enhanced"]) == 8
assert len(approved["erased"]) == 14
assert not (expected & set(approved["erased"]))
+187 -3
View File
@@ -316,18 +316,18 @@ class _Restructurer(EvidenceRestructurer):
return tuple(self.candidates)
def _candidate(*, title="Fascia pediatrica", existing_id=None):
def _candidate(*, title="Fascia pediatrica", existing_id=None, kind="domain", payload=None):
return RestructureCandidate.model_validate({
"schema_version": 1,
"existing_id": existing_id,
"title": title,
"kind": "domain",
"kind": kind,
"purposes": ["disambiguation"],
"applies_to": {"concepts": ["fascia pediatrica"]},
"language": "it",
"supporting_excerpts": ["I pazienti sotto i 18 anni sono pediatrici."],
"review_items": [],
"payload": {"rule": "La fascia pediatrica comprende i minori."},
"payload": payload or {"rule": "La fascia pediatrica comprende i minori."},
})
@@ -378,6 +378,47 @@ def test_prepare_rejects_dirty_curated_state_before_model_call(tmp_path):
assert restructurer.requests == []
def test_prepare_refuses_an_incompatible_pipeline_version_without_writing(tmp_path):
source_text = "I pazienti sotto i 18 anni sono pediatrici."
_write_workspace(tmp_path, _evidence(source_text), source_text)
manifest_path = tmp_path / "evidence" / "manifest.yaml"
curated_path = tmp_path / "evidence" / "curated" / "domain" / "fascia-pediatrica.md"
manifest_path.write_text(
manifest_path.read_text(encoding="utf-8").replace("evidence-authoring-v1", "evidence-authoring-v2"),
encoding="utf-8",
)
original_manifest = manifest_path.read_text(encoding="utf-8")
original_curated = curated_path.read_text(encoding="utf-8")
with pytest.raises(EvidencePreparationError, match="pipeline_upgrade_required"):
prepare_workspace_evidence(tmp_path, restructurer=_Restructurer([]), git_status=lambda _: ())
assert manifest_path.read_text(encoding="utf-8") == original_manifest
assert curated_path.read_text(encoding="utf-8") == original_curated
def test_prepare_preserves_an_id_when_reclassified_and_allocates_a_new_id_for_a_split(tmp_path):
source_text = "I pazienti sotto i 18 anni sono pediatrici."
_write_workspace(tmp_path, _evidence(source_text), source_text)
source_path = tmp_path / "evidence" / "source" / "domain" / "patient.md"
source_path.write_text(source_text + " I pazienti sopra i 18 anni sono adulti.\n", encoding="utf-8")
restructurer = _Restructurer([
_candidate(
existing_id="evidence:fascia-pediatrica",
kind="glossary",
payload={"definition": "Paziente con meno di 18 anni."},
),
_candidate(title="Fascia adulta"),
])
report = prepare_workspace_evidence(tmp_path, restructurer=restructurer, git_status=lambda _: ())
assert report.created == ("evidence:fascia-adulta",)
documents = {document.id: document for document in load_curated_tree(tmp_path / "evidence" / "curated")}
assert documents["evidence:fascia-pediatrica"].kind == "glossary"
assert documents["evidence:fascia-adulta"].kind == "domain"
def test_prepare_retains_units_from_a_removed_source_as_blocking_orphans(tmp_path):
source_text = "I pazienti sotto i 18 anni sono pediatrici."
_write_workspace(tmp_path, _evidence(source_text), source_text)
@@ -437,3 +478,146 @@ def test_prepare_marks_an_omitted_prior_unit_for_human_review(tmp_path):
]
retained = load_curated_tree(tmp_path / "evidence" / "curated")[0]
assert retained.review_items[0].code == "source_no_longer_supports_unit"
def test_resolve_retires_an_orphan_and_updates_curated_content_and_manifest_atomically(tmp_path):
from tht.evidence import resolve_workspace_evidence
source_text = "I pazienti sotto i 18 anni sono pediatrici."
_write_workspace(tmp_path, _evidence(source_text), source_text)
source_path = tmp_path / "evidence" / "source" / "domain" / "patient.md"
source_path.unlink()
prepare_workspace_evidence(tmp_path, restructurer=_Restructurer([]), git_status=lambda _: ())
report = resolve_workspace_evidence(
tmp_path,
"evidence:fascia-pediatrica",
retire=True,
git_status=lambda _: (),
)
assert report.action == "retired"
assert report.evidence_id == "evidence:fascia-pediatrica"
assert not (tmp_path / "evidence" / "curated" / "domain" / "fascia-pediatrica.md").exists()
manifest = load_manifest(tmp_path / "evidence" / "manifest.yaml")
assert manifest.orphans == ()
assert manifest.sources == {}
def test_resolve_relinks_an_unit_and_updates_its_manifest_membership(tmp_path):
from tht.evidence import resolve_workspace_evidence
source_text = "I pazienti sotto i 18 anni sono pediatrici."
_write_workspace(tmp_path, _evidence(source_text), source_text)
next_source = tmp_path / "evidence" / "source" / "domain" / "patient-age.md"
next_source.write_text(source_text, encoding="utf-8")
report = resolve_workspace_evidence(
tmp_path,
"evidence:fascia-pediatrica",
source="source/domain/patient-age.md",
git_status=lambda _: (),
)
assert report.action == "relinked"
assert report.source_file == "source/domain/patient-age.md"
document = load_curated_tree(tmp_path / "evidence" / "curated")[0]
assert document.provenance.source_file == "source/domain/patient-age.md"
manifest = load_manifest(tmp_path / "evidence" / "manifest.yaml")
assert manifest.sources["source/domain/patient.md"].units == ()
assert manifest.sources["source/domain/patient-age.md"].units == ("evidence:fascia-pediatrica",)
assert validate_workspace_evidence(tmp_path).publishable is True
def test_resolve_relink_clears_an_unsupported_review_item_when_the_new_source_supports_it(tmp_path):
from tht.evidence import resolve_workspace_evidence
source_text = "I pazienti sotto i 18 anni sono pediatrici."
_write_workspace(tmp_path, _evidence(source_text), source_text)
source_path = tmp_path / "evidence" / "source" / "domain" / "patient.md"
source_path.write_text("La classificazione pediatrica non è documentata.\n", encoding="utf-8")
prepare_workspace_evidence(tmp_path, restructurer=_Restructurer([]), git_status=lambda _: ())
replacement_source = tmp_path / "evidence" / "source" / "domain" / "patient-revised.md"
replacement_source.write_text(source_text, encoding="utf-8")
report = resolve_workspace_evidence(
tmp_path,
"evidence:fascia-pediatrica",
source="source/domain/patient-revised.md",
git_status=lambda _: (),
)
assert report.findings == ()
document = load_curated_tree(tmp_path / "evidence" / "curated")[0]
assert all(item.code != "source_no_longer_supports_unit" for item in document.review_items)
assert validate_workspace_evidence(tmp_path).publishable is True
def test_resolve_refuses_an_unsafe_relink_without_writing(tmp_path):
from tht.evidence import EvidencePreparationError, resolve_workspace_evidence
source_text = "I pazienti sotto i 18 anni sono pediatrici."
_write_workspace(tmp_path, _evidence(source_text), source_text)
curated_path = tmp_path / "evidence" / "curated" / "domain" / "fascia-pediatrica.md"
manifest_path = tmp_path / "evidence" / "manifest.yaml"
original_curated = curated_path.read_text(encoding="utf-8")
original_manifest = manifest_path.read_text(encoding="utf-8")
with pytest.raises(EvidencePreparationError, match="source_invalid"):
resolve_workspace_evidence(
tmp_path,
"evidence:fascia-pediatrica",
source="../outside.md",
git_status=lambda _: (),
)
assert curated_path.read_text(encoding="utf-8") == original_curated
assert manifest_path.read_text(encoding="utf-8") == original_manifest
def test_resolve_refuses_a_relink_through_a_symlinked_source_directory(tmp_path):
from tht.evidence import EvidencePreparationError, resolve_workspace_evidence
source_text = "I pazienti sotto i 18 anni sono pediatrici."
_write_workspace(tmp_path, _evidence(source_text), source_text)
curated_path = tmp_path / "evidence" / "curated" / "domain" / "fascia-pediatrica.md"
manifest_path = tmp_path / "evidence" / "manifest.yaml"
original_curated = curated_path.read_text(encoding="utf-8")
original_manifest = manifest_path.read_text(encoding="utf-8")
source_root = tmp_path / "evidence" / "source"
outside_source = tmp_path / "outside-source"
source_root.rename(outside_source)
source_root.symlink_to(outside_source, target_is_directory=True)
with pytest.raises(EvidencePreparationError, match="source_invalid"):
resolve_workspace_evidence(
tmp_path,
"evidence:fascia-pediatrica",
source="source/domain/patient.md",
git_status=lambda _: (),
)
assert curated_path.read_text(encoding="utf-8") == original_curated
assert manifest_path.read_text(encoding="utf-8") == original_manifest
def test_resolve_rejects_any_dirty_worktree_without_writing(tmp_path):
from tht.evidence import EvidencePreparationError, resolve_workspace_evidence
source_text = "I pazienti sotto i 18 anni sono pediatrici."
_write_workspace(tmp_path, _evidence(source_text), source_text)
curated_path = tmp_path / "evidence" / "curated" / "domain" / "fascia-pediatrica.md"
manifest_path = tmp_path / "evidence" / "manifest.yaml"
original_curated = curated_path.read_text(encoding="utf-8")
original_manifest = manifest_path.read_text(encoding="utf-8")
with pytest.raises(EvidencePreparationError, match="worktree_dirty"):
resolve_workspace_evidence(
tmp_path,
"evidence:fascia-pediatrica",
retire=True,
git_status=lambda _: (" M README.md",),
)
assert curated_path.read_text(encoding="utf-8") == original_curated
assert manifest_path.read_text(encoding="utf-8") == original_manifest
+77
View File
@@ -10,9 +10,86 @@ def test_evidence_authoring_commands_are_distinct_from_runtime_preprocessing():
assert result.exit_code == 0
assert "prepare" in result.output
assert "resolve" in result.output
assert "validate" in result.output
def test_evidence_resolve_json_is_pristine(monkeypatch, tmp_path):
from tht.cli import evidence_cmd
from tht.evidence.authoring import EvidenceResolutionReport
monkeypatch.setattr(evidence_cmd, "_canonical_worktree", lambda root: root)
monkeypatch.setattr(evidence_cmd, "resolve_workspace_evidence", lambda *args, **kwargs: EvidenceResolutionReport(
action="retired",
evidence_id="evidence:fascia-pediatrica",
source_file=None,
findings=(),
))
result = CliRunner().invoke(app, [
"evidence", "resolve", str(tmp_path), "evidence:fascia-pediatrica", "--retire", "--json",
])
assert result.exit_code == 0
assert result.stderr == ""
assert json.loads(result.stdout) == {
"action": "retired",
"evidenceId": "evidence:fascia-pediatrica",
"findings": [],
"operation": "evidence_resolve",
"schemaVersion": 1,
"sourceFile": None,
"status": "resolved",
}
def test_evidence_resolve_reports_unsafe_source_as_cli_misuse(monkeypatch, tmp_path):
from tht.cli import evidence_cmd
from tht.evidence import EvidencePreparationError
monkeypatch.setattr(evidence_cmd, "_canonical_worktree", lambda root: root)
monkeypatch.setattr(
evidence_cmd,
"resolve_workspace_evidence",
lambda *args, **kwargs: (_ for _ in ()).throw(EvidencePreparationError("source_invalid")),
)
result = CliRunner().invoke(app, [
"evidence", "resolve", str(tmp_path), "evidence:fascia-pediatrica", "--source", "source/domain/x.md", "--json",
])
assert result.exit_code == 2
assert result.stderr == ""
assert json.loads(result.stdout) == {
"code": "source_invalid",
"operation": "evidence_resolve",
"schemaVersion": 1,
"status": "failed",
}
def test_evidence_resolve_reports_structural_findings_as_operational_failure(monkeypatch, tmp_path):
from tht.cli import evidence_cmd
from tht.evidence import ValidationFinding
from tht.evidence.authoring import EvidenceResolutionReport
monkeypatch.setattr(evidence_cmd, "_canonical_worktree", lambda root: root)
monkeypatch.setattr(evidence_cmd, "resolve_workspace_evidence", lambda *args, **kwargs: EvidenceResolutionReport(
action="relinked",
evidence_id="evidence:fascia-pediatrica",
source_file="source/domain/patient.md",
findings=(ValidationFinding(
"error", "supporting_excerpt_missing", "source/domain/patient.md", "Excerpt missing.",
),),
))
result = CliRunner().invoke(app, [
"evidence", "resolve", str(tmp_path), "evidence:fascia-pediatrica", "--source", "source/domain/patient.md",
])
assert result.exit_code == 1
def test_evidence_validate_json_is_pristine_and_reports_review_required(monkeypatch, tmp_path):
from tht.cli import evidence_cmd
from tht.evidence import ValidationFinding, ValidationReport
+36
View File
@@ -14,6 +14,7 @@ from tht.evidence import (
EvidencePreparationError,
PiEvidenceRestructurer,
prepare_workspace_evidence,
resolve_workspace_evidence,
validate_workspace_evidence,
)
@@ -103,3 +104,38 @@ def validate_cmd(
if all(finding.code in {"orphaned_unit", "unresolved_review_item"} for finding in report.findings):
raise typer.Exit(code=3)
raise typer.Exit(code=1)
@evidence_app.command("resolve")
def resolve_cmd(
workspace_root: Path,
evidence_id: str,
retire: Annotated[bool, typer.Option(help="Retire the Evidence unit.")] = False,
source: Annotated[Path | None, typer.Option(help="Relink the unit to this source/ path.")] = None,
json_output: Annotated[bool, typer.Option("--json", help="Write machine JSON to stdout.")] = False,
) -> None:
"""Explicitly retire or relink one Evidence unit without publishing it."""
if retire == (source is not None):
raise typer.BadParameter("choose exactly one of --retire or --source")
root = _canonical_worktree(workspace_root)
try:
report = resolve_workspace_evidence(root, evidence_id, retire=retire, source=source)
except EvidencePreparationError as error:
_emit({"schemaVersion": 1, "operation": "evidence_resolve", "status": "failed", "code": error.code}, json_output)
exit_code = 2 if error.code in {
"evidence_id_invalid", "resolve_mode_invalid", "source_invalid",
} else 1
raise typer.Exit(code=exit_code) from error
_emit({
"schemaVersion": 1,
"operation": "evidence_resolve",
"status": "resolved",
"action": report.action,
"evidenceId": report.evidence_id,
"sourceFile": report.source_file,
"findings": _findings_payload(report.findings),
}, json_output)
if report.findings:
if all(finding.code in {"orphaned_unit", "unresolved_review_item"} for finding in report.findings):
raise typer.Exit(code=3)
raise typer.Exit(code=1)
+4
View File
@@ -5,6 +5,7 @@ from tht.evidence.authoring import (
EvidenceManifest,
EvidencePreparationError,
EvidencePreparationReport,
EvidenceResolutionReport,
EvidenceRestructurer,
PiEvidenceRestructurer,
RestructureCandidate,
@@ -14,6 +15,7 @@ from tht.evidence.authoring import (
dump_manifest,
load_manifest,
prepare_workspace_evidence,
resolve_workspace_evidence,
validate_workspace_evidence,
)
from tht.evidence.canonical import (
@@ -54,6 +56,7 @@ __all__ = [
"EvidenceManifest",
"EvidencePreparationError",
"EvidencePreparationReport",
"EvidenceResolutionReport",
"EvidenceRestructurer",
"EvidenceSource",
"EvidenceSourceError",
@@ -80,6 +83,7 @@ __all__ = [
"prepare_workspace_evidence",
"project_session",
"resolve_citation",
"resolve_workspace_evidence",
"validate_corpus_workspace",
"validate_namespaced_value",
"validate_safe_metadata",
+136
View File
@@ -173,6 +173,16 @@ class EvidencePreparationReport:
model_calls: int
@dataclass(frozen=True)
class EvidenceResolutionReport:
"""The result of one curator-directed Evidence resolution."""
action: Literal["retired", "relinked"]
evidence_id: str
source_file: str | None
findings: tuple[ValidationFinding, ...]
class ManifestSource(StrictModel):
sha256: str
units: tuple[str, ...]
@@ -531,6 +541,100 @@ def prepare_workspace_evidence(
)
def resolve_workspace_evidence(
workspace_root: Path,
evidence_id: str,
*,
retire: bool = False,
source: str | Path | None = None,
git_status: Callable[[Path], tuple[str, ...]] | None = None,
) -> EvidenceResolutionReport:
"""Retire or relink one Evidence unit as one staged curator update.
The operation intentionally neither creates a commit nor publishes anything. Both
the curated tree and the managed manifest are replaced only after the staged tree
has been written and structurally validated.
"""
if retire == (source is not None):
raise EvidencePreparationError("resolve_mode_invalid")
if not is_evidence_id(evidence_id):
raise EvidencePreparationError("evidence_id_invalid")
workspace_root = workspace_root.resolve()
evidence_root = workspace_root / "evidence"
_reject_dirty_worktree(workspace_root, git_status or _git_status)
try:
manifest = load_manifest(evidence_root / "manifest.yaml")
documents = load_curated_tree(evidence_root / "curated")
except (OSError, ValidationError, ValueError) as error:
raise EvidencePreparationError("authoring_state_invalid") from error
documents_by_id = {document.id: document for document in documents}
if len(documents_by_id) != len(documents):
raise EvidencePreparationError("duplicate_evidence_id")
if evidence_id not in documents_by_id:
raise EvidencePreparationError("evidence_not_found")
source_units = {path: tuple(entry.units) for path, entry in manifest.sources.items()}
orphaned = set(manifest.orphans)
source_file: str | None = None
action: Literal["retired", "relinked"]
if retire:
action = "retired"
del documents_by_id[evidence_id]
for path, units in tuple(source_units.items()):
source_units[path] = tuple(unit for unit in units if unit != evidence_id)
orphaned.discard(evidence_id)
else:
action = "relinked"
assert source is not None
source_file = _resolve_source_file(evidence_root, source)
source_text = _load_resolution_source(evidence_root, source_file)
document = documents_by_id[evidence_id]
review_items = document.review_items
if all(_normalize(excerpt) in source_text for excerpt in document.provenance.supporting_excerpts):
review_items = tuple(
item for item in review_items if item.code != "source_no_longer_supports_unit"
)
documents_by_id[evidence_id] = document.model_copy(update={
"provenance": document.provenance.model_copy(update={
"source_file": source_file,
"source_sha256": _source_hash(source_text),
}),
"review_items": review_items,
})
for path, units in tuple(source_units.items()):
source_units[path] = tuple(unit for unit in units if unit != evidence_id)
source_units[source_file] = tuple(sorted((*source_units.get(source_file, ()), evidence_id)))
orphaned.discard(evidence_id)
sources = {
path: ManifestSource(
sha256=(
_source_hash(_load_resolution_source(evidence_root, path))
if path == source_file
else manifest.sources[path].sha256
),
units=units,
)
for path, units in sorted(source_units.items())
}
next_manifest = EvidenceManifest(
schema_version=1,
pipeline_version=manifest.pipeline_version,
sources=sources,
orphans=tuple(sorted(orphaned)),
)
findings = _stage_and_apply_authoring_tree(workspace_root, documents_by_id, next_manifest)
return EvidenceResolutionReport(
action=action,
evidence_id=evidence_id,
source_file=source_file,
findings=findings,
)
def _empty_manifest() -> EvidenceManifest:
return EvidenceManifest(
schema_version=1,
@@ -558,6 +662,30 @@ def _load_preparation_manifest(path: Path, *, upgrade: bool) -> EvidenceManifest
raise EvidencePreparationError("authoring_state_invalid") from error
def _resolve_source_file(evidence_root: Path, source: str | Path) -> str:
raw_source = source.as_posix() if isinstance(source, Path) else source
try:
source_file = validate_source_file(raw_source)
except ValueError as error:
raise EvidencePreparationError("source_invalid", raw_source) from error
path = evidence_root / source_file
ancestor = evidence_root
for part in Path(source_file).parts:
ancestor /= part
if ancestor.is_symlink():
raise EvidencePreparationError("source_invalid", source_file)
if not path.is_file() or path.stat().st_size > MAX_AUTHORING_FILE_BYTES:
raise EvidencePreparationError("source_invalid", source_file)
return source_file
def _load_resolution_source(evidence_root: Path, source_file: str) -> str:
try:
return normalize_source_text((evidence_root / source_file).read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError) as error:
raise EvidencePreparationError("source_invalid", source_file) from error
def _git_status(workspace_root: Path) -> tuple[str, ...]:
result = subprocess.run(
["git", "status", "--porcelain"],
@@ -581,6 +709,14 @@ def _reject_dirty_authoring_state(
raise EvidencePreparationError("authoring_worktree_dirty")
def _reject_dirty_worktree(
workspace_root: Path,
git_status: Callable[[Path], tuple[str, ...]],
) -> None:
if git_status(workspace_root):
raise EvidencePreparationError("worktree_dirty")
def _load_source_texts(evidence_root: Path) -> dict[str, str]:
source_root = evidence_root / "source"
if not source_root.is_dir():