1use std::collections::HashSet;
2
3use serde::de::DeserializeOwned;
4use serde::{Deserialize, Serialize};
5
6use super::named;
7use super::{DecisionInput, Question};
8use crate::{Error, Image, ProviderOptions, RequestOptions, Result};
9
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31#[non_exhaustive]
32pub struct DecisionRequest {
33 pub model: String,
35 pub input: DecisionInput,
37 #[serde(default, skip_serializing_if = "Vec::is_empty")]
45 pub images: Vec<Image>,
46 #[serde(with = "named")]
48 pub questions: Vec<(String, Question)>,
49 #[serde(default, skip_serializing_if = "RequestOptions::is_empty")]
51 pub options: RequestOptions,
52}
53
54impl DecisionRequest {
55 #[must_use]
57 pub fn new(model: impl Into<String>, input: impl Into<DecisionInput>) -> Self {
58 Self {
59 model: model.into(),
60 input: input.into(),
61 images: Vec::new(),
62 questions: Vec::new(),
63 options: RequestOptions::new(),
64 }
65 }
66
67 #[must_use]
80 pub fn image(mut self, image: impl Into<Image>) -> Self {
81 self.images.push(image.into());
82 self
83 }
84
85 #[must_use]
87 pub fn question(mut self, name: impl Into<String>, question: impl Into<Question>) -> Self {
88 self.questions.push((name.into(), question.into()));
89 self
90 }
91
92 #[must_use]
94 pub fn questions<I, N, Q>(mut self, questions: I) -> Self
95 where
96 I: IntoIterator<Item = (N, Q)>,
97 N: Into<String>,
98 Q: Into<Question>,
99 {
100 self.questions.extend(
101 questions
102 .into_iter()
103 .map(|(name, question)| (name.into(), question.into())),
104 );
105 self
106 }
107
108 #[must_use]
110 pub fn find_question(&self, name: &str) -> Option<&Question> {
111 named::get(&self.questions, name)
112 }
113
114 pub fn validate(&self, limits: &DecisionLimits) -> Result<()> {
132 if self.model.trim().is_empty() {
133 return Err(invalid("model must not be blank"));
134 }
135 if self.input.is_blank() {
136 return Err(invalid("input must not be blank"));
137 }
138 if self.questions.is_empty() {
139 return Err(invalid("at least one question is required"));
140 }
141 check_max(self.questions.len(), limits.max_questions, || {
142 "questions".into()
143 })?;
144 check_max(self.images.len(), limits.max_images, || "images".into())?;
145
146 let mut names = HashSet::new();
147 for (name, question) in &self.questions {
148 if is_blank(name) {
149 return Err(invalid("question names must not be blank"));
150 }
151 if !names.insert(name.as_str()) {
152 return Err(invalid(format!("duplicate question name {name:?}")));
153 }
154 let fail = |problem: String| invalid(format!("question {name:?}: {problem}"));
155 if is_blank(question.instructions()) {
156 return Err(fail("instructions must not be blank".into()));
157 }
158 match question {
159 Question::YesNo(question) => {
160 if [&question.yes, &question.no]
161 .into_iter()
162 .flatten()
163 .any(|description| is_blank(description))
164 {
165 return Err(fail("outcome descriptions must not be blank".into()));
166 }
167 }
168 Question::Choice(choice) => {
169 check_entries(
170 choice
171 .options
172 .iter()
173 .map(|option| (option.key.as_str(), option.description.as_deref())),
174 "option",
175 "key",
176 "options",
177 )
178 .map_err(fail)?;
179 check_max(choice.options.len(), limits.max_choice_options, || {
180 format!("options in question {name:?}")
181 })?;
182 }
183 Question::Score(score) => {
184 check_entries(
185 score
186 .levels
187 .iter()
188 .map(|level| (level.label.as_str(), level.description.as_deref())),
189 "level",
190 "label",
191 "levels",
192 )
193 .map_err(fail)?;
194 check_max(score.levels.len(), limits.max_score_levels, || {
195 format!("levels in question {name:?}")
196 })?;
197 }
198 }
199 }
200 Ok(())
201 }
202
203 #[must_use]
205 pub fn with_option<T: ProviderOptions + Serialize>(mut self, value: T) -> Self {
206 self.options.insert(value);
207 self
208 }
209
210 #[must_use]
219 pub fn with_opaque_option<T: Clone + Send + Sync + 'static>(mut self, value: T) -> Self {
220 self.options.insert_opaque(value);
221 self
222 }
223
224 #[must_use]
226 pub fn with_options_json(
227 mut self,
228 namespace: impl Into<String>,
229 value: serde_json::Value,
230 ) -> Self {
231 self.options.insert_json(namespace, value);
232 self
233 }
234
235 #[must_use]
237 pub fn option<T: 'static>(&self) -> Option<&T> {
238 self.options.get::<T>()
239 }
240
241 pub fn resolve_option<T>(&self) -> Result<T>
247 where
248 T: ProviderOptions + DeserializeOwned + Default,
249 {
250 Ok(self.options.resolve_or_default::<T>()?.into_owned())
251 }
252
253 pub fn option_mut<T: Clone + Send + Sync + 'static>(&mut self) -> Option<&mut T> {
259 self.options.get_mut::<T>()
260 }
261
262 pub fn resolve_option_mut<T>(&mut self) -> Result<&mut T>
271 where
272 T: ProviderOptions + Serialize + DeserializeOwned + Default,
273 {
274 self.options.resolve_mut::<T>()
275 }
276}
277
278#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
285#[non_exhaustive]
286pub struct DecisionLimits {
287 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub max_questions: Option<usize>,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub max_choice_options: Option<usize>,
293 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub max_score_levels: Option<usize>,
296 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub max_images: Option<usize>,
299}
300
301impl DecisionLimits {
302 #[must_use]
304 pub fn new() -> Self {
305 Self::default()
306 }
307
308 #[must_use]
310 pub fn max_questions(mut self, max: usize) -> Self {
311 self.max_questions = Some(max);
312 self
313 }
314
315 #[must_use]
317 pub fn max_choice_options(mut self, max: usize) -> Self {
318 self.max_choice_options = Some(max);
319 self
320 }
321
322 #[must_use]
324 pub fn max_score_levels(mut self, max: usize) -> Self {
325 self.max_score_levels = Some(max);
326 self
327 }
328
329 #[must_use]
331 pub fn max_images(mut self, max: usize) -> Self {
332 self.max_images = Some(max);
333 self
334 }
335}
336
337fn invalid(message: impl Into<String>) -> Error {
338 Error::invalid_request(message).authored()
339}
340
341fn is_blank(text: &str) -> bool {
342 text.trim().is_empty()
343}
344
345fn check_entries<'a>(
349 entries: impl ExactSizeIterator<Item = (&'a str, Option<&'a str>)>,
350 entry: &str,
351 name: &str,
352 plural: &str,
353) -> std::result::Result<(), String> {
354 let count = entries.len();
355 if count < 2 {
356 return Err(format!("at least two {plural} are required, found {count}"));
357 }
358 let mut keys = HashSet::new();
359 for (key, description) in entries {
360 if is_blank(key) {
361 return Err(format!("{entry} {name}s must not be blank"));
362 }
363 if !keys.insert(key) {
364 return Err(format!("duplicate {entry} {key:?}"));
365 }
366 if description.is_some_and(is_blank) {
367 return Err(format!(
368 "the description of {entry} {key:?} must not be blank"
369 ));
370 }
371 }
372 Ok(())
373}
374
375fn check_max(count: usize, max: Option<usize>, what: impl FnOnce() -> String) -> Result<()> {
376 match max {
377 Some(max) if count > max => Err(invalid(format!(
378 "too many {}: {count}, the model accepts at most {max}",
379 what()
380 ))),
381 _ => Ok(()),
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use serde_json::json;
389
390 #[test]
391 fn options_change_in_place() {
392 #[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
393 struct Tags(Vec<String>);
394
395 impl ProviderOptions for Tags {
396 const NAMESPACE: &'static str = "tags";
397 }
398
399 let mut request = request().with_options_json("tags", json!(["caller"]));
400 assert!(request.option_mut::<Tags>().is_none());
401 request
402 .resolve_option_mut::<Tags>()
403 .unwrap()
404 .0
405 .push("wrapper".into());
406 request.option_mut::<Tags>().unwrap().0.push("more".into());
407 assert_eq!(
408 request.option::<Tags>().unwrap().0,
409 ["caller", "wrapper", "more"]
410 );
411 }
412
413 fn request() -> DecisionRequest {
414 DecisionRequest::new("m", "ticket")
415 .question(
416 "label",
417 Question::choice("Label?").bare_option("a").bare_option("b"),
418 )
419 .question("spam", Question::yes_no("Spam?"))
420 .question(
421 "urgency",
422 Question::score("Urgency?").levels(["low", "high"]),
423 )
424 }
425
426 fn message(request: DecisionRequest) -> String {
427 match request.validate(&DecisionLimits::new()).unwrap_err() {
428 Error::InvalidRequest { message, .. } => message,
429 other => panic!("expected InvalidRequest, got {other:?}"),
430 }
431 }
432
433 fn one(question: impl Into<Question>) -> DecisionRequest {
434 DecisionRequest::new("m", "x").question("q", question)
435 }
436
437 #[test]
438 fn valid_request_passes() {
439 request().validate(&DecisionLimits::new()).unwrap();
440 }
441
442 #[test]
443 fn structure_is_checked() {
444 assert!(message(DecisionRequest::new("m", "x")).contains("at least one question"));
445 assert!(
446 message(DecisionRequest::new("m", " ").question("q", Question::yes_no("?")))
447 .contains("input")
448 );
449 assert_eq!(
451 message(DecisionRequest::new("m", json!(null)).question("q", Question::yes_no("?"))),
452 "input must not be blank"
453 );
454 assert!(
455 message(request().question("spam", Question::yes_no("Again?")))
456 .contains("duplicate question")
457 );
458 assert!(
459 message(one(Question::choice("?").bare_option("only")))
460 .contains("at least two options")
461 );
462 assert!(
463 message(one(Question::choice("?").bare_option("a").bare_option("a")))
464 .contains("duplicate option \"a\"")
465 );
466 assert!(message(one(Question::yes_no(""))).contains("instructions"));
467 assert!(
468 message(one(Question::score("?")))
469 .contains("at least two levels are required, found 0")
470 );
471 assert!(message(one(Question::score("?").levels(["x", "x"]))).contains("duplicate level"));
472 }
473
474 #[test]
475 fn blank_descriptions_are_rejected() {
476 assert!(
477 message(one(Question::choice("?").option("a", " ").bare_option("b")))
478 .contains("description of option \"a\"")
479 );
480 assert!(
481 message(one(Question::score("?")
482 .level("low", "")
483 .bare_level("high")))
484 .contains("description of level \"low\"")
485 );
486 assert!(message(one(Question::yes_no("?").yes(" "))).contains("outcome descriptions"));
487 }
488
489 #[test]
490 fn limits_are_enforced() {
491 let limits = DecisionLimits::new().max_questions(2);
492 assert!(request().validate(&limits).is_err());
493
494 let limits = DecisionLimits::new().max_choice_options(1);
495 let err = request().validate(&limits).unwrap_err();
496 assert!(
497 err.message().contains("options in question \"label\""),
498 "{err}"
499 );
500
501 let limits = DecisionLimits::new().max_score_levels(1);
502 let err = request().validate(&limits).unwrap_err();
503 assert!(err.message().contains("levels"), "{err}");
504
505 let limits = DecisionLimits::new().max_images(1);
506 let two = request()
507 .image(Image::base64("image/png", "aGk="))
508 .image(Image::base64("image/png", "aGk="));
509 let err = two.validate(&limits).unwrap_err();
510 assert!(err.message().contains("too many images: 2"), "{err}");
511 two.validate(&DecisionLimits::new().max_images(2)).unwrap();
512 }
513
514 #[test]
515 fn images_serialize_only_when_present() {
516 assert!(
517 serde_json::to_value(request())
518 .unwrap()
519 .get("images")
520 .is_none()
521 );
522 let request = request().image(Image::base64("image/png", "aGk="));
523 let value = serde_json::to_value(&request).unwrap();
524 assert_eq!(
525 value["images"],
526 json!([{"source": {"type": "base64", "media_type": "image/png", "data": "aGk="}}])
527 );
528 let back: DecisionRequest = serde_json::from_value(value).unwrap();
529 assert_eq!(back, request);
530 }
531
532 #[test]
533 fn questions_serialize_in_order() {
534 let text = serde_json::to_string(&request()).unwrap();
535 let (label, spam, urgency) = (
536 text.find("\"label\"").unwrap(),
537 text.find("\"spam\"").unwrap(),
538 text.find("\"urgency\"").unwrap(),
539 );
540 assert!(label < spam && spam < urgency);
541 let back: DecisionRequest = serde_json::from_str(&text).unwrap();
542 assert_eq!(back, request());
543 assert_eq!(
544 serde_json::to_value(request()).unwrap()["input"],
545 json!("ticket")
546 );
547 }
548}