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:
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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()),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user