Skip to main content

subcog/hooks/search_intent/
types.rs

1//! Types for search intent detection.
2//!
3//! This module contains the core types used for representing search intent:
4//! - [`SearchIntentType`]: The type of search intent detected
5//! - [`DetectionSource`]: How the intent was detected (keyword, LLM, or hybrid)
6//! - [`SearchIntent`]: The complete intent detection result
7
8use serde::{Deserialize, Serialize};
9
10/// Types of search intent detected from user prompts.
11///
12/// Each intent type corresponds to a specific information-seeking pattern
13/// and has associated namespace weights for memory retrieval.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
15#[serde(rename_all = "snake_case")]
16pub enum SearchIntentType {
17    /// "How do I...", "How to..." - seeking implementation guidance.
18    HowTo,
19    /// "Where is...", "Find..." - seeking file or code location.
20    Location,
21    /// "What is...", "What does..." - seeking explanation.
22    Explanation,
23    /// "Difference between...", "X vs Y" - seeking comparison.
24    Comparison,
25    /// "Why is...error", "...not working" - seeking troubleshooting help.
26    Troubleshoot,
27    /// Generic search or unclassified intent.
28    #[default]
29    General,
30}
31
32impl SearchIntentType {
33    /// Returns namespace weight multipliers for this intent type.
34    ///
35    /// Higher weights mean more memories from that namespace should be retrieved.
36    #[must_use]
37    pub fn namespace_weights(&self) -> Vec<(&'static str, f32)> {
38        match self {
39            Self::HowTo => vec![
40                ("patterns", 2.0),
41                ("learnings", 1.5),
42                ("decisions", 1.0),
43                ("context", 1.0),
44            ],
45            Self::Location => vec![("context", 1.5), ("patterns", 1.2), ("decisions", 1.0)],
46            Self::Explanation => vec![("decisions", 1.5), ("context", 1.2), ("learnings", 1.0)],
47            Self::Comparison => vec![("decisions", 2.0), ("patterns", 1.5), ("learnings", 1.0)],
48            Self::Troubleshoot => vec![
49                ("blockers", 2.0),
50                ("learnings", 1.5),
51                ("tech-debt", 1.2),
52                ("patterns", 1.0),
53            ],
54            Self::General => vec![
55                ("decisions", 1.0),
56                ("patterns", 1.0),
57                ("learnings", 1.0),
58                ("context", 1.0),
59            ],
60        }
61    }
62
63    /// Parses a string into a `SearchIntentType`.
64    ///
65    /// Case-insensitive matching with support for common aliases.
66    #[must_use]
67    pub fn parse(s: &str) -> Option<Self> {
68        match s.to_lowercase().as_str() {
69            "howto" | "how_to" | "how-to" | "implementation" => Some(Self::HowTo),
70            "location" | "find" | "where" => Some(Self::Location),
71            "explanation" | "explain" | "what" => Some(Self::Explanation),
72            "comparison" | "compare" | "vs" => Some(Self::Comparison),
73            "troubleshoot" | "debug" | "error" | "fix" => Some(Self::Troubleshoot),
74            "general" | "search" | "query" => Some(Self::General),
75            _ => None,
76        }
77    }
78
79    /// Returns the string representation used in serialization.
80    #[must_use]
81    pub const fn as_str(&self) -> &'static str {
82        match self {
83            Self::HowTo => "howto",
84            Self::Location => "location",
85            Self::Explanation => "explanation",
86            Self::Comparison => "comparison",
87            Self::Troubleshoot => "troubleshoot",
88            Self::General => "general",
89        }
90    }
91}
92
93impl std::fmt::Display for SearchIntentType {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        write!(f, "{}", self.as_str())
96    }
97}
98
99/// Source of intent detection.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
101#[serde(rename_all = "snake_case")]
102pub enum DetectionSource {
103    /// Detected via keyword pattern matching.
104    #[default]
105    Keyword,
106    /// Detected via LLM classification.
107    Llm,
108    /// Detected via hybrid (keyword + LLM) approach.
109    Hybrid,
110}
111
112impl DetectionSource {
113    /// Returns the string representation.
114    #[must_use]
115    pub const fn as_str(&self) -> &'static str {
116        match self {
117            Self::Keyword => "keyword",
118            Self::Llm => "llm",
119            Self::Hybrid => "hybrid",
120        }
121    }
122}
123
124impl std::fmt::Display for DetectionSource {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        write!(f, "{}", self.as_str())
127    }
128}
129
130/// Result of search intent detection.
131///
132/// Contains the detected intent type, confidence score, matched keywords,
133/// extracted topics, and detection source.
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct SearchIntent {
136    /// The type of search intent detected.
137    pub intent_type: SearchIntentType,
138    /// Confidence score (0.0 to 1.0).
139    pub confidence: f32,
140    /// Keywords that triggered the detection.
141    pub keywords: Vec<String>,
142    /// Extracted topics from the prompt.
143    pub topics: Vec<String>,
144    /// How the intent was detected.
145    pub source: DetectionSource,
146}
147
148impl Default for SearchIntent {
149    fn default() -> Self {
150        Self {
151            intent_type: SearchIntentType::General,
152            confidence: 0.0,
153            keywords: Vec::new(),
154            topics: Vec::new(),
155            source: DetectionSource::Keyword,
156        }
157    }
158}
159
160impl SearchIntent {
161    /// Creates a new `SearchIntent` with the given type.
162    #[must_use]
163    pub fn new(intent_type: SearchIntentType) -> Self {
164        Self {
165            intent_type,
166            ..Default::default()
167        }
168    }
169
170    /// Sets the confidence score.
171    #[must_use]
172    pub const fn with_confidence(mut self, confidence: f32) -> Self {
173        self.confidence = confidence;
174        self
175    }
176
177    /// Sets the matched keywords.
178    #[must_use]
179    pub fn with_keywords(mut self, keywords: Vec<String>) -> Self {
180        self.keywords = keywords;
181        self
182    }
183
184    /// Sets the extracted topics.
185    #[must_use]
186    pub fn with_topics(mut self, topics: Vec<String>) -> Self {
187        self.topics = topics;
188        self
189    }
190
191    /// Sets the detection source.
192    #[must_use]
193    pub const fn with_source(mut self, source: DetectionSource) -> Self {
194        self.source = source;
195        self
196    }
197
198    /// Returns whether this is a high-confidence detection (≥ 0.8).
199    #[must_use]
200    pub fn is_high_confidence(&self) -> bool {
201        self.confidence >= 0.8
202    }
203
204    /// Returns whether this is a medium-confidence detection (≥ 0.5).
205    #[must_use]
206    pub fn is_medium_confidence(&self) -> bool {
207        self.confidence >= 0.5
208    }
209
210    /// Returns the recommended memory count based on confidence.
211    ///
212    /// - High confidence (≥ 0.8): 15 memories
213    /// - Medium confidence (≥ 0.5): 10 memories
214    /// - Low confidence (< 0.5): 5 memories
215    #[must_use]
216    pub const fn recommended_memory_count(&self) -> usize {
217        if self.confidence >= 0.8 {
218            15
219        } else if self.confidence >= 0.5 {
220            10
221        } else {
222            5
223        }
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn test_intent_type_parse() {
233        assert_eq!(
234            SearchIntentType::parse("howto"),
235            Some(SearchIntentType::HowTo)
236        );
237        assert_eq!(
238            SearchIntentType::parse("HowTo"),
239            Some(SearchIntentType::HowTo)
240        );
241        assert_eq!(
242            SearchIntentType::parse("how_to"),
243            Some(SearchIntentType::HowTo)
244        );
245        assert_eq!(
246            SearchIntentType::parse("troubleshoot"),
247            Some(SearchIntentType::Troubleshoot)
248        );
249        assert_eq!(SearchIntentType::parse("unknown"), None);
250    }
251
252    #[test]
253    fn test_intent_type_as_str() {
254        assert_eq!(SearchIntentType::HowTo.as_str(), "howto");
255        assert_eq!(SearchIntentType::Location.as_str(), "location");
256        assert_eq!(SearchIntentType::General.as_str(), "general");
257    }
258
259    #[test]
260    fn test_detection_source_display() {
261        assert_eq!(DetectionSource::Keyword.to_string(), "keyword");
262        assert_eq!(DetectionSource::Llm.to_string(), "llm");
263        assert_eq!(DetectionSource::Hybrid.to_string(), "hybrid");
264    }
265
266    #[test]
267    fn test_search_intent_default() {
268        let intent = SearchIntent::default();
269        assert_eq!(intent.intent_type, SearchIntentType::General);
270        assert!(intent.confidence.abs() < f32::EPSILON);
271        assert!(intent.keywords.is_empty());
272        assert!(intent.topics.is_empty());
273        assert_eq!(intent.source, DetectionSource::Keyword);
274    }
275
276    #[test]
277    fn test_search_intent_builder() {
278        let intent = SearchIntent::new(SearchIntentType::HowTo)
279            .with_confidence(0.85)
280            .with_keywords(vec!["how".to_string()])
281            .with_topics(vec!["rust".to_string()])
282            .with_source(DetectionSource::Llm);
283
284        assert_eq!(intent.intent_type, SearchIntentType::HowTo);
285        assert!((intent.confidence - 0.85).abs() < f32::EPSILON);
286        assert_eq!(intent.keywords, vec!["how"]);
287        assert_eq!(intent.topics, vec!["rust"]);
288        assert_eq!(intent.source, DetectionSource::Llm);
289    }
290
291    #[test]
292    fn test_confidence_levels() {
293        let high = SearchIntent::new(SearchIntentType::General).with_confidence(0.9);
294        assert!(high.is_high_confidence());
295        assert!(high.is_medium_confidence());
296        assert_eq!(high.recommended_memory_count(), 15);
297
298        let medium = SearchIntent::new(SearchIntentType::General).with_confidence(0.6);
299        assert!(!medium.is_high_confidence());
300        assert!(medium.is_medium_confidence());
301        assert_eq!(medium.recommended_memory_count(), 10);
302
303        let low = SearchIntent::new(SearchIntentType::General).with_confidence(0.3);
304        assert!(!low.is_high_confidence());
305        assert!(!low.is_medium_confidence());
306        assert_eq!(low.recommended_memory_count(), 5);
307    }
308
309    #[test]
310    fn test_namespace_weights() {
311        let weights = SearchIntentType::Troubleshoot.namespace_weights();
312        assert!(
313            weights
314                .iter()
315                .any(|(ns, w)| *ns == "blockers" && (*w - 2.0).abs() < f32::EPSILON)
316        );
317        assert!(
318            weights
319                .iter()
320                .any(|(ns, w)| *ns == "learnings" && (*w - 1.5).abs() < f32::EPSILON)
321        );
322    }
323}