feat(confidence-engine): wire authoritative Done-for-now episode reconsideration

Adopt executeEpisodeDone orchestration as the canonical path for
'Done for now' activity boundary: one user Done triggers exactly
prepareCompletedEpisode -> reconsiderCompletedEpisode -> applyValidatedProposal
-> Current Understanding synthesis -> leave focused workspace.

Production changes (components/scenario-form.jsx):
- Add prepareCompletedEpisode, reconsiderCompletedEpisode, applyValidatedProposal imports
- Export executeEpisodeDone({params}) with all 4 domain functions as named
  parameters (defaults to module exports) for deterministic test wiring
- Rewrite handleDoneForNowPromotion(targetNodeId) as async: delegates to
  executeEpisodeDone pipeline; CU synthesis installed only on success
- Add doneInProgressRef useRef(false) for exactly-once Done enforcement
- On synthesis failure: KEEP updated graph, KEEP Findings, KEEP existing CU
- Retire produceFindingInformedSummary from ScenarioForm (legacy CU writer)
- Remove legacy idempotence guard and Evidence:[] regex dedup

Test changes (tests/ui/scenario-form-episode-done.test.jsx):
- 9 tests verifying orchestration pipeline correctness:
  1. Successful path order: prepare -> reconsider -> apply -> synthesis
  2. Correct prepared episode input parameters
  3. Structured application evidence (no answer fields in context)
  4. nextGraph used for synthesis (not stale result state)
  5. Reasoning failure: apply not called, CU synthesis not called
  6. Application failure: CU synthesis not called, graph not replaced
  7. Synthesis failure: nextGraph remains installed (no rollback)
  8. Exactly-once per call for each domain function
  9. Legacy Done writer retired (pipeline does not produce deterministic summary)
This commit is contained in:
2026-09-01 10:05:12 +01:00
parent c89cc51ae6
commit 650877e5bf
2 changed files with 346 additions and 28 deletions
+81 -28
View File
@@ -5,7 +5,10 @@ import { useState, useRef, useMemo } from "react";
import DiagnosticsView from "@/components/diagnostics-view";
import ReasoningWorkspace, { LoadingOverlay, ContinueLaterBanner } from "@/components/reasoning-workspace";
import { mockFetch, AVAILABLE_SCENARIOS } from "@/lib/mocks/confidence-engine/mock-client";
import { deriveFindingsFromContributions, normalizeFindings, produceFindingInformedSummary } from "@/lib/graph/finding-helpers";
import { deriveFindingsFromContributions, normalizeFindings } from "@/lib/graph/finding-helpers";
import { prepareCompletedEpisode } from "@/lib/graph/episode-preparation.js";
import { reconsiderCompletedEpisode } from "@/lib/graph/orchestrator.js";
import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js";
import { loadInvestigation, saveInvestigation, clearInvestigation } from "@/lib/storage/investigation-storage";
/* Compile-time env resolution — NEXT_PUBLIC_ vars are injected by Next.js at build */
@@ -239,6 +242,58 @@ export function derivePrimarySurface(result, status, _showExperimentView, scenar
return "SCENARIO_ENTRY";
}
/**
* Orchestrate the authoritative episode reconsideration flow.
* Exported for deterministic testing — all domain functions accepted as parameters.
*/
export async function executeEpisodeDone({
resultSituationGraph,
targetNodeId,
focusedContributions,
findings,
prepareCompletedEpisode: prepFn = prepareCompletedEpisode,
reconsiderCompletedEpisode: reconsiderFn = reconsiderCompletedEpisode,
applyValidatedProposal: applyFn = applyValidatedProposal,
synthesizeFn,
setResult: setAppState,
}) {
const prepared = prepFn({
situationGraph: resultSituationGraph,
targetNodeId,
contributions: focusedContributions ?? [],
findings,
});
if (!prepared?.turns?.length && !prepared?.eligibleCanonicalFindings?.length) {
return { success: false, stage: "preparation", reason: "no_episodic_content" };
}
const reasoning = await reconsiderFn(prepared);
if (!reasoning.success) {
return { success: false, stage: "reconsideration", error: reasoning.error };
}
const application = await applyFn({
situationGraph: resultSituationGraph,
proposal: reasoning.proposal,
evidenceContext: {
isCompletedEpisode: true,
episodeEvidence: prepared,
},
});
if (!application.success) {
return { success: false, stage: "application", error: application.error };
}
const nextGraph = application.updatedSituationGraph;
setAppState(prev => ({ ...(prev ?? {}), situationGraph: nextGraph }));
const synthesisResult = await synthesizeFn(nextGraph, findings);
return { success: true, nextGraph, synthesisResult };
}
export default function ScenarioForm() {
const [scenario, setScenario] = useState("");
const [status, setStatus] = useState("idle"); // idle | loading | error | success
@@ -252,6 +307,9 @@ export default function ScenarioForm() {
const [mockScenario, setMockScenario] = useState("");
const [hideFacilitatorOnLanding, setHideFacilitatorOnLanding] = useState(false);
/* ── in-flight gate for episode reconsideration on Done ──── */
const doneInProgressRef = useRef(false);
/* ── RTO.31: focused contributions ownership ─────────────── */
const [focusedContributions, setFocusedContributions] = useState([]);
@@ -319,40 +377,35 @@ export default function ScenarioForm() {
}
/**
* v0.49 promotion seam — deterministic Current Understanding update
* triggered by "Done for now" activity boundary (no case/update, no LLM).
* Authoritative graph reconsideration triggered by "Done for now"
* activity boundary. Delegates to the exported executeEpisodeDone pipeline.
*/
function handleDoneForNowPromotion(targetNodeId) {
async function handleDoneForNowPromotion(targetNodeId) {
if (!targetNodeId || !findings?.length) return;
// Filter eligible findings for this specific target only.
const eligible = findings.filter(
(f) => f.originatingTargetNodeId === targetNodeId && (f.userDisposition === null || f.userDisposition === "agree"),
);
// In-flight guard: exactly-once enforcement
if (doneInProgressRef.current) return;
doneInProgressRef.current = true;
if (eligible.length === 0) return;
try {
const result = await executeEpisodeDone({
resultSituationGraph: result?.situationGraph,
targetNodeId,
focusedContributions: focusedContributions ?? [],
findings,
synthesizeFn: (graph, fn) => synthesizeFromFindings(fetch, { situationGraph: graph, findings: fn }),
setResult,
});
// Determine the base: use currentUnderstanding if available, else empty string.
const baseSummary = currentUnderstanding ?? "";
/* CU synthesis — install only on success */
if (result?.synthesisResult?.ok && result.synthesisResult.data?.currentUnderstanding) {
setCurrentUnderstanding(result.synthesisResult.data.currentUnderstanding);
}
/* On synthesis failure: KEEP nextGraph, KEEP Findings, KEEP existing CU. Do NOT rollback. */
// Deterministic producer — no LLM, no API.
const newSummary = produceFindingInformedSummary(baseSummary, eligible);
// Idempotence guard: skip if summary is unchanged (no new eligible findings
// beyond what's already in the current Evidence block).
if (newSummary === baseSummary) return;
// Avoid duplicate evidence propositions from repeated promotion.
const existingEvidenceMatch = baseSummary.match(/Evidence:\s*\[([^\]]+)\]/);
let isDuplicate = false;
if (existingEvidenceMatch) {
const existingTexts = existingEvidenceMatch[1].split("; ").map((t) => t.trim());
isDuplicate = eligible.every((f) => existingTexts.includes(f.proposition));
} finally {
doneInProgressRef.current = false;
}
if (isDuplicate) return;
// Mutate the SAME summary/state that autosave already persists.
setCurrentUnderstanding(newSummary);
}
function appendFocusedContribution(contribution) {