Implementer synops-summarize CLI-verktøy (oppgave 21.6)
Ekstraherer AI-oppsummeringslogikk fra maskinrommet til standalone CLI-verktøy, i tråd med unix_filosofi.md-prinsippet om at maskinrommet orkestrerer og CLI-verktøy gjør jobben. synops-summarize: - Henter meldinger og deltakere fra kommunikasjonsnode i PG - Sender samtalelogg til LiteLLM for oppsummering - Med --write: oppretter sammendrag-node, belongs_to/summary-edges, logger AI-ressursforbruk - Uten --write: dry-run som skriver JSON til stdout maskinrommet/src/summarize.rs er nå en tynn dispatcher som spawner synops-summarize med --write, tilsvarende transcribe.rs-mønsteret. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8baeb0ec0e
commit
08ff14028b
6 changed files with 3487 additions and 381 deletions
|
|
@ -1,102 +1,36 @@
|
|||
// Oppsummering — kommunikasjonsnode → AI-generert sammendrag som ny node.
|
||||
// Oppsummerings-dispatcher — delegerer til synops-summarize CLI.
|
||||
//
|
||||
// Maskinrommet orkestrerer, CLI-verktøyet gjør jobben.
|
||||
// Ref: docs/retninger/unix_filosofi.md
|
||||
//
|
||||
// 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 std::process::Stdio;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::jobs::JobRow;
|
||||
use crate::resource_usage;
|
||||
use crate::stdb::StdbClient;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct MessageRow {
|
||||
content: Option<String>,
|
||||
created_by: Uuid,
|
||||
#[allow(dead_code)]
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
/// Synops-summarize binary path.
|
||||
/// Søker i PATH, men kan overrides med SYNOPS_SUMMARIZE_BIN.
|
||||
fn summarize_bin() -> String {
|
||||
std::env::var("SYNOPS_SUMMARIZE_BIN")
|
||||
.unwrap_or_else(|_| "synops-summarize".to_string())
|
||||
}
|
||||
|
||||
#[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>,
|
||||
#[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>,
|
||||
}
|
||||
|
||||
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.
|
||||
/// Handler for summarize_communication-jobber.
|
||||
///
|
||||
/// Spawner synops-summarize med --write for å gjøre alt arbeidet:
|
||||
/// LLM-kall, node-opprettelse, edge-skriving, ressurslogging.
|
||||
///
|
||||
/// Payload forventer:
|
||||
/// - communication_id: UUID — kommunikasjonsnoden som skal oppsummeres
|
||||
/// - requested_by: UUID — brukeren som utløste oppsummeringen
|
||||
pub async fn handle_summarize_communication(
|
||||
job: &JobRow,
|
||||
db: &PgPool,
|
||||
stdb: &StdbClient,
|
||||
_db: &sqlx::PgPool,
|
||||
_stdb: &StdbClient,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let communication_id: Uuid = job
|
||||
.payload
|
||||
|
|
@ -112,312 +46,75 @@ pub async fn handle_summarize_communication(
|
|||
.and_then(|s| s.parse().ok())
|
||||
.ok_or("Mangler gyldig requested_by i payload")?;
|
||||
|
||||
// Bygg kommando
|
||||
let bin = summarize_bin();
|
||||
let mut cmd = tokio::process::Command::new(&bin);
|
||||
|
||||
cmd.arg("--communication-id")
|
||||
.arg(communication_id.to_string())
|
||||
.arg("--requested-by")
|
||||
.arg(requested_by.to_string())
|
||||
.arg("--write");
|
||||
|
||||
// Sett miljøvariabler CLI-verktøyet trenger
|
||||
let db_url = std::env::var("DATABASE_URL")
|
||||
.map_err(|_| "DATABASE_URL ikke satt".to_string())?;
|
||||
|
||||
cmd.env("DATABASE_URL", &db_url);
|
||||
|
||||
// Videresend AI-relaterte env-variabler hvis satt
|
||||
if let Ok(v) = std::env::var("AI_GATEWAY_URL") {
|
||||
cmd.env("AI_GATEWAY_URL", v);
|
||||
}
|
||||
if let Ok(v) = std::env::var("LITELLM_MASTER_KEY") {
|
||||
cmd.env("LITELLM_MASTER_KEY", v);
|
||||
}
|
||||
if let Ok(v) = std::env::var("AI_SUMMARY_MODEL") {
|
||||
cmd.env("AI_SUMMARY_MODEL", v);
|
||||
}
|
||||
|
||||
cmd.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
tracing::info!(
|
||||
communication_id = %communication_id,
|
||||
requested_by = %requested_by,
|
||||
"Starter oppsummering av kommunikasjonsnode"
|
||||
bin = %bin,
|
||||
"Starter synops-summarize"
|
||||
);
|
||||
|
||||
// 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());
|
||||
// Spawn og vent
|
||||
let child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Kunne ikke starte {bin}: {e}"))?;
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.await
|
||||
.map_err(|e| format!("Feil ved kjøring av {bin}: {e}"))?;
|
||||
|
||||
// 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"
|
||||
}));
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if !stderr.is_empty() {
|
||||
tracing::info!(stderr = %stderr, "synops-summarize stderr");
|
||||
}
|
||||
|
||||
// 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 !output.status.success() {
|
||||
let code = output.status.code().unwrap_or(-1);
|
||||
return Err(format!(
|
||||
"synops-summarize feilet (exit {code}): {stderr}"
|
||||
));
|
||||
}
|
||||
|
||||
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, llm_usage, llm_model) = call_llm_summary(&user_content).await?;
|
||||
// Parse stdout som JSON — det er resultatet
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let result: serde_json::Value = serde_json::from_str(&stdout)
|
||||
.map_err(|e| format!("Kunne ikke parse synops-summarize output: {e}"))?;
|
||||
|
||||
tracing::info!(
|
||||
communication_id = %communication_id,
|
||||
summary_len = summary_text.len(),
|
||||
"Sammendrag generert"
|
||||
summary_node_id = result["summary_node_id"].as_str().unwrap_or("n/a"),
|
||||
status = result["status"].as_str().unwrap_or("unknown"),
|
||||
"synops-summarize fullført"
|
||||
);
|
||||
|
||||
// 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"
|
||||
);
|
||||
|
||||
// Logg AI-ressursforbruk
|
||||
let collection_id = resource_usage::find_collection_for_node(db, communication_id).await;
|
||||
let (tokens_in, tokens_out) = llm_usage
|
||||
.map(|u| (u.prompt_tokens, u.completion_tokens))
|
||||
.unwrap_or((0, 0));
|
||||
|
||||
if let Err(e) = resource_usage::log(
|
||||
db,
|
||||
communication_id,
|
||||
Some(requested_by),
|
||||
collection_id,
|
||||
"ai",
|
||||
serde_json::json!({
|
||||
"model_level": "smart",
|
||||
"model_id": llm_model.unwrap_or_else(|| "unknown".to_string()),
|
||||
"tokens_in": tokens_in,
|
||||
"tokens_out": tokens_out,
|
||||
"job_type": "summarize_communication"
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "Kunne ikke logge AI-ressursforbruk for oppsummering");
|
||||
}
|
||||
|
||||
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. Returnerer (tekst, usage, model).
|
||||
async fn call_llm_summary(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();
|
||||
|
||||
// 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(), chat_resp.usage, chat_resp.model))
|
||||
Ok(result)
|
||||
}
|
||||
|
|
|
|||
3
tasks.md
3
tasks.md
|
|
@ -246,8 +246,7 @@ kaller dem direkte. Samme verktøy, to brukere.
|
|||
- [x] 21.3 `synops-render`: Tera HTML-rendering. Input: `--node-id <uuid> --theme <tema>`. Output: CAS-hash for rendret HTML. Erstatter `publishing.rs`.
|
||||
- [x] 21.4 `synops-rss`: RSS/Atom-generering. Input: `--collection-id <uuid>`. Output: XML til stdout. Erstatter `rss.rs`.
|
||||
- [x] 21.5 `synops-tts`: Tekst-til-tale. Input: `--text <tekst> --voice <stemme>`. Output: CAS-hash for lydfil. Erstatter `tts.rs`.
|
||||
- [~] 21.6 `synops-summarize`: AI-oppsummering. Input: `--communication-id <uuid>`. Output: sammendrag som tekst. Erstatter `summarize.rs`.
|
||||
> Påbegynt: 2026-03-18T09:30
|
||||
- [x] 21.6 `synops-summarize`: AI-oppsummering. Input: `--communication-id <uuid>`. Output: sammendrag som tekst. Erstatter `summarize.rs`.
|
||||
- [ ] 21.7 `synops-suggest-edges`: AI-foreslåtte edges. Input: `--node-id <uuid>`. Output: JSON med forslag (target, edge_type, confidence). Erstatter `ai_edges.rs`.
|
||||
- [ ] 21.8 `synops-respond`: Claude chat-svar. Input: `--communication-id <uuid> --message-id <uuid>`. Output: svartekst. Erstatter `agent.rs` sin prosessering (auth/ratelimit forblir i maskinrommet).
|
||||
- [ ] 21.9 `synops-prune`: Opprydding av gamle noder. Input: `--dry-run` for forhåndsvisning. Erstatter `pruning.rs`.
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ eller maskinrommet-API. Ligger i PATH via symlink eller direkte kall.
|
|||
| `synops-render` | Tera HTML-rendering til CAS (artikler, forsider) | Ferdig |
|
||||
| `synops-rss` | RSS/Atom-feed generering for samlinger | Ferdig |
|
||||
| `synops-tts` | Tekst-til-tale via ElevenLabs, lagrer lyd i CAS | Ferdig |
|
||||
| `synops-summarize` | AI-oppsummering av kommunikasjonsnode via LiteLLM | Ferdig |
|
||||
|
||||
## Konvensjoner
|
||||
- Navnekonvensjon: `synops-<verb>` (f.eks. `synops-context`)
|
||||
|
|
|
|||
2912
tools/synops-summarize/Cargo.lock
generated
Normal file
2912
tools/synops-summarize/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
20
tools/synops-summarize/Cargo.toml
Normal file
20
tools/synops-summarize/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[package]
|
||||
name = "synops-summarize"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "synops-summarize"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
uuid = { version = "1", features = ["v7", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
477
tools/synops-summarize/src/main.rs
Normal file
477
tools/synops-summarize/src/main.rs
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
// synops-summarize — AI-oppsummering av kommunikasjonsnode.
|
||||
//
|
||||
// Input: --communication-id <uuid>. 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<Uuid>,
|
||||
|
||||
/// Skriv sammendrag-node og edges til database (uten: kun generering + stdout)
|
||||
#[arg(long)]
|
||||
write: bool,
|
||||
}
|
||||
|
||||
// --- Database-rader ---
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
// --- LLM request/response (OpenAI-kompatibel) ---
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ChatRequest {
|
||||
model: String,
|
||||
messages: Vec<ChatMessage>,
|
||||
temperature: f32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ChatMessage {
|
||||
role: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
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() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "synops_summarize=info".parse().unwrap()),
|
||||
)
|
||||
.with_target(false)
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
|
||||
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_url = std::env::var("DATABASE_URL")
|
||||
.map_err(|_| "DATABASE_URL er ikke satt".to_string())?;
|
||||
|
||||
let db = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(2)
|
||||
.connect(&db_url)
|
||||
.await
|
||||
.map_err(|e| format!("Kunne ikke koble til database: {e}"))?;
|
||||
|
||||
let communication_id = cli.communication_id;
|
||||
|
||||
// 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}"))?
|
||||
.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<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() {
|
||||
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::<Vec<_>>()
|
||||
.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<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 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<Uuid, String> {
|
||||
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<Uuid> = 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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue