// synops-suggest-edges — AI-foreslåtte edges for en node. // // Input: --node-id . Henter nodens innhold fra PG, sender til LiteLLM // for analyse, returnerer foreslåtte topics og mentions som JSON. // Med --write: oppretter topic-noder og mentions-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_EDGES_MODEL — Modellalias (default: sidelinja/rutine) // // Erstatter: maskinrommet/src/ai_edges.rs // Ref: docs/retninger/unix_filosofi.md, docs/infra/ai_gateway.md, // docs/concepts/kunnskapsgrafen.md use clap::Parser; use serde::{Deserialize, Serialize}; use std::process; use uuid::Uuid; /// AI-foreslåtte edges (topics og mentions) for en node via LiteLLM. #[derive(Parser)] #[command(name = "synops-suggest-edges", about = "Foreslå AI-genererte edges for en node")] struct Cli { /// Node-ID som skal analyseres #[arg(long)] node_id: Uuid, /// Bruker-ID som utløste analysen #[arg(long)] requested_by: Option, /// Skriv topic-noder og mentions-edges til database (uten: kun forslag + stdout) #[arg(long)] write: bool, } // --- Database-rader --- #[derive(sqlx::FromRow)] struct SourceNode { title: Option, content: Option, created_by: Option, } #[derive(sqlx::FromRow)] struct TopicRow { id: Uuid, title: String, } // --- LLM request/response (OpenAI-kompatibel) --- #[derive(Serialize)] struct ChatRequest { model: String, messages: Vec, temperature: f32, response_format: ResponseFormat, } #[derive(Serialize)] struct ResponseFormat { r#type: String, } #[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, } // --- LLM-analysens output --- #[derive(Deserialize, Debug)] struct AiSuggestion { #[serde(default)] topics: Vec, #[serde(default)] mentions: Vec, } #[derive(Deserialize, Debug)] struct MentionSuggestion { name: String, #[serde(default = "default_entity_type")] entity_type: String, } fn default_entity_type() -> String { "person".to_string() } const SYSTEM_PROMPT: &str = r#"Du er en innholdsanalysator for en norsk redaksjonsplattform. Analyser teksten og ekstraher: 1. **topics**: Emner/temaer teksten handler om. Bruk korte, presise norske termer (f.eks. "skolepolitikk", "klimaendringer", "statsbudsjettet"). Maks 5 topics. 2. **mentions**: Navngitte entiteter (personer, organisasjoner, steder) som er eksplisitt nevnt. Inkluder entity_type ("person", "organisasjon", "sted", "konsept"). Returner KUN et JSON-objekt med denne strukturen: { "topics": ["emne1", "emne2"], "mentions": [{"name": "Navn", "entity_type": "person"}] } Regler: - Returner tom liste hvis teksten ikke har meningsfullt innhold (hilsener, korte svar, etc.) - Bruk eksisterende topics fra listen nedenfor der det passer, i stedet for å lage nye varianter - Ikke inkluder generiske termer som "samtale" eller "diskusjon" - Navngi entiteter med full, autoritativ form (f.eks. "Jonas Gahr Støre", ikke "Støre")"#; #[tokio::main] async fn main() { synops_common::logging::init("synops_suggest_edges"); 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 node_id = cli.node_id; // 1. Hent kildenode let source = sqlx::query_as::<_, SourceNode>( "SELECT title, content, created_by FROM nodes WHERE id = $1", ) .bind(node_id) .fetch_optional(&db) .await .map_err(|e| format!("PG-feil ved henting av node: {e}"))? .ok_or_else(|| format!("Node {node_id} finnes ikke"))?; let title = source.title.unwrap_or_default(); let content = source.content.unwrap_or_default(); // Ikke analyser tomme noder eller veldig korte meldinger let text = format!("{title}\n{content}").trim().to_string(); if text.len() < 20 { tracing::info!(node_id = %node_id, len = text.len(), "For kort innhold, hopper over"); let result = serde_json::json!({ "status": "skipped", "reason": "content_too_short", "node_id": node_id.to_string() }); println!("{}", serde_json::to_string_pretty(&result).unwrap()); return Ok(()); } // 2. Hent eksisterende topic-noder for kontekst let existing_topics = sqlx::query_as::<_, TopicRow>( "SELECT id, title FROM nodes WHERE node_kind = 'topic' ORDER BY created_at DESC LIMIT 100", ) .fetch_all(&db) .await .map_err(|e| format!("PG-feil ved henting av topics: {e}"))?; let topic_list: Vec<&str> = existing_topics.iter().map(|t| t.title.as_str()).collect(); // 3. Bygg prompt og kall LiteLLM let user_content = if topic_list.is_empty() { format!("Analyser følgende tekst:\n\n{text}") } else { format!( "Eksisterende topics: {}\n\nAnalyser følgende tekst:\n\n{text}", topic_list.join(", ") ) }; tracing::info!(node_id = %node_id, "Sender til LLM for edge-analyse"); let (suggestion, llm_usage, llm_model) = call_llm(&user_content).await?; tracing::info!( node_id = %node_id, topics = ?suggestion.topics, mentions = suggestion.mentions.len(), "LLM-forslag mottatt" ); // 4. Bygg forslag-liste med confidence og target-info let mut suggestions = Vec::new(); for topic_name in &suggestion.topics { let topic_name = topic_name.trim(); if topic_name.is_empty() { continue; } let existing = existing_topics .iter() .find(|t| t.title.to_lowercase() == topic_name.to_lowercase()); suggestions.push(serde_json::json!({ "target": topic_name, "target_id": existing.map(|t| t.id.to_string()), "edge_type": "mentions", "kind": "topic", "confidence": 0.8, "exists": existing.is_some() })); } for mention in &suggestion.mentions { let name = mention.name.trim(); if name.is_empty() { continue; } // Sjekk om entiteten allerede finnes let existing_entity = sqlx::query_scalar::<_, Uuid>( "SELECT id FROM nodes WHERE node_kind = 'topic' AND LOWER(title) = LOWER($1) LIMIT 1", ) .bind(name) .fetch_optional(&db) .await .map_err(|e| format!("PG-feil ved entitet-søk: {e}"))?; suggestions.push(serde_json::json!({ "target": name, "target_id": existing_entity.map(|id| id.to_string()), "edge_type": "mentions", "kind": mention.entity_type, "confidence": 0.9, "exists": existing_entity.is_some() })); } 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()); // 5. Skriv til database hvis --write let result = if cli.write { let requested_by = cli.requested_by.unwrap(); // Allerede validert let created_by = source.created_by.unwrap_or(node_id); let (topics_created, edges_created) = write_to_db(&db, node_id, &suggestion, &existing_topics, created_by).await?; // Logg AI-ressursforbruk log_resource_usage(&db, node_id, source.created_by, &model_id, tokens_in, tokens_out, requested_by) .await; serde_json::json!({ "status": "completed", "node_id": node_id.to_string(), "suggestions": suggestions, "topics_created": topics_created, "edges_created": edges_created, "model": model_id, "tokens_in": tokens_in, "tokens_out": tokens_out, }) } else { serde_json::json!({ "status": "completed", "node_id": node_id.to_string(), "suggestions": suggestions, "model": model_id, "tokens_in": tokens_in, "tokens_out": tokens_out, }) }; // Output JSON til stdout println!( "{}", serde_json::to_string_pretty(&result) .map_err(|e| format!("JSON-serialisering feilet: {e}"))? ); Ok(()) } /// Kall LiteLLM for innholdsanalyse. Returnerer (forslag, usage, model). async fn call_llm(user_content: &str) -> Result<(AiSuggestion, 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_EDGES_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.2, response_format: ResponseFormat { r#type: "json_object".to_string(), }, }; 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(30)) .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")?; let suggestion: AiSuggestion = serde_json::from_str(content) .map_err(|e| format!("Kunne ikke parse LLM JSON: {e}. Rå output: {content}"))?; Ok((suggestion, chat_resp.usage, chat_resp.model)) } /// Opprett topic-noder og mentions-edges i PG. /// Returnerer (topics_created, edges_created). async fn write_to_db( db: &sqlx::PgPool, node_id: Uuid, suggestion: &AiSuggestion, existing_topics: &[TopicRow], created_by: Uuid, ) -> Result<(u32, u32), String> { let mut topics_created = 0u32; let mut edges_created = 0u32; // Prosesser topics for topic_name in &suggestion.topics { let topic_name = topic_name.trim(); if topic_name.is_empty() { continue; } let existing = existing_topics .iter() .find(|t| t.title.to_lowercase() == topic_name.to_lowercase()); let topic_id = if let Some(t) = existing { t.id } else { let new_id = Uuid::now_v7(); create_topic_node(db, new_id, topic_name, created_by).await?; topics_created += 1; new_id }; if create_mentions_edge(db, node_id, topic_id, created_by).await? { edges_created += 1; } } // Prosesser mentions (entiteter) for mention in &suggestion.mentions { let name = mention.name.trim(); if name.is_empty() { continue; } let existing_entity = sqlx::query_scalar::<_, Uuid>( "SELECT id FROM nodes WHERE node_kind = 'topic' AND LOWER(title) = LOWER($1) LIMIT 1", ) .bind(name) .fetch_optional(db) .await .map_err(|e| format!("PG-feil ved entitet-søk: {e}"))?; let entity_id = if let Some(id) = existing_entity { id } else { let new_id = Uuid::now_v7(); create_entity_node(db, new_id, name, &mention.entity_type, created_by).await?; topics_created += 1; new_id }; if create_mentions_edge(db, node_id, entity_id, created_by).await? { edges_created += 1; } } tracing::info!( node_id = %node_id, topics_created = topics_created, edges_created = edges_created, "AI edge-forslag skrevet til database" ); Ok((topics_created, edges_created)) } /// Opprett en topic-node i PG. async fn create_topic_node( db: &sqlx::PgPool, id: Uuid, title: &str, created_by: Uuid, ) -> Result<(), String> { let metadata = serde_json::json!({"ai_generated": true}); sqlx::query( r#" INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by) VALUES ($1, 'topic', $2, '', 'discoverable', $3, $4) ON CONFLICT (id) DO NOTHING "#, ) .bind(id) .bind(title) .bind(&metadata) .bind(created_by) .execute(db) .await .map_err(|e| format!("PG insert topic feilet: {e}"))?; tracing::info!(topic_id = %id, title = %title, "Ny topic-node opprettet (AI)"); Ok(()) } /// Opprett en entitet-node (person, org, sted) i PG. async fn create_entity_node( db: &sqlx::PgPool, id: Uuid, name: &str, entity_type: &str, created_by: Uuid, ) -> Result<(), String> { let metadata = serde_json::json!({ "ai_generated": true, "entity_type": entity_type }); sqlx::query( r#" INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by) VALUES ($1, 'topic', $2, '', 'discoverable', $3, $4) ON CONFLICT (id) DO NOTHING "#, ) .bind(id) .bind(name) .bind(&metadata) .bind(created_by) .execute(db) .await .map_err(|e| format!("PG insert entity feilet: {e}"))?; tracing::info!(entity_id = %id, name = %name, entity_type = %entity_type, "Ny entitet-node opprettet (AI)"); Ok(()) } /// Opprett en mentions-edge. Returnerer true hvis ny edge ble opprettet. async fn create_mentions_edge( db: &sqlx::PgPool, source_id: Uuid, target_id: Uuid, created_by: Uuid, ) -> Result { let exists = sqlx::query_scalar::<_, bool>( "SELECT EXISTS(SELECT 1 FROM edges WHERE source_id = $1 AND target_id = $2 AND edge_type = 'mentions')", ) .bind(source_id) .bind(target_id) .fetch_one(db) .await .map_err(|e| format!("PG-feil ved edge-sjekk: {e}"))?; if exists { return Ok(false); } let edge_id = Uuid::now_v7(); let metadata = serde_json::json!({"origin": "ai"}); sqlx::query( r#" INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by) VALUES ($1, $2, $3, 'mentions', $4, false, $5) ON CONFLICT (source_id, target_id, edge_type) DO NOTHING "#, ) .bind(edge_id) .bind(source_id) .bind(target_id) .bind(&metadata) .bind(created_by) .execute(db) .await .map_err(|e| format!("PG insert mentions-edge feilet: {e}"))?; tracing::info!( edge_id = %edge_id, source = %source_id, target = %target_id, "Mentions-edge opprettet (AI)" ); Ok(true) } /// Logg AI-ressursforbruk til resource_usage_log. async fn log_resource_usage( db: &sqlx::PgPool, node_id: Uuid, _created_by: Option, model_id: &str, tokens_in: i64, tokens_out: i64, requested_by: Uuid, ) { // Finn eventuell collection 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(node_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(node_id) .bind(Some(requested_by)) .bind(collection_id) .bind("ai") .bind(serde_json::json!({ "model_level": "fast", "model_id": model_id, "tokens_in": tokens_in, "tokens_out": tokens_out, "job_type": "suggest_edges" })) .execute(db) .await { tracing::warn!(error = %e, "Kunne ikke logge AI-ressursforbruk"); } }