Skip to main content

tj_core/classifier/
mod.rs

1//! Event classifier: takes a chat chunk + recent task context,
2//! returns suggested event_type + task_id + confidence.
3
4use crate::event::{EventType, EvidenceStrength};
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Serialize)]
8pub struct ClassifyInput {
9    pub text: String,
10    pub author_hint: String,
11    pub recent_tasks: Vec<TaskContext>,
12}
13
14#[derive(Debug, Clone, Serialize)]
15pub struct TaskContext {
16    pub task_id: String,
17    pub title: String,
18    pub last_events: Vec<String>,
19}
20
21#[derive(Debug, Clone, Deserialize, Serialize)]
22pub struct ClassifyOutput {
23    pub event_type: EventType,
24    pub task_id_guess: Option<String>,
25    pub confidence: f64,
26    pub evidence_strength: Option<EvidenceStrength>,
27    pub suggested_text: String,
28    /// v0.6.0: optional structured artifacts the classifier extracted
29    /// directly. When absent (old protocol or model didn't bother),
30    /// the journal falls back to regex extraction in
31    /// `db::ingest_new_events`. When present, the two sets are merged
32    /// at ingest time so the model can surface artifacts the regex
33    /// would miss (e.g. ticket ids in non-ASCII brackets).
34    #[serde(default)]
35    pub artifacts: Option<crate::artifacts::Artifacts>,
36}
37
38pub trait Classifier: Send + Sync {
39    fn classify(&self, input: &ClassifyInput) -> anyhow::Result<ClassifyOutput>;
40}
41
42use crate::event::EventStatus;
43
44pub const CONFIDENCE_THRESHOLD: f64 = 0.85;
45
46pub fn decide_status(confidence: f64) -> EventStatus {
47    if confidence >= CONFIDENCE_THRESHOLD {
48        EventStatus::Confirmed
49    } else {
50        EventStatus::Suggested
51    }
52}
53
54pub mod cli;
55pub mod heuristic;
56pub mod http;
57pub mod hybrid;
58pub mod mock;
59pub mod prompt;
60pub mod telemetry;
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65
66    /// Both classifiers must honour `TJ_CLASSIFIER_MODEL`. Combined into a
67    /// single test to avoid env-var races with other tests in this crate;
68    /// inside the test we serialize the read-set-restore steps.
69    #[test]
70    fn tj_classifier_model_env_var_overrides_defaults_for_both_backends() {
71        let prev_model = std::env::var("TJ_CLASSIFIER_MODEL").ok();
72        let prev_key = std::env::var("ANTHROPIC_API_KEY").ok();
73
74        // Unset → defaults.
75        // SAFETY: tests in this crate do not concurrently read these env vars.
76        unsafe {
77            std::env::remove_var("TJ_CLASSIFIER_MODEL");
78        }
79
80        let cli_default = cli::ClaudeCliClassifier::default();
81        assert_eq!(cli_default.model, cli::DEFAULT_MODEL);
82
83        unsafe {
84            std::env::set_var("ANTHROPIC_API_KEY", "test-key-do-not-use");
85        }
86        let http_default = http::AnthropicClassifier::from_env().unwrap();
87        assert_eq!(http_default.model, http::DEFAULT_MODEL);
88
89        // Set → override applied to both.
90        unsafe {
91            std::env::set_var("TJ_CLASSIFIER_MODEL", "sonnet-override");
92        }
93        let cli_override = cli::ClaudeCliClassifier::default();
94        assert_eq!(cli_override.model, "sonnet-override");
95
96        let http_override = http::AnthropicClassifier::from_env().unwrap();
97        assert_eq!(http_override.model, "sonnet-override");
98
99        // Restore.
100        unsafe {
101            match prev_model {
102                Some(v) => std::env::set_var("TJ_CLASSIFIER_MODEL", v),
103                None => std::env::remove_var("TJ_CLASSIFIER_MODEL"),
104            }
105            match prev_key {
106                Some(v) => std::env::set_var("ANTHROPIC_API_KEY", v),
107                None => std::env::remove_var("ANTHROPIC_API_KEY"),
108            }
109        }
110    }
111
112    #[test]
113    fn classify_input_serializes() {
114        let i = ClassifyInput {
115            text: "Adopted Rust for the journal".into(),
116            author_hint: "assistant".into(),
117            recent_tasks: vec![],
118        };
119        let s = serde_json::to_string(&i).unwrap();
120        assert!(s.contains("Adopted Rust"));
121    }
122
123    #[test]
124    fn decide_status_high_confidence_is_confirmed() {
125        assert_eq!(decide_status(0.95), EventStatus::Confirmed);
126        assert_eq!(decide_status(0.85), EventStatus::Confirmed);
127    }
128
129    #[test]
130    fn decide_status_low_confidence_is_suggested() {
131        assert_eq!(decide_status(0.84), EventStatus::Suggested);
132        assert_eq!(decide_status(0.0), EventStatus::Suggested);
133    }
134}