diff --git a/harness/.pi/skills/tht-sessione/SKILL.md b/harness/.pi/skills/tht-sessione/SKILL.md index 6d82885a..297dbbc2 100644 --- a/harness/.pi/skills/tht-sessione/SKILL.md +++ b/harness/.pi/skills/tht-sessione/SKILL.md @@ -348,6 +348,14 @@ Prerequisite: Phase 3 closed. ""` (or derive it from the evidence/context), present it, and let the reviewer approve/reject (`concept_formula_approved`/`concept_formula_rejected`). Reflect the approved formula in `schema_linking.json` (`concept_formulas`). +4. **Formula proposals.** A `kind=formula` result from Evidence search is Published +Evidence and can be cited with its provenance. If no published formula is suitable and +you synthesize one for this question, present it to the reviewer and, after their F4 +decision, include `{concept, columns, sql, sources}` in `concept_formulas`. This creates +a schema-versioned, **session-only Formula proposal**: it helps this session but is not +Published Evidence, has no `evidence:` ID, is not returned by runtime search, and never +writes to the workspace repository. A curator must separately import, review, and +publish it before another session can treat it as Evidence. 5. Persist the **joins** (and any `concept_formulas`/`open_questions`) with the gate's `write_schema_linking` tool — it validates the object against the `SchemaLinking` model and writes the file deterministically (never hand-write it, never edit it diff --git a/harness/.pi/skills/tht-sessione/modules/evidence/formula-proposals.md b/harness/.pi/skills/tht-sessione/modules/evidence/formula-proposals.md new file mode 100644 index 00000000..2b80ce6f --- /dev/null +++ b/harness/.pi/skills/tht-sessione/modules/evidence/formula-proposals.md @@ -0,0 +1,8 @@ +4. **Formula proposals.** A `kind=formula` result from Evidence search is Published +Evidence and can be cited with its provenance. If no published formula is suitable and +you synthesize one for this question, present it to the reviewer and, after their F4 +decision, include `{concept, columns, sql, sources}` in `concept_formulas`. This creates +a schema-versioned, **session-only Formula proposal**: it helps this session but is not +Published Evidence, has no `evidence:` ID, is not returned by runtime search, and never +writes to the workspace repository. A curator must separately import, review, and +publish it before another session can treat it as Evidence. diff --git a/harness/.pi/skills/tht-sessione/projection.md.tmpl b/harness/.pi/skills/tht-sessione/projection.md.tmpl index 59789b54..acee16d2 100644 --- a/harness/.pi/skills/tht-sessione/projection.md.tmpl +++ b/harness/.pi/skills/tht-sessione/projection.md.tmpl @@ -216,6 +216,7 @@ Prerequisite: Phase 3 closed. those to joins you derive yourself, and flag to the reviewer any join you need that is NOT in the list. {{DISAMBIGUATION_SCHEMA_GROUNDING}} +{{EVIDENCE_FORMULA_PROPOSALS}} 5. Persist the **joins** (and any `concept_formulas`/`open_questions`) with the gate's `write_schema_linking` tool — it validates the object against the `SchemaLinking` model and writes the file deterministically (never hand-write it, never edit it diff --git a/harness/tests/test_evidence_formula_migration.py b/harness/tests/test_evidence_formula_migration.py new file mode 100644 index 00000000..dd266c3f --- /dev/null +++ b/harness/tests/test_evidence_formula_migration.py @@ -0,0 +1,26 @@ +"""Migration boundary: legacy formulas become curated evidence or session proposals.""" + +from tht.evidence import formula_store +from tht.evidence.formula_store import ConceptFormula + + +def test_reviewed_formula_migration_has_deterministic_provenance_hash(): + formula = ConceptFormula( + concept="fascia pediatrica", + columns=["clinical.patient.birth_date"], + sql="CASE WHEN age < 18 THEN 'pediatrica' ELSE 'adulta' END", + status="reviewed", + sources=["Regola clinica approvata dal gruppo pediatrico."], + ) + + first = formula_store.legacy_formula_to_curated( + formula, legacy_path="formulas/fascia-pediatrica-1.sql.md", + ) + second = formula_store.legacy_formula_to_curated( + formula, legacy_path="formulas/fascia-pediatrica-1.sql.md", + ) + + assert first is not None + assert second is not None + assert first.provenance.source_sha256 == second.provenance.source_sha256 + assert first.provenance.source_sha256.startswith("sha256:") diff --git a/harness/tests/test_formula.py b/harness/tests/test_formula.py index 006f22ee..700809c5 100644 --- a/harness/tests/test_formula.py +++ b/harness/tests/test_formula.py @@ -8,7 +8,13 @@ part of the schema-linking artifact. The store is frontmatter-YAML + SQL body. """ +from datetime import UTC, datetime + +from tht.decisions import DecisionRecord +from tht.evidence import formula_store from tht.evidence.formula_store import ConceptFormula, retrieve_formula, save_formula +from tht.evidence.session import project_session +from tht.session.models import SchemaLinking def test_formula_retrieval_by_concept(tmp_path): @@ -82,3 +88,63 @@ def test_concept_formula_default_status(tmp_path): f = ConceptFormula(concept="x", columns=["c"], sql="SELECT 1") assert f.status == "draft" # not yet reviewed assert f.sources == [] + + +def test_reviewed_legacy_formula_becomes_curated_formula_with_stable_provenance(): + formula = ConceptFormula( + concept="fascia pediatrica", + columns=["clinical.patient.birth_date"], + sql="CASE WHEN age < 18 THEN 'pediatrica' ELSE 'adulta' END", + status="reviewed", + sources=["Regola clinica approvata dal gruppo pediatrico."], + ) + + migrated = formula_store.legacy_formula_to_curated( + formula, legacy_path="formulas/fascia-pediatrica-1.sql.md", + ) + + assert migrated is not None + assert migrated.id == "evidence:fascia-pediatrica" + assert migrated.title == "Fascia pediatrica" + assert migrated.kind == "formula" + assert migrated.payload.concept == formula.concept + assert migrated.payload.columns == ("clinical.patient.birth_date",) + assert migrated.payload.sql == formula.sql + assert migrated.provenance.source_file == "source/formulas/fascia-pediatrica-1.sql.md" + assert migrated.provenance.supporting_excerpts == tuple(formula.sources) + assert migrated.review_items == () + assert formula_store.legacy_formula_to_curated( + formula, legacy_path="formulas/fascia-pediatrica-1.sql.md", + ).id == migrated.id + + +def test_session_formula_proposal_is_versioned_and_is_not_published_evidence(tmp_path): + linking = SchemaLinking( + question="Conta i pazienti pediatrici", + concept_formulas=[{ + "concept": "fascia pediatrica", + "columns": ["clinical.patient.birth_date"], + "sql": "CASE WHEN age < 18 THEN 'pediatrica' ELSE 'adulta' END", + "sources": ["Sintetizzata nella sessione"], + }], + ) + decisions = [DecisionRecord( + seq=9, + ts=datetime(2026, 8, 25, tzinfo=UTC), + type="concept_formula_approved", + subject="phase:4", + detail="fascia pediatrica", + )] + + projected = project_session(decisions, linking, tmp_path / "evidence") + + assert projected == [{ + "schema_version": 1, + "kind": "formula_proposal", + "publication": "session_only", + "concept": "fascia pediatrica", + "columns": ["clinical.patient.birth_date"], + "sql": "CASE WHEN age < 18 THEN 'pediatrica' ELSE 'adulta' END", + "sources": ["Sintetizzata nella sessione"], + "decision_seq": 9, + }] diff --git a/harness/tests/test_formula_wiring.py b/harness/tests/test_formula_wiring.py index d670315a..6fb0dd0d 100644 --- a/harness/tests/test_formula_wiring.py +++ b/harness/tests/test_formula_wiring.py @@ -5,8 +5,16 @@ Completes the formula layer beyond the store: the `auto` status the spec require and the guarantee that load_evidence_dir does NOT choke on *.sql.md formula files when they live under the evidence root. """ +import json +from types import SimpleNamespace + +from typer.testing import CliRunner + +from tht.cli import app, search_cmd +from tht.evidence import formula_store from tht.evidence.formula_store import ConceptFormula, save_formula, search_formulas from tht.evidence.model import EvidenceDoc, load_evidence_dir +from tht.evidence.search import EvidenceSearchOutcome def test_status_auto_is_valid(): @@ -35,3 +43,77 @@ def test_load_evidence_dir_skips_formula_files(tmp_path): ids = [d.id for d in docs] assert ids == ["ev1"] # the .sql.md formula file is skipped, no crash assert all(isinstance(d, EvidenceDoc) for d in docs) + + +def test_unreviewed_legacy_formulas_cannot_become_curated_evidence(): + for status in ("auto", "draft"): + formula = ConceptFormula( + concept="fascia pediatrica", + columns=["clinical.patient.birth_date"], + sql="CASE WHEN age < 18 THEN 'pediatrica' ELSE 'adulta' END", + status=status, + ) + assert formula_store.legacy_formula_to_curated( + formula, legacy_path="formulas/pediatric-1.sql.md", + ) is None + + +def test_formula_search_uses_typed_evidence_with_a_formula_constraint(): + 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:formula", similarity=0.9, content="formula excerpt", + title="Fascia pediatrica", metadata={ + "evidence_id": "evidence:fascia-pediatrica", + "evidence_kind": "formula", + "document_id": "doc:formula", + "ordinal": 0, + "source_uri": "file:///curated/formula/fascia-pediatrica.md", + "provenance": {}, + }, + )] + + class Embedder: + def embed_query(self, query): + return [0.25] + + searcher = Searcher() + outcome = search_cmd.search_formula_evidence( + "fascia pediatrica", searcher=searcher, embedder=Embedder(), top=3, + ) + + assert outcome.status == "available" + assert [result.evidence_id for result in outcome.results] == ["evidence:fascia-pediatrica"] + assert searcher.calls[0][1]["metadata_filter"]["required_kinds"] == ["formula"] + + +def test_formula_search_json_is_pristine_while_human_output_warns_about_legacy_store(monkeypatch): + monkeypatch.setattr(search_cmd, "_load_config_or_exit", lambda _path: SimpleNamespace(embeddings=object())) + monkeypatch.setattr(search_cmd, "workspace_id_for_config", lambda _cfg, _path: "workspace-a") + monkeypatch.setattr(search_cmd, "search_formula_evidence", lambda *args, **kwargs: EvidenceSearchOutcome( + "available", "gen:" + "a" * 32, + )) + monkeypatch.setattr("tht.cli.vector_cmd.require_vector_cfg", lambda _cfg: None) + monkeypatch.setattr("tht.cli.vector_cmd.open_searcher", lambda _cfg: object()) + monkeypatch.setattr("tht.cli.vector_cmd.make_embedder", lambda _cfg: object()) + monkeypatch.setattr("tht.evidence.active_searcher", lambda *args, **kwargs: object()) + monkeypatch.setattr("tht.evidence.validate_corpus_workspace", lambda _cfg, _workspace: None) + + json_result = CliRunner().invoke( + app, ["search", "find", "fascia pediatrica", "--kind", "formula", "--top", "1", "--json"], + ) + human_result = CliRunner().invoke( + app, ["search", "find", "fascia pediatrica", "--kind", "formula", "--top", "1"], + ) + + assert json_result.exit_code == 0 + assert json.loads(json_result.stdout) == [] + assert "ATTENZIONE" not in json_result.stdout + assert human_result.exit_code == 0 + assert "ATTENZIONE" in human_result.output diff --git a/harness/tests/test_pi_skill_projection.py b/harness/tests/test_pi_skill_projection.py index bd40e50f..8ab5e5c2 100644 --- a/harness/tests/test_pi_skill_projection.py +++ b/harness/tests/test_pi_skill_projection.py @@ -9,7 +9,7 @@ from tht.pi_skill_projection import ( render_projection, ) -BASELINE_SHA256 = "bb6daa6fe83d22f5e701025c5334d72ec9eb35349e90d24cb9d7f6290d0fecfe" +BASELINE_SHA256 = "62bfa0dbc1179b43b2d80dc48155a6119421488a1ffc160e9c664f1fe280ce52" def test_modular_pi_skill_renders_the_byte_identical_approved_projection(): @@ -23,6 +23,7 @@ def test_modular_pi_skill_renders_the_byte_identical_approved_projection(): ("{{DISAMBIGUATION_REWRITING_INSTRUCTIONS}}", "disambiguation/phase-3.md"), ("{{MEMORY_SOLVED_SEARCH_F4}}", "memory/solved-search-f4.md"), ("{{DISAMBIGUATION_SCHEMA_GROUNDING}}", "disambiguation/schema-grounding.md"), + ("{{EVIDENCE_FORMULA_PROPOSALS}}", "evidence/formula-proposals.md"), ("{{MEMORY_SOLVED_SEARCH_F6}}", "memory/solved-search-f6.md"), ("{{MEMORY_SOLVED_SEARCH_F7}}", "memory/solved-search-f7.md"), ("{{MEMORY_PROMOTION_F8}}", "memory/phase-8-promotion.md"), diff --git a/harness/tht/cli/search_cmd.py b/harness/tht/cli/search_cmd.py index 90f9b0bc..cc2f4c85 100644 --- a/harness/tht/cli/search_cmd.py +++ b/harness/tht/cli/search_cmd.py @@ -11,7 +11,7 @@ KIND_MAP = { "evidence": ["evidence"], "schema": ["schema_table", "schema_column"], "values": [], # solo LSH - "formula": [], # solo formula store (D14b), niente LSH/vector + "formula": ["evidence"], } _STAGE_PURPOSES = { @@ -30,6 +30,20 @@ DEFAULT_TOP_FALLBACK = 10 search_app = typer.Typer(help="Ricerca semantica (evidence/schema/values) nel vectorstore") +def search_formula_evidence(keyword: str, *, searcher, embedder, top: int): + """Search only published Formula Evidence through the shared typed facade.""" + from tht.evidence import EvidenceSearchContext, search_evidence + + return search_evidence( + keyword, + "sql_generation", + EvidenceSearchContext(required_kinds=("formula",)), + searcher=searcher, + embedder=embedder, + top_n=top, + ) + + def _leased_dwh_snapshot(cfg, context: typer.Context): from tht.jobs.dwh_pipeline import lease_dwh_snapshot @@ -143,12 +157,6 @@ def search_cmd( workspace_id = workspace_id_for_config(cfg, config) validate_corpus_workspace(cfg, workspace_id) - dwh_snapshot = _leased_dwh_snapshot(cfg, ctx) - require_vector_cfg(cfg) - runtime_searcher = active_searcher( - cfg, open_searcher(cfg), - workspace_id=workspace_id, - ) if kind is not None and kind not in KIND_MAP: typer.secho( f"ERRORE: --kind sconosciuto: {kind} (validi: {', '.join(KIND_MAP)})", @@ -160,29 +168,60 @@ def search_cmd( top = cfg.search.top_schema_tables if kind == "schema" else DEFAULT_TOP_FALLBACK if kind == "formula": - # D14b: recupero formule di concetto dallo store locale (niente LSH/vector). - from tht.cli.evidence_cmd import evidence_root - from tht.evidence.formula_store import search_formulas - - formulas = search_formulas(evidence_root(cfg), keyword)[:top] + require_vector_cfg(cfg) + outcome = search_formula_evidence( + keyword, + searcher=active_searcher(cfg, open_searcher(cfg), workspace_id=workspace_id), + embedder=make_embedder(cfg.embeddings), + top=top, + ) + if outcome.status == "unavailable": + payload = {"status": outcome.status, "code": outcome.code, "message": outcome.message} + if json_out: + typer.echo(json.dumps(payload, ensure_ascii=False)) + else: + typer.secho(f"ERRORE: {outcome.message}", fg=typer.colors.RED, err=True) + raise typer.Exit(1) if json_out: - typer.echo(json.dumps( - [f.model_dump(mode="json") for f in formulas], ensure_ascii=False, indent=2)) + typer.echo(json.dumps([ + { + "evidence_id": result.evidence_id, + "title": result.title, + "kind": result.kind, + "excerpts": list(result.excerpts), + "provenance": result.provenance, + "citation": result.citation, + "document_id": result.document_id, + } + for result in outcome.results + ], ensure_ascii=False, indent=2)) return - if not formulas: + typer.secho( + "ATTENZIONE: lo store formule legacy non viene più consultato; " + "sono disponibili solo Formula Evidence pubblicate.", + fg=typer.colors.YELLOW, + err=True, + ) + if not outcome.results: typer.secho(f"Nessuna formula per '{keyword}'.", fg=typer.colors.YELLOW) return - table = Table(title=f"Formule per '{keyword}'") - table.add_column("Concetto") - table.add_column("Status") - table.add_column("Colonne") - table.add_column("SQL") - for f in formulas: - sql_preview = (f.sql[:80] + "…") if len(f.sql) > 80 else f.sql - table.add_row(f.concept, f.status, ", ".join(f.columns), sql_preview) + table = Table(title=f"Formula Evidence per '{keyword}'") + table.add_column("Formula") + table.add_column("Provenienza") + table.add_column("Estratto") + for result in outcome.results: + excerpt = result.excerpts[0] if result.excerpts else "" + table.add_row(result.title, result.citation, excerpt[:120]) Console().print(table) return + dwh_snapshot = _leased_dwh_snapshot(cfg, ctx) + require_vector_cfg(cfg) + runtime_searcher = active_searcher( + cfg, open_searcher(cfg), + workspace_id=workspace_id, + ) + lsh_hits = None try: lsh, minhashes, meta = load_index( diff --git a/harness/tht/evidence/formula_store.py b/harness/tht/evidence/formula_store.py index 3c4cd53e..cd020fc0 100644 --- a/harness/tht/evidence/formula_store.py +++ b/harness/tht/evidence/formula_store.py @@ -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: /formulas/-.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, + }, + }) diff --git a/harness/tht/evidence/session.py b/harness/tht/evidence/session.py index bce7fb43..d74d35f0 100644 --- a/harness/tht/evidence/session.py +++ b/harness/tht/evidence/session.py @@ -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) diff --git a/harness/tht/pi_skill_projection.py b/harness/tht/pi_skill_projection.py index d5e49f63..9c6249be 100644 --- a/harness/tht/pi_skill_projection.py +++ b/harness/tht/pi_skill_projection.py @@ -19,6 +19,7 @@ FRAGMENT_ORDER = ( ("{{DISAMBIGUATION_REWRITING_INSTRUCTIONS}}", "disambiguation/phase-3.md"), ("{{MEMORY_SOLVED_SEARCH_F4}}", "memory/solved-search-f4.md"), ("{{DISAMBIGUATION_SCHEMA_GROUNDING}}", "disambiguation/schema-grounding.md"), + ("{{EVIDENCE_FORMULA_PROPOSALS}}", "evidence/formula-proposals.md"), ("{{MEMORY_SOLVED_SEARCH_F6}}", "memory/solved-search-f6.md"), ("{{MEMORY_SOLVED_SEARCH_F7}}", "memory/solved-search-f7.md"), ("{{MEMORY_PROMOTION_F8}}", "memory/phase-8-promotion.md"),