Allt i Kubernetes: containrar, manifest, orkestertjänst och CI

Målarkitekturen ur Master Prompt som kod:

- Webben containeriserad (flerstegs-Dockerfile → oprivilegierad nginx
  med SPA-fallback, hashade assets cachas hårt, säkerhetsheaders).
- AI-orkestern som egen tjänst (services/ai-orkester): samma routing
  och AI-regler som edge-funktionen men körbar som pod — egen
  HS256-JWT-verifiering (fail closed), /halsa, storleksgränser,
  read-only container som kör som node-användaren. Livetestad:
  hälsokontroll ok, 401 utan/med ogiltig token, 400-validering bakom
  giltig JWT.
- Kubernetes-manifest (infra/k8s, kustomize): namespace, Deployments
  med readiness/liveness och åtstramad securityContext, Services,
  HPA 2–10 pods på 70 % CPU, PodDisruptionBudgets, Ingress med TLS
  via cert-manager, secret-mall (riktiga värden skapas utanför git).
- Klienten väljer AI-väg vid bygget: VITE_AI_ORKESTER_URL → tjänsten
  i klustret, annars Supabase-edge-funktionen. Samma orkester i båda,
  låst av paritetstest.
- CI-arbetsflöde: tester + produktionsbygge + verifierande
  containerbyggen av båda bilderna på varje push/PR.
- docs/DRIFT.md: arkitekturdiagram, driftsättningssteg, skalning,
  och vad som återstår mot full självhostning.

Verifierat: 27 vitest-tester gröna, produktionsbygge ok, alla
K8s-manifest YAML-validerade, orkestertjänsten livetestad,
Playwright-röktest grönt.

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:27:54 +00:00
parent 8b79e3dfd5
commit 03f1ef20f6
16 changed files with 719 additions and 4 deletions
+28
View File
@@ -0,0 +1,28 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test-och-bygg:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci --no-audit --no-fund
- run: npx vitest run
- run: npm run build
container-byggen:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Bygger båda bilderna för att verifiera Dockerfiler — publicering
# till registry sker i ett separat, behörighetsstyrt flöde.
- run: docker build -t guidad-felsokning-web .
- run: docker build -t guidad-felsokning-ai-orkester services/ai-orkester
+22
View File
@@ -0,0 +1,22 @@
# Guidad Felsökning — webbklienten som statisk SPA bakom nginx (oprivilegierad).
# Vite-variabler bakas in vid byggtillfället — skicka som build args.
FROM node:22-alpine AS bygg
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
ARG VITE_SUPABASE_URL
ARG VITE_SUPABASE_PUBLISHABLE_KEY
ARG VITE_SUPABASE_PROJECT_ID
ARG VITE_AI_ORKESTER_URL
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL \
VITE_SUPABASE_PUBLISHABLE_KEY=$VITE_SUPABASE_PUBLISHABLE_KEY \
VITE_SUPABASE_PROJECT_ID=$VITE_SUPABASE_PROJECT_ID \
VITE_AI_ORKESTER_URL=$VITE_AI_ORKESTER_URL
RUN npm run build
FROM nginxinc/nginx-unprivileged:1.27-alpine
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=bygg /app/dist /usr/share/nginx/html
EXPOSE 8080
+24
View File
@@ -0,0 +1,24 @@
# SPA-servering: alla rutter faller tillbaka till index.html.
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# Byggda tillgångar är innehållshashade — cacha hårt.
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location / {
try_files $uri /index.html;
}
}
+68
View File
@@ -0,0 +1,68 @@
# Guidad Felsökning Drift i Kubernetes
Målarkitekturen ur [Master Prompt](MASTER-PROMPT.md) som kod: allt körbart i kluster, med secrets för plattformsnycklarna och CI som verifierar både kod och containerbyggen.
## Arkitektur
```mermaid
flowchart LR
T[Tekniker] -->|HTTPS| I[Ingress + TLS]
I -->|/| W[web\n210 pods, HPA]
I -->|/api/ai| A[ai-orkester\n210 pods, HPA]
A -->|Claude API| C[(Anthropic)]
W & A -.->|auth + data| S[(Supabase\nPostgres · Auth)]
K[Secret: felsokning-hemligheter] --> A
```
| Komponent | Vad | Var |
| --- | --- | --- |
| `web` | SPA:n bakom oprivilegierad nginx (`Dockerfile`, `docker/nginx.conf`) | Deployment + Service + HPA + PDB |
| `ai-orkester` | AI-orkestern som egen tjänst (`services/ai-orkester`) — samma routing som edge-funktionen, med egen JWT-verifiering och `/halsa` | Deployment + Service + HPA + PDB |
| Hemligheter | `ANTHROPIC_API_KEY` + `SUPABASE_JWT_SECRET` | Secret `felsokning-hemligheter` — aldrig i bilder eller manifest |
| Databas & auth | Postgres (händelselogg, RLS, `hamta_delat_arende`) och inloggning | Supabase — managerad, eller självhostad Supabase i klustret när det steget tas |
Klienten väljer AI-väg vid byggtillfället: med `VITE_AI_ORKESTER_URL` satt går anropen till orkestertjänsten i klustret; utan den används Supabase-edge-funktionen. Samma orkester (modeller, systemprompter, scheman) i båda.
## Driftsätta
```sh
# 1. Bygg och publicera bilderna (ersätt registry i infra/k8s/*.yaml)
docker build -t ghcr.io/ORG/guidad-felsokning-web \
--build-arg VITE_SUPABASE_URL=\
--build-arg VITE_SUPABASE_PUBLISHABLE_KEY=\
--build-arg VITE_SUPABASE_PROJECT_ID=\
--build-arg VITE_AI_ORKESTER_URL=https://app.exempel.se .
docker build -t ghcr.io/ORG/guidad-felsokning-ai-orkester services/ai-orkester
docker push ghcr.io/ORG/guidad-felsokning-web
docker push ghcr.io/ORG/guidad-felsokning-ai-orkester
# 2. Skapa secret:en (eller använd External Secrets/Sealed Secrets)
kubectl create namespace guidad-felsokning
kubectl -n guidad-felsokning create secret generic felsokning-hemligheter \
--from-literal=anthropic-api-key='sk-ant-…' \
--from-literal=supabase-jwt-secret='…'
# 3. Applicera manifesten
kubectl apply -k infra/k8s
# 4. Verifiera
kubectl -n guidad-felsokning get pods
curl https://app.exempel.se/halsa # → {"status":"ok"}
```
Byt domän och cert-issuer i `infra/k8s/ingress.yaml`.
## Skalning och robusthet
- **HPA** 210 pods per tjänst på 70 % CPU; **PDB** håller minst en pod uppe vid noddränering.
- Båda containrarna kör **non-root** utan capabilities; orkestern dessutom med read-only rotfilsystem.
- **Hälsokontroller**: nginx svarar på `/`, orkestern på `/halsa` (readiness + liveness + Docker HEALTHCHECK).
- Orkestern **failar closed**: utan nyckel/JWT-hemlighet svarar den 503, utan giltig JWT 401.
## CI
`.github/workflows/ci.yml` kör tester + produktionsbygge och verifierar båda Dockerfilerna på varje push/PR. Publicering till registry och `kubectl apply` läggs i ett separat, behörighetsstyrt deploy-flöde (t.ex. environments med godkännande eller GitOps via Argo CD/Flux).
## Kvar mot full självhostning
Synk, Live Share-funktionen och inloggningen går fortfarande mot Supabase (managerad Postgres + Auth). Nästa steg för "allt i kluster" är självhostad Supabase eller ett eget API-lager över Postgres i klustret — händelsemodellen är redan API-klar, så det bytet påverkar inte klienten.
+80
View File
@@ -0,0 +1,80 @@
# AI-orkestern: plattformens AI-endpoint. Claude API-nyckeln och
# JWT-hemligheten kommer från secret:en felsokning-hemligheter —
# de finns aldrig i bilder, manifest eller klient.
apiVersion: apps/v1
kind: Deployment
metadata:
name: ai-orkester
namespace: guidad-felsokning
labels: { app: ai-orkester }
spec:
replicas: 2
selector:
matchLabels: { app: ai-orkester }
template:
metadata:
labels: { app: ai-orkester }
spec:
securityContext:
runAsNonRoot: true
containers:
- name: ai-orkester
image: ghcr.io/ORGANISATION/guidad-felsokning-ai-orkester:latest # ersätt med ert registry
ports:
- containerPort: 8080
env:
- name: ANTHROPIC_API_KEY
valueFrom:
secretKeyRef: { name: felsokning-hemligheter, key: anthropic-api-key }
- name: SUPABASE_JWT_SECRET
valueFrom:
secretKeyRef: { name: felsokning-hemligheter, key: supabase-jwt-secret }
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { cpu: "1", memory: 256Mi }
readinessProbe:
httpGet: { path: /halsa, port: 8080 }
initialDelaySeconds: 3
livenessProbe:
httpGet: { path: /halsa, port: 8080 }
periodSeconds: 15
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: [ALL] }
---
apiVersion: v1
kind: Service
metadata:
name: ai-orkester
namespace: guidad-felsokning
spec:
selector: { app: ai-orkester }
ports:
- port: 80
targetPort: 8080
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: ai-orkester
namespace: guidad-felsokning
spec:
scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: ai-orkester }
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target: { type: Utilization, averageUtilization: 70 }
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: ai-orkester
namespace: guidad-felsokning
spec:
minAvailable: 1
selector:
matchLabels: { app: ai-orkester }
+17
View File
@@ -0,0 +1,17 @@
# EXEMPEL — checka aldrig in riktiga värden. Skapa hellre direkt:
#
# kubectl -n guidad-felsokning create secret generic felsokning-hemligheter \
# --from-literal=anthropic-api-key='sk-ant-…' \
# --from-literal=supabase-jwt-secret='…'
#
# I produktion: använd extern secrets-hantering (External Secrets Operator,
# Sealed Secrets eller molnleverantörens secret manager).
apiVersion: v1
kind: Secret
metadata:
name: felsokning-hemligheter
namespace: guidad-felsokning
type: Opaque
stringData:
anthropic-api-key: ERSATT-MIG
supabase-jwt-secret: ERSATT-MIG
+29
View File
@@ -0,0 +1,29 @@
# Ingress med TLS (cert-manager): webben på /, AI-orkestern på /api/ai.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: guidad-felsokning
namespace: guidad-felsokning
annotations:
cert-manager.io/cluster-issuer: letsencrypt # ersätt med er issuer
spec:
ingressClassName: nginx
tls:
- hosts: [app.exempel.se] # ersätt med er domän
secretName: guidad-felsokning-tls
rules:
- host: app.exempel.se # ersätt med er domän
http:
paths:
- path: /api/ai
pathType: Prefix
backend:
service: { name: ai-orkester, port: { number: 80 } }
- path: /halsa
pathType: Prefix
backend:
service: { name: ai-orkester, port: { number: 80 } }
- path: /
pathType: Prefix
backend:
service: { name: web, port: { number: 80 } }
+9
View File
@@ -0,0 +1,9 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- web.yaml
- ai-orkester.yaml
- ingress.yaml
# hemligheter.exempel.yaml appliceras medvetet inte — skapa secret:en
# separat (se kommentaren i den filen).
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: guidad-felsokning
+70
View File
@@ -0,0 +1,70 @@
# Webbklienten: statisk SPA bakom oprivilegierad nginx.
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
namespace: guidad-felsokning
labels: { app: web }
spec:
replicas: 2
selector:
matchLabels: { app: web }
template:
metadata:
labels: { app: web }
spec:
securityContext:
runAsNonRoot: true
containers:
- name: web
image: ghcr.io/ORGANISATION/guidad-felsokning-web:latest # ersätt med ert registry
ports:
- containerPort: 8080
resources:
requests: { cpu: 50m, memory: 64Mi }
limits: { cpu: 250m, memory: 128Mi }
readinessProbe:
httpGet: { path: /, port: 8080 }
initialDelaySeconds: 3
livenessProbe:
httpGet: { path: /, port: 8080 }
periodSeconds: 15
securityContext:
allowPrivilegeEscalation: false
capabilities: { drop: [ALL] }
---
apiVersion: v1
kind: Service
metadata:
name: web
namespace: guidad-felsokning
spec:
selector: { app: web }
ports:
- port: 80
targetPort: 8080
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: web
namespace: guidad-felsokning
spec:
scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: web }
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target: { type: Utilization, averageUtilization: 70 }
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: web
namespace: guidad-felsokning
spec:
minAvailable: 1
selector:
matchLabels: { app: web }
+13
View File
@@ -0,0 +1,13 @@
# AI-orkestern — plattformens AI-endpoint som pod.
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force
COPY server.mjs ./
ENV NODE_ENV=production PORT=8080
USER node
EXPOSE 8080
HEALTHCHECK CMD wget -qO- http://127.0.0.1:8080/halsa || exit 1
CMD ["node", "server.mjs"]
+86
View File
@@ -0,0 +1,86 @@
{
"name": "ai-orkester",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ai-orkester",
"version": "1.0.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.115.0"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.115.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.115.0.tgz",
"integrity": "sha512-BJrFIVyjNuU8lfDyIJTvlRYzgQg+zEl78BxE7fq8esULsGz9IRQvGtW5spq3tydmtjQb/GFdooKGdGsetpx+lQ==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1",
"standardwebhooks": "^1.0.0"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "ai-orkester",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "server.mjs",
"scripts": {
"start": "node server.mjs"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.115.0"
}
}
+216
View File
@@ -0,0 +1,216 @@
// AI-orkestern som fristående tjänst — plattformens K8s-native AI-endpoint.
//
// Samma orkester som edge-funktionen (supabase/functions/felsokning-ai),
// men körbar som pod i Kubernetes: Claude API-nyckeln och Supabase
// JWT-hemligheten kommer från secrets, hälsokontroll på /halsa, och
// tjänsten verifierar användarens JWT själv (HS256) eftersom den inte
// står bakom Supabase-gatewayen.
//
// Miljövariabler:
// ANTHROPIC_API_KEY Claude-nyckel (plattformshemlighet, krävs)
// SUPABASE_JWT_SECRET JWT-hemlighet för verifiering (krävs — fail closed)
// PORT default 8080
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
import Anthropic from "@anthropic-ai/sdk";
const PORT = Number(process.env.PORT ?? 8080);
const MAX_PROMPT_LANGD = 40000;
const MAX_KROPP = 256 * 1024;
const GRUND_REGLER = `Du arbetar i Guidad Felsökning, en professionell diagnostikplattform för tekniker. Du är inte en AI-mekaniker: du ersätter aldrig teknikerns kompetens eller tillverkarens dokumentation.
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.
- Svara på svenska, konsekvent, kortfattat och metodiskt. "nastaSteg" är EN konkret, verifierbar åtgärd.`;
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,
};
const METODIK_SCHEMA = {
type: "object",
properties: {
metodikId: { type: "string", enum: ["vibration", "elsystem", "generisk"] },
},
required: ["metodikId"],
additionalProperties: false,
};
// Orkestern: en modell per uppgiftstyp (samma routing som edge-funktionen).
const ORKESTER = {
handledning: {
modell: "claude-sonnet-5",
effort: "medium",
maxTokens: 1024,
system: `${GRUND_REGLER}\n\nUppgift: Du är teknikerns digitala felsökningshandledare. Teknikern har just dokumenterat något nytt — svara på det i ljuset av ärendebriefen. En kontroll i taget, inte långa utläggningar: max fyra rader plus nästa steg.`,
schema: SVARS_SCHEMA,
},
granskning: {
modell: "claude-opus-5",
effort: "high",
maxTokens: 2048,
system: `${GRUND_REGLER}\n\nUppgift: Granska HELA underlaget i ärendet. Leta specifikt efter (1) motsägelser mellan observationer eller mätvärden, (2) luckor — kontroller som borde vara gjorda givet symptombilden men saknas, (3) förhastade slutsatser som saknar stöd. Rapportera motsägelser och luckor som "observation", möjliga felorsaker som underlaget antyder som "hypotes", och det viktigaste att åtgärda som "rekommendation". Max sex rader.`,
schema: SVARS_SCHEMA,
},
sammanfattning: {
modell: "claude-sonnet-5",
effort: "low",
maxTokens: 1024,
system: `${GRUND_REGLER}\n\nUppgift: En tekniker lämnar över ärendet. Komplettera överlämningen med det som inte syns i checklistorna: risker och osäkerheter i underlaget, klassificerade som rader. "nastaSteg" är det första nästa tekniker bör göra. Max fyra rader.`,
schema: SVARS_SCHEMA,
},
metodikval: {
modell: "claude-haiku-4-5",
maxTokens: 256,
system: `Du klassificerar en felbeskrivning från en verkstad till EN felsökningsmetodik:
- "vibration": vibrationer, skakningar eller obalans under körning.
- "elsystem": elektriska fel — reläer, säkringar, spänning, batteri, belysning, givare, strömförsörjning.
- "generisk": allt annat, eller när det är oklart.
Gissa inte: välj "generisk" om beskrivningen inte tydligt hör till en specifik metodik.`,
schema: METODIK_SCHEMA,
},
};
// Minimal HS256-JWT-verifiering (Supabase legacy JWT secret) utan beroenden.
export function verifieraJwt(token, hemlighet) {
const delar = token.split(".");
if (delar.length !== 3) return null;
const [huvud, kropp, signatur] = delar;
const forvantad = createHmac("sha256", hemlighet)
.update(`${huvud}.${kropp}`)
.digest("base64url");
const a = Buffer.from(signatur);
const b = Buffer.from(forvantad);
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
try {
const anspServer = JSON.parse(Buffer.from(kropp, "base64url").toString("utf8"));
if (typeof anspServer.exp === "number" && anspServer.exp * 1000 < Date.now()) return null;
return anspServer;
} catch {
return null;
}
}
function svara(res, status, kropp) {
const data = JSON.stringify(kropp);
res.writeHead(status, {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, content-type",
});
res.end(data);
}
async function lasKropp(req) {
const bitar = [];
let storlek = 0;
for await (const bit of req) {
storlek += bit.length;
if (storlek > MAX_KROPP) throw new Error("för stor kropp");
bitar.push(bit);
}
return JSON.parse(Buffer.concat(bitar).toString("utf8"));
}
export function skapaServer() {
return createServer(async (req, res) => {
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
});
return res.end();
}
if (req.method === "GET" && req.url === "/halsa") {
return svara(res, 200, { status: "ok" });
}
if (req.method !== "POST" || req.url !== "/api/ai") {
return svara(res, 404, { error: "Okänd resurs." });
}
const apiNyckel = process.env.ANTHROPIC_API_KEY;
const jwtHemlighet = process.env.SUPABASE_JWT_SECRET;
if (!apiNyckel || !jwtHemlighet) {
return svara(res, 503, { error: "AI-tjänsten är inte konfigurerad." });
}
const auth = req.headers.authorization ?? "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
if (!token || !verifieraJwt(token, jwtHemlighet)) {
return svara(res, 401, { error: "Inloggning krävs." });
}
let uppgift, prompt;
try {
({ uppgift, prompt } = await lasKropp(req));
} catch {
return svara(res, 400, { error: "Ogiltig förfrågan." });
}
const konfig = ORKESTER[uppgift];
if (!konfig) return svara(res, 400, { error: "Okänd uppgift." });
if (typeof prompt !== "string" || prompt.trim().length === 0) {
return svara(res, 400, { error: "prompt saknas." });
}
if (prompt.length > MAX_PROMPT_LANGD) {
return svara(res, 400, { error: "prompt är för lång." });
}
try {
const klient = new Anthropic({ apiKey: apiNyckel });
const svar = await klient.beta.messages.create({
model: konfig.modell,
max_tokens: konfig.maxTokens,
output_config: {
...(konfig.effort ? { effort: konfig.effort } : {}),
format: { type: "json_schema", schema: konfig.schema },
},
betas: ["server-side-fallback-2026-07-01"],
fallbacks: "default",
system: [{ type: "text", text: konfig.system, cache_control: { type: "ephemeral" } }],
messages: [{ role: "user", content: prompt }],
});
if (svar.stop_reason === "refusal") {
return svara(res, 502, { error: "AI-tjänsten avböjde förfrågan." });
}
const textBlock = svar.content.find((block) => block.type === "text");
if (!textBlock) return svara(res, 502, { error: "AI-svaret saknade innehåll." });
return svara(res, 200, { modell: konfig.modell, svar: JSON.parse(textBlock.text) });
} catch (fel) {
console.error("ai-orkester:", fel);
return svara(res, 500, { error: "AI-anropet misslyckades." });
}
});
}
if (process.env.NODE_ENV !== "test") {
skapaServer().listen(PORT, () => {
console.log(`ai-orkester lyssnar på :${PORT}`);
});
}
+17
View File
@@ -19,6 +19,23 @@ describe("AI-orkestern", () => {
expect(ENDPOINT).toContain("ANTHROPIC_API_KEY"); expect(ENDPOINT).toContain("ANTHROPIC_API_KEY");
}); });
it("K8s-tjänsten (services/ai-orkester) kör samma orkester som edge-funktionen", () => {
const tjanst = readFileSync("services/ai-orkester/server.mjs", "utf8");
for (const bit of [
'"claude-sonnet-5"',
'"claude-opus-5"',
'"claude-haiku-4-5"',
"handledning:",
"granskning:",
"sammanfattning:",
"metodikval:",
"Hitta aldrig på fakta",
"SUPABASE_JWT_SECRET",
]) {
expect(tjanst).toContain(bit);
}
});
it("endpointen kodar AI-reglerna i grundprompten", () => { it("endpointen kodar AI-reglerna i grundprompten", () => {
expect(ENDPOINT).toContain("Hitta aldrig på fakta"); expect(ENDPOINT).toContain("Hitta aldrig på fakta");
expect(ENDPOINT).toContain("aldrig en hypotes som ett konstaterat fel"); expect(ENDPOINT).toContain("aldrig en hypotes som ett konstaterat fel");
+23 -4
View File
@@ -89,6 +89,10 @@ export function tolkaAiSvar(data: unknown): AiSvar {
return { rader, nastaSteg: svar.nastaSteg }; return { rader, nastaSteg: svar.nastaSteg };
} }
// I Kubernetes-driften pekar VITE_AI_ORKESTER_URL på orkestertjänsten
// (services/ai-orkester); utan den används Supabase-edge-funktionen.
const ORKESTER_URL = (import.meta.env.VITE_AI_ORKESTER_URL as string | undefined)?.replace(/\/$/, "");
// Returnerar null i lokalt läge (ej inloggad) — orkestern nås via // Returnerar null i lokalt läge (ej inloggad) — orkestern nås via
// plattformens autentiserade backend. // plattformens autentiserade backend.
async function anropa(uppgift: AiUppgift, prompt: string): Promise<{ modell: string; svar: unknown } | null> { async function anropa(uppgift: AiUppgift, prompt: string): Promise<{ modell: string; svar: unknown } | null> {
@@ -96,10 +100,25 @@ async function anropa(uppgift: AiUppgift, prompt: string): Promise<{ modell: str
const { data } = await supabase.auth.getSession(); const { data } = await supabase.auth.getSession();
if (!data.session) return null; if (!data.session) return null;
const { data: resultat, error } = await supabase.functions.invoke("felsokning-ai", { let resultat: unknown;
body: { uppgift, prompt }, if (ORKESTER_URL) {
}); const res = await fetch(`${ORKESTER_URL}/api/ai`, {
if (error) throw error; method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${data.session.access_token}`,
},
body: JSON.stringify({ uppgift, prompt }),
});
if (!res.ok) throw new Error(`AI-orkestern svarade ${res.status}`);
resultat = await res.json();
} else {
const { data: svar, error } = await supabase.functions.invoke("felsokning-ai", {
body: { uppgift, prompt },
});
if (error) throw error;
resultat = svar;
}
const { modell, svar } = resultat as { modell?: string; svar?: unknown }; const { modell, svar } = resultat as { modell?: string; svar?: unknown };
if (typeof modell !== "string") throw new Error("Oväntat svar från AI-endpointen"); if (typeof modell !== "string") throw new Error("Oväntat svar från AI-endpointen");
return { modell, svar }; return { modell, svar };