diff --git a/tasks.md b/tasks.md index 335d1ff..0b83a04 100644 --- a/tasks.md +++ b/tasks.md @@ -254,8 +254,7 @@ kaller dem direkte. Samme verktøy, to brukere. ### Oppslag (Claude-verktøy) - [x] 21.10 `synops-context`: Hent kontekst for en samtale. Input: `--communication-id `. Output: markdown med spec, deltakere, historikk, relaterte noder. -- [~] 21.11 `synops-search`: Fulltekstsøk i grafen. Input: ` [--kind ] [--limit N]`. Output: matchende noder med utdrag. - > Påbegynt: 2026-03-18T10:05 +- [x] 21.11 `synops-search`: Fulltekstsøk i grafen. Input: ` [--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: ``. Output: spec-sammendrag, oppgavestatus, nylige commits, ubesvart feedback. - [ ] 21.14 `synops-node`: Hent/vis en node med edges. Input: ` [--depth N] [--format json|md]`. Output: node-data med edges. diff --git a/tools/README.md b/tools/README.md index 7c6cb4a..4718f37 100644 --- a/tools/README.md +++ b/tools/README.md @@ -17,6 +17,7 @@ eller maskinrommet-API. Ligger i PATH via symlink eller direkte kall. | `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 | +| `synops-search` | Fulltekstsøk i noder (title + content, norsk tsvector) | Ferdig | ## Konvensjoner - Navnekonvensjon: `synops-` (f.eks. `synops-context`) @@ -29,7 +30,7 @@ eller maskinrommet-API. Ligger i PATH via symlink eller direkte kall. Ref: `docs/infra/agent_api.md` - ~~`synops-context`~~ — implementert (se tabell over) -- `synops-search ` — søk i grafen (noder + edges) +- ~~`synops-search`~~ — implementert (se tabell over) - `synops-tasks [--phase N] [--status S]` — oppgavestatus fra tasks.md - `synops-feature-status ` — implementeringsstatus for en feature - ~~`synops-respond`~~ — implementert (se tabell over) diff --git a/tools/synops-search/Cargo.toml b/tools/synops-search/Cargo.toml new file mode 100644 index 0000000..29d837a --- /dev/null +++ b/tools/synops-search/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "synops-search" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "synops-search" +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"] } diff --git a/tools/synops-search/src/main.rs b/tools/synops-search/src/main.rs new file mode 100644 index 0000000..805de89 --- /dev/null +++ b/tools/synops-search/src/main.rs @@ -0,0 +1,165 @@ +// synops-search — Fulltekstsøk i grafen. +// +// Bruker PostgreSQL sin tsvector/tsquery med norsk tekstkonfigurasjon +// for å søke i noder (title vektet A, content vektet B). +// Migration 011 setter opp search_vector-kolonnen og GIN-indeks. +// +// Output: matchende noder med relevans-score og utdrag til stdout. +// Logging: structured tracing til stderr. +// +// Miljøvariabler: +// DATABASE_URL — PostgreSQL-tilkobling (påkrevd) +// +// Ref: docs/retninger/unix_filosofi.md, migrations/011_fulltext_search.sql + +use clap::Parser; +use std::process; +use uuid::Uuid; + +/// Fulltekstsøk i grafen (noder). +#[derive(Parser)] +#[command(name = "synops-search", about = "Fulltekstsøk i noder")] +struct Cli { + /// Søkestreng (fritekst) + query: String, + + /// Filtrer på node_kind (f.eks. content, communication, task) + #[arg(long)] + kind: Option, + + /// Maks antall resultater (default: 20) + #[arg(long, default_value_t = 20)] + limit: i64, +} + +#[derive(sqlx::FromRow)] +struct SearchResult { + id: Uuid, + node_kind: String, + title: Option, + visibility: String, + created_at: chrono::DateTime, + rank: f32, + headline: Option, +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "synops_search=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 query = &cli.query; + + // Bygg SQL dynamisk basert på om --kind er satt + let results = if let Some(ref kind) = cli.kind { + sqlx::query_as::<_, SearchResult>( + "SELECT \ + n.id, \ + n.node_kind::text, \ + n.title, \ + n.visibility::text, \ + n.created_at, \ + ts_rank(n.search_vector, websearch_to_tsquery('norwegian', $1)) AS rank, \ + ts_headline('norwegian', COALESCE(n.content, ''), websearch_to_tsquery('norwegian', $1), \ + 'MaxWords=35, MinWords=15, MaxFragments=1, StartSel=«, StopSel=»') AS headline \ + FROM nodes n \ + WHERE n.search_vector @@ websearch_to_tsquery('norwegian', $1) \ + AND n.node_kind = $2 \ + ORDER BY rank DESC \ + LIMIT $3", + ) + .bind(query) + .bind(kind) + .bind(cli.limit) + .fetch_all(&db) + .await + .map_err(|e| format!("Søkefeil: {e}"))? + } else { + sqlx::query_as::<_, SearchResult>( + "SELECT \ + n.id, \ + n.node_kind::text, \ + n.title, \ + n.visibility::text, \ + n.created_at, \ + ts_rank(n.search_vector, websearch_to_tsquery('norwegian', $1)) AS rank, \ + ts_headline('norwegian', COALESCE(n.content, ''), websearch_to_tsquery('norwegian', $1), \ + 'MaxWords=35, MinWords=15, MaxFragments=1, StartSel=«, StopSel=»') AS headline \ + FROM nodes n \ + WHERE n.search_vector @@ websearch_to_tsquery('norwegian', $1) \ + ORDER BY rank DESC \ + LIMIT $2", + ) + .bind(query) + .bind(cli.limit) + .fetch_all(&db) + .await + .map_err(|e| format!("Søkefeil: {e}"))? + }; + + tracing::info!( + query = %query, + kind = cli.kind.as_deref().unwrap_or("alle"), + results = results.len(), + "Søk utført" + ); + + if results.is_empty() { + println!("Ingen treff for «{query}»."); + return Ok(()); + } + + // Bygg markdown-output + println!("# Søkeresultater for «{query}»\n"); + println!("{} treff:\n", results.len()); + + for (i, r) in results.iter().enumerate() { + let title = r.title.as_deref().unwrap_or("Uten tittel"); + let date = r.created_at.format("%Y-%m-%d %H:%M"); + + println!( + "{}. **{}** (`{}`)\n ID: `{}` | {} | {}\n Relevans: {:.3}", + i + 1, + title, + r.node_kind, + r.id, + r.visibility, + date, + r.rank, + ); + + if let Some(ref headline) = r.headline { + let hl = headline.trim(); + if !hl.is_empty() { + println!(" > {hl}"); + } + } + println!(); + } + + Ok(()) +}