Implementer synops-search CLI-verktøy (oppgave 21.11)

Fulltekstsøk i noder via PostgreSQL tsvector/tsquery med norsk
tekstkonfigurasjon. Bruker eksisterende GIN-indeks fra migrasjon 011.
Støtter --kind-filter og --limit. Output: markdown med relevans-score
og utdrag (ts_headline med «»-markering).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 10:08:51 +00:00
parent 61a0e186be
commit cfec20f024
4 changed files with 187 additions and 3 deletions

View file

@ -254,8 +254,7 @@ kaller dem direkte. Samme verktøy, to brukere.
### Oppslag (Claude-verktøy) ### Oppslag (Claude-verktøy)
- [x] 21.10 `synops-context`: Hent kontekst for en samtale. Input: `--communication-id <uuid>`. Output: markdown med spec, deltakere, historikk, relaterte noder. - [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. - [x] 21.11 `synops-search`: Fulltekstsøk i grafen. Input: `<query> [--kind <node_kind>] [--limit N]`. Output: matchende noder med utdrag.
> Påbegynt: 2026-03-18T10:05
- [ ] 21.12 `synops-tasks`: Parse tasks.md og vis status. Input: `[--phase N] [--status todo|done|blocked]`. Output: formatert oppgaveliste. - [ ] 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. - [ ] 21.13 `synops-feature-status`: Sjekk feature-status. Input: `<feature_key>`. Output: spec-sammendrag, oppgavestatus, nylige commits, ubesvart feedback.
- [ ] 21.14 `synops-node`: Hent/vis en node med edges. Input: `<uuid> [--depth N] [--format json|md]`. Output: node-data med edges. - [ ] 21.14 `synops-node`: Hent/vis en node med edges. Input: `<uuid> [--depth N] [--format json|md]`. Output: node-data med edges.

View file

@ -17,6 +17,7 @@ eller maskinrommet-API. Ligger i PATH via symlink eller direkte kall.
| `synops-respond` | Claude chat-svar i kommunikasjonsnoder | Ferdig | | `synops-respond` | Claude chat-svar i kommunikasjonsnoder | Ferdig |
| `synops-prune` | Opprydding av gamle CAS-filer (TTL + disk-nødventil) | 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-context` | Hent kontekst for en samtale (deltakere, historikk, spec, relaterte noder) | Ferdig |
| `synops-search` | Fulltekstsøk i noder (title + content, norsk tsvector) | Ferdig |
## Konvensjoner ## Konvensjoner
- Navnekonvensjon: `synops-<verb>` (f.eks. `synops-context`) - Navnekonvensjon: `synops-<verb>` (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` Ref: `docs/infra/agent_api.md`
- ~~`synops-context`~~ — implementert (se tabell over) - ~~`synops-context`~~ — implementert (se tabell over)
- `synops-search <query>` — søk i grafen (noder + edges) - ~~`synops-search`~~ — implementert (se tabell over)
- `synops-tasks [--phase N] [--status S]` — oppgavestatus fra tasks.md - `synops-tasks [--phase N] [--status S]` — oppgavestatus fra tasks.md
- `synops-feature-status <key>` — implementeringsstatus for en feature - `synops-feature-status <key>` — implementeringsstatus for en feature
- ~~`synops-respond`~~ — implementert (se tabell over) - ~~`synops-respond`~~ — implementert (se tabell over)

View file

@ -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"] }

View file

@ -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<String>,
/// 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<String>,
visibility: String,
created_at: chrono::DateTime<chrono::Utc>,
rank: f32,
headline: Option<String>,
}
#[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(())
}