Skip to content

Commit 5522d02

Browse files
artizclaude
andcommitted
vlm: don't fail the whole page on malformed DocLang from the model (#153)
The VLM pipeline routes non-DocTags model output through the DocLang XML reader, which is a strict parser: a model that leaves a <heading>/<text> unclosed produces markup it rejects with `expected 'heading' tag, not 'doclang'`, and that error propagated all the way out — dropping the entire normal_4pages fixture from the #153 corpus run ("rust side failed"). The DocTags path never hard-fails on hostile model output (#152: best-effort document out); the DocLang fallback now matches it. parse_doclang_fragments tries the strict reader first (well-formed DocLang keeps its heading levels and table structure) and, on a parse error, salvages the fragments through the tolerant DocTags parser, which degrades broken/unknown tags to text and never errors. Two regression tests cover both branches. Refs #153 Signed-off-by: artiz <artem.kustikov@gmail.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EY5KAiquN4YpVf2PXEQkVT
1 parent 2eeff63 commit 5522d02

1 file changed

Lines changed: 51 additions & 6 deletions

File tree

crates/docling/src/vlm.rs

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -184,11 +184,7 @@ pub fn convert_vlm(
184184
doc.name = source.name.clone();
185185
doc
186186
} else {
187-
let body: Vec<String> = fragments.iter().map(|f| prose_fallback(f)).collect();
188-
let xml = format!("<doclang version=\"0.7\">\n{}\n</doclang>", body.join("\n"));
189-
let synthetic =
190-
SourceDocument::from_bytes(&source.name, InputFormat::XmlDoclang, xml.into_bytes());
191-
DoclangBackend.convert(&synthetic)?
187+
parse_doclang_fragments(&source.name, &fragments)
192188
};
193189
if doc.nodes.is_empty() {
194190
// The request loop succeeded, so this is a content problem, not a
@@ -338,6 +334,30 @@ fn looks_like_doctags(fragment: &str) -> bool {
338334
|| fragment.contains("<section_header_level_")
339335
}
340336

337+
/// Assemble the non-DocTags fragments (proper DocLang XML, or the prose a model
338+
/// emits when it ignores the markup instruction) into a document.
339+
///
340+
/// The DocLang reader is a *strict* XML parser: a misbehaving model that leaves
341+
/// a `<heading>`/`<text>` unclosed or nests a stray root produces markup it
342+
/// rejects outright (`expected 'heading' tag, not 'doclang' …`), which — when
343+
/// propagated — fails the entire conversion on one bad page. The DocTags path
344+
/// never does that (#152: hostile model output, best-effort document out), so
345+
/// neither should this one: on a parse error, salvage the fragments through the
346+
/// tolerant DocTags parser, which degrades broken/unknown tags to text and
347+
/// never errors. Well-formed DocLang still takes the strict reader, keeping its
348+
/// heading levels and table structure.
349+
fn parse_doclang_fragments(name: &str, fragments: &[String]) -> DoclingDocument {
350+
let body: Vec<String> = fragments.iter().map(|f| prose_fallback(f)).collect();
351+
let xml = format!("<doclang version=\"0.7\">\n{}\n</doclang>", body.join("\n"));
352+
let synthetic = SourceDocument::from_bytes(name, InputFormat::XmlDoclang, xml.into_bytes());
353+
let mut doc = match DoclangBackend.convert(&synthetic) {
354+
Ok(doc) => doc,
355+
Err(_) => docling_core::doctags::parse_pages(fragments.iter().map(String::as_str)),
356+
};
357+
doc.name = name.to_string();
358+
doc
359+
}
360+
341361
/// DocLang-path fallback for a model that ignored the markup instruction
342362
/// entirely (plain prose / Markdown, no tags): one `<text>` per non-empty
343363
/// line, instead of silently dropping the page's content.
@@ -396,7 +416,32 @@ fn encode_png(image: &image::RgbImage) -> Result<Vec<u8>, String> {
396416

397417
#[cfg(test)]
398418
mod tests {
399-
use super::{looks_like_doctags, prose_fallback, strip_wrappers};
419+
use super::{looks_like_doctags, parse_doclang_fragments, prose_fallback, strip_wrappers};
420+
421+
/// A misbehaving model can emit malformed DocLang — here an unclosed
422+
/// `<heading>` — that the strict XML reader rejects with
423+
/// `expected 'heading' tag, not 'doclang'`. That hard error dropped the
424+
/// whole `normal_4pages` fixture from the #153 corpus run; the fallback
425+
/// must salvage the page's text instead of failing the conversion.
426+
#[test]
427+
fn malformed_doclang_degrades_instead_of_erroring() {
428+
let frag = "<heading level=\"1\">Unclosed title\n<text>Body line.</text>".to_string();
429+
let md = parse_doclang_fragments("normal_4pages.pdf", &[frag]).export_to_markdown();
430+
// Best-effort salvage via the tolerant DocTags parser: the text that
431+
// the strict reader would have thrown away with the whole page survives.
432+
assert!(md.contains("Body line."), "md: {md:?}");
433+
assert!(md.contains("Unclosed title"), "md: {md:?}");
434+
}
435+
436+
/// Well-formed DocLang still takes the strict reader, which keeps the
437+
/// heading *level* (the tolerant fallback would flatten it to text).
438+
#[test]
439+
fn wellformed_doclang_keeps_structure() {
440+
let frag = "<heading level=\"2\">Sec</heading>\n<text>Para.</text>".to_string();
441+
let md = parse_doclang_fragments("d.pdf", &[frag]).export_to_markdown();
442+
assert!(md.contains("## Sec"), "md: {md:?}");
443+
assert!(md.contains("Para."), "md: {md:?}");
444+
}
400445

401446
#[test]
402447
fn wrapper_stripping() {

0 commit comments

Comments
 (0)