diff --git a/docs/current-handoff.md b/docs/current-handoff.md index c9da7ea..367543c 100644 --- a/docs/current-handoff.md +++ b/docs/current-handoff.md @@ -122,6 +122,12 @@ - An experiment-only OpenAI Responses API provider apparatus exists for `gpt-5.6-terra`, using the same current v0.5 prompt and canonical reconstruction schema. Production provider selection remains Ollama/Qwen. - No live OpenAI calls have occurred. Next boundary: a bounded live semantic comparison, not production migration. +## Reconstruction provider injection seam + +- OpenAI provider apparatus exists at `860ee6f`; the canonical helper was previously blocked because `startCase()` and `analyseScenario()` created providers internally. +- An optional reconstruction-provider injection seam now carries an experiment provider through the same production analysis path. Production callers still default to configured Ollama/Qwen through `getProvider()`. +- No live calls occurred. Next boundary: exactly one live OpenAI fixed-scenario run through the canonical helper; this is not a production provider migration. + ## Current product architecture Three distinct routes, not a single page: diff --git a/lib/analysis.js b/lib/analysis.js index f75579f..cb5ae92 100644 --- a/lib/analysis.js +++ b/lib/analysis.js @@ -23,6 +23,8 @@ const MAX_SCENARIO_LENGTH = 10000; * @param {string} scenario - The scenario text to analyse * @param {object} [opts] * @param {"v0.1" | "v0.2"} [opts.promptVersion="v0.2"] - Prompt version to use + * @param {{ generateReconstruction: Function }} [opts.reconstructionProvider] - Experiment-only reconstruction provider override + * @param {string} [opts.reconstructionModelName] - Experiment-only model override for an injected provider * @returns {Promise} Analysis result with diagnostics */ export async function analyseScenario(scenario, opts = {}) { @@ -70,14 +72,15 @@ export async function analyseScenario(scenario, opts = {}) { } // ── Call provider ────────────────────────────────── - const provider = getProvider(); + const provider = opts.reconstructionProvider ?? getProvider(); + const reconstructionModelName = opts.reconstructionModelName ?? OLLAMA_MODEL; let rawResponse; let providerApiPath; let providerExecution; try { const providerResult = await provider.generateReconstruction( promptObj.prompt, - OLLAMA_MODEL, + reconstructionModelName, ); if ( providerResult && @@ -122,7 +125,7 @@ export async function analyseScenario(scenario, opts = {}) { if (resultV2.valid) { return buildSuccessResultV2( resultV2.data, - OLLAMA_MODEL, + reconstructionModelName, duration, promptVersion, compatibility, @@ -137,7 +140,7 @@ export async function analyseScenario(scenario, opts = {}) { if (resultV1.valid) { return buildSuccessResultV1( resultV1.data, - OLLAMA_MODEL, + reconstructionModelName, duration, promptVersion, compatibility, @@ -148,7 +151,7 @@ export async function analyseScenario(scenario, opts = {}) { return buildPartialResult( rawResponseStr, resultV2.error ?? resultV1.error, - OLLAMA_MODEL, + reconstructionModelName, duration, promptVersion, compatibility, diff --git a/lib/graph/orchestrator.js b/lib/graph/orchestrator.js index 966ff47..fcc725b 100644 --- a/lib/graph/orchestrator.js +++ b/lib/graph/orchestrator.js @@ -359,7 +359,14 @@ export async function startCase(body, dependencies = {}) { } const { scenario, promptVersion } = parsedRequest.data; - const analysis = await analyseScenario(scenario, { promptVersion }); + const analysisOptions = { promptVersion }; + if (dependencies.reconstructionProvider) { + analysisOptions.reconstructionProvider = dependencies.reconstructionProvider; + } + if (dependencies.reconstructionModelName) { + analysisOptions.reconstructionModelName = dependencies.reconstructionModelName; + } + const analysis = await analyseScenario(scenario, analysisOptions); if (!analysis.success) { return { diff --git a/scripts/start-case-experiment-helper.cjs b/scripts/start-case-experiment-helper.cjs index d543473..b3ce8ab 100755 --- a/scripts/start-case-experiment-helper.cjs +++ b/scripts/start-case-experiment-helper.cjs @@ -53,7 +53,7 @@ function readScenarioInput(argv) { return { scenario: argv.slice(startIdx).join(" ") }; } -async function runStartCaseExperiment(scenarioInput, experimentInstruction) { +async function runStartCaseExperiment(scenarioInput, experimentInstruction, options = {}) { // ── Experiment seam bridge: supply instruction to production path ── const hadEnvVar = process.env.RECONSTRUCTION_EXPERIMENT_INSTRUCTION != null; const previousEnvValue = process.env.RECONSTRUCTION_EXPERIMENT_INSTRUCTION; @@ -61,10 +61,10 @@ async function runStartCaseExperiment(scenarioInput, experimentInstruction) { process.env.RECONSTRUCTION_EXPERIMENT_INSTRUCTION = experimentInstruction; } - let startCase; + let startCase = options.startCase; const isMock = process.env.START_CASE_EXPERIMENT_HELPER_MOCK === "1"; - if (isMock) { + if (!startCase && isMock) { // Deterministic mode: skip environment checks and use inline test double. startCase = async (body) => { // Deterministic inline test double — no live model calls. @@ -81,7 +81,7 @@ async function runStartCaseExperiment(scenarioInput, experimentInstruction) { diagnostics: { validationStatus: "mock", modelName: "inline-test-double", graphReferenceValidation: { valid: true, errors: [] } }, }; }; - } else { + } else if (!startCase) { const baseUrl = assertRequiredEnv("OLLAMA_BASE_URL"); const model = assertRequiredEnv("OLLAMA_MODEL"); @@ -99,7 +99,10 @@ async function runStartCaseExperiment(scenarioInput, experimentInstruction) { let result; const endToEndStartedAt = Date.now(); try { - result = await startCase(scenarioInput); + result = await startCase(scenarioInput, { + reconstructionProvider: options.reconstructionProvider, + reconstructionModelName: options.reconstructionModelName, + }); } finally { // Clean up experiment env var after execution regardless of outcome if (experimentInstruction && !hadEnvVar) { @@ -132,7 +135,8 @@ async function runStartCaseExperiment(scenarioInput, experimentInstruction) { }; } -(async () => { +if (require.main === module) { + (async () => { // Import-only mode: prove real production seam without invoking startCase. // Used only for deterministic apparatus verification of the import path. if (process.env.START_CASE_EXPERIMENT_HELPER_IMPORT_ONLY === "1") { @@ -183,4 +187,9 @@ async function runStartCaseExperiment(scenarioInput, experimentInstruction) { console.log(JSON.stringify(failure)); process.exit(1); } -})(); + })(); +} + +module.exports = { runStartCaseExperiment }; + +module.exports = { runStartCaseExperiment }; diff --git a/tests/graph/orchestrator.test.js b/tests/graph/orchestrator.test.js index 96b4610..4fe3f4f 100644 --- a/tests/graph/orchestrator.test.js +++ b/tests/graph/orchestrator.test.js @@ -356,6 +356,24 @@ describe("lib/graph/orchestrator startCase", () => { ); }); + it("forwards an injected reconstruction provider to analyseScenario", async () => { + mockAnalyseScenario.mockResolvedValue(makeAnalysisResult()); + const reconstructionProvider = { generateReconstruction: vi.fn() }; + const { startCase } = await import("@/lib/graph/orchestrator.js"); + + const result = await startCase( + { scenario: "Scenario text" }, + { reconstructionProvider, reconstructionModelName: "experiment-model" }, + ); + + expect(result.success).toBe(true); + expect(mockAnalyseScenario).toHaveBeenCalledWith("Scenario text", { + promptVersion: undefined, + reconstructionProvider, + reconstructionModelName: "experiment-model", + }); + }); + it("rejects invalid request input without throwing", async () => { const { startCase } = await import("@/lib/graph/orchestrator.js"); diff --git a/tests/reconstruction/compatibility.test.js b/tests/reconstruction/compatibility.test.js index 8ec78c8..eb3354a 100644 --- a/tests/reconstruction/compatibility.test.js +++ b/tests/reconstruction/compatibility.test.js @@ -112,6 +112,35 @@ describe("normaliseAnalysisResponse", () => { }); describe("analyseScenario compatibility", () => { + it("uses an injected reconstruction provider with the production prompt path", async () => { + const injectedProvider = { + generateReconstruction: vi.fn().mockResolvedValue({ + inputClassification: { primaryType: "other", classificationReason: "test", confidence: "low" }, + reconstruction: { + summary: "summary", actors: [], systemsOrObjects: [], expectedStates: [], observedStates: [], + differences: [], knownTransitions: [], unexplainedTransitions: [], contradictions: [], + importantUnknowns: [], plausibleInterpretations: [], + }, + evidence: [], + nextQuestion: { id: "q1", question: "What next?", targets: [], reason: "test", expectedInformationValue: "low" }, + }), + }; + const { analyseScenario } = await import("@/lib/analysis.js"); + + const result = await analyseScenario("Scenario text", { + promptVersion: "v0.3", + reconstructionProvider: injectedProvider, + reconstructionModelName: "experiment-model", + }); + + expect(result.success).toBe(true); + expect(injectedProvider.generateReconstruction).toHaveBeenCalledWith( + "prompt", + "experiment-model", + ); + expect(mockGenerateReconstruction).not.toHaveBeenCalled(); + }); + it("preserves an attempted provider API path on provider failure", async () => { const providerError = new Error("Provider failed"); providerError.providerApiPath = "/api/chat"; diff --git a/tests/scripts/start-case-experiment-helper.test.js b/tests/scripts/start-case-experiment-helper.test.js index f0796eb..688f4e2 100644 --- a/tests/scripts/start-case-experiment-helper.test.js +++ b/tests/scripts/start-case-experiment-helper.test.js @@ -14,10 +14,12 @@ import { execFile } from "child_process"; import { writeFile, unlink } from "fs/promises"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; +import { createRequire } from "module"; const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = join(__dirname, "..", ".."); const helperPath = join(rootDir, "scripts", "start-case-experiment-helper.cjs"); +const require = createRequire(import.meta.url); function runHelper(args = [], envOverrides = {}) { return new Promise((resolve, reject) => { @@ -128,6 +130,34 @@ describe("start-case-experiment-helper.cjs apparatus", () => { // ── D — Success output ────────────────────────────────────────── describe("D — success output", () => { + it("forwards an explicit reconstruction provider through the startCase path", async () => { + const { runStartCaseExperiment } = require(helperPath); + const reconstructionProvider = { generateReconstruction() {} }; + const startCase = async (input, dependencies) => { + expect(input).toEqual({ scenario: "Provider seam scenario" }); + expect(dependencies).toMatchObject({ + reconstructionProvider, + reconstructionModelName: "gpt-5.6-terra", + }); + return { + success: true, + situationGraph: { nodes: [], edges: [] }, + assessment: null, + selectedQuestion: null, + summary: "test", + }; + }; + + const result = await runStartCaseExperiment( + { scenario: "Provider seam scenario" }, + null, + { reconstructionProvider, reconstructionModelName: "gpt-5.6-terra", startCase }, + ); + + expect(result.success).toBe(true); + expect(result.summary).toBe("test"); + }); + it("exit code is 0", async () => { const result = await runHelper(["Success test scenario"]); expect(result.exitCode).toBe(0);