diff --git a/docs/features/ai_verktoy.md b/docs/features/ai_verktoy.md index 9aaf9e6..ac43abd 100644 --- a/docs/features/ai_verktoy.md +++ b/docs/features/ai_verktoy.md @@ -225,7 +225,7 @@ Flere AI-verktøy i serie: dra output fra "Oversett" videre til ### Fase A: Grunnleggende verktøy-panel - [x] AI-preset node-type (`node_kind: 'ai_preset'`) + metadata-skjema - [x] Standard-presets som seed-data (rens tekst, korrektør, oppsummering osv.) -- [ ] `POST /intentions/ai_process` endepunkt i maskinrommet +- [x] `POST /intentions/ai_process` endepunkt i maskinrommet - [ ] Verktøy-panel UI med prompt-velger og modell-indikator - [ ] Jobbkø-integrasjon med AI Gateway diff --git a/maskinrommet/src/ai_process.rs b/maskinrommet/src/ai_process.rs new file mode 100644 index 0000000..d038231 --- /dev/null +++ b/maskinrommet/src/ai_process.rs @@ -0,0 +1,314 @@ +// AI-prosessering — hent kilde-content + preset-prompt, kall AI Gateway. +// +// Jobbtype: "ai_process" +// Payload: { +// "source_node_id": "", +// "ai_preset_id": "", +// "direction": "node_to_tool" | "tool_to_node", +// "requested_by": "" +// } +// +// Flyten: +// 1. Hent kilde-node content fra PG +// 2. Hent AI-preset prompt + modellprofil fra PG +// 3. Map modellprofil → LiteLLM-alias (flash → sidelinja/rutine, standard → sidelinja/resonering) +// 4. Send til AI Gateway (LiteLLM) +// 5. Logg forbruk i ai_usage_log +// 6. Returner AI-output (direction-logikk implementeres i oppgave 18.3) +// +// Ref: docs/features/ai_verktoy.md, docs/infra/ai_gateway.md + +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::jobs::JobRow; +use crate::resource_usage; + +#[derive(sqlx::FromRow)] +struct SourceNodeRow { + content: Option, + #[allow(dead_code)] // Brukes i oppgave 18.3 (direction-logikk) + title: Option, + #[allow(dead_code)] // Brukes i oppgave 18.3 (direction-logikk) + node_kind: String, +} + +#[derive(sqlx::FromRow)] +struct PresetRow { + title: Option, + metadata: Option, +} + +/// OpenAI-kompatibel chat completion request. +#[derive(Serialize)] +struct ChatRequest { + model: String, + messages: Vec, + temperature: f32, +} + +#[derive(Serialize)] +struct ChatMessage { + role: String, + content: String, +} + +/// OpenAI-kompatibel chat completion response. +#[derive(Deserialize)] +struct ChatResponse { + choices: Vec, + #[serde(default)] + usage: Option, + #[serde(default)] + model: Option, +} + +#[derive(Deserialize, Clone)] +struct UsageInfo { + #[serde(default)] + prompt_tokens: i64, + #[serde(default)] + completion_tokens: i64, +} + +#[derive(Deserialize)] +struct Choice { + message: MessageContent, +} + +#[derive(Deserialize)] +struct MessageContent { + content: Option, +} + +/// Mapper modellprofil til LiteLLM-alias. +/// Ref: docs/features/ai_verktoy.md § 4, docs/infra/ai_gateway.md § 3.4 +fn model_profile_to_alias(profile: &str) -> &'static str { + match profile { + "flash" => "sidelinja/rutine", + "standard" => "sidelinja/resonering", + _ => "sidelinja/rutine", // fallback til billigste + } +} + +/// Håndterer ai_process-jobb. +pub async fn handle_ai_process( + job: &JobRow, + db: &PgPool, +) -> Result { + let source_node_id: Uuid = job + .payload + .get("source_node_id") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse().ok()) + .ok_or("Mangler source_node_id i payload")?; + + let ai_preset_id: Uuid = job + .payload + .get("ai_preset_id") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse().ok()) + .ok_or("Mangler ai_preset_id i payload")?; + + let direction = job + .payload + .get("direction") + .and_then(|v| v.as_str()) + .ok_or("Mangler direction i payload")?; + + let requested_by: Uuid = job + .payload + .get("requested_by") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse().ok()) + .ok_or("Mangler requested_by i payload")?; + + // 1. Hent kilde-node + let source = sqlx::query_as::<_, SourceNodeRow>( + "SELECT content, title, node_kind FROM nodes WHERE id = $1", + ) + .bind(source_node_id) + .fetch_optional(db) + .await + .map_err(|e| format!("PG-feil ved henting av kilde-node: {e}"))? + .ok_or("Kilde-node finnes ikke")?; + + let source_content = source + .content + .filter(|c| !c.is_empty()) + .ok_or("Kilde-noden har ikke innhold å behandle")?; + + // 2. Hent AI-preset + let preset = sqlx::query_as::<_, PresetRow>( + "SELECT title, metadata FROM nodes WHERE id = $1 AND node_kind = 'ai_preset'", + ) + .bind(ai_preset_id) + .fetch_optional(db) + .await + .map_err(|e| format!("PG-feil ved henting av AI-preset: {e}"))? + .ok_or("AI-preset finnes ikke")?; + + let metadata = preset + .metadata + .ok_or("AI-preset mangler metadata")?; + + let prompt = metadata + .get("prompt") + .and_then(|v| v.as_str()) + .ok_or("AI-preset mangler prompt i metadata")?; + + let model_profile = metadata + .get("model_profile") + .and_then(|v| v.as_str()) + .unwrap_or("flash"); + + // 3. Map modellprofil → LiteLLM-alias + let model_alias = model_profile_to_alias(model_profile); + + tracing::info!( + source_node_id = %source_node_id, + ai_preset_id = %ai_preset_id, + direction = %direction, + model_alias = %model_alias, + preset_title = ?preset.title, + source_content_len = source_content.len(), + "Starter AI-prosessering" + ); + + // 4. Kall AI Gateway + let (ai_output, usage, actual_model) = + call_ai_gateway(model_alias, prompt, &source_content).await?; + + tracing::info!( + source_node_id = %source_node_id, + output_len = ai_output.len(), + actual_model = ?actual_model, + "AI-prosessering fullført" + ); + + // 5. Logg forbruk i ai_usage_log + let collection_id = resource_usage::find_collection_for_node(db, source_node_id).await; + 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 + if let Err(e) = sqlx::query( + r#" + INSERT INTO ai_usage_log + (collection_node_id, job_id, model_alias, model_actual, + prompt_tokens, completion_tokens, total_tokens, job_type) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'ai_process') + "#, + ) + .bind(collection_id) + .bind(job.id) + .bind(model_alias) + .bind(actual_model.as_deref()) + .bind(tokens_in as i32) + .bind(tokens_out as i32) + .bind(total_tokens as i32) + .execute(db) + .await + { + tracing::warn!(error = %e, "Kunne ikke logge AI-forbruk i ai_usage_log"); + } + + // resource_usage_log — generell ressurslogging + if let Err(e) = resource_usage::log( + db, + source_node_id, + Some(requested_by), + collection_id, + "ai", + serde_json::json!({ + "model_level": model_profile, + "model_id": actual_model.unwrap_or_else(|| "unknown".to_string()), + "model_alias": model_alias, + "tokens_in": tokens_in, + "tokens_out": tokens_out, + "job_type": "ai_process", + "preset_id": ai_preset_id.to_string(), + "direction": direction + }), + ) + .await + { + tracing::warn!(error = %e, "Kunne ikke logge AI-ressursforbruk"); + } + + // 6. Returner resultat + // Direction-logikk (opprett ny node / oppdater eksisterende) implementeres i oppgave 18.3 + Ok(serde_json::json!({ + "status": "completed", + "source_node_id": source_node_id.to_string(), + "ai_preset_id": ai_preset_id.to_string(), + "direction": direction, + "ai_output": ai_output, + "tokens_in": tokens_in, + "tokens_out": tokens_out, + "total_tokens": total_tokens + })) +} + +/// Kall AI Gateway (LiteLLM) for tekstbehandling. +/// Returnerer (output_text, usage, actual_model_name). +async fn call_ai_gateway( + model_alias: &str, + system_prompt: &str, + user_content: &str, +) -> Result<(String, Option, Option), String> { + let gateway_url = std::env::var("AI_GATEWAY_URL") + .unwrap_or_else(|_| "http://localhost:4000".to_string()); + let api_key = std::env::var("LITELLM_MASTER_KEY").unwrap_or_default(); + + let request = ChatRequest { + model: model_alias.to_string(), + messages: vec![ + ChatMessage { + role: "system".to_string(), + content: system_prompt.to_string(), + }, + ChatMessage { + role: "user".to_string(), + content: user_content.to_string(), + }, + ], + temperature: 0.3, + }; + + let client = reqwest::Client::new(); + let url = format!("{gateway_url}/v1/chat/completions"); + + let resp = client + .post(&url) + .header("Authorization", format!("Bearer {api_key}")) + .header("Content-Type", "application/json") + .json(&request) + .timeout(std::time::Duration::from_secs(120)) + .send() + .await + .map_err(|e| format!("AI Gateway-kall feilet: {e}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("AI Gateway returnerte {status}: {body}")); + } + + let chat_resp: ChatResponse = resp + .json() + .await + .map_err(|e| format!("Kunne ikke parse AI Gateway-respons: {e}"))?; + + let content = chat_resp + .choices + .first() + .and_then(|c| c.message.content.as_deref()) + .ok_or("AI Gateway returnerte ingen content")?; + + Ok((content.to_string(), chat_resp.usage, chat_resp.model)) +} diff --git a/maskinrommet/src/intentions.rs b/maskinrommet/src/intentions.rs index 3e1d3f7..fe94b33 100644 --- a/maskinrommet/src/intentions.rs +++ b/maskinrommet/src/intentions.rs @@ -3228,6 +3228,134 @@ pub async fn summarize( Ok(Json(SummarizeResponse { job_id })) } +// ============================================================================= +// POST /intentions/ai_process — AI-prosessering via AI Gateway +// ============================================================================= + +#[derive(Deserialize)] +pub struct AiProcessRequest { + /// Kilde-noden som skal prosesseres. + pub source_node_id: Uuid, + /// AI-preset som definerer prompt og modellprofil. + pub ai_preset_id: Uuid, + /// Retning: "node_to_tool" (opprett ny node) eller "tool_to_node" (modifiser in-place). + pub direction: String, +} + +#[derive(Serialize)] +pub struct AiProcessResponse { + pub job_id: Uuid, +} + +/// POST /intentions/ai_process +/// +/// Legger en `ai_process`-jobb i køen. +/// AI-prosesseringen skjer asynkront — kilde-content sendes til AI Gateway +/// med preset-prompt, og forbruk logges i ai_usage_log. +/// +/// Direction-logikk (opprett ny node vs. oppdater eksisterende) implementeres +/// i oppgave 18.3. +/// +/// Ref: docs/features/ai_verktoy.md § 6.1 +pub async fn ai_process( + State(state): State, + user: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + // Valider direction + if req.direction != "node_to_tool" && req.direction != "tool_to_node" { + return Err(bad_request( + "direction må være 'node_to_tool' eller 'tool_to_node'", + )); + } + + // Sjekk at kilde-noden finnes + let source_exists: bool = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1)", + ) + .bind(req.source_node_id) + .fetch_one(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "PG-feil ved kilde-node-sjekk"); + internal_error("Databasefeil") + })?; + + if !source_exists { + return Err(bad_request("Kilde-node finnes ikke")); + } + + // Sjekk at AI-preset finnes + let preset_exists: bool = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1 AND node_kind = 'ai_preset')", + ) + .bind(req.ai_preset_id) + .fetch_one(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "PG-feil ved preset-sjekk"); + internal_error("Databasefeil") + })?; + + if !preset_exists { + return Err(bad_request("AI-preset finnes ikke")); + } + + // For tool_to_node-retning trengs skrivetilgang til kilde-noden + if req.direction == "tool_to_node" { + let can_modify = user_can_modify_node(&state.db, user.node_id, req.source_node_id) + .await + .map_err(|e| { + tracing::error!(error = %e, "PG-feil ved tilgangssjekk"); + internal_error("Databasefeil") + })?; + + if !can_modify { + return Err(forbidden( + "Ingen tilgang til å endre kilde-noden (tool_to_node krever skrivetilgang)", + )); + } + } + + // Finn samlings-ID for kilde-noden (for prioritering) + let collection_id = crate::resource_usage::find_collection_for_node( + &state.db, + req.source_node_id, + ) + .await; + + let payload = serde_json::json!({ + "source_node_id": req.source_node_id.to_string(), + "ai_preset_id": req.ai_preset_id.to_string(), + "direction": req.direction, + "requested_by": user.node_id.to_string() + }); + + let job_id = crate::jobs::enqueue( + &state.db, + "ai_process", + payload, + collection_id, + 5, // Medium prioritet + ) + .await + .map_err(|e| { + tracing::error!(error = %e, "Kunne ikke legge ai_process-jobb i kø"); + internal_error("Kunne ikke starte AI-prosessering") + })?; + + tracing::info!( + job_id = %job_id, + source_node_id = %req.source_node_id, + ai_preset_id = %req.ai_preset_id, + direction = %req.direction, + user = %user.node_id, + "ai_process-jobb lagt i kø" + ); + + Ok(Json(AiProcessResponse { job_id })) +} + // ============================================================================= // POST /intentions/generate_tts — tekst-til-tale via ElevenLabs // ============================================================================= diff --git a/maskinrommet/src/jobs.rs b/maskinrommet/src/jobs.rs index 7624762..8e13cda 100644 --- a/maskinrommet/src/jobs.rs +++ b/maskinrommet/src/jobs.rs @@ -16,6 +16,7 @@ use uuid::Uuid; use crate::agent; use crate::ai_edges; +use crate::ai_process; use crate::audio; use crate::cas::CasStore; use crate::maintenance::MaintenanceState; @@ -184,6 +185,9 @@ async fn dispatch( "audio_process" => { audio::handle_audio_process_job(job, db, stdb, cas).await } + "ai_process" => { + ai_process::handle_ai_process(job, db).await + } "render_article" => { handle_render_article(job, db, cas).await } diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 9c9c789..e1f302a 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -1,6 +1,7 @@ pub mod agent; pub mod ai_admin; pub mod ai_edges; +pub mod ai_process; pub mod audio; pub mod bandwidth; mod auth; @@ -202,6 +203,7 @@ async fn main() { .route("/intentions/retranscribe", post(intentions::retranscribe)) .route("/intentions/resolve_retranscription", post(intentions::resolve_retranscription)) .route("/intentions/summarize", post(intentions::summarize)) + .route("/intentions/ai_process", post(intentions::ai_process)) .route("/intentions/generate_tts", post(intentions::generate_tts)) .route("/intentions/join_communication", post(intentions::join_communication)) .route("/intentions/leave_communication", post(intentions::leave_communication)) diff --git a/tasks.md b/tasks.md index e4f35b5..48fcfa8 100644 --- a/tasks.md +++ b/tasks.md @@ -202,8 +202,7 @@ Ref: Kodegjennomgang av `b4c4bb8` (Lydstudio: lydredigering via FFmpeg). Ref: `docs/features/ai_verktoy.md`, `docs/retninger/arbeidsflaten.md` - [x] 18.1 AI-preset node-type: `node_kind: 'ai_preset'` med metadata (prompt, model_profile, category, icon, color). Maskinrommet validerer ved opprettelse. Seed standardprompter (rens tekst, korrektør, oppsummering, oversett, skriv om, trekk ut fakta, forenkle, endre tone). -- [~] 18.2 AI-prosessering endepunkt: `POST /intentions/ai_process` med source_node_id, ai_preset_id, direction (node_to_tool / tool_to_node). Maskinrommet henter kilde-content og preset-prompt, mapper modellprofil → LiteLLM-alias, sender til AI Gateway. Logg forbruk i ai_usage_log. - > Påbegynt: 2026-03-18T06:14 +- [x] 18.2 AI-prosessering endepunkt: `POST /intentions/ai_process` med source_node_id, ai_preset_id, direction (node_to_tool / tool_to_node). Maskinrommet henter kilde-content og preset-prompt, mapper modellprofil → LiteLLM-alias, sender til AI Gateway. Logg forbruk i ai_usage_log. - [ ] 18.3 Direction-logikk: `tool_to_node` → lagre original som revisjon, oppdater node content. `node_to_tool` → opprett ny node med AI-output, opprett `derived_from`-edge til kilde + `processed_by`-edge til AI-preset. - [ ] 18.4 AI-verktøy panel (frontend): Svelte-komponent for arbeidsflaten. Prompt-velger med standardprompter, fritekst-felt for egendefinert prompt, modell-indikator (readonly). Drag-and-drop mottak for tekstnoder. - [ ] 18.5 Drag-and-drop integrasjon: node → verktøy (ny node), verktøy → node (in-place revisjon). Drop-sone feedback med verktøyets farge. Inkompatibilitet for lyd/bilde-noder med forklaring.