synops/tools/synops-context/src/main.rs
vegard 6496434bd3 synops-common: delt lib for alle CLI-verktøy (oppgave 21.16)
Ny crate `tools/synops-common` samler duplisert kode som var
spredt over 13 CLI-verktøy:

- db::connect() — PG-pool fra DATABASE_URL (erstatter 10+ identiske blokker)
- cas::path() — CAS-stioppslag med to-nivå hash-katalog
- cas::root() — CAS_ROOT env med default
- cas::hash_bytes() / hash_file() / store() — SHA-256 hashing og lagring
- cas::mime_to_extension() — MIME → filendelse
- logging::init() — tracing til stderr med env-filter
- types::{NodeRow, EdgeRow, NodeSummary} — delte FromRow-structs

Alle verktøy (unntatt synops-tasks som ikke bruker DB) er refaktorert
til å bruke synops-common. Alle kompilerer og tester passerer.
2026-03-18 10:51:40 +00:00

276 lines
8.3 KiB
Rust

// synops-context — Hent kontekst for en samtale (kommunikasjonsnode).
//
// Samler all relevant informasjon om en samtale i ett markdown-dokument:
// tittel, deltakere, spesifikasjon (discusses-edge), meldingshistorikk,
// og relaterte noder. Designet for å gi Claude (eller andre konsumenter)
// komplett kontekst for å delta i en samtale.
//
// Output: markdown til stdout.
// Logging: structured tracing til stderr.
//
// Miljøvariabler:
// DATABASE_URL — PostgreSQL-tilkobling (påkrevd)
//
// Ref: docs/retninger/unix_filosofi.md, docs/infra/claude_agent.md
use clap::Parser;
use std::process;
use uuid::Uuid;
/// Hent kontekst for en kommunikasjonsnode (samtale).
#[derive(Parser)]
#[command(name = "synops-context", about = "Hent kontekst for en samtale")]
struct Cli {
/// Kommunikasjonsnode-ID
#[arg(long)]
communication_id: Uuid,
/// Maks antall meldinger i historikken (default: 50)
#[arg(long, default_value_t = 50)]
max_messages: i64,
}
// --- Database-rader ---
#[derive(sqlx::FromRow)]
struct NodeRow {
#[allow(dead_code)]
id: Uuid,
title: Option<String>,
#[allow(dead_code)]
content: Option<String>,
node_kind: String,
visibility: String,
metadata: Option<serde_json::Value>,
created_at: chrono::DateTime<chrono::Utc>,
#[allow(dead_code)]
created_by: Uuid,
}
#[derive(sqlx::FromRow)]
struct ParticipantRow {
id: Uuid,
title: Option<String>,
node_kind: String,
edge_type: String,
}
#[derive(sqlx::FromRow)]
struct MessageRow {
#[allow(dead_code)]
id: Uuid,
content: Option<String>,
created_by: Uuid,
created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(sqlx::FromRow)]
struct RelatedNodeRow {
#[allow(dead_code)]
id: Uuid,
title: Option<String>,
node_kind: String,
edge_type: String,
direction: String,
}
#[tokio::main]
async fn main() {
synops_common::logging::init("synops_context");
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 comm_id = cli.communication_id;
// 1. Hent kommunikasjonsnoden
let comm: NodeRow = sqlx::query_as(
"SELECT id, title, content, node_kind::text, visibility::text, metadata, created_at, created_by \
FROM nodes WHERE id = $1",
)
.bind(comm_id)
.fetch_optional(&db)
.await
.map_err(|e| format!("DB-feil: {e}"))?
.ok_or_else(|| format!("Kommunikasjonsnode {comm_id} finnes ikke"))?;
if comm.node_kind != "communication" {
return Err(format!(
"Node {comm_id} er '{}', ikke 'communication'",
comm.node_kind
));
}
// 2. Hent deltakere (owner, member_of, admin, reader)
let participants = sqlx::query_as::<_, ParticipantRow>(
"SELECT n.id, n.title, n.node_kind::text as node_kind, e.edge_type \
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', 'admin', 'reader')",
)
.bind(comm_id)
.fetch_all(&db)
.await
.map_err(|e| format!("DB-feil (deltakere): {e}"))?;
// 3. Hent spesifikasjon (discusses-edge)
let spec_content: Option<String> = 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(comm_id)
.fetch_optional(&db)
.await
.map_err(|e| format!("DB-feil (spec): {e}"))?
.flatten();
// 4. 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(comm_id)
.bind(cli.max_messages)
.fetch_all(&db)
.await
.map_err(|e| format!("DB-feil (meldinger): {e}"))?;
messages.reverse();
// 5. Hent relaterte noder (andre edges enn belongs_to og deltaker-edges)
let related = sqlx::query_as::<_, RelatedNodeRow>(
"SELECT n.id, n.title, n.node_kind::text as node_kind, e.edge_type, 'ut' as direction \
FROM nodes n JOIN edges e ON e.target_id = n.id \
WHERE e.source_id = $1 AND e.edge_type NOT IN ('owner', 'member_of', 'admin', 'reader') \
UNION ALL \
SELECT n.id, n.title, n.node_kind::text as node_kind, e.edge_type, 'inn' as direction \
FROM nodes n JOIN edges e ON e.source_id = n.id \
WHERE e.target_id = $1 AND e.edge_type NOT IN ('belongs_to', 'owner', 'member_of', 'admin', 'reader')",
)
.bind(comm_id)
.bind(comm_id)
.fetch_all(&db)
.await
.map_err(|e| format!("DB-feil (relaterte): {e}"))?;
// --- Bygg markdown ---
let mut md = String::new();
// Tittel og metadata
let title = comm.title.as_deref().unwrap_or("Uten tittel");
md.push_str(&format!("# {title}\n\n"));
md.push_str(&format!("- **Type:** {}\n", comm.node_kind));
md.push_str(&format!("- **Synlighet:** {}\n", comm.visibility));
md.push_str(&format!(
"- **Opprettet:** {}\n",
comm.created_at.format("%Y-%m-%d %H:%M")
));
if let Some(meta) = &comm.metadata {
if let Some(started) = meta.get("started_at").and_then(|v| v.as_str()) {
md.push_str(&format!("- **Startet:** {started}\n"));
}
if let Some(ended) = meta.get("ended_at").and_then(|v| v.as_str()) {
md.push_str(&format!("- **Avsluttet:** {ended}\n"));
}
}
// Deltakere
md.push_str("\n## Deltakere\n\n");
if participants.is_empty() {
md.push_str("_Ingen deltakere registrert._\n");
} else {
// Bygg navn-map for meldingshistorikken
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();
for p in &participants {
let name = p.title.as_deref().unwrap_or("Ukjent");
let role = match p.edge_type.as_str() {
"owner" => "eier",
"admin" => "admin",
"member_of" => "deltaker",
"reader" => "leser",
other => other,
};
md.push_str(&format!(
"- **{name}** ({}, {role})\n",
p.node_kind
));
}
// Spesifikasjon
if let Some(ref spec) = spec_content {
if !spec.is_empty() {
md.push_str("\n## Spesifikasjon\n\n");
md.push_str(spec);
md.push('\n');
}
}
// Meldingshistorikk
md.push_str(&format!(
"\n## Meldingshistorikk ({} meldinger)\n\n",
messages.len()
));
if messages.is_empty() {
md.push_str("_Ingen meldinger._\n");
} else {
for m in &messages {
let name = name_map
.get(&m.created_by)
.map(|s| s.as_str())
.unwrap_or("Ukjent");
let ts = m.created_at.format("%H:%M");
let content = m.content.as_deref().unwrap_or("");
md.push_str(&format!("**{name}** ({ts}):\n{content}\n\n"));
}
}
// Relaterte noder
if !related.is_empty() {
md.push_str("## Relaterte noder\n\n");
for r in &related {
let name = r.title.as_deref().unwrap_or("Uten tittel");
let arrow = match r.direction.as_str() {
"ut" => "",
"inn" => "",
_ => "-",
};
md.push_str(&format!(
"- {arrow} **{name}** ({}, {})\n",
r.node_kind, r.edge_type
));
}
}
}
tracing::info!(
communication_id = %comm_id,
participants = participants.len(),
messages = messages.len(),
related = related.len(),
"Kontekst generert"
);
print!("{md}");
Ok(())
}