Implementer synops-suggest-edges CLI-verktøy (oppgave 21.7)
Erstatter maskinrommet/src/ai_edges.rs med et selvstendig CLI-verktøy som følger unix-filosofien: maskinrommet orkestrerer, verktøy gjør jobben. Verktøyet analyserer en nodes innhold via LiteLLM og foreslår topics og mentions (entiteter) som edges. I lesemodus (default) returneres kun forslag som JSON med confidence-score. Med --write opprettes topic-noder og mentions-edges i PG. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2ab73715f7
commit
9032ab0225
5 changed files with 3552 additions and 2 deletions
3
tasks.md
3
tasks.md
|
|
@ -247,8 +247,7 @@ kaller dem direkte. Samme verktøy, to brukere.
|
||||||
- [x] 21.4 `synops-rss`: RSS/Atom-generering. Input: `--collection-id <uuid>`. Output: XML til stdout. Erstatter `rss.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`.
|
- [x] 21.5 `synops-tts`: Tekst-til-tale. Input: `--text <tekst> --voice <stemme>`. Output: CAS-hash for lydfil. Erstatter `tts.rs`.
|
||||||
- [x] 21.6 `synops-summarize`: AI-oppsummering. Input: `--communication-id <uuid>`. Output: sammendrag som tekst. Erstatter `summarize.rs`.
|
- [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`.
|
- [x] 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`.
|
||||||
> Påbegynt: 2026-03-18T09:38
|
|
||||||
- [ ] 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.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`.
|
- [ ] 21.9 `synops-prune`: Opprydding av gamle noder. Input: `--dry-run` for forhåndsvisning. Erstatter `pruning.rs`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ eller maskinrommet-API. Ligger i PATH via symlink eller direkte kall.
|
||||||
| `synops-rss` | RSS/Atom-feed generering for samlinger | Ferdig |
|
| `synops-rss` | RSS/Atom-feed generering for samlinger | Ferdig |
|
||||||
| `synops-tts` | Tekst-til-tale via ElevenLabs, lagrer lyd i CAS | Ferdig |
|
| `synops-tts` | Tekst-til-tale via ElevenLabs, lagrer lyd i CAS | Ferdig |
|
||||||
| `synops-summarize` | AI-oppsummering av kommunikasjonsnode via LiteLLM | Ferdig |
|
| `synops-summarize` | AI-oppsummering av kommunikasjonsnode via LiteLLM | Ferdig |
|
||||||
|
| `synops-suggest-edges` | AI-foreslåtte edges (topics/mentions) for en node via LiteLLM | Ferdig |
|
||||||
|
|
||||||
## Konvensjoner
|
## Konvensjoner
|
||||||
- Navnekonvensjon: `synops-<verb>` (f.eks. `synops-context`)
|
- Navnekonvensjon: `synops-<verb>` (f.eks. `synops-context`)
|
||||||
|
|
|
||||||
2912
tools/synops-suggest-edges/Cargo.lock
generated
Normal file
2912
tools/synops-suggest-edges/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
20
tools/synops-suggest-edges/Cargo.toml
Normal file
20
tools/synops-suggest-edges/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
[package]
|
||||||
|
name = "synops-suggest-edges"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "synops-suggest-edges"
|
||||||
|
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"] }
|
||||||
618
tools/synops-suggest-edges/src/main.rs
Normal file
618
tools/synops-suggest-edges/src/main.rs
Normal file
|
|
@ -0,0 +1,618 @@
|
||||||
|
// synops-suggest-edges — AI-foreslåtte edges for en node.
|
||||||
|
//
|
||||||
|
// Input: --node-id <uuid>. 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<Uuid>,
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
content: Option<String>,
|
||||||
|
created_by: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct TopicRow {
|
||||||
|
id: Uuid,
|
||||||
|
title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- LLM request/response (OpenAI-kompatibel) ---
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ChatRequest {
|
||||||
|
model: String,
|
||||||
|
messages: Vec<ChatMessage>,
|
||||||
|
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<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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- LLM-analysens output ---
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
struct AiSuggestion {
|
||||||
|
#[serde(default)]
|
||||||
|
topics: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
mentions: Vec<MentionSuggestion>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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() {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "synops_suggest_edges=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 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<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_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<bool, String> {
|
||||||
|
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<Uuid>,
|
||||||
|
model_id: &str,
|
||||||
|
tokens_in: i64,
|
||||||
|
tokens_out: i64,
|
||||||
|
requested_by: Uuid,
|
||||||
|
) {
|
||||||
|
// Finn eventuell collection
|
||||||
|
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(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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue