Fullfører oppgave 18.3: Direction-logikk for AI-prosessering

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) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 06:40:26 +00:00
parent 9855488d12
commit 776bc895c1
3 changed files with 242 additions and 21 deletions

View file

@ -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" // Jobbtype: "ai_process"
// Payload: { // Payload: {
@ -14,7 +15,9 @@
// 3. Map modellprofil → LiteLLM-alias (flash → sidelinja/rutine, standard → sidelinja/resonering) // 3. Map modellprofil → LiteLLM-alias (flash → sidelinja/rutine, standard → sidelinja/resonering)
// 4. Send til AI Gateway (LiteLLM) // 4. Send til AI Gateway (LiteLLM)
// 5. Logg forbruk i ai_usage_log // 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 // Ref: docs/features/ai_verktoy.md, docs/infra/ai_gateway.md
@ -24,14 +27,15 @@ use uuid::Uuid;
use crate::jobs::JobRow; use crate::jobs::JobRow;
use crate::resource_usage; use crate::resource_usage;
use crate::stdb::StdbClient;
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct SourceNodeRow { struct SourceNodeRow {
content: Option<String>, content: Option<String>,
#[allow(dead_code)] // Brukes i oppgave 18.3 (direction-logikk)
title: Option<String>, title: Option<String>,
#[allow(dead_code)] // Brukes i oppgave 18.3 (direction-logikk)
node_kind: String, node_kind: String,
visibility: String,
metadata: serde_json::Value,
} }
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
@ -96,6 +100,7 @@ fn model_profile_to_alias(profile: &str) -> &'static str {
pub async fn handle_ai_process( pub async fn handle_ai_process(
job: &JobRow, job: &JobRow,
db: &PgPool, db: &PgPool,
stdb: &StdbClient,
) -> Result<serde_json::Value, String> { ) -> Result<serde_json::Value, String> {
let source_node_id: Uuid = job let source_node_id: Uuid = job
.payload .payload
@ -124,9 +129,9 @@ pub async fn handle_ai_process(
.and_then(|s| s.parse().ok()) .and_then(|s| s.parse().ok())
.ok_or("Mangler requested_by i payload")?; .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>( 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) .bind(source_node_id)
.fetch_optional(db) .fetch_optional(db)
@ -136,8 +141,10 @@ pub async fn handle_ai_process(
let source_content = source let source_content = source
.content .content
.as_ref()
.filter(|c| !c.is_empty()) .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 // 2. Hent AI-preset
let preset = sqlx::query_as::<_, PresetRow>( let preset = sqlx::query_as::<_, PresetRow>(
@ -240,19 +247,234 @@ pub async fn handle_ai_process(
tracing::warn!(error = %e, "Kunne ikke logge AI-ressursforbruk"); tracing::warn!(error = %e, "Kunne ikke logge AI-ressursforbruk");
} }
// 6. Returner resultat // 6. Direction-logikk
// Direction-logikk (opprett ny node / oppdater eksisterende) implementeres i oppgave 18.3 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!({ Ok(serde_json::json!({
"status": "completed", "status": "completed",
"source_node_id": source_node_id.to_string(), "source_node_id": source_node_id.to_string(),
"ai_preset_id": ai_preset_id.to_string(), "ai_preset_id": ai_preset_id.to_string(),
"direction": direction, "direction": "tool_to_node",
"ai_output": ai_output, "ai_output": ai_output,
"tokens_in": tokens_in, "tokens_in": tokens_in,
"tokens_out": tokens_out, "tokens_out": tokens_out,
"total_tokens": total_tokens "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<Uuid, String> {
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()
});
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. /// Kall AI Gateway (LiteLLM) for tekstbehandling.
/// Returnerer (output_text, usage, actual_model_name). /// Returnerer (output_text, usage, actual_model_name).

View file

@ -186,7 +186,7 @@ async fn dispatch(
audio::handle_audio_process_job(job, db, stdb, cas).await audio::handle_audio_process_job(job, db, stdb, cas).await
} }
"ai_process" => { "ai_process" => {
ai_process::handle_ai_process(job, db).await ai_process::handle_ai_process(job, db, stdb).await
} }
"render_article" => { "render_article" => {
handle_render_article(job, db, cas).await handle_render_article(job, db, cas).await

View file

@ -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.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. - [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. - [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.
> Påbegynt: 2026-03-18T06:25
- [ ] 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.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.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. - [ ] 18.6 Egendefinerte presets: brukere kan opprette egne AI-preset-noder med custom prompt. Dele via edges til samling/team. Modellprofil satt av admin.