Fullfører oppgave 18.2: AI-prosessering endepunkt
POST /intentions/ai_process med source_node_id, ai_preset_id og direction (node_to_tool / tool_to_node). Endepunktet validerer input, sjekker at kilde-node og AI-preset finnes, verifiserer skrivetilgang for tool_to_node-retning, og legger en ai_process-jobb i køen. Jobb-handleren (ai_process.rs) henter kilde-content og preset-prompt, mapper modellprofil → LiteLLM-alias (flash → sidelinja/rutine, standard → sidelinja/resonering), kaller AI Gateway, og logger forbruk i både ai_usage_log og resource_usage_log. Direction-logikk (opprett ny node vs. oppdater eksisterende) implementeres i oppgave 18.3. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7224cf9897
commit
bca0ff1deb
6 changed files with 450 additions and 3 deletions
|
|
@ -225,7 +225,7 @@ Flere AI-verktøy i serie: dra output fra "Oversett" videre til
|
|||
### Fase A: Grunnleggende verktøy-panel
|
||||
- [x] AI-preset node-type (`node_kind: 'ai_preset'`) + metadata-skjema
|
||||
- [x] Standard-presets som seed-data (rens tekst, korrektør, oppsummering osv.)
|
||||
- [ ] `POST /intentions/ai_process` endepunkt i maskinrommet
|
||||
- [x] `POST /intentions/ai_process` endepunkt i maskinrommet
|
||||
- [ ] Verktøy-panel UI med prompt-velger og modell-indikator
|
||||
- [ ] Jobbkø-integrasjon med AI Gateway
|
||||
|
||||
|
|
|
|||
314
maskinrommet/src/ai_process.rs
Normal file
314
maskinrommet/src/ai_process.rs
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
// AI-prosessering — hent kilde-content + preset-prompt, kall AI Gateway.
|
||||
//
|
||||
// Jobbtype: "ai_process"
|
||||
// Payload: {
|
||||
// "source_node_id": "<uuid>",
|
||||
// "ai_preset_id": "<uuid>",
|
||||
// "direction": "node_to_tool" | "tool_to_node",
|
||||
// "requested_by": "<uuid>"
|
||||
// }
|
||||
//
|
||||
// Flyten:
|
||||
// 1. Hent kilde-node content fra PG
|
||||
// 2. Hent AI-preset prompt + modellprofil fra PG
|
||||
// 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)
|
||||
//
|
||||
// Ref: docs/features/ai_verktoy.md, docs/infra/ai_gateway.md
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::jobs::JobRow;
|
||||
use crate::resource_usage;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct SourceNodeRow {
|
||||
content: Option<String>,
|
||||
#[allow(dead_code)] // Brukes i oppgave 18.3 (direction-logikk)
|
||||
title: Option<String>,
|
||||
#[allow(dead_code)] // Brukes i oppgave 18.3 (direction-logikk)
|
||||
node_kind: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct PresetRow {
|
||||
title: Option<String>,
|
||||
metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
#[serde(default)]
|
||||
usage: Option<UsageInfo>,
|
||||
#[serde(default)]
|
||||
model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
struct UsageInfo {
|
||||
#[serde(default)]
|
||||
prompt_tokens: i64,
|
||||
#[serde(default)]
|
||||
completion_tokens: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Choice {
|
||||
message: MessageContent,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MessageContent {
|
||||
content: Option<String>,
|
||||
}
|
||||
|
||||
/// Mapper modellprofil til LiteLLM-alias.
|
||||
/// Ref: docs/features/ai_verktoy.md § 4, docs/infra/ai_gateway.md § 3.4
|
||||
fn model_profile_to_alias(profile: &str) -> &'static str {
|
||||
match profile {
|
||||
"flash" => "sidelinja/rutine",
|
||||
"standard" => "sidelinja/resonering",
|
||||
_ => "sidelinja/rutine", // fallback til billigste
|
||||
}
|
||||
}
|
||||
|
||||
/// Håndterer ai_process-jobb.
|
||||
pub async fn handle_ai_process(
|
||||
job: &JobRow,
|
||||
db: &PgPool,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let source_node_id: Uuid = job
|
||||
.payload
|
||||
.get("source_node_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.ok_or("Mangler source_node_id i payload")?;
|
||||
|
||||
let ai_preset_id: Uuid = job
|
||||
.payload
|
||||
.get("ai_preset_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.ok_or("Mangler ai_preset_id i payload")?;
|
||||
|
||||
let direction = job
|
||||
.payload
|
||||
.get("direction")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Mangler direction 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 requested_by i payload")?;
|
||||
|
||||
// 1. Hent kilde-node
|
||||
let source = sqlx::query_as::<_, SourceNodeRow>(
|
||||
"SELECT content, title, node_kind FROM nodes WHERE id = $1",
|
||||
)
|
||||
.bind(source_node_id)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.map_err(|e| format!("PG-feil ved henting av kilde-node: {e}"))?
|
||||
.ok_or("Kilde-node finnes ikke")?;
|
||||
|
||||
let source_content = source
|
||||
.content
|
||||
.filter(|c| !c.is_empty())
|
||||
.ok_or("Kilde-noden har ikke innhold å behandle")?;
|
||||
|
||||
// 2. Hent AI-preset
|
||||
let preset = sqlx::query_as::<_, PresetRow>(
|
||||
"SELECT title, metadata FROM nodes WHERE id = $1 AND node_kind = 'ai_preset'",
|
||||
)
|
||||
.bind(ai_preset_id)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.map_err(|e| format!("PG-feil ved henting av AI-preset: {e}"))?
|
||||
.ok_or("AI-preset finnes ikke")?;
|
||||
|
||||
let metadata = preset
|
||||
.metadata
|
||||
.ok_or("AI-preset mangler metadata")?;
|
||||
|
||||
let prompt = metadata
|
||||
.get("prompt")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("AI-preset mangler prompt i metadata")?;
|
||||
|
||||
let model_profile = metadata
|
||||
.get("model_profile")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("flash");
|
||||
|
||||
// 3. Map modellprofil → LiteLLM-alias
|
||||
let model_alias = model_profile_to_alias(model_profile);
|
||||
|
||||
tracing::info!(
|
||||
source_node_id = %source_node_id,
|
||||
ai_preset_id = %ai_preset_id,
|
||||
direction = %direction,
|
||||
model_alias = %model_alias,
|
||||
preset_title = ?preset.title,
|
||||
source_content_len = source_content.len(),
|
||||
"Starter AI-prosessering"
|
||||
);
|
||||
|
||||
// 4. Kall AI Gateway
|
||||
let (ai_output, usage, actual_model) =
|
||||
call_ai_gateway(model_alias, prompt, &source_content).await?;
|
||||
|
||||
tracing::info!(
|
||||
source_node_id = %source_node_id,
|
||||
output_len = ai_output.len(),
|
||||
actual_model = ?actual_model,
|
||||
"AI-prosessering fullført"
|
||||
);
|
||||
|
||||
// 5. Logg forbruk i ai_usage_log
|
||||
let collection_id = resource_usage::find_collection_for_node(db, source_node_id).await;
|
||||
let (tokens_in, tokens_out) = usage
|
||||
.as_ref()
|
||||
.map(|u| (u.prompt_tokens, u.completion_tokens))
|
||||
.unwrap_or((0, 0));
|
||||
let total_tokens = tokens_in + tokens_out;
|
||||
|
||||
// ai_usage_log — detaljert AI-forbrukslogg
|
||||
if let Err(e) = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO ai_usage_log
|
||||
(collection_node_id, job_id, model_alias, model_actual,
|
||||
prompt_tokens, completion_tokens, total_tokens, job_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 'ai_process')
|
||||
"#,
|
||||
)
|
||||
.bind(collection_id)
|
||||
.bind(job.id)
|
||||
.bind(model_alias)
|
||||
.bind(actual_model.as_deref())
|
||||
.bind(tokens_in as i32)
|
||||
.bind(tokens_out as i32)
|
||||
.bind(total_tokens as i32)
|
||||
.execute(db)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "Kunne ikke logge AI-forbruk i ai_usage_log");
|
||||
}
|
||||
|
||||
// resource_usage_log — generell ressurslogging
|
||||
if let Err(e) = resource_usage::log(
|
||||
db,
|
||||
source_node_id,
|
||||
Some(requested_by),
|
||||
collection_id,
|
||||
"ai",
|
||||
serde_json::json!({
|
||||
"model_level": model_profile,
|
||||
"model_id": actual_model.unwrap_or_else(|| "unknown".to_string()),
|
||||
"model_alias": model_alias,
|
||||
"tokens_in": tokens_in,
|
||||
"tokens_out": tokens_out,
|
||||
"job_type": "ai_process",
|
||||
"preset_id": ai_preset_id.to_string(),
|
||||
"direction": direction
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
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",
|
||||
"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
|
||||
}))
|
||||
}
|
||||
|
||||
/// Kall AI Gateway (LiteLLM) for tekstbehandling.
|
||||
/// Returnerer (output_text, usage, actual_model_name).
|
||||
async fn call_ai_gateway(
|
||||
model_alias: &str,
|
||||
system_prompt: &str,
|
||||
user_content: &str,
|
||||
) -> Result<(String, Option<UsageInfo>, Option<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();
|
||||
|
||||
let request = ChatRequest {
|
||||
model: model_alias.to_string(),
|
||||
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(120))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("AI Gateway-kall feilet: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("AI Gateway returnerte {status}: {body}"));
|
||||
}
|
||||
|
||||
let chat_resp: ChatResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Kunne ikke parse AI Gateway-respons: {e}"))?;
|
||||
|
||||
let content = chat_resp
|
||||
.choices
|
||||
.first()
|
||||
.and_then(|c| c.message.content.as_deref())
|
||||
.ok_or("AI Gateway returnerte ingen content")?;
|
||||
|
||||
Ok((content.to_string(), chat_resp.usage, chat_resp.model))
|
||||
}
|
||||
|
|
@ -3228,6 +3228,134 @@ pub async fn summarize(
|
|||
Ok(Json(SummarizeResponse { job_id }))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// POST /intentions/ai_process — AI-prosessering via AI Gateway
|
||||
// =============================================================================
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AiProcessRequest {
|
||||
/// Kilde-noden som skal prosesseres.
|
||||
pub source_node_id: Uuid,
|
||||
/// AI-preset som definerer prompt og modellprofil.
|
||||
pub ai_preset_id: Uuid,
|
||||
/// Retning: "node_to_tool" (opprett ny node) eller "tool_to_node" (modifiser in-place).
|
||||
pub direction: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AiProcessResponse {
|
||||
pub job_id: Uuid,
|
||||
}
|
||||
|
||||
/// POST /intentions/ai_process
|
||||
///
|
||||
/// Legger en `ai_process`-jobb i køen.
|
||||
/// AI-prosesseringen skjer asynkront — kilde-content sendes til AI Gateway
|
||||
/// med preset-prompt, og forbruk logges i ai_usage_log.
|
||||
///
|
||||
/// Direction-logikk (opprett ny node vs. oppdater eksisterende) implementeres
|
||||
/// i oppgave 18.3.
|
||||
///
|
||||
/// Ref: docs/features/ai_verktoy.md § 6.1
|
||||
pub async fn ai_process(
|
||||
State(state): State<AppState>,
|
||||
user: AuthUser,
|
||||
Json(req): Json<AiProcessRequest>,
|
||||
) -> Result<Json<AiProcessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
// Valider direction
|
||||
if req.direction != "node_to_tool" && req.direction != "tool_to_node" {
|
||||
return Err(bad_request(
|
||||
"direction må være 'node_to_tool' eller 'tool_to_node'",
|
||||
));
|
||||
}
|
||||
|
||||
// Sjekk at kilde-noden finnes
|
||||
let source_exists: bool = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1)",
|
||||
)
|
||||
.bind(req.source_node_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "PG-feil ved kilde-node-sjekk");
|
||||
internal_error("Databasefeil")
|
||||
})?;
|
||||
|
||||
if !source_exists {
|
||||
return Err(bad_request("Kilde-node finnes ikke"));
|
||||
}
|
||||
|
||||
// Sjekk at AI-preset finnes
|
||||
let preset_exists: bool = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1 AND node_kind = 'ai_preset')",
|
||||
)
|
||||
.bind(req.ai_preset_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "PG-feil ved preset-sjekk");
|
||||
internal_error("Databasefeil")
|
||||
})?;
|
||||
|
||||
if !preset_exists {
|
||||
return Err(bad_request("AI-preset finnes ikke"));
|
||||
}
|
||||
|
||||
// For tool_to_node-retning trengs skrivetilgang til kilde-noden
|
||||
if req.direction == "tool_to_node" {
|
||||
let can_modify = user_can_modify_node(&state.db, user.node_id, req.source_node_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "PG-feil ved tilgangssjekk");
|
||||
internal_error("Databasefeil")
|
||||
})?;
|
||||
|
||||
if !can_modify {
|
||||
return Err(forbidden(
|
||||
"Ingen tilgang til å endre kilde-noden (tool_to_node krever skrivetilgang)",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Finn samlings-ID for kilde-noden (for prioritering)
|
||||
let collection_id = crate::resource_usage::find_collection_for_node(
|
||||
&state.db,
|
||||
req.source_node_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"source_node_id": req.source_node_id.to_string(),
|
||||
"ai_preset_id": req.ai_preset_id.to_string(),
|
||||
"direction": req.direction,
|
||||
"requested_by": user.node_id.to_string()
|
||||
});
|
||||
|
||||
let job_id = crate::jobs::enqueue(
|
||||
&state.db,
|
||||
"ai_process",
|
||||
payload,
|
||||
collection_id,
|
||||
5, // Medium prioritet
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Kunne ikke legge ai_process-jobb i kø");
|
||||
internal_error("Kunne ikke starte AI-prosessering")
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
job_id = %job_id,
|
||||
source_node_id = %req.source_node_id,
|
||||
ai_preset_id = %req.ai_preset_id,
|
||||
direction = %req.direction,
|
||||
user = %user.node_id,
|
||||
"ai_process-jobb lagt i kø"
|
||||
);
|
||||
|
||||
Ok(Json(AiProcessResponse { job_id }))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// POST /intentions/generate_tts — tekst-til-tale via ElevenLabs
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ use uuid::Uuid;
|
|||
|
||||
use crate::agent;
|
||||
use crate::ai_edges;
|
||||
use crate::ai_process;
|
||||
use crate::audio;
|
||||
use crate::cas::CasStore;
|
||||
use crate::maintenance::MaintenanceState;
|
||||
|
|
@ -184,6 +185,9 @@ async fn dispatch(
|
|||
"audio_process" => {
|
||||
audio::handle_audio_process_job(job, db, stdb, cas).await
|
||||
}
|
||||
"ai_process" => {
|
||||
ai_process::handle_ai_process(job, db).await
|
||||
}
|
||||
"render_article" => {
|
||||
handle_render_article(job, db, cas).await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
pub mod agent;
|
||||
pub mod ai_admin;
|
||||
pub mod ai_edges;
|
||||
pub mod ai_process;
|
||||
pub mod audio;
|
||||
pub mod bandwidth;
|
||||
mod auth;
|
||||
|
|
@ -202,6 +203,7 @@ async fn main() {
|
|||
.route("/intentions/retranscribe", post(intentions::retranscribe))
|
||||
.route("/intentions/resolve_retranscription", post(intentions::resolve_retranscription))
|
||||
.route("/intentions/summarize", post(intentions::summarize))
|
||||
.route("/intentions/ai_process", post(intentions::ai_process))
|
||||
.route("/intentions/generate_tts", post(intentions::generate_tts))
|
||||
.route("/intentions/join_communication", post(intentions::join_communication))
|
||||
.route("/intentions/leave_communication", post(intentions::leave_communication))
|
||||
|
|
|
|||
3
tasks.md
3
tasks.md
|
|
@ -202,8 +202,7 @@ Ref: Kodegjennomgang av `b4c4bb8` (Lydstudio: lydredigering via FFmpeg).
|
|||
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).
|
||||
- [~] 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.
|
||||
> Påbegynt: 2026-03-18T06:14
|
||||
- [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.
|
||||
- [ ] 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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue