diff --git a/docs/infra/claude_agent.md b/docs/infra/claude_agent.md index 5443aca..f14b396 100644 --- a/docs/infra/claude_agent.md +++ b/docs/infra/claude_agent.md @@ -26,10 +26,31 @@ Bruker sender melding (via frontend) ``` Ansvarsdeling (unix-filosofi): -- **Maskinrommet:** Auth, kill switch, rate limiting, loop-prevensjon -- **synops-respond:** Kontekst-henting, prompt-bygging, claude-kall, PG-skriving +- **Maskinrommet:** Auth, kill switch, rate limiting, loop-prevensjon, env-videresending +- **synops-respond:** Kontekst-henting, URL-deteksjon, synops-clip-kall, prompt-bygging, claude-kall, PG-skriving +- **synops-clip:** Artikkelhenting, parsing, paywall-deteksjon, AI-oppsummering, node-opprettelse -Latens: ~3-5 sekunder fra melding til svar. +Latens: ~3-5 sekunder uten URL, ~10-20 sekunder med URL (synops-clip + LLM-oppsummering). + +### URL-klipping i chat (oppgave 25.3) + +Når en bruker limer inn en URL i chatten, gjenkjenner `synops-respond` URL-en +og kaller `synops-clip --write` automatisk før Claude svarer: + +``` +Bruker: "Sjekk denne: https://nrk.no/artikkel" + → synops-respond detekterer URL med regex + → spawner: synops-clip --url ... --write --created-by + → synops-clip henter, parser, oppsummerer, oppretter node + → resultat (tittel, oppsummering, paywall) inkluderes i prompt + → Claude svarer med oppsummering basert på klipp-kontekst +``` + +Ved betalingsmur: Claude informerer om at artikkelen er bak betalingsmur, +presenterer det som er tilgjengelig (tittel/ingress), og ber brukeren +lime inn innholdet om de vil dele resten. + +Begrensninger: Maks 3 URL-er per melding. 60 sekunders timeout per klipp. ## Noder og tabeller diff --git a/maskinrommet/src/agent.rs b/maskinrommet/src/agent.rs index 612f512..dad6a8c 100644 --- a/maskinrommet/src/agent.rs +++ b/maskinrommet/src/agent.rs @@ -109,6 +109,13 @@ pub async fn handle_agent_respond( cmd.env("PROJECT_DIR", v); } + // Videresend env-variabler for synops-clip (URL-klipping i chat) + for key in &["AI_GATEWAY_URL", "LITELLM_MASTER_KEY", "SYNOPS_CLIP_SCRIPTS"] { + if let Ok(v) = std::env::var(key) { + cmd.env(key, v); + } + } + cmd.stdout(Stdio::piped()) .stderr(Stdio::piped()); diff --git a/scripts/maskinrommet-env.sh b/scripts/maskinrommet-env.sh index a818d98..97f7c37 100755 --- a/scripts/maskinrommet-env.sh +++ b/scripts/maskinrommet-env.sh @@ -30,5 +30,6 @@ ELEVENLABS_API_KEY=$(read_env ELEVENLABS_API_KEY) ELEVENLABS_DEFAULT_VOICE=$(read_env ELEVENLABS_DEFAULT_VOICE) ELEVENLABS_MODEL=$(read_env ELEVENLABS_MODEL) PROJECT_DIR=/home/vegard/synops +SYNOPS_CLIP_SCRIPTS=/home/vegard/synops/tools/synops-clip/scripts RUST_LOG=maskinrommet=debug,tower_http=debug EOF diff --git a/tasks.md b/tasks.md index fad0dff..509fe6b 100644 --- a/tasks.md +++ b/tasks.md @@ -336,8 +336,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. -- [~] 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." - > Påbegynt: 2026-03-18T18:35 +- [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". ## Fase 26: Epost — send og motta via synops.no diff --git a/tools/synops-respond/Cargo.lock b/tools/synops-respond/Cargo.lock index ffe126a..d8cad0b 100644 --- a/tools/synops-respond/Cargo.lock +++ b/tools/synops-respond/Cargo.lock @@ -1102,6 +1102,18 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -1627,6 +1639,7 @@ version = "0.1.0" dependencies = [ "chrono", "clap", + "regex", "serde", "serde_json", "sqlx", diff --git a/tools/synops-respond/Cargo.toml b/tools/synops-respond/Cargo.toml index 244dc76..248ece5 100644 --- a/tools/synops-respond/Cargo.toml +++ b/tools/synops-respond/Cargo.toml @@ -17,4 +17,5 @@ uuid = { version = "1", features = ["v7", "serde"] } chrono = { version = "0.4", features = ["serde"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +regex = "1" synops-common = { path = "../synops-common" } diff --git a/tools/synops-respond/src/main.rs b/tools/synops-respond/src/main.rs index 9f8e21b..af8ff38 100644 --- a/tools/synops-respond/src/main.rs +++ b/tools/synops-respond/src/main.rs @@ -9,14 +9,24 @@ // Auth, ratelimit, kill switch og loop-prevensjon forblir i maskinrommet. // // Miljøvariabler: -// DATABASE_URL — PostgreSQL-tilkobling (påkrevd) -// CLAUDE_PATH — Sti til claude CLI (default: "claude") -// PROJECT_DIR — Arbeidskatalog for claude (default: "/home/vegard/synops") +// DATABASE_URL — PostgreSQL-tilkobling (påkrevd) +// CLAUDE_PATH — Sti til claude CLI (default: "claude") +// PROJECT_DIR — Arbeidskatalog for claude (default: "/home/vegard/synops") +// AI_GATEWAY_URL — LiteLLM gateway (videresend til synops-clip) +// LITELLM_MASTER_KEY — API-nøkkel for LiteLLM (videresend til synops-clip) +// SYNOPS_CLIP_SCRIPTS — Sti til synops-clip scripts/ (videresend) +// SYNOPS_CLIP_BIN — Sti til synops-clip (default: "synops-clip") +// +// URL-klipping (oppgave 25.3): +// Når siste melding inneholder URL-er, kjøres synops-clip automatisk. +// Artikkelen hentes, oppsummeres og lagres som node. Resultatet inkluderes +// i prompten slik at Claude kan presentere oppsummeringen i chat. // // Erstatter: prosesseringslogikken i maskinrommet/src/agent.rs // Ref: docs/retninger/unix_filosofi.md, docs/infra/claude_agent.md use clap::Parser; +use regex::Regex; use std::process; use uuid::Uuid; @@ -154,6 +164,22 @@ async fn run(cli: Cli) -> Result<(), String> { .map_err(|e| format!("DB-feil: {e}"))? .unwrap_or_else(|| "none".to_string()); + // 3b. Sjekk om siste melding inneholder URL-er → kjør synops-clip + let clip_context = if let Some(last_msg) = messages.last() { + if last_msg.created_by != agent_node_id { + let urls = extract_urls(last_msg.content.as_deref().unwrap_or("")); + if !urls.is_empty() { + run_clips(&urls, sender_node_id).await + } else { + String::new() + } + } else { + String::new() + } + } else { + String::new() + }; + // 4. Bygg prompt let name_map: std::collections::HashMap = participants .iter() @@ -216,7 +242,7 @@ Svar på norsk med mindre brukeren skriver på engelsk. {perm_desc} Svar konsist. Bruk vanlig tekst uten markdown-overskrifter. Svar KUN med meldingsteksten. -{spec_context} +{spec_context}{clip_context} --- Samtalehistorikk --- {conversation}--- Svar ---"# ); @@ -338,6 +364,140 @@ async fn call_claude( Err("Alle forsøk brukt opp".to_string()) } +/// Ekstraher URL-er fra meldingstekst. +fn extract_urls(text: &str) -> Vec { + let re = Regex::new(r"https?://[^\s<>\)\]\}]+").unwrap(); + re.find_iter(text) + .map(|m| { + // Fjern etterfølgende tegnsetting som ofte henger på + let url = m.as_str().trim_end_matches(|c: char| matches!(c, '.' | ',' | ';' | ':' | '!' | '?')); + url.to_string() + }) + .collect::>() + .into_iter() + .collect::>() + .into_iter() + .collect() +} + +/// Sti til synops-clip-binæren. +fn clip_bin() -> String { + std::env::var("SYNOPS_CLIP_BIN") + .unwrap_or_else(|_| "synops-clip".to_string()) +} + +/// Kjør synops-clip for hver URL og bygg prompt-kontekst. +/// +/// Returnerer en streng som kan inkluderes i prompten med artikkeloppsummering +/// og instruksjoner for hvordan boten skal presentere resultatet. +async fn run_clips(urls: &[String], created_by: Uuid) -> String { + let bin = clip_bin(); + let mut results: Vec = Vec::new(); + + for url in urls.iter().take(3) { + tracing::info!(url = %url, "Kjører synops-clip for URL i melding"); + + let mut cmd = tokio::process::Command::new(&bin); + cmd.arg("--url").arg(url) + .arg("--write") + .arg("--created-by").arg(created_by.to_string()); + + // Videresend nødvendige miljøvariabler + if let Ok(v) = std::env::var("DATABASE_URL") { + cmd.env("DATABASE_URL", v); + } + if let Ok(v) = std::env::var("AI_GATEWAY_URL") { + cmd.env("AI_GATEWAY_URL", v); + } + if let Ok(v) = std::env::var("LITELLM_MASTER_KEY") { + cmd.env("LITELLM_MASTER_KEY", v); + } + if let Ok(v) = std::env::var("SYNOPS_CLIP_SCRIPTS") { + cmd.env("SYNOPS_CLIP_SCRIPTS", v); + } + + cmd.stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + match cmd.spawn() { + Ok(child) => { + match tokio::time::timeout( + std::time::Duration::from_secs(60), + child.wait_with_output(), + ) + .await + { + Ok(Ok(output)) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout); + match serde_json::from_str::(&stdout) { + Ok(json) => { + let title = json["title"].as_str().unwrap_or("(ukjent tittel)"); + let summary = json["summary"].as_str().unwrap_or(""); + let paywall = json["paywall"].as_bool().unwrap_or(false); + let node_id = json["node_id"].as_str().unwrap_or(""); + + if paywall { + results.push(format!( + "KLIPP ({url}): Betalingsmur detektert.\n\ + Tittel: {title}\n\ + Tilgjengelig innhold: {summary}\n\ + Node opprettet: {node_id}\n\ + INSTRUKSJON: Fortell brukeren at artikkelen er bak betalingsmur. \ + Du fikk med tittel og ingress. Be brukeren lime inn innholdet \ + om de vil dele resten." + )); + } else { + results.push(format!( + "KLIPP ({url}): Artikkel hentet og lagret.\n\ + Tittel: {title}\n\ + Oppsummering: {summary}\n\ + Node opprettet: {node_id}\n\ + INSTRUKSJON: Presenter en kort oppsummering av artikkelen \ + for brukeren. Nevn at den er lagret i Synops." + )); + } + + tracing::info!( + url = %url, + title = %title, + paywall = paywall, + node_id = %node_id, + "synops-clip fullført" + ); + } + Err(e) => { + tracing::warn!(url = %url, error = %e, "Kunne ikke parse synops-clip output"); + } + } + } + Ok(Ok(output)) => { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!(url = %url, stderr = %stderr, "synops-clip feilet"); + } + Ok(Err(e)) => { + tracing::warn!(url = %url, error = %e, "synops-clip prosessfeil"); + } + Err(_) => { + tracing::warn!(url = %url, "synops-clip tidsavbrutt (60s)"); + } + } + } + Err(e) => { + tracing::warn!(url = %url, error = %e, "Kunne ikke starte synops-clip"); + } + } + } + + if results.is_empty() { + return String::new(); + } + + format!( + "\n--- Web-klipp (automatisk hentet) ---\n{}\n--- Slutt web-klipp ---\n\n", + results.join("\n\n") + ) +} + /// Opprett svar-node, belongs_to-edge, ai_usage_log og resource_usage_log i PG. async fn write_to_db( db: &sqlx::PgPool,