Skip to main content

openkind_client/
question.rs

1//! Ergonomic constructors for [`Question`] and [`State`] wire types.
2//!
3//! The [`crate`] re-exports the `openkind-core` structs directly; these
4//! helpers just remove the enum-wrapping noise for the common
5//! string-instructions cases, mirroring the Python SDK's `Noul(...)`,
6//! `Choice(...)`, `Score(...)` sugar:
7//!
8//! ```
9//! use openkind_client::{question, Client};
10//!
11//! # async fn demo(client: &Client) -> Result<(), openkind_client::Error> {
12//! let answer = client
13//!     .system_one(
14//!         "I was charged twice. Please help.",
15//!         [
16//!             ("billing", question::noul("Is this about billing?")),
17//!             ("tone", question::choice("What is the tone?", [("calm", None), ("angry", None)])),
18//!             ("severity", question::score("How severe?", ["low", "medium", "high"])),
19//!         ],
20//!     )
21//!     .await?;
22//! # Ok(())
23//! # }
24//! ```
25
26use std::collections::HashMap;
27
28use openkind_core::{ChoiceQuestion, NoulCriteria, NoulQuestion, Question, ScoreQuestion, State};
29
30/// The wire type for question instructions: a JSON string, object, or array.
31pub type Instructions = serde_json::Value;
32
33/// A boolean-probability (`noul`) question with plain-text instructions.
34pub fn noul(instructions: impl Into<Instructions>) -> Question {
35    Question::Noul(NoulQuestion {
36        instructions: instructions.into(),
37        criteria: None,
38    })
39}
40
41/// A `noul` question with explicit meanings for the true and false outcomes.
42pub fn noul_with(
43    instructions: impl Into<Instructions>,
44    is_true: impl Into<String>,
45    is_false: impl Into<String>,
46) -> Question {
47    Question::Noul(NoulQuestion {
48        instructions: instructions.into(),
49        criteria: Some(NoulCriteria {
50            r#true: is_true.into(),
51            r#false: is_false.into(),
52        }),
53    })
54}
55
56/// A categorical (`choice`) question. `criteria` maps each option key to an
57/// optional description (`None` when the key needs no detail).
58pub fn choice<K: Into<String>>(
59    instructions: impl Into<Instructions>,
60    criteria: impl IntoIterator<Item = (K, Option<String>)>,
61) -> Question {
62    Question::Choice(ChoiceQuestion {
63        instructions: instructions.into(),
64        criteria: criteria
65            .into_iter()
66            .map(|(k, v)| (k.into(), v))
67            .collect::<HashMap<_, _, _>>(),
68    })
69}
70
71/// An ordinal (`score`) question over an ordered rubric of at least two levels.
72pub fn score(
73    instructions: impl Into<Instructions>,
74    levels: impl IntoIterator<Item: Into<String>>,
75) -> Question {
76    Question::Score(ScoreQuestion {
77        instructions: instructions.into(),
78        criteria: levels.into_iter().map(Into::into).collect(),
79    })
80}
81
82/// Plain-text [`State`] (the most common case).
83pub fn text(s: impl Into<String>) -> State {
84    State::Text(s.into())
85}
86
87/// Structured JSON-object [`State`] (chat logs, app records, diffs, ...).
88pub fn structured_object(value: serde_json::Map<String, serde_json::Value>) -> State {
89    State::Object(value)
90}
91
92/// JSON-array [`State`] (e.g. an ordered list of chat entries).
93pub fn structured_array(values: Vec<serde_json::Value>) -> State {
94    State::Array(values)
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use openkind_core::{validate_request, SystemRequest};
101
102    #[test]
103    fn helpers_produce_valid_wire_requests() {
104        let request = SystemRequest {
105            state: text("I was charged twice. Please help."),
106            model: "mock".into(),
107            questions: HashMap::from_iter([
108                ("billing".to_string(), noul("Is this about billing?")),
109                (
110                    "tone".to_string(),
111                    choice("What is the tone?", [("calm", None), ("angry", None)]),
112                ),
113                (
114                    "severity".to_string(),
115                    score("How severe?", ["low", "medium", "high"]),
116                ),
117                (
118                    "truthy".to_string(),
119                    noul_with("Did the user dispute?", "user disputes", "user accepts"),
120                ),
121            ]),
122        };
123        assert!(validate_request(&request).is_ok(), "must pass validation");
124    }
125
126    #[test]
127    fn noul_serializes_without_confidence() {
128        let q = noul("yes or no?");
129        let wire = serde_json::to_value(q).unwrap();
130        assert_eq!(wire["type"], "noul");
131        assert_eq!(wire["instructions"], "yes or no?");
132    }
133
134    #[test]
135    fn noul_with_uses_reserved_true_false_keys() {
136        let q = noul_with("disputed?", "disputed", "accepted");
137        let wire = serde_json::to_value(q).unwrap();
138        assert_eq!(wire["criteria"]["true"], "disputed");
139        assert_eq!(wire["criteria"]["false"], "accepted");
140    }
141
142    #[test]
143    fn structured_states_serialize() {
144        let mut map = serde_json::Map::new();
145        map.insert("user".into(), serde_json::json!({"id": 7}));
146        assert!(matches!(structured_object(map), State::Object(_)));
147        assert!(matches!(
148            structured_array(vec![serde_json::json!("hello")]),
149            State::Array(_)
150        ));
151    }
152}