diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index dc90a24..6db6e73 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -54,7 +54,7 @@ services: networks: - sidelinja-dev healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:3000/database/ping || exit 1"] + test: ["CMD-SHELL", "curl -f http://localhost:3000/v1/ping || exit 1"] interval: 10s timeout: 5s retries: 5 @@ -68,8 +68,9 @@ services: environment: LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY} GEMINI_API_KEY: ${GEMINI_API_KEY} + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY} volumes: - - ./config/litellm/config.yaml:/etc/litellm/config.yaml:ro + - ./config/litellm:/etc/litellm ports: - "127.0.0.1:4000:4000" networks: diff --git a/migrations/0007_ai_config.sql b/migrations/0007_ai_config.sql new file mode 100644 index 0000000..62ebd95 --- /dev/null +++ b/migrations/0007_ai_config.sql @@ -0,0 +1,78 @@ +-- 0007_ai_config.sql +-- AI-administrasjon: modellaliaser, leverandører, jobbruting og tokenlogging. +-- PG som source of truth for LiteLLM config-generering. + +BEGIN; + +-- === Modellaliaser (globale, ikke per workspace) === +CREATE TABLE ai_model_aliases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + alias TEXT NOT NULL UNIQUE, -- f.eks. "sidelinja/rutine" + description TEXT, -- kort beskrivelse av aliaset + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- === Leverandør-modeller per alias med prioritet === +CREATE TABLE ai_model_providers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + alias_id UUID NOT NULL REFERENCES ai_model_aliases(id) ON DELETE CASCADE, + priority INT NOT NULL DEFAULT 1, -- lavere = høyere prioritet i LiteLLM + litellm_model TEXT NOT NULL, -- f.eks. "gemini/gemini-2.5-flash-lite" + api_key_env TEXT NOT NULL, -- f.eks. "GEMINI_API_KEY" + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (alias_id, priority) +); + +-- === Jobbtype → alias mapping === +CREATE TABLE ai_job_routing ( + job_type TEXT PRIMARY KEY, -- f.eks. "ai_text_process" + alias_id UUID NOT NULL REFERENCES ai_model_aliases(id) ON DELETE RESTRICT, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- === Per-kall token-logging (workspace-scopet) === +CREATE TABLE ai_usage_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + job_id UUID REFERENCES job_queue(id) ON DELETE SET NULL, + job_type TEXT NOT NULL, + model_alias TEXT NOT NULL, -- alias brukt (snapshot) + model_actual TEXT, -- faktisk modell fra respons + prompt_tokens INT NOT NULL DEFAULT 0, + completion_tokens INT NOT NULL DEFAULT 0, + total_tokens INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_ai_usage_log_workspace ON ai_usage_log (workspace_id, created_at DESC); +CREATE INDEX idx_ai_usage_log_alias ON ai_usage_log (model_alias, created_at DESC); + +-- === Seed-data: matcher nåværende config.yaml === + +-- Aliaser +INSERT INTO ai_model_aliases (alias, description) VALUES + ('sidelinja/rutine', 'Billig, høyt volum — tekstrensing, faktauthenting, oversettelse'), + ('sidelinja/resonering', 'Presis, lav volum — kompleks analyse, research'); + +-- Leverandører for sidelinja/rutine +INSERT INTO ai_model_providers (alias_id, priority, litellm_model, api_key_env) VALUES + ((SELECT id FROM ai_model_aliases WHERE alias = 'sidelinja/rutine'), 1, 'gemini/gemini-2.5-flash-lite', 'GEMINI_API_KEY'), + ((SELECT id FROM ai_model_aliases WHERE alias = 'sidelinja/rutine'), 2, 'gemini/gemini-2.5-flash', 'GEMINI_API_KEY'), + ((SELECT id FROM ai_model_aliases WHERE alias = 'sidelinja/rutine'), 3, 'openrouter/google/gemini-2.5-flash-preview', 'OPENROUTER_API_KEY'); + +-- Leverandører for sidelinja/resonering +INSERT INTO ai_model_providers (alias_id, priority, litellm_model, api_key_env) VALUES + ((SELECT id FROM ai_model_aliases WHERE alias = 'sidelinja/resonering'), 1, 'openrouter/anthropic/claude-sonnet-4', 'OPENROUTER_API_KEY'), + ((SELECT id FROM ai_model_aliases WHERE alias = 'sidelinja/resonering'), 2, 'gemini/gemini-2.5-flash', 'GEMINI_API_KEY'); + +-- Jobbruting +INSERT INTO ai_job_routing (job_type, alias_id, description) VALUES + ('ai_text_process', (SELECT id FROM ai_model_aliases WHERE alias = 'sidelinja/rutine'), 'Tekstrensing og AI-behandling via ✨-knappen'); + +COMMIT; diff --git a/web/package-lock.json b/web/package-lock.json index 9f04f89..2e708b0 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -25,6 +25,7 @@ "vite": "^8.0.0" }, "devDependencies": { + "@types/node": "^25.5.0", "svelte-check": "^4.4.5" } }, @@ -1435,6 +1436,16 @@ "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -2777,6 +2788,13 @@ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "license": "MIT" }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, + "license": "MIT" + }, "node_modules/url-polyfill": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz", diff --git a/web/package.json b/web/package.json index 5c6dff5..63b4f90 100644 --- a/web/package.json +++ b/web/package.json @@ -27,6 +27,7 @@ "vite": "^8.0.0" }, "devDependencies": { + "@types/node": "^25.5.0", "svelte-check": "^4.4.5" } } diff --git a/web/src/routes/admin/ai/+page.server.ts b/web/src/routes/admin/ai/+page.server.ts new file mode 100644 index 0000000..61027e1 --- /dev/null +++ b/web/src/routes/admin/ai/+page.server.ts @@ -0,0 +1,41 @@ +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { sql } from '$lib/server/db'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!locals.workspace) error(404); + + const aliases = await sql` + SELECT id, alias, description, is_active, created_at + FROM ai_model_aliases + ORDER BY alias + `; + + const providers = await sql` + SELECT id, alias_id, priority, litellm_model, api_key_env, is_active + FROM ai_model_providers + ORDER BY alias_id, priority + `; + + const routing = await sql` + SELECT r.job_type, r.alias_id, r.description, a.alias + FROM ai_job_routing r + JOIN ai_model_aliases a ON a.id = r.alias_id + ORDER BY r.job_type + `; + + const usage = await sql` + SELECT + model_alias, + count(*)::int AS call_count, + sum(prompt_tokens)::int AS prompt_tokens, + sum(completion_tokens)::int AS completion_tokens, + sum(total_tokens)::int AS total_tokens + FROM ai_usage_log + WHERE created_at > now() - interval '30 days' + GROUP BY model_alias + ORDER BY total_tokens DESC + `; + + return { aliases, providers, routing, usage }; +}; diff --git a/web/src/routes/admin/ai/+page.svelte b/web/src/routes/admin/ai/+page.svelte new file mode 100644 index 0000000..98fa617 --- /dev/null +++ b/web/src/routes/admin/ai/+page.svelte @@ -0,0 +1,668 @@ + + +
+
+

AI-administrasjon

+ {aliases.length} aliaser / {providers.length} leverandører / {totalTokens.toLocaleString('nb-NO')} tokens (30d) +
+ + {#if errorMsg} +
{errorMsg}
+ {/if} + + +
+

Modellaliaser

+
+
+ Alias + Beskrivelse + Leverandører + Aktiv + +
+ + {#each aliases as alias (alias.id)} + {@const ap = providersForAlias(alias.id)} +
+ (expandedAlias = expandedAlias === alias.id ? null : alias.id)} + > + {alias.alias} + + {alias.description ?? '—'} + {ap.length} + + + + + {#if saving === alias.id} + ... + {:else if saved === alias.id} + OK + {/if} + +
+ + {#if expandedAlias === alias.id} +
+ {#each ap as provider (provider.id)} +
+ #{provider.priority} + {provider.litellm_model} + {provider.api_key_env} + + + + + + {#if saving === provider.id} + ... + {:else if saved === provider.id} + OK + {/if} + +
+ {/each} + +
+ + + + +
+
+ {/if} + {/each} +
+ +
+ + + +
+
+ + +
+

Jobbruting

+
+
+ Jobbtype + Modellalias + Beskrivelse + +
+ + {#each routing as route (route.job_type)} +
+ {route.job_type} + + + + {route.description ?? '—'} + + {#if saving === route.job_type} + ... + {:else if saved === route.job_type} + OK + {/if} + +
+ {/each} +
+ +
+ + + + +
+
+ + +
+

Tokenforbruk (siste 30 dager)

+ {#if usage.length === 0} +

Ingen AI-kall registrert ennå.

+ {:else} +
+
+ Modellalias + Kall + Prompt + Completion + Totalt +
+ + {#each usage as row} +
+ {row.model_alias} + {row.call_count} + {row.prompt_tokens.toLocaleString('nb-NO')} + {row.completion_tokens.toLocaleString('nb-NO')} + {row.total_tokens.toLocaleString('nb-NO')} +
+ {/each} +
+ {/if} +
+ + +
+

Konfigurasjon

+
+ + {#if configMsg} + {configMsg} + {/if} +

Genererer LiteLLM config.yaml fra databasen. AI Gateway (LiteLLM) må restartes for å lese ny config.

+
+
+
+ + diff --git a/web/src/routes/api/admin/ai/aliases/+server.ts b/web/src/routes/api/admin/ai/aliases/+server.ts new file mode 100644 index 0000000..3f35544 --- /dev/null +++ b/web/src/routes/api/admin/ai/aliases/+server.ts @@ -0,0 +1,19 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { sql } from '$lib/server/db'; + +/** POST — opprett nytt alias */ +export const POST: RequestHandler = async ({ request, locals }) => { + if (!locals.workspace || !locals.user) error(401); + + const { alias, description } = await request.json(); + if (!alias || typeof alias !== 'string') error(400, 'alias er påkrevd'); + + const [row] = await sql` + INSERT INTO ai_model_aliases (alias, description) + VALUES (${alias}, ${description ?? null}) + RETURNING id, alias, description, is_active, created_at + `; + + return json(row, { status: 201 }); +}; diff --git a/web/src/routes/api/admin/ai/aliases/[id]/+server.ts b/web/src/routes/api/admin/ai/aliases/[id]/+server.ts new file mode 100644 index 0000000..5ee83a1 --- /dev/null +++ b/web/src/routes/api/admin/ai/aliases/[id]/+server.ts @@ -0,0 +1,39 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { sql } from '$lib/server/db'; + +/** PATCH — oppdater alias */ +export const PATCH: RequestHandler = async ({ params, request, locals }) => { + if (!locals.workspace || !locals.user) error(401); + + const body = await request.json(); + const updates: Record = {}; + if ('description' in body) updates.description = body.description; + if ('is_active' in body) updates.is_active = body.is_active; + + const [row] = await sql` + UPDATE ai_model_aliases SET + description = COALESCE(${body.description ?? null}, description), + is_active = COALESCE(${body.is_active ?? null}, is_active), + updated_at = now() + WHERE id = ${params.id}::uuid + RETURNING id, alias, description, is_active + `; + + if (!row) error(404, 'Alias ikke funnet'); + return json(row); +}; + +/** DELETE — slett alias (feiler hvis brukt i routing) */ +export const DELETE: RequestHandler = async ({ params, locals }) => { + if (!locals.workspace || !locals.user) error(401); + + try { + await sql`DELETE FROM ai_model_aliases WHERE id = ${params.id}::uuid`; + } catch (e: any) { + if (e.code === '23503') error(409, 'Aliaset er i bruk av jobbruting og kan ikke slettes'); + throw e; + } + + return json({ ok: true }); +}; diff --git a/web/src/routes/api/admin/ai/generate-config/+server.ts b/web/src/routes/api/admin/ai/generate-config/+server.ts new file mode 100644 index 0000000..6625d57 --- /dev/null +++ b/web/src/routes/api/admin/ai/generate-config/+server.ts @@ -0,0 +1,54 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { sql } from '$lib/server/db'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * POST /api/admin/ai/generate-config — Generer LiteLLM config.yaml fra PG. + */ +export const POST: RequestHandler = async ({ locals }) => { + if (!locals.workspace || !locals.user) error(401); + + // Hent aktive aliaser med aktive providers + const rows = await sql` + SELECT + a.alias AS model_name, + p.litellm_model, + p.api_key_env + FROM ai_model_aliases a + JOIN ai_model_providers p ON p.alias_id = a.id + WHERE a.is_active = true AND p.is_active = true + ORDER BY a.alias, p.priority ASC + `; + + if (rows.length === 0) { + error(400, 'Ingen aktive modellkonfigurasjoner funnet'); + } + + // Bygg YAML manuelt (unngå avhengighet) + let yaml = 'model_list:\n'; + for (const row of rows) { + yaml += ` - model_name: "${row.model_name}"\n`; + yaml += ` litellm_params:\n`; + yaml += ` model: "${row.litellm_model}"\n`; + yaml += ` api_key: "os.environ/${row.api_key_env}"\n`; + } + + yaml += '\nrouter_settings:\n'; + yaml += ' routing_strategy: "simple-shuffle"\n'; + yaml += ' num_retries: 2\n'; + yaml += ' timeout: 60\n'; + yaml += '\ngeneral_settings:\n'; + yaml += ' master_key: "os.environ/LITELLM_MASTER_KEY"\n'; + + // Skriv til config-fil + const configPath = join(process.cwd(), 'config', 'litellm', 'config.yaml'); + writeFileSync(configPath, yaml, 'utf-8'); + + return json({ + ok: true, + message: 'Config generert. Restart ai-gateway for å aktivere.', + model_count: rows.length + }); +}; diff --git a/web/src/routes/api/admin/ai/providers/+server.ts b/web/src/routes/api/admin/ai/providers/+server.ts new file mode 100644 index 0000000..9da97bb --- /dev/null +++ b/web/src/routes/api/admin/ai/providers/+server.ts @@ -0,0 +1,21 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { sql } from '$lib/server/db'; + +/** POST — opprett ny provider for et alias */ +export const POST: RequestHandler = async ({ request, locals }) => { + if (!locals.workspace || !locals.user) error(401); + + const { alias_id, priority, litellm_model, api_key_env } = await request.json(); + if (!alias_id || !litellm_model || !api_key_env) { + error(400, 'alias_id, litellm_model og api_key_env er påkrevd'); + } + + const [row] = await sql` + INSERT INTO ai_model_providers (alias_id, priority, litellm_model, api_key_env) + VALUES (${alias_id}::uuid, ${priority ?? 99}, ${litellm_model}, ${api_key_env}) + RETURNING id, alias_id, priority, litellm_model, api_key_env, is_active + `; + + return json(row, { status: 201 }); +}; diff --git a/web/src/routes/api/admin/ai/providers/[id]/+server.ts b/web/src/routes/api/admin/ai/providers/[id]/+server.ts new file mode 100644 index 0000000..5611a72 --- /dev/null +++ b/web/src/routes/api/admin/ai/providers/[id]/+server.ts @@ -0,0 +1,32 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { sql } from '$lib/server/db'; + +/** PATCH — oppdater provider */ +export const PATCH: RequestHandler = async ({ params, request, locals }) => { + if (!locals.workspace || !locals.user) error(401); + + const body = await request.json(); + + const [row] = await sql` + UPDATE ai_model_providers SET + priority = COALESCE(${body.priority ?? null}, priority), + litellm_model = COALESCE(${body.litellm_model ?? null}, litellm_model), + api_key_env = COALESCE(${body.api_key_env ?? null}, api_key_env), + is_active = COALESCE(${body.is_active ?? null}, is_active), + updated_at = now() + WHERE id = ${params.id}::uuid + RETURNING id, alias_id, priority, litellm_model, api_key_env, is_active + `; + + if (!row) error(404, 'Provider ikke funnet'); + return json(row); +}; + +/** DELETE — slett provider */ +export const DELETE: RequestHandler = async ({ params, locals }) => { + if (!locals.workspace || !locals.user) error(401); + + await sql`DELETE FROM ai_model_providers WHERE id = ${params.id}::uuid`; + return json({ ok: true }); +}; diff --git a/web/src/routes/api/admin/ai/routing/+server.ts b/web/src/routes/api/admin/ai/routing/+server.ts new file mode 100644 index 0000000..1392cd0 --- /dev/null +++ b/web/src/routes/api/admin/ai/routing/+server.ts @@ -0,0 +1,23 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { sql } from '$lib/server/db'; + +/** POST — opprett ny jobbruting */ +export const POST: RequestHandler = async ({ request, locals }) => { + if (!locals.workspace || !locals.user) error(401); + + const { job_type, alias_id, description } = await request.json(); + if (!job_type || !alias_id) error(400, 'job_type og alias_id er påkrevd'); + + const [row] = await sql` + INSERT INTO ai_job_routing (job_type, alias_id, description) + VALUES (${job_type}, ${alias_id}::uuid, ${description ?? null}) + ON CONFLICT (job_type) DO UPDATE SET + alias_id = EXCLUDED.alias_id, + description = EXCLUDED.description, + updated_at = now() + RETURNING job_type, alias_id, description + `; + + return json(row, { status: 201 }); +}; diff --git a/web/src/routes/api/admin/ai/routing/[jobType]/+server.ts b/web/src/routes/api/admin/ai/routing/[jobType]/+server.ts new file mode 100644 index 0000000..6375bbe --- /dev/null +++ b/web/src/routes/api/admin/ai/routing/[jobType]/+server.ts @@ -0,0 +1,30 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { sql } from '$lib/server/db'; + +/** PATCH — oppdater ruting for jobbtype */ +export const PATCH: RequestHandler = async ({ params, request, locals }) => { + if (!locals.workspace || !locals.user) error(401); + + const body = await request.json(); + + const [row] = await sql` + UPDATE ai_job_routing SET + alias_id = COALESCE(${body.alias_id ?? null}::uuid, alias_id), + description = COALESCE(${body.description ?? null}, description), + updated_at = now() + WHERE job_type = ${params.jobType} + RETURNING job_type, alias_id, description + `; + + if (!row) error(404, 'Jobbtype ikke funnet'); + return json(row); +}; + +/** DELETE — slett ruting */ +export const DELETE: RequestHandler = async ({ params, locals }) => { + if (!locals.workspace || !locals.user) error(401); + + await sql`DELETE FROM ai_job_routing WHERE job_type = ${params.jobType}`; + return json({ ok: true }); +}; diff --git a/web/src/routes/api/ai/usage/+server.ts b/web/src/routes/api/ai/usage/+server.ts new file mode 100644 index 0000000..adbb0ba --- /dev/null +++ b/web/src/routes/api/ai/usage/+server.ts @@ -0,0 +1,31 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { sql } from '$lib/server/db'; + +/** + * GET /api/ai/usage?days=30 — Aggregert tokenforbruk for workspace. + */ +export const GET: RequestHandler = async ({ url, locals }) => { + if (!locals.workspace || !locals.user) error(401); + + const days = Math.min(Math.max(parseInt(url.searchParams.get('days') ?? '7'), 1), 365); + + const breakdown = await sql` + SELECT + model_alias, + job_type, + count(*)::int AS call_count, + sum(prompt_tokens)::int AS prompt_tokens, + sum(completion_tokens)::int AS completion_tokens, + sum(total_tokens)::int AS total_tokens + FROM ai_usage_log + WHERE workspace_id = ${locals.workspace.id} + AND created_at > now() - make_interval(days => ${days}) + GROUP BY model_alias, job_type + ORDER BY total_tokens DESC + `; + + const total_tokens = breakdown.reduce((s: number, r: any) => s + r.total_tokens, 0); + + return json({ total_tokens, breakdown }); +}; diff --git a/worker/src/handlers/ai_text_process.rs b/worker/src/handlers/ai_text_process.rs index cef6281..ccc25ab 100644 --- a/worker/src/handlers/ai_text_process.rs +++ b/worker/src/handlers/ai_text_process.rs @@ -5,6 +5,15 @@ use sqlx::{PgPool, Row}; use tracing::{info, warn}; use uuid::Uuid; +/// Respons fra AI Gateway med innhold og tokenforbruk. +struct AiResponse { + content: String, + prompt_tokens: i32, + completion_tokens: i32, + total_tokens: i32, + model_actual: Option, +} + /// Handler for AI-behandling av tekst i editoren. /// /// Payload: @@ -21,6 +30,7 @@ use uuid::Uuid; /// 3. Send til AI Gateway med riktig prompt /// 4. Oppdater messages.body med AI-resultatet /// 5. Sett metadata.ai_processed = true +/// 6. Logg tokenforbruk til ai_usage_log pub struct AiTextProcessHandler { http: reqwest::Client, ai_gateway_url: String, @@ -41,6 +51,7 @@ impl JobHandler for AiTextProcessHandler { &self, pool: &PgPool, workspace_id: &Uuid, + job_id: &Uuid, payload: &Value, ) -> anyhow::Result> { let message_id: Uuid = payload @@ -94,7 +105,20 @@ impl JobHandler for AiTextProcessHandler { return Ok(Some(json!({ "skipped": true, "reason": "tom melding" }))); } - // 2. Lagre original som revisjon + // 2. Sett ai_processing-flagg så frontend kan vise spinner + sqlx::query( + r#" + UPDATE messages + SET metadata = COALESCE(metadata, '{}'::jsonb) || '{"ai_processing": true}'::jsonb + WHERE id = $1 + "#, + ) + .bind(message_id) + .execute(pool) + .await + .context("Feil ved setting av ai_processing-flagg")?; + + // 3. Lagre original som revisjon (etter at vi har satt processing-flagg) sqlx::query( r#" INSERT INTO message_revisions (id, message_id, body) @@ -107,19 +131,37 @@ impl JobHandler for AiTextProcessHandler { .await .context("Feil ved lagring av revisjon")?; - // 3. Bygg system-prompt basert på action + // 4. Bygg system-prompt basert på action let system_prompt = match prompt_override { Some(custom) => custom.to_string(), None => get_system_prompt(action), }; - // 4. Send til AI Gateway - let ai_response = self + // 5. Send til AI Gateway + let ai_resp = self .call_ai_gateway(&system_prompt, &plain_text, model) .await .context("AI Gateway-kall feilet")?; - // 5. Oppdater meldingens body med AI-resultat + // 6. Logg tokenforbruk til ai_usage_log + sqlx::query( + r#" + INSERT INTO ai_usage_log (workspace_id, job_id, job_type, model_alias, model_actual, prompt_tokens, completion_tokens, total_tokens) + VALUES ($1, $2, 'ai_text_process', $3, $4, $5, $6, $7) + "#, + ) + .bind(workspace_id) + .bind(job_id) + .bind(model) + .bind(&ai_resp.model_actual) + .bind(ai_resp.prompt_tokens) + .bind(ai_resp.completion_tokens) + .bind(ai_resp.total_tokens) + .execute(pool) + .await + .context("Feil ved logging av tokenforbruk")?; + + // 7. Oppdater meldingens body med AI-resultat, fjern ai_processing let metadata = json!({ "ai_processed": true, "ai_action": action @@ -130,11 +172,11 @@ impl JobHandler for AiTextProcessHandler { UPDATE messages SET body = $1, edited_at = now(), - metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb + metadata = (COALESCE(metadata, '{}'::jsonb) - 'ai_processing') || $2::jsonb WHERE id = $3 "#, ) - .bind(&ai_response) + .bind(&ai_resp.content) .bind(metadata) .bind(message_id) .execute(pool) @@ -145,7 +187,8 @@ impl JobHandler for AiTextProcessHandler { message_id = %message_id, action = action, original_len = original_body.len(), - result_len = ai_response.len(), + result_len = ai_resp.content.len(), + tokens = ai_resp.total_tokens, "AI-behandling fullført" ); @@ -153,7 +196,12 @@ impl JobHandler for AiTextProcessHandler { "message_id": message_id.to_string(), "action": action, "original_length": original_body.len(), - "result_length": ai_response.len() + "result_length": ai_resp.content.len(), + "tokens": { + "prompt": ai_resp.prompt_tokens, + "completion": ai_resp.completion_tokens, + "total": ai_resp.total_tokens + } }))) } } @@ -164,7 +212,7 @@ impl AiTextProcessHandler { system_prompt: &str, user_text: &str, model: &str, - ) -> anyhow::Result { + ) -> anyhow::Result { let request_body = json!({ "model": model, "messages": [ @@ -195,10 +243,24 @@ impl AiTextProcessHandler { .await .context("Kunne ikke parse AI Gateway-respons")?; - json["choices"][0]["message"]["content"] + let content = json["choices"][0]["message"]["content"] .as_str() .map(|s| s.to_string()) - .ok_or_else(|| anyhow!("Ingen content i AI Gateway-respons")) + .ok_or_else(|| anyhow!("Ingen content i AI Gateway-respons"))?; + + let usage = &json["usage"]; + let prompt_tokens = usage["prompt_tokens"].as_i64().unwrap_or(0) as i32; + let completion_tokens = usage["completion_tokens"].as_i64().unwrap_or(0) as i32; + let total_tokens = usage["total_tokens"].as_i64().unwrap_or(0) as i32; + let model_actual = json["model"].as_str().map(|s| s.to_string()); + + Ok(AiResponse { + content, + prompt_tokens, + completion_tokens, + total_tokens, + model_actual, + }) } } diff --git a/worker/src/handlers/echo.rs b/worker/src/handlers/echo.rs index 0b52b47..af55155 100644 --- a/worker/src/handlers/echo.rs +++ b/worker/src/handlers/echo.rs @@ -14,6 +14,7 @@ impl JobHandler for EchoHandler { &self, _pool: &PgPool, workspace_id: &Uuid, + _job_id: &Uuid, payload: &Value, ) -> anyhow::Result> { info!(workspace_id = %workspace_id, "Echo-handler kjører"); diff --git a/worker/src/handlers/mod.rs b/worker/src/handlers/mod.rs index 4e15a44..2f20deb 100644 --- a/worker/src/handlers/mod.rs +++ b/worker/src/handlers/mod.rs @@ -14,6 +14,7 @@ pub trait JobHandler: Send + Sync { &self, pool: &PgPool, workspace_id: &Uuid, + job_id: &Uuid, payload: &Value, ) -> anyhow::Result>; } diff --git a/worker/src/worker.rs b/worker/src/worker.rs index 182e1f4..3a55873 100644 --- a/worker/src/worker.rs +++ b/worker/src/worker.rs @@ -103,7 +103,7 @@ async fn process_job(pool: &PgPool, registry: &HandlerRegistry, job: Job) { let handler = registry.get(&job.job_type); let result = match handler { - Some(handler) => handler.handle(pool, &job.workspace_id, &job.payload).await, + Some(handler) => handler.handle(pool, &job.workspace_id, &job.id, &job.payload).await, None => { warn!(job_type = %job.job_type, "Ukjent jobbtype — ingen handler registrert"); Err(anyhow::anyhow!("Ukjent jobbtype: {}", job.job_type))