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 _extract_name, 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 or _extract_name(question)) 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("set-schema-linking") def set_schema_linking_cmd( session_id: str = typer.Argument(...), file: str = typer.Option( ..., "--file", "-f", help="Path al JSON dello schema-linking, oppure '-' per leggere da stdin."), config: Path = CONFIG_OPT, ) -> None: """Valida (modello SchemaLinking) e scrive schema_linking.json deterministicamente.""" import sys from pydantic import ValidationError from tht.session.store import set_schema_linking cfg = _load_config_or_exit(config) load_session_or_exit(cfg, session_id) raw = sys.stdin.read() if file == "-" else Path(file).read_text() try: data = json.loads(raw) except json.JSONDecodeError as e: typer.secho(f"ERRORE: JSON non valido: {e}", fg=typer.colors.RED, err=True) raise typer.Exit(code=5) try: path = set_schema_linking(session_id, data, cfg.paths.sessions) except ValidationError as e: typer.secho(f"ERRORE: schema_linking non valido:\n{e}", fg=typer.colors.RED, err=True) raise typer.Exit(code=5) typer.secho(f"OK: schema_linking.json aggiornato ({path}).", fg=typer.colors.GREEN) @session_app.command("sync-schema-linking") def sync_schema_linking_cmd( session_id: str = typer.Argument(...), config: Path = CONFIG_OPT, ) -> None: """Riproietta schema_linking.json dalle decisioni F4 del ledger (deterministico).""" from pydantic import ValidationError from tht.session.store import sync_schema_linking cfg = _load_config_or_exit(config) load_session_or_exit(cfg, session_id) try: path = sync_schema_linking(session_id, cfg.paths.sessions) except ValidationError as e: typer.secho(f"ERRORE: schema_linking non valido:\n{e}", fg=typer.colors.RED, err=True) raise typer.Exit(code=5) typer.secho(f"OK: schema_linking.json riproiettato ({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) @session_app.command("set-name") def set_name_cmd( session_id: str = typer.Argument(...), name: str = typer.Option(..., "--name", help="Nome descrittivo (vuoto = azzera)."), config: Path = CONFIG_OPT, ) -> None: """Imposta il nome descrittivo della sessione.""" from tht.session.store import set_name cfg = _load_config_or_exit(config) load_session_or_exit(cfg, session_id) set_name(session_id, name, cfg.paths.sessions) typer.secho(f"OK: nome aggiornato per {session_id}.", fg=typer.colors.GREEN) @session_app.command("set-group") def set_group_cmd( session_id: str = typer.Argument(...), group: str = typer.Option(..., "--group", help="Nome del gruppo (vuoto = nessun gruppo)."), config: Path = CONFIG_OPT, ) -> None: """Sposta la sessione in un gruppo (o la toglie da ogni gruppo).""" from tht.session.store import set_group cfg = _load_config_or_exit(config) load_session_or_exit(cfg, session_id) set_group(session_id, group, cfg.paths.sessions) typer.secho(f"OK: gruppo aggiornato per {session_id}.", fg=typer.colors.GREEN) @session_app.command("archive") def archive_cmd(session_id: str = typer.Argument(...), config: Path = CONFIG_OPT) -> None: """Archivia la sessione (la toglie dalla lista attiva, sola lettura).""" from tht.session.store import set_archived cfg = _load_config_or_exit(config) load_session_or_exit(cfg, session_id) set_archived(session_id, True, cfg.paths.sessions) typer.secho(f"OK: sessione {session_id} archiviata.", fg=typer.colors.GREEN) @session_app.command("unarchive") def unarchive_cmd(session_id: str = typer.Argument(...), config: Path = CONFIG_OPT) -> None: """Ripristina la sessione dall'archivio (non ne cambia la ripristinabilità).""" from tht.session.store import set_archived cfg = _load_config_or_exit(config) load_session_or_exit(cfg, session_id) set_archived(session_id, False, cfg.paths.sessions) typer.secho(f"OK: sessione {session_id} ripristinata.", fg=typer.colors.GREEN) @session_app.command("delete") def delete_cmd(session_id: str = typer.Argument(...), config: Path = CONFIG_OPT) -> None: """Elimina definitivamente la cartella di sessione.""" from tht.session.store import delete_session cfg = _load_config_or_exit(config) load_session_or_exit(cfg, session_id) delete_session(session_id, cfg.paths.sessions) typer.secho(f"OK: sessione {session_id} eliminata.", fg=typer.colors.GREEN) @session_app.command("documents") def documents_cmd( session_id: str = typer.Argument(...), json_out: bool = typer.Option(False, "--json", help="Emetti JSON su stdout (pristine)."), config: Path = CONFIG_OPT, ) -> None: """Documenti di sola lettura della sessione (domanda, rivista, schema, SQL, report, decisioni).""" from tht.session.store import build_documents cfg = _load_config_or_exit(config) manifest = load_session_or_exit(cfg, session_id) docs = build_documents(manifest, session_dir(cfg, session_id)) if json_out: typer.echo(json.dumps(docs, ensure_ascii=False, indent=2)) return for d in docs: typer.echo(f"[{d['phase']}] {d['title']} ({d['format']})") 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}")