feat(evidence): resolve curated evidence explicitly
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
"decision add-batch",
|
"decision add-batch",
|
||||||
"decision add-join-set",
|
"decision add-join-set",
|
||||||
"evidence prepare",
|
"evidence prepare",
|
||||||
|
"evidence resolve",
|
||||||
"evidence validate",
|
"evidence validate",
|
||||||
"memory promote",
|
"memory promote",
|
||||||
"memory save-one",
|
"memory save-one",
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def test_typer_tree_matches_the_approved_command_surface():
|
|||||||
approved = _approved_surface()
|
approved = _approved_surface()
|
||||||
expected = set(approved["maintained"]) | set(approved["enhanced"])
|
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["enhanced"]) == 8
|
||||||
assert len(approved["erased"]) == 14
|
assert len(approved["erased"]) == 14
|
||||||
assert not (expected & set(approved["erased"]))
|
assert not (expected & set(approved["erased"]))
|
||||||
|
|||||||
@@ -316,18 +316,18 @@ class _Restructurer(EvidenceRestructurer):
|
|||||||
return tuple(self.candidates)
|
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({
|
return RestructureCandidate.model_validate({
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"existing_id": existing_id,
|
"existing_id": existing_id,
|
||||||
"title": title,
|
"title": title,
|
||||||
"kind": "domain",
|
"kind": kind,
|
||||||
"purposes": ["disambiguation"],
|
"purposes": ["disambiguation"],
|
||||||
"applies_to": {"concepts": ["fascia pediatrica"]},
|
"applies_to": {"concepts": ["fascia pediatrica"]},
|
||||||
"language": "it",
|
"language": "it",
|
||||||
"supporting_excerpts": ["I pazienti sotto i 18 anni sono pediatrici."],
|
"supporting_excerpts": ["I pazienti sotto i 18 anni sono pediatrici."],
|
||||||
"review_items": [],
|
"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 == []
|
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):
|
def test_prepare_retains_units_from_a_removed_source_as_blocking_orphans(tmp_path):
|
||||||
source_text = "I pazienti sotto i 18 anni sono pediatrici."
|
source_text = "I pazienti sotto i 18 anni sono pediatrici."
|
||||||
_write_workspace(tmp_path, _evidence(source_text), source_text)
|
_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]
|
retained = load_curated_tree(tmp_path / "evidence" / "curated")[0]
|
||||||
assert retained.review_items[0].code == "source_no_longer_supports_unit"
|
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
|
||||||
|
|||||||
@@ -10,9 +10,86 @@ def test_evidence_authoring_commands_are_distinct_from_runtime_preprocessing():
|
|||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "prepare" in result.output
|
assert "prepare" in result.output
|
||||||
|
assert "resolve" in result.output
|
||||||
assert "validate" 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):
|
def test_evidence_validate_json_is_pristine_and_reports_review_required(monkeypatch, tmp_path):
|
||||||
from tht.cli import evidence_cmd
|
from tht.cli import evidence_cmd
|
||||||
from tht.evidence import ValidationFinding, ValidationReport
|
from tht.evidence import ValidationFinding, ValidationReport
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from tht.evidence import (
|
|||||||
EvidencePreparationError,
|
EvidencePreparationError,
|
||||||
PiEvidenceRestructurer,
|
PiEvidenceRestructurer,
|
||||||
prepare_workspace_evidence,
|
prepare_workspace_evidence,
|
||||||
|
resolve_workspace_evidence,
|
||||||
validate_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):
|
if all(finding.code in {"orphaned_unit", "unresolved_review_item"} for finding in report.findings):
|
||||||
raise typer.Exit(code=3)
|
raise typer.Exit(code=3)
|
||||||
raise typer.Exit(code=1)
|
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)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from tht.evidence.authoring import (
|
|||||||
EvidenceManifest,
|
EvidenceManifest,
|
||||||
EvidencePreparationError,
|
EvidencePreparationError,
|
||||||
EvidencePreparationReport,
|
EvidencePreparationReport,
|
||||||
|
EvidenceResolutionReport,
|
||||||
EvidenceRestructurer,
|
EvidenceRestructurer,
|
||||||
PiEvidenceRestructurer,
|
PiEvidenceRestructurer,
|
||||||
RestructureCandidate,
|
RestructureCandidate,
|
||||||
@@ -14,6 +15,7 @@ from tht.evidence.authoring import (
|
|||||||
dump_manifest,
|
dump_manifest,
|
||||||
load_manifest,
|
load_manifest,
|
||||||
prepare_workspace_evidence,
|
prepare_workspace_evidence,
|
||||||
|
resolve_workspace_evidence,
|
||||||
validate_workspace_evidence,
|
validate_workspace_evidence,
|
||||||
)
|
)
|
||||||
from tht.evidence.canonical import (
|
from tht.evidence.canonical import (
|
||||||
@@ -54,6 +56,7 @@ __all__ = [
|
|||||||
"EvidenceManifest",
|
"EvidenceManifest",
|
||||||
"EvidencePreparationError",
|
"EvidencePreparationError",
|
||||||
"EvidencePreparationReport",
|
"EvidencePreparationReport",
|
||||||
|
"EvidenceResolutionReport",
|
||||||
"EvidenceRestructurer",
|
"EvidenceRestructurer",
|
||||||
"EvidenceSource",
|
"EvidenceSource",
|
||||||
"EvidenceSourceError",
|
"EvidenceSourceError",
|
||||||
@@ -80,6 +83,7 @@ __all__ = [
|
|||||||
"prepare_workspace_evidence",
|
"prepare_workspace_evidence",
|
||||||
"project_session",
|
"project_session",
|
||||||
"resolve_citation",
|
"resolve_citation",
|
||||||
|
"resolve_workspace_evidence",
|
||||||
"validate_corpus_workspace",
|
"validate_corpus_workspace",
|
||||||
"validate_namespaced_value",
|
"validate_namespaced_value",
|
||||||
"validate_safe_metadata",
|
"validate_safe_metadata",
|
||||||
|
|||||||
@@ -173,6 +173,16 @@ class EvidencePreparationReport:
|
|||||||
model_calls: int
|
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):
|
class ManifestSource(StrictModel):
|
||||||
sha256: str
|
sha256: str
|
||||||
units: tuple[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:
|
def _empty_manifest() -> EvidenceManifest:
|
||||||
return EvidenceManifest(
|
return EvidenceManifest(
|
||||||
schema_version=1,
|
schema_version=1,
|
||||||
@@ -558,6 +662,30 @@ def _load_preparation_manifest(path: Path, *, upgrade: bool) -> EvidenceManifest
|
|||||||
raise EvidencePreparationError("authoring_state_invalid") from error
|
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, ...]:
|
def _git_status(workspace_root: Path) -> tuple[str, ...]:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["git", "status", "--porcelain"],
|
["git", "status", "--porcelain"],
|
||||||
@@ -581,6 +709,14 @@ def _reject_dirty_authoring_state(
|
|||||||
raise EvidencePreparationError("authoring_worktree_dirty")
|
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]:
|
def _load_source_texts(evidence_root: Path) -> dict[str, str]:
|
||||||
source_root = evidence_root / "source"
|
source_root = evidence_root / "source"
|
||||||
if not source_root.is_dir():
|
if not source_root.is_dir():
|
||||||
|
|||||||
Reference in New Issue
Block a user