use reqwest::Client;
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
use crate::classify::tiers::ClassificationResult;
use crate::core::models::ClassificationMethod;
const DEFAULT_ENDPOINT: &str = "https://api.openai.com/v1/chat/completions";
const SYSTEM_PROMPT: &str = "You are a git commit classifier. Respond with ONLY a JSON \
object: {\"category\": \"feature|bugfix|chore|documentation|refactor|test|ci|performance|style|build|revert|merge|breaking|uncategorized\", \
\"subcategory\": \"optional string or null\", \"confidence\": 0.0-1.0}. No prose, no markdown.";
pub struct LlmClassifier {
client: Client,
model: String,
api_key: Option<String>,
endpoint: String,
}
impl LlmClassifier {
pub fn new(model: &str, api_key: Option<String>) -> Self {
Self {
client: Client::new(),
model: model.to_string(),
api_key,
endpoint: DEFAULT_ENDPOINT.to_string(),
}
}
pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = endpoint.into();
self
}
pub async fn classify(&self, message: &str) -> Option<ClassificationResult> {
let api_key = self.api_key.as_deref()?;
let body = ChatRequest {
model: &self.model,
messages: vec![
ChatMessage {
role: "system",
content: SYSTEM_PROMPT.to_string(),
},
ChatMessage {
role: "user",
content: format!("Classify this commit message:\n\n{message}"),
},
],
temperature: 0.0,
response_format: Some(ResponseFormat {
kind: "json_object".to_string(),
}),
};
let response = match self
.client
.post(&self.endpoint)
.bearer_auth(api_key)
.json(&body)
.send()
.await
{
Ok(r) => r,
Err(e) => {
warn!(error = %e, "LLM request failed");
return None;
}
};
if !response.status().is_success() {
warn!(status = %response.status(), "LLM returned non-success status");
return None;
}
let parsed: ChatResponse = match response.json().await {
Ok(j) => j,
Err(e) => {
warn!(error = %e, "LLM response JSON decode failed");
return None;
}
};
let content = parsed.choices.first()?.message.content.clone();
debug!(content = %content, "LLM raw response");
let verdict: LlmVerdict = serde_json::from_str(&content)
.map_err(|e| warn!(error = %e, "LLM JSON parse failed"))
.ok()?;
Some(ClassificationResult {
category: verdict.category,
subcategory: verdict.subcategory,
confidence: verdict.confidence.clamp(0.0, 1.0),
method: ClassificationMethod::LlmFallback,
ticket_id: None,
})
}
}
#[derive(Serialize)]
struct ChatRequest<'a> {
model: &'a str,
messages: Vec<ChatMessage>,
temperature: f64,
#[serde(skip_serializing_if = "Option::is_none")]
response_format: Option<ResponseFormat>,
}
#[derive(Serialize)]
struct ChatMessage {
role: &'static str,
content: String,
}
#[derive(Serialize)]
struct ResponseFormat {
#[serde(rename = "type")]
kind: String,
}
#[derive(Deserialize)]
struct ChatResponse {
choices: Vec<ChatChoice>,
}
#[derive(Deserialize)]
struct ChatChoice {
message: ChatChoiceMessage,
}
#[derive(Deserialize)]
struct ChatChoiceMessage {
content: String,
}
#[derive(Deserialize)]
struct LlmVerdict {
category: String,
#[serde(default)]
subcategory: Option<String>,
#[serde(default = "default_confidence")]
confidence: f64,
}
fn default_confidence() -> f64 {
0.5
}