use tracing::warn;
use crate::classify::rules::CategoryDef;
use crate::classify::tiers::llm::LlmVerdict;
use crate::classify::tiers::ClassificationResult;
use crate::core::models::ClassificationMethod;
pub const ABSTAIN_LABEL: &str = "unclear";
const COMPLEXITY_GUIDE: &str = "Complexity 1-5: \
1=trivial (config/version bump/typo), 2=simple (single-file bugfix), \
3=moderate (multi-file feature), 4=complex (cross-module/arch change), \
5=highly complex (system design/major refactor).";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LlmOutcome {
Answered,
Abstained,
OutOfSet,
Failed,
}
impl LlmOutcome {
pub fn as_str(self) -> &'static str {
match self {
Self::Answered => "answered",
Self::Abstained => "abstained",
Self::OutOfSet => "out_of_set",
Self::Failed => "failed",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct LlmUsage {
pub input_tokens: u64,
pub output_tokens: u64,
}
#[derive(Debug, Clone)]
pub struct LlmCall {
pub verdict: Option<ClassificationResult>,
pub usage: Option<LlmUsage>,
pub outcome: LlmOutcome,
}
impl LlmCall {
pub fn failed(usage: Option<LlmUsage>) -> Self {
Self {
verdict: None,
usage,
outcome: LlmOutcome::Failed,
}
}
}
pub fn restricted_system_prompt(categories: &[CategoryDef]) -> String {
let mut lines = String::from(
"You are a git commit classifier. Assign the commit to exactly one of these categories:\n",
);
for c in categories {
match c
.description
.as_deref()
.map(str::trim)
.filter(|d| !d.is_empty())
{
Some(d) => {
let flat = d.split_whitespace().collect::<Vec<_>>().join(" ");
lines.push_str(&format!("- {}: {flat}\n", c.name));
}
None => lines.push_str(&format!("- {}\n", c.name)),
}
}
lines.push_str(&format!(
"- {ABSTAIN_LABEL}: none of the categories above clearly fits, or the message \
is too vague to decide.\n"
));
let names: Vec<&str> = categories
.iter()
.map(|c| c.name.as_str())
.chain(std::iter::once(ABSTAIN_LABEL))
.collect();
lines.push_str(&format!(
"Respond with ONLY a JSON object: {{\"category\": \"<one of: {}>\", \
\"subcategory\": null, \"confidence\": 0.0-1.0, \"complexity\": <integer 1-5>}}. \
{COMPLEXITY_GUIDE} No prose, no markdown.",
names.join("|")
));
lines
}
fn parse_verdict(text: &str, lenient: bool) -> Option<LlmVerdict> {
let t = text.trim();
let span = match (t.find('{'), t.rfind('}')) {
(Some(a), Some(b)) if lenient && a < b => &t[a..=b],
_ => t,
};
serde_json::from_str(span)
.map_err(|e| warn!(error = %e, "LLM verdict JSON parse failed"))
.ok()
}
pub fn resolve(
text: Option<&str>,
allowed: Option<&[CategoryDef]>,
usage: Option<LlmUsage>,
) -> LlmCall {
let Some(verdict) = text.and_then(|t| parse_verdict(t, allowed.is_some())) else {
return LlmCall::failed(usage);
};
let mut category = verdict.category.trim().to_string();
let mut subcategory = verdict.subcategory;
if let Some(allowed) = allowed {
if category.eq_ignore_ascii_case(ABSTAIN_LABEL) {
return LlmCall {
verdict: None,
usage,
outcome: LlmOutcome::Abstained,
};
}
match allowed
.iter()
.find(|c| c.name.eq_ignore_ascii_case(&category))
{
Some(c) => {
category = c.name.clone();
subcategory = None;
}
None => {
warn!(category = %category, "LLM category outside the configured set; abstaining");
return LlmCall {
verdict: None,
usage,
outcome: LlmOutcome::OutOfSet,
};
}
}
}
LlmCall {
verdict: Some(ClassificationResult {
category,
subcategory,
top_level: None, confidence: verdict.confidence.clamp(0.0, 1.0),
method: ClassificationMethod::LlmFallback,
ticket_id: None,
complexity: verdict.complexity.map(|v| v.clamp(1, 5)),
}),
usage,
outcome: LlmOutcome::Answered,
}
}