lc_core/structured_output/
extract.rs1use async_trait::async_trait;
5use serde::de::DeserializeOwned;
6use serde::Serialize;
7use serde_json::Value;
8
9use crate::language_models::{BaseChatModel, LLMResult};
10use crate::output_parsers::BaseOutputParser;
11use crate::output_parsers::JsonOutputParser;
12use crate::text::truncate_at_char_boundary;
13use lc_schema::Message;
14
15#[derive(Debug, Clone, thiserror::Error)]
17#[non_exhaustive]
18pub enum StructuredOutputError {
19 #[error("Schema error: {0}")]
21 SchemaError(String),
22
23 #[error("Parse error: {0}")]
25 ParseError(String),
26
27 #[error("Provider unsupported: {0}")]
29 ProviderUnsupported(String),
30
31 #[error("LLM error: {0}")]
33 LLMError(String),
34
35 #[error("Stream incomplete: {0}")]
37 StreamIncomplete(String),
38}
39
40#[async_trait]
45pub trait StructuredOutputExt: BaseChatModel {
46 async fn with_structured_output<T: DeserializeOwned + Serialize + Send + Sync + 'static>(
60 &self,
61 schema: Value,
62 prompt: &str,
63 ) -> Result<T, StructuredOutputError> {
64 with_structured_output(self, schema, prompt).await
65 }
66}
67
68impl<M: BaseChatModel> StructuredOutputExt for M {}
70
71pub async fn with_structured_output<T, M>(
95 llm: &M,
96 schema: Value,
97 prompt: &str,
98) -> Result<T, StructuredOutputError>
99where
100 T: DeserializeOwned + Serialize + Send + Sync + 'static,
101 M: BaseChatModel + ?Sized,
102{
103 if !schema.is_object() {
105 return Err(StructuredOutputError::SchemaError(format!(
106 "Schema must be a JSON object, got: {}",
107 schema
108 )));
109 }
110
111 let system_prompt = build_structured_system_prompt(&schema);
113
114 let messages = vec![Message::system(system_prompt), Message::human(prompt)];
115
116 let result: LLMResult = llm
118 .chat(messages, None)
119 .await
120 .map_err(|e| StructuredOutputError::LLMError(e.to_string()))?;
121
122 parse_structured_response::<T>(&result.content).await
124}
125
126pub(crate) fn build_structured_system_prompt(schema: &Value) -> String {
128 let schema_str = serde_json::to_string_pretty(schema).unwrap_or_else(|_| schema.to_string());
129
130 format!(
131 "You are a helpful assistant that responds exclusively in valid JSON format.\n\
132 \n\
133 You must respond with a JSON object that conforms to the following JSON Schema:\n\
134 ```json\n\
135 {schema_str}\n\
136 ```\n\
137 \n\
138 Important rules:\n\
139 1. Respond ONLY with valid JSON. Do not include any explanatory text before or after the JSON.\n\
140 2. The JSON must conform exactly to the schema above.\n\
141 3. All required fields must be present.\n\
142 4. Do not include fields that are not in the schema.\n\
143 5. If you cannot satisfy the schema, respond with the closest valid JSON you can produce."
144 )
145}
146
147pub(crate) async fn parse_structured_response<
152 T: DeserializeOwned + Serialize + Send + Sync + 'static,
153>(
154 content: &str,
155) -> Result<T, StructuredOutputError> {
156 let parser = JsonOutputParser::new();
157
158 let json_value: Value = parser.parse(content).await.map_err(|e| {
159 StructuredOutputError::ParseError(format!("Failed to parse LLM response as JSON: {}", e))
160 })?;
161
162 serde_json::from_value::<T>(json_value).map_err(|e| {
163 StructuredOutputError::ParseError(format!(
164 "Failed to deserialize JSON into target type: {}. Response was: {}",
165 e,
166 truncate_at_char_boundary(content, 200)
167 ))
168 })
169}