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`. Its inner schema replaces every
46    /// `{{RESPONSE_SCHEMA}}` placeholder in the prompt, while the complete response format is
47    /// retained for the model request.
48    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    /// Builds a provider response format around a user-supplied inner JSON Schema.
64    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    /// Validates a dynamic verdict when this contract carries a runtime schema validator.
125    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}