From 3c1d85026bca2928ef89db54315a4d4ce01e8e5d Mon Sep 17 00:00:00 2001 From: vegard Date: Tue, 17 Mar 2026 23:31:16 +0000 Subject: [PATCH] AI-oppsummering av kommunikasjonsnoder (oppgave 10.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ny jobbtype `summarize_communication` som henter alle meldinger fra en kommunikasjonsnode, sender dem til LiteLLM for oppsummering, og oppretter en content-node med sammendraget. Sammendraget knyttes til kommunikasjonsnoden med `belongs_to`-edge (del av samtalen) og `summary`-edge (lett å finne sammendrag for en gitt samtale). API-endepunkt: POST /intentions/summarize { communication_id } Verifiserer at brukeren er deltaker i samtalen. Jobbprioritiet 3 (bakgrunn). Modell konfigurerbar via AI_SUMMARY_MODEL env-variabel. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/infra/jobbkø.md | 1 + maskinrommet/src/intentions.rs | 86 ++++++++ maskinrommet/src/jobs.rs | 4 + maskinrommet/src/main.rs | 2 + maskinrommet/src/summarize.rs | 385 +++++++++++++++++++++++++++++++++ tasks.md | 3 +- 6 files changed, 479 insertions(+), 2 deletions(-) create mode 100644 maskinrommet/src/summarize.rs diff --git a/docs/infra/jobbkø.md b/docs/infra/jobbkø.md index 1b9db42..c15f2a9 100644 --- a/docs/infra/jobbkø.md +++ b/docs/infra/jobbkø.md @@ -117,6 +117,7 @@ Verdiene er veiledende — SvelteKit setter prioritet ved opprettelse basert på | `generate_embeddings` | Kunnskaps-Bridge | Generer vector embeddings for noder (pgvector) | | `prompt_eval` | Prompt-Laboratorium | Batch-evaluering av testsett mot valgte modeller | | `suggest_edges` | Kunnskapsgraf (AI) | Analyser innhold via LLM, foreslå topics og mentions-edges. Trigges automatisk ved opprettelse av content-noder med tilstrekkelig tekst | +| `summarize_communication` | Oppsummering (AI) | Generer AI-sammendrag av kommunikasjonsnode (chat/møte). Oppretter content-node med summary-edge tilbake. Trigges via `/intentions/summarize` | | `url_ingest` | Web Clipper (proposal) | Hent URL, oppsummer via AI, opprett research-klipp med graf-koblinger | | `generate_waveform` | Waveforms (proposal) | Generer audio-peaks fra lydfil for visuell bølgeform | diff --git a/maskinrommet/src/intentions.rs b/maskinrommet/src/intentions.rs index 536633f..5fa99da 100644 --- a/maskinrommet/src/intentions.rs +++ b/maskinrommet/src/intentions.rs @@ -2141,3 +2141,89 @@ pub async fn resolve_retranscription( kept_new, })) } + +// ============================================================================= +// POST /intentions/summarize — generer AI-sammendrag av kommunikasjonsnode +// ============================================================================= + +#[derive(Deserialize)] +pub struct SummarizeRequest { + /// Kommunikasjonsnode-ID som skal oppsummeres. + pub communication_id: Uuid, +} + +#[derive(Serialize)] +pub struct SummarizeResponse { + pub job_id: Uuid, +} + +/// POST /intentions/summarize +/// +/// Legger en `summarize_communication`-jobb i køen. +/// Sammendraget opprettes asynkront som en ny content-node +/// med summary-edge tilbake til kommunikasjonsnoden. +pub async fn summarize( + State(state): State, + user: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + // Verifiser at kommunikasjonsnoden finnes og brukeren har tilgang + let exists: bool = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1 AND node_kind = 'communication')", + ) + .bind(req.communication_id) + .fetch_one(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "PG-feil ved kommunikasjonssjekk"); + internal_error("Databasefeil") + })?; + + if !exists { + return Err(bad_request("Kommunikasjonsnode finnes ikke")); + } + + // Sjekk at brukeren er deltaker (owner eller member_of) + let is_participant: bool = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM edges WHERE source_id = $1 AND target_id = $2 AND edge_type IN ('owner', 'member_of'))", + ) + .bind(user.node_id) + .bind(req.communication_id) + .fetch_one(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "PG-feil ved deltagersjekk"); + internal_error("Databasefeil") + })?; + + if !is_participant { + return Err(forbidden("Ikke deltaker i samtalen")); + } + + let payload = serde_json::json!({ + "communication_id": req.communication_id.to_string(), + "requested_by": user.node_id.to_string() + }); + + let job_id = crate::jobs::enqueue( + &state.db, + "summarize_communication", + payload, + None, + 3, // Lav prioritet — ikke tidskritisk + ) + .await + .map_err(|e| { + tracing::error!(error = %e, "Kunne ikke legge oppsummerings-jobb i kø"); + internal_error("Kunne ikke starte oppsummering") + })?; + + tracing::info!( + job_id = %job_id, + communication_id = %req.communication_id, + user = %user.node_id, + "Oppsummerings-jobb lagt i kø" + ); + + Ok(Json(SummarizeResponse { job_id })) +} diff --git a/maskinrommet/src/jobs.rs b/maskinrommet/src/jobs.rs index 2310f49..eff7e7c 100644 --- a/maskinrommet/src/jobs.rs +++ b/maskinrommet/src/jobs.rs @@ -12,6 +12,7 @@ use crate::agent; use crate::ai_edges; use crate::cas::CasStore; use crate::stdb::StdbClient; +use crate::summarize; use crate::transcribe; /// Rad fra job_queue-tabellen. @@ -159,6 +160,9 @@ async fn dispatch( "suggest_edges" => { ai_edges::handle_suggest_edges(job, db, stdb).await } + "summarize_communication" => { + summarize::handle_summarize_communication(job, db, stdb).await + } other => Err(format!("Ukjent jobbtype: {other}")), } } diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 4832eda..03b77e6 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -7,6 +7,7 @@ pub mod jobs; mod queries; mod serving; mod stdb; +pub mod summarize; pub mod transcribe; mod warmup; @@ -149,6 +150,7 @@ async fn main() { .route("/intentions/update_segment", post(intentions::update_segment)) .route("/intentions/retranscribe", post(intentions::retranscribe)) .route("/intentions/resolve_retranscription", post(intentions::resolve_retranscription)) + .route("/intentions/summarize", post(intentions::summarize)) .route("/query/aliases", get(queries::query_aliases)) .route("/query/graph", get(queries::query_graph)) .route("/query/transcription_versions", get(queries::query_transcription_versions)) diff --git a/maskinrommet/src/summarize.rs b/maskinrommet/src/summarize.rs new file mode 100644 index 0000000..a2bfa21 --- /dev/null +++ b/maskinrommet/src/summarize.rs @@ -0,0 +1,385 @@ +// Oppsummering — kommunikasjonsnode → AI-generert sammendrag som ny node. +// +// Jobbtype: "summarize_communication" +// Payload: { "communication_id": "", "requested_by": "" } +// +// Flyten: +// 1. Hent alle meldinger fra kommunikasjonsnoden (content-noder med belongs_to-edge) +// 2. Hent deltakernavn for lesbar kontekst +// 3. Send til LiteLLM for oppsummering +// 4. Opprett ny content-node med sammendraget +// 5. Opprett belongs_to-edge (sammendrag → kommunikasjonsnode) +// 6. Opprett summary-edge (kommunikasjonsnode → sammendrag) +// +// Ref: docs/infra/jobbkø.md, docs/primitiver/nodes.md, docs/primitiver/edges.md + +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::jobs::JobRow; +use crate::stdb::StdbClient; + +#[derive(sqlx::FromRow)] +struct MessageRow { + content: Option, + created_by: Uuid, + #[allow(dead_code)] + created_at: chrono::DateTime, +} + +#[derive(sqlx::FromRow)] +struct ParticipantRow { + id: Uuid, + title: 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, +} + +#[derive(Deserialize)] +struct Choice { + message: MessageContent, +} + +#[derive(Deserialize)] +struct MessageContent { + content: Option, +} + +const SYSTEM_PROMPT: &str = r#"Du er en oppsummeringsassistent for en norsk redaksjonsplattform. + +Du mottar en samtalelogg fra en kommunikasjonsnode (chat/møte/diskusjon). Lag et konsist sammendrag som fanger: + +1. **Hovedtema:** Hva handlet samtalen om? +2. **Nøkkelpunkter:** De viktigste poengene, beslutningene eller konklusjonene. +3. **Handlingspunkter:** Eventuelle oppgaver, avtaler eller neste steg som ble nevnt. + +Regler: +- Skriv på norsk, i prosa (ikke punktlister med mindre det passer naturlig for handlingspunkter). +- Vær konsis — maks 3-4 avsnitt. +- Referer til deltakere ved navn der det er relevant. +- Ikke inkluder metadata, tidsstempler eller systeminfo. +- Hvis samtalen er svært kort eller innholdsløs, skriv en kort setning som oppsummerer det."#; + +/// Håndterer summarize_communication-jobb. +pub async fn handle_summarize_communication( + job: &JobRow, + db: &PgPool, + stdb: &StdbClient, +) -> Result { + let communication_id: Uuid = job + .payload + .get("communication_id") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse().ok()) + .ok_or("Mangler gyldig communication_id 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 gyldig requested_by i payload")?; + + tracing::info!( + communication_id = %communication_id, + requested_by = %requested_by, + "Starter oppsummering av kommunikasjonsnode" + ); + + // 1. Verifiser at kommunikasjonsnoden finnes + let comm_title: String = sqlx::query_scalar::<_, Option>( + "SELECT title FROM nodes WHERE id = $1 AND node_kind = 'communication'", + ) + .bind(communication_id) + .fetch_optional(db) + .await + .map_err(|e| format!("PG-feil: {e}"))? + .flatten() + .unwrap_or_else(|| "Samtale".to_string()); + + // 2. Hent alle meldinger i samtalen + let messages = sqlx::query_as::<_, MessageRow>( + r#" + SELECT n.content, n.created_by, n.created_at + FROM nodes n + JOIN edges e ON e.source_id = n.id + WHERE e.target_id = $1 + AND e.edge_type = 'belongs_to' + AND n.node_kind = 'content' + ORDER BY n.created_at ASC + "#, + ) + .bind(communication_id) + .fetch_all(db) + .await + .map_err(|e| format!("PG-feil ved henting av meldinger: {e}"))?; + + if messages.is_empty() { + tracing::info!( + communication_id = %communication_id, + "Ingen meldinger å oppsummere" + ); + return Ok(serde_json::json!({ + "status": "skipped", + "reason": "no_messages" + })); + } + + // 3. Hent deltakere + let participants = sqlx::query_as::<_, ParticipantRow>( + r#" + SELECT n.id, n.title + FROM nodes n + JOIN edges e ON e.source_id = n.id + WHERE e.target_id = $1 + AND e.edge_type IN ('owner', 'member_of') + "#, + ) + .bind(communication_id) + .fetch_all(db) + .await + .map_err(|e| format!("PG-feil ved henting av deltakere: {e}"))?; + + let name_map: std::collections::HashMap = participants + .iter() + .map(|p| { + ( + p.id, + p.title.clone().unwrap_or_else(|| "Ukjent".to_string()), + ) + }) + .collect(); + + // 4. Bygg samtalelogg for LLM + let mut conversation = String::new(); + for m in &messages { + let name = name_map + .get(&m.created_by) + .map(|s| s.as_str()) + .unwrap_or("Ukjent"); + let content = m.content.as_deref().unwrap_or(""); + if !content.is_empty() { + conversation.push_str(&format!("{name}: {content}\n")); + } + } + + if conversation.trim().is_empty() { + return Ok(serde_json::json!({ + "status": "skipped", + "reason": "empty_conversation" + })); + } + + let participant_names: String = participants + .iter() + .filter_map(|p| p.title.as_deref()) + .collect::>() + .join(", "); + + let user_content = format!( + "Samtale: \"{comm_title}\"\nDeltakere: {participant_names}\nAntall meldinger: {}\n\n--- Samtalelogg ---\n{conversation}", + messages.len() + ); + + // 5. Kall LiteLLM + let summary_text = call_llm_summary(&user_content).await?; + + tracing::info!( + communication_id = %communication_id, + summary_len = summary_text.len(), + "Sammendrag generert" + ); + + // 6. Opprett sammendrag-node + let summary_node_id = Uuid::now_v7(); + let summary_title = format!("Sammendrag: {comm_title}"); + let metadata = serde_json::json!({ + "ai_generated": true, + "source_type": "communication_summary", + "message_count": messages.len(), + "communication_id": communication_id.to_string() + }); + let metadata_str = metadata.to_string(); + let empty_meta = serde_json::json!({}).to_string(); + + // STDB først (sanntid) + stdb.create_node( + &summary_node_id.to_string(), + "content", + &summary_title, + &summary_text, + "hidden", + &metadata_str, + &requested_by.to_string(), + ) + .await + .map_err(|e| format!("STDB create_node feilet: {e}"))?; + + // PG (persistering) + 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(summary_node_id) + .bind(&summary_title) + .bind(&summary_text) + .bind(&metadata) + .bind(requested_by) + .execute(db) + .await + .map_err(|e| format!("PG insert summary-node feilet: {e}"))?; + + // 7. Opprett belongs_to-edge: sammendrag → kommunikasjonsnode + let belongs_edge_id = Uuid::now_v7(); + stdb.create_edge( + &belongs_edge_id.to_string(), + &summary_node_id.to_string(), + &communication_id.to_string(), + "belongs_to", + &empty_meta, + false, + &requested_by.to_string(), + ) + .await + .map_err(|e| format!("STDB create_edge (belongs_to) feilet: {e}"))?; + + sqlx::query( + r#" + INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by) + VALUES ($1, $2, $3, 'belongs_to', '{}', false, $4) + "#, + ) + .bind(belongs_edge_id) + .bind(summary_node_id) + .bind(communication_id) + .bind(requested_by) + .execute(db) + .await + .map_err(|e| format!("PG insert belongs_to-edge feilet: {e}"))?; + + // 8. Opprett summary-edge: kommunikasjonsnode → sammendrag + // Denne gjør det enkelt å finne sammendrag for en samtale. + let summary_edge_id = Uuid::now_v7(); + let summary_edge_meta = serde_json::json!({ + "generated_at": chrono::Utc::now().to_rfc3339() + }); + + stdb.create_edge( + &summary_edge_id.to_string(), + &communication_id.to_string(), + &summary_node_id.to_string(), + "summary", + &summary_edge_meta.to_string(), + false, + &requested_by.to_string(), + ) + .await + .map_err(|e| format!("STDB create_edge (summary) feilet: {e}"))?; + + sqlx::query( + r#" + INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by) + VALUES ($1, $2, $3, 'summary', $4, false, $5) + "#, + ) + .bind(summary_edge_id) + .bind(communication_id) + .bind(summary_node_id) + .bind(&summary_edge_meta) + .bind(requested_by) + .execute(db) + .await + .map_err(|e| format!("PG insert summary-edge feilet: {e}"))?; + + tracing::info!( + summary_node_id = %summary_node_id, + communication_id = %communication_id, + "Sammendrag-node opprettet og knyttet til kommunikasjonsnode" + ); + + Ok(serde_json::json!({ + "status": "completed", + "summary_node_id": summary_node_id.to_string(), + "summary_length": summary_text.len(), + "message_count": messages.len() + })) +} + +/// Kall LiteLLM for oppsummering. +async fn call_llm_summary(user_content: &str) -> Result { + 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(); + + // Oppsummering kan bruke en bedre modell enn edge-forslag + let model = std::env::var("AI_SUMMARY_MODEL") + .unwrap_or_else(|_| "sidelinja/rutine".to_string()); + + let request = ChatRequest { + model, + 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(60)) + .send() + .await + .map_err(|e| format!("LiteLLM-kall feilet: {e}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("LiteLLM returnerte {status}: {body}")); + } + + let chat_resp: ChatResponse = resp + .json() + .await + .map_err(|e| format!("Kunne ikke parse LiteLLM-respons: {e}"))?; + + let content = chat_resp + .choices + .first() + .and_then(|c| c.message.content.as_deref()) + .ok_or("LiteLLM returnerte ingen content")?; + + Ok(content.to_string()) +} diff --git a/tasks.md b/tasks.md index 7aebd08..907a556 100644 --- a/tasks.md +++ b/tasks.md @@ -118,8 +118,7 @@ Uavhengige faser kan fortsatt plukkes. - [x] 10.1 LiteLLM oppsett: Docker-container, API-nøkler, modell-routing. Ref: `docs/infra/ai_gateway.md`. - [x] 10.2 AI-foreslåtte edges: maskinrommet sender innhold til LLM → foreslår mentions, topics. -- [~] 10.3 Oppsummering: kommunikasjonsnode → AI-generert sammendrag som ny node. - > Påbegynt: 2026-03-17T23:25 +- [x] 10.3 Oppsummering: kommunikasjonsnode → AI-generert sammendrag som ny node. - [ ] 10.4 TTS: tekst → lyd via ElevenLabs. Mottaker-preferanse i metadata. ## Fase 11: Produksjons-pipeline