1use crate::serde_json::{Map, Value};
48
49#[derive(Debug, Clone, PartialEq)]
54pub struct SourceRow {
55 pub urn: String,
56 pub payload: String,
57}
58
59#[derive(Debug, Clone, PartialEq)]
61pub struct Citation {
62 pub marker: u32,
63 pub urn: String,
64}
65
66#[derive(Debug, Clone, PartialEq)]
70pub struct ValidationWarning {
71 pub kind: String,
72 pub detail: String,
73}
74
75#[derive(Debug, Clone, PartialEq)]
78pub struct ValidationError {
79 pub kind: String,
80 pub detail: String,
81}
82
83#[derive(Debug, Clone, PartialEq)]
86pub struct Validation {
87 pub ok: bool,
88 pub warnings: Vec<ValidationWarning>,
89 pub errors: Vec<ValidationError>,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum Mode {
97 Strict,
98 Lenient,
99}
100
101impl Mode {
102 fn as_str(self) -> &'static str {
103 match self {
104 Mode::Strict => "strict",
105 Mode::Lenient => "lenient",
106 }
107 }
108}
109
110#[derive(Debug, Clone)]
114pub struct AskResult {
115 pub answer: String,
116 pub sources_flat: Vec<SourceRow>,
117 pub citations: Vec<Citation>,
118 pub validation: Validation,
119 pub cache_hit: bool,
120 pub provider: String,
121 pub model: String,
122 pub prompt_tokens: u32,
123 pub completion_tokens: u32,
124 pub cost_usd: f64,
125 pub effective_mode: Mode,
126 pub retry_count: u32,
127}
128
129impl Default for AskResult {
130 fn default() -> Self {
131 Self {
132 answer: String::new(),
133 sources_flat: Vec::new(),
134 citations: Vec::new(),
135 validation: Validation {
136 ok: true,
137 warnings: Vec::new(),
138 errors: Vec::new(),
139 },
140 cache_hit: false,
141 provider: String::new(),
142 model: String::new(),
143 prompt_tokens: 0,
144 completion_tokens: 0,
145 cost_usd: 0.0,
146 effective_mode: Mode::Strict,
147 retry_count: 0,
148 }
149 }
150}
151
152pub fn build(result: &AskResult) -> Value {
160 let mut m = Map::new();
161 m.insert("answer".into(), Value::String(result.answer.clone()));
162 m.insert("cache_hit".into(), Value::Bool(result.cache_hit));
163 m.insert("citations".into(), citations_value(&result.citations));
164 m.insert(
165 "completion_tokens".into(),
166 Value::Number(result.completion_tokens as f64),
167 );
168 m.insert("cost_usd".into(), Value::Number(result.cost_usd));
169 m.insert(
170 "mode".into(),
171 Value::String(result.effective_mode.as_str().into()),
172 );
173 m.insert("model".into(), Value::String(result.model.clone()));
174 m.insert(
175 "prompt_tokens".into(),
176 Value::Number(result.prompt_tokens as f64),
177 );
178 m.insert("provider".into(), Value::String(result.provider.clone()));
179 m.insert(
180 "retry_count".into(),
181 Value::Number(result.retry_count as f64),
182 );
183 m.insert("sources_flat".into(), sources_value(&result.sources_flat));
184 m.insert("validation".into(), validation_value(&result.validation));
185 Value::Object(m)
186}
187
188fn citations_value(cites: &[Citation]) -> Value {
189 let mut sorted: Vec<Citation> = cites.to_vec();
193 sorted.sort_by_key(|c| c.marker);
194 Value::Array(
195 sorted
196 .iter()
197 .map(|c| {
198 let mut o = Map::new();
199 o.insert("marker".into(), Value::Number(c.marker as f64));
200 o.insert("urn".into(), Value::String(c.urn.clone()));
201 Value::Object(o)
202 })
203 .collect(),
204 )
205}
206
207fn sources_value(rows: &[SourceRow]) -> Value {
208 Value::Array(
209 rows.iter()
210 .map(|r| {
211 let mut o = Map::new();
212 o.insert("payload".into(), Value::String(r.payload.clone()));
213 o.insert("urn".into(), Value::String(r.urn.clone()));
214 Value::Object(o)
215 })
216 .collect(),
217 )
218}
219
220fn warning_value(w: &ValidationWarning) -> Value {
221 let mut o = Map::new();
222 o.insert("detail".into(), Value::String(w.detail.clone()));
223 o.insert("kind".into(), Value::String(w.kind.clone()));
224 Value::Object(o)
225}
226
227fn error_value(e: &ValidationError) -> Value {
228 let mut o = Map::new();
229 o.insert("detail".into(), Value::String(e.detail.clone()));
230 o.insert("kind".into(), Value::String(e.kind.clone()));
231 Value::Object(o)
232}
233
234fn validation_value(v: &Validation) -> Value {
235 let mut o = Map::new();
236 o.insert(
237 "errors".into(),
238 Value::Array(v.errors.iter().map(error_value).collect()),
239 );
240 o.insert("ok".into(), Value::Bool(v.ok));
241 o.insert(
242 "warnings".into(),
243 Value::Array(v.warnings.iter().map(warning_value).collect()),
244 );
245 Value::Object(o)
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 fn fixture() -> AskResult {
253 AskResult {
254 answer: "X is 42 [^1].".into(),
255 sources_flat: vec![SourceRow {
256 urn: "urn:reddb:row:1".into(),
257 payload: "{\"k\":\"v\"}".into(),
258 }],
259 citations: vec![Citation {
260 marker: 1,
261 urn: "urn:reddb:row:1".into(),
262 }],
263 validation: Validation {
264 ok: true,
265 warnings: vec![],
266 errors: vec![],
267 },
268 cache_hit: false,
269 provider: "openai".into(),
270 model: "gpt-4o-mini".into(),
271 prompt_tokens: 123,
272 completion_tokens: 45,
273 cost_usd: 0.000_321,
274 effective_mode: Mode::Strict,
275 retry_count: 0,
276 }
277 }
278
279 #[test]
280 fn build_emits_every_required_key() {
281 let v = build(&fixture());
282 let obj = v.as_object().unwrap();
283 let mut keys: Vec<&str> = obj.keys().map(|s| s.as_str()).collect();
284 keys.sort();
285 assert_eq!(
286 keys,
287 vec![
288 "answer",
289 "cache_hit",
290 "citations",
291 "completion_tokens",
292 "cost_usd",
293 "mode",
294 "model",
295 "prompt_tokens",
296 "provider",
297 "retry_count",
298 "sources_flat",
299 "validation",
300 ]
301 );
302 }
303
304 #[test]
305 fn answer_text_preserved_with_inline_markers() {
306 let v = build(&fixture());
307 assert_eq!(
308 v.get("answer").and_then(|x| x.as_str()),
309 Some("X is 42 [^1].")
310 );
311 }
312
313 #[test]
314 fn cache_hit_serializes_as_bool() {
315 let mut r = fixture();
316 r.cache_hit = true;
317 let v = build(&r);
318 assert_eq!(v.get("cache_hit").and_then(|x| x.as_bool()), Some(true));
319 }
320
321 #[test]
322 fn citations_are_sorted_by_marker_ascending() {
323 let mut r = fixture();
324 r.citations = vec![
325 Citation {
326 marker: 3,
327 urn: "urn:c".into(),
328 },
329 Citation {
330 marker: 1,
331 urn: "urn:a".into(),
332 },
333 Citation {
334 marker: 2,
335 urn: "urn:b".into(),
336 },
337 ];
338 let v = build(&r);
339 let arr = v.get("citations").and_then(|x| x.as_array()).unwrap();
340 let markers: Vec<u64> = arr
341 .iter()
342 .map(|c| c.get("marker").and_then(|m| m.as_u64()).unwrap())
343 .collect();
344 assert_eq!(markers, vec![1, 2, 3]);
345 }
346
347 #[test]
348 fn sources_flat_preserves_input_order() {
349 let mut r = fixture();
352 r.sources_flat = vec![
353 SourceRow {
354 urn: "urn:z".into(),
355 payload: "{}".into(),
356 },
357 SourceRow {
358 urn: "urn:a".into(),
359 payload: "{}".into(),
360 },
361 ];
362 let v = build(&r);
363 let arr = v.get("sources_flat").and_then(|x| x.as_array()).unwrap();
364 assert_eq!(arr[0].get("urn").and_then(|x| x.as_str()), Some("urn:z"));
365 assert_eq!(arr[1].get("urn").and_then(|x| x.as_str()), Some("urn:a"));
366 }
367
368 #[test]
369 fn sources_row_carries_payload_as_string() {
370 let v = build(&fixture());
371 let arr = v.get("sources_flat").and_then(|x| x.as_array()).unwrap();
372 assert_eq!(
373 arr[0].get("payload").and_then(|x| x.as_str()),
374 Some("{\"k\":\"v\"}")
375 );
376 }
377
378 #[test]
379 fn validation_ok_carries_empty_arrays() {
380 let v = build(&fixture());
381 let val = v.get("validation").unwrap();
382 assert_eq!(val.get("ok").and_then(|x| x.as_bool()), Some(true));
383 assert_eq!(
384 val.get("warnings")
385 .and_then(|x| x.as_array())
386 .unwrap()
387 .len(),
388 0
389 );
390 assert_eq!(
391 val.get("errors").and_then(|x| x.as_array()).unwrap().len(),
392 0
393 );
394 }
395
396 #[test]
397 fn validation_carries_warnings_and_errors_with_kind_detail() {
398 let mut r = fixture();
399 r.validation = Validation {
400 ok: false,
401 warnings: vec![ValidationWarning {
402 kind: "mode_fallback".into(),
403 detail: "ollama".into(),
404 }],
405 errors: vec![ValidationError {
406 kind: "out_of_range".into(),
407 detail: "marker 7 > 3 sources".into(),
408 }],
409 };
410 let v = build(&r);
411 let val = v.get("validation").unwrap();
412 assert_eq!(val.get("ok").and_then(|x| x.as_bool()), Some(false));
413 let warns = val.get("warnings").and_then(|x| x.as_array()).unwrap();
414 assert_eq!(
415 warns[0].get("kind").and_then(|x| x.as_str()),
416 Some("mode_fallback")
417 );
418 assert_eq!(
419 warns[0].get("detail").and_then(|x| x.as_str()),
420 Some("ollama")
421 );
422 let errs = val.get("errors").and_then(|x| x.as_array()).unwrap();
423 assert_eq!(
424 errs[0].get("kind").and_then(|x| x.as_str()),
425 Some("out_of_range")
426 );
427 }
428
429 #[test]
430 fn mode_serializes_as_strict_or_lenient() {
431 let mut r = fixture();
432 r.effective_mode = Mode::Strict;
433 assert_eq!(
434 build(&r).get("mode").and_then(|x| x.as_str()),
435 Some("strict")
436 );
437 r.effective_mode = Mode::Lenient;
438 assert_eq!(
439 build(&r).get("mode").and_then(|x| x.as_str()),
440 Some("lenient")
441 );
442 }
443
444 #[test]
445 fn usage_fields_flat_at_top_level() {
446 let v = build(&fixture());
450 assert_eq!(v.get("prompt_tokens").and_then(|x| x.as_u64()), Some(123));
451 assert_eq!(
452 v.get("completion_tokens").and_then(|x| x.as_u64()),
453 Some(45)
454 );
455 assert!(v.get("cost_usd").is_some());
456 }
457
458 #[test]
459 fn cost_usd_keeps_fractional_precision() {
460 let mut r = fixture();
461 r.cost_usd = 0.000_321;
462 let v = build(&r);
463 assert_eq!(v.get("cost_usd").and_then(|x| x.as_f64()), Some(0.000_321));
464 }
465
466 #[test]
467 fn retry_count_zero_and_one_both_round_trip() {
468 let mut r = fixture();
471 r.retry_count = 0;
472 assert_eq!(
473 build(&r).get("retry_count").and_then(|x| x.as_u64()),
474 Some(0)
475 );
476 r.retry_count = 1;
477 assert_eq!(
478 build(&r).get("retry_count").and_then(|x| x.as_u64()),
479 Some(1)
480 );
481 }
482
483 #[test]
484 fn does_not_expose_seed_or_temperature() {
485 let v = build(&fixture());
489 let obj = v.as_object().unwrap();
490 assert!(!obj.contains_key("seed"));
491 assert!(!obj.contains_key("temperature"));
492 }
493
494 #[test]
495 fn empty_sources_and_citations_are_arrays_not_null() {
496 let mut r = fixture();
500 r.sources_flat = vec![];
501 r.citations = vec![];
502 let v = build(&r);
503 assert!(v
504 .get("sources_flat")
505 .and_then(|x| x.as_array())
506 .unwrap()
507 .is_empty());
508 assert!(v
509 .get("citations")
510 .and_then(|x| x.as_array())
511 .unwrap()
512 .is_empty());
513 }
514
515 #[test]
516 fn answer_escaping_handled_by_compact_encoder() {
517 let mut r = fixture();
518 r.answer = "she said \"hi\"\nnewline".into();
519 let bytes = build(&r).to_string_compact();
520 assert!(bytes.contains(r#"\"hi\""#));
521 assert!(bytes.contains(r#"\n"#));
522 }
523
524 #[test]
525 fn build_is_deterministic_across_calls() {
526 let r = fixture();
527 let a = build(&r).to_string_compact();
528 let b = build(&r).to_string_compact();
529 assert_eq!(a, b);
530 }
531
532 #[test]
533 fn build_is_deterministic_across_clone_inputs() {
534 let r1 = fixture();
535 let r2 = r1.clone();
536 assert_eq!(
537 build(&r1).to_string_compact(),
538 build(&r2).to_string_compact()
539 );
540 }
541
542 #[test]
543 fn top_level_key_order_is_alphabetical() {
544 let bytes = build(&fixture()).to_string_compact();
548 let answer_pos = bytes.find("\"answer\"").unwrap();
549 let cache_pos = bytes.find("\"cache_hit\"").unwrap();
550 let citations_pos = bytes.find("\"citations\"").unwrap();
551 let validation_pos = bytes.find("\"validation\"").unwrap();
552 assert!(answer_pos < cache_pos);
553 assert!(cache_pos < citations_pos);
554 assert!(citations_pos < validation_pos);
555 }
556
557 #[test]
558 fn citation_with_same_marker_is_stable_under_sort() {
559 let mut r = fixture();
565 r.citations = vec![
566 Citation {
567 marker: 1,
568 urn: "urn:first".into(),
569 },
570 Citation {
571 marker: 1,
572 urn: "urn:second".into(),
573 },
574 ];
575 let v = build(&r);
576 let arr = v.get("citations").and_then(|x| x.as_array()).unwrap();
577 assert_eq!(
578 arr[0].get("urn").and_then(|x| x.as_str()),
579 Some("urn:first")
580 );
581 assert_eq!(
582 arr[1].get("urn").and_then(|x| x.as_str()),
583 Some("urn:second")
584 );
585 }
586}