Watch
0
0
Fork
You've already forked hyperhive
0

swarm-review-classifier: answer a try-out with one claude run per classification

A new service crate. It reads its queue client secret from the store at
start, takes try-outs on the review classifier subject in its queue
group, and for each run reads the Anthropic API key from the store,
builds the shared context (commits with full messages, diff with
lockfiles collapsed), runs one claude --print per classification with no
session, no tools, no MCP server and the key in the child's environment
only, validates the JSON answer and aggregates the rows. A missing key or
a run where no classification got an answer fails the run; one failed
classification is an excluded row with its error. One journal line per
run names the operator, repo, PR, head SHA, rows and overall verdict.

swarm-secret-client gains the two paths the service reads.
This commit is contained in:
atlas 2026-10-12 03:15:04 +02:00 • committed by mara
commit 88a55f136e
11 changed files with 1235 additions and 1 deletions

View file

@ -0,0 +1,134 @@
//! Reading the model's answer to one classification.
//!
//! An answer that does not parse, names an unknown verdict, carries a
//! certainty outside 0..=100 or a reason over the cap is an error, and the
//! row it belongs to is excluded rather than guessed at.
use serde::Deserialize;
use swarm_queue_client::review_classifier::Verdict;
/// The longest reason, in characters, an answer may carry.
pub const REASON_MAX_CHARS: usize = 500;
/// 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 surrounding code fence is
/// tolerated; anything else around the 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
)
})?;
let length = raw.reason.chars().count();
if length > REASON_MAX_CHARS {
return Err(format!(
"the answer's reason is {length} characters, over the {REASON_MAX_CHARS}-character cap"
));
}
Ok(Answer {
verdict,
certainty,
reason: raw.reason,
})
}
/// `text` without a surrounding code fence, with or without a language tag.
fn unfence(text: &str) -> &str {
let Some(inner) = text.strip_prefix("```").and_then(|t| t.strip_suffix("```")) else {
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_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_an_error_and_one_at_the_cap_is_not() {
let answer = |n: usize| {
format!(
r#"{{"verdict":"match","certainty":90,"reason":"{}"}}"#,
"é".repeat(n)
)
};
let e = parse(&answer(REASON_MAX_CHARS + 1)).expect_err("over the cap");
assert!(e.contains("501 characters"), "{e}");
// Characters, not bytes: `é` is two bytes.
assert!(parse(&answer(REASON_MAX_CHARS)).is_ok());
}
}