fix(confidence-engine): honor openai alternate output schema

This commit is contained in:
2026-09-07 10:18:41 +01:00
parent bb3082d633
commit 0eadef6e3b
3 changed files with 66 additions and 6 deletions
+6
View File
@@ -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`
+13 -6
View File
@@ -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",
};
}
+47
View File
@@ -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",