use super::backend::Capabilities;
use super::error::DecisionError;
use serde::ser::SerializeMap;
use serde::{Serialize, Serializer};
use serde_json::Value;
use std::collections::HashSet;
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum QuestionKind {
Noul,
Choice,
Score,
}
impl QuestionKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::Noul => "noul",
Self::Choice => "choice",
Self::Score => "score",
}
}
pub fn all() -> &'static [QuestionKind] {
&[Self::Noul, Self::Choice, Self::Score]
}
}
impl fmt::Display for QuestionKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq)]
enum Body {
Noul {
criteria: Option<(Value, Value)>,
},
Choice {
options: Vec<(String, Option<Value>)>,
},
Score {
levels: Vec<Value>,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct Question {
instructions: Value,
body: Body,
}
impl Question {
pub fn noul(instructions: impl Into<Value>) -> Self {
Self {
instructions: instructions.into(),
body: Body::Noul { criteria: None },
}
}
pub fn noul_with_criteria(
instructions: impl Into<Value>,
if_true: impl Into<Value>,
if_false: impl Into<Value>,
) -> Self {
Self {
instructions: instructions.into(),
body: Body::Noul {
criteria: Some((if_true.into(), if_false.into())),
},
}
}
pub fn choice<I, S>(instructions: impl Into<Value>, options: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
instructions: instructions.into(),
body: Body::Choice {
options: options.into_iter().map(|o| (o.into(), None)).collect(),
},
}
}
pub fn choice_with_criteria<I, K, D>(instructions: impl Into<Value>, options: I) -> Self
where
I: IntoIterator<Item = (K, D)>,
K: Into<String>,
D: Into<Value>,
{
Self {
instructions: instructions.into(),
body: Body::Choice {
options: options
.into_iter()
.map(|(k, d)| (k.into(), Some(d.into())))
.collect(),
},
}
}
pub fn score<I, S>(instructions: impl Into<Value>, levels: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<Value>,
{
Self {
instructions: instructions.into(),
body: Body::Score {
levels: levels.into_iter().map(Into::into).collect(),
},
}
}
pub fn kind(&self) -> QuestionKind {
match self.body {
Body::Noul { .. } => QuestionKind::Noul,
Body::Choice { .. } => QuestionKind::Choice,
Body::Score { .. } => QuestionKind::Score,
}
}
pub fn instructions(&self) -> &Value {
&self.instructions
}
pub fn options(&self) -> Option<Vec<&str>> {
match &self.body {
Body::Choice { options } => Some(options.iter().map(|(o, _)| o.as_str()).collect()),
_ => None,
}
}
pub fn choice_criteria(&self) -> Option<Vec<(&str, Option<&Value>)>> {
match &self.body {
Body::Choice { options } => Some(
options
.iter()
.map(|(o, d)| (o.as_str(), d.as_ref()))
.collect(),
),
_ => None,
}
}
pub fn noul_criteria(&self) -> Option<(&Value, &Value)> {
match &self.body {
Body::Noul {
criteria: Some((t, f)),
} => Some((t, f)),
_ => None,
}
}
pub fn levels(&self) -> Option<&[Value]> {
match &self.body {
Body::Score { levels } => Some(levels),
_ => None,
}
}
pub(crate) fn validate(&self, id: &str, caps: &Capabilities) -> Result<(), DecisionError> {
let kind = self.kind();
if !caps.supports(kind) {
return Err(DecisionError::Unsupported(format!(
"questions.{id}: {kind} questions are not supported by this backend"
)));
}
check_text_like(&self.instructions, &format!("questions.{id}.instructions"))?;
match &self.body {
Body::Noul { criteria } => {
if let Some((t, f)) = criteria {
check_text_like(t, &format!("questions.{id}.criteria.true"))?;
check_text_like(f, &format!("questions.{id}.criteria.false"))?;
}
}
Body::Choice { options } => {
if options.len() < 2 {
return Err(DecisionError::Invalid(format!(
"questions.{id}.criteria: a choice needs at least 2 options, got {}",
options.len()
)));
}
if options.len() > caps.max_choice_options {
return Err(DecisionError::Invalid(format!(
"questions.{id}.criteria: {} options exceed the backend's limit of {}",
options.len(),
caps.max_choice_options
)));
}
let mut seen = HashSet::new();
for (name, desc) in options {
if name.trim().is_empty() {
return Err(DecisionError::Invalid(format!(
"questions.{id}.criteria: an option name is empty"
)));
}
if !seen.insert(name.as_str()) {
return Err(DecisionError::Invalid(format!(
"questions.{id}.criteria: duplicate option {name:?}"
)));
}
if let Some(d) = desc {
check_text_like(d, &format!("questions.{id}.criteria.{name}"))?;
}
}
}
Body::Score { levels } => {
if levels.len() < 2 || levels.len() > caps.max_score_levels {
return Err(DecisionError::Invalid(format!(
"questions.{id}.criteria: a score needs 2 to {} levels, got {}",
caps.max_score_levels,
levels.len()
)));
}
for (i, level) in levels.iter().enumerate() {
check_text_like(level, &format!("questions.{id}.criteria[{i}]"))?;
}
}
}
Ok(())
}
}
fn check_text_like(v: &Value, field: &str) -> Result<(), DecisionError> {
match v {
Value::String(s) if s.trim().is_empty() => Err(DecisionError::Invalid(format!(
"{field}: must not be empty"
))),
Value::String(_) | Value::Object(_) | Value::Array(_) => Ok(()),
other => Err(DecisionError::Invalid(format!(
"{field}: must be a string, object or array, got {}",
json_type(other)
))),
}
}
fn json_type(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "a boolean",
Value::Number(_) => "a number",
Value::String(_) => "a string",
Value::Array(_) => "an array",
Value::Object(_) => "an object",
}
}
impl Serialize for Question {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let has_criteria = !matches!(self.body, Body::Noul { criteria: None });
let mut map = serializer.serialize_map(Some(if has_criteria { 3 } else { 2 }))?;
map.serialize_entry("type", self.kind().as_str())?;
map.serialize_entry("instructions", &self.instructions)?;
match &self.body {
Body::Noul { criteria: None } => {}
Body::Noul {
criteria: Some((t, f)),
} => map.serialize_entry("criteria", &NoulCriteria(t, f))?,
Body::Choice { options } => {
map.serialize_entry("criteria", &ChoiceCriteria(options))?
}
Body::Score { levels } => map.serialize_entry("criteria", levels)?,
}
map.end()
}
}
struct NoulCriteria<'a>(&'a Value, &'a Value);
impl Serialize for NoulCriteria<'_> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(2))?;
map.serialize_entry("true", self.0)?;
map.serialize_entry("false", self.1)?;
map.end()
}
}
struct ChoiceCriteria<'a>(&'a [(String, Option<Value>)]);
impl Serialize for ChoiceCriteria<'_> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(self.0.len()))?;
for (name, desc) in self.0 {
map.serialize_entry(name, desc)?;
}
map.end()
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct Request {
pub model: String,
pub state: Value,
pub questions: Vec<(String, Question)>,
}
impl Request {
pub fn new(model: impl Into<String>, state: impl Into<Value>) -> Self {
Self {
model: model.into(),
state: state.into(),
questions: Vec::new(),
}
}
pub fn question(mut self, id: impl Into<String>, question: Question) -> Self {
self.questions.push((id.into(), question));
self
}
pub fn get(&self, id: &str) -> Option<&Question> {
self.questions
.iter()
.find(|(qid, _)| qid == id)
.map(|(_, q)| q)
}
pub(crate) fn validate(&self, caps: &Capabilities) -> Result<(), DecisionError> {
if self.model.trim().is_empty() {
return Err(DecisionError::Invalid("model: must not be empty".into()));
}
check_text_like(&self.state, "state")?;
if self.questions.is_empty() {
return Err(DecisionError::Invalid(
"questions: at least one question is required".into(),
));
}
let mut seen = HashSet::new();
for (id, q) in &self.questions {
if id.trim().is_empty() {
return Err(DecisionError::Invalid(
"questions: a question id must not be empty".into(),
));
}
if !seen.insert(id.as_str()) {
return Err(DecisionError::Invalid(format!(
"questions.{id}: duplicate question id"
)));
}
q.validate(id, caps)?;
}
check_token_limits(&self.state, &self.questions, caps)
}
}
pub(crate) fn check_token_limits(
state: &Value,
questions: &[(String, Question)],
caps: &Capabilities,
) -> Result<(), DecisionError> {
let state_tokens = estimate_json_tokens(state);
let question_tokens: Vec<usize> = questions
.iter()
.map(|(_, q)| estimate_json_tokens(q))
.collect();
if let Some(max) = caps.max_request_tokens {
let total = state_tokens + question_tokens.iter().sum::<usize>();
if total > max {
return Err(DecisionError::Invalid(format!(
"request: about {total} tokens (estimated) exceeds the backend's \
{max}-token request limit; trim the state or split the questions"
)));
}
}
if let Some(max) = caps.max_state_and_question_tokens {
let longest = question_tokens.iter().copied().max().unwrap_or(0);
if state_tokens + longest > max {
return Err(DecisionError::Invalid(format!(
"state: about {} tokens (estimated) for the state plus the longest \
question exceeds the backend's {max}-token limit",
state_tokens + longest
)));
}
}
Ok(())
}
fn estimate_json_tokens<T: Serialize>(v: &T) -> usize {
let len = match serde_json::to_vec(v) {
Ok(bytes) => bytes.len(),
Err(_) => 0,
};
len.div_ceil(4)
}
impl Serialize for Request {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(3))?;
map.serialize_entry("state", &self.state)?;
map.serialize_entry("model", &self.model)?;
map.serialize_entry("questions", &Questions(&self.questions))?;
map.end()
}
}
struct Questions<'a>(&'a [(String, Question)]);
impl Serialize for Questions<'_> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(self.0.len()))?;
for (id, q) in self.0 {
map.serialize_entry(id, q)?;
}
map.end()
}
}