fix(confidence-engine): handle propertyless openai object schemas

This commit is contained in:
2026-09-07 10:59:28 +01:00
parent 0eadef6e3b
commit e289f0be1f
3 changed files with 64 additions and 7 deletions
+2 -1
View File
@@ -36,7 +36,8 @@ If YES, the next live experiment is one timed/costed OpenAI UI investigation mea
## 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.
- The next live run exposed incomplete recursive strict projection: OpenAI rejected `relationships.items` because it lacked `additionalProperties: false`. The projector now recognizes every `type: "object"` node, including property-less objects in array items, and recursively enforces strict object schemas while preserving initial-reconstruction optionality/nullability behavior.
- This deterministically fixes the focused-deconstruction transport failures. Provider and focused-route suites pass with zero live calls.
- Next boundary: retry one fresh real Playwright-driven OpenAI/Terra investigation; do not reuse the failed focused-deconstruction attempt.
## Repository checkpoint
+19 -6
View File
@@ -112,7 +112,7 @@ const openAIReconstructionJsonSchema = createOpenAIStrictSchema(
/** @internal OpenAI Structured Outputs requires every object property. */
export function createOpenAIStrictSchema(schema, zodSchema = reconstructionV2Schema) {
const projected = structuredClone(schema);
projectOpenAIStrictSchema(projected, zodSchema, projected);
projectOpenAIStrictSchema(projected, zodSchema, projected, new WeakSet());
return projected;
}
@@ -157,10 +157,12 @@ function zodAcceptsNull(schema) {
return schema?.isNullable?.() === true;
}
function projectOpenAIStrictSchema(schema, zodSchema, rootSchema) {
function projectOpenAIStrictSchema(schema, zodSchema, rootSchema, visited) {
const resolved = resolveSchema(schema, rootSchema);
if (!resolved || typeof resolved !== "object" || visited.has(resolved)) return;
visited.add(resolved);
const shape = zodObjectShape(zodSchema);
if (resolved?.properties) {
if (resolved?.type === "object" || resolved?.properties) {
if (shape) {
for (const [key, property] of Object.entries(resolved.properties)) {
const propertyZodSchema = shape[key];
@@ -169,17 +171,28 @@ function projectOpenAIStrictSchema(schema, zodSchema, rootSchema) {
}
}
}
resolved.required = Object.keys(resolved.properties);
if (resolved.properties) resolved.required = Object.keys(resolved.properties);
resolved.additionalProperties = false;
}
if (resolved?.items) {
projectOpenAIStrictSchema(resolved.items, zodArrayItem(zodSchema), rootSchema);
projectOpenAIStrictSchema(resolved.items, zodArrayItem(zodSchema), rootSchema, visited);
}
if (resolved?.properties) {
for (const [key, property] of Object.entries(resolved.properties)) {
projectOpenAIStrictSchema(property, shape?.[key], rootSchema);
projectOpenAIStrictSchema(property, shape?.[key], rootSchema, visited);
}
}
for (const branch of [
...(resolved.anyOf ?? []),
...(resolved.oneOf ?? []),
...(resolved.allOf ?? []),
]) {
projectOpenAIStrictSchema(branch, null, rootSchema, visited);
}
for (const definition of Object.values(resolved.$defs ?? resolved.definitions ?? {})) {
projectOpenAIStrictSchema(definition, null, rootSchema, visited);
}
}
function schemaAllowsNull(schema, rootSchema) {
+43
View File
@@ -220,6 +220,7 @@ describe("OpenAI reconstruction provider experiment seam", () => {
const resolved = resolveLocalRef(schema, root);
if (resolved?.properties) {
expect(resolved.required).toEqual(Object.keys(resolved.properties));
expect(resolved.additionalProperties).toBe(false);
Object.values(resolved.properties).forEach((property) => assertAllPropertiesRequired(property, root));
}
(resolved?.anyOf ?? []).forEach((branch) => assertAllPropertiesRequired(branch, root));
@@ -337,7 +338,9 @@ describe("OpenAI reconstruction provider experiment seam", () => {
"possibleFollowUpQuestions",
]);
expect(Object.keys(schema.properties)).toEqual(schema.required);
expect(schema.additionalProperties).toBe(false);
expect(schema.properties).toHaveProperty("targetNodeId");
expect(schema.properties.relationships.items.additionalProperties).toBe(false);
expect(schema.properties).not.toHaveProperty("reconstruction");
expect(schema.properties).not.toHaveProperty("inputClassification");
expect(result.response).toEqual({
@@ -350,6 +353,46 @@ describe("OpenAI reconstruction provider experiment seam", () => {
});
});
it("recursively projects arbitrary nested object schemas as strict", () => {
const alternateSchema = {
type: "object",
properties: {
groups: {
type: "array",
items: {
type: "object",
properties: {
details: {
type: "object",
properties: { value: { type: "string" } },
},
},
},
},
},
};
const projected = createOpenAIStrictSchema(alternateSchema, null);
expect(projected.additionalProperties).toBe(false);
expect(projected.properties.groups.items.additionalProperties).toBe(false);
expect(projected.properties.groups.items.properties.details.additionalProperties).toBe(false);
});
it("projects property-less objects nested in arrays as strict", () => {
const projected = createOpenAIStrictSchema({
type: "object",
properties: {
relationships: { type: "array", items: { type: "object" } },
},
}, null);
expect(projected.properties.relationships.items).toEqual({
type: "object",
additionalProperties: false,
});
});
it("extracts ordered output_text parts from raw Responses output", async () => {
const provider = createOpenAIReconstructionProvider({
apiKey: "test-key",