diff --git a/harness/tests/memory/test_solved_finalization.py b/harness/tests/memory/test_solved_finalization.py index 038edff8..6c3d00eb 100644 --- a/harness/tests/memory/test_solved_finalization.py +++ b/harness/tests/memory/test_solved_finalization.py @@ -77,7 +77,7 @@ def test_finalize_commits_session_before_best_effort_post_commit_read_failure( monkeypatch.setattr("tht.execute.warnings.runtime_warnings", lambda *args: []) monkeypatch.setattr("tht.execute.warnings.static_warnings", lambda *args: []) monkeypatch.setattr("tht.report.render_validation_report", lambda **kwargs: "verified\n") - monkeypatch.setattr("tht.session.artifacts.build_evidence_entries", lambda *args: []) + monkeypatch.setattr("tht.evidence.project_session", lambda *args: []) session_cmd.finalize_cmd(session_id, config=Path("unused.yaml")) diff --git a/harness/tests/test_evidence_facade_contract.py b/harness/tests/test_evidence_facade_contract.py index 4564f2a8..73d5eae9 100644 --- a/harness/tests/test_evidence_facade_contract.py +++ b/harness/tests/test_evidence_facade_contract.py @@ -10,6 +10,7 @@ from tht.decisions import DecisionRecord from tht.evidence import ( acquire, active_searcher, + build_retrieval_entries, discover, project_session, resolve_citation, @@ -125,6 +126,18 @@ def test_search_facade_preserves_active_filtering_and_global_order(tmp_path): assert facade_delegate.calls == legacy_delegate.calls +def test_retrieval_entries_preserve_hit_order_and_existing_projection_shape(): + hits = [ + SimpleNamespace(label="Second", status="reviewed", content="abcdefgh"), + SimpleNamespace(label="First", status=None, content="12345678"), + ] + + assert build_retrieval_entries(hits, excerpt_chars=5) == [ + {"title": "Second", "status": "reviewed", "excerpt": "abcde"}, + {"title": "First", "status": None, "excerpt": "12345"}, + ] + + def _canonical_store(root, evidence_id): content = f"# {evidence_id}\n" digest = hashlib.sha256(content.encode()).hexdigest() @@ -159,6 +172,40 @@ def test_citation_facade_matches_active_corpus_resolution(tmp_path): assert resolve_citation(store, "missing", materialized_root=materialized) == "" +def test_session_projection_routes_corpus_citations_through_the_facade(tmp_path, monkeypatch): + evidence_root = tmp_path / "artifacts" / "evidence" + (tmp_path / "corpus").mkdir() + calls = [] + + def fake_resolve(store, evidence_id, *, materialized_root=None): + calls.append((store.root, evidence_id, materialized_root)) + return f"/materialized/{evidence_id}.md" + + monkeypatch.setattr("tht.evidence.resolve_citation", fake_resolve) + linking = SchemaLinking( + question="q", + candidates=[Candidate( + kind="table", + name="fact_procedure", + evidence=["evi-used"], + decision="promoted", + decision_seq=17, + )], + ) + + assert build_evidence_entries([], linking, evidence_root) == [{ + "id": "evi-used", + "file": "/materialized/evi-used.md", + "esito": "usata", + "decision_seq": 17, + }] + assert calls == [( + tmp_path / "corpus", + "evi-used", + tmp_path / "artifacts" / ".materialized-evidence", + )] + + def _record(seq, type_, subject): return DecisionRecord( seq=seq, diff --git a/harness/tests/test_search_pack.py b/harness/tests/test_search_pack.py index cdb517eb..35421d9e 100644 --- a/harness/tests/test_search_pack.py +++ b/harness/tests/test_search_pack.py @@ -93,6 +93,18 @@ def _patch(monkeypatch, embedder, searcher): def test_pack_single_embed_and_sections(tmp_path, monkeypatch): + import tht.evidence as evidence_facade + + projected = [] + build_retrieval_entries = evidence_facade.build_retrieval_entries + monkeypatch.setattr( + evidence_facade, + "build_retrieval_entries", + lambda results, *, excerpt_chars: ( + projected.append((list(results), excerpt_chars)) + or build_retrieval_entries(results, excerpt_chars=excerpt_chars) + ), + ) cfg = _workspace(tmp_path) emb = _FakeEmbedder() searcher = _FakeSearcher() @@ -109,6 +121,7 @@ def test_pack_single_embed_and_sections(tmp_path, monkeypatch): # must not leak into a new search pack. assert "Dominio ablazione" not in res.output assert "SELECT 1" in res.output + assert projected == [([], 400)] def test_pack_json_and_session_file(tmp_path, monkeypatch): diff --git a/harness/tests/test_workflow_observable_contract.py b/harness/tests/test_workflow_observable_contract.py index be672024..9382a527 100644 --- a/harness/tests/test_workflow_observable_contract.py +++ b/harness/tests/test_workflow_observable_contract.py @@ -9,8 +9,8 @@ import pytest from tht.corpus.models import CanonicalDocument, CorpusManifest from tht.corpus.store import CorpusStore from tht.decisions import DecisionRecord, append_decision +from tht.evidence import project_session from tht.phase import current_phase, effective_decisions -from tht.session.artifacts import build_evidence_entries from tht.session.models import Candidate, SchemaLinking from tht.workflow import load_workflow @@ -173,7 +173,7 @@ def test_schema_linking_evidence_used_resolves_from_the_active_canonical_corpus( ) store.publish(generation) - entries = build_evidence_entries( + entries = project_session( [], _linking("evi-used"), tmp_path / "artifacts" / "evidence", @@ -195,7 +195,7 @@ def test_legacy_evidence_without_a_canonical_corpus_keeps_used_and_reviewed_outc for evidence_id in ("evi-used", "evi-accepted", "evi-rejected"): (evidence_root / f"{evidence_id}.md").write_text(f"# {evidence_id}\n") - entries = build_evidence_entries( + entries = project_session( [ _record(21, "evidence_accepted", "evi-accepted"), _record(22, "evidence_rejected", "evi-rejected"), diff --git a/harness/tht/cli/search_cmd.py b/harness/tht/cli/search_cmd.py index ec27254b..757704d4 100644 --- a/harness/tht/cli/search_cmd.py +++ b/harness/tht/cli/search_cmd.py @@ -54,18 +54,16 @@ def search_cmd( from rich.table import Table from tht.cli.vector_cmd import make_embedder, open_searcher, require_vector_cfg + from tht.evidence import active_searcher, validate_corpus_workspace from tht.lshindex import LshIndexError, load_index, query_index from tht.search import combined_search cfg = _load_config_or_exit(config) - from tht.search.evidence import validate_corpus_workspace workspace_id = workspace_id_for_config(cfg, config) validate_corpus_workspace(cfg, workspace_id) dwh_snapshot = _leased_dwh_snapshot(cfg, ctx) require_vector_cfg(cfg) - from tht.search.evidence import active_searcher - runtime_searcher = active_searcher( cfg, open_searcher(cfg), workspace_id=workspace_id, @@ -255,13 +253,17 @@ def pack_cmd( from sqlalchemy.exc import OperationalError from tht.cli.vector_cmd import make_embedder, open_searcher, require_vector_cfg + from tht.evidence import ( + active_searcher, + build_retrieval_entries, + validate_corpus_workspace, + ) from tht.ports.vector import VectorReadUnavailable, VectorStoreError from tht.search import combined_search, schema_tables from tht.memory import SOLVED_KIND from tht.vectorstore.embeddings import EmbeddingsError cfg = _load_config_or_exit(config) - from tht.search.evidence import validate_corpus_workspace workspace_id = workspace_id_for_config(cfg, config) validate_corpus_workspace(cfg, workspace_id) @@ -277,8 +279,6 @@ def pack_cmd( vec = None searcher = embedder = None try: - from tht.search.evidence import active_searcher - searcher = active_searcher( cfg, open_searcher(cfg), workspace_id=workspace_id, @@ -314,11 +314,7 @@ def pack_cmd( top=PACK_EVIDENCE_TOP, rrf_k=cfg.search.rrf_k, kinds=KIND_MAP["evidence"], query_vec=vec, ) - evidence = [ - {"title": r.label, "status": r.status, - "excerpt": r.content[:PACK_EXCERPT_CHARS]} - for r in ev - ] + evidence = build_retrieval_entries(ev, excerpt_chars=PACK_EXCERPT_CHARS) except degrade as e: warnings.append(f"ricerca evidence fallita ({e})") try: diff --git a/harness/tht/cli/session_cmd.py b/harness/tht/cli/session_cmd.py index bf8acf27..17abc7d4 100644 --- a/harness/tht/cli/session_cmd.py +++ b/harness/tht/cli/session_cmd.py @@ -518,7 +518,7 @@ def finalize_cmd(session_id: str = typer.Argument(...), config: Path = CONFIG_OP from tht.execute import ExecutionError from tht.execute.warnings import plan_warnings, runtime_warnings, static_warnings from tht.report import extract_reviewer_notes, render_validation_report - from tht.session.artifacts import build_evidence_entries + from tht.evidence import project_session from tht.phase import cte_plan as effective_cte_plan from tht.phase import effective_decisions from tht.session.models import SchemaLinking @@ -614,7 +614,7 @@ def finalize_cmd(session_id: str = typer.Argument(...), config: Path = CONFIG_OP linking = SchemaLinking.model_validate( json.loads(snapshot.artifacts["schema_linking"]) ) - entries = build_evidence_entries(decisions, linking, cfg.paths.artifacts / "evidence") + entries = project_session(decisions, linking, cfg.paths.artifacts / "evidence") evidence = json.dumps(entries, ensure_ascii=False, indent=2) # --- manifest + riepilogo --- diff --git a/harness/tht/evidence/__init__.py b/harness/tht/evidence/__init__.py index af43dd6f..f5e4cce4 100644 --- a/harness/tht/evidence/__init__.py +++ b/harness/tht/evidence/__init__.py @@ -55,6 +55,18 @@ def validate_corpus_workspace(cfg, workspace_id: str) -> None: legacy_validate(cfg, workspace_id) +def build_retrieval_entries(results, *, excerpt_chars: int) -> list[dict]: + """Project ordered Evidence search hits into the existing retrieval-pack shape.""" + return [ + { + "title": result.label, + "status": result.status, + "excerpt": result.content[:excerpt_chars], + } + for result in results + ] + + def resolve_citation( store: "CorpusStore", evidence_id: str, @@ -71,15 +83,48 @@ def resolve_citation( ) +def _resolve_session_citation(evidence_root: Path, evidence_id: str) -> str: + # New deployments resolve only immutable materialized files from ACTIVE. Keep the + # curated-tree fallback for sessions created before a canonical corpus exists. + corpus_root = evidence_root.parent.parent / "corpus" + if corpus_root.exists(): + from tht.corpus.store import CorpusStore + + return resolve_citation( + CorpusStore(corpus_root), + evidence_id, + materialized_root=evidence_root.parent / ".materialized-evidence", + ) + for match in evidence_root.rglob(f"{evidence_id}.md"): + return str(match) + return "" + + def project_session( decisions: list["DecisionRecord"], linking: "SchemaLinking", evidence_root: Path, ) -> list[dict]: """Project cited and reviewed Evidence into the existing session artifact shape.""" - from tht.session.artifacts import build_evidence_entries - - return build_evidence_entries(decisions, linking, evidence_root) + entries: dict[str, dict] = {} + for candidate in linking.candidates: + for evidence_id in candidate.evidence: + entries.setdefault(evidence_id, { + "id": evidence_id, + "file": _resolve_session_citation(evidence_root, evidence_id), + "esito": "usata", + "decision_seq": candidate.decision_seq, + }) + for decision in decisions: + if decision.type not in ("evidence_accepted", "evidence_rejected"): + continue + entries[decision.subject] = { + "id": decision.subject, + "file": _resolve_session_citation(evidence_root, decision.subject), + "esito": "accettata" if decision.type == "evidence_accepted" else "scartata", + "decision_seq": decision.seq, + } + return list(entries.values()) __all__ = [ @@ -90,6 +135,7 @@ __all__ = [ "SourceObject", "acquire", "active_searcher", + "build_retrieval_entries", "discover", "project_session", "resolve_citation", diff --git a/harness/tht/session/artifacts.py b/harness/tht/session/artifacts.py index 6f4a65cd..159aa3e6 100644 --- a/harness/tht/session/artifacts.py +++ b/harness/tht/session/artifacts.py @@ -1,49 +1,17 @@ +"""Legacy session-artifact entrypoints retained during the Evidence migration.""" + from pathlib import Path from tht.decisions import DecisionRecord from tht.session.models import SchemaLinking -def _find_evidence_file(evidence_root: Path, evidence_id: str) -> str: - # New deployments resolve only immutable materialized files from ACTIVE. Keep - # the legacy curated-tree fallback for sessions created before a corpus exists. - corpus_root = evidence_root.parent.parent / "corpus" - if corpus_root.exists(): - from tht.corpus.store import CorpusStore - from tht.search.evidence import resolve_evidence_file - return resolve_evidence_file( - CorpusStore(corpus_root), evidence_id, - materialized_root=evidence_root.parent / ".materialized-evidence", - ) - for match in evidence_root.rglob(f"{evidence_id}.md"): - return str(match) - return "" - - def build_evidence_entries( decisions: list[DecisionRecord], linking: SchemaLinking, evidence_root: Path, ) -> list[dict]: - """Elenco {id, file, esito, decision_seq}: evidence citate nello schema linking - (esito 'usata') e decisioni esplicite del reviewer (accettata/scartata, che - prevalgono sul linking).""" - entries: dict[str, dict] = {} - for candidate in linking.candidates: - for evidence_id in candidate.evidence: - entries.setdefault(evidence_id, { - "id": evidence_id, - "file": _find_evidence_file(evidence_root, evidence_id), - "esito": "usata", - "decision_seq": candidate.decision_seq, - }) - for d in decisions: - if d.type not in ("evidence_accepted", "evidence_rejected"): - continue - entries[d.subject] = { - "id": d.subject, - "file": _find_evidence_file(evidence_root, d.subject), - "esito": "accettata" if d.type == "evidence_accepted" else "scartata", - "decision_seq": d.seq, - } - return list(entries.values()) + """Compatibility shim for callers not yet migrated to ``tht.evidence``.""" + from tht.evidence import project_session + + return project_session(decisions, linking, evidence_root)