Skip to main content

tj_core/classifier/
http.rs

1//! Anthropic API HTTP client implementing Classifier.
2
3use super::*;
4use anyhow::{anyhow, Context};
5use serde::{Deserialize, Serialize};
6
7pub struct AnthropicClassifier {
8    pub api_key: String,
9    pub model: String,
10    pub base_url: String, // overridable for tests
11}
12
13impl AnthropicClassifier {
14    pub fn from_env() -> anyhow::Result<Self> {
15        let api_key =
16            std::env::var("ANTHROPIC_API_KEY").context("ANTHROPIC_API_KEY env var not set")?;
17        Ok(Self {
18            api_key,
19            model: "claude-haiku-4-5-20251001".into(),
20            base_url: "https://api.anthropic.com".into(),
21        })
22    }
23}
24
25#[derive(Serialize)]
26struct MessagesRequest<'a> {
27    model: &'a str,
28    max_tokens: u32,
29    messages: Vec<MessageIn<'a>>,
30}
31#[derive(Serialize)]
32struct MessageIn<'a> {
33    role: &'a str,
34    content: &'a str,
35}
36#[derive(Deserialize)]
37struct MessagesResponse {
38    content: Vec<ContentBlock>,
39}
40#[derive(Deserialize)]
41struct ContentBlock {
42    #[serde(rename = "type")]
43    kind: String,
44    #[serde(default)]
45    text: String,
46}
47
48impl Classifier for AnthropicClassifier {
49    fn classify(&self, input: &ClassifyInput) -> anyhow::Result<ClassifyOutput> {
50        let prompt = crate::classifier::prompt::build(input);
51        let body = MessagesRequest {
52            model: &self.model,
53            max_tokens: 256,
54            messages: vec![MessageIn {
55                role: "user",
56                content: &prompt,
57            }],
58        };
59
60        let url = format!("{}/v1/messages", self.base_url);
61        let resp: MessagesResponse = ureq::post(&url)
62            .set("x-api-key", &self.api_key)
63            .set("anthropic-version", "2023-06-01")
64            .set("content-type", "application/json")
65            .send_json(serde_json::to_value(&body)?)
66            .context("Anthropic API request failed")?
67            .into_json()
68            .context("decode Anthropic response")?;
69
70        let text = resp
71            .content
72            .iter()
73            .find(|b| b.kind == "text")
74            .map(|b| b.text.clone())
75            .ok_or_else(|| anyhow!("no text content in response"))?;
76
77        let json_str = text
78            .trim()
79            .trim_start_matches("```json")
80            .trim_start_matches("```")
81            .trim_end_matches("```")
82            .trim();
83        let out: ClassifyOutput = serde_json::from_str(json_str)
84            .with_context(|| format!("classifier JSON parse failed; got: {json_str}"))?;
85        Ok(out)
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use crate::event::EventType;
93
94    #[test]
95    fn classifier_parses_anthropic_response() {
96        let mut server = mockito::Server::new();
97        let url = server.url();
98
99        let body = serde_json::json!({
100            "id": "msg_test",
101            "type": "message",
102            "role": "assistant",
103            "model": "claude-haiku-4-5-20251001",
104            "content": [
105                { "type": "text", "text": "{\"event_type\":\"decision\",\"task_id_guess\":\"tj-x\",\"confidence\":0.93,\"evidence_strength\":null,\"suggested_text\":\"Adopt Rust.\"}" }
106            ],
107            "stop_reason": "end_turn"
108        });
109
110        let mock = server
111            .mock("POST", "/v1/messages")
112            .with_status(200)
113            .with_header("content-type", "application/json")
114            .with_body(body.to_string())
115            .create();
116
117        let c = AnthropicClassifier {
118            api_key: "test".into(),
119            model: "claude-haiku-4-5-20251001".into(),
120            base_url: url,
121        };
122        let out = c
123            .classify(&ClassifyInput {
124                text: "We adopted Rust.".into(),
125                author_hint: "assistant".into(),
126                recent_tasks: vec![],
127            })
128            .unwrap();
129
130        assert_eq!(out.event_type, EventType::Decision);
131        assert_eq!(out.task_id_guess.as_deref(), Some("tj-x"));
132        assert!((out.confidence - 0.93).abs() < 1e-6);
133        mock.assert();
134    }
135}