lean_ctx/core/ocla/builtin/
intent_classifier.rs1use crate::core::intent_engine;
7use crate::core::ocla::traits::{IntentClassifier, OclaService};
8use crate::core::ocla::types::{
9 IntentDecision, IntentRequest, OclaCapability, OclaCapabilityKind, OclaResult,
10};
11use crate::core::ocla_bus::{self, OclaEvent};
12
13pub struct BuiltinIntentClassifier;
14
15impl BuiltinIntentClassifier {
16 pub fn new() -> Self {
17 Self
18 }
19}
20
21impl Default for BuiltinIntentClassifier {
22 fn default() -> Self {
23 Self::new()
24 }
25}
26
27impl OclaService for BuiltinIntentClassifier {
28 fn capability(&self) -> OclaCapability {
29 OclaCapability::available(OclaCapabilityKind::IntentClassifier)
30 }
31}
32
33impl IntentClassifier for BuiltinIntentClassifier {
34 fn classify_intent(&self, request: IntentRequest) -> OclaResult<IntentDecision> {
35 let (intent, confidence) = request
36 .candidate_intents
37 .iter()
38 .filter_map(|candidate| engine_score(candidate).map(|score| (candidate, score)))
39 .max_by(|(_, left), (_, right)| left.total_cmp(right))
40 .map_or_else(
41 || fallback_decision(&request.candidate_intents),
42 |(candidate, score)| (candidate.clone(), confidence_milli(score)),
43 );
44
45 ocla_bus::emit(OclaEvent::IntentClassified {
46 tier: intent.clone(),
47 confidence: f64::from(confidence) / 1000.0,
48 reasoning: format!("builtin:{}", request.context.request_id),
49 });
50
51 Ok(IntentDecision {
52 intent,
53 confidence_milli: confidence,
54 rationale_ref: None,
55 })
56 }
57}
58
59fn engine_score(candidate: &str) -> Option<f64> {
60 std::panic::catch_unwind(|| intent_engine::classify(candidate).confidence)
61 .ok()
62 .filter(|confidence| confidence.is_finite())
63}
64
65fn confidence_milli(confidence: f64) -> u16 {
66 (confidence.clamp(0.0, 1.0) * 1000.0).round() as u16
67}
68
69fn fallback_decision(candidates: &[String]) -> (String, u16) {
70 (
71 candidates
72 .first()
73 .cloned()
74 .unwrap_or_else(|| "unknown".to_string()),
75 0,
76 )
77}
78
79#[cfg(test)]
80mod tests {
81 use super::*;
82 use crate::core::ocla::types::OclaRequestContext;
83
84 fn req(intents: &[&str]) -> IntentRequest {
85 IntentRequest {
86 context: OclaRequestContext {
87 request_id: "r1".into(),
88 session_id: "s1".into(),
89 agent_id: "agent-test".into(),
90 content_ref: "ref:test".into(),
91 tenant_id: None,
92 trace_id: "tr-unit".into(),
93 },
94 candidate_intents: intents.iter().map(|s| (*s).to_string()).collect(),
95 }
96 }
97
98 #[test]
99 fn single_candidate_high_confidence() {
100 let classifier = BuiltinIntentClassifier::new();
101 let decision = classifier
102 .classify_intent(req(&["fix the bug in parser.rs"]))
103 .unwrap();
104 assert_eq!(decision.intent, "fix the bug in parser.rs");
105 assert_eq!(decision.confidence_milli, 950);
106 }
107
108 #[test]
109 fn engine_selects_highest_confidence_candidate() {
110 let classifier = BuiltinIntentClassifier::new();
111 let decision = classifier
112 .classify_intent(req(&["review the parser", "fix the bug in parser.rs"]))
113 .unwrap();
114 assert_eq!(decision.intent, "fix the bug in parser.rs");
115 assert_eq!(decision.confidence_milli, 950);
116 }
117
118 #[test]
119 fn empty_candidates_unknown() {
120 let classifier = BuiltinIntentClassifier::new();
121 let decision = classifier.classify_intent(req(&[])).unwrap();
122 assert_eq!(decision.intent, "unknown");
123 assert_eq!(decision.confidence_milli, 0);
124 }
125
126 #[test]
127 fn registry_uses_engine_backed_classifier() {
128 use crate::core::ocla::OclaRegistry;
129
130 let decision = OclaRegistry::with_builtins()
131 .intent_classifier
132 .classify_intent(req(&["explain the cache", "debug the parser crash"]))
133 .unwrap();
134
135 assert_eq!(decision.intent, "debug the parser crash");
136 assert!(
137 decision.confidence_milli > 0,
138 "engine should produce non-zero confidence"
139 );
140 }
141}