refactor(evidence): unify formulas with typed evidence

This commit is contained in:
2026-08-25 01:23:04 +02:00
parent d5d65f3659
commit cc30148b69
11 changed files with 342 additions and 38 deletions
+47 -13
View File
@@ -1,17 +1,12 @@
"""SQL concept->formula evidence store (spec D14b, §4.7).
"""Legacy ConceptFormula reader and one-way migration into Curated Evidence.
A concept (e.g. 'fascia pediatrica', 'ablazione') maps to a reusable SQL formula
(a CASE WHEN ...) that derives it from physical columns. These are reviewable
units: the gate surfaces a candidate formula, the reviewer approves or rejects it
(decision types concept_formula_approved / concept_formula_rejected), and approved
formulas travel with the schema-linking artifact.
Storage: one file per formula, frontmatter YAML + SQL body (same shape as
EvidenceDoc.parse). Directory layout: <root>/formulas/<slug>-<n>.sql.md.
Retrieve is by concept (may return several, e.g. competing drafts vs reviewed).
The ``formulas/*.sql.md`` store is retained only for the migration window. Runtime
lookup uses typed, published ``kind=formula`` Evidence instead. A session reviewer
may still approve a formula locally; that is a proposal, not publication.
"""
from __future__ import annotations
import hashlib
import re
from pathlib import Path
from typing import Literal
@@ -19,6 +14,8 @@ from typing import Literal
import yaml
from pydantic import BaseModel
from tht.evidence.canonical import CuratedEvidence
FORMULAS_SUBDIR = "formulas"
_SUFFIX_RE = re.compile(r"^(.*?)-(\d+)\.sql\.md$")
@@ -104,8 +101,45 @@ def retrieve_formula(root: Path | str, concept: str) -> list[ConceptFormula]:
def search_formulas(root: Path | str, query: str) -> list[ConceptFormula]:
"""Formulas whose concept contains `query` (case-insensitive). Used by
`tht search find --kind formula` (D14b retrieval, §4.7.2): the reviewer searches a
concept term and gets the candidate formulas to approve before they reach the CTE."""
"""Read legacy formulas for migration tooling only (case-insensitive concept match)."""
q = query.strip().lower()
return [f for f in _load_all(Path(root)) if q in f.concept.lower()]
def legacy_formula_to_curated(
formula: ConceptFormula,
*,
legacy_path: str,
) -> CuratedEvidence | None:
"""Convert one reviewed legacy formula into its deterministic curated counterpart.
Drafts and model-generated formulas have no global publication status. Their
caller must project them as session-local Formula proposals instead.
"""
if formula.status != "reviewed":
return None
source_notes = tuple(formula.sources) or (
"Legacy formula migrated without a recorded provenance note.",
)
source_sha256 = hashlib.sha256(formula.dump().encode("utf-8")).hexdigest()
source_file = legacy_path if legacy_path.startswith("source/") else f"source/{legacy_path}"
return CuratedEvidence.model_validate({
"schema_version": 1,
"id": f"evidence:{formula._slug}",
"title": formula.concept[:1].upper() + formula.concept[1:],
"kind": "formula",
"purposes": ["schema_linking", "sql_generation"],
"applies_to": {"concepts": [formula.concept], "columns": formula.columns},
"language": "it",
"provenance": {
"source_file": source_file,
"source_sha256": f"sha256:{source_sha256}",
"supporting_excerpts": source_notes,
},
"review_items": [],
"payload": {
"concept": formula.concept,
"columns": formula.columns,
"sql": formula.sql,
},
})
+39 -1
View File
@@ -55,7 +55,45 @@ def project_session(
"esito": "accettata" if decision.type == "evidence_accepted" else "scartata",
"decision_seq": decision.seq,
}
return list(entries.values())
return list(entries.values()) + _formula_proposals(decisions, linking)
def _formula_proposals(decisions: list["DecisionRecord"], linking: "SchemaLinking") -> list[dict]:
"""Project locally approved F4 formulas without representing them as Evidence.
A proposal remains in the persisted session artifact until a separate curator
imports, reviews, and publishes it in the workspace repository.
"""
approved = {
decision.detail: decision.seq
for decision in decisions
if decision.type == "concept_formula_approved" and decision.detail
}
proposals = []
for formula in linking.concept_formulas:
if not isinstance(formula, dict):
continue
concept = formula.get("concept")
sql = formula.get("sql")
columns = formula.get("columns")
if not isinstance(concept, str) or not isinstance(sql, str) or not isinstance(columns, list):
continue
# A referenced published Formula Evidence is already represented by its
# Evidence receipt/citation, so it must not be reintroduced as a proposal.
evidence_id = formula.get("evidence_id", formula.get("id", ""))
if isinstance(evidence_id, str) and evidence_id.startswith("evidence:"):
continue
proposals.append({
"schema_version": 1,
"kind": "formula_proposal",
"publication": "session_only",
"concept": concept,
"columns": columns,
"sql": sql,
"sources": formula.get("sources", []),
"decision_seq": approved.get(concept),
})
return proposals
@dataclass(frozen=True)