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``. \
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}