Skip to main content

lean_ctx/core/triage/
semantic_analyzer.rs

1//! Semantic triage hook for the Phase 5 ONNX classifier.
2
3use std::path::{Path, PathBuf};
4
5use super::{
6    ProfileHypothesis, TaskAnalysisInput, TaskAnalyzer, TriageBackendLocal, TriageError,
7    model_loader::ModelLoader, profile::TaskProfileLocal,
8};
9
10#[derive(Debug)]
11pub struct SemanticAnalyzer {
12    model_path: PathBuf,
13    model: Option<ModelLoader>,
14}
15
16impl SemanticAnalyzer {
17    /// Load the managed model from `<data_dir>/models/triage-v1.onnx`.
18    pub fn from_data_dir(data_dir: &Path) -> Self {
19        Self::from_model_path(super::model_loader::model_path(data_dir))
20    }
21
22    pub fn from_model_path(model_path: impl Into<PathBuf>) -> Self {
23        let model_path = model_path.into();
24        Self {
25            model: ModelLoader::from_model_path(&model_path).ok().flatten(),
26            model_path,
27        }
28    }
29
30    pub fn model_path(&self) -> &Path {
31        &self.model_path
32    }
33
34    pub fn model_available(&self) -> bool {
35        self.model.is_some()
36    }
37
38    fn fallback(&self) -> ProfileHypothesis {
39        let profile = TaskProfileLocal {
40            task_class: "coding".into(),
41            intent: "needs_semantic_model".into(),
42            confidence_milli: 0,
43            ..Default::default()
44        };
45        ProfileHypothesis {
46            profile,
47            confidence_milli: 0,
48            backend: TriageBackendLocal::Semantic,
49        }
50    }
51}
52
53impl TaskAnalyzer for SemanticAnalyzer {
54    fn analyze(&self, input: &TaskAnalysisInput) -> Result<ProfileHypothesis, TriageError> {
55        Ok(self
56            .model
57            .as_ref()
58            .and_then(|model| model.infer(input).ok())
59            .unwrap_or_else(|| self.fallback()))
60    }
61
62    fn name(&self) -> &'static str {
63        "semantic"
64    }
65
66    fn shadow_enabled(&self) -> bool {
67        self.model_available()
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    #[test]
76    fn missing_model_returns_low_confidence_fallback() {
77        let analyzer = SemanticAnalyzer::from_model_path("missing-triage-model.onnx");
78        let result = analyzer.analyze(&TaskAnalysisInput::default()).unwrap();
79
80        assert!(!analyzer.model_available());
81        assert_eq!(result.confidence_milli, 0);
82        assert_eq!(result.profile.intent, "needs_semantic_model");
83    }
84
85    #[test]
86    fn data_dir_constructor_uses_managed_model_path() {
87        let data = tempfile::tempdir().unwrap();
88        let analyzer = SemanticAnalyzer::from_data_dir(data.path());
89        assert_eq!(
90            analyzer.model_path(),
91            data.path().join("models/triage-v1.onnx")
92        );
93        assert!(!analyzer.model_available());
94    }
95}