From 776bc895c1503fe030e62d687c11ca5cbd9ff713 Mon Sep 17 00:00:00 2001 From: vegard Date: Wed, 18 Mar 2026 06:40:26 +0000 Subject: [PATCH] =?UTF-8?q?Fullf=C3=B8rer=20oppgave=2018.3:=20Direction-lo?= =?UTF-8?q?gikk=20for=20AI-prosessering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementerer de to retningene for AI-verktøyet: - tool_to_node ("Penselen"): Lagrer original content som revisjon i ny node_revisions-tabell, deretter oppdaterer noden med AI-output i både STDB (sanntid) og PG (persistering). - node_to_tool ("Kverna"): Oppretter ny node med AI-output, med derived_from-edge tilbake til kildenoden og processed_by-edge til AI-preseten. Full sporbarhet i grafen. Ny PG-tabell: node_revisions (node_id, content, title, metadata, revision_type, created_by, ai_preset_id, job_id). Ref: docs/features/ai_verktoy.md § 2.2, § 6.1 Co-Authored-By: Claude Opus 4.6 (1M context) --- maskinrommet/src/ai_process.rs | 258 ++++++++++++++++++++++++++++++--- maskinrommet/src/jobs.rs | 2 +- tasks.md | 3 +- 3 files changed, 242 insertions(+), 21 deletions(-) diff --git a/maskinrommet/src/ai_process.rs b/maskinrommet/src/ai_process.rs index d038231..3b3ba96 100644 --- a/maskinrommet/src/ai_process.rs +++ b/maskinrommet/src/ai_process.rs @@ -1,4 +1,5 @@ -// AI-prosessering — hent kilde-content + preset-prompt, kall AI Gateway. +// AI-prosessering — hent kilde-content + preset-prompt, kall AI Gateway, +// og utfør direction-spesifikk logikk. // // Jobbtype: "ai_process" // Payload: { @@ -14,7 +15,9 @@ // 3. Map modellprofil → LiteLLM-alias (flash → sidelinja/rutine, standard → sidelinja/resonering) // 4. Send til AI Gateway (LiteLLM) // 5. Logg forbruk i ai_usage_log -// 6. Returner AI-output (direction-logikk implementeres i oppgave 18.3) +// 6. Direction-logikk: +// - tool_to_node: lagre original som revisjon i node_revisions, oppdater node content +// - node_to_tool: opprett ny node med AI-output, opprett derived_from + processed_by edges // // Ref: docs/features/ai_verktoy.md, docs/infra/ai_gateway.md @@ -24,14 +27,15 @@ use uuid::Uuid; use crate::jobs::JobRow; use crate::resource_usage; +use crate::stdb::StdbClient; #[derive(sqlx::FromRow)] struct SourceNodeRow { content: Option, - #[allow(dead_code)] // Brukes i oppgave 18.3 (direction-logikk) title: Option, - #[allow(dead_code)] // Brukes i oppgave 18.3 (direction-logikk) node_kind: String, + visibility: String, + metadata: serde_json::Value, } #[derive(sqlx::FromRow)] @@ -96,6 +100,7 @@ fn model_profile_to_alias(profile: &str) -> &'static str { pub async fn handle_ai_process( job: &JobRow, db: &PgPool, + stdb: &StdbClient, ) -> Result { let source_node_id: Uuid = job .payload @@ -124,9 +129,9 @@ pub async fn handle_ai_process( .and_then(|s| s.parse().ok()) .ok_or("Mangler requested_by i payload")?; - // 1. Hent kilde-node + // 1. Hent kilde-node (inkl. visibility og metadata for direction-logikk) let source = sqlx::query_as::<_, SourceNodeRow>( - "SELECT content, title, node_kind FROM nodes WHERE id = $1", + "SELECT content, title, node_kind, visibility::text AS visibility, metadata FROM nodes WHERE id = $1", ) .bind(source_node_id) .fetch_optional(db) @@ -136,8 +141,10 @@ pub async fn handle_ai_process( let source_content = source .content + .as_ref() .filter(|c| !c.is_empty()) - .ok_or("Kilde-noden har ikke innhold å behandle")?; + .ok_or("Kilde-noden har ikke innhold å behandle")? + .clone(); // 2. Hent AI-preset let preset = sqlx::query_as::<_, PresetRow>( @@ -240,18 +247,233 @@ pub async fn handle_ai_process( tracing::warn!(error = %e, "Kunne ikke logge AI-ressursforbruk"); } - // 6. Returner resultat - // Direction-logikk (opprett ny node / oppdater eksisterende) implementeres i oppgave 18.3 - Ok(serde_json::json!({ - "status": "completed", + // 6. Direction-logikk + match direction { + "tool_to_node" => { + handle_tool_to_node( + db, stdb, job, source_node_id, ai_preset_id, requested_by, + &source, &source_content, &ai_output, + ).await?; + + tracing::info!( + source_node_id = %source_node_id, + "tool_to_node: original lagret som revisjon, node oppdatert med AI-output" + ); + + Ok(serde_json::json!({ + "status": "completed", + "source_node_id": source_node_id.to_string(), + "ai_preset_id": ai_preset_id.to_string(), + "direction": "tool_to_node", + "ai_output": ai_output, + "tokens_in": tokens_in, + "tokens_out": tokens_out, + "total_tokens": total_tokens + })) + } + "node_to_tool" => { + let new_node_id = handle_node_to_tool( + db, stdb, source_node_id, ai_preset_id, requested_by, + &source, &ai_output, preset.title.as_deref(), + ).await?; + + tracing::info!( + source_node_id = %source_node_id, + new_node_id = %new_node_id, + "node_to_tool: ny node opprettet med derived_from + processed_by edges" + ); + + Ok(serde_json::json!({ + "status": "completed", + "source_node_id": source_node_id.to_string(), + "new_node_id": new_node_id.to_string(), + "ai_preset_id": ai_preset_id.to_string(), + "direction": "node_to_tool", + "ai_output": ai_output, + "tokens_in": tokens_in, + "tokens_out": tokens_out, + "total_tokens": total_tokens + })) + } + other => Err(format!("Ugyldig direction: {other}")), + } +} + +/// tool_to_node: "Penselen" — AI-verktøyet brukes PÅ noden. +/// Lagrer original content som revisjon, oppdaterer noden med AI-output. +/// Ref: docs/features/ai_verktoy.md § 2.2 +async fn handle_tool_to_node( + db: &PgPool, + stdb: &StdbClient, + job: &JobRow, + source_node_id: Uuid, + ai_preset_id: Uuid, + requested_by: Uuid, + source: &SourceNodeRow, + original_content: &str, + ai_output: &str, +) -> Result<(), String> { + // 1. Lagre originalt innhold som revisjon + sqlx::query( + r#" + INSERT INTO node_revisions (node_id, content, title, metadata, revision_type, created_by, ai_preset_id, job_id) + VALUES ($1, $2, $3, $4, 'ai_edit', $5, $6, $7) + "#, + ) + .bind(source_node_id) + .bind(original_content) + .bind(source.title.as_deref()) + .bind(&source.metadata) + .bind(requested_by) + .bind(ai_preset_id) + .bind(job.id) + .execute(db) + .await + .map_err(|e| format!("Kunne ikke lagre revisjon: {e}"))?; + + // 2. Oppdater node content i STDB (sanntid) + let metadata_str = source.metadata.to_string(); + stdb.update_node( + &source_node_id.to_string(), + &source.node_kind, + source.title.as_deref().unwrap_or(""), + ai_output, + &source.visibility, + &metadata_str, + ) + .await + .map_err(|e| format!("STDB update_node feilet: {e}"))?; + + // 3. Oppdater node content i PG (persistering) + sqlx::query( + "UPDATE nodes SET content = $2 WHERE id = $1", + ) + .bind(source_node_id) + .bind(ai_output) + .execute(db) + .await + .map_err(|e| format!("PG update node content feilet: {e}"))?; + + Ok(()) +} + +/// node_to_tool: "Kverna" — noden sendes GJENNOM verktøyet. +/// Oppretter ny node med AI-output, med derived_from-edge til kilde +/// og processed_by-edge til AI-preset. +/// Ref: docs/features/ai_verktoy.md § 2.2 +async fn handle_node_to_tool( + db: &PgPool, + stdb: &StdbClient, + source_node_id: Uuid, + ai_preset_id: Uuid, + requested_by: Uuid, + source: &SourceNodeRow, + ai_output: &str, + preset_title: Option<&str>, +) -> Result { + let new_node_id = Uuid::now_v7(); + let new_title = format!( + "{} → {}", + source.title.as_deref().unwrap_or("Uten tittel"), + preset_title.unwrap_or("AI"), + ); + let new_metadata = serde_json::json!({ + "ai_generated": true, "source_node_id": source_node_id.to_string(), - "ai_preset_id": ai_preset_id.to_string(), - "direction": direction, - "ai_output": ai_output, - "tokens_in": tokens_in, - "tokens_out": tokens_out, - "total_tokens": total_tokens - })) + "ai_preset_id": ai_preset_id.to_string() + }); + let new_metadata_str = new_metadata.to_string(); + let empty_meta = serde_json::json!({}).to_string(); + + // 1. Opprett ny node i STDB (sanntid) + stdb.create_node( + &new_node_id.to_string(), + "content", + &new_title, + ai_output, + &source.visibility, + &new_metadata_str, + &requested_by.to_string(), + ) + .await + .map_err(|e| format!("STDB create_node feilet: {e}"))?; + + // 2. Opprett ny node i PG (persistering) + sqlx::query( + r#" + INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by) + VALUES ($1, 'content', $2, $3, $4::visibility, $5, $6) + "#, + ) + .bind(new_node_id) + .bind(&new_title) + .bind(ai_output) + .bind(&source.visibility) + .bind(&new_metadata) + .bind(requested_by) + .execute(db) + .await + .map_err(|e| format!("PG insert ny node feilet: {e}"))?; + + // 3. Opprett derived_from-edge: ny node → kilde-node + // Sporbarhet: "denne noden er avledet fra kilden" + let derived_edge_id = Uuid::now_v7(); + stdb.create_edge( + &derived_edge_id.to_string(), + &new_node_id.to_string(), + &source_node_id.to_string(), + "derived_from", + &empty_meta, + false, + &requested_by.to_string(), + ) + .await + .map_err(|e| format!("STDB create_edge (derived_from) feilet: {e}"))?; + + sqlx::query( + r#" + INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by) + VALUES ($1, $2, $3, 'derived_from', '{}', false, $4) + "#, + ) + .bind(derived_edge_id) + .bind(new_node_id) + .bind(source_node_id) + .bind(requested_by) + .execute(db) + .await + .map_err(|e| format!("PG insert derived_from-edge feilet: {e}"))?; + + // 4. Opprett processed_by-edge: ny node → AI-preset + // Sporbarhet: "denne noden ble prosessert av dette AI-verktøyet" + let processed_edge_id = Uuid::now_v7(); + stdb.create_edge( + &processed_edge_id.to_string(), + &new_node_id.to_string(), + &ai_preset_id.to_string(), + "processed_by", + &empty_meta, + false, + &requested_by.to_string(), + ) + .await + .map_err(|e| format!("STDB create_edge (processed_by) feilet: {e}"))?; + + sqlx::query( + r#" + INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by) + VALUES ($1, $2, $3, 'processed_by', '{}', false, $4) + "#, + ) + .bind(processed_edge_id) + .bind(new_node_id) + .bind(ai_preset_id) + .bind(requested_by) + .execute(db) + .await + .map_err(|e| format!("PG insert processed_by-edge feilet: {e}"))?; + + Ok(new_node_id) } /// Kall AI Gateway (LiteLLM) for tekstbehandling. diff --git a/maskinrommet/src/jobs.rs b/maskinrommet/src/jobs.rs index 8e13cda..a5ad8cb 100644 --- a/maskinrommet/src/jobs.rs +++ b/maskinrommet/src/jobs.rs @@ -186,7 +186,7 @@ async fn dispatch( audio::handle_audio_process_job(job, db, stdb, cas).await } "ai_process" => { - ai_process::handle_ai_process(job, db).await + ai_process::handle_ai_process(job, db, stdb).await } "render_article" => { handle_render_article(job, db, cas).await diff --git a/tasks.md b/tasks.md index 6b11313..86c5f68 100644 --- a/tasks.md +++ b/tasks.md @@ -203,8 +203,7 @@ Ref: `docs/features/ai_verktoy.md`, `docs/retninger/arbeidsflaten.md` - [x] 18.1 AI-preset node-type: `node_kind: 'ai_preset'` med metadata (prompt, model_profile, category, icon, color). Maskinrommet validerer ved opprettelse. Seed standardprompter (rens tekst, korrektør, oppsummering, oversett, skriv om, trekk ut fakta, forenkle, endre tone). - [x] 18.2 AI-prosessering endepunkt: `POST /intentions/ai_process` med source_node_id, ai_preset_id, direction (node_to_tool / tool_to_node). Maskinrommet henter kilde-content og preset-prompt, mapper modellprofil → LiteLLM-alias, sender til AI Gateway. Logg forbruk i ai_usage_log. -- [~] 18.3 Direction-logikk: `tool_to_node` → lagre original som revisjon, oppdater node content. `node_to_tool` → opprett ny node med AI-output, opprett `derived_from`-edge til kilde + `processed_by`-edge til AI-preset. - > Påbegynt: 2026-03-18T06:25 +- [x] 18.3 Direction-logikk: `tool_to_node` → lagre original som revisjon, oppdater node content. `node_to_tool` → opprett ny node med AI-output, opprett `derived_from`-edge til kilde + `processed_by`-edge til AI-preset. - [ ] 18.4 AI-verktøy panel (frontend): Svelte-komponent for arbeidsflaten. Prompt-velger med standardprompter, fritekst-felt for egendefinert prompt, modell-indikator (readonly). Drag-and-drop mottak for tekstnoder. - [ ] 18.5 Drag-and-drop integrasjon: node → verktøy (ny node), verktøy → node (in-place revisjon). Drop-sone feedback med verktøyets farge. Inkompatibilitet for lyd/bilde-noder med forklaring. - [ ] 18.6 Egendefinerte presets: brukere kan opprette egne AI-preset-noder med custom prompt. Dele via edges til samling/team. Modellprofil satt av admin.