synops/tools/synops-respond/src/main.rs
vegard f22465d72b @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
2026-03-18 18:41:33 +00:00

592 lines
20 KiB
Rust

// synops-respond — Claude chat-svar for kommunikasjonsnoder.
//
// Henter meldingshistorikk og kontekst fra PG, bygger prompt,
// kaller claude CLI, og returnerer svartekst. Med --write:
// oppretter svar-node og edges i PG + logger ressursforbruk.
//
// PG NOTIFY-triggere sender sanntidsoppdateringer til klienter.
//
// 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")
// 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;
/// Claude chat-svar for kommunikasjonsnoder.
#[derive(Parser)]
#[command(name = "synops-respond", about = "Generer Claude-svar i en kommunikasjonsnode")]
struct Cli {
/// Kommunikasjonsnode-ID der samtalen foregår
#[arg(long)]
communication_id: Uuid,
/// Meldings-ID som trigget svaret
#[arg(long)]
message_id: Uuid,
/// Agent-node-ID (Claude sin node)
#[arg(long)]
agent_node_id: Uuid,
/// Avsender-node-ID (brukeren som sendte meldingen)
#[arg(long)]
sender_node_id: Uuid,
/// Jobb-ID fra maskinrommet (for ai_usage_log)
#[arg(long)]
job_id: Option<Uuid>,
/// Skriv svar-node og edges til database (uten: kun generering + stdout)
#[arg(long)]
write: bool,
}
// --- Database-rader ---
#[derive(sqlx::FromRow)]
struct MessageRow {
#[allow(dead_code)]
id: Uuid,
content: Option<String>,
created_by: Uuid,
#[allow(dead_code)]
created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(sqlx::FromRow)]
struct ParticipantRow {
id: Uuid,
title: Option<String>,
node_kind: String,
}
#[tokio::main]
async fn main() {
synops_common::logging::init("synops_respond");
let cli = Cli::parse();
if let Err(e) = run(cli).await {
eprintln!("Feil: {e}");
process::exit(1);
}
}
async fn run(cli: Cli) -> Result<(), String> {
let db = synops_common::db::connect().await?;
let communication_id = cli.communication_id;
let agent_node_id = cli.agent_node_id;
let sender_node_id = cli.sender_node_id;
// 1. Hent agent-config for max_context_messages
let config: serde_json::Value = sqlx::query_scalar(
"SELECT config FROM agent_identities WHERE node_id = $1",
)
.bind(agent_node_id)
.fetch_optional(&db)
.await
.map_err(|e| format!("DB-feil: {e}"))?
.unwrap_or(serde_json::json!({}));
let max_context_messages = config["max_context_messages"].as_i64().unwrap_or(50);
// 2. Hent meldingshistorikk
let mut messages = sqlx::query_as::<_, MessageRow>(
"SELECT n.id, n.content, n.created_by, n.created_at \
FROM nodes n JOIN edges e ON e.source_id = n.id \
WHERE e.target_id = $1 AND e.edge_type = 'belongs_to' AND n.node_kind = 'content' \
ORDER BY n.created_at DESC LIMIT $2",
)
.bind(communication_id)
.bind(max_context_messages)
.fetch_all(&db)
.await
.map_err(|e| format!("DB-feil: {e}"))?;
messages.reverse();
if messages.is_empty() {
let result = serde_json::json!({
"status": "skipped",
"reason": "no_messages"
});
println!("{}", serde_json::to_string_pretty(&result).unwrap());
return Ok(());
}
// 3. Hent kontekst: tittel, deltakere, tillatelser
let comm_title: String = sqlx::query_scalar::<_, Option<String>>(
"SELECT title FROM nodes WHERE id = $1",
)
.bind(communication_id)
.fetch_optional(&db)
.await
.map_err(|e| format!("DB-feil: {e}"))?
.flatten()
.unwrap_or_else(|| "Samtale".to_string());
let participants = sqlx::query_as::<_, ParticipantRow>(
"SELECT n.id, n.title, n.node_kind FROM nodes n \
JOIN edges e ON e.source_id = n.id \
WHERE e.target_id = $1 AND e.edge_type IN ('owner', 'member_of')",
)
.bind(communication_id)
.fetch_all(&db)
.await
.map_err(|e| format!("DB-feil: {e}"))?;
let permission: String = sqlx::query_scalar(
"SELECT permission FROM agent_permissions WHERE user_node_id = $1 AND agent_node_id = $2",
)
.bind(sender_node_id)
.bind(agent_node_id)
.fetch_optional(&db)
.await
.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()
.map(|p| (p.id, p.title.clone().unwrap_or_else(|| p.node_kind.clone())))
.collect();
let participant_names: String = participants
.iter()
.filter(|p| p.id != agent_node_id)
.map(|p| p.title.as_deref().unwrap_or("Ukjent"))
.collect::<Vec<_>>()
.join(", ");
let mut conversation = String::new();
for m in &messages {
let name = name_map.get(&m.created_by).map(|s| s.as_str()).unwrap_or("Ukjent");
let content = m.content.as_deref().unwrap_or("");
if m.created_by == agent_node_id {
conversation.push_str(&format!("Claude: {content}\n"));
} else {
conversation.push_str(&format!("{name}: {content}\n"));
}
}
let perm_desc = match permission.as_str() {
"direct" => "Brukeren har 'direct'-tilgang.",
"propose" => "Brukeren har 'propose'-tilgang.",
_ => "",
};
// Sjekk om chatten diskuterer en spec-node
let spec_context: String = match sqlx::query_scalar::<_, Option<String>>(
"SELECT n.content FROM nodes n \
JOIN edges e ON e.source_id = $1 AND e.target_id = n.id \
WHERE e.edge_type = 'discusses' LIMIT 1",
)
.bind(communication_id)
.fetch_optional(&db)
.await
{
Ok(Some(Some(content))) if !content.is_empty() => {
let truncated = if content.len() > 4000 {
&content[..4000]
} else {
&content
};
format!(
"\n--- Gjeldende spesifikasjon ---\n{truncated}\n--- Slutt spesifikasjon ---\n\n\
Du har tilgang til spesifikasjonen over. Gi konkret feedback: hva er implementert, \
hva er planlagt, hva er teknisk vanskelig. Vær ærlig om begrensninger.\n"
)
}
_ => String::new(),
};
let prompt = format!(
r#"Du er Claude, en AI-assistent integrert i Synops-plattformen.
Du deltar i samtalen "{comm_title}" med {participant_names}.
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}{clip_context}
--- Samtalehistorikk ---
{conversation}--- Svar ---"#
);
// 5. Kall claude CLI med retry
let claude_path = std::env::var("CLAUDE_PATH").unwrap_or_else(|_| "claude".to_string());
let project_dir =
std::env::var("PROJECT_DIR").unwrap_or_else(|_| "/home/vegard/synops".to_string());
tracing::info!(prompt_len = prompt.len(), "Kaller claude CLI");
let response_text = call_claude(&claude_path, &project_dir, &prompt).await?;
if response_text.is_empty() {
return Err("Tom respons fra claude".to_string());
}
tracing::info!(response_len = response_text.len(), "Fikk svar fra claude");
// 6. Skriv til database hvis --write
let mut result = serde_json::json!({
"status": "completed",
"response_text": response_text,
"response_len": response_text.len(),
});
if cli.write {
let reply_node_id = write_to_db(
&db,
communication_id,
agent_node_id,
sender_node_id,
cli.job_id,
&response_text,
)
.await?;
result["reply_node_id"] = serde_json::Value::String(reply_node_id.to_string());
}
// 7. Output JSON til stdout
println!(
"{}",
serde_json::to_string_pretty(&result)
.map_err(|e| format!("JSON-serialisering feilet: {e}"))?
);
Ok(())
}
/// Kall claude CLI med retry ved API-feil (500/529).
async fn call_claude(
claude_path: &str,
project_dir: &str,
prompt: &str,
) -> Result<String, String> {
let max_retries = 3u32;
for attempt in 0..=max_retries {
let output = tokio::process::Command::new(claude_path)
.arg("-p")
.arg(prompt)
.arg("--output-format")
.arg("json")
.arg("--dangerously-skip-permissions")
.env("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
.current_dir(project_dir)
.output()
.await
.map_err(|e| format!("Kunne ikke starte claude: {e}"))?;
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let is_api_error = !output.status.success()
&& (stderr.contains("500")
|| stderr.contains("529")
|| stderr.contains("overloaded")
|| stderr.contains("Internal Server Error"));
if is_api_error && attempt < max_retries {
let delay = std::time::Duration::from_secs(2u64.pow(attempt + 1));
tracing::warn!(
attempt = attempt + 1,
delay_secs = delay.as_secs(),
"Claude API-feil, prøver igjen"
);
tokio::time::sleep(delay).await;
continue;
}
if !output.status.success() {
if is_api_error {
tracing::error!(
attempts = max_retries + 1,
"Claude API utilgjengelig etter alle forsøk"
);
return Ok(
"Beklager, jeg er midlertidig utilgjengelig — Anthropic sitt API svarer \
ikke akkurat nå. Prøv igjen om litt."
.to_string(),
);
}
return Err(format!(
"claude feilet ({}): {}",
output.status,
&stderr[..stderr.len().min(500)]
));
}
let text = match serde_json::from_str::<serde_json::Value>(&stdout) {
Ok(json) => json["result"].as_str().unwrap_or("").to_string(),
Err(_) => stdout.trim().to_string(),
};
return Ok(text);
}
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,
communication_id: Uuid,
agent_node_id: Uuid,
sender_node_id: Uuid,
job_id: Option<Uuid>,
response_text: &str,
) -> Result<Uuid, String> {
let reply_id = Uuid::now_v7();
let edge_id = Uuid::now_v7();
let metadata = serde_json::json!({});
// Svar-node
sqlx::query(
"INSERT INTO nodes (id, node_kind, content, visibility, metadata, created_by) \
VALUES ($1, 'content', $2, 'hidden'::visibility, $3, $4)",
)
.bind(reply_id)
.bind(response_text)
.bind(&metadata)
.bind(agent_node_id)
.execute(db)
.await
.map_err(|e| format!("PG insert svar-node feilet: {e}"))?;
tracing::info!(reply_node_id = %reply_id, "Svar-node opprettet");
// belongs_to-edge: svar → kommunikasjonsnode
sqlx::query(
"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by) \
VALUES ($1, $2, $3, 'belongs_to', '{}', false, $4)",
)
.bind(edge_id)
.bind(reply_id)
.bind(communication_id)
.bind(agent_node_id)
.execute(db)
.await
.map_err(|e| format!("PG insert belongs_to-edge feilet: {e}"))?;
// ai_usage_log
sqlx::query(
"INSERT INTO ai_usage_log \
(agent_node_id, communication_id, job_id, model_alias, model_actual, \
prompt_tokens, completion_tokens, total_tokens, job_type) \
VALUES ($1, $2, $3, 'claude-code', 'claude-code-cli', 0, 0, 0, 'agent_respond')",
)
.bind(agent_node_id)
.bind(communication_id)
.bind(job_id)
.execute(db)
.await
.map_err(|e| format!("PG insert ai_usage_log feilet: {e}"))?;
// resource_usage_log
let collection_id: Option<Uuid> = sqlx::query_scalar(
"SELECT e.target_id FROM edges e \
JOIN nodes n ON n.id = e.target_id \
WHERE e.source_id = $1 AND e.edge_type = 'belongs_to' AND n.node_kind = 'collection' \
LIMIT 1",
)
.bind(communication_id)
.fetch_optional(db)
.await
.ok()
.flatten();
if let Err(e) = sqlx::query(
"INSERT INTO resource_usage_log \
(target_node_id, triggered_by, collection_id, resource_type, detail) \
VALUES ($1, $2, $3, $4, $5)",
)
.bind(communication_id)
.bind(Some(sender_node_id))
.bind(collection_id)
.bind("ai")
.bind(serde_json::json!({
"model_level": "deep",
"model_id": "claude-code-cli",
"tokens_in": 0,
"tokens_out": 0,
"job_type": "agent_respond"
}))
.execute(db)
.await
{
tracing::warn!(error = %e, "Kunne ikke logge AI-ressursforbruk");
}
Ok(reply_id)
}