feat(harness): port sql/cte/datamart/lsh cmd (Onda 4) — CLI completa F1→F8
Ultima onda CLI. 4 cmd portati con rename + grep-per-file (3 residui nsp nei messaggi fixati). Nessun drift costanti phase in questi cmd. La CLI tht e' ora COMPLETA: 14 gruppi di comandi (phase config schema session vector memory search evidence db decision sql cte datamart lsh). tht --help li list tutti. Suite: 165 passed. Il loop skill->LLM->gate ora ha tutti i comandi che la skill chiamera'. Resta: skill riscritta (S), setup pre-sessione (0b), sessione L2 manuale.
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
import hashlib
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
|
||||
from tht.cli.config_cmd import CONFIG_OPT
|
||||
from tht.cli.schema_cmd import _load_config_or_exit
|
||||
from tht.cli.session_cmd import load_session_or_exit, session_dir
|
||||
from tht.cli.sql_cmd import promoted_tables_for, require_action
|
||||
|
||||
_RULE6_HINT = (
|
||||
" — il file del CTE deve contenere SOLO il blocco WITH ... AS (...), "
|
||||
"senza SELECT finale: la SELECT la aggiunge tht in fase di test."
|
||||
)
|
||||
|
||||
cte_app = typer.Typer(help="Test controllato dei CTE proposti (Agent View Generation)")
|
||||
|
||||
|
||||
@cte_app.command("test")
|
||||
def test_cmd(
|
||||
name: str = typer.Argument(..., help="Nome del CTE (file sessions/<id>/ctes/<nome>.sql)."),
|
||||
session: str = typer.Option(..., "--session"),
|
||||
json_out: bool = typer.Option(False, "--json", help="Emette l'esito come JSON (per Pi)."),
|
||||
config: Path = CONFIG_OPT,
|
||||
) -> None:
|
||||
"""Valida e testa un CTE: wrap su ultimo CTE + esecuzione nelle 4 reti."""
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from tht.cli.sql_cmd import do_run
|
||||
from tht.ctetest import CteError, CteTestRecord, append_cte_test, build_test_sql, has_trailing_select
|
||||
from tht.execute import ExecutionError
|
||||
from tht.execute.warnings import runtime_warnings
|
||||
|
||||
cfg = _load_config_or_exit(config)
|
||||
require_action(cfg, "cte_test")
|
||||
load_session_or_exit(cfg, session)
|
||||
sdir = session_dir(cfg, session)
|
||||
|
||||
from tht.cli.phase_cmd import require_phase_or_exit
|
||||
from tht.phase import next_cte
|
||||
|
||||
require_phase_or_exit(cfg, session, 6)
|
||||
nxt = next_cte(sdir)
|
||||
if nxt is not None and name != nxt:
|
||||
typer.secho(
|
||||
f"ERRORE: ordine CTE. Ora tocca a '{nxt}' (il primo CTE del piano non "
|
||||
f"ancora approvato), non a '{name}'. Testa '{nxt}', poi falla approvare "
|
||||
f"dal reviewer (decisione cte_approved); solo allora potrai testare il "
|
||||
f"CTE successivo.",
|
||||
fg=typer.colors.RED, err=True,
|
||||
)
|
||||
raise typer.Exit(code=5)
|
||||
|
||||
cte_file = sdir / "ctes" / f"{name}.sql"
|
||||
if not cte_file.exists():
|
||||
typer.secho(f"ERRORE: file CTE non trovato: {cte_file}", fg=typer.colors.RED, err=True)
|
||||
raise typer.Exit(code=1)
|
||||
cte_sql = cte_file.read_text()
|
||||
sql_hash = hashlib.sha256(cte_sql.encode()).hexdigest()
|
||||
|
||||
def _record_error(message: str) -> None:
|
||||
record = CteTestRecord(
|
||||
name=name, ts=datetime.now(UTC), sql_hash=sql_hash,
|
||||
status="error", error=message,
|
||||
)
|
||||
append_cte_test(sdir, record)
|
||||
if json_out:
|
||||
typer.echo(record.model_dump_json())
|
||||
else:
|
||||
typer.secho(f"ERRORE: {message}", fg=typer.colors.RED, err=True)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
try:
|
||||
test_sql = build_test_sql(cte_sql)
|
||||
except CteError as e:
|
||||
msg = str(e)
|
||||
if "non parsabile" in msg and has_trailing_select(cte_sql):
|
||||
msg += _RULE6_HINT
|
||||
_record_error(msg)
|
||||
|
||||
# validazione statica (esce con messaggi propri se invalida; qui la
|
||||
# intercettiamo per registrare comunque l'esito nel cte_tests.json)
|
||||
from tht.cli.sql_cmd import _load_physical_or_exit
|
||||
from tht.sqlcheck import validate_sql
|
||||
|
||||
check = validate_sql(
|
||||
test_sql,
|
||||
physical=_load_physical_or_exit(cfg),
|
||||
promoted_tables=promoted_tables_for(cfg, session),
|
||||
forbidden_functions=set(cfg.execution.forbidden_functions),
|
||||
)
|
||||
for w in check.warnings:
|
||||
typer.secho(f" warning: {w}", fg=typer.colors.YELLOW)
|
||||
if not check.ok:
|
||||
msg = "; ".join(check.errors)
|
||||
if any("statement" in e for e in check.errors):
|
||||
msg += _RULE6_HINT
|
||||
_record_error(msg)
|
||||
|
||||
try:
|
||||
result = do_run(cfg, test_sql, limit=cfg.execution.max_preview_rows)
|
||||
except ExecutionError as e:
|
||||
_record_error(str(e))
|
||||
|
||||
warnings = runtime_warnings(result, cfg.execution)
|
||||
record = CteTestRecord(
|
||||
name=name, ts=datetime.now(UTC), sql_hash=sql_hash, status="ok",
|
||||
columns=result.columns, row_sample=len(result.rows),
|
||||
execution_ms=result.execution_ms, warnings=warnings,
|
||||
)
|
||||
append_cte_test(sdir, record)
|
||||
|
||||
if json_out:
|
||||
typer.echo(record.model_dump_json())
|
||||
return
|
||||
|
||||
typer.echo(f"CTE {name} — {result.execution_ms} ms")
|
||||
table = Table()
|
||||
for col in result.columns:
|
||||
table.add_column(col)
|
||||
for row in result.rows:
|
||||
table.add_row(*[str(v) for v in row])
|
||||
Console().print(table)
|
||||
for w in warnings:
|
||||
typer.secho(f" warning: {w}", fg=typer.colors.YELLOW)
|
||||
typer.secho(f"OK: esito registrato in {sdir / 'cte_tests.json'}", fg=typer.colors.GREEN)
|
||||
|
||||
|
||||
@cte_app.command("plan")
|
||||
def plan_cmd(
|
||||
session: str = typer.Option(..., "--session"),
|
||||
name: list[str] = typer.Option(..., "--name", help="Nome CTE (ripetibile, in ordine)."),
|
||||
config: Path = CONFIG_OPT,
|
||||
) -> None:
|
||||
"""Persiste il piano CTE ordinato (sessions/<id>/cte_plan.json)."""
|
||||
import json
|
||||
|
||||
from tht.phase import CTE_PLAN_FILE
|
||||
|
||||
cfg = _load_config_or_exit(config)
|
||||
load_session_or_exit(cfg, session)
|
||||
from tht.cli.phase_cmd import require_phase_or_exit
|
||||
require_phase_or_exit(cfg, session, 6)
|
||||
sdir = session_dir(cfg, session)
|
||||
sdir.mkdir(parents=True, exist_ok=True)
|
||||
(sdir / CTE_PLAN_FILE).write_text(json.dumps(name, ensure_ascii=False))
|
||||
typer.secho(f"OK: piano CTE salvato ({len(name)} CTE) in {sdir / CTE_PLAN_FILE}.",
|
||||
fg=typer.colors.GREEN)
|
||||
|
||||
|
||||
@cte_app.command("list")
|
||||
def list_cmd(
|
||||
session: str = typer.Option(..., "--session"),
|
||||
config: Path = CONFIG_OPT,
|
||||
) -> None:
|
||||
"""Ultimo esito registrato per ogni CTE della sessione."""
|
||||
from tht.ctetest import CteError, load_cte_tests
|
||||
|
||||
cfg = _load_config_or_exit(config)
|
||||
load_session_or_exit(cfg, session)
|
||||
try:
|
||||
records = load_cte_tests(session_dir(cfg, session))
|
||||
except CteError as e:
|
||||
typer.secho(f"ERRORE: impossibile leggere cte_tests.json: {e}",
|
||||
fg=typer.colors.RED, err=True)
|
||||
raise typer.Exit(code=1)
|
||||
if not records:
|
||||
typer.echo("Nessun test CTE registrato.")
|
||||
return
|
||||
latest = {}
|
||||
for r in records:
|
||||
latest[r.name] = r
|
||||
for name, r in sorted(latest.items()):
|
||||
line = f"{name}: {r.status} ({r.ts:%Y-%m-%d %H:%M}, {r.execution_ms} ms)"
|
||||
if r.error:
|
||||
line += f" — {r.error}"
|
||||
if r.warnings:
|
||||
line += f" [{len(r.warnings)} warning]"
|
||||
typer.echo(line)
|
||||
Reference in New Issue
Block a user