Files
confidence-engine/components/scenario-form.jsx

359 lines
12 KiB
React

"use client";
import React from "react";
import { useState, useRef } from "react";
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,
newlySurfacedNodeIds: data?.newlySurfacedNodeIds ?? [],
};
}
function normaliseUpdateSelectedQuestion(selectedQuestion) {
if (!selectedQuestion) return null;
if (typeof selectedQuestion === "string") return selectedQuestion;
return 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" && (
<div className="space-y-3">
{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>
)}
{!hasGraph && !hasQuestion && (
<div className="rounded-lg border border-yellow-300 bg-yellow-50 px-4 py-2 text-sm text-yellow-800">
Validation failed no structured graph output was produced.
</div>
)}
</div>
)}
{(status === "success" || hasGraph || hasQuestion) && (
<SituationGraphView
situationGraph={result.situationGraph}
selectedQuestion={result.selectedQuestion}
newlySurfacedNodeIds={result.newlySurfacedNodeIds}
/>
)}
{hasDiagnostics && <DiagnosticsView result={result} />}
</>
);
}
export function UpdateErrorPanel({ updateError }) {
if (!updateError) return null;
const errors = [
...(updateError.errors || []),
...(updateError.validationErrors || []),
...(updateError.graphValidationErrors || []),
...(updateError.proposalErrors || []),
...(updateError.providerErrors || []),
];
return (
<div className="space-y-3">
<div className="rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700 whitespace-pre-wrap">
Update error: {updateError.error}
</div>
{errors.length > 0 && (
<details className="rounded-lg border border-red-200 bg-red-50 px-4 py-3">
<summary className="cursor-pointer text-sm font-medium text-red-700 underline">
Update details ({errors.length})
</summary>
<ul className="mt-2 space-y-1 text-sm text-red-700">
{errors.map((item, index) => (
<li key={index}>
{typeof item === "string" ? item : item?.message || JSON.stringify(item)}
</li>
))}
</ul>
</details>
)}
</div>
);
}
export default function ScenarioForm() {
const [scenario, setScenario] = useState("");
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 submitScenarioForStartCase(fetch, scenario);
const data = await res.json();
if (res.ok && data.success) {
setStatus("success");
setResult(normaliseStartResult(data));
} else {
setStatus("error");
setResult(normaliseStartResult(data));
}
} catch (err) {
setStatus("error");
setResult({ error: err.message || "Network request failed" });
}
};
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: normaliseUpdateSelectedQuestion(
outcome.selectedQuestion,
),
newlySurfacedNodeIds: (outcome.proposal?.addedNodes || [])
.filter((node) => node.kind === "unknown")
.map((node) => node.id),
diagnostics: outcome.diagnostics,
}));
setAnswer("");
} else {
setUpdateStatus("error");
setUpdateError(outcome);
}
} catch (err) {
setUpdateStatus("error");
setUpdateError({ error: err.message || "Network request failed" });
}
};
const canRenderAnswerForm =
status === "success" &&
updateStatus === "idle" &&
Boolean(result?.situationGraph) &&
Boolean(result?.selectedQuestion);
const canRenderDisabledFollowUpForm =
updateStatus === "success" &&
Boolean(updateResult?.selectedQuestion?.question || result?.selectedQuestion);
return (
<div className="space-y-6">
<form onSubmit={handleSubmit} className="space-y-4">
<textarea
ref={textareaRef}
value={scenario}
onChange={(e) => setScenario(e.target.value)}
placeholder="Describe the scenario you want analysed..."
rows={10}
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"
/>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400">
{scenario.length}/{MAX_LENGTH}
</span>
<button
type="submit"
disabled={status === "loading" || !scenario.trim()}
className="rounded-lg bg-gray-900 px-6 py-2.5 text-sm font-medium text-white transition hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-40"
>
{status === "loading" ? "Analysing..." : "Analyse"}
</button>
</div>
</form>
{canRenderAnswerForm && (
<form onSubmit={handleUpdate} className="space-y-4 rounded-lg border border-gray-200 bg-white p-4">
<div>
<h2 className="text-base font-semibold text-gray-900">Selected Question</h2>
<p className="mt-1 text-sm text-gray-700">{result.selectedQuestion}</p>
</div>
<div>
<label htmlFor="answer-textarea" className="mb-2 block text-sm font-medium text-gray-700">
Your answer
</label>
<textarea
id="answer-textarea"
value={answer}
onChange={(e) => setAnswer(e.target.value)}
rows={4}
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"
placeholder="Enter the answer to the selected question..."
/>
</div>
<div className="flex items-center justify-between gap-4">
<p className="text-xs text-gray-500">
{updateStatus === "loading"
? "Applying validated graph update..."
: "One update turn only in this prototype."}
</p>
<button
type="submit"
disabled={updateStatus === "loading"}
className="rounded-lg bg-blue-700 px-4 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>
)}
<UpdateErrorPanel updateError={updateError} />
{updateStatus === "success" && updateResult && (
<>
<div className="rounded-lg border border-yellow-300 bg-yellow-50 px-4 py-3 text-sm text-yellow-800">
{updateResult.selectedQuestion?.question
? updateResult.selectedQuestion.question
: "No next question selected yet."}
</div>
{canRenderDisabledFollowUpForm && (
<form className="space-y-4 rounded-lg border border-gray-200 bg-white p-4 opacity-70">
<div>
<h2 className="text-base font-semibold text-gray-900">Selected Question</h2>
<p className="mt-1 text-sm text-gray-700">
{updateResult.selectedQuestion?.question || result?.selectedQuestion}
</p>
</div>
<div>
<label htmlFor="follow-up-disabled-textarea" className="mb-2 block text-sm font-medium text-gray-700">
Your answer
</label>
<textarea
id="follow-up-disabled-textarea"
rows={4}
disabled
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm opacity-70"
placeholder="Additional submission is disabled in this one-update prototype."
/>
</div>
<div className="flex items-center justify-between gap-4">
<p className="text-xs text-gray-500">
Additional submission is disabled in this one-update prototype.
</p>
<button
type="button"
disabled
className="rounded-lg bg-blue-700 px-4 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-40"
>
Update situation
</button>
</div>
</form>
)}
<GraphUpdateView updateResult={updateResult} />
</>
)}
<ScenarioResultPanels status={status} result={result} />
{(status === "loading" || updateStatus === "loading") && (
<div className="py-12 text-center text-sm text-gray-400">
Waiting for model response...
</div>
)}
{/* Empty state */}
{status === "idle" && (
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 px-6 py-8 text-center">
<p className="text-sm text-gray-400">
Enter a scenario above and click Analyse to begin.
</p>
</div>
)}
</div>
);
}