1use super::ask_response_envelope::{
70 AskResult, Citation as EnvCitation, Mode, SourceRow, Validation as EnvValidation,
71 ValidationError, ValidationWarning,
72};
73
74#[derive(Debug, Clone, PartialEq)]
76pub struct GrpcCitation {
77 pub marker: u32,
78 pub urn: String,
79}
80
81#[derive(Debug, Clone, PartialEq)]
83pub struct GrpcValidationItem {
84 pub kind: String,
85 pub detail: String,
86}
87
88#[derive(Debug, Clone, PartialEq)]
90pub struct GrpcValidation {
91 pub ok: bool,
92 pub warnings: Vec<GrpcValidationItem>,
93 pub errors: Vec<GrpcValidationItem>,
94}
95
96#[derive(Debug, Clone, PartialEq)]
98pub struct GrpcAskReply {
99 pub answer: String,
100 pub sources_flat_json: String,
101 pub citations: Vec<GrpcCitation>,
102 pub validation: GrpcValidation,
103 pub provider: String,
104 pub model: String,
105 pub prompt_tokens: u32,
106 pub completion_tokens: u32,
107 pub cost_usd: f64,
108 pub cache_hit: bool,
109 pub mode: String,
110 pub retry_count: u32,
111}
112
113pub mod proto_tags {
117 pub mod ask_reply {
118 pub const ANSWER: u32 = 1;
119 pub const SOURCES_FLAT_JSON: u32 = 2;
120 pub const CITATIONS: u32 = 3;
121 pub const VALIDATION: u32 = 4;
122 pub const PROVIDER: u32 = 5;
123 pub const MODEL: u32 = 6;
124 pub const PROMPT_TOKENS: u32 = 7;
125 pub const COMPLETION_TOKENS: u32 = 8;
126 pub const COST_USD: u32 = 9;
127 pub const CACHE_HIT: u32 = 10;
128 pub const MODE: u32 = 11;
129 pub const RETRY_COUNT: u32 = 12;
130 }
131 pub mod citation {
132 pub const MARKER: u32 = 1;
133 pub const URN: u32 = 2;
134 }
135 pub mod validation {
136 pub const OK: u32 = 1;
137 pub const WARNINGS: u32 = 2;
138 pub const ERRORS: u32 = 3;
139 }
140 pub mod validation_item {
141 pub const KIND: u32 = 1;
142 pub const DETAIL: u32 = 2;
143 }
144}
145
146pub fn build(result: &AskResult) -> GrpcAskReply {
154 let mut citations: Vec<GrpcCitation> = result
155 .citations
156 .iter()
157 .map(|c: &EnvCitation| GrpcCitation {
158 marker: c.marker,
159 urn: c.urn.clone(),
160 })
161 .collect();
162 citations.sort_by_key(|c| c.marker);
163
164 GrpcAskReply {
165 answer: result.answer.clone(),
166 sources_flat_json: sources_flat_json(&result.sources_flat),
167 citations,
168 validation: validation_from(&result.validation),
169 provider: result.provider.clone(),
170 model: result.model.clone(),
171 prompt_tokens: result.prompt_tokens,
172 completion_tokens: result.completion_tokens,
173 cost_usd: result.cost_usd,
174 cache_hit: result.cache_hit,
175 mode: mode_str(result.effective_mode).to_string(),
176 retry_count: result.retry_count,
177 }
178}
179
180fn mode_str(mode: Mode) -> &'static str {
181 match mode {
182 Mode::Strict => "strict",
183 Mode::Lenient => "lenient",
184 }
185}
186
187fn validation_from(v: &EnvValidation) -> GrpcValidation {
188 GrpcValidation {
189 ok: v.ok,
190 warnings: v.warnings.iter().map(warning_item).collect(),
191 errors: v.errors.iter().map(error_item).collect(),
192 }
193}
194
195fn warning_item(w: &ValidationWarning) -> GrpcValidationItem {
196 GrpcValidationItem {
197 kind: w.kind.clone(),
198 detail: w.detail.clone(),
199 }
200}
201
202fn error_item(e: &ValidationError) -> GrpcValidationItem {
203 GrpcValidationItem {
204 kind: e.kind.clone(),
205 detail: e.detail.clone(),
206 }
207}
208
209fn sources_flat_json(rows: &[SourceRow]) -> String {
210 let mut out = String::from("[");
215 for (i, r) in rows.iter().enumerate() {
216 if i > 0 {
217 out.push(',');
218 }
219 out.push('{');
220 out.push_str("\"payload\":");
221 push_json_string(&mut out, &r.payload);
222 out.push(',');
223 out.push_str("\"urn\":");
224 push_json_string(&mut out, &r.urn);
225 out.push('}');
226 }
227 out.push(']');
228 out
229}
230
231fn push_json_string(out: &mut String, s: &str) {
232 out.push('"');
233 for ch in s.chars() {
234 match ch {
235 '"' => out.push_str("\\\""),
236 '\\' => out.push_str("\\\\"),
237 '\n' => out.push_str("\\n"),
238 '\r' => out.push_str("\\r"),
239 '\t' => out.push_str("\\t"),
240 c if (c as u32) < 0x20 => {
241 out.push_str(&format!("\\u{:04x}", c as u32));
242 }
243 c => out.push(c),
244 }
245 }
246 out.push('"');
247}
248
249#[cfg(test)]
250mod tests {
251 use super::proto_tags::*;
252 use super::*;
253 use crate::runtime::ai::ask_response_envelope::{
254 AskResult, Citation as EnvCitation, Mode, SourceRow, Validation as EnvValidation,
255 ValidationError, ValidationWarning,
256 };
257
258 fn sample_result() -> AskResult {
259 AskResult {
260 answer: "The capital is Lisbon [^1].".to_string(),
261 sources_flat: vec![SourceRow {
262 urn: "urn:reddb:row:cities/42".to_string(),
263 payload: "{\"name\":\"Lisbon\"}".to_string(),
264 }],
265 citations: vec![EnvCitation {
266 marker: 1,
267 urn: "urn:reddb:row:cities/42".to_string(),
268 }],
269 validation: EnvValidation {
270 ok: true,
271 warnings: vec![],
272 errors: vec![],
273 },
274 cache_hit: false,
275 provider: "openai".to_string(),
276 model: "gpt-4o-mini".to_string(),
277 prompt_tokens: 123,
278 completion_tokens: 17,
279 cost_usd: 0.0042,
280 effective_mode: Mode::Strict,
281 retry_count: 0,
282 }
283 }
284
285 #[test]
286 fn build_emits_every_top_level_field() {
287 let r = sample_result();
288 let reply = build(&r);
289 assert_eq!(reply.answer, r.answer);
290 assert_eq!(reply.provider, r.provider);
291 assert_eq!(reply.model, r.model);
292 assert_eq!(reply.prompt_tokens, r.prompt_tokens);
293 assert_eq!(reply.completion_tokens, r.completion_tokens);
294 assert_eq!(reply.cost_usd, r.cost_usd);
295 assert_eq!(reply.cache_hit, r.cache_hit);
296 assert_eq!(reply.retry_count, r.retry_count);
297 assert_eq!(reply.mode, "strict");
298 assert!(reply.validation.ok);
299 assert_eq!(reply.citations.len(), 1);
300 assert_eq!(reply.citations[0].marker, 1);
301 assert!(reply.sources_flat_json.starts_with('['));
302 assert!(reply.sources_flat_json.ends_with(']'));
303 }
304
305 #[test]
306 fn mode_strict_serialises_as_strict() {
307 let mut r = sample_result();
308 r.effective_mode = Mode::Strict;
309 assert_eq!(build(&r).mode, "strict");
310 }
311
312 #[test]
313 fn mode_lenient_serialises_as_lenient() {
314 let mut r = sample_result();
315 r.effective_mode = Mode::Lenient;
316 assert_eq!(build(&r).mode, "lenient");
317 }
318
319 #[test]
320 fn citations_sorted_by_marker_ascending() {
321 let mut r = sample_result();
322 r.citations = vec![
323 EnvCitation {
324 marker: 3,
325 urn: "urn:c".to_string(),
326 },
327 EnvCitation {
328 marker: 1,
329 urn: "urn:a".to_string(),
330 },
331 EnvCitation {
332 marker: 2,
333 urn: "urn:b".to_string(),
334 },
335 ];
336 let reply = build(&r);
337 assert_eq!(
338 reply.citations.iter().map(|c| c.marker).collect::<Vec<_>>(),
339 vec![1, 2, 3]
340 );
341 }
342
343 #[test]
344 fn citation_same_marker_is_stable() {
345 let mut r = sample_result();
346 r.citations = vec![
347 EnvCitation {
348 marker: 1,
349 urn: "urn:first".to_string(),
350 },
351 EnvCitation {
352 marker: 1,
353 urn: "urn:second".to_string(),
354 },
355 ];
356 let reply = build(&r);
357 assert_eq!(reply.citations[0].urn, "urn:first");
358 assert_eq!(reply.citations[1].urn, "urn:second");
359 }
360
361 #[test]
362 fn sources_flat_preserves_order_verbatim() {
363 let mut r = sample_result();
364 r.sources_flat = vec![
365 SourceRow {
366 urn: "urn:b".to_string(),
367 payload: "{}".to_string(),
368 },
369 SourceRow {
370 urn: "urn:a".to_string(),
371 payload: "{}".to_string(),
372 },
373 ];
374 let reply = build(&r);
375 let pos_b = reply.sources_flat_json.find("urn:b").unwrap();
376 let pos_a = reply.sources_flat_json.find("urn:a").unwrap();
377 assert!(pos_b < pos_a, "RRF order must be preserved");
378 }
379
380 #[test]
381 fn empty_sources_serialises_as_empty_array() {
382 let mut r = sample_result();
383 r.sources_flat = vec![];
384 assert_eq!(build(&r).sources_flat_json, "[]");
385 }
386
387 #[test]
388 fn empty_citations_yields_empty_vec_not_panic() {
389 let mut r = sample_result();
390 r.citations = vec![];
391 assert!(build(&r).citations.is_empty());
392 }
393
394 #[test]
395 fn sources_flat_json_keys_alphabetised() {
396 let mut r = sample_result();
397 r.sources_flat = vec![SourceRow {
398 urn: "urn:x".to_string(),
399 payload: "p".to_string(),
400 }];
401 let json = build(&r).sources_flat_json;
402 let pos_payload = json.find("\"payload\"").unwrap();
403 let pos_urn = json.find("\"urn\"").unwrap();
404 assert!(pos_payload < pos_urn, "envelope parity: payload before urn");
405 }
406
407 #[test]
408 fn sources_flat_json_escapes_quotes_and_backslashes() {
409 let mut r = sample_result();
410 r.sources_flat = vec![SourceRow {
411 urn: "urn:row".to_string(),
412 payload: "{\"k\":\"v\\\"\"}".to_string(),
413 }];
414 let json = build(&r).sources_flat_json;
415 let parsed: crate::serde_json::Value = crate::serde_json::from_str(&json).unwrap();
417 let arr = parsed.as_array().unwrap();
418 assert_eq!(arr.len(), 1);
419 }
420
421 #[test]
422 fn sources_flat_json_escapes_control_chars() {
423 let mut r = sample_result();
424 r.sources_flat = vec![SourceRow {
425 urn: "urn:row".to_string(),
426 payload: "line1\nline2\ttab\u{0001}ctrl".to_string(),
427 }];
428 let json = build(&r).sources_flat_json;
429 let parsed: crate::serde_json::Value = crate::serde_json::from_str(&json).unwrap();
430 let arr = parsed.as_array().unwrap();
431 let payload = arr[0]["payload"].as_str().unwrap();
432 assert!(payload.contains('\n'));
433 assert!(payload.contains('\t'));
434 assert!(payload.contains('\u{0001}'));
435 }
436
437 #[test]
438 fn validation_warnings_and_errors_roundtrip() {
439 let mut r = sample_result();
440 r.validation = EnvValidation {
441 ok: false,
442 warnings: vec![ValidationWarning {
443 kind: "malformed".to_string(),
444 detail: "missing marker".to_string(),
445 }],
446 errors: vec![ValidationError {
447 kind: "out_of_range".to_string(),
448 detail: "marker > sources".to_string(),
449 }],
450 };
451 let reply = build(&r);
452 assert!(!reply.validation.ok);
453 assert_eq!(reply.validation.warnings.len(), 1);
454 assert_eq!(reply.validation.warnings[0].kind, "malformed");
455 assert_eq!(reply.validation.warnings[0].detail, "missing marker");
456 assert_eq!(reply.validation.errors.len(), 1);
457 assert_eq!(reply.validation.errors[0].kind, "out_of_range");
458 }
459
460 #[test]
461 fn cache_hit_records_zero_cost_and_tokens_when_zero() {
462 let mut r = sample_result();
463 r.cache_hit = true;
464 r.prompt_tokens = 0;
465 r.completion_tokens = 0;
466 r.cost_usd = 0.0;
467 let reply = build(&r);
468 assert!(reply.cache_hit);
469 assert_eq!(reply.prompt_tokens, 0);
470 assert_eq!(reply.completion_tokens, 0);
471 assert_eq!(reply.cost_usd, 0.0);
472 }
473
474 #[test]
475 fn build_is_deterministic_across_calls() {
476 let r = sample_result();
477 assert_eq!(build(&r), build(&r));
478 }
479
480 #[test]
481 fn does_not_expose_seed_or_temperature() {
482 let r = sample_result();
485 let GrpcAskReply {
486 answer: _,
487 sources_flat_json: _,
488 citations: _,
489 validation: _,
490 provider: _,
491 model: _,
492 prompt_tokens: _,
493 completion_tokens: _,
494 cost_usd: _,
495 cache_hit: _,
496 mode: _,
497 retry_count: _,
498 } = build(&r);
499 }
500
501 #[test]
502 fn ask_reply_proto_tags_pinned() {
503 assert_eq!(ask_reply::ANSWER, 1);
504 assert_eq!(ask_reply::SOURCES_FLAT_JSON, 2);
505 assert_eq!(ask_reply::CITATIONS, 3);
506 assert_eq!(ask_reply::VALIDATION, 4);
507 assert_eq!(ask_reply::PROVIDER, 5);
508 assert_eq!(ask_reply::MODEL, 6);
509 assert_eq!(ask_reply::PROMPT_TOKENS, 7);
510 assert_eq!(ask_reply::COMPLETION_TOKENS, 8);
511 assert_eq!(ask_reply::COST_USD, 9);
512 assert_eq!(ask_reply::CACHE_HIT, 10);
513 assert_eq!(ask_reply::MODE, 11);
514 assert_eq!(ask_reply::RETRY_COUNT, 12);
515 }
516
517 #[test]
518 fn ask_reply_proto_tags_are_unique_and_contiguous() {
519 let tags = [
520 ask_reply::ANSWER,
521 ask_reply::SOURCES_FLAT_JSON,
522 ask_reply::CITATIONS,
523 ask_reply::VALIDATION,
524 ask_reply::PROVIDER,
525 ask_reply::MODEL,
526 ask_reply::PROMPT_TOKENS,
527 ask_reply::COMPLETION_TOKENS,
528 ask_reply::COST_USD,
529 ask_reply::CACHE_HIT,
530 ask_reply::MODE,
531 ask_reply::RETRY_COUNT,
532 ];
533 let mut sorted = tags.to_vec();
534 sorted.sort();
535 sorted.dedup();
536 assert_eq!(sorted.len(), tags.len(), "duplicate proto field tag");
537 assert_eq!(sorted, (1u32..=tags.len() as u32).collect::<Vec<_>>());
538 }
539
540 #[test]
541 fn nested_message_proto_tags_pinned() {
542 assert_eq!(citation::MARKER, 1);
543 assert_eq!(citation::URN, 2);
544 assert_eq!(validation::OK, 1);
545 assert_eq!(validation::WARNINGS, 2);
546 assert_eq!(validation::ERRORS, 3);
547 assert_eq!(validation_item::KIND, 1);
548 assert_eq!(validation_item::DETAIL, 2);
549 }
550
551 #[test]
552 fn field_set_matches_json_envelope() {
553 let r = sample_result();
557 let envelope = crate::runtime::ai::ask_response_envelope::build(&r);
558 let keys: Vec<&str> = envelope
559 .as_object()
560 .unwrap()
561 .keys()
562 .map(|s| s.as_str())
563 .collect();
564 let expected = [
565 "answer",
566 "cache_hit",
567 "citations",
568 "completion_tokens",
569 "cost_usd",
570 "mode",
571 "model",
572 "prompt_tokens",
573 "provider",
574 "retry_count",
575 "sources_flat",
576 "validation",
577 ];
578 for k in &expected {
579 assert!(keys.contains(k), "envelope missing key {k}");
580 }
581 assert_eq!(keys.len(), expected.len(), "envelope keys drift detected");
582 }
583}