tj_core/classifier/
http.rs1use super::*;
4use anyhow::{anyhow, Context};
5use serde::{Deserialize, Serialize};
6use std::time::Duration;
7
8pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);
12
13pub const DEFAULT_MODEL: &str = "claude-haiku-4-5-20251001";
15
16pub struct AnthropicClassifier {
17 pub api_key: String,
18 pub model: String,
19 pub base_url: String, pub timeout: Duration,
21}
22
23impl AnthropicClassifier {
24 pub fn from_env() -> anyhow::Result<Self> {
25 let api_key =
26 std::env::var("ANTHROPIC_API_KEY").context("ANTHROPIC_API_KEY env var not set")?;
27 let model = std::env::var("TJ_CLASSIFIER_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into());
28 Ok(Self {
29 api_key,
30 model,
31 base_url: "https://api.anthropic.com".into(),
32 timeout: DEFAULT_TIMEOUT,
33 })
34 }
35}
36
37#[derive(Serialize)]
38struct MessagesRequest<'a> {
39 model: &'a str,
40 max_tokens: u32,
41 messages: Vec<MessageIn<'a>>,
42}
43#[derive(Serialize)]
44struct MessageIn<'a> {
45 role: &'a str,
46 content: &'a str,
47}
48#[derive(Deserialize)]
49struct MessagesResponse {
50 content: Vec<ContentBlock>,
51}
52#[derive(Deserialize)]
53struct ContentBlock {
54 #[serde(rename = "type")]
55 kind: String,
56 #[serde(default)]
57 text: String,
58}
59
60impl Classifier for AnthropicClassifier {
61 fn classify(&self, input: &ClassifyInput) -> anyhow::Result<ClassifyOutput> {
62 let prompt = crate::classifier::prompt::build(input);
63 let body = MessagesRequest {
64 model: &self.model,
65 max_tokens: 256,
66 messages: vec![MessageIn {
67 role: "user",
68 content: &prompt,
69 }],
70 };
71
72 let url = format!("{}/v1/messages", self.base_url);
73 let resp: MessagesResponse = ureq::post(&url)
74 .timeout(self.timeout)
75 .set("x-api-key", &self.api_key)
76 .set("anthropic-version", "2023-06-01")
77 .set("content-type", "application/json")
78 .send_json(serde_json::to_value(&body)?)
79 .context("Anthropic API request failed")?
80 .into_json()
81 .context("decode Anthropic response")?;
82
83 let text = resp
84 .content
85 .iter()
86 .find(|b| b.kind == "text")
87 .map(|b| b.text.clone())
88 .ok_or_else(|| anyhow!("no text content in response"))?;
89
90 super::parse_verdict(&text)
91 }
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97 use crate::event::EventType;
98
99 #[test]
100 fn classifier_parses_anthropic_response() {
101 let mut server = mockito::Server::new();
102 let url = server.url();
103
104 let body = serde_json::json!({
105 "id": "msg_test",
106 "type": "message",
107 "role": "assistant",
108 "model": "claude-haiku-4-5-20251001",
109 "content": [
110 { "type": "text", "text": "{\"event_type\":\"decision\",\"task_id_guess\":\"tj-x\",\"confidence\":0.93,\"evidence_strength\":null,\"suggested_text\":\"Adopt Rust.\"}" }
111 ],
112 "stop_reason": "end_turn"
113 });
114
115 let mock = server
116 .mock("POST", "/v1/messages")
117 .with_status(200)
118 .with_header("content-type", "application/json")
119 .with_body(body.to_string())
120 .create();
121
122 let c = AnthropicClassifier {
123 api_key: "test".into(),
124 model: "claude-haiku-4-5-20251001".into(),
125 base_url: url,
126 timeout: DEFAULT_TIMEOUT,
127 };
128 let out = c
129 .classify(&ClassifyInput {
130 text: "We adopted Rust.".into(),
131 author_hint: "assistant".into(),
132 recent_tasks: vec![],
133 })
134 .unwrap();
135
136 assert_eq!(out.event_type, EventType::Decision);
137 assert_eq!(out.task_id_guess.as_deref(), Some("tj-x"));
138 assert!((out.confidence - 0.93).abs() < 1e-6);
139 mock.assert();
140 }
141
142 #[test]
143 fn classifier_times_out_on_unresponsive_server() {
144 use std::net::TcpListener;
145 use std::time::Instant;
146
147 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
151 let addr = listener.local_addr().unwrap();
152 let url = format!("http://{addr}");
153
154 let c = AnthropicClassifier {
155 api_key: "test".into(),
156 model: "test-model".into(),
157 base_url: url,
158 timeout: Duration::from_millis(300),
159 };
160
161 let start = Instant::now();
162 let res = c.classify(&ClassifyInput {
163 text: "x".into(),
164 author_hint: "user".into(),
165 recent_tasks: vec![],
166 });
167 let elapsed = start.elapsed();
168
169 assert!(res.is_err(), "expected a timeout error, got Ok");
170 assert!(
171 elapsed < Duration::from_secs(3),
172 "expected timeout near 300ms, got {elapsed:?}"
173 );
174
175 drop(listener);
177 }
178}