#!/usr/bin/env bash set -euo pipefail repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)" python="$repo_root/harness/.venv/bin/python" fixture="$repo_root/harness/tests/fixtures/evidence_authoring/poorly_structured.md" [[ -x "$python" ]] || { printf 'missing harness virtualenv: %s\n' "$python" >&2; exit 127; } [[ -f "$fixture" ]] || { printf 'missing authoring fixture: %s\n' "$fixture" >&2; exit 2; } before_status="$(git -C "$repo_root" status --porcelain)" temporary_root="$(mktemp -d "${TMPDIR:-/tmp}/thothii-evidence-restructuring.XXXXXXXX")" trap 'rm -rf -- "$temporary_root"' EXIT HUP INT TERM # The authoring target is created under mktemp and passed directly to the Python API. # This runner has no PSD workspace argument or configured external authoring path. TASK12_WORKSPACE="$temporary_root/workspace" TASK12_FIXTURE="$fixture" \ PYTHONPATH="$repo_root/harness" "$python" - <<'PY' import os import shutil import subprocess from pathlib import Path from tht.evidence import ( EvidencePreparationError, EvidenceRestructurer, RestructureCandidate, dump_manifest, load_curated_tree, load_manifest, prepare_workspace_evidence, validate_workspace_evidence, ) workspace = Path(os.environ["TASK12_WORKSPACE"]).resolve() fixture = Path(os.environ["TASK12_FIXTURE"]).resolve() source_root = workspace / "evidence" / "source" source_root.mkdir(parents=True) fixture_target = source_root / "notes" / "poorly-structured.md" fixture_target.parent.mkdir() shutil.copyfile(fixture, fixture_target) independent_target = source_root / "notes" / "independent.md" independent_target.write_text("La codifica ADT è usata soltanto per il triage.\n", encoding="utf-8") class FakeRestructurer(EvidenceRestructurer): """Hermetic, deterministic substitute for the one-call-per-source Pi boundary.""" def __init__(self): self.requests = [] def restructure(self, request): self.requests.append(request) existing = {unit.title: unit.id for unit in request.previous_units} def candidate(title, kind, purposes, excerpt, payload, review_items=()): return RestructureCandidate.model_validate({ "schema_version": 1, "existing_id": existing.get(title), "title": title, "kind": kind, "purposes": purposes, "applies_to": { "concepts": ["fascia pediatrica"], "tables": ["clinical.patient"], "columns": ["clinical.patient.birth_date"], }, "language": "it", "supporting_excerpts": [excerpt], "review_items": review_items, "payload": payload, }) if request.source_file.endswith("independent.md"): return (candidate( "Codifica ADT", "domain", ["schema_linking"], "La codifica ADT è usata soltanto per il triage.", {"rule": "Usare ADT solo per il triage."}, ),) return ( candidate( "Fascia pediatrica", "formula", ["sql_generation"], "Formula candidata: `CASE WHEN age < 18 THEN 'pediatrica' ELSE 'adulta' END`.", { "concept": "fascia pediatrica", "columns": ["clinical.patient.birth_date"], "sql": "CASE WHEN age < 18 THEN 'pediatrica' ELSE 'adulta' END", }, ), candidate( "Stato dimissione", "enum", ["schema_linking"], "Lo stato di dimissione usa i codici: D = dimesso, T = trasferito.", {"column": "clinical.episode.discharge_status", "values": {"D": "dimesso", "T": "trasferito"}}, ), candidate( "Linea guida dimissione", "reference", ["rewriting"], "https://example.test/linee-guida-dimissione", { "url": "https://example.test/linee-guida-dimissione", "label": "Linea guida dimissione", "description": "Riferimento clinico per la dimissione.", }, ), candidate( "Regola età", "domain", ["disambiguation"], "La fascia pediatrica riguarda i pazienti con età minore di 18 anni; il testo non chiarisce se l'età debba essere calcolata alla data di ricovero o alla data odierna.", {"rule": "La fascia pediatrica comprende i pazienti con meno di 18 anni."}, [{ "code": "ambiguous_source_statement", "message": "La data di calcolo dell'età non è specificata.", "field": "domain.rule", }], ), ) def run_git(*arguments): return subprocess.run(["git", *arguments], cwd=workspace, check=True, capture_output=True, text=True) fake = FakeRestructurer() first = prepare_workspace_evidence(workspace, restructurer=fake, git_status=lambda _: ()) assert first.model_calls == 2 and len(first.created) == 5 documents = load_curated_tree(workspace / "evidence" / "curated") assert {document.kind for document in documents} == {"domain", "enum", "formula", "reference"} assert len({document.id for document in documents}) == 5 assert {document.provenance.source_file for document in documents} == { "source/notes/poorly-structured.md", "source/notes/independent.md", } manifest = load_manifest(workspace / "evidence" / "manifest.yaml") assert not set(manifest.sources["source/notes/poorly-structured.md"].units).intersection( manifest.sources["source/notes/independent.md"].units ) assert "unresolved_review_item" in {finding.code for finding in validate_workspace_evidence(workspace).findings} run_git("init", "-q") run_git("config", "user.email", "acceptance@example.test") run_git("config", "user.name", "Acceptance") run_git("add", "evidence") run_git("commit", "-qm", "baseline human-reviewed curated evidence") baseline_formula = run_git("show", "HEAD:evidence/curated/formula/fascia-pediatrica.md").stdout fixture_target.write_text(fixture_target.read_text(encoding="utf-8") + "\nNota revisionata.\n", encoding="utf-8") second = prepare_workspace_evidence(workspace, restructurer=fake) assert second.model_calls == 1 and second.changed == ("source/notes/poorly-structured.md",) changed_paths = set(run_git("diff", "--name-only").stdout.splitlines()) assert "evidence/manifest.yaml" in changed_paths assert "evidence/curated/formula/fascia-pediatrica.md" in changed_paths assert run_git("show", "HEAD:evidence/curated/formula/fascia-pediatrica.md").stdout == baseline_formula run_git("add", "evidence") run_git("commit", "-qm", "proposed fake restructuring") snapshot = { path.relative_to(workspace): path.read_bytes() for path in (workspace / "evidence").rglob("*") if path.is_file() } no_op = prepare_workspace_evidence(workspace, restructurer=fake) assert no_op.model_calls == 0 and no_op.changed == () assert snapshot == { path.relative_to(workspace): path.read_bytes() for path in (workspace / "evidence").rglob("*") if path.is_file() } curated_path = workspace / "evidence" / "curated" / "formula" / "fascia-pediatrica.md" curated_path.write_text(curated_path.read_text(encoding="utf-8") + "\n", encoding="utf-8") try: prepare_workspace_evidence(workspace, restructurer=fake) except EvidencePreparationError as failure: assert failure.code == "authoring_worktree_dirty" else: raise AssertionError("dirty curated state was not refused") run_git("checkout", "--", "evidence/curated") manifest_path = workspace / "evidence" / "manifest.yaml" manifest_path.write_text( manifest_path.read_text(encoding="utf-8").replace("evidence-authoring-v1", "evidence-authoring-v2"), encoding="utf-8", ) run_git("add", "evidence/manifest.yaml") run_git("commit", "-qm", "incompatible authoring pipeline") incompatible = manifest_path.read_bytes() try: prepare_workspace_evidence(workspace, restructurer=fake) except EvidencePreparationError as failure: assert failure.code == "pipeline_upgrade_required" else: raise AssertionError("incompatible pipeline version was not refused") assert manifest_path.read_bytes() == incompatible upgrade = prepare_workspace_evidence(workspace, restructurer=fake, upgrade=True) assert upgrade.model_calls == 2 and set(upgrade.changed) == { "source/notes/independent.md", "source/notes/poorly-structured.md", } manifest = load_manifest(manifest_path).model_copy(update={"orphans": ("evidence:fascia-pediatrica",)}) manifest_path.write_text(dump_manifest(manifest), encoding="utf-8") assert "orphaned_unit" in {finding.code for finding in validate_workspace_evidence(workspace).findings} print("PASS hermetic fake-restructurer: split, review, Git recovery/diff, no-op, dirty, upgrade, orphan") print("PASS isolation: only a temporary workspace was supplied; no external PSD path was read or written") PY ( cd "$repo_root/harness" .venv/bin/pytest -q \ tests/test_evidence_restructuring_fixture.py \ tests/test_evidence_candidate_publication.py \ tests/test_evidence_authoring.py \ tests/test_evidence_canonical.py \ tests/test_evidence_pi_restructurer.py \ tests/test_evidence_evaluation.py \ tests/test_evidence_formula_migration.py \ tests/test_evidence_facade_contract.py \ tests/test_corpus_chunk.py \ tests/test_corpus_pipeline.py \ tests/test_qdrant_vector_store.py \ tests/l0/test_qdrant_bm25_inference.py ) after_status="$(git -C "$repo_root" status --porcelain)" [[ "$before_status" == "$after_status" ]] || { printf 'acceptance runner unexpectedly changed the ThothII worktree\n' >&2 exit 1 } printf 'PASS evidence restructuring automated acceptance\n'