1use crate::serde_json::{Map, Value};
52
53#[derive(Debug, Clone, PartialEq)]
58pub struct SourceRow {
59 pub urn: String,
60 pub payload: String,
61}
62
63#[derive(Debug, Clone, PartialEq)]
65pub struct Citation {
66 pub marker: u32,
67 pub urn: String,
68}
69
70#[derive(Debug, Clone, PartialEq)]
74pub struct ValidationWarning {
75 pub kind: String,
76 pub detail: String,
77}
78
79#[derive(Debug, Clone, PartialEq)]
82pub struct ValidationError {
83 pub kind: String,
84 pub detail: String,
85}
86
87#[derive(Debug, Clone, PartialEq)]
90pub struct Validation {
91 pub ok: bool,
92 pub warnings: Vec<ValidationWarning>,
93 pub errors: Vec<ValidationError>,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum Mode {
101 Strict,
102 Lenient,
103}
104
105impl Mode {
106 fn as_str(self) -> &'static str {
107 match self {
108 Mode::Strict => "strict",
109 Mode::Lenient => "lenient",
110 }
111 }
112}
113
114#[derive(Debug, Clone)]
118pub struct AskResult {
119 pub answer: String,
120 pub reasoning: Value,
123 pub exploratory_queries: Value,
124 pub final_query: Option<String>,
125 pub final_query_params: Value,
126 pub sources_flat: Vec<SourceRow>,
127 pub citations: Vec<Citation>,
128 pub validation: Validation,
129 pub cache_hit: bool,
130 pub provider: String,
131 pub model: String,
132 pub prompt_tokens: u32,
133 pub completion_tokens: u32,
134 pub cost_usd: f64,
135 pub effective_mode: Mode,
136 pub retry_count: u32,
137}
138
139impl Default for AskResult {
140 fn default() -> Self {
141 Self {
142 answer: String::new(),
143 reasoning: Value::Null,
144 exploratory_queries: Value::Array(Vec::new()),
145 final_query: None,
146 final_query_params: Value::Array(Vec::new()),
147 sources_flat: Vec::new(),
148 citations: Vec::new(),
149 validation: Validation {
150 ok: true,
151 warnings: Vec::new(),
152 errors: Vec::new(),
153 },
154 cache_hit: false,
155 provider: String::new(),
156 model: String::new(),
157 prompt_tokens: 0,
158 completion_tokens: 0,
159 cost_usd: 0.0,
160 effective_mode: Mode::Strict,
161 retry_count: 0,
162 }
163 }
164}
165
166pub fn build(result: &AskResult) -> Value {
174 let mut m = Map::new();
175 m.insert("answer".into(), Value::String(result.answer.clone()));
176 m.insert("cache_hit".into(), Value::Bool(result.cache_hit));
177 m.insert("citations".into(), citations_value(&result.citations));
178 m.insert(
179 "completion_tokens".into(),
180 Value::Number(result.completion_tokens as f64),
181 );
182 m.insert("cost_usd".into(), Value::Number(result.cost_usd));
183 m.insert(
184 "exploratory_queries".into(),
185 result.exploratory_queries.clone(),
186 );
187 m.insert(
188 "final_query".into(),
189 result
190 .final_query
191 .as_ref()
192 .map_or(Value::Null, |query| Value::String(query.clone())),
193 );
194 m.insert(
195 "final_query_params".into(),
196 result.final_query_params.clone(),
197 );
198 m.insert(
199 "mode".into(),
200 Value::String(result.effective_mode.as_str().into()),
201 );
202 m.insert("model".into(), Value::String(result.model.clone()));
203 m.insert(
204 "prompt_tokens".into(),
205 Value::Number(result.prompt_tokens as f64),
206 );
207 m.insert("provider".into(), Value::String(result.provider.clone()));
208 m.insert("reasoning".into(), result.reasoning.clone());
209 m.insert(
210 "retry_count".into(),
211 Value::Number(result.retry_count as f64),
212 );
213 m.insert("sources_flat".into(), sources_value(&result.sources_flat));
214 m.insert("validation".into(), validation_value(&result.validation));
215 Value::Object(m)
216}
217
218fn citations_value(cites: &[Citation]) -> Value {
219 let mut sorted: Vec<Citation> = cites.to_vec();
223 sorted.sort_by_key(|c| c.marker);
224 Value::Array(
225 sorted
226 .iter()
227 .map(|c| {
228 let mut o = Map::new();
229 o.insert("marker".into(), Value::Number(c.marker as f64));
230 o.insert("urn".into(), Value::String(c.urn.clone()));
231 Value::Object(o)
232 })
233 .collect(),
234 )
235}
236
237fn sources_value(rows: &[SourceRow]) -> Value {
238 Value::Array(
239 rows.iter()
240 .map(|r| {
241 let mut o = Map::new();
242 o.insert("payload".into(), Value::String(r.payload.clone()));
243 o.insert("urn".into(), Value::String(r.urn.clone()));
244 Value::Object(o)
245 })
246 .collect(),
247 )
248}
249
250fn warning_value(w: &ValidationWarning) -> Value {
251 let mut o = Map::new();
252 o.insert("detail".into(), Value::String(w.detail.clone()));
253 o.insert("kind".into(), Value::String(w.kind.clone()));
254 Value::Object(o)
255}
256
257fn error_value(e: &ValidationError) -> Value {
258 let mut o = Map::new();
259 o.insert("detail".into(), Value::String(e.detail.clone()));
260 o.insert("kind".into(), Value::String(e.kind.clone()));
261 Value::Object(o)
262}
263
264fn validation_value(v: &Validation) -> Value {
265 let mut o = Map::new();
266 o.insert(
267 "errors".into(),
268 Value::Array(v.errors.iter().map(error_value).collect()),
269 );
270 o.insert("ok".into(), Value::Bool(v.ok));
271 o.insert(
272 "warnings".into(),
273 Value::Array(v.warnings.iter().map(warning_value).collect()),
274 );
275 Value::Object(o)
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 fn fixture() -> AskResult {
283 AskResult {
284 answer: "X is 42 [^1].".into(),
285 reasoning: Value::Null,
286 exploratory_queries: Value::Array(Vec::new()),
287 final_query: None,
288 final_query_params: Value::Array(Vec::new()),
289 sources_flat: vec![SourceRow {
290 urn: "urn:reddb:row:1".into(),
291 payload: "{\"k\":\"v\"}".into(),
292 }],
293 citations: vec![Citation {
294 marker: 1,
295 urn: "urn:reddb:row:1".into(),
296 }],
297 validation: Validation {
298 ok: true,
299 warnings: vec![],
300 errors: vec![],
301 },
302 cache_hit: false,
303 provider: "openai".into(),
304 model: "gpt-4o-mini".into(),
305 prompt_tokens: 123,
306 completion_tokens: 45,
307 cost_usd: 0.000_321,
308 effective_mode: Mode::Strict,
309 retry_count: 0,
310 }
311 }
312
313 #[test]
314 fn build_emits_every_required_key() {
315 let v = build(&fixture());
316 let obj = v.as_object().unwrap();
317 let mut keys: Vec<&str> = obj.keys().map(|s| s.as_str()).collect();
318 keys.sort();
319 assert_eq!(
320 keys,
321 vec![
322 "answer",
323 "cache_hit",
324 "citations",
325 "completion_tokens",
326 "cost_usd",
327 "exploratory_queries",
328 "final_query",
329 "final_query_params",
330 "mode",
331 "model",
332 "prompt_tokens",
333 "provider",
334 "reasoning",
335 "retry_count",
336 "sources_flat",
337 "validation",
338 ]
339 );
340 }
341
342 #[test]
343 fn answer_text_preserved_with_inline_markers() {
344 let v = build(&fixture());
345 assert_eq!(
346 v.get("answer").and_then(|x| x.as_str()),
347 Some("X is 42 [^1].")
348 );
349 }
350
351 #[test]
352 fn planner_trace_is_preserved_without_reformatting() {
353 let mut result = fixture();
354 result.reasoning = Value::Object(
355 [("summary".into(), Value::String("Checked events".into()))]
356 .into_iter()
357 .collect(),
358 );
359 result.exploratory_queries = Value::Array(vec![Value::Object(
360 [(
361 "sql".into(),
362 Value::String("SELECT id FROM events LIMIT 1".into()),
363 )]
364 .into_iter()
365 .collect(),
366 )]);
367 result.final_query = Some("SELECT id FROM events ORDER BY id DESC LIMIT 1".into());
368 result.final_query_params = Value::Array(vec![Value::Integer(7)]);
369
370 let value = build(&result);
371 assert_eq!(
372 value
373 .get("reasoning")
374 .and_then(|reasoning| reasoning.get("summary"))
375 .and_then(Value::as_str),
376 Some("Checked events")
377 );
378 assert_eq!(
379 value.get("final_query").and_then(Value::as_str),
380 Some("SELECT id FROM events ORDER BY id DESC LIMIT 1")
381 );
382 }
383
384 #[test]
385 fn cache_hit_serializes_as_bool() {
386 let mut r = fixture();
387 r.cache_hit = true;
388 let v = build(&r);
389 assert_eq!(v.get("cache_hit").and_then(|x| x.as_bool()), Some(true));
390 }
391
392 #[test]
393 fn citations_are_sorted_by_marker_ascending() {
394 let mut r = fixture();
395 r.citations = vec![
396 Citation {
397 marker: 3,
398 urn: "urn:c".into(),
399 },
400 Citation {
401 marker: 1,
402 urn: "urn:a".into(),
403 },
404 Citation {
405 marker: 2,
406 urn: "urn:b".into(),
407 },
408 ];
409 let v = build(&r);
410 let arr = v.get("citations").and_then(|x| x.as_array()).unwrap();
411 let markers: Vec<u64> = arr
412 .iter()
413 .map(|c| c.get("marker").and_then(|m| m.as_u64()).unwrap())
414 .collect();
415 assert_eq!(markers, vec![1, 2, 3]);
416 }
417
418 #[test]
419 fn sources_flat_preserves_input_order() {
420 let mut r = fixture();
423 r.sources_flat = vec![
424 SourceRow {
425 urn: "urn:z".into(),
426 payload: "{}".into(),
427 },
428 SourceRow {
429 urn: "urn:a".into(),
430 payload: "{}".into(),
431 },
432 ];
433 let v = build(&r);
434 let arr = v.get("sources_flat").and_then(|x| x.as_array()).unwrap();
435 assert_eq!(arr[0].get("urn").and_then(|x| x.as_str()), Some("urn:z"));
436 assert_eq!(arr[1].get("urn").and_then(|x| x.as_str()), Some("urn:a"));
437 }
438
439 #[test]
440 fn sources_row_carries_payload_as_string() {
441 let v = build(&fixture());
442 let arr = v.get("sources_flat").and_then(|x| x.as_array()).unwrap();
443 assert_eq!(
444 arr[0].get("payload").and_then(|x| x.as_str()),
445 Some("{\"k\":\"v\"}")
446 );
447 }
448
449 #[test]
450 fn validation_ok_carries_empty_arrays() {
451 let v = build(&fixture());
452 let val = v.get("validation").unwrap();
453 assert_eq!(val.get("ok").and_then(|x| x.as_bool()), Some(true));
454 assert_eq!(
455 val.get("warnings")
456 .and_then(|x| x.as_array())
457 .unwrap()
458 .len(),
459 0
460 );
461 assert_eq!(
462 val.get("errors").and_then(|x| x.as_array()).unwrap().len(),
463 0
464 );
465 }
466
467 #[test]
468 fn validation_carries_warnings_and_errors_with_kind_detail() {
469 let mut r = fixture();
470 r.validation = Validation {
471 ok: false,
472 warnings: vec![ValidationWarning {
473 kind: "mode_fallback".into(),
474 detail: "ollama".into(),
475 }],
476 errors: vec![ValidationError {
477 kind: "out_of_range".into(),
478 detail: "marker 7 > 3 sources".into(),
479 }],
480 };
481 let v = build(&r);
482 let val = v.get("validation").unwrap();
483 assert_eq!(val.get("ok").and_then(|x| x.as_bool()), Some(false));
484 let warns = val.get("warnings").and_then(|x| x.as_array()).unwrap();
485 assert_eq!(
486 warns[0].get("kind").and_then(|x| x.as_str()),
487 Some("mode_fallback")
488 );
489 assert_eq!(
490 warns[0].get("detail").and_then(|x| x.as_str()),
491 Some("ollama")
492 );
493 let errs = val.get("errors").and_then(|x| x.as_array()).unwrap();
494 assert_eq!(
495 errs[0].get("kind").and_then(|x| x.as_str()),
496 Some("out_of_range")
497 );
498 }
499
500 #[test]
501 fn mode_serializes_as_strict_or_lenient() {
502 let mut r = fixture();
503 r.effective_mode = Mode::Strict;
504 assert_eq!(
505 build(&r).get("mode").and_then(|x| x.as_str()),
506 Some("strict")
507 );
508 r.effective_mode = Mode::Lenient;
509 assert_eq!(
510 build(&r).get("mode").and_then(|x| x.as_str()),
511 Some("lenient")
512 );
513 }
514
515 #[test]
516 fn usage_fields_flat_at_top_level() {
517 let v = build(&fixture());
521 assert_eq!(v.get("prompt_tokens").and_then(|x| x.as_u64()), Some(123));
522 assert_eq!(
523 v.get("completion_tokens").and_then(|x| x.as_u64()),
524 Some(45)
525 );
526 assert!(v.get("cost_usd").is_some());
527 }
528
529 #[test]
530 fn cost_usd_keeps_fractional_precision() {
531 let mut r = fixture();
532 r.cost_usd = 0.000_321;
533 let v = build(&r);
534 assert_eq!(v.get("cost_usd").and_then(|x| x.as_f64()), Some(0.000_321));
535 }
536
537 #[test]
538 fn retry_count_zero_and_one_both_round_trip() {
539 let mut r = fixture();
542 r.retry_count = 0;
543 assert_eq!(
544 build(&r).get("retry_count").and_then(|x| x.as_u64()),
545 Some(0)
546 );
547 r.retry_count = 1;
548 assert_eq!(
549 build(&r).get("retry_count").and_then(|x| x.as_u64()),
550 Some(1)
551 );
552 }
553
554 #[test]
555 fn does_not_expose_seed_or_temperature() {
556 let v = build(&fixture());
560 let obj = v.as_object().unwrap();
561 assert!(!obj.contains_key("seed"));
562 assert!(!obj.contains_key("temperature"));
563 }
564
565 #[test]
566 fn empty_sources_and_citations_are_arrays_not_null() {
567 let mut r = fixture();
571 r.sources_flat = vec![];
572 r.citations = vec![];
573 let v = build(&r);
574 assert!(v
575 .get("sources_flat")
576 .and_then(|x| x.as_array())
577 .unwrap()
578 .is_empty());
579 assert!(v
580 .get("citations")
581 .and_then(|x| x.as_array())
582 .unwrap()
583 .is_empty());
584 }
585
586 #[test]
587 fn answer_escaping_handled_by_compact_encoder() {
588 let mut r = fixture();
589 r.answer = "she said \"hi\"\nnewline".into();
590 let bytes = build(&r).to_string_compact();
591 assert!(bytes.contains(r#"\"hi\""#));
592 assert!(bytes.contains(r#"\n"#));
593 }
594
595 #[test]
596 fn build_is_deterministic_across_calls() {
597 let r = fixture();
598 let a = build(&r).to_string_compact();
599 let b = build(&r).to_string_compact();
600 assert_eq!(a, b);
601 }
602
603 #[test]
604 fn build_is_deterministic_across_clone_inputs() {
605 let r1 = fixture();
606 let r2 = r1.clone();
607 assert_eq!(
608 build(&r1).to_string_compact(),
609 build(&r2).to_string_compact()
610 );
611 }
612
613 #[test]
614 fn top_level_key_order_is_alphabetical() {
615 let bytes = build(&fixture()).to_string_compact();
619 let answer_pos = bytes.find("\"answer\"").unwrap();
620 let cache_pos = bytes.find("\"cache_hit\"").unwrap();
621 let citations_pos = bytes.find("\"citations\"").unwrap();
622 let validation_pos = bytes.find("\"validation\"").unwrap();
623 assert!(answer_pos < cache_pos);
624 assert!(cache_pos < citations_pos);
625 assert!(citations_pos < validation_pos);
626 }
627
628 #[test]
629 fn citation_with_same_marker_is_stable_under_sort() {
630 let mut r = fixture();
636 r.citations = vec![
637 Citation {
638 marker: 1,
639 urn: "urn:first".into(),
640 },
641 Citation {
642 marker: 1,
643 urn: "urn:second".into(),
644 },
645 ];
646 let v = build(&r);
647 let arr = v.get("citations").and_then(|x| x.as_array()).unwrap();
648 assert_eq!(
649 arr[0].get("urn").and_then(|x| x.as_str()),
650 Some("urn:first")
651 );
652 assert_eq!(
653 arr[1].get("urn").and_then(|x| x.as_str()),
654 Some("urn:second")
655 );
656 }
657}