Implementer synops-context CLI-verktøy (oppgave 21.10)
Nytt verktøy som samler kontekst for en kommunikasjonsnode i markdown-format — deltakere, spesifikasjon (discusses-edge), meldingshistorikk og relaterte noder. Brukes av synops-respond og andre verktøy som trenger samtalekontekst. Input: --communication-id <uuid> [--max-messages N] Output: markdown til stdout Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
304b4a62fa
commit
b0faa6cf60
4 changed files with 312 additions and 3 deletions
3
tasks.md
3
tasks.md
|
|
@ -253,8 +253,7 @@ kaller dem direkte. Samme verktøy, to brukere.
|
|||
|
||||
### Oppslag (Claude-verktøy)
|
||||
|
||||
- [~] 21.10 `synops-context`: Hent kontekst for en samtale. Input: `--communication-id <uuid>`. Output: markdown med spec, deltakere, historikk, relaterte noder.
|
||||
> Påbegynt: 2026-03-18T09:59
|
||||
- [x] 21.10 `synops-context`: Hent kontekst for en samtale. Input: `--communication-id <uuid>`. Output: markdown med spec, deltakere, historikk, relaterte noder.
|
||||
- [ ] 21.11 `synops-search`: Fulltekstsøk i grafen. Input: `<query> [--kind <node_kind>] [--limit N]`. Output: matchende noder med utdrag.
|
||||
- [ ] 21.12 `synops-tasks`: Parse tasks.md og vis status. Input: `[--phase N] [--status todo|done|blocked]`. Output: formatert oppgaveliste.
|
||||
- [ ] 21.13 `synops-feature-status`: Sjekk feature-status. Input: `<feature_key>`. Output: spec-sammendrag, oppgavestatus, nylige commits, ubesvart feedback.
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ eller maskinrommet-API. Ligger i PATH via symlink eller direkte kall.
|
|||
| `synops-suggest-edges` | AI-foreslåtte edges (topics/mentions) for en node via LiteLLM | Ferdig |
|
||||
| `synops-respond` | Claude chat-svar i kommunikasjonsnoder | Ferdig |
|
||||
| `synops-prune` | Opprydding av gamle CAS-filer (TTL + disk-nødventil) | Ferdig |
|
||||
| `synops-context` | Hent kontekst for en samtale (deltakere, historikk, spec, relaterte noder) | Ferdig |
|
||||
|
||||
## Konvensjoner
|
||||
- Navnekonvensjon: `synops-<verb>` (f.eks. `synops-context`)
|
||||
|
|
@ -27,7 +28,7 @@ eller maskinrommet-API. Ligger i PATH via symlink eller direkte kall.
|
|||
## Planlagte verktøy
|
||||
Ref: `docs/infra/agent_api.md`
|
||||
|
||||
- `synops-context <communication_id>` — hent kontekst for en chat
|
||||
- ~~`synops-context`~~ — implementert (se tabell over)
|
||||
- `synops-search <query>` — søk i grafen (noder + edges)
|
||||
- `synops-tasks [--phase N] [--status S]` — oppgavestatus fra tasks.md
|
||||
- `synops-feature-status <key>` — implementeringsstatus for en feature
|
||||
|
|
|
|||
19
tools/synops-context/Cargo.toml
Normal file
19
tools/synops-context/Cargo.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "synops-context"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "synops-context"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
uuid = { version = "1", features = ["v7", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
290
tools/synops-context/src/main.rs
Normal file
290
tools/synops-context/src/main.rs
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
// 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() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "synops_context=info".parse().unwrap()),
|
||||
)
|
||||
.with_target(false)
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
|
||||
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_url = std::env::var("DATABASE_URL")
|
||||
.map_err(|_| "DATABASE_URL er ikke satt".to_string())?;
|
||||
|
||||
let db = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(2)
|
||||
.connect(&db_url)
|
||||
.await
|
||||
.map_err(|e| format!("Kunne ikke koble til database: {e}"))?;
|
||||
|
||||
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(())
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue