synops/maskinrommet/src/summarize.rs
vegard 3c1d85026b AI-oppsummering av kommunikasjonsnoder (oppgave 10.3)
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) <noreply@anthropic.com>
2026-03-17 23:31:16 +00:00

385 lines
11 KiB
Rust

// Oppsummering — kommunikasjonsnode → AI-generert sammendrag som ny node.
//
// Jobbtype: "summarize_communication"
// Payload: { "communication_id": "<uuid>", "requested_by": "<uuid>" }
//
// 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<String>,
created_by: Uuid,
#[allow(dead_code)]
created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(sqlx::FromRow)]
struct ParticipantRow {
id: Uuid,
title: Option<String>,
}
/// 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>,
}
#[derive(Deserialize)]
struct Choice {
message: MessageContent,
}
#[derive(Deserialize)]
struct MessageContent {
content: Option<String>,
}
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<serde_json::Value, String> {
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<String>>(
"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<Uuid, String> = 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::<Vec<_>>()
.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<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();
// 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())
}