Fix session resume and PSD container configuration

This commit is contained in:
2026-07-20 20:22:47 +02:00
parent ec9b12dff4
commit 0cf09777f2
17 changed files with 486 additions and 22 deletions
+2
View File
@@ -40,6 +40,8 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
harnessDir: config.harnessDir, harnessDir: config.harnessDir,
configPath: process.env.THT_CONFIG ?? "config/tht.yaml", configPath: process.env.THT_CONFIG ?? "config/tht.yaml",
dataRoot: config.dataRoot, dataRoot: config.dataRoot,
secretsFile: config.secretsFile,
secretFiles: config.secretFiles,
}); });
const mgr = deps?.mgr ?? new PiProcessManager(config, deps?.spawnFn ? { spawnFn: deps.spawnFn } : undefined); const mgr = deps?.mgr ?? new PiProcessManager(config, deps?.spawnFn ? { spawnFn: deps.spawnFn } : undefined);
const hub = deps?.hub ?? new SseHub(); const hub = deps?.hub ?? new SseHub();
+11 -2
View File
@@ -68,9 +68,18 @@ export class PiProcessManager {
// this managed session process the adapter values already loaded by the core // this managed session process the adapter values already loaded by the core
// entrypoint; the generic provider helper continues to scrub them by default. // entrypoint; the generic provider helper continues to scrub them by default.
for (const name of [ 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) { ] 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; delete env.THT_DATA_ROOT;
if (this.cfg.dataRoot !== undefined) env.THT_DATA_ROOT = this.cfg.dataRoot; if (this.cfg.dataRoot !== undefined) env.THT_DATA_ROOT = this.cfg.dataRoot;
+13 -1
View File
@@ -2,8 +2,9 @@ import { spawn } from "node:child_process";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { clearPrincipalEnvironment, principalEnvironment, type PrincipalContext } from "../auth/principal.js"; 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; thtBin: string;
harnessDir: string; harnessDir: string;
configPath: string; configPath: string;
@@ -82,6 +83,17 @@ export class ThtRunner {
clearPrincipalEnvironment(env); clearPrincipalEnvironment(env);
if (this.cfg.dataRoot !== undefined) env.THT_DATA_ROOT = this.cfg.dataRoot; if (this.cfg.dataRoot !== undefined) env.THT_DATA_ROOT = this.cfg.dataRoot;
if (this.principal) Object.assign(env, principalEnvironment(this.principal)); 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), { const ch = spawn(this.cfg.thtBin, this.buildArgv(args, workspace), {
cwd: this.cfg.harnessDir, cwd: this.cfg.harnessDir,
env, env,
+15 -1
View File
@@ -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 () => { test("session Pi spawn reads the single secret bundle and scrubs its path", async () => {
const secret = path.resolve(__dirname, `.bundle-${process.pid}`); 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); chmodSync(secret, 0o600);
const calls: any[][] = []; const calls: any[][] = [];
const child = recordingChild(); const child = recordingChild();
@@ -361,6 +368,13 @@ test("session Pi spawn reads the single secret bundle and scrubs its path", asyn
try { try {
await mgr.spawnFor("bundle-session", { provider: "openai" }); await mgr.spawnFor("bundle-session", { provider: "openai" });
expect(calls[0][2].env.OPENAI_API_KEY).toBe("bundle-secret"); 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"); expect(calls[0][2].env).not.toHaveProperty("THT_SECRETS_FILE");
} finally { } finally {
mgr.teardown("bundle-session"); mgr.teardown("bundle-session");
+38
View File
@@ -1,5 +1,8 @@
import { test, expect, vi } from "vitest"; import { test, expect, vi } from "vitest";
import { EventEmitter } from "node:events"; 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"; import { ThtRunner } from "../src/tht/tht-runner.js";
// Spy on child_process.spawn so we can capture the resolved argv (incl. -c config) // 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 () => { test("run omits ambient THT_DATA_ROOT when config does not provide one", async () => {
const previousDataRoot = process.env.THT_DATA_ROOT; const previousDataRoot = process.env.THT_DATA_ROOT;
const previousCredential = process.env.PI_PROVIDER_API_KEY; const previousCredential = process.env.PI_PROVIDER_API_KEY;
+33 -3
View File
@@ -1,11 +1,41 @@
services: services:
core: core:
environment: 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_DOCS_ROOT: /data/workspaces/psd
THT_CONFIG: /app/harness/config/tht.yaml
extra_hosts: extra_hosts:
- host.docker.internal:host-gateway - host.docker.internal:host-gateway
networks: !override
default:
aliases: [core, thothii-core]
volumes: 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 - ./deploy/workspaces/psd.yaml:/app/harness/config/tht.yaml:ro
- type: bind - ${THT_PSD_WORKSPACE_HOST_PATH:?set THT_PSD_WORKSPACE_HOST_PATH}:/data/workspaces/psd
source: ${THT_PSD_WORKSPACE_HOST_PATH:?set THT_PSD_WORKSPACE_HOST_PATH}
target: /data/workspaces/psd volumes:
thoth_data:
thoth_pi_config:
networks:
default:
external: true
name: thothii_default
+6 -2
View File
@@ -29,6 +29,7 @@ RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
# Utente non-root # Utente non-root
RUN useradd --create-home --uid 10001 --shell /bin/bash thoth 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/ COPY harness/ /app/harness/
# Pi scrive lock/settings in .pi: ownership thoth per sopravvivere al rebuild # 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 \ && /opt/venv/bin/pip install --no-cache-dir --upgrade pip \
&& (cd /app/harness && /opt/venv/bin/pip install --no-cache-dir .) \ && (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 && 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. # Default locale e alias PSD convergono sul file canonico. Il CLI onora anche
RUN mkdir -p /app/harness/config && ln -sf /app/harness/workspaces/local.yaml /app/harness/config/tht.yaml # 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 # PiProcessManager (backend) prepende harnessDir/.venv/bin al PATH del child Pi → symlink al venv reale
RUN ln -s /opt/venv /app/harness/.venv RUN ln -s /opt/venv /app/harness/.venv
@@ -245,3 +245,61 @@ test("reviewer_schema_linking returns a textResult (not a throw) when a proposed
_handler = null; _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;
}
});
@@ -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;
}
});
+8 -3
View File
@@ -1117,6 +1117,7 @@ export default function (pi) {
const adv = advance ? forceAdvance(ctx, session) : { advanced: false }; const adv = advance ? forceAdvance(ctx, session) : { advanced: false };
const parts = [`Schema linking registrato dal reviewer (${n} tabelle + colonne curate). schema_linking.json scritto.`]; 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."); 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(" ")); return textResult(parts.join(" "));
} catch (fatal) { } catch (fatal) {
const msg = (fatal.stderr || fatal.message || String(fatal)).toString().trim(); const msg = (fatal.stderr || fatal.message || String(fatal)).toString().trim();
@@ -1636,9 +1637,13 @@ export default function (pi) {
["session", "set-schema-linking", session, "--file", "-"], ["session", "set-schema-linking", session, "--file", "-"],
JSON.stringify(schema_linking), JSON.stringify(schema_linking),
); );
return textResult( const message = `schema_linking.json scritto e validato per la sessione ${session}.`;
`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) { } catch (e) {
const cliMsg = (e.stderr || e.message || String(e)).toString().trim(); const cliMsg = (e.stderr || e.message || String(e)).toString().trim();
return textResult(`${cliMsg} Correggi schema_linking e riprova.`); return textResult(`${cliMsg} Correggi schema_linking e riprova.`);
+12 -7
View File
@@ -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`/ 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` `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 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 phase that recorded substantive decisions. FIVE phase-completion mechanisms close their phase
there the human interaction IS the phase approval: `rewrite_question` (F3), themselves, because there the human interaction IS the phase approval: `rewrite_question` (F3),
`reviewer_schema_linking` with `advance:true` (F4), the LAST `reviewer_confirm 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 kind:"cte_result"` of the plan (F6), `reviewer_confirm kind:"sql"` (F7), and
`reviewer_memory_promote` (F8). `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"` | | F1 chiarimento | — | `reviewer_confirm kind:"phase"` |
| F2 memoria | — | `advance:true` only if nothing recorded; else `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 | | 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`) | | 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) | | 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) | | 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` 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 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, 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 so never rely on it to advance. The self-closing mechanisms are the five listed above
(F3 `rewrite_question`, F4 `reviewer_schema_linking` `advance:true`, F6 last (F3 `rewrite_question`, F4 final schema persistence, F6 last
`kind:"cte_result"`, F7 `kind:"sql"`, F8 `reviewer_memory_promote`) — after one of `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 those, do NOT add a `reviewer_confirm kind:"phase"` that merely echoes it; the phase is
already closed. already closed.
@@ -286,6 +287,9 @@ Prerequisite: Phase 3 closed.
columns: project exactly those in the final SELECT (Phase 6/7); you remain free 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 to reference other columns as join keys or filter predicates when the query
requires them. 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** Propose **all required joins together in a separate, join-only**
`reviewer_decide(advance:false)`, registering `join_modified`. Do not mix `reviewer_decide(advance:false)`, registering `join_modified`. Do not mix
`join_modified` with other decision types in that call. The gate renders this proposal `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` excluded:[...], open_questions:[], concept_formulas:[]}` — `candidates`/`excluded`
are owned by `reviewer_schema_linking`/`sync-schema-linking` (step 2), so if you 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` 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). kind:"phase"`. Do NOT run `tht session check` (that's Phase 5).
## Phase 5 — Synthesis ## Phase 5 — Synthesis
@@ -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)
@@ -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) == []
@@ -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()
+5 -1
View File
@@ -1,3 +1,4 @@
import os
from pathlib import Path from pathlib import Path
import typer import typer
@@ -7,7 +8,10 @@ from tht.config import ConfigError, load_config
config_app = typer.Typer(help="Gestione configurazione") config_app = typer.Typer(help="Gestione configurazione")
CONFIG_OPT = typer.Option( 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.",
) )
+22
View File
@@ -183,6 +183,28 @@ def advance_problems(source: Path | SessionSnapshot, phase: int) -> list[str]:
l'evaluator generico (F2 pieno) entra in un secondo momento. l'evaluator generico (F2 pieno) entra in un secondo momento.
""" """
problems: list[str] = [] 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"): if phase == 3 and not _has_decision(source, "question_rewritten"):
problems.append("manca la decisione question_rewritten (Fase 3)") problems.append("manca la decisione question_rewritten (Fase 3)")
if phase == 5: if phase == 5:
+3 -2
View File
@@ -25,12 +25,13 @@ test -n "$model_key"
umask 077 umask 077
mkdir -p "$root/deploy/secrets" "$root/deploy/workspaces" 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/compose.psd-local.yaml.example" "$root/deploy/compose.psd-local.yaml"
cp "$root/deploy/workspaces/psd.yaml.example" "$root/deploy/workspaces/psd.yaml" cp "$root/deploy/workspaces/psd.yaml.example" "$root/deploy/workspaces/psd.yaml"
cat >"$root/.env" <<EOF cat >"$root/.env" <<EOF
COMPOSE_FILE=compose.yaml:deploy/compose.psd-local.yaml COMPOSE_FILE=compose.yaml:deploy/compose.psd-local.yaml
COMPOSE_PROFILES= COMPOSE_PROFILES=
THT_SECRETS_FILE=deploy/secrets/thothii.secrets THT_SECRETS_FILE=./deploy/secrets/thothii.secrets
THOTH_HTTP_PORT=8080 THOTH_HTTP_PORT=8080
AUTH_MODE=none AUTH_MODE=none
THOTH_PUBLIC_EXPOSURE=false THOTH_PUBLIC_EXPOSURE=false
@@ -64,5 +65,5 @@ EOF
else else
printf '%s\n' 'THT_CA=/etc/ssl/certs/ca-certificates.crt' >>"$root/deploy/secrets/thothii.secrets" printf '%s\n' 'THT_CA=/etc/ssl/certs/ca-certificates.crt' >>"$root/deploy/secrets/thothii.secrets"
fi 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." echo "Local PSD Docker configuration materialized without printing secret values."