1use std::collections::BTreeMap;
73
74use sha2::{Digest, Sha256};
75
76use crate::json;
77use crate::runtime::ai::strict_validator::{Mode, ValidationError, ValidationErrorKind};
78use crate::serde_json::Value;
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
86pub struct Settings {
87 pub include_answer: bool,
90}
91
92#[derive(Debug, Clone)]
96pub struct CallState<'a> {
97 pub ts_nanos: i64,
100 pub tenant: &'a str,
101 pub user: &'a str,
102 pub role: &'a str,
103 pub question: &'a str,
104 pub sources_urns: &'a [String],
105 pub provider: &'a str,
106 pub model: &'a str,
107 pub prompt_tokens: i64,
108 pub completion_tokens: i64,
109 pub cost_usd: f64,
110 pub answer: &'a str,
113 pub citations: &'a [u32],
115 pub cache_hit: bool,
116 pub effective_mode: Mode,
119 pub temperature: Option<f32>,
120 pub seed: Option<u64>,
121 pub validation_ok: bool,
122 pub retry_count: u32,
123 pub errors: &'a [ValidationError],
124 pub intent: Option<&'a str>,
128 pub plan_summary: Option<&'a str>,
129 pub executed_query: Option<&'a str>,
130}
131
132pub fn build(state: &CallState<'_>, settings: Settings) -> BTreeMap<&'static str, Value> {
140 let mut row: BTreeMap<&'static str, Value> = BTreeMap::new();
141
142 row.insert("ts", json!(state.ts_nanos));
143 row.insert("tenant", json!(state.tenant));
144 row.insert("user", json!(state.user));
145 row.insert("role", json!(state.role));
146 row.insert("question", json!(state.question));
147 row.insert("sources_urns", json!(state.sources_urns));
148 row.insert("provider", json!(state.provider));
149 row.insert("model", json!(state.model));
150 row.insert("prompt_tokens", json!(state.prompt_tokens));
151 row.insert("completion_tokens", json!(state.completion_tokens));
152 row.insert("cost_usd", json!(state.cost_usd));
153 row.insert("answer_hash", json!(answer_hash(state.answer)));
154 row.insert("citations", json!(state.citations));
155 row.insert("cache_hit", json!(state.cache_hit));
156 row.insert("mode", json!(mode_str(state.effective_mode)));
157 row.insert(
158 "temperature",
159 state
160 .temperature
161 .map(|value| json!(value))
162 .unwrap_or(Value::Null),
163 );
164 row.insert(
165 "seed",
166 state.seed.map(|value| json!(value)).unwrap_or(Value::Null),
167 );
168 row.insert("validation_ok", json!(state.validation_ok));
169 row.insert("retry_count", json!(state.retry_count));
170 row.insert(
171 "errors",
172 Value::Array(state.errors.iter().map(error_json).collect()),
173 );
174
175 if settings.include_answer {
176 row.insert("answer", json!(state.answer));
177 }
178
179 if let Some(intent) = state.intent {
181 row.insert("intent", json!(intent));
182 }
183 if let Some(plan_summary) = state.plan_summary {
184 row.insert("plan_summary", json!(plan_summary));
185 }
186 if let Some(executed_query) = state.executed_query {
187 row.insert("executed_query", json!(executed_query));
188 }
189
190 row
191}
192
193pub fn answer_hash(answer: &str) -> String {
197 let mut hasher = Sha256::new();
198 hasher.update(answer.as_bytes());
199 let bytes = hasher.finalize();
200 let mut out = String::with_capacity(bytes.len() * 2);
201 for b in bytes {
202 out.push_str(&format!("{b:02x}"));
203 }
204 out
205}
206
207fn mode_str(mode: Mode) -> &'static str {
208 match mode {
209 Mode::Strict => "strict",
210 Mode::Lenient => "lenient",
211 }
212}
213
214fn error_kind_str(kind: ValidationErrorKind) -> &'static str {
215 match kind {
216 ValidationErrorKind::Malformed => "malformed",
217 ValidationErrorKind::OutOfRange => "out_of_range",
218 }
219}
220
221fn error_json(err: &ValidationError) -> Value {
222 json!({
223 "kind": error_kind_str(err.kind),
224 "detail": err.detail,
225 })
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 fn base_state<'a>(
233 question: &'a str,
234 urns: &'a [String],
235 answer: &'a str,
236 citations: &'a [u32],
237 errors: &'a [ValidationError],
238 ) -> CallState<'a> {
239 CallState {
240 ts_nanos: 1_700_000_000_000_000_000,
241 tenant: "acme",
242 user: "alice",
243 role: "analyst",
244 question,
245 sources_urns: urns,
246 provider: "openai",
247 model: "gpt-4o-mini",
248 prompt_tokens: 123,
249 completion_tokens: 45,
250 cost_usd: 0.0012,
251 answer,
252 citations,
253 cache_hit: false,
254 effective_mode: Mode::Strict,
255 temperature: Some(0.0),
256 seed: Some(42),
257 validation_ok: true,
258 retry_count: 0,
259 errors,
260 intent: None,
261 plan_summary: None,
262 executed_query: None,
263 }
264 }
265
266 #[test]
267 fn planner_fields_present_only_when_set() {
268 let urns: Vec<String> = vec![];
269 let citations: Vec<u32> = vec![];
270 let errors: Vec<ValidationError> = vec![];
271 let mut state = base_state("q?", &urns, "answer", &citations, &errors);
272 let row = build(&state, Settings::default());
273 assert!(!row.contains_key("intent"));
274 assert!(!row.contains_key("plan_summary"));
275 assert!(!row.contains_key("executed_query"));
276
277 state.intent = Some("factual");
278 state.plan_summary = Some("intent=factual; query=SELECT * FROM t WHERE a = 'x'");
279 state.executed_query = Some("SELECT * FROM t WHERE a = 'x'");
280 let row = build(&state, Settings::default());
281 assert_eq!(row.get("intent"), Some(&json!("factual")));
282 assert_eq!(
283 row.get("executed_query"),
284 Some(&json!("SELECT * FROM t WHERE a = 'x'"))
285 );
286 assert!(row.contains_key("plan_summary"));
287 }
288
289 #[test]
292 fn answer_hash_is_deterministic_sha256() {
293 assert_eq!(
295 answer_hash(""),
296 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
297 );
298 }
299
300 #[test]
301 fn answer_hash_known_value_for_short_string() {
302 assert_eq!(
304 answer_hash("hello"),
305 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
306 );
307 }
308
309 #[test]
310 fn answer_hash_repeated_calls_byte_equal() {
311 let a = answer_hash("the cat sat on the mat");
312 let b = answer_hash("the cat sat on the mat");
313 assert_eq!(a, b);
314 }
315
316 #[test]
317 fn answer_hash_differs_for_differing_input() {
318 assert_ne!(answer_hash("a"), answer_hash("b"));
319 }
320
321 #[test]
324 fn build_emits_every_required_field() {
325 let urns = vec!["urn:a".to_string(), "urn:b".to_string()];
326 let citations = vec![1u32, 2];
327 let errors: Vec<ValidationError> = vec![];
328 let state = base_state("q?", &urns, "answer text", &citations, &errors);
329
330 let row = build(&state, Settings::default());
331
332 for key in [
333 "ts",
334 "tenant",
335 "user",
336 "role",
337 "question",
338 "sources_urns",
339 "provider",
340 "model",
341 "prompt_tokens",
342 "completion_tokens",
343 "cost_usd",
344 "answer_hash",
345 "citations",
346 "cache_hit",
347 "mode",
348 "temperature",
349 "seed",
350 "validation_ok",
351 "retry_count",
352 "errors",
353 ] {
354 assert!(row.contains_key(key), "row missing required field `{key}`");
355 }
356 }
357
358 #[test]
359 fn build_field_values_match_state() {
360 let urns = vec!["urn:x".to_string()];
361 let citations = vec![3u32];
362 let errors: Vec<ValidationError> = vec![];
363 let state = base_state("why?", &urns, "because", &citations, &errors);
364
365 let row = build(&state, Settings::default());
366
367 assert_eq!(row["ts"], json!(1_700_000_000_000_000_000_i64));
368 assert_eq!(row["tenant"], json!("acme"));
369 assert_eq!(row["user"], json!("alice"));
370 assert_eq!(row["role"], json!("analyst"));
371 assert_eq!(row["question"], json!("why?"));
372 assert_eq!(row["sources_urns"], json!(["urn:x"]));
373 assert_eq!(row["provider"], json!("openai"));
374 assert_eq!(row["model"], json!("gpt-4o-mini"));
375 assert_eq!(row["prompt_tokens"], json!(123));
376 assert_eq!(row["completion_tokens"], json!(45));
377 assert_eq!(row["cost_usd"], json!(0.0012));
378 assert_eq!(row["answer_hash"], json!(answer_hash("because")));
379 assert_eq!(row["citations"], json!([3]));
380 assert_eq!(row["cache_hit"], json!(false));
381 assert_eq!(row["mode"], json!("strict"));
382 assert_eq!(row["temperature"], json!(0.0));
383 assert_eq!(row["seed"], json!(42u64));
384 assert_eq!(row["validation_ok"], json!(true));
385 assert_eq!(row["retry_count"], json!(0));
386 assert_eq!(row["errors"], json!([]));
387 }
388
389 #[test]
390 fn unsupported_determinism_knobs_are_recorded_as_null() {
391 let urns: Vec<String> = vec![];
392 let citations: Vec<u32> = vec![];
393 let errors: Vec<ValidationError> = vec![];
394 let mut state = base_state("q", &urns, "a", &citations, &errors);
395 state.temperature = None;
396 state.seed = None;
397
398 let row = build(&state, Settings::default());
399
400 assert_eq!(row["temperature"], Value::Null);
401 assert_eq!(row["seed"], Value::Null);
402 }
403
404 #[test]
407 fn answer_field_absent_by_default() {
408 let urns: Vec<String> = vec![];
409 let citations: Vec<u32> = vec![];
410 let errors: Vec<ValidationError> = vec![];
411 let state = base_state("q", &urns, "secret answer", &citations, &errors);
412
413 let row = build(&state, Settings::default());
414
415 assert!(!row.contains_key("answer"));
416 assert_eq!(row["answer_hash"], json!(answer_hash("secret answer")));
419 }
420
421 #[test]
422 fn answer_field_present_when_include_answer_set() {
423 let urns: Vec<String> = vec![];
424 let citations: Vec<u32> = vec![];
425 let errors: Vec<ValidationError> = vec![];
426 let state = base_state("q", &urns, "full text", &citations, &errors);
427
428 let row = build(
429 &state,
430 Settings {
431 include_answer: true,
432 },
433 );
434
435 assert_eq!(row["answer"], json!("full text"));
436 assert_eq!(row["answer_hash"], json!(answer_hash("full text")));
439 }
440
441 #[test]
444 fn lenient_mode_serializes_as_lenient_string() {
445 let urns: Vec<String> = vec![];
446 let citations: Vec<u32> = vec![];
447 let errors: Vec<ValidationError> = vec![];
448 let mut state = base_state("q", &urns, "a", &citations, &errors);
449 state.effective_mode = Mode::Lenient;
450
451 let row = build(&state, Settings::default());
452
453 assert_eq!(row["mode"], json!("lenient"));
454 }
455
456 #[test]
457 fn errors_round_trip_with_kind_and_detail() {
458 let urns: Vec<String> = vec![];
459 let citations: Vec<u32> = vec![];
460 let errors = vec![
461 ValidationError {
462 kind: ValidationErrorKind::Malformed,
463 detail: "empty marker body".to_string(),
464 },
465 ValidationError {
466 kind: ValidationErrorKind::OutOfRange,
467 detail: "marker [^9] references source #9".to_string(),
468 },
469 ];
470 let mut state = base_state("q", &urns, "a", &citations, &errors);
471 state.validation_ok = false;
472 state.retry_count = 1;
473
474 let row = build(&state, Settings::default());
475
476 assert_eq!(row["validation_ok"], json!(false));
477 assert_eq!(row["retry_count"], json!(1));
478 assert_eq!(
479 row["errors"],
480 json!([
481 json!({"kind": "malformed", "detail": "empty marker body"}),
482 json!({"kind": "out_of_range", "detail": "marker [^9] references source #9"}),
483 ])
484 );
485 }
486
487 #[test]
490 fn cache_hit_recorded() {
491 let urns: Vec<String> = vec![];
492 let citations: Vec<u32> = vec![];
493 let errors: Vec<ValidationError> = vec![];
494 let mut state = base_state("q", &urns, "cached", &citations, &errors);
495 state.cache_hit = true;
496 state.prompt_tokens = 0;
497 state.completion_tokens = 0;
498 state.cost_usd = 0.0;
499
500 let row = build(&state, Settings::default());
501
502 assert_eq!(row["cache_hit"], json!(true));
503 assert_eq!(row["cost_usd"], json!(0.0));
506 assert_eq!(row["prompt_tokens"], json!(0));
507 }
508
509 #[test]
512 fn empty_identity_fields_allowed() {
513 let urns: Vec<String> = vec![];
516 let citations: Vec<u32> = vec![];
517 let errors: Vec<ValidationError> = vec![];
518 let mut state = base_state("q", &urns, "a", &citations, &errors);
519 state.tenant = "";
520 state.user = "";
521 state.role = "";
522
523 let row = build(&state, Settings::default());
524
525 assert_eq!(row["tenant"], json!(""));
526 assert_eq!(row["user"], json!(""));
527 assert_eq!(row["role"], json!(""));
528 }
529
530 #[test]
531 fn empty_sources_serializes_as_empty_array() {
532 let urns: Vec<String> = vec![];
533 let citations: Vec<u32> = vec![];
534 let errors: Vec<ValidationError> = vec![];
535 let state = base_state("q", &urns, "a", &citations, &errors);
536
537 let row = build(&state, Settings::default());
538
539 assert_eq!(row["sources_urns"], json!([]));
540 assert_eq!(row["citations"], json!([]));
541 assert_eq!(row["errors"], json!([]));
542 }
543
544 #[test]
545 fn sources_order_preserved() {
546 let urns = vec![
550 "urn:c".to_string(),
551 "urn:a".to_string(),
552 "urn:b".to_string(),
553 ];
554 let citations: Vec<u32> = vec![];
555 let errors: Vec<ValidationError> = vec![];
556 let state = base_state("q", &urns, "a", &citations, &errors);
557
558 let row = build(&state, Settings::default());
559
560 assert_eq!(row["sources_urns"], json!(["urn:c", "urn:a", "urn:b"]));
561 }
562
563 #[test]
564 fn build_is_deterministic_across_calls() {
565 let urns = vec!["urn:a".to_string()];
569 let citations = vec![1u32];
570 let errors: Vec<ValidationError> = vec![];
571 let state = base_state("q", &urns, "a", &citations, &errors);
572
573 let a = build(&state, Settings::default());
574 let b = build(&state, Settings::default());
575 assert_eq!(a, b);
576 }
577}