import json from pathlib import Path import typer from tht.cli.config_cmd import CONFIG_OPT from tht.cli.schema_cmd import _load_config_or_exit session_app = typer.Typer(help="Sessioni (directory artefatti)") def session_dir(cfg, session_id: str) -> Path: return cfg.paths.sessions / session_id def load_session_or_exit(cfg, session_id: str): from tht.session.store import SessionError, load_session try: return load_session(session_id, cfg.paths.sessions) except SessionError as e: typer.secho(f"ERRORE: {e}", fg=typer.colors.RED, err=True) raise typer.Exit(code=1) def _list_sessions(sessions_root: Path) -> list[dict]: """Return session summary dicts sorted by created_at desc (pure helper, no I/O side-effects).""" from tht.session.models import SessionManifest MANIFEST_NAME = "session_manifest.yaml" out = [] if not sessions_root.exists(): return out for d in sessions_root.iterdir(): manifest_path = d / MANIFEST_NAME if not manifest_path.exists(): continue m = SessionManifest.from_yaml(manifest_path) out.append({ "id": m.id, "status": m.status, "question": m.question, "summary": m.summary, "created_at": m.created_at.isoformat(), "updated_at": m.updated_at.isoformat() if m.updated_at else None, "author": m.author, "name": m.name, "group": m.group, "archived": m.archived, }) out.sort(key=lambda r: r["created_at"], reverse=True) return out @session_app.command("list") def list_cmd( json_out: bool = typer.Option(False, "--json", help="Emetti JSON su stdout (pristine)."), config: Path = CONFIG_OPT, ) -> None: """Elenca le sessioni esistenti.""" cfg = _load_config_or_exit(config) rows = _list_sessions(cfg.paths.sessions) if json_out: typer.echo(json.dumps(rows, ensure_ascii=False, indent=2)) return for r in rows: typer.echo(f"{r['id']} [{r['status']}] {r['summary'] or r['question'][:60]}") @session_app.command("new") def new_cmd( question: str = typer.Argument(..., help="La domanda in linguaggio naturale."), provider: str = typer.Option(None, "--provider", help="Provider LLM (es. zai, anthropic)."), model: str = typer.Option(None, "--model", help="Modello LLM (es. glm-5.2)."), thinking: str = typer.Option(None, "--thinking", help="Livello di thinking (es. medium)."), name: str = typer.Option(None, "--name", help="Nome descrittivo della sessione."), json_out: bool = typer.Option(False, "--json", help="Emetti JSON puro {\"id\": ...} su stdout."), config: Path = CONFIG_OPT, ) -> None: """Crea una sessione e stampa il suo id (ultima riga dell'output).""" from tht.session.store import create_session cfg = _load_config_or_exit(config) manifest = create_session(question, cfg.database, cfg.paths.sessions, provider=provider, model=model, thinking=thinking, name=name) if json_out: typer.echo(json.dumps({"id": manifest.id}, ensure_ascii=False)) return typer.secho(f"OK: sessione creata in {session_dir(cfg, manifest.id)}", fg=typer.colors.GREEN) typer.echo(manifest.id) @session_app.command("set-question") def set_question_cmd( session_id: str = typer.Argument(...), question: str = typer.Option(..., "--question", "-q", help="La domanda riscritta (chiara)."), assumption: list[str] = typer.Option( None, "--assumption", "-a", help="Una assunzione (ripetibile). Testo libero, già formattato."), config: Path = CONFIG_OPT, ) -> None: """Scrive question.md (domanda riscritta + assunzioni) in modo deterministico.""" from tht.session.store import set_question cfg = _load_config_or_exit(config) load_session_or_exit(cfg, session_id) path = set_question(session_id, question, assumption or [], cfg.paths.sessions) typer.secho(f"OK: question.md aggiornato ({path}).", fg=typer.colors.GREEN) @session_app.command("show") def show_cmd( session_id: str = typer.Argument(...), json_out: bool = typer.Option(False, "--json", help="Emetti JSON su stdout (pristine)."), config: Path = CONFIG_OPT, ) -> None: """Stato della sessione: manifest + decisioni registrate (per la ripresa).""" from tht.decisions import list_decisions from tht.phase import current_phase cfg = _load_config_or_exit(config) manifest = load_session_or_exit(cfg, session_id) sdir = session_dir(cfg, session_id) if json_out: has_schema_linking = (sdir / "schema_linking.json").exists() phase = current_phase(sdir) data = manifest.model_dump(mode="json", by_alias=True) data["phase"] = phase data["has_schema_linking"] = has_schema_linking typer.echo(json.dumps(data, ensure_ascii=False, indent=2)) return decisions = list_decisions(sdir) typer.echo(f"id : {manifest.id}") typer.echo(f"stato : {manifest.status}") typer.echo(f"domanda : {manifest.question}") typer.echo(f"target : {manifest.database} / {manifest.db_schema}") typer.echo(f"decisioni: {len(decisions)}") for d in decisions[-10:]: typer.echo(f" [{d.seq}] {d.type}: {d.subject}" + (f" — {d.detail}" if d.detail else "")) linking = sdir / "schema_linking.json" typer.echo(f"schema_linking.json: {'presente' if linking.exists() else 'assente'}") @session_app.command("close") def close_cmd(session_id: str = typer.Argument(...), config: Path = CONFIG_OPT) -> None: """Chiude la sessione (status=closed).""" from tht.session.store import close_session cfg = _load_config_or_exit(config) load_session_or_exit(cfg, session_id) close_session(session_id, cfg.paths.sessions) typer.secho(f"OK: sessione {session_id} chiusa.", fg=typer.colors.GREEN) def session_problems(cfg, session_id: str) -> list[str]: """Problemi del Blocco 3 (decisioni + schema_linking). Riusato da check e finalize.""" import json from pydantic import ValidationError from tht.decisions import list_decisions from tht.session.models import SchemaLinking sdir = session_dir(cfg, session_id) problems: list[str] = [] if not list_decisions(sdir): problems.append("nessuna decisione registrata (review_decisions.jsonl vuoto o assente)") linking_path = sdir / "schema_linking.json" if not linking_path.exists(): problems.append("schema_linking.json assente") else: try: SchemaLinking.model_validate(json.loads(linking_path.read_text())) except (json.JSONDecodeError, ValidationError) as e: problems.append(f"schema_linking.json non valido: {e}") return problems @session_app.command("check") def check_cmd(session_id: str = typer.Argument(...), config: Path = CONFIG_OPT) -> None: """Gate oggettivo: decisioni presenti e schema_linking.json valido (dalla Fase 4).""" from tht.phase import current_phase from tht.workflow import load_workflow cfg = _load_config_or_exit(config) load_session_or_exit(cfg, session_id) cur = current_phase(session_dir(cfg, session_id)) wf = load_workflow() schema_linking_phase = wf.schema_linking_phase() if cur < schema_linking_phase: # Prima della fase schema-linking non e' ancora atteso: il gate Blocco 3 # non e' applicabile. Riportarlo come errore manderebbe il workflow in loop. nome = wf.phase_name(cur) typer.secho( f"Sessione {session_id} in Fase {cur} ({nome}): il gate Blocco 3 " f"(schema_linking.json) si applica dalla Fase {schema_linking_phase} " f"({wf.phase_name(schema_linking_phase)}). Niente da verificare ora: " "prosegui con lo schema linking.", fg=typer.colors.CYAN, ) return problems = session_problems(cfg, session_id) if problems: typer.secho(f"Sessione {session_id} incompleta:", fg=typer.colors.YELLOW) for p in problems: typer.echo(f" - {p}") raise typer.Exit(code=3) typer.secho(f"OK: sessione {session_id} completa per il Blocco 3.", fg=typer.colors.GREEN) ARTIFACT_FILES = [ "session_manifest.yaml", "question.md", "schema_linking.json", "evidence.json", "cte_tests.json", "sql_final.sql", "validation_report.md", "review_decisions.jsonl", ] @session_app.command("finalize") def finalize_cmd(session_id: str = typer.Argument(...), config: Path = CONFIG_OPT) -> None: """Gate finale + batteria di validazione + artefatto di sessione completo.""" import json from tht.cli.sql_cmd import ( _load_physical_or_exit, do_explain, do_run, promoted_tables_for, ) from tht.ctetest import CteError, load_cte_tests from tht.execute import ExecutionError from tht.execute.warnings import plan_warnings, runtime_warnings, static_warnings from tht.report import extract_reviewer_notes, render_validation_report from tht.session.artifacts import build_evidence_entries from tht.phase import cte_plan as effective_cte_plan from tht.phase import effective_decisions from tht.session.models import SchemaLinking from tht.sqlcheck import validate_sql cfg = _load_config_or_exit(config) manifest = load_session_or_exit(cfg, session_id) sdir = session_dir(cfg, session_id) from tht.phase import current_phase from tht.workflow import load_workflow cur = current_phase(sdir) wf = load_workflow() if cur <= wf.max_phase: typer.secho( f"Finalize rifiutato: workflow non completo, sei in Fase {cur} " f"({wf.phase_name(min(cur, wf.max_phase))}). Tutte le {wf.max_phase} fasi " "devono essere approvate (anche dopo eventuali reopen).", fg=typer.colors.YELLOW, err=True, ) raise typer.Exit(code=5) # --- gate di ingresso --- # Vista effective (D15): un sql_approved/cte ritirato o stale post-rollback non conta. problems = session_problems(cfg, session_id) decisions = effective_decisions(sdir) sql_file = sdir / "sql_final.sql" if not sql_file.exists(): problems.append("sql_final.sql assente") if not any(d.type == "sql_approved" for d in decisions): problems.append( "decisione sql_approved assente: la validazione semantica del reviewer " "e' obbligatoria prima del finalize" ) # I CTE richiesti sono quelli del PIANO effettivo, non i file glob su disco: un # ctes/*.sql orfano lasciato da un teardown incompleto non deve bloccare il finalize. plan = effective_cte_plan(sdir) if plan: try: tested = {r.name for r in load_cte_tests(sdir)} except CteError as e: typer.secho( f"Finalize rifiutato: impossibile leggere cte_tests.json: {e}", fg=typer.colors.RED, err=True, ) raise typer.Exit(code=3) for name in plan: if name not in tested: problems.append(f"CTE mai testato: {name}") if problems: typer.secho(f"Finalize rifiutato per {session_id}:", fg=typer.colors.YELLOW) for p in problems: typer.echo(f" - {p}") raise typer.Exit(code=3) # --- batteria di validazione su sql_final.sql --- sql = sql_file.read_text() check = validate_sql( sql, physical=_load_physical_or_exit(cfg), promoted_tables=promoted_tables_for(cfg, session_id), forbidden_functions=set(cfg.execution.forbidden_functions), ) if not check.ok: typer.secho("Finalize rifiutato: sql_final.sql non passa la validazione statica:", fg=typer.colors.RED, err=True) for e in check.errors: typer.echo(f" - {e}", err=True) raise typer.Exit(code=3) limit = cfg.execution.max_preview_rows try: plan = do_explain(cfg, sql) result = do_run(cfg, sql, limit=limit) except ExecutionError as e: typer.secho(f"Finalize rifiutato: {e}", fg=typer.colors.RED, err=True) raise typer.Exit(code=3) # --- validation_report.md (preservando le note del reviewer) --- report_path = sdir / "validation_report.md" existing_notes = ( extract_reviewer_notes(report_path.read_text()) if report_path.exists() else "" ) report_path.write_text(render_validation_report( session_id=session_id, check=check, plan=plan, plan_warnings=plan_warnings(plan, cfg.execution), result=result, runtime_warnings=runtime_warnings(result, cfg.execution), static_warnings=static_warnings(check.ast), limit=limit, reviewer_notes=existing_notes, )) # --- evidence.json --- linking = SchemaLinking.model_validate( json.loads((sdir / "schema_linking.json").read_text()) ) entries = build_evidence_entries(decisions, linking, cfg.paths.artifacts / "evidence") (sdir / "evidence.json").write_text(json.dumps(entries, ensure_ascii=False, indent=2)) # --- manifest + riepilogo --- from datetime import UTC, datetime from tht.session.store import MANIFEST, current_author manifest.status = "finalized" manifest.updated_at = datetime.now(UTC) manifest.updated_by = current_author() manifest.to_yaml(sdir / MANIFEST) typer.secho(f"OK: sessione {session_id} finalizzata. Artefatti:", fg=typer.colors.GREEN) for name in ARTIFACT_FILES: state = "presente" if (sdir / name).exists() else "assente" typer.echo(f" - {name}: {state}")