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