fix: make join review read only

This commit is contained in:
User
2026-07-14 15:06:16 +02:00
parent c7474f3852
commit 27af6733c8
11 changed files with 378 additions and 22 deletions
@@ -16,6 +16,7 @@ const {
buildInfoRequest,
buildFreetextRequest,
buildSchemaLinkingRequest,
buildJoinReviewRequest,
withChildLinkage,
} = require("../builders.js");
@@ -130,6 +131,34 @@ test("buildSchemaLinkingRequest carries tables + reserved", () => {
assert.deepEqual(out.reserved, ["back", "exit", "other"]);
});
test("buildJoinReviewRequest exposes joins as read-only review items", () => {
const out = buildJoinReviewRequest({
id: "u8",
phase: "F4",
title: "F4 — Proposed joins",
options: [
{
id: "patient-events",
label: "Patient to events",
detail: "JOIN dim_patient p ON p.cod_paz = e.cod_paz",
rationale: "Standard patient key",
},
],
});
assert.equal(out.widget, "join-review");
assert.equal(out.confirm_label, "Continue");
assert.deepEqual(out.options, [
{
id: "patient-events",
label: "Patient to events",
detail: "JOIN dim_patient p ON p.cod_paz = e.cod_paz",
rationale: "Standard patient key",
},
]);
assert.deepEqual(out.reserved, ["back", "exit", "other"]);
});
test("withChildLinkage sets option.opens and returns the option", () => {
const child = { widget: "freetext", title: "Motivazione del rifiuto" };
const option = withChildLinkage({ id: "reject", label: "Rifiuta" }, child);
@@ -0,0 +1,122 @@
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);
}
const JOIN_OPTIONS = [
{
id: "procedure-event",
label: "Procedure to event",
decision: {
type: "join_modified",
subject: "procedure-event",
detail: "JOIN procedure p ON p.patient_id = e.patient_id",
rationale: "Patient grain",
},
},
{
id: "event-time",
label: "Event to time",
decision: {
type: "join_modified",
subject: "event-time",
detail: "JOIN dim_time d ON d.day_key = e.time_key",
rationale: "DWH time key",
},
},
];
function shellStub(calls) {
return (_file, args) => {
calls.push(args.join(" "));
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";
return "";
};
}
test("join-only reviewer_decide is read-only and Continue persists every join", async () => {
const calls = [];
const original = cp.execFileSync;
cp.execFileSync = shellStub(calls);
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);
let descriptor;
ctx.ui.input = async (title) => {
descriptor = JSON.parse(title);
return JSON.stringify({
id: descriptor.id,
kind: "join-review",
choices: descriptor.options.map((option) => option.id),
});
};
const tool = tools.get("reviewer_decide");
await tool.def.execute(
"call-joins",
{ session: "s1", title: "Review joins", options: JOIN_OPTIONS, advance: false },
null,
null,
ctx,
);
assert.equal(descriptor.widget, "join-review");
assert.equal(descriptor.options[0].detail, JOIN_OPTIONS[0].decision.detail);
assert.equal(descriptor.options[0].rationale, JOIN_OPTIONS[0].decision.rationale);
const decisions = calls.filter((call) => call.startsWith("decision add"));
assert.equal(decisions.length, 2);
assert.ok(decisions.every((call) => call.includes("--type join_modified")));
} finally {
cp.execFileSync = original;
}
});
test("Other specify rejects the current join proposal without persisting it", async () => {
const calls = [];
const original = cp.execFileSync;
cp.execFileSync = shellStub(calls);
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);
ctx.ui.input = async (title) => {
const descriptor = JSON.parse(title);
return JSON.stringify({
id: descriptor.id,
control: "freetext",
text: "Use the episode composite key",
});
};
const tool = tools.get("reviewer_decide");
const result = await tool.def.execute(
"call-other",
{ session: "s1", title: "Review joins", options: JOIN_OPTIONS, advance: false },
null,
null,
ctx,
);
assert.equal(calls.filter((call) => call.startsWith("decision add")).length, 0);
assert.match(result.content[0].text, /Use the episode composite key/);
} finally {
cp.execFileSync = original;
}
});
+23
View File
@@ -177,6 +177,28 @@ function buildSchemaLinkingRequest({ id, phase, title, tables }) {
};
}
// Build a blocking, read-only join review. The reviewer can accept the complete
// proposal or use Other to request a textual correction; individual joins are
// deliberately not selectable because omitting one could create a Cartesian product.
function buildJoinReviewRequest({ id, phase, title, options }) {
requireString(title, "title", "join-review");
const opts = requireArray(options, "options", "join-review");
if (opts.length === 0) {
throw new Error("builders: join-review requires at least one option");
}
return {
type: "ui_request",
id,
phase,
schema_version: SCHEMA_VERSION,
widget: "join-review",
title,
options: [...opts],
confirm_label: "Continue",
reserved: RESERVED,
};
}
// Attach a child-widget spec to an option (linkage, §4.2). Returns the option.
function withChildLinkage(option, widgetSpec) {
option.opens = widgetSpec;
@@ -190,6 +212,7 @@ module.exports = {
buildInfoRequest,
buildFreetextRequest,
buildSchemaLinkingRequest,
buildJoinReviewRequest,
withChildLinkage,
SCHEMA_VERSION,
RESERVED,
+29 -9
View File
@@ -31,6 +31,7 @@ import {
buildMultiselectRequest,
buildArtifactGate,
buildSchemaLinkingRequest,
buildJoinReviewRequest,
} from "./gate/builders.js";
import {
validateCtePlanV2,
@@ -728,6 +729,9 @@ export default function (pi) {
const meritOptions = opts
.filter((o) => !isReserved(o.label))
.map((o) => ({ id: o.id, label: o.label }));
const joinOnly = meritOptions.length > 0 && opts
.filter((o) => !isReserved(o.label))
.every((o) => o.decision.type === "join_modified");
if (shouldSkipEmptyDecide({ meritCount: meritOptions.length, allowEmpty: params.allow_empty ?? false, advance })) {
await ctx.ui.notify(
"Nessuna memory riutilizzabile per questa domanda — passo alla fase successiva.",
@@ -738,14 +742,28 @@ export default function (pi) {
"Fase memoria vuota: nessuna decisione da registrare, avanzamento automatico alla fase successiva.",
);
}
const widget = buildMultiselectRequest({
id: `u${Date.now()}`,
phase,
title: phase === "F2" ? memorySelectionWidgetProps(opts).title : title,
allowEmpty: params.allow_empty ?? false,
options: meritOptions,
...(phase === "F2" ? memorySelectionWidgetProps(opts) : {}),
});
const widget = joinOnly
? buildJoinReviewRequest({
id: `u${Date.now()}`,
phase,
title,
options: opts
.filter((o) => !isReserved(o.label))
.map((o) => ({
id: o.id,
label: o.label,
detail: o.decision.detail ?? "",
rationale: o.decision.rationale ?? "",
})),
})
: buildMultiselectRequest({
id: `u${Date.now()}`,
phase,
title: phase === "F2" ? memorySelectionWidgetProps(opts).title : title,
allowEmpty: params.allow_empty ?? false,
options: meritOptions,
...(phase === "F2" ? memorySelectionWidgetProps(opts) : {}),
});
const resp = await emitAndWait(ctx, widget);
if (resp.control === "freetext") {
return textResult(
@@ -756,7 +774,9 @@ export default function (pi) {
return textResult("Il reviewer vuole tornare indietro.");
if (resp.control === "exit")
return textResult("Il reviewer vuole uscire.");
const chosen = opts.filter((o) => (resp.choices ?? []).includes(o.id));
const chosen = joinOnly
? opts.filter((o) => !isReserved(o.label))
: opts.filter((o) => (resp.choices ?? []).includes(o.id));
for (const c of chosen) {
const d = c.decision;
const err = relayIfThtFails(ctx, decisionAddArgs(session, d), "");
+11 -5
View File
@@ -13,8 +13,8 @@ NEVER advance a phase or record a decision without explicit reviewer confirmatio
The reviewer answers via the gate's **widgets** (built by `tht-gate.js`):
`reviewer_select` (single pick; a chosen option carrying a `decision` payload IS the
confirmation and is persisted directly — an option without a payload only asks),
`reviewer_decide` (multiselect, each selected option IS a decision — the choice is the
confirmation), `reviewer_confirm` (gate on an artifact / phase transition). Free text
`reviewer_decide` (normally a multiselect; a join-only proposal is rendered read-only and
Continue records the complete join set), `reviewer_confirm` (gate on an artifact / phase transition). Free text
arrives via the "Altro/Other" option or by prefixing `!` in chat.
**Language contract (from the workspace `language` field):** the table/column
@@ -36,7 +36,7 @@ substantive decisions.
|-------|--------------|--------------------|
| F1 chiarimento | — | `reviewer_confirm kind:"phase"` |
| F2 memoria | — | `advance:true` only if nothing recorded; else `reviewer_confirm kind:"phase"` |
| F3 riscrittura | `question.md` | `reviewer_confirm kind:"phase"` (after `rewrite_question`) |
| F3 riscrittura | `question.md` | `rewrite_question` records approval and advances automatically |
| F4 schema_linking | `schema_linking.json` | `reviewer_confirm kind:"phase"` (after `reviewer_schema_linking` + `write_schema_linking`). 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 `kind:"cte_result"`, then `reviewer_confirm kind:"phase"` |
@@ -278,8 +278,14 @@ 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.
Propose joins separately in `reviewer_decide(advance:false)`, registering
`join_modified`. Ground them in the `【Foreign keys】` section of the mschema-text
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
as read-only information: **Continue records every proposed join**; the reviewer cannot
remove individual joins (which could create an accidental Cartesian product). If the
reviewer uses **Other — specify**, none of the current joins is recorded: incorporate
the textual correction and present the complete revised join set again.
Ground joins in the `【Foreign keys】` section of the mschema-text
render: it lists the curated logical FKs of the workspace (e.g.
`fact_x.cod_paz=dim_patient.cod_paz`, `*_time_key=dim_time.day_key`) — prefer
those to joins you derive yourself, and flag to the reviewer any join you need