Compare commits
2
Commits
707fe1b3c0
...
00d7593ffd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00d7593ffd | ||
|
|
60c90bdd6a |
@@ -33,6 +33,28 @@ export async function submitScenarioForStartCase(fetchImpl, scenario) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isUnavailableStartResponse(response, data) {
|
||||||
|
return response.status === 503 &&
|
||||||
|
data?.success === false &&
|
||||||
|
data?.error === "Reasoning service is temporarily unavailable.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UnavailableStartPanel({ onRetry }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||||
|
<p className="font-medium">Confidence Engine is temporarily unavailable.</p>
|
||||||
|
<p className="mt-1">We couldn't process this right now. Your scenario is still here and you can try again.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRetry}
|
||||||
|
className="mt-3 rounded-lg border border-amber-400 px-4 py-2 text-sm font-medium text-amber-900 transition hover:bg-amber-100"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function submitAnswerForUpdateCase(
|
export async function submitAnswerForUpdateCase(
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
{ situationGraph, previousQuestion, answer, findings },
|
{ situationGraph, previousQuestion, answer, findings },
|
||||||
@@ -672,8 +694,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
|
|||||||
updateStatus === "loading"
|
updateStatus === "loading"
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleStart = async () => {
|
||||||
e.preventDefault();
|
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setAnswer("");
|
setAnswer("");
|
||||||
@@ -699,6 +720,8 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
|
|||||||
/* ── v0.59a — provenance: first meaningful change sets revision to 1 ── */
|
/* ── v0.59a — provenance: first meaningful change sets revision to 1 ── */
|
||||||
setInvestigationRevision(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 });
|
persist({ id: investigationId, scenario, situationGraph: normalised.situationGraph, selectedQuestion: normalised.selectedQuestion, summary: data.summary ?? null, updatedAt: new Date().toISOString(), focusedContributions, findings: [], investigationReport, investigationRevision: 1 });
|
||||||
|
} else if (isUnavailableStartResponse(res, data)) {
|
||||||
|
setStatus("unavailable");
|
||||||
} else {
|
} else {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
setCurrentUnderstanding(data.summary ?? null);
|
setCurrentUnderstanding(data.summary ?? null);
|
||||||
@@ -710,6 +733,11 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleStart();
|
||||||
|
};
|
||||||
|
|
||||||
const handleUpdate = async (e) => {
|
const handleUpdate = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -799,7 +827,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* ── Idle form for scenario input ─ */}
|
{/* ── Idle form for scenario input ─ */}
|
||||||
{!result?.situationGraph && status === "idle" && (
|
{!result?.situationGraph && (status === "idle" || status === "unavailable") && (
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
|
||||||
{/* Two-column landing workspace */}
|
{/* Two-column landing workspace */}
|
||||||
@@ -864,6 +892,7 @@ export default function ScenarioForm({ investigationId, onNavigateToReport }) {
|
|||||||
<p className="mt-3 text-xs italic text-gray-400">
|
<p className="mt-3 text-xs italic text-gray-400">
|
||||||
You do not need all the answers yet.
|
You do not need all the answers yet.
|
||||||
</p>
|
</p>
|
||||||
|
{status === "unavailable" && <UnavailableStartPanel onRetry={handleStart} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+3
-1
@@ -100,6 +100,7 @@ export async function analyseScenario(scenario, opts = {}) {
|
|||||||
"500",
|
"500",
|
||||||
e.providerApiPath,
|
e.providerApiPath,
|
||||||
e.providerExecution,
|
e.providerExecution,
|
||||||
|
e.code,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +176,7 @@ function tryValidateAgainstSchema(data, schema) {
|
|||||||
|
|
||||||
// ── Result builders ──────────────────────────────────
|
// ── Result builders ──────────────────────────────────
|
||||||
|
|
||||||
function buildErrorResponse(message, elapsed, statusCode = 500, providerApiPath, providerExecution) {
|
function buildErrorResponse(message, elapsed, statusCode = 500, providerApiPath, providerExecution, code) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: message,
|
error: message,
|
||||||
@@ -187,6 +188,7 @@ function buildErrorResponse(message, elapsed, statusCode = 500, providerApiPath,
|
|||||||
statusCode,
|
statusCode,
|
||||||
providerApiPath,
|
providerApiPath,
|
||||||
providerExecution,
|
providerExecution,
|
||||||
|
code,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -382,6 +382,7 @@ export async function startCase(body, dependencies = {}) {
|
|||||||
providerApiPath: analysis.providerApiPath ?? undefined,
|
providerApiPath: analysis.providerApiPath ?? undefined,
|
||||||
providerExecution: analysis.providerExecution ?? undefined,
|
providerExecution: analysis.providerExecution ?? undefined,
|
||||||
rawResponse: analysis.rawResponse ?? undefined,
|
rawResponse: analysis.rawResponse ?? undefined,
|
||||||
|
code: analysis.code ?? undefined,
|
||||||
statusCode: Number(analysis.statusCode) || 502,
|
statusCode: Number(analysis.statusCode) || 502,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const mockStartCase = vi.fn();
|
const mockStartCase = vi.fn();
|
||||||
|
let realStartCase;
|
||||||
let warnSpy;
|
let warnSpy;
|
||||||
let errorSpy;
|
let errorSpy;
|
||||||
|
|
||||||
vi.mock("@/lib/graph/orchestrator.js", () => ({
|
vi.mock("@/lib/config.js", () => ({
|
||||||
startCase: (...args) => mockStartCase(...args),
|
getConfig: () => ({ ok: true }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/graph/orchestrator.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal();
|
||||||
|
realStartCase = actual.startCase;
|
||||||
|
return { ...actual, startCase: (...args) => mockStartCase(...args) };
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("@/lib/supabase/api-auth.js", () => ({
|
vi.mock("@/lib/supabase/api-auth.js", () => ({
|
||||||
withAuthenticatedApi: (handler) => handler,
|
withAuthenticatedApi: (handler) => handler,
|
||||||
}));
|
}));
|
||||||
@@ -128,13 +135,21 @@ describe("app/api/cases/start route", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns a sanitized 503 when the provider is unavailable", async () => {
|
it("returns a sanitized 503 when the provider is unavailable", async () => {
|
||||||
mockStartCase.mockResolvedValue({
|
const unavailableError = Object.assign(
|
||||||
success: false,
|
new Error("Ollama /api/generate request timed out after 5 minutes"),
|
||||||
code: "PROVIDER_UNAVAILABLE",
|
{
|
||||||
error: "Ollama /api/generate request timed out after 5 minutes",
|
code: "PROVIDER_UNAVAILABLE",
|
||||||
providerApiPath: "/api/generate",
|
providerApiPath: "/api/generate",
|
||||||
providerExecution: { generateRequestAttempted: true },
|
providerExecution: { generateRequestAttempted: true },
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
const reconstructionProvider = {
|
||||||
|
generateReconstruction: vi.fn().mockRejectedValue(unavailableError),
|
||||||
|
};
|
||||||
|
mockStartCase.mockImplementation((body) => realStartCase(body, {
|
||||||
|
reconstructionProvider,
|
||||||
|
reconstructionModelName: "configured-model",
|
||||||
|
}));
|
||||||
|
|
||||||
const { POST } = await import("@/app/api/cases/start/route.js");
|
const { POST } = await import("@/app/api/cases/start/route.js");
|
||||||
const response = await POST(
|
const response = await POST(
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import ReasoningWorkspace, {
|
|||||||
} from "@/components/reasoning-workspace.jsx";
|
} from "@/components/reasoning-workspace.jsx";
|
||||||
import {
|
import {
|
||||||
ScenarioResultPanels,
|
ScenarioResultPanels,
|
||||||
|
UnavailableStartPanel,
|
||||||
UpdateErrorPanel,
|
UpdateErrorPanel,
|
||||||
|
isUnavailableStartResponse,
|
||||||
submitAnswerForUpdateCase,
|
submitAnswerForUpdateCase,
|
||||||
submitScenarioForStartCase,
|
submitScenarioForStartCase,
|
||||||
} from "@/components/scenario-form.jsx";
|
} from "@/components/scenario-form.jsx";
|
||||||
@@ -372,6 +374,26 @@ function makeCommercialUpdateSuccess(overrides = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("scenario-form UI helpers", () => {
|
describe("scenario-form UI helpers", () => {
|
||||||
|
it("recognises only the established sanitized unavailable start response", () => {
|
||||||
|
expect(isUnavailableStartResponse(
|
||||||
|
{ status: 503 },
|
||||||
|
{ success: false, error: "Reasoning service is temporarily unavailable." },
|
||||||
|
)).toBe(true);
|
||||||
|
expect(isUnavailableStartResponse(
|
||||||
|
{ status: 500 },
|
||||||
|
{ success: false, error: "Reasoning service is temporarily unavailable." },
|
||||||
|
)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("presents generic recovery without provider details", () => {
|
||||||
|
const html = renderToStaticMarkup(<UnavailableStartPanel onRetry={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(html).toContain("Confidence Engine is temporarily unavailable.");
|
||||||
|
expect(html).toContain("Your scenario is still here and you can try again.");
|
||||||
|
expect(html).toContain(">Retry<");
|
||||||
|
expect(html).not.toMatch(/ollama|provider|local model|server|infrastructure/i);
|
||||||
|
});
|
||||||
|
|
||||||
it("submits to /api/cases/start", async () => {
|
it("submits to /api/cases/start", async () => {
|
||||||
const fetchImpl = vi.fn().mockResolvedValue({ ok: true });
|
const fetchImpl = vi.fn().mockResolvedValue({ ok: true });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user