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:
@@ -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
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
@@ -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 } }
|
||||||
@@ -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).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: guidad-felsokning
|
||||||
@@ -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 }
|
||||||
@@ -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"]
|
||||||
Generated
+86
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user