feat(evidence): contribute to semantic stages (#42)

This commit is contained in:
2026-08-24 22:05:30 +02:00
parent 3420c57c8b
commit f1a9b567ba
17 changed files with 619 additions and 23 deletions
+1
View File
@@ -31,6 +31,7 @@
"schema render",
"schema suggest-fks",
"search find",
"search evidence",
"search pack",
"session archive",
"session check",
+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"]) == 58
assert len(approved["maintained"]) == 59
assert len(approved["enhanced"]) == 8
assert len(approved["erased"]) == 14
assert not (expected & set(approved["erased"]))
+132 -1
View File
@@ -8,6 +8,7 @@ import pytest
from tht.decisions import DecisionRecord
from tht.evidence import (
EvidenceSearchContext,
acquire,
active_searcher,
build_preprocessing_pipeline,
@@ -16,6 +17,7 @@ from tht.evidence import (
discover,
project_session,
resolve_citation,
search_evidence,
)
from tht.evidence.contracts import (
AcquiredDocument,
@@ -25,6 +27,7 @@ from tht.evidence.contracts import (
)
from tht.evidence.corpus.models import CanonicalDocument, CorpusManifest
from tht.evidence.corpus.store import CorpusStore
from tht.ports.vector import VectorReadUnavailable
from tht.session.models import Candidate, SchemaLinking
@@ -186,13 +189,141 @@ def test_retrieval_entries_preserve_hit_order_and_existing_projection_shape():
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 test_typed_search_renders_one_stable_query_and_groups_fragments_by_evidence_unit():
"""Removing context rendering, hard filters, or grouping changes this public result."""
class Searcher:
vector_generation = "gen:" + "a" * 32
def __init__(self):
self.calls = []
def search(self, embedding, **kwargs):
self.calls.append((embedding, kwargs))
return [
SimpleNamespace(
id="fragment:second", similarity=0.7, content="second excerpt",
title="Pediatric range", metadata={
"evidence_id": "evidence:pediatric-range", "evidence_kind": "formula",
"document_id": "doc:range", "ordinal": 1,
"source_uri": "file:///curated/pediatric-range.md",
"provenance": {"source_file": "source/range.md"},
},
),
SimpleNamespace(
id="fragment:first", similarity=0.9, content="first excerpt",
title="Pediatric range", metadata={
"evidence_id": "evidence:pediatric-range", "evidence_kind": "formula",
"document_id": "doc:range", "ordinal": 0,
"source_uri": "file:///curated/pediatric-range.md",
"provenance": {"source_file": "source/range.md"},
},
),
]
class Embedder:
def __init__(self):
self.queries = []
def embed_query(self, query):
self.queries.append(query)
return [0.25]
searcher = Searcher()
embedder = Embedder()
outcome = search_evidence(
" Pazienti \"Età" + "\r\n" + " pediatrica ",
"schema_linking",
EvidenceSearchContext(
concepts=("pediatrica", "pediatrica", " Età "),
tables=("clinical.patient",),
columns=("clinical.patient.Age",),
required_kinds=("formula",),
required_concepts=("Età",),
required_tables=("clinical.patient",),
required_columns=("clinical.patient.Age",),
),
searcher=searcher,
embedder=embedder,
)
rendered = (
"Domanda: Pazienti \"Età\n pediatrica\n"
"Concetti: Età, pediatrica\n"
"Tabelle: clinical.patient\n"
"Colonne: clinical.patient.Age"
)
assert embedder.queries == [rendered]
assert searcher.calls == [([0.25], {
"top_n": 10,
"kinds": ["evidence"],
"query_text": rendered,
"metadata_filter": {
"purpose": "schema_linking",
"required_kinds": ["formula"],
"required_concepts": ["Età"],
"required_tables": ["clinical.patient"],
"required_columns": ["clinical.patient.Age"],
},
})]
assert outcome.status == "available"
assert outcome.vector_generation == "gen:" + "a" * 32
assert [(item.evidence_id, item.excerpts, item.provenance, item.citation) for item in outcome.results] == [
("evidence:pediatric-range", ("first excerpt", "second excerpt"),
{"source_file": "source/range.md"}, "file:///curated/pediatric-range.md"),
]
def test_typed_search_reports_vector_errors_as_unavailable_not_empty_results():
class UnavailableSearcher:
vector_generation = "gen:" + "a" * 32
def search(self, _embedding, **_kwargs):
raise VectorReadUnavailable("reader unavailable")
outcome = search_evidence(
"question", "rewriting", EvidenceSearchContext(),
searcher=UnavailableSearcher(), embedder=SimpleNamespace(embed_query=lambda _query: [0.25]),
)
assert outcome.status == "unavailable"
assert outcome.code == "vector_unavailable"
assert outcome.results == ()
def test_typed_search_without_an_active_generation_is_unavailable_not_an_empty_search():
outcome = search_evidence(
"question", "rewriting", EvidenceSearchContext(),
searcher=SimpleNamespace(), embedder=SimpleNamespace(embed_query=lambda _query: [0.25]),
)
assert outcome.status == "unavailable"
assert outcome.code == "active_corpus_unavailable"
def test_typed_search_reports_a_malformed_fragment_payload_as_unavailable():
class Searcher:
vector_generation = "gen:" + "a" * 32
def search(self, _embedding, **_kwargs):
return [SimpleNamespace(
id="fragment:bad", similarity=0.5, title="Bad", content="bad",
metadata={"evidence_id": "evidence:bad"},
)]
outcome = search_evidence(
"question", "rewriting", EvidenceSearchContext(),
searcher=Searcher(), embedder=SimpleNamespace(embed_query=lambda _query: [0.25]),
)
assert outcome.status == "unavailable"
assert outcome.code == "evidence_search_unavailable"
def _canonical_store(root, evidence_id):
content = f"# {evidence_id}\n"
digest = hashlib.sha256(content.encode()).hexdigest()
@@ -0,0 +1,38 @@
import json
from datetime import UTC, datetime
from tht.evidence import EvidenceReceipt, replace_evidence_receipt
from tht.session.filesystem_repository import FilesystemSessionRepository
from tht.session.models import PrincipalContext, SessionManifest
def test_receipt_replaces_only_its_semantic_stage_without_copying_evidence_content(tmp_path):
repository = FilesystemSessionRepository(
tmp_path, "workspace-a", PrincipalContext(issuer="test", subject="operator"),
root=tmp_path / "sessions",
)
session_id = "c2cdbbf5-ae30-432e-8298-837bf55abad9"
repository.create(SessionManifest(
id=session_id, question="q", database="d", schema="s", created_at=datetime(2026, 8, 24, tzinfo=UTC),
))
replace_evidence_receipt(repository, session_id, EvidenceReceipt(
stage="clarification", purpose="disambiguation", vector_generation="gen:" + "a" * 32,
evidence_ids=("evidence:age",),
))
replace_evidence_receipt(repository, session_id, EvidenceReceipt(
stage="clarification", purpose="disambiguation", vector_generation="gen:" + "b" * 32,
evidence_ids=(),
))
replace_evidence_receipt(repository, session_id, EvidenceReceipt(
stage="cte", purpose="sql_generation", vector_generation="gen:" + "b" * 32,
evidence_ids=("evidence:age", "evidence:procedure"),
))
stored = json.loads(repository.read_artifact(session_id, "evidence_receipts"))
assert stored == [
{"stage": "clarification", "purpose": "disambiguation", "vector_generation": "gen:" + "b" * 32,
"evidence_ids": []},
{"stage": "cte", "purpose": "sql_generation", "vector_generation": "gen:" + "b" * 32,
"evidence_ids": ["evidence:age", "evidence:procedure"]},
]
+2 -1
View File
@@ -9,13 +9,14 @@ from tht.pi_skill_projection import (
render_projection,
)
BASELINE_SHA256 = "626a794071c095a4f20fffabb3bab901f05c101590adbdc58e45adfae56f3219"
BASELINE_SHA256 = "bb6daa6fe83d22f5e701025c5334d72ec9eb35349e90d24cb9d7f6290d0fecfe"
def test_modular_pi_skill_renders_the_byte_identical_approved_projection():
rendered = render_projection()
assert FRAGMENT_ORDER == (
("{{EVIDENCE_RUNTIME_SEARCH}}", "evidence/runtime-search.md"),
("{{DISAMBIGUATION_OPEN_AMBIGUITY}}", "disambiguation/open-ambiguity.md"),
("{{DISAMBIGUATION_INSTRUCTIONS}}", "disambiguation/phase-1.md"),
("{{MEMORY_INSTRUCTIONS}}", "memory/phase-2.md"),
+28 -2
View File
@@ -5,7 +5,9 @@ from types import SimpleNamespace
from typer.testing import CliRunner
from tht.cli import app
from tht.config import load_config
from tht.config import load_config, workspace_id_for_config
from tht.evidence.corpus.models import CorpusManifest
from tht.evidence.corpus.store import CorpusStore
from tht.jobs.dwh_pipeline import DwhPreprocessPipeline, config_dwh_binding
from tht.mschema.models import ColumnPhysical, PhysicalSchema, TablePhysical
from tht.ports.vector import VectorReadUnavailable
@@ -75,6 +77,19 @@ def _workspace(tmp_path, with_session=None):
],
lsh_filenames=("s_lsh.pkl", "s_minhashes.pkl", "s_meta.json"),
).run()
corpus = CorpusStore(tmp_path / "corpus")
generation = "gen:" + "a" * 32
active = corpus.stage(
CorpusManifest(
vector_generation=generation,
embedding_model="test",
embedding_dimensions=1,
metadata={"workspace_id": workspace_id_for_config(load_config(cfg), cfg)},
),
{},
generation=generation,
)
corpus.publish(active)
if with_session:
sdir = tmp_path / "sessions" / with_session
sdir.mkdir(parents=True)
@@ -111,7 +126,7 @@ def test_pack_single_embed_and_sections(tmp_path, monkeypatch):
_patch(monkeypatch, emb, searcher)
res = CliRunner().invoke(app, ["search", "pack", "quanti pazienti", "-c", str(cfg)])
assert res.exit_code == 0, res.output
assert emb.calls == 1 # UN solo embedding per le tre ricerche
assert emb.calls == 2 # schema/memory share one; Evidence embeds its own deterministic text
assert [call["kinds"] for call in searcher.calls] == [
["schema_table", "schema_column"],
["solved_question"],
@@ -167,3 +182,14 @@ def test_pack_degrades_gracefully(tmp_path, monkeypatch):
data = json.loads(res.output[res.output.index("{"):])
assert data["tables"] == [] and data["evidence"] == [] and data["solved"] == []
assert any("retrieval non disponibile" in w for w in data["warnings"])
def test_pack_refuses_to_treat_an_unavailable_evidence_corpus_as_no_matches(tmp_path, monkeypatch):
cfg = _workspace(tmp_path)
(tmp_path / "corpus" / "ACTIVE").unlink()
_patch(monkeypatch, _FakeEmbedder(), _FakeSearcher())
result = CliRunner().invoke(app, ["search", "pack", "q", "-c", str(cfg)])
assert result.exit_code == 1
assert "Evidence non disponibile" in result.output