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:
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" && (
|
||||
<>
|
||||
|
||||
@@ -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 på
|
||||
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 på 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) => {
|
||||
|
||||
Reference in New Issue
Block a user