Ny crate `tools/synops-common` samler duplisert kode som var
spredt over 13 CLI-verktøy:
- db::connect() — PG-pool fra DATABASE_URL (erstatter 10+ identiske blokker)
- cas::path() — CAS-stioppslag med to-nivå hash-katalog
- cas::root() — CAS_ROOT env med default
- cas::hash_bytes() / hash_file() / store() — SHA-256 hashing og lagring
- cas::mime_to_extension() — MIME → filendelse
- logging::init() — tracing til stderr med env-filter
- types::{NodeRow, EdgeRow, NodeSummary} — delte FromRow-structs
Alle verktøy (unntatt synops-tasks som ikke bruker DB) er refaktorert
til å bruke synops-common. Alle kompilerer og tester passerer.
463 lines
14 KiB
Rust
463 lines
14 KiB
Rust
// 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() {
|
|
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<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)
|
|
}
|