Kör Claude som AI-modell i Guidad Felsökning

- AI-motor (ai.ts): Claude Opus 5 via officiella TypeScript-SDK:n.
  Teknikerns bekräftade dokumentation skickas med ärendebriefen som
  kontext; svaret är schema-bundet (structured outputs) och strikt
  klassificerat enligt AI-reglerna: observation, verifierat, hypotes
  (kräver alltid verifiering) och rekommendation, plus ETT konkret
  nästa steg. Fryst systemprompt med promptcache; automatisk
  server-side fallback till Anthropics rekommenderade reservmodell
  när säkerhetsklassificerarna avböjer; refusal hanteras innan
  innehållet läses.
- Onboarding enligt direktivet: organisationens Claude API-nyckel
  läggs in på startsidan (maskerat fält). Utan nyckel guidar den
  deterministiska metodiken ensam. MVP-varning: nyckeln lagras på
  enheten och anropet görs från webbläsaren — i produktion via
  backend.
- AI-svaren loggas som händelser med modellnamn och visas i guiden;
  de är arbetsmaterial och filtreras ur kundrapport, Live Share och
  databasens delningsfunktion, precis som hypoteser.
- Master Prompt uppdaterad: AI-wrapper ovanpå Claude API, standard-
  modell claude-opus-5.

Verifierat: 25 vitest-tester gröna (inkl. promptbygge, svarstolkning
och systemprompt-regler), produktionsbygge ok, Playwright-röktest
genom hela flödet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-03 00:55:23 +00:00
parent a33e2bfcbf
commit 2202737d48
13 changed files with 393 additions and 16 deletions
+71 -9
View File
@@ -18,6 +18,7 @@ import {
} from "@/felsokning/projektioner";
import { metodikForArende, useFelsokning } from "@/felsokning/store";
import { synkroniseraArende, type SynkStatus } from "@/felsokning/synk";
import { AI_MODELL, AI_RADTYP_LABEL, fragaAi } from "@/felsokning/ai";
import { FelsokningSkal, NivaBadge, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
import { skalaNerFoto, tidKlockslag } from "@/felsokning/format";
@@ -184,6 +185,25 @@ function GuideFlik({
}) {
const steg = useMemo(() => nastaSteg(arende, metodik), [arende, metodik]);
const [visaOverlamning, setVisaOverlamning] = useState(false);
const aiNyckel = useFelsokning((s) => s.aiNyckel);
const [aiStatus, setAiStatus] = useState<"vilar" | "arbetar" | "fel">("vilar");
// AI:n svarar skriftligt på varje bekräftad dokumentation: klassificerat
// (observation/verifierat/hypotes/rekommendation) och loggat som händelse.
// Utan nyckel guidar den deterministiska metodiken ensam.
const fragaAiOmDokumentation = async (inmatning: string) => {
if (!aiNyckel) return;
setAiStatus("arbetar");
try {
const svar = await fragaAi(aiNyckel, brief(arende, metodik, nu), metodik.namn, inmatning);
skicka({ typ: "ai_svar", rader: svar.rader, nastaSteg: svar.nastaSteg, modell: AI_MODELL });
setAiStatus("vilar");
} catch {
setAiStatus("fel");
}
};
const senasteAiSvar = [...arende.handelser].reverse().find((p) => p.handelse.typ === "ai_svar");
if (avslutat) {
return (
@@ -217,7 +237,29 @@ function GuideFlik({
) : null}
</Panel>
<SnabbDokumentation skicka={skicka} />
{(aiStatus !== "vilar" || senasteAiSvar) && (
<Panel rubrik={`AI-handledning (${AI_MODELL})`}>
{aiStatus === "arbetar" && <p className="mb-2 animate-pulse font-bold text-amber-400">AI analyserar </p>}
{aiStatus === "fel" && (
<p className="mb-2 font-bold text-red-400">AI-svaret kunde inte hämtas kontrollera nyckel och nät. Metodiken fortsätter som vanligt.</p>
)}
{senasteAiSvar && senasteAiSvar.handelse.typ === "ai_svar" && (
<>
{senasteAiSvar.handelse.rader.map((rad, i) => (
<p key={i} className="py-0.5 text-lg">
<span className="font-extrabold text-zinc-400">{AI_RADTYP_LABEL[rad.typ]}:</span> {rad.text}
</p>
))}
<p className="mt-2 text-lg">
<span className="font-extrabold text-amber-400">Nästa steg:</span>{" "}
{senasteAiSvar.handelse.nastaSteg}
</p>
</>
)}
</Panel>
)}
<SnabbDokumentation skicka={skicka} paSparad={fragaAiOmDokumentation} />
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setVisaOverlamning(true)}>
@@ -380,7 +422,15 @@ const DOKTYPER = [
// Fri dokumentation vid sidan av guiden. Hypoteser märks alltid som
// ej verifierade — de kan aldrig loggas som konstaterade fel.
function SnabbDokumentation({ skicka }: { skicka: (h: Handelse) => void }) {
// paSparad anropas med en textsammanfattning EFTER att användaren bekräftat
// med Spara — det är den texten AI:n får (aldrig obekräftad inmatning).
function SnabbDokumentation({
skicka,
paSparad,
}: {
skicka: (h: Handelse) => void;
paSparad?: (sammanfattning: string) => void;
}) {
const [typ, setTyp] = useState<(typeof DOKTYPER)[number]["id"] | null>(null);
const [text, setText] = useState("");
const [varde, setVarde] = useState("");
@@ -394,10 +444,22 @@ function SnabbDokumentation({ skicka }: { skicka: (h: Handelse) => void }) {
const spara = () => {
const t = text.trim();
if (typ === "observation" && t) skicka({ typ: "observation", text: t });
if (typ === "kommentar" && t) skicka({ typ: "kommentar", text: t });
if (typ === "hypotes" && t) skicka({ typ: "hypotes", text: t, niva: "lag" });
if (typ === "matvarde" && t && varde.trim()) skicka({ typ: "matvarde", beskrivning: t, varde: varde.trim() });
if (typ === "observation" && t) {
skicka({ typ: "observation", text: t });
paSparad?.(`Observation: ${t}`);
}
if (typ === "kommentar" && t) {
skicka({ typ: "kommentar", text: t });
paSparad?.(t);
}
if (typ === "hypotes" && t) {
skicka({ typ: "hypotes", text: t, niva: "lag" });
paSparad?.(`Teknikerns hypotes (ej verifierad): ${t}`);
}
if (typ === "matvarde" && t && varde.trim()) {
skicka({ typ: "matvarde", beskrivning: t, varde: varde.trim() });
paSparad?.(`Mätvärde: ${t} = ${varde.trim()}`);
}
aterstall();
};
@@ -606,10 +668,10 @@ function RapportFlik({
const b = brief(arende, metodik, nu);
const bilder = foton(arende);
const fordelning = tidsfordelningsRader(arende, nu);
// Kategoribyten är interna; hypoteser är arbetsmaterial och ingår inte
// i det som delas med kund.
// Kategoribyten är interna; hypoteser och AI-dialogen är arbetsmaterial
// och ingår inte i det som delas med kund.
const kundposter = arende.handelser.filter(
(p) => p.handelse.typ !== "kategori_byte" && p.handelse.typ !== "hypotes",
(p) => !["kategori_byte", "hypotes", "ai_svar"].includes(p.handelse.typ),
);
// Alla exporter bygger på samma händelselogg och versionsmärks:
+40 -1
View File
@@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
import { useFelsokning, metodikForArende } from "@/felsokning/store";
import { arAvslutat, felbeskrivning, objekt, sistaAktivitet, brief } from "@/felsokning/projektioner";
import { byggDemoArende } from "@/felsokning/demo";
import { AI_MODELL } from "@/felsokning/ai";
import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
import { tidDatum, tidKlockslag } from "@/felsokning/format";
@@ -11,9 +12,11 @@ type Filter = "alla" | "pagaende" | "klara";
// Dashboard enligt direktivet: endast det viktigaste — mina ärenden,
// pågående, klara och starta nytt ärende.
export default function Arendelista() {
const { anvandare, arenden, sattAnvandare, laggInArende, nastaNummer } = useFelsokning();
const { anvandare, arenden, sattAnvandare, laggInArende, nastaNummer, aiNyckel, sattAiNyckel } = useFelsokning();
const [namn, setNamn] = useState("");
const [filter, setFilter] = useState<Filter>("alla");
const [visaAi, setVisaAi] = useState(false);
const [nyckel, setNyckel] = useState("");
if (!anvandare) {
return (
@@ -90,6 +93,42 @@ export default function Arendelista() {
<p className="text-center text-lg text-zinc-400">Inga ärenden i det här filtret.</p>
)}
<Panel rubrik="AI-handledning">
<p className="mb-2 text-zinc-300">
{aiNyckel
? `Aktiv — Claude (${AI_MODELL}) svarar klassificerat på dokumenterade observationer och mätvärden.`
: "Inte aktiverad — den deterministiska metodiken guidar. Lägg till organisationens Claude API-nyckel för AI-handledning."}
</p>
{!visaAi ? (
<StorKnapp variant="sekundar" onClick={() => setVisaAi(true)}>
{aiNyckel ? "Ändra API-nyckel" : "Lägg till Claude API-nyckel"}
</StorKnapp>
) : (
<>
<TextFalt label="Claude API-nyckel" varde={nyckel} satt={setNyckel} platshallare="sk-ant-…" losenord />
<p className="mb-3 text-sm text-zinc-500">
MVP: nyckeln sparas endast den här enheten och anropen görs direkt från webbläsaren. I
produktionsversionen hanteras nycklar av plattformens backend.
</p>
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => { setVisaAi(false); setNyckel(""); }}>
Avbryt
</StorKnapp>
<StorKnapp
disabled={!nyckel.trim() && !aiNyckel}
onClick={() => {
sattAiNyckel(nyckel);
setVisaAi(false);
setNyckel("");
}}
>
{nyckel.trim() ? "Spara nyckel" : "Ta bort nyckel"}
</StorKnapp>
</div>
</>
)}
</Panel>
{lista.map((arende) => {
const o = objekt(arende);
const fel = felbeskrivning(arende);