#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
use cratefield_core::{
Answer, AnswerValue, Calibration, Classifier, ClassifierError, ClassifierProfile, Completion,
DEFAULT_MAX_STATE_CHARS, ModelTier, Prompt, Question, TextModel, TextModelError,
validate_questions,
};
use serde_json::{Map, Value};
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::sync::Arc;
const DEFAULT_TIER: ModelTier = ModelTier::Strong;
const SYSTEM_PROMPT: &str = "You are a classification backend. You are given one state and a set of questions about it. For every question choose exactly one of the labels the question offers, and give a probability for every label the question offers; the probabilities of one question sum to 1. Answer with JSON conforming to the schema given, and with nothing else.";
pub struct ClassifierLlm {
model: Arc<dyn TextModel>,
tier: ModelTier,
max_state_chars: usize,
}
impl ClassifierLlm {
#[must_use]
pub fn new(model: Arc<dyn TextModel>) -> Self {
Self {
model,
tier: DEFAULT_TIER,
max_state_chars: DEFAULT_MAX_STATE_CHARS,
}
}
#[must_use]
pub fn tier(mut self, tier: ModelTier) -> Self {
self.tier = tier;
self
}
#[must_use]
pub fn max_state_chars(mut self, max_state_chars: usize) -> Self {
self.max_state_chars = max_state_chars;
self
}
}
#[async_trait::async_trait]
impl Classifier for ClassifierLlm {
fn profile(&self) -> ClassifierProfile {
ClassifierProfile::new(Calibration::LanguageModel, self.max_state_chars)
}
async fn ask(
&self,
state: &str,
questions: &BTreeMap<String, Question>,
) -> Result<BTreeMap<String, Answer>, ClassifierError> {
validate_questions(questions)?;
let profile = self.profile();
let (state, truncated) = profile.truncate(state);
if truncated {
tracing::warn!(
limit = profile.max_state_chars,
"classifier state truncated to this adapter's state budget"
);
}
let prompt = Prompt::new(self.tier)
.system(SYSTEM_PROMPT)
.user(format!(
"STATE:\n{state}\nEND STATE.\n\n{}",
render_questions(questions)
))
.json_schema(answer_schema(questions));
let completion = self.model.complete(&prompt).await.map_err(model_error)?;
let Some(answered) = answer_json(&completion) else {
return Err(ClassifierError::Transport(format!(
"the model answered {} chars, but neither the structured payload nor the text parsed as the answer object",
completion.text.len()
)));
};
answers_of(&answered, questions)
}
}
fn model_error(error: TextModelError) -> ClassifierError {
#[allow(clippy::match_same_arms)]
match error {
TextModelError::NotConfigured => ClassifierError::NotConfigured,
TextModelError::Rejected(message) => ClassifierError::Rejected(message),
TextModelError::Transient { retry_after } => ClassifierError::Transient { retry_after },
TextModelError::Transport(message) => ClassifierError::Transport(message),
TextModelError::Unsupported(_) => ClassifierError::NotConfigured,
_ => ClassifierError::NotConfigured,
}
}
fn render_questions(questions: &BTreeMap<String, Question>) -> String {
let mut rendered = String::from("QUESTIONS:\n");
for (index, (id, question)) in questions.iter().enumerate() {
let _ = writeln!(
rendered,
"{}. id {} ({}): {}",
index + 1,
id,
question.kind(),
question.instructions()
);
match question {
Question::Choice { criteria, .. } => {
rendered.push_str(" pick exactly one of:\n");
for (name, meaning) in criteria {
let _ = writeln!(rendered, " - {name}: {meaning}");
}
}
Question::Score { levels, .. } => {
rendered.push_str(" place on the ordered scale:\n");
for (name, meaning) in levels {
let _ = writeln!(rendered, " - {name}: {meaning}");
}
}
Question::Noul { .. } => {
rendered.push_str(" answer exactly true or false.\n");
}
}
}
rendered
}
fn answer_schema(questions: &BTreeMap<String, Question>) -> Value {
let mut properties = Map::new();
let mut required: Vec<Value> = Vec::new();
for (id, question) in questions {
let labels = question.labels();
let mut probability_properties = Map::new();
for label in &labels {
probability_properties
.insert((*label).to_owned(), serde_json::json!({ "type": "number" }));
}
properties.insert(
id.clone(),
serde_json::json!({
"type": "object",
"additionalProperties": false,
"required": ["value", "probabilities"],
"properties": {
"value": { "enum": labels },
"probabilities": {
"type": "object",
"additionalProperties": false,
"required": labels,
"properties": probability_properties,
},
},
}),
);
required.push(Value::String(id.clone()));
}
serde_json::json!({
"type": "object",
"additionalProperties": false,
"required": ["answers"],
"properties": {
"answers": {
"type": "object",
"additionalProperties": false,
"required": required,
"properties": properties,
},
},
})
}
fn answer_json(completion: &Completion) -> Option<Value> {
if let Some(json) = &completion.json {
return Some(json.clone());
}
serde_json::from_str(strip_code_fence(&completion.text)).ok()
}
fn strip_code_fence(text: &str) -> &str {
let trimmed = text.trim();
let Some(after_open) = trimmed.strip_prefix("```") else {
return trimmed;
};
let body = match after_open.split_once('\n') {
Some((_, body)) => body,
None => after_open,
};
match body.rfind("```") {
Some(end) => body[..end].trim(),
None => body.trim(),
}
}
fn answers_of(
answered: &Value,
questions: &BTreeMap<String, Question>,
) -> Result<BTreeMap<String, Answer>, ClassifierError> {
let Some(answers) = answered.get("answers").and_then(Value::as_object) else {
return Err(ClassifierError::Rejected(
"the model's answer carries no `answers` object".to_owned(),
));
};
for id in answers.keys() {
if !questions.contains_key(id) {
return Err(ClassifierError::Rejected(format!(
"the model answered question {id:?}, which was not asked"
)));
}
}
let mut out = BTreeMap::new();
for (id, question) in questions {
let Some(entry) = answers.get(id) else {
return Err(ClassifierError::Rejected(format!(
"the model answered no question {id:?}"
)));
};
out.insert(id.clone(), answer_for(question, entry)?);
}
Ok(out)
}
fn answer_for(question: &Question, entry: &Value) -> Result<Answer, ClassifierError> {
let object = entry.as_object().ok_or_else(|| {
ClassifierError::Rejected("the answer to a question is not an object".to_owned())
})?;
let chosen = object.get("value").and_then(Value::as_str).ok_or_else(|| {
ClassifierError::Rejected("the answer carries no chosen label as a string".to_owned())
})?;
let reported = object
.get("probabilities")
.and_then(Value::as_object)
.ok_or_else(|| {
ClassifierError::Rejected("the answer carries no `probabilities` object".to_owned())
})?;
let labels = question.labels();
let mut weights = BTreeMap::<String, f64>::new();
for (label, weight) in reported {
if !labels.contains(&label.as_str()) {
return Err(ClassifierError::Rejected(format!(
"the model gave a probability under {label:?}, a label the question never offered"
)));
}
let weight = weight.as_f64().ok_or_else(|| {
ClassifierError::Rejected(format!("the probability under {label:?} is not a number"))
})?;
if !weight.is_finite() {
return Err(ClassifierError::Rejected(format!(
"the probability under {label:?} is not finite"
)));
}
if weight < 0.0 {
return Err(ClassifierError::Rejected(format!(
"the probability under {label:?} is negative"
)));
}
weights.insert(label.clone(), weight);
}
for label in &labels {
if !weights.contains_key(*label) {
return Err(ClassifierError::Rejected(format!(
"the model gave no probability for the offered label {label:?}"
)));
}
}
let total: f64 = weights.values().sum();
if !(total.is_finite() && total > 0.0) {
return Err(ClassifierError::Rejected(
"the probabilities do not add up to a distribution".to_owned(),
));
}
let mut probabilities = BTreeMap::<String, f32>::new();
for (label, weight) in &weights {
#[allow(clippy::cast_possible_truncation)]
let narrowed = (weight / total) as f32;
probabilities.insert(label.clone(), narrowed);
}
if !probabilities.contains_key(chosen) {
return Err(ClassifierError::Rejected(format!(
"the model chose {chosen:?}, a label the question never offered"
)));
}
match question {
Question::Choice { .. } => Ok(Answer::choice(chosen, probabilities)),
Question::Score { .. } => {
let parsed: f32 = chosen.parse().map_err(|_| {
ClassifierError::Rejected(format!(
"the score question chose {chosen:?}, which is not a number on its scale"
))
})?;
if parsed.to_string() == chosen {
Ok(Answer::score(parsed, probabilities))
} else {
let confidence = probabilities[chosen];
Ok(Answer::new(
AnswerValue::Score(parsed),
probabilities,
confidence,
))
}
}
Question::Noul { .. } => {
let value = chosen == "true";
Ok(Answer::noul(value, probabilities))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_code_fence_is_stripped_and_bare_json_is_left_alone() {
let fenced = "```json\n{\"answers\": {}}\n```";
assert_eq!(strip_code_fence(fenced), "{\"answers\": {}}");
let bare_fence = "```\n{\"answers\": {}}\n```";
assert_eq!(strip_code_fence(bare_fence), "{\"answers\": {}}");
let padded = " \n{\"answers\": {}}\n ";
assert_eq!(strip_code_fence(padded), "{\"answers\": {}}");
assert_eq!(strip_code_fence("{\"answers\": {}}"), "{\"answers\": {}}");
assert_eq!(strip_code_fence("```json\n{\"a\": 1}"), "{\"a\": 1}");
}
#[test]
fn every_text_model_error_maps_one_to_one() {
use std::time::Duration;
assert_eq!(
model_error(TextModelError::NotConfigured),
ClassifierError::NotConfigured
);
assert_eq!(
model_error(TextModelError::Rejected("4xx words".to_owned())),
ClassifierError::Rejected("4xx words".to_owned())
);
assert_eq!(
model_error(TextModelError::Transient {
retry_after: Some(Duration::from_secs(7))
}),
ClassifierError::Transient {
retry_after: Some(Duration::from_secs(7))
}
);
assert_eq!(
model_error(TextModelError::Transient { retry_after: None }),
ClassifierError::Transient { retry_after: None }
);
assert_eq!(
model_error(TextModelError::Transport("boom".to_owned())),
ClassifierError::Transport("boom".to_owned())
);
assert_eq!(
model_error(TextModelError::Unsupported(
cratefield_core::Capability::Tools
)),
ClassifierError::NotConfigured
);
}
#[test]
fn the_schema_pins_each_offered_label_and_nothing_else() {
let questions = BTreeMap::from([
(
"topic".to_owned(),
Question::Choice {
instructions: "Which?".to_owned(),
criteria: BTreeMap::from([
("billing".to_owned(), "money".to_owned()),
("bugs".to_owned(), "broken".to_owned()),
]),
},
),
(
"angry".to_owned(),
Question::Noul {
instructions: "Angry?".to_owned(),
},
),
]);
let schema = answer_schema(&questions);
let answers = &schema["properties"]["answers"];
assert_eq!(answers["required"], serde_json::json!(["angry", "topic"]));
let topic = &answers["properties"]["topic"];
assert_eq!(
topic["properties"]["value"]["enum"],
serde_json::json!(["billing", "bugs"])
);
assert_eq!(
topic["properties"]["probabilities"]["required"],
serde_json::json!(["billing", "bugs"])
);
let angry = &answers["properties"]["angry"];
assert_eq!(
angry["properties"]["value"]["enum"],
serde_json::json!(["true", "false"])
);
let rendered = schema.to_string();
assert!(
!rendered.contains("spam"),
"an unoffered label is not pinned"
);
assert_eq!(schema["additionalProperties"], serde_json::json!(false));
}
}