fix(focused-investigation): suppress incompatible question strategies

This commit is contained in:
2026-10-08 17:48:53 +01:00
parent 92ae463d10
commit f74fe6de10
6 changed files with 672 additions and 16 deletions
@@ -0,0 +1,186 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { makeGraph, makeNode } from "@/lib/graph/schema.js";
const generateReconstruction = vi.fn();
vi.mock("@/lib/llm/provider", () => ({
getProvider: () => ({ generateReconstruction }),
getProviderModelName: () => "test-model",
}));
vi.mock("@/lib/supabase/api-auth.js", () => ({
withAuthenticatedApi: (handler) => handler,
}));
function makeRecruitmentGraph() {
const target = makeNode({
id: "recruitment-target",
label: "Staffing certainty gap",
description:
"Whether additional skilled staff needed for expansion can be recruited",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
return makeGraph({
centralStatement:
"We need to compare our current baseline against the comparable state before staffing is secured for expansion.",
currentSummary: "Test summary",
nodes: [target],
edges: [],
activeUnknownNodeId: target.id,
resolvedNodeIds: [],
});
}
function makeHistoricalGraph() {
const target = makeNode({
id: "historical-target",
label: "Baseline conversion rate",
description:
"Need the previous baseline because the change cannot be assessed without it.",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
return makeGraph({
centralStatement: "Assess the change against the comparable prior baseline.",
currentSummary: "Test summary",
nodes: [target],
edges: [],
activeUnknownNodeId: target.id,
resolvedNodeIds: [],
});
}
function makeRequest(graph, targetNodeId = graph.nodes[0].id) {
return new Request("http://localhost/api/focused-investigation/formulate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ situationGraph: graph, targetNodeId }),
});
}
describe("POST /api/focused-investigation/formulate semantic compatibility", () => {
beforeEach(() => {
generateReconstruction.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it("suppresses an assessor-incompatible strategy without changing the selected target or graph", async () => {
const graph = makeRecruitmentGraph();
const snapshot = JSON.stringify(graph);
generateReconstruction.mockResolvedValue({
response: {
strategyKey: "baseline_reconstruction",
relevance: "incompatible",
},
});
const { POST } = await import("@/app/api/focused-investigation/formulate/route.js");
const response = await POST(makeRequest(graph));
const json = await response.json();
expect(response.status).toBe(200);
expect(json.targetNodeId).toBe("recruitment-target");
expect(json.strategy).toBeNull();
expect(json.question).toBe(
"What evidence would clarify whether additional skilled staff needed for expansion can be recruited?",
);
expect(JSON.stringify(graph)).toBe(snapshot);
expect(generateReconstruction).toHaveBeenCalledTimes(1);
});
it("does not let a compatible semantic verdict authorize a grammatically incompatible baseline template", async () => {
const graph = makeRecruitmentGraph();
generateReconstruction.mockResolvedValue({
response: {
strategyKey: "baseline_reconstruction",
relevance: "compatible",
},
});
const { POST } = await import("@/app/api/focused-investigation/formulate/route.js");
const response = await POST(makeRequest(graph));
const json = await response.json();
expect(json.strategy).toBeNull();
expect(json.question.toLowerCase()).not.toContain("before whether");
expect(json.question).toContain("What evidence would clarify whether");
});
it("preserves a compatible historical strategy when its template is suitable", async () => {
const graph = makeHistoricalGraph();
generateReconstruction.mockResolvedValue({
response: {
strategyKey: "baseline_reconstruction",
relevance: "compatible",
},
});
const { POST } = await import("@/app/api/focused-investigation/formulate/route.js");
const response = await POST(makeRequest(graph));
const json = await response.json();
expect(json.strategy).toBe("baseline_reconstruction");
expect(json.question).toContain("What was the comparable state before");
});
it("treats invalid or mismatched assessment output as non-authorizing", async () => {
const graph = makeRecruitmentGraph();
generateReconstruction.mockResolvedValue({
response: { strategyKey: "evidence_gathering", relevance: "compatible" },
});
const { POST } = await import("@/app/api/focused-investigation/formulate/route.js");
const response = await POST(makeRequest(graph));
const json = await response.json();
expect(json.strategy).toBeNull();
expect(json.question).toContain("What evidence would clarify whether");
});
it("treats provider unavailability as non-authorizing fallback", async () => {
const graph = makeRecruitmentGraph();
generateReconstruction.mockRejectedValue(
Object.assign(new Error("provider unavailable"), { code: "PROVIDER_UNAVAILABLE" }),
);
const { POST } = await import("@/app/api/focused-investigation/formulate/route.js");
const response = await POST(makeRequest(graph));
const json = await response.json();
expect(response.status).toBe(200);
expect(json.strategy).toBeNull();
expect(json.question).toContain("What evidence would clarify whether");
});
it("aborts the one assessment invocation at the route deadline and falls back", async () => {
vi.useFakeTimers();
const graph = makeRecruitmentGraph();
let observedSignal;
generateReconstruction.mockImplementation((_, __, ___, { signal }) => {
observedSignal = signal;
return new Promise((_, reject) => {
signal.addEventListener("abort", () => {
const error = new Error("assessment aborted");
error.name = "AbortError";
reject(error);
}, { once: true });
});
});
const { POST } = await import("@/app/api/focused-investigation/formulate/route.js");
const responsePromise = POST(makeRequest(graph));
await vi.advanceTimersByTimeAsync(10000);
const response = await responsePromise;
const json = await response.json();
expect(observedSignal.aborted).toBe(true);
expect(json.strategy).toBeNull();
expect(json.question).toContain("What evidence would clarify whether");
});
});