// 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()) }