// 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() { synops_common::logging::init("synops_search"); 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 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(()) }