1use std::collections::{BTreeSet, HashMap, HashSet};
34use std::sync::atomic::{AtomicBool, Ordering};
35use std::time::Instant;
36
37use tracing::{debug, info, info_span, warn};
38
39use super::ai::ner::{
40 AuthContext as NerAuthContext, HeuristicFallback, LlmNer, NerError, NerProvider, NER_CAPABILITY,
41};
42use super::statement_frame::{EffectiveScope, ReadFrame};
43use super::RedDBRuntime;
44use crate::api::{RedDBError, RedDBResult};
45use crate::application::SearchContextInput;
46use crate::storage::unified::entity::{EntityData, EntityKind, UnifiedEntity};
47use reddb_types::Value;
48
49pub const DEFAULT_ROW_CAP: usize = 20;
52
53#[derive(Debug, Clone, Default, PartialEq, Eq)]
55pub struct TokenSet {
56 pub keywords: Vec<String>,
59 pub literals: Vec<String>,
63}
64
65impl TokenSet {
66 pub fn is_empty(&self) -> bool {
67 self.keywords.is_empty() && self.literals.is_empty()
68 }
69}
70
71#[derive(Debug, Clone, Default)]
73pub struct CandidateCollections {
74 pub collections: Vec<String>,
77 pub columns_by_collection: HashMap<String, Vec<String>>,
81}
82
83#[derive(Debug, Clone)]
85pub struct TextHit {
86 pub collection: String,
87 pub entity_id: u64,
88 pub score: f32,
89}
90
91#[derive(Debug, Clone)]
94pub struct VectorHit {
95 pub collection: String,
96 pub entity_id: u64,
97 pub score: f32,
98}
99
100#[derive(Debug, Clone)]
102pub struct GraphHit {
103 pub collection: String,
104 pub entity_id: u64,
105 pub score: f32,
106 pub depth: usize,
107 pub kind: GraphHitKind,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum GraphHitKind {
112 Node,
113 Edge,
114}
115
116#[derive(Debug, Clone)]
118pub struct FilteredRow {
119 pub collection: String,
120 pub entity: UnifiedEntity,
121 pub matched_literal: String,
123 pub matched_column: Option<String>,
125}
126
127#[derive(Debug, Clone, Default, PartialEq, Eq)]
129pub struct StageTimings {
130 pub extract_us: u64,
131 pub schema_us: u64,
132 pub text_us: u64,
133 pub vector_us: u64,
134 pub graph_us: u64,
135 pub filter_us: u64,
136}
137
138#[derive(Debug, Clone)]
142pub struct AskContext {
143 pub question: String,
144 pub tokens: TokenSet,
145 pub candidates: CandidateCollections,
146 pub text_hits: Vec<TextHit>,
147 pub vector_hits: Vec<VectorHit>,
148 pub graph_hits: Vec<GraphHit>,
149 pub filtered_rows: Vec<FilteredRow>,
150 pub source_limit: usize,
151 pub timings: StageTimings,
152}
153
154impl Default for AskContext {
155 fn default() -> Self {
156 Self {
157 question: String::new(),
158 tokens: TokenSet::default(),
159 candidates: CandidateCollections::default(),
160 text_hits: Vec::new(),
161 vector_hits: Vec::new(),
162 graph_hits: Vec::new(),
163 filtered_rows: Vec::new(),
164 source_limit: DEFAULT_ROW_CAP,
165 timings: StageTimings::default(),
166 }
167 }
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub enum FusedSourceRef {
173 FilteredRow(usize),
174 TextHit(usize),
175 VectorHit(usize),
176 GraphHit(usize),
177}
178
179#[derive(Debug, Clone, Copy, PartialEq)]
181pub struct FusedSource {
182 pub source: FusedSourceRef,
183 pub rrf_score: f64,
184}
185
186pub enum AskPipeline {}
190
191impl AskPipeline {
192 pub fn execute(
194 runtime: &RedDBRuntime,
195 scope: &EffectiveScope,
196 question: &str,
197 ) -> RedDBResult<AskContext> {
198 Self::execute_with_limit(runtime, scope, question, DEFAULT_ROW_CAP)
199 }
200
201 pub fn execute_with_limit(
203 runtime: &RedDBRuntime,
204 scope: &EffectiveScope,
205 question: &str,
206 row_cap: usize,
207 ) -> RedDBResult<AskContext> {
208 Self::execute_with_limit_and_min_score(runtime, scope, question, row_cap, None, None)
209 }
210
211 pub fn execute_with_limit_and_min_score(
214 runtime: &RedDBRuntime,
215 scope: &EffectiveScope,
216 question: &str,
217 row_cap: usize,
218 min_score: Option<f32>,
219 graph_depth: Option<usize>,
220 ) -> RedDBResult<AskContext> {
221 let span = info_span!(
222 "ask_pipeline.execute",
223 tenant = ?scope.effective_scope(),
224 question_len = question.len(),
225 row_cap = row_cap,
226 min_score = ?min_score,
227 graph_depth = ?graph_depth,
228 );
229 let _enter = span.enter();
230
231 let stage1 = Instant::now();
236 let tokens = extract_tokens_routed(runtime, scope, question)?;
237 let extract_us = stage1.elapsed().as_micros() as u64;
238 debug!(
239 target: "ask_pipeline",
240 stage = "extract_tokens",
241 keywords = ?tokens.keywords,
242 literals = ?tokens.literals,
243 elapsed_us = extract_us,
244 "stage 1 done"
245 );
246 if tokens.is_empty() {
247 warn!(
248 target: "ask_pipeline",
249 question_len = question.len(),
250 "refused: empty token set"
251 );
252 return Err(RedDBError::Query(
253 "ASK question yielded no usable tokens (heuristic NER produced empty keyword + literal set)"
254 .to_string(),
255 ));
256 }
257
258 let stage2 = Instant::now();
260 let candidates = match_schema(runtime, scope, &tokens)?;
261 let schema_us = stage2.elapsed().as_micros() as u64;
262 debug!(
263 target: "ask_pipeline",
264 stage = "match_schema",
265 collections = ?candidates.collections,
266 elapsed_us = schema_us,
267 "stage 2 done"
268 );
269
270 let stage3 = Instant::now();
272 let text_hits = text_search_bm25_scoped(runtime, scope, question, &candidates, row_cap);
273 let text_us = stage3.elapsed().as_micros() as u64;
274 debug!(
275 target: "ask_pipeline",
276 stage = "text_search_bm25_scoped",
277 hits = text_hits.len(),
278 elapsed_us = text_us,
279 "stage 3 done"
280 );
281
282 let stage3b = Instant::now();
284 let vector_hits =
285 vector_search_scoped(runtime, scope, question, &candidates, row_cap, min_score);
286 let vector_us = stage3b.elapsed().as_micros() as u64;
287 debug!(
288 target: "ask_pipeline",
289 stage = "vector_search_scoped",
290 hits = vector_hits.len(),
291 elapsed_us = vector_us,
292 "stage 3b done"
293 );
294
295 let stage3c = Instant::now();
297 let graph_hits = graph_search_scoped(
298 runtime,
299 scope,
300 question,
301 &candidates,
302 row_cap,
303 min_score,
304 graph_depth,
305 );
306 let graph_us = stage3c.elapsed().as_micros() as u64;
307 debug!(
308 target: "ask_pipeline",
309 stage = "graph_search_scoped",
310 hits = graph_hits.len(),
311 elapsed_us = graph_us,
312 "stage 3c done"
313 );
314
315 let stage4 = Instant::now();
317 let filtered_rows = filter_values(runtime, scope, &candidates, &tokens, row_cap);
318 let filter_us = stage4.elapsed().as_micros() as u64;
319 debug!(
320 target: "ask_pipeline",
321 stage = "filter_values",
322 rows = filtered_rows.len(),
323 elapsed_us = filter_us,
324 "stage 4 done"
325 );
326
327 Ok(AskContext {
328 question: question.to_string(),
329 tokens,
330 candidates,
331 text_hits,
332 vector_hits,
333 graph_hits,
334 filtered_rows,
335 source_limit: row_cap,
336 timings: StageTimings {
337 extract_us,
338 schema_us,
339 text_us,
340 vector_us,
341 graph_us,
342 filter_us,
343 },
344 })
345 }
346}
347
348pub fn fused_source_order(ctx: &AskContext) -> Vec<FusedSourceRef> {
351 fused_sources(ctx)
352 .into_iter()
353 .map(|fused| fused.source)
354 .collect()
355}
356
357pub fn fused_sources(ctx: &AskContext) -> Vec<FusedSource> {
360 use super::ai::rrf_fuser::{fuse, Bucket, Candidate, RRF_K_DEFAULT};
361
362 if ctx.source_limit == 0
363 || (ctx.filtered_rows.is_empty()
364 && ctx.text_hits.is_empty()
365 && ctx.vector_hits.is_empty()
366 && ctx.graph_hits.is_empty())
367 {
368 return Vec::new();
369 }
370
371 let mut refs: HashMap<String, FusedSourceRef> = HashMap::new();
372 let row_bucket = Bucket {
373 candidates: ctx
374 .filtered_rows
375 .iter()
376 .enumerate()
377 .map(|(idx, row)| {
378 let id = source_identity(&row.collection, row.entity.id.raw());
379 refs.entry(id.clone())
380 .or_insert(FusedSourceRef::FilteredRow(idx));
381 Candidate { id, score: 1.0 }
382 })
383 .collect(),
384 min_score: None,
385 };
386 let text_bucket = Bucket {
387 candidates: ctx
388 .text_hits
389 .iter()
390 .enumerate()
391 .map(|(idx, hit)| {
392 let id = source_identity(&hit.collection, hit.entity_id);
393 refs.entry(id.clone())
394 .or_insert(FusedSourceRef::TextHit(idx));
395 Candidate {
396 id,
397 score: hit.score as f64,
398 }
399 })
400 .collect(),
401 min_score: None,
402 };
403 let vector_bucket = Bucket {
404 candidates: ctx
405 .vector_hits
406 .iter()
407 .enumerate()
408 .map(|(idx, hit)| {
409 let id = source_identity(&hit.collection, hit.entity_id);
410 refs.entry(id.clone())
411 .or_insert(FusedSourceRef::VectorHit(idx));
412 Candidate {
413 id,
414 score: hit.score as f64,
415 }
416 })
417 .collect(),
418 min_score: None,
419 };
420 let graph_bucket = Bucket {
421 candidates: ctx
422 .graph_hits
423 .iter()
424 .enumerate()
425 .map(|(idx, hit)| {
426 let id = source_identity(&hit.collection, hit.entity_id);
427 refs.entry(id.clone())
428 .or_insert(FusedSourceRef::GraphHit(idx));
429 Candidate {
430 id,
431 score: hit.score as f64,
432 }
433 })
434 .collect(),
435 min_score: None,
436 };
437
438 fuse(
439 &[row_bucket, text_bucket, vector_bucket, graph_bucket],
440 RRF_K_DEFAULT,
441 ctx.source_limit,
442 )
443 .into_iter()
444 .filter_map(|item| {
445 refs.get(&item.id).copied().map(|source| FusedSource {
446 source,
447 rrf_score: item.rrf_score,
448 })
449 })
450 .collect()
451}
452
453fn source_identity(collection: &str, entity_id: u64) -> String {
454 format!("{collection}/{entity_id}")
455}
456
457pub fn text_search_bm25_scoped(
465 runtime: &RedDBRuntime,
466 scope: &EffectiveScope,
467 question: &str,
468 candidates: &CandidateCollections,
469 top_k: usize,
470) -> Vec<TextHit> {
471 if candidates.collections.is_empty() || top_k == 0 {
472 return Vec::new();
473 }
474
475 let visible = scope.visible_collections();
476 let allowed: BTreeSet<String> = candidates
477 .collections
478 .iter()
479 .filter(|collection| visible.is_none_or(|set| set.contains(*collection)))
480 .cloned()
481 .collect();
482 if allowed.is_empty() {
483 return Vec::new();
484 }
485
486 let _scope_guard = AskScopeGuard::install(runtime, scope);
487 let snap_ctx = crate::runtime::impl_core::capture_current_snapshot();
488 let mut rls_cache: HashMap<String, Option<reddb_rql::ast::Filter>> = HashMap::new();
489 let store = runtime.inner.db.store();
490
491 runtime
492 .inner
493 .db
494 .store()
495 .context_index()
496 .search_bm25(question, top_k, Some(&allowed))
497 .into_iter()
498 .filter_map(|hit| {
499 let entity = store.get(&hit.collection, hit.entity_id)?;
500 if !ask_entity_allowed(
501 runtime,
502 scope,
503 &hit.collection,
504 &entity,
505 snap_ctx.as_ref(),
506 &mut rls_cache,
507 ) {
508 return None;
509 }
510 Some(TextHit {
511 collection: hit.collection,
512 entity_id: hit.entity_id.raw(),
513 score: hit.score,
514 })
515 })
516 .collect()
517}
518
519fn extract_tokens_routed(
544 runtime: &RedDBRuntime,
545 scope: &EffectiveScope,
546 question: &str,
547) -> RedDBResult<TokenSet> {
548 let backend = runtime.config_string("ai.ner.backend", "heuristic");
549 if backend != "llm" {
550 return Ok(extract_tokens(question));
551 }
552
553 let endpoint = runtime.config_string("ai.ner.endpoint", "");
554 let model = runtime.config_string("ai.ner.model", "");
555 let timeout_ms = runtime
556 .config_string("ai.ner.timeout_ms", "5000")
557 .parse::<u32>()
558 .unwrap_or(5000);
559 let fallback = match runtime
560 .config_string("ai.ner.fallback", "use_heuristic")
561 .as_str()
562 {
563 "empty_on_fail" => HeuristicFallback::EmptyOnFail,
564 "propagate" => HeuristicFallback::Propagate,
565 _ => HeuristicFallback::UseHeuristic,
566 };
567
568 let provider = if endpoint.is_empty() && model.is_empty() {
571 NerProvider::Stub(super::ai::ner::StubBehavior::Empty)
575 } else {
576 NerProvider::OpenAiCompat { endpoint, model }
577 };
578
579 let mut ner = LlmNer::new(provider, fallback);
580 ner.timeout_ms = timeout_ms;
581
582 let auth = ScopeAuthAdapter(scope);
583 let llm_result = match tokio::runtime::Handle::try_current() {
584 Ok(handle) => {
585 tokio::task::block_in_place(|| handle.block_on(ner.extract(question, scope, &auth)))
590 }
591 Err(_) => {
592 warn!(
593 target: "ask_pipeline",
594 "ai.ner.backend=llm configured but no Tokio runtime reachable from extract_tokens; using heuristic fallback"
595 );
596 return Ok(extract_tokens(question));
597 }
598 };
599
600 match llm_result {
601 Ok(tokens) => Ok(tokens),
602 Err(NerError::AuthDenied) => {
603 log_auth_denial_once();
604 apply_fallback(fallback, question)
609 }
610 Err(err) => {
611 warn!(
612 target: "ask_pipeline",
613 error = %err,
614 "LlmNer extract failed; honouring HeuristicFallback policy"
615 );
616 apply_fallback(fallback, question)
617 }
618 }
619}
620
621fn apply_fallback(fallback: HeuristicFallback, question: &str) -> RedDBResult<TokenSet> {
622 match fallback {
623 HeuristicFallback::UseHeuristic => Ok(extract_tokens(question)),
624 HeuristicFallback::EmptyOnFail => Ok(TokenSet::default()),
625 HeuristicFallback::Propagate => Err(RedDBError::Query(
626 "ai.ner.backend=llm: extract failed and ai.ner.fallback=propagate".to_string(),
627 )),
628 }
629}
630
631fn log_auth_denial_once() {
635 static EMITTED: AtomicBool = AtomicBool::new(false);
636 if !EMITTED.swap(true, Ordering::Relaxed) {
637 info!(
638 target: "ask_pipeline",
639 capability = NER_CAPABILITY,
640 "LlmNer routing configured but capability `{}` not yet wired into auth engine; falling back to heuristic",
641 NER_CAPABILITY
642 );
643 }
644}
645
646struct ScopeAuthAdapter<'a>(&'a EffectiveScope);
650
651impl<'a> std::fmt::Debug for ScopeAuthAdapter<'a> {
652 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
653 f.debug_struct("ScopeAuthAdapter").finish_non_exhaustive()
654 }
655}
656
657impl<'a> NerAuthContext for ScopeAuthAdapter<'a> {
658 fn has_capability(&self, capability: &str) -> bool {
659 self.0.has_capability(capability)
660 }
661}
662
663pub fn extract_tokens(question: &str) -> TokenSet {
674 let mut keywords: Vec<String> = Vec::new();
675 let mut literals: Vec<String> = Vec::new();
676
677 let mut chars = question.chars().peekable();
678 let mut buf = String::new();
679
680 let flush = |buf: &mut String, keywords: &mut Vec<String>, literals: &mut Vec<String>| {
681 if buf.is_empty() {
682 return;
683 }
684 let word = std::mem::take(buf);
685 classify_token(&word, keywords, literals);
686 };
687
688 while let Some(ch) = chars.next() {
689 if ch.is_alphanumeric() || ch == '_' || ch == '-' {
690 buf.push(ch);
691 } else {
692 flush(&mut buf, &mut keywords, &mut literals);
693 let _ = ch;
695 }
696 if chars.peek().is_none() {
698 flush(&mut buf, &mut keywords, &mut literals);
699 }
700 }
701 if !buf.is_empty() {
704 classify_token(&buf, &mut keywords, &mut literals);
705 }
706
707 let mut seen = HashSet::new();
709 keywords.retain(|tok| seen.insert(tok.clone()));
710 let mut seen_lit = HashSet::new();
711 literals.retain(|tok| seen_lit.insert(tok.clone()));
712
713 TokenSet { keywords, literals }
714}
715
716fn classify_token(word: &str, keywords: &mut Vec<String>, literals: &mut Vec<String>) {
717 let is_upper_id_shape = word.len() >= 3
720 && word
721 .chars()
722 .all(|c| c.is_ascii_digit() || c == '-' || c.is_ascii_uppercase())
723 && word.chars().any(|c| c.is_ascii_digit())
724 && word.chars().any(|c| c.is_ascii_uppercase() || c == '-');
725 let is_long_digit_run = word.len() >= 6 && word.chars().all(|c| c.is_ascii_digit());
726 if is_upper_id_shape || is_long_digit_run {
727 literals.push(word.to_string());
728 return;
729 }
730 let trimmed = word.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '_');
733 if trimmed.len() < 2 {
734 return;
735 }
736 if !trimmed
737 .chars()
738 .next()
739 .map(|c| c.is_ascii_alphabetic())
740 .unwrap_or(false)
741 {
742 return;
743 }
744 if !trimmed
745 .chars()
746 .all(|c| c.is_ascii_alphanumeric() || c == '_')
747 {
748 return;
751 }
752 let lower = trimmed.to_ascii_lowercase();
753 if STOP_WORDS.binary_search(&lower.as_str()).is_ok() {
754 return;
755 }
756 keywords.push(lower);
757}
758
759const STOP_WORDS: &[&str] = &[
762 "a", "about", "an", "and", "are", "as", "at", "be", "by", "do", "for", "from", "how", "in",
763 "is", "it", "of", "on", "or", "que", "qual", "quais", "sobre", "te", "the", "to", "what",
764 "where", "which", "with",
765];
766
767pub fn match_schema(
776 runtime: &RedDBRuntime,
777 scope: &EffectiveScope,
778 tokens: &TokenSet,
779) -> RedDBResult<CandidateCollections> {
780 let visible = match scope.visible_collections() {
781 Some(set) => set.clone(),
782 None => {
783 runtime
788 .inner
789 .db
790 .store()
791 .list_collections()
792 .into_iter()
793 .collect()
794 }
795 };
796
797 let mut collections: BTreeSet<String> = BTreeSet::new();
798 let mut columns_by_collection: HashMap<String, BTreeSet<String>> = HashMap::new();
799 for keyword in &tokens.keywords {
800 let hits = runtime.schema_vocabulary_lookup(keyword);
801 for hit in hits {
802 if !visible.contains(&hit.collection) {
803 continue;
804 }
805 collections.insert(hit.collection.clone());
806 if let Some(column) = hit.column {
807 columns_by_collection
808 .entry(hit.collection)
809 .or_default()
810 .insert(column);
811 }
812 }
813 }
814
815 Ok(CandidateCollections {
816 collections: collections.into_iter().collect(),
817 columns_by_collection: columns_by_collection
818 .into_iter()
819 .map(|(k, v)| (k, v.into_iter().collect()))
820 .collect(),
821 })
822}
823
824fn partial_top_k<T: Clone>(
833 items: &mut Vec<T>,
834 k: usize,
835 cmp: impl Fn(&T, &T) -> std::cmp::Ordering,
836) {
837 let n = items.len();
838 if k == 0 {
839 items.clear();
840 return;
841 }
842 if n <= k.saturating_mul(2) {
845 items.sort_by(|a, b| cmp(a, b));
846 items.truncate(k);
847 return;
848 }
849 let mut idxs: Vec<usize> = (0..n).collect();
850 idxs.select_nth_unstable_by(k - 1, |&a, &b| {
851 cmp(&items[a], &items[b]).then_with(|| a.cmp(&b))
852 });
853 idxs.truncate(k);
854 idxs.sort_by(|&a, &b| cmp(&items[a], &items[b]).then_with(|| a.cmp(&b)));
855 let orig = std::mem::take(items);
856 *items = idxs.into_iter().map(|i| orig[i].clone()).collect();
857}
858
859pub fn vector_search_scoped(
868 runtime: &RedDBRuntime,
869 scope: &EffectiveScope,
870 question: &str,
871 candidates: &CandidateCollections,
872 top_k: usize,
873 min_score: Option<f32>,
874) -> Vec<VectorHit> {
875 if candidates.collections.is_empty() {
876 return Vec::new();
877 }
878 let Some(embedding) = embed_question(runtime, question) else {
879 return Vec::new();
880 };
881 let per_collection = top_k.max(1);
882 let mut hits: Vec<VectorHit> = Vec::new();
883 let _scope_guard = AskScopeGuard::install(runtime, scope);
884 let snap_ctx = crate::runtime::impl_core::capture_current_snapshot();
885 let mut rls_cache: HashMap<String, Option<reddb_rql::ast::Filter>> = HashMap::new();
886 let store = runtime.inner.db.store();
887 for collection in &candidates.collections {
888 match super::authorized_search::AuthorizedSearch::execute_similar(
889 runtime,
890 scope,
891 collection,
892 &embedding,
893 per_collection,
894 min_score.unwrap_or(0.0),
895 ) {
896 Ok(results) => {
897 for result in results {
898 let Some(entity) = store.get(collection, result.entity_id) else {
899 continue;
900 };
901 if !ask_entity_allowed(
902 runtime,
903 scope,
904 collection,
905 &entity,
906 snap_ctx.as_ref(),
907 &mut rls_cache,
908 ) {
909 continue;
910 }
911 hits.push(VectorHit {
912 collection: collection.clone(),
913 entity_id: result.entity_id.raw(),
914 score: result.score,
915 });
916 }
917 }
918 Err(err) => {
919 debug!(
920 target: "ask_pipeline",
921 collection = collection,
922 err = %err,
923 "vector_search_scoped: collection skipped"
924 );
925 }
926 }
927 }
928 partial_top_k(&mut hits, top_k, |a, b| {
929 b.score
930 .partial_cmp(&a.score)
931 .unwrap_or(std::cmp::Ordering::Equal)
932 .then_with(|| a.entity_id.cmp(&b.entity_id))
933 });
934 hits
935}
936
937pub fn graph_search_scoped(
945 runtime: &RedDBRuntime,
946 scope: &EffectiveScope,
947 question: &str,
948 candidates: &CandidateCollections,
949 top_k: usize,
950 min_score: Option<f32>,
951 graph_depth: Option<usize>,
952) -> Vec<GraphHit> {
953 if candidates.collections.is_empty() || top_k == 0 {
954 return Vec::new();
955 }
956 let depth = graph_depth
957 .unwrap_or(crate::runtime::ai::mcp_ask_tool::DEPTH_DEFAULT as usize)
958 .max(1);
959 let _scope_guard = AskScopeGuard::install(runtime, scope);
960 let input = SearchContextInput {
961 query: question.to_string(),
962 field: None,
963 vector: None,
964 collections: Some(candidates.collections.clone()),
965 graph_depth: Some(depth),
966 graph_max_edges: None,
967 max_cross_refs: Some(0),
968 follow_cross_refs: Some(false),
969 expand_graph: Some(true),
970 global_scan: Some(true),
971 reindex: Some(false),
972 limit: Some(top_k),
973 min_score,
974 };
975 let result = match if scope.visible_collections().is_some() {
976 super::authorized_search::AuthorizedSearch::execute_context(runtime, scope, input)
977 } else {
978 runtime.search_context(input)
979 } {
980 Ok(result) => result,
981 Err(err) => {
982 debug!(
983 target: "ask_pipeline",
984 err = %err,
985 "graph_search_scoped: context search skipped"
986 );
987 return Vec::new();
988 }
989 };
990
991 let mut hits = Vec::new();
992 for entity in result.graph.nodes.into_iter().chain(result.graph.edges) {
993 let crate::runtime::DiscoveryMethod::GraphTraversal { depth, .. } = entity.discovery else {
994 continue;
995 };
996 let kind = match entity.entity.kind {
997 EntityKind::GraphNode(_) => GraphHitKind::Node,
998 EntityKind::GraphEdge(_) => GraphHitKind::Edge,
999 _ => continue,
1000 };
1001 hits.push(GraphHit {
1002 collection: entity.collection,
1003 entity_id: entity.entity.id.raw(),
1004 score: entity.score,
1005 depth,
1006 kind,
1007 });
1008 }
1009 partial_top_k(&mut hits, top_k, |a, b| {
1010 b.score
1011 .partial_cmp(&a.score)
1012 .unwrap_or(std::cmp::Ordering::Equal)
1013 .then_with(|| a.depth.cmp(&b.depth))
1014 .then_with(|| a.collection.cmp(&b.collection))
1015 .then_with(|| a.entity_id.cmp(&b.entity_id))
1016 });
1017 hits
1018}
1019
1020fn embed_question(runtime: &RedDBRuntime, question: &str) -> Option<Vec<f32>> {
1024 let kv_getter = |key: &str| -> RedDBResult<Option<String>> {
1025 match runtime.inner.db.get_kv("red_config", key) {
1026 Some((Value::Text(value), _)) => Ok(Some(value.to_string())),
1027 Some(_) => Ok(None),
1028 None => Ok(None),
1029 }
1030 };
1031 let provider = crate::ai::resolve_embeddings_provider(&kv_getter).ok()?;
1032 if !provider.is_openai_compatible() {
1033 return None;
1034 }
1035 let model = crate::ai::resolve_embeddings_model(&provider, &kv_getter);
1036 let crate::ai::AiConnection { api_key, api_base } =
1037 crate::ai::resolve_connection_from_runtime(&provider, None, runtime).ok()?;
1038 let transport = crate::runtime::ai::transport::AiTransport::from_runtime(runtime);
1039 let request = crate::ai::OpenAiEmbeddingRequest {
1040 api_key,
1041 model,
1042 inputs: vec![question.to_string()],
1043 dimensions: None,
1044 api_base,
1045 };
1046 let response = crate::runtime::ai::block_on_ai(async move {
1047 crate::ai::openai_embeddings_async(&transport, request).await
1048 })
1049 .and_then(|result| result)
1050 .ok()?;
1051 response.embeddings.into_iter().next()
1052}
1053
1054pub fn filter_values(
1063 runtime: &RedDBRuntime,
1064 scope: &EffectiveScope,
1065 candidates: &CandidateCollections,
1066 tokens: &TokenSet,
1067 row_cap: usize,
1068) -> Vec<FilteredRow> {
1069 if tokens.literals.is_empty() || candidates.collections.is_empty() {
1070 return Vec::new();
1071 }
1072 let visible = scope.visible_collections();
1073 let store = runtime.inner.db.store();
1074 let mut out: Vec<FilteredRow> = Vec::new();
1075 let _scope_guard = AskScopeGuard::install(runtime, scope);
1076 let snap_ctx = crate::runtime::impl_core::capture_current_snapshot();
1077 let mut rls_cache: HashMap<String, Option<reddb_rql::ast::Filter>> = HashMap::new();
1078
1079 'collection: for collection in &candidates.collections {
1080 if let Some(set) = visible {
1084 if !set.contains(collection) {
1085 continue;
1086 }
1087 }
1088 let Some(manager) = store.get_collection(collection) else {
1089 continue;
1090 };
1091 let hint_columns: &[String] = candidates
1092 .columns_by_collection
1093 .get(collection)
1094 .map(|v| v.as_slice())
1095 .unwrap_or(&[]);
1096
1097 for entity in manager.scan(snap_ctx.as_ref(), |_| true) {
1098 if !ask_scanned_entity_allowed(runtime, scope, collection, &entity, &mut rls_cache) {
1099 continue;
1100 }
1101 if let Some(hit) = literal_match_in_entity(&entity, &tokens.literals, hint_columns) {
1102 out.push(FilteredRow {
1103 collection: collection.clone(),
1104 entity,
1105 matched_literal: hit.0,
1106 matched_column: hit.1,
1107 });
1108 if out.len() >= row_cap {
1109 break 'collection;
1110 }
1111 }
1112 }
1113 }
1114 out
1115}
1116
1117fn ask_entity_allowed(
1118 runtime: &RedDBRuntime,
1119 scope: &EffectiveScope,
1120 collection: &str,
1121 entity: &UnifiedEntity,
1122 snap_ctx: Option<&crate::runtime::impl_core::SnapshotContext>,
1123 rls_cache: &mut HashMap<String, Option<reddb_rql::ast::Filter>>,
1124) -> bool {
1125 if scope
1126 .visible_collections()
1127 .is_some_and(|visible| !visible.contains(collection))
1128 {
1129 return false;
1130 }
1131 runtime.search_entity_allowed(collection, entity, snap_ctx, rls_cache)
1132}
1133
1134fn ask_scanned_entity_allowed(
1135 runtime: &RedDBRuntime,
1136 scope: &EffectiveScope,
1137 collection: &str,
1138 entity: &UnifiedEntity,
1139 rls_cache: &mut HashMap<String, Option<reddb_rql::ast::Filter>>,
1140) -> bool {
1141 if scope
1142 .visible_collections()
1143 .is_some_and(|visible| !visible.contains(collection))
1144 {
1145 return false;
1146 }
1147 runtime.search_entity_rls_allowed(collection, entity, rls_cache)
1148}
1149
1150pub(crate) struct AskScopeGuard {
1151 _snapshot: super::execution_context::CurrentSnapshotGuard,
1152 prev_tenant: Option<String>,
1153 prev_auth: Option<(String, crate::auth::Role)>,
1154 prev_principal: Option<crate::auth::UserId>,
1155}
1156
1157impl AskScopeGuard {
1158 pub(crate) fn install(runtime: &RedDBRuntime, scope: &EffectiveScope) -> Self {
1159 let prev_tenant = crate::runtime::impl_core::current_tenant();
1160 let prev_auth = crate::runtime::impl_core::current_auth_identity();
1161 let prev_principal = super::execution_context::current_auth_principal();
1162
1163 match scope.effective_scope() {
1164 Some(tenant) => crate::runtime::impl_core::set_current_tenant(tenant.to_string()),
1165 None => crate::runtime::impl_core::clear_current_tenant(),
1166 }
1167 crate::runtime::impl_core::clear_current_auth_identity();
1171 match (scope.auth_principal.as_ref(), scope.identity()) {
1172 (Some(principal), Some((user, role))) => {
1173 super::execution_context::set_current_auth_principal(principal.clone(), role);
1174 super::execution_context::set_current_auth_projection(user.to_string(), role);
1175 }
1176 (None, Some((user, role))) => {
1177 super::execution_context::set_current_auth_projection(user.to_string(), role)
1178 }
1179 _ => {}
1180 }
1181
1182 Self {
1183 _snapshot: super::execution_context::CurrentSnapshotGuard::install(
1184 scope.snapshot_context(runtime),
1185 ),
1186 prev_tenant,
1187 prev_auth,
1188 prev_principal,
1189 }
1190 }
1191}
1192
1193impl Drop for AskScopeGuard {
1194 fn drop(&mut self) {
1195 match self.prev_tenant.take() {
1196 Some(tenant) => crate::runtime::impl_core::set_current_tenant(tenant),
1197 None => crate::runtime::impl_core::clear_current_tenant(),
1198 }
1199 match (self.prev_principal.take(), self.prev_auth.take()) {
1200 (Some(principal), Some((_, role))) => {
1201 crate::runtime::impl_core::set_current_auth_principal(principal, role)
1202 }
1203 _ => crate::runtime::impl_core::clear_current_auth_identity(),
1204 }
1205 }
1206}
1207
1208fn literal_match_in_entity(
1211 entity: &UnifiedEntity,
1212 literals: &[String],
1213 hint_columns: &[String],
1214) -> Option<(String, Option<String>)> {
1215 let row = match &entity.data {
1216 EntityData::Row(row) => row,
1217 _ => return None,
1218 };
1219
1220 for column in hint_columns {
1222 if let Some(value) = row.get_field(column) {
1223 if let Some(lit) = first_literal_in_value(value, literals) {
1224 return Some((lit, Some(column.clone())));
1225 }
1226 }
1227 }
1228 for (name, value) in row.iter_fields() {
1230 if hint_columns.iter().any(|c| c == name) {
1231 continue;
1232 }
1233 if let Some(lit) = first_literal_in_value(value, literals) {
1234 return Some((lit, Some(name.to_string())));
1235 }
1236 }
1237 None
1238}
1239
1240fn first_literal_in_value(value: &Value, literals: &[String]) -> Option<String> {
1241 let rendered = match value {
1242 Value::Text(s) => s.to_string(),
1243 Value::Integer(i) => i.to_string(),
1244 Value::Float(f) => f.to_string(),
1245 Value::Boolean(b) => b.to_string(),
1246 Value::Json(j) => String::from_utf8_lossy(j).to_string(),
1247 _ => return None,
1248 };
1249 for lit in literals {
1250 if rendered.contains(lit) {
1254 return Some(lit.clone());
1255 }
1256 }
1257 None
1258}
1259
1260#[cfg(test)]
1261mod tests {
1262 use super::*;
1263
1264 #[test]
1267 fn extract_tokens_splits_keywords_and_literals() {
1268 let tokens = extract_tokens("quais as novidades sobre o passport FDD-12313?");
1269 assert!(tokens.keywords.contains(&"novidades".to_string()));
1272 assert!(tokens.keywords.contains(&"passport".to_string()));
1273 assert!(tokens.literals.contains(&"FDD-12313".to_string()));
1274 assert!(!tokens.is_empty());
1275 }
1276
1277 #[test]
1278 fn extract_tokens_returns_empty_for_punctuation_only() {
1279 let tokens = extract_tokens("??? ...");
1280 assert!(tokens.is_empty());
1281 }
1282
1283 #[test]
1284 fn extract_tokens_long_digit_run_is_a_literal() {
1285 let tokens = extract_tokens("show order 987654321 details");
1286 assert!(tokens.literals.contains(&"987654321".to_string()));
1287 assert!(tokens.keywords.contains(&"order".to_string()));
1288 assert!(tokens.keywords.contains(&"details".to_string()));
1289 assert!(tokens.keywords.contains(&"show".to_string()));
1290 }
1291
1292 #[test]
1293 fn extract_tokens_short_uppercase_word_is_keyword_not_literal() {
1294 let tokens = extract_tokens("USA exports report");
1297 assert!(tokens.keywords.contains(&"usa".to_string()));
1298 assert!(tokens.literals.is_empty());
1299 }
1300
1301 #[test]
1302 fn extract_tokens_dedups() {
1303 let tokens = extract_tokens("passport passport FDD-1 FDD-1");
1304 assert_eq!(
1305 tokens.keywords.iter().filter(|k| *k == "passport").count(),
1306 1
1307 );
1308 assert_eq!(tokens.literals.iter().filter(|l| *l == "FDD-1").count(), 1);
1309 }
1310
1311 #[test]
1314 fn first_literal_in_value_substring_match() {
1315 let lit = first_literal_in_value(
1316 &Value::text("issue FDD-12313 reported by user"),
1317 &["FDD-12313".to_string()],
1318 );
1319 assert_eq!(lit.as_deref(), Some("FDD-12313"));
1320 }
1321
1322 #[test]
1323 fn first_literal_in_value_no_match_returns_none() {
1324 assert!(
1325 first_literal_in_value(&Value::text("nothing here"), &["FDD-12313".to_string()],)
1326 .is_none()
1327 );
1328 }
1329
1330 use crate::api::RedDBOptions;
1333 use crate::auth::Role;
1334 use crate::runtime::statement_frame::EffectiveScope;
1335 use crate::runtime::RedDBRuntime;
1336 use crate::storage::unified::entity::{
1337 EntityData, EntityId, EntityKind, RowData, UnifiedEntity,
1338 };
1339 use reddb_types::Value;
1340 use std::sync::Arc;
1341
1342 fn make_scope(rt: &RedDBRuntime, visible: HashSet<String>) -> EffectiveScope {
1343 EffectiveScope {
1344 captured_snapshot: None,
1345 tenant: Some("acme".to_string()),
1346 identity: Some(("alice".to_string(), Role::Read)),
1347 auth_principal: Some(crate::auth::UserId::platform("alice".to_string())),
1348 snapshot: rt.current_snapshot(),
1349 visible_collections: Some(visible),
1350 }
1351 }
1352
1353 fn fresh_runtime() -> RedDBRuntime {
1354 RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime boots")
1355 }
1356
1357 fn test_row(collection: &str, id: u64) -> FilteredRow {
1358 FilteredRow {
1359 collection: collection.to_string(),
1360 entity: UnifiedEntity::new(
1361 EntityId::new(id),
1362 EntityKind::TableRow {
1363 table: Arc::from(collection),
1364 row_id: id,
1365 },
1366 EntityData::Row(RowData {
1367 columns: Vec::new(),
1368 named: Some(
1369 [("body".to_string(), Value::text("ticket FDD-1".to_string()))]
1370 .into_iter()
1371 .collect(),
1372 ),
1373 schema: None,
1374 }),
1375 ),
1376 matched_literal: "FDD-1".to_string(),
1377 matched_column: Some("body".to_string()),
1378 }
1379 }
1380
1381 fn test_graph_hit(collection: &str, id: u64, score: f32, depth: usize) -> GraphHit {
1382 GraphHit {
1383 collection: collection.to_string(),
1384 entity_id: id,
1385 score,
1386 depth,
1387 kind: GraphHitKind::Node,
1388 }
1389 }
1390
1391 fn test_text_hit(collection: &str, id: u64, score: f32) -> TextHit {
1392 TextHit {
1393 collection: collection.to_string(),
1394 entity_id: id,
1395 score,
1396 }
1397 }
1398
1399 struct TenantGuard;
1400
1401 impl TenantGuard {
1402 fn set(tenant: &str) -> Self {
1403 crate::runtime::impl_core::set_current_tenant(tenant.to_string());
1404 Self
1405 }
1406 }
1407
1408 impl Drop for TenantGuard {
1409 fn drop(&mut self) {
1410 crate::runtime::impl_core::clear_current_tenant();
1411 }
1412 }
1413
1414 fn row_text<'a>(entity: &'a UnifiedEntity, field: &str) -> Option<&'a str> {
1415 let row = entity.data.as_row()?;
1416 match row.get_field(field)? {
1417 Value::Text(value) => Some(value.as_ref()),
1418 _ => None,
1419 }
1420 }
1421
1422 #[test]
1423 fn fused_source_order_uses_rrf_and_total_limit() {
1424 let ctx = AskContext {
1425 source_limit: 2,
1426 filtered_rows: vec![test_row("incidents", 2), test_row("incidents", 1)],
1427 vector_hits: vec![
1428 VectorHit {
1429 collection: "incidents".to_string(),
1430 entity_id: 1,
1431 score: 0.91,
1432 },
1433 VectorHit {
1434 collection: "docs".to_string(),
1435 entity_id: 9,
1436 score: 0.88,
1437 },
1438 ],
1439 ..AskContext::default()
1440 };
1441
1442 let order = fused_source_order(&ctx);
1443
1444 assert_eq!(
1445 order,
1446 vec![
1447 FusedSourceRef::FilteredRow(1),
1448 FusedSourceRef::FilteredRow(0)
1449 ]
1450 );
1451 }
1452
1453 #[test]
1454 fn fused_source_order_includes_graph_bucket() {
1455 let ctx = AskContext {
1456 source_limit: 4,
1457 filtered_rows: vec![test_row("incidents", 1)],
1458 text_hits: vec![test_text_hit("articles", 5, 1.2)],
1459 vector_hits: vec![
1460 VectorHit {
1461 collection: "incidents".to_string(),
1462 entity_id: 1,
1463 score: 0.91,
1464 },
1465 VectorHit {
1466 collection: "docs".to_string(),
1467 entity_id: 9,
1468 score: 0.88,
1469 },
1470 ],
1471 graph_hits: vec![test_graph_hit("topology", 7, 0.80, 1)],
1472 ..AskContext::default()
1473 };
1474
1475 let order = fused_source_order(&ctx);
1476
1477 assert_eq!(
1478 order,
1479 vec![
1480 FusedSourceRef::FilteredRow(0),
1481 FusedSourceRef::TextHit(0),
1482 FusedSourceRef::GraphHit(0),
1483 FusedSourceRef::VectorHit(1),
1484 ]
1485 );
1486 }
1487
1488 #[test]
1489 fn text_search_bm25_scoped_ranks_specific_document_first() {
1490 let rt = fresh_runtime();
1491 rt.execute_query("CREATE TABLE docs (body TEXT) WITH CONTEXT INDEX ON (body)")
1492 .expect("create docs");
1493 rt.execute_query("INSERT INTO docs (body) VALUES ('passport renewal')")
1494 .expect("insert specific doc");
1495 rt.execute_query(
1496 "INSERT INTO docs (body) VALUES ('passport renewal travel hotel airline visa luggage itinerary')",
1497 )
1498 .expect("insert broad doc");
1499
1500 let scope = make_scope(&rt, ["docs".to_string()].into_iter().collect());
1501 let candidates = CandidateCollections {
1502 collections: vec!["docs".to_string()],
1503 columns_by_collection: HashMap::new(),
1504 };
1505 let hits = text_search_bm25_scoped(&rt, &scope, "passport renewal", &candidates, 10);
1506
1507 assert_eq!(hits.len(), 2);
1508 assert!(
1509 hits[0].score > hits[1].score,
1510 "BM25 text bucket should prefer the shorter exact match: {hits:?}"
1511 );
1512 }
1513
1514 #[test]
1515 fn text_search_bm25_scoped_filters_rls_denied_hits() {
1516 let rt = fresh_runtime();
1517 rt.execute_query(
1518 "CREATE TABLE docs (id INT, tenant_id TEXT, body TEXT) WITH CONTEXT INDEX ON (body)",
1519 )
1520 .expect("create docs");
1521 rt.execute_query(
1522 "INSERT INTO docs (id, tenant_id, body) VALUES \
1523 (1, 'acme', 'shared launch plan'), \
1524 (2, 'globex', 'shared launch plan')",
1525 )
1526 .expect("seed docs");
1527 rt.execute_query(
1528 "CREATE POLICY tenant_only ON docs FOR SELECT USING (tenant_id = CURRENT_TENANT())",
1529 )
1530 .expect("create policy");
1531 rt.execute_query("ALTER TABLE docs ENABLE ROW LEVEL SECURITY")
1532 .expect("enable rls");
1533
1534 let _tenant = TenantGuard::set("acme");
1535 let scope = make_scope(&rt, ["docs".to_string()].into_iter().collect());
1536 let candidates = CandidateCollections {
1537 collections: vec!["docs".to_string()],
1538 columns_by_collection: HashMap::new(),
1539 };
1540
1541 let hits = text_search_bm25_scoped(&rt, &scope, "shared launch", &candidates, 10);
1542
1543 assert_eq!(hits.len(), 1, "RLS should hide the globex hit: {hits:?}");
1544 let entity = rt
1545 .inner
1546 .db
1547 .store()
1548 .get(
1549 "docs",
1550 crate::storage::unified::entity::EntityId::new(hits[0].entity_id),
1551 )
1552 .expect("hit entity exists");
1553 assert_eq!(row_text(&entity, "tenant_id"), Some("acme"));
1554 }
1555
1556 #[test]
1557 fn execute_pipeline_retrieves_known_good_bm25_source_order() {
1558 let rt = fresh_runtime();
1559 rt.execute_query("CREATE TABLE docs (body TEXT) WITH CONTEXT INDEX ON (body)")
1560 .expect("create docs");
1561 rt.execute_query("INSERT INTO docs (body) VALUES ('passport renewal')")
1562 .expect("insert specific doc");
1563 rt.execute_query(
1564 "INSERT INTO docs (body) VALUES ('passport renewal travel hotel airline visa luggage itinerary')",
1565 )
1566 .expect("insert broad doc");
1567 rt.schema_vocabulary_apply(
1568 crate::runtime::schema_vocabulary::DdlEvent::CreateCollection {
1569 collection: "docs".to_string(),
1570 columns: vec!["body".into()],
1571 type_tags: Vec::new(),
1572 description: None,
1573 },
1574 );
1575
1576 let scope = make_scope(&rt, ["docs".to_string()].into_iter().collect());
1577 let ctx = AskPipeline::execute_with_limit_and_min_score(
1578 &rt,
1579 &scope,
1580 "body passport renewal",
1581 2,
1582 None,
1583 Some(1),
1584 )
1585 .expect("pipeline executes");
1586
1587 assert_eq!(ctx.text_hits.len(), 2);
1588 assert!(
1589 ctx.text_hits[0].score > ctx.text_hits[1].score,
1590 "BM25 source order should prefer the shorter exact match: {:?}",
1591 ctx.text_hits
1592 );
1593 assert!(matches!(
1594 fused_source_order(&ctx).first(),
1595 Some(FusedSourceRef::TextHit(0))
1596 ));
1597 }
1598
1599 #[test]
1600 fn graph_search_scoped_honors_depth() {
1601 let rt = fresh_runtime();
1602 rt.execute_query("INSERT INTO tales NODE (label, name) VALUES ('alice', 'Alice')")
1603 .expect("insert alice");
1604 rt.execute_query("INSERT INTO tales NODE (label, name) VALUES ('bob', 'Bob')")
1605 .expect("insert bob");
1606 rt.execute_query("INSERT INTO tales NODE (label, name) VALUES ('carol', 'Carol')")
1607 .expect("insert carol");
1608 rt.execute_query(
1609 "INSERT INTO tales EDGE (label, from, to) VALUES ('knows', 'alice', 'bob')",
1610 )
1611 .expect("insert alice-bob edge");
1612 rt.execute_query(
1613 "INSERT INTO tales EDGE (label, from, to) VALUES ('knows', 'bob', 'carol')",
1614 )
1615 .expect("insert bob-carol edge");
1616
1617 let scope = make_scope(&rt, ["tales".to_string()].into_iter().collect());
1618 let candidates = CandidateCollections {
1619 collections: vec!["tales".to_string()],
1620 columns_by_collection: HashMap::new(),
1621 };
1622 let depth1 = graph_search_scoped(&rt, &scope, "alice", &candidates, 10, None, Some(1));
1623 let depth2 = graph_search_scoped(&rt, &scope, "alice", &candidates, 10, None, Some(2));
1624
1625 assert!(
1626 depth1.iter().all(|hit| hit.depth <= 1),
1627 "DEPTH 1 returned hits beyond one hop: {depth1:?}"
1628 );
1629 assert!(
1630 depth2.iter().any(|hit| hit.depth == 2),
1631 "DEPTH 2 should include the second-hop graph hit: {depth2:?}"
1632 );
1633 }
1634
1635 #[test]
1636 fn filter_values_filters_rls_denied_rows() {
1637 let rt = fresh_runtime();
1638 rt.execute_query("CREATE TABLE docs (id INT, tenant_id TEXT, body TEXT)")
1639 .expect("create docs");
1640 rt.execute_query(
1641 "INSERT INTO docs (id, tenant_id, body) VALUES \
1642 (1, 'acme', 'incident FDD-12313'), \
1643 (2, 'globex', 'incident FDD-12313')",
1644 )
1645 .expect("seed docs");
1646 rt.execute_query(
1647 "CREATE POLICY tenant_only ON docs FOR SELECT USING (tenant_id = CURRENT_TENANT())",
1648 )
1649 .expect("create policy");
1650 rt.execute_query("ALTER TABLE docs ENABLE ROW LEVEL SECURITY")
1651 .expect("enable rls");
1652
1653 let _tenant = TenantGuard::set("acme");
1654 let scope = make_scope(&rt, ["docs".to_string()].into_iter().collect());
1655 let candidates = CandidateCollections {
1656 collections: vec!["docs".to_string()],
1657 columns_by_collection: HashMap::from([("docs".to_string(), vec!["body".to_string()])]),
1658 };
1659 let tokens = TokenSet {
1660 keywords: vec!["incident".to_string()],
1661 literals: vec!["FDD-12313".to_string()],
1662 };
1663
1664 let rows = filter_values(&rt, &scope, &candidates, &tokens, 10);
1665
1666 assert_eq!(rows.len(), 1, "RLS should hide the globex row: {rows:?}");
1667 assert_eq!(row_text(&rows[0].entity, "tenant_id"), Some("acme"));
1668 }
1669
1670 #[test]
1673 fn execute_refuses_empty_token_set() {
1674 let rt = fresh_runtime();
1675 let scope = make_scope(&rt, HashSet::new());
1676 let err = AskPipeline::execute(&rt, &scope, "??? ...")
1677 .expect_err("empty token set must short-circuit");
1678 let msg = format!("{err}");
1679 assert!(
1680 msg.contains("yielded no usable tokens"),
1681 "expected structured empty-token error, got: {msg}"
1682 );
1683 }
1684
1685 #[test]
1690 fn match_schema_intersects_with_visible_set() {
1691 let rt = fresh_runtime();
1692 rt.schema_vocabulary_apply(
1696 crate::runtime::schema_vocabulary::DdlEvent::CreateCollection {
1697 collection: "travel".to_string(),
1698 columns: vec!["id".into(), "passport".into()],
1699 type_tags: Vec::new(),
1700 description: None,
1701 },
1702 );
1703 rt.schema_vocabulary_apply(
1704 crate::runtime::schema_vocabulary::DdlEvent::CreateCollection {
1705 collection: "secrets".to_string(),
1706 columns: vec!["passport".into()],
1707 type_tags: Vec::new(),
1708 description: None,
1709 },
1710 );
1711 let visible: HashSet<String> = ["travel".to_string()].into_iter().collect();
1712 let scope = make_scope(&rt, visible.clone());
1713 let tokens = TokenSet {
1714 keywords: vec!["passport".to_string()],
1715 literals: Vec::new(),
1716 };
1717 let candidates = match_schema(&rt, &scope, &tokens).expect("ok");
1718 assert_eq!(candidates.collections, vec!["travel".to_string()]);
1719 assert!(!candidates.collections.contains(&"secrets".to_string()));
1720 let cols = candidates
1722 .columns_by_collection
1723 .get("travel")
1724 .expect("hint columns");
1725 assert!(cols.contains(&"passport".to_string()));
1726 }
1727
1728 use proptest::prelude::*;
1736
1737 fn arb_collection() -> impl Strategy<Value = String> {
1738 "[a-z]{1,4}"
1739 }
1740
1741 fn arb_visible() -> impl Strategy<Value = HashSet<String>> {
1742 prop::collection::hash_set(arb_collection(), 0..6)
1743 }
1744
1745 fn arb_candidates() -> impl Strategy<Value = Vec<String>> {
1746 prop::collection::vec(arb_collection(), 0..8)
1747 }
1748
1749 proptest! {
1750 #![proptest_config(ProptestConfig::with_cases(256))]
1751 #[test]
1752 fn stage4_rows_subset_of_visible_collections(
1753 visible in arb_visible(),
1754 candidate_names in arb_candidates(),
1755 literal_count in 0usize..3,
1756 ) {
1757 let rt = PROPTEST_RUNTIME.get_or_init(fresh_runtime);
1763 let candidates = CandidateCollections {
1764 collections: candidate_names,
1765 columns_by_collection: HashMap::new(),
1766 };
1767 let literals: Vec<String> = (0..literal_count)
1768 .map(|i| format!("ID-{i}"))
1769 .collect();
1770 let tokens = TokenSet {
1771 keywords: vec!["passport".to_string()],
1772 literals,
1773 };
1774 let scope = make_scope(&rt, visible.clone());
1775 let rows = filter_values(rt, &scope, &candidates, &tokens, DEFAULT_ROW_CAP);
1776 for row in &rows {
1777 prop_assert!(
1778 visible.contains(&row.collection),
1779 "Stage 4 leaked row collection={} not in visible={:?}",
1780 row.collection, visible
1781 );
1782 }
1783 }
1784 }
1785
1786 static PROPTEST_RUNTIME: std::sync::OnceLock<RedDBRuntime> = std::sync::OnceLock::new();
1787
1788 #[test]
1801 fn integration_passport_fdd_12313_funnels_through_four_stages() {
1802 let rt = fresh_runtime();
1803 rt.execute_query("CREATE TABLE travel (id INT, passport TEXT, notes TEXT)")
1807 .expect("CREATE TABLE travel");
1808 rt.execute_query(
1809 "INSERT INTO travel (id, passport, notes) VALUES \
1810 (1, 'BR-001', 'unrelated note'), \
1811 (2, 'PT-002', 'incident FDD-12313 escalated'), \
1812 (3, 'US-003', 'standard renewal')",
1813 )
1814 .expect("seed rows");
1815 rt.execute_query("CREATE TABLE secrets (id INT, passport TEXT)")
1817 .expect("CREATE TABLE secrets");
1818 rt.execute_query("INSERT INTO secrets (id, passport) VALUES (99, 'FDD-12313')")
1819 .expect("seed secrets");
1820
1821 let visible: HashSet<String> = ["travel".to_string()].into_iter().collect();
1822 let scope = make_scope(&rt, visible);
1823
1824 let ctx = AskPipeline::execute(
1825 &rt,
1826 &scope,
1827 "quais as novidades sobre o passport FDD-12313?",
1828 )
1829 .expect("pipeline runs");
1830
1831 assert!(ctx.tokens.keywords.contains(&"passport".to_string()));
1833 assert!(ctx.tokens.literals.contains(&"FDD-12313".to_string()));
1834
1835 assert_eq!(ctx.candidates.collections, vec!["travel".to_string()]);
1839
1840 let _ = &ctx.vector_hits;
1844
1845 assert!(
1848 ctx.filtered_rows
1849 .iter()
1850 .any(|r| r.collection == "travel" && r.matched_literal == "FDD-12313"),
1851 "expected travel row with FDD-12313 match, got: {:?}",
1852 ctx.filtered_rows
1853 );
1854 for row in &ctx.filtered_rows {
1855 assert_ne!(
1856 row.collection, "secrets",
1857 "secrets row leaked into Stage 4 output"
1858 );
1859 }
1860
1861 let _ = ctx.timings.extract_us
1865 + ctx.timings.schema_us
1866 + ctx.timings.vector_us
1867 + ctx.timings.filter_us;
1868 }
1869
1870 fn write_config(rt: &RedDBRuntime, key: &str, value: &str) {
1884 let store = rt.inner.db.store();
1885 store.set_config_tree(key, &crate::serde_json::Value::String(value.to_string()));
1886 }
1887
1888 #[test]
1891 fn routed_default_backend_runs_heuristic() {
1892 let rt = fresh_runtime();
1893 let scope = make_scope(&rt, HashSet::new());
1894 let tokens = extract_tokens_routed(&rt, &scope, "passport FDD-12313")
1895 .expect("heuristic path is infallible");
1896 assert!(tokens.keywords.contains(&"passport".to_string()));
1897 assert!(tokens.literals.contains(&"FDD-12313".to_string()));
1898 }
1899
1900 #[tokio::test(flavor = "multi_thread")]
1903 async fn routed_llm_auth_denied_uses_heuristic_fallback() {
1904 let rt = fresh_runtime();
1905 write_config(&rt, "ai.ner.backend", "llm");
1906 write_config(&rt, "ai.ner.fallback", "use_heuristic");
1907 let scope = make_scope(&rt, HashSet::new());
1908 let tokens = tokio::task::spawn_blocking(move || {
1909 extract_tokens_routed(&rt, &scope, "passport FDD-12313")
1910 })
1911 .await
1912 .unwrap()
1913 .expect("fallback policy keeps the call OK");
1914 assert!(tokens.keywords.contains(&"passport".to_string()));
1915 assert!(tokens.literals.contains(&"FDD-12313".to_string()));
1916 }
1917
1918 #[tokio::test(flavor = "multi_thread")]
1921 async fn routed_llm_auth_denied_empty_on_fail() {
1922 let rt = fresh_runtime();
1923 write_config(&rt, "ai.ner.backend", "llm");
1924 write_config(&rt, "ai.ner.fallback", "empty_on_fail");
1925 let scope = make_scope(&rt, HashSet::new());
1926 let tokens = tokio::task::spawn_blocking(move || {
1927 extract_tokens_routed(&rt, &scope, "passport FDD-12313")
1928 })
1929 .await
1930 .unwrap()
1931 .expect("empty_on_fail returns Ok with empty TokenSet");
1932 assert!(tokens.is_empty(), "expected empty TokenSet, got {tokens:?}");
1933 }
1934
1935 #[tokio::test(flavor = "multi_thread")]
1939 async fn routed_llm_auth_denied_propagate_returns_error() {
1940 let rt = fresh_runtime();
1941 write_config(&rt, "ai.ner.backend", "llm");
1942 write_config(&rt, "ai.ner.fallback", "propagate");
1943 let scope = make_scope(&rt, HashSet::new());
1944 let err = tokio::task::spawn_blocking(move || {
1945 extract_tokens_routed(&rt, &scope, "passport FDD-12313")
1946 })
1947 .await
1948 .unwrap()
1949 .expect_err("propagate must surface the error");
1950 let msg = format!("{err}");
1951 assert!(
1952 msg.contains("propagate") || msg.contains("ai.ner.backend"),
1953 "expected propagate error message, got: {msg}"
1954 );
1955 }
1956
1957 #[tokio::test(flavor = "multi_thread")]
1962 async fn execute_with_llm_backend_falls_back_and_completes_pipeline() {
1963 let rt = fresh_runtime();
1964 write_config(&rt, "ai.ner.backend", "llm");
1965 rt.execute_query("CREATE TABLE travel (id INT, passport TEXT, notes TEXT)")
1967 .expect("CREATE TABLE travel");
1968 rt.execute_query(
1969 "INSERT INTO travel (id, passport, notes) VALUES \
1970 (2, 'PT-002', 'incident FDD-12313 escalated')",
1971 )
1972 .expect("seed rows");
1973 let visible: HashSet<String> = ["travel".to_string()].into_iter().collect();
1974 let scope = make_scope(&rt, visible);
1975 let ctx = tokio::task::spawn_blocking(move || {
1976 AskPipeline::execute(&rt, &scope, "passport FDD-12313")
1977 })
1978 .await
1979 .unwrap()
1980 .expect("pipeline runs");
1981 assert!(ctx.tokens.keywords.contains(&"passport".to_string()));
1982 assert!(ctx.tokens.literals.contains(&"FDD-12313".to_string()));
1983 assert_eq!(ctx.candidates.collections, vec!["travel".to_string()]);
1984 assert!(
1985 ctx.filtered_rows
1986 .iter()
1987 .any(|r| r.matched_literal == "FDD-12313"),
1988 "Stage 4 still runs after Stage 1 fallback"
1989 );
1990 }
1991}