// synops-summarize — AI-oppsummering av kommunikasjonsnode. // // Input: --communication-id . Henter meldinger og deltakere fra PG, // sender til LiteLLM for oppsummering, returnerer sammendrag som JSON. // Med --write: oppretter sammendrag-node og edges i PG. // // Miljøvariabler: // DATABASE_URL — PostgreSQL-tilkobling (påkrevd) // AI_GATEWAY_URL — LiteLLM gateway (default: http://localhost:4000) // LITELLM_MASTER_KEY — API-nøkkel for LiteLLM // AI_SUMMARY_MODEL — Modellalias (default: sidelinja/rutine) // // Erstatter: maskinrommet/src/summarize.rs // Ref: docs/retninger/unix_filosofi.md, docs/infra/ai_gateway.md use clap::Parser; use serde::{Deserialize, Serialize}; use std::process; use uuid::Uuid; /// AI-oppsummering av kommunikasjonsnode via LiteLLM. #[derive(Parser)] #[command(name = "synops-summarize", about = "Generer AI-sammendrag av en kommunikasjonsnode")] struct Cli { /// Kommunikasjonsnode-ID som skal oppsummeres #[arg(long)] communication_id: Uuid, /// Bruker-ID som utløste oppsummeringen #[arg(long)] requested_by: Option, /// Skriv sammendrag-node og edges til database (uten: kun generering + stdout) #[arg(long)] write: bool, } // --- Database-rader --- #[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, } // --- LLM request/response (OpenAI-kompatibel) --- #[derive(Serialize)] struct ChatRequest { model: String, messages: Vec, temperature: f32, } #[derive(Serialize)] struct ChatMessage { role: String, content: String, } #[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, } 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."#; #[tokio::main] async fn main() { synops_common::logging::init("synops_summarize"); let cli = Cli::parse(); if cli.write && cli.requested_by.is_none() { eprintln!("Feil: --requested-by er påkrevd sammen med --write"); process::exit(1); } if let Err(e) = run(cli).await { eprintln!("Feil: {e}"); process::exit(1); } } async fn run(cli: Cli) -> Result<(), String> { let db = synops_common::db::connect().await?; let communication_id = cli.communication_id; // 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}"))? .ok_or_else(|| format!("Kommunikasjonsnode {communication_id} ikke funnet"))? .unwrap_or_else(|| "Samtale".to_string()); tracing::info!( communication_id = %communication_id, title = %comm_title, "Henter meldinger for oppsummering" ); // 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() { let result = serde_json::json!({ "status": "skipped", "reason": "no_messages", "communication_id": communication_id.to_string() }); println!("{}", serde_json::to_string_pretty(&result).unwrap()); return Ok(()); } // 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() { let result = serde_json::json!({ "status": "skipped", "reason": "empty_conversation", "communication_id": communication_id.to_string() }); println!("{}", serde_json::to_string_pretty(&result).unwrap()); return Ok(()); } 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 tracing::info!(message_count = messages.len(), "Sender til LLM for oppsummering"); let (summary_text, llm_usage, llm_model) = call_llm_summary(&user_content).await?; tracing::info!(summary_len = summary_text.len(), "Sammendrag generert"); // 6. Bygg resultat let tokens_in = llm_usage.as_ref().map(|u| u.prompt_tokens).unwrap_or(0); let tokens_out = llm_usage.as_ref().map(|u| u.completion_tokens).unwrap_or(0); let model_id = llm_model.unwrap_or_else(|| "unknown".to_string()); let mut result = serde_json::json!({ "status": "completed", "communication_id": communication_id.to_string(), "summary": summary_text, "message_count": messages.len(), "model": model_id, "tokens_in": tokens_in, "tokens_out": tokens_out, }); // 7. Skriv til database hvis --write if cli.write { let requested_by = cli.requested_by.unwrap(); // Allerede validert let summary_node_id = write_to_db( &db, communication_id, &comm_title, &summary_text, messages.len(), requested_by, &model_id, tokens_in, tokens_out, ) .await?; result["summary_node_id"] = serde_json::Value::String(summary_node_id.to_string()); } // 8. Output JSON til stdout println!( "{}", serde_json::to_string_pretty(&result) .map_err(|e| format!("JSON-serialisering feilet: {e}"))? ); Ok(()) } /// Kall LiteLLM for oppsummering. Returnerer (tekst, usage, model). async fn call_llm_summary( 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 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(), chat_resp.usage, chat_resp.model)) } /// Opprett sammendrag-node, belongs_to-edge, summary-edge og logg ressursforbruk. /// Returnerer ID-en til den nye sammendrag-noden. async fn write_to_db( db: &sqlx::PgPool, communication_id: Uuid, comm_title: &str, summary_text: &str, message_count: usize, requested_by: Uuid, model_id: &str, tokens_in: i64, tokens_out: i64, ) -> Result { 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": message_count, "communication_id": communication_id.to_string() }); // Opprett sammendrag-node 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}"))?; tracing::info!(summary_node_id = %summary_node_id, "Sammendrag-node opprettet"); // belongs_to-edge: sammendrag → kommunikasjonsnode let belongs_edge_id = Uuid::now_v7(); 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}"))?; // summary-edge: kommunikasjonsnode → sammendrag let summary_edge_id = Uuid::now_v7(); let summary_edge_meta = serde_json::json!({ "generated_at": chrono::Utc::now().to_rfc3339() }); 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 knyttet til kommunikasjonsnode" ); // Logg AI-ressursforbruk let collection_id: Option = sqlx::query_scalar( "SELECT e.target_id FROM edges e JOIN nodes n ON n.id = e.target_id WHERE e.source_id = $1 AND e.edge_type = 'belongs_to' AND n.node_kind = 'collection' LIMIT 1", ) .bind(communication_id) .fetch_optional(db) .await .ok() .flatten(); if let Err(e) = sqlx::query( "INSERT INTO resource_usage_log (target_node_id, triggered_by, collection_id, resource_type, detail) VALUES ($1, $2, $3, $4, $5)", ) .bind(communication_id) .bind(Some(requested_by)) .bind(collection_id) .bind("ai") .bind(serde_json::json!({ "model_level": "smart", "model_id": model_id, "tokens_in": tokens_in, "tokens_out": tokens_out, "job_type": "summarize_communication" })) .execute(db) .await { tracing::warn!(error = %e, "Kunne ikke logge AI-ressursforbruk"); } Ok(summary_node_id) }