Skip to main content

knowledge_runtime/query/
classify.rs

1//! Rule-based intent classification for incoming queries.
2//!
3//! Maps raw query text to a `QueryMode` that drives route planning.
4
5use serde::{Deserialize, Serialize};
6
7/// Intent classification for an incoming query.
8///
9/// The classifier maps raw query text to a `QueryMode` that determines
10/// which route legs to plan. This is a rule-based heuristic; LLM-based
11/// classification is a future extension point.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum QueryMode {
15    /// Pure semantic / free-text similarity search.
16    SemanticLookup,
17    /// The query names a specific entity (person, function, file, etc.).
18    EntityLookup {
19        /// The raw entity mention extracted from the query.
20        mention: String,
21    },
22    /// The query involves time constraints ("last week", "before v2.0").
23    TemporalLookup {
24        /// Raw temporal expression extracted from the query.
25        temporal_expr: String,
26    },
27    /// Multiple intents detected — will fan out to multiple route legs.
28    Mixed {
29        /// Component intents.
30        components: Vec<QueryMode>,
31    },
32}
33
34impl QueryMode {
35    /// Stable discriminant string.
36    pub fn kind(&self) -> &'static str {
37        match self {
38            Self::SemanticLookup => "semantic",
39            Self::EntityLookup { .. } => "entity",
40            Self::TemporalLookup { .. } => "temporal",
41            Self::Mixed { .. } => "mixed",
42        }
43    }
44
45    /// Parse a `QueryMode` from a kind string.
46    ///
47    /// Returns `None` for unknown kinds — this is the correct fallback for
48    /// unknown variant names (the `unreachable!` sites were never reachable in
49    /// practice; this makes the exhaustive match non-trapping).
50    pub fn from_kind(kind: &str) -> Option<Self> {
51        match kind {
52            "semantic" => Some(Self::SemanticLookup),
53            "entity" => None, // EntityLookup needs a mention — not reconstructable from kind alone
54            "temporal" => None, // TemporalLookup needs a temporal_expr
55            "mixed" => None,  // Mixed needs components
56            _ => None,
57        }
58    }
59}
60
61#[cfg(test)]
62impl std::fmt::Display for QueryMode {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        write!(f, "{:?}", self)
65    }
66}
67
68#[cfg(test)]
69impl std::error::Error for QueryMode {}
70
71/// Classification result with confidence metadata.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ClassifyResult {
74    /// Determined query mode.
75    pub mode: QueryMode,
76    /// Confidence in the classification (0.0 to 1.0).
77    /// A value of 1.0 means the classifier is certain (e.g. exact entity match).
78    pub confidence: f32,
79    /// Optional reasoning for observability.
80    pub reason: Option<String>,
81}
82
83/// Classify a raw query string into a `QueryMode`.
84///
85/// Current implementation uses simple heuristics:
86/// - Quoted strings or `@mentions` → `EntityLookup`
87/// - Temporal keywords → `TemporalLookup`
88/// - Multiple signals → `Mixed`
89/// - Default → `SemanticLookup`
90pub fn classify(query: &str) -> ClassifyResult {
91    let mut components = Vec::new();
92
93    // Check for entity mentions: quoted strings or @-prefixed tokens.
94    for mention in extract_entity_mentions(query) {
95        components.push(QueryMode::EntityLookup { mention });
96    }
97
98    // Check for temporal expressions.
99    if let Some(temporal_expr) = extract_temporal_expr(query) {
100        components.push(QueryMode::TemporalLookup { temporal_expr });
101    }
102
103    match components.len() {
104        0 => ClassifyResult {
105            mode: QueryMode::SemanticLookup,
106            confidence: 0.8,
107            reason: Some("no entity or temporal signals detected".into()),
108        },
109        1 => {
110            if let Some(mode) = components.pop() {
111                ClassifyResult {
112                    confidence: 0.7,
113                    reason: Some(format!("single signal: {}", mode.kind())),
114                    mode,
115                }
116            } else {
117                ClassifyResult {
118                    mode: QueryMode::SemanticLookup,
119                    confidence: 0.8,
120                    reason: Some("no entity or temporal signals detected".into()),
121                }
122            }
123        }
124        _ => ClassifyResult {
125            mode: QueryMode::Mixed { components },
126            confidence: 0.6,
127            reason: Some("multiple signal types detected".into()),
128        },
129    }
130}
131
132/// Extract an entity mention from a query.
133///
134/// Recognizes:
135/// - `@name` tokens
136/// - `"quoted strings"`
137fn extract_entity_mentions(query: &str) -> Vec<String> {
138    let mut mentions = Vec::new();
139
140    // @-mention: first @-prefixed word
141    for word in query.split_whitespace() {
142        if let Some(name) = word.strip_prefix('@') {
143            if !name.is_empty() {
144                mentions.push(
145                    name.trim_matches(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
146                        .to_string(),
147                );
148            }
149        }
150    }
151
152    // Quoted strings, allowing escaped quotes.
153    let mut in_quote = false;
154    let mut escaped = false;
155    let mut current = String::new();
156    for ch in query.chars() {
157        if escaped {
158            if in_quote {
159                current.push(ch);
160            }
161            escaped = false;
162            continue;
163        }
164        if ch == '\\' {
165            escaped = true;
166            continue;
167        }
168        if ch == '"' {
169            if in_quote && !current.is_empty() {
170                mentions.push(std::mem::take(&mut current));
171            } else {
172                current.clear();
173            }
174            in_quote = !in_quote;
175            continue;
176        }
177        if in_quote {
178            current.push(ch);
179        }
180    }
181
182    mentions.retain(|mention| !mention.is_empty());
183    mentions.sort();
184    mentions.dedup();
185    mentions
186}
187
188/// Extract a temporal expression from a query.
189///
190/// Recognizes common temporal keywords. Full NLP temporal parsing is deferred.
191fn extract_temporal_expr(query: &str) -> Option<String> {
192    let lower = query.to_lowercase();
193    let temporal_markers = [
194        "yesterday",
195        "last week",
196        "last month",
197        "last year",
198        "today",
199        "this week",
200        "this month",
201        "before",
202        "after",
203        "since",
204        "between",
205        "recent",
206        "recently",
207        "latest",
208        "oldest",
209        "earlier",
210    ];
211
212    let mut markers = temporal_markers.to_vec();
213    markers.sort_by_key(|marker| std::cmp::Reverse(marker.len()));
214    for marker in markers {
215        if lower.contains(marker) {
216            return Some(marker.to_string());
217        }
218    }
219    None
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn pure_semantic_query() {
228        let result = classify("how does the search pipeline work");
229        assert_eq!(result.mode.kind(), "semantic");
230    }
231
232    #[test]
233    fn at_mention_triggers_entity() {
234        let result = classify("what does @reembed_all do");
235        match &result.mode {
236            QueryMode::EntityLookup { mention } => assert_eq!(mention, "reembed_all"),
237            other => unreachable!("QueryMode::EntityLookup expected, got {other:?}"),
238        }
239    }
240
241    #[test]
242    fn quoted_string_triggers_entity() {
243        let result = classify("find \"MemoryStore\" usage");
244        match &result.mode {
245            QueryMode::EntityLookup { mention } => assert_eq!(mention, "MemoryStore"),
246            other => unreachable!("QueryMode::EntityLookup expected, got {other:?}"),
247        }
248    }
249
250    #[test]
251    fn temporal_keyword_triggers_temporal() {
252        let result = classify("what changed last week");
253        match &result.mode {
254            QueryMode::TemporalLookup { temporal_expr } => {
255                assert_eq!(temporal_expr, "last week");
256            }
257            other => unreachable!("QueryMode::TemporalLookup expected, got {other:?}"),
258        }
259    }
260
261    #[test]
262    fn mixed_entity_and_temporal() {
263        let result = classify("what did @alice do last week");
264        assert_eq!(result.mode.kind(), "mixed");
265    }
266
267    #[test]
268    fn multiple_entity_mentions_are_preserved() {
269        let result = classify(r#"compare @alice and @bob with "MemoryStore""#);
270        match &result.mode {
271            QueryMode::Mixed { components } => {
272                let mentions: Vec<_> = components
273                    .iter()
274                    .filter_map(|component| match component {
275                        QueryMode::EntityLookup { mention } => Some(mention.as_str()),
276                        _ => None,
277                    })
278                    .collect();
279                assert_eq!(mentions, vec!["MemoryStore", "alice", "bob"]);
280            }
281            other => unreachable!("QueryMode::Mixed expected, got {other:?}"),
282        }
283    }
284
285    #[test]
286    fn temporal_marker_prefers_more_specific_phrase() {
287        let result = classify("summarize last week and recent changes");
288        match &result.mode {
289            QueryMode::TemporalLookup { temporal_expr } => {
290                assert_eq!(temporal_expr, "last week");
291            }
292            other => unreachable!("QueryMode::TemporalLookup expected, got {other:?}"),
293        }
294    }
295}