From 00365cc6e66f66d318d6915fedcd1d0fd122d815 Mon Sep 17 00:00:00 2001 From: mptyl Date: Mon, 6 Jul 2026 19:01:28 +0200 Subject: [PATCH] feat(tht): sync-schema-linking projects F4 ledger into schema_linking.json Co-Authored-By: Claude Opus 4.8 --- harness/tests/test_sync_schema_linking.py | 63 +++++++++++++++++++++++ harness/tht/cli/session_cmd.py | 14 +++++ harness/tht/session/store.py | 45 ++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 harness/tests/test_sync_schema_linking.py diff --git a/harness/tests/test_sync_schema_linking.py b/harness/tests/test_sync_schema_linking.py new file mode 100644 index 00000000..2c42b204 --- /dev/null +++ b/harness/tests/test_sync_schema_linking.py @@ -0,0 +1,63 @@ +"""Tests for the F4 ledger -> schema_linking.json deterministic projection.""" +import json + +from tht.config import DatabaseConfig +from tht.decisions import append_decision +from tht.session.store import create_session, set_schema_linking, sync_schema_linking + + +def _db(): + return DatabaseConfig( + database="testdb", user="u", password="p", # noqa: S106 + **{"schema": "public"}, + ) + + +def test_projection_from_ledger(tmp_path): + m = create_session("domanda X", _db(), tmp_path) + sid = m.id + sdir = tmp_path / sid + # seed a prior schema_linking with a join to prove joins are preserved + set_schema_linking(sid, {"question": "domanda X", "candidates": [], "joins": [ + {"from": "dim_patient.cod_paz", "to": "fact_x.cod_paz"}], "excluded": []}, tmp_path) + append_decision(sdir, type="table_promoted", subject="dim_patient") + append_decision(sdir, type="column_promoted", subject="dim_patient.cod_paz") + append_decision(sdir, type="column_promoted", subject="dim_patient.nome") + append_decision(sdir, type="table_excluded", subject="fact_sost") + + sync_schema_linking(sid, tmp_path) + + data = json.loads((sdir / "schema_linking.json").read_text()) + tabs = {(c["kind"], c["name"], c["decision"]) for c in data["candidates"]} + assert ("table", "dim_patient", "promoted") in tabs + assert ("column", "dim_patient.cod_paz", "promoted") in tabs + assert ("column", "dim_patient.nome", "promoted") in tabs + assert {"kind": "table", "name": "fact_sost"} in [ + {"kind": e["kind"], "name": e["name"]} for e in data["excluded"]] + assert data["joins"], "existing joins must be preserved" + + +def test_projection_last_decision_wins(tmp_path): + m = create_session("domanda Y", _db(), tmp_path) + sid = m.id + sdir = tmp_path / sid + append_decision(sdir, type="table_promoted", subject="dim_x") + append_decision(sdir, type="table_excluded", subject="dim_x") + + sync_schema_linking(sid, tmp_path) + + data = json.loads((sdir / "schema_linking.json").read_text()) + assert data["candidates"] == [] + assert [e["name"] for e in data["excluded"]] == ["dim_x"] + + +def test_projection_defaults_question_from_manifest(tmp_path): + m = create_session("domanda Z", _db(), tmp_path) + sid = m.id + + path = sync_schema_linking(sid, tmp_path) + + data = json.loads(path.read_text()) + assert data["question"] == "domanda Z" + assert data["candidates"] == [] + assert data["excluded"] == [] diff --git a/harness/tht/cli/session_cmd.py b/harness/tht/cli/session_cmd.py index ff21568a..a2b5d314 100644 --- a/harness/tht/cli/session_cmd.py +++ b/harness/tht/cli/session_cmd.py @@ -141,6 +141,20 @@ def set_schema_linking_cmd( typer.secho(f"OK: schema_linking.json aggiornato ({path}).", fg=typer.colors.GREEN) +@session_app.command("sync-schema-linking") +def sync_schema_linking_cmd( + session_id: str = typer.Argument(...), + config: Path = CONFIG_OPT, +) -> None: + """Riproietta schema_linking.json dalle decisioni F4 del ledger (deterministico).""" + from tht.session.store import sync_schema_linking + + cfg = _load_config_or_exit(config) + load_session_or_exit(cfg, session_id) + path = sync_schema_linking(session_id, cfg.paths.sessions) + typer.secho(f"OK: schema_linking.json riproiettato ({path}).", fg=typer.colors.GREEN) + + @session_app.command("show") def show_cmd( session_id: str = typer.Argument(...), diff --git a/harness/tht/session/store.py b/harness/tht/session/store.py index 859f1de3..c51e6b69 100644 --- a/harness/tht/session/store.py +++ b/harness/tht/session/store.py @@ -181,6 +181,51 @@ def set_schema_linking( return path +def sync_schema_linking(session_id: str, sessions_root: Path) -> Path: + """Project the effective F4 ledger decisions into schema_linking.json. + + candidates/excluded are rebuilt from table_promoted/table_excluded + + column_promoted/column_excluded (last decision per subject wins). question, + joins, concept_formulas and open_questions are preserved from the existing + file when present. The reviewer's curation is thus authoritative and + deterministic (no model transcription).""" + from tht.phase import effective_decisions + + session_dir = sessions_root / session_id + manifest = load_session(session_id, sessions_root) + + existing: dict = {} + sl_path = session_dir / "schema_linking.json" + if sl_path.exists(): + existing = json.loads(sl_path.read_text()) + + # last decision per subject wins (handles a re-run of the gate). + latest: dict[str, str] = {} + for d in effective_decisions(session_dir): + if d.type in ("table_promoted", "table_excluded", "column_promoted", "column_excluded"): + latest[d.subject] = d.type + + candidates: list[dict] = [] + excluded: list[dict] = [] + for subject, dtype in latest.items(): + is_column = "." in subject + kind = "column" if is_column else "table" + if dtype in ("table_promoted", "column_promoted"): + candidates.append({"kind": kind, "name": subject, "decision": "promoted"}) + else: + excluded.append({"kind": kind, "name": subject}) + + data = { + "question": existing.get("question") or manifest.question, + "candidates": candidates, + "joins": existing.get("joins", []), + "excluded": excluded, + "open_questions": existing.get("open_questions", []), + "concept_formulas": existing.get("concept_formulas", []), + } + return set_schema_linking(session_id, data, sessions_root) + + def load_session(session_id: str, sessions_root: Path) -> SessionManifest: path = sessions_root / session_id / MANIFEST if not path.exists():