switchyard_libsy/algorithms/util/
classifier_contract.rs1use serde::Deserialize;
7use serde_json::Value;
8
9use crate::{LibsyError, Result};
10
11#[derive(Clone, Debug, Default, Deserialize)]
15pub struct ClassifierContractConfig {
16 #[serde(default)]
17 prompt: Option<String>,
18}
19
20impl ClassifierContractConfig {
21 pub fn with_prompt(mut self, prompt: impl Into<String>) -> Self {
23 self.prompt = Some(prompt.into());
24 self
25 }
26
27 pub fn prompt(&self) -> Option<&str> {
29 self.prompt.as_deref()
30 }
31}
32
33#[derive(Debug)]
35pub(crate) struct ClassifierContract {
36 system_prompt: String,
37 response_format: Value,
38}
39
40impl ClassifierContract {
41 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}