Skip to main content

subcog/hooks/search_intent/
hybrid.rs

1//! Hybrid and timeout-aware search intent detection.
2//!
3//! This module combines keyword and LLM detection for optimal accuracy
4//! while maintaining responsive performance through configurable timeouts.
5
6use super::keyword::detect_search_intent;
7use super::llm::classify_intent_with_llm;
8use super::types::{DetectionSource, SearchIntent};
9use crate::config::SearchIntentConfig;
10use crate::llm::LlmProvider as LlmProviderTrait;
11use crate::observability::{RequestContext, current_request_id, enter_request_context};
12use std::sync::{Arc, mpsc};
13use std::time::Duration;
14
15/// Detects search intent with LLM classification and timeout.
16///
17/// Uses LLM classification with a configurable timeout. Falls back to keyword
18/// detection if LLM times out or fails.
19///
20/// # Arguments
21///
22/// * `provider` - Optional LLM provider. If None, uses keyword-only detection.
23/// * `prompt` - The user prompt to analyze.
24/// * `config` - Configuration for intent detection.
25///
26/// # Returns
27///
28/// A `SearchIntent` from either LLM or keyword detection.
29///
30/// # Panics
31///
32/// This function does not panic under normal operation.
33#[must_use]
34pub fn detect_search_intent_with_timeout(
35    provider: Option<Arc<dyn LlmProviderTrait>>,
36    prompt: &str,
37    config: &SearchIntentConfig,
38) -> SearchIntent {
39    // If LLM is disabled or no provider, use keyword detection
40    if !config.use_llm || provider.is_none() {
41        return detect_search_intent(prompt).unwrap_or_default();
42    }
43
44    let timeout = Duration::from_millis(config.llm_timeout_ms);
45    let llm_result = run_llm_with_timeout(provider, prompt.to_string(), timeout);
46
47    // Return LLM result if successful, otherwise fall back to keyword
48    llm_result.unwrap_or_else(|| detect_search_intent(prompt).unwrap_or_default())
49}
50
51/// Detects search intent using hybrid keyword + LLM detection.
52///
53/// Runs keyword detection immediately and LLM detection in parallel.
54/// Merges results with LLM taking precedence for intent type if confidence is high.
55///
56/// # Arguments
57///
58/// * `provider` - Optional LLM provider.
59/// * `prompt` - The user prompt to analyze.
60/// * `config` - Configuration for intent detection.
61///
62/// # Returns
63///
64/// A merged `SearchIntent` from both detection methods.
65///
66/// # Panics
67///
68/// This function does not panic under normal operation.
69#[must_use]
70pub fn detect_search_intent_hybrid(
71    provider: Option<Arc<dyn LlmProviderTrait>>,
72    prompt: &str,
73    config: &SearchIntentConfig,
74) -> SearchIntent {
75    // Always run keyword detection (fast)
76    let keyword_result = detect_search_intent(prompt);
77
78    // If LLM is disabled or no provider, return keyword result
79    if !config.use_llm || provider.is_none() {
80        return keyword_result.unwrap_or_default();
81    }
82
83    let timeout = Duration::from_millis(config.llm_timeout_ms);
84    let llm_result = run_llm_with_timeout(provider, prompt.to_string(), timeout);
85
86    // Merge results
87    merge_intent_results(keyword_result, llm_result, config)
88}
89
90/// Runs LLM classification with a timeout (CHAOS-H3).
91///
92/// # Thread Lifecycle
93///
94/// Spawns a background thread for LLM classification. If the timeout is exceeded:
95/// - The result is discarded (receiver times out)
96/// - The thread continues to completion naturally (Rust threads cannot be killed)
97/// - A metric is recorded for monitoring orphaned operations
98/// - The thread will complete its HTTP request and exit cleanly
99///
100/// This is a deliberate design choice because:
101/// 1. Rust has no safe way to forcefully terminate threads
102/// 2. Interrupting HTTP requests mid-flight can cause resource leaks
103/// 3. The thread will complete quickly once the LLM responds
104///
105/// For production, consider using async with timeout + cancellation tokens.
106fn run_llm_with_timeout(
107    provider: Option<Arc<dyn LlmProviderTrait>>,
108    prompt: String,
109    timeout: Duration,
110) -> Option<SearchIntent> {
111    let provider = provider?;
112    let (tx, rx) = mpsc::channel();
113    let parent_span = tracing::Span::current();
114    let request_id = current_request_id();
115
116    // Record that we're starting an LLM call
117    metrics::counter!("search_intent_llm_started").increment(1);
118
119    std::thread::spawn(move || {
120        let _request_guard = request_id
121            .map(RequestContext::from_id)
122            .map(enter_request_context);
123        let _parent = parent_span.enter();
124        let span = tracing::info_span!("search_intent.llm");
125        let _guard = span.enter();
126        let result = classify_intent_with_llm(provider.as_ref(), &prompt);
127        // If receiver dropped (timeout), send will fail silently - this is expected
128        let _ = tx.send(result);
129    });
130
131    // Record completion based on whether we received a result within timeout (CHAOS-H3)
132    match rx.recv_timeout(timeout) {
133        Ok(Ok(intent)) => {
134            metrics::counter!("search_intent_llm_completed", "status" => "success").increment(1);
135            Some(intent)
136        },
137        Ok(Err(_)) => {
138            // LLM returned an error - no timeout, just failure
139            metrics::counter!("search_intent_llm_completed", "status" => "error").increment(1);
140            None
141        },
142        Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
143            // CHAOS-H3: Record metric for monitoring orphaned threads
144            // Note: The spawned thread will continue running in the background.
145            // Rust cannot cancel/kill threads, so it will complete eventually.
146            // This is acceptable for short-lived LLM classification tasks.
147            metrics::counter!("search_intent_llm_timeout_total", "reason" => "timeout")
148                .increment(1);
149            metrics::counter!("search_intent_llm_completed", "status" => "timeout").increment(1);
150            tracing::debug!("LLM classification timed out, thread will complete in background");
151            None
152        },
153        Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
154            // Thread panicked or dropped sender - unusual but handle gracefully
155            metrics::counter!("search_intent_llm_timeout_total", "reason" => "disconnected")
156                .increment(1);
157            metrics::counter!("search_intent_llm_completed", "status" => "disconnected")
158                .increment(1);
159            None
160        },
161    }
162}
163
164/// Merges keyword and LLM intent results.
165fn merge_intent_results(
166    keyword: Option<SearchIntent>,
167    llm: Option<SearchIntent>,
168    config: &SearchIntentConfig,
169) -> SearchIntent {
170    match (keyword, llm) {
171        // Both available: prefer LLM if high confidence
172        (Some(kw), Some(llm_intent)) => {
173            if llm_intent.confidence >= config.min_confidence {
174                SearchIntent {
175                    intent_type: llm_intent.intent_type,
176                    // Average confidence weighted toward LLM
177                    confidence: llm_intent
178                        .confidence
179                        .mul_add(0.7, kw.confidence * 0.3)
180                        .min(0.95),
181                    // Combine keywords from keyword detection
182                    keywords: kw.keywords,
183                    // Prefer LLM topics if available, otherwise keyword topics
184                    topics: if llm_intent.topics.is_empty() {
185                        kw.topics
186                    } else {
187                        llm_intent.topics
188                    },
189                    source: DetectionSource::Hybrid,
190                }
191            } else {
192                // LLM confidence too low, use keyword result
193                SearchIntent {
194                    source: DetectionSource::Hybrid,
195                    ..kw
196                }
197            }
198        },
199        // Only keyword available
200        (Some(kw), None) => kw,
201        // Only LLM available (unusual but possible)
202        (None, Some(llm_intent)) => SearchIntent {
203            source: DetectionSource::Llm,
204            ..llm_intent
205        },
206        // Neither available
207        (None, None) => SearchIntent::default(),
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::hooks::search_intent::types::SearchIntentType;
215
216    fn test_config() -> SearchIntentConfig {
217        SearchIntentConfig {
218            enabled: true,
219            use_llm: true,
220            llm_timeout_ms: 200,
221            min_confidence: 0.5,
222            ..Default::default()
223        }
224    }
225
226    #[test]
227    fn test_merge_both_available_llm_high_confidence() {
228        let keyword = Some(
229            SearchIntent::new(SearchIntentType::HowTo)
230                .with_confidence(0.6)
231                .with_keywords(vec!["how".to_string()])
232                .with_topics(vec!["keyword_topic".to_string()]),
233        );
234        let llm = Some(
235            SearchIntent::new(SearchIntentType::Troubleshoot)
236                .with_confidence(0.9)
237                .with_topics(vec!["llm_topic".to_string()]),
238        );
239
240        let result = merge_intent_results(keyword, llm, &test_config());
241
242        // Should use LLM intent type (high confidence)
243        assert_eq!(result.intent_type, SearchIntentType::Troubleshoot);
244        // Should keep keyword keywords
245        assert_eq!(result.keywords, vec!["how"]);
246        // Should use LLM topics
247        assert_eq!(result.topics, vec!["llm_topic"]);
248        assert_eq!(result.source, DetectionSource::Hybrid);
249    }
250
251    #[test]
252    fn test_merge_both_available_llm_low_confidence() {
253        let keyword = Some(
254            SearchIntent::new(SearchIntentType::HowTo)
255                .with_confidence(0.6)
256                .with_keywords(vec!["how".to_string()]),
257        );
258        let llm = Some(
259            SearchIntent::new(SearchIntentType::Troubleshoot).with_confidence(0.3), // Below min_confidence
260        );
261
262        let result = merge_intent_results(keyword, llm, &test_config());
263
264        // Should use keyword intent type (LLM confidence too low)
265        assert_eq!(result.intent_type, SearchIntentType::HowTo);
266        assert_eq!(result.source, DetectionSource::Hybrid);
267    }
268
269    #[test]
270    fn test_merge_keyword_only() {
271        let keyword = Some(SearchIntent::new(SearchIntentType::Location).with_confidence(0.7));
272
273        let result = merge_intent_results(keyword, None, &test_config());
274
275        assert_eq!(result.intent_type, SearchIntentType::Location);
276        assert_eq!(result.source, DetectionSource::Keyword);
277    }
278
279    #[test]
280    fn test_merge_llm_only() {
281        let llm = Some(
282            SearchIntent::new(SearchIntentType::Explanation)
283                .with_confidence(0.8)
284                .with_source(DetectionSource::Llm),
285        );
286
287        let result = merge_intent_results(None, llm, &test_config());
288
289        assert_eq!(result.intent_type, SearchIntentType::Explanation);
290        assert_eq!(result.source, DetectionSource::Llm);
291    }
292
293    #[test]
294    fn test_merge_neither_available() {
295        let result = merge_intent_results(None, None, &test_config());
296
297        assert_eq!(result.intent_type, SearchIntentType::General);
298        assert!(result.confidence.abs() < f32::EPSILON);
299    }
300
301    #[test]
302    fn test_detect_with_timeout_no_provider() {
303        let config = SearchIntentConfig {
304            enabled: true,
305            use_llm: true,
306            llm_timeout_ms: 200,
307            min_confidence: 0.5,
308            ..Default::default()
309        };
310
311        let result = detect_search_intent_with_timeout(None, "How do I test?", &config);
312
313        // Should fall back to keyword detection
314        assert_eq!(result.intent_type, SearchIntentType::HowTo);
315        assert_eq!(result.source, DetectionSource::Keyword);
316    }
317
318    #[test]
319    fn test_detect_with_timeout_llm_disabled() {
320        let config = SearchIntentConfig {
321            enabled: true,
322            use_llm: false,
323            llm_timeout_ms: 200,
324            min_confidence: 0.5,
325            ..Default::default()
326        };
327
328        let result = detect_search_intent_with_timeout(None, "Where is the config?", &config);
329
330        assert_eq!(result.intent_type, SearchIntentType::Location);
331        assert_eq!(result.source, DetectionSource::Keyword);
332    }
333
334    #[test]
335    fn test_detect_hybrid_no_provider() {
336        let config = test_config();
337
338        // Use "difference between" which triggers Comparison without "What is" (Explanation)
339        let result = detect_search_intent_hybrid(None, "Difference between X and Y?", &config);
340
341        // Should fall back to keyword detection
342        assert_eq!(result.intent_type, SearchIntentType::Comparison);
343        assert_eq!(result.source, DetectionSource::Keyword);
344    }
345}