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 serde::Deserialize;
7use serde_json::Value;
8
9use crate::{LibsyError, Result};
10
11/// User-configurable parts of a classifier's prompt and verdict contract.
12///
13/// Fields are private so new contract settings can be added without breaking Rust struct literals.
14#[derive(Clone, Debug, Default, Deserialize)]
15pub struct ClassifierContractConfig {
16    #[serde(default)]
17    prompt: Option<String>,
18}
19
20impl ClassifierContractConfig {
21    /// Overrides the packaged classifier prompt.
22    pub fn with_prompt(mut self, prompt: impl Into<String>) -> Self {
23        self.prompt = Some(prompt.into());
24        self
25    }
26
27    /// Returns the configured prompt override.
28    pub fn prompt(&self) -> Option<&str> {
29        self.prompt.as_deref()
30    }
31}
32
33/// Rendered prompt and response format for one classifier.
34#[derive(Debug)]
35pub(crate) struct ClassifierContract {
36    system_prompt: String,
37    response_format: Value,
38}
39
40impl ClassifierContract {
41    /// Builds a contract from user settings and packaged defaults.
42    ///
43    /// The response format must contain `json_schema.schema`. Its inner schema replaces every
44    /// `{{RESPONSE_SCHEMA}}` placeholder in the prompt, while the complete response format is
45    /// retained for the model request.
46    pub(crate) fn from_config(
47        config: &ClassifierContractConfig,
48        default_prompt: &str,
49        response_format_json: &str,
50    ) -> Result<Self> {
51        let prompt_template = config.prompt().unwrap_or(default_prompt);
52        if prompt_template.trim().is_empty() {
53            return Err(LibsyError::AlgorithmError {
54                message: "classifier prompt must not be empty".to_string(),
55            });
56        }
57        let response_format: Value =
58            serde_json::from_str(response_format_json).map_err(|error| {
59                LibsyError::AlgorithmError {
60                    message: format!("response schema is invalid: {error}"),
61                }
62            })?;
63        let prompt_schema = response_format
64            .pointer("/json_schema/schema")
65            .ok_or_else(|| LibsyError::AlgorithmError {
66                message: "response schema has no json_schema.schema".to_string(),
67            })?;
68        let prompt_schema = serde_json::to_string_pretty(prompt_schema).map_err(|error| {
69            LibsyError::AlgorithmError {
70                message: format!("prompt schema could not be rendered: {error}"),
71            }
72        })?;
73
74        Ok(Self {
75            system_prompt: prompt_template.replace("{{RESPONSE_SCHEMA}}", &prompt_schema),
76            response_format,
77        })
78    }
79
80    pub(crate) fn system_prompt(&self) -> &str {
81        &self.system_prompt
82    }
83
84    pub(crate) fn response_format(&self) -> &Value {
85        &self.response_format
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn a_runtime_contract_renders_its_own_schema() -> Result<()> {
95        let schema = r#"{
96            "type": "json_schema",
97            "json_schema": {
98                "name": "RiskDecision",
99                "schema": {
100                    "type": "object",
101                    "properties": {"risk": {"type": "number"}}
102                }
103            }
104        }"#;
105        let config = ClassifierContractConfig::default()
106            .with_prompt("Return a risk verdict matching:\n{{RESPONSE_SCHEMA}}");
107        let contract = ClassifierContract::from_config(&config, "packaged prompt", schema)?;
108
109        assert!(contract.system_prompt().contains("\"risk\""));
110        assert!(!contract.system_prompt().contains("{{RESPONSE_SCHEMA}}"));
111        assert_eq!(
112            contract
113                .response_format()
114                .pointer("/json_schema/name")
115                .and_then(Value::as_str),
116            Some("RiskDecision")
117        );
118        Ok(())
119    }
120
121    #[test]
122    fn a_contract_requires_an_inner_json_schema() {
123        let error = ClassifierContract::from_config(
124            &ClassifierContractConfig::default(),
125            "classify",
126            r#"{"type":"json"}"#,
127        )
128        .expect_err("missing inner schema should be rejected");
129
130        assert!(error.to_string().contains("json_schema.schema"));
131    }
132
133    #[test]
134    fn a_contract_rejects_an_empty_prompt() {
135        let config = ClassifierContractConfig::default().with_prompt("  \n");
136        let error = ClassifierContract::from_config(
137            &config,
138            "packaged prompt",
139            r#"{"json_schema":{"schema":{"type":"object"}}}"#,
140        )
141        .expect_err("empty prompt should be rejected");
142
143        assert!(error.to_string().contains("prompt must not be empty"));
144    }
145}