synops/maskinrommet/src/describe_image.rs
vegard 1d8ebf259b Skjermklipp-input: paste screenshot i chat → CAS → media-node (oppgave 29.1)
Frontend:
- ChatInput: paste-handler detekterer bilder fra clipboard (ClipboardEvent),
  laster opp til CAS via uploadMedia med metadata_extra { source: "screenshot" }
- Chat-side: viser bildenoder inline med AI-beskrivelse når tilgjengelig
- api.ts: uploadMedia støtter nå metadata_extra for ekstra node-metadata

Backend (maskinrommet):
- upload_media: nytt metadata_extra multipart-felt som merges inn i
  media-nodens metadata (f.eks. source, description)
- describe_image: ny jobbtype — enqueuues automatisk for screenshot-uploads,
  kaller synops-ai med --image for AI-beskrivelse av bildet
- Beskrivelsen lagres tilbake i media-nodens metadata.description

synops-ai:
- Nytt --image flag for multimodal LLM-kall (vision) via LiteLLM
- Sender bilde som base64 data-URL i OpenAI-kompatibelt format
- Brukes av describe_image-jobben for bildbeskrivelse
2026-03-18 21:07:00 +00:00

108 lines
3.2 KiB
Rust

// describe_image — AI-beskrivelse av bilder via synops-ai CLI.
//
// Jobbtype: "describe_image"
// Payload: { "media_node_id", "cas_hash", "mime" }
//
// Flyten:
// 1. Sender bildefilen (CAS-sti) til synops-ai prompt --image
// 2. synops-ai sender multimodal melding til LLM via LiteLLM
// 3. Oppdaterer media-nodens metadata med description-feltet
//
// Ref: docs/retninger/unix_filosofi.md, oppgave 29.1
use sqlx::PgPool;
use uuid::Uuid;
use crate::cas::CasStore;
use crate::jobs::JobRow;
/// Håndterer describe_image-jobb.
///
/// Sender bildet til synops-ai for beskrivelse via vision-modell,
/// og lagrer resultatet i media-nodens metadata.
pub async fn handle_describe_image(
job: &JobRow,
db: &PgPool,
cas: &CasStore,
) -> Result<serde_json::Value, String> {
let media_node_id: Uuid = job.payload["media_node_id"]
.as_str()
.and_then(|s| s.parse().ok())
.ok_or("Mangler gyldig 'media_node_id' i payload")?;
let cas_hash = job.payload["cas_hash"]
.as_str()
.ok_or("Mangler 'cas_hash' i payload")?;
let mime = job.payload["mime"]
.as_str()
.unwrap_or("image/png");
// Verifiser at bildet finnes i CAS
let cas_path = cas.path_for(cas_hash);
if !cas_path.exists() {
return Err(format!("Bilde ikke funnet i CAS: {cas_hash}"));
}
let bin = std::env::var("SYNOPS_AI_BIN")
.unwrap_or_else(|_| "synops-ai".to_string());
tracing::info!(
media_node_id = %media_node_id,
cas_hash,
"Sender bilde til AI for beskrivelse"
);
let output = tokio::process::Command::new(&bin)
.arg("prompt")
.arg("--prompt")
.arg("Beskriv dette bildet kort og konsist på norsk. Fokuser på innholdet, ikke formatet. Maks 2-3 setninger.")
.arg("--image")
.arg(cas_path.to_str().unwrap_or(""))
.arg("--image-mime")
.arg(mime)
.arg("--job-type")
.arg("describe_image")
.arg("--temperature")
.arg("0.3")
.output()
.await
.map_err(|e| format!("Kunne ikke starte {bin}: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("synops-ai feilet: {stderr}"));
}
let description = String::from_utf8_lossy(&output.stdout).trim().to_string();
if description.is_empty() {
return Err("synops-ai returnerte tom beskrivelse".into());
}
tracing::info!(
media_node_id = %media_node_id,
description_len = description.len(),
"Bildebeskrivelse mottatt fra AI"
);
// Oppdater media-nodens metadata med description
sqlx::query(
r#"UPDATE nodes
SET metadata = metadata || jsonb_build_object('description', $2::text),
updated_at = now()
WHERE id = $1"#,
)
.bind(media_node_id)
.bind(&description)
.execute(db)
.await
.map_err(|e| format!("Kunne ikke oppdatere media-node med beskrivelse: {e}"))?;
// PG NOTIFY-trigger på nodes-tabellen sender sanntidsoppdatering automatisk.
Ok(serde_json::json!({
"media_node_id": media_node_id.to_string(),
"description": description,
}))
}