feat(evidence): contribute to semantic stages (#42)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user