Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
719a65b3d3 | ||
|
|
e6c78f87fe | ||
|
|
d6df1d210e | ||
|
|
6dd447e56a | ||
|
|
b949eea831 | ||
|
|
30bf44f2e5 | ||
|
|
1c17452bee | ||
|
|
24d9e466f6 | ||
|
|
85b9f4411f | ||
|
|
0b5a38f83d | ||
|
|
7af708159e | ||
|
|
949a7024b3 | ||
|
|
ae00e70ced | ||
|
|
7548a6af59 | ||
|
|
3f2e2e05ae | ||
|
|
14630cf6b7 | ||
|
|
0cbe49913e | ||
|
|
0d27d4935b | ||
|
|
e289f0be1f | ||
|
|
0eadef6e3b | ||
|
|
bb3082d633 | ||
|
|
642a969b18 | ||
|
|
5878ce45ec | ||
|
|
be8b725a8d | ||
|
|
a93b6798cc | ||
|
|
188dd04ab9 | ||
|
|
a0f90e8885 | ||
|
|
d24ad48f62 | ||
|
|
e8d401e506 | ||
|
|
0bb2f01100 | ||
|
|
215c783d11 | ||
|
|
a45dd903cf | ||
|
|
1daf2bb6ce | ||
|
|
860ee6fc5b | ||
|
|
653934559c | ||
|
|
5c9f94ca13 | ||
|
|
726746f22d | ||
|
|
7070342fb1 | ||
|
|
cd1c6f4fc5 | ||
|
|
c7a0a79d0f | ||
|
|
8c5b47bcf5 | ||
|
|
46b9bd8b03 | ||
|
|
dabd9e2245 | ||
|
|
677f5e5757 | ||
|
|
8c3edfec7d | ||
|
|
72324e63c8 | ||
|
|
84fc53f017 | ||
|
|
e5de8564a4 | ||
|
|
55e935066c | ||
|
|
e1839147b1 | ||
|
|
fb49df87aa | ||
|
|
7472b6ecb0 | ||
|
|
13fbceee7a | ||
|
|
37245a8e28 | ||
|
|
a59d60262e | ||
|
|
f2a761d26f | ||
|
|
844ec0eb8c | ||
|
|
863f7dcbf2 | ||
|
|
65c5ded9ab | ||
|
|
6218a3ed6d | ||
|
|
898c3dcaaf | ||
|
|
42da768e66 | ||
|
|
95d9965420 | ||
|
|
580b2a122e | ||
|
|
642554038e | ||
|
|
928954ee4a | ||
|
|
41ea2cb6b9 | ||
|
|
e6f2249413 | ||
|
|
cc3a5dabd4 | ||
|
|
4bc998ee3f | ||
|
|
2af5971987 | ||
|
|
df142ca76c | ||
|
|
7ba1771bcf | ||
|
|
4b55ad1eae | ||
|
|
01e141aa66 | ||
|
|
827411f254 | ||
|
|
8c85120b1c | ||
|
|
f23d442eb3 | ||
|
|
06a200bb03 | ||
|
|
2df026d024 | ||
|
|
ede5d54e36 | ||
|
|
f06138de32 | ||
|
|
99b3d26817 | ||
|
|
37a9a12f93 | ||
|
|
fb2384cff1 | ||
|
|
2ed91468ed | ||
|
|
c33bcdbefa | ||
|
|
53cb99ee8f | ||
|
|
bb3da3d197 | ||
|
|
37b1892d86 | ||
|
|
1f3b26c983 | ||
|
|
e611283e3e | ||
|
|
83a66560ae | ||
|
|
577781eff8 | ||
|
|
7db28c8611 | ||
|
|
99b75dca4e | ||
|
|
0da7b63e30 | ||
|
|
32e1b01767 | ||
|
|
745026f0a0 | ||
|
|
83818c0c71 | ||
|
|
194a742772 | ||
|
|
f2c9e4c0b2 | ||
|
|
2b2096e41d | ||
|
|
a061428711 | ||
|
|
043ba5f264 | ||
|
|
b647236d44 | ||
|
|
161527f66c | ||
|
|
cd895a33ff | ||
|
|
a23da2b727 | ||
|
|
9da0928453 | ||
|
|
76c6096905 | ||
|
|
d22c992f60 | ||
|
|
7177c7bb61 | ||
|
|
8432ed45d4 | ||
|
|
650877e5bf | ||
|
|
c89cc51ae6 | ||
|
|
ab655e2222 | ||
|
|
0752c53a25 | ||
|
|
6b77e32771 | ||
|
|
efa39f52de | ||
|
|
18a7eb97cc | ||
|
|
0059c10f14 | ||
|
|
0624bc20e2 | ||
|
|
b270aa5624 | ||
|
|
989b88a4a1 | ||
|
|
addec52461 | ||
|
|
5fb32e628c | ||
|
|
8e941b0c7b | ||
|
|
75f7c6bafd | ||
|
|
ff1119b4d5 | ||
|
|
00ba343ed9 | ||
|
|
8c98ce94de | ||
|
|
bdb234262c | ||
|
|
07e1363368 | ||
|
|
16cab4645a | ||
|
|
922f58a49f | ||
|
|
bf7629691f | ||
|
|
ae1201bb27 | ||
|
|
8bded90094 | ||
|
|
17c6048047 | ||
|
|
890a18c5a7 | ||
|
|
50a66749ae | ||
|
|
88d9768276 | ||
|
|
dac19a3552 | ||
|
|
b9c0b6f6f7 | ||
|
|
0f4dfcbb17 | ||
|
|
a8539e2494 | ||
|
|
06f3f501d2 | ||
|
|
10cbcbdd05 | ||
|
|
b9a54589f0 | ||
|
|
556acfb156 | ||
|
|
f1bd91faf8 | ||
|
|
e221bd3bf8 | ||
|
|
c45b703b3a | ||
|
|
b215846478 | ||
|
|
3e9123fe0e | ||
|
|
3c5257cbd7 | ||
|
|
d55f179d37 | ||
|
|
166ee91698 | ||
|
|
bc35e05253 | ||
|
|
ba956eeb3a | ||
|
|
22e1d7484b | ||
|
|
5c926154cd | ||
|
|
bf6c4241a5 | ||
|
|
0f4e49fc6c | ||
|
|
7858650334 | ||
|
|
ca7d8e5384 | ||
|
|
0659599795 | ||
|
|
dc558e9c37 | ||
|
|
061ea364b3 | ||
|
|
6a5cb43a30 | ||
|
|
abeb3fcb03 | ||
|
|
990b51aecf | ||
|
|
48b7185176 | ||
|
|
3235c35cf0 | ||
|
|
d3015f63d8 | ||
|
|
854160726e | ||
|
|
10aa18d367 | ||
|
|
ac5fbe7896 | ||
|
|
b26ea7d0ba | ||
|
|
cb707c0192 | ||
|
|
787c8114ad | ||
|
|
fdb173d0e9 | ||
|
|
cfd463d8b3 | ||
|
|
fd02be0f29 | ||
|
|
6288ef1031 | ||
|
|
81dda77392 | ||
|
|
42a7e82d88 | ||
|
|
3ca37b0918 | ||
|
|
112739b8e5 | ||
|
|
9d670822a3 | ||
|
|
2c108df5a9 | ||
|
|
c134b5cb04 | ||
|
|
01c57788ee | ||
|
|
96ad0e7915 | ||
|
|
517d780e2c | ||
|
|
68be2344c6 | ||
|
|
fce68a050f | ||
|
|
41afd9b49f | ||
|
|
c9335cf850 | ||
|
|
86287bebe8 | ||
|
|
412551c968 | ||
|
|
4b264c5681 | ||
|
|
65ced2e406 | ||
|
|
4761d07a76 | ||
|
|
173240d76c | ||
|
|
cef8f46bd6 | ||
|
|
86bb3426ef | ||
|
|
df0e3b5a9b | ||
|
|
e7a1bc689c | ||
|
|
36060faf16 | ||
|
|
49c4b904df | ||
|
|
09eeed5a9e | ||
|
|
47cd0c7d7b | ||
|
|
6bf9e7e710 | ||
|
|
8163c5d014 | ||
|
|
0ac2e05c40 | ||
|
|
ad42f67800 | ||
|
|
058ad2326f | ||
|
|
9fb9735c6e | ||
|
|
a00adfabf4 | ||
|
|
601e46e4b7 | ||
|
|
85204f96ac | ||
|
|
e1a18e27e7 | ||
|
|
a73f125f8d | ||
|
|
c97f07ba65 | ||
|
|
67103fa8d4 | ||
|
|
4687226bd8 | ||
|
|
8fb284c374 | ||
|
|
8c52939c02 | ||
|
|
644108db71 | ||
|
|
0b0d5594fe | ||
|
|
fbeaf01f90 | ||
|
|
e6d0327641 | ||
|
|
a12f9555af | ||
|
|
5b43c1b8f9 | ||
|
|
e1b54e4073 | ||
|
|
6ed3415220 | ||
|
|
98889039c2 | ||
|
|
9c715161b0 | ||
|
|
56de4a7ef3 | ||
|
|
b20707c447 | ||
|
|
6ed4d60029 | ||
|
|
153bbee85e | ||
|
|
8520f2195d | ||
|
|
ebcf1d9306 | ||
|
|
dafc020f66 | ||
|
|
7add85d8d2 | ||
|
|
c0b963973f | ||
|
|
913dfec507 | ||
|
|
952cb442b5 | ||
|
|
2f6c90b027 | ||
|
|
648e1c7a29 | ||
|
|
fd98cda8ba | ||
|
|
6b25100f9a | ||
|
|
db5016c138 | ||
|
|
4a34dcc361 | ||
|
|
25a88c5fc3 |
+37
-10
@@ -19,6 +19,18 @@ It:
|
||||
6. updates the graph from the answer;
|
||||
7. repeats until action is justified or the remaining uncertainty is clear.
|
||||
|
||||
> **NOTE:** The flow above describes historical/current implementation mechanics.
|
||||
> It does not represent current Confidence Engine methodology direction.
|
||||
> See `docs/Confidence_Engine_Return_to_Origin_Methodology_Context_2026-08-18.md`
|
||||
> for the current working hypothesis (granular answer-fragment inquiry).
|
||||
|
||||
The linear selector-led flow described above is a **historical capability**, not
|
||||
an automatic architecture to continue. Under Return-to-Origin:
|
||||
|
||||
- The Engine facilitates inquiry; it does not compel a single-question route.
|
||||
- The user owns which unresolved investigation/question to pursue.
|
||||
- Accumulated reasoning memory does not necessarily belong inside repeated LLM calls.
|
||||
|
||||
A chatbot remembers the conversation.
|
||||
|
||||
The Confidence Engine preserves the state of the reasoning.
|
||||
@@ -50,15 +62,23 @@ The engine should help a user reach one of these states:
|
||||
|
||||
## Current development stage
|
||||
|
||||
The deterministic reasoning architecture reached a stable alpha checkpoint.
|
||||
> **Version lineage note:** The Confidence Engine uses two distinct version
|
||||
> lineages that must not be conflated:
|
||||
> - **Reasoning-engine experimental lineage** (v0.8+): reasoning-fidelity,
|
||||
> investigation-state assessment, semantic selectors — under RTO pause.
|
||||
> - **UX/product development lineage** (v0.7): workspace layout, user views,
|
||||
> loading feedback — also paused.
|
||||
> These are independent tracks; do not assume they describe one product version.
|
||||
|
||||
Current work is primarily improving:
|
||||
The deterministic reasoning architecture reached a stable alpha checkpoint
|
||||
(reasoning-engine v0.8). UI/product work reached v0.7 staging. Both have
|
||||
paused under Return to Origin while the granular answer-fragment hypothesis
|
||||
is evaluated as working methodology context.
|
||||
|
||||
- usability;
|
||||
- presentation;
|
||||
- loading feedback;
|
||||
- plain-language explanations;
|
||||
- separation of user and developer views.
|
||||
Current work is paused. The next step begins from the methodology question:
|
||||
given the useful investigation structure the Engine can already derive, how
|
||||
should that structure be surfaced so a person can see, choose, defer, and
|
||||
return to open questions while the Engine continues to guide their thinking?
|
||||
|
||||
Do not resume broad reasoning architecture work unless a repeated observed
|
||||
failure clearly requires it.
|
||||
@@ -93,7 +113,11 @@ The interface should minimise cognitive load by presenting the current state fir
|
||||
|
||||
The engine may contain hundreds of reasoning nodes; the user should only see the information required to take the next meaningful action.
|
||||
|
||||
## Why workspace layout matters (v0.7)
|
||||
## Why workspace layout matters (v0.7 — UX/product lineage)
|
||||
|
||||
> **This section documents paused UX design intent.** It belongs to the v0.7
|
||||
> product development lineage, not the reasoning-engine lineage. UI work is
|
||||
> currently paused under Return to Origin.
|
||||
|
||||
This phase optimises for simultaneous visibility instead of sequential scrolling.
|
||||
Related panels — Understanding alongside Investigation Map, Situation alongside History — can appear side-by-side on wide screens while mobile continues to stack everything vertically. The reasoning engine is completely unaware of these changes; only the presentation layer is affected.
|
||||
@@ -104,7 +128,10 @@ Read `docs/current-working-principles.md` for current guidance. Treat `docs/arch
|
||||
|
||||
For UI mock work, read `docs/ui-mock-reference.md`. Do not load
|
||||
`docs/archive/deferred-ux-backlog.md` unless a named past UX idea is being reviewed.
|
||||
Engine and UI experiments are paused. First file to inspect when resuming:
|
||||
`docs/current-project-state.md`, then `docs/project-knowledge-inventory.md`.
|
||||
|
||||
Engine and UI experiments are paused under Return to Origin. First file to inspect when resuming:
|
||||
**`docs/current-handoff.md`** (methodology continuity anchor), then `docs/current-project-state.md`, then `docs/project-knowledge-inventory.md`.
|
||||
|
||||
> After reading `docs/current-project-state.md`, choose the relevant minimal pack from `docs/task-context-packs.md`. Do not combine packs unless a specific task genuinely crosses boundaries.
|
||||
>
|
||||
> **Historical experiment families are evidence to load only when a specific question requires them; they are not default architecture context.**
|
||||
|
||||
@@ -515,6 +515,21 @@ Three tiers, applied top to bottom:
|
||||
- Omit items too verbose to scan; do not synthesise rewritten claims.
|
||||
- Never invent facts absent from the graph.
|
||||
|
||||
### Provenance and attribution
|
||||
|
||||
Preserve authorship and provenance in every user-facing presentation.
|
||||
|
||||
When displaying a user's previous input, keep it visibly distinct from system-generated interpretation. If the original user wording is available, present it as the user's response rather than rewriting it into system prose. Derived Findings, summaries, uncertainties, assumptions, or follow-up questions must not be styled or worded in a way that implies the user said them.
|
||||
|
||||
The distinction should be:
|
||||
|
||||
```text
|
||||
User response → user-authored (verbatim)
|
||||
What we learned → Engine-derived
|
||||
```
|
||||
|
||||
Exact labels are subject to UX refinement; the durable rule is separating provenance, not prescribing specific copy.
|
||||
|
||||
## Investigation Narrative
|
||||
|
||||
The reasoning graph is the machine representation of the investigation.
|
||||
|
||||
@@ -123,3 +123,58 @@ Stop after reporting. Do not begin the next task automatically.
|
||||
When a task is interrupted by output limits, resume with a narrowly scoped repair prompt rather than restating the entire original brief.
|
||||
|
||||
User interfaces communicate reasoning, not implementation. If a piece of information exists only because the engine tracks it internally (graph nodes, unresolved counts, edge totals, confidence scores), it should remain in Developer Details unless it directly helps the user make their next decision.
|
||||
|
||||
## Playwright MCP — canonical dev server ownership
|
||||
|
||||
- Assume `http://localhost:3000` is already running when a task names it.
|
||||
- Never start / stop / kill / restart / replace / port-probe the dev server.
|
||||
- Never reinterpret "do not start/restart/kill/probe" as "start normally" or "use npm run dev".
|
||||
- If the canonical dev server is unavailable: **BLOCKED** — do not proceed.
|
||||
|
||||
## Playwright MCP — known controls and semantic locators
|
||||
|
||||
For known UI controls, use **Run Playwright code** with exact semantic locators:
|
||||
|
||||
```js
|
||||
await page.getByRole('button', { name: 'Review current understanding' }).click();
|
||||
```
|
||||
|
||||
Do NOT first try MCP Click. Do NOT use snapshot refs (`[ref=...]`) for actions — they are observational only.
|
||||
|
||||
Semantic scoping is allowed and encouraged where names repeat, e.g.:
|
||||
|
||||
```js
|
||||
page.getByRole('dialog').getByRole('button', { name: 'Restart investigation' });
|
||||
```
|
||||
|
||||
## Playwright MCP — semantic waits
|
||||
|
||||
For known async/hydration states, use `waitFor` with a semantic state — not arbitrary sleeps:
|
||||
|
||||
```js
|
||||
await page.getByRole(...).waitFor({ state: 'visible', timeout: ... });
|
||||
```
|
||||
|
||||
Client hydration is real product behaviour. Always await before classifying localStorage-backed UI state.
|
||||
|
||||
## Playwright MCP — selector failure
|
||||
|
||||
If the prescribed semantic locator cannot find its expected control: **STOP**.
|
||||
|
||||
Do NOT fall back to snapshot refs, CSS selectors, XPath, DOM traversal, `page.evaluate`, aria-label guessing, or locator archaeology.
|
||||
|
||||
## Playwright MCP — browser state and live freeze
|
||||
|
||||
During live verification do not inspect / inject / mutate browser storage merely to manufacture expected test state (unless storage manipulation itself is the explicit experiment).
|
||||
|
||||
Once live Playwright verification begins: **NO PRODUCTION FILE EDITS**. First visible discrepancy is evidence to capture and stop on.
|
||||
|
||||
## Deterministic test rules — apparatus ownership
|
||||
|
||||
**Tests are instruments, not product truth.**
|
||||
|
||||
At the first deterministic failure classify: **PRODUCT FAILURE** or **APPARATUS FAILURE**, then stop.
|
||||
|
||||
For APPARATUS FAILURE: do not turn the product task into test-harness development. Do not enter repeated vi.mock / dynamic re-import / module-cache manipulation / duplicate render / global mutation repair loops. Route apparatus correction separately.
|
||||
|
||||
If a lower-layer function is mocked, test the value crossing the mocked seam — do NOT require the mock to reproduce its real implementation. Storage-layer tests own storage writes.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
dist
|
||||
coverage
|
||||
*.lcov
|
||||
test-results
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
evaluation-results
|
||||
provider-debug-results
|
||||
tests-results
|
||||
.playwright-mcp/
|
||||
.evidence-temp/
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Environment files with secrets (never bake into image)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Documentation / handoff (not needed for build)
|
||||
docs
|
||||
*.md
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
+6
-9
@@ -1,15 +1,12 @@
|
||||
# Local Ollama server address
|
||||
OLLAMA_BASE_URL=http://192.168.x.x:11434
|
||||
# ── Supabase Auth (public browser configuration only) ────────────────
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://supabase.rdbcloud.co.uk
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=replace-with-supabase-anon-key
|
||||
|
||||
# Model name (e.g., llama3, mistral, codellama, etc.)
|
||||
# ── Ollama provider (runtime, server-only) ────────────────────────────
|
||||
OLLAMA_BASE_URL=http://192.168.x.x:11434
|
||||
OLLAMA_MODEL=replace-with-model-name
|
||||
|
||||
# ── Mock / Demo Mode (UI development only) ──────────────────
|
||||
# Set to "true" to use pre-recorded scenario fixtures instead of Ollama.
|
||||
# ── Mock / Demo Mode (UI development only) ────────────────────────────
|
||||
NEXT_PUBLIC_CONFIDENCE_ENGINE_MOCKS=true
|
||||
|
||||
# Mock delay mode: "instant" | "normal" (default, 700ms) | "slow" (2500ms)
|
||||
NEXT_PUBLIC_CONFIDENCE_MOCK_DELAY=normal
|
||||
|
||||
# Scenario to replay: "complete" (jump to end after start) | "error" | "" (default sequential turns)
|
||||
NEXT_PUBLIC_CONFIDENCE_ENGINE_MOCK_SCENARIO=complete
|
||||
|
||||
@@ -39,3 +39,10 @@ yarn-error.log*
|
||||
evaluation-results/
|
||||
provider-debug-results/
|
||||
tests-results/
|
||||
|
||||
# Local Playwright MCP runtime output
|
||||
.playwright-mcp/
|
||||
|
||||
|
||||
# Evidence/temp directories from live experiments
|
||||
.evidence-temp/
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# ── Stage 1: Build ─────────────────────────────────────────────────────
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NEXT_PUBLIC_SUPABASE_URL="" \
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=""
|
||||
|
||||
COPY package.json package-lock.json* yarn.lock* pnpm-lock.yaml* ./
|
||||
|
||||
RUN corepack enable && \
|
||||
if [ -f pnpm-lock.yaml ]; then \
|
||||
corepack prepare pnpm@latest --activate; \
|
||||
pnpm install --frozen-lockfile; \
|
||||
elif [ -f yarn.lock ]; then \
|
||||
yarn install --frozen-lockfile; \
|
||||
else \
|
||||
npm ci; \
|
||||
fi
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL} \
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY} \
|
||||
next build
|
||||
|
||||
# ── Stage 2: Production runtime ────────────────────────────────────────
|
||||
FROM node:22-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
PORT=3000 \
|
||||
HOSTNAME="0.0.0.0"
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=node:node /app/.next/standalone ./
|
||||
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
|
||||
|
||||
USER node
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -3,8 +3,9 @@ import {
|
||||
PROMPT_VERSIONS,
|
||||
DEFAULT_PROMPT_VERSION,
|
||||
} from "@/lib/analysis";
|
||||
import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js";
|
||||
|
||||
export async function POST(request) {
|
||||
async function post(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
@@ -47,3 +48,5 @@ export async function POST(request) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withAuthenticatedApi(post);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Investigation Overview synthesis API route.
|
||||
*
|
||||
* Route: POST /api/cases/overview
|
||||
*
|
||||
* Thin route pattern — no overview business logic here.
|
||||
*/
|
||||
|
||||
import { getProvider, getProviderModelName } from "@/lib/llm/provider.js";
|
||||
import { synthesizeInvestigationOverview } from "@/lib/graph/investigation-overview-synthesis.js";
|
||||
import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js";
|
||||
|
||||
async function post(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
if (!body || typeof body !== "object") {
|
||||
return Response.json(
|
||||
{ success: false, stage: "request_validation", error: "Invalid request body" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { situationGraph, findings, plausibleInterpretations } = body;
|
||||
|
||||
if (!situationGraph) {
|
||||
return Response.json(
|
||||
{ success: false, stage: "request_validation", error: "Missing situationGraph" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await synthesizeInvestigationOverview(
|
||||
{ situationGraph, findings, plausibleInterpretations },
|
||||
{
|
||||
provider: getProvider(),
|
||||
modelName: getProviderModelName(),
|
||||
}
|
||||
);
|
||||
|
||||
return Response.json(
|
||||
{ success: true, understanding: result.understanding, plausibleInterpretations: result.plausibleInterpretations },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
return Response.json(
|
||||
{ success: false, stage: "request_validation", error: "Invalid JSON request body" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error.statusCode) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
stage: error.statusCode === 400 ? "request_validation" : "provider",
|
||||
error: error.message ?? "Overview synthesis failed",
|
||||
},
|
||||
{ status: error.statusCode }
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{ success: false, stage: "internal", error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withAuthenticatedApi(post);
|
||||
@@ -1,6 +1,7 @@
|
||||
import { startCase } from "@/lib/graph/orchestrator.js";
|
||||
import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js";
|
||||
|
||||
export async function POST(request) {
|
||||
async function post(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const result = await startCase(body);
|
||||
@@ -16,17 +17,43 @@ export async function POST(request) {
|
||||
? result.statusCode
|
||||
: 500;
|
||||
|
||||
const diagnostics = {
|
||||
status,
|
||||
error: result.error ?? "Start case failed",
|
||||
validationErrors: result.validationErrors,
|
||||
analysisErrors: result.analysisErrors,
|
||||
validationIssues: result.validationIssues,
|
||||
providerApiPath: result.providerApiPath,
|
||||
providerExecution: result.providerExecution,
|
||||
rawResponse: result.rawResponse ?? undefined,
|
||||
};
|
||||
|
||||
if (status >= 500) {
|
||||
console.error("[api/cases/start] error response", diagnostics);
|
||||
} else {
|
||||
console.warn("[api/cases/start] error response", diagnostics);
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: result.error ?? "Start case failed",
|
||||
error: diagnostics.error,
|
||||
validationErrors: result.validationErrors,
|
||||
diagnostics: result.diagnostics,
|
||||
analysisErrors: result.analysisErrors,
|
||||
validationIssues: result.validationIssues,
|
||||
providerApiPath: result.providerApiPath,
|
||||
providerExecution: result.providerExecution,
|
||||
rawResponse: result.rawResponse ?? undefined,
|
||||
},
|
||||
{ status },
|
||||
);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("[api/cases/start] unhandled exception", {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
error,
|
||||
});
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
@@ -36,3 +63,5 @@ export async function POST(request) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withAuthenticatedApi(post);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Dedicated Current Understanding synthesis API route.
|
||||
*
|
||||
* Route: POST /api/cases/synthesis
|
||||
*
|
||||
* Follows the thin route pattern established by cases/start and cases/update routes:
|
||||
* parse request → invoke domain seam → return validated result → map failure status
|
||||
*
|
||||
* No synthesis business logic belongs in this file.
|
||||
*/
|
||||
|
||||
import { getProvider, getProviderModelName } from "@/lib/llm/provider.js";
|
||||
import { synthesizeCurrentUnderstanding } from "@/lib/graph/current-understanding-synthesis.js";
|
||||
import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js";
|
||||
|
||||
async function post(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// ── Parse / validate input contract ───────────────────────
|
||||
if (!body || typeof body !== "object") {
|
||||
return Response.json(
|
||||
{ success: false, stage: "request_validation", error: "Invalid request body" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { situationGraph, findings } = body;
|
||||
|
||||
if (!situationGraph) {
|
||||
return Response.json(
|
||||
{ success: false, stage: "request_validation", error: "Missing situationGraph" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// ── Invoke domain seam with configured model ──────────────
|
||||
const result = await synthesizeCurrentUnderstanding(
|
||||
{ situationGraph, findings },
|
||||
{
|
||||
provider: getProvider(),
|
||||
modelName: getProviderModelName(),
|
||||
}
|
||||
);
|
||||
|
||||
return Response.json({ success: true, currentUnderstanding: result.currentUnderstanding }, { status: 200 });
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
return Response.json(
|
||||
{ success: false, stage: "request_validation", error: "Invalid JSON request body" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error.statusCode) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
stage: error.statusCode === 400 ? "request_validation" : "provider",
|
||||
error: error.message ?? "Synthesis failed",
|
||||
},
|
||||
{ status: error.statusCode }
|
||||
);
|
||||
}
|
||||
|
||||
// Unexpected error
|
||||
return Response.json(
|
||||
{ success: false, stage: "internal", error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withAuthenticatedApi(post);
|
||||
@@ -1,4 +1,8 @@
|
||||
import { updateCase } from "@/lib/graph/orchestrator.js";
|
||||
import { updateCase, reconsiderCompletedEpisode } from "@/lib/graph/orchestrator.js";
|
||||
import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js";
|
||||
import { prepareCompletedEpisode } from "@/lib/graph/episode-preparation.js";
|
||||
import { updateCaseEpisodeRequestSchema } from "@/lib/graph/schema.js";
|
||||
import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js";
|
||||
|
||||
function mapFailureStatus(result) {
|
||||
switch (result?.stage) {
|
||||
@@ -32,9 +36,28 @@ function buildFailureResponse(result) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
async function post(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const isEpisodeMode = body?.episodeMode === true;
|
||||
|
||||
if (isEpisodeMode) {
|
||||
const parsed = updateCaseEpisodeRequestSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
stage: "request_validation",
|
||||
error: "Invalid episode request",
|
||||
validationErrors: parsed.error.issues,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
return await handleEpisodeMode(body.situationGraph, body);
|
||||
}
|
||||
|
||||
const result = await updateCase(body, { applyProposal: true });
|
||||
|
||||
if (result.success) {
|
||||
@@ -66,3 +89,59 @@ export async function POST(request) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withAuthenticatedApi(post);
|
||||
|
||||
/** Server-side completed-episode reconsideration flow. */
|
||||
async function handleEpisodeMode(situationGraph, body) {
|
||||
const prepared = prepareCompletedEpisode({
|
||||
situationGraph,
|
||||
targetNodeId: body.targetNodeId,
|
||||
contributions: body.contributions ?? [],
|
||||
findings: body.findings,
|
||||
});
|
||||
|
||||
if (!prepared?.turns?.length && !prepared?.eligibleCanonicalFindings?.length) {
|
||||
return Response.json(
|
||||
{ success: false, stage: "preparation", error: "no_episodic_content" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const reasoning = await reconsiderCompletedEpisode(prepared);
|
||||
if (!reasoning.success) {
|
||||
return Response.json(
|
||||
buildFailureResponse(reasoning),
|
||||
{ status: mapFailureStatus(reasoning) },
|
||||
);
|
||||
}
|
||||
|
||||
const application = await applyValidatedProposal({
|
||||
situationGraph,
|
||||
proposal: reasoning.proposal,
|
||||
evidenceContext: {
|
||||
isCompletedEpisode: true,
|
||||
episodeEvidence: prepared,
|
||||
},
|
||||
});
|
||||
|
||||
if (!application.success) {
|
||||
return Response.json(
|
||||
buildFailureResponse(application),
|
||||
{ status: mapFailureStatus(application) },
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedNodeIds = new Set(application.updatedSituationGraph.resolvedNodeIds ?? []);
|
||||
resolvedNodeIds.add(body.targetNodeId);
|
||||
const updatedSituationGraph = {
|
||||
...application.updatedSituationGraph,
|
||||
resolvedNodeIds: [...resolvedNodeIds],
|
||||
};
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
updatedSituationGraph,
|
||||
proposal: reasoning.proposal,
|
||||
}, { status: 200 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { getProvider, getProviderModelName } from "@/lib/llm/provider";
|
||||
import {
|
||||
buildFocusedDeconstructPrompt,
|
||||
focusedDeconstructJsonSchema,
|
||||
validateFocusedDeconstructSchema,
|
||||
} from "@/lib/graph/focused-investigation";
|
||||
import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js";
|
||||
|
||||
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(
|
||||
{ error: "Request must include a 'targetNodeId' string field" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (!body.targetLabel || typeof body.targetLabel !== "string") {
|
||||
return Response.json(
|
||||
{ error: "Request must include a 'targetLabel' string field" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (!body.targetDescription || typeof body.targetDescription !== "string") {
|
||||
return Response.json(
|
||||
{ error: "Request must include a 'targetDescription' string field" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (!body.centralStatement || typeof body.centralStatement !== "string") {
|
||||
return Response.json(
|
||||
{ error: "Request must include a 'centralStatement' string field" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (!body.question || typeof body.question !== "string") {
|
||||
return Response.json(
|
||||
{ error: "Request must include a 'question' string field" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (!body.answer || typeof body.answer !== "string") {
|
||||
return Response.json(
|
||||
{ error: "Request must include an 'answer' string field" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const prompt = buildFocusedDeconstructPrompt({
|
||||
targetLabel: body.targetLabel,
|
||||
targetDescription: body.targetDescription,
|
||||
centralStatement: body.centralStatement,
|
||||
question: body.question,
|
||||
answer: body.answer,
|
||||
});
|
||||
|
||||
const provider = getProvider();
|
||||
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,
|
||||
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,
|
||||
error: "Focused deconstruction result did not match expected schema",
|
||||
validationErrors,
|
||||
targetNodeId: body.targetNodeId,
|
||||
elapsedMs,
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const response = Response.json({
|
||||
success: true,
|
||||
targetNodeId: body.targetNodeId,
|
||||
observations: deconstruction.observations,
|
||||
uncertainties: deconstruction.uncertainties,
|
||||
assumptions: deconstruction.assumptions,
|
||||
relationships: deconstruction.relationships,
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withAuthenticatedApi(post);
|
||||
@@ -0,0 +1,55 @@
|
||||
import { formulateQuestionForTarget } from "@/lib/graph/focused-investigation";
|
||||
import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js";
|
||||
|
||||
async function post(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
if (!body.targetNodeId || typeof body.targetNodeId !== "string") {
|
||||
return Response.json(
|
||||
{ error: "Request must include a 'targetNodeId' string field" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!body.situationGraph || typeof body.situationGraph !== "object") {
|
||||
return Response.json(
|
||||
{ error: "Request must include a 'situationGraph' object field" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = formulateQuestionForTarget({
|
||||
situationGraph: body.situationGraph,
|
||||
targetNodeId: body.targetNodeId,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return Response.json(
|
||||
{ success: false, error: result.error },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
targetNodeId: result.targetNodeId,
|
||||
question: result.question,
|
||||
strategy: result.strategy,
|
||||
reasoningPattern: result.reasoningPattern,
|
||||
reasoningPatternReason: result.reasoningPatternReason,
|
||||
reason: result.reason,
|
||||
questionFamily: result.questionFamily,
|
||||
selectedQuestionTemplate: result.selectedQuestionTemplate,
|
||||
allowedQuestionFamilies: result.allowedQuestionFamilies,
|
||||
rejectedQuestionFamilies: result.rejectedQuestionFamilies,
|
||||
});
|
||||
} catch (e) {
|
||||
return Response.json(
|
||||
{ error: e.message || "Unknown server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withAuthenticatedApi(post);
|
||||
@@ -0,0 +1,14 @@
|
||||
import { restartInvestigation } from "@/lib/storage/server-investigation-persistence.js";
|
||||
import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js";
|
||||
|
||||
async function post(_request, { params }) {
|
||||
try {
|
||||
const snapshot = await restartInvestigation(params.id);
|
||||
if (!snapshot) return Response.json({ error: "Investigation not found" }, { status: 404 });
|
||||
return Response.json({ snapshot });
|
||||
} catch {
|
||||
return Response.json({ error: "Investigation persistence request failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withAuthenticatedApi(post);
|
||||
@@ -0,0 +1,14 @@
|
||||
import { loadInvestigation } from "@/lib/storage/server-investigation-persistence.js";
|
||||
import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js";
|
||||
|
||||
async function get(_request, { params }) {
|
||||
try {
|
||||
const snapshot = await loadInvestigation(params.id);
|
||||
if (!snapshot) return Response.json({ error: "Investigation not found" }, { status: 404 });
|
||||
return Response.json({ snapshot });
|
||||
} catch {
|
||||
return Response.json({ error: "Investigation persistence request failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = withAuthenticatedApi(get);
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
listInvestigations,
|
||||
saveInvestigation,
|
||||
} from "@/lib/storage/server-investigation-persistence.js";
|
||||
import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js";
|
||||
|
||||
async function get() {
|
||||
try {
|
||||
return Response.json({ investigations: await listInvestigations() });
|
||||
} catch {
|
||||
return Response.json({ error: "Investigation persistence request failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function post(request) {
|
||||
try {
|
||||
const { id, snapshot } = await request.json();
|
||||
if (!id || !snapshot || typeof snapshot !== "object") {
|
||||
return Response.json({ error: "An investigation id and snapshot are required" }, { status: 400 });
|
||||
}
|
||||
const savedSnapshot = await saveInvestigation(snapshot, id);
|
||||
return Response.json({ snapshot: savedSnapshot }, { status: 200 });
|
||||
} catch {
|
||||
return Response.json({ error: "Investigation persistence request failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = withAuthenticatedApi(get);
|
||||
export const POST = withAuthenticatedApi(post);
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET(request) {
|
||||
const requestUrl = new URL(request.url);
|
||||
const code = requestUrl.searchParams.get("code");
|
||||
const response = NextResponse.redirect(new URL("/", requestUrl.origin));
|
||||
|
||||
if (code) {
|
||||
const supabase = createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
||||
{
|
||||
cookies: {
|
||||
getAll: () => request.cookies.getAll(),
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value, options }) => response.cookies.set(name, value, options));
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
await supabase.auth.exchangeCodeForSession(code);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
+114
@@ -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); }
|
||||
@@ -24,6 +90,50 @@
|
||||
animation-delay: 0.16s;
|
||||
}
|
||||
|
||||
/* ── CU skeleton overlay during synthesis refresh ─────────────── */
|
||||
|
||||
.cu-skeleton-overlay {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cu-skeleton-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.cu-skeleton-line {
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--ce-skeleton);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Striped shimmer that travels left → right through each bar */
|
||||
.cu-skeleton-line::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
105deg,
|
||||
transparent 0%,
|
||||
transparent 8px,
|
||||
rgba(255, 255, 255, 0.45) 8px,
|
||||
rgba(255, 255, 255, 0.45) 16px,
|
||||
transparent 16px,
|
||||
transparent 24px
|
||||
);
|
||||
animation: cuSkeletonShimmer 1.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes cuSkeletonShimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[style*="animation:spin"] {
|
||||
animation: none !important;
|
||||
@@ -32,4 +142,8 @@
|
||||
.investigation-card {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.cu-skeleton-line::after {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { loadInvestigation } from "@/lib/storage/investigation-storage";
|
||||
import ScenarioForm from "@/components/scenario-form";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function InvestigationPage({ params }) {
|
||||
const router = useRouter();
|
||||
const routeId = typeof params?.id === "string" ? params.id : "";
|
||||
const [existing, setExisting] = useState(null);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setHydrated(false);
|
||||
if (!routeId) { setHydrated(true); return; }
|
||||
(async () => {
|
||||
try {
|
||||
const snapshot = await loadInvestigation(routeId);
|
||||
if (active) setExisting(snapshot);
|
||||
} finally {
|
||||
if (active) setHydrated(true);
|
||||
}
|
||||
})();
|
||||
return () => { active = false; };
|
||||
}, [routeId]);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-[1600px] px-6 py-12">
|
||||
{/* Page-level navigation — owned by route, not ReasoningWorkspace */}
|
||||
<nav className="mb-4 flex gap-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-lg border border-teal-600 bg-white px-4 py-2 text-sm font-medium text-teal-700 hover:bg-teal-50 transition"
|
||||
>
|
||||
Back to portfolio
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<h1 className="mb-2 text-3xl font-bold tracking-tight">Confidence Engine</h1>
|
||||
<p className="mb-8 text-sm text-gray-500">
|
||||
Experimental prototype: enter a scenario and send it to a local LLM for
|
||||
evidence-based structured reconstruction. This is a technical vertical
|
||||
slice — not a production system.
|
||||
</p>
|
||||
{!hydrated ? (
|
||||
<p className="text-sm text-gray-500">Loading investigation…</p>
|
||||
) : existing ? (
|
||||
<ScenarioForm
|
||||
investigationId={routeId}
|
||||
existingSnapshot={existing}
|
||||
onNavigateToReport={() => router.push(`/investigations/${routeId}/report`)}
|
||||
/>
|
||||
) : (
|
||||
<ScenarioForm
|
||||
investigationId={routeId}
|
||||
onNavigateToReport={() => router.push(`/investigations/${routeId}/report`)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { loadInvestigation, saveInvestigation } from "@/lib/storage/investigation-storage";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ReportPage({ params }) {
|
||||
const routeId = (typeof params === "object" && params?.id != null) ? String(params.id) : "";
|
||||
const [existing, setExisting] = useState(null);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [generationLoading, setGenerationLoading] = useState(false);
|
||||
const [generationError, setGenerationError] = useState(false);
|
||||
const [updateLoading, setUpdateLoading] = useState(false);
|
||||
|
||||
const generationAttempted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setHydrated(false);
|
||||
(async () => {
|
||||
try {
|
||||
const snapshot = await loadInvestigation(routeId);
|
||||
if (active) setExisting(snapshot);
|
||||
} catch {
|
||||
if (active) setGenerationError(true);
|
||||
} finally {
|
||||
if (active) setHydrated(true);
|
||||
}
|
||||
})();
|
||||
return () => { active = false; };
|
||||
}, [routeId]);
|
||||
|
||||
// First-generation: create report when none persists (v0.58)
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
if (existing?.investigationReport) return;
|
||||
if (generationAttempted.current) return;
|
||||
generationAttempted.current = true;
|
||||
|
||||
const situationGraph = existing?.situationGraph;
|
||||
const findings = existing?.findings ?? [];
|
||||
|
||||
if (!situationGraph) {
|
||||
setGenerationError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
setGenerationLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/cases/overview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ situationGraph, findings }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
setGenerationError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
/* ── v0.59a — provenance: record generation revision (does NOT change Investigation revision) ── */
|
||||
const rev = existing?.investigationRevision ?? 0;
|
||||
const reportData = { understanding: data.understanding, plausibleInterpretations: data.plausibleInterpretations, hasPlausibleInterpretations: true, generatedFromRevision: rev };
|
||||
setExisting((p) => {
|
||||
void saveInvestigation({ ...p, investigationReport: reportData })
|
||||
.catch((error) => console.error("Investigation report save failed", error));
|
||||
return { ...p, investigationReport: reportData };
|
||||
});
|
||||
} else {
|
||||
setGenerationError(true);
|
||||
}
|
||||
} catch {
|
||||
setGenerationError(true);
|
||||
} finally {
|
||||
setGenerationLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [hydrated, existing]);
|
||||
|
||||
// Manual Report update (v0.59b — freshness manual update)
|
||||
const handleUpdateReport = async () => {
|
||||
if (updateLoading) return;
|
||||
setUpdateLoading(true);
|
||||
|
||||
const snap = await loadInvestigation(routeId);
|
||||
const situationGraph = snap?.situationGraph;
|
||||
const findings = snap?.findings ?? [];
|
||||
const rev = snap?.investigationRevision ?? 0;
|
||||
|
||||
if (!situationGraph) {
|
||||
setUpdateLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/cases/overview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ situationGraph, findings }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
setUpdateLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
const reportData = { understanding: data.understanding, plausibleInterpretations: data.plausibleInterpretations, hasPlausibleInterpretations: true, generatedFromRevision: rev };
|
||||
setExisting((p) => {
|
||||
void saveInvestigation({ ...p, investigationReport: reportData })
|
||||
.catch((error) => console.error("Investigation report save failed", error));
|
||||
return { ...p, investigationReport: reportData };
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* failure: retain existing Report and updateAvailable state */
|
||||
} finally {
|
||||
setUpdateLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const report = existing?.investigationReport || null;
|
||||
const scenario = hydrated ? (existing?.scenario || "") : null;
|
||||
|
||||
const paragraphs = (report?.understanding || "")
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-[800px] px-6 py-16">
|
||||
<h1 className="mb-2 text-[15px] font-bold tracking-[.2em] uppercase text-teal-700/90">
|
||||
Investigation Report
|
||||
</h1>
|
||||
|
||||
{/* Report freshness — only when a Report exists */}
|
||||
{report ? (
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
{existing?.investigationRevision === report.generatedFromRevision ? (
|
||||
<span className="text-[11px] font-semibold tracking-wider uppercase text-teal-700/70">Current</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[11px] font-semibold tracking-wider uppercase text-gray-500">Update available</span>
|
||||
<span className="text-xs text-gray-400">The investigation has changed since this report was generated.</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpdateReport}
|
||||
disabled={updateLoading}
|
||||
className="rounded-lg border border-teal-600 bg-white px-3 py-1.5 text-[11px] font-semibold tracking-wider uppercase text-teal-700 hover:bg-teal-50 transition disabled:opacity-40"
|
||||
>
|
||||
{updateLoading ? "Updating…" : "Update report"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Situation */}
|
||||
{scenario && (
|
||||
<div className="mt-8 rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 pt-6 pb-7 shadow-sm">
|
||||
<h2 className="mb-3 text-[11px] font-bold tracking-[.18em] uppercase text-teal-700/70">
|
||||
Situation
|
||||
</h2>
|
||||
<p className="text-base leading-relaxed text-gray-800 whitespace-pre-wrap">
|
||||
{scenario}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* What we understand */}
|
||||
{report ? (
|
||||
<>
|
||||
{paragraphs.length > 0 ? (
|
||||
paragraphs.map((p, i) => (
|
||||
<div key={i} className="mt-6 rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 pt-6 pb-7 shadow-sm">
|
||||
<h2 className="mb-3 text-[11px] font-bold tracking-[.18em] uppercase text-teal-700/70">
|
||||
What we understand
|
||||
</h2>
|
||||
<p className="text-base leading-relaxed text-gray-800">{p}</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="mt-6 rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 pt-6 pb-7 shadow-sm">
|
||||
<h2 className="mb-3 text-[11px] font-bold tracking-[.18em] uppercase text-teal-700/70">
|
||||
What we understand
|
||||
</h2>
|
||||
<p className="text-base leading-relaxed text-gray-800">{report.understanding || ""}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* What remains plausible — conditional */}
|
||||
{report.hasPlausibleInterpretations && report.plausibleInterpretations ? (
|
||||
<div className="mt-6 rounded-xl border-[2.5px] border-blue-300/70 bg-gradient-to-b from-blue-50/60 to-white px-8 pt-6 pb-7 shadow-sm">
|
||||
<h2 className="mb-3 text-[11px] font-bold tracking-[.18em] uppercase text-blue-700/70">
|
||||
What remains plausible
|
||||
</h2>
|
||||
<p className="text-base leading-relaxed text-gray-800 italic">
|
||||
{report.plausibleInterpretations}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
/* Skeleton / loading state when no persisted report exists */
|
||||
<>
|
||||
<div className="mt-8 rounded-xl border-[2.5px] border-gray-200 bg-gray-50/50 px-8 pt-6 pb-7 shadow-sm">
|
||||
<h2 className="mb-3 text-[11px] font-bold tracking-[.18em] uppercase text-gray-400">
|
||||
What we understand
|
||||
</h2>
|
||||
{generationLoading ? (
|
||||
<div className="flex flex-col gap-3 py-2" aria-live="polite">
|
||||
<span className="text-[11px] font-semibold tracking-wider text-gray-400 uppercase">Generating report…</span>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="h-4 w-full rounded animate-pulse" style={{ backgroundColor: "rgb(229 231 235)", animationDelay: `${i * 150}ms`, width: i === 1 ? "80%" : i === 2 ? "65%" : "90%" }} />
|
||||
))}
|
||||
</div>
|
||||
) : generationError ? (
|
||||
<p className="text-sm text-red-600">Report generation failed. You may try again from the Investigation page.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Back to investigation */}
|
||||
<div className="mt-10">
|
||||
<Link
|
||||
href={`/investigations/${routeId}`}
|
||||
className="rounded-lg border border-teal-600 bg-white px-4 py-2 text-sm font-medium text-teal-700 hover:bg-teal-50 transition"
|
||||
>
|
||||
Back to investigation
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Back to portfolio */}
|
||||
<div className="mt-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-lg border border-teal-600 bg-white px-4 py-2 text-sm font-medium text-teal-700 hover:bg-teal-50 transition"
|
||||
>
|
||||
Back to portfolio
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import "./globals.css";
|
||||
import ThemeToggle from "@/components/theme-toggle";
|
||||
import LogoutButton from "@/components/logout-button";
|
||||
|
||||
export const metadata = {
|
||||
title: "Confidence Engine",
|
||||
@@ -9,6 +11,20 @@ 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>
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import LoginForm from "@/components/login-form";
|
||||
|
||||
export default function LoginPage() {
|
||||
return <LoginForm />;
|
||||
}
|
||||
+157
-7
@@ -1,15 +1,165 @@
|
||||
import ScenarioForm from "@/components/scenario-form";
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { listInvestigations, restartInvestigation } from "@/lib/storage/investigation-storage";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
function Portfolio() {
|
||||
const router = useRouter();
|
||||
const [summaries, setSummaries] = React.useState([]);
|
||||
const [hydrated, setHydrated] = React.useState(false);
|
||||
const [loadError, setLoadError] = React.useState(null);
|
||||
const [showRestartConfirm, setShowRestartConfirm] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const investigations = await listInvestigations();
|
||||
if (active) setSummaries(investigations);
|
||||
} catch (error) {
|
||||
if (active) setLoadError(error);
|
||||
} finally {
|
||||
if (active) setHydrated(true);
|
||||
}
|
||||
})();
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="mx-auto max-w-[1600px] px-6 py-12">
|
||||
<main className="mx-auto max-w-[640px] px-6 py-16">
|
||||
<h1 className="mb-2 text-3xl font-bold tracking-tight">Confidence Engine</h1>
|
||||
<p className="mb-8 text-sm text-gray-500">
|
||||
Experimental prototype: enter a scenario and send it to a local LLM for
|
||||
evidence-based structured reconstruction. This is a technical vertical
|
||||
slice — not a production system.
|
||||
Investigator's notebook — index of persisted investigations.
|
||||
</p>
|
||||
<ScenarioForm />
|
||||
|
||||
{/* Investigation collection */}
|
||||
{summaries.length > 0 && (
|
||||
<section className="mb-10">
|
||||
<h2 className="mb-4 text-[13px] font-bold tracking-[.18em] uppercase text-teal-700/80">
|
||||
Investigations
|
||||
</h2>
|
||||
|
||||
{summaries.map((summary) => (
|
||||
<div key={summary.id} className="rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 py-6 shadow-sm">
|
||||
<p className="text-sm text-gray-700">
|
||||
{summary.scenario || "Untitled investigation"}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex items-start gap-3 text-sm">
|
||||
{summary.reportExists ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Link
|
||||
href={`/investigations/${summary.id}/report`}
|
||||
className="rounded-lg border border-teal-600 bg-white px-4 py-2 font-medium text-teal-700 hover:bg-teal-50 transition"
|
||||
>
|
||||
View report
|
||||
</Link>
|
||||
|
||||
{summary.reportGeneratedFromRevision === summary.investigationRevision ? (
|
||||
<span className="text-[11px] font-semibold tracking-wider uppercase text-teal-700/70">
|
||||
Current
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] font-semibold tracking-wider uppercase text-gray-500">
|
||||
Update available
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Link
|
||||
href={`/investigations/${summary.id}`}
|
||||
className="self-start rounded-lg border border-teal-600 bg-white px-4 py-2 font-medium text-teal-700 hover:bg-teal-50 transition"
|
||||
>
|
||||
Continue investigation
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => setShowRestartConfirm(summary.id)}
|
||||
className="self-start rounded-lg border border-red-400 bg-white px-4 py-2 font-medium text-red-700 hover:bg-red-50 transition"
|
||||
>
|
||||
Restart investigation
|
||||
</button>
|
||||
|
||||
{showRestartConfirm === summary.id && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={`restart-title-${summary.id}`}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||
onClick={() => setShowRestartConfirm(null)}
|
||||
>
|
||||
<div
|
||||
className="w-[420px] rounded-xl border border-gray-200 bg-white p-6 shadow-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 id={`restart-title-${summary.id}`} className="mb-3 text-lg font-semibold">
|
||||
Restart this investigation?
|
||||
</h2>
|
||||
<p className="mb-5 text-sm text-gray-600">
|
||||
Your current investigation, findings, clarified questions, and report will be lost. Are you sure you want to continue?
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowRestartConfirm(null)}
|
||||
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setShowRestartConfirm(null);
|
||||
try {
|
||||
await restartInvestigation(summary.id);
|
||||
setSummaries(await listInvestigations());
|
||||
} catch (error) {
|
||||
setLoadError(error);
|
||||
}
|
||||
}}
|
||||
className="rounded-lg border border-red-400 bg-white px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-50 transition"
|
||||
>
|
||||
Restart investigation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* No investigations */}
|
||||
{!hydrated && (
|
||||
<section className="mb-10"><h2 className="mb-4 text-[13px] font-bold tracking-[.18em] uppercase text-teal-700/80">Investigations</h2><p className="text-sm text-gray-500 italic">Loading investigations…</p></section>
|
||||
)}
|
||||
{hydrated && loadError && (
|
||||
<section className="mb-10"><h2 className="mb-4 text-[13px] font-bold tracking-[.18em] uppercase text-teal-700/80">Investigations</h2><p className="text-sm text-red-600">Unable to load investigations.</p></section>
|
||||
)}
|
||||
{hydrated && !loadError && summaries.length === 0 && (
|
||||
<section className="mb-10">
|
||||
<h2 className="mb-4 text-[13px] font-bold tracking-[.18em] uppercase text-teal-700/80">
|
||||
Investigations
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 italic">No investigations yet.</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const id = crypto.randomUUID();
|
||||
router.push(`/investigations/${id}`);
|
||||
}}
|
||||
className="rounded-lg border-[2.5px] border-dashed border-teal-400 px-6 py-3 text-sm font-medium text-teal-700 hover:bg-teal-50 transition"
|
||||
>
|
||||
+ Create new investigation
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default Portfolio;
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Experimental branch switcher — RTO.25A
|
||||
*
|
||||
* Smallest branch representation needed to test passive late-result indication.
|
||||
* Does NOT replace production branch navigation. Temporary fixture only.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
||||
/* ── Keyframes (injected once via <style> at render) ───── */
|
||||
|
||||
const PulseStyle = () => (
|
||||
<style>{`
|
||||
@keyframes rto-pulse {
|
||||
0%, 100% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
);
|
||||
|
||||
/* ── Status dot (passive new-result indicator) ─────────── */
|
||||
|
||||
function NewIndicator({ visible }) {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
className="ml-2 inline-flex items-center"
|
||||
title="Something new is available here"
|
||||
aria-label="New result available"
|
||||
>
|
||||
<span
|
||||
className="relative inline-block h-[8px] w-[8px]"
|
||||
style={{ animation: "rto-pulse 3s ease-in-out infinite" }}
|
||||
>
|
||||
<span
|
||||
className="absolute inset-0 rounded-full bg-blue-400/70"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Single branch row ─────────────────────────────────── */
|
||||
|
||||
function BranchRow({ id, label, active, isNew, isPaused, origin, onClick }) {
|
||||
const isActive = Boolean(active);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={isActive}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={`w-full flex items-start gap-2 rounded-md px-3 py-2 text-left transition text-sm ${
|
||||
isActive
|
||||
? "bg-blue-50/80 border border-blue-200/60 text-blue-900 font-medium"
|
||||
: "text-gray-600 hover:bg-gray-100/70 hover:text-gray-800 border border-transparent"
|
||||
} ${!isActive ? "cursor-pointer" : "cursor-default"}`}
|
||||
>
|
||||
{/* Active indicator — ● vs ○ */}
|
||||
<span
|
||||
className={`flex-none leading-none text-base ${
|
||||
isActive ? "text-blue-500" : "text-gray-400"
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{isActive ? "●" : "○"}
|
||||
</span>
|
||||
|
||||
{/* Branch label + origin */}
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="truncate block">{label}</span>
|
||||
{origin && (
|
||||
<span className="block text-[11px] leading-tight text-gray-500/80 truncate" title={origin}>
|
||||
{origin}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Passive indicators: pause + new */}
|
||||
<span className="flex items-center gap-1.5 flex-none">
|
||||
{!isActive && isPaused && (
|
||||
<span
|
||||
className="text-[10px] text-gray-400"
|
||||
title="Done for now"
|
||||
>
|
||||
Paused
|
||||
</span>
|
||||
)}
|
||||
{!isActive && <NewIndicator visible={isNew} />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Card wrapper ────────────────────────────────────────── */
|
||||
|
||||
export default function ExperimentalBranchSwitcher({
|
||||
branches = [],
|
||||
activeBranchId,
|
||||
branchNewResults = {},
|
||||
branchPauseState = [],
|
||||
onBranchSelect,
|
||||
}) {
|
||||
if (!branches.length) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border border-gray-200/60 bg-gray-50/30 p-4"
|
||||
role="radiogroup"
|
||||
aria-label="Experimental branch switcher — RTO.25A"
|
||||
>
|
||||
{/* Label — clearly experimental */}
|
||||
<h2 className="mb-1 text-[10px] font-semibold tracking-widest uppercase text-gray-600">
|
||||
Branches{" "}
|
||||
<span className="font-normal text-gray-500">(exp)</span>
|
||||
</h2>
|
||||
<p className="mb-3 text-[11px] font-medium leading-tight text-gray-500/80">
|
||||
Browse branches. Current focus is preserved.
|
||||
</p>
|
||||
|
||||
<div className="space-y-1" role="list" aria-label="Available branches">
|
||||
{branches.map((branch) => (
|
||||
<BranchRow
|
||||
key={branch.id}
|
||||
id={branch.id}
|
||||
label={branch.label}
|
||||
active={activeBranchId === branch.id}
|
||||
isNew={Boolean(branchNewResults[branch.id])}
|
||||
isPaused={branchPauseState.includes(branch.id)}
|
||||
origin={branch.origin}
|
||||
onClick={() => onBranchSelect?.(branch.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { PulseStyle };
|
||||
@@ -84,10 +84,10 @@ export default function InvestigationMap({ turnCount = 0 }) {
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200/60 bg-gray-50/30 p-4" role="region" aria-label="Investigation map preview">
|
||||
<h2 className="mb-1 text-[11px] font-medium tracking-widest uppercase text-gray-300">
|
||||
<h2 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">
|
||||
Investigation Map
|
||||
</h2>
|
||||
<p className="mb-3 text-xs text-gray-400/70">
|
||||
<p className="mb-3 text-xs font-medium leading-tight text-gray-500/80">
|
||||
Active investigation topics and their status.
|
||||
</p>
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ function InvestigationSummaryPanelV2({ graph, selectedQuestion, result, updateSt
|
||||
<div>
|
||||
{stillInvestigating.length > 1 ? (
|
||||
<>
|
||||
<h3 className="mb-2 text-xs font-medium text-gray-400">Still investigating</h3>
|
||||
<h3 className="mb-2 text-xs font-medium text-gray-500">Still investigating</h3>
|
||||
<ul className="space-y-1.5">
|
||||
{Object.entries(investigatingByGroup).map(([group, items]) => (
|
||||
<li key={group}>
|
||||
@@ -227,7 +227,7 @@ function InvestigationSummaryPanelV2({ graph, selectedQuestion, result, updateSt
|
||||
{/* ── What we have learned ────────────────────────── */}
|
||||
{known.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-xs font-medium text-gray-400">What we know</h3>
|
||||
<h3 className="mb-2 text-xs font-medium text-gray-500">What we know</h3>
|
||||
<ul className="space-y-1.5">
|
||||
{known.map((item, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
@@ -243,8 +243,8 @@ function InvestigationSummaryPanelV2({ graph, selectedQuestion, result, updateSt
|
||||
|
||||
{/* ── Quiet reasoning summary — secondary ─────────── */}
|
||||
<div className="pt-2 border-t border-gray-200/40">
|
||||
<p className="text-[10px] font-medium tracking-widest uppercase text-gray-300 mb-1.5">Reasoning</p>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-400">
|
||||
<p className="text-[10px] font-semibold tracking-widest uppercase text-gray-500 mb-1.5">Reasoning</p>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500">
|
||||
{reasonEntries.map(([label, count]) => (
|
||||
<span key={label}>
|
||||
{count} {label}
|
||||
|
||||
@@ -47,7 +47,7 @@ function KnownSection({ title, items }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 text-[11px] font-medium tracking-widest uppercase text-gray-400">
|
||||
<h3 className="mb-2 text-[11px] font-medium tracking-widest uppercase text-gray-500">
|
||||
{title}
|
||||
</h3>
|
||||
<ul className="space-y-1.5">
|
||||
@@ -67,7 +67,7 @@ function InvestigatingSection({ title, items }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 text-[11px] font-medium tracking-widest uppercase text-gray-400">
|
||||
<h3 className="mb-2 text-[11px] font-medium tracking-widest uppercase text-gray-500">
|
||||
{title}
|
||||
</h3>
|
||||
<ul className="space-y-1.5">
|
||||
@@ -87,7 +87,7 @@ function ExplanationSection({ items }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 text-[11px] font-medium tracking-widest uppercase text-gray-400">
|
||||
<h3 className="mb-2 text-[11px] font-medium tracking-widest uppercase text-gray-500">
|
||||
Possible explanations
|
||||
</h3>
|
||||
<ul className="space-y-1.5">
|
||||
@@ -102,10 +102,10 @@ function QuietSummary({ text }) {
|
||||
|
||||
return (
|
||||
<div className="pt-2 border-t border-gray-200/40">
|
||||
<p className="text-[10px] font-medium tracking-widest uppercase text-gray-300 mb-1.5">
|
||||
<p className="text-[10px] font-semibold tracking-widest uppercase text-gray-500 mb-1.5">
|
||||
Investigation state
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">{text}</p>
|
||||
<p className="text-xs text-gray-500">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ function InvestigationSummaryPanel({ graph, selectedQuestion, result, updateStat
|
||||
{/* Current understanding */}
|
||||
{currentUnderstanding && (
|
||||
<div>
|
||||
<h3 className="mb-1 text-[11px] font-medium tracking-widest uppercase text-gray-400/70">
|
||||
<h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">
|
||||
What we understand so far
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-gray-600">{currentUnderstanding}</p>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { createClient, magicLinkRedirectTo } from "@/lib/supabase/browser.js";
|
||||
|
||||
export default function LoginForm() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [status, setStatus] = useState("idle");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function sendMagicLink(event) {
|
||||
event.preventDefault();
|
||||
setStatus("pending");
|
||||
setError("");
|
||||
const { error: signInError } = await createClient().auth.signInWithOtp({
|
||||
email,
|
||||
options: { emailRedirectTo: magicLinkRedirectTo(window.location.origin) },
|
||||
});
|
||||
if (signInError) {
|
||||
setError("We could not send a magic link. Please try again.");
|
||||
setStatus("idle");
|
||||
return;
|
||||
}
|
||||
setStatus("sent");
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-[calc(100vh-57px)] max-w-[640px] items-center px-6 py-16">
|
||||
<section className="w-full rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 py-9 shadow-sm">
|
||||
<p className="mb-3 text-[11px] font-bold uppercase tracking-[.18em] text-teal-700/70">Welcome</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Confidence Engine</h1>
|
||||
<p className="mt-3 text-sm leading-relaxed text-gray-600">Enter your email and we will send you a secure link to continue.</p>
|
||||
<form className="mt-7 space-y-4" onSubmit={sendMagicLink}>
|
||||
<label className="block text-sm font-medium text-gray-700" htmlFor="email">Email address</label>
|
||||
<input id="email" type="email" autoComplete="email" required value={email} onChange={(event) => setEmail(event.target.value)} className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-teal-600 focus:outline-none focus:ring-2 focus:ring-teal-400" />
|
||||
<button type="submit" disabled={status === "pending"} className="rounded-lg bg-teal-700 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-teal-600 disabled:cursor-wait disabled:opacity-60">
|
||||
{status === "pending" ? "Sending magic link…" : "Send magic link"}
|
||||
</button>
|
||||
</form>
|
||||
{status === "sent" && <p className="mt-5 text-sm text-green-700" role="status">Check your email for your magic link.</p>}
|
||||
{error && <p className="mt-5 text-sm text-red-700" role="alert">{error}</p>}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createClient } from "@/lib/supabase/browser.js";
|
||||
|
||||
export default function LogoutButton() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const client = createClient();
|
||||
|
||||
async function checkSession() {
|
||||
const { data: { session } } = await client.auth.getSession();
|
||||
setVisible(!!session);
|
||||
}
|
||||
|
||||
checkSession();
|
||||
|
||||
const { data: { subscription } } = client.auth.onAuthStateChange((_event, session) => {
|
||||
setVisible(!!session);
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
async function handleLogout() {
|
||||
setLoggingOut(true);
|
||||
setError("");
|
||||
try {
|
||||
const client = createClient();
|
||||
await client.auth.signOut();
|
||||
window.location.href = "/login";
|
||||
} catch (err) {
|
||||
setError("Could not logout. Try again.");
|
||||
setLoggingOut(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
disabled={loggingOut}
|
||||
aria-label="Logout"
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-600 transition hover:bg-gray-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-500 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
{loggingOut ? "Logging out…" : "Logout"}
|
||||
</button>
|
||||
{error && (
|
||||
<p className="text-xs text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1680
-150
File diff suppressed because it is too large
Load Diff
+515
-66
@@ -5,6 +5,8 @@ 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 } from "@/lib/graph/finding-helpers";
|
||||
import { loadInvestigation, saveInvestigation, restartInvestigation } from "@/lib/storage/investigation-storage";
|
||||
|
||||
/* Compile-time env resolution — NEXT_PUBLIC_ vars are injected by Next.js at build */
|
||||
const MOCK_ENABLED = process.env.NEXT_PUBLIC_CONFIDENCE_ENGINE_MOCKS === "true";
|
||||
@@ -33,7 +35,7 @@ export async function submitScenarioForStartCase(fetchImpl, scenario) {
|
||||
|
||||
export async function submitAnswerForUpdateCase(
|
||||
fetchImpl,
|
||||
{ situationGraph, previousQuestion, answer },
|
||||
{ situationGraph, previousQuestion, answer, findings },
|
||||
) {
|
||||
if (!answer?.trim()) {
|
||||
return {
|
||||
@@ -47,10 +49,15 @@ export async function submitAnswerForUpdateCase(
|
||||
};
|
||||
}
|
||||
|
||||
const body = { situationGraph, previousQuestion, answer };
|
||||
if (findings && findings.length > 0) {
|
||||
body.findings = findings;
|
||||
}
|
||||
|
||||
const response = await fetchImpl("/api/cases/update", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ situationGraph, previousQuestion, answer }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -60,6 +67,19 @@ export async function submitAnswerForUpdateCase(
|
||||
};
|
||||
}
|
||||
|
||||
export async function synthesizeFromFindings(fetchImpl, { situationGraph, findings }) {
|
||||
const response = await fetchImpl("/api/cases/synthesis", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ situationGraph, findings }),
|
||||
});
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
data: await response.json(),
|
||||
};
|
||||
}
|
||||
|
||||
function normaliseStartResult(data) {
|
||||
return {
|
||||
...data,
|
||||
@@ -186,28 +206,72 @@ export function UpdateErrorPanel({ updateError }) {
|
||||
|
||||
export { INITIAL_MESSAGES, UPDATE_MESSAGES, useLoadingStatus };
|
||||
|
||||
// ── Session key ────────────────────────────────────────────────
|
||||
const SESSION_KEY = "confidence-engine-session";
|
||||
|
||||
function getSession() {
|
||||
if (typeof sessionStorage === "undefined") return null;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch (_) { return null; }
|
||||
/**
|
||||
* Derives whether the current component state represents a valid investigation
|
||||
* context sufficient to render a workspace surface.
|
||||
*
|
||||
* Valid only when:
|
||||
* - result carries a situationGraph (renderable graph), OR
|
||||
* - status is "success" AND there is a non-empty scenario
|
||||
* (from session restoration with real data).
|
||||
*
|
||||
* This predicate is the single source of truth for all render-gate decisions.
|
||||
* showExperimentView, fixture availability, or sessionStorage keys alone are
|
||||
* NOT sufficient to constitute valid context.
|
||||
*/
|
||||
export function hasValidInvestigationContext(result, status, scenario) {
|
||||
return Boolean(result?.situationGraph) ||
|
||||
(status === "success" && Boolean(scenario?.trim()));
|
||||
}
|
||||
|
||||
function saveSession(state) {
|
||||
if (typeof sessionStorage === "undefined") return;
|
||||
try { sessionStorage.setItem(SESSION_KEY, JSON.stringify(state)); } catch (_) {}
|
||||
/**
|
||||
* Derives the primary surface that must render for the given state tuple.
|
||||
* Enforces exactly-one-primary-surface invariant: no zero, no two.
|
||||
*/
|
||||
export function derivePrimarySurface(result, status, _showExperimentView, scenario, activeBranchId) {
|
||||
if (status === "loading") return "LOADING";
|
||||
if (status === "error") return "ERROR_SURFACE";
|
||||
|
||||
const valid = hasValidInvestigationContext(result, status, scenario);
|
||||
|
||||
if (valid) return "NORMAL_WORKSPACE";
|
||||
return "SCENARIO_ENTRY";
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
if (typeof sessionStorage === "undefined") return;
|
||||
try { sessionStorage.removeItem(SESSION_KEY); } catch (_) {}
|
||||
/**
|
||||
* Orchestrate the authoritative episode reconsideration flow.
|
||||
* Exported for deterministic testing — domain functions and server endpoint accepted as parameters.
|
||||
*/
|
||||
export async function executeEpisodeDone({
|
||||
resultSituationGraph,
|
||||
targetNodeId,
|
||||
focusedContributions,
|
||||
findings,
|
||||
episodeDoneServer,
|
||||
synthesizeFn,
|
||||
setResult: setAppState,
|
||||
}) {
|
||||
const serverResult = await episodeDoneServer({
|
||||
situationGraph: resultSituationGraph,
|
||||
targetNodeId,
|
||||
contributions: focusedContributions ?? [],
|
||||
findings,
|
||||
});
|
||||
|
||||
if (!serverResult.success) {
|
||||
return { success: false, stage: "episode_done", error: serverResult.error };
|
||||
}
|
||||
|
||||
const nextGraph = serverResult.updatedSituationGraph;
|
||||
setAppState(prev => ({ ...(prev ?? {}), situationGraph: nextGraph }));
|
||||
|
||||
const synthesisResult = await synthesizeFn(nextGraph, findings);
|
||||
|
||||
return { success: true, nextGraph, synthesisResult };
|
||||
}
|
||||
|
||||
export default function ScenarioForm() {
|
||||
export default function ScenarioForm({ investigationId, onNavigateToReport }) {
|
||||
const [scenario, setScenario] = useState("");
|
||||
const [status, setStatus] = useState("idle"); // idle | loading | error | success
|
||||
const [result, setResult] = useState(null);
|
||||
@@ -217,20 +281,345 @@ export default function ScenarioForm() {
|
||||
const [updateResult, setUpdateResult] = useState(null);
|
||||
const [lastSubmittedAnswer, setLastSubmittedAnswer] = useState("");
|
||||
const [currentUnderstanding, setCurrentUnderstanding] = useState(null);
|
||||
const [cuSynthesisLoading, setCuSynthesisLoading] = useState(false);
|
||||
const [mockScenario, setMockScenario] = useState("");
|
||||
const [hideFacilitatorOnLanding, setHideFacilitatorOnLanding] = useState(false);
|
||||
|
||||
/* ── v0.54b — investigation overview transient state ────── */
|
||||
const [overviewState, setOverviewState] = useState(null);
|
||||
const [overviewLoading, setOverviewLoading] = useState(false);
|
||||
|
||||
/* ── v0.55 — persisted investigation report (derived artefact) ── */
|
||||
const [investigationReport, setInvestigationReport] = useState(null);
|
||||
|
||||
/* ── v0.59a — provenance: Investigation revision tracking ── */
|
||||
const [investigationRevision, setInvestigationRevision] = useState(0);
|
||||
|
||||
/* ── in-flight gate for episode reconsideration on Done ──── */
|
||||
const doneInProgressRef = useRef(false);
|
||||
|
||||
/* ── RTO.31: focused contributions ownership ─────────────── */
|
||||
const [focusedContributions, setFocusedContributions] = useState([]);
|
||||
|
||||
/* ── v2 findings from focused contributions ─────────────── */
|
||||
const [findings, setFindings] = useState([]);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
function persist(snapshot) {
|
||||
void saveInvestigation(snapshot).catch((error) => console.error("Investigation autosave failed", error));
|
||||
}
|
||||
|
||||
function restartPersistedInvestigation() {
|
||||
void restartInvestigation(investigationId)
|
||||
.catch((error) => console.error("Investigation restart failed", error));
|
||||
}
|
||||
|
||||
function appendFinding(finding) {
|
||||
setFindings((prev) => {
|
||||
return [...prev, finding];
|
||||
});
|
||||
}
|
||||
|
||||
function updateFindingDisposition(findingId, newDisposition) {
|
||||
// Derive explicit next state — not a React-state reread.
|
||||
const nextFindings = (findings ?? []).map((f) =>
|
||||
f.id === findingId ? { ...f, userDisposition: newDisposition } : f,
|
||||
);
|
||||
|
||||
setFindings(() => nextFindings);
|
||||
|
||||
// ── Synthesis trigger: completed canonical eligibility transition ──
|
||||
const prevFinding = (findings ?? []).find((f) => f.id === findingId);
|
||||
const previousDisposition = prevFinding?.userDisposition;
|
||||
|
||||
const notRelevantTransition =
|
||||
previousDisposition !== "not_relevant" && newDisposition === "not_relevant";
|
||||
const restoreTransition =
|
||||
previousDisposition === "not_relevant" && newDisposition === null;
|
||||
|
||||
if (!notRelevantTransition && !restoreTransition) return;
|
||||
|
||||
/* ── v0.59a — provenance: eligible evidence set changed ── */
|
||||
setInvestigationRevision((prev) => (prev ?? 0) + 1);
|
||||
|
||||
const currentGraph = result?.situationGraph;
|
||||
if (!currentGraph) return;
|
||||
|
||||
void synthesizeFromFindings(fetch, {
|
||||
situationGraph: currentGraph,
|
||||
findings: normalizeFindings(nextFindings),
|
||||
}).then((res) => {
|
||||
if (res.ok && res.data?.currentUnderstanding) {
|
||||
setCurrentUnderstanding(res.data.currentUnderstanding);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateFindingProposition(findingId, newProposition) {
|
||||
// Derive explicit next state — not a React-state reread.
|
||||
const nextFindings = (findings ?? []).map((f) =>
|
||||
f.id === findingId ? { ...f, proposition: newProposition, userDisposition: null } : f,
|
||||
);
|
||||
|
||||
/* ── v0.59a — provenance: no-op guard ── */
|
||||
const prevFinding = (findings ?? []).find((f) => f.id === findingId);
|
||||
if (prevFinding?.proposition === newProposition) return; // no semantic change
|
||||
|
||||
setFindings(() => nextFindings);
|
||||
|
||||
/* ── v0.59a — provenance: corrected Finding changes evidence ── */
|
||||
setInvestigationRevision((prev) => (prev ?? 0) + 1);
|
||||
|
||||
// ── Synthesis trigger: corrected Finding → one reconstruction ──
|
||||
const currentGraph = result?.situationGraph;
|
||||
if (!currentGraph) return;
|
||||
|
||||
void synthesizeFromFindings(fetch, {
|
||||
situationGraph: currentGraph,
|
||||
findings: normalizeFindings(nextFindings),
|
||||
}).then((res) => {
|
||||
if (res.ok && res.data?.currentUnderstanding) {
|
||||
setCurrentUnderstanding(res.data.currentUnderstanding);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Authoritative graph reconsideration triggered by "Done for now"
|
||||
* activity boundary. Delegates to the exported executeEpisodeDone pipeline.
|
||||
*/
|
||||
async function handleDoneForNowPromotion(targetNodeId, onImmediateGraphUpdate) {
|
||||
if (!targetNodeId) return;
|
||||
|
||||
// Gate: only invoke episode processing when the active target has focused contributions.
|
||||
// Scenario-wide findings no longer determine whether an empty target enters episode processing.
|
||||
const hasActiveTargetContent = (focusedContributions ?? []).some(
|
||||
(c) => c.targetNodeId === targetNodeId || c.originatingTargetNodeId === targetNodeId,
|
||||
);
|
||||
if (!hasActiveTargetContent) return;
|
||||
|
||||
// In-flight guard: exactly-once enforcement
|
||||
if (doneInProgressRef.current) return;
|
||||
doneInProgressRef.current = true;
|
||||
|
||||
/* ── Immediate client transition — before awaiting async work ── */
|
||||
const preDoneGraph = result?.situationGraph;
|
||||
if (preDoneGraph && onImmediateGraphUpdate) {
|
||||
const immediateResolvedIds = new Set(preDoneGraph.resolvedNodeIds || []);
|
||||
immediateResolvedIds.add(targetNodeId);
|
||||
const immediateGraph = {
|
||||
...preDoneGraph,
|
||||
resolvedNodeIds: Array.from(immediateResolvedIds),
|
||||
};
|
||||
onImmediateGraphUpdate(immediateGraph);
|
||||
}
|
||||
|
||||
setCuSynthesisLoading(true);
|
||||
try {
|
||||
const doneResult = await executeEpisodeDone({
|
||||
resultSituationGraph: preDoneGraph,
|
||||
targetNodeId,
|
||||
focusedContributions: focusedContributions ?? [],
|
||||
findings,
|
||||
episodeDoneServer: (payload) =>
|
||||
fetch("/api/cases/update", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ...payload, episodeMode: true }),
|
||||
}).then((res) => res.json()),
|
||||
synthesizeFn: (graph, fn) => synthesizeFromFindings(fetch, { situationGraph: graph, findings: fn }),
|
||||
setResult,
|
||||
});
|
||||
|
||||
/* ── v0.59a — provenance: episode done is meaningful evidence change ── */
|
||||
const nextRev = (investigationRevision ?? 0) + 1;
|
||||
setInvestigationRevision(nextRev);
|
||||
|
||||
/* CU synthesis — install only on success */
|
||||
if (doneResult?.synthesisResult?.ok && doneResult.synthesisResult.data?.currentUnderstanding) {
|
||||
setCurrentUnderstanding(doneResult.synthesisResult.data.currentUnderstanding);
|
||||
}
|
||||
/* On synthesis failure: KEEP nextGraph, KEEP Findings, KEEP existing CU. Do NOT rollback. */
|
||||
|
||||
} finally {
|
||||
doneInProgressRef.current = false;
|
||||
setCuSynthesisLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.54b/v0.55 — request investigation overview via the established POST /api/cases/overview seam.
|
||||
* Produces a distinct Investigation Report: a derived artefact, not canonical reasoning state.
|
||||
*/
|
||||
async function handleRequestOverview() {
|
||||
if (overviewLoading || !result?.situationGraph) return;
|
||||
|
||||
setOverviewLoading(true);
|
||||
setOverviewState(null); // clear any previous overview before new request
|
||||
|
||||
const plausibleInput = (result.situationGraph?.reconstruction || {}).plausibleInterpretations ?? [];
|
||||
const hasPlausibleInput = Array.isArray(plausibleInput) && plausibleInput.length > 0;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/cases/overview", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
situationGraph: result.situationGraph,
|
||||
findings,
|
||||
plausibleInterpretations: plausibleInput,
|
||||
}),
|
||||
}).then((r) => r.json());
|
||||
|
||||
if (res?.success && res?.understanding != null) {
|
||||
setOverviewState(res);
|
||||
|
||||
// Persist as a derived artefact of this investigation
|
||||
const rev = investigationRevision ?? 0;
|
||||
const report = {
|
||||
understanding: res.understanding,
|
||||
plausibleInterpretations: hasPlausibleInput ? res.plausibleInterpretations ?? "" : "",
|
||||
hasPlausibleInterpretations: hasPlausibleInput,
|
||||
generatedFromRevision: rev,
|
||||
};
|
||||
setInvestigationReport(report);
|
||||
|
||||
// Trigger autosave to persist the report
|
||||
void saveInvestigation({
|
||||
id: investigationId,
|
||||
scenario,
|
||||
situationGraph: result.situationGraph,
|
||||
selectedQuestion: result.selectedQuestion,
|
||||
summary: currentUnderstanding,
|
||||
updatedAt: new Date().toISOString(),
|
||||
focusedContributions,
|
||||
findings,
|
||||
investigationReport: report,
|
||||
investigationRevision: rev,
|
||||
});
|
||||
}
|
||||
// On failure: do not clear existing CU, do not block further attempts
|
||||
} finally {
|
||||
setOverviewLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function appendFocusedContribution(contribution) {
|
||||
// Derive a single stored contribution object and use it for BOTH
|
||||
// contribution storage AND Finding derivation so the same identity
|
||||
// appears in focusedContributions[] and Finding.contributionId.
|
||||
setFocusedContributions((prev) => {
|
||||
const seq = prev.length + 1;
|
||||
const storedContribution = { ...contribution, sequence: seq, id: `contrib-${String(seq).padStart(4, "0")}` };
|
||||
|
||||
// Derive Findings from the exact stored Contribution (not a separate approximation)
|
||||
setFindings((prevFindings) => {
|
||||
const newFindings = deriveFindingsFromContributions([storedContribution]).findings;
|
||||
return normalizeFindings([...prevFindings, ...newFindings]);
|
||||
});
|
||||
|
||||
return [...prev, storedContribution];
|
||||
});
|
||||
|
||||
// ── Synthesis trigger: once per completed Finding transition ──
|
||||
const newFindingsDelta = deriveFindingsFromContributions([
|
||||
{
|
||||
...contribution,
|
||||
sequence: (focusedContributions?.length ?? 0) + 1,
|
||||
id: `contrib-${String((focusedContributions?.length ?? 0) + 1).padStart(4, "0")}`,
|
||||
},
|
||||
]).findings;
|
||||
|
||||
if (newFindingsDelta.length === 0) return;
|
||||
|
||||
const currentGraph = result?.situationGraph;
|
||||
if (!currentGraph) return;
|
||||
|
||||
void synthesizeFromFindings(fetch, {
|
||||
situationGraph: currentGraph,
|
||||
findings: normalizeFindings([...(findings ?? []), ...newFindingsDelta]),
|
||||
}).then((res) => {
|
||||
if (res.ok && res.data?.currentUnderstanding) {
|
||||
setCurrentUnderstanding(res.data.currentUnderstanding);
|
||||
}
|
||||
});
|
||||
}
|
||||
const textareaRef = useRef(null);
|
||||
|
||||
/* Restore persisted session on mount (Phase 3) ─────────── */
|
||||
/* ── Valid investigation predicate ─────────────────────── */
|
||||
|
||||
// Delegated to the exported utility below.
|
||||
const validCtx = hasValidInvestigationContext(result, status, scenario);
|
||||
|
||||
/* Restore persisted session on mount ─────────── */
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const saved = getSession();
|
||||
if (!saved) return;
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const saved = investigationId ? await loadInvestigation(investigationId) : null;
|
||||
if (!active || !saved) return;
|
||||
|
||||
const hasGraph = Boolean(saved.situationGraph);
|
||||
|
||||
setScenario(saved.scenario || "");
|
||||
setResult(saved.situationGraph ? { ...saved, situationGraph: saved.situationGraph } : null);
|
||||
setResult(hasGraph ? { ...saved, situationGraph: saved.situationGraph } : null);
|
||||
setCurrentUnderstanding(saved.summary || null);
|
||||
setStatus("success");
|
||||
}, []);
|
||||
setFocusedContributions(saved.focusedContributions || []);
|
||||
setFindings(saved.findings || []);
|
||||
|
||||
/* ── v0.55 — hydrate persisted investigation report ─── */
|
||||
if (saved.investigationReport) {
|
||||
setInvestigationReport(saved.investigationReport);
|
||||
}
|
||||
|
||||
/* ── v0.59a — hydrate provenance revision ─────────── */
|
||||
setInvestigationRevision(saved.investigationRevision ?? 0);
|
||||
|
||||
// Partial sessions (present but no graph) must NOT suppress the
|
||||
// scenario-entry form. Only promote to success when there is actual
|
||||
// investigation data to render.
|
||||
if (hasGraph) {
|
||||
setStatus("success");
|
||||
}
|
||||
} finally {
|
||||
if (active) setHydrated(true);
|
||||
}
|
||||
})();
|
||||
return () => { active = false; };
|
||||
}, [investigationId]);
|
||||
|
||||
/* ── Canonical autosave — persist whenever state changes (Phase 2) ── */
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
// Guard: no valid investigation yet → skip autosave during idle/start flows.
|
||||
// Also prevents overwriting an existing saved investigation with the initial
|
||||
// empty state of a fresh ScenarioForm instance (hydration race guard).
|
||||
if (!hydrated || !result?.situationGraph) return;
|
||||
|
||||
persist({
|
||||
id: investigationId,
|
||||
scenario,
|
||||
situationGraph: result.situationGraph,
|
||||
selectedQuestion: result.selectedQuestion,
|
||||
summary: currentUnderstanding,
|
||||
updatedAt: new Date().toISOString(),
|
||||
focusedContributions,
|
||||
findings,
|
||||
investigationReport,
|
||||
investigationRevision,
|
||||
});
|
||||
}, [
|
||||
scenario,
|
||||
result?.situationGraph,
|
||||
result?.selectedQuestion,
|
||||
currentUnderstanding,
|
||||
focusedContributions,
|
||||
findings,
|
||||
investigationReport,
|
||||
investigationRevision, hydrated,
|
||||
]);
|
||||
|
||||
/* Restore facilitator dismiss preference (Experiment 05) ─── */
|
||||
useEffect(() => {
|
||||
@@ -307,7 +696,9 @@ export default function ScenarioForm() {
|
||||
setCurrentUnderstanding(data.summary ?? null);
|
||||
const normalised = normaliseStartResult(data);
|
||||
setResult(normalised);
|
||||
saveSession({ scenario, situationGraph: normalised.situationGraph, selectedQuestion: normalised.selectedQuestion, summary: data.summary ?? null, updatedAt: new Date().toISOString() });
|
||||
/* ── v0.59a — provenance: first meaningful change sets revision to 1 ── */
|
||||
setInvestigationRevision(1);
|
||||
persist({ id: investigationId, scenario, situationGraph: normalised.situationGraph, selectedQuestion: normalised.selectedQuestion, summary: data.summary ?? null, updatedAt: new Date().toISOString(), focusedContributions, findings: [], investigationReport, investigationRevision: 1 });
|
||||
} else {
|
||||
setStatus("error");
|
||||
setCurrentUnderstanding(data.summary ?? null);
|
||||
@@ -340,6 +731,7 @@ export default function ScenarioForm() {
|
||||
situationGraph: result?.situationGraph,
|
||||
previousQuestion: result?.selectedQuestion,
|
||||
answer,
|
||||
findings,
|
||||
});
|
||||
|
||||
if (submission.skipped) {
|
||||
@@ -352,17 +744,21 @@ export default function ScenarioForm() {
|
||||
const outcome = submission.data;
|
||||
|
||||
if (submission.ok && outcome.success) {
|
||||
// ── Derive explicit next canonical state (no React-state reread) ──
|
||||
const nextGraph = outcome.updatedSituationGraph;
|
||||
let nextFindings = [...findings];
|
||||
if (outcome.appendedFindings && Array.isArray(outcome.appendedFindings)) {
|
||||
nextFindings = [...nextFindings, ...outcome.appendedFindings];
|
||||
}
|
||||
|
||||
setUpdateStatus("success");
|
||||
setCurrentUnderstanding(
|
||||
outcome.summary ? outcome.summary : currentUnderstanding,
|
||||
);
|
||||
setUpdateResult({
|
||||
...outcome,
|
||||
previousSituationGraph: result?.situationGraph ?? null,
|
||||
});
|
||||
setResult((current) => ({
|
||||
...current,
|
||||
situationGraph: outcome.updatedSituationGraph,
|
||||
situationGraph: nextGraph,
|
||||
selectedQuestion: normaliseUpdateSelectedQuestion(
|
||||
outcome.selectedQuestion,
|
||||
),
|
||||
@@ -371,9 +767,25 @@ export default function ScenarioForm() {
|
||||
.map((node) => node.id),
|
||||
diagnostics: outcome.diagnostics,
|
||||
}));
|
||||
setFindings(nextFindings);
|
||||
|
||||
// ── Coalesced transition: one synthesis per successful update ──
|
||||
void synthesizeFromFindings(fetch, {
|
||||
situationGraph: nextGraph,
|
||||
findings: normalizeFindings(nextFindings),
|
||||
}).then((res) => {
|
||||
if (res.ok && res.data?.currentUnderstanding) {
|
||||
setCurrentUnderstanding(res.data.currentUnderstanding);
|
||||
}
|
||||
// On synthesis failure: graph/Findings already persisted, CU preserved, no retry.
|
||||
});
|
||||
|
||||
setAnswer("");
|
||||
// Persist after successful update turn
|
||||
saveSession({ scenario, situationGraph: outcome.updatedSituationGraph, selectedQuestion: normaliseUpdateSelectedQuestion(outcome.selectedQuestion), summary: outcome.summary ?? currentUnderstanding, updatedAt: new Date().toISOString() });
|
||||
// Persist after successful update turn — include explicit next state
|
||||
/* ── v0.59a — provenance: meaningful change advances revision ── */
|
||||
const nextRev = (investigationRevision ?? 0) + 1;
|
||||
setInvestigationRevision(nextRev);
|
||||
persist({ id: investigationId, scenario, situationGraph: nextGraph, selectedQuestion: normaliseUpdateSelectedQuestion(outcome.selectedQuestion), summary: currentUnderstanding, updatedAt: new Date().toISOString(), focusedContributions, findings: nextFindings, investigationReport, investigationRevision: nextRev });
|
||||
} else {
|
||||
setUpdateStatus("error");
|
||||
setUpdateError(outcome);
|
||||
@@ -386,7 +798,8 @@ export default function ScenarioForm() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{status === "idle" && (
|
||||
{/* ── Idle form for scenario input ─ */}
|
||||
{!result?.situationGraph && status === "idle" && (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
|
||||
{/* Two-column landing workspace */}
|
||||
@@ -419,7 +832,7 @@ export default function ScenarioForm() {
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="dismiss-facilitator" className="text-xs text-gray-500">
|
||||
Don't show this introduction again
|
||||
{`Dismiss this introduction permanently`}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -428,7 +841,7 @@ export default function ScenarioForm() {
|
||||
|
||||
{/* Right panel — Workspace (2/3 on desktop) */}
|
||||
<div className={hideFacilitatorOnLanding ? "md:col-span-3" : "md:col-span-2"}>
|
||||
<h2 className="mb-4 text-xs font-bold tracking-widest uppercase text-gray-400">Tell me what's happening</h2>
|
||||
<h2 className="mb-4 text-xs font-bold tracking-widest uppercase text-gray-400">What's the situation</h2>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={scenario}
|
||||
@@ -505,42 +918,75 @@ export default function ScenarioForm() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Main result workspace ─────────────────────── */}
|
||||
{((status === "success" || status === "error") && status !== "loading") && (
|
||||
<ReasoningWorkspace
|
||||
scenario={scenario}
|
||||
status={status}
|
||||
updateStatus={updateStatus}
|
||||
currentUnderstanding={currentUnderstanding}
|
||||
result={{
|
||||
...(result || {}),
|
||||
situationGraph: updateResult?.updatedSituationGraph ?? result?.situationGraph,
|
||||
selectedQuestion: updateResult?.selectedQuestion ?? result?.selectedQuestion,
|
||||
newlySurfacedNodeIds: result?.newlySurfacedNodeIds || [],
|
||||
diagnostics: result?.diagnostics || null,
|
||||
updateError,
|
||||
}}
|
||||
answer={answer}
|
||||
setAnswer={setAnswer}
|
||||
onAnswerSubmit={handleUpdate}
|
||||
lastSubmittedAnswer={lastSubmittedAnswer}
|
||||
onRestart={() => {
|
||||
clearSession();
|
||||
setStatus("idle");
|
||||
setResult(null);
|
||||
setAnswer("");
|
||||
setUpdateStatus("idle");
|
||||
setUpdateResult(null);
|
||||
setLastSubmittedAnswer("");
|
||||
setCurrentUnderstanding(null);
|
||||
setUpdateError(null);
|
||||
}}
|
||||
/>
|
||||
{/* ── Main result workspace ─── */}
|
||||
{(status === "success" || status === "error") && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Workspace — uses result from Analyse or Update only */}
|
||||
<div className="lg:col-span-2">
|
||||
<ReasoningWorkspace
|
||||
scenario={scenario}
|
||||
status={status}
|
||||
updateStatus={updateStatus}
|
||||
cuSynthesisLoading={cuSynthesisLoading}
|
||||
currentUnderstanding={currentUnderstanding}
|
||||
/* ── v0.54b — investigation overview transient state ─── */
|
||||
overviewState={overviewState}
|
||||
setOverviewState={setOverviewState}
|
||||
overviewLoading={overviewLoading}
|
||||
handleRequestOverview={handleRequestOverview}
|
||||
onNavigateToReport={onNavigateToReport}
|
||||
result={{
|
||||
...(result || {}),
|
||||
situationGraph: updateResult?.updatedSituationGraph ?? result?.situationGraph,
|
||||
selectedQuestion: updateResult?.selectedQuestion ?? result?.selectedQuestion,
|
||||
newlySurfacedNodeIds: result?.newlySurfacedNodeIds || [],
|
||||
diagnostics: result?.diagnostics || null,
|
||||
updateError,
|
||||
}}
|
||||
answer={answer}
|
||||
setAnswer={setAnswer}
|
||||
onAnswerSubmit={handleUpdate}
|
||||
lastSubmittedAnswer={lastSubmittedAnswer}
|
||||
focusedContributions={focusedContributions}
|
||||
onFocusedContribution={appendFocusedContribution}
|
||||
findings={findings}
|
||||
onUpdateFindingDisposition={updateFindingDisposition}
|
||||
onUpdateFindingProposition={updateFindingProposition}
|
||||
/* ── v0.49 — done-for-now promotion seam ─────────── */
|
||||
onSummaryUpdate={handleDoneForNowPromotion}
|
||||
/* ── immediate graph transition (Done acknowledged before async) ── */
|
||||
onImmediateGraphChange={(nextGraph) => setResult((prev) => ({ ...(prev ?? {}), situationGraph: nextGraph }))}
|
||||
/* ── v0.59a — provenance tracking ─────────────────── */
|
||||
investigationRevision={investigationRevision}
|
||||
onSituationGraphChange={(nextGraph) => {
|
||||
const nextRev = (investigationRevision ?? 0) + 1;
|
||||
setInvestigationRevision(nextRev);
|
||||
setResult((prev) => ({ ...(prev ?? {}), situationGraph: nextGraph }));
|
||||
}}
|
||||
onRestart={() => {
|
||||
restartPersistedInvestigation();
|
||||
setInvestigationRevision(0);
|
||||
setStatus("idle");
|
||||
setResult(null);
|
||||
setAnswer("");
|
||||
setUpdateStatus("idle");
|
||||
setUpdateResult(null);
|
||||
setLastSubmittedAnswer("");
|
||||
setCurrentUnderstanding(null);
|
||||
setUpdateError(null);
|
||||
setFocusedContributions([]);
|
||||
setFindings([]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Continue later banner when session was restored ── */}
|
||||
{status === "success" && result?.updatedAt && (
|
||||
<ContinueLaterBanner onRestart={() => { clearSession(); setStatus("idle"); setResult(null); setAnswer(""); setUpdateStatus("idle"); setCurrentUnderstanding(null); }} />
|
||||
<ContinueLaterBanner onRestart={() => { restartPersistedInvestigation(); setInvestigationRevision(0); setStatus("idle"); setResult(null); setAnswer(""); setUpdateStatus("idle"); setCurrentUnderstanding(null); setFocusedContributions([]); setFindings([]); }} />
|
||||
)}
|
||||
|
||||
{/* Reset button after successful analysis */}
|
||||
@@ -548,7 +994,8 @@ export default function ScenarioForm() {
|
||||
<div className="text-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
clearSession();
|
||||
restartPersistedInvestigation();
|
||||
setInvestigationRevision(0);
|
||||
setScenario("");
|
||||
setStatus("idle");
|
||||
setResult(null);
|
||||
@@ -558,6 +1005,8 @@ export default function ScenarioForm() {
|
||||
setLastSubmittedAnswer("");
|
||||
setCurrentUnderstanding(null);
|
||||
setUpdateError(null);
|
||||
setFocusedContributions([]);
|
||||
setFindings([]);
|
||||
}}
|
||||
className="rounded-lg border border-gray-200/60 px-4 py-2 text-sm font-medium text-gray-500 transition hover:bg-gray-50/80"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -211,6 +211,160 @@ Given the useful investigation structure the Engine can already derive, how shou
|
||||
|
||||
The next phase should begin from this methodology question, not from a preselected technical solution.
|
||||
|
||||
## Granular Answer-Fragment Learning (RTO.14–17)
|
||||
|
||||
Recent experiments explored what happens when further answers are made inside the same focused investigation (RTO.14–17).
|
||||
|
||||
### What RTO.14–17 proved
|
||||
|
||||
The experiments demonstrated that an LLM can:
|
||||
|
||||
- Retain prior focused knowledge across turns
|
||||
- Revise uncertainty in response to new information
|
||||
- Separate focused understanding from decision significance
|
||||
- Carry coherent reasoning across several turns inside a single investigation
|
||||
|
||||
This learning was valuable and should be preserved as experimental evidence. The apparatus created during RTO.14–17 remains available and relevant.
|
||||
|
||||
### What RTO.14–17 began recreating
|
||||
|
||||
Pushing that design further exposed that we had reproduced the original structural assumption at a lower level:
|
||||
|
||||
- **Original global pattern:**
|
||||
```text
|
||||
whole case state + new answer → LLM rewrites whole case state
|
||||
```
|
||||
- **Focused version (RTO.14–17):**
|
||||
```text
|
||||
whole focused-investigation state + new answer → LLM rewrites whole focused-investigation state
|
||||
```
|
||||
|
||||
The second version is much smaller and technically better, but it is still the same cumulative reconstruction pattern — just at a lower scope. Prompt growth from later RTO experiments helped expose this.
|
||||
|
||||
**Learning:** Do not immediately respond by optimising or compressing the cumulative focused-state implementation. Reconsider whether accumulated state needs to be sent back through the LLM at all.
|
||||
|
||||
### The granular answer-fragment hypothesis (working hypothesis — not yet architecture)
|
||||
|
||||
The natural reasoning unit appears to be:
|
||||
|
||||
> **one question → one answer → one interpretation/capture**
|
||||
|
||||
Granularity's purpose is not merely token or latency optimisation. The small cycle is how the methodology makes a large problem manageable for the user. A difficult scenario is progressively decomposed into pieces small enough to reason about confidently.
|
||||
|
||||
The working hypothesis is:
|
||||
|
||||
```text
|
||||
user chooses a question
|
||||
→ user provides an answer
|
||||
→ Engine deconstructs that answer
|
||||
→ Engine captures the granular contribution
|
||||
→ resulting uncertainties/questions are exposed
|
||||
→ user chooses what to investigate next
|
||||
→ repeat
|
||||
```
|
||||
|
||||
Each accepted answer can produce a small evidence-bearing reasoning fragment. Those fragments are remembered outside the LLM call. The larger investigation understanding and eventual graph emerge from composing those pieces over time. Only directly relevant prior knowledge may need to be supplied when a specific earlier fragment is being qualified, contradicted or refined.
|
||||
|
||||
A software implementation may eventually represent granular contributions as things such as:
|
||||
|
||||
- observations
|
||||
- uncertainties
|
||||
- assumptions
|
||||
- relationships
|
||||
- questions raised
|
||||
|
||||
linked to the question/investigation that produced them. This illustrative list is not a production schema — it exists here only as a design hint.
|
||||
|
||||
### Memory / graph principle
|
||||
|
||||
The LLM does not necessarily need to own accumulated reasoning memory. The graph/state/notebook layer can remember the reasoning fragments. The LLM may be used to interpret a new answer, but a software implementation should not assume every new answer requires sending all accumulated investigation state back through the model and asking it to regenerate the whole current understanding.
|
||||
|
||||
### Optional capability: "Help me answer" / "Answer for me"
|
||||
|
||||
A software implementation may optionally offer something like:
|
||||
|
||||
> **Help me answer** or **Answer for me**
|
||||
|
||||
where the LLM proposes an answer. This is an optional application capability — not part of the core method. The methodology works without it.
|
||||
|
||||
**Ownership rule:** A generated answer is a proposal, not gospel and not automatically evidence. The user must be able to accept it, edit it or reject it. Only an accepted contribution enters the normal reasoning/deconstruction flow. Where practical, provenance should remain distinguishable between:
|
||||
|
||||
- user-supplied answer
|
||||
- LLM-proposed answer accepted/edited by user
|
||||
|
||||
### Development principle reaffirmed: BUILD → BREAK → LEARN → STOP
|
||||
|
||||
When an experiment exposes that an architectural assumption is breaking:
|
||||
|
||||
```text
|
||||
do not immediately optimise the broken assumption
|
||||
do not add complexity to preserve it
|
||||
capture what was learned
|
||||
return to the methodology
|
||||
design the next smallest experiment from that learning
|
||||
```
|
||||
|
||||
RTO.14–17 should therefore remain valuable evidence, not be deleted or described as mistakes. They helped reveal the next underlying assumption.
|
||||
|
||||
## Methodology test for future development
|
||||
|
||||
> **Could this reasoning operation be described in the Confidence Engine methodology and performed by a trained human facilitator without an LLM?**
|
||||
|
||||
- If YES: the application may use an LLM to automate, accelerate or scale it
|
||||
- If NO: stop and ask whether the work is developing the Confidence Engine methodology or merely exploiting an LLM capability
|
||||
|
||||
This does not apply to implementation mechanics such as JSON, APIs or databases. It applies to the underlying reasoning behaviour.
|
||||
|
||||
## Recent experimental evidence supporting methodological principles (2026-08-18/19)
|
||||
|
||||
The following experiments provide specific evidence for the durable methodology principles
|
||||
documented in `docs/current-working-principles.md`. Each is recorded as one data point, not generalisation.
|
||||
|
||||
### RTO.18 — Independent granular question/answer deconstruction (without accumulated state)
|
||||
|
||||
Independent per-turn question and answer deconstruction worked when each turn received only its own
|
||||
question + answer, without any accumulated focused state from previous turns. This supports:
|
||||
|
||||
- **A3** (reasoning on meaning, not accumulated vocabulary)
|
||||
- **A6** (non-linear investigation via independent fragments)
|
||||
- **The granular answer-fragment hypothesis** as a working direction
|
||||
|
||||
### RTO.20 — Narrow derived current view from selected fragments + known relationship
|
||||
|
||||
A narrow, derived current understanding state worked when computed from selected fragments combined with known structural relationships rather than full-graph reconstruction. This supports:
|
||||
|
||||
- **A5** (deterministic structure for identity/storage; semantic interpretation only where needed)
|
||||
- **A10** (progressive disclosure of relevant reasoning to the user)
|
||||
|
||||
### RTO.21 — Semantic relationship discovery: one genuine positive case
|
||||
|
||||
Semantic interpretation found one genuine cross-fragment relationship from two fragments alone. The operation correctly identified that two contributions meaningfully related without prior keyword dictionary matching. This supports:
|
||||
|
||||
- **A4** (semantic interpretation as a suitable facilitation capability)
|
||||
- **A3** (meaning-based over vocabulary-based reasoning)
|
||||
|
||||
### RTO.22 — Semantic relationship discovery: one obvious negative case (control)
|
||||
|
||||
The same semantic operation correctly returned no relationship for one obviously unrelated pair of contributions. This supports:
|
||||
|
||||
- **A4** (semantic interpretation is useful but produces proposals, not decisions)
|
||||
- **A3** (meaning-based reasoning does not produce false positives at high rates on obvious cases)
|
||||
|
||||
### RTO.23 — Current apparatus work
|
||||
|
||||
RTO.23 apparatus development is ongoing. No live experimental evidence exists for RTO.23 yet.
|
||||
|
||||
---
|
||||
|
||||
## Methodology principles reinforced by this evidence
|
||||
|
||||
The experiments above support (without proving) the following durable methodology boundaries:
|
||||
|
||||
- **Delivery-platform independence** (A1): all results were observed through a software delivery path, but the reasoning operations described (question deconstruction, relationship inference, fragment composition) are equally performable by a human facilitator.
|
||||
- **Meaning over dictionary** (A3/A4): RTO.21 and RTO.22 together suggest semantic interpretation can produce both true-positive and true-negative relationship proposals without keyword scoring — but two data points do not establish reliability. The guardrail remains: treat all inferred relationships as proposals until handled per the delivery method.
|
||||
- **Non-linear investigation** (A6/A7): independent fragment processing validates that reasoning can proceed asynchronously across branches without blocking the user.
|
||||
- **Progressive disclosure** (A10): RTO.20 demonstrates that a derived narrow view from relevant fragments is more useful to the user than a full-graph reconstruction of everything known.
|
||||
|
||||
## Source basis
|
||||
|
||||
- `01_Confidence_Engine_Founding_Principles`
|
||||
@@ -224,4 +378,11 @@ The next phase should begin from this methodology question, not from a preselect
|
||||
- `Confidence_Engine_Project_Context_Update_2026-08-17`
|
||||
- `Confidence_Engine_Current_Handoff_2026-08-17`
|
||||
|
||||
This context update distinguishes established project principles from current implementation learning. The workspace/user-directed investigation model is recorded as the current hypothesis to test, not as a completed replacement architecture.
|
||||
> **Provenance note:** Some source-basis documents listed above were external
|
||||
> project/session context supplied during the methodology work and are not
|
||||
> repository-managed files. They informed this document's content but cannot be
|
||||
> verified as originating from the Git history of this repository. Their role
|
||||
> is to document where the methodology context came from, not to assert Git
|
||||
> provenance for those external documents.
|
||||
|
||||
This context update distinguishes established project principles from current implementation learning. The workspace/user-directed investigation model is recorded as the current hypothesis to test, not as a completed replacement architecture. The granular answer-fragment hypothesis (RTO.14–17) is recorded as working hypothesis, not yet accepted architecture.
|
||||
@@ -15,6 +15,30 @@ All files below were moved from `docs/` on 2026-08-06 by Experiment 29 to reduce
|
||||
| `docs/v0.7-observation-report.md` (136 lines) | `docs/archive/v0.7-observation-report.md` | Experimental observation snapshot from v0.7 UX work. | Useful as a reference but not a current working document. UX work is paused. | When reviewing past UX observations that may inform future interface design decisions. |
|
||||
| `docs/archive/deferred-ux-backlog.md` (376 lines) | `docs/archive/deferred-ux-backlog.md` | Deferred and exploratory UX ideas from original `docs/backlog info.md` (lines 21–390). Retained for historical reference. Not commitments, priorities or active tasks. | Superseded `docs/backlog info.md`. Deferred UX planning separated from mock reference in Experiment 31. | When a named past UX idea from the deferred backlog is being reviewed; not loaded by default. |
|
||||
|
||||
## Phase 2B Experiment Archives (2026-08-19)
|
||||
|
||||
All files below were classified `HISTORICAL_EVIDENCE + SAFE` during the Phase 1B/2B context audit and moved to reduce default reading burden while preserving full traceability. They are preserved evidence — not discarded, obsolete, or invalidated. Load only when a specific historical question requires them.
|
||||
|
||||
| Subdirectory | What Was Moved | Count |
|
||||
|---|---|---|
|
||||
| `docs/archive/experiments/reasoning-fidelity-v0.8/` | Experiment 56 family (reasoning-fidelity v0.8 pass) | 11 files (experiment-56a–m, excluding c) |
|
||||
| `docs/archive/experiments/semantic-action-contract/` | Experiment 58 family (semantic action contract) | 8 files (experiment-58a1–a6, b1–b2) |
|
||||
| `docs/archive/experiments/question-formulation/` | Experiment 59 family (question formulation) | 7 files (experiment-59a1–a3, b1–b4) |
|
||||
| `docs/archive/experiments/decision-options/` | Experiment 60A family (decision options analysis) | 7 files (experiment-60a1–8, excluding a3) |
|
||||
| `docs/archive/experiments/decision-closure-integration/` | Experiment 60B subfamilies {10–15}, {55–82}, {95,97,100} | 35 files (experiment-60b{10-15}, {55-56,58-82}, {95,97,100}) |
|
||||
| `docs/archive/experiments/knowledge-mgmt/` | Cold-start validation historical evidence | 1 file (cold-start-validation.md) |
|
||||
| `docs/archive/experiments/context-routing/` | Document-role review (classification/routing analysis) | 1 file (document-role-review.md) |
|
||||
| `docs/archive/experiments/pre-RTO/` | Pre-Return-to-Origin experiments and version-specific docs: v0.5–v0.7 | 7 files (pre-RTO experiments + release notes/UX pass) |
|
||||
|
||||
**Not moved in Phase 2B:** checkpoint-60b93.md, docs/design-evolution-log.md, docs/investigation-state-assessment*.md, architectural-principles.md, v0.6-reasoning-architecture.md, success-signals.md, failure-modes.md, investigation-narrative.md, behaviour-selection.md, orchestrator-contract.md, reasoning-contract-backlog.md, reasoning-refinement-requirements.md, reasoning-production-path-map.md.
|
||||
|
||||
**Phase 2D experiment archives (2026-08-19):** After Phase 2C carry-forward verification confirmed all three families SAFE for archival:
|
||||
|
||||
| Subdirectory | What Was Moved | Count |
|
||||
|---|---|---|
|
||||
| `docs/archive/experiments/post-v0.8-investigation/` | Experiment 57 family (post-v0.8 investigation) | 69 files (experiment-57* family) |
|
||||
| `docs/archive/experiments/decision-closure-integration/` | Experiment 60B subfamilies {1–8}, {19–48} | 37 files (experiment-60b{1-8}, experiment-60b{19-48}) |
|
||||
|
||||
## Superseded Files
|
||||
|
||||
The following files were superseded by a structured split in Experiment 31 and are no longer in use. Their contents remain fully represented in the documents below.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user