Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-review-classifier/src/answer.rs
atlas ce7b3e055f review-classifier: ask a check once more when its answer isn't valid JSON
An answer that fails to validate is asked for a second time, for that
classification only, with a reminder to reply with the JSON object alone.
The second ask comes out of the classification's CLASSIFICATION_DEADLINE:
it gets what the first ask left, and is skipped when less than 20 seconds
remain, so RUN_DEADLINE's bound still holds. A failed ask (runtime error,
timeout) is not repeated. If the second answer does not validate either,
the row has no answer, as before.

Model::ask takes the deadline for the ask; the ACP runtime no longer holds
its own.

The parser also takes the first fenced block of a reply that has prose
around the fence; prose around a bare object is still rejected.
2026-10-16 16:55:07 +02:00

176 lines
6 KiB
Rust

//! Reading the model's answer to one classification.
//!
//! An answer that does not parse, names an unknown verdict or carries a
//! certainty outside 0..=100 is an error, and the row it belongs to is
//! excluded rather than guessed at. A reason over the cap is not an error: it
//! is cut to the cap, so the verdict and certainty survive.
use serde::Deserialize;
use swarm_queue_client::review_classifier::Verdict;
/// The longest reason, in characters, an answer carries; a longer one is
/// truncated to this, ending in `…`.
pub const REASON_MAX_CHARS: usize = 500;
/// The length the prompt asks a reason to stay under. Below `REASON_MAX_CHARS`
/// so a model that overshoots the ask still lands inside the cap, uncut.
pub const REASON_ASKED_CHARS: usize = 350;
const _: () = assert!(REASON_ASKED_CHARS < REASON_MAX_CHARS);
/// One validated answer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Answer {
pub verdict: Verdict,
pub certainty: u8,
pub reason: String,
}
/// The answer's fields as the model wrote them, before validation.
#[derive(Deserialize)]
struct Raw {
verdict: String,
certainty: serde_json::Number,
reason: String,
}
/// Parse `text`, the model's final message. A code fence around the object is
/// tolerated, with prose around the fence; prose around a bare object is not.
///
/// # Errors
/// A message describing what is wrong with the answer, without quoting it.
pub fn parse(text: &str) -> Result<Answer, String> {
let body = unfence(text.trim());
let raw: Raw = serde_json::from_str(body)
.map_err(|e| format!("the answer is not the JSON asked for: {e}"))?;
let verdict = match raw.verdict.as_str() {
"match" => Verdict::Match,
"no-match" => Verdict::NoMatch,
"not-applicable" => Verdict::NotApplicable,
other => {
return Err(format!(
"the answer's verdict {other:?} is not one of the three"
));
}
};
let certainty = raw
.certainty
.as_u64()
.and_then(|c| u8::try_from(c).ok())
.filter(|c| *c <= 100)
.ok_or_else(|| {
format!(
"the answer's certainty {} is not a whole 0-100",
raw.certainty
)
})?;
Ok(Answer {
verdict,
certainty,
reason: cap_reason(raw.reason),
})
}
/// `reason` cut to `REASON_MAX_CHARS` characters, the last being `…`, if longer.
fn cap_reason(reason: String) -> String {
if reason.chars().count() <= REASON_MAX_CHARS {
return reason;
}
let mut cut: String = reason.chars().take(REASON_MAX_CHARS - 1).collect();
cut.push('…');
cut
}
/// `text` without its code fence, with or without a language tag. A fence
/// around the whole message is stripped; otherwise the first fenced block in
/// the message is taken, so prose before or after it is dropped.
fn unfence(text: &str) -> &str {
let inner = match text.strip_prefix("```").and_then(|t| t.strip_suffix("```")) {
Some(inner) => inner,
None => match text
.split_once("```")
.and_then(|(_, t)| t.split_once("```"))
{
Some((block, _)) => block,
None => return text,
},
};
let inner = inner.strip_prefix("json").unwrap_or(inner);
inner.trim()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_valid_answer_parses() {
let a = parse(r#"{"verdict": "no-match", "certainty": 92, "reason": "Naive datetimes."}"#)
.expect("valid");
assert_eq!(
a,
Answer {
verdict: Verdict::NoMatch,
certainty: 92,
reason: "Naive datetimes.".to_owned(),
}
);
let fenced =
"```json\n{\"verdict\":\"not-applicable\",\"certainty\":0,\"reason\":\"\"}\n```";
assert_eq!(
parse(fenced).expect("fenced").verdict,
Verdict::NotApplicable
);
}
#[test]
fn a_fenced_answer_inside_prose_parses() {
let a = parse(
"Here is my answer:\n```json\n{\"verdict\":\"match\",\"certainty\":80,\"reason\":\"x\"}\n```\nHope that helps.",
)
.expect("fence in prose");
assert_eq!((a.verdict, a.certainty), (Verdict::Match, 80));
let untagged =
"Answer: ```{\"verdict\":\"no-match\",\"certainty\":70,\"reason\":\"\"}``` done";
assert_eq!(parse(untagged).expect("untagged").verdict, Verdict::NoMatch);
assert!(parse("Here is my answer:\n```json\nnot json\n```").is_err());
assert!(parse("an unclosed ```json\n{\"verdict\":\"match\"").is_err());
}
#[test]
fn a_malformed_answer_is_an_error() {
for bad in [
"",
"yes",
"Sure! {\"verdict\":\"match\",\"certainty\":90,\"reason\":\"x\"}",
r#"{"verdict":"maybe","certainty":50,"reason":"x"}"#,
r#"{"verdict":"match","certainty":101,"reason":"x"}"#,
r#"{"verdict":"match","certainty":-1,"reason":"x"}"#,
r#"{"verdict":"match","certainty":80.5,"reason":"x"}"#,
r#"{"verdict":"match","reason":"x"}"#,
] {
assert!(parse(bad).is_err(), "{bad:?} parsed");
}
}
#[test]
fn an_oversized_reason_is_truncated_and_one_at_the_cap_is_kept() {
let answer = |n: usize| {
format!(
r#"{{"verdict":"match","certainty":90,"reason":"{}"}}"#,
"é".repeat(n)
)
};
// Characters, not bytes: `é` is two bytes.
let at_cap = parse(&answer(REASON_MAX_CHARS)).expect("at the cap");
assert_eq!(at_cap.reason, "é".repeat(REASON_MAX_CHARS));
let long = parse(&answer(600)).expect("over the cap still parses");
assert_eq!(long.verdict, Verdict::Match);
assert_eq!(long.certainty, 90);
assert_eq!(long.reason.chars().count(), REASON_MAX_CHARS);
assert_eq!(
long.reason,
format!("{}…", "é".repeat(REASON_MAX_CHARS - 1))
);
}
}