Skip to content

Commit ff0b750

Browse files
artizclaude
andcommitted
vlm: never exit 0 with empty output; raw-response debug dump (#77)
A granite-docling run via Ollama printed nothing and exited 0 — the model's answer produced zero parseable blocks and the empty document serialized to an empty Markdown. Three fixes: - an empty parsed document is now a loud error pointing at DOCLING_RS_VLM_DEBUG and the custom-prompt env, instead of silence (degradation convention: "nothing convertible at all" errors); - DOCLING_RS_VLM_DEBUG=1 prints each raw model response (or raw endpoint body when the shape is unexpected) to stderr, so "what did the model actually say" takes one env var, not a proxy dump; - a response with no markup at all (model ignored the instruction and answered prose/Markdown) degrades to one <text> per non-empty line instead of dropping the page's content. Refs #77 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 c84ed4c commit ff0b750

1 file changed

Lines changed: 36 additions & 3 deletions

File tree

crates/docling/src/vlm.rs

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,19 @@ pub fn convert_vlm(
178178
);
179179
let synthetic =
180180
SourceDocument::from_bytes(&source.name, InputFormat::XmlDoclang, xml.into_bytes());
181-
DoclangBackend.convert(&synthetic)
181+
let doc = DoclangBackend.convert(&synthetic)?;
182+
if doc.nodes.is_empty() {
183+
// The request loop succeeded, so this is a content problem, not a
184+
// transport one: the model answered with nothing our reader keeps.
185+
// An empty stdout with exit 0 buried that; say it loudly instead.
186+
return Err(ConversionError::Parse(
187+
"vlm: the model's responses contained no parseable DocLang/DocTags blocks \
188+
(set DOCLING_RS_VLM_DEBUG=1 to print raw responses; a generic VLM may need \
189+
a DOCLING_RS_VLM_PROMPT that describes the expected markup)"
190+
.into(),
191+
));
192+
}
193+
Ok(doc)
182194
}
183195

184196
fn pdf_err(e: docling_pdf::PdfError) -> ConversionError {
@@ -264,10 +276,19 @@ fn request_page(agent: &ureq::Agent, opts: &VlmOptions, image: &[u8]) -> Result<
264276
}
265277
let parsed: serde_json::Value = serde_json::from_str(&text)
266278
.map_err(|e| format!("{url}: malformed JSON response: {e}"))?;
267-
return parsed["choices"][0]["message"]["content"]
279+
let content = parsed["choices"][0]["message"]["content"]
268280
.as_str()
269281
.map(str::to_string)
270282
.ok_or_else(|| format!("{url}: no choices[0].message.content in response"));
283+
if std::env::var_os("DOCLING_RS_VLM_DEBUG").is_some() {
284+
match &content {
285+
Ok(c) => {
286+
eprintln!("vlm: raw model response ({} chars):\n{c}\n---", c.len())
287+
}
288+
Err(_) => eprintln!("vlm: raw endpoint body:\n{text}\n---"),
289+
}
290+
}
291+
return content;
271292
}
272293
Err(e) => {
273294
last_err = format!("{url}: {e} (attempt {})", attempt + 1);
@@ -287,7 +308,19 @@ fn request_page(agent: &ureq::Agent, opts: &VlmOptions, image: &[u8]) -> Result<
287308
/// for already-DocLang answers, load-bearing for granite-docling-class
288309
/// models whose raw DocTags token stream is not XML at all.
289310
fn doclang_fragment(response: &str) -> String {
290-
doctags_to_doclang(&strip_wrappers(response))
311+
let translated = doctags_to_doclang(&strip_wrappers(response));
312+
// A model that ignored the markup instruction entirely (plain prose /
313+
// Markdown, no tags) still carries the page text — wrap it as one
314+
// paragraph per non-empty line instead of silently dropping everything.
315+
if !translated.contains('<') && !translated.trim().is_empty() {
316+
return translated
317+
.lines()
318+
.filter(|l| !l.trim().is_empty())
319+
.map(|l| format!("<text>{}</text>", l.trim()))
320+
.collect::<Vec<_>>()
321+
.join("\n");
322+
}
323+
translated
291324
}
292325

293326
/// The wrapper-stripping half of [`doclang_fragment`].

0 commit comments

Comments
 (0)