cratefield_core/structured_output.rs
1//! JSON-Schema-constrained output for the [`TextModel`] port (issue #580):
2//! ask for a value that conforms to a schema, and get either a conforming
3//! value or an error — never a half-parsed one.
4//!
5//! [`Prompt::json_schema`] is already a *request*; what was missing was the
6//! guarantee. [`TextModelExt`] is that guarantee, blanket-implemented so an
7//! adapter cannot skip it: it checks the schema before the call, asks for it,
8//! validates the answer (native [`Completion::json`] or text parsed as JSON)
9//! and allows one repair retry. The validator is the tool loop's narrow,
10//! fail-closed subset. See the trait and its methods for the full contract.
11
12use async_trait::async_trait;
13use schemars::{JsonSchema, Schema};
14use serde::de::DeserializeOwned;
15use serde_json::Value;
16
17use crate::ports::{Completion, Prompt, TextModel, TextModelError, Turn};
18use crate::surface::schema_for;
19use crate::tool_loop::{validate_instance, validate_schema};
20
21/// Structured output on any [`TextModel`] (issue #580). Blanket-implemented
22/// for every `TextModel`, so an adapter cannot override — and cannot skip —
23/// the schema check, the validation, or the repair retry; import the trait
24/// to call the methods.
25#[async_trait]
26pub trait TextModelExt: TextModel {
27 /// Completes `prompt` and returns a value that conforms to `schema`.
28 ///
29 /// The schema is checked on the supported subset before the first call,
30 /// the prompt's [`Prompt::json_schema`] is set to it, and the answer —
31 /// native structured output, or text parsed as JSON — is validated
32 /// against it. On failure the model gets one repair retry; a second
33 /// failure is [`TextModelError::SchemaViolation`].
34 ///
35 /// # Errors
36 ///
37 /// - [`TextModelError::InvalidSchema`] if `schema` uses a keyword the
38 /// validator does not implement; the model is never called.
39 /// - [`TextModelError::SchemaViolation`] if the answer still does not
40 /// conform after the repair retry. The message names the failed
41 /// path and reason only, never the model's output.
42 /// - Whatever `complete` itself returned, unchanged: `NotConfigured`,
43 /// `Rejected`, `Transient`, `Transport`, `Unsupported`.
44 async fn complete_json(&self, prompt: Prompt, schema: &Schema)
45 -> Result<Value, TextModelError>;
46
47 /// Completes `prompt` and deserializes the conforming value into `T`,
48 /// using `T`'s own schema ([`schema_for`]) as the constraint — the
49 /// typed form of [`TextModelExt::complete_json`].
50 ///
51 /// # Errors
52 ///
53 /// The same as [`TextModelExt::complete_json`], plus
54 /// [`TextModelError::SchemaViolation`] if the conforming value does not
55 /// deserialize into `T` (a shape the schema generator and the
56 /// deserializer disagree on).
57 async fn complete_as<T>(&self, prompt: Prompt) -> Result<T, TextModelError>
58 where
59 T: JsonSchema + DeserializeOwned + Send;
60}
61
62#[async_trait]
63impl<M: TextModel + ?Sized> TextModelExt for M {
64 async fn complete_json(
65 &self,
66 mut prompt: Prompt,
67 schema: &Schema,
68 ) -> Result<Value, TextModelError> {
69 let schema = schema.as_value().clone();
70 // Fail closed before the model is asked: an unsupported keyword, or a
71 // root that is not an object, is the caller's mistake.
72 validate_schema(&schema).map_err(TextModelError::InvalidSchema)?;
73 // Both native paths require an object root: Anthropic's forced tool
74 // takes an object `input_schema`, OpenAI's strict `response_format`
75 // an object. A non-object root has no provider that could honour it.
76 if schema.get("type").and_then(Value::as_str) != Some("object") {
77 return Err(TextModelError::InvalidSchema(
78 "the schema's root type must be \"object\"".to_owned(),
79 ));
80 }
81 prompt.json_schema = Some(schema.clone());
82
83 let completion = self.complete(&prompt).await?;
84 match checked(&schema, &completion) {
85 Ok(value) => Ok(value),
86 Err(reason) => {
87 // One repair retry: show the model what it said and what was
88 // wrong with it, and ask once more for only the JSON.
89 prompt.messages.push(Turn::assistant(said(&completion)));
90 prompt
91 .messages
92 .push(Turn::user(repair_instruction(&reason)));
93 let retry = self.complete(&prompt).await?;
94 checked(&schema, &retry).map_err(TextModelError::SchemaViolation)
95 }
96 }
97 }
98
99 async fn complete_as<T>(&self, prompt: Prompt) -> Result<T, TextModelError>
100 where
101 T: JsonSchema + DeserializeOwned + Send,
102 {
103 let schema = schema_for::<T>();
104 let value = self.complete_json(prompt, &schema).await?;
105 serde_json::from_value(value).map_err(|_| {
106 // The serde error quotes the offending value, which may be the
107 // personal data the schema was there to structure; the schema
108 // already checked the shape, so name the failure without it.
109 TextModelError::SchemaViolation(
110 "the validated value did not deserialize into the requested type".to_owned(),
111 )
112 })
113 }
114}
115
116/// The candidate value from `completion`, or the reason it is unusable:
117/// [`Completion::json`] when the provider honoured the schema request, else
118/// [`Completion::text`] parsed as JSON with any markdown fence stripped.
119fn checked(schema: &Value, completion: &Completion) -> Result<Value, String> {
120 let candidate = match &completion.json {
121 Some(json) => json.clone(),
122 None => serde_json::from_str(strip_code_fence(&completion.text))
123 .map_err(|_| "the reply was not valid JSON".to_owned())?,
124 };
125 validate_instance(schema, &candidate)?;
126 Ok(candidate)
127}
128
129/// What the model said, for quoting back as the assistant turn of a repair:
130/// its text, its parsed JSON when a native provider put nothing in the text,
131/// or a placeholder — an empty assistant turn is refused by some providers.
132fn said(completion: &Completion) -> String {
133 if !completion.text.is_empty() {
134 return completion.text.clone();
135 }
136 match &completion.json {
137 Some(json) => json.to_string(),
138 None => "(empty reply)".to_owned(),
139 }
140}
141
142/// The user turn a repair asks for: the failure, and the one thing to do
143/// about it.
144fn repair_instruction(reason: &str) -> String {
145 format!(
146 "That reply did not conform to the required JSON schema ({reason}). \
147 Reply with only a JSON value that conforms to the schema, and nothing else."
148 )
149}
150
151/// Strips one wrapping markdown code fence, tolerating the language tag
152/// (`json`), a bare fence, and plain whitespace-only padding — the shape a
153/// model without native structured output wraps its JSON in.
154fn strip_code_fence(text: &str) -> &str {
155 let trimmed = text.trim();
156 let Some(after_open) = trimmed.strip_prefix("```") else {
157 return trimmed;
158 };
159 let body = match after_open.split_once('\n') {
160 Some((_, body)) => body,
161 None => after_open,
162 };
163 match body.rfind("```") {
164 Some(end) => body[..end].trim(),
165 None => body.trim(),
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[test]
174 fn a_fenced_reply_is_unwrapped() {
175 assert_eq!(strip_code_fence("```json\n{\"a\": 1}\n```"), "{\"a\": 1}");
176 assert_eq!(strip_code_fence("```\n{\"a\": 1}\n```"), "{\"a\": 1}");
177 assert_eq!(strip_code_fence(" {\"a\": 1} "), "{\"a\": 1}");
178 assert_eq!(strip_code_fence("```json\n{\"a\": 1}"), "{\"a\": 1}");
179 }
180
181 #[test]
182 fn a_repair_names_the_reason_and_asks_for_only_json() {
183 let instruction = repair_instruction("the answer is not valid JSON");
184 assert!(instruction.contains("the answer is not valid JSON"));
185 assert!(instruction.contains("only a JSON value"));
186 }
187}