Files
confidence-engine/components/reasoning-workspace.jsx
T

720 lines
28 KiB
React

"use client";
import React, { useState, useRef, useEffect, useMemo } from "react";
import DiagnosticsView from "@/components/diagnostics-view";
import GraphUpdateView from "@/components/graph-update-view";
import SituationGraphView from "@/components/situation-graph-view";
// ── Technical summary detector (main view filters these) ───
const TECHNICAL_PATTERNS = [
/nodes?\s*[:\d]/i,
/edges?\s*[:\d]/i,
/\b(?:unknown|observation|conclusion)\b\s/i,
/\bsorted\b/i,
/by_kind/i,
/\b(?:node|edge|unknown|state)\s+count/i,
];
function isTechnicalSummary(summary) {
if (!summary || typeof summary !== "string") return false;
const trimmed = summary.trim();
if (!trimmed) return false;
for (const p of TECHNICAL_PATTERNS) {
if (p.test(trimmed)) return true;
}
return false;
}
function resolveCurrentSummary(currentSummary) {
if (isTechnicalSummary(currentSummary)) {
return null;
}
return currentSummary || null;
}
// ── Status message pools for loading feedback ────────────────
const INITIAL_MESSAGES = [
{ min: 0, text: "Reading your situation" },
{ min: 10, text: "Building a structured understanding" },
{ min: 25, text: "Identifying what is known and still unclear" },
{ min: 45, text: "Selecting the next useful question" },
];
const UPDATE_MESSAGES = [
{ min: 0, text: "Considering your answer" },
{ min: 10, text: "Updating the situation" },
{ min: 25, text: "Checking what changed" },
{ min: 45, text: "Choosing the next question" },
];
const REVISION_MESSAGES = [
{ min: 0, text: "Reading what changed" },
{ min: 10, text: "Rebuilding the investigation" },
{ min: 25, text: "Checking what this affects" },
{ min: 45, text: "Choosing the next justified question" },
];
function useLoadingStatus(messages, isLoading) {
const [elapsed, setElapsed] = useState(0);
const startRef = useRef(null);
useEffect(() => {
if (isLoading) {
startRef.current = Date.now();
const iv = setInterval(() => {
setElapsed(Math.floor((Date.now() - startRef.current) / 1000));
}, 1000);
return () => clearInterval(iv);
} else {
setElapsed(0);
startRef.current = null;
}
}, [isLoading]);
const currentMessage = useMemo(() => {
if (!messages || messages.length === 0) return "";
let msg = messages[0].text;
for (const m of messages) {
if (elapsed >= m.min) msg = m.text;
}
return msg;
}, [messages, elapsed]);
return { elapsed, currentMessage };
}
// ── Revision warning banner ─────────────────────────────────
function RevisionWarningCard() {
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-5 py-3 text-sm text-amber-900">
Changing this answer may alter the questions and conclusions that followed it.
</div>
);
}
// ── Revision inline editor ────────────────────────────────────
function RevisionEditor({ turn, onSave, onCancel }) {
const [draft, setDraft] = useState(turn.answer);
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-5 py-4 space-y-3">
<RevisionWarningCard />
<p className="text-sm font-medium text-amber-900">{turn.question}</p>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
rows={3}
className="w-full rounded-lg border border-amber-300 bg-white px-4 py-2 text-sm focus:border-amber-400 focus:outline-none"
/>
<div className="flex gap-3">
<button
type="button"
onClick={() => onSave(draft)}
disabled={!draft.trim()}
className="rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 disabled:opacity-40"
>
Save revised answer
</button>
<button
type="button"
onClick={onCancel}
className="text-sm text-amber-700 underline hover:text-amber-900"
>
Cancel
</button>
</div>
</div>
);
}
// ── Stale turn summary (shown under "Earlier answers that may need reviewing") ───
function StaleTurnSummary({ turn, index }) {
return (
<div className="rounded-lg border border-red-100 bg-red-50/40 px-4 py-2 text-sm text-red-800">
<span className="font-medium">Earlier answer #{index + 1}:</span> {turn.question} "{turn.answer}" may no longer apply.
</div>
);
}
// ── Spinner component ───────────────────────────────────────
function ActivitySpinner() {
return (
<span
className="inline-block h-4 w-4 border-[2px] border-gray-300 border-t-gray-600 rounded-full"
style={{ animation: "spin 1s linear infinite" }}
/>
);
}
// ── Situation card ────────────────────────────────────────────
function SituationCard({ centralStatement }) {
if (!centralStatement) return null;
return (
<div className="investigation-card rounded-lg border border-gray-200 bg-white p-5">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-gray-500">
Your situation
</h2>
<p className="text-base leading-relaxed text-gray-900">{centralStatement}</p>
</div>
);
}
// ── Current understanding card ────────────────────────────────
function CurrentUnderstanding({ currentSummary }) {
const summary = resolveCurrentSummary(currentSummary);
return (
<div className="investigation-card rounded-lg border border-gray-200 bg-white p-5">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500">
What we&#x27;ve established
</h2>
{summary ? (
<p className="text-sm leading-relaxed text-gray-700">{summary}</p>
) : (
<p className="text-sm leading-relaxed text-gray-600">
We have separated what is known from what still needs checking.
</p>
)}
</div>
);
}
// ── Current investigation card (prominent) ─────────────────────
function CurrentInvestigationCard({ selectedQuestion, graph }) {
if (!selectedQuestion) return null;
const q = typeof selectedQuestion === "string" ? selectedQuestion : selectedQuestion.question;
if (!q) return null;
const activeNode = graph?.activeUnknownNodeId
? graph.nodes.find((n) => n.id === graph.activeUnknownNodeId)
: null;
return (
<div className="investigation-card rounded-lg border-2 border-green-300 bg-green-50 p-6">
<h2 className="mb-2 text-sm font-bold uppercase tracking-wide text-green-700">
Current investigation
</h2>
<p className="text-xl font-semibold leading-snug text-gray-900">{q}</p>
{activeNode?.description && activeNode.description !== activeNode.label && (
<div className="mt-4 space-y-1">
<h3 className="text-xs font-bold uppercase tracking-wide text-green-800">Why we are asking this</h3>
<p className="text-sm text-gray-700">{activeNode.description}</p>
</div>
)}
{graph?.activeUnknownNodeId && activeNode && (
<div className="mt-2 space-y-1">
<h3 className="text-xs font-bold uppercase tracking-wide text-green-800">What we are investigating</h3>
<p className="text-sm text-gray-700">
Understanding whether "{activeNode.label}" affects the confidence in this situation.
</p>
</div>
)}
</div>
);
}
// ── Outcome helpers ───────────────────────────────────────────
function hasGenuineCompletion(graph) {
if (!graph || !graph.nodes?.length) return false;
const resolvedIds = new Set(graph.resolvedNodeIds || []);
const unresolvedCount = graph.nodes.filter(
(n) => n.kind === "unknown" && n.status !== "resolved" && !resolvedIds.has(n.id),
).length;
if (unresolvedCount > 0) return false;
if (graph.activeUnknownNodeId) {
const active = graph.nodes.find((n) => n.id === graph.activeUnknownNodeId);
if (active && active.status !== "resolved" && !resolvedIds.has(active.id)) return false;
}
return true;
}
// ── Current focus card ───────────────────────────────────────
function CurrentFocusCard({ graph }) {
const activeNode = graph?.activeUnknownNodeId
? graph.nodes.find((n) => n.id === graph.activeUnknownNodeId)
: null;
if (!graph || !activeNode) return null;
return (
<div className="rounded-lg border border-gray-200 bg-gray-50 px-5 py-4">
<h3 className="mb-1 text-xs font-bold uppercase tracking-wide text-gray-500">
Current focus
</h3>
<p className="text-sm leading-relaxed text-gray-700">
We are investigating one part of your situation at a time.
{activeNode && (
<>
<br />
Right now we are trying to understand{" "}
<strong>{activeNode.label}</strong>.
</>
)}
</p>
</div>
);
}
// ── Investigation history card ────────────────────────────────
function InvestigationHistoryCard({ turn, onReview }) {
const hasReview = !onReview && turn.status === "current";
return (
<div className="rounded-lg border border-gray-100 bg-gray-50/60 px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs text-gray-400">
{new Date(turn.timestamp).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
<details className="mt-1">
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-wide text-gray-400 hover:text-gray-600">
{turn.question}
</summary>
<div className="mt-2 space-y-2 text-sm">
<p><strong>You told us:</strong> {turn.answer}</p>
{turn.engineResponse && (
<p className="italic text-gray-500">{turn.engineResponse}</p>
)}
</div>
</details>
</div>
{hasReview && (
<button
type="button"
onClick={onReview}
className="shrink-0 rounded border border-gray-300 bg-white px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900"
>
Review answer
</button>
)}
</div>
</div>
);
}
// ── Investigation history section ─────────────────────────────
function InvestigationHistory({ turns, onReviewTurn }) {
if (!turns || turns.length === 0) return null;
const activeTurns = turns.filter((t) => t.status !== "superseded");
if (activeTurns.length === 0) return null;
return (
<div className="space-y-3">
<h2 className="text-xs font-bold uppercase tracking-wider text-gray-400">
Investigation history
</h2>
<div className="space-y-2">
{activeTurns.map((turn, idx) => (
<InvestigationHistoryCard key={turn.id ?? idx} turn={turn} onReview={turn.status === "current" ? () => onReviewTurn?.(idx) : undefined} />
))}
</div>
</div>
);
}
// ── Investigation complete state ───────────────────────────────
function InvestigationCompleteMessage({ noQuestionReason }) {
let message = "We have established enough for now.";
if (noQuestionReason) {
const reason = String(noQuestionReason);
if (
reason.toLowerCase().includes("resolved") ||
reason.toLowerCase().includes("satisfied") ||
reason.toLowerCase().includes("complete")
) {
message = "You have provided enough information. The situation has been fully investigated.";
} else if (reason.toLowerCase().includes("insufficient")) {
message = "There is not yet enough evidence to guide the next step. Your original situation will remain our focus when new information becomes available.";
} else {
message = reason;
}
}
return (
<div className="rounded-lg border border-gray-200 bg-gray-50 px-5 py-4 text-center transition-opacity duration-300">
<p className="text-sm text-gray-600">{message}</p>
</div>
);
}
// ── Loading overlay (for both start and update) ───────────────
function LoadingOverlay({ isLoading, elapsed, currentMessage, variant }) {
if (!isLoading) return null;
const messages = variant === "update" ? UPDATE_MESSAGES : INITIAL_MESSAGES;
let statusText = messages[0].text;
for (const m of messages) {
if (elapsed >= m.min) statusText = m.text;
}
return (
<div className="rounded-lg border border-gray-200 bg-blue-50 px-5 py-6" role="status" aria-busy="true">
<div className="flex items-center gap-3">
<ActivitySpinner />
<span className="text-base font-medium text-blue-900">Working through your situation</span>
</div>
<p className="mt-2 text-sm text-blue-700">{statusText}</p>
<p className="mt-1 text-xs text-blue-500" aria-live="polite">
This has been running for {elapsed}s.
{variant === "initial" && elapsed >= 45 && (
<span className="block mt-1">This can take around a minute with the current local model.</span>
)}
</p>
</div>
);
}
// ── Update acknowledgement ────────────────────────────────────
function UpdateAcknowledgement({ updateResult }) {
if (!updateResult) return null;
const summary = updateResult.summary;
const hasResolvedNodes = updateResult.resolvedUnknownNodeIds?.length > 0;
const hasAffectedNodes = updateResult.affectedNodeIds?.length > 0;
const graph = updateResult.updatedSituationGraph;
function getNodeText(nodeId) {
if (!graph?.nodes) return String(nodeId);
const node = graph.nodes.find((n) => n.id === nodeId);
if (node) {
const parts = [node.label];
if (node.status !== "resolved") {
parts.push(node.status);
}
return parts.join(" ");
}
return String(nodeId);
}
let changedText;
if (hasResolvedNodes) {
const items = [];
for (const id of updateResult.resolvedUnknownNodeIds.slice(0, 3)) {
items.push(getNodeText(id));
}
if (updateResult.resolvedUnknownNodeIds.length > 3) {
items.push(`and ${updateResult.resolvedUnknownNodeIds.length - 3} more resolved`);
}
changedText = items.join(". ") + ".";
} else if (hasAffectedNodes) {
const items = [];
for (const id of updateResult.affectedNodeIds.slice(0, 3)) {
items.push(getNodeText(id));
}
if (updateResult.affectedNodeIds.length > 3) {
items.push(`and ${updateResult.affectedNodeIds.length - 3} more affected`);
}
changedText = items.join(". ") + ".";
} else if (updateResult.changesApplied) {
const ca = updateResult.changesApplied;
const parts = [];
if (ca.addedNodeCount) parts.push(`${ca.addedNodeCount} node(s) added`);
if (ca.updatedNodeCount) parts.push(`${ca.updatedNodeCount} node(s) updated`);
if (ca.addedEdgeCount) parts.push(`${ca.addedEdgeCount} edge(s) added`);
if (ca.removedEdgeCount) parts.push(`${ca.removedEdgeCount} edge(s) removed`);
changedText = parts.length > 0 ? parts.join(", ") : null;
}
const displayMessage = summary || changedText || "Your answer has been added to the investigation.";
return (
<div className="rounded-lg border border-blue-100 bg-blue-50/80 px-5 py-3 text-sm text-blue-900">
{displayMessage}
</div>
);
}
// ── Developer details disclosure ──────────────────────────────
function DeveloperDetails({ graph, selectedQuestion, diagnostics, newlySurfacedNodeIds, updateResult }) {
return (
<details className="rounded-lg border border-gray-200 bg-gray-50">
<summary className="cursor-pointer px-5 py-3 text-sm font-medium text-gray-600 hover:text-gray-800">
Developer details
</summary>
<div className="border-t border-gray-200 px-5 pb-4 pt-3 space-y-4">
{graph && (
<SituationGraphView
situationGraph={graph}
selectedQuestion={selectedQuestion}
newlySurfacedNodeIds={newlySurfacedNodeIds}
/>
)}
{updateResult && (
<GraphUpdateView updateResult={{ ...updateResult, previousSituationGraph: graph }} />
)}
{diagnostics && <DiagnosticsView result={{ diagnostics }} />}
</div>
</details>
);
}
// ── Main workspace component ──────────────────────────────────
export default function ReasoningWorkspace({
status,
updateStatus,
result,
answer,
setAnswer,
onAnswerSubmit,
lastSubmittedAnswer,
isRevisionLoading: propIsRevisionLoading,
}) {
const [investigationHistory, setInvestigationHistory] = useState([]);
// Inline revision editor state (local — no parent re-render until submit)
const [editingTurnIndex, setEditingTurnIndex] = useState(-1);
const [revisionDraft, setRevisionDraft] = useState("");
const [showRevisionEditor, setShowRevisionEditor] = useState(false);
// Track which turn was revised so we can mark stale turns after update succeeds
const revisedTurnIndexRef = useRef(-1);
// Capture the previous question before each new question is set
const prevQuestionRef = useRef(null);
const hasCapturedInitialQuestion = useRef(false);
useEffect(() => {
if (result?.selectedQuestion && !hasCapturedInitialQuestion.current) {
prevQuestionRef.current = result.selectedQuestion;
hasCapturedInitialQuestion.current = true;
}
}, [result?.selectedQuestion]);
// Append completed turn to history after a successful update (normal or revision)
useEffect(() => {
if (updateStatus !== "success" || !lastSubmittedAnswer) return;
const q = prevQuestionRef.current;
setInvestigationHistory((prev) => {
const newTurn = {
id: Date.now(),
question: typeof q === "string" ? q : q?.question ?? "",
answer: lastSubmittedAnswer,
engineResponse: result?.summary || null,
graphBeforeAnswer: result?.situationGraph ? JSON.parse(JSON.stringify(result.situationGraph)) : null,
selectedQuestionBeforeAnswer: q,
timestamp: Date.now(),
status: "current",
};
// If this was a revision, mark the target turn as superseded and all later turns as stale
const revIdx = revisedTurnIndexRef.current;
if (revIdx >= 0) {
return prev.map((t, i) => {
if (i === revIdx) return { ...t, status: "superseded" };
if (i > revIdx) return { ...t, status: t.status === "superseded" ? "superseded" : "stale" };
return t;
}).concat(newTurn);
}
return [...prev, newTurn];
});
revisedTurnIndexRef.current = -1;
}, [updateStatus, lastSubmittedAnswer]);
const isRevisionLoading = propIsRevisionLoading || false;
const { elapsed: startElapsed, currentMessage: startMsg } = useLoadingStatus(
INITIAL_MESSAGES,
status === "loading"
);
const { elapsed: updateElapsed, currentMessage: updateMsg } = useLoadingStatus(
UPDATE_MESSAGES,
updateStatus === "loading"
);
const isUpdating = updateStatus === "loading";
const hasSelectedQuestion = Boolean(result?.selectedQuestion);
const canAnswer =
status === "success" &&
!isUpdating &&
!isRevisionLoading &&
Boolean(result?.situationGraph) &&
hasSelectedQuestion;
const selectedQ = result?.selectedQuestion ?? null;
const graph = result?.situationGraph ?? null;
const diagnostics = result?.diagnostics ?? null;
const newlySurfacedNodeIds = result?.newlySurfacedNodeIds || [];
const noQuestionReason = diagnostics?.noQuestionReason ?? null;
const genuineCompletion = hasGenuineCompletion(graph);
// Filter turns into active and stale groups after revision
const activeTurns = investigationHistory.filter((t) => t.status !== "superseded");
const staleTurns = investigationHistory.filter((t) => t.status === "superseded");
// Open inline revision editor for a specific turn
function openRevisionEditor(idx) {
const turn = investigationHistory[idx];
if (!turn) return;
setEditingTurnIndex(idx);
setRevisionDraft(turn.answer);
setShowRevisionEditor(true);
}
function closeRevisionEditor() {
setEditingTurnIndex(-1);
setShowRevisionEditor(false);
setRevisionDraft("");
}
// Save revised answer — use graph checkpoint from the target turn,
// or fall back to the current situationGraph as a replay starting point.
function saveRevision(draft) {
if (!draft?.trim()) return;
closeRevisionEditor();
const checkpoint = investigationHistory[editingTurnIndex]?.graphBeforeAnswer ?? graph;
revisedTurnIndexRef.current = editingTurnIndex;
setAnswer(draft.trim());
onAnswerSubmit({ preventDefault: () => {} }, { graphCheckpoint: checkpoint, turnIndex: editingTurnIndex });
}
return (
<div className="space-y-5">
{/* ── Loading overlays ─────────────────────────────── */}
<LoadingOverlay
isLoading={status === "loading"}
elapsed={startElapsed}
currentMessage={startMsg}
variant="initial"
/>
{updateStatus === "loading" && (
<div className="h-px bg-gray-100" />
)}
<LoadingOverlay
isLoading={updateStatus === "loading"}
elapsed={updateElapsed}
currentMessage={updateMsg}
variant="update"
/>
{/* ── User-facing workspace ────────────────────────── */}
{(status === "success" || status === "error") && !graph ? (
<div className="rounded-lg border border-yellow-300 bg-yellow-50 px-4 py-3 text-sm text-yellow-800">
{noQuestionReason
? "Validation failed — no structured graph output was produced."
: "The analysis completed but did not produce a structured result."}
</div>
) : (
<>
{/* Post-update acknowledgement */}
{updateStatus === "success" && canAnswer && <UpdateAcknowledgement updateResult={result} />}
{/* Revision editor inline */}
{showRevisionEditor && investigationHistory[editingTurnIndex] && (
<RevisionEditor
turn={investigationHistory[editingTurnIndex]}
onSave={(draft) => saveRevision(draft)}
onCancel={closeRevisionEditor}
/>
)}
{/* Stale turns summary — shown after revision */}
{staleTurns.length > 0 && (
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400">
Earlier answers that may need reviewing
</h3>
{staleTurns.map((turn, idx) => (
<StaleTurnSummary key={turn.id} turn={turn} index={idx} />
))}
</div>
)}
{/* Completion state — suppressed during any loading or revision */}
{status === "success" && !canAnswer && graph && genuineCompletion && !isRevisionLoading && (
<InvestigationCompleteMessage noQuestionReason={noQuestionReason} />
)}
{status === "success" && !canAnswer && graph && !genuineCompletion && updateStatus !== "success" && !isRevisionLoading && (
<div className="rounded-lg border border-gray-200 bg-gray-50 px-5 py-4 text-center">
<p className="text-sm text-gray-600">There is no further question the engine can justify at the moment.</p>
<p className="mt-1 text-xs text-gray-500">More evidence may be needed before a next step is clear.</p>
</div>
)}
{/* Situation context */}
{graph && <SituationCard centralStatement={graph.centralStatement} />}
{graph && <CurrentUnderstanding currentSummary={graph.currentSummary} />}
{/* Active investigation (only when we have a question to answer) */}
{canAnswer && <CurrentInvestigationCard selectedQuestion={selectedQ} graph={graph} />}
{canAnswer && <CurrentFocusCard graph={graph} />}
{/* ── Answer form ──────────────────────────────── */}
{canAnswer && (
<form onSubmit={onAnswerSubmit} className="space-y-4 rounded-lg border border-gray-200 bg-white p-5">
<div>
<label htmlFor="rw-answer" className="mb-2 block text-sm font-medium text-gray-700">
Your response
</label>
<textarea
id="rw-answer"
value={answer}
onChange={(e) => setAnswer(e.target.value)}
rows={4}
disabled={updateStatus === "loading"}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400 disabled:cursor-not-allowed disabled:opacity-60"
placeholder="Enter the answer to the selected question..."
/>
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-gray-400">
{updateStatus === "loading" ? "Updating..." : "One update turn only in this prototype."}
</p>
<button
type="submit"
disabled={updateStatus === "loading" || !answer.trim()}
className="rounded-lg bg-blue-700 px-5 py-2 text-sm font-medium text-white transition hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
>
{updateStatus === "loading" ? "Updating..." : "Update situation"}
</button>
</div>
</form>
)}
{/* ── Investigation history (below the answer form) ─ */}
<InvestigationHistory turns={activeTurns} onReviewTurn={openRevisionEditor} />
{/* ── Developer details (collapsed by default) ─── */}
{(status === "success" || status === "error") && graph && (
<DeveloperDetails
graph={graph}
selectedQuestion={selectedQ}
diagnostics={diagnostics}
newlySurfacedNodeIds={newlySurfacedNodeIds}
updateResult={updateStatus === "success" ? result : null}
/>
)}
</>
)}
{/* ── Errors (always visible above debug) ─────────── */}
{(status === "error" || updateStatus === "error") && (
<div className="space-y-3">
{status === "error" && result?.error && (
<div className="rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700 whitespace-pre-wrap">
Error: {result.error}
</div>
)}
{updateStatus === "error" && result?.updateError && (
<div className="rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
Update error: {result.updateError.error || JSON.stringify(result.updateError)}
</div>
)}
</div>
)}
</div>
);
}
export { useLoadingStatus, INITIAL_MESSAGES, UPDATE_MESSAGES, REVISION_MESSAGES, LoadingOverlay, resolveCurrentSummary, isTechnicalSummary };