diff --git a/backend/src/app.ts b/backend/src/app.ts index 5c7ba9d5..fff44715 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -40,6 +40,8 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc harnessDir: config.harnessDir, configPath: process.env.THT_CONFIG ?? "config/tht.yaml", dataRoot: config.dataRoot, + secretsFile: config.secretsFile, + secretFiles: config.secretFiles, }); const mgr = deps?.mgr ?? new PiProcessManager(config, deps?.spawnFn ? { spawnFn: deps.spawnFn } : undefined); const hub = deps?.hub ?? new SseHub(); diff --git a/backend/src/pi/pi-process-manager.ts b/backend/src/pi/pi-process-manager.ts index acd9662d..aa3e49a2 100644 --- a/backend/src/pi/pi-process-manager.ts +++ b/backend/src/pi/pi-process-manager.ts @@ -68,9 +68,18 @@ export class PiProcessManager { // this managed session process the adapter values already loaded by the core // entrypoint; the generic provider helper continues to scrub them by default. for (const name of [ - "THT_DWH_API_KEY", "THT_VEC_API_KEY", "THT_VEC_WRITE_API_KEY", "THT_SSL_CA", + "THT_DWH_API_KEY", "THT_VEC_API_KEY", "THT_VEC_WRITE_API_KEY", ] as const) { - if (process.env[name] !== undefined) env[name] = process.env[name]; + const value = secretValue(this.cfg, name) ?? process.env[name]; + if (value !== undefined) env[name] = value; + } + const ca = secretValue(this.cfg, "THT_SSL_CA") + ?? secretValue(this.cfg, "THT_CA") + ?? process.env.THT_SSL_CA + ?? process.env.THT_CA; + if (ca !== undefined) { + env.THT_CA = ca; + env.THT_SSL_CA = ca; } delete env.THT_DATA_ROOT; if (this.cfg.dataRoot !== undefined) env.THT_DATA_ROOT = this.cfg.dataRoot; diff --git a/backend/src/tht/tht-runner.ts b/backend/src/tht/tht-runner.ts index ab416e8c..26dabe3d 100644 --- a/backend/src/tht/tht-runner.ts +++ b/backend/src/tht/tht-runner.ts @@ -2,8 +2,9 @@ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { clearPrincipalEnvironment, principalEnvironment, type PrincipalContext } from "../auth/principal.js"; +import { secretValue, type SecretBundleConfig } from "../config/secret-bundle.js"; -export interface ThtConfig { +export interface ThtConfig extends SecretBundleConfig { thtBin: string; harnessDir: string; configPath: string; @@ -82,6 +83,17 @@ export class ThtRunner { clearPrincipalEnvironment(env); if (this.cfg.dataRoot !== undefined) env.THT_DATA_ROOT = this.cfg.dataRoot; if (this.principal) Object.assign(env, principalEnvironment(this.principal)); + for (const name of [ + "THT_DWH_API_KEY", "THT_VEC_API_KEY", "THT_VEC_WRITE_API_KEY", + ] as const) { + const value = secretValue(this.cfg, name); + if (value !== undefined) env[name] = value; + } + const ca = secretValue(this.cfg, "THT_SSL_CA") ?? secretValue(this.cfg, "THT_CA"); + if (ca !== undefined) { + env.THT_CA = ca; + env.THT_SSL_CA = ca; + } const ch = spawn(this.cfg.thtBin, this.buildArgv(args, workspace), { cwd: this.cfg.harnessDir, env, diff --git a/backend/test/pi-process-manager.test.ts b/backend/test/pi-process-manager.test.ts index ba296b19..7d898370 100644 --- a/backend/test/pi-process-manager.test.ts +++ b/backend/test/pi-process-manager.test.ts @@ -350,7 +350,14 @@ test("skips managed-key injection for a provider present in pi's auth store", as test("session Pi spawn reads the single secret bundle and scrubs its path", async () => { const secret = path.resolve(__dirname, `.bundle-${process.pid}`); - writeFileSync(secret, "THT_MODEL_API_KEY=bundle-secret\n", { mode: 0o600 }); + writeFileSync(secret, [ + "THT_MODEL_API_KEY=bundle-secret", + "THT_DWH_API_KEY=dwh-secret", + "THT_VEC_API_KEY=vector-reader-secret", + "THT_VEC_WRITE_API_KEY=vector-writer-secret", + "THT_CA=/run/secrets/ca-chain.pem", + "", + ].join("\n"), { mode: 0o600 }); chmodSync(secret, 0o600); const calls: any[][] = []; const child = recordingChild(); @@ -361,6 +368,13 @@ test("session Pi spawn reads the single secret bundle and scrubs its path", asyn try { await mgr.spawnFor("bundle-session", { provider: "openai" }); expect(calls[0][2].env.OPENAI_API_KEY).toBe("bundle-secret"); + expect(calls[0][2].env).toMatchObject({ + THT_DWH_API_KEY: "dwh-secret", + THT_VEC_API_KEY: "vector-reader-secret", + THT_VEC_WRITE_API_KEY: "vector-writer-secret", + THT_CA: "/run/secrets/ca-chain.pem", + THT_SSL_CA: "/run/secrets/ca-chain.pem", + }); expect(calls[0][2].env).not.toHaveProperty("THT_SECRETS_FILE"); } finally { mgr.teardown("bundle-session"); diff --git a/backend/test/tht-runner.test.ts b/backend/test/tht-runner.test.ts index 102d0593..f07a8b4a 100644 --- a/backend/test/tht-runner.test.ts +++ b/backend/test/tht-runner.test.ts @@ -1,5 +1,8 @@ import { test, expect, vi } from "vitest"; import { EventEmitter } from "node:events"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { ThtRunner } from "../src/tht/tht-runner.js"; // Spy on child_process.spawn so we can capture the resolved argv (incl. -c config) @@ -63,6 +66,41 @@ test("run passes configured THT_DATA_ROOT and preserves the remaining environmen } }); +test("run injects DWH/vector credentials from the mounted secret bundle", async () => { + const dir = mkdtempSync(join(tmpdir(), "tht-runner-bundle-")); + const secret = join(dir, "thothii.secrets"); + writeFileSync(secret, [ + "THT_DWH_API_KEY=dwh-secret", + "THT_VEC_API_KEY=vector-reader-secret", + "THT_VEC_WRITE_API_KEY=vector-writer-secret", + "THT_CA=/run/secrets/ca-chain.pem", + "", + ].join("\n"), { mode: 0o600 }); + chmodSync(secret, 0o600); + try { + (spawn as any).mockClear(); + const runner = new ThtRunner({ + thtBin: "tht", + harnessDir: "/app/harness", + configPath: "config/tht.yaml", + secretsFile: secret, + } as any); + + await runner.run(["session", "list", "--json"]); + + const env = (spawn as any).mock.calls[0][2].env; + expect(env).toMatchObject({ + THT_DWH_API_KEY: "dwh-secret", + THT_VEC_API_KEY: "vector-reader-secret", + THT_VEC_WRITE_API_KEY: "vector-writer-secret", + THT_CA: "/run/secrets/ca-chain.pem", + THT_SSL_CA: "/run/secrets/ca-chain.pem", + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("run omits ambient THT_DATA_ROOT when config does not provide one", async () => { const previousDataRoot = process.env.THT_DATA_ROOT; const previousCredential = process.env.PI_PROVIDER_API_KEY; diff --git a/deploy/compose.psd-local.yaml.example b/deploy/compose.psd-local.yaml.example index 0325db47..6ffab682 100644 --- a/deploy/compose.psd-local.yaml.example +++ b/deploy/compose.psd-local.yaml.example @@ -1,11 +1,41 @@ services: core: environment: + AUTH_MODE: ${AUTH_MODE:-none} + THOTH_PUBLIC_EXPOSURE: ${THOTH_PUBLIC_EXPOSURE:-false} + MAX_PI_PROCESSES: ${MAX_PI_PROCESSES:-4} + PI_PROVIDER: ${PI_PROVIDER:?set PI_PROVIDER} + PI_MODEL: ${PI_MODEL:?set PI_MODEL} + PI_THINKING: ${PI_THINKING:-medium} + THT_PROFILE: ${THT_PROFILE:-workstation} + THT_DB_NAME: ${THT_DB_NAME:?set THT_DB_NAME} + THT_DWH_REST_URL: ${THT_DWH_REST_URL:?set THT_DWH_REST_URL} + THT_VEC_REST_URL: ${THT_VEC_REST_URL:?set THT_VEC_REST_URL} + THT_VEC_WRITE_REST_URL: ${THT_VEC_WRITE_REST_URL:?set THT_VEC_WRITE_REST_URL} + THT_OLLAMA_URL: ${THT_OLLAMA_URL:?set THT_OLLAMA_URL} + THT_SECRETS_FILE: /run/secrets/thothii.secrets THT_DOCS_ROOT: /data/workspaces/psd + THT_CONFIG: /app/harness/config/tht.yaml extra_hosts: - host.docker.internal:host-gateway + networks: !override + default: + aliases: [core, thothii-core] volumes: + - thoth_data:/data + - thoth_pi_config:/home/thoth/.pi + - ./deploy/pi/models.json:/home/thoth/.pi/agent/models.json:ro + - ./deploy/pi/settings.json:/home/thoth/.pi/agent/settings.json:ro + - ${THT_SECRETS_FILE:?set THT_SECRETS_FILE}:/run/secrets/thothii.secrets:ro + - ${THT_PSD_WORKSPACE_HOST_PATH:?set THT_PSD_WORKSPACE_HOST_PATH}/evidence:/data/evidence:ro - ./deploy/workspaces/psd.yaml:/app/harness/config/tht.yaml:ro - - type: bind - source: ${THT_PSD_WORKSPACE_HOST_PATH:?set THT_PSD_WORKSPACE_HOST_PATH} - target: /data/workspaces/psd + - ${THT_PSD_WORKSPACE_HOST_PATH:?set THT_PSD_WORKSPACE_HOST_PATH}:/data/workspaces/psd + +volumes: + thoth_data: + thoth_pi_config: + +networks: + default: + external: true + name: thothii_default diff --git a/docker/core.Dockerfile b/docker/core.Dockerfile index db75fe6d..a1287633 100644 --- a/docker/core.Dockerfile +++ b/docker/core.Dockerfile @@ -29,6 +29,7 @@ RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \ # Utente non-root RUN useradd --create-home --uid 10001 --shell /bin/bash thoth +RUN mkdir -p /home/thoth/.pi/agent && chown -R thoth:thoth /home/thoth/.pi COPY harness/ /app/harness/ # Pi scrive lock/settings in .pi: ownership thoth per sopravvivere al rebuild @@ -43,8 +44,11 @@ RUN python -m venv /opt/venv \ && /opt/venv/bin/pip install --no-cache-dir --upgrade pip \ && (cd /app/harness && /opt/venv/bin/pip install --no-cache-dir .) \ && cp /app/harness/workflow.yaml /opt/venv/lib/python3.12/site-packages/workflow.yaml -# tht cerca config/tht.yaml relativo al CWD (ignora THT_CONFIG env). Symlink al workspace attivo. -RUN mkdir -p /app/harness/config && ln -sf /app/harness/workspaces/local.yaml /app/harness/config/tht.yaml +# Default locale e alias PSD convergono sul file canonico. Il CLI onora anche +# THT_CONFIG, quindi cambiare CWD non cambia l'identita' dello workspace. +RUN mkdir -p /app/harness/config \ + && cp --remove-destination /app/harness/workspaces/local.yaml /app/harness/config/tht.yaml \ + && ln -sfn /app/harness/config/tht.yaml /app/harness/workspaces/psd.yaml # PiProcessManager (backend) prepende harnessDir/.venv/bin al PATH del child Pi → symlink al venv reale RUN ln -s /opt/venv /app/harness/.venv diff --git a/harness/.pi/extensions/gate/__tests__/gate_schema_linking.test.js b/harness/.pi/extensions/gate/__tests__/gate_schema_linking.test.js index 8197421b..257d9a1e 100644 --- a/harness/.pi/extensions/gate/__tests__/gate_schema_linking.test.js +++ b/harness/.pi/extensions/gate/__tests__/gate_schema_linking.test.js @@ -245,3 +245,61 @@ test("reviewer_schema_linking returns a textResult (not a throw) when a proposed _handler = null; } }); + +test("reviewer_schema_linking explains that phase four stays open when joins are missing", async () => { + _handler = (_file, args) => { + if (args[0] === "phase" && args[1] === "meta") + return JSON.stringify({ phases: [{ num: 4, id: "F4" }] }); + if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 4\n"; + if (args[0] === "schema" && args[1] === "columns") + return JSON.stringify({ ...CATALOG, table: args[2] }); + if (args[0] === "phase" && args[1] === "advance") { + const error = new Error("phase advance failed"); + error.stderr = "Fase 4: più tabelle promosse richiedono join strutturati"; + throw error; + } + return ""; + }; + + try { + const gate = require(GATE); + const { createFakePi } = require("./fake_pi_runtime.js"); + const { pi, ctx, tools } = createFakePi(); + ctx.cwd = "/nonexistent-thothii-test-cwd-open-f4"; + gate.default(pi); + + ctx.ui.input = async (title) => { + const descriptor = JSON.parse(title); + return JSON.stringify({ + id: descriptor.id, + kind: "schema-linking", + tables: descriptor.tables.map((table) => ({ + id: table.id, + enacted: true, + columns: ["cod_paz"], + })), + }); + }; + + const result = await tools.get("reviewer_schema_linking").def.execute( + "call-open-f4", + { + session: "s1", + title: "Schema linking", + tables: [ + { id: "fact", name: "fact_event", kind: "promote", suggested_columns: ["cod_paz"] }, + { id: "patient", name: "dim_patient", kind: "promote", suggested_columns: ["cod_paz"] }, + ], + advance: true, + }, + null, + null, + ctx, + ); + + assert.match(result.content[0].text, /Fase resta aperta/i); + assert.match(result.content[0].text, /join/i); + } finally { + _handler = null; + } +}); diff --git a/harness/.pi/extensions/gate/__tests__/gate_write_schema_linking_autoclose.test.js b/harness/.pi/extensions/gate/__tests__/gate_write_schema_linking_autoclose.test.js new file mode 100644 index 00000000..eeaefe07 --- /dev/null +++ b/harness/.pi/extensions/gate/__tests__/gate_write_schema_linking_autoclose.test.js @@ -0,0 +1,53 @@ +const test = require("node:test"); +const assert = require("node:assert"); +const cp = require("node:child_process"); +const { createRequire } = require("node:module"); +const path = require("node:path"); + +const GATE = path.join(__dirname, "..", "..", "tht-gate.js"); + +if (typeof globalThis.require === "undefined") { + globalThis.require = createRequire(GATE); +} + +test("writing reviewed structured joins closes phase four", async () => { + const calls = []; + const original = cp.execFileSync; + cp.execFileSync = (_file, args) => { + calls.push(args.join(" ")); + if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 4\n"; + return ""; + }; + + try { + const gate = require(GATE); + const { createFakePi } = require("./fake_pi_runtime.js"); + const { pi, ctx, tools } = createFakePi(); + ctx.cwd = "/nonexistent-thothii-test-cwd"; + gate.default(pi); + + const result = await tools.get("write_schema_linking").def.execute( + "call-write-linking", + { + session: "s1", + schema_linking: { + question: "q", + candidates: [ + { kind: "table", name: "fact_event", decision: "promoted" }, + { kind: "table", name: "dim_patient", decision: "promoted" }, + ], + joins: [{ from: "fact_event.cod_paz", to: "dim_patient.cod_paz" }], + }, + }, + null, + null, + ctx, + ); + + assert.ok(calls.includes("session set-schema-linking s1 --file -")); + assert.ok(calls.includes("phase advance --session s1")); + assert.match(result.content[0].text, /Fase avanzata automaticamente/); + } finally { + cp.execFileSync = original; + } +}); diff --git a/harness/.pi/extensions/tht-gate.js b/harness/.pi/extensions/tht-gate.js index 8f8643e6..05817c9f 100644 --- a/harness/.pi/extensions/tht-gate.js +++ b/harness/.pi/extensions/tht-gate.js @@ -1117,6 +1117,7 @@ export default function (pi) { const adv = advance ? forceAdvance(ctx, session) : { advanced: false }; const parts = [`Schema linking registrato dal reviewer (${n} tabelle + colonne curate). schema_linking.json scritto.`]; if (adv.advanced) parts.push("Fase avanzata automaticamente — nessun gate aggiuntivo necessario."); + else if (advance && adv.error) parts.push(`Fase resta aperta: ${adv.error}`); return textResult(parts.join(" ")); } catch (fatal) { const msg = (fatal.stderr || fatal.message || String(fatal)).toString().trim(); @@ -1636,9 +1637,13 @@ export default function (pi) { ["session", "set-schema-linking", session, "--file", "-"], JSON.stringify(schema_linking), ); - return textResult( - `schema_linking.json scritto e validato per la sessione ${session}.`, - ); + const message = `schema_linking.json scritto e validato per la sessione ${session}.`; + if (currentPhase(ctx, session) !== 4) return textResult(message); + const advanced = forceAdvance(ctx, session); + if (advanced.error) { + return textResult(`${message} Fase non avanzata: ${advanced.error}`); + } + return textResult(`${message} Fase avanzata automaticamente.`); } catch (e) { const cliMsg = (e.stderr || e.message || String(e)).toString().trim(); return textResult(`${cliMsg} Correggi schema_linking e riprova.`); diff --git a/harness/.pi/skills/tht-sessione/SKILL.md b/harness/.pi/skills/tht-sessione/SKILL.md index 24ef3773..38463e48 100644 --- a/harness/.pi/skills/tht-sessione/SKILL.md +++ b/harness/.pi/skills/tht-sessione/SKILL.md @@ -31,9 +31,10 @@ closes the phase itself; a `reviewer_confirm kind:"phase"` summary gate exists o completeness is a human judgment (F1, F2 with recorded memories, F5). A `reviewer_decide`/ `reviewer_select` choice records its OWN decision but does NOT advance the phase. `advance:true` on `reviewer_decide` auto-advances only F2 (empty memory) and F6 (skipped/empty) — never a -phase that recorded substantive decisions. FIVE gates close their phase themselves, because -there the human interaction IS the phase approval: `rewrite_question` (F3), -`reviewer_schema_linking` with `advance:true` (F4), the LAST `reviewer_confirm +phase that recorded substantive decisions. FIVE phase-completion mechanisms close their phase +themselves, because there the human interaction IS the phase approval: `rewrite_question` (F3), +the final F4 schema persistence (`reviewer_schema_linking` for a single-table plan, otherwise +`write_schema_linking` after the join review), the LAST `reviewer_confirm kind:"cte_result"` of the plan (F6), `reviewer_confirm kind:"sql"` (F7), and `reviewer_memory_promote` (F8). @@ -42,7 +43,7 @@ kind:"cte_result"` of the plan (F6), `reviewer_confirm kind:"sql"` (F7), and | F1 chiarimento | — | `reviewer_confirm kind:"phase"` | | F2 memoria | — | `advance:true` only if nothing recorded; else `reviewer_confirm kind:"phase"` | | F3 riscrittura | `question.md` | `rewrite_question` records approval and advances automatically | -| F4 schema_linking | `schema_linking.json` | `reviewer_schema_linking` with `advance:true` closes the phase itself (the curation IS the approval; `reviewer_confirm kind:"phase"` only as fallback if it reports an error). Promoted columns are the reviewer-approved OUTPUT columns — project exactly those in the final SELECT. | +| F4 schema_linking | `schema_linking.json` | `reviewer_schema_linking(advance:true)` closes a single-table plan. With multiple promoted tables it deliberately keeps F4 open until the separate join review is persisted into `schema_linking.json`; the succeeding `write_schema_linking` closes F4 automatically. Never add `reviewer_confirm kind:"phase"`. Promoted columns are the reviewer-approved OUTPUT columns — project exactly those in the final SELECT. | | F5 sintesi | — | `reviewer_confirm kind:"phase"` (after `tht session check`) | | F6 cte | `cte_plan.json`, `ctes/`, `cte_tests.json` | approve each CTE with `kind:"cte_result"`; approving the LAST CTE of the plan closes the phase automatically (`kind:"phase"` only as fallback if the auto-close reports an error) | | F7 sql_finale | `sql_final.sql` | `kind:"sql"` records `sql_approved` AND closes the phase automatically (`kind:"phase"` only as fallback if it reports an error) | @@ -60,8 +61,8 @@ kind:"cte_result"` of the plan (F6), `reviewer_confirm kind:"sql"` (F7), and with the deliberate `reviewer_confirm kind:"phase"` summary gate. The `advance:true` flag on `reviewer_decide` is a shortcut that auto-advances ONLY F2 when the memory phase recorded nothing and F6 when it is skipped/empty; everywhere else it is a silent no-op, - so never rely on it to advance. The self-closing gates are the five listed above - (F3 `rewrite_question`, F4 `reviewer_schema_linking` `advance:true`, F6 last + so never rely on it to advance. The self-closing mechanisms are the five listed above + (F3 `rewrite_question`, F4 final schema persistence, F6 last `kind:"cte_result"`, F7 `kind:"sql"`, F8 `reviewer_memory_promote`) — after one of those, do NOT add a `reviewer_confirm kind:"phase"` that merely echoes it; the phase is already closed. @@ -286,6 +287,9 @@ Prerequisite: Phase 3 closed. columns: project exactly those in the final SELECT (Phase 6/7); you remain free to reference other columns as join keys or filter predicates when the query requires them. + Call `reviewer_schema_linking` before the join review. For a multi-table plan, + `advance:true` will report that F4 remains open because the structured joins are + not present yet; this is expected. Stay in F4 and continue with the join-only gate. Propose **all required joins together in a separate, join-only** `reviewer_decide(advance:false)`, registering `join_modified`. Do not mix `join_modified` with other decision types in that call. The gate renders this proposal @@ -317,7 +321,8 @@ Prerequisite: Phase 3 closed. excluded:[...], open_questions:[], concept_formulas:[]}` — `candidates`/`excluded` are owned by `reviewer_schema_linking`/`sync-schema-linking` (step 2), so if you call `write_schema_linking` after step 2, carry over its `candidates`/`excluded` - unchanged rather than overwriting them. Then close with `reviewer_confirm + unchanged rather than overwriting them. After the reviewer-approved joins are present, + `write_schema_linking` closes F4 automatically. Do not add a `reviewer_confirm kind:"phase"`. Do NOT run `tht session check` (that's Phase 5). ## Phase 5 — Synthesis diff --git a/harness/tests/test_cli_config_environment.py b/harness/tests/test_cli_config_environment.py new file mode 100644 index 00000000..ab42a754 --- /dev/null +++ b/harness/tests/test_cli_config_environment.py @@ -0,0 +1,20 @@ +import os +from importlib import reload +from pathlib import Path + + +def test_cli_default_config_honors_tht_config(monkeypatch): + from tht.cli import config_cmd + + previous = os.environ.get("THT_CONFIG") + try: + monkeypatch.setenv("THT_CONFIG", "/app/harness/config/tht.yaml") + reload(config_cmd) + + assert config_cmd.CONFIG_OPT.default == Path("/app/harness/config/tht.yaml") + finally: + if previous is None: + monkeypatch.delenv("THT_CONFIG", raising=False) + else: + monkeypatch.setenv("THT_CONFIG", previous) + reload(config_cmd) diff --git a/harness/tests/test_phase_schema_linking_joins.py b/harness/tests/test_phase_schema_linking_joins.py new file mode 100644 index 00000000..69e9314a --- /dev/null +++ b/harness/tests/test_phase_schema_linking_joins.py @@ -0,0 +1,63 @@ +import json + +from tht.decisions import append_decision +from tht.phase import advance_problems + + +def _phase_four_session(tmp_path): + session = tmp_path / "session" + session.mkdir() + for phase in range(1, 4): + append_decision(session, type="phase_approved", subject=f"phase:{phase}") + return session + + +def test_phase_four_cannot_close_multiple_tables_without_reviewed_structured_joins(tmp_path): + session = _phase_four_session(tmp_path) + (session / "schema_linking.json").write_text(json.dumps({ + "question": "q", + "candidates": [ + {"kind": "table", "name": "fact_event", "decision": "promoted"}, + {"kind": "table", "name": "dim_patient", "decision": "promoted"}, + ], + "joins": [], + })) + + problems = advance_problems(session, 4) + + assert any("join" in problem.lower() for problem in problems) + + +def test_phase_four_allows_a_single_promoted_table_without_joins(tmp_path): + session = _phase_four_session(tmp_path) + (session / "schema_linking.json").write_text(json.dumps({ + "question": "q", + "candidates": [ + {"kind": "table", "name": "dim_patient", "decision": "promoted"}, + ], + "joins": [], + })) + + assert advance_problems(session, 4) == [] + + +def test_phase_four_allows_reviewed_structured_joins_for_multiple_tables(tmp_path): + session = _phase_four_session(tmp_path) + append_decision( + session, + type="join_modified", + subject="fact_event_to_patient", + detail="fact_event.cod_paz = dim_patient.cod_paz", + ) + (session / "schema_linking.json").write_text(json.dumps({ + "question": "q", + "candidates": [ + {"kind": "table", "name": "fact_event", "decision": "promoted"}, + {"kind": "table", "name": "dim_patient", "decision": "promoted"}, + ], + "joins": [ + {"from": "fact_event.cod_paz", "to": "dim_patient.cod_paz"}, + ], + })) + + assert advance_problems(session, 4) == [] diff --git a/harness/tests/test_psd_local_compose_contract.py b/harness/tests/test_psd_local_compose_contract.py new file mode 100644 index 00000000..aa1ba22b --- /dev/null +++ b/harness/tests/test_psd_local_compose_contract.py @@ -0,0 +1,124 @@ +from pathlib import Path +import shutil +import subprocess + +import yaml + + +class ComposeLoader(yaml.SafeLoader): + pass + + +def _compose_override(loader, node): + if isinstance(node, yaml.MappingNode): + return loader.construct_mapping(node) + return loader.construct_sequence(node) + + +ComposeLoader.add_constructor("!override", _compose_override) + + +def test_psd_overlay_uses_generated_workspace_for_default_and_named_commands(): + root = Path(__file__).resolve().parents[2] + compose = yaml.load( + (root / "deploy/compose.psd-local.yaml.example").read_text(), + Loader=ComposeLoader, + ) + core = compose["services"]["core"] + + assert core["environment"]["THT_CONFIG"] == "/app/harness/config/tht.yaml" + assert core["environment"]["THT_SECRETS_FILE"] == "/run/secrets/thothii.secrets" + for name in ( + "THT_DB_NAME", "THT_DWH_REST_URL", "THT_VEC_REST_URL", + "THT_VEC_WRITE_REST_URL", "THT_OLLAMA_URL", "THT_PROFILE", + "PI_PROVIDER", "PI_MODEL", "PI_THINKING", + ): + assert name in core["environment"] + def target(volume): + if isinstance(volume, dict): + return volume["target"] + parts = volume.rsplit(":", 2) + return parts[-2] if parts[-1] in {"ro", "rw"} else parts[-1] + + targets = {target(volume) for volume in core["volumes"]} + assert "/app/harness/config/tht.yaml" in targets + assert "/app/harness/workspaces/psd.yaml" not in targets + assert "/data/workspaces/psd/config/tht.yaml" not in targets + assert "/data" in targets + assert "/home/thoth/.pi" in targets + assert "/home/thoth/.pi/agent/models.json" in targets + assert "/home/thoth/.pi/agent/settings.json" in targets + assert "/data/evidence" in targets + assert "/run/secrets/thothii.secrets" in targets + assert set(compose["volumes"]) == {"thoth_data", "thoth_pi_config"} + assert core["networks"]["default"]["aliases"] == ["core", "thothii-core"] + assert compose["networks"]["default"] == { + "external": True, + "name": "thothii_default", + } + + +def test_core_image_prepares_the_writable_pi_profile_before_mounting_config_files(): + root = Path(__file__).resolve().parents[2] + dockerfile = (root / "docker/core.Dockerfile").read_text() + + assert "mkdir -p /home/thoth/.pi/agent" in dockerfile + assert "chown -R thoth:thoth /home/thoth/.pi" in dockerfile + assert ( + "cp --remove-destination /app/harness/workspaces/local.yaml " + "/app/harness/config/tht.yaml" in dockerfile + ) + assert "ln -sf /app/harness/workspaces/local.yaml /app/harness/config/tht.yaml" not in dockerfile + assert ( + "ln -sfn /app/harness/config/tht.yaml /app/harness/workspaces/psd.yaml" + in dockerfile + ) + + +def test_psd_bootstrap_materializes_the_base_compose_env_file(tmp_path): + source_root = Path(__file__).resolve().parents[2] + root = tmp_path / "ThothII" + (root / "scripts").mkdir(parents=True) + (root / "deploy/workspaces").mkdir(parents=True) + shutil.copy( + source_root / "scripts/bootstrap-local-psd-docker-config.sh", + root / "scripts/bootstrap-local-psd-docker-config.sh", + ) + shutil.copy( + source_root / "deploy/compose.psd-local.yaml.example", + root / "deploy/compose.psd-local.yaml.example", + ) + shutil.copy( + source_root / "deploy/workspaces/psd.yaml.example", + root / "deploy/workspaces/psd.yaml.example", + ) + source_env = tmp_path / "source.env" + source_env.write_text("\n".join([ + "THT_DB_NAME=postgres", + "THT_DWH_REST_URL=https://dwh.invalid/", + "THT_VEC_REST_URL=https://vec.invalid/read/", + "THT_VEC_WRITE_REST_URL=https://vec.invalid/write/", + "THT_DWH_API_KEY=dwh", + "THT_VEC_API_KEY=reader", + "THT_VEC_WRITE_API_KEY=writer", + "", + ])) + workspace = tmp_path / "workspace" + workspace.mkdir() + auth = tmp_path / "auth.json" + auth.write_text('{"zai":{"key":"model"}}') + + subprocess.run( + [ + "sh", str(root / "scripts/bootstrap-local-psd-docker-config.sh"), + str(source_env), str(workspace), str(auth), + ], + check=True, + capture_output=True, + text=True, + ) + + assert (root / "deploy/thothii.env").is_file() + assert "THT_SECRETS_FILE=./deploy/secrets/thothii.secrets" in ( + root / ".env" + ).read_text() diff --git a/harness/tht/cli/config_cmd.py b/harness/tht/cli/config_cmd.py index b5f55d3d..375eb5c6 100644 --- a/harness/tht/cli/config_cmd.py +++ b/harness/tht/cli/config_cmd.py @@ -1,3 +1,4 @@ +import os from pathlib import Path import typer @@ -7,7 +8,10 @@ from tht.config import ConfigError, load_config config_app = typer.Typer(help="Gestione configurazione") CONFIG_OPT = typer.Option( - Path("config/tht.yaml"), "--config", "-c", help="Percorso del file di configurazione." + Path(os.environ.get("THT_CONFIG", "config/tht.yaml")), + "--config", + "-c", + help="Percorso del file di configurazione.", ) diff --git a/harness/tht/phase.py b/harness/tht/phase.py index 41df97e4..62cfedb8 100644 --- a/harness/tht/phase.py +++ b/harness/tht/phase.py @@ -183,6 +183,28 @@ def advance_problems(source: Path | SessionSnapshot, phase: int) -> list[str]: l'evaluator generico (F2 pieno) entra in un secondo momento. """ problems: list[str] = [] + if phase == 4: + raw = _artifact(source, "schema_linking", "schema_linking.json") + if raw is None: + problems.append("schema_linking.json assente (Fase 4)") + else: + try: + linking = SchemaLinking.model_validate(json.loads(raw)) + except (json.JSONDecodeError, ValidationError) as e: + problems.append(f"schema_linking.json non valido (Fase 4): {e}") + else: + promoted_tables = { + candidate.name + for candidate in linking.candidates + if candidate.kind == "table" and candidate.decision == "promoted" + } + if len(promoted_tables) > 1 and ( + not linking.joins or not _has_decision(source, "join_modified") + ): + problems.append( + "Fase 4: più tabelle promosse richiedono join strutturati in " + "schema_linking.json e una decisione join_modified del reviewer" + ) if phase == 3 and not _has_decision(source, "question_rewritten"): problems.append("manca la decisione question_rewritten (Fase 3)") if phase == 5: diff --git a/scripts/bootstrap-local-psd-docker-config.sh b/scripts/bootstrap-local-psd-docker-config.sh index 1077ccfe..dd5b9f0e 100644 --- a/scripts/bootstrap-local-psd-docker-config.sh +++ b/scripts/bootstrap-local-psd-docker-config.sh @@ -25,12 +25,13 @@ test -n "$model_key" umask 077 mkdir -p "$root/deploy/secrets" "$root/deploy/workspaces" +: >"$root/deploy/thothii.env" cp "$root/deploy/compose.psd-local.yaml.example" "$root/deploy/compose.psd-local.yaml" cp "$root/deploy/workspaces/psd.yaml.example" "$root/deploy/workspaces/psd.yaml" cat >"$root/.env" <>"$root/deploy/secrets/thothii.secrets" fi -chmod 600 "$root/.env" "$root/deploy/secrets/thothii.secrets" +chmod 600 "$root/.env" "$root/deploy/thothii.env" "$root/deploy/secrets/thothii.secrets" echo "Local PSD Docker configuration materialized without printing secret values."