From 03f1ef20f653aa3be60baf1ce095db9483dfd2f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 01:27:54 +0000 Subject: [PATCH] =?UTF-8?q?Allt=20i=20Kubernetes:=20containrar,=20manifest?= =?UTF-8?q?,=20orkestertj=C3=A4nst=20och=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt --- .github/workflows/ci.yml | 28 ++++ Dockerfile | 22 +++ docker/nginx.conf | 24 +++ docs/DRIFT.md | 68 ++++++++ infra/k8s/ai-orkester.yaml | 80 +++++++++ infra/k8s/hemligheter.exempel.yaml | 17 ++ infra/k8s/ingress.yaml | 29 ++++ infra/k8s/kustomization.yaml | 9 ++ infra/k8s/namespace.yaml | 4 + infra/k8s/web.yaml | 70 ++++++++ services/ai-orkester/Dockerfile | 13 ++ services/ai-orkester/package-lock.json | 86 ++++++++++ services/ai-orkester/package.json | 13 ++ services/ai-orkester/server.mjs | 216 +++++++++++++++++++++++++ src/felsokning/__tests__/ai.test.ts | 17 ++ src/felsokning/ai.ts | 27 +++- 16 files changed, 719 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 Dockerfile create mode 100644 docker/nginx.conf create mode 100644 docs/DRIFT.md create mode 100644 infra/k8s/ai-orkester.yaml create mode 100644 infra/k8s/hemligheter.exempel.yaml create mode 100644 infra/k8s/ingress.yaml create mode 100644 infra/k8s/kustomization.yaml create mode 100644 infra/k8s/namespace.yaml create mode 100644 infra/k8s/web.yaml create mode 100644 services/ai-orkester/Dockerfile create mode 100644 services/ai-orkester/package-lock.json create mode 100644 services/ai-orkester/package.json create mode 100644 services/ai-orkester/server.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7eacccc --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5b5e2cf --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..d8c12d7 --- /dev/null +++ b/docker/nginx.conf @@ -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; + } +} diff --git a/docs/DRIFT.md b/docs/DRIFT.md new file mode 100644 index 0000000..cbe4fc8 --- /dev/null +++ b/docs/DRIFT.md @@ -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\n2–10 pods, HPA] + I -->|/api/ai| A[ai-orkester\n2–10 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** 2–10 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. diff --git a/infra/k8s/ai-orkester.yaml b/infra/k8s/ai-orkester.yaml new file mode 100644 index 0000000..6657002 --- /dev/null +++ b/infra/k8s/ai-orkester.yaml @@ -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 } diff --git a/infra/k8s/hemligheter.exempel.yaml b/infra/k8s/hemligheter.exempel.yaml new file mode 100644 index 0000000..82a10d4 --- /dev/null +++ b/infra/k8s/hemligheter.exempel.yaml @@ -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 diff --git a/infra/k8s/ingress.yaml b/infra/k8s/ingress.yaml new file mode 100644 index 0000000..7dbc99d --- /dev/null +++ b/infra/k8s/ingress.yaml @@ -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 } } diff --git a/infra/k8s/kustomization.yaml b/infra/k8s/kustomization.yaml new file mode 100644 index 0000000..496d2df --- /dev/null +++ b/infra/k8s/kustomization.yaml @@ -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). diff --git a/infra/k8s/namespace.yaml b/infra/k8s/namespace.yaml new file mode 100644 index 0000000..1894df4 --- /dev/null +++ b/infra/k8s/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: guidad-felsokning diff --git a/infra/k8s/web.yaml b/infra/k8s/web.yaml new file mode 100644 index 0000000..b8b4ed9 --- /dev/null +++ b/infra/k8s/web.yaml @@ -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 } diff --git a/services/ai-orkester/Dockerfile b/services/ai-orkester/Dockerfile new file mode 100644 index 0000000..2b63f02 --- /dev/null +++ b/services/ai-orkester/Dockerfile @@ -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"] diff --git a/services/ai-orkester/package-lock.json b/services/ai-orkester/package-lock.json new file mode 100644 index 0000000..5e3cd50 --- /dev/null +++ b/services/ai-orkester/package-lock.json @@ -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" + } + } +} diff --git a/services/ai-orkester/package.json b/services/ai-orkester/package.json new file mode 100644 index 0000000..4a7e0a2 --- /dev/null +++ b/services/ai-orkester/package.json @@ -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" + } +} diff --git a/services/ai-orkester/server.mjs b/services/ai-orkester/server.mjs new file mode 100644 index 0000000..91ccc4b --- /dev/null +++ b/services/ai-orkester/server.mjs @@ -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}`); + }); +} diff --git a/src/felsokning/__tests__/ai.test.ts b/src/felsokning/__tests__/ai.test.ts index 5f9606c..3f844c3 100644 --- a/src/felsokning/__tests__/ai.test.ts +++ b/src/felsokning/__tests__/ai.test.ts @@ -19,6 +19,23 @@ describe("AI-orkestern", () => { 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", () => { expect(ENDPOINT).toContain("Hitta aldrig på fakta"); expect(ENDPOINT).toContain("aldrig en hypotes som ett konstaterat fel"); diff --git a/src/felsokning/ai.ts b/src/felsokning/ai.ts index 7ba34ed..90f6d7f 100644 --- a/src/felsokning/ai.ts +++ b/src/felsokning/ai.ts @@ -89,6 +89,10 @@ export function tolkaAiSvar(data: unknown): AiSvar { 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 // plattformens autentiserade backend. 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(); if (!data.session) return null; - const { data: resultat, error } = await supabase.functions.invoke("felsokning-ai", { - body: { uppgift, prompt }, - }); - if (error) throw error; + let resultat: unknown; + if (ORKESTER_URL) { + const res = await fetch(`${ORKESTER_URL}/api/ai`, { + 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 }; if (typeof modell !== "string") throw new Error("Oväntat svar från AI-endpointen"); return { modell, svar };