Skip to content

Commit 99a1338

Browse files
author
Florian Wunderlich
committed
fix: render PDF pages for vision extraction
1 parent 00c4dc5 commit 99a1338

5 files changed

Lines changed: 179 additions & 13 deletions

File tree

models/ai_document.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
These are not Odoo models — they are imported by account_move.py.
66
"""
77

8+
import base64
89
import io
910
import logging
1011
import re
@@ -44,6 +45,17 @@
4445
_pdfplumber = None
4546
PDFPLUMBER_AVAILABLE = False
4647

48+
# ---------------------------------------------------------------------------
49+
# PyMuPDF -- optional dependency for rendering PDF pages in vision mode
50+
# ---------------------------------------------------------------------------
51+
try:
52+
import fitz as _fitz
53+
54+
PYMUPDF_AVAILABLE = True
55+
except ImportError:
56+
_fitz = None
57+
PYMUPDF_AVAILABLE = False
58+
4759
# ---------------------------------------------------------------------------
4860
# Supported image MIME types for vision mode
4961
# ---------------------------------------------------------------------------
@@ -82,6 +94,39 @@ def is_image(mimetype):
8294
return mimetype in IMAGE_MIMES
8395

8496

97+
def render_pdf_pages_for_vision(pdf_bytes, max_pages=3, dpi=144):
98+
"""Render PDF pages to PNG images for vision-capable AI providers.
99+
100+
Returns a list of base64-encoded image payloads suitable for ``user_content``
101+
image blocks. Empty list means rendering is unavailable or failed.
102+
"""
103+
if not PYMUPDF_AVAILABLE or _fitz is None:
104+
_logger.warning('No PDF renderer available for vision mode (PyMuPDF not installed)')
105+
return []
106+
107+
doc = None
108+
try:
109+
doc = _fitz.open(stream=pdf_bytes, filetype='pdf')
110+
matrix = _fitz.Matrix(dpi / 72.0, dpi / 72.0)
111+
rendered_pages = []
112+
for page_index in range(min(doc.page_count, max_pages)):
113+
page = doc.load_page(page_index)
114+
pixmap = page.get_pixmap(matrix=matrix, alpha=False)
115+
rendered_pages.append(
116+
{
117+
'media_type': 'image/png',
118+
'data': base64.b64encode(pixmap.tobytes('png')).decode('ascii'),
119+
}
120+
)
121+
return rendered_pages
122+
except Exception:
123+
_logger.exception('Failed to render PDF pages for vision mode')
124+
return []
125+
finally:
126+
if doc is not None:
127+
doc.close()
128+
129+
85130
def extract_text_from_pdf(pdf_bytes):
86131
"""Extract text from a PDF using PyPDF2/pypdf.
87132

models/ai_document_builder.py

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -206,20 +206,35 @@ def _ai_build_content(self, doc_info, raw_data, mimetype, vendor, company, extra
206206

207207
# Build content blocks for the provider
208208
if doc_info.get('is_vision'):
209-
media_type = ai_document.IMAGE_MIMES.get(mimetype, 'image/png')
209+
user_content = []
210210
if ai_document.is_pdf(mimetype):
211-
media_type = 'image/png'
212-
user_content = [
213-
{
214-
'type': 'image',
215-
'source': {
216-
'type': 'base64',
217-
'media_type': media_type,
218-
'data': base64.b64encode(raw_data).decode('ascii'),
219-
},
220-
},
221-
{'type': 'text', 'text': user_prompt},
222-
]
211+
rendered_pages = ai_document.render_pdf_pages_for_vision(raw_data)
212+
if not rendered_pages:
213+
return SYSTEM_PROMPT, [], user_prompt
214+
for rendered_page in rendered_pages:
215+
user_content.append(
216+
{
217+
'type': 'image',
218+
'source': {
219+
'type': 'base64',
220+
'media_type': rendered_page['media_type'],
221+
'data': rendered_page['data'],
222+
},
223+
}
224+
)
225+
else:
226+
user_content.append(
227+
{
228+
'type': 'image',
229+
'source': {
230+
'type': 'base64',
231+
'media_type': ai_document.IMAGE_MIMES.get(mimetype, 'image/png'),
232+
'data': base64.b64encode(raw_data).decode('ascii'),
233+
},
234+
}
235+
)
236+
if user_content:
237+
user_content.append({'type': 'text', 'text': user_prompt})
223238
else:
224239
doc_section = 'Document text:\n' + doc_info['text']
225240
if doc_info.get('table_markdown'):

models/ai_extraction_engine.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,17 @@ def _ai_run_pipeline(self, api_key, cfg, raw_data, mimetype):
155155
preprocess_context=preprocess_context,
156156
cfg=cfg,
157157
)
158+
if doc_info.get('is_vision') and not any(
159+
isinstance(block, dict) and block.get('type') == 'image'
160+
for block in user_content
161+
):
162+
_logger.warning(
163+
'Vision extraction unavailable: no rendered image content could be built '
164+
'from invoice %s',
165+
self.id,
166+
)
167+
self.ai_extraction_status = 'failed'
168+
return None
158169

159170
# --- 4b. Prompt size estimation -----------------------------------
160171
estimated_tokens = self._ai_estimate_prompt_tokens(system_prompt, user_prompt)
@@ -288,6 +299,10 @@ def _ai_retry_vision(self, api_key, cfg, raw_data):
288299
cfg['extract_lines'],
289300
)
290301

302+
if not any(isinstance(block, dict) and block.get('type') == 'image' for block in user_content):
303+
_logger.warning('Vision retry unavailable: no rendered image content could be built from the PDF')
304+
return None
305+
291306
result = self._ai_call_provider(
292307
api_key, cfg, system_prompt, user_content, user_prompt, mode='vision',
293308
)

tests/test_extraction_modes.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,53 @@ def test_free_mode_sends_no_context(self, mock_build_ctx):
127127

128128
mock_build_ctx.assert_not_called()
129129

130+
@patch('odoo.addons.account_invoice_digitize_ai.models.ai_document.render_pdf_pages_for_vision')
131+
@patch('odoo.addons.account_invoice_digitize_ai.models.ai_fiscal_context.build_fiscal_context')
132+
def test_vision_pdf_uses_rendered_pages(self, mock_build_ctx, mock_render_pdf):
133+
"""Vision mode for PDFs should send rendered page images, not raw PDF bytes."""
134+
self._set_mode('guided')
135+
mock_build_ctx.return_value = 'full context'
136+
mock_render_pdf.return_value = [
137+
{'media_type': 'image/png', 'data': 'page-one'},
138+
{'media_type': 'image/png', 'data': 'page-two'},
139+
]
140+
141+
doc_info = {'text': '', 'is_vision': True}
142+
_system_prompt, user_content, _user_prompt = self.move._ai_build_content(
143+
doc_info,
144+
b'%PDF-1.7 fake',
145+
'application/pdf',
146+
self.partner,
147+
self.company,
148+
False,
149+
)
150+
151+
image_blocks = [block for block in user_content if block.get('type') == 'image']
152+
self.assertEqual(len(image_blocks), 2)
153+
self.assertEqual(image_blocks[0]['source']['data'], 'page-one')
154+
self.assertEqual(image_blocks[1]['source']['data'], 'page-two')
155+
self.assertEqual(user_content[-1]['type'], 'text')
156+
157+
@patch('odoo.addons.account_invoice_digitize_ai.models.ai_document.render_pdf_pages_for_vision')
158+
@patch('odoo.addons.account_invoice_digitize_ai.models.ai_fiscal_context.build_fiscal_context')
159+
def test_vision_pdf_without_rendered_pages_returns_no_content(self, mock_build_ctx, mock_render_pdf):
160+
"""Vision mode should not build a prompt-only payload when PDF rendering fails."""
161+
self._set_mode('guided')
162+
mock_build_ctx.return_value = 'full context'
163+
mock_render_pdf.return_value = []
164+
165+
doc_info = {'text': '', 'is_vision': True}
166+
_system_prompt, user_content, _user_prompt = self.move._ai_build_content(
167+
doc_info,
168+
b'%PDF-1.7 fake',
169+
'application/pdf',
170+
self.partner,
171+
self.company,
172+
False,
173+
)
174+
175+
self.assertEqual(user_content, [])
176+
130177
# ---------------------------------------------------------------
131178
# Vendor memory in prompt
132179
# ---------------------------------------------------------------

tests/test_vision_retry.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,3 +174,47 @@ def test_retry_keeps_original_on_failure(self, _cv):
174174
'user prompt', doc_info,
175175
)
176176
self.assertEqual(result['vendor']['name'], 'Test')
177+
178+
def test_retry_skips_when_pdf_cannot_be_rendered(self):
179+
"""Vision retry should bail out before the provider call if no page images are available."""
180+
with (
181+
patch(f'{_MODULE}.models.ai_document.extract_pdf_metadata', return_value={}),
182+
patch(f'{_MODULE}.models.ai_document.extract_text_from_pdf', return_value='some text'),
183+
patch.object(type(self.move), '_ai_extract_qr_data'),
184+
patch.object(type(self.move), '_ai_pre_identify_vendor', return_value=None),
185+
patch.object(
186+
type(self.move),
187+
'_ai_build_content',
188+
return_value=('system', [{'type': 'text', 'text': 'prompt only'}], 'user prompt'),
189+
),
190+
patch.object(type(self.move), '_ai_call_provider') as mock_call_provider,
191+
):
192+
result = self.move._ai_retry_vision('test-key', _CFG, b'fake-pdf')
193+
self.assertIsNone(result)
194+
mock_call_provider.assert_not_called()
195+
196+
def test_initial_vision_skips_when_pdf_cannot_be_rendered(self):
197+
"""The primary vision path should also fail before the provider call if no page images are available."""
198+
with (
199+
patch.object(type(self.move), '_ai_try_facturx', return_value=None),
200+
patch.object(
201+
type(self.move),
202+
'_ai_preprocess_or_prepare',
203+
return_value=(None, {
204+
'text': '',
205+
'is_vision': True,
206+
'pdf_metadata': {},
207+
'detected_number_format': None,
208+
'table_markdown': '',
209+
'is_proforma': False,
210+
'unsupported': False,
211+
'qr_data': [],
212+
}, ''),
213+
),
214+
patch.object(type(self.move), '_ai_build_content', return_value=('system', [], 'user prompt')),
215+
patch.object(type(self.move), '_ai_call_provider') as mock_call_provider,
216+
):
217+
result = self.move._ai_run_pipeline('test-key', _CFG, b'fake-pdf', 'application/pdf')
218+
self.assertIsNone(result)
219+
self.assertEqual(self.move.ai_extraction_status, 'failed')
220+
mock_call_provider.assert_not_called()

0 commit comments

Comments
 (0)