use std::sync::Arc;
use async_trait::async_trait;
use super::{
COMPLEXITY_HINT_LIMIT, ContextDecision, ContextRequest, ContextRouter, clamp_complexity,
};
pub const CLASSIFIER_SYSTEM_PROMPT: &str = "\
You classify user requests for an OS-level chat agent. Reply with JSON only:\n\
{ \"archetype\": \"Reasoning\" | \"CodeGen\" | \"Analysis\" | \"Conversational\" | \"Creative\",\n\
\"complexity\": <number in [-0.3, 0.3]> }\n\
\n\
The `complexity` value adjusts model-tier selection downstream:\n\
-0.3 → strongly cheap/fast\n\
+0.3 → strongly capable/expensive\n\
0.0 → no opinion\n\
\n\
Examples:\n\
\"what's 2+2?\" → {\"archetype\":\"Conversational\",\"complexity\":-0.3}\n\
\"refactor this Rust\" → {\"archetype\":\"CodeGen\",\"complexity\":0.1}\n\
\"prove ZF + AC\" → {\"archetype\":\"Reasoning\",\"complexity\":0.3}\n\
";
pub const DEFAULT_CLASSIFIER_MAX_TOKENS: u32 = 64;
#[derive(Debug, Clone)]
pub struct ClassifierOutput {
pub archetype: String,
pub complexity: f32,
}
#[async_trait]
pub trait Classifier: Send + Sync + 'static {
async fn classify(
&self,
user_content: &str,
max_tokens: u32,
model_override: Option<&str>,
) -> Result<String, String>;
}
pub struct LlmClassifierRouter {
backend: Arc<dyn Classifier>,
model_override: Option<String>,
max_tokens: u32,
}
impl LlmClassifierRouter {
pub fn from_backend(backend: Arc<dyn Classifier>) -> Self {
Self {
backend,
model_override: None,
max_tokens: DEFAULT_CLASSIFIER_MAX_TOKENS,
}
}
#[cfg(feature = "native")]
pub fn new(llm: clawft_service_llm::SharedLlmClient) -> Self {
Self::from_backend(llm)
}
#[must_use]
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model_override = Some(model.into());
self
}
#[must_use]
pub fn with_max_tokens(mut self, tokens: u32) -> Self {
self.max_tokens = tokens;
self
}
}
#[async_trait]
impl ContextRouter for LlmClassifierRouter {
async fn route(&self, request: &ContextRequest) -> ContextDecision {
let raw = match self
.backend
.classify(
&request.content,
self.max_tokens,
self.model_override.as_deref(),
)
.await
{
Ok(body) if body.trim().is_empty() => {
tracing::warn!("LlmClassifierRouter: empty classifier response; falling back");
return ContextDecision::default();
}
Ok(body) => body,
Err(e) => {
tracing::warn!(
error = %e,
"LlmClassifierRouter: classifier call failed; falling back to empty decision"
);
return ContextDecision::default();
}
};
match parse_classifier_envelope(&raw) {
Some(out) => {
let safe = if out.complexity.is_finite() {
out.complexity
.clamp(-COMPLEXITY_HINT_LIMIT, COMPLEXITY_HINT_LIMIT)
} else {
0.0
};
let _ = clamp_complexity(safe);
let mut decision = ContextDecision::new(Vec::new(), None, safe);
if !out.archetype.is_empty() {
decision.archetype = Some(out.archetype);
}
decision
}
None => {
tracing::warn!(
body = %raw,
"LlmClassifierRouter: malformed JSON; falling back"
);
ContextDecision::default()
}
}
}
}
fn parse_classifier_envelope(body: &str) -> Option<ClassifierOutput> {
let trimmed = body.trim();
let stripped = trimmed
.strip_prefix("```json")
.or_else(|| trimmed.strip_prefix("```"))
.unwrap_or(trimmed);
let stripped = stripped.trim_start_matches('\n').trim();
let stripped = stripped.strip_suffix("```").unwrap_or(stripped).trim();
let parsed: serde_json::Value = serde_json::from_str(stripped).ok()?;
let archetype = parsed
.get("archetype")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let complexity = parsed
.get("complexity")
.and_then(|v| v.as_f64())
.map(|f| f as f32)
.unwrap_or(0.0);
Some(ClassifierOutput {
archetype,
complexity,
})
}
#[cfg(feature = "native")]
#[async_trait]
impl Classifier for clawft_service_llm::LlmClient {
async fn classify(
&self,
user_content: &str,
max_tokens: u32,
_model_override: Option<&str>,
) -> Result<String, String> {
use clawft_service_llm::ChatMessage;
let messages = vec![
ChatMessage::system(CLASSIFIER_SYSTEM_PROMPT),
ChatMessage::user(user_content.to_string()),
];
let resp = self
.complete(messages, Some(0.0), Some(max_tokens))
.await
.map_err(|e| e.to_string())?;
let body = resp
.choices
.first()
.map(|c| c.message.content.as_text().into_owned())
.unwrap_or_default();
Ok(body)
}
}
#[cfg(feature = "native")]
#[async_trait]
impl Classifier for tokio::sync::RwLock<clawft_service_llm::LlmClient> {
async fn classify(
&self,
user_content: &str,
max_tokens: u32,
model_override: Option<&str>,
) -> Result<String, String> {
let client = self.read().await;
client
.classify(user_content, max_tokens, model_override)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
struct MockClassifier {
response: Result<String, String>,
}
#[async_trait]
impl Classifier for MockClassifier {
async fn classify(
&self,
_user_content: &str,
_max_tokens: u32,
_model_override: Option<&str>,
) -> Result<String, String> {
self.response.clone()
}
}
fn req(content: &str) -> ContextRequest {
ContextRequest {
content: content.into(),
channel: "panel".into(),
chat_id: "c1".into(),
metadata: Default::default(),
}
}
fn router_with(response: Result<String, String>) -> LlmClassifierRouter {
LlmClassifierRouter::from_backend(Arc::new(MockClassifier { response }))
}
#[tokio::test]
async fn llm_classifier_parses_valid_envelope() {
let r = router_with(Ok(r#"{"archetype":"CodeGen","complexity":0.15}"#.into()));
let d = r.route(&req("refactor this loop")).await;
assert!((d.complexity_hint - 0.15).abs() < 1e-5);
assert_eq!(d.archetype.as_deref(), Some("CodeGen"));
assert!(d.skills.is_empty());
assert!(d.tool_subset.is_none());
}
#[tokio::test]
async fn llm_classifier_clamps_out_of_range_complexity() {
let r = router_with(Ok(r#"{"archetype":"Reasoning","complexity":0.9}"#.into()));
let d = r.route(&req("prove ZF + AC")).await;
assert_eq!(d.complexity_hint, COMPLEXITY_HINT_LIMIT);
assert_eq!(d.archetype.as_deref(), Some("Reasoning"));
}
#[tokio::test]
async fn llm_classifier_clamps_negative_out_of_range() {
let r = router_with(Ok(
r#"{"archetype":"Conversational","complexity":-1.0}"#.into()
));
let d = r.route(&req("hi")).await;
assert_eq!(d.complexity_hint, -COMPLEXITY_HINT_LIMIT);
assert_eq!(d.archetype.as_deref(), Some("Conversational"));
}
#[tokio::test]
async fn llm_classifier_strips_code_fences() {
let body = "```json\n{\"archetype\":\"Analysis\",\"complexity\":0.0}\n```";
let r = router_with(Ok(body.into()));
let d = r.route(&req("explain this graph")).await;
assert_eq!(d.complexity_hint, 0.0);
assert_eq!(d.archetype.as_deref(), Some("Analysis"));
}
#[tokio::test]
async fn llm_classifier_strips_bare_fences() {
let body = "```\n{\"archetype\":\"Creative\",\"complexity\":0.1}\n```";
let r = router_with(Ok(body.into()));
let d = r.route(&req("write a poem")).await;
assert!((d.complexity_hint - 0.1).abs() < 1e-5);
assert_eq!(d.archetype.as_deref(), Some("Creative"));
}
#[tokio::test]
async fn llm_classifier_falls_back_on_malformed_json() {
let r = router_with(Ok("not json at all, sorry".into()));
let d = r.route(&req("x")).await;
assert_eq!(d.complexity_hint, 0.0);
assert!(d.archetype.is_none());
assert!(d.skills.is_empty());
assert!(d.tool_subset.is_none());
}
#[tokio::test]
async fn llm_classifier_falls_back_on_backend_error() {
let r = router_with(Err("transport: connection refused".into()));
let d = r.route(&req("x")).await;
assert_eq!(d.complexity_hint, 0.0);
assert!(d.archetype.is_none());
}
#[tokio::test]
async fn llm_classifier_falls_back_on_empty_body() {
let r = router_with(Ok(String::new()));
let d = r.route(&req("x")).await;
assert_eq!(d.complexity_hint, 0.0);
assert!(d.archetype.is_none());
}
#[tokio::test]
async fn llm_classifier_falls_back_on_whitespace_body() {
let r = router_with(Ok(" \n\t ".into()));
let d = r.route(&req("x")).await;
assert_eq!(d.complexity_hint, 0.0);
assert!(d.archetype.is_none());
}
#[tokio::test]
async fn llm_classifier_handles_missing_archetype() {
let r = router_with(Ok(r#"{"complexity":0.2}"#.into()));
let d = r.route(&req("x")).await;
assert!((d.complexity_hint - 0.2).abs() < 1e-5);
assert!(d.archetype.is_none());
}
#[tokio::test]
async fn llm_classifier_handles_missing_complexity() {
let r = router_with(Ok(r#"{"archetype":"Reasoning"}"#.into()));
let d = r.route(&req("x")).await;
assert_eq!(d.complexity_hint, 0.0);
assert_eq!(d.archetype.as_deref(), Some("Reasoning"));
}
#[tokio::test]
async fn llm_classifier_handles_non_finite_complexity() {
let r = router_with(Ok(r#"{"archetype":"X","complexity":1e308}"#.into()));
let d = r.route(&req("x")).await;
assert_eq!(d.complexity_hint, 0.0);
}
#[test]
fn llm_classifier_builder_methods() {
let backend: Arc<dyn Classifier> = Arc::new(MockClassifier {
response: Ok("{}".into()),
});
let r = LlmClassifierRouter::from_backend(backend)
.with_model("haiku-3.5")
.with_max_tokens(32);
assert_eq!(r.model_override.as_deref(), Some("haiku-3.5"));
assert_eq!(r.max_tokens, 32);
}
#[test]
fn llm_classifier_default_max_tokens() {
let backend: Arc<dyn Classifier> = Arc::new(MockClassifier {
response: Ok("{}".into()),
});
let r = LlmClassifierRouter::from_backend(backend);
assert_eq!(r.max_tokens, DEFAULT_CLASSIFIER_MAX_TOKENS);
assert!(r.model_override.is_none());
}
}