use crate::serde_json::{Map, Value};
#[derive(Debug, Clone, PartialEq)]
pub struct SourceRow {
pub urn: String,
pub payload: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Citation {
pub marker: u32,
pub urn: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ValidationWarning {
pub kind: String,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ValidationError {
pub kind: String,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Validation {
pub ok: bool,
pub warnings: Vec<ValidationWarning>,
pub errors: Vec<ValidationError>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Strict,
Lenient,
}
impl Mode {
fn as_str(self) -> &'static str {
match self {
Mode::Strict => "strict",
Mode::Lenient => "lenient",
}
}
}
#[derive(Debug, Clone)]
pub struct AskResult {
pub answer: String,
pub sources_flat: Vec<SourceRow>,
pub citations: Vec<Citation>,
pub validation: Validation,
pub cache_hit: bool,
pub provider: String,
pub model: String,
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub cost_usd: f64,
pub effective_mode: Mode,
pub retry_count: u32,
}
pub fn build(result: &AskResult) -> Value {
let mut m = Map::new();
m.insert("answer".into(), Value::String(result.answer.clone()));
m.insert("cache_hit".into(), Value::Bool(result.cache_hit));
m.insert("citations".into(), citations_value(&result.citations));
m.insert(
"completion_tokens".into(),
Value::Number(result.completion_tokens as f64),
);
m.insert("cost_usd".into(), Value::Number(result.cost_usd));
m.insert(
"mode".into(),
Value::String(result.effective_mode.as_str().into()),
);
m.insert("model".into(), Value::String(result.model.clone()));
m.insert(
"prompt_tokens".into(),
Value::Number(result.prompt_tokens as f64),
);
m.insert("provider".into(), Value::String(result.provider.clone()));
m.insert(
"retry_count".into(),
Value::Number(result.retry_count as f64),
);
m.insert("sources_flat".into(), sources_value(&result.sources_flat));
m.insert("validation".into(), validation_value(&result.validation));
Value::Object(m)
}
fn citations_value(cites: &[Citation]) -> Value {
let mut sorted: Vec<Citation> = cites.to_vec();
sorted.sort_by_key(|c| c.marker);
Value::Array(
sorted
.iter()
.map(|c| {
let mut o = Map::new();
o.insert("marker".into(), Value::Number(c.marker as f64));
o.insert("urn".into(), Value::String(c.urn.clone()));
Value::Object(o)
})
.collect(),
)
}
fn sources_value(rows: &[SourceRow]) -> Value {
Value::Array(
rows.iter()
.map(|r| {
let mut o = Map::new();
o.insert("payload".into(), Value::String(r.payload.clone()));
o.insert("urn".into(), Value::String(r.urn.clone()));
Value::Object(o)
})
.collect(),
)
}
fn warning_value(w: &ValidationWarning) -> Value {
let mut o = Map::new();
o.insert("detail".into(), Value::String(w.detail.clone()));
o.insert("kind".into(), Value::String(w.kind.clone()));
Value::Object(o)
}
fn error_value(e: &ValidationError) -> Value {
let mut o = Map::new();
o.insert("detail".into(), Value::String(e.detail.clone()));
o.insert("kind".into(), Value::String(e.kind.clone()));
Value::Object(o)
}
fn validation_value(v: &Validation) -> Value {
let mut o = Map::new();
o.insert(
"errors".into(),
Value::Array(v.errors.iter().map(error_value).collect()),
);
o.insert("ok".into(), Value::Bool(v.ok));
o.insert(
"warnings".into(),
Value::Array(v.warnings.iter().map(warning_value).collect()),
);
Value::Object(o)
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> AskResult {
AskResult {
answer: "X is 42 [^1].".into(),
sources_flat: vec![SourceRow {
urn: "urn:reddb:row:1".into(),
payload: "{\"k\":\"v\"}".into(),
}],
citations: vec![Citation {
marker: 1,
urn: "urn:reddb:row:1".into(),
}],
validation: Validation {
ok: true,
warnings: vec![],
errors: vec![],
},
cache_hit: false,
provider: "openai".into(),
model: "gpt-4o-mini".into(),
prompt_tokens: 123,
completion_tokens: 45,
cost_usd: 0.000_321,
effective_mode: Mode::Strict,
retry_count: 0,
}
}
#[test]
fn build_emits_every_required_key() {
let v = build(&fixture());
let obj = v.as_object().unwrap();
let mut keys: Vec<&str> = obj.keys().map(|s| s.as_str()).collect();
keys.sort();
assert_eq!(
keys,
vec![
"answer",
"cache_hit",
"citations",
"completion_tokens",
"cost_usd",
"mode",
"model",
"prompt_tokens",
"provider",
"retry_count",
"sources_flat",
"validation",
]
);
}
#[test]
fn answer_text_preserved_with_inline_markers() {
let v = build(&fixture());
assert_eq!(
v.get("answer").and_then(|x| x.as_str()),
Some("X is 42 [^1].")
);
}
#[test]
fn cache_hit_serializes_as_bool() {
let mut r = fixture();
r.cache_hit = true;
let v = build(&r);
assert_eq!(v.get("cache_hit").and_then(|x| x.as_bool()), Some(true));
}
#[test]
fn citations_are_sorted_by_marker_ascending() {
let mut r = fixture();
r.citations = vec![
Citation {
marker: 3,
urn: "urn:c".into(),
},
Citation {
marker: 1,
urn: "urn:a".into(),
},
Citation {
marker: 2,
urn: "urn:b".into(),
},
];
let v = build(&r);
let arr = v.get("citations").and_then(|x| x.as_array()).unwrap();
let markers: Vec<u64> = arr
.iter()
.map(|c| c.get("marker").and_then(|m| m.as_u64()).unwrap())
.collect();
assert_eq!(markers, vec![1, 2, 3]);
}
#[test]
fn sources_flat_preserves_input_order() {
let mut r = fixture();
r.sources_flat = vec![
SourceRow {
urn: "urn:z".into(),
payload: "{}".into(),
},
SourceRow {
urn: "urn:a".into(),
payload: "{}".into(),
},
];
let v = build(&r);
let arr = v.get("sources_flat").and_then(|x| x.as_array()).unwrap();
assert_eq!(arr[0].get("urn").and_then(|x| x.as_str()), Some("urn:z"));
assert_eq!(arr[1].get("urn").and_then(|x| x.as_str()), Some("urn:a"));
}
#[test]
fn sources_row_carries_payload_as_string() {
let v = build(&fixture());
let arr = v.get("sources_flat").and_then(|x| x.as_array()).unwrap();
assert_eq!(
arr[0].get("payload").and_then(|x| x.as_str()),
Some("{\"k\":\"v\"}")
);
}
#[test]
fn validation_ok_carries_empty_arrays() {
let v = build(&fixture());
let val = v.get("validation").unwrap();
assert_eq!(val.get("ok").and_then(|x| x.as_bool()), Some(true));
assert_eq!(
val.get("warnings")
.and_then(|x| x.as_array())
.unwrap()
.len(),
0
);
assert_eq!(
val.get("errors").and_then(|x| x.as_array()).unwrap().len(),
0
);
}
#[test]
fn validation_carries_warnings_and_errors_with_kind_detail() {
let mut r = fixture();
r.validation = Validation {
ok: false,
warnings: vec![ValidationWarning {
kind: "mode_fallback".into(),
detail: "ollama".into(),
}],
errors: vec![ValidationError {
kind: "out_of_range".into(),
detail: "marker 7 > 3 sources".into(),
}],
};
let v = build(&r);
let val = v.get("validation").unwrap();
assert_eq!(val.get("ok").and_then(|x| x.as_bool()), Some(false));
let warns = val.get("warnings").and_then(|x| x.as_array()).unwrap();
assert_eq!(
warns[0].get("kind").and_then(|x| x.as_str()),
Some("mode_fallback")
);
assert_eq!(
warns[0].get("detail").and_then(|x| x.as_str()),
Some("ollama")
);
let errs = val.get("errors").and_then(|x| x.as_array()).unwrap();
assert_eq!(
errs[0].get("kind").and_then(|x| x.as_str()),
Some("out_of_range")
);
}
#[test]
fn mode_serializes_as_strict_or_lenient() {
let mut r = fixture();
r.effective_mode = Mode::Strict;
assert_eq!(
build(&r).get("mode").and_then(|x| x.as_str()),
Some("strict")
);
r.effective_mode = Mode::Lenient;
assert_eq!(
build(&r).get("mode").and_then(|x| x.as_str()),
Some("lenient")
);
}
#[test]
fn usage_fields_flat_at_top_level() {
let v = build(&fixture());
assert_eq!(v.get("prompt_tokens").and_then(|x| x.as_u64()), Some(123));
assert_eq!(
v.get("completion_tokens").and_then(|x| x.as_u64()),
Some(45)
);
assert!(v.get("cost_usd").is_some());
}
#[test]
fn cost_usd_keeps_fractional_precision() {
let mut r = fixture();
r.cost_usd = 0.000_321;
let v = build(&r);
assert_eq!(v.get("cost_usd").and_then(|x| x.as_f64()), Some(0.000_321));
}
#[test]
fn retry_count_zero_and_one_both_round_trip() {
let mut r = fixture();
r.retry_count = 0;
assert_eq!(
build(&r).get("retry_count").and_then(|x| x.as_u64()),
Some(0)
);
r.retry_count = 1;
assert_eq!(
build(&r).get("retry_count").and_then(|x| x.as_u64()),
Some(1)
);
}
#[test]
fn does_not_expose_seed_or_temperature() {
let v = build(&fixture());
let obj = v.as_object().unwrap();
assert!(!obj.contains_key("seed"));
assert!(!obj.contains_key("temperature"));
}
#[test]
fn empty_sources_and_citations_are_arrays_not_null() {
let mut r = fixture();
r.sources_flat = vec![];
r.citations = vec![];
let v = build(&r);
assert!(v
.get("sources_flat")
.and_then(|x| x.as_array())
.unwrap()
.is_empty());
assert!(v
.get("citations")
.and_then(|x| x.as_array())
.unwrap()
.is_empty());
}
#[test]
fn answer_escaping_handled_by_compact_encoder() {
let mut r = fixture();
r.answer = "she said \"hi\"\nnewline".into();
let bytes = build(&r).to_string_compact();
assert!(bytes.contains(r#"\"hi\""#));
assert!(bytes.contains(r#"\n"#));
}
#[test]
fn build_is_deterministic_across_calls() {
let r = fixture();
let a = build(&r).to_string_compact();
let b = build(&r).to_string_compact();
assert_eq!(a, b);
}
#[test]
fn build_is_deterministic_across_clone_inputs() {
let r1 = fixture();
let r2 = r1.clone();
assert_eq!(
build(&r1).to_string_compact(),
build(&r2).to_string_compact()
);
}
#[test]
fn top_level_key_order_is_alphabetical() {
let bytes = build(&fixture()).to_string_compact();
let answer_pos = bytes.find("\"answer\"").unwrap();
let cache_pos = bytes.find("\"cache_hit\"").unwrap();
let citations_pos = bytes.find("\"citations\"").unwrap();
let validation_pos = bytes.find("\"validation\"").unwrap();
assert!(answer_pos < cache_pos);
assert!(cache_pos < citations_pos);
assert!(citations_pos < validation_pos);
}
#[test]
fn citation_with_same_marker_is_stable_under_sort() {
let mut r = fixture();
r.citations = vec![
Citation {
marker: 1,
urn: "urn:first".into(),
},
Citation {
marker: 1,
urn: "urn:second".into(),
},
];
let v = build(&r);
let arr = v.get("citations").and_then(|x| x.as_array()).unwrap();
assert_eq!(
arr[0].get("urn").and_then(|x| x.as_str()),
Some("urn:first")
);
assert_eq!(
arr[1].get("urn").and_then(|x| x.as_str()),
Some("urn:second")
);
}
}