Compare commits
10
Commits
0cbe49913e
...
1c17452bee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c17452bee | ||
|
|
24d9e466f6 | ||
|
|
85b9f4411f | ||
|
|
0b5a38f83d | ||
|
|
7af708159e | ||
|
|
949a7024b3 | ||
|
|
ae00e70ced | ||
|
|
7548a6af59 | ||
|
|
3f2e2e05ae | ||
|
|
14630cf6b7 |
@@ -129,9 +129,16 @@ async function handleEpisodeMode(situationGraph, body) {
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedNodeIds = new Set(application.updatedSituationGraph.resolvedNodeIds ?? []);
|
||||
resolvedNodeIds.add(body.targetNodeId);
|
||||
const updatedSituationGraph = {
|
||||
...application.updatedSituationGraph,
|
||||
resolvedNodeIds: [...resolvedNodeIds],
|
||||
};
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
updatedSituationGraph: application.updatedSituationGraph,
|
||||
updatedSituationGraph,
|
||||
proposal: reasoning.proposal,
|
||||
}, { status: 200 });
|
||||
}
|
||||
|
||||
@@ -6,8 +6,11 @@ import {
|
||||
} from "@/lib/graph/focused-investigation";
|
||||
|
||||
export async function POST(request) {
|
||||
let targetNodeId = null;
|
||||
let startedAt = null;
|
||||
try {
|
||||
const body = await request.json();
|
||||
targetNodeId = body.targetNodeId ?? null;
|
||||
|
||||
if (!body.targetNodeId || typeof body.targetNodeId !== "string") {
|
||||
return Response.json(
|
||||
@@ -55,20 +58,49 @@ export async function POST(request) {
|
||||
});
|
||||
|
||||
const provider = getProvider();
|
||||
const startedAt = Date.now();
|
||||
const modelName = getProviderModelName();
|
||||
startedAt = Date.now();
|
||||
console.info("[api/focused-investigation/deconstruct] start", {
|
||||
targetNodeId,
|
||||
modelName,
|
||||
providerMode: process.env.CONFIDENCE_ENGINE_EXPERIMENT_PROVIDER ?? "ollama",
|
||||
startedAt,
|
||||
});
|
||||
const wrapper = await provider.generateReconstruction(
|
||||
prompt,
|
||||
getProviderModelName(),
|
||||
modelName,
|
||||
focusedDeconstructJsonSchema,
|
||||
);
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
|
||||
// Unwrap the semantic deconstruction from the provider envelope.
|
||||
const deconstruction = wrapper.response;
|
||||
console.info("[api/focused-investigation/deconstruct] provider success", {
|
||||
targetNodeId,
|
||||
elapsedMs,
|
||||
providerApiPath: wrapper.providerApiPath ?? null,
|
||||
responsePresent: Boolean(deconstruction),
|
||||
responseKeys: deconstruction && typeof deconstruction === "object"
|
||||
? Object.keys(deconstruction)
|
||||
: [],
|
||||
});
|
||||
|
||||
// Validate schema (required fields present, no graph-mutation fields)
|
||||
const validationErrors = validateFocusedDeconstructSchema(deconstruction);
|
||||
console.info("[api/focused-investigation/deconstruct] validation", {
|
||||
targetNodeId,
|
||||
schemaValid: validationErrors.length === 0,
|
||||
responseKeys: deconstruction && typeof deconstruction === "object"
|
||||
? Object.keys(deconstruction)
|
||||
: [],
|
||||
validationErrors,
|
||||
});
|
||||
if (validationErrors.length > 0) {
|
||||
console.info("[api/focused-investigation/deconstruct] end", {
|
||||
targetNodeId,
|
||||
status: 502,
|
||||
elapsedMs,
|
||||
});
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
@@ -81,7 +113,7 @@ export async function POST(request) {
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
const response = Response.json({
|
||||
success: true,
|
||||
targetNodeId: body.targetNodeId,
|
||||
observations: deconstruction.observations,
|
||||
@@ -91,7 +123,29 @@ export async function POST(request) {
|
||||
possibleFollowUpQuestions: deconstruction.possibleFollowUpQuestions,
|
||||
elapsedMs,
|
||||
});
|
||||
console.info("[api/focused-investigation/deconstruct] end", {
|
||||
targetNodeId,
|
||||
status: 200,
|
||||
elapsedMs,
|
||||
});
|
||||
return response;
|
||||
} catch (e) {
|
||||
const elapsedMs = startedAt == null ? null : Date.now() - startedAt;
|
||||
console.error("[api/focused-investigation/deconstruct] provider failure", {
|
||||
targetNodeId,
|
||||
elapsedMs,
|
||||
errorName: e?.name ?? "Error",
|
||||
errorMessage: e?.message ?? "Unknown server error",
|
||||
statusCode: e?.statusCode ?? e?.status ?? null,
|
||||
providerApiPath: e?.providerApiPath ?? null,
|
||||
errorCode: e?.code ?? null,
|
||||
errorParam: e?.param ?? null,
|
||||
});
|
||||
console.info("[api/focused-investigation/deconstruct] end", {
|
||||
targetNodeId,
|
||||
status: 500,
|
||||
elapsedMs,
|
||||
});
|
||||
return Response.json(
|
||||
{ error: e.message || "Unknown server error" },
|
||||
{ status: 500 },
|
||||
|
||||
+67
-1
@@ -2,6 +2,72 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--ce-page: #f9fafb;
|
||||
--ce-surface: #ffffff;
|
||||
--ce-surface-muted: #f9fafb;
|
||||
--ce-surface-elevated: #ffffff;
|
||||
--ce-text: #111827;
|
||||
--ce-text-muted: #6b7280;
|
||||
--ce-border: #d1d5db;
|
||||
--ce-teal: #0f766e;
|
||||
--ce-teal-surface: #f0fdfa;
|
||||
--ce-focus: #14b8a6;
|
||||
--ce-skeleton: #e5e7eb;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
--ce-page: #172128;
|
||||
--ce-surface: #202c34;
|
||||
--ce-surface-muted: #1b262e;
|
||||
--ce-surface-elevated: #293740;
|
||||
--ce-text: #edf2f3;
|
||||
--ce-text-muted: #b4c0c5;
|
||||
--ce-border: #40515a;
|
||||
--ce-teal: #62d3c5;
|
||||
--ce-teal-surface: #203a3c;
|
||||
--ce-focus: #78ded2;
|
||||
--ce-skeleton: #3a4a53;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] body { background-color: var(--ce-page) !important; color: var(--ce-text) !important; }
|
||||
html[data-theme="dark"] .app-chrome { background-color: var(--ce-surface-muted); border-color: var(--ce-border); }
|
||||
html[data-theme="dark"] .theme-toggle { background-color: var(--ce-surface-elevated); border-color: var(--ce-border); color: var(--ce-text); }
|
||||
html[data-theme="dark"] .theme-toggle:hover { background-color: #33444d; }
|
||||
html[data-theme="dark"] .bg-white,
|
||||
html[data-theme="dark"] [class*="bg-white"] { background-color: var(--ce-surface) !important; }
|
||||
html[data-theme="dark"] [class*="bg-gray-50"],
|
||||
html[data-theme="dark"] [class*="bg-gray-100"] { background-color: var(--ce-surface-muted) !important; }
|
||||
html[data-theme="dark"] [class*="bg-gradient-to"] { background-image: none !important; background-color: var(--ce-surface) !important; }
|
||||
html[data-theme="dark"] [class*="bg-teal-50"] { background-color: var(--ce-teal-surface) !important; }
|
||||
html[data-theme="dark"] [class*="bg-amber-50"],
|
||||
html[data-theme="dark"] [class*="bg-orange-50"],
|
||||
html[data-theme="dark"] [class*="bg-yellow-50"] { background-color: #3a3324 !important; }
|
||||
html[data-theme="dark"] [class*="bg-green-50"] { background-color: #20392f !important; }
|
||||
html[data-theme="dark"] [class*="bg-blue-50"] { background-color: #243540 !important; }
|
||||
html[data-theme="dark"] [class*="border-gray"],
|
||||
html[data-theme="dark"] [class*="border-teal"],
|
||||
html[data-theme="dark"] [class*="border-amber"],
|
||||
html[data-theme="dark"] [class*="border-orange"],
|
||||
html[data-theme="dark"] [class*="border-green"],
|
||||
html[data-theme="dark"] [class*="border-blue"] { border-color: var(--ce-border) !important; }
|
||||
html[data-theme="dark"] :is(.text-gray-900, .text-gray-800, .text-gray-700, .text-gray-600) { color: var(--ce-text) !important; }
|
||||
html[data-theme="dark"] :is(.text-gray-500, .text-gray-400) { color: var(--ce-text-muted) !important; }
|
||||
html[data-theme="dark"] :is(.text-teal-700, .text-teal-800) { color: var(--ce-teal) !important; }
|
||||
html[data-theme="dark"] :is(.text-amber-700, .text-amber-800, .text-orange-700, .text-orange-800, .text-yellow-700, .text-yellow-800) { color: #f2c879 !important; }
|
||||
html[data-theme="dark"] :is(.text-green-700, .text-green-800) { color: #8bd8a8 !important; }
|
||||
html[data-theme="dark"] input,
|
||||
html[data-theme="dark"] textarea,
|
||||
html[data-theme="dark"] select { background-color: var(--ce-surface-elevated); color: var(--ce-text); border-color: var(--ce-border); }
|
||||
html[data-theme="dark"] input::placeholder,
|
||||
html[data-theme="dark"] textarea::placeholder { color: #94a3ab; }
|
||||
html[data-theme="dark"] details { background-color: var(--ce-surface-muted) !important; }
|
||||
html[data-theme="dark"] button:focus-visible,
|
||||
html[data-theme="dark"] a:focus-visible,
|
||||
html[data-theme="dark"] input:focus-visible,
|
||||
html[data-theme="dark"] textarea:focus-visible,
|
||||
html[data-theme="dark"] select:focus-visible { outline: 2px solid var(--ce-focus); outline-offset: 2px; }
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
@@ -41,7 +107,7 @@
|
||||
.cu-skeleton-line {
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
background-color: #e5e7eb;
|
||||
background-color: var(--ce-skeleton);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "./globals.css";
|
||||
import ThemeToggle from "@/components/theme-toggle";
|
||||
|
||||
export const metadata = {
|
||||
title: "Confidence Engine",
|
||||
@@ -9,6 +10,17 @@ export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="min-h-screen bg-gray-50 text-gray-900">
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `try { const saved = localStorage.getItem('confidence-engine-theme'); const theme = saved === 'dark' || saved === 'light' ? saved : (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); document.documentElement.dataset.theme = theme; document.documentElement.style.colorScheme = theme; } catch (_) {}`,
|
||||
}}
|
||||
/>
|
||||
<header className="app-chrome border-b border-gray-200/80">
|
||||
<div className="mx-auto flex max-w-[1600px] items-center justify-between px-6 py-3">
|
||||
<span className="text-sm font-semibold tracking-wide text-teal-700">Confidence Engine</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
readThemePreference,
|
||||
resolveTheme,
|
||||
saveThemePreference,
|
||||
toggleTheme,
|
||||
} from "@/lib/theme-preference.js";
|
||||
|
||||
function applyTheme(theme) {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
}
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [theme, setTheme] = useState("light");
|
||||
|
||||
useEffect(() => {
|
||||
const nextTheme = resolveTheme({
|
||||
savedTheme: readThemePreference(window.localStorage),
|
||||
systemPrefersDark: window.matchMedia?.("(prefers-color-scheme: dark)").matches,
|
||||
});
|
||||
setTheme(nextTheme);
|
||||
applyTheme(nextTheme);
|
||||
}, []);
|
||||
|
||||
const switchTheme = () => {
|
||||
const nextTheme = toggleTheme(theme);
|
||||
setTheme(nextTheme);
|
||||
saveThemePreference(nextTheme, window.localStorage);
|
||||
applyTheme(nextTheme);
|
||||
};
|
||||
|
||||
const isDark = theme === "dark";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={switchTheme}
|
||||
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||
className="theme-toggle rounded-lg border px-3 py-2 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-500 focus-visible:ring-offset-2"
|
||||
>
|
||||
<span aria-hidden="true" className="mr-1.5">{isDark ? "☀" : "☾"}</span>
|
||||
{isDark ? "Light mode" : "Dark mode"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
# Confidence Engine Product Checkpoint — 2026-09-08
|
||||
|
||||
## Purpose
|
||||
|
||||
Record the evidence boundary at which Confidence Engine moves from proving isolated reasoning mechanics toward commercially testing the working product loop. This is a current checkpoint, not a claim of universal provider or production validation.
|
||||
|
||||
## PROVEN / OBSERVED
|
||||
|
||||
### Working product loop
|
||||
|
||||
```text
|
||||
messy scenario → initial reconstruction / SituationGraph → Current Understanding
|
||||
→ Open Questions → user selects a question → focused multi-turn investigation
|
||||
→ canonical Findings and accumulated Contributions → Done for now
|
||||
→ authoritative clarification → completed-episode reconsideration
|
||||
→ Current Understanding resynthesis → user chooses what to investigate next
|
||||
```
|
||||
|
||||
When all Open Questions are clarified, the direction is to surface a report derived from accumulated case understanding. It is not a recommendation engine and must not claim that the user is ready, sufficiently informed, confident, or should act.
|
||||
|
||||
The engine facilitates the user's reasoning; it does not steer it. The user owns question selection, depth, Done-for-now, re-opening, sufficiency, confidence, and eventual action.
|
||||
|
||||
### Multi-turn, closure, and persistence
|
||||
|
||||
- A manufacturing supplier thread retained one existing plus five additional answer turns: six learned Contributions in total.
|
||||
- After the sixth contribution, Done for now clarified the supplier question, removed it from Open Questions, preserved the investigation, and resynthesised Current Understanding without retrying or selecting another question.
|
||||
- A completed episode may legitimately have no semantic graph mutation. Done for now remains user-owned; the server makes the target authoritative in graph resolution state without fabricating semantic change.
|
||||
- Persisted investigations resume Current Understanding, Open and clarified Questions, focused Contributions, Findings, and graph identity/provenance. Re-open preserves learned thread state.
|
||||
|
||||
### Re-open graph-state fix
|
||||
|
||||
Canonical persisted Done-for-now state is an `unknown` node with `status: "unknown"` whose ID is in `resolvedNodeIds`. The former Re-open check required `status: "resolved"`, silently no-oping across both Playwright Chrome and normal Chrome persisted state. Commit `85b9f44` uses authoritative resolution state for unknown nodes.
|
||||
|
||||
Deterministic evidence: `npx vitest run tests/graph/reopen-resolved-unknown.test.js --environment=node` — 1 file, 14/14 tests passed. Live evidence: supplier `resolvedNodeIds` changed from `["n9joe0e"]` to `[]`, and the supplier question visibly returned to Open Questions in both browsers.
|
||||
|
||||
### Provider position and measured Terra journeys
|
||||
|
||||
- Available routes: OpenAI / GPT-5.6 Terra and Ollama / network Qwen. Terra has shown good-enough semantic behavior on tested reasoning paths; neither route is universally validated.
|
||||
- Clean shallow manufacturing journey (start → supplier question → one answer → Done for now → refreshed Current Understanding): 6 OpenAI requests and $0.12. Observed browser timings: `/start` 40.04s; formulate 0.56s; deconstruct 3.49s; synthesis 3.67s; `/update` 21.50s; synthesis 4.59s.
|
||||
- Extending the same supplier thread from one to six Contributions required five additional answers and one Done-for-now completion, with no retries, other questions, or new investigation: +10 requests and +$0.05, moving the dashboard to 16 requests and $0.17. Current dashboard totals were 37.862K input, 6.822K output, 44.684K cumulative tokens. Earlier token figures lack a confirmed input/output split and are not total-token evidence.
|
||||
|
||||
## PROVISIONAL / EXTRAPOLATED
|
||||
|
||||
With five Open Questions remaining in the representative manufacturing scenario, a six-question investigation of broadly similar depth may plausibly cost $0.50–$0.80 in Terra inference, with ~$0.60 as a working midpoint. This is an extrapolation, not a validated production unit cost; replace it with a measured full-scenario run.
|
||||
|
||||
Inference cost is not yet the evident commercial constraint. Latency is more conspicuous: graph-level `/start` is about 40s and observed `/update` runs are about 20–40s, while focused deconstruction and synthesis are commonly low-single-digit seconds. The likely optimisation target is time-to-first-value, subject to measurement.
|
||||
|
||||
## KNOWN BUT NOT CURRENT WORK
|
||||
|
||||
- Measure the operations dominating `/start` and `/update`, and whether each must block the visible transition. Do not assume initial reconstruction alone is the issue because `/update` has comparable latency.
|
||||
- Turns 3–6 revisited whether unit-level records could connect defects/materials to a supplier after evidence repeatedly established those records were unavailable. This is a future evidence-boundary-exhaustion question, not an instruction for the engine to stop or steer the user.
|
||||
- Investigation-turn allowances may become a commercial entitlement mechanism, per investigation or monthly. They must never imply epistemic sufficiency or tell a user to stop.
|
||||
|
||||
## NEXT STRATEGIC DIRECTION
|
||||
|
||||
The immediate question is no longer whether the fundamental reasoning architecture can work at all. Prioritise complete realistic scenarios, trust-critical failures encountered in real use, a commercially testable report experience, a small realistic end-to-end set, prospective-user testing, repeat use, willingness to pay, and what users value. Do not use this transition to ignore genuine trust-critical defects or to return to theoretical perfection work before user-value evidence.
|
||||
|
||||
## Development discipline for bounded live work
|
||||
|
||||
A failed prescribed UI step is evidence, not permission to explore. Use exact semantic `page.getByRole(...)` locators; snapshot refs are observational only. Do not use selector fallbacks, navigate/restart/refresh without instruction, substitute questions, or retry model-backed actions. Stop at the first unexpected state and classify PRODUCT FAILURE or APPARATUS FAILURE. When a persisted browser state is manually positioned, do not navigate away. Define explicit action budgets for live cost experiments.
|
||||
+13
-9
@@ -7,6 +7,8 @@
|
||||
|
||||
Initial-decomposition hardening is frozen for the current MVP stage.
|
||||
|
||||
**Current product checkpoint:** Read `docs/confidence-engine-product-checkpoint-2026-09-08.md` before planning new product, live-evidence, or commercial work. The core investigation loop is now sufficiently established to prioritise realistic end-to-end use, report experience, prospective-user value, repeat use, and willingness to pay—not endless isolated reasoning-mechanics experiments. Preserve user ownership and address trust-critical defects when found.
|
||||
|
||||
Do not resume:
|
||||
- repeated-same-input reconstruction experiments
|
||||
- Qwen/Terra reconstruction comparison
|
||||
@@ -17,15 +19,9 @@ Do not resume:
|
||||
|
||||
unless new end-to-end user-flow evidence reopens one of those boundaries.
|
||||
|
||||
**Immediate next evidence question:**
|
||||
**Immediate strategic evidence question:**
|
||||
|
||||
Measure one real browser investigation configured for OpenAI/Terra across every LLM stage, including user-visible latency, call sequence, and cost.
|
||||
|
||||
If YES, the next live experiment is one timed/costed OpenAI UI investigation measuring:
|
||||
- user-visible latency
|
||||
- OpenAI call count
|
||||
- token usage where available
|
||||
- approximate cost per investigation
|
||||
Does the complete investigation process leave real people materially clearer about genuinely difficult situations, repeatedly enough that they will pay to use it? Use measured realistic scenarios and bounded live action budgets; the September 8 checkpoint records the current Terra cost/latency evidence.
|
||||
|
||||
## Server-owned UI journey provider experiment
|
||||
|
||||
@@ -39,7 +35,13 @@ If YES, the next live experiment is one timed/costed OpenAI UI investigation mea
|
||||
- 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.
|
||||
- The latest Terra request then exposed an inconsistent root `properties`/`required` contract. The projector now derives `required` after projection from the surviving property keys, and recursive tests verify `properties`, `required`, and `additionalProperties` consistency. Property-less object strictness and initial-reconstruction transport behavior remain preserved.
|
||||
- Current deterministic final-fetch schema remains internally valid, yet the live rejection contradicts it. `CONFIDENCE_ENGINE_EXPERIMENT_TRACE_OPENAI_SCHEMA=1` now emits one safe, server-side structural summary immediately before the OpenAI fetch—no prompt, answer, request body, secret, or model output.
|
||||
- Provider suite passes with zero live calls. Next boundary: one fresh focused UI submission with the OpenAI provider and schema-trace flags enabled; capture the single trace and OpenAI response, with no Retry.
|
||||
- The focused-deconstruction route now emits complementary safe server diagnostics for start, provider success/failure, focused validation, and end status; the OpenAI schema trace remains provider-owned. No user content or secrets are logged.
|
||||
- Canonical focused relationship items are now strict `{ from, to, type }`: all required non-empty strings, free-text `type`, and no `rationale`; `relationships` remains required and may be `[]`. Schema, prompt field names, and validator align; the stale rationale-bearing test fixture was corrected.
|
||||
- Live Terra focused deconstruction now passes through the real UI, but the following Current Understanding synthesis exposed a separate caller/schema mismatch: its prompt and validator require `{ currentUnderstanding }` while the provider received the default initial-reconstruction schema.
|
||||
- Terra synthesis now supplies its own output schema and unwraps `providerResult.response` before validation; deterministic synthesis coverage is 62/62 PASS and the saved isolated Terra synthesis POST returned HTTP 200.
|
||||
- Real Terra completed-episode reconsideration can legitimately return no additional meaningful graph change. Completed episodes now tolerate only that exact compatibility outcome; ordinary no-op updates and invalid completed-episode proposals remain rejected, without fake graph mutation or new next-question steering.
|
||||
- The full apply-proposal owner suite remains known-red in independent pre-existing 60B.43 tests, so the changed Done-for-now boundary was verified through exact isolated owner tests. Zero live calls occurred during closeout. Next boundary: reuse the existing investigation and click Done for now once, observing cases/update and subsequent synthesis.
|
||||
- Live UI proved Done for now briefly clarified the question, then server graph replacement reopened it: the client sends `preDoneGraph`, and the server previously had no deterministic closure owner. Episode-mode update now adds the selected `targetNodeId` to `resolvedNodeIds` only after successful semantic application; semantic no-ops and meaningful mutations remain valid, while failed episodes do not resolve the target and ordinary updates are unchanged. Zero live calls occurred during implementation. Next boundary: one live Done-for-now check on the existing investigation.
|
||||
|
||||
## Repository checkpoint
|
||||
|
||||
@@ -151,6 +153,7 @@ RAW USER EVIDENCE
|
||||
- If a prescribed semantic locator cannot find its control → STOP. No fallback to CSS/XPath/DOM traversal.
|
||||
- Live freeze: once Playwright verification begins, no production file edits until evidence is captured.
|
||||
- **Tests are instruments, not product truth.** At first deterministic failure: classify PRODUCT vs APPARATUS, then stop.
|
||||
- A failed prescribed UI step is evidence, not permission to explore: semantic `page.getByRole(...)` locators only; do not substitute actions, retry model-backed steps, or navigate away from a manually positioned persisted state. Define action budgets for live cost experiments.
|
||||
|
||||
## Current genuinely open boundaries
|
||||
|
||||
@@ -186,6 +189,7 @@ These results are documented as **historical experiment evidence**. The v0.61 li
|
||||
| Methodology / RTO axioms | `docs/current-working-principles.md` §0 (A1–A12) |
|
||||
| Architecture guardrails | `.claude/architecture-guardrails.md` |
|
||||
| Task routing by work type | `docs/task-context-packs.md` |
|
||||
| September 8 product/economics checkpoint | `docs/confidence-engine-product-checkpoint-2026-09-08.md` |
|
||||
| Full experiment history (specific) | `docs/design-evolution/README.md` → relevant chapter |
|
||||
|
||||
Consult `docs/current-project-state.md` for broader project context and passive classifier status.
|
||||
|
||||
@@ -34,7 +34,13 @@ The product direction is a **facilitated investigation** presented across three
|
||||
|
||||
**Report:** Renders persisted `investigationReport` snapshot. Generation is on-demand (exactly one `/api/cases/overview` call on first visit; zero on subsequent visits). The Report is a derived artefact, not canonical reasoning evidence.
|
||||
|
||||
The user controls which question to investigate, when to say Done for now, whether Current Understanding is sufficient, whether to reopen work, and when to review the Report. The engine facilitates — it does not steer or prioritise.
|
||||
The user controls which question to investigate, how deeply to investigate it, when to say Done for now, whether Current Understanding is sufficient, whether to reopen work, and when to review the Report. The engine facilitates — it does not steer or prioritise.
|
||||
|
||||
## September 8, 2026 Product Checkpoint
|
||||
|
||||
`docs/confidence-engine-product-checkpoint-2026-09-08.md` is the current checkpoint for the established working loop, multi-turn supplier evidence, Done-for-now/Re-open graph-state behavior, persistence, provider position, measured Terra economics, and the move toward commercially testing realistic end-to-end use. It distinguishes proven observations from provisional cost extrapolation and known future questions.
|
||||
|
||||
For the current stage, the primary question is increasingly whether this process leaves real people materially clearer about difficult situations, repeatedly enough that they will pay to use it. This does not weaken the invariant that the user owns investigation choice, depth, closure, reopening, sufficiency, confidence, and action; nor does it excuse trust-critical defects.
|
||||
|
||||
## 3. Current Engine Capabilities
|
||||
|
||||
|
||||
@@ -4089,7 +4089,16 @@ export async function applyValidatedProposal({
|
||||
);
|
||||
|
||||
if (!proposalGraphValidation.valid) {
|
||||
proposalCompatibilityErrors.push(...proposalGraphValidation.errors);
|
||||
const acceptsCompletedEpisodeNoOp = evidenceContext?.isCompletedEpisode === true;
|
||||
proposalCompatibilityErrors.push(
|
||||
...proposalGraphValidation.errors.filter(
|
||||
(error) =>
|
||||
!(
|
||||
acceptsCompletedEpisodeNoOp &&
|
||||
error === "Update contains no meaningful change"
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const existingEdgeIds = new Set(situationGraph.edges.map((edge) => edge.id));
|
||||
|
||||
@@ -186,6 +186,15 @@ export const synthesisResponseSchema = z.object({
|
||||
.min(1, "currentUnderstanding must be a non-empty string"),
|
||||
});
|
||||
|
||||
export const synthesisOutputSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
currentUnderstanding: { type: "string" },
|
||||
},
|
||||
required: ["currentUnderstanding"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
/** Validate raw provider output against synthesis response schema */
|
||||
export function validateSynthesisResponse(raw) {
|
||||
if (raw == null) {
|
||||
@@ -270,11 +279,12 @@ export async function synthesizeCurrentUnderstanding(
|
||||
modelName = process.env.OLLAMA_MODEL ?? null;
|
||||
}
|
||||
|
||||
let rawResponse;
|
||||
let providerResult;
|
||||
try {
|
||||
rawResponse = await provider.generateReconstruction(
|
||||
providerResult = await provider.generateReconstruction(
|
||||
prompt,
|
||||
modelName
|
||||
modelName,
|
||||
synthesisOutputSchema,
|
||||
);
|
||||
} catch (error) {
|
||||
const err = new Error(error.message ?? "Synthesis provider call failed");
|
||||
@@ -283,7 +293,7 @@ export async function synthesizeCurrentUnderstanding(
|
||||
}
|
||||
|
||||
// 5. Validate response
|
||||
const validated = validateSynthesisResponse(rawResponse);
|
||||
const validated = validateSynthesisResponse(providerResult.response);
|
||||
if (!validated.valid) {
|
||||
const err = new Error(`Synthesis validation failed: ${validated.error}`);
|
||||
err.statusCode = 502;
|
||||
|
||||
@@ -16,7 +16,19 @@ export const focusedDeconstructJsonSchema = {
|
||||
observations: { type: "array", items: { type: "string" } },
|
||||
uncertainties: { type: "array", items: { type: "string" } },
|
||||
assumptions: { type: "array", items: { type: "string" } },
|
||||
relationships: { type: "array", items: { type: "object" } },
|
||||
relationships: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
from: { type: "string" },
|
||||
to: { type: "string" },
|
||||
type: { type: "string" },
|
||||
},
|
||||
required: ["from", "to", "type"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
possibleFollowUpQuestions: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
required: FOCUSED_ANSWER_SCHEMA_FIELDS,
|
||||
@@ -115,7 +127,7 @@ Field rules (semantic contract):
|
||||
- assumptions: what unstated proposition does the user's answer itself rely upon for it to make sense? Include only when such a proposition is genuinely attributable to the user's reasoning. The boundary is narrow: attribute only propositions that the user's answer would cease to make sense if they were false. Do NOT import plausible interpretations from the wider investigation context, scenario framing, domain relevance, strategic implications, or model-generated analysis into this field — those belong in uncertainties, relationships (where permitted), or possibleFollowUpQuestions. Do NOT connect a factual statement the user makes to a broader capability or constraint concept unless the user explicitly links them. Example: answering "I only have bank account access" to a question about delegation constraints does NOT assume that "delegation feasibility is contingent upon banking access" — it only states a fact about access, and connecting that fact to delegation feasibility is your own scenario-level inference, not a user-held assumption. If the user's answer does not contain or rely upon an identifiable assumption, return assumptions: []. Do NOT require verbatim copying from the user's answer; paraphrasing is allowed only when the reasoning genuinely relies on it.
|
||||
|
||||
Answer-dependence test: Only attribute an assumption if the user's answer needs that proposition to make sense. If the proposition could be false and the user's answer would still make complete sense, do not attribute it. One observed success in a single concrete example does NOT by itself establish a general rule about competence, readiness, training, safety, transferability, or similar tasks across other work. Do not generalise from one successful example into a broader capability/readiness rule unless the user explicitly or implicitly relies on that broader proposition.
|
||||
- relationships: must connect two distinct propositions that the user's answer itself links. Do not create a relationship by merely restating, reformatting, or relabelling an observation. Co-mentioned facts do not themselves create a relationship. Tentative, speculative, or conditional language must not be promoted into an established relationship. If the answer does not directly establish a relationship, return relationships: [].
|
||||
- relationships: each non-empty item must be { "from": "first proposition", "to": "second distinct proposition", "type": "concise free-text relationship label" }. It must connect two distinct propositions that the user's answer itself links. Do not create a relationship by merely restating, reformatting, or relabelling an observation. Co-mentioned facts do not themselves create a relationship. Tentative, speculative, or conditional language must not be promoted into an established relationship. If the answer does not directly establish a relationship, return relationships: [].
|
||||
- possibleFollowUpQuestions: must be a JSON array containing exactly one string — your single best follow-up question. Example shape: ["one question"]. This question must directly investigate the single uncertainty returned in uncertainties (uncertainties[0] → possibleFollowUpQuestions[0]): one unresolved proposition mapped to one question designed to clarify it. The question must not introduce a second unresolved issue, must not broaden beyond the uncertainty it is meant to resolve, and must not contain more than one investigative step. Do not provide alternatives, a roadmap, or questions that belong after this one has been answered. A later question must be generated only after the current question has been answered and deconstructed. Do not ask about consequences, expansion, requirements, interventions, or other branches until the immediate unresolved relationship has been clarified. Those may become later questions after new evidence is obtained. Ask only what the Engine has earned the right to ask now. Each epistemic step waits its turn — do not combine steps that should happen in sequence across multiple turns: one question that investigates one thing only, never a bundle of future reasoning joined together. Before formulating, check whether the question tests a proposition against the current epistemic state: if an explanation, deficit, dependency, cause, intervention, recommendation, or solution has not been established by prior evidence, phrase the question so it tests whether that proposition is true rather than assuming it — verify the unresolved fact before seeking remedy. Prefer questions that identify what remains unknown, distinguish competing explanations, test whether a suspected factor actually matters, clarify scope, or identify what evidence would change the investigation. Do not jump to implementation details unless the answer has already established that intervention as the relevant next issue. Use ordinary language that a capable person with no specialist vocabulary can understand immediately. If the question needs abstract phrases, management jargon, specialist terminology, or several concepts joined together to express it, break the reasoning down again before returning it. Simple wording of an over-composed idea is still a failure: first ask "what is the smallest thing we actually do not know yet?" then express that one thing simply.
|
||||
- cross-field ownership: preserve who or what owns each proposition. When a statement expresses the user's comfort, willingness, threshold, belief, uncertainty, preference, or judgement, keep it attached to that stance — do not elevate it into an objective requirement, capability fact, or situational constraint.
|
||||
|
||||
@@ -150,6 +162,30 @@ export function validateFocusedDeconstructSchema(result) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(result.relationships)) {
|
||||
errors.push("relationships must be an array");
|
||||
} else {
|
||||
result.relationships.forEach((relationship, index) => {
|
||||
if (!relationship || typeof relationship !== "object" || Array.isArray(relationship)) {
|
||||
errors.push(`relationships[${index}] must be an object`);
|
||||
return;
|
||||
}
|
||||
const keys = Object.keys(relationship);
|
||||
for (const field of ["from", "to", "type"]) {
|
||||
if (!(field in relationship)) {
|
||||
errors.push(`relationships[${index}] missing required field: ${field}`);
|
||||
} else if (typeof relationship[field] !== "string" || relationship[field].trim().length === 0) {
|
||||
errors.push(`relationships[${index}].${field} must be a non-empty string`);
|
||||
}
|
||||
}
|
||||
for (const field of keys) {
|
||||
if (!["from", "to", "type"].includes(field)) {
|
||||
errors.push(`relationships[${index}] contains unknown field: ${field}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* Deterministically reopen a resolved unknown node in a SituationGraph.
|
||||
*
|
||||
* Transition: node.status "resolved" → "unknown", and removes the node's ID
|
||||
* Transition: removes the node's ID from resolvedNodeIds and establishes
|
||||
* node.status "unknown".
|
||||
* from resolvedNodeIds. This reverses canonical resolution so the question
|
||||
* reappears among Open Questions for further investigation.
|
||||
*
|
||||
* Idempotent — if the target is not a currently-resolved unknown, returns the
|
||||
* Idempotent — if the target is not an unknown marked resolved in graph state, returns the
|
||||
* original graph unchanged (no mutation). Does NOT create nodes, delete edges,
|
||||
* or touch contributions/findings/historical evidence.
|
||||
*/
|
||||
@@ -17,7 +18,9 @@ export function reopenResolvedUnknown(situationGraph, nodeId) {
|
||||
if (nodeIndex === -1) return situationGraph;
|
||||
|
||||
const node = situationGraph.nodes[nodeIndex];
|
||||
if (node.kind !== "unknown" || node.status !== "resolved") return situationGraph;
|
||||
if (node.kind !== "unknown" || !situationGraph.resolvedNodeIds?.includes(nodeId)) {
|
||||
return situationGraph;
|
||||
}
|
||||
|
||||
const newNode = { ...node, status: "unknown" };
|
||||
const newNodes = [...situationGraph.nodes];
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export const THEME_STORAGE_KEY = "confidence-engine-theme";
|
||||
|
||||
export function resolveTheme({ savedTheme, systemPrefersDark = false } = {}) {
|
||||
if (savedTheme === "light" || savedTheme === "dark") return savedTheme;
|
||||
return systemPrefersDark ? "dark" : "light";
|
||||
}
|
||||
|
||||
export function toggleTheme(theme) {
|
||||
return theme === "dark" ? "light" : "dark";
|
||||
}
|
||||
|
||||
export function readThemePreference(storage) {
|
||||
try {
|
||||
return storage?.getItem(THEME_STORAGE_KEY) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveThemePreference(theme, storage) {
|
||||
storage?.setItem(THEME_STORAGE_KEY, theme);
|
||||
}
|
||||
@@ -1,11 +1,45 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { makeGraph, makeNode } from "@/lib/graph/schema.js";
|
||||
|
||||
const mockUpdateCase = vi.fn();
|
||||
const mockReconsiderCompletedEpisode = vi.fn();
|
||||
const mockApplyValidatedProposal = vi.fn();
|
||||
const mockPrepareCompletedEpisode = vi.fn();
|
||||
|
||||
vi.mock("@/lib/graph/orchestrator.js", () => ({
|
||||
updateCase: (...args) => mockUpdateCase(...args),
|
||||
reconsiderCompletedEpisode: (...args) => mockReconsiderCompletedEpisode(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/graph/apply-proposal.js", () => ({
|
||||
applyValidatedProposal: (...args) => mockApplyValidatedProposal(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/graph/episode-preparation.js", () => ({
|
||||
prepareCompletedEpisode: (...args) => mockPrepareCompletedEpisode(...args),
|
||||
}));
|
||||
|
||||
function makeEpisodeGraph() {
|
||||
return makeGraph({
|
||||
centralStatement: "Episode scenario",
|
||||
currentSummary: "Episode graph",
|
||||
nodes: [makeNode({ id: "target", label: "Target question", kind: "unknown", status: "unknown" })],
|
||||
edges: [],
|
||||
activeUnknownNodeId: "target",
|
||||
resolvedNodeIds: ["already-resolved"],
|
||||
});
|
||||
}
|
||||
|
||||
function mockSuccessfulEpisodeApplication(graph, overrides = {}) {
|
||||
mockPrepareCompletedEpisode.mockReturnValue({ turns: [{ question: "Q?", answer: "A." }] });
|
||||
mockReconsiderCompletedEpisode.mockResolvedValue({ success: true, proposal: {} });
|
||||
mockApplyValidatedProposal.mockResolvedValue({
|
||||
success: true,
|
||||
updatedSituationGraph: graph,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function makeSuccessResult() {
|
||||
return {
|
||||
success: true,
|
||||
@@ -76,6 +110,67 @@ describe("app/api/cases/update route", () => {
|
||||
expect(mockUpdateCase.mock.calls[0][0]).not.toHaveProperty("modelName");
|
||||
});
|
||||
|
||||
it("authoritatively resolves the selected target after a successful no-op episode", async () => {
|
||||
const graph = makeEpisodeGraph();
|
||||
mockSuccessfulEpisodeApplication(graph);
|
||||
const { POST } = await import("@/app/api/cases/update/route.js");
|
||||
|
||||
const response = await POST(new Request("http://localhost/api/cases/update", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
episodeMode: true,
|
||||
situationGraph: graph,
|
||||
targetNodeId: "target",
|
||||
contributions: [{ targetNodeId: "target" }],
|
||||
}),
|
||||
}));
|
||||
|
||||
const body = await response.json();
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.updatedSituationGraph.resolvedNodeIds).toEqual(
|
||||
expect.arrayContaining(["already-resolved", "target"]),
|
||||
);
|
||||
expect(body.updatedSituationGraph.nodes).toEqual(graph.nodes);
|
||||
expect(body.updatedSituationGraph.edges).toEqual(graph.edges);
|
||||
});
|
||||
|
||||
it("preserves meaningful episode mutations while authoritatively resolving the target", async () => {
|
||||
const graph = makeEpisodeGraph();
|
||||
const mutatedGraph = {
|
||||
...graph,
|
||||
nodes: [...graph.nodes, makeNode({ id: "meaningful", label: "Meaningful change", kind: "observation", status: "known" })],
|
||||
};
|
||||
mockSuccessfulEpisodeApplication(mutatedGraph);
|
||||
const { POST } = await import("@/app/api/cases/update/route.js");
|
||||
|
||||
const response = await POST(new Request("http://localhost/api/cases/update", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ episodeMode: true, situationGraph: graph, targetNodeId: "target", contributions: [{ targetNodeId: "target" }] }),
|
||||
}));
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.updatedSituationGraph.nodes).toEqual(mutatedGraph.nodes);
|
||||
expect(body.updatedSituationGraph.resolvedNodeIds).toContain("target");
|
||||
});
|
||||
|
||||
it("does not return an authoritative closure when completed-episode reconsideration fails", async () => {
|
||||
const graph = makeEpisodeGraph();
|
||||
mockPrepareCompletedEpisode.mockReturnValue({ turns: [{ question: "Q?", answer: "A." }] });
|
||||
mockReconsiderCompletedEpisode.mockResolvedValue({ success: false, stage: "proposal_compatibility", error: "invalid" });
|
||||
const { POST } = await import("@/app/api/cases/update/route.js");
|
||||
|
||||
const response = await POST(new Request("http://localhost/api/cases/update", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ episodeMode: true, situationGraph: graph, targetNodeId: "target", contributions: [{ targetNodeId: "target" }] }),
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
await expect(response.json()).resolves.not.toHaveProperty("updatedSituationGraph");
|
||||
});
|
||||
|
||||
it("invalid JSON returns 400", async () => {
|
||||
const { POST } = await import("@/app/api/cases/update/route.js");
|
||||
const request = {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
|
||||
import { focusedDeconstructJsonSchema } from "@/lib/graph/focused-investigation";
|
||||
import { focusedDeconstructJsonSchema, validateFocusedDeconstructSchema } from "@/lib/graph/focused-investigation";
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -20,8 +20,8 @@ function makeMockProvider(inventedTargetNodeId) {
|
||||
uncertainties: ["whether formal docs can capture tacit knowledge"],
|
||||
assumptions: ["documentation is primary mechanism for knowledge transfer"],
|
||||
relationships: [
|
||||
{ from: "founder", to: "processes", type: "holds", rationale: "tacit" },
|
||||
{ from: "ops-context", to: "docs-infra", type: "depends_on", rationale: "formal docs required" },
|
||||
{ from: "founder", to: "processes", type: "holds" },
|
||||
{ from: "ops-context", to: "docs-infra", type: "depends_on" },
|
||||
],
|
||||
possibleFollowUpQuestions: [
|
||||
"What processes does the founder hold tacitly?",
|
||||
@@ -45,6 +45,33 @@ describe("focused-deconstruct targetNodeId identity boundary", () => {
|
||||
vi.doUnmock("@/lib/llm/provider");
|
||||
});
|
||||
|
||||
it("enforces the canonical focused relationship structure", () => {
|
||||
const base = {
|
||||
targetNodeId: "node-id", observations: [], uncertainties: [], assumptions: [],
|
||||
possibleFollowUpQuestions: [],
|
||||
};
|
||||
expect(validateFocusedDeconstructSchema({ ...base, relationships: [] })).toEqual([]);
|
||||
expect(validateFocusedDeconstructSchema({
|
||||
...base,
|
||||
relationships: [{ from: "supplier changed", to: "defect rate increased", type: "associated with" }],
|
||||
})).toEqual([]);
|
||||
|
||||
const invalidRelationships = [
|
||||
"not an array",
|
||||
[{}],
|
||||
[{ from: "a", type: "links" }],
|
||||
[{ from: "a", to: "b" }],
|
||||
[{ from: "", to: "b", type: "links" }],
|
||||
[{ from: "a", to: "", type: "links" }],
|
||||
[{ from: "a", to: "b", type: "" }],
|
||||
[{ from: "a", to: "b", type: "links", rationale: "extra" }],
|
||||
[{ from: "a", to: "b", type: "links", extra: "extra" }],
|
||||
];
|
||||
invalidRelationships.forEach((relationships) => {
|
||||
expect(validateFocusedDeconstructSchema({ ...base, relationships }).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("request targetNodeId overrides model-invented targetNodeId", async () => {
|
||||
const requestTargetNodeId = "nk04xvk"; // original graph node ID
|
||||
const inventedModelId = "invented-model-id";
|
||||
@@ -128,8 +155,8 @@ describe("focused-deconstruct targetNodeId identity boundary", () => {
|
||||
const mockUnc = ["whether formal docs can capture tacit knowledge"];
|
||||
const mockAssm = ["documentation is primary mechanism for knowledge transfer"];
|
||||
const mockRel = [
|
||||
{ from: "founder", to: "processes", type: "holds", rationale: "tacit" },
|
||||
{ from: "ops-context", to: "docs-infra", type: "depends_on", rationale: "formal docs required" },
|
||||
{ from: "founder", to: "processes", type: "holds" },
|
||||
{ from: "ops-context", to: "docs-infra", type: "depends_on" },
|
||||
];
|
||||
const mockFuq = [
|
||||
"What processes does the founder hold tacitly?",
|
||||
@@ -328,4 +355,64 @@ describe("focused-deconstruct targetNodeId identity boundary", () => {
|
||||
expect(json.providerApiPath).toBeUndefined();
|
||||
expect(json.providerExecution).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves the 500 provider-failure contract while logging structural diagnostics", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vi.doMock("@/lib/llm/provider", () => ({
|
||||
getProvider: () => ({
|
||||
generateReconstruction: vi.fn().mockRejectedValue(Object.assign(new Error("provider failed"), {
|
||||
providerApiPath: "/v1/responses",
|
||||
statusCode: 400,
|
||||
})),
|
||||
}),
|
||||
getProviderModelName: () => "gpt-5.6-terra",
|
||||
}));
|
||||
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
|
||||
|
||||
try {
|
||||
const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
targetNodeId: "node-id", targetLabel: "label", targetDescription: "description",
|
||||
centralStatement: "central", question: "question?", answer: "answer.",
|
||||
}),
|
||||
}));
|
||||
expect(response.status).toBe(500);
|
||||
await expect(response.json()).resolves.toEqual({ error: "provider failed" });
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
"[api/focused-investigation/deconstruct] provider failure",
|
||||
expect.objectContaining({ targetNodeId: "node-id", providerApiPath: "/v1/responses" }),
|
||||
);
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the 502 validation-failure contract with diagnostics", async () => {
|
||||
vi.doMock("@/lib/llm/provider", () => ({
|
||||
getProvider: () => ({
|
||||
generateReconstruction: vi.fn().mockResolvedValue({
|
||||
response: {}, providerApiPath: "/v1/responses",
|
||||
}),
|
||||
}),
|
||||
getProviderModelName: () => "gpt-5.6-terra",
|
||||
}));
|
||||
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
|
||||
const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
targetNodeId: "node-id", targetLabel: "label", targetDescription: "description",
|
||||
centralStatement: "central", question: "question?", answer: "answer.",
|
||||
}),
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: "Focused deconstruction result did not match expected schema",
|
||||
targetNodeId: "node-id",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -706,10 +706,10 @@ describe("applyValidatedProposal", () => {
|
||||
expect(graph).toEqual(originalGraph);
|
||||
});
|
||||
|
||||
it("rejects a proposal with no meaningful change", () => {
|
||||
it("rejects a proposal with no meaningful change", async () => {
|
||||
const { graph } = makeApplicationFixture();
|
||||
|
||||
const result = applyValidatedProposal({
|
||||
const result = await applyValidatedProposal({
|
||||
situationGraph: graph,
|
||||
proposal: {
|
||||
addedNodes: [],
|
||||
@@ -740,6 +740,68 @@ describe("applyValidatedProposal", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a completed-episode proposal with no additional graph mutation", async () => {
|
||||
const { graph } = makeApplicationFixture();
|
||||
const originalGraph = JSON.parse(JSON.stringify(graph));
|
||||
const noOpProposal = {
|
||||
addedNodes: [],
|
||||
updatedNodes: [
|
||||
{
|
||||
nodeId: "n-quality-deterioration",
|
||||
previousStatus: null,
|
||||
newStatus: null,
|
||||
previousValue: null,
|
||||
newValue: null,
|
||||
reason: "No further graph change.",
|
||||
},
|
||||
],
|
||||
addedEdges: [],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [],
|
||||
affectedNodeIds: [],
|
||||
selectedQuestion: null,
|
||||
};
|
||||
|
||||
const result = await applyValidatedProposal({
|
||||
situationGraph: graph,
|
||||
proposal: noOpProposal,
|
||||
evidenceContext: {
|
||||
isCompletedEpisode: true,
|
||||
episodeEvidence: {
|
||||
turns: [{ question: "What did you find?", answer: "No further change." }],
|
||||
eligibleCanonicalFindings: [{ proposition: "The episode is preserved." }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.updatedSituationGraph.nodes).toEqual(originalGraph.nodes);
|
||||
expect(result.updatedSituationGraph.edges).toEqual(originalGraph.edges);
|
||||
});
|
||||
|
||||
it("still rejects an invalid completed-episode proposal", async () => {
|
||||
const { graph } = makeApplicationFixture();
|
||||
|
||||
const result = await applyValidatedProposal({
|
||||
situationGraph: graph,
|
||||
proposal: {
|
||||
addedNodes: [],
|
||||
updatedNodes: [{ nodeId: "missing-node", reason: "Invalid target." }],
|
||||
addedEdges: [],
|
||||
removedEdgeIds: [],
|
||||
resolvedUnknownNodeIds: [],
|
||||
affectedNodeIds: [],
|
||||
selectedQuestion: null,
|
||||
},
|
||||
evidenceContext: { isCompletedEpisode: true, episodeEvidence: { turns: [] } },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ success: false, stage: "proposal_compatibility" });
|
||||
expect(result.errors).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining("Cannot update non-existent node"),
|
||||
]));
|
||||
});
|
||||
|
||||
it("resolves one unknown and adds consequential unknowns with one selected question", () => {
|
||||
const { graph, ids } = makeApplicationFixture();
|
||||
|
||||
|
||||
@@ -56,9 +56,10 @@ const agreedFinding = () => ({
|
||||
/** Fake provider factory for tests */
|
||||
const makeFakeProvider = (defaultResponse) => ({
|
||||
generateReconstruction: vi.fn(async () => {
|
||||
return typeof defaultResponse === "function"
|
||||
? defaultResponse()
|
||||
const response = typeof defaultResponse === "function"
|
||||
? await defaultResponse()
|
||||
: JSON.stringify({ currentUnderstanding: "Synthesized output" });
|
||||
return { response, providerApiPath: "/test-provider" };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -375,6 +376,20 @@ describe("synthesizeCurrentUnderstanding — full seam", () => {
|
||||
const prompt = fake.generateReconstruction.mock.calls[0][0];
|
||||
expect(prompt).toContain("Complaint count increased by 35%");
|
||||
expect(prompt).toContain("[known]");
|
||||
expect(fake.generateReconstruction.mock.calls[0][2]).toEqual({
|
||||
type: "object",
|
||||
properties: { currentUnderstanding: { type: "string" } },
|
||||
required: ["currentUnderstanding"],
|
||||
additionalProperties: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("unwraps the provider response before validating the synthesis narrative", async () => {
|
||||
const fake = makeFakeProvider(() => ({ currentUnderstanding: "Wrapped narrative" }));
|
||||
await expect(synthesizeCurrentUnderstanding(
|
||||
{ situationGraph: fullScenario, findings: [] },
|
||||
{ provider: fake },
|
||||
)).resolves.toEqual({ currentUnderstanding: "Wrapped narrative" });
|
||||
});
|
||||
|
||||
it("supported node content flows to provider via synthesis prompt", async () => {
|
||||
|
||||
@@ -145,6 +145,23 @@ describe("reopenResolvedUnknown — pure deterministic transformation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonical resolved-node-ID target", () => {
|
||||
it("reopens an unknown-status node marked resolved by graph state without mutating input", () => {
|
||||
const graph = makeTestGraph();
|
||||
graph.nodes[0] = { ...graph.nodes[0], status: "unknown" };
|
||||
|
||||
const result = reopenResolvedUnknown(graph, "u-1");
|
||||
const targetNode = result.nodes.find((n) => n.id === "u-1");
|
||||
|
||||
expect(targetNode.kind).toBe("unknown");
|
||||
expect(targetNode.status).toBe("unknown");
|
||||
expect(result.resolvedNodeIds).not.toContain("u-1");
|
||||
expect(result.resolvedNodeIds).toContain("u-2");
|
||||
expect(graph.nodes.find((n) => n.id === "u-1").status).toBe("unknown");
|
||||
expect(graph.resolvedNodeIds).toContain("u-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid/no-op cases", () => {
|
||||
it("returns original graph when node does not exist", () => {
|
||||
const graph = makeTestGraph();
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
readThemePreference,
|
||||
resolveTheme,
|
||||
saveThemePreference,
|
||||
THEME_STORAGE_KEY,
|
||||
toggleTheme,
|
||||
} from "@/lib/theme-preference.js";
|
||||
|
||||
function makeStorage(initial = {}) {
|
||||
const values = new Map(Object.entries(initial));
|
||||
return {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
};
|
||||
}
|
||||
|
||||
describe("theme preference", () => {
|
||||
it("restores a saved dark preference ahead of system preference", () => {
|
||||
const storage = makeStorage({ [THEME_STORAGE_KEY]: "dark" });
|
||||
expect(resolveTheme({ savedTheme: readThemePreference(storage), systemPrefersDark: false })).toBe("dark");
|
||||
});
|
||||
|
||||
it("uses system dark preference when no explicit choice exists", () => {
|
||||
expect(resolveTheme({ savedTheme: null, systemPrefersDark: true })).toBe("dark");
|
||||
});
|
||||
|
||||
it("switches themes and persists the explicit choice", () => {
|
||||
const storage = makeStorage();
|
||||
const nextTheme = toggleTheme("light");
|
||||
saveThemePreference(nextTheme, storage);
|
||||
|
||||
expect(nextTheme).toBe("dark");
|
||||
expect(readThemePreference(storage)).toBe("dark");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user