fix: make join review read only
This commit is contained in:
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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), "");
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user