1use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum QueryMode {
15 SemanticLookup,
17 EntityLookup {
19 mention: String,
21 },
22 TemporalLookup {
24 temporal_expr: String,
26 },
27 Mixed {
29 components: Vec<QueryMode>,
31 },
32}
33
34impl QueryMode {
35 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 pub fn from_kind(kind: &str) -> Option<Self> {
51 match kind {
52 "semantic" => Some(Self::SemanticLookup),
53 "entity" => None, "temporal" => None, "mixed" => None, _ => 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#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ClassifyResult {
74 pub mode: QueryMode,
76 pub confidence: f32,
79 pub reason: Option<String>,
81}
82
83pub fn classify(query: &str) -> ClassifyResult {
91 let mut components = Vec::new();
92
93 for mention in extract_entity_mentions(query) {
95 components.push(QueryMode::EntityLookup { mention });
96 }
97
98 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
132fn extract_entity_mentions(query: &str) -> Vec<String> {
138 let mut mentions = Vec::new();
139
140 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 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
188fn 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}