From 0eadef6e3b4ea225210be01d66f675279c0fe6d2 Mon Sep 17 00:00:00 2001 From: robbond Date: Mon, 7 Sep 2026 10:18:41 +0100 Subject: [PATCH] fix(confidence-engine): honor openai alternate output schema --- docs/current-handoff.md | 6 +++++ lib/llm/provider.js | 19 ++++++++++----- tests/llm/provider.test.js | 47 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/docs/current-handoff.md b/docs/current-handoff.md index e1de560..a01fe08 100644 --- a/docs/current-handoff.md +++ b/docs/current-handoff.md @@ -33,6 +33,12 @@ If YES, the next live experiment is one timed/costed OpenAI UI investigation mea - Browser request contracts and client state remain unchanged. Deterministic coverage includes initial start, normal update, episode reconsideration, focused deconstruction, overview synthesis, and Current Understanding synthesis. - Zero live calls occurred. Next boundary: one real Playwright-driven Terra investigation measuring user-visible latency, actual LLM-call sequence, and OpenAI usage/cost. +## OpenAI alternate structured output + +- The OpenAI provider now honors a caller-supplied structured-output schema, applying its existing strict-schema transport projection; absent an alternate schema, initial reconstruction retains its existing strict schema and transport normalization. +- This deterministically fixes the focused-deconstruction 502 root cause: its six-field schema now reaches OpenAI rather than being replaced by the initial-reconstruction schema. Zero live calls occurred. +- Next boundary: retry one fresh real Playwright-driven OpenAI/Terra investigation; do not reuse the failed focused-deconstruction attempt. + ## Repository checkpoint - **Branch:** `feature/initial-decomposition-v0.61` diff --git a/lib/llm/provider.js b/lib/llm/provider.js index 5c29249..1ca6762 100644 --- a/lib/llm/provider.js +++ b/lib/llm/provider.js @@ -160,22 +160,24 @@ function zodAcceptsNull(schema) { function projectOpenAIStrictSchema(schema, zodSchema, rootSchema) { const resolved = resolveSchema(schema, rootSchema); const shape = zodObjectShape(zodSchema); - if (resolved?.properties && shape) { + if (resolved?.properties) { + if (shape) { for (const [key, property] of Object.entries(resolved.properties)) { const propertyZodSchema = shape[key]; if (propertyZodSchema?.isOptional?.() && !schemaAllowsNull(property, rootSchema)) { resolved.properties[key] = { anyOf: [property, { type: "null" }] }; } } + } resolved.required = Object.keys(resolved.properties); } if (resolved?.items) { projectOpenAIStrictSchema(resolved.items, zodArrayItem(zodSchema), rootSchema); } - if (resolved?.properties && shape) { + if (resolved?.properties) { for (const [key, property] of Object.entries(resolved.properties)) { - projectOpenAIStrictSchema(property, shape[key], rootSchema); + projectOpenAIStrictSchema(property, shape?.[key], rootSchema); } } } @@ -458,8 +460,11 @@ class OpenAIReconstructionProvider { this.fetchImpl = fetchImpl; } - async generateReconstruction(prompt, modelName = "gpt-5.6-terra") { + async generateReconstruction(prompt, modelName = "gpt-5.6-terra", outputSchema) { if (!this.apiKey) throw new Error("OPENAI_API_KEY is not set"); + const structuredOutputSchema = outputSchema + ? createOpenAIStrictSchema(outputSchema, null) + : openAIReconstructionJsonSchema; const response = await this.fetchImpl("https://api.openai.com/v1/responses", { method: "POST", @@ -475,7 +480,7 @@ class OpenAIReconstructionProvider { type: "json_schema", name: "reconstruction", strict: true, - schema: openAIReconstructionJsonSchema, + schema: structuredOutputSchema, }, }, }), @@ -501,7 +506,9 @@ class OpenAIReconstructionProvider { } return { - response: normaliseOpenAITransportResponse(recoverJson(outputText)), + response: outputSchema + ? recoverJson(outputText) + : normaliseOpenAITransportResponse(recoverJson(outputText)), providerApiPath: "/v1/responses", }; } diff --git a/tests/llm/provider.test.js b/tests/llm/provider.test.js index 66a3d1a..3ee063c 100644 --- a/tests/llm/provider.test.js +++ b/tests/llm/provider.test.js @@ -9,6 +9,7 @@ import { reconstructionJsonSchema, } from "@/lib/llm/provider.js"; import { reconstructionV2Schema } from "@/lib/reconstruction/schema.js"; +import { focusedDeconstructJsonSchema } from "@/lib/graph/focused-investigation.js"; import { z } from "zod"; describe("OllamaLlmProvider chat capability detection", () => { @@ -303,6 +304,52 @@ describe("OpenAI reconstruction provider experiment seam", () => { }); }); + it("projects a caller-supplied focused schema for OpenAI structured output", async () => { + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + output_text: JSON.stringify({ + targetNodeId: "target", + observations: [], + uncertainties: [], + assumptions: [], + relationships: [], + possibleFollowUpQuestions: [], + }), + }), + }); + const provider = createOpenAIReconstructionProvider({ apiKey: "test-key", fetchImpl: fetchSpy }); + + const result = await provider.generateReconstruction( + "focused prompt", + "gpt-5.6-terra", + focusedDeconstructJsonSchema, + ); + const request = JSON.parse(fetchSpy.mock.calls[0][1].body); + const schema = request.text.format.schema; + + expect(schema.required).toEqual([ + "targetNodeId", + "observations", + "uncertainties", + "assumptions", + "relationships", + "possibleFollowUpQuestions", + ]); + expect(Object.keys(schema.properties)).toEqual(schema.required); + expect(schema.properties).toHaveProperty("targetNodeId"); + expect(schema.properties).not.toHaveProperty("reconstruction"); + expect(schema.properties).not.toHaveProperty("inputClassification"); + expect(result.response).toEqual({ + targetNodeId: "target", + observations: [], + uncertainties: [], + assumptions: [], + relationships: [], + possibleFollowUpQuestions: [], + }); + }); + it("extracts ordered output_text parts from raw Responses output", async () => { const provider = createOpenAIReconstructionProvider({ apiKey: "test-key",