diff --git a/components/reasoning-workspace.jsx b/components/reasoning-workspace.jsx index 647d83a..f168bae 100644 --- a/components/reasoning-workspace.jsx +++ b/components/reasoning-workspace.jsx @@ -1890,6 +1890,7 @@ export default function ReasoningWorkspace({ focusedInvestigations[node.id] && focusedInvestigations[node.id].question ); + const openQuestion = node.openQuestion || node.label; function handleNodeClick(n) { if (hasCompleted && selectedPresentationItemId === n.id) { @@ -1907,8 +1908,8 @@ export default function ReasoningWorkspace({ style={{ cursor: "pointer" }} className="w-full text-left rounded-lg border border-gray-200 bg-white px-5 py-4 transition hover:border-gray-300 hover:bg-gray-50" > - {node.label} - {node.description && node.description !== node.label && ( + {openQuestion} + {node.description && node.description !== openQuestion && (
{node.description}
)} {!isFocused && Unclear} diff --git a/lib/analysis.js b/lib/analysis.js index 5f36965..475f2b9 100644 --- a/lib/analysis.js +++ b/lib/analysis.js @@ -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), }; } diff --git a/lib/graph/builder.js b/lib/graph/builder.js index 61dd8c7..322a31a 100644 --- a/lib/graph/builder.js +++ b/lib/graph/builder.js @@ -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); } diff --git a/lib/graph/orchestrator.js b/lib/graph/orchestrator.js index 86cf066..c0b5bbf 100644 --- a/lib/graph/orchestrator.js +++ b/lib/graph/orchestrator.js @@ -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, diff --git a/lib/graph/schema.js b/lib/graph/schema.js index 110de8a..0654983 100644 --- a/lib/graph/schema.js +++ b/lib/graph/schema.js @@ -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", diff --git a/lib/llm/provider.js b/lib/llm/provider.js index 5ceaf7e..e3caaec 100644 --- a/lib/llm/provider.js +++ b/lib/llm/provider.js @@ -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", }; } diff --git a/lib/presentation/facilitator-view-adapter.js b/lib/presentation/facilitator-view-adapter.js index 8859788..3d4409a 100644 --- a/lib/presentation/facilitator-view-adapter.js +++ b/lib/presentation/facilitator-view-adapter.js @@ -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; diff --git a/lib/reconstruction/open-question-presentation.js b/lib/reconstruction/open-question-presentation.js new file mode 100644 index 0000000..561b10c --- /dev/null +++ b/lib/reconstruction/open-question-presentation.js @@ -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, + }; +} \ No newline at end of file diff --git a/lib/reconstruction/schema.js b/lib/reconstruction/schema.js index ce13010..96cf9b8 100644 --- a/lib/reconstruction/schema.js +++ b/lib/reconstruction/schema.js @@ -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(), diff --git a/prompts/reconstruct-v0.2.md b/prompts/reconstruct-v0.2.md index 6d0f7dd..413901b 100644 --- a/prompts/reconstruct-v0.2.md +++ b/prompts/reconstruct-v0.2.md @@ -19,6 +19,7 @@ You are a neutral analyst performing evidence-based situation reconstruction. 7. Distinguish: what was said / what it may mean / why it may have been said. 8. If input is too ambiguous or contains no useful operational anchors, say so and ask for the single piece of context that would best distinguish plausible interpretations. +9. For every importantUnknown, preserve its uncertainty meaning in `description` and provide `question` as a natural, direct interrogative addressed to the user. Retain all scope, conditions, timeframes, and qualifications without adding assumptions or an implied conclusion. Do not use fragments or indirect proposition forms such as "Whether …". ## Confidence scale @@ -83,7 +84,7 @@ Return a JSON object with exactly these four top-level keys (use **camelCase**): "knownTransitions": [{"id": "...", "description": "...", "confidence": "