feat(evidence): contribute to semantic stages (#42)
This commit is contained in:
@@ -31,6 +31,7 @@
|
||||
"schema render",
|
||||
"schema suggest-fks",
|
||||
"search find",
|
||||
"search evidence",
|
||||
"search pack",
|
||||
"session archive",
|
||||
"session check",
|
||||
|
||||
@@ -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"]))
|
||||
|
||||
@@ -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"]},
|
||||
]
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user