AI-foreslåtte edges: LLM-analyse av innhold → topics og mentions (oppgave 10.2)
Ny jobbtype `suggest_edges` som automatisk trigges ved opprettelse av content-noder med tilstrekkelig tekst (≥20 tegn). Sender innholdet til LiteLLM (sidelinja/rutine) via AI Gateway, parser JSON-respons med topics og mentions, og oppretter topic-noder + mentions-edges i grafen. Flyten: 1. create_node oppdager content-node med nok tekst → enqueue suggest_edges 2. Worker henter node-innhold og eksisterende topics fra PG 3. LLM analyserer tekst og returnerer foreslåtte topics/mentions 4. Nye topic-noder opprettes (med ai_generated-flagg i metadata) 5. mentions-edges opprettes fra innholdsnode til topic/entitet-noder 6. Deduplisering: gjenbruker eksisterende topics ved case-insensitivt match Filer: - maskinrommet/src/ai_edges.rs: Ny modul med LLM-kall og edge-opprettelse - maskinrommet/src/jobs.rs: suggest_edges registrert i dispatcher - maskinrommet/src/intentions.rs: Trigger i create_node - docs/: Oppdatert jobbkø og AI gateway-docs med ny jobbtype NB: Krever gyldig API-nøkkel i LiteLLM (OpenRouter/Gemini/Anthropic). Jobben feiler gracefully med retry+backoff ved manglende nøkkel. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e08a4fd22f
commit
63f022d739
7 changed files with 528 additions and 2 deletions
|
|
@ -85,6 +85,7 @@ Jobbkøen bruker `ai_job_routing` for å bestemme modellalias per jobbtype:
|
||||||
| `ai_text_process` (✨-behandling) | `sidelinja/rutine` | Tekstvasking, høyt volum |
|
| `ai_text_process` (✨-behandling) | `sidelinja/rutine` | Tekstvasking, høyt volum |
|
||||||
| `whisper_postprocess` | `sidelinja/rutine` | Transkripsjonsvasking, høyt volum |
|
| `whisper_postprocess` | `sidelinja/rutine` | Transkripsjonsvasking, høyt volum |
|
||||||
| `research_clip` | `sidelinja/rutine` | Research-oppsummering, høyt volum |
|
| `research_clip` | `sidelinja/rutine` | Research-oppsummering, høyt volum |
|
||||||
|
| `suggest_edges` | `sidelinja/rutine` | AI-foreslåtte topics og mentions, automatisk ved ny node |
|
||||||
| `live_factoid_eval` | `sidelinja/resonering` | Krever presis vurdering under tidspress |
|
| `live_factoid_eval` | `sidelinja/resonering` | Krever presis vurdering under tidspress |
|
||||||
|
|
||||||
Modellalias lagres som felt på jobben i PG — kan overstyres manuelt per jobb ved behov.
|
Modellalias lagres som felt på jobben i PG — kan overstyres manuelt per jobb ved behov.
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@ Verdiene er veiledende — SvelteKit setter prioritet ved opprettelse basert på
|
||||||
| `dictation_cleanup` | Lydmeldinger | AI-opprydding av diktert transkripsjon til strukturert notat |
|
| `dictation_cleanup` | Lydmeldinger | AI-opprydding av diktert transkripsjon til strukturert notat |
|
||||||
| `generate_embeddings` | Kunnskaps-Bridge | Generer vector embeddings for noder (pgvector) |
|
| `generate_embeddings` | Kunnskaps-Bridge | Generer vector embeddings for noder (pgvector) |
|
||||||
| `prompt_eval` | Prompt-Laboratorium | Batch-evaluering av testsett mot valgte modeller |
|
| `prompt_eval` | Prompt-Laboratorium | Batch-evaluering av testsett mot valgte modeller |
|
||||||
|
| `suggest_edges` | Kunnskapsgraf (AI) | Analyser innhold via LLM, foreslå topics og mentions-edges. Trigges automatisk ved opprettelse av content-noder med tilstrekkelig tekst |
|
||||||
| `url_ingest` | Web Clipper (proposal) | Hent URL, oppsummer via AI, opprett research-klipp med graf-koblinger |
|
| `url_ingest` | Web Clipper (proposal) | Hent URL, oppsummer via AI, opprett research-klipp med graf-koblinger |
|
||||||
| `generate_waveform` | Waveforms (proposal) | Generer audio-peaks fra lydfil for visuell bølgeform |
|
| `generate_waveform` | Waveforms (proposal) | Generer audio-peaks fra lydfil for visuell bølgeform |
|
||||||
|
|
||||||
|
|
|
||||||
486
maskinrommet/src/ai_edges.rs
Normal file
486
maskinrommet/src/ai_edges.rs
Normal file
|
|
@ -0,0 +1,486 @@
|
||||||
|
// AI-foreslåtte edges — sender innhold til LLM, foreslår mentions og topics.
|
||||||
|
//
|
||||||
|
// Jobbtype: "suggest_edges"
|
||||||
|
// Payload: { "node_id": "<uuid>" }
|
||||||
|
//
|
||||||
|
// Flyten:
|
||||||
|
// 1. Hent nodens innhold fra PG
|
||||||
|
// 2. Hent eksisterende topic-noder for gjenbruk
|
||||||
|
// 3. Send til LiteLLM (OpenAI-kompatibelt API via AI Gateway)
|
||||||
|
// 4. Parse LLM-svar (JSON med topics og mentions)
|
||||||
|
// 5. Opprett nye topic-noder for ukjente topics
|
||||||
|
// 6. Opprett mentions-edges fra innholdsnode til topic/entity-noder
|
||||||
|
//
|
||||||
|
// Ref: docs/infra/ai_gateway.md, docs/concepts/kunnskapsgrafen.md
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::jobs::JobRow;
|
||||||
|
use crate::stdb::StdbClient;
|
||||||
|
|
||||||
|
/// Eksisterende topic-node fra PG.
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct TopicRow {
|
||||||
|
id: Uuid,
|
||||||
|
title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kilde-node fra PG.
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct SourceNode {
|
||||||
|
title: Option<String>,
|
||||||
|
content: Option<String>,
|
||||||
|
created_by: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// LLM-respons: foreslåtte topics og mentions.
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
struct AiSuggestion {
|
||||||
|
/// Emnene innholdet handler om (nye eller eksisterende).
|
||||||
|
#[serde(default)]
|
||||||
|
topics: Vec<String>,
|
||||||
|
/// Entiteter nevnt i innholdet (personer, organisasjoner, steder).
|
||||||
|
#[serde(default)]
|
||||||
|
mentions: Vec<MentionSuggestion>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
struct MentionSuggestion {
|
||||||
|
/// Navn på entiteten.
|
||||||
|
name: String,
|
||||||
|
/// Type: person, organisasjon, sted, konsept.
|
||||||
|
#[serde(default = "default_entity_type")]
|
||||||
|
entity_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_entity_type() -> String {
|
||||||
|
"person".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenAI-kompatibel chat completion request.
|
||||||
|
#[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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 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")"#;
|
||||||
|
|
||||||
|
/// Håndterer suggest_edges-jobb.
|
||||||
|
pub async fn handle_suggest_edges(
|
||||||
|
job: &JobRow,
|
||||||
|
db: &PgPool,
|
||||||
|
stdb: &StdbClient,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let node_id: Uuid = job
|
||||||
|
.payload
|
||||||
|
.get("node_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.ok_or("Mangler gyldig node_id i payload")?;
|
||||||
|
|
||||||
|
tracing::info!(node_id = %node_id, "Starter AI edge-forslag");
|
||||||
|
|
||||||
|
// 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 AI-analyse");
|
||||||
|
return Ok(serde_json::json!({
|
||||||
|
"status": "skipped",
|
||||||
|
"reason": "content_too_short"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(", ")
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let suggestion = call_llm(&user_content).await?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
node_id = %node_id,
|
||||||
|
topics = ?suggestion.topics,
|
||||||
|
mentions = suggestion.mentions.len(),
|
||||||
|
"LLM-forslag mottatt"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Opprett topic-noder og mentions-edges
|
||||||
|
let created_by = source.created_by.unwrap_or(node_id);
|
||||||
|
let mut created_topics = 0u32;
|
||||||
|
let mut created_edges = 0u32;
|
||||||
|
|
||||||
|
// Prosesser topics
|
||||||
|
for topic_name in &suggestion.topics {
|
||||||
|
let topic_name = topic_name.trim();
|
||||||
|
if topic_name.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finn eksisterende topic med case-insensitivt match
|
||||||
|
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 {
|
||||||
|
// Opprett ny topic-node
|
||||||
|
let new_id = Uuid::now_v7();
|
||||||
|
create_topic_node(db, stdb, new_id, topic_name, created_by).await?;
|
||||||
|
created_topics += 1;
|
||||||
|
new_id
|
||||||
|
};
|
||||||
|
|
||||||
|
// Opprett mentions-edge: innholdsnode → topic
|
||||||
|
if create_mentions_edge(db, stdb, node_id, topic_id, created_by).await? {
|
||||||
|
created_edges += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prosesser mentions (entiteter)
|
||||||
|
for mention in &suggestion.mentions {
|
||||||
|
let name = mention.name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Søk etter eksisterende entitet med samme tittel
|
||||||
|
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 {
|
||||||
|
// Opprett ny entitet som topic-node med entity_type i metadata
|
||||||
|
let new_id = Uuid::now_v7();
|
||||||
|
create_entity_node(db, stdb, new_id, name, &mention.entity_type, created_by).await?;
|
||||||
|
created_topics += 1;
|
||||||
|
new_id
|
||||||
|
};
|
||||||
|
|
||||||
|
// Opprett mentions-edge: innholdsnode → entitet
|
||||||
|
if create_mentions_edge(db, stdb, node_id, entity_id, created_by).await? {
|
||||||
|
created_edges += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = serde_json::json!({
|
||||||
|
"status": "completed",
|
||||||
|
"topics_created": created_topics,
|
||||||
|
"edges_created": created_edges,
|
||||||
|
"suggestions": {
|
||||||
|
"topics": suggestion.topics,
|
||||||
|
"mentions": suggestion.mentions.iter().map(|m| &m.name).collect::<Vec<_>>()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
node_id = %node_id,
|
||||||
|
topics_created = created_topics,
|
||||||
|
edges_created = created_edges,
|
||||||
|
"AI edge-forslag fullført"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kall LiteLLM (OpenAI-kompatibelt API) for å analysere innhold.
|
||||||
|
async fn call_llm(user_content: &str) -> Result<AiSuggestion, 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();
|
||||||
|
|
||||||
|
// Bruk sidelinja/rutine (billig modell) for edge-forslag
|
||||||
|
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")?;
|
||||||
|
|
||||||
|
// Parse JSON fra LLM-output
|
||||||
|
let suggestion: AiSuggestion = serde_json::from_str(content)
|
||||||
|
.map_err(|e| format!("Kunne ikke parse LLM JSON: {e}. Rå output: {content}"))?;
|
||||||
|
|
||||||
|
Ok(suggestion)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opprett en topic-node i PG og STDB.
|
||||||
|
async fn create_topic_node(
|
||||||
|
db: &PgPool,
|
||||||
|
stdb: &StdbClient,
|
||||||
|
id: Uuid,
|
||||||
|
title: &str,
|
||||||
|
created_by: Uuid,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let metadata = serde_json::json!({"ai_generated": true});
|
||||||
|
|
||||||
|
// STDB først
|
||||||
|
stdb.create_node(
|
||||||
|
&id.to_string(),
|
||||||
|
"topic",
|
||||||
|
title,
|
||||||
|
"",
|
||||||
|
"discoverable",
|
||||||
|
&metadata.to_string(),
|
||||||
|
&created_by.to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("STDB create_node (topic) feilet: {e}"))?;
|
||||||
|
|
||||||
|
// PG
|
||||||
|
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 og STDB.
|
||||||
|
async fn create_entity_node(
|
||||||
|
db: &PgPool,
|
||||||
|
stdb: &StdbClient,
|
||||||
|
id: Uuid,
|
||||||
|
name: &str,
|
||||||
|
entity_type: &str,
|
||||||
|
created_by: Uuid,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let metadata = serde_json::json!({
|
||||||
|
"ai_generated": true,
|
||||||
|
"entity_type": entity_type
|
||||||
|
});
|
||||||
|
|
||||||
|
stdb.create_node(
|
||||||
|
&id.to_string(),
|
||||||
|
"topic",
|
||||||
|
name,
|
||||||
|
"",
|
||||||
|
"discoverable",
|
||||||
|
&metadata.to_string(),
|
||||||
|
&created_by.to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("STDB create_node (entity) feilet: {e}"))?;
|
||||||
|
|
||||||
|
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 fra innholdsnode til target.
|
||||||
|
/// Returnerer true hvis ny edge ble opprettet, false hvis den allerede eksisterte.
|
||||||
|
async fn create_mentions_edge(
|
||||||
|
db: &PgPool,
|
||||||
|
stdb: &StdbClient,
|
||||||
|
source_id: Uuid,
|
||||||
|
target_id: Uuid,
|
||||||
|
created_by: Uuid,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
// Sjekk om edge allerede finnes
|
||||||
|
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"});
|
||||||
|
|
||||||
|
// STDB først
|
||||||
|
stdb.create_edge(
|
||||||
|
&edge_id.to_string(),
|
||||||
|
&source_id.to_string(),
|
||||||
|
&target_id.to_string(),
|
||||||
|
"mentions",
|
||||||
|
&metadata.to_string(),
|
||||||
|
false,
|
||||||
|
&created_by.to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("STDB create_edge (mentions) feilet: {e}"))?;
|
||||||
|
|
||||||
|
// PG
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -329,6 +329,10 @@ pub async fn create_node(
|
||||||
"Node opprettet i STDB"
|
"Node opprettet i STDB"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Fang verdier for AI-trigger før de flyttes inn i spawn_pg_insert_node
|
||||||
|
let is_content_node = node_kind == "content";
|
||||||
|
let has_enough_text = content.len() >= 20 || title.len() >= 20;
|
||||||
|
|
||||||
// -- Spawn async PG-skriving --
|
// -- Spawn async PG-skriving --
|
||||||
spawn_pg_insert_node(
|
spawn_pg_insert_node(
|
||||||
state.db.clone(),
|
state.db.clone(),
|
||||||
|
|
@ -433,6 +437,36 @@ pub async fn create_node(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- AI edge-forslag: analyser innholdet for topics og mentions --
|
||||||
|
// Trigges for content-noder med nok tekst. Lav prioritet (bakgrunnsjobb).
|
||||||
|
// NB: node_kind, title og content er flyttet inn i spawn_pg_insert_node over,
|
||||||
|
// så vi sjekker på kopi av verdiene tatt før move.
|
||||||
|
if is_content_node && has_enough_text {
|
||||||
|
let db_clone = state.db.clone();
|
||||||
|
let created_node_id = node_id;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"node_id": created_node_id.to_string()
|
||||||
|
});
|
||||||
|
match crate::jobs::enqueue(&db_clone, "suggest_edges", payload, None, 2).await {
|
||||||
|
Ok(job_id) => {
|
||||||
|
tracing::info!(
|
||||||
|
job_id = %job_id,
|
||||||
|
node_id = %created_node_id,
|
||||||
|
"suggest_edges-jobb lagt i kø"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
node_id = %created_node_id,
|
||||||
|
error = %e,
|
||||||
|
"Kunne ikke legge suggest_edges-jobb i kø"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Json(CreateNodeResponse { node_id, belongs_to_edge_id }))
|
Ok(Json(CreateNodeResponse { node_id, belongs_to_edge_id }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::agent;
|
use crate::agent;
|
||||||
|
use crate::ai_edges;
|
||||||
use crate::cas::CasStore;
|
use crate::cas::CasStore;
|
||||||
use crate::stdb::StdbClient;
|
use crate::stdb::StdbClient;
|
||||||
use crate::transcribe;
|
use crate::transcribe;
|
||||||
|
|
@ -155,6 +156,9 @@ async fn dispatch(
|
||||||
"agent_respond" => {
|
"agent_respond" => {
|
||||||
agent::handle_agent_respond(job, db, stdb).await
|
agent::handle_agent_respond(job, db, stdb).await
|
||||||
}
|
}
|
||||||
|
"suggest_edges" => {
|
||||||
|
ai_edges::handle_suggest_edges(job, db, stdb).await
|
||||||
|
}
|
||||||
other => Err(format!("Ukjent jobbtype: {other}")),
|
other => Err(format!("Ukjent jobbtype: {other}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
pub mod agent;
|
pub mod agent;
|
||||||
|
pub mod ai_edges;
|
||||||
mod auth;
|
mod auth;
|
||||||
pub mod cas;
|
pub mod cas;
|
||||||
mod intentions;
|
mod intentions;
|
||||||
|
|
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -117,8 +117,7 @@ Uavhengige faser kan fortsatt plukkes.
|
||||||
## Fase 10: AI og beriking
|
## Fase 10: AI og beriking
|
||||||
|
|
||||||
- [x] 10.1 LiteLLM oppsett: Docker-container, API-nøkler, modell-routing. Ref: `docs/infra/ai_gateway.md`.
|
- [x] 10.1 LiteLLM oppsett: Docker-container, API-nøkler, modell-routing. Ref: `docs/infra/ai_gateway.md`.
|
||||||
- [~] 10.2 AI-foreslåtte edges: maskinrommet sender innhold til LLM → foreslår mentions, topics.
|
- [x] 10.2 AI-foreslåtte edges: maskinrommet sender innhold til LLM → foreslår mentions, topics.
|
||||||
> Påbegynt: 2026-03-17T23:14
|
|
||||||
- [ ] 10.3 Oppsummering: kommunikasjonsnode → AI-generert sammendrag som ny node.
|
- [ ] 10.3 Oppsummering: kommunikasjonsnode → AI-generert sammendrag som ny node.
|
||||||
- [ ] 10.4 TTS: tekst → lyd via ElevenLabs. Mottaker-preferanse i metadata.
|
- [ ] 10.4 TTS: tekst → lyd via ElevenLabs. Mottaker-preferanse i metadata.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue