refactor(evidence): migrate runtime consumption (#29)

This commit is contained in:
2026-08-24 02:10:06 +02:00
parent 8b63715b56
commit d5e78febd3
8 changed files with 128 additions and 58 deletions
@@ -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.runtime_warnings", lambda *args: [])
monkeypatch.setattr("tht.execute.warnings.static_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.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")) session_cmd.finalize_cmd(session_id, config=Path("unused.yaml"))
@@ -10,6 +10,7 @@ from tht.decisions import DecisionRecord
from tht.evidence import ( from tht.evidence import (
acquire, acquire,
active_searcher, active_searcher,
build_retrieval_entries,
discover, discover,
project_session, project_session,
resolve_citation, 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 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): def _canonical_store(root, evidence_id):
content = f"# {evidence_id}\n" content = f"# {evidence_id}\n"
digest = hashlib.sha256(content.encode()).hexdigest() 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) == "" 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): def _record(seq, type_, subject):
return DecisionRecord( return DecisionRecord(
seq=seq, seq=seq,
+13
View File
@@ -93,6 +93,18 @@ def _patch(monkeypatch, embedder, searcher):
def test_pack_single_embed_and_sections(tmp_path, monkeypatch): 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) cfg = _workspace(tmp_path)
emb = _FakeEmbedder() emb = _FakeEmbedder()
searcher = _FakeSearcher() searcher = _FakeSearcher()
@@ -109,6 +121,7 @@ def test_pack_single_embed_and_sections(tmp_path, monkeypatch):
# must not leak into a new search pack. # must not leak into a new search pack.
assert "Dominio ablazione" not in res.output assert "Dominio ablazione" not in res.output
assert "SELECT 1" in res.output assert "SELECT 1" in res.output
assert projected == [([], 400)]
def test_pack_json_and_session_file(tmp_path, monkeypatch): def test_pack_json_and_session_file(tmp_path, monkeypatch):
@@ -9,8 +9,8 @@ import pytest
from tht.corpus.models import CanonicalDocument, CorpusManifest from tht.corpus.models import CanonicalDocument, CorpusManifest
from tht.corpus.store import CorpusStore from tht.corpus.store import CorpusStore
from tht.decisions import DecisionRecord, append_decision from tht.decisions import DecisionRecord, append_decision
from tht.evidence import project_session
from tht.phase import current_phase, effective_decisions 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.session.models import Candidate, SchemaLinking
from tht.workflow import load_workflow 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) store.publish(generation)
entries = build_evidence_entries( entries = project_session(
[], [],
_linking("evi-used"), _linking("evi-used"),
tmp_path / "artifacts" / "evidence", 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"): for evidence_id in ("evi-used", "evi-accepted", "evi-rejected"):
(evidence_root / f"{evidence_id}.md").write_text(f"# {evidence_id}\n") (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(21, "evidence_accepted", "evi-accepted"),
_record(22, "evidence_rejected", "evi-rejected"), _record(22, "evidence_rejected", "evi-rejected"),
+7 -11
View File
@@ -54,18 +54,16 @@ def search_cmd(
from rich.table import Table from rich.table import Table
from tht.cli.vector_cmd import make_embedder, open_searcher, require_vector_cfg 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.lshindex import LshIndexError, load_index, query_index
from tht.search import combined_search from tht.search import combined_search
cfg = _load_config_or_exit(config) cfg = _load_config_or_exit(config)
from tht.search.evidence import validate_corpus_workspace
workspace_id = workspace_id_for_config(cfg, config) workspace_id = workspace_id_for_config(cfg, config)
validate_corpus_workspace(cfg, workspace_id) validate_corpus_workspace(cfg, workspace_id)
dwh_snapshot = _leased_dwh_snapshot(cfg, ctx) dwh_snapshot = _leased_dwh_snapshot(cfg, ctx)
require_vector_cfg(cfg) require_vector_cfg(cfg)
from tht.search.evidence import active_searcher
runtime_searcher = active_searcher( runtime_searcher = active_searcher(
cfg, open_searcher(cfg), cfg, open_searcher(cfg),
workspace_id=workspace_id, workspace_id=workspace_id,
@@ -255,13 +253,17 @@ def pack_cmd(
from sqlalchemy.exc import OperationalError from sqlalchemy.exc import OperationalError
from tht.cli.vector_cmd import make_embedder, open_searcher, require_vector_cfg 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.ports.vector import VectorReadUnavailable, VectorStoreError
from tht.search import combined_search, schema_tables from tht.search import combined_search, schema_tables
from tht.memory import SOLVED_KIND from tht.memory import SOLVED_KIND
from tht.vectorstore.embeddings import EmbeddingsError from tht.vectorstore.embeddings import EmbeddingsError
cfg = _load_config_or_exit(config) cfg = _load_config_or_exit(config)
from tht.search.evidence import validate_corpus_workspace
workspace_id = workspace_id_for_config(cfg, config) workspace_id = workspace_id_for_config(cfg, config)
validate_corpus_workspace(cfg, workspace_id) validate_corpus_workspace(cfg, workspace_id)
@@ -277,8 +279,6 @@ def pack_cmd(
vec = None vec = None
searcher = embedder = None searcher = embedder = None
try: try:
from tht.search.evidence import active_searcher
searcher = active_searcher( searcher = active_searcher(
cfg, open_searcher(cfg), cfg, open_searcher(cfg),
workspace_id=workspace_id, workspace_id=workspace_id,
@@ -314,11 +314,7 @@ def pack_cmd(
top=PACK_EVIDENCE_TOP, rrf_k=cfg.search.rrf_k, top=PACK_EVIDENCE_TOP, rrf_k=cfg.search.rrf_k,
kinds=KIND_MAP["evidence"], query_vec=vec, kinds=KIND_MAP["evidence"], query_vec=vec,
) )
evidence = [ evidence = build_retrieval_entries(ev, excerpt_chars=PACK_EXCERPT_CHARS)
{"title": r.label, "status": r.status,
"excerpt": r.content[:PACK_EXCERPT_CHARS]}
for r in ev
]
except degrade as e: except degrade as e:
warnings.append(f"ricerca evidence fallita ({e})") warnings.append(f"ricerca evidence fallita ({e})")
try: try:
+2 -2
View File
@@ -518,7 +518,7 @@ def finalize_cmd(session_id: str = typer.Argument(...), config: Path = CONFIG_OP
from tht.execute import ExecutionError from tht.execute import ExecutionError
from tht.execute.warnings import plan_warnings, runtime_warnings, static_warnings from tht.execute.warnings import plan_warnings, runtime_warnings, static_warnings
from tht.report import extract_reviewer_notes, render_validation_report 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 cte_plan as effective_cte_plan
from tht.phase import effective_decisions from tht.phase import effective_decisions
from tht.session.models import SchemaLinking 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( linking = SchemaLinking.model_validate(
json.loads(snapshot.artifacts["schema_linking"]) 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) evidence = json.dumps(entries, ensure_ascii=False, indent=2)
# --- manifest + riepilogo --- # --- manifest + riepilogo ---
+49 -3
View File
@@ -55,6 +55,18 @@ def validate_corpus_workspace(cfg, workspace_id: str) -> None:
legacy_validate(cfg, workspace_id) 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( def resolve_citation(
store: "CorpusStore", store: "CorpusStore",
evidence_id: str, 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( def project_session(
decisions: list["DecisionRecord"], decisions: list["DecisionRecord"],
linking: "SchemaLinking", linking: "SchemaLinking",
evidence_root: Path, evidence_root: Path,
) -> list[dict]: ) -> list[dict]:
"""Project cited and reviewed Evidence into the existing session artifact shape.""" """Project cited and reviewed Evidence into the existing session artifact shape."""
from tht.session.artifacts import build_evidence_entries entries: dict[str, dict] = {}
for candidate in linking.candidates:
return build_evidence_entries(decisions, linking, evidence_root) 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__ = [ __all__ = [
@@ -90,6 +135,7 @@ __all__ = [
"SourceObject", "SourceObject",
"acquire", "acquire",
"active_searcher", "active_searcher",
"build_retrieval_entries",
"discover", "discover",
"project_session", "project_session",
"resolve_citation", "resolve_citation",
+6 -38
View File
@@ -1,49 +1,17 @@
"""Legacy session-artifact entrypoints retained during the Evidence migration."""
from pathlib import Path from pathlib import Path
from tht.decisions import DecisionRecord from tht.decisions import DecisionRecord
from tht.session.models import SchemaLinking 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( def build_evidence_entries(
decisions: list[DecisionRecord], decisions: list[DecisionRecord],
linking: SchemaLinking, linking: SchemaLinking,
evidence_root: Path, evidence_root: Path,
) -> list[dict]: ) -> list[dict]:
"""Elenco {id, file, esito, decision_seq}: evidence citate nello schema linking """Compatibility shim for callers not yet migrated to ``tht.evidence``."""
(esito 'usata') e decisioni esplicite del reviewer (accettata/scartata, che from tht.evidence import project_session
prevalgono sul linking)."""
entries: dict[str, dict] = {} return project_session(decisions, linking, evidence_root)
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())