From 2fa5d7ef2f6b25a8601bfe0eb4990ee536defa37 Mon Sep 17 00:00:00 2001 From: vegard Date: Wed, 18 Mar 2026 20:19:52 +0000 Subject: [PATCH] =?UTF-8?q?AI-kostnadstak=20per=20bruker/samling:=20budsje?= =?UTF-8?q?ttsjekk=20f=C3=B8r=20AI-kall=20(oppgave=2028.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Samlings- og brukernoder kan nå ha ai_budget i metadata: { "ai_budget": { "monthly_limit_usd": 50.0 } } Før hvert AI-kall aggregeres inneværende måneds forbruk fra ai_usage_log og sammenlignes med grensen. Ved overskridelse: - AI-kallet blokkeres med feilmelding - En work_item-node opprettes med tag "budget_exceeded" - Work_item knyttes til samlingen via belongs_to-edge Endringer: - migrations/029: requested_by-kolonne i ai_usage_log + indekser - synops-ai: --collection-id/--user-id flagg, budsjettsjekk i prompt - maskinrommet/ai_budget.rs: delt budsjettsjekk-modul - maskinrommet/ai_process.rs: budsjettsjekk før AI gateway-kall - docs/infra/ai_gateway.md: oppdatert § 6.3 fra "fase 2" til implementert --- docs/infra/ai_gateway.md | 17 ++- maskinrommet/src/ai_budget.rs | 232 +++++++++++++++++++++++++++++++++ maskinrommet/src/ai_process.rs | 34 ++++- maskinrommet/src/main.rs | 1 + migrations/029_ai_budget.sql | 24 ++++ tasks.md | 3 +- tools/synops-ai/src/main.rs | 206 ++++++++++++++++++++++++++++- 7 files changed, 502 insertions(+), 15 deletions(-) create mode 100644 maskinrommet/src/ai_budget.rs create mode 100644 migrations/029_ai_budget.sql diff --git a/docs/infra/ai_gateway.md b/docs/infra/ai_gateway.md index 8d815d5..935c5bc 100644 --- a/docs/infra/ai_gateway.md +++ b/docs/infra/ai_gateway.md @@ -261,14 +261,19 @@ Aggregert oversikt over alle samlings-noder. Tabell med totaler per samlings-nod **Samlings-node (sidebar-widget):** Enkel tekst-indikator i sidebar: `12.4k tokens denne uken`. Klikk åpner detaljert visning med fordeling per jobbtype og modell. Ingen speedometer -- det krever et definert budsjett for å gi mening, og det er overkill for MVP. -### 6.3 Budsjett per samlings-node (fase 2) +### 6.3 Budsjett per samlings-node og bruker (implementert) -Når token-logging er på plass, kan budsjett-tak legges til: +Budsjettgrense for AI-bruk, implementert i oppgave 28.3: -- Budsjett lagres som JSONB-metadata på samlings-noden: `{ "ai_budget": { "monthly_limit_usd": 50 } }` -- Rust-worker sjekker aggregert forbruk før AI-kall -- Ved budsjett nær: fall tilbake til `sidelinja/rutine` (billigste) -- Ved budsjett nådd: sett jobb i `paused` med varsel i samlings-nodens chat +- Budsjett lagres som JSONB-metadata på samlings- eller brukernoden: `{ "ai_budget": { "monthly_limit_usd": 50 } }` +- `synops-ai prompt` sjekker budsjett via `--collection-id`/`--user-id` flagg +- `maskinrommet/ai_process` sjekker samlings- og brukerbudsjett automatisk før AI-kall +- Estimert kostnad beregnes fra tokens: $3/MTok input, $15/MTok output (konservativt gjennomsnitt) +- Aggregering: `SUM(prompt_tokens, completion_tokens)` fra `ai_usage_log` for inneværende måned +- Ved budsjett overskredet: AI-kall blokkeres, `work_item`-node opprettes med tag `budget_exceeded` +- `ai_usage_log.requested_by` sporer hvilken bruker som utløste kallet (migrasjon 029) +- Indekser: `(collection_node_id, created_at)` og `(requested_by, created_at)` for effektiv aggregering +- Fail-open: ved DB-feil i budsjettsjekk tillates kallet (unngår at feil blokkerer arbeid) ### 6.4 Per-episode maks-kostnad Podcastfabrikken-jobber (whisper + metadata + oppsummering) kan estimere totalkostnad basert på lydlengde. Jobben avbrytes med varsel hvis estimert kostnad overstiger `max_cost_per_episode` (default: $5). diff --git a/maskinrommet/src/ai_budget.rs b/maskinrommet/src/ai_budget.rs new file mode 100644 index 0000000..9ed4fcb --- /dev/null +++ b/maskinrommet/src/ai_budget.rs @@ -0,0 +1,232 @@ +// AI-budsjettsjekk — sjekker ai_budget-metadata mot ai_usage_log aggregat. +// +// Samlings- og brukernoder kan ha budsjettgrense i metadata: +// { "ai_budget": { "monthly_limit_usd": 50.0 } } +// +// Sjekken aggregerer denne månedens forbruk fra ai_usage_log og +// sammenligner med grensen. Ved overskridelse returneres feil +// og en work_item-node opprettes. +// +// Ref: docs/infra/ai_gateway.md § 6.3, oppgave 28.3 + +use sqlx::PgPool; +use uuid::Uuid; + +/// Estimerer kostnad fra tokens (samme formel som metrics.rs). +/// Input: $3/MTok, Output: $15/MTok (konservativt gjennomsnitt). +pub fn estimate_cost_usd(prompt_tokens: i64, completion_tokens: i64) -> f64 { + let input_cost = prompt_tokens as f64 * 3.0 / 1_000_000.0; + let output_cost = completion_tokens as f64 * 15.0 / 1_000_000.0; + ((input_cost + output_cost) * 100.0).round() / 100.0 +} + +/// Resultat av en budsjettsjekk. +pub struct BudgetStatus { + pub current_cost: f64, + pub limit: f64, + pub node_title: String, +} + +/// Sjekker ai_budget for en samlings-node. +/// +/// Returnerer Ok(()) hvis budsjettet ikke er overskredet eller ikke er satt. +/// Returnerer Err med BudgetStatus hvis overskredet. +pub async fn check_collection_budget( + db: &PgPool, + collection_id: Uuid, +) -> Result<(), BudgetStatus> { + check_budget_for_node(db, collection_id, "collection_node_id").await +} + +/// Sjekker ai_budget for en bruker-node. +pub async fn check_user_budget( + db: &PgPool, + user_id: Uuid, +) -> Result<(), BudgetStatus> { + check_budget_for_node(db, user_id, "requested_by").await +} + +async fn check_budget_for_node( + db: &PgPool, + node_id: Uuid, + column: &str, +) -> Result<(), BudgetStatus> { + // Hent metadata fra noden + let metadata: Option = + match sqlx::query_scalar("SELECT metadata FROM nodes WHERE id = $1") + .bind(node_id) + .fetch_optional(db) + .await + { + Ok(m) => m, + Err(e) => { + tracing::warn!(error = %e, "Kunne ikke hente metadata for budsjettsjekk"); + return Ok(()); // Fail open — ikke blokker AI-kall ved DB-feil + } + }; + + let monthly_limit = metadata + .as_ref() + .and_then(|m| m.get("ai_budget")) + .and_then(|b| b.get("monthly_limit_usd")) + .and_then(|v| v.as_f64()); + + let limit = match monthly_limit { + Some(l) => l, + None => return Ok(()), // Ingen budsjettgrense satt + }; + + // Aggreger denne månedens forbruk + // Bruker dynamisk kolonne via to separate queries for sikkerhet (unngår SQL injection) + let (total_prompt, total_completion): (i64, i64) = match column { + "collection_node_id" => { + match sqlx::query_as::<_, (i64, i64)>( + r#"SELECT + COALESCE(SUM(prompt_tokens)::BIGINT, 0), + COALESCE(SUM(completion_tokens)::BIGINT, 0) + FROM ai_usage_log + WHERE collection_node_id = $1 + AND created_at >= date_trunc('month', now())"#, + ) + .bind(node_id) + .fetch_one(db) + .await + { + Ok(r) => r, + Err(e) => { + tracing::warn!(error = %e, "Kunne ikke aggregere AI-forbruk"); + return Ok(()); // Fail open + } + } + } + "requested_by" => { + match sqlx::query_as::<_, (i64, i64)>( + r#"SELECT + COALESCE(SUM(prompt_tokens)::BIGINT, 0), + COALESCE(SUM(completion_tokens)::BIGINT, 0) + FROM ai_usage_log + WHERE requested_by = $1 + AND created_at >= date_trunc('month', now())"#, + ) + .bind(node_id) + .fetch_one(db) + .await + { + Ok(r) => r, + Err(e) => { + tracing::warn!(error = %e, "Kunne ikke aggregere bruker-AI-forbruk"); + return Ok(()); // Fail open + } + } + } + _ => return Ok(()), + }; + + let current_cost = estimate_cost_usd(total_prompt, total_completion); + + if current_cost >= limit { + let node_title: String = sqlx::query_scalar("SELECT title FROM nodes WHERE id = $1") + .bind(node_id) + .fetch_optional(db) + .await + .ok() + .flatten() + .unwrap_or_else(|| node_id.to_string()); + + return Err(BudgetStatus { + current_cost, + limit, + node_title, + }); + } + + tracing::debug!( + node_id = %node_id, + current_cost, + limit, + "Budsjettsjekk OK" + ); + + Ok(()) +} + +/// Oppretter work_item-node når budsjett er overskredet. +pub async fn create_budget_work_item( + db: &PgPool, + collection_id: Uuid, + created_by: Option, + status: &BudgetStatus, +) { + let work_item_id = Uuid::now_v7(); + let title = format!("AI-budsjett overskredet: {}", status.node_title); + let content = format!( + "AI-budsjettet for \"{}\" er overskredet.\n\ + Forbruk denne måneden: ${:.2}\n\ + Budsjettgrense: ${:.2}\n\n\ + AI-kall er blokkert inntil budsjettet økes eller ny måned starter.", + status.node_title, status.current_cost, status.limit + ); + let creator = created_by.unwrap_or(collection_id); + + let metadata = serde_json::json!({ + "work_item_type": "budget_exceeded", + "collection_id": collection_id.to_string(), + "current_cost_usd": status.current_cost, + "monthly_limit_usd": status.limit, + }); + + if let Err(e) = sqlx::query( + r#"INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by) + VALUES ($1, 'content', $2, $3, 'hidden'::visibility, $4, $5)"#, + ) + .bind(work_item_id) + .bind(&title) + .bind(&content) + .bind(&metadata) + .bind(creator) + .execute(db) + .await + { + tracing::warn!(error = %e, "Kunne ikke opprette work_item for budsjettoverskridelse"); + return; + } + + // tagged-edge: budget_exceeded + let tag_edge_id = Uuid::now_v7(); + if let Err(e) = sqlx::query( + r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by) + VALUES ($1, $2, $2, 'tagged', '{"tag": "budget_exceeded"}'::jsonb, true, $3)"#, + ) + .bind(tag_edge_id) + .bind(work_item_id) + .bind(creator) + .execute(db) + .await + { + tracing::warn!(error = %e, "Kunne ikke opprette tagged-edge for work_item"); + } + + // belongs_to-edge til samlingen + let belongs_edge_id = Uuid::now_v7(); + if let Err(e) = sqlx::query( + r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by) + VALUES ($1, $2, $3, 'belongs_to', '{}'::jsonb, true, $4)"#, + ) + .bind(belongs_edge_id) + .bind(work_item_id) + .bind(collection_id) + .bind(creator) + .execute(db) + .await + { + tracing::warn!(error = %e, "Kunne ikke opprette belongs_to-edge for work_item"); + } + + tracing::info!( + work_item_id = %work_item_id, + collection_id = %collection_id, + current_cost = status.current_cost, + limit = status.limit, + "Work item opprettet for budsjettoverskridelse" + ); +} diff --git a/maskinrommet/src/ai_process.rs b/maskinrommet/src/ai_process.rs index 6676553..bc49c38 100644 --- a/maskinrommet/src/ai_process.rs +++ b/maskinrommet/src/ai_process.rs @@ -172,6 +172,31 @@ pub async fn handle_ai_process( // 3. Map modellprofil → LiteLLM-alias let model_alias = model_profile_to_alias(model_profile); + // 3.5. Budsjettsjekk for samlingen (oppgave 28.3) + let collection_id = resource_usage::find_collection_for_node(db, source_node_id).await; + if let Some(coll_id) = collection_id { + if let Err(status) = crate::ai_budget::check_collection_budget(db, coll_id).await { + crate::ai_budget::create_budget_work_item( + db, coll_id, Some(requested_by), &status, + ).await; + return Err(format!( + "AI-budsjett overskredet for \"{}\": forbruk ${:.2} >= grense ${:.2}", + status.node_title, status.current_cost, status.limit + )); + } + } + // Budsjettsjekk for brukeren + if let Err(status) = crate::ai_budget::check_user_budget(db, requested_by).await { + let coll = collection_id.unwrap_or(source_node_id); + crate::ai_budget::create_budget_work_item( + db, coll, Some(requested_by), &status, + ).await; + return Err(format!( + "AI-budsjett overskredet for bruker \"{}\": forbruk ${:.2} >= grense ${:.2}", + status.node_title, status.current_cost, status.limit + )); + } + tracing::info!( source_node_id = %source_node_id, ai_preset_id = %ai_preset_id, @@ -194,24 +219,25 @@ pub async fn handle_ai_process( ); // 5. Logg forbruk i ai_usage_log - let collection_id = resource_usage::find_collection_for_node(db, source_node_id).await; + // collection_id allerede hentet i budsjettsjekk (steg 3.5) let (tokens_in, tokens_out) = usage .as_ref() .map(|u| (u.prompt_tokens, u.completion_tokens)) .unwrap_or((0, 0)); let total_tokens = tokens_in + tokens_out; - // ai_usage_log — detaljert AI-forbrukslogg + // ai_usage_log — detaljert AI-forbrukslogg (inkl. requested_by) if let Err(e) = sqlx::query( r#" INSERT INTO ai_usage_log - (collection_node_id, job_id, model_alias, model_actual, + (collection_node_id, job_id, requested_by, model_alias, model_actual, prompt_tokens, completion_tokens, total_tokens, job_type) - VALUES ($1, $2, $3, $4, $5, $6, $7, 'ai_process') + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'ai_process') "#, ) .bind(collection_id) .bind(job.id) + .bind(requested_by) .bind(model_alias) .bind(actual_model.as_deref()) .bind(tokens_in as i32) diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 0f3f0fd..58888e9 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -1,5 +1,6 @@ pub mod agent; pub mod ai_admin; +pub mod ai_budget; pub mod ai_edges; pub mod ai_process; pub mod audio; diff --git a/migrations/029_ai_budget.sql b/migrations/029_ai_budget.sql new file mode 100644 index 0000000..049c9b9 --- /dev/null +++ b/migrations/029_ai_budget.sql @@ -0,0 +1,24 @@ +-- 029: AI-budsjett per bruker/samling +-- +-- Legger til støtte for kostnadstak (ai_budget) i metadata på bruker- og +-- samlingsnoder. Utvider ai_usage_log med requested_by for bruker-attribusjon, +-- og legger til indeks for effektiv månedlig aggregering. +-- +-- Budsjett lagres som JSONB-metadata på noden: +-- { "ai_budget": { "monthly_limit_usd": 50.0 } } +-- +-- Ref: docs/infra/ai_gateway.md § 6.3 + +-- Bruker-attribusjon: hvem utløste AI-kallet +ALTER TABLE ai_usage_log + ADD COLUMN IF NOT EXISTS requested_by UUID REFERENCES nodes(id) ON DELETE SET NULL; + +-- Indeks for effektiv månedlig aggregering per samling +CREATE INDEX IF NOT EXISTS idx_ai_usage_log_collection_month + ON ai_usage_log (collection_node_id, created_at) + WHERE collection_node_id IS NOT NULL; + +-- Indeks for effektiv månedlig aggregering per bruker +CREATE INDEX IF NOT EXISTS idx_ai_usage_log_requested_by_month + ON ai_usage_log (requested_by, created_at) + WHERE requested_by IS NOT NULL; diff --git a/tasks.md b/tasks.md index 8ce36c7..cbad369 100644 --- a/tasks.md +++ b/tasks.md @@ -374,8 +374,7 @@ modell som brukes til hva. - [x] 28.1 `synops-ai` CLI: direkte LLM-kall via LiteLLM. Input: `--prompt [--model ] [--system ]`. Output: tekst til stdout. Ingen fillesing, ingen verktøy, bare prompt inn/ut. Bruker `ai_job_routing`-tabellen for å bestemme modell hvis `--model` ikke er satt. Logger i `ai_usage_log`. - [x] 28.2 AI-rutingskontroll i admin: utvid admin-UI (fase 15.4) med konfigurasjon av hvilken modell som brukes per kontekst. Tabellen `ai_job_routing` mapper `(job_type, context)` → `model_alias`. Kontekster: `orchestration_script`, `orchestration_dream`, `bot_chat`, `bot_triage`, `summarize`, `suggest_edges`, `classify`. Admin kan endre uten redeploy. -- [~] 28.3 Kostnadstak per bruker/samling: `ai_budget`-felt i metadata for brukere og samlinger. `synops-ai` sjekker budsjett mot `ai_usage_log` aggregat før kall. Ved overskridelse: returner feilmelding, opprett work_item. - > Påbegynt: 2026-03-18T20:10 +- [x] 28.3 Kostnadstak per bruker/samling: `ai_budget`-felt i metadata for brukere og samlinger. `synops-ai` sjekker budsjett mot `ai_usage_log` aggregat før kall. Ved overskridelse: returner feilmelding, opprett work_item. ### Øvrige manglende verktøy diff --git a/tools/synops-ai/src/main.rs b/tools/synops-ai/src/main.rs index 9e257c4..7218f96 100644 --- a/tools/synops-ai/src/main.rs +++ b/tools/synops-ai/src/main.rs @@ -51,6 +51,14 @@ struct PromptArgs { /// Temperatur (0.0–2.0, default: 0.7) #[arg(long, default_value = "0.7")] temperature: f32, + + /// Samlings-ID for budsjettsjekk og logging + #[arg(long)] + collection_id: Option, + + /// Bruker-ID som utløste kallet (for budsjettsjekk og logging) + #[arg(long)] + user_id: Option, } #[derive(Parser)] @@ -156,6 +164,21 @@ async fn run_prompt(args: PromptArgs) -> Result<(), String> { None => resolve_model(&db, &args.job_type).await?, }; + // Sjekk budsjett før LLM-kall + if let Some(collection_id) = args.collection_id { + if let Err(msg) = check_budget(&db, collection_id, "collection").await { + create_budget_work_item(&db, collection_id, args.user_id, &msg).await; + return Err(msg); + } + } + if let Some(user_id) = args.user_id { + if let Err(msg) = check_budget(&db, user_id, "user").await { + let coll = args.collection_id.unwrap_or(user_id); + create_budget_work_item(&db, coll, Some(user_id), &msg).await; + return Err(msg); + } + } + tracing::info!( model = %model_alias, job_type = %args.job_type, @@ -182,15 +205,17 @@ async fn run_prompt(args: PromptArgs) -> Result<(), String> { // Skriv svar til stdout println!("{content}"); - // Logg i ai_usage_log + // Logg i ai_usage_log (inkludert collection_node_id og requested_by) let tokens_in = usage.as_ref().map(|u| u.prompt_tokens).unwrap_or(0); let tokens_out = usage.as_ref().map(|u| u.completion_tokens).unwrap_or(0); let actual = actual_model.as_deref().unwrap_or("unknown"); if let Err(e) = sqlx::query( - "INSERT INTO ai_usage_log (model_alias, model_actual, prompt_tokens, completion_tokens, total_tokens, job_type) - VALUES ($1, $2, $3, $4, $5, $6)", + "INSERT INTO ai_usage_log (collection_node_id, requested_by, model_alias, model_actual, prompt_tokens, completion_tokens, total_tokens, job_type) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", ) + .bind(args.collection_id) + .bind(args.user_id) .bind(&model_alias) .bind(actual) .bind(tokens_in as i32) @@ -214,6 +239,181 @@ async fn run_prompt(args: PromptArgs) -> Result<(), String> { Ok(()) } +// ============================================================ +// Budsjettsjekk (oppgave 28.3) +// ============================================================ + +/// Estimerer kostnad fra tokens (samme formel som maskinrommet/metrics.rs). +/// Input: $3/MTok, Output: $15/MTok (konservativt gjennomsnitt). +fn estimate_cost_usd(prompt_tokens: i64, completion_tokens: i64) -> f64 { + let input_cost = prompt_tokens as f64 * 3.0 / 1_000_000.0; + let output_cost = completion_tokens as f64 * 15.0 / 1_000_000.0; + ((input_cost + output_cost) * 100.0).round() / 100.0 +} + +/// Sjekker ai_budget for en node (samling eller bruker). +/// +/// Leser `metadata.ai_budget.monthly_limit_usd` fra noden, +/// aggregerer denne månedens forbruk fra ai_usage_log, +/// og returnerer Err med feilmelding hvis budsjettet er overskredet. +async fn check_budget( + db: &sqlx::PgPool, + node_id: Uuid, + node_type: &str, // "collection" eller "user" +) -> Result<(), String> { + // Hent metadata fra noden + let metadata: Option = + sqlx::query_scalar("SELECT metadata FROM nodes WHERE id = $1") + .bind(node_id) + .fetch_optional(db) + .await + .map_err(|e| format!("PG-feil ved budsjettsjekk: {e}"))?; + + let monthly_limit = metadata + .as_ref() + .and_then(|m| m.get("ai_budget")) + .and_then(|b| b.get("monthly_limit_usd")) + .and_then(|v| v.as_f64()); + + let limit = match monthly_limit { + Some(l) => l, + None => return Ok(()), // Ingen budsjettgrense satt + }; + + // Aggreger denne månedens forbruk + let (total_prompt, total_completion) = match node_type { + "collection" => { + sqlx::query_as::<_, (i64, i64)>( + r#"SELECT + COALESCE(SUM(prompt_tokens)::BIGINT, 0), + COALESCE(SUM(completion_tokens)::BIGINT, 0) + FROM ai_usage_log + WHERE collection_node_id = $1 + AND created_at >= date_trunc('month', now())"#, + ) + .bind(node_id) + .fetch_one(db) + .await + .map_err(|e| format!("PG-feil ved aggregering av forbruk: {e}"))? + } + "user" => { + sqlx::query_as::<_, (i64, i64)>( + r#"SELECT + COALESCE(SUM(prompt_tokens)::BIGINT, 0), + COALESCE(SUM(completion_tokens)::BIGINT, 0) + FROM ai_usage_log + WHERE requested_by = $1 + AND created_at >= date_trunc('month', now())"#, + ) + .bind(node_id) + .fetch_one(db) + .await + .map_err(|e| format!("PG-feil ved aggregering av brukerforbruk: {e}"))? + } + _ => return Ok(()), + }; + + let current_cost = estimate_cost_usd(total_prompt, total_completion); + + if current_cost >= limit { + let node_title: Option = + sqlx::query_scalar("SELECT title FROM nodes WHERE id = $1") + .bind(node_id) + .fetch_optional(db) + .await + .ok() + .flatten(); + + let name = node_title.unwrap_or_else(|| node_id.to_string()); + return Err(format!( + "AI-budsjett overskredet for {node_type} \"{name}\": \ + forbruk ${current_cost:.2} >= grense ${limit:.2} denne måneden" + )); + } + + tracing::debug!( + node_id = %node_id, + node_type, + current_cost, + limit, + "Budsjettsjekk OK" + ); + + Ok(()) +} + +/// Oppretter work_item-node når budsjett er overskredet. +async fn create_budget_work_item( + db: &sqlx::PgPool, + collection_id: Uuid, + user_id: Option, + error_msg: &str, +) { + let work_item_id = Uuid::now_v7(); + let title = format!("AI-budsjett overskredet"); + let created_by = user_id.unwrap_or(collection_id); + + let metadata = serde_json::json!({ + "work_item_type": "budget_exceeded", + "collection_id": collection_id.to_string(), + "error": error_msg, + }); + + // Opprett work_item-node + if let Err(e) = sqlx::query( + r#"INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by) + VALUES ($1, 'content', $2, $3, 'hidden'::visibility, $4, $5)"#, + ) + .bind(work_item_id) + .bind(&title) + .bind(error_msg) + .bind(&metadata) + .bind(created_by) + .execute(db) + .await + { + tracing::warn!(error = %e, "Kunne ikke opprette work_item for budsjettoverskridelse"); + return; + } + + // tagged-edge: budget_exceeded + let tag_edge_id = Uuid::now_v7(); + if let Err(e) = sqlx::query( + r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by) + VALUES ($1, $2, $2, 'tagged', '{"tag": "budget_exceeded"}'::jsonb, true, $3)"#, + ) + .bind(tag_edge_id) + .bind(work_item_id) + .bind(created_by) + .execute(db) + .await + { + tracing::warn!(error = %e, "Kunne ikke opprette tagged-edge for work_item"); + } + + // belongs_to-edge til samlingen + let belongs_edge_id = Uuid::now_v7(); + if let Err(e) = sqlx::query( + r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by) + VALUES ($1, $2, $3, 'belongs_to', '{}'::jsonb, true, $4)"#, + ) + .bind(belongs_edge_id) + .bind(work_item_id) + .bind(collection_id) + .bind(created_by) + .execute(db) + .await + { + tracing::warn!(error = %e, "Kunne ikke opprette belongs_to-edge for work_item"); + } + + tracing::info!( + work_item_id = %work_item_id, + collection_id = %collection_id, + "Work item opprettet for budsjettoverskridelse" + ); +} + /// Slå opp modellalias fra ai_job_routing. Fallback: sidelinja/rutine. async fn resolve_model(db: &sqlx::PgPool, job_type: &str) -> Result { let row: Option<(String,)> =