1use schemars::JsonSchema;
35use serde::de::DeserializeOwned;
36
37use crate::LlmError;
38use crate::provider::{LlmProvider, Message, Role};
39
40pub struct Extractor<'a, P: LlmProvider> {
44 provider: &'a P,
45 preamble: Option<String>,
46}
47
48impl<'a, P: LlmProvider> Extractor<'a, P> {
49 pub fn new(provider: &'a P) -> Self {
51 Self {
52 provider,
53 preamble: None,
54 }
55 }
56
57 #[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 #[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}