Skip to main content

uni_xervo/provider/llamacpp/
docs.rs

1//! [`ModelTask::DocumentExtract`](crate::api::ModelTask::DocumentExtract) on
2//! `POST /v1/chat/completions`.
3//!
4//! Document extraction is a vision generation call with a fixed prompt: a
5//! rendered page image goes in, the model's Markdown-or-DocTags transcription
6//! comes out, and [`crate::doc_parse`] turns that text into typed blocks. So
7//! this module owns the prompt, the sampling policy and the parse step, and
8//! reuses [`LlamaCppChatClient`](super::chat::LlamaCppChatClient) verbatim for
9//! the wire format.
10//!
11//! The server must have been started with `--mmproj` and a document VLM —
12//! olmOCR-2, MinerU 2.5 or Granite-Docling. As with plain generation, that is a
13//! property of the server process rather than of the alias.
14//!
15//! # Options
16//!
17//! | key | type | required | meaning |
18//! |-----|------|----------|---------|
19//! | `style` | string | no | output dialect of the loaded model: `olmocr` (default), `mineru` or `granite-docling` |
20//!
21//! `style` selects the parser, not the model: it has to match whatever the
22//! server actually loaded, because the three families emit different syntax for
23//! the same page. The value is checked at catalog registration rather than at
24//! load, so a typo fails when the catalog is read instead of on the first
25//! extraction.
26//!
27//! # One page per request
28//!
29//! Pages are sent one at a time, which is what olmOCR-2 expects — it is
30//! single-image — and what keeps a long document from being capped by one
31//! shared `--ctx-size`. A page that fails fails the whole call: a partially
32//! transcribed document silently missing page 7 is worse than an error.
33//!
34//! # `DocExtractOptions` is ignored
35//!
36//! [`DocExtractOptions::include_bboxes`] and `output_format` are deliberately
37//! not honoured, matching the provider this replaced. A generative backend
38//! cannot be asked for trustworthy bounding boxes — it would invent plausible
39//! ones — and the output format follows from the model's own dialect, which the
40//! `style` option already names. Blocks carry whatever positions the model
41//! emitted (Granite-Docling's DocTags do include `loc` attributes) and `None`
42//! otherwise.
43
44use super::chat::LlamaCppChatClient;
45use super::{LlamaCppCommon, PROVIDER_ID};
46use crate::doc_parse::{DocStyle, style_from_str};
47use crate::error::{Result, RuntimeError};
48use crate::traits::{
49    ContentBlock, DocExtractOptions, DocExtractResult, DocumentExtractionModel, GenerationOptions,
50    ImageInput, Message, MessageRole, ModelInfo,
51};
52use async_trait::async_trait;
53use reqwest::Client;
54use serde_json::Value;
55
56/// The olmOCR-2 prompt — image-only, no document-anchoring text.
57///
58/// olmOCR-2 dropped olmOCR-1's anchor-text requirement; this is the static
59/// no-anchoring instruction. The model is given a single rendered page and
60/// returns Markdown with a YAML front-matter header.
61const OLMOCR_PROMPT: &str = "Attached is one page of a document that you must process. \
62Just return the plain text representation of this document as if you were reading it naturally. \
63Convert equations to LateX and tables to HTML. \
64If there are any figures or charts, label them with the following markdown syntax \
65`![Alt text...](page_startx_starty_width_height.png)`. \
66Return your output as markdown, with a front matter section on top specifying values for the \
67primary_language, is_rotation_valid, rotation_correction, is_table, and is_diagram parameters.";
68
69/// First-pass sampling temperature for document extraction.
70///
71/// olmOCR's reference pipeline starts near-greedy and only escalates on retry;
72/// this is the deterministic-leaning first pass. Temperature-escalating retries
73/// are a future refinement layered above this single pass.
74const DOC_EXTRACT_TEMPERATURE: f32 = 0.1;
75
76/// Maximum tokens to generate per page (olmOCR's reference cap).
77const DOC_EXTRACT_MAX_TOKENS: usize = 8000;
78
79/// Default `style` when the alias does not set one.
80pub(crate) const DEFAULT_STYLE: &str = "olmocr";
81
82/// Accepted `style` values, for validation and error messages.
83pub(crate) const STYLES: [&str; 3] = ["granite-docling", "mineru", "olmocr"];
84
85// ---------------------------------------------------------------------------
86// Configuration
87// ---------------------------------------------------------------------------
88
89/// Resolved document-extraction configuration for one alias.
90#[derive(Debug, Clone)]
91pub(crate) struct DocsConfig {
92    pub common: LlamaCppCommon,
93    pub style: DocStyle,
94}
95
96impl DocsConfig {
97    /// Parse the alias `options`. The catalog validator already enforces the
98    /// same rules, but `ModelProvider::load` is public, so this is defensive.
99    pub(crate) fn from_options(options: &Value) -> Result<Self> {
100        let style_str = options
101            .get("style")
102            .and_then(|v| v.as_str())
103            .unwrap_or(DEFAULT_STYLE);
104        let style = style_from_str(style_str).ok_or_else(|| {
105            RuntimeError::Config(format!(
106                "Option 'style' for provider '{}' must be one of {} (got '{}')",
107                PROVIDER_ID,
108                STYLES.join(", "),
109                style_str
110            ))
111        })?;
112        Ok(Self {
113            common: LlamaCppCommon::from_options(options)?,
114            style,
115        })
116    }
117}
118
119// ---------------------------------------------------------------------------
120// Extractor
121// ---------------------------------------------------------------------------
122
123/// Document extraction backed by a document VLM on a llama.cpp server.
124pub struct LlamaCppDocumentExtractor {
125    cb: crate::reliability::CircuitBreakerWrapper,
126    model_id: String,
127    style: DocStyle,
128    chat: LlamaCppChatClient,
129}
130
131impl LlamaCppDocumentExtractor {
132    pub(crate) fn new(
133        client: Client,
134        cb: crate::reliability::CircuitBreakerWrapper,
135        model_id: String,
136        cfg: DocsConfig,
137    ) -> Self {
138        Self {
139            cb,
140            model_id: model_id.clone(),
141            style: cfg.style,
142            chat: LlamaCppChatClient::new(client, cfg.common, model_id),
143        }
144    }
145
146    /// Transcribe and parse every page in order, one request each.
147    async fn extract_pipeline(&self, pages: Vec<ImageInput>) -> Result<Vec<DocExtractResult>> {
148        let mut results = Vec::with_capacity(pages.len());
149        for page in pages {
150            let messages = [Message {
151                role: MessageRole::User,
152                // Text first: the document VLMs are trained with the
153                // instruction ahead of the page.
154                content: vec![
155                    ContentBlock::Text(OLMOCR_PROMPT.to_string()),
156                    ContentBlock::Image(page),
157                ],
158            }];
159            let options = GenerationOptions {
160                max_tokens: Some(DOC_EXTRACT_MAX_TOKENS),
161                temperature: Some(DOC_EXTRACT_TEMPERATURE),
162                top_p: None,
163                width: None,
164                height: None,
165            };
166            let generated = self.chat.complete(&messages, &options).await?;
167            results.push(crate::doc_parse::parse_by_style(
168                self.style,
169                &generated.text,
170            ));
171        }
172        Ok(results)
173    }
174}
175
176#[async_trait]
177impl DocumentExtractionModel for LlamaCppDocumentExtractor {
178    async fn extract(
179        &self,
180        pages: Vec<ImageInput>,
181        _options: DocExtractOptions,
182    ) -> Result<Vec<DocExtractResult>> {
183        if pages.is_empty() {
184            return Ok(Vec::new());
185        }
186
187        // One breaker call around the whole document rather than one per page:
188        // a half-open probe should be a document, not a page, and a document
189        // that fails midway has already failed.
190        //
191        // Deterministic rejections (`InferenceError`, produced only by
192        // `map_llamacpp_status`) are a configuration problem rather than a
193        // server outage, so they are returned as `Ok(Err(..))` — the breaker
194        // sees a success and stays closed — and flattened here. Same contract
195        // as `embed` and `chat`.
196        self.cb
197            .call(move || async move {
198                match self.extract_pipeline(pages).await {
199                    Err(e @ RuntimeError::InferenceError(_)) => Ok(Err(e)),
200                    other => other.map(Ok),
201                }
202            })
203            .await?
204    }
205}
206
207impl ModelInfo for LlamaCppDocumentExtractor {
208    fn model_id(&self) -> &str {
209        &self.model_id
210    }
211}
212
213// ---------------------------------------------------------------------------
214// Tests (no HTTP)
215// ---------------------------------------------------------------------------
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use serde_json::json;
221
222    fn options(extra: Value) -> Value {
223        let mut o = json!({ "base_url": "http://127.0.0.1:8080/v1" });
224        if let Value::Object(map) = extra {
225            for (k, v) in map {
226                o[k] = v;
227            }
228        }
229        o
230    }
231
232    #[test]
233    fn style_defaults_to_olmocr() {
234        let cfg = DocsConfig::from_options(&options(json!({}))).unwrap();
235        assert_eq!(cfg.style, DocStyle::OlmOcr);
236    }
237
238    #[test]
239    fn every_documented_style_is_accepted() {
240        for (name, expected) in [
241            ("olmocr", DocStyle::OlmOcr),
242            ("mineru", DocStyle::Mineru),
243            ("granite-docling", DocStyle::GraniteDocling),
244        ] {
245            let cfg = DocsConfig::from_options(&options(json!({ "style": name }))).unwrap();
246            assert_eq!(cfg.style, expected, "{name}");
247            assert!(STYLES.contains(&name), "{name} missing from STYLES");
248        }
249    }
250
251    #[test]
252    fn an_unknown_style_names_the_accepted_values() {
253        let err = DocsConfig::from_options(&options(json!({ "style": "docling" })))
254            .expect_err("must fail");
255        match err {
256            RuntimeError::Config(msg) => {
257                assert!(msg.contains("granite-docling"), "{msg}");
258                assert!(msg.contains("docling"), "{msg}");
259            }
260            other => panic!("expected Config, got {other:?}"),
261        }
262    }
263
264    /// `style` is read from a non-string value as if absent, rather than
265    /// panicking — the validator is what rejects the shape.
266    #[test]
267    fn a_non_string_style_falls_back_to_the_default() {
268        let cfg = DocsConfig::from_options(&options(json!({ "style": 7 }))).unwrap();
269        assert_eq!(cfg.style, DocStyle::OlmOcr);
270    }
271
272    #[test]
273    fn the_shared_options_still_apply() {
274        let err = DocsConfig::from_options(&json!({ "style": "olmocr" })).expect_err("must fail");
275        assert!(err.to_string().contains("base_url"), "{err}");
276    }
277
278    #[test]
279    fn the_prompt_is_the_olmocr_no_anchoring_instruction() {
280        // Pinned because a reworded prompt changes model output, which is the
281        // kind of regression a parse test would not catch.
282        assert!(OLMOCR_PROMPT.starts_with("Attached is one page of a document"));
283        assert!(OLMOCR_PROMPT.contains("front matter section"));
284        assert!(
285            !OLMOCR_PROMPT.contains("RAW_TEXT_START"),
286            "anchoring removed"
287        );
288    }
289}