@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):
|
||||
- **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 <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
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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.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
|
||||
|
|
|
|||
13
tools/synops-respond/Cargo.lock
generated
13
tools/synops-respond/Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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<Uuid, String> = 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<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.
|
||||
async fn write_to_db(
|
||||
db: &sqlx::PgPool,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue