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:
parent
c0f05ab5e9
commit
2fa5d7ef2f
7 changed files with 502 additions and 15 deletions
|
|
@ -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).
|
||||||
|
|
|
||||||
232
maskinrommet/src/ai_budget.rs
Normal file
232
maskinrommet/src/ai_budget.rs
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
24
migrations/029_ai_budget.sql
Normal file
24
migrations/029_ai_budget.sql
Normal 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;
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,14 @@ struct PromptArgs {
|
||||||
/// Temperatur (0.0–2.0, default: 0.7)
|
/// Temperatur (0.0–2.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,)> =
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue