diff --git a/app/api/cases/start/route.js b/app/api/cases/start/route.js new file mode 100644 index 0000000..ac8ad6b --- /dev/null +++ b/app/api/cases/start/route.js @@ -0,0 +1,38 @@ +import { startCase } from "@/lib/graph/orchestrator.js"; + +export async function POST(request) { + try { + const body = await request.json(); + const result = await startCase(body); + + if (result.success) { + return Response.json(result, { status: 200 }); + } + + const status = + result.statusCode === 400 + ? 400 + : result.statusCode >= 500 + ? result.statusCode + : 500; + + return Response.json( + { + success: false, + error: result.error ?? "Start case failed", + validationErrors: result.validationErrors, + diagnostics: result.diagnostics, + analysisErrors: result.analysisErrors, + }, + { status }, + ); + } catch { + return Response.json( + { + success: false, + error: "Internal server error", + }, + { status: 500 }, + ); + } +} diff --git a/app/api/cases/update/route.js b/app/api/cases/update/route.js new file mode 100644 index 0000000..adcc5af --- /dev/null +++ b/app/api/cases/update/route.js @@ -0,0 +1,68 @@ +import { updateCase } from "@/lib/graph/orchestrator.js"; + +function mapFailureStatus(result) { + switch (result?.stage) { + case "request_validation": + case "graph_validation": + return 400; + case "provider": + return 502; + case "proposal_validation": + case "proposal_compatibility": + case "application": + return 422; + case "result_validation": + return 500; + default: + return 500; + } +} + +function buildFailureResponse(result) { + return { + success: false, + stage: result?.stage ?? "internal", + error: result?.error ?? "Update case failed", + validationErrors: result?.validationErrors, + graphValidationErrors: result?.graphValidationErrors, + proposalErrors: result?.proposalErrors, + providerErrors: result?.providerErrors, + errors: result?.errors, + diagnostics: result?.diagnostics, + }; +} + +export async function POST(request) { + try { + const body = await request.json(); + const result = await updateCase(body, { applyProposal: true }); + + if (result.success) { + return Response.json(result, { status: 200 }); + } + + return Response.json(buildFailureResponse(result), { + status: mapFailureStatus(result), + }); + } catch (error) { + if (error instanceof SyntaxError) { + return Response.json( + { + success: false, + stage: "request_validation", + error: "Invalid JSON request body", + }, + { status: 400 }, + ); + } + + return Response.json( + { + success: false, + stage: "internal", + error: "Internal server error", + }, + { status: 500 }, + ); + } +} diff --git a/components/diagnostics-view.jsx b/components/diagnostics-view.jsx index 60a5d05..8049c26 100644 --- a/components/diagnostics-view.jsx +++ b/components/diagnostics-view.jsx @@ -1,3 +1,5 @@ +import React from "react"; + const ValidationIndicator = ({ status }) => { const styles = { valid: "text-green-600", @@ -27,23 +29,66 @@ const validationIcons = { export default function DiagnosticsView({ result }) { if (!result) return null; + const diagnostics = result.diagnostics || result; + const metrics = [ - { label: "Model", value: result.modelName || "?" }, + { label: "Model", value: diagnostics.modelName || result.modelName || "?" }, { label: "Provider", value: "Ollama" }, - { label: "Prompt version", value: result.promptVersion || "?" }, + { + label: "Prompt version", + value: diagnostics.promptVersion || result.promptVersion || "?", + }, { label: "Duration", value: - result.responseDurationMs != null - ? `${result.responseDurationMs}ms` + diagnostics.responseDurationMs != null + ? `${diagnostics.responseDurationMs}ms` : "?", }, { label: "Validation", value: ( - + ), }, + { + label: "Node count", + value: + diagnostics.nodeCount != null + ? diagnostics.nodeCount + : diagnostics.graphNodeCount != null + ? diagnostics.graphNodeCount + : "?", + }, + { + label: "Edge count", + value: + diagnostics.edgeCount != null + ? diagnostics.edgeCount + : diagnostics.graphEdgeCount != null + ? diagnostics.graphEdgeCount + : "?", + }, + { + label: "Graph references", + value: + diagnostics.graphReferenceValidation == null + ? "?" + : diagnostics.graphReferenceValidation.valid + ? `${validationIcons.valid} valid` + : `${validationIcons.invalid} invalid`, + }, + ]; + + const errors = [ + ...(result.errors || []), + ...(result.validationErrors || []), + ...(result.graphValidationErrors || []), + ...(result.proposalErrors || []), + ...(result.providerErrors || []), + ...(result.analysisErrors || []), ]; return ( @@ -72,14 +117,14 @@ export default function DiagnosticsView({ result }) { )} {/* Errors if present */} - {result.errors && result.errors.length > 0 && ( + {errors.length > 0 && (
- Validation errors ({result.errors.length}) + Validation errors ({errors.length})
    - {result.errors.map((err, i) => ( -
  • {err}
  • + {errors.map((err, i) => ( +
  • {typeof err === "string" ? err : err?.message || JSON.stringify(err)}
  • ))}
diff --git a/components/graph-update-view.jsx b/components/graph-update-view.jsx new file mode 100644 index 0000000..78f9bcd --- /dev/null +++ b/components/graph-update-view.jsx @@ -0,0 +1,169 @@ +import React from "react"; + +function ListSection({ title, items, renderItem = (item) => item }) { + if (!items?.length) return null; + + return ( +
+

{title}

+
    + {items.map((item, index) => ( +
  • {renderItem(item)}
  • + ))} +
+
+ ); +} + +export default function GraphUpdateView({ updateResult }) { + if (!updateResult?.proposal) return null; + + const { + resolvedUnknownNodeIds, + affectedNodeIds, + previousActiveUnknownNodeId, + newActiveUnknownNodeId, + changesApplied, + proposal, + previousSituationGraph, + updatedSituationGraph, + } = updateResult; + + const previousNodesById = new Map( + (previousSituationGraph?.nodes || []).map((node) => [node.id, node]), + ); + const updatedNodesById = new Map( + (updatedSituationGraph?.nodes || []).map((node) => [node.id, node]), + ); + const proposalUpdatesByNodeId = new Map( + (proposal.updatedNodes || []).map((update) => [update.nodeId, update]), + ); + + function resolveNodePresentation(nodeId) { + const previousNode = previousNodesById.get(nodeId) || null; + const updatedNode = updatedNodesById.get(nodeId) || null; + const node = updatedNode || previousNode; + const update = proposalUpdatesByNodeId.get(nodeId) || null; + + if (!node) { + return ( +
+
Unknown node (ID: {nodeId})
+
+ ); + } + + return ( +
+
{node.label}
+
+ {node.kind} · {node.confidence} +
+ {(update?.previousStatus || update?.newStatus || node.status) && ( +
+ {update?.previousStatus ? `Previous status: ${update.previousStatus}` : null} + {update?.previousStatus && update?.newStatus ? " → " : null} + {update?.newStatus + ? `New status: ${update.newStatus}` + : !update?.previousStatus + ? `Status: ${node.status}` + : null} +
+ )} + {update?.reason &&
{update.reason}
} +
+ ); + } + + function resolveActiveUnknown(nodeId) { + if (!nodeId) return null; + + const node = updatedNodesById.get(nodeId) || previousNodesById.get(nodeId); + if (!node) { + return `Unknown node (ID: ${nodeId})`; + } + + return `${node.label} · ${node.status} · ${node.confidence}`; + } + + const changeItems = [ + changesApplied?.addedNodeCount + ? `${changesApplied.addedNodeCount} node(s) added` + : null, + changesApplied?.updatedNodeCount + ? `${changesApplied.updatedNodeCount} node(s) updated` + : null, + changesApplied?.addedEdgeCount + ? `${changesApplied.addedEdgeCount} edge(s) added` + : null, + changesApplied?.removedEdgeCount + ? `${changesApplied.removedEdgeCount} edge(s) removed` + : null, + changesApplied?.resolvedUnknownCount + ? `${changesApplied.resolvedUnknownCount} unknown(s) resolved` + : null, + ].filter(Boolean); + + return ( +
+
+

+ Graph update applied +

+
+ {previousActiveUnknownNodeId && ( +
+ Previous active unknown:{" "} + {resolveActiveUnknown(previousActiveUnknownNodeId)} +
+ )} + {newActiveUnknownNodeId && ( +
+ New active unknown:{" "} + {resolveActiveUnknown(newActiveUnknownNodeId)} +
+ )} + {!newActiveUnknownNodeId && previousActiveUnknownNodeId && ( +
+ Next question status: No next + question selected yet. +
+ )} +
+
+ + + + + +
+ + Proposal details + +
+          {JSON.stringify(proposal, null, 2)}
+        
+
+          {JSON.stringify(
+            {
+              previousActiveUnknownNodeId,
+              newActiveUnknownNodeId,
+              resolvedUnknownNodeIds,
+              affectedNodeIds,
+            },
+            null,
+            2,
+          )}
+        
+
+
+ ); +} \ No newline at end of file diff --git a/components/scenario-form.jsx b/components/scenario-form.jsx index fb2e7d8..58e6cc6 100644 --- a/components/scenario-form.jsx +++ b/components/scenario-form.jsx @@ -1,41 +1,160 @@ "use client"; +import React from "react"; import { useState, useRef } from "react"; -import ReconstructionView from "@/components/reconstruction-view"; import DiagnosticsView from "@/components/diagnostics-view"; +import GraphUpdateView from "@/components/graph-update-view"; +import SituationGraphView from "@/components/situation-graph-view"; const MAX_LENGTH = 10000; +export async function submitScenarioForStartCase(fetchImpl, scenario) { + return fetchImpl("/api/cases/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ scenario }), + }); +} + +export async function submitAnswerForUpdateCase( + fetchImpl, + { situationGraph, previousQuestion, answer }, +) { + if (!answer?.trim()) { + return { + ok: false, + skipped: true, + data: { + success: false, + stage: "request_validation", + error: "Please enter an answer before updating.", + }, + }; + } + + const response = await fetchImpl("/api/cases/update", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ situationGraph, previousQuestion, answer }), + }); + + return { + ok: response.ok, + skipped: false, + data: await response.json(), + }; +} + +function normaliseStartResult(data) { + return { + ...data, + selectedQuestion: + typeof data?.selectedQuestion === "string" + ? data.selectedQuestion + : data?.selectedQuestion?.question ?? null, + }; +} + +export function ScenarioResultPanels({ status, result }) { + if (!result) return null; + + const hasGraph = Boolean(result.situationGraph); + const hasQuestion = Boolean(result.selectedQuestion?.question); + const hasDiagnostics = Boolean(result.diagnostics); + + return ( + <> + {status === "error" && ( +
+ {result.error && ( +
+ Error: {result.error} +
+ )} + {!hasGraph && !hasQuestion && ( +
+ Validation failed — no structured graph output was produced. +
+ )} +
+ )} + + {(status === "success" || hasGraph || hasQuestion) && ( + + )} + + {hasDiagnostics && } + + ); +} + +export function UpdateErrorPanel({ updateError }) { + if (!updateError) return null; + + const errors = [ + ...(updateError.errors || []), + ...(updateError.validationErrors || []), + ...(updateError.graphValidationErrors || []), + ...(updateError.proposalErrors || []), + ...(updateError.providerErrors || []), + ]; + + return ( +
+
+ Update error: {updateError.error} +
+ {errors.length > 0 && ( +
+ + Update details ({errors.length}) + +
    + {errors.map((item, index) => ( +
  • + {typeof item === "string" ? item : item?.message || JSON.stringify(item)} +
  • + ))} +
+
+ )} +
+ ); +} + export default function ScenarioForm() { const [scenario, setScenario] = useState(""); - const [status, setStatus] = useState("idle"); // idle | loading | error | success | partial + const [status, setStatus] = useState("idle"); // idle | loading | error | success const [result, setResult] = useState(null); + const [answer, setAnswer] = useState(""); + const [updateStatus, setUpdateStatus] = useState("idle"); // idle | loading | error | success + const [updateError, setUpdateError] = useState(null); + const [updateResult, setUpdateResult] = useState(null); const textareaRef = useRef(null); const handleSubmit = async (e) => { e.preventDefault(); setStatus("loading"); setResult(null); + setAnswer(""); + setUpdateStatus("idle"); + setUpdateError(null); + setUpdateResult(null); try { - const res = await fetch("/api/analyse", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ scenario }), - }); + const res = await submitScenarioForStartCase(fetch, scenario); const data = await res.json(); - if (res.ok && data.validationStatus === "valid") { + if (res.ok && data.success) { setStatus("success"); - setResult(data); - } else if (data.success) { - // Success in analysis but validation may be partial - setStatus("success"); - setResult(data); + setResult(normaliseStartResult(data)); } else { setStatus("error"); - setResult(data); + setResult(normaliseStartResult(data)); } } catch (err) { setStatus("error"); @@ -43,13 +162,54 @@ export default function ScenarioForm() { } }; - // Determine if we have meaningful content to display - const hasClassification = result?.inputClassification; - const hasReconstruction = result?.reconstruction; - const hasNextQuestion = result?.nextQuestion; - const hasEvidence = result?.evidence && result.evidence.length > 0; - const hasMeaningfulContent = - hasClassification || hasReconstruction || hasNextQuestion || hasEvidence; + const handleUpdate = async (e) => { + e.preventDefault(); + + const submission = await submitAnswerForUpdateCase(fetch, { + situationGraph: result?.situationGraph, + previousQuestion: result?.selectedQuestion, + answer, + }); + + if (submission.skipped) { + setUpdateStatus("error"); + setUpdateError(submission.data); + return; + } + + setUpdateStatus("loading"); + setUpdateError(null); + + try { + const outcome = submission.data; + + if (submission.ok && outcome.success) { + setUpdateStatus("success"); + setUpdateResult({ + ...outcome, + previousSituationGraph: result?.situationGraph ?? null, + }); + setResult((current) => ({ + ...current, + situationGraph: outcome.updatedSituationGraph, + selectedQuestion: null, + diagnostics: outcome.diagnostics, + })); + setAnswer(""); + } else { + setUpdateStatus("error"); + setUpdateError(outcome); + } + } catch (err) { + setUpdateStatus("error"); + setUpdateError({ error: err.message || "Network request failed" }); + } + }; + + const canRenderAnswerForm = + status === "success" && + Boolean(result?.situationGraph) && + Boolean(result?.selectedQuestion); return (
@@ -76,40 +236,56 @@ export default function ScenarioForm() {
- {/* Error state */} - {status === "error" && ( -
- {result?.error && ( -
- Error: {result.error} -
- )} - {/* Show partial content even on validation failure */} - {(hasClassification || hasReconstruction) && ( -
- ⚠ Partial result — some fields failed validation. Showing what was - accepted. -
- )} - {hasReconstruction && ( - - )} -
+ {canRenderAnswerForm && ( +
+
+

Selected Question

+

{result.selectedQuestion}

+
+
+ +