Fullfører oppgave 18.2: AI-prosessering endepunkt

POST /intentions/ai_process med source_node_id, ai_preset_id og
direction (node_to_tool / tool_to_node).

Endepunktet validerer input, sjekker at kilde-node og AI-preset
finnes, verifiserer skrivetilgang for tool_to_node-retning, og
legger en ai_process-jobb i køen.

Jobb-handleren (ai_process.rs) henter kilde-content og preset-prompt,
mapper modellprofil → LiteLLM-alias (flash → sidelinja/rutine,
standard → sidelinja/resonering), kaller AI Gateway, og logger
forbruk i både ai_usage_log og resource_usage_log.

Direction-logikk (opprett ny node vs. oppdater eksisterende)
implementeres i oppgave 18.3.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 06:24:31 +00:00
parent 7224cf9897
commit bca0ff1deb
6 changed files with 450 additions and 3 deletions

View file

@ -225,7 +225,7 @@ Flere AI-verktøy i serie: dra output fra "Oversett" videre til
### Fase A: Grunnleggende verktøy-panel ### Fase A: Grunnleggende verktøy-panel
- [x] AI-preset node-type (`node_kind: 'ai_preset'`) + metadata-skjema - [x] AI-preset node-type (`node_kind: 'ai_preset'`) + metadata-skjema
- [x] Standard-presets som seed-data (rens tekst, korrektør, oppsummering osv.) - [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 - [ ] Verktøy-panel UI med prompt-velger og modell-indikator
- [ ] Jobbkø-integrasjon med AI Gateway - [ ] Jobbkø-integrasjon med AI Gateway

View file

@ -0,0 +1,314 @@
// AI-prosessering — hent kilde-content + preset-prompt, kall AI Gateway.
//
// Jobbtype: "ai_process"
// Payload: {
// "source_node_id": "<uuid>",
// "ai_preset_id": "<uuid>",
// "direction": "node_to_tool" | "tool_to_node",
// "requested_by": "<uuid>"
// }
//
// 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<String>,
#[allow(dead_code)] // Brukes i oppgave 18.3 (direction-logikk)
title: Option<String>,
#[allow(dead_code)] // Brukes i oppgave 18.3 (direction-logikk)
node_kind: String,
}
#[derive(sqlx::FromRow)]
struct PresetRow {
title: Option<String>,
metadata: Option<serde_json::Value>,
}
/// OpenAI-kompatibel chat completion request.
#[derive(Serialize)]
struct ChatRequest {
model: String,
messages: Vec<ChatMessage>,
temperature: f32,
}
#[derive(Serialize)]
struct ChatMessage {
role: String,
content: String,
}
/// OpenAI-kompatibel chat completion response.
#[derive(Deserialize)]
struct ChatResponse {
choices: Vec<Choice>,
#[serde(default)]
usage: Option<UsageInfo>,
#[serde(default)]
model: Option<String>,
}
#[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<String>,
}
/// 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<serde_json::Value, String> {
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<UsageInfo>, Option<String>), 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))
}

View file

@ -3228,6 +3228,134 @@ pub async fn summarize(
Ok(Json(SummarizeResponse { job_id })) 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<AppState>,
user: AuthUser,
Json(req): Json<AiProcessRequest>,
) -> Result<Json<AiProcessResponse>, (StatusCode, Json<ErrorResponse>)> {
// 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 // POST /intentions/generate_tts — tekst-til-tale via ElevenLabs
// ============================================================================= // =============================================================================

View file

@ -16,6 +16,7 @@ use uuid::Uuid;
use crate::agent; use crate::agent;
use crate::ai_edges; use crate::ai_edges;
use crate::ai_process;
use crate::audio; use crate::audio;
use crate::cas::CasStore; use crate::cas::CasStore;
use crate::maintenance::MaintenanceState; use crate::maintenance::MaintenanceState;
@ -184,6 +185,9 @@ async fn dispatch(
"audio_process" => { "audio_process" => {
audio::handle_audio_process_job(job, db, stdb, cas).await audio::handle_audio_process_job(job, db, stdb, cas).await
} }
"ai_process" => {
ai_process::handle_ai_process(job, db).await
}
"render_article" => { "render_article" => {
handle_render_article(job, db, cas).await handle_render_article(job, db, cas).await
} }

View file

@ -1,6 +1,7 @@
pub mod agent; pub mod agent;
pub mod ai_admin; pub mod ai_admin;
pub mod ai_edges; pub mod ai_edges;
pub mod ai_process;
pub mod audio; pub mod audio;
pub mod bandwidth; pub mod bandwidth;
mod auth; mod auth;
@ -202,6 +203,7 @@ async fn main() {
.route("/intentions/retranscribe", post(intentions::retranscribe)) .route("/intentions/retranscribe", post(intentions::retranscribe))
.route("/intentions/resolve_retranscription", post(intentions::resolve_retranscription)) .route("/intentions/resolve_retranscription", post(intentions::resolve_retranscription))
.route("/intentions/summarize", post(intentions::summarize)) .route("/intentions/summarize", post(intentions::summarize))
.route("/intentions/ai_process", post(intentions::ai_process))
.route("/intentions/generate_tts", post(intentions::generate_tts)) .route("/intentions/generate_tts", post(intentions::generate_tts))
.route("/intentions/join_communication", post(intentions::join_communication)) .route("/intentions/join_communication", post(intentions::join_communication))
.route("/intentions/leave_communication", post(intentions::leave_communication)) .route("/intentions/leave_communication", post(intentions::leave_communication))

View file

@ -202,8 +202,7 @@ Ref: Kodegjennomgang av `b4c4bb8` (Lydstudio: lydredigering via FFmpeg).
Ref: `docs/features/ai_verktoy.md`, `docs/retninger/arbeidsflaten.md` 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). - [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. - [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.
> Påbegynt: 2026-03-18T06:14
- [ ] 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.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.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. - [ ] 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.