From 1f429b6b35e2cc5844337ae67a883f2a2d6b9c8f Mon Sep 17 00:00:00 2001 From: mptyl Date: Tue, 7 Jul 2026 13:43:52 +0200 Subject: [PATCH] feat(cli): tht memory solved-index / solved-search (question->SQL exemplars) Co-Authored-By: Claude Fable 5 --- harness/tests/test_solved_build.py | 55 +++++++++++++++ harness/tht/cli/memory_cmd.py | 106 +++++++++++++++++++++++++++++ harness/tht/solved.py | 25 +++++++ 3 files changed, 186 insertions(+) create mode 100644 harness/tests/test_solved_build.py diff --git a/harness/tests/test_solved_build.py b/harness/tests/test_solved_build.py new file mode 100644 index 00000000..2925f297 --- /dev/null +++ b/harness/tests/test_solved_build.py @@ -0,0 +1,55 @@ +"""L1: build del record solved_question dagli artefatti persistiti della sessione. + +Il record si costruisce SOLO da cio' che il workflow ha approvato: sql_final.sql +presente + decisione sql_approved nella vista effective; la domanda e' l'ultima +question_rewritten (fallback: la domanda del manifest).""" +from datetime import datetime + +import pytest + +from tht.decisions import append_decision +from tht.session.models import SessionManifest +from tht.solved import SolvedIndexError, build_solved_record + + +def _manifest() -> SessionManifest: + return SessionManifest( + id="s1", created_at=datetime(2026, 1, 1), question="domanda originale", + database="db", schema="public", + ) + + +def test_build_uses_rewritten_question_sql_and_tables(tmp_path): + (tmp_path / "sql_final.sql").write_text("SELECT 1\n") + append_decision(tmp_path, type="question_rewritten", subject="domanda", + detail="domanda riscritta esplicita") + append_decision(tmp_path, type="sql_approved", subject="phase:7") + for n in range(1, 8): + append_decision(tmp_path, type="phase_approved", subject=f"phase:{n}") + rec = build_solved_record(tmp_path, _manifest(), {"fact_x", "dim_y"}) + assert rec.id == "solved:s1" + assert rec.content == "domanda riscritta esplicita" + assert rec.metadata["sql"] == "SELECT 1" + assert rec.metadata["tables"] == ["dim_y", "fact_x"] # ordinate + + +def test_build_falls_back_to_manifest_question(tmp_path): + (tmp_path / "sql_final.sql").write_text("SELECT 1") + append_decision(tmp_path, type="sql_approved", subject="phase:7") + for n in range(1, 8): + append_decision(tmp_path, type="phase_approved", subject=f"phase:{n}") + rec = build_solved_record(tmp_path, _manifest(), None) + assert rec.content == "domanda originale" + assert rec.metadata["tables"] == [] + + +def test_build_requires_sql_file(tmp_path): + append_decision(tmp_path, type="sql_approved", subject="phase:7") + with pytest.raises(SolvedIndexError, match="sql_final.sql"): + build_solved_record(tmp_path, _manifest(), None) + + +def test_build_requires_sql_approved(tmp_path): + (tmp_path / "sql_final.sql").write_text("SELECT 1") + with pytest.raises(SolvedIndexError, match="sql_approved"): + build_solved_record(tmp_path, _manifest(), None) diff --git a/harness/tht/cli/memory_cmd.py b/harness/tht/cli/memory_cmd.py index 1faf0ea1..f4e1a6be 100644 --- a/harness/tht/cli/memory_cmd.py +++ b/harness/tht/cli/memory_cmd.py @@ -441,3 +441,109 @@ def search_cmd( table.add_row(r["id"], r["type"], r["subject"], r["question_context"][:60], f"{r['score']:.3f}") Console().print(table) + + +def index_solved_session(cfg, session_id: str) -> int: + """Indicizza la coppia domanda->SQL della sessione (kind solved_question). + + Solleva RuntimeError se manca la writer key e SolvedIndexError se mancano gli + artefatti: il finalize li degrada a warning, il comando CLI li converte in + errori espliciti.""" + from tht.cli.sql_cmd import promoted_tables_for + from tht.cli.vector_cmd import make_embedder + from tht.solved import build_solved_record, save_solved_question + from tht.vectorstore.rest_client import VectorRestClient + + if not has_vector_write_rest(cfg): + raise RuntimeError( + "vector_write_rest assente: la coppia domanda->SQL si indicizza con la " + "writer key (workstation) o dal server" + ) + manifest = load_session_or_exit(cfg, session_id) + record = build_solved_record( + session_dir(cfg, session_id), manifest, promoted_tables_for(cfg, session_id) + ) + return save_solved_question( + record, + writer=VectorRestClient(cfg.vector_write_rest), + embedder=make_embedder(cfg.embeddings), + ) + + +@memory_app.command("solved-index") +def solved_index_cmd( + session_id: str = typer.Argument(..., help="Id sessione con sql_final.sql approvato."), + json_out: bool = typer.Option(False, "--json", help="Output JSON (per Pi)."), + config: Path = CONFIG_OPT, +) -> None: + """Indicizza la coppia domanda->SQL nel vectordb (backfill; il finalize lo fa da solo).""" + import json as _json + + from tht.solved import SolvedIndexError + + cfg = _load_config_or_exit(config) + require_vector_write_allowed(cfg, "memory solved-index") + try: + count = index_solved_session(cfg, session_id) + except RuntimeError as e: + typer.secho(f"ERRORE: {e}", fg=typer.colors.RED, err=True) + raise typer.Exit(code=4) + except SolvedIndexError as e: + typer.secho(f"ERRORE: sessione {session_id} non indicizzabile: {e}", + fg=typer.colors.RED, err=True) + raise typer.Exit(code=3) + msg = ( + f"1 coppia domanda->SQL indicizzata (solved:{session_id})." + if count else "Nessun upsert: coppia gia' aggiornata." + ) + if json_out: + typer.echo(_json.dumps({"upserted": count, "id": f"solved:{session_id}"}, + ensure_ascii=False)) + return + typer.secho(f"OK: {msg}", fg=typer.colors.GREEN) + + +@memory_app.command("solved-search") +def solved_search_cmd( + question: str = typer.Argument(..., help="Domanda da confrontare con quelle risolte."), + top: int = typer.Option(3, "--top"), + json_out: bool = typer.Option(False, "--json", help="Output JSON (per Pi)."), + config: Path = CONFIG_OPT, +) -> None: + """Domande gia' risolte simili (kind solved_question): domanda, SQL e tabelle.""" + from rich.console import Console + from rich.table import Table + + from tht.cli.vector_cmd import make_embedder, open_searcher + from tht.solved import SOLVED_KIND + + cfg = _load_config_or_exit(config) + require_vector_cfg(cfg) + searcher = open_searcher(cfg) + embedder = make_embedder(cfg.embeddings) + hits = searcher.search(embedder.embed_query(question), top_n=top, kinds=[SOLVED_KIND]) + results = [ + { + "session_id": h.metadata.get("session_id", h.ref), + "question": h.metadata.get("question", h.content), + "sql": h.metadata.get("sql", ""), + "tables": h.metadata.get("tables", []), + "score": round(h.similarity, 4), + } + for h in hits + ] + if json_out: + typer.echo(json.dumps(results, ensure_ascii=False, indent=2)) + return + if not results: + typer.secho("Nessuna domanda risolta simile.", fg=typer.colors.YELLOW) + return + table = Table(title=f"Domande risolte simili a: {question}") + table.add_column("Sessione") + table.add_column("Domanda") + table.add_column("Tabelle") + table.add_column("Score", justify="right") + for r in results: + table.add_row(r["session_id"], r["question"][:60], + ", ".join(r["tables"]), f"{r['score']:.3f}") + Console().print(table) diff --git a/harness/tht/solved.py b/harness/tht/solved.py index 88b2b0e2..96de27e5 100644 --- a/harness/tht/solved.py +++ b/harness/tht/solved.py @@ -62,3 +62,28 @@ def save_solved_question(record: VectorRecord, *, writer, embedder) -> int: "metadata": pack_metadata(record), "embedding": embedding, }]) + + +class SolvedIndexError(Exception): + """La sessione non ha (ancora) gli artefatti per il record solved_question.""" + + +def build_solved_record(session_dir, manifest, promoted_tables) -> VectorRecord: + """Costruisce il record dagli artefatti persistiti (vista effective D15): + richiede sql_final.sql e la decisione sql_approved; la domanda e' l'ultima + question_rewritten, fallback la domanda del manifest.""" + from tht.memory import question_context + from tht.phase import effective_decisions + + sql_file = session_dir / "sql_final.sql" + if not sql_file.exists(): + raise SolvedIndexError("sql_final.sql assente") + decisions = effective_decisions(session_dir) + if not any(d.type == "sql_approved" for d in decisions): + raise SolvedIndexError("decisione sql_approved assente") + return solved_question_record( + session_id=manifest.id, + question=question_context(decisions, manifest), + sql=sql_file.read_text().strip(), + tables=sorted(promoted_tables or set()), + )