1use serde::{Deserialize, Serialize};
15
16use crate::IntentPacket;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum ProblemClass {
27 Decision,
30 Research,
33 Evaluation,
36 Planning,
39 Diligence,
42 Incident,
45 Strategy,
48}
49
50impl ProblemClass {
51 #[must_use]
53 pub fn as_str(self) -> &'static str {
54 match self {
55 Self::Decision => "decision",
56 Self::Research => "research",
57 Self::Evaluation => "evaluation",
58 Self::Planning => "planning",
59 Self::Diligence => "diligence",
60 Self::Incident => "incident",
61 Self::Strategy => "strategy",
62 }
63 }
64}
65
66impl std::fmt::Display for ProblemClass {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 f.write_str(self.as_str())
69 }
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct ProblemClassification {
75 pub class: ProblemClass,
76 pub matched_keywords: Vec<String>,
80 pub defaulted: bool,
83 #[serde(default)]
88 pub tiebroken: bool,
89}
90
91#[async_trait::async_trait]
97pub trait ClassifierTiebreaker: Send + Sync {
98 async fn break_tie(&self, text: &str) -> Result<ProblemClass, TiebreakerError>;
108}
109
110#[derive(Debug, Clone, thiserror::Error)]
112pub enum TiebreakerError {
113 #[error("classifier tiebreaker unavailable")]
114 Unavailable,
115 #[error("classifier tiebreaker failed: {0}")]
116 Other(String),
117}
118
119#[must_use]
131pub fn classify(intent: &IntentPacket) -> ProblemClassification {
132 classify_text(&build_haystack(intent))
133}
134
135#[must_use]
139pub fn classify_text(haystack: &str) -> ProblemClassification {
140 let words = tokenize(haystack);
141
142 let mut hits: Vec<(ProblemClass, Vec<String>)> = Vec::new();
143 for class in ALL_CLASSES {
144 let keywords = class_keywords(class);
145 let matched: Vec<String> = words
146 .iter()
147 .filter(|w| keywords.iter().any(|k| word_matches(w, k)))
148 .cloned()
149 .collect();
150 if !matched.is_empty() {
151 hits.push((class, matched));
152 }
153 }
154
155 if hits.is_empty() {
156 return ProblemClassification {
157 class: ProblemClass::Decision,
158 matched_keywords: Vec::new(),
159 defaulted: true,
160 tiebroken: false,
161 };
162 }
163
164 hits.sort_by(|a, b| {
166 let by_count = b.1.len().cmp(&a.1.len());
167 if by_count.is_eq() {
168 tie_rank(a.0).cmp(&tie_rank(b.0))
169 } else {
170 by_count
171 }
172 });
173
174 let (class, matched) = hits.into_iter().next().expect("non-empty");
175 ProblemClassification {
176 class,
177 matched_keywords: matched,
178 defaulted: false,
179 tiebroken: false,
180 }
181}
182
183pub async fn classify_with_tiebreaker<T: ClassifierTiebreaker + ?Sized>(
192 intent: &IntentPacket,
193 tiebreaker: &T,
194) -> ProblemClassification {
195 classify_text_with_tiebreaker(&build_haystack(intent), tiebreaker).await
196}
197
198pub async fn classify_text_with_tiebreaker<T: ClassifierTiebreaker + ?Sized>(
201 haystack: &str,
202 tiebreaker: &T,
203) -> ProblemClassification {
204 let initial = classify_text(haystack);
205 if !initial.defaulted {
206 return initial;
207 }
208 match tiebreaker.break_tie(haystack).await {
209 Ok(class) => ProblemClassification {
210 class,
211 matched_keywords: vec![format!("tiebreaker:{class}")],
212 defaulted: false,
213 tiebroken: true,
214 },
215 Err(_) => initial, }
217}
218
219const ALL_CLASSES: [ProblemClass; 7] = [
220 ProblemClass::Decision,
221 ProblemClass::Research,
222 ProblemClass::Evaluation,
223 ProblemClass::Planning,
224 ProblemClass::Diligence,
225 ProblemClass::Incident,
226 ProblemClass::Strategy,
227];
228
229const TIE_ORDER: [ProblemClass; 7] = [
230 ProblemClass::Incident,
231 ProblemClass::Diligence,
232 ProblemClass::Evaluation,
233 ProblemClass::Decision,
234 ProblemClass::Research,
235 ProblemClass::Planning,
236 ProblemClass::Strategy,
237];
238
239fn tie_rank(class: ProblemClass) -> usize {
240 TIE_ORDER
241 .iter()
242 .position(|c| *c == class)
243 .unwrap_or(usize::MAX)
244}
245
246fn class_keywords(class: ProblemClass) -> &'static [&'static str] {
247 match class {
248 ProblemClass::Decision => &[
249 "decide",
250 "decision",
251 "select",
252 "selection",
253 "choose",
254 "choice",
255 "pick",
256 "approve",
257 "approval",
258 "reject",
259 "rejection",
260 ],
261 ProblemClass::Research => &[
262 "research",
263 "investigate",
264 "investigation",
265 "explore",
266 "exploration",
267 "discover",
268 "find",
269 "study",
270 "learn",
271 "survey",
272 ],
273 ProblemClass::Evaluation => &[
274 "evaluate",
275 "evaluation",
276 "assess",
277 "assessment",
278 "score",
279 "rank",
280 "rating",
281 "rate",
282 "compare",
283 "comparison",
284 "benchmark",
285 "review",
286 ],
287 ProblemClass::Planning => &[
288 "plan",
289 "planning",
290 "schedule",
291 "scheduling",
292 "design",
293 "prepare",
294 "organize",
295 "structure",
296 "roadmap-execution",
297 "rollout",
298 "sequence",
299 ],
300 ProblemClass::Diligence => &[
301 "diligence",
302 "due-diligence",
303 "vet",
304 "audit",
305 "verify",
306 "verification",
307 "validate",
308 "validation",
309 "qualify",
310 "qualification",
311 "background-check",
312 ],
313 ProblemClass::Incident => &[
314 "incident",
315 "outage",
316 "issue",
317 "bug",
318 "fix",
319 "resolve",
320 "emergency",
321 "urgent",
322 "stabilize",
323 "remediate",
324 "rollback",
325 "respond",
326 ],
327 ProblemClass::Strategy => &[
328 "strategy",
329 "strategic",
330 "vision",
331 "roadmap",
332 "long-term",
333 "direction",
334 "positioning",
335 "market-position",
336 "framing",
337 ],
338 }
339}
340
341fn build_haystack(intent: &IntentPacket) -> String {
342 let mut buf = intent.outcome.clone();
343 for c in &intent.constraints {
344 buf.push(' ');
345 buf.push_str(c);
346 }
347 for f in &intent.forbidden {
348 buf.push(' ');
349 buf.push_str(&f.action);
350 }
351 if let Some(s) = intent.context.as_str() {
352 buf.push(' ');
353 buf.push_str(s);
354 }
355 buf
356}
357
358fn tokenize(haystack: &str) -> Vec<String> {
359 haystack
360 .to_lowercase()
361 .split(|c: char| !(c.is_alphanumeric() || c == '-'))
362 .filter(|w| !w.is_empty())
363 .map(str::to_owned)
364 .collect()
365}
366
367fn word_matches(word: &str, keyword: &str) -> bool {
368 word == keyword || word.starts_with(keyword) || keyword.starts_with(word) && word.len() >= 4
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374 use chrono::{Duration, Utc};
375
376 fn intent(outcome: &str) -> IntentPacket {
377 IntentPacket::new(outcome, Utc::now() + Duration::hours(1))
378 }
379
380 #[test]
381 fn decision_keyword_matches() {
382 let i = intent("decide which vendor to approve");
383 let r = classify(&i);
384 assert_eq!(r.class, ProblemClass::Decision);
385 assert!(!r.defaulted);
386 }
387
388 #[test]
389 fn research_keyword_matches() {
390 let i = intent("research the competitive landscape for Q3");
391 assert_eq!(classify(&i).class, ProblemClass::Research);
392 }
393
394 #[test]
395 fn evaluation_keyword_matches() {
396 let i = intent("evaluate vendor proposals against the rubric");
397 assert_eq!(classify(&i).class, ProblemClass::Evaluation);
398 }
399
400 #[test]
401 fn planning_keyword_matches() {
402 let i = intent("plan the Q3 launch sequence");
403 assert_eq!(classify(&i).class, ProblemClass::Planning);
404 }
405
406 #[test]
407 fn diligence_keyword_matches() {
408 let i = intent("vet the acquisition target end-to-end");
409 assert_eq!(classify(&i).class, ProblemClass::Diligence);
410 }
411
412 #[test]
413 fn incident_keyword_matches() {
414 let i = intent("respond to the prod outage and stabilize");
415 assert_eq!(classify(&i).class, ProblemClass::Incident);
416 }
417
418 #[test]
419 fn strategy_keyword_matches() {
420 let i = intent("set our three-year strategic direction");
421 assert_eq!(classify(&i).class, ProblemClass::Strategy);
422 }
423
424 #[test]
425 fn empty_outcome_defaults_to_decision() {
426 let i = intent("doing the thing");
427 let r = classify(&i);
428 assert_eq!(r.class, ProblemClass::Decision);
429 assert!(r.defaulted);
430 assert!(r.matched_keywords.is_empty());
431 }
432
433 #[test]
434 fn incident_wins_tie_against_decision() {
435 let i = intent("decide how to respond to the outage");
436 assert_eq!(classify(&i).class, ProblemClass::Incident);
439 }
440
441 #[test]
442 fn diligence_wins_over_research_when_keywords_co_occur() {
443 let i = intent("vet and research the new partner");
444 let r = classify(&i);
445 assert_eq!(r.class, ProblemClass::Diligence);
449 }
450
451 #[test]
452 fn matched_keywords_recorded() {
453 let i = intent("evaluate and rank the vendor proposals");
454 let r = classify(&i);
455 assert_eq!(r.class, ProblemClass::Evaluation);
456 assert!(r.matched_keywords.iter().any(|w| w == "evaluate"));
457 assert!(r.matched_keywords.iter().any(|w| w == "rank"));
458 }
459
460 #[test]
461 fn constraints_and_forbidden_contribute_to_classification() {
462 let mut i = intent("ship the thing");
463 i.constraints = vec!["audit trail required".into()];
464 assert_eq!(classify(&i).class, ProblemClass::Diligence);
466 }
467
468 #[test]
469 fn problem_class_serde_snake_case() {
470 let s = serde_json::to_string(&ProblemClass::Diligence).unwrap();
471 assert_eq!(s, "\"diligence\"");
472 let back: ProblemClass = serde_json::from_str("\"incident\"").unwrap();
473 assert_eq!(back, ProblemClass::Incident);
474 }
475
476 #[test]
477 fn problem_class_display_matches_as_str() {
478 for class in ALL_CLASSES {
479 assert_eq!(class.to_string(), class.as_str());
480 }
481 }
482
483 struct StubTiebreaker {
486 class: ProblemClass,
487 }
488
489 #[async_trait::async_trait]
490 impl ClassifierTiebreaker for StubTiebreaker {
491 async fn break_tie(&self, _text: &str) -> Result<ProblemClass, TiebreakerError> {
492 Ok(self.class)
493 }
494 }
495
496 struct UnavailableTiebreaker;
497
498 #[async_trait::async_trait]
499 impl ClassifierTiebreaker for UnavailableTiebreaker {
500 async fn break_tie(&self, _text: &str) -> Result<ProblemClass, TiebreakerError> {
501 Err(TiebreakerError::Unavailable)
502 }
503 }
504
505 #[tokio::test]
506 async fn tiebreaker_invoked_only_when_keyword_pass_defaulted() {
507 let tb = StubTiebreaker {
508 class: ProblemClass::Strategy,
509 };
510 let i = intent("evaluate the proposal carefully");
512 let r = classify_with_tiebreaker(&i, &tb).await;
513 assert_eq!(r.class, ProblemClass::Evaluation);
514 assert!(!r.tiebroken);
515 }
516
517 #[tokio::test]
518 async fn tiebreaker_resolves_ambiguous_classification() {
519 let tb = StubTiebreaker {
520 class: ProblemClass::Strategy,
521 };
522 let i = intent("doing the thing today");
523 let r = classify_with_tiebreaker(&i, &tb).await;
524 assert_eq!(r.class, ProblemClass::Strategy);
525 assert!(!r.defaulted);
526 assert!(r.tiebroken);
527 assert!(
528 r.matched_keywords
529 .iter()
530 .any(|k| k.starts_with("tiebreaker:"))
531 );
532 }
533
534 #[tokio::test]
535 async fn tiebreaker_failure_falls_back_to_default() {
536 let tb = UnavailableTiebreaker;
537 let i = intent("doing the thing today");
538 let r = classify_with_tiebreaker(&i, &tb).await;
539 assert_eq!(r.class, ProblemClass::Decision);
540 assert!(r.defaulted);
541 assert!(!r.tiebroken);
542 }
543}