feat(cli): tht memory solved-index / solved-search (question->SQL exemplars)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 13:43:52 +02:00
co-authored by Claude Fable 5
parent c0324c127a
commit 1f429b6b35
3 changed files with 186 additions and 0 deletions
+55
View File
@@ -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)
+106
View File
@@ -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)
+25
View File
@@ -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()),
)