@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:
parent
16ee209283
commit
f22465d72b
7 changed files with 211 additions and 9 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
3
tasks.md
3
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.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
|
||||||
|
|
|
||||||
13
tools/synops-respond/Cargo.lock
generated
13
tools/synops-respond/Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue