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, Debug, Default, Deserialize)]
16pub struct ClassifierContractConfig {
17 #[serde(default)]
18 prompt: Option<String>,
19}
20
21impl ClassifierContractConfig {
22 pub fn with_prompt(mut self, prompt: impl Into<String>) -> Self {
24 self.prompt = Some(prompt.into());
25 self
26 }
27
28 pub fn prompt(&self) -> Option<&str> {
30 self.prompt.as_deref()
31 }
32}
33
34#[derive(Debug)]
36pub(crate) struct ClassifierContract {
37 system_prompt: String,
38 response_format: Value,
39 validator: Option<Validator>,
40}
41
42impl ClassifierContract {
43 pub(crate) fn from_config(
48 config: &ClassifierContractConfig,
49 default_prompt: &str,
50 response_format_json: &str,
51 ) -> Result<Self> {
52 let prompt_template = config.prompt().unwrap_or(default_prompt);
53 let response_format: Value =
54 serde_json::from_str(response_format_json).map_err(|error| {
55 LibsyError::AlgorithmError {
56 message: format!("response schema is invalid: {error}"),
57 }
58 })?;
59 Self::from_response_format(prompt_template, response_format, None)
60 }
61
62 pub(crate) fn from_inner_schema(prompt_template: &str, schema: Value) -> Result<Self> {
64 if schema.get("json_schema").is_some() {
65 return Err(LibsyError::AlgorithmError {
66 message:
67 "response_schema must be the inner JSON Schema, not a response_format wrapper"
68 .to_string(),
69 });
70 }
71 let validator = compile_schema(&schema)?;
72 Self::from_response_format(
73 prompt_template,
74 json!({
75 "type": "json_schema",
76 "json_schema": {
77 "name": "switchyard_classifier_response",
78 "strict": true,
79 "schema": schema,
80 }
81 }),
82 Some(validator),
83 )
84 }
85
86 fn from_response_format(
87 prompt_template: &str,
88 response_format: Value,
89 validator: Option<Validator>,
90 ) -> Result<Self> {
91 if prompt_template.trim().is_empty() {
92 return Err(LibsyError::AlgorithmError {
93 message: "classifier prompt must not be empty".to_string(),
94 });
95 }
96 if prompt_template.contains("{{RESPONSE_SCHEMA}}") {
97 return Err(LibsyError::AlgorithmError {
98 message: "classifier prompt must not include {{RESPONSE_SCHEMA}}; the response schema is sent separately".to_string(),
99 });
100 }
101 response_format
102 .pointer("/json_schema/schema")
103 .ok_or_else(|| LibsyError::AlgorithmError {
104 message: "response schema has no json_schema.schema".to_string(),
105 })?;
106
107 Ok(Self {
108 system_prompt: prompt_template.to_string(),
109 response_format,
110 validator,
111 })
112 }
113
114 pub(crate) fn system_prompt(&self) -> &str {
115 &self.system_prompt
116 }
117
118 pub(crate) fn response_format(&self) -> &Value {
119 &self.response_format
120 }
121
122 pub(crate) fn validate_verdict(&self, verdict: &Value) -> Result<()> {
124 let Some(validator) = &self.validator else {
125 return Ok(());
126 };
127 validator
128 .validate(verdict)
129 .map_err(|error| LibsyError::AlgorithmError {
130 message: format!("classifier verdict did not match response_schema: {error}"),
131 })
132 }
133}
134
135fn compile_schema(schema: &Value) -> Result<Validator> {
136 if !schema.is_object() {
137 return Err(algorithm_error("response_schema must be a JSON object"));
138 }
139 jsonschema::meta::validate(schema).map_err(|error| {
140 algorithm_error(format!(
141 "response_schema is not a valid JSON Schema: {error}"
142 ))
143 })?;
144 jsonschema::validator_for(schema)
145 .map_err(|error| algorithm_error(format!("response_schema could not be compiled: {error}")))
146}
147
148fn algorithm_error(message: impl Into<String>) -> LibsyError {
149 LibsyError::AlgorithmError {
150 message: message.into(),
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 #[test]
159 fn a_runtime_contract_keeps_its_schema_out_of_the_prompt() -> Result<()> {
160 let schema = r#"{
161 "type": "json_schema",
162 "json_schema": {
163 "name": "RiskDecision",
164 "schema": {
165 "type": "object",
166 "properties": {"risk": {"type": "number"}}
167 }
168 }
169 }"#;
170 let config = ClassifierContractConfig::default().with_prompt(
171 "Return a risk verdict matching the response schema supplied with the request.",
172 );
173 let contract = ClassifierContract::from_config(&config, "packaged prompt", schema)?;
174
175 assert_eq!(
176 contract.system_prompt(),
177 "Return a risk verdict matching the response schema supplied with the request."
178 );
179 assert!(!contract.system_prompt().contains("\"risk\""));
180 assert_eq!(
181 contract
182 .response_format()
183 .pointer("/json_schema/name")
184 .and_then(Value::as_str),
185 Some("RiskDecision")
186 );
187 Ok(())
188 }
189
190 #[test]
191 fn a_contract_requires_an_inner_json_schema() {
192 let error = ClassifierContract::from_config(
193 &ClassifierContractConfig::default(),
194 "classify",
195 r#"{"type":"json"}"#,
196 )
197 .expect_err("missing inner schema should be rejected");
198
199 assert!(error.to_string().contains("json_schema.schema"));
200 }
201
202 #[test]
203 fn a_contract_rejects_an_empty_prompt() {
204 let config = ClassifierContractConfig::default().with_prompt(" \n");
205 let error = ClassifierContract::from_config(
206 &config,
207 "packaged prompt",
208 r#"{"json_schema":{"schema":{"type":"object"}}}"#,
209 )
210 .expect_err("empty prompt should be rejected");
211
212 assert!(error.to_string().contains("prompt must not be empty"));
213 }
214
215 #[test]
216 fn a_contract_rejects_a_response_schema_placeholder() {
217 let config = ClassifierContractConfig::default()
218 .with_prompt("Return JSON matching {{RESPONSE_SCHEMA}}");
219 let error = ClassifierContract::from_config(
220 &config,
221 "packaged prompt",
222 r#"{"json_schema":{"schema":{"type":"object"}}}"#,
223 )
224 .expect_err("schema placeholders should be rejected");
225
226 assert!(error.to_string().contains("schema is sent separately"));
227 }
228
229 #[test]
230 fn a_custom_contract_wraps_and_validates_its_inner_schema() -> Result<()> {
231 let contract = ClassifierContract::from_inner_schema(
232 "Choose a target matching the response schema supplied with the request.",
233 json!({
234 "type": "object",
235 "$defs": {
236 "decision": {
237 "type": "object",
238 "properties": {
239 "target": {"type": "string", "enum": ["sonnet", "opus"]}
240 },
241 "required": ["target"],
242 "additionalProperties": false
243 }
244 },
245 "properties": {
246 "decision": {"$ref": "#/$defs/decision"}
247 },
248 "required": ["decision"],
249 "additionalProperties": false
250 }),
251 )?;
252
253 assert_eq!(
254 contract
255 .response_format()
256 .pointer("/json_schema/name")
257 .and_then(Value::as_str),
258 Some("switchyard_classifier_response")
259 );
260 assert_eq!(
261 contract
262 .response_format()
263 .pointer("/json_schema/strict")
264 .and_then(Value::as_bool),
265 Some(true)
266 );
267 assert!(!contract.system_prompt().contains("\"target\""));
268 contract.validate_verdict(&json!({"decision": {"target": "sonnet"}}))?;
269 assert!(
270 contract
271 .validate_verdict(&json!({"decision": {"target": "unknown"}}))
272 .is_err()
273 );
274 Ok(())
275 }
276
277 #[test]
278 fn a_provider_wrapper_is_rejected_as_an_inner_schema() {
279 let error = ClassifierContract::from_inner_schema(
280 "classify",
281 json!({"json_schema": {"schema": {"type": "object"}}}),
282 )
283 .expect_err("provider wrapper should be rejected");
284
285 assert!(error.to_string().contains("inner JSON Schema"));
286 }
287}