Skip to main content

reddb_server/runtime/ai/
ask_response_envelope.rs

1//! `AskResponseEnvelope` — pure serializer for the canonical
2//! non-streaming ASK JSON response (issue #406, PRD #391).
3//!
4//! Deep module: no I/O, no transport, no clock. Owns the on-wire JSON
5//! shape that the embedded stdio JSON-RPC `query` method returns, and
6//! that gRPC (#407), Postgres-wire (#408), and MCP non-stream (#409)
7//! all embed verbatim. Pinning the shape here means a future transport
8//! slice cannot accidentally drop `citations`, rename `cache_hit`, or
9//! re-shape `validation` without the tests in this file failing first.
10//!
11//! ## Why a separate module
12//!
13//! ASK has six new fields the legacy bucketed response did not carry
14//! (`answer`, `sources_flat`, `citations`, `validation`, `cache_hit`,
15//! `cost_usd`). The acceptance criteria for #406 require every one
16//! present in the JSON-RPC response. That's a "field-presence" bug
17//! surface — easy to forget one when the wiring slice lands and hard
18//! to notice in review. Building the JSON in a tested deep module
19//! keeps the wiring slice focused on "where do I write these bytes"
20//! and the contract here on "are the bytes right".
21//!
22//! ## Shape pinned by tests
23//!
24//! Top-level keys (alphabetised by the `BTreeMap`-backed encoder):
25//!
26//! - `answer` — full answer text with inline `[^N]` markers.
27//! - `cache_hit` — bool.
28//! - `citations` — `[{marker, urn}]`, sorted by marker ascending.
29//! - `completion_tokens` — number.
30//! - `cost_usd` — number.
31//! - `exploratory_queries` — bounded SQL queries used during investigation.
32//! - `final_query` / `final_query_params` — terminal validation query.
33//! - `mode` — `"strict"` or `"lenient"`, the *effective* mode after
34//!   provider-capability fallback (#396) — mirrors the audit row #402.
35//! - `model` — string.
36//! - `prompt_tokens` — number.
37//! - `provider` — string.
38//! - `reasoning` — bounded planner summary and auditable tool steps; never raw
39//!   hidden chain-of-thought.
40//! - `retry_count` — number (0 or 1 per #395's one-retry budget).
41//! - `sources_flat` — `[{payload, urn}]`, post-RRF (#398) order
42//!   preserved verbatim so the client can map `[^N]` → `sources_flat[N-1]`.
43//! - `validation` — `{errors, ok, warnings}`. Errors and warnings are
44//!   `[{detail, kind}]` to match the shapes audit_record_builder (#402)
45//!   and sse_frame_encoder (#405) already pin.
46//!
47//! Determinism = seed (#400) is *not* in the response. It's recorded
48//! in the audit row, not surfaced to the caller — leaking the seed
49//! would let a hostile caller replay deterministic answers.
50
51use crate::serde_json::{Map, Value};
52
53/// One row from `sources_flat`. `urn` is the engine entity URN,
54/// `payload` is the column-policy-redacted JSON serialised as a
55/// string so the envelope JSON stays flat (the client re-parses if
56/// it wants structure — matches the SSE `sources` frame shape #405).
57#[derive(Debug, Clone, PartialEq)]
58pub struct SourceRow {
59    pub urn: String,
60    pub payload: String,
61}
62
63/// One citation: `[^N]` in the answer ↔ `sources_flat[N-1]`.
64#[derive(Debug, Clone, PartialEq)]
65pub struct Citation {
66    pub marker: u32,
67    pub urn: String,
68}
69
70/// One validation warning. Same shape as the SSE terminal frame so
71/// HTTP clients can share parsing code across streaming and non-
72/// streaming paths.
73#[derive(Debug, Clone, PartialEq)]
74pub struct ValidationWarning {
75    pub kind: String,
76    pub detail: String,
77}
78
79/// One validation error. Same shape as warnings; `kind` is one of
80/// `"malformed"` / `"out_of_range"` per #395's `ValidationErrorKind`.
81#[derive(Debug, Clone, PartialEq)]
82pub struct ValidationError {
83    pub kind: String,
84    pub detail: String,
85}
86
87/// Validation block. `ok = false` with non-empty `errors` corresponds
88/// to the HTTP 422 path on retry exhaustion (#395).
89#[derive(Debug, Clone, PartialEq)]
90pub struct Validation {
91    pub ok: bool,
92    pub warnings: Vec<ValidationWarning>,
93    pub errors: Vec<ValidationError>,
94}
95
96/// Effective mode actually applied — *after* provider-capability
97/// fallback (#396). The originally-requested mode is recorded in the
98/// audit row, not here.
99#[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/// Internal result a non-streaming ASK call produces — input to
115/// [`build`]. The wiring slice (deferred) constructs this from
116/// `execute_ask`'s outputs.
117#[derive(Debug, Clone)]
118pub struct AskResult {
119    pub answer: String,
120    /// Bounded planner trace intended for callers. This is a structured
121    /// summary, not hidden model chain-of-thought.
122    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
166/// Serialise an [`AskResult`] to its canonical JSON envelope.
167///
168/// Output is a `Value::Object` ready to drop into a JSON-RPC `result`
169/// field, a gRPC message, or a Postgres-wire single-row result set.
170/// Re-running on byte-equal input is byte-equal output (pinned by
171/// `build_is_deterministic_across_calls`) — required by the ASK
172/// determinism contract (#400).
173pub 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    // Marker order is the contract — `[^1]` must come before `[^2]`
220    // in the array so the index aligns with the marker. Pinned by
221    // `citations_are_sorted_by_marker_ascending`.
222    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        // Post-RRF order is the contract — `[^N]` indexes into this
421        // array, so reordering would silently break grounding.
422        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        // Matches the audit row shape (#402) and SSE audit frame
518        // (#405). Nested `usage: {...}` would force every transport
519        // and SDK to re-shape.
520        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        // #395 caps retries at one — pinning both endpoints guards
540        // against an off-by-one if the budget ever changes.
541        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        // Determinism inputs (#400) are recorded in the audit row,
557        // not surfaced to the caller. Leaking the seed would let a
558        // hostile caller replay deterministic answers.
559        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        // Empty arrays are well-formed (`STRICT OFF` on a refusal can
568        // produce no citations). Missing keys would break a downstream
569        // `.length` access.
570        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        // Pinned because clients on weak parsers (e.g. some PG-wire
616        // bindings doing string slicing) have been known to depend on
617        // it. BTreeMap-backed encoder gives it for free.
618        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        // Defensive: if two citations share a marker (malformed input
631        // from the validator path), the sort must be stable so the
632        // input order is preserved. Pinned because a different sort
633        // strategy (unstable + tie on marker) would non-determinise
634        // the response and break #400.
635        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}