Skip to main content

switchyard_libsy/algorithms/util/
classifier_contract.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Prompt and structured-output contracts shared by LLM classifiers.
5
6use jsonschema::Validator;
7use serde::Deserialize;
8use serde_json::{Value, json};
9
10use crate::{LibsyError, Result};
11
12/// User-configurable parts of a classifier's prompt and verdict contract.
13///
14/// Fields are private so new contract settings can be added without breaking Rust struct literals.
15#[derive(Clone, Debug, Default, Deserialize)]
16pub struct ClassifierContractConfig {
17    #[serde(default)]
18    prompt: Option<String>,
19}
20
21impl ClassifierContractConfig {
22    /// Overrides the packaged classifier prompt.
23    pub fn with_prompt(mut self, prompt: impl Into<String>) -> Self {
24        self.prompt = Some(prompt.into());
25        self
26    }
27
28    /// Returns the configured prompt override.
29    pub fn prompt(&self) -> Option<&str> {
30        self.prompt.as_deref()
31    }
32}
33
34/// Rendered prompt and response format for one classifier.
35#[derive(Debug)]
36pub(crate) struct ClassifierContract {
37    system_prompt: String,
38    response_format: Value,
39    validator: Option<Validator>,
40}
41
42impl ClassifierContract {
43    /// Builds a contract from user settings and packaged defaults.
44    ///
45    /// The response format must contain `json_schema.schema` and is retained separately for the
46    /// model request. Schemas are never copied into the system prompt.
47    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    /// Builds a provider response format around a user-supplied inner JSON Schema.
63    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    /// Validates a dynamic verdict when this contract carries a runtime schema validator.
123    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}