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();