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
+15 -1
View File
@@ -39,12 +39,18 @@ from tht.evidence.preprocessing import EvidenceEmbedder, build_preprocessing_pip
from tht.evidence.search import (
ActiveEvidenceSearcher,
CorpusWorkspaceMismatchError,
EvidenceQueryEmbedder,
EvidenceResult,
EvidenceSearchContext,
EvidenceSearchOutcome,
active_searcher,
build_retrieval_entries,
render_evidence_query,
resolve_citation,
search_evidence,
validate_corpus_workspace,
)
from tht.evidence.session import project_session
from tht.evidence.session import EvidenceReceipt, project_session, replace_evidence_receipt
from tht.evidence.sources import build_sources
__all__ = [
@@ -56,8 +62,13 @@ __all__ = [
"EvidenceManifest",
"EvidencePreparationError",
"EvidencePreparationReport",
"EvidenceQueryEmbedder",
"EvidenceReceipt",
"EvidenceResolutionReport",
"EvidenceRestructurer",
"EvidenceResult",
"EvidenceSearchContext",
"EvidenceSearchOutcome",
"EvidenceSource",
"EvidenceSourceError",
"EvidenceSourceErrorCategory",
@@ -82,8 +93,11 @@ __all__ = [
"parse_curated_markdown",
"prepare_workspace_evidence",
"project_session",
"render_evidence_query",
"replace_evidence_receipt",
"resolve_citation",
"resolve_workspace_evidence",
"search_evidence",
"validate_corpus_workspace",
"validate_namespaced_value",
"validate_safe_metadata",
+180 -9
View File
@@ -1,15 +1,175 @@
"""Evidence-owned runtime lookup bound to the atomically active corpus generation."""
import re
import unicodedata
from dataclasses import dataclass
from typing import Literal, Protocol
from pydantic import BaseModel, ConfigDict
from tht.evidence.canonical import EvidenceKind, EvidencePurpose
from tht.evidence.corpus.store import CorpusStore
from tht.ports.vector import VectorStoreError
from tht.ports.vector import VectorReadUnavailable, VectorStoreError
class CorpusWorkspaceMismatchError(RuntimeError):
"""The configured workspace does not own the persisted corpus."""
class EvidenceQueryEmbedder(Protocol):
def embed_query(self, query: str) -> list[float]: ...
class EvidenceSearchContext(BaseModel):
"""Optional query enrichment and explicit, server-enforced Evidence constraints."""
model_config = ConfigDict(frozen=True, extra="forbid")
concepts: tuple[str, ...] = ()
tables: tuple[str, ...] = ()
columns: tuple[str, ...] = ()
required_kinds: tuple[EvidenceKind, ...] = ()
required_concepts: tuple[str, ...] = ()
required_tables: tuple[str, ...] = ()
required_columns: tuple[str, ...] = ()
@dataclass(frozen=True)
class EvidenceResult:
evidence_id: str
title: str
kind: str
excerpts: tuple[str, ...]
provenance: dict
citation: str
document_id: str
score: float
@dataclass(frozen=True)
class EvidenceSearchOutcome:
status: Literal["available", "unavailable"]
vector_generation: str | None
results: tuple[EvidenceResult, ...] = ()
code: str | None = None
message: str | None = None
@classmethod
def unavailable(cls, code: str, message: str) -> "EvidenceSearchOutcome":
return cls("unavailable", None, (), code, message[:240])
def _normalized_values(values: tuple[str, ...]) -> tuple[str, ...]:
normalized = {
unicodedata.normalize("NFC", value).strip()
for value in values
if isinstance(value, str) and unicodedata.normalize("NFC", value).strip()
}
return tuple(sorted(normalized))
def render_evidence_query(query: str, context: EvidenceSearchContext) -> str:
"""Build the one exact query text shared by dense and BM25 retrieval."""
question = unicodedata.normalize("NFC", query).replace("\r\n", "\n").replace("\r", "\n").strip()
if not question:
raise ValueError("Evidence query must not be empty")
sections = [("Domanda", question)]
for label, values in (
("Concetti", _normalized_values(context.concepts)),
("Tabelle", _normalized_values(context.tables)),
("Colonne", _normalized_values(context.columns)),
):
if values:
sections.append((label, ", ".join(values)))
return "\n".join(f"{label}: {value}" for label, value in sections)
def _required_metadata_filter(purpose: EvidencePurpose, context: EvidenceSearchContext) -> dict[str, object]:
return {
"purpose": purpose,
"required_kinds": list(_normalized_values(context.required_kinds)),
"required_concepts": list(_normalized_values(context.required_concepts)),
"required_tables": list(_normalized_values(context.required_tables)),
"required_columns": list(_normalized_values(context.required_columns)),
}
def _active_vector_generation(searcher) -> str | None:
supplied = getattr(searcher, "vector_generation", None)
if isinstance(supplied, str) and supplied:
return supplied
corpus = getattr(searcher, "corpus", None)
if corpus is None:
return None
with corpus.writer_lock():
manifest = corpus.active_manifest()
return manifest.vector_generation if manifest is not None else None
def _group_evidence_fragments(hits) -> tuple[EvidenceResult, ...]:
grouped: dict[str, list] = {}
for hit in hits:
metadata = getattr(hit, "metadata", {})
evidence_id = metadata.get("evidence_id") if isinstance(metadata, dict) else None
if not isinstance(evidence_id, str) or not evidence_id:
raise VectorStoreError("Evidence search returned malformed payload")
grouped.setdefault(evidence_id, []).append(hit)
results = []
for evidence_id, fragments in grouped.items():
ordered = sorted(
fragments,
key=lambda item: (-float(item.similarity), int(item.metadata.get("ordinal", 0)), item.id),
)
first = ordered[0]
metadata = first.metadata
citation = metadata.get("source_uri")
document_id = metadata.get("document_id")
evidence_kind = metadata.get("evidence_kind")
if not all(isinstance(value, str) and value for value in (citation, document_id, evidence_kind)):
raise VectorStoreError("Evidence search returned malformed payload")
results.append(EvidenceResult(
evidence_id=evidence_id,
title=str(first.title),
kind=evidence_kind,
excerpts=tuple(str(item.content) for item in ordered),
provenance=dict(metadata.get("provenance", {})),
citation=citation,
document_id=document_id,
score=float(first.similarity),
))
return tuple(sorted(results, key=lambda item: (-item.score, item.evidence_id)))
def search_evidence(
query: str,
purpose: EvidencePurpose,
context: EvidenceSearchContext,
*,
searcher,
embedder: EvidenceQueryEmbedder,
top_n: int = 10,
) -> EvidenceSearchOutcome:
"""Search the active generation once, with no stale-generation or purpose fallback."""
try:
rendered = render_evidence_query(query, context)
generation = _active_vector_generation(searcher)
if generation is None:
return EvidenceSearchOutcome.unavailable("active_corpus_unavailable", "Active Evidence corpus is unavailable")
query_embedding = embedder.embed_query(rendered)
hits = searcher.search(
query_embedding,
top_n=top_n,
kinds=["evidence"],
query_text=rendered,
metadata_filter=_required_metadata_filter(purpose, context),
)
return EvidenceSearchOutcome("available", generation, _group_evidence_fragments(hits))
except VectorReadUnavailable:
return EvidenceSearchOutcome.unavailable("vector_unavailable", "Evidence vector search is unavailable")
except (VectorStoreError, CorpusWorkspaceMismatchError):
return EvidenceSearchOutcome.unavailable("evidence_search_unavailable", "Evidence search is unavailable")
class ActiveEvidenceSearcher:
"""Searcher facade that enforces ACTIVE generation predicates before LIMIT."""
@@ -78,15 +238,17 @@ class ActiveEvidenceSearcher:
if by_generation and (not isinstance(query_text, str) or query_text.strip() == ""):
raise VectorStoreError("Evidence hybrid query text is required")
for generation, document_ids in sorted(by_generation.items()):
filters = dict(metadata_filter or {})
filters.update({
"vector_generation": generation,
"document_ids": sorted(document_ids),
"workspace_id": workspace_id,
})
hits.extend(self.delegate.search(
embedding, top_n=top_n, kinds=["evidence"],
query_text=query_text,
query_language=query_language or self.evidence_language,
metadata_filter={
"vector_generation": generation,
"document_ids": sorted(document_ids),
"workspace_id": workspace_id,
},
metadata_filter=filters,
))
return sorted(hits, key=lambda hit: (-hit.similarity, hit.id))[:top_n]
@@ -144,9 +306,12 @@ def build_retrieval_entries(results, *, excerpt_chars: int) -> list[dict]:
"""Project ordered Evidence search hits into the retrieval-pack shape."""
return [
{
"title": result.label,
"status": result.status,
"excerpt": result.content[:excerpt_chars],
"title": getattr(result, "title", getattr(result, "label", "")),
"status": getattr(result, "status", None),
"excerpt": (
result.excerpts[0] if isinstance(result, EvidenceResult) and result.excerpts
else getattr(result, "content", "")
)[:excerpt_chars],
}
for result in results
]
@@ -155,8 +320,14 @@ def build_retrieval_entries(results, *, excerpt_chars: int) -> list[dict]:
__all__ = [
"ActiveEvidenceSearcher",
"CorpusWorkspaceMismatchError",
"EvidenceQueryEmbedder",
"EvidenceResult",
"EvidenceSearchContext",
"EvidenceSearchOutcome",
"active_searcher",
"build_retrieval_entries",
"render_evidence_query",
"resolve_citation",
"search_evidence",
"validate_corpus_workspace",
]
+33 -1
View File
@@ -1,5 +1,7 @@
"""Evidence-specific projection into persisted session artifacts."""
import json
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
@@ -56,4 +58,34 @@ def project_session(
return list(entries.values())
__all__ = ["project_session"]
@dataclass(frozen=True)
class EvidenceReceipt:
stage: str
purpose: str
vector_generation: str
evidence_ids: tuple[str, ...]
def payload(self) -> dict:
return {
"stage": self.stage,
"purpose": self.purpose,
"vector_generation": self.vector_generation,
"evidence_ids": list(self.evidence_ids),
}
def replace_evidence_receipt(repository, session_id: str, receipt: EvidenceReceipt) -> None:
"""Replace the one minimal receipt for a semantic stage; preserve other stages."""
current = repository.read_artifact(session_id, "evidence_receipts")
try:
receipts = json.loads(current) if current else []
except json.JSONDecodeError as error:
raise ValueError("Evidence receipts artifact is malformed") from error
if not isinstance(receipts, list):
raise TypeError("Evidence receipts artifact is malformed")
replaced = [item for item in receipts if isinstance(item, dict) and item.get("stage") != receipt.stage]
replaced.append(receipt.payload())
repository.write_artifact(session_id, "evidence_receipts", json.dumps(replaced, ensure_ascii=False, indent=2) + "\n")
__all__ = ["EvidenceReceipt", "project_session", "replace_evidence_receipt"]