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,
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            delayed_text_start: None,
162        };
163        let stream = stream
164            .map(move |part| stream::iter(state.process(part)))
165            .flatten();
166        StreamResult {
167            stream: Box::pin(stream),
168            request,
169            response,
170        }
171    }
172}
173
174impl LanguageModelMiddleware for ExtractReasoning {
175    fn wrap_generate<'a>(
176        &'a self,
177        options: CallOptions,
178        next: GenerateNext<'a>,
179        _ctx: MiddlewareContext<'a>,
180    ) -> BoxFuture<'a, Result<GenerateResult, ProviderError>> {
181        Box::pin(async move { Ok(self.apply(next(options).await?)) })
182    }
183
184    fn wrap_stream<'a>(
185        &'a self,
186        options: CallOptions,
187        next: StreamNext<'a>,
188        _ctx: MiddlewareContext<'a>,
189    ) -> BoxFuture<'a, Result<StreamResult, ProviderError>> {
190        Box::pin(async move { Ok(self.apply_stream(next(options).await?)) })
191    }
192}
193
194struct Extraction {
195    is_first_reasoning: bool,
196    is_first_text: bool,
197    after_switch: bool,
198    is_reasoning: bool,
199    buffer: String,
200    id_counter: u32,
201    text_id: PartId,
202}
203
204struct StreamState {
205    opening_tag: String,
206    closing_tag: String,
207    separator: String,
208    start_with_reasoning: bool,
209    extractions: HashMap<PartId, Extraction>,
210    delayed_text_start: Option<StreamPart>,
211}
212
213fn reasoning_id(counter: u32) -> PartId {
214    PartId::new(format!("reasoning-{counter}"))
215}
216
217impl StreamState {
218    fn process(&mut self, part: StreamPart) -> Vec<StreamPart> {
219        let mut out = Vec::new();
220        match part {
221            // Never send `text-start` before `reasoning-start`.
222            StreamPart::TextStart { .. } => {
223                self.delayed_text_start = Some(part);
224            }
225            StreamPart::TextEnd { .. } => {
226                out.extend(self.delayed_text_start.take());
227                out.push(part);
228            }
229            StreamPart::TextDelta { id, delta, .. } => {
230                self.process_delta(&id, &delta, &mut out);
231            }
232            other => out.push(other),
233        }
234        out
235    }
236
237    fn process_delta(&mut self, id: &PartId, delta: &str, out: &mut Vec<StreamPart>) {
238        let start_with_reasoning = self.start_with_reasoning;
239        let extraction = self
240            .extractions
241            .entry(id.clone())
242            .or_insert_with(|| Extraction {
243                is_first_reasoning: true,
244                is_first_text: true,
245                after_switch: false,
246                is_reasoning: start_with_reasoning,
247                buffer: String::new(),
248                id_counter: 0,
249                text_id: id.clone(),
250            });
251        extraction.buffer.push_str(delta);
252        loop {
253            let next_tag = if extraction.is_reasoning {
254                self.closing_tag.as_str()
255            } else {
256                self.opening_tag.as_str()
257            };
258            let Some(start) = potential_start_index(&extraction.buffer, next_tag) else {
259                let buffer = std::mem::take(&mut extraction.buffer);
260                publish(
261                    extraction,
262                    &buffer,
263                    &self.separator,
264                    &mut self.delayed_text_start,
265                    out,
266                );
267                break;
268            };
269            let before = extraction.buffer[..start].to_owned();
270            publish(
271                extraction,
272                &before,
273                &self.separator,
274                &mut self.delayed_text_start,
275                out,
276            );
277            let end = start + next_tag.len();
278            if end <= extraction.buffer.len() {
279                extraction.buffer = extraction.buffer[end..].to_owned();
280                if extraction.is_reasoning {
281                    // Empty sections still open a reasoning part.
282                    if extraction.is_first_reasoning {
283                        out.push(StreamPart::ReasoningStart {
284                            id: reasoning_id(extraction.id_counter),
285                            provider_metadata: None,
286                        });
287                    }
288                    out.push(StreamPart::ReasoningEnd {
289                        id: reasoning_id(extraction.id_counter),
290                        provider_metadata: None,
291                    });
292                    extraction.id_counter += 1;
293                }
294                extraction.is_reasoning = !extraction.is_reasoning;
295                extraction.after_switch = true;
296            } else {
297                extraction.buffer = extraction.buffer[start..].to_owned();
298                break;
299            }
300        }
301    }
302}
303
304fn publish(
305    extraction: &mut Extraction,
306    text: &str,
307    separator: &str,
308    delayed_text_start: &mut Option<StreamPart>,
309    out: &mut Vec<StreamPart>,
310) {
311    if text.is_empty() {
312        return;
313    }
314    let continuing = if extraction.is_reasoning {
315        !extraction.is_first_reasoning
316    } else {
317        !extraction.is_first_text
318    };
319    let prefix = if extraction.after_switch && continuing {
320        separator
321    } else {
322        ""
323    };
324    if extraction.is_reasoning {
325        if extraction.after_switch || extraction.is_first_reasoning {
326            out.push(StreamPart::ReasoningStart {
327                id: reasoning_id(extraction.id_counter),
328                provider_metadata: None,
329            });
330        }
331        out.push(StreamPart::ReasoningDelta {
332            id: reasoning_id(extraction.id_counter),
333            delta: format!("{prefix}{text}"),
334            provider_metadata: None,
335        });
336        extraction.is_first_reasoning = false;
337    } else {
338        out.extend(delayed_text_start.take());
339        out.push(StreamPart::TextDelta {
340            id: extraction.text_id.clone(),
341            delta: format!("{prefix}{text}"),
342            provider_metadata: None,
343        });
344        extraction.is_first_text = false;
345    }
346    extraction.after_switch = false;
347}
348
349/// Index of `needle` in `text`, or of the longest suffix of `text` that is
350/// a prefix of `needle` (a tag possibly split across deltas).
351fn potential_start_index(text: &str, needle: &str) -> Option<usize> {
352    if needle.is_empty() {
353        return None;
354    }
355    if let Some(index) = text.find(needle) {
356        return Some(index);
357    }
358    text.char_indices()
359        .rev()
360        .find(|(index, _)| needle.starts_with(&text[*index..]))
361        .map(|(index, _)| index)
362}