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
+1 -1
View File
@@ -18,7 +18,7 @@ export function DelatArendeVy({ arende, nu, notis }: { arende: Arende; nu: strin
const bilder = foton(arende);
const matvarden = arende.handelser.filter((p) => p.handelse.typ === "matvarde");
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),
);
const pagaende = b.rekommenderatNastaSteg[0];
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { AI_MODELL, SYSTEM_PROMPT, byggAnvandarPrompt, tolkaAiSvar } from "../ai";
import { VIBRATION_METODIK } from "../metodik";
import { brief } from "../projektioner";
import { byggDemoArende } from "../demo";
describe("AI-motorn", () => {
it("använder Claude Opus 5", () => {
expect(AI_MODELL).toBe("claude-opus-5");
});
it("systemprompten kodar AI-reglerna", () => {
expect(SYSTEM_PROMPT).toContain("Hitta aldrig på fakta");
expect(SYSTEM_PROMPT).toContain("aldrig en hypotes som ett konstaterat fel");
expect(SYSTEM_PROMPT).toContain("KRÄVER verifiering");
expect(SYSTEM_PROMPT).toContain("kortfattat");
});
it("användarprompten byggs ur ärendebriefen och den nya inmatningen", () => {
const arende = byggDemoArende(1);
const prompt = byggAnvandarPrompt(
brief(arende, VIBRATION_METODIK),
VIBRATION_METODIK.namn,
"Reläet klickar inte.",
);
expect(prompt).toContain("Volvo XC60 D4 2019");
expect(prompt).toContain("Felbeskrivning:");
expect(prompt).toContain("Kontrollera lufttryck");
expect(prompt).toContain("Ej kontrollerat enligt metodiken:");
expect(prompt).toContain("Teknikerns nya inmatning: Reläet klickar inte.");
});
it("tolkar giltiga svar och kastar på ogiltiga", () => {
const svar = tolkaAiSvar({
rader: [
{ typ: "verifierat", text: "Matningsspänning finns på stift 14." },
{ typ: "okand_typ", text: "ska filtreras bort" },
],
nastaSteg: "Kontrollera jordanslutningen på stift 7.",
});
expect(svar.rader).toHaveLength(1);
expect(svar.rader[0].typ).toBe("verifierat");
expect(svar.nastaSteg).toContain("stift 7");
expect(() => tolkaAiSvar({ rader: "inte en lista", nastaSteg: "x" })).toThrow();
expect(() => tolkaAiSvar(null)).toThrow();
});
});
+140
View File
@@ -0,0 +1,140 @@
// AI-motor: Claude som diagnostikhandledare.
//
// Kommunikationsmodellen är text in, text ut: teknikerns (bekräftade)
// inmatning skickas tillsammans med ärendebriefen, och Claude svarar
// kortfattat och klassificerat enligt AI-reglerna i Master Prompt —
// observation / verifierat / hypotes / rekommendation. Svaret är
// schema-bundet (structured outputs), så det kan aldrig komma tillbaka
// som fritext som råkar läsas som ett konstaterat fel.
//
// MVP: tenantens egen API-nyckel används direkt från klienten
// (dangerouslyAllowBrowser). I produktionsversionen flyttas anropet
// bakom plattformens backend så att nycklar aldrig lämnar servern.
import Anthropic from "@anthropic-ai/sdk";
import type { Brief } from "./projektioner";
export const AI_MODELL = "claude-opus-5";
export type AiRadTyp = "observation" | "verifierat" | "hypotes" | "rekommendation";
export const AI_RADTYP_LABEL: Record<AiRadTyp, string> = {
observation: "Observation",
verifierat: "Verifierat",
hypotes: "🔴 Hypotes",
rekommendation: "Rekommendation",
};
export interface AiRad {
typ: AiRadTyp;
text: string;
}
export interface AiSvar {
rader: AiRad[];
nastaSteg: string;
}
const SVARS_SCHEMA = {
type: "object",
properties: {
rader: {
type: "array",
items: {
type: "object",
properties: {
typ: { type: "string", enum: ["observation", "verifierat", "hypotes", "rekommendation"] },
text: { type: "string" },
},
required: ["typ", "text"],
additionalProperties: false,
},
},
nastaSteg: { type: "string" },
},
required: ["rader", "nastaSteg"],
additionalProperties: false,
} as const;
// Fryst systemprompt (cachas mellan anrop) — all ärendespecifik kontext
// ligger i användarmeddelandet.
export const SYSTEM_PROMPT = `Du är en digital felsökningshandledare för professionella tekniker i plattformen Guidad Felsökning. Du är inte en AI-mekaniker: du ersätter aldrig teknikerns kompetens eller tillverkarens dokumentation — du säkerställer att arbetet sker metodiskt och dokumenteras korrekt.
Absoluta regler:
- Hitta aldrig på fakta. Låtsas aldrig veta. Gissa aldrig.
- Presentera aldrig en hypotes som ett konstaterat fel.
- Skilj strikt mellan raderna du returnerar:
- "verifierat": endast det som är belagt av mätvärden eller dokumenterade kontroller i underlaget.
- "observation": det som konstaterats utan slutsats.
- "hypotes": möjlig felorsak som KRÄVER verifiering — formulera alltid vad som skulle verifiera den.
- "rekommendation": nästa verifierbara kontroll eller mätning.
- Om underlaget är otillräckligt: säg det uttryckligen i en observation och rekommendera vad som behöver dokumenteras.
Arbetssätt: en kontroll i taget, inte långa utläggningar. Svara på svenska, konsekvent, kortfattat och metodiskt — max fyra rader plus nästa steg. "nastaSteg" är EN konkret, verifierbar åtgärd.`;
export function byggAnvandarPrompt(brief: Brief, metodikNamn: string, inmatning: string): string {
const rader: string[] = [];
rader.push(`Metodik: ${metodikNamn}`);
if (brief.objekt) rader.push(`Objekt: ${brief.objekt.beskrivning} (${brief.objekt.identifierare})`);
if (brief.felbeskrivning) rader.push(`Felbeskrivning: ${brief.felbeskrivning}`);
if (brief.utfordaKontroller.length > 0) {
rader.push("Utförda kontroller:");
for (const k of brief.utfordaKontroller) rader.push(`- ${k.text}${k.resultat ? `: ${k.resultat}` : ""}`);
}
if (brief.observationer.length > 0) {
rader.push("Observationer och mätvärden:");
for (const o of brief.observationer) rader.push(`- ${o}`);
}
if (brief.ejKontrollerat.length > 0) {
rader.push(`Ej kontrollerat enligt metodiken: ${brief.ejKontrollerat.join("; ")}`);
}
rader.push("");
rader.push(`Teknikerns nya inmatning: ${inmatning}`);
return rader.join("\n");
}
export function tolkaAiSvar(data: unknown): AiSvar {
const svar = data as AiSvar;
if (!svar || !Array.isArray(svar.rader) || typeof svar.nastaSteg !== "string") {
throw new Error("Oväntat AI-svarsformat");
}
const giltiga: AiRadTyp[] = ["observation", "verifierat", "hypotes", "rekommendation"];
const rader = svar.rader.filter(
(rad): rad is AiRad => !!rad && giltiga.includes(rad.typ) && typeof rad.text === "string",
);
return { rader, nastaSteg: svar.nastaSteg };
}
export async function fragaAi(
apiNyckel: string,
brief: Brief,
metodikNamn: string,
inmatning: string,
): Promise<AiSvar> {
const klient = new Anthropic({ apiKey: apiNyckel, dangerouslyAllowBrowser: true });
const svar = await klient.beta.messages.create({
model: AI_MODELL,
max_tokens: 1024,
// Verkstadsgolvet är latenskänsligt och uppgiften är väl avgränsad
// av briefen — medium balanserar svarstid mot kvalitet.
output_config: {
effort: "medium",
format: { type: "json_schema", schema: SVARS_SCHEMA },
},
// Avböjer säkerhetsklassificerarna faller anropet automatiskt
// tillbaka till Anthropics rekommenderade reservmodell.
betas: ["server-side-fallback-2026-07-01"],
fallbacks: "default",
system: [{ type: "text", text: SYSTEM_PROMPT, cache_control: { type: "ephemeral" } }],
messages: [{ role: "user", content: byggAnvandarPrompt(brief, metodikNamn, inmatning) }],
} as Parameters<typeof klient.beta.messages.create>[0]);
if (svar.stop_reason === "refusal") {
throw new Error("AI-tjänsten avböjde förfrågan.");
}
const textBlock = svar.content.find((block) => block.type === "text");
if (!textBlock || textBlock.type !== "text") {
throw new Error("AI-svaret saknade innehåll.");
}
return tolkaAiSvar(JSON.parse(textBlock.text));
}
+10
View File
@@ -52,6 +52,12 @@ export type Handelse =
| { typ: "inaktivitet_forklarad"; text: string; minuter: number }
| { typ: "overlamning"; fran: string; till?: string }
| { typ: "export_skapad"; format: string; version: number }
| {
typ: "ai_svar";
rader: { typ: "observation" | "verifierat" | "hypotes" | "rekommendation"; text: string }[];
nastaSteg: string;
modell: string;
}
| { typ: "arende_avslutat" };
export interface LoggPost {
@@ -112,6 +118,10 @@ export function handelseRubrik(post: LoggPost): string {
return h.till ? `Arbete överlämnat från ${h.fran} till ${h.till}` : `Arbete överlämnat av ${h.fran}`;
case "export_skapad":
return `Export skapad: ${h.format}, version ${h.version}`;
case "ai_svar": {
const forsta = h.rader[0];
return `AI: ${forsta ? `${forsta.text} ` : ""}— Nästa steg: ${h.nastaSteg}`;
}
case "arende_avslutat":
return "Felsökning avslutad";
}
+6
View File
@@ -12,9 +12,13 @@ import { GENERISK_METODIK, valjMetodik, type Metodik } from "./metodik";
interface FelsokningState {
anvandare: string;
// Tenantens Claude API-nyckel (MVP: lagras endast på enheten; i
// produktionsversionen hanteras nycklar av backend, aldrig i klienten).
aiNyckel: string;
arenden: Record<string, Arende>;
nastaNummer: number;
sattAnvandare: (namn: string) => void;
sattAiNyckel: (nyckel: string) => void;
skapaArende: (objekt: Objekt, felbeskrivningText: string) => string;
laggTill: (arendeId: string, handelse: Handelse) => void;
// Används endast av synken: ersätter listan med den ihopflätade versionen.
@@ -34,10 +38,12 @@ export const useFelsokning = create<FelsokningState>()(
persist(
(set, get) => ({
anvandare: "",
aiNyckel: "",
arenden: {},
nastaNummer: 1,
sattAnvandare: (namn) => set({ anvandare: namn.trim() }),
sattAiNyckel: (nyckel) => set({ aiNyckel: nyckel.trim() }),
skapaArende: (objekt, felbeskrivningText) => {
const { anvandare, nastaNummer } = get();
+9 -1
View File
@@ -92,6 +92,7 @@ export function TextFalt({
platshallare,
flerRad,
rost,
losenord,
}: {
label: string;
varde: string;
@@ -99,6 +100,7 @@ export function TextFalt({
platshallare?: string;
flerRad?: boolean;
rost?: boolean;
losenord?: boolean;
}) {
// Tal-till-text skriver in i samma redigerbara fält — användaren
// granskar och bekräftar alltid innan något sparas i loggen.
@@ -119,7 +121,13 @@ export function TextFalt({
{flerRad ? (
<textarea value={varde} onChange={(e) => satt(e.target.value)} placeholder={platshallare} rows={3} className={klass} />
) : (
<input value={varde} onChange={(e) => satt(e.target.value)} placeholder={platshallare} className={klass} />
<input
type={losenord ? "password" : "text"}
value={varde}
onChange={(e) => satt(e.target.value)}
placeholder={platshallare}
className={klass}
/>
)}
</label>
);
+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);