use std::collections::HashMap;
use axum::{extract::State, http::StatusCode, Extension, Json};
use gradatum_acl_policy::{AclDecision, AclOp};
use gradatum_core::error::GradatumError;
use gradatum_core::scope::VaultId;
use gradatum_core::trust::TrustContext;
use gradatum_embed::EmbedBackend;
use gradatum_search::rrf_fuse;
use gradatum_search::scoring::{composite_score, pagerank_factor, recency_factor};
use crate::api_v1::dto::{
AuthorEntry, GraphEdge, SearchHit, TagEntry, TraceEntry, VaultAuthorsResponse,
VaultContextRequest, VaultContextResponse, VaultEntry, VaultGraphRequest, VaultGraphResponse,
VaultLinksRequest, VaultLinksResponse, VaultListRequest, VaultListResponse, VaultReadRequest,
VaultReadResponse, VaultSearchRequest, VaultSearchResponse, VaultStatusResponse,
VaultTagsResponse, VaultTraceRequest, VaultTraceResponse,
};
use crate::state::AppState;
fn locus_for_tenant(tenant_id: &str) -> String {
format!("{}/main", tenant_id)
}
fn locus_for_section(tenant_id: &str, section: Option<&str>) -> String {
match section {
Some(s) => format!("{}/{}", tenant_id, s),
None => format!("{}/main", tenant_id),
}
}
#[allow(dead_code)] pub(crate) fn build_snippet(body: &str, max_chars: usize) -> String {
let end = body
.char_indices()
.nth(max_chars)
.map(|(i, _)| i)
.unwrap_or(body.len());
if end < body.len() {
format!("{}…", &body[..end])
} else {
body.to_string()
}
}
pub(crate) fn build_fts_query(query: &str) -> String {
let is_safe = |c: char| c.is_alphanumeric() || c == '_' || c == ' ';
let upper = query.to_uppercase();
let has_fts5_keyword = upper
.split_whitespace()
.any(|t| matches!(t, "AND" | "OR" | "NOT" | "NEAR"));
let needs_wrap = !query.chars().all(is_safe) || has_fts5_keyword;
if needs_wrap {
let escaped = query.replace('"', "\"\"").replace('\'', "''");
format!("\"{escaped}\"")
} else {
query.to_string()
}
}
pub async fn vault_search(
State(state): State<AppState>,
Extension(trust): Extension<TrustContext>,
Json(req): Json<VaultSearchRequest>,
) -> Result<Json<VaultSearchResponse>, StatusCode> {
if !trust.is_authenticated() {
return Err(StatusCode::UNAUTHORIZED);
}
let locus = locus_for_section(&req.tenant_id, req.section.as_deref());
if state.acl.evaluate(&trust, AclOp::Read, &locus) != AclDecision::Allow {
return Err(StatusCode::FORBIDDEN);
}
let query = req.query.trim();
if query.is_empty() {
return Ok(Json(VaultSearchResponse { items: vec![] }));
}
let limit = req.limit.unwrap_or(10).clamp(1, 50) as usize;
let vault_id = VaultId::new(&req.tenant_id);
let fts_query = build_fts_query(query);
let bm25_hits = state
.search
.search_fts_with_snippet(
&vault_id,
&fts_query,
limit * 2,
req.include_downgraded,
req.section.as_deref(),
)
.await
.map_err(|e| {
tracing::error!(err = %e, query = %query, "vault_search: search_fts_with_snippet failed");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let semantic_hits: Vec<(gradatum_core::identity::NoteId, f32)> =
if state.embedder.backend_kind() != EmbedBackend::Noop {
match state.embedder.embed(query).await {
Ok(query_emb) => state
.search
.search_semantic(
&req.tenant_id,
state.embedder.embedder_id(),
&query_emb,
limit * 2,
)
.await
.unwrap_or_else(|e| {
tracing::warn!(
err = %e,
query = %query,
"vault_search: search_semantic failed, BM25 only"
);
vec![]
}),
Err(e) => {
tracing::warn!(
err = %e,
query = %query,
"vault_search: embed() failed, BM25 only"
);
vec![]
}
}
} else {
vec![]
};
let bm25_for_rrf: Vec<(String, f64)> = bm25_hits
.iter()
.map(|h| (h.note_id.to_string(), h.bm25))
.collect();
let sem_for_rrf: Vec<(String, f32)> = semantic_hits
.iter()
.map(|(id, score)| (id.to_string(), *score))
.collect();
let bm25_map: HashMap<String, &gradatum_index::SearchHitRaw> = bm25_hits
.iter()
.map(|h| (h.note_id.to_string(), h))
.collect();
let rrf_buffer = (limit * 4).clamp(20, 200);
let mut fused = rrf_fuse(&bm25_for_rrf, &sem_for_rrf, 60.0, rrf_buffer);
for hit in &mut fused {
if let Some(bh) = bm25_map.get(&hit.note_id) {
hit.section = bh.section.clone();
hit.snippet = Some(bh.snippet.clone());
hit.title = bh.title.clone();
}
}
let now_ms = chrono::Utc::now().timestamp_millis();
let mut composite_hits: Vec<(gradatum_search::RrfHit, f64)> = Vec::with_capacity(fused.len());
for hit in fused {
let (created_ms, in_degree) = match state
.search
.get_note_created_and_indegree(&req.tenant_id, &hit.note_id)
.await
{
Ok(v) => v,
Err(GradatumError::NoteNotFound(_)) => {
tracing::debug!(
note_id = %hit.note_id,
"vault_search: note absente, fallback (now_ms, 0)"
);
(now_ms, 0u64)
}
Err(e) => {
tracing::error!(
err = %e,
note_id = %hit.note_id,
"vault_search: get_note_created_and_indegree storage error"
);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
let recency = recency_factor(created_ms, now_ms);
let pagerank = pagerank_factor(in_degree);
let composite = composite_score(hit.rrf_score, recency, pagerank);
composite_hits.push((hit, composite));
}
composite_hits.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let rerank_n = state.reranker.max_batch_size().min(20);
let final_hits: Vec<(gradatum_search::RrfHit, f32)> =
if state.reranker.requires_body() && !composite_hits.is_empty() && rerank_n > 0 {
let mut top_for_rerank: Vec<(gradatum_search::RrfHit, f64)> =
composite_hits.into_iter().take(rerank_n).collect();
let mut rerank_candidates: Vec<(String, String)> =
Vec::with_capacity(top_for_rerank.len());
for (hit, _composite) in &top_for_rerank {
let body = match state.search.get_note(&req.tenant_id, &hit.note_id).await {
Ok(Some(rec)) => rec.body_text,
Ok(None) => {
tracing::debug!(
note_id = %hit.note_id,
"vault_search reranker: note absente, body=\"\""
);
String::new()
}
Err(e) => {
tracing::warn!(
err = %e,
note_id = %hit.note_id,
"vault_search reranker: get_note storage error, body=\"\""
);
String::new()
}
};
rerank_candidates.push((hit.note_id.clone(), body));
}
let reranker = std::sync::Arc::clone(&state.reranker);
let query_owned = query.to_string();
let cand_clone = rerank_candidates.clone();
let rerank_start = std::time::Instant::now();
let rerank_result =
tokio::task::block_in_place(move || reranker.rerank(&query_owned, &cand_clone));
let rerank_elapsed = rerank_start.elapsed();
let scores: Vec<f32> = match rerank_result {
Ok(s) => {
tracing::info!(
rerank_n = top_for_rerank.len(),
elapsed_ms = rerank_elapsed.as_millis(),
"vault_search: reranker OK"
);
s
}
Err(e) => {
tracing::warn!(
err = %e,
"vault_search: reranker failed, falling back to composite order"
);
let n = top_for_rerank.len();
let denom = n as f32 + 1.0;
(0..n).map(|i| 1.0 - (i as f32) / denom).collect()
}
};
let mut zipped: Vec<(gradatum_search::RrfHit, f32)> = top_for_rerank
.drain(..)
.map(|(hit, _composite)| hit)
.zip(scores)
.collect();
zipped.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
zipped.truncate(limit);
zipped
} else {
composite_hits
.into_iter()
.take(limit)
.map(|(hit, composite)| (hit, composite as f32))
.collect()
};
let semantic_only_ids: Vec<String> = final_hits
.iter()
.filter(|(hit, _score)| hit.title.is_none() && hit.section.is_empty())
.map(|(hit, _score)| hit.note_id.clone())
.collect();
let title_section_map: HashMap<String, (Option<String>, String)> =
if semantic_only_ids.is_empty() {
HashMap::new()
} else {
state
.search
.get_titles_sections(&req.tenant_id, &semantic_only_ids)
.await
.unwrap_or_else(|e| {
tracing::warn!(
err = %e,
count = semantic_only_ids.len(),
"vault_search: get_titles_sections failed, semantic-only hits sans titre"
);
HashMap::new()
})
};
let items: Vec<SearchHit> = final_hits
.into_iter()
.map(|(mut hit, score)| {
if hit.title.is_none() || hit.section.is_empty() {
if let Some((fetched_title, fetched_section)) = title_section_map.get(&hit.note_id)
{
if hit.title.is_none() {
hit.title = fetched_title.clone();
}
if hit.section.is_empty() {
hit.section = fetched_section.clone();
}
}
}
let section = if hit.section.is_empty() {
"main".to_string()
} else {
hit.section
};
SearchHit {
path: format!("{}/{}", section, hit.note_id),
score,
title: hit.title,
snippet: hit.snippet,
}
})
.collect();
Ok(Json(VaultSearchResponse { items }))
}
pub async fn vault_read(
State(state): State<AppState>,
Extension(trust): Extension<TrustContext>,
Json(req): Json<VaultReadRequest>,
) -> Result<Json<VaultReadResponse>, StatusCode> {
if !trust.is_authenticated() {
return Err(StatusCode::UNAUTHORIZED);
}
let locus = locus_for_section(&req.tenant_id, req.section.as_deref());
if state.acl.evaluate(&trust, AclOp::Read, &locus) != AclDecision::Allow {
return Err(StatusCode::FORBIDDEN);
}
let resolved_path: String = if ulid::Ulid::from_string(&req.path).is_ok() {
req.path.clone()
} else {
match state.search.title_lookup(&req.tenant_id, &req.path).await {
Ok(Some(found_id)) => {
tracing::debug!(
title = %req.path,
resolved_id = %found_id,
"vault_read: titre résolu via title_lookup"
);
found_id
}
Ok(None) => {
tracing::debug!(path = %req.path, "vault_read: titre non trouvé (live)");
return Err(StatusCode::NOT_FOUND);
}
Err(e) => {
tracing::error!(err = %e, path = %req.path, "vault_read: title_lookup failed");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
}
};
match state.vault.read_note_by_id(&resolved_path).await {
Ok(note) => {
let body = note.body.markdown;
let size_bytes = body.len() as u64;
let sha256: String = note
.content_hash
.0
.iter()
.map(|b| format!("{b:02x}"))
.collect();
Ok(Json(VaultReadResponse {
path: note.id.to_string(),
content: body,
metadata: Some(serde_json::json!({
"section": note.frontmatter.section.to_string(),
"status": note.frontmatter.status.to_string(),
"author": note.frontmatter.author.as_ref().map(|a| a.id.as_str()),
"tags": note.frontmatter.tags.iter().map(|t| t.as_str()).collect::<Vec<_>>(),
"vault_id": note.frontmatter.vault_id.as_str(),
"created": note.frontmatter.created.timestamp_millis(),
"updated": note.frontmatter.updated.map(|d| d.timestamp_millis()),
})),
size_bytes,
sha256,
}))
}
Err(GradatumError::NoteNotFound(_)) => Err(StatusCode::NOT_FOUND),
Err(GradatumError::Storage(ref msg)) if msg.contains("ULID invalide") => {
Err(StatusCode::NOT_FOUND)
}
Err(e) => {
tracing::error!(err = %e, note_id = %resolved_path, "vault_read: read_note_by_id failed");
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
pub async fn vault_list(
State(state): State<AppState>,
Extension(trust): Extension<TrustContext>,
Json(req): Json<VaultListRequest>,
) -> Result<Json<VaultListResponse>, StatusCode> {
if !trust.is_authenticated() {
return Err(StatusCode::UNAUTHORIZED);
}
let locus = locus_for_section(&req.tenant_id, req.section.as_deref());
if state.acl.evaluate(&trust, AclOp::Read, &locus) != AclDecision::Allow {
return Err(StatusCode::FORBIDDEN);
}
let _ = req.pattern;
let limit = req.limit.unwrap_or(20).clamp(1, 200) as usize;
let (records, total) = state
.search
.list_notes(
&req.tenant_id,
req.section.as_deref(),
limit,
req.cursor.as_deref(),
)
.await
.map_err(|e| {
tracing::error!(err = %e, "vault_list: list_notes failed");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let next_cursor = if records.len() == limit {
records.last().map(|r| r.id.clone())
} else {
None
};
let entries: Vec<VaultEntry> = records
.into_iter()
.map(|r| {
let modified_at = {
let ms = r.updated.unwrap_or(r.created);
chrono::DateTime::from_timestamp_millis(ms)
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
.unwrap_or_default()
};
VaultEntry {
path: format!("{}/{}", r.section, r.id),
size_bytes: r.body_text.len() as u64,
modified_at,
}
})
.collect();
Ok(Json(VaultListResponse {
entries,
next_cursor,
total,
}))
}
pub async fn vault_status(
State(state): State<AppState>,
Extension(trust): Extension<TrustContext>,
) -> Result<Json<VaultStatusResponse>, StatusCode> {
if !trust.is_authenticated() {
return Err(StatusCode::UNAUTHORIZED);
}
let locus = locus_for_tenant("main");
if state.acl.evaluate(&trust, AclOp::Read, &locus) != AclDecision::Allow {
return Err(StatusCode::FORBIDDEN);
}
let note_count = state.search.live_note_count("main").await.unwrap_or(0);
let total_size_bytes = state
.search
.total_body_size_bytes("main")
.await
.unwrap_or(0);
Ok(Json(VaultStatusResponse {
tenant_id: "main".to_string(),
note_count,
total_size_bytes,
index_version: "v1".to_string(),
last_indexed_at: None,
health: "healthy".to_string(),
}))
}
pub async fn vault_graph(
State(state): State<AppState>,
Extension(trust): Extension<TrustContext>,
Json(req): Json<VaultGraphRequest>,
) -> Result<Json<VaultGraphResponse>, StatusCode> {
if !trust.is_authenticated() {
return Err(StatusCode::UNAUTHORIZED);
}
let locus = locus_for_tenant(&req.tenant_id);
if state.acl.evaluate(&trust, AclOp::Read, &locus) != AclDecision::Allow {
return Err(StatusCode::FORBIDDEN);
}
let depth = req.depth.unwrap_or(2).min(3) as u8;
let neighbors = state
.search
.neighbors(&req.tenant_id, &req.root, depth)
.await
.map_err(|e| {
tracing::error!(err = %e, "vault_graph: neighbors failed");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let mut edges: Vec<GraphEdge> = neighbors
.iter()
.map(|n| GraphEdge {
from: req.root.clone(),
to: n.clone(),
kind: "wikilink".to_string(),
})
.collect();
let mut nodes: Vec<String> = neighbors;
nodes.push(req.root.clone());
nodes.sort();
nodes.dedup();
if req.include_backlinks.unwrap_or(false) {
let backlinks = state
.search
.backlinks(&req.tenant_id, &req.root)
.await
.map_err(|e| {
tracing::error!(err = %e, "vault_graph: backlinks failed");
StatusCode::INTERNAL_SERVER_ERROR
})?;
for bl in &backlinks {
edges.push(GraphEdge {
from: bl.clone(),
to: req.root.clone(),
kind: "wikilink".to_string(),
});
if !nodes.contains(bl) {
nodes.push(bl.clone());
}
}
nodes.sort();
nodes.dedup();
}
Ok(Json(VaultGraphResponse { nodes, edges }))
}
pub async fn vault_links(
State(state): State<AppState>,
Extension(trust): Extension<TrustContext>,
Json(req): Json<VaultLinksRequest>,
) -> Result<Json<VaultLinksResponse>, StatusCode> {
if !trust.is_authenticated() {
return Err(StatusCode::UNAUTHORIZED);
}
let locus = locus_for_tenant(&req.tenant_id);
if state.acl.evaluate(&trust, AclOp::Read, &locus) != AclDecision::Allow {
return Err(StatusCode::FORBIDDEN);
}
let outbound = state
.search
.neighbors(&req.tenant_id, &req.path, 1)
.await
.map_err(|e| {
tracing::error!(err = %e, "vault_links: neighbors failed");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let mut edges: Vec<GraphEdge> = outbound
.iter()
.map(|n| GraphEdge {
from: req.path.clone(),
to: n.clone(),
kind: "wikilink".to_string(),
})
.collect();
let mut nodes: Vec<String> = outbound;
nodes.push(req.path.clone());
if req.include_backlinks.unwrap_or(true) {
let backlinks = state
.search
.backlinks(&req.tenant_id, &req.path)
.await
.map_err(|e| {
tracing::error!(err = %e, "vault_links: backlinks failed");
StatusCode::INTERNAL_SERVER_ERROR
})?;
for bl in &backlinks {
edges.push(GraphEdge {
from: bl.clone(),
to: req.path.clone(),
kind: "wikilink".to_string(),
});
if !nodes.contains(bl) {
nodes.push(bl.clone());
}
}
}
nodes.sort();
nodes.dedup();
Ok(Json(VaultLinksResponse { nodes, edges }))
}
pub async fn vault_trace(
State(state): State<AppState>,
Extension(trust): Extension<TrustContext>,
Json(req): Json<VaultTraceRequest>,
) -> Result<Json<VaultTraceResponse>, StatusCode> {
if !trust.is_authenticated() {
return Err(StatusCode::UNAUTHORIZED);
}
let locus = locus_for_tenant(&req.tenant_id);
if state.acl.evaluate(&trust, AclOp::Read, &locus) != AclDecision::Allow {
return Err(StatusCode::FORBIDDEN);
}
let limit = req.limit.unwrap_or(20).clamp(1, 200) as usize;
let resolved_seeds: Vec<String> = if ulid::Ulid::from_string(&req.query).is_ok() {
vec![req.query.clone()]
} else {
match state.search.title_lookup(&req.tenant_id, &req.query).await {
Ok(Some(note_id)) => {
tracing::debug!(
title = %req.query,
id = %note_id,
"vault_trace: titre résolu via title_lookup"
);
vec![note_id]
}
Ok(None) => {
let fts_q = build_fts_query(&req.query);
if fts_q.trim_matches(['"', ' ']).is_empty() {
return Ok(Json(VaultTraceResponse { entries: vec![] }));
}
let vault_id = VaultId::new(&req.tenant_id);
let fts_limit = limit.min(5);
match state
.search
.search_fts_with_snippet(
&vault_id, &fts_q, fts_limit, false,
None,
)
.await
{
Ok(hits) => {
let ids: Vec<String> =
hits.into_iter().map(|h| h.note_id.to_string()).collect();
tracing::debug!(
query = %req.query,
seeds = ids.len(),
"vault_trace: FTS textuel — seeds trouvées"
);
ids
}
Err(e) => {
tracing::error!(
err = %e,
query = %req.query,
"vault_trace: search_fts_with_snippet failed"
);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
}
}
Err(e) => {
tracing::error!(err = %e, query = %req.query, "vault_trace: title_lookup failed");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
}
};
if resolved_seeds.is_empty() {
return Ok(Json(VaultTraceResponse { entries: vec![] }));
}
let mut join_set = tokio::task::JoinSet::new();
for seed_id in resolved_seeds {
let search = state.search.clone();
let tenant_id = req.tenant_id.clone();
join_set.spawn(async move {
search
.trace_lineage(&tenant_id, &seed_id)
.await
.map_err(|e| {
tracing::error!(
err = %e,
seed = %seed_id,
"vault_trace: trace_lineage failed"
);
StatusCode::INTERNAL_SERVER_ERROR
})
});
}
let mut all_ids: Vec<String> = Vec::new();
while let Some(join_result) = join_set.join_next().await {
let lineage = join_result.map_err(|e| {
tracing::error!(err = %e, "vault_trace: JoinSet task panicked");
StatusCode::INTERNAL_SERVER_ERROR
})??;
all_ids.extend(lineage.parents);
all_ids.extend(lineage.children);
}
let mut seen = std::collections::HashSet::new();
all_ids.retain(|id| seen.insert(id.clone()));
all_ids.truncate(limit);
let entries = all_ids
.into_iter()
.map(|id| TraceEntry {
path: id,
score: 1.0,
snippet: None,
tags: vec![],
})
.collect();
Ok(Json(VaultTraceResponse { entries }))
}
pub async fn vault_context(
State(state): State<AppState>,
Extension(trust): Extension<TrustContext>,
Json(req): Json<VaultContextRequest>,
) -> Result<Json<VaultContextResponse>, StatusCode> {
if !trust.is_authenticated() {
return Err(StatusCode::UNAUTHORIZED);
}
let locus = locus_for_section(&req.tenant_id, req.section.as_deref());
if state.acl.evaluate(&trust, AclOp::Read, &locus) != AclDecision::Allow {
return Err(StatusCode::FORBIDDEN);
}
let max_tokens = req.max_tokens.unwrap_or(2000).clamp(1, 8000) as usize;
let top_note_ids: Vec<String> = if ulid::Ulid::from_string(&req.query).is_ok() {
let backlinks = state
.search
.backlinks(&req.tenant_id, &req.query)
.await
.unwrap_or_default();
let mut ids = vec![req.query.clone()];
ids.extend(backlinks);
ids
} else {
let fts_q = build_fts_query(&req.query);
if fts_q.trim_matches(['"', ' ']).is_empty() {
return Ok(Json(VaultContextResponse {
context: String::new(),
estimated_tokens: 0,
sources: vec![],
}));
}
let vault_id = VaultId::new(&req.tenant_id);
match state
.search
.search_fts_with_snippet(
&vault_id,
&fts_q,
10,
false,
req.section.as_deref(),
)
.await
{
Ok(hits) => hits.into_iter().map(|h| h.note_id.to_string()).collect(),
Err(e) => {
tracing::error!(err = %e, "vault_context: search_fts_with_snippet failed");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
}
};
let mut context_parts: Vec<String> = Vec::new();
let mut sources: Vec<String> = Vec::new();
let mut used_tokens: usize = 0;
for note_id in &top_note_ids {
if used_tokens >= max_tokens {
break;
}
match state.search.get_note(&req.tenant_id, note_id).await {
Ok(Some(record)) => {
let note_chars = record.body_text.chars().count();
let note_tokens = note_chars.div_ceil(3).max(1);
let remaining = max_tokens.saturating_sub(used_tokens);
let body_part = if note_tokens > remaining {
let char_limit = remaining.saturating_mul(3);
let end = record
.body_text
.char_indices()
.nth(char_limit)
.map(|(i, _)| i)
.unwrap_or(record.body_text.len());
record.body_text[..end].to_string()
} else {
record.body_text.clone()
};
let consumed = body_part.chars().count().div_ceil(3).max(1);
context_parts.push(body_part);
sources.push(note_id.clone());
used_tokens = used_tokens.saturating_add(consumed);
}
Ok(None) => {
tracing::debug!(note_id = %note_id, "vault_context: note absente, ignorée");
}
Err(e) => {
tracing::warn!(err = %e, note_id = %note_id, "vault_context: get_note failed, ignoré");
}
}
}
let context = context_parts.join("\n\n---\n\n");
let estimated_tokens = (context.chars().count() / 3) as u32;
Ok(Json(VaultContextResponse {
context,
estimated_tokens,
sources,
}))
}
pub async fn vault_authors(
State(state): State<AppState>,
Extension(trust): Extension<TrustContext>,
) -> Result<Json<VaultAuthorsResponse>, StatusCode> {
if !trust.is_authenticated() {
return Err(StatusCode::UNAUTHORIZED);
}
let locus = locus_for_tenant("main");
if state.acl.evaluate(&trust, AclOp::Read, &locus) != AclDecision::Allow {
return Err(StatusCode::FORBIDDEN);
}
let rows = state.search.distinct_authors("main").await.map_err(|e| {
tracing::error!(err = %e, "vault_authors: distinct_authors failed");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let authors = rows
.into_iter()
.map(|r| AuthorEntry {
name: r.name,
note_count: r.note_count,
})
.collect();
Ok(Json(VaultAuthorsResponse { authors }))
}
pub async fn vault_tags(
State(state): State<AppState>,
Extension(trust): Extension<TrustContext>,
) -> Result<Json<VaultTagsResponse>, StatusCode> {
if !trust.is_authenticated() {
return Err(StatusCode::UNAUTHORIZED);
}
let locus = locus_for_tenant("main");
if state.acl.evaluate(&trust, AclOp::Read, &locus) != AclDecision::Allow {
return Err(StatusCode::FORBIDDEN);
}
let rows = state.search.distinct_tags("main").await.map_err(|e| {
tracing::error!(err = %e, "vault_tags: distinct_tags failed");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let tags = rows
.into_iter()
.map(|(tag, count)| TagEntry {
tag,
note_count: count,
})
.collect();
Ok(Json(VaultTagsResponse { tags }))
}
#[cfg(test)]
mod tests {
use super::{build_fts_query, build_snippet};
#[test]
fn snippet_utf8_boundary_char_safe() {
let body: String = "a".repeat(199) + "é" + &"b".repeat(10);
assert_eq!(body.len(), 211, "précondition longueur bytes");
assert_eq!(body.chars().count(), 210, "précondition longueur chars");
let snip = build_snippet(&body, 200);
let expected_chars = 199 + 1; let snip_without_ellipsis: &str = snip.trim_end_matches('…');
assert_eq!(
snip_without_ellipsis.chars().count(),
expected_chars,
"snippet doit contenir exactement 200 chars Unicode"
);
assert!(snip.ends_with('…'), "snippet doit se terminer par '…'");
assert!(
snip_without_ellipsis.ends_with('é'),
"le dernier char du snippet doit être 'é' entier"
);
}
#[test]
fn snippet_short_body_no_ellipsis() {
let body = "Ceci est un texte court avec des accents : éàü.";
let snip = build_snippet(body, 200);
assert_eq!(snip, body, "corps court doit être retourné intégral");
assert!(!snip.ends_with('…'), "pas d'ellipsis sur corps court");
}
#[test]
fn snippet_exact_200_ascii_no_ellipsis() {
let body: String = "x".repeat(200);
let snip = build_snippet(&body, 200);
assert_eq!(
snip, body,
"corps de 200 chars exact retourné sans ellipsis"
);
}
#[test]
fn snippet_emoji_boundary_char_safe() {
let body: String = "a".repeat(199) + "🦀" + &"z".repeat(10);
let snip = build_snippet(&body, 200);
assert!(
snip.ends_with('…'),
"snippet avec emoji doit avoir ellipsis"
);
}
#[test]
fn build_snippet_zwj_emoji_preserves_utf8_boundary() {
let body = "Famille 👨\u{200D}👩\u{200D}👧\u{200D}👦 explore le monde";
let snippet = build_snippet(body, 9);
assert!(
std::str::from_utf8(snippet.as_bytes()).is_ok(),
"snippet doit être UTF-8 valide : {:?}",
snippet
);
let last_char = snippet.trim_end_matches('…').chars().last();
if let Some(c) = last_char {
assert_ne!(
c as u32, 0x200D,
"snippet finit par ZWJ orphelin : {:?}",
snippet
);
}
}
#[test]
fn build_snippet_short_body_returns_full() {
let body = "court";
let snippet = build_snippet(body, 200);
assert_eq!(snippet, "court");
assert!(!snippet.contains('…'));
}
#[test]
fn build_snippet_long_body_truncates_with_ellipsis() {
let body = "a".repeat(300);
let snippet = build_snippet(&body, 200);
assert_eq!(
snippet.chars().count(),
201,
"snippet long : 200 chars + 1 ellipsis = 201 codepoints"
);
assert!(snippet.ends_with('…'));
}
#[test]
fn build_fts_query_dot_is_wrapped() {
let q = build_fts_query("2.1.1");
assert_eq!(
q, r#""2.1.1""#,
"query avec point doit être wrappée en phrase FTS5"
);
}
#[test]
fn build_fts_query_alpha_dot_is_wrapped() {
let q = build_fts_query("alpha.8");
assert_eq!(q, r#""alpha.8""#, "alpha.8 doit être wrappé");
}
#[test]
fn build_fts_query_apostrophe_is_doubled() {
let q = build_fts_query("O'Reilly");
assert_eq!(
q, r#""O''Reilly""#,
"apostrophe doit être doublée dans la phrase FTS5"
);
}
#[test]
fn build_fts_query_alphanumeric_not_wrapped() {
let q = build_fts_query("gradatum");
assert_eq!(
q, "gradatum",
"query alphanumérique ne doit pas être wrappée"
);
}
#[test]
fn build_fts_query_underscore_not_wrapped() {
let q = build_fts_query("vault_search");
assert_eq!(q, "vault_search", "underscore est safe, pas de wrap");
}
#[test]
fn build_fts_query_fts5_keyword_and_is_wrapped() {
let q = build_fts_query("gradatum AND notes");
assert_eq!(
q, r#""gradatum AND notes""#,
"AND keyword doit déclencher le wrap phrase"
);
}
#[test]
fn build_fts_query_fts5_keyword_not_is_wrapped() {
let q = build_fts_query("notes NOT debug");
assert_eq!(
q, r#""notes NOT debug""#,
"NOT keyword doit déclencher le wrap phrase"
);
}
#[test]
fn build_fts_query_internal_quotes_doubled() {
let q = build_fts_query(r#"say "hello""#);
assert_eq!(
q, r#""say ""hello""""#,
"guillemets internes doublés dans la phrase"
);
}
#[test]
fn build_fts_query_dash_and_dot_wrapped() {
let q = build_fts_query("phase-2.x");
assert_eq!(
q, r#""phase-2.x""#,
"tiret+point doivent déclencher le wrap"
);
}
#[test]
fn build_fts_query_accented_chars_not_wrapped() {
let q = build_fts_query("éàü gradatum");
assert_eq!(
q, "éàü gradatum",
"accents ne doivent pas déclencher le wrap"
);
}
#[test]
fn build_fts_query_empty_is_empty_string() {
let q = build_fts_query("");
assert_eq!(q, "", "query vide retourne chaîne vide");
}
#[test]
fn build_fts_query_near_with_parens_is_wrapped() {
let q = build_fts_query("NEAR(gradatum 5)");
assert!(
q.starts_with('"') && q.ends_with('"'),
"NEAR(gradatum 5) doit être wrappé — parens = char spécial FTS5"
);
}
#[test]
fn build_fts_query_frontmatter_colon_is_wrapped() {
let q = build_fts_query("section: reasoning");
assert!(
q.starts_with('"') && q.ends_with('"'),
"deux-points dans la query doivent déclencher le wrap (opérateur de colonne FTS5)"
);
}
#[test]
fn bm25_score_mapping_is_monotone_decreasing_in_zero_one_range() {
fn map(bm25_raw: f64) -> f32 {
(1.0_f64 / (1.0 + bm25_raw.abs())) as f32
}
let s_excellent = map(-0.1);
let s_good = map(-0.5);
let s_poor = map(-10.0);
assert!((s_excellent - 0.909).abs() < 0.01, "got {}", s_excellent);
assert!((s_good - 0.667).abs() < 0.01, "got {}", s_good);
assert!((s_poor - 0.091).abs() < 0.01, "got {}", s_poor);
assert!(s_excellent > s_good);
assert!(s_good > s_poor);
assert!((0.0..=1.0).contains(&s_excellent));
assert!(s_poor >= 0.0);
assert_eq!(map(0.0), 1.0_f32);
let s_terrible = map(-1000.0);
assert!(s_terrible < 0.01);
}
}