#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CompletionVerdict {
Complete,
Incomplete(String),
Uncertain(String),
}
impl CompletionVerdict {
pub fn is_complete(&self) -> bool {
matches!(self, CompletionVerdict::Complete)
}
}
pub fn parse_completion_verdict(text: &str) -> Option<CompletionVerdict> {
for line in text.lines() {
let trimmed = line.trim();
let Some(rest) = strip_prefix_ci(trimmed, "VERDICT:") else {
continue;
};
let rest = rest.trim();
if rest.eq_ignore_ascii_case("COMPLETE") {
return Some(CompletionVerdict::Complete);
}
if let Some(reason) = strip_prefix_ci(rest, "INCOMPLETE:") {
return Some(CompletionVerdict::Incomplete(reason.trim().to_string()));
}
if let Some(reason) = strip_prefix_ci(rest, "UNCERTAIN:") {
return Some(CompletionVerdict::Uncertain(reason.trim().to_string()));
}
}
None
}
fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
let (sb, pb) = (s.as_bytes(), prefix.as_bytes());
if sb.len() >= pb.len() && sb[..pb.len()].eq_ignore_ascii_case(pb) {
Some(&s[pb.len()..])
} else {
None
}
}
pub fn build_completion_prompt(
task: &str,
key_points: &[String],
action_history: &[String],
final_snapshot: &str,
) -> String {
let mut out = String::with_capacity(
task.len() + final_snapshot.len() + (key_points.len() + action_history.len()) * 24 + 512,
);
out.push_str(
"You are a strict evaluator deciding whether a web-automation task was \
actually completed. Judge ONLY from the evidence below; do not assume \
success.\n\n",
);
out.push_str("# Task\n");
out.push_str(task);
out.push_str("\n\n");
out.push_str("# Success criteria (all must hold)\n");
if key_points.is_empty() {
out.push_str("(none given — infer the criteria from the task)\n");
} else {
for kp in key_points {
out.push_str("- ");
out.push_str(kp);
out.push('\n');
}
}
out.push('\n');
out.push_str("# Actions taken\n");
if action_history.is_empty() {
out.push_str("(no actions recorded)\n");
} else {
for (i, a) in action_history.iter().enumerate() {
out.push_str(&format!("{}. {}\n", i + 1, a));
}
}
out.push('\n');
out.push_str("# Final page state (accessibility snapshot)\n");
out.push_str(final_snapshot);
out.push_str("\n\n");
out.push_str(
"Decide whether EVERY success criterion is satisfied by the final state. \
Reply with exactly one line:\n\
- VERDICT: COMPLETE\n\
- VERDICT: INCOMPLETE: <the specific unmet criterion>\n\
- VERDICT: UNCERTAIN: <what evidence is missing>\n",
);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_complete() {
assert_eq!(
parse_completion_verdict("VERDICT: COMPLETE"),
Some(CompletionVerdict::Complete)
);
assert!(
parse_completion_verdict("VERDICT: COMPLETE")
.unwrap()
.is_complete()
);
}
#[test]
fn parses_incomplete_with_reason() {
assert_eq!(
parse_completion_verdict("VERDICT: INCOMPLETE: the order was never submitted"),
Some(CompletionVerdict::Incomplete(
"the order was never submitted".to_string()
))
);
}
#[test]
fn parses_uncertain_with_reason() {
assert_eq!(
parse_completion_verdict("VERDICT: UNCERTAIN: no confirmation page captured"),
Some(CompletionVerdict::Uncertain(
"no confirmation page captured".to_string()
))
);
}
#[test]
fn is_case_insensitive_and_skips_reasoning() {
let reply = "The form shows value=\"\" so the email was never typed.\n\
verdict: incomplete: email field is empty";
assert_eq!(
parse_completion_verdict(reply),
Some(CompletionVerdict::Incomplete(
"email field is empty".to_string()
))
);
}
#[test]
fn none_when_no_verdict_line() {
assert_eq!(parse_completion_verdict("I think it looks fine?"), None);
assert!(
!parse_completion_verdict("looks done to me")
.map(|v| v.is_complete())
.unwrap_or(false),
"an unparseable reply must never read as complete"
);
}
#[test]
fn non_ascii_reply_does_not_panic() {
assert_eq!(parse_completion_verdict("VERDICTé stray line"), None);
assert_eq!(
parse_completion_verdict("VERDICT: INCOMPLETE: café not confirmé"),
Some(CompletionVerdict::Incomplete(
"café not confirmé".to_string()
))
);
}
#[test]
fn first_recognized_verdict_wins_after_unrecognized() {
let reply = "VERDICT: MAYBE\nVERDICT: COMPLETE";
assert_eq!(
parse_completion_verdict(reply),
Some(CompletionVerdict::Complete)
);
}
#[test]
fn incomplete_with_empty_reason_is_allowed() {
assert_eq!(
parse_completion_verdict("VERDICT: INCOMPLETE:"),
Some(CompletionVerdict::Incomplete(String::new()))
);
}
#[test]
fn only_complete_is_terminal_success() {
assert!(CompletionVerdict::Complete.is_complete());
assert!(!CompletionVerdict::Incomplete("x".into()).is_complete());
assert!(!CompletionVerdict::Uncertain("x".into()).is_complete());
}
#[test]
fn prompt_includes_task_criteria_actions_and_snapshot() {
let p = build_completion_prompt(
"Buy one widget",
&[
"cart contains 1 widget".to_string(),
"order confirmed".to_string(),
],
&[
"navigate to shop".to_string(),
"click Add to cart".to_string(),
],
"uid=1_0 RootWebArea \"Cart\"\n uid=1_1 StaticText \"1 item\"",
);
assert!(p.contains("Buy one widget"), "task present");
assert!(p.contains("cart contains 1 widget"), "key point present");
assert!(p.contains("click Add to cart"), "action present");
assert!(
p.contains("uid=1_1 StaticText \"1 item\""),
"snapshot present"
);
assert!(
p.contains("VERDICT: COMPLETE"),
"instructs the verdict format"
);
let p2 = build_completion_prompt(
"Buy one widget",
&[
"cart contains 1 widget".to_string(),
"order confirmed".to_string(),
],
&[
"navigate to shop".to_string(),
"click Add to cart".to_string(),
],
"uid=1_0 RootWebArea \"Cart\"\n uid=1_1 StaticText \"1 item\"",
);
assert_eq!(p, p2);
}
#[test]
fn prompt_handles_empty_criteria_and_actions() {
let p = build_completion_prompt("Do a thing", &[], &[], "uid=1_0 RootWebArea \"X\"");
assert!(p.contains("infer the criteria"), "empty key points handled");
assert!(p.contains("no actions recorded"), "empty history handled");
}
}