Skip to main content

ferrin_core/middleware/builtin/
extract_json.rs

1//! JSON extraction from fenced text.
2
3use std::collections::HashMap;
4use std::fmt;
5use std::sync::Arc;
6
7use ferrin_spec::BoxFuture;
8use ferrin_spec::CallOptions;
9use ferrin_spec::Content;
10use ferrin_spec::PartId;
11use ferrin_spec::StreamPart;
12use ferrin_spec::StreamResult;
13use ferrin_spec::error::ProviderError;
14use ferrin_spec::language_model::GenerateResult;
15use futures_util::StreamExt;
16use futures_util::stream;
17
18use crate::middleware::GenerateNext;
19use crate::middleware::LanguageModelMiddleware;
20use crate::middleware::MiddlewareContext;
21use crate::middleware::StreamNext;
22
23/// Rewrites the complete text of a text part.
24pub type JsonTransformFn = Arc<dyn Fn(&str) -> String + Send + Sync>;
25
26/// Middleware created by [`extract_json`].
27#[derive(Clone)]
28pub struct ExtractJson {
29    transform: Option<JsonTransformFn>,
30}
31
32impl fmt::Debug for ExtractJson {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        f.debug_struct("ExtractJson")
35            .field("custom_transform", &self.transform.is_some())
36            .finish()
37    }
38}
39
40/// Strips a markdown code fence (```` ```json ```` .. ```` ``` ````) around
41/// text parts and trims the result.
42///
43/// Streams are rewritten incrementally: the opening fence is removed once a
44/// full line is available, the last twelve characters are held back until
45/// `text-end` so the closing fence can be removed, and `text-start` is
46/// delayed until the first delta is forwarded. A custom transform buffers
47/// the whole part and applies at `text-end`.
48#[must_use]
49pub fn extract_json() -> ExtractJson {
50    ExtractJson { transform: None }
51}
52
53/// Characters held back while streaming so a trailing fence can be removed.
54const SUFFIX_BUFFER_CHARS: usize = 12;
55
56impl ExtractJson {
57    /// Replaces the default fence stripping with `transform`.
58    #[must_use]
59    pub fn transform(mut self, transform: impl Fn(&str) -> String + Send + Sync + 'static) -> Self {
60        self.transform = Some(Arc::new(transform));
61        self
62    }
63
64    fn run_transform(&self, text: &str) -> String {
65        match &self.transform {
66            Some(transform) => transform(text),
67            None => strip_json_fences(text),
68        }
69    }
70
71    /// Applies the extraction to a complete result.
72    #[must_use]
73    pub fn apply(&self, mut result: GenerateResult) -> GenerateResult {
74        for part in &mut result.content {
75            if let Content::Text { text, .. } = part {
76                *text = self.run_transform(text);
77            }
78        }
79        result
80    }
81
82    /// Applies the extraction to a stream result.
83    #[must_use]
84    pub fn apply_stream(&self, result: StreamResult) -> StreamResult {
85        let StreamResult {
86            stream,
87            request,
88            response,
89        } = result;
90        let mut state = StreamState {
91            transform: self.transform.clone(),
92            blocks: HashMap::new(),
93        };
94        let stream = stream
95            .map(move |part| stream::iter(state.process(part)))
96            .flatten();
97        StreamResult {
98            stream: Box::pin(stream),
99            request,
100            response,
101        }
102    }
103}
104
105impl LanguageModelMiddleware for ExtractJson {
106    fn wrap_generate<'a>(
107        &'a self,
108        options: CallOptions,
109        next: GenerateNext<'a>,
110        _ctx: MiddlewareContext<'a>,
111    ) -> BoxFuture<'a, Result<GenerateResult, ProviderError>> {
112        Box::pin(async move { Ok(self.apply(next(options).await?)) })
113    }
114
115    fn wrap_stream<'a>(
116        &'a self,
117        options: CallOptions,
118        next: StreamNext<'a>,
119        _ctx: MiddlewareContext<'a>,
120    ) -> BoxFuture<'a, Result<StreamResult, ProviderError>> {
121        Box::pin(async move { Ok(self.apply_stream(next(options).await?)) })
122    }
123}
124
125/// Default rewrite: strip the opening and closing fences, then trim.
126#[must_use]
127pub fn strip_json_fences(text: &str) -> String {
128    strip_fence_suffix(strip_fence_prefix(text))
129        .trim()
130        .to_owned()
131}
132
133/// Removes ```` ``` ```` or ```` ```json ```` and the following whitespace.
134fn strip_fence_prefix(text: &str) -> &str {
135    match text.strip_prefix("```") {
136        Some(rest) => rest.strip_prefix("json").unwrap_or(rest).trim_start(),
137        None => text,
138    }
139}
140
141/// Removes a trailing ```` ``` ```` (with trailing whitespace and one
142/// preceding newline).
143fn strip_fence_suffix(text: &str) -> &str {
144    let trimmed = text.trim_end();
145    match trimmed.strip_suffix("```") {
146        Some(rest) => rest.strip_suffix('\n').unwrap_or(rest),
147        None => text,
148    }
149}
150
151fn strip_markdown_code_fence_suffix(text: &str) -> String {
152    strip_fence_suffix(text).trim_end().to_owned()
153}
154
155/// Length of a complete opening fence line (```` ```json ```` plus
156/// whitespace ending in a newline), when `text` starts with one.
157fn fence_prefix_len(text: &str) -> Option<usize> {
158    let rest = text.strip_prefix("```")?;
159    let rest = rest.strip_prefix("json").unwrap_or(rest);
160    let consumed = text.len() - rest.len();
161    let whitespace_len = rest
162        .char_indices()
163        .find(|(_, c)| !c.is_whitespace())
164        .map_or(rest.len(), |(index, _)| index);
165    let whitespace = &rest[..whitespace_len];
166    let last_newline = whitespace.rfind('\n')?;
167    Some(consumed + last_newline + 1)
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171enum Phase {
172    Prefix,
173    Streaming,
174    Buffering,
175}
176
177struct Block {
178    start: StreamPart,
179    phase: Phase,
180    buffer: String,
181    prefix_stripped: bool,
182}
183
184struct StreamState {
185    transform: Option<JsonTransformFn>,
186    blocks: HashMap<PartId, Block>,
187}
188
189impl StreamState {
190    fn process(&mut self, part: StreamPart) -> Vec<StreamPart> {
191        let mut out = Vec::new();
192        match part {
193            StreamPart::TextStart { ref id, .. } => {
194                let phase = if self.transform.is_some() {
195                    Phase::Buffering
196                } else {
197                    Phase::Prefix
198                };
199                self.blocks.insert(
200                    id.clone(),
201                    Block {
202                        start: part,
203                        phase,
204                        buffer: String::new(),
205                        prefix_stripped: false,
206                    },
207                );
208            }
209            StreamPart::TextDelta { id, delta, .. } => {
210                let Some(block) = self.blocks.get_mut(&id) else {
211                    out.push(StreamPart::TextDelta {
212                        id,
213                        delta,
214                        provider_metadata: None,
215                    });
216                    return out;
217                };
218                block.buffer.push_str(&delta);
219                if block.phase == Phase::Buffering {
220                    return out;
221                }
222                if block.phase == Phase::Prefix {
223                    if !block.buffer.is_empty() && !block.buffer.starts_with('`') {
224                        block.phase = Phase::Streaming;
225                        out.push(block.start.clone());
226                    } else if block.buffer.starts_with("```") {
227                        // Strip the fence only once the line is complete.
228                        if block.buffer.contains('\n') {
229                            if let Some(len) = fence_prefix_len(&block.buffer) {
230                                block.buffer = block.buffer[len..].to_owned();
231                                block.prefix_stripped = true;
232                            }
233                            block.phase = Phase::Streaming;
234                            out.push(block.start.clone());
235                        }
236                    } else if block.buffer.chars().count() >= 3 {
237                        block.phase = Phase::Streaming;
238                        out.push(block.start.clone());
239                    }
240                }
241                if block.phase == Phase::Streaming {
242                    let count = block.buffer.chars().count();
243                    if count > SUFFIX_BUFFER_CHARS {
244                        let split = block
245                            .buffer
246                            .char_indices()
247                            .nth(count - SUFFIX_BUFFER_CHARS)
248                            .map_or(block.buffer.len(), |(index, _)| index);
249                        let to_stream = block.buffer[..split].to_owned();
250                        block.buffer = block.buffer[split..].to_owned();
251                        out.push(StreamPart::TextDelta {
252                            id,
253                            delta: to_stream,
254                            provider_metadata: None,
255                        });
256                    }
257                }
258            }
259            StreamPart::TextEnd { ref id, .. } => {
260                if let Some(block) = self.blocks.remove(id) {
261                    if matches!(block.phase, Phase::Prefix | Phase::Buffering) {
262                        out.push(block.start);
263                    }
264                    let remaining = match block.phase {
265                        Phase::Buffering => match &self.transform {
266                            Some(transform) => transform(&block.buffer),
267                            None => strip_json_fences(&block.buffer),
268                        },
269                        // Nothing streamed yet: the full transform is safe.
270                        Phase::Prefix => strip_json_fences(&block.buffer),
271                        // Earlier text already streamed: only strip the
272                        // suffix so leading whitespace at the boundary stays.
273                        Phase::Streaming => strip_markdown_code_fence_suffix(&block.buffer),
274                    };
275                    if !remaining.is_empty() {
276                        out.push(StreamPart::TextDelta {
277                            id: id.clone(),
278                            delta: remaining,
279                            provider_metadata: None,
280                        });
281                    }
282                }
283                out.push(part);
284            }
285            other => out.push(other),
286        }
287        out
288    }
289}