subcog/hooks/search_intent/
hybrid.rs1use 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#[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 !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 llm_result.unwrap_or_else(|| detect_search_intent(prompt).unwrap_or_default())
49}
50
51#[must_use]
70pub fn detect_search_intent_hybrid(
71 provider: Option<Arc<dyn LlmProviderTrait>>,
72 prompt: &str,
73 config: &SearchIntentConfig,
74) -> SearchIntent {
75 let keyword_result = detect_search_intent(prompt);
77
78 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_intent_results(keyword_result, llm_result, config)
88}
89
90fn 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 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 let _ = tx.send(result);
129 });
130
131 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 metrics::counter!("search_intent_llm_completed", "status" => "error").increment(1);
140 None
141 },
142 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
143 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 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
164fn merge_intent_results(
166 keyword: Option<SearchIntent>,
167 llm: Option<SearchIntent>,
168 config: &SearchIntentConfig,
169) -> SearchIntent {
170 match (keyword, llm) {
171 (Some(kw), Some(llm_intent)) => {
173 if llm_intent.confidence >= config.min_confidence {
174 SearchIntent {
175 intent_type: llm_intent.intent_type,
176 confidence: llm_intent
178 .confidence
179 .mul_add(0.7, kw.confidence * 0.3)
180 .min(0.95),
181 keywords: kw.keywords,
183 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 SearchIntent {
194 source: DetectionSource::Hybrid,
195 ..kw
196 }
197 }
198 },
199 (Some(kw), None) => kw,
201 (None, Some(llm_intent)) => SearchIntent {
203 source: DetectionSource::Llm,
204 ..llm_intent
205 },
206 (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 assert_eq!(result.intent_type, SearchIntentType::Troubleshoot);
244 assert_eq!(result.keywords, vec!["how"]);
246 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), );
261
262 let result = merge_intent_results(keyword, llm, &test_config());
263
264 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 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 let result = detect_search_intent_hybrid(None, "Difference between X and Y?", &config);
340
341 assert_eq!(result.intent_type, SearchIntentType::Comparison);
343 assert_eq!(result.source, DetectionSource::Keyword);
344 }
345}