AI:n drivs av plattformen — flytta Claude-anropet till backend

- Ny edge-funktion supabase/functions/felsokning-ai: äger Claude
  API-nyckeln (serverhemligheten ANTHROPIC_API_KEY), systemprompten,
  modellvalet (claude-opus-5) och svarsschemat. Kräver inloggad
  användare (verify_jwt = true), validerar promptlängd och hanterar
  refusal/fallback på serversidan.
- Klienten anropar plattformens endpoint via functions.invoke —
  @anthropic-ai/sdk borttagen ur klientberoendena, ingen nyckel
  hanteras eller lagras någonsin i webbläsaren.
- Nyckelpanelen på startsidan ersatt med statuspanel: AI-handledning
  ingår i tjänsten; i lokalt läge (ej inloggad) guidar den
  deterministiska metodiken ensam, utan felbrus.
- Master Prompt: onboarding utan API-nyckelsteg; nytt stycke om att
  AI:n drivs av plattformen och att AI-reglerna inte kan kringgås
  från klientsidan.
- Test låser att endpointen äger systemprompt, modell och schema.

Verifierat: 25 vitest-tester gröna, produktionsbygge ok, båda
Playwright-sviterna gröna.

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 01:08:19 +00:00
parent 82b469b7e6
commit ed483774fb
11 changed files with 170 additions and 199 deletions
+12 -6
View File
@@ -1,5 +1,6 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { AI_MODELL, SYSTEM_PROMPT, byggAnvandarPrompt, tolkaAiSvar } from "../ai";
import { AI_MODELL, byggAnvandarPrompt, tolkaAiSvar } from "../ai";
import { VIBRATION_METODIK } from "../metodik";
import { brief } from "../projektioner";
import { byggDemoArende } from "../demo";
@@ -9,11 +10,16 @@ describe("AI-motorn", () => {
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("plattformens AI-endpoint äger systemprompten och kodar AI-reglerna", () => {
// AI:n drivs av plattformen: systemprompt, modell och schema ligger i
// backend (edge-funktionen), inte i klienten.
const endpoint = readFileSync("supabase/functions/felsokning-ai/index.ts", "utf8");
expect(endpoint).toContain('const AI_MODELL = "claude-opus-5"');
expect(endpoint).toContain("Hitta aldrig på fakta");
expect(endpoint).toContain("aldrig en hypotes som ett konstaterat fel");
expect(endpoint).toContain("KRÄVER verifiering");
expect(endpoint).toContain("ANTHROPIC_API_KEY");
expect(endpoint).toContain('"json_schema"');
});
it("användarprompten byggs ur ärendebriefen och den nya inmatningen", () => {
+20 -75
View File
@@ -1,17 +1,15 @@
// AI-motor: Claude som diagnostikhandledare.
// AI-handledning: Claude, driven av plattformen.
//
// 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.
// AI:n hostas av tjänsten — inte av kunden. Klienten skickar ärendekontexten
// till plattformens backend (edge-funktionen felsokning-ai), som äger
// Claude API-nyckeln, systemprompten, modellvalet och svarsschemat.
// Inga AI-nycklar hanteras eller lagras någonsin i klienten.
//
// 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.
// Kommunikationsmodellen är text in, text ut: teknikerns bekräftade
// inmatning skickas med ärendebriefen, och svaret kommer tillbaka strikt
// klassificerat enligt AI-reglerna — observation / verifierat / hypotes /
// rekommendation — plus ETT konkret nästa steg.
import Anthropic from "@anthropic-ai/sdk";
import type { Brief } from "./projektioner";
export const AI_MODELL = "claude-opus-5";
@@ -35,43 +33,6 @@ export interface AiSvar {
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}`);
@@ -105,36 +66,20 @@ export function tolkaAiSvar(data: unknown): AiSvar {
return { rader, nastaSteg: svar.nastaSteg };
}
// Returnerar null i lokalt läge (ej inloggad) — AI:n är en del av tjänsten
// och nås via plattformens autentiserade backend.
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]);
): Promise<AiSvar | null> {
const { supabase } = await import("@/integrations/supabase/client");
const { data } = await supabase.auth.getSession();
if (!data.session) return null;
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));
const { data: svar, error } = await supabase.functions.invoke("felsokning-ai", {
body: { prompt: byggAnvandarPrompt(brief, metodikNamn, inmatning) },
});
if (error) throw error;
return tolkaAiSvar(svar);
}
-6
View File
@@ -12,13 +12,9 @@ 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.
@@ -38,12 +34,10 @@ 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 -8
View File
@@ -185,18 +185,19 @@ 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.
// AI:n drivs av plattformen och svarar skriftligt på varje bekräftad
// dokumentation: klassificerat (observation/verifierat/hypotes/
// rekommendation) och loggat som händelse. I lokalt läge (ej inloggad)
// 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 });
const svar = await fragaAi(brief(arende, metodik, nu), metodik.namn, inmatning);
if (svar) {
skicka({ typ: "ai_svar", rader: svar.rader, nastaSteg: svar.nastaSteg, modell: AI_MODELL });
}
setAiStatus("vilar");
} catch {
setAiStatus("fel");
@@ -245,7 +246,7 @@ function GuideFlik({
>
{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>
<p className="mb-2 font-bold text-red-400">AI-svaret kunde inte hämtas försök igen. Metodiken fortsätter som vanligt.</p>
)}
{senasteAiSvar && senasteAiSvar.handelse.typ === "ai_svar" && (
<>
+6 -35
View File
@@ -12,11 +12,9 @@ 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, aiNyckel, sattAiNyckel } = useFelsokning();
const { anvandare, arenden, sattAnvandare, laggInArende, nastaNummer } = useFelsokning();
const [namn, setNamn] = useState("");
const [filter, setFilter] = useState<Filter>("alla");
const [visaAi, setVisaAi] = useState(false);
const [nyckel, setNyckel] = useState("");
if (!anvandare) {
return (
@@ -94,39 +92,12 @@ export default function Arendelista() {
)}
<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 className="text-zinc-300">
Ingår i tjänsten och drivs av plattformen Claude ({AI_MODELL}) svarar klassificerat
dokumenterade observationer och mätvärden. Anropen går via plattformens backend; inga AI-nycklar
hanteras av verkstaden. Aktiveras automatiskt för inloggade användare; i lokalt läge guidar den
deterministiska metodiken ensam.
</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) => {