warble_eval_runner/
capture.rs1use serde::Serialize;
13
14const MATCH_MODES: [&str; 3] = ["scalar", "set", "ordered"];
16
17pub struct CaptureInput<'a> {
19 pub id: &'a str,
20 pub question: &'a str,
21 pub match_mode: &'a str,
23 pub numeric_tolerance: f64,
24 pub tags: Vec<String>,
25 pub result_text: &'a str,
28}
29
30#[derive(Serialize)]
31struct Tol {
32 numeric: f64,
33}
34
35#[derive(Serialize)]
36struct CandidateCase {
37 id: String,
38 question: String,
39 #[serde(skip_serializing_if = "Vec::is_empty")]
40 tags: Vec<String>,
41 #[serde(rename = "match")]
42 match_mode: String,
43 tolerance: Tol,
44 expected: serde_json::Value,
45}
46
47pub fn candidates_header(dataset: Option<&str>, context_version: Option<&str>) -> String {
50 let mut out = String::from(
51 "# Candidate goldens captured from confirmed runs (`warble eval capture`).\n\
52# Review each case, then move accepted ones into the golden set. NOT auto-accepted.\n",
53 );
54 if let Some(d) = dataset {
55 out.push_str(&format!("dataset: {d}\n"));
56 }
57 if let Some(cv) = context_version {
58 out.push_str(&format!("context_version: {cv}\n"));
59 }
60 out.push_str("cases:\n");
61 out
62}
63
64pub fn build_candidate_yaml(input: &CaptureInput) -> Result<String, String> {
68 if !MATCH_MODES.contains(&input.match_mode) {
69 return Err(format!(
70 "unknown match mode '{}' (expected one of: {})",
71 input.match_mode,
72 MATCH_MODES.join(", ")
73 ));
74 }
75 let expected = crate::extract_result_json(input.result_text).ok_or_else(|| {
76 "no parseable {columns, rows} object in the captured result — nothing to capture"
77 .to_string()
78 })?;
79
80 let case = CandidateCase {
81 id: input.id.to_string(),
82 question: input.question.to_string(),
83 tags: input.tags.clone(),
84 match_mode: input.match_mode.to_string(),
85 tolerance: Tol {
86 numeric: input.numeric_tolerance,
87 },
88 expected,
89 };
90 serde_yaml::to_string(&vec![case]).map_err(|e| format!("serialize candidate: {e}"))
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97 use crate::{Golden, GoldenCase};
98
99 fn input<'a>(result: &'a str, match_mode: &'a str) -> CaptureInput<'a> {
100 CaptureInput {
101 id: "q_new_case",
102 question: "How many orders shipped in Q3?",
103 match_mode,
104 numeric_tolerance: 0.01,
105 tags: vec!["agg".into(), "time-filter".into()],
106 result_text: result,
107 }
108 }
109
110 #[test]
111 fn candidate_from_fenced_claude_result_parses_back_as_a_golden() {
112 let result = "Here you go:\n```json\n{\"columns\":[\"n\"],\"rows\":[[42]]}\n```\n";
114 let block = build_candidate_yaml(&input(result, "scalar")).unwrap();
115
116 let doc = format!(
118 "{}{}",
119 candidates_header(Some("jaffle_shop"), Some("jaffle_shop@abc1234")),
120 block
121 );
122 let golden: Golden = serde_yaml::from_str(&doc).expect("candidate file parses as a golden");
123 assert_eq!(golden.dataset.as_deref(), Some("jaffle_shop"));
124 assert_eq!(
125 golden.context_version.as_deref(),
126 Some("jaffle_shop@abc1234")
127 );
128 assert_eq!(golden.cases.len(), 1);
129 let case: &GoldenCase = &golden.cases[0];
130 assert_eq!(case.id, "q_new_case");
131 assert_eq!(case.expected.columns, vec!["n"]);
132 assert_eq!(case.tags, vec!["agg", "time-filter"]);
133 }
134
135 #[test]
136 fn candidate_from_bare_object_works() {
137 let result = "{\"columns\":[\"seg\",\"rev\"],\"rows\":[[\"ent\",100],[\"smb\",50]]}";
138 let block = build_candidate_yaml(&input(result, "set")).unwrap();
139 let doc = format!("{}{}", candidates_header(None, None), block);
140 let golden: Golden = serde_yaml::from_str(&doc).unwrap();
141 assert_eq!(golden.cases[0].expected.rows.len(), 2);
142 }
143
144 #[test]
145 fn rejects_result_without_a_table() {
146 let err =
147 build_candidate_yaml(&input("sorry, I couldn't answer that", "scalar")).unwrap_err();
148 assert!(err.contains("no parseable"));
149 }
150
151 #[test]
152 fn rejects_unknown_match_mode() {
153 let result = "{\"columns\":[\"n\"],\"rows\":[[1]]}";
154 let err = build_candidate_yaml(&input(result, "fuzzy")).unwrap_err();
155 assert!(err.contains("unknown match mode"));
156 }
157}