feat(opt): three efficiency levers for NL→SQL workflow

Lever 1: Join-graph via FK logics in annotations + suggest-fks command
  - TableAnnotation.foreign_keys field stores curated logical FKs (DWH has no FK constraints)
  - tht schema suggest-fks: mine from approved SQL, heuristics (time_key → dim_time),
    same-name discovery + explicit --assume flag for multi-owner PKs
  - mschema renders 【Foreign keys】 section populated; validation in merge.py
  - SKILL.md F4 now reads FKs from mschema-text, no custom data_time_key logic

Lever 2: Context-pack consolidation at kickoff (tht search pack)
  - Single embedding of question, reused for schema + evidence + solved searches
  - One command: tht search pack <question> --session <id> → retrieval_pack.md
  - Graceful degradation when Ollama/vector store unreachable (exit 0, empty sections)
  - SKILL.md F1 prescribes as first call; reduces model thinking turns via pre-retrieval

Lever 3: Phase-summary recap v2 auto-construction from session ledger
  - tht session show --json includes full decisions ledger
  - tht phase meta --json exports 'emits' (substantive decision types per phase)
  - Gate appends deterministic 【Decisioni registrate in questa fase】 section (appendLedgerSection)
  - Model authors only summary + checks; recap table comes from persisted state (exact by construction)
  - SKILL.md Disciplina 6: brief model output, gate fills the rest

Tests: 358 Python (including 10 FK + 3 pack + 1 session-ledger tests) + 111 JS gate tests, all pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 17:43:08 +02:00
co-authored by Claude Fable 5
parent 87e875bc81
commit e24b41b156
19 changed files with 936 additions and 29 deletions
+153
View File
@@ -205,3 +205,156 @@ def search_cmd(
row.append((r.content[:120] + "…") if len(r.content) > 120 else r.content)
table.add_row(*row)
Console().print(table)
# Dimensioni fisse del pack (niente config: il pack deve restare piccolo perche'
# entra nel contesto del modello in un turno solo).
PACK_EVIDENCE_TOP = 5
PACK_SOLVED_TOP = 3
PACK_EXCERPT_CHARS = 400
@search_app.command("pack")
def pack_cmd(
question: str = typer.Argument(..., help="La domanda in linguaggio naturale."),
config: Path = CONFIG_OPT,
session: str = typer.Option(
None, "--session", help="Scrive il pack in sessions/<id>/retrieval_pack.md."
),
json_out: bool = typer.Option(False, "--json", help="Output JSON (per Pi)."),
) -> None:
"""Context-pack F1: tabelle candidate + evidence + domande risolte in UNA chiamata.
Un solo embedding della domanda, riusato per le tre ricerche vettoriali.
Degrado gentile: se Ollama/vectordb non rispondono, le sezioni restano vuote
con un'avvertenza (exit 0) — la sessione prosegue con le ricerche live.
"""
from sqlalchemy.exc import OperationalError
from tht.cli.vector_cmd import make_embedder, open_searcher, require_vector_cfg
from tht.search import combined_search, schema_tables
from tht.solved import SOLVED_KIND
from tht.vectorstore.embeddings import EmbeddingsError
from tht.vectorstore.rest_client import VectorRestError
cfg = _load_config_or_exit(config)
require_vector_cfg(cfg)
tables: list[dict] = []
evidence: list[dict] = []
solved: list[dict] = []
warnings: list[str] = []
degrade = (VectorRestError, EmbeddingsError, OperationalError)
vec = None
searcher = embedder = None
try:
searcher = open_searcher(cfg)
embedder = make_embedder(cfg.embeddings)
vec = embedder.embed_query(question)
except degrade as e:
warnings.append(f"retrieval non disponibile ({e}): prosegui con le ricerche live")
if vec is not None:
from tht.cli.schema_cmd import physical_path
descriptions: dict[str, str] = {}
phys_file = physical_path(cfg)
if phys_file.exists():
from tht.mschema.models import PhysicalSchema
phys = PhysicalSchema.from_yaml(phys_file)
descriptions = {t: tab.comment for t, tab in phys.tables.items()}
try:
cand = combined_search(
keyword=question, lsh_hits=None, store=searcher, embedder=embedder,
top=cfg.search.schema_chunk_pool, rrf_k=cfg.search.rrf_k,
kinds=KIND_MAP["schema"], query_vec=vec,
)
tables = [
{"name": n, "rrf": round(s, 6), "description": descriptions.get(n, "")}
for n, s in schema_tables(cand, top_tables=cfg.search.top_schema_tables)
]
except degrade as e:
warnings.append(f"ricerca schema fallita ({e})")
try:
ev = combined_search(
keyword=question, lsh_hits=None, store=searcher, embedder=embedder,
top=PACK_EVIDENCE_TOP, rrf_k=cfg.search.rrf_k,
kinds=KIND_MAP["evidence"], query_vec=vec,
)
evidence = [
{"title": r.label, "status": r.status,
"excerpt": r.content[:PACK_EXCERPT_CHARS]}
for r in ev
]
except degrade as e:
warnings.append(f"ricerca evidence fallita ({e})")
try:
hits = searcher.search(vec, top_n=PACK_SOLVED_TOP, kinds=[SOLVED_KIND])
solved = [
{
"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
]
except degrade as e:
warnings.append(f"solved-search fallita ({e})")
for w in warnings:
typer.secho(f"ATTENZIONE: {w}", fg=typer.colors.YELLOW, err=True)
md_lines = ["# Retrieval pack", "", f"Domanda: {question}", ""]
md_lines += [f"## Tabelle candidate (top {len(tables)}, vettoriale sull'intera domanda)", ""]
if tables:
for i, t in enumerate(tables, 1):
desc = f" — {t['description']}" if t["description"] else ""
md_lines.append(f"{i}. **{t['name']}**{desc} (rrf {t['rrf']})")
else:
md_lines.append("_nessuna (retrieval non disponibile o nessun match)_")
md_lines += ["", "## Evidence rilevanti", ""]
if evidence:
for e in evidence:
status = f" [{e['status']}]" if e["status"] else ""
md_lines.append(f"- **{e['title']}**{status}: {e['excerpt']}")
else:
md_lines.append("_nessuna_")
md_lines += ["", "## Domande risolte simili (exemplar di riferimento, NON decisioni)", ""]
if solved:
for s in solved:
md_lines.append(
f"### {s['question']} \n(sessione `{s['session_id']}`; "
f"tabelle: {', '.join(s['tables']) or '-'})"
)
if s["sql"]:
md_lines += ["", "```sql", s["sql"], "```", ""]
else:
md_lines.append("_nessuna_")
if warnings:
md_lines += ["", "## Avvertenze", ""] + [f"- {w}" for w in warnings]
md = "\n".join(md_lines) + "\n"
if session:
from tht.cli.session_cmd import load_session_or_exit, session_dir
load_session_or_exit(cfg, session)
out = session_dir(cfg, session) / "retrieval_pack.md"
out.write_text(md)
if not json_out:
typer.secho(
f"OK: retrieval pack scritto in {out} "
f"({len(tables)} tabelle, {len(evidence)} evidence, {len(solved)} solved).",
fg=typer.colors.GREEN,
)
if json_out:
typer.echo(json.dumps(
{"question": question, "tables": tables, "evidence": evidence,
"solved": solved, "warnings": warnings},
ensure_ascii=False, indent=2,
))
elif not session:
typer.echo(md)