Skip to main content

zeph_llm/
extractor.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Typed structured extraction from free-form text.
5//!
6//! [`Extractor`] wraps any [`LlmProvider`] and exposes a single
7//! [`extract::<T>()`](Extractor::extract) method that:
8//! 1. Injects a JSON schema derived from `T` into the system prompt.
9//! 2. Sends the input text as a user message.
10//! 3. Parses the response as `T`, retrying once on parse failure.
11//!
12//! # Examples
13//!
14//! ```rust,no_run
15//! use serde::Deserialize;
16//! use schemars::JsonSchema;
17//! use zeph_llm::Extractor;
18//!
19//! #[derive(Debug, Deserialize, JsonSchema)]
20//! struct Sentiment {
21//!     label: String,  // "positive" | "negative" | "neutral"
22//!     score: f32,
23//! }
24//!
25//! # async fn run(provider: &impl zeph_llm::provider::LlmProvider) -> Result<(), zeph_llm::LlmError> {
26//! let sentiment: Sentiment = Extractor::new(provider)
27//!     .with_preamble("Classify the sentiment of the following text.")
28//!     .extract("I love Rust!").await?;
29//! println!("{:?}", sentiment);
30//! # Ok(())
31//! # }
32//! ```
33
34use schemars::JsonSchema;
35use serde::de::DeserializeOwned;
36
37use crate::LlmError;
38use crate::provider::{LlmProvider, Message, Role};
39
40/// Structured data extractor built on top of any [`LlmProvider`].
41///
42/// See the [module documentation](self) for usage examples.
43pub struct Extractor<'a, P: LlmProvider> {
44    provider: &'a P,
45    preamble: Option<String>,
46}
47
48impl<'a, P: LlmProvider> Extractor<'a, P> {
49    /// Create a new extractor borrowing `provider`.
50    pub fn new(provider: &'a P) -> Self {
51        Self {
52            provider,
53            preamble: None,
54        }
55    }
56
57    /// Set an optional system-level preamble that guides the model on what to extract.
58    #[must_use]
59    pub fn with_preamble(mut self, preamble: impl Into<String>) -> Self {
60        self.preamble = Some(preamble.into());
61        self
62    }
63
64    /// Extract structured data of type `T` from free-form `input` text.
65    ///
66    /// The JSON schema for `T` is injected into the prompt automatically. On a parse failure
67    /// the call is retried once with the raw response appended for self-correction.
68    ///
69    /// # Errors
70    ///
71    /// Returns an error if the provider fails or the response cannot be parsed after the retry.
72    #[tracing::instrument(name = "llm.extractor.extract", skip_all)]
73    pub async fn extract<T>(&self, input: &str) -> Result<T, LlmError>
74    where
75        T: DeserializeOwned + JsonSchema + 'static,
76    {
77        let mut messages = Vec::new();
78        if let Some(ref preamble) = self.preamble {
79            messages.push(Message::from_legacy(Role::System, preamble.clone()));
80        }
81        messages.push(Message::from_legacy(Role::User, input));
82        self.provider.chat_typed::<T>(&messages).await
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use crate::provider::{ChatStream, LlmProvider, Message};
90    use std::assert_matches;
91
92    struct StubProvider {
93        response: String,
94    }
95
96    impl LlmProvider for StubProvider {
97        async fn chat(&self, _messages: &[Message]) -> Result<String, LlmError> {
98            Ok(self.response.clone())
99        }
100
101        async fn chat_stream(&self, messages: &[Message]) -> Result<ChatStream, LlmError> {
102            let response = self.chat(messages).await?;
103            Ok(Box::pin(tokio_stream::once(Ok(
104                crate::StreamChunk::Content(response),
105            ))))
106        }
107
108        fn supports_streaming(&self) -> bool {
109            false
110        }
111
112        async fn embed(&self, _text: &str) -> Result<Vec<f32>, LlmError> {
113            Err(LlmError::EmbedUnsupported {
114                provider: "stub".into(),
115            })
116        }
117
118        fn supports_embeddings(&self) -> bool {
119            false
120        }
121
122        fn name(&self) -> &'static str {
123            "stub"
124        }
125    }
126
127    #[derive(Debug, serde::Deserialize, schemars::JsonSchema, PartialEq)]
128    struct TestOutput {
129        value: String,
130    }
131
132    #[tokio::test]
133    async fn extract_without_preamble() {
134        let provider = StubProvider {
135            response: r#"{"value": "result"}"#.into(),
136        };
137        let extractor = Extractor::new(&provider);
138        let result: TestOutput = extractor.extract("test input").await.unwrap();
139        assert_eq!(
140            result,
141            TestOutput {
142                value: "result".into()
143            }
144        );
145    }
146
147    #[tokio::test]
148    async fn extract_with_preamble() {
149        let provider = StubProvider {
150            response: r#"{"value": "with_preamble"}"#.into(),
151        };
152        let extractor = Extractor::new(&provider).with_preamble("Analyze this");
153        let result: TestOutput = extractor.extract("test input").await.unwrap();
154        assert_eq!(
155            result,
156            TestOutput {
157                value: "with_preamble".into()
158            }
159        );
160    }
161
162    #[tokio::test]
163    async fn extract_error_propagation() {
164        struct FailProvider;
165
166        impl LlmProvider for FailProvider {
167            async fn chat(&self, _messages: &[Message]) -> Result<String, LlmError> {
168                Err(LlmError::Unavailable)
169            }
170
171            async fn chat_stream(&self, _messages: &[Message]) -> Result<ChatStream, LlmError> {
172                Err(LlmError::Unavailable)
173            }
174
175            fn supports_streaming(&self) -> bool {
176                false
177            }
178
179            async fn embed(&self, _text: &str) -> Result<Vec<f32>, LlmError> {
180                Err(LlmError::Unavailable)
181            }
182
183            fn supports_embeddings(&self) -> bool {
184                false
185            }
186
187            fn name(&self) -> &'static str {
188                "fail"
189            }
190        }
191
192        let provider = FailProvider;
193        let extractor = Extractor::new(&provider);
194        let result = extractor.extract::<TestOutput>("test").await;
195        assert_matches!(result, Err(LlmError::Unavailable));
196    }
197}