Skip to main content

ferrin_core/middleware/builtin/
extract_reasoning.rs

1//! Reasoning extraction from tagged text.
2
3use std::collections::HashMap;
4
5use ferrin_spec::BoxFuture;
6use ferrin_spec::CallOptions;
7use ferrin_spec::Content;
8use ferrin_spec::PartId;
9use ferrin_spec::StreamPart;
10use ferrin_spec::StreamResult;
11use ferrin_spec::error::ProviderError;
12use ferrin_spec::language_model::GenerateResult;
13use futures_util::StreamExt;
14use futures_util::stream;
15use regex::Regex;
16
17use crate::middleware::GenerateNext;
18use crate::middleware::LanguageModelMiddleware;
19use crate::middleware::MiddlewareContext;
20use crate::middleware::StreamNext;
21
22/// Middleware created by [`extract_reasoning`].
23#[derive(Debug, Clone)]
24pub struct ExtractReasoning {
25    opening_tag: String,
26    closing_tag: String,
27    separator: String,
28    start_with_reasoning: bool,
29    pattern: Regex,
30}
31
32/// Extracts `<tag_name>..</tag_name>` sections from text parts into
33/// reasoning parts.
34///
35/// Complete responses: every match becomes reasoning (joined by the
36/// separator, default `"\n"`) placed before the remaining text. Streams:
37/// deltas are split at tag boundaries into `reasoning-*` parts with ids
38/// `reasoning-<n>` and the original text part; `text-start` is delayed
39/// until the first text delta so it never precedes `reasoning-start`, and
40/// empty sections still emit `reasoning-start`/`reasoning-end`.
41///
42/// # Panics
43///
44/// Never: the tag name is escaped before being compiled into a pattern.
45#[must_use]
46pub fn extract_reasoning(tag_name: impl AsRef<str>) -> ExtractReasoning {
47    let tag_name = tag_name.as_ref();
48    let opening_tag = format!("<{tag_name}>");
49    let closing_tag = format!("</{tag_name}>");
50    let source = format!(
51        "(?s){}(.*?){}",
52        regex::escape(&opening_tag),
53        regex::escape(&closing_tag)
54    );
55    let pattern = match Regex::new(&source) {
56        Ok(pattern) => pattern,
57        Err(_) => unreachable!("escaped tags form a valid pattern"),
58    };
59    ExtractReasoning {
60        opening_tag,
61        closing_tag,
62        separator: "\n".to_owned(),
63        start_with_reasoning: false,
64        pattern,
65    }
66}
67
68impl ExtractReasoning {
69    /// Separator between extracted sections (default `"\n"`).
70    #[must_use]
71    pub fn separator(mut self, separator: impl Into<String>) -> Self {
72        self.separator = separator.into();
73        self
74    }
75
76    /// Treats the text as starting inside a tag (for models that omit the
77    /// opening tag).
78    #[must_use]
79    pub fn start_with_reasoning(mut self, start_with_reasoning: bool) -> Self {
80        self.start_with_reasoning = start_with_reasoning;
81        self
82    }
83
84    /// Applies the extraction to a complete result.
85    #[must_use]
86    pub fn apply(&self, mut result: GenerateResult) -> GenerateResult {
87        let mut transformed = Vec::with_capacity(result.content.len() + 1);
88        for part in result.content.drain(..) {
89            let Content::Text {
90                text,
91                provider_metadata,
92            } = part
93            else {
94                transformed.push(part);
95                continue;
96            };
97            let subject = if self.start_with_reasoning {
98                format!("{}{text}", self.opening_tag)
99            } else {
100                text.clone()
101            };
102            let matches: Vec<(std::ops::Range<usize>, String)> = self
103                .pattern
104                .captures_iter(&subject)
105                .filter_map(|captures| {
106                    let whole = captures.get(0)?;
107                    let inner = captures.get(1)?;
108                    Some((whole.range(), inner.as_str().to_owned()))
109                })
110                .collect();
111            if matches.is_empty() {
112                transformed.push(Content::Text {
113                    text,
114                    provider_metadata,
115                });
116                continue;
117            }
118            let reasoning = matches
119                .iter()
120                .map(|(_, inner)| inner.as_str())
121                .collect::<Vec<_>>()
122                .join(&self.separator);
123            let mut remaining = subject;
124            for (range, _) in matches.iter().rev() {
125                let before = &remaining[..range.start];
126                let after = &remaining[range.end..];
127                let separator = if before.is_empty() || after.is_empty() {
128                    ""
129                } else {
130                    self.separator.as_str()
131                };
132                remaining = format!("{before}{separator}{after}");
133            }
134            transformed.push(Content::Reasoning {
135                text: reasoning,
136                provider_metadata: None,
137            });
138            transformed.push(Content::Text {
139                text: remaining,
140                provider_metadata: None,
141            });
142        }
143        result.content = transformed;
144        result
145    }
146
147    /// Applies the extraction to a stream result.
148    #[must_use]
149    pub fn apply_stream(&self, result: StreamResult) -> StreamResult {
150        let StreamResult {
151            stream,
152            request,
153            response,
154        } = result;
155        let mut state = StreamState {
156            opening_tag: self.opening_tag.clone(),
157            closing_tag: self.closing_tag.clone(),
158            separator: self.separator.clone(),
159            start_with_reasoning: self.start_with_reasoning,
160            extractions: HashMap::new(),
161            next_reasoning_id: 0,
162        };
163        let stream = stream
164            .map(Some)
165            .chain(stream::once(async { None }))
166            .map(move |part| {
167                stream::iter(match part {
168                    Some(part) => state.process(part),
169                    None => state.finish_all(),
170                })
171            })
172            .flatten();
173        StreamResult {
174            stream: Box::pin(stream),
175            request,
176            response,
177        }
178    }
179}
180
181impl LanguageModelMiddleware for ExtractReasoning {
182    fn wrap_generate<'a>(
183        &'a self,
184        options: CallOptions,
185        next: GenerateNext<'a>,
186        _ctx: MiddlewareContext<'a>,
187    ) -> BoxFuture<'a, Result<GenerateResult, ProviderError>> {
188        Box::pin(async move { Ok(self.apply(next(options).await?)) })
189    }
190
191    fn wrap_stream<'a>(
192        &'a self,
193        options: CallOptions,
194        next: StreamNext<'a>,
195        _ctx: MiddlewareContext<'a>,
196    ) -> BoxFuture<'a, Result<StreamResult, ProviderError>> {
197        Box::pin(async move { Ok(self.apply_stream(next(options).await?)) })
198    }
199}
200
201struct Extraction {
202    is_first_reasoning: bool,
203    is_first_text: bool,
204    after_switch: bool,
205    is_reasoning: bool,
206    buffer: String,
207    reasoning_id: Option<PartId>,
208    text_id: PartId,
209    delayed_text_start: Option<StreamPart>,
210    text_started: bool,
211}
212
213impl Extraction {
214    fn new(id: PartId, start_with_reasoning: bool) -> Self {
215        Self {
216            is_first_reasoning: true,
217            is_first_text: true,
218            after_switch: false,
219            is_reasoning: start_with_reasoning,
220            buffer: String::new(),
221            reasoning_id: None,
222            text_id: id,
223            delayed_text_start: None,
224            text_started: false,
225        }
226    }
227
228    fn start_text(&mut self, out: &mut Vec<StreamPart>) {
229        if let Some(start) = self.delayed_text_start.take() {
230            self.text_started = true;
231            out.push(start);
232        }
233    }
234
235    fn start_reasoning(&mut self, counter: &mut u32, out: &mut Vec<StreamPart>) -> PartId {
236        self.reasoning_id
237            .get_or_insert_with(|| {
238                let id = PartId::new(format!("reasoning-{counter}"));
239                *counter += 1;
240                out.push(StreamPart::ReasoningStart {
241                    id: id.clone(),
242                    provider_metadata: None,
243                });
244                id
245            })
246            .clone()
247    }
248
249    fn end_reasoning(&mut self, counter: &mut u32, out: &mut Vec<StreamPart>) {
250        // Empty and unterminated sections still have a complete lifecycle.
251        let id = self.start_reasoning(counter, out);
252        out.push(StreamPart::ReasoningEnd {
253            id,
254            provider_metadata: None,
255        });
256        self.reasoning_id = None;
257    }
258
259    fn finish(&mut self, separator: &str, counter: &mut u32, out: &mut Vec<StreamPart>) {
260        let buffer = std::mem::take(&mut self.buffer);
261        publish(self, &buffer, separator, counter, out);
262        if self.is_reasoning {
263            self.end_reasoning(counter, out);
264        }
265        self.start_text(out);
266    }
267}
268
269struct StreamState {
270    opening_tag: String,
271    closing_tag: String,
272    separator: String,
273    start_with_reasoning: bool,
274    extractions: HashMap<PartId, Extraction>,
275    next_reasoning_id: u32,
276}
277
278impl StreamState {
279    fn finish_all(&mut self) -> Vec<StreamPart> {
280        let mut out = Vec::new();
281        let mut extractions: Vec<_> = self.extractions.drain().collect();
282        extractions.sort_by(|(left, _), (right, _)| left.as_str().cmp(right.as_str()));
283        for (id, mut extraction) in extractions {
284            extraction.finish(&self.separator, &mut self.next_reasoning_id, &mut out);
285            if extraction.text_started {
286                out.push(StreamPart::TextEnd {
287                    id,
288                    provider_metadata: None,
289                });
290            }
291        }
292        out
293    }
294
295    fn process(&mut self, part: StreamPart) -> Vec<StreamPart> {
296        let mut out = Vec::new();
297        match part {
298            // Delay each source part independently until its first text delta.
299            StreamPart::TextStart { ref id, .. } => {
300                let id = id.clone();
301                let extraction = self
302                    .extractions
303                    .entry(id.clone())
304                    .or_insert_with(|| Extraction::new(id, self.start_with_reasoning));
305                extraction.delayed_text_start = Some(part);
306            }
307            StreamPart::TextEnd { ref id, .. } => {
308                if let Some(mut extraction) = self.extractions.remove(id) {
309                    extraction.finish(&self.separator, &mut self.next_reasoning_id, &mut out);
310                }
311                out.push(part);
312            }
313            StreamPart::TextDelta { id, delta, .. } => {
314                self.process_delta(&id, &delta, &mut out);
315            }
316            StreamPart::Finish { .. } => {
317                out.extend(self.finish_all());
318                out.push(part);
319            }
320            other => out.push(other),
321        }
322        out
323    }
324
325    fn process_delta(&mut self, id: &PartId, delta: &str, out: &mut Vec<StreamPart>) {
326        let extraction = self
327            .extractions
328            .entry(id.clone())
329            .or_insert_with(|| Extraction::new(id.clone(), self.start_with_reasoning));
330        extraction.buffer.push_str(delta);
331        loop {
332            let next_tag = if extraction.is_reasoning {
333                self.closing_tag.as_str()
334            } else {
335                self.opening_tag.as_str()
336            };
337            let Some(start) = potential_start_index(&extraction.buffer, next_tag) else {
338                let buffer = std::mem::take(&mut extraction.buffer);
339                publish(
340                    extraction,
341                    &buffer,
342                    &self.separator,
343                    &mut self.next_reasoning_id,
344                    out,
345                );
346                break;
347            };
348            let before = extraction.buffer[..start].to_owned();
349            publish(
350                extraction,
351                &before,
352                &self.separator,
353                &mut self.next_reasoning_id,
354                out,
355            );
356            let end = start + next_tag.len();
357            if end <= extraction.buffer.len() {
358                extraction.buffer = extraction.buffer[end..].to_owned();
359                if extraction.is_reasoning {
360                    extraction.end_reasoning(&mut self.next_reasoning_id, out);
361                }
362                extraction.is_reasoning = !extraction.is_reasoning;
363                extraction.after_switch = true;
364            } else {
365                extraction.buffer = extraction.buffer[start..].to_owned();
366                break;
367            }
368        }
369    }
370}
371
372fn publish(
373    extraction: &mut Extraction,
374    text: &str,
375    separator: &str,
376    counter: &mut u32,
377    out: &mut Vec<StreamPart>,
378) {
379    if text.is_empty() {
380        return;
381    }
382    let continuing = if extraction.is_reasoning {
383        !extraction.is_first_reasoning
384    } else {
385        !extraction.is_first_text
386    };
387    let prefix = if extraction.after_switch && continuing {
388        separator
389    } else {
390        ""
391    };
392    if extraction.is_reasoning {
393        let id = extraction.start_reasoning(counter, out);
394        out.push(StreamPart::ReasoningDelta {
395            id,
396            delta: format!("{prefix}{text}"),
397            provider_metadata: None,
398        });
399        extraction.is_first_reasoning = false;
400    } else {
401        extraction.start_text(out);
402        out.push(StreamPart::TextDelta {
403            id: extraction.text_id.clone(),
404            delta: format!("{prefix}{text}"),
405            provider_metadata: None,
406        });
407        extraction.is_first_text = false;
408    }
409    extraction.after_switch = false;
410}
411
412/// Index of `needle` in `text`, or of the longest suffix of `text` that is
413/// a prefix of `needle` (a tag possibly split across deltas).
414fn potential_start_index(text: &str, needle: &str) -> Option<usize> {
415    if needle.is_empty() {
416        return None;
417    }
418    if let Some(index) = text.find(needle) {
419        return Some(index);
420    }
421    text.char_indices()
422        .rev()
423        .find(|(index, _)| needle.starts_with(&text[*index..]))
424        .map(|(index, _)| index)
425}