diff --git a/web/src/routes/admin/entities/+page.server.ts b/web/src/routes/admin/entities/+page.server.ts new file mode 100644 index 0000000..ebe1512 --- /dev/null +++ b/web/src/routes/admin/entities/+page.server.ts @@ -0,0 +1,19 @@ +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { sql } from '$lib/server/db'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!locals.workspace) error(401); + + // Hent alle entiteter med antall edges (for å identifisere duplikater) + const entities = await sql` + SELECT e.id, e.name, e.type, e.aliases, e.avatar_url, + (SELECT COUNT(*) FROM graph_edges WHERE source_id = e.id OR target_id = e.id) AS edge_count + FROM entities e + JOIN nodes n ON n.id = e.id + WHERE n.workspace_id = ${locals.workspace.id} + ORDER BY e.name + `; + + return { entities }; +}; diff --git a/web/src/routes/admin/entities/+page.svelte b/web/src/routes/admin/entities/+page.svelte new file mode 100644 index 0000000..90a6a28 --- /dev/null +++ b/web/src/routes/admin/entities/+page.svelte @@ -0,0 +1,347 @@ + + +
+

Entiteter

+ +
+ + + {#if selected.size >= 2} +
+ {selected.size} valgt + {#if targetId} + + {:else} + Klikk "Behold" på entiteten som skal beholdes + {/if} + +
+ {/if} +
+ + {#if message} +
{message}
+ {/if} + + + + + + + + + + + + + + {#each filtered as entity (entity.id)} + + + + + + + + + {/each} + +
NavnTypeAliaserEdges
+ toggleSelect(entity.id)} + /> + + + {entity.name} + {entity.type}{entity.aliases?.join(', ') || '—'}{entity.edge_count} + {#if selected.has(entity.id) && selected.size >= 2} + + {/if} +
+
+ + diff --git a/web/src/routes/api/ai/process/+server.ts b/web/src/routes/api/ai/process/+server.ts new file mode 100644 index 0000000..c53f6a1 --- /dev/null +++ b/web/src/routes/api/ai/process/+server.ts @@ -0,0 +1,50 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { sql } from '$lib/server/db'; + +/** + * POST /api/ai/process — Opprett AI-behandlingsjobb for en melding. + * + * Body: { message_id, action?, prompt_override?, model? } + * + * Oppretter en jobb i job_queue som Rust-workeren plukker opp. + * Returnerer jobb-ID umiddelbart (asynkront). + */ +export const POST: RequestHandler = async ({ request, locals }) => { + if (!locals.workspace || !locals.user) error(401); + const workspace = locals.workspace; + + const body = await request.json(); + const { message_id, action, prompt_override, model } = body; + + if (!message_id || typeof message_id !== 'string') { + error(400, 'message_id er påkrevd'); + } + + // Verifiser at meldingen finnes og tilhører workspace + const [msg] = await sql` + SELECT m.id FROM messages m + JOIN nodes n ON n.id = m.id + WHERE m.id = ${message_id} AND n.workspace_id = ${workspace.id} + `; + if (!msg) error(404, 'Melding ikke funnet'); + + // Opprett jobb i køen + const [job] = await sql` + INSERT INTO job_queue (workspace_id, job_type, payload, priority) + VALUES ( + ${workspace.id}, + 'ai_text_process', + ${JSON.stringify({ + message_id, + action: action ?? 'fix_text', + prompt_override: prompt_override ?? null, + model: model ?? null + })}::jsonb, + 10 + ) + RETURNING id, status, created_at + `; + + return json({ job_id: job.id, status: job.status }, { status: 202 }); +}; diff --git a/web/src/routes/api/entities/merge/+server.ts b/web/src/routes/api/entities/merge/+server.ts new file mode 100644 index 0000000..8267af8 --- /dev/null +++ b/web/src/routes/api/entities/merge/+server.ts @@ -0,0 +1,133 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { sql } from '$lib/server/db'; + +/** + * POST /api/entities/merge — Slå sammen duplikate entiteter. + * + * Body: { target_id: "uuid", source_ids: ["uuid", ...] } + * + * target_id = autoritativ entitet som beholdes + * source_ids = duplikater som absorberes og slettes + * + * For hver source: + * 1. Flytt alle graph_edges (source_id og target_id) til target + * 2. Oppdater mentions i messages.body (data-id attributter) + * 3. Legg til source.name som alias på target + * 4. Slett source-noden (cascader til entities, edges) + */ +export const POST: RequestHandler = async ({ request, locals }) => { + if (!locals.workspace || !locals.user) error(401); + const workspace = locals.workspace; + + const body = await request.json(); + const { target_id, source_ids } = body; + + if (!target_id || !Array.isArray(source_ids) || source_ids.length === 0) { + error(400, 'target_id og source_ids (array) er påkrevd'); + } + + if (source_ids.includes(target_id)) { + error(400, 'target_id kan ikke være i source_ids'); + } + + // Verifiser at alle entiteter finnes og tilhører workspace + const allIds = [target_id, ...source_ids]; + const entities = await sql` + SELECT e.id, e.name, e.aliases + FROM entities e + JOIN nodes n ON n.id = e.id + WHERE e.id = ANY(${allIds}) AND n.workspace_id = ${workspace.id} + `; + + if (entities.length !== allIds.length) { + error(404, 'En eller flere entiteter ikke funnet i workspace'); + } + + const targetEntity = entities.find((e) => (e as { id: string }).id === target_id) as { + id: string; + name: string; + aliases: string[]; + }; + + const merged: string[] = []; + + for (const sourceId of source_ids) { + const sourceEntity = entities.find((e) => (e as { id: string }).id === sourceId) as { + id: string; + name: string; + aliases: string[]; + }; + + // 1. Flytt graph_edges der source er source_id → target + // Bruk ON CONFLICT for å unngå duplikate edges + await sql` + UPDATE graph_edges + SET source_id = ${target_id} + WHERE source_id = ${sourceId} + AND NOT EXISTS ( + SELECT 1 FROM graph_edges existing + WHERE existing.source_id = ${target_id} + AND existing.target_id = graph_edges.target_id + AND existing.relation_type = graph_edges.relation_type + ) + `; + // Slett eventuelle gjenværende (duplikater som ikke ble flyttet) + await sql` + DELETE FROM graph_edges WHERE source_id = ${sourceId} + `; + + // 2. Flytt graph_edges der source er target_id → target + await sql` + UPDATE graph_edges + SET target_id = ${target_id} + WHERE target_id = ${sourceId} + AND source_id != ${target_id} + AND NOT EXISTS ( + SELECT 1 FROM graph_edges existing + WHERE existing.source_id = graph_edges.source_id + AND existing.target_id = ${target_id} + AND existing.relation_type = graph_edges.relation_type + ) + `; + await sql` + DELETE FROM graph_edges WHERE target_id = ${sourceId} + `; + + // 3. Oppdater mentions i messages.body (data-id="source" → data-id="target") + await sql` + UPDATE messages + SET body = REPLACE(body, ${`data-id="${sourceId}"`}, ${`data-id="${target_id}"`}) + WHERE body LIKE ${`%${sourceId}%`} + `; + + // 4. Legg til source.name + aliases som alias på target + const newAliases = [sourceEntity.name, ...(sourceEntity.aliases ?? [])].filter( + (a) => a !== targetEntity.name && !targetEntity.aliases?.includes(a) + ); + if (newAliases.length > 0) { + await sql` + UPDATE entities + SET aliases = aliases || ${newAliases}::text[] + WHERE id = ${target_id} + `; + targetEntity.aliases = [...(targetEntity.aliases ?? []), ...newAliases]; + } + + // 5. Slett source-noden (cascader til entities via FK) + await sql`DELETE FROM nodes WHERE id = ${sourceId}`; + + merged.push(sourceEntity.name); + } + + // Hent oppdatert target + const [result] = await sql` + SELECT e.id, e.name, e.type, e.aliases, e.avatar_url + FROM entities e WHERE e.id = ${target_id} + `; + + return json({ + merged: merged, + target: result + }); +}; diff --git a/web/src/routes/entities/[id]/+page.server.ts b/web/src/routes/entities/[id]/+page.server.ts new file mode 100644 index 0000000..74edc2d --- /dev/null +++ b/web/src/routes/entities/[id]/+page.server.ts @@ -0,0 +1,71 @@ +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { sql } from '$lib/server/db'; + +export const load: PageServerLoad = async ({ params, locals }) => { + if (!locals.workspace || !locals.user) error(401); + const workspace = locals.workspace; + const entityId = params.id; + + // 1. Hent entiteten + const [entity] = await sql` + SELECT e.id, e.name, e.type, e.aliases, e.avatar_url, + n.created_at, n.updated_at + FROM entities e + JOIN nodes n ON n.id = e.id + WHERE e.id = ${entityId} AND n.workspace_id = ${workspace.id} + `; + if (!entity) error(404, 'Entitet ikke funnet'); + + // 2. Hent relasjoner (edges) med info om tilkoblede noder + const edges = await sql` + SELECT + ge.id AS edge_id, + ge.source_id, + ge.target_id, + ge.relation_type, + ge.confidence, + ge.origin, + ge.created_at, + CASE + WHEN ge.source_id = ${entityId} THEN target_e.name + ELSE source_e.name + END AS connected_name, + CASE + WHEN ge.source_id = ${entityId} THEN target_e.type + ELSE source_e.type + END AS connected_type, + CASE + WHEN ge.source_id = ${entityId} THEN ge.target_id + ELSE ge.source_id + END AS connected_id, + CASE + WHEN ge.source_id = ${entityId} THEN 'outgoing' + ELSE 'incoming' + END AS direction + FROM graph_edges ge + LEFT JOIN entities source_e ON source_e.id = ge.source_id + LEFT JOIN entities target_e ON target_e.id = ge.target_id + WHERE ge.workspace_id = ${workspace.id} + AND (ge.source_id = ${entityId} OR ge.target_id = ${entityId}) + ORDER BY ge.created_at DESC + `; + + // 3. Hent meldinger som nevner denne entiteten (via MENTIONS-edges) + const mentions = await sql` + SELECT m.id, m.body, m.title, m.message_type, m.created_at, + u.display_name AS author_name, + c.id AS channel_id + FROM graph_edges ge + JOIN messages m ON m.id = ge.source_id + LEFT JOIN users u ON u.authentik_id = m.author_id + LEFT JOIN channels c ON c.id = m.channel_id + WHERE ge.target_id = ${entityId} + AND ge.relation_type = 'MENTIONS' + AND ge.workspace_id = ${workspace.id} + ORDER BY m.created_at DESC + LIMIT 50 + `; + + return { entity, edges, mentions }; +}; diff --git a/web/src/routes/entities/[id]/+page.svelte b/web/src/routes/entities/[id]/+page.svelte new file mode 100644 index 0000000..cf1189a --- /dev/null +++ b/web/src/routes/entities/[id]/+page.svelte @@ -0,0 +1,560 @@ + + +
+ ← Alle entiteter + +
+
+ {#if entity.avatar_url} + {entity.name} + {:else} +
+ {entity.name[0]} +
+ {/if} +
+ {#if editing} +
+ +
+ + +
+
+ + +
+
+ {:else} +
+

{entity.name}

+
+ + {#if confirmDelete} + + + {:else} + + {/if} +
+
+
+ + {entity.type} + + {#if entity.aliases?.length > 0} + aka {entity.aliases.join(', ')} + {/if} + Opprettet {formatDate(entity.created_at)} +
+ {/if} +
+
+
+ +
+ + {#if entityEdges.length > 0} +
+

Relasjoner ({entityEdges.length})

+ +
+ {/if} + + +
+

Nevnt i {mentions.length} {mentions.length === 1 ? 'melding' : 'meldinger'}

+ {#if mentions.length === 0} +

Ingen meldinger nevner denne entiteten ennå.

+ {:else} + + {/if} +
+
+
+ + diff --git a/worker/src/handlers/ai_text_process.rs b/worker/src/handlers/ai_text_process.rs new file mode 100644 index 0000000..cef6281 --- /dev/null +++ b/worker/src/handlers/ai_text_process.rs @@ -0,0 +1,257 @@ +use super::JobHandler; +use anyhow::{anyhow, Context}; +use serde_json::{json, Value}; +use sqlx::{PgPool, Row}; +use tracing::{info, warn}; +use uuid::Uuid; + +/// Handler for AI-behandling av tekst i editoren. +/// +/// Payload: +/// { +/// "message_id": "uuid", +/// "action": "fix_text" | "extract_facts" | "rewrite" | "translate" | "custom", +/// "prompt_override": "optional custom prompt", +/// "model": "sidelinja/rutine" (optional, default basert på action) +/// } +/// +/// Flyten: +/// 1. Hent meldingens nåværende body fra PG +/// 2. Lagre originalen som revisjon i message_revisions +/// 3. Send til AI Gateway med riktig prompt +/// 4. Oppdater messages.body med AI-resultatet +/// 5. Sett metadata.ai_processed = true +pub struct AiTextProcessHandler { + http: reqwest::Client, + ai_gateway_url: String, +} + +impl AiTextProcessHandler { + pub fn new(http: reqwest::Client, ai_gateway_url: String) -> Self { + Self { + http, + ai_gateway_url, + } + } +} + +#[async_trait::async_trait] +impl JobHandler for AiTextProcessHandler { + async fn handle( + &self, + pool: &PgPool, + workspace_id: &Uuid, + payload: &Value, + ) -> anyhow::Result> { + let message_id: Uuid = payload + .get("message_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("message_id mangler i payload"))? + .parse() + .context("Ugyldig message_id UUID")?; + + let action = payload + .get("action") + .and_then(|v| v.as_str()) + .unwrap_or("fix_text"); + + let prompt_override = payload.get("prompt_override").and_then(|v| v.as_str()); + + let model = payload + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or("sidelinja/rutine"); + + info!( + message_id = %message_id, + action = action, + model = model, + workspace_id = %workspace_id, + "AI-behandling starter" + ); + + // 1. Hent meldingens body + let row = sqlx::query( + r#" + SELECT m.body FROM messages m + JOIN nodes n ON n.id = m.id + WHERE m.id = $1 AND n.workspace_id = $2 + "#, + ) + .bind(message_id) + .bind(workspace_id) + .fetch_optional(pool) + .await + .context("Feil ved henting av melding")? + .ok_or_else(|| anyhow!("Melding {} ikke funnet i workspace", message_id))?; + + let original_body: String = row.get("body"); + + // Strip HTML-tags for å sende ren tekst til LLM + let plain_text = strip_html(&original_body); + + if plain_text.trim().is_empty() { + return Ok(Some(json!({ "skipped": true, "reason": "tom melding" }))); + } + + // 2. Lagre original som revisjon + sqlx::query( + r#" + INSERT INTO message_revisions (id, message_id, body) + VALUES (gen_random_uuid(), $1, $2) + "#, + ) + .bind(message_id) + .bind(&original_body) + .execute(pool) + .await + .context("Feil ved lagring av revisjon")?; + + // 3. Bygg system-prompt basert på action + let system_prompt = match prompt_override { + Some(custom) => custom.to_string(), + None => get_system_prompt(action), + }; + + // 4. Send til AI Gateway + let ai_response = self + .call_ai_gateway(&system_prompt, &plain_text, model) + .await + .context("AI Gateway-kall feilet")?; + + // 5. Oppdater meldingens body med AI-resultat + let metadata = json!({ + "ai_processed": true, + "ai_action": action + }); + + sqlx::query( + r#" + UPDATE messages + SET body = $1, + edited_at = now(), + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb + WHERE id = $3 + "#, + ) + .bind(&ai_response) + .bind(metadata) + .bind(message_id) + .execute(pool) + .await + .context("Feil ved oppdatering av melding")?; + + info!( + message_id = %message_id, + action = action, + original_len = original_body.len(), + result_len = ai_response.len(), + "AI-behandling fullført" + ); + + Ok(Some(json!({ + "message_id": message_id.to_string(), + "action": action, + "original_length": original_body.len(), + "result_length": ai_response.len() + }))) + } +} + +impl AiTextProcessHandler { + async fn call_ai_gateway( + &self, + system_prompt: &str, + user_text: &str, + model: &str, + ) -> anyhow::Result { + let request_body = json!({ + "model": model, + "messages": [ + { "role": "system", "content": system_prompt }, + { "role": "user", "content": user_text } + ], + "temperature": 0.3, + "max_tokens": 4096 + }); + + let response = self + .http + .post(format!("{}/chat/completions", self.ai_gateway_url)) + .json(&request_body) + .send() + .await + .context("HTTP-kall til AI Gateway feilet")?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + warn!(status = %status, body = %body, "AI Gateway returnerte feil"); + return Err(anyhow!("AI Gateway feil: {} — {}", status, body)); + } + + let json: Value = response + .json() + .await + .context("Kunne ikke parse AI Gateway-respons")?; + + json["choices"][0]["message"]["content"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| anyhow!("Ingen content i AI Gateway-respons")) + } +} + +fn get_system_prompt(action: &str) -> String { + match action { + "fix_text" => r#"Fiks denne teksten. Output på norsk. +- Fiks skrivefeil og grammatikk +- Start med en kort oppsummering av det viktigste (2–3 setninger) +- Fjern metainformasjon, navigasjon, annonser og annen støy fra innlimt webinnhold +- Dersom det er tydelig hva kilden er, oppgi den etter innledende oppsummering +- Behold saklig innhold og fakta intakt +- Bruk markdown-formatering der det gir bedre lesbarhet"# + .to_string(), + + "extract_facts" => r#"Analyser denne teksten og trekk ut fakta. Output på norsk. +- Identifiser konkrete påstander, tall, sitater og fakta +- List dem opp som punktliste +- For hver fakta: noter hvilken person eller organisasjon den gjelder (bruk #Navn-format) +- Ignorer meninger og spekulasjoner — kun verifiserbare påstander +- Behold kildehenvisninger der de finnes"# + .to_string(), + + "rewrite" => r#"Skriv om denne teksten til artikkelformat. Output på norsk. +- Lag en tittel som fanger essensen +- Skriv en ingress på 2–3 setninger +- Strukturer resten med mellomtitler der det er naturlig +- Hold deg til fakta fra originalteksten — ikke legg til informasjon +- Bruk markdown-formatering"# + .to_string(), + + "translate" => r#"Oversett denne teksten til norsk. +- Behold formatering og struktur +- Oversett fagtermer korrekt, behold engelske termer i parentes der det er vanlig +- Behold egennavn uoversatt"# + .to_string(), + + _ => "Behandle denne teksten. Output på norsk.".to_string(), + } +} + +/// Enkel HTML-stripping for å sende ren tekst til LLM. +fn strip_html(html: &str) -> String { + let mut result = String::with_capacity(html.len()); + let mut in_tag = false; + + for ch in html.chars() { + match ch { + '<' => in_tag = true, + '>' => in_tag = false, + _ if !in_tag => result.push(ch), + _ => {} + } + } + + result +} diff --git a/worker/src/handlers/mod.rs b/worker/src/handlers/mod.rs index 1365741..4e15a44 100644 --- a/worker/src/handlers/mod.rs +++ b/worker/src/handlers/mod.rs @@ -3,6 +3,7 @@ use sqlx::PgPool; use std::collections::HashMap; use uuid::Uuid; +mod ai_text_process; mod echo; /// Trait for jobbhandlere. @@ -21,18 +22,24 @@ pub type HandlerRegistry = HashMap>; /// Bygg registeret med alle tilgjengelige handlers. pub fn build_registry(http: reqwest::Client, ai_gateway_url: String) -> HandlerRegistry { - let _ = (&http, &ai_gateway_url); // brukes av fremtidige handlers - let mut registry: HandlerRegistry = HashMap::new(); // Echo-handler for testing registry.insert("echo".into(), Box::new(echo::EchoHandler)); - // Fremtidige handlers registreres her: - // registry.insert("whisper_transcribe".into(), Box::new(whisper::WhisperHandler::new(http.clone()))); - // registry.insert("openrouter_analyze".into(), Box::new(ai::AnalyzeHandler::new(http.clone(), ai_gateway_url.clone()))); - // registry.insert("research_clip".into(), Box::new(ai::ResearchClipHandler::new(http.clone(), ai_gateway_url.clone()))); - // registry.insert("stats_parse".into(), Box::new(stats::StatsHandler)); + // AI-behandling av tekst (✨-knappen i editoren) + registry.insert( + "ai_text_process".into(), + Box::new(ai_text_process::AiTextProcessHandler::new( + http.clone(), + ai_gateway_url.clone(), + )), + ); + + // Fremtidige handlers: + // registry.insert("whisper_transcribe".into(), ...); + // registry.insert("openrouter_analyze".into(), ...); + // registry.insert("research_clip".into(), ...); registry }