diff --git a/harness/.pi/extensions/gate/__tests__/gate_antibypass.test.js b/harness/.pi/extensions/gate/__tests__/gate_antibypass.test.js new file mode 100644 index 00000000..6f272d47 --- /dev/null +++ b/harness/.pi/extensions/gate/__tests__/gate_antibypass.test.js @@ -0,0 +1,27 @@ +const test = require("node:test"); +const assert = require("node:assert"); +const { createFakePi } = require("./fake_pi_runtime.js"); + +const installGatePromise = import("../../tht-gate.js").then((m) => m.default); + +test("tht schema introspect --refresh e' bloccato in sessione (manutenzione)", async () => { + const installGate = await installGatePromise; + const { pi } = createFakePi(); + installGate(pi); + const res = await pi.emit("tool_call", { + toolName: "bash", + input: { command: "tht schema introspect -c workspaces/psd.yaml --refresh" }, + }); + assert.equal(res?.block, true); +}); + +test("tht schema introspect senza --refresh passa (cache hit innocuo)", async () => { + const installGate = await installGatePromise; + const { pi } = createFakePi(); + installGate(pi); + const res = await pi.emit("tool_call", { + toolName: "bash", + input: { command: "tht schema introspect -c workspaces/psd.yaml" }, + }); + assert.equal(res, undefined); +}); diff --git a/harness/.pi/extensions/tht-gate.js b/harness/.pi/extensions/tht-gate.js index 9302d743..f3726377 100644 --- a/harness/.pi/extensions/tht-gate.js +++ b/harness/.pi/extensions/tht-gate.js @@ -109,6 +109,8 @@ const FORBIDDEN = [ /\btht\s+cte\s+plan\b/, // La promozione in memoria passa dal reviewer (reviewer_memory_promote), mai da shell. /\btht\s+memory\s+(promote|save-one)\b/, + // La re-introspezione del DWH (~3 min) è manutenzione fuori sessione, mai in workflow. + /\btht\s+schema\s+introspect\b[^\n]*--refresh/, ]; const PROTECTED_FILES = /(review_decisions\.jsonl|session_manifest\.yaml|cte_plan\.json)/; diff --git a/harness/.pi/skills/tht-sessione/SKILL.md b/harness/.pi/skills/tht-sessione/SKILL.md index 196b52f7..57755389 100644 --- a/harness/.pi/skills/tht-sessione/SKILL.md +++ b/harness/.pi/skills/tht-sessione/SKILL.md @@ -148,10 +148,21 @@ it is complete. (The backend already refuses resume for finalized/archived sessi Prerequisite: you must already be in Phase 1. +**F1 toolbox.** The only commands you need here are `tht search find` and `tht schema +render` — both fast, read-only lookups over workspace artifacts already on disk. +Evidence lives in `/evidence/**` and is what `tht search find --kind evidence` +returns — do not browse it with `find`/`cat`. Do NOT run `tht schema introspect`: it is +a maintenance command that re-reads the remote DWH (~3 minutes); the catalog +`artifacts/mschema/physical.yaml` is already in the workspace. Do NOT explore with +`--help` or ad-hoc shell commands — every command you need is named in this skill. + 1. Explore the DWH and knowledge base: `tht search find ""` (evidence + schema, LSH over real values) and `tht search find --kind evidence ""`. The LSH exposes EVERY column where a value appears — it does not collapse to a single best match, so a value like "ablazione" may anchor on multiple columns. + First list ALL the ambiguous terms in the question, then run the `tht search find` + calls for every term in ONE batch (a single message with multiple shell invocations) + — not one lookup per turn. 2. For each ambiguity (clinical term, population, time window, outcome), present the candidate interpretations (`recommended:true` on the best) + "Altro". Pick the widget by the question's shape: @@ -230,8 +241,10 @@ Phase 3 (CLI exit 5). Prerequisite: Phase 3 closed. -1. `tht schema introspect` + `tht schema render --format mschema-text` for the schema - context. Copy table/column names EXACTLY from it — never invent objects. +1. `tht schema render --format mschema-text` for the schema context (the catalog + `artifacts/mschema/physical.yaml` is already in the workspace; only if render fails + with `physical.yaml non trovato`, run `tht schema introspect` once, then render). + Copy table/column names EXACTLY from it — never invent objects. Also run `tht memory solved-search "" --json`: similar already-solved questions show which tables comparable questions used. Cite relevant precedents (session id + tables) to the reviewer as CONTEXT — they are reference material, diff --git a/harness/tests/test_schema_introspect_guard.py b/harness/tests/test_schema_introspect_guard.py new file mode 100644 index 00000000..056e361f --- /dev/null +++ b/harness/tests/test_schema_introspect_guard.py @@ -0,0 +1,70 @@ +from datetime import datetime + +from typer.testing import CliRunner + +from tht.cli import app +from tht.mschema.models import ColumnPhysical, PhysicalSchema, TablePhysical + + +def _write_catalog(tmp_path): + phys = PhysicalSchema( + database="d", schema="s", introspected_at=datetime(2026, 1, 1), + tables={ + "dim_patient": TablePhysical( + comment="Anagrafica", + columns={ + "cod_paz": ColumnPhysical(type="bigint", pk=True, comment="Codice paziente"), + }, + ) + }, + ) + out = tmp_path / "artifacts" / "mschema" / "physical.yaml" + phys.to_yaml(out) + return out + + +def _write_config(tmp_path): + cfg = tmp_path / "workspace.yaml" + cfg.write_text( + "database: {database: d, schema: s, user: u, password: p, transport: direct}\n" + f"paths: {{artifacts: {tmp_path/'artifacts'}, indexes: {tmp_path/'i'}, sessions: {tmp_path/'s'}}}\n" + ) + return cfg + + +def test_introspect_cache_hit_skips_dwh(tmp_path): + # Le credenziali sono fasulle: se la guardia non scattasse PRIMA del branch + # transport, il comando tenterebbe la connessione e fallirebbe. + catalog = _write_catalog(tmp_path) + before = catalog.read_bytes() + cfg = _write_config(tmp_path) + res = CliRunner().invoke(app, ["schema", "introspect", "-c", str(cfg)]) + assert res.exit_code == 0, res.output + assert "OK (cache)" in res.output + assert "1 tabelle" in res.output + assert catalog.read_bytes() == before + + +def test_introspect_refresh_bypasses_cache(tmp_path): + _write_catalog(tmp_path) + cfg = _write_config(tmp_path) + res = CliRunner().invoke(app, ["schema", "introspect", "-c", str(cfg), "--refresh"]) + assert res.exit_code != 0 + assert "OK (cache)" not in res.output + + +def test_introspect_corrupt_catalog_falls_through(tmp_path): + catalog = tmp_path / "artifacts" / "mschema" / "physical.yaml" + catalog.parent.mkdir(parents=True) + catalog.write_text("{not: [valid") + cfg = _write_config(tmp_path) + res = CliRunner().invoke(app, ["schema", "introspect", "-c", str(cfg)]) + assert res.exit_code != 0 + assert "OK (cache)" not in res.output + + +def test_render_without_catalog_guides_fallback(tmp_path): + cfg = _write_config(tmp_path) + res = CliRunner().invoke(app, ["schema", "render", "-c", str(cfg)]) + assert res.exit_code == 1 + assert "Esegui prima" in res.output diff --git a/harness/tht/cli/schema_cmd.py b/harness/tht/cli/schema_cmd.py index 41d933c3..e860abf6 100644 --- a/harness/tht/cli/schema_cmd.py +++ b/harness/tht/cli/schema_cmd.py @@ -30,9 +30,40 @@ def annotations_path(cfg) -> Path: @schema_app.command("introspect") -def introspect_cmd(config: Path = CONFIG_OPT) -> None: - """Introspeziona lo schema target e genera artifacts/mschema/physical.yaml.""" +def introspect_cmd( + config: Path = CONFIG_OPT, + refresh: bool = typer.Option( + False, + "--refresh", + help="Forza la re-introspezione del DWH anche se physical.yaml esiste già.", + ), +) -> None: + """Introspeziona lo schema target e genera artifacts/mschema/physical.yaml. + + Se physical.yaml esiste già, esce subito (cache); usa --refresh per rigenerarlo. + """ cfg = _load_config_or_exit(config) + out = physical_path(cfg) + if out.exists() and not refresh: + from datetime import UTC, datetime + + from tht.mschema.models import PhysicalSchema + + try: + cached = PhysicalSchema.from_yaml(out) + except Exception: + pass # catalogo illeggibile: procedi con la re-introspezione + else: + ts = cached.introspected_at + if ts.tzinfo is None: + ts = ts.replace(tzinfo=UTC) + age_days = (datetime.now(UTC) - ts).days + typer.secho( + f"OK (cache): {out} esistente ({len(cached.tables)} tabelle, " + f"età {age_days}g). Re-introspezione solo con --refresh (manutenzione).", + fg=typer.colors.GREEN, + ) + return if cfg.database.transport == "rest": from tht.db.introspect import introspect_rest from tht.db.sampling import add_examples_rest @@ -59,7 +90,6 @@ def introspect_cmd(config: Path = CONFIG_OPT) -> None: except (OperationalError, IntrospectionError) as e: typer.secho(f"ERRORE: {e}", fg=typer.colors.RED, err=True) raise typer.Exit(code=1) - out = physical_path(cfg) phys.to_yaml(out) n_cols = sum(len(t.columns) for t in phys.tables.values()) n_ignored = sum(