tj_core/classifier/
http.rs1use 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, }
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}