switchyard_libsy/algorithms/util/
classifier_contract.rs1use jsonschema::Validator;
7use serde::Deserialize;
8use serde_json::{Value, json};
9
10use crate::{LibsyError, Result};
11
12#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
14#[serde(rename_all = "snake_case")]
15pub enum ClassifierResponseFormat {
16 #[default]
18 JsonSchema,
19 JsonObject,
21}
22
23#[derive(Clone, Debug, Default, Deserialize)]
27pub struct ClassifierContractConfig {
28 #[serde(default)]
29 prompt: Option<String>,
30 #[serde(default)]
31 response_format_type: ClassifierResponseFormat,
32}
33
34impl ClassifierContractConfig {
35 pub fn with_prompt(mut self, prompt: impl Into<String>) -> Self {
37 self.prompt = Some(prompt.into());
38 self
39 }
40
41 pub fn prompt(&self) -> Option<&str> {
43 self.prompt.as_deref()
44 }
45
46 pub fn with_response_format_type(
48 mut self,
49 response_format_type: ClassifierResponseFormat,
50 ) -> Self {
51 self.response_format_type = response_format_type;
52 self
53 }
54
55 pub fn response_format_type(&self) -> ClassifierResponseFormat {
57 self.response_format_type
58 }
59}
60
61#[derive(Debug)]
63pub(crate) struct ClassifierContract {
64 system_prompt: String,
65 response_format: Value,
66 validator: Option<Validator>,
67}
68
69impl ClassifierContract {
70 pub(crate) fn from_config(
76 config: &ClassifierContractConfig,
77 default_prompt: &str,
78 response_format_json: &str,
79 ) -> Result<Self> {
80 let prompt_template = config.prompt().unwrap_or(default_prompt);
81 let response_format: Value =
82 serde_json::from_str(response_format_json).map_err(|error| {
83 LibsyError::AlgorithmError {
84 message: format!("response schema is invalid: {error}"),
85 }
86 })?;
87 let schema = response_format
88 .pointer("/json_schema/schema")
89 .ok_or_else(|| LibsyError::AlgorithmError {
90 message: "response schema has no json_schema.schema".to_string(),
91 })?;
92 match config.response_format_type() {
93 ClassifierResponseFormat::JsonSchema => {
94 Self::from_response_format(prompt_template, response_format, None)
95 }
96 ClassifierResponseFormat::JsonObject => {
97 validate_prompt(prompt_template)?;
98 let validator = compile_schema(schema)?;
99 let rendered_schema = serde_json::to_string_pretty(schema).map_err(|error| {
100 algorithm_error(format!("response schema could not be rendered: {error}"))
101 })?;
102 let system_prompt = format!(
103 "{prompt_template}\n\nReturn exactly one JSON object matching this JSON Schema:\n{rendered_schema}"
104 );
105 Self::from_response_format(
106 &system_prompt,
107 json!({"type": "json_object"}),
108 Some(validator),
109 )
110 }
111 }
112 }
113
114 pub(crate) fn from_inner_schema(prompt_template: &str, schema: Value) -> Result<Self> {
116 if schema.get("json_schema").is_some() {
117 return Err(LibsyError::AlgorithmError {
118 message:
119 "response_schema must be the inner JSON Schema, not a response_format wrapper"
120 .to_string(),
121 });
122 }
123 let validator = compile_schema(&schema)?;
124 Self::from_response_format(
125 prompt_template,
126 json!({
127 "type": "json_schema",
128 "json_schema": {
129 "name": "switchyard_classifier_response",
130 "strict": true,
131 "schema": schema,
132 }
133 }),
134 Some(validator),
135 )
136 }
137
138 fn from_response_format(
139 prompt_template: &str,
140 response_format: Value,
141 validator: Option<Validator>,
142 ) -> Result<Self> {
143 validate_prompt(prompt_template)?;
144
145 Ok(Self {
146 system_prompt: prompt_template.to_string(),
147 response_format,
148 validator,
149 })
150 }
151
152 pub(crate) fn system_prompt(&self) -> &str {
153 &self.system_prompt
154 }
155
156 pub(crate) fn response_format(&self) -> &Value {
157 &self.response_format
158 }
159
160 pub(crate) fn validates_locally(&self) -> bool {
162 self.validator.is_some()
163 }
164
165 pub(crate) fn validate_verdict(&self, verdict: &Value) -> Result<()> {
167 let Some(validator) = &self.validator else {
168 return Ok(());
169 };
170 validator
171 .validate(verdict)
172 .map_err(|error| LibsyError::AlgorithmError {
173 message: format!("classifier verdict did not match response_schema: {error}"),
174 })
175 }
176}
177
178fn validate_prompt(prompt_template: &str) -> Result<()> {
179 if prompt_template.trim().is_empty() {
180 return Err(algorithm_error("classifier prompt must not be empty"));
181 }
182 if prompt_template.contains("{{RESPONSE_SCHEMA}}") {
183 return Err(algorithm_error(
184 "classifier prompt must not include {{RESPONSE_SCHEMA}}; remove the placeholder because Switchyard supplies the schema automatically",
185 ));
186 }
187 Ok(())
188}
189
190fn compile_schema(schema: &Value) -> Result<Validator> {
191 if !schema.is_object() {
192 return Err(algorithm_error("response_schema must be a JSON object"));
193 }
194 jsonschema::meta::validate(schema).map_err(|error| {
195 algorithm_error(format!(
196 "response_schema is not a valid JSON Schema: {error}"
197 ))
198 })?;
199 jsonschema::validator_for(schema)
200 .map_err(|error| algorithm_error(format!("response_schema could not be compiled: {error}")))
201}
202
203fn algorithm_error(message: impl Into<String>) -> LibsyError {
204 LibsyError::AlgorithmError {
205 message: message.into(),
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn a_runtime_contract_keeps_its_schema_out_of_the_prompt() -> Result<()> {
215 let schema = r#"{
216 "type": "json_schema",
217 "json_schema": {
218 "name": "RiskDecision",
219 "schema": {
220 "type": "object",
221 "properties": {"risk": {"type": "number"}}
222 }
223 }
224 }"#;
225 let config = ClassifierContractConfig::default().with_prompt(
226 "Return a risk verdict matching the response schema supplied with the request.",
227 );
228 let contract = ClassifierContract::from_config(&config, "packaged prompt", schema)?;
229
230 assert_eq!(
231 contract.system_prompt(),
232 "Return a risk verdict matching the response schema supplied with the request."
233 );
234 assert!(!contract.system_prompt().contains("\"risk\""));
235 assert_eq!(
236 contract
237 .response_format()
238 .pointer("/json_schema/name")
239 .and_then(Value::as_str),
240 Some("RiskDecision")
241 );
242 Ok(())
243 }
244
245 #[test]
246 fn a_contract_requires_an_inner_json_schema() {
247 let error = ClassifierContract::from_config(
248 &ClassifierContractConfig::default(),
249 "classify",
250 r#"{"type":"json"}"#,
251 )
252 .expect_err("missing inner schema should be rejected");
253
254 assert!(error.to_string().contains("json_schema.schema"));
255 }
256
257 #[test]
258 fn a_contract_rejects_an_empty_prompt() {
259 let config = ClassifierContractConfig::default().with_prompt(" \n");
260 let error = ClassifierContract::from_config(
261 &config,
262 "packaged prompt",
263 r#"{"json_schema":{"schema":{"type":"object"}}}"#,
264 )
265 .expect_err("empty prompt should be rejected");
266
267 assert!(error.to_string().contains("prompt must not be empty"));
268 }
269
270 #[test]
271 fn a_contract_rejects_a_response_schema_placeholder() {
272 let config = ClassifierContractConfig::default()
273 .with_prompt("Return JSON matching {{RESPONSE_SCHEMA}}");
274 let error = ClassifierContract::from_config(
275 &config,
276 "packaged prompt",
277 r#"{"json_schema":{"schema":{"type":"object"}}}"#,
278 )
279 .expect_err("schema placeholders should be rejected");
280
281 assert!(
282 error
283 .to_string()
284 .contains("Switchyard supplies the schema automatically")
285 );
286 }
287
288 #[test]
289 fn a_custom_contract_wraps_and_validates_its_inner_schema() -> Result<()> {
290 let contract = ClassifierContract::from_inner_schema(
291 "Choose a target matching the response schema supplied with the request.",
292 json!({
293 "type": "object",
294 "$defs": {
295 "decision": {
296 "type": "object",
297 "properties": {
298 "target": {"type": "string", "enum": ["sonnet", "opus"]}
299 },
300 "required": ["target"],
301 "additionalProperties": false
302 }
303 },
304 "properties": {
305 "decision": {"$ref": "#/$defs/decision"}
306 },
307 "required": ["decision"],
308 "additionalProperties": false
309 }),
310 )?;
311
312 assert_eq!(
313 contract
314 .response_format()
315 .pointer("/json_schema/name")
316 .and_then(Value::as_str),
317 Some("switchyard_classifier_response")
318 );
319 assert_eq!(
320 contract
321 .response_format()
322 .pointer("/json_schema/strict")
323 .and_then(Value::as_bool),
324 Some(true)
325 );
326 assert!(!contract.system_prompt().contains("\"target\""));
327 contract.validate_verdict(&json!({"decision": {"target": "sonnet"}}))?;
328 assert!(
329 contract
330 .validate_verdict(&json!({"decision": {"target": "unknown"}}))
331 .is_err()
332 );
333 Ok(())
334 }
335
336 #[test]
337 fn a_provider_wrapper_is_rejected_as_an_inner_schema() {
338 let error = ClassifierContract::from_inner_schema(
339 "classify",
340 json!({"json_schema": {"schema": {"type": "object"}}}),
341 )
342 .expect_err("provider wrapper should be rejected");
343
344 assert!(error.to_string().contains("inner JSON Schema"));
345 }
346}