1use crate::runtime::ai::citation_parser::{CitationParseResult, CitationWarning, CitationWarningKind};
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Mode {
48 Strict,
51 Lenient,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Attempt {
59 First,
60 Retry,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct ValidationError {
70 pub kind: ValidationErrorKind,
71 pub detail: String,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum ValidationErrorKind {
76 Malformed,
78 OutOfRange,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum Decision {
85 Ok,
87 Retry { prompt: String },
91 GiveUp { errors: Vec<ValidationError> },
94}
95
96pub fn validate(parsed: &CitationParseResult, mode: Mode, attempt: Attempt) -> Decision {
102 if mode == Mode::Lenient {
103 return Decision::Ok;
104 }
105
106 let structural_warnings: Vec<&CitationWarning> = parsed
107 .warnings
108 .iter()
109 .filter(|w| {
110 matches!(
111 w.kind,
112 CitationWarningKind::Malformed | CitationWarningKind::OutOfRange
113 )
114 })
115 .collect();
116
117 if structural_warnings.is_empty() {
118 return Decision::Ok;
119 }
120
121 match attempt {
122 Attempt::First => Decision::Retry {
123 prompt: build_retry_prompt(&structural_warnings),
124 },
125 Attempt::Retry => Decision::GiveUp {
126 errors: structural_warnings
127 .iter()
128 .map(|w| ValidationError {
129 kind: match w.kind {
130 CitationWarningKind::Malformed => ValidationErrorKind::Malformed,
131 CitationWarningKind::OutOfRange => ValidationErrorKind::OutOfRange,
132 },
133 detail: w.detail.clone(),
134 })
135 .collect(),
136 },
137 }
138}
139
140fn build_retry_prompt(warnings: &[&CitationWarning]) -> String {
151 let mut out = String::from(
152 "Your previous answer contained citation markers that do not match \
153 the available sources. Reissue the answer in full, with every \
154 `[^N]` marker referring to a real source by its 1-indexed position \
155 in the provided context. Do not invent or renumber sources; if a \
156 claim is not supported by a real source, drop the marker rather \
157 than fabricate one. Problems detected:\n",
158 );
159 for w in warnings {
160 let kind = match w.kind {
161 CitationWarningKind::Malformed => "malformed",
162 CitationWarningKind::OutOfRange => "out_of_range",
163 };
164 out.push_str(&format!("- [{kind}] {}\n", w.detail));
165 }
166 out
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::runtime::ai::citation_parser::{Citation, CitationParseResult, CitationWarning};
173
174 fn ok_result() -> CitationParseResult {
175 CitationParseResult {
176 citations: vec![Citation {
177 marker: 1,
178 span: 0..4,
179 source_index: 0,
180 }],
181 warnings: vec![],
182 }
183 }
184
185 fn malformed_result() -> CitationParseResult {
186 CitationParseResult {
187 citations: vec![],
188 warnings: vec![CitationWarning {
189 kind: CitationWarningKind::Malformed,
190 span: 0..4,
191 detail: "empty marker body".to_string(),
192 }],
193 }
194 }
195
196 fn out_of_range_result() -> CitationParseResult {
197 CitationParseResult {
198 citations: vec![Citation {
199 marker: 9,
200 span: 0..4,
201 source_index: 8,
202 }],
203 warnings: vec![CitationWarning {
204 kind: CitationWarningKind::OutOfRange,
205 span: 0..4,
206 detail: "marker [^9] references source #9 but only 2 sources available".to_string(),
207 }],
208 }
209 }
210
211 fn mixed_result() -> CitationParseResult {
212 CitationParseResult {
213 citations: vec![],
214 warnings: vec![
215 CitationWarning {
216 kind: CitationWarningKind::Malformed,
217 span: 0..3,
218 detail: "empty".into(),
219 },
220 CitationWarning {
221 kind: CitationWarningKind::OutOfRange,
222 span: 4..8,
223 detail: "marker [^7] references source #7 but only 1 sources available"
224 .to_string(),
225 },
226 ],
227 }
228 }
229
230 #[test]
233 fn strict_clean_is_ok_on_first() {
234 assert_eq!(
235 validate(&ok_result(), Mode::Strict, Attempt::First),
236 Decision::Ok
237 );
238 }
239
240 #[test]
241 fn strict_clean_is_ok_on_retry_too() {
242 assert_eq!(
245 validate(&ok_result(), Mode::Strict, Attempt::Retry),
246 Decision::Ok
247 );
248 }
249
250 #[test]
251 fn strict_malformed_first_attempt_asks_for_retry() {
252 let decision = validate(&malformed_result(), Mode::Strict, Attempt::First);
253 match decision {
254 Decision::Retry { prompt } => {
255 assert!(prompt.contains("Reissue the answer"));
256 assert!(prompt.contains("malformed"));
257 assert!(prompt.contains("empty marker body"));
258 }
259 other => panic!("expected Retry, got {other:?}"),
260 }
261 }
262
263 #[test]
264 fn strict_out_of_range_first_attempt_asks_for_retry() {
265 let decision = validate(&out_of_range_result(), Mode::Strict, Attempt::First);
266 match decision {
267 Decision::Retry { prompt } => {
268 assert!(prompt.contains("out_of_range"));
269 assert!(prompt.contains("source #9"));
270 assert!(prompt.contains("Do not invent"));
272 }
273 other => panic!("expected Retry, got {other:?}"),
274 }
275 }
276
277 #[test]
278 fn strict_malformed_retry_attempt_gives_up() {
279 let decision = validate(&malformed_result(), Mode::Strict, Attempt::Retry);
280 match decision {
281 Decision::GiveUp { errors } => {
282 assert_eq!(errors.len(), 1);
283 assert_eq!(errors[0].kind, ValidationErrorKind::Malformed);
284 assert_eq!(errors[0].detail, "empty marker body");
285 }
286 other => panic!("expected GiveUp, got {other:?}"),
287 }
288 }
289
290 #[test]
291 fn strict_out_of_range_retry_attempt_gives_up() {
292 let decision = validate(&out_of_range_result(), Mode::Strict, Attempt::Retry);
293 match decision {
294 Decision::GiveUp { errors } => {
295 assert_eq!(errors.len(), 1);
296 assert_eq!(errors[0].kind, ValidationErrorKind::OutOfRange);
297 assert!(errors[0].detail.contains("source #9"));
298 }
299 other => panic!("expected GiveUp, got {other:?}"),
300 }
301 }
302
303 #[test]
304 fn strict_mixed_warnings_carry_through_to_giveup() {
305 let decision = validate(&mixed_result(), Mode::Strict, Attempt::Retry);
306 match decision {
307 Decision::GiveUp { errors } => {
308 assert_eq!(errors.len(), 2);
309 assert_eq!(errors[0].kind, ValidationErrorKind::Malformed);
310 assert_eq!(errors[1].kind, ValidationErrorKind::OutOfRange);
311 }
312 other => panic!("expected GiveUp, got {other:?}"),
313 }
314 }
315
316 #[test]
317 fn strict_mixed_warnings_first_attempt_still_retries() {
318 let decision = validate(&mixed_result(), Mode::Strict, Attempt::First);
319 assert!(matches!(decision, Decision::Retry { .. }));
320 }
321
322 #[test]
325 fn lenient_passes_clean() {
326 assert_eq!(
327 validate(&ok_result(), Mode::Lenient, Attempt::First),
328 Decision::Ok
329 );
330 }
331
332 #[test]
333 fn lenient_passes_malformed() {
334 assert_eq!(
337 validate(&malformed_result(), Mode::Lenient, Attempt::First),
338 Decision::Ok
339 );
340 }
341
342 #[test]
343 fn lenient_passes_out_of_range() {
344 assert_eq!(
345 validate(&out_of_range_result(), Mode::Lenient, Attempt::First),
346 Decision::Ok
347 );
348 }
349
350 #[test]
351 fn lenient_ignores_attempt() {
352 assert_eq!(
355 validate(&malformed_result(), Mode::Lenient, Attempt::Retry),
356 Decision::Ok
357 );
358 }
359
360 #[test]
363 fn retry_prompt_includes_every_warning_detail() {
364 let parsed = mixed_result();
365 let decision = validate(&parsed, Mode::Strict, Attempt::First);
366 let Decision::Retry { prompt } = decision else {
367 panic!("expected Retry");
368 };
369 for w in &parsed.warnings {
370 assert!(
371 prompt.contains(&w.detail),
372 "retry prompt missing detail `{}`, got:\n{prompt}",
373 w.detail
374 );
375 }
376 }
377
378 #[test]
379 fn retry_prompt_is_deterministic() {
380 let parsed = mixed_result();
385 let a = validate(&parsed, Mode::Strict, Attempt::First);
386 let b = validate(&parsed, Mode::Strict, Attempt::First);
387 assert_eq!(a, b);
388 }
389
390 #[test]
391 fn retry_prompt_forbids_fabrication() {
392 let decision = validate(&out_of_range_result(), Mode::Strict, Attempt::First);
393 let Decision::Retry { prompt } = decision else {
394 panic!("expected Retry");
395 };
396 assert!(prompt.contains("Do not invent"));
399 }
400
401 #[test]
404 fn empty_parse_is_ok_in_either_mode() {
405 let empty = CitationParseResult::default();
406 assert_eq!(
407 validate(&empty, Mode::Strict, Attempt::First),
408 Decision::Ok
409 );
410 assert_eq!(
411 validate(&empty, Mode::Strict, Attempt::Retry),
412 Decision::Ok
413 );
414 assert_eq!(
415 validate(&empty, Mode::Lenient, Attempt::First),
416 Decision::Ok
417 );
418 }
419
420 #[test]
421 fn citations_without_warnings_are_ok() {
422 let parsed = CitationParseResult {
424 citations: vec![
425 Citation {
426 marker: 1,
427 span: 0..4,
428 source_index: 0,
429 },
430 Citation {
431 marker: 2,
432 span: 5..9,
433 source_index: 1,
434 },
435 Citation {
436 marker: 3,
437 span: 10..14,
438 source_index: 2,
439 },
440 ],
441 warnings: vec![],
442 };
443 assert_eq!(
444 validate(&parsed, Mode::Strict, Attempt::First),
445 Decision::Ok
446 );
447 }
448}