AI-oppsummering av kommunikasjonsnoder (oppgave 10.3)
Ny jobbtype `summarize_communication` som henter alle meldinger fra
en kommunikasjonsnode, sender dem til LiteLLM for oppsummering, og
oppretter en content-node med sammendraget. Sammendraget knyttes til
kommunikasjonsnoden med `belongs_to`-edge (del av samtalen) og
`summary`-edge (lett å finne sammendrag for en gitt samtale).
API-endepunkt: POST /intentions/summarize { communication_id }
Verifiserer at brukeren er deltaker i samtalen. Jobbprioritiet 3
(bakgrunn). Modell konfigurerbar via AI_SUMMARY_MODEL env-variabel.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b0c037ef71
commit
3c1d85026b
6 changed files with 479 additions and 2 deletions
|
|
@ -117,6 +117,7 @@ Verdiene er veiledende — SvelteKit setter prioritet ved opprettelse basert på
|
||||||
| `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 |
|
| `suggest_edges` | Kunnskapsgraf (AI) | Analyser innhold via LLM, foreslå topics og mentions-edges. Trigges automatisk ved opprettelse av content-noder med tilstrekkelig tekst |
|
||||||
|
| `summarize_communication` | Oppsummering (AI) | Generer AI-sammendrag av kommunikasjonsnode (chat/møte). Oppretter content-node med summary-edge tilbake. Trigges via `/intentions/summarize` |
|
||||||
| `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 |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2141,3 +2141,89 @@ pub async fn resolve_retranscription(
|
||||||
kept_new,
|
kept_new,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// POST /intentions/summarize — generer AI-sammendrag av kommunikasjonsnode
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SummarizeRequest {
|
||||||
|
/// Kommunikasjonsnode-ID som skal oppsummeres.
|
||||||
|
pub communication_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct SummarizeResponse {
|
||||||
|
pub job_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /intentions/summarize
|
||||||
|
///
|
||||||
|
/// Legger en `summarize_communication`-jobb i køen.
|
||||||
|
/// Sammendraget opprettes asynkront som en ny content-node
|
||||||
|
/// med summary-edge tilbake til kommunikasjonsnoden.
|
||||||
|
pub async fn summarize(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
|
Json(req): Json<SummarizeRequest>,
|
||||||
|
) -> Result<Json<SummarizeResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
// Verifiser at kommunikasjonsnoden finnes og brukeren har tilgang
|
||||||
|
let exists: bool = sqlx::query_scalar::<_, bool>(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1 AND node_kind = 'communication')",
|
||||||
|
)
|
||||||
|
.bind(req.communication_id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!(error = %e, "PG-feil ved kommunikasjonssjekk");
|
||||||
|
internal_error("Databasefeil")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return Err(bad_request("Kommunikasjonsnode finnes ikke"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sjekk at brukeren er deltaker (owner eller member_of)
|
||||||
|
let is_participant: bool = sqlx::query_scalar::<_, bool>(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM edges WHERE source_id = $1 AND target_id = $2 AND edge_type IN ('owner', 'member_of'))",
|
||||||
|
)
|
||||||
|
.bind(user.node_id)
|
||||||
|
.bind(req.communication_id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!(error = %e, "PG-feil ved deltagersjekk");
|
||||||
|
internal_error("Databasefeil")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !is_participant {
|
||||||
|
return Err(forbidden("Ikke deltaker i samtalen"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"communication_id": req.communication_id.to_string(),
|
||||||
|
"requested_by": user.node_id.to_string()
|
||||||
|
});
|
||||||
|
|
||||||
|
let job_id = crate::jobs::enqueue(
|
||||||
|
&state.db,
|
||||||
|
"summarize_communication",
|
||||||
|
payload,
|
||||||
|
None,
|
||||||
|
3, // Lav prioritet — ikke tidskritisk
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!(error = %e, "Kunne ikke legge oppsummerings-jobb i kø");
|
||||||
|
internal_error("Kunne ikke starte oppsummering")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
job_id = %job_id,
|
||||||
|
communication_id = %req.communication_id,
|
||||||
|
user = %user.node_id,
|
||||||
|
"Oppsummerings-jobb lagt i kø"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Json(SummarizeResponse { job_id }))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ use crate::agent;
|
||||||
use crate::ai_edges;
|
use crate::ai_edges;
|
||||||
use crate::cas::CasStore;
|
use crate::cas::CasStore;
|
||||||
use crate::stdb::StdbClient;
|
use crate::stdb::StdbClient;
|
||||||
|
use crate::summarize;
|
||||||
use crate::transcribe;
|
use crate::transcribe;
|
||||||
|
|
||||||
/// Rad fra job_queue-tabellen.
|
/// Rad fra job_queue-tabellen.
|
||||||
|
|
@ -159,6 +160,9 @@ async fn dispatch(
|
||||||
"suggest_edges" => {
|
"suggest_edges" => {
|
||||||
ai_edges::handle_suggest_edges(job, db, stdb).await
|
ai_edges::handle_suggest_edges(job, db, stdb).await
|
||||||
}
|
}
|
||||||
|
"summarize_communication" => {
|
||||||
|
summarize::handle_summarize_communication(job, db, stdb).await
|
||||||
|
}
|
||||||
other => Err(format!("Ukjent jobbtype: {other}")),
|
other => Err(format!("Ukjent jobbtype: {other}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ pub mod jobs;
|
||||||
mod queries;
|
mod queries;
|
||||||
mod serving;
|
mod serving;
|
||||||
mod stdb;
|
mod stdb;
|
||||||
|
pub mod summarize;
|
||||||
pub mod transcribe;
|
pub mod transcribe;
|
||||||
mod warmup;
|
mod warmup;
|
||||||
|
|
||||||
|
|
@ -149,6 +150,7 @@ async fn main() {
|
||||||
.route("/intentions/update_segment", post(intentions::update_segment))
|
.route("/intentions/update_segment", post(intentions::update_segment))
|
||||||
.route("/intentions/retranscribe", post(intentions::retranscribe))
|
.route("/intentions/retranscribe", post(intentions::retranscribe))
|
||||||
.route("/intentions/resolve_retranscription", post(intentions::resolve_retranscription))
|
.route("/intentions/resolve_retranscription", post(intentions::resolve_retranscription))
|
||||||
|
.route("/intentions/summarize", post(intentions::summarize))
|
||||||
.route("/query/aliases", get(queries::query_aliases))
|
.route("/query/aliases", get(queries::query_aliases))
|
||||||
.route("/query/graph", get(queries::query_graph))
|
.route("/query/graph", get(queries::query_graph))
|
||||||
.route("/query/transcription_versions", get(queries::query_transcription_versions))
|
.route("/query/transcription_versions", get(queries::query_transcription_versions))
|
||||||
|
|
|
||||||
385
maskinrommet/src/summarize.rs
Normal file
385
maskinrommet/src/summarize.rs
Normal file
|
|
@ -0,0 +1,385 @@
|
||||||
|
// Oppsummering — kommunikasjonsnode → AI-generert sammendrag som ny node.
|
||||||
|
//
|
||||||
|
// 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 uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::jobs::JobRow;
|
||||||
|
use crate::stdb::StdbClient;
|
||||||
|
|
||||||
|
#[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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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.
|
||||||
|
pub async fn handle_summarize_communication(
|
||||||
|
job: &JobRow,
|
||||||
|
db: &PgPool,
|
||||||
|
stdb: &StdbClient,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let communication_id: Uuid = job
|
||||||
|
.payload
|
||||||
|
.get("communication_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.ok_or("Mangler gyldig communication_id i payload")?;
|
||||||
|
|
||||||
|
let requested_by: Uuid = job
|
||||||
|
.payload
|
||||||
|
.get("requested_by")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.ok_or("Mangler gyldig requested_by i payload")?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
communication_id = %communication_id,
|
||||||
|
requested_by = %requested_by,
|
||||||
|
"Starter oppsummering av kommunikasjonsnode"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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());
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() {
|
||||||
|
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 = call_llm_summary(&user_content).await?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
communication_id = %communication_id,
|
||||||
|
summary_len = summary_text.len(),
|
||||||
|
"Sammendrag generert"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
);
|
||||||
|
|
||||||
|
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.
|
||||||
|
async fn call_llm_summary(user_content: &str) -> Result<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())
|
||||||
|
}
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -118,8 +118,7 @@ Uavhengige faser kan fortsatt plukkes.
|
||||||
|
|
||||||
- [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`.
|
||||||
- [x] 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.
|
||||||
- [~] 10.3 Oppsummering: kommunikasjonsnode → AI-generert sammendrag som ny node.
|
- [x] 10.3 Oppsummering: kommunikasjonsnode → AI-generert sammendrag som ny node.
|
||||||
> Påbegynt: 2026-03-17T23:25
|
|
||||||
- [ ] 10.4 TTS: tekst → lyd via ElevenLabs. Mottaker-preferanse i metadata.
|
- [ ] 10.4 TTS: tekst → lyd via ElevenLabs. Mottaker-preferanse i metadata.
|
||||||
|
|
||||||
## Fase 11: Produksjons-pipeline
|
## Fase 11: Produksjons-pipeline
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue