@bot URL-klipping i chat: synops-clip-integrasjon (oppgave 25.3)

Når en bruker limer inn en URL i chatten, gjenkjenner synops-respond
URL-en automatisk, kaller synops-clip --write for å hente, parse og
oppsummere artikkelen, og inkluderer resultatet i prompten slik at
Claude kan presentere oppsummeringen naturlig.

Ved betalingsmur: Claude informerer brukeren og ber om innlimt innhold.
Maks 3 URL-er per melding, 60s timeout per klipp.

Endringer:
- synops-respond: URL-deteksjon (regex), synops-clip-kall, prompt-kontekst
- maskinrommet/agent.rs: videresend env-variabler for synops-clip
- maskinrommet-env.sh: SYNOPS_CLIP_SCRIPTS env-variabel
- docs/infra/claude_agent.md: dokumentert URL-klipping-flyten
This commit is contained in:
vegard 2026-03-18 18:41:33 +00:00
parent 16ee209283
commit f22465d72b
7 changed files with 211 additions and 9 deletions

View file

@ -26,10 +26,31 @@ Bruker sender melding (via frontend)
``` ```
Ansvarsdeling (unix-filosofi): Ansvarsdeling (unix-filosofi):
- **Maskinrommet:** Auth, kill switch, rate limiting, loop-prevensjon - **Maskinrommet:** Auth, kill switch, rate limiting, loop-prevensjon, env-videresending
- **synops-respond:** Kontekst-henting, prompt-bygging, claude-kall, PG-skriving - **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 <bruker>
→ 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 ## Noder og tabeller

View file

@ -109,6 +109,13 @@ pub async fn handle_agent_respond(
cmd.env("PROJECT_DIR", v); 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()) cmd.stdout(Stdio::piped())
.stderr(Stdio::piped()); .stderr(Stdio::piped());

View file

@ -30,5 +30,6 @@ ELEVENLABS_API_KEY=$(read_env ELEVENLABS_API_KEY)
ELEVENLABS_DEFAULT_VOICE=$(read_env ELEVENLABS_DEFAULT_VOICE) ELEVENLABS_DEFAULT_VOICE=$(read_env ELEVENLABS_DEFAULT_VOICE)
ELEVENLABS_MODEL=$(read_env ELEVENLABS_MODEL) ELEVENLABS_MODEL=$(read_env ELEVENLABS_MODEL)
PROJECT_DIR=/home/vegard/synops PROJECT_DIR=/home/vegard/synops
SYNOPS_CLIP_SCRIPTS=/home/vegard/synops/tools/synops-clip/scripts
RUST_LOG=maskinrommet=debug,tower_http=debug RUST_LOG=maskinrommet=debug,tower_http=debug
EOF EOF

View file

@ -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.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.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." - [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."
> Påbegynt: 2026-03-18T18:35
- [ ] 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". - [ ] 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 ## Fase 26: Epost — send og motta via synops.no

View file

@ -1102,6 +1102,18 @@ dependencies = [
"bitflags", "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]] [[package]]
name = "regex-automata" name = "regex-automata"
version = "0.4.14" version = "0.4.14"
@ -1627,6 +1639,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"clap", "clap",
"regex",
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",

View file

@ -17,4 +17,5 @@ uuid = { version = "1", features = ["v7", "serde"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
regex = "1"
synops-common = { path = "../synops-common" } synops-common = { path = "../synops-common" }

View file

@ -9,14 +9,24 @@
// Auth, ratelimit, kill switch og loop-prevensjon forblir i maskinrommet. // Auth, ratelimit, kill switch og loop-prevensjon forblir i maskinrommet.
// //
// Miljøvariabler: // Miljøvariabler:
// DATABASE_URL — PostgreSQL-tilkobling (påkrevd) // DATABASE_URL — PostgreSQL-tilkobling (påkrevd)
// CLAUDE_PATH — Sti til claude CLI (default: "claude") // CLAUDE_PATH — Sti til claude CLI (default: "claude")
// PROJECT_DIR — Arbeidskatalog for claude (default: "/home/vegard/synops") // 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 // Erstatter: prosesseringslogikken i maskinrommet/src/agent.rs
// Ref: docs/retninger/unix_filosofi.md, docs/infra/claude_agent.md // Ref: docs/retninger/unix_filosofi.md, docs/infra/claude_agent.md
use clap::Parser; use clap::Parser;
use regex::Regex;
use std::process; use std::process;
use uuid::Uuid; use uuid::Uuid;
@ -154,6 +164,22 @@ async fn run(cli: Cli) -> Result<(), String> {
.map_err(|e| format!("DB-feil: {e}"))? .map_err(|e| format!("DB-feil: {e}"))?
.unwrap_or_else(|| "none".to_string()); .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 // 4. Bygg prompt
let name_map: std::collections::HashMap<Uuid, String> = participants let name_map: std::collections::HashMap<Uuid, String> = participants
.iter() .iter()
@ -216,7 +242,7 @@ Svar på norsk med mindre brukeren skriver på engelsk.
{perm_desc} {perm_desc}
Svar konsist. Bruk vanlig tekst uten markdown-overskrifter. Svar konsist. Bruk vanlig tekst uten markdown-overskrifter.
Svar KUN med meldingsteksten. Svar KUN med meldingsteksten.
{spec_context} {spec_context}{clip_context}
--- Samtalehistorikk --- --- Samtalehistorikk ---
{conversation}--- Svar ---"# {conversation}--- Svar ---"#
); );
@ -338,6 +364,140 @@ async fn call_claude(
Err("Alle forsøk brukt opp".to_string()) Err("Alle forsøk brukt opp".to_string())
} }
/// Ekstraher URL-er fra meldingstekst.
fn extract_urls(text: &str) -> Vec<String> {
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::<Vec<_>>()
.into_iter()
.collect::<std::collections::HashSet<_>>()
.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<String> = 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::<serde_json::Value>(&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. /// Opprett svar-node, belongs_to-edge, ai_usage_log og resource_usage_log i PG.
async fn write_to_db( async fn write_to_db(
db: &sqlx::PgPool, db: &sqlx::PgPool,