wip(confidence-engine): checkpoint open-question presentation work

This commit is contained in:
2026-10-09 07:46:19 +01:00
parent f74fe6de10
commit 6bc8be6edb
19 changed files with 812 additions and 22 deletions
+12 -1
View File
@@ -12,9 +12,12 @@ import {
} from "../lib/reconstruction/prompt.js";
import { normaliseAnalysisResponse } from "../lib/reconstruction/compatibility.js";
import {
reconstructionGenerationJsonSchema,
reconstructionGenerationSchema,
reconstructionV2Schema,
reconstructionSchema as reconstructionV1Schema,
} from "../lib/reconstruction/schema.js";
import { validateReconstructionOpenQuestions } from "../lib/reconstruction/open-question-presentation.js";
const MAX_SCENARIO_LENGTH = 10000;
@@ -80,6 +83,8 @@ export async function analyseScenario(scenario, opts = {}) {
const providerResult = await provider.generateReconstruction(
promptObj.prompt,
reconstructionModelName,
reconstructionGenerationJsonSchema,
{ outputZodSchema: reconstructionGenerationSchema },
);
if (
providerResult &&
@@ -201,6 +206,9 @@ function buildCompatibilityDiagnostics(compatibility) {
}
function buildSuccessResultV2(data, model, duration, version, compatibility) {
const presentationValidation = validateReconstructionOpenQuestions(
data.reconstruction,
);
return {
success: true,
validationStatus: "valid",
@@ -209,10 +217,13 @@ function buildSuccessResultV2(data, model, duration, version, compatibility) {
rawResponse: JSON.stringify(data).slice(0, 3000),
promptVersion: version,
inputClassification: data.inputClassification,
reconstruction: data.reconstruction,
reconstruction: presentationValidation.reconstruction,
evidence: data.evidence,
nextQuestion: data.nextQuestion,
errors: undefined,
presentationValidationStatus:
presentationValidation.issues.length > 0 ? "partial" : "valid",
presentationValidationIssues: presentationValidation.issues,
...buildCompatibilityDiagnostics(compatibility),
};
}
+1
View File
@@ -184,6 +184,7 @@ export function buildInitialGraph(analysisData) {
null,
unk.confidence || "low",
);
if (unk.question) node.openQuestion = unk.question;
mapSourceId(unk.id, node);
unknownNodes.push(node);
}
+4
View File
@@ -88,6 +88,10 @@ function buildDiagnostics({
compatibilityApplied: analysis?.compatibilityApplied ?? false,
compatibilityChanges: analysis?.compatibilityChanges ?? [],
compatibilityWarnings: analysis?.compatibilityWarnings ?? [],
presentationValidationStatus:
analysis?.presentationValidationStatus ?? "not_applicable",
presentationValidationIssues:
analysis?.presentationValidationIssues ?? [],
unknownSelectionExplanation: unknownSelectionExplanation ?? null,
reconstructionQuestion: reconstructionQuestion ?? null,
reconstructionQuestionAccepted: reconstructionQuestionAccepted ?? null,
+2
View File
@@ -57,6 +57,7 @@ export const situationNodeSchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
description: z.string().min(1),
openQuestion: z.string().min(1).optional(),
kind: z.enum(Object.values(SituationKind)),
status: z.enum(Object.values(SituationStatus)),
confidence: z.enum(Object.values(ConfidenceLevel)),
@@ -259,6 +260,7 @@ export function makeNode(opts) {
id,
label: opts.label,
description: opts.description ?? opts.label,
openQuestion: opts.openQuestion,
kind: opts.kind ?? "observation",
status: opts.status ?? "unknown",
confidence: opts.confidence ?? "medium",
+17 -5
View File
@@ -546,10 +546,15 @@ class OpenAIReconstructionProvider {
this.fetchImpl = fetchImpl;
}
async generateReconstruction(prompt, modelName = "gpt-5.6-terra", outputSchema, { signal } = {}) {
async generateReconstruction(
prompt,
modelName = "gpt-5.6-terra",
outputSchema,
{ signal, outputZodSchema } = {},
) {
if (!this.apiKey) throw new Error("OPENAI_API_KEY is not set");
const structuredOutputSchema = outputSchema
? createOpenAIStrictSchema(outputSchema, null)
? createOpenAIStrictSchema(outputSchema, outputZodSchema ?? null)
: openAIReconstructionJsonSchema;
const format = {
@@ -594,10 +599,17 @@ class OpenAIReconstructionProvider {
throw error;
}
const recoveredResponse = recoverJson(outputText);
const normalisedResponse = outputSchema && !outputZodSchema
? recoveredResponse
: normaliseOpenAITransportResponse(
recoveredResponse,
outputZodSchema ?? reconstructionV2Schema,
outputSchema ?? reconstructionJsonSchema,
);
return {
response: outputSchema
? recoverJson(outputText)
: normaliseOpenAITransportResponse(recoverJson(outputText)),
response: normalisedResponse,
providerApiPath: "/v1/responses",
};
}
+11 -3
View File
@@ -274,8 +274,14 @@ export function buildFacilitatorViewModel(_ref) {
// Scaffold items are entirely suppressed from user-facing sections.
if (semanticRole === "scaffolding") continue;
// An unknown's openQuestion is a model-authored presentation projection.
// Its label and description remain the canonical uncertainty representation.
var displayText =
node.kind === "unknown" && !resolved && node.openQuestion
? node.openQuestion.trim()
: displayResult.displayText;
var entry = {
text: displayResult.displayText,
text: displayText,
normalised: normaliseText(displayResult.displayText),
kind: node.kind,
semanticRole: semanticRole,
@@ -410,8 +416,10 @@ export function buildFacilitatorViewModel(_ref) {
if (!a.isActiveUnknown && b.isActiveUnknown) return 1;
// Selected-question target: match by normalised text
if (sqText && a.normalised === sqText && b.normalised !== sqText) return -1;
if (sqText && a.normalised !== sqText && b.normalised === sqText) return 1;
var aMatchesSelectedQuestion = sqText && normaliseText(a.text) === sqText;
var bMatchesSelectedQuestion = sqText && normaliseText(b.text) === sqText;
if (aMatchesSelectedQuestion && !bMatchesSelectedQuestion) return -1;
if (!aMatchesSelectedQuestion && bMatchesSelectedQuestion) return 1;
// Explicit priority fields where available in the graph
var pa = a.priority != null ? a.priority : null;
@@ -0,0 +1,63 @@
const ISSUE_MESSAGES = {
question_missing: "No user-facing Open Question was generated.",
question_empty: "The generated Open Question was empty.",
question_not_interrogative: "The generated Open Question was not a direct interrogative.",
question_indirect_proposition:
"The generated Open Question used an indirect uncertainty proposition.",
};
/**
* Narrow presentation validation only. This does not attempt to prove semantic
* equivalence with the canonical uncertainty and never rewrites either value.
*/
export function validateOpenQuestionCandidate(candidate) {
if (candidate === undefined || candidate === null) {
return { valid: false, category: "question_missing" };
}
if (typeof candidate !== "string" || candidate.trim().length === 0) {
return { valid: false, category: "question_empty" };
}
const question = candidate.trim();
if (/^whether\b/i.test(question)) {
return { valid: false, category: "question_indirect_proposition" };
}
if (!question.endsWith("?")) {
return { valid: false, category: "question_not_interrogative" };
}
return { valid: true, question };
}
/**
* Remove invalid presentation candidates without changing canonical descriptions,
* IDs, ordering, relationships, or any other reconstruction content.
*/
export function validateReconstructionOpenQuestions(reconstruction) {
const issues = [];
const importantUnknowns = (reconstruction?.importantUnknowns ?? []).map(
(unknown, index) => {
const validation = validateOpenQuestionCandidate(unknown.question);
if (validation.valid) {
return { ...unknown, question: validation.question };
}
const { question: _invalidQuestion, ...canonicalUnknown } = unknown;
issues.push({
category: validation.category,
path: ["reconstruction", "importantUnknowns", index, "question"],
unknownId: unknown.id,
message: ISSUE_MESSAGES[validation.category],
severity: "nonfatal",
});
return canonicalUnknown;
},
);
return {
reconstruction: { ...reconstruction, importantUnknowns },
issues,
};
}
+29 -1
View File
@@ -144,6 +144,21 @@ const reconstructionRelationshipSchema = z.object({
confidence: confidenceEnum,
});
const importantUnknownSchema = itemSchemaV1.extend({
// Canonical uncertainty meaning remains in description. question is an
// optional, model-authored user-facing interrogative for the Open Questions UI.
// Keep this tolerant for historical data and nonfatal presentation validation.
question: z.string().optional(),
});
const generatedImportantUnknownSchema = itemSchemaV1.extend({
// New generation requests require a distinct presentation candidate. More
// nuanced interrogative validation is intentionally performed locally after
// canonical reconstruction validation rather than encoded as provider-specific
// JSON Schema grammar.
question: z.string().trim().min(1),
});
const reconstructionSchemaV2 = z.object({
summary: z.string().min(1),
actors: z.array(itemSchemaV1),
@@ -167,7 +182,7 @@ const reconstructionSchemaV2 = z.object({
}),
),
contradictions: z.array(itemSchemaV1),
importantUnknowns: z.array(itemSchemaV1),
importantUnknowns: z.array(importantUnknownSchema),
plausibleInterpretations: z.array(
z.object({
id: z.string().min(1),
@@ -205,6 +220,19 @@ export const reconstructionV2Schema = z.object({
nextQuestion: nextQuestionSchema,
});
// Generation-only contract. Durable reconstruction parsing remains tolerant so
// historical snapshots and otherwise valid canonical reconstructions do not fail
// solely because presentation wording is absent or malformed.
export const reconstructionGenerationSchema = reconstructionV2Schema.extend({
reconstruction: reconstructionSchemaV2.extend({
importantUnknowns: z.array(generatedImportantUnknownSchema),
}),
});
export const reconstructionGenerationJsonSchema = z.toJSONSchema(
reconstructionGenerationSchema,
);
// Outer wrapper for API return (includes diagnostics + v0.2 data)
export const analyseResponseV2Schema = z.object({
inputClassification: inputClassificationSchema.optional(),