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:
parent
61a0e186be
commit
cfec20f024
4 changed files with 187 additions and 3 deletions
3
tasks.md
3
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 <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.
|
||||
> Påbegynt: 2026-03-18T10:05
|
||||
- [x] 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.
|
||||
- [ ] 21.14 `synops-node`: Hent/vis en node med edges. Input: `<uuid> [--depth N] [--format json|md]`. Output: node-data med edges.
|
||||
|
|
|
|||
|
|
@ -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-<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`
|
||||
|
||||
- ~~`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-feature-status <key>` — implementeringsstatus for en feature
|
||||
- ~~`synops-respond`~~ — implementert (se tabell over)
|
||||
|
|
|
|||
19
tools/synops-search/Cargo.toml
Normal file
19
tools/synops-search/Cargo.toml
Normal 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"] }
|
||||
165
tools/synops-search/src/main.rs
Normal file
165
tools/synops-search/src/main.rs
Normal 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(())
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue