synops-clip orkestrering-støtte: cli_tool-registrering + clip_url jobb/API (oppgave 25.4)

Gjør synops-clip tilgjengelig i orkestreringer ved å:

1. Registrere synops-clip som cli_tool-node (migration 026) med norske
   aliases (clip, klipp, hent artikkel) og args_hints for script-kompilatoren.
   Orkestreringer kan nå skrive "1. clip fra event (lagre node, bruker)"
   som kompileres til "synops-clip --url {event.url} --write --created-by ...".

2. Legge til clip_url som jobbtype i jobbkøen (clip.rs) — spawner
   synops-clip med riktige env-variabler (DATABASE_URL, AI_GATEWAY_URL, etc).

3. Legge til POST /intentions/clip_url API-endepunkt slik at frontend
   og andre klienter kan trigge URL-klipping direkte.

4. Utvide trigger-konteksten med event.url og event.created_by slik at
   orkestreringer som reagerer på URL-deling kan videresende URL til
   synops-clip via variabel-substitusjon.
This commit is contained in:
vegard 2026-03-18 18:55:11 +00:00
parent 8f02dbabc4
commit 8af4265b6e
8 changed files with 268 additions and 2 deletions

105
maskinrommet/src/clip.rs Normal file
View file

@ -0,0 +1,105 @@
// URL-klipping dispatcher — delegerer til synops-clip CLI.
//
// Maskinrommet orkestrerer (payload-parsing, sikkerhetskontroller),
// CLI-verktøyet gjør jobben (HTTP-henting, Readability-parsing,
// node-opprettelse, AI-oppsummering).
//
// Jobbtype: "clip_url"
// Payload: { "url": "<url>", "created_by": "<uuid>", "write": true }
//
// Ref: docs/retninger/unix_filosofi.md
use uuid::Uuid;
use crate::cli_dispatch;
use crate::jobs::JobRow;
/// Synops-clip binary path.
fn clip_bin() -> String {
std::env::var("SYNOPS_CLIP_BIN")
.unwrap_or_else(|_| "synops-clip".to_string())
}
/// Håndterer clip_url-jobb.
///
/// Spawner synops-clip med URL og valgfri --write for å gjøre alt arbeidet:
/// HTTP-henting, Readability-parsing, node-opprettelse, AI-oppsummering.
pub async fn handle_clip_url(
job: &JobRow,
_db: &sqlx::PgPool,
) -> Result<serde_json::Value, String> {
let url = job
.payload
.get("url")
.and_then(|v| v.as_str())
.ok_or("Mangler url i payload")?;
let write = job
.payload
.get("write")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let created_by: Option<Uuid> = job
.payload
.get("created_by")
.and_then(|v| v.as_str())
.and_then(|s| s.parse().ok());
let playwright = job
.payload
.get("playwright")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let timeout = job
.payload
.get("timeout")
.and_then(|v| v.as_u64());
// Bygg kommando
let bin = clip_bin();
let mut cmd = tokio::process::Command::new(&bin);
cmd.arg("--url").arg(url);
if write {
cmd.arg("--write");
if let Some(uid) = created_by {
cmd.arg("--created-by").arg(uid.to_string());
}
}
if playwright {
cmd.arg("--playwright");
}
if let Some(t) = timeout {
cmd.arg("--timeout").arg(t.to_string());
}
// Sett miljøvariabler CLI-verktøyet trenger
cli_dispatch::set_database_url(&mut cmd)?;
cli_dispatch::forward_env(&mut cmd, "AI_GATEWAY_URL");
cli_dispatch::forward_env(&mut cmd, "LITELLM_MASTER_KEY");
cli_dispatch::forward_env(&mut cmd, "AI_CLIP_MODEL");
cli_dispatch::forward_env(&mut cmd, "SYNOPS_CLIP_SCRIPTS");
tracing::info!(
url = %url,
write = write,
bin = %bin,
"Starter synops-clip"
);
let result = cli_dispatch::run_cli_tool(&bin, &mut cmd).await?;
tracing::info!(
url = %url,
title = result["title"].as_str().unwrap_or("n/a"),
paywall = result["paywall"].as_bool().unwrap_or(false),
"synops-clip fullført"
);
Ok(result)
}

View file

@ -4648,6 +4648,76 @@ pub async fn ai_suggest_script(
}))
}
// =============================================================================
// POST /intentions/clip_url — klipp URL og opprett content-node
// =============================================================================
#[derive(Deserialize)]
pub struct ClipUrlRequest {
/// URL som skal klippes.
pub url: String,
/// Skriv resultat til database (default: true).
#[serde(default = "default_true")]
pub write: bool,
/// Tving bruk av Playwright (headless browser).
#[serde(default)]
pub playwright: bool,
}
fn default_true() -> bool {
true
}
#[derive(Serialize)]
pub struct ClipUrlResponse {
pub job_id: Uuid,
}
/// POST /intentions/clip_url
///
/// Legger en `clip_url`-jobb i køen.
/// synops-clip henter artikkelen, parser med Readability,
/// og oppretter content-node med AI-oppsummering.
pub async fn clip_url(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<ClipUrlRequest>,
) -> Result<Json<ClipUrlResponse>, (StatusCode, Json<ErrorResponse>)> {
// Enkel URL-validering
if !req.url.starts_with("http://") && !req.url.starts_with("https://") {
return Err(bad_request("URL må starte med http:// eller https://"));
}
let payload = serde_json::json!({
"url": req.url,
"created_by": user.node_id.to_string(),
"write": req.write,
"playwright": req.playwright,
});
let job_id = crate::jobs::enqueue(
&state.db,
"clip_url",
payload,
None,
3, // Lav prioritet — ikke tidskritisk
)
.await
.map_err(|e| {
tracing::error!(error = %e, "Kunne ikke legge clip_url-jobb i kø");
internal_error("Kunne ikke starte URL-klipping")
})?;
tracing::info!(
job_id = %job_id,
url = %req.url,
user = %user.node_id,
"clip_url-jobb lagt i kø"
);
Ok(Json(ClipUrlResponse { job_id }))
}
// =============================================================================
// Tester
// =============================================================================

View file

@ -20,6 +20,7 @@ use crate::ai_process;
use crate::audio;
use crate::cas::CasStore;
use crate::cli_dispatch;
use crate::clip;
use crate::maintenance::MaintenanceState;
use crate::pg_writes;
use crate::publishing::IndexCache;
@ -219,6 +220,9 @@ async fn dispatch(
"pg_delete_edge" => {
pg_writes::handle_delete_edge(job, db, index_cache).await
}
"clip_url" => {
clip::handle_clip_url(job, db).await
}
// Orchestration: trigger-evaluering har lagt jobben i kø.
// Kompilatoren parser scriptet og validerer det.
// Utførelse av kompilert script kommer i oppgave 24.5.

View file

@ -7,6 +7,7 @@ pub mod bandwidth;
mod auth;
pub mod cas;
pub mod cli_dispatch;
pub mod clip;
mod custom_domain;
mod intentions;
pub mod jobs;
@ -259,6 +260,7 @@ async fn main() {
.route("/custom-domain/om", get(custom_domain::serve_custom_domain_about))
.route("/custom-domain/{article_id}", get(custom_domain::serve_custom_domain_article))
// Orkestrering UI (oppgave 24.6) + AI-assistert (oppgave 24.7)
.route("/intentions/clip_url", post(intentions::clip_url))
.route("/intentions/compile_script", post(intentions::compile_script))
.route("/intentions/test_orchestration", post(intentions::test_orchestration))
.route("/intentions/ai_suggest_script", post(intentions::ai_suggest_script))

View file

@ -250,6 +250,8 @@ const VALID_EVENT_VARS: &[&str] = &[
"event.cas_hash",
"event.communication_id",
"event.collection_id",
"event.url",
"event.created_by",
];
/// Sjekk om en variabelreferanse er gyldig.
@ -992,4 +994,39 @@ ved feil: opprett oppgave "Pipeline feilet" (bug)
vec!["--collection-id", "{event.collection_id}"]
);
}
#[test]
fn test_compile_clip_url() {
let registry = ToolRegistry {
tools: vec![ToolDef {
binary: "synops-clip".into(),
aliases: vec!["clip".into(), "klipp".into(), "hent artikkel".into(), "clip url".into()],
description: "Hent og parse webartikler".into(),
args_hints: HashMap::from([
("url".into(), "--url {arg}".into()),
("fra event".into(), "--url {event.url}".into()),
("lagre node".into(), "--write".into()),
("bruker".into(), "--created-by {event.created_by}".into()),
("med timeout".into(), "--timeout {arg}".into()),
("force playwright".into(), "--playwright".into()),
]),
}],
};
// Test: "clip fra event (lagre node, bruker)"
let script = "1. clip fra event (lagre node, bruker)\n";
let parsed = parse(script).unwrap();
let result = compile(&parsed, &registry);
assert!(
!result.has_errors(),
"clip fra event bør kompilere: {:?}",
result.diagnostics
);
let compiled = result.compiled.unwrap();
assert_eq!(compiled.steps[0].binary, "synops-clip");
assert_eq!(
compiled.steps[0].args,
vec!["--url", "{event.url}", "--write", "--created-by", "{event.created_by}"]
);
}
}

View file

@ -28,6 +28,10 @@ pub struct ExecutionContext {
pub cas_hash: Option<String>,
pub communication_id: Option<String>,
pub collection_id: Option<String>,
/// URL fra trigger-kontekst (f.eks. URL delt i chat)
pub url: Option<String>,
/// Bruker-ID som utløste eventet
pub created_by: Option<String>,
/// ID til oppstrøms orkestrering (ved kaskade via triggers-edge)
pub upstream_orchestration_id: Option<String>,
}
@ -47,6 +51,8 @@ impl ExecutionContext {
cas_hash: s("cas_hash"),
communication_id: s("communication_id"),
collection_id: s("collection_id"),
url: s("url"),
created_by: s("created_by"),
upstream_orchestration_id: s("upstream_orchestration_id"),
}
}
@ -67,6 +73,8 @@ impl ExecutionContext {
"event.cas_hash" => self.cas_hash.clone(),
"event.communication_id" => self.communication_id.clone(),
"event.collection_id" => self.collection_id.clone(),
"event.url" => self.url.clone(),
"event.created_by" => self.created_by.clone(),
"event.upstream_orchestration_id" => self.upstream_orchestration_id.clone(),
_ => None,
}
@ -450,6 +458,8 @@ mod tests {
cas_hash: Some("sha256:abc".into()),
communication_id: Some("comm-456".into()),
collection_id: Some("coll-789".into()),
url: Some("https://example.com/article".into()),
created_by: Some("user-999".into()),
upstream_orchestration_id: None,
};
@ -457,6 +467,8 @@ mod tests {
assert_eq!(ctx.substitute("{event.cas_hash}"), "sha256:abc");
assert_eq!(ctx.substitute("{event.communication_id}"), "comm-456");
assert_eq!(ctx.substitute("{event.collection_id}"), "coll-789");
assert_eq!(ctx.substitute("{event.url}"), "https://example.com/article");
assert_eq!(ctx.substitute("{event.created_by}"), "user-999");
// Ukjent variabel returneres uendret
assert_eq!(ctx.substitute("{event.unknown}"), "{event.unknown}");
// Ikke-variabel returneres uendret
@ -476,6 +488,8 @@ mod tests {
cas_hash: Some("sha256:abc".into()),
communication_id: None,
collection_id: None,
url: None,
created_by: None,
upstream_orchestration_id: None,
};
@ -501,6 +515,8 @@ mod tests {
cas_hash: None,
communication_id: None,
collection_id: None,
url: None,
created_by: None,
upstream_orchestration_id: None,
};

View file

@ -0,0 +1,33 @@
-- 026_cli_tool_synops_clip.sql
-- Oppgave 25.4: Registrer synops-clip som cli_tool-node for orkestreringer.
-- Gjør synops-clip tilgjengelig i script-kompilatoren slik at orkestreringer
-- kan skrive f.eks. "1. clip URL (lagre node)" og få det kompilert til
-- "synops-clip --url {event.url} --write".
--
-- Ref: docs/retninger/unix_filosofi.md, migrations/022_cli_tool_seeds.sql
BEGIN;
INSERT INTO nodes (id, node_kind, title, visibility, metadata, created_by)
VALUES (
'f0000000-c100-4000-b000-000000000016',
'cli_tool',
'synops-clip',
'discoverable',
'{
"binary": "synops-clip",
"aliases": ["clip", "klipp", "hent artikkel", "clip url"],
"description": "Hent og parse webartikler med Readability, opprett content-node med AI-oppsummering",
"args_hints": {
"url": "--url {arg}",
"fra event": "--url {event.url}",
"lagre node": "--write",
"bruker": "--created-by {event.created_by}",
"med timeout": "--timeout {arg}",
"force playwright": "--playwright"
}
}'::jsonb,
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'
);
COMMIT;

View file

@ -337,8 +337,7 @@ Readability, og oppretter innholdsnode med AI-beriking. Brukes av @bot i chat
- [x] 25.1 `synops-clip` CLI: hent URL, parse med Readability (mozilla/readability via JS eller Rust-port), returner ren tekst + metadata (tittel, forfatter, dato, ingress). Fallback til headless browser (Playwright) for JS-rendrede sider. Detekter betalingsmur (kort/avkuttet innhold, "logg inn for å lese", kjente paywall-mønstre) — returner `"paywall": true` og tilgjengelig innhold (ingress/utdrag). Output: JSON med `title`, `author`, `date`, `content`, `url`, `paywall`.
- [x] 25.2 Node-opprettelse: `synops-clip --write` oppretter `content`-node med artikkelinnhold, `metadata.source_url`, og `tagged`-edge "clipped". AI-oppsummering via LiteLLM. `mentions`-edges til gjenkjente entiteter i kunnskapsgrafen.
- [x] 25.3 @bot-integrasjon: bruker limer inn URL i chat → boten gjenkjenner URL, kaller `synops-clip`, presenterer oppsummering i chatten, oppretter node i bakgrunnen. Ved paywall: "Denne artikkelen er bak betalingsmur. Jeg fikk med tittel og ingress — lim inn innholdet om du vil dele resten."
- [~] 25.4 Orkestrering-støtte: `synops-clip` tilgjengelig som verktøy i orkestreringer. F.eks. "Clip alle URL-er som deles i #Redaksjonen og oppsummer dem".
> Påbegynt: 2026-03-18T18:45
- [x] 25.4 Orkestrering-støtte: `synops-clip` tilgjengelig som verktøy i orkestreringer. F.eks. "Clip alle URL-er som deles i #Redaksjonen og oppsummer dem".
## Fase 26: Epost — send og motta via synops.no