AI-kostnadstak per bruker/samling: budsjettsjekk før AI-kall (oppgave 28.3)

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
This commit is contained in:
vegard 2026-03-18 20:19:52 +00:00
parent c0f05ab5e9
commit 2fa5d7ef2f
7 changed files with 502 additions and 15 deletions

View file

@ -261,14 +261,19 @@ Aggregert oversikt over alle samlings-noder. Tabell med totaler per samlings-nod
**Samlings-node (sidebar-widget):** **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. 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 } }` - Budsjett lagres som JSONB-metadata på samlings- eller brukernoden: `{ "ai_budget": { "monthly_limit_usd": 50 } }`
- Rust-worker sjekker aggregert forbruk før AI-kall - `synops-ai prompt` sjekker budsjett via `--collection-id`/`--user-id` flagg
- Ved budsjett nær: fall tilbake til `sidelinja/rutine` (billigste) - `maskinrommet/ai_process` sjekker samlings- og brukerbudsjett automatisk før AI-kall
- Ved budsjett nådd: sett jobb i `paused` med varsel i samlings-nodens chat - 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 ### 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). 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).

View file

@ -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<serde_json::Value> =
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<Uuid>,
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"
);
}

View file

@ -172,6 +172,31 @@ pub async fn handle_ai_process(
// 3. Map modellprofil → LiteLLM-alias // 3. Map modellprofil → LiteLLM-alias
let model_alias = model_profile_to_alias(model_profile); 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!( tracing::info!(
source_node_id = %source_node_id, source_node_id = %source_node_id,
ai_preset_id = %ai_preset_id, ai_preset_id = %ai_preset_id,
@ -194,24 +219,25 @@ pub async fn handle_ai_process(
); );
// 5. Logg forbruk i ai_usage_log // 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 let (tokens_in, tokens_out) = usage
.as_ref() .as_ref()
.map(|u| (u.prompt_tokens, u.completion_tokens)) .map(|u| (u.prompt_tokens, u.completion_tokens))
.unwrap_or((0, 0)); .unwrap_or((0, 0));
let total_tokens = tokens_in + tokens_out; 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( if let Err(e) = sqlx::query(
r#" r#"
INSERT INTO ai_usage_log 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) 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(collection_id)
.bind(job.id) .bind(job.id)
.bind(requested_by)
.bind(model_alias) .bind(model_alias)
.bind(actual_model.as_deref()) .bind(actual_model.as_deref())
.bind(tokens_in as i32) .bind(tokens_in as i32)

View file

@ -1,5 +1,6 @@
pub mod agent; pub mod agent;
pub mod ai_admin; pub mod ai_admin;
pub mod ai_budget;
pub mod ai_edges; pub mod ai_edges;
pub mod ai_process; pub mod ai_process;
pub mod audio; pub mod audio;

View file

@ -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;

View file

@ -374,8 +374,7 @@ modell som brukes til hva.
- [x] 28.1 `synops-ai` CLI: direkte LLM-kall via LiteLLM. Input: `--prompt <tekst> [--model <alias>] [--system <systemprompt>]`. 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.1 `synops-ai` CLI: direkte LLM-kall via LiteLLM. Input: `--prompt <tekst> [--model <alias>] [--system <systemprompt>]`. 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. - [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. - [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.
> Påbegynt: 2026-03-18T20:10
### Øvrige manglende verktøy ### Øvrige manglende verktøy

View file

@ -51,6 +51,14 @@ struct PromptArgs {
/// Temperatur (0.02.0, default: 0.7) /// Temperatur (0.02.0, default: 0.7)
#[arg(long, default_value = "0.7")] #[arg(long, default_value = "0.7")]
temperature: f32, temperature: f32,
/// Samlings-ID for budsjettsjekk og logging
#[arg(long)]
collection_id: Option<Uuid>,
/// Bruker-ID som utløste kallet (for budsjettsjekk og logging)
#[arg(long)]
user_id: Option<Uuid>,
} }
#[derive(Parser)] #[derive(Parser)]
@ -156,6 +164,21 @@ async fn run_prompt(args: PromptArgs) -> Result<(), String> {
None => resolve_model(&db, &args.job_type).await?, 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!( tracing::info!(
model = %model_alias, model = %model_alias,
job_type = %args.job_type, job_type = %args.job_type,
@ -182,15 +205,17 @@ async fn run_prompt(args: PromptArgs) -> Result<(), String> {
// Skriv svar til stdout // Skriv svar til stdout
println!("{content}"); 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_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 tokens_out = usage.as_ref().map(|u| u.completion_tokens).unwrap_or(0);
let actual = actual_model.as_deref().unwrap_or("unknown"); let actual = actual_model.as_deref().unwrap_or("unknown");
if let Err(e) = sqlx::query( if let Err(e) = sqlx::query(
"INSERT INTO ai_usage_log (model_alias, model_actual, prompt_tokens, completion_tokens, total_tokens, job_type) "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)", VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
) )
.bind(args.collection_id)
.bind(args.user_id)
.bind(&model_alias) .bind(&model_alias)
.bind(actual) .bind(actual)
.bind(tokens_in as i32) .bind(tokens_in as i32)
@ -214,6 +239,181 @@ async fn run_prompt(args: PromptArgs) -> Result<(), String> {
Ok(()) 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<serde_json::Value> =
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<String> =
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<Uuid>,
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. /// Slå opp modellalias fra ai_job_routing. Fallback: sidelinja/rutine.
async fn resolve_model(db: &sqlx::PgPool, job_type: &str) -> Result<String, String> { async fn resolve_model(db: &sqlx::PgPool, job_type: &str) -> Result<String, String> {
let row: Option<(String,)> = let row: Option<(String,)> =