everalgo-parser 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,428 @@
1
+ """Document parser — handles PDF / HTML / EMAIL / Office modalities.
2
+
3
+ Per ``AGENTS.md`` and the engineering alignment for the parser migration, the
4
+ ``document`` submodule covers every non-image / non-audio file shape:
5
+
6
+ - ``Modality.PDF`` — sent as a single ``application/pdf`` data URI to the
7
+ multimodal LLM (mirrors the path used in
8
+ ``evermemos-multimodal/src/parser/pdf/gemini_parser.py``).
9
+ - ``Modality.HTML`` — bs4-cleaned (``_utils.clean_html_for_llm``) before
10
+ sending the structured HTML to the LLM with the HTML prompt.
11
+ - ``Modality.EMAIL`` — pure-python ``email`` stdlib parsing; when inline
12
+ images are attached and an ``LLMClient`` is available, each image is
13
+ OCR'd via ``image.aparse`` and the result substitutes the original
14
+ ``<img src="cid:...">`` references (now ``[image:N]`` placeholders).
15
+ - ``Modality.DOCUMENT`` — Office / iWork / ODF; LibreOffice subprocess to
16
+ convert to PDF, then reuse the PDF path. Requires ``soffice`` on PATH.
17
+
18
+ This module is stateless: callers hydrate ``RawFile.content`` upstream and
19
+ pass it in. Retry, rate-limit, fallback are caller / deployment concerns.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import email
26
+ import email.policy
27
+ import logging
28
+ import re
29
+ import shutil
30
+ import tempfile
31
+ from pathlib import Path
32
+ from typing import TYPE_CHECKING
33
+
34
+ from asgiref.sync import async_to_sync
35
+
36
+ from everalgo.llm import ChatMessage, LLMClient, TextPart, image_url_part_from_bytes
37
+ from everalgo.parser._utils import clean_html_content, clean_html_for_llm
38
+ from everalgo.parser.prompts.en import PROMPT_FOR_FILE, PROMPT_FOR_HTML, PROMPT_FOR_PICTURE
39
+ from everalgo.types import Modality, ParsedContent, RawFile, get_modality
40
+
41
+ if TYPE_CHECKING:
42
+ from email.message import EmailMessage
43
+
44
+ __all__ = ["aparse", "parse"]
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+
49
+ _PDF_MIME = "application/pdf"
50
+ _HTML_MAX_INPUT_CHARS = 1_000_000
51
+ _MACOS_SOFFICE = "/Applications/LibreOffice.app/Contents/MacOS/soffice"
52
+
53
+ # Verbatim from ``evermemos-multimodal/src/parser/email/eml_parser.py``:
54
+ # inline image OCR caps at 8K tokens (single-image scope, no need for the
55
+ # 32K merge budget used by the long-image stitcher).
56
+ _INLINE_OCR_MAX_TOKENS = 8_000
57
+ _INLINE_IMAGE_MIMETYPES = frozenset(
58
+ {
59
+ "image/png",
60
+ "image/jpeg",
61
+ "image/jpg",
62
+ "image/webp",
63
+ "image/bmp",
64
+ "image/tiff",
65
+ "image/svg+xml",
66
+ }
67
+ )
68
+ _MIME_TO_EXTENSION = {
69
+ "image/png": "png",
70
+ "image/jpeg": "jpg",
71
+ "image/jpg": "jpg",
72
+ "image/webp": "webp",
73
+ "image/bmp": "bmp",
74
+ "image/tiff": "tiff",
75
+ "image/svg+xml": "svg",
76
+ }
77
+
78
+
79
+ async def aparse(raw_file: RawFile, *, llm: LLMClient) -> ParsedContent:
80
+ """Parse a document-shaped ``RawFile`` into ``ParsedContent``."""
81
+ if not raw_file.content:
82
+ raise ValueError("RawFile.content is empty; cannot parse document")
83
+
84
+ modality = get_modality(raw_file.extension)
85
+ if modality is Modality.PDF:
86
+ return await _aparse_pdf(raw_file, llm=llm)
87
+ if modality is Modality.HTML:
88
+ return await _aparse_html(raw_file, llm=llm)
89
+ if modality is Modality.EMAIL:
90
+ return await _aparse_email(raw_file, llm=llm)
91
+ if modality is Modality.DOCUMENT:
92
+ return await _aparse_office(raw_file, llm=llm)
93
+ raise ValueError(f"document.aparse does not handle modality {modality.value!r} (extension={raw_file.extension!r})")
94
+
95
+
96
+ parse = async_to_sync(aparse)
97
+ """Sync bridge for non-event-loop callers (CLI scripts, plain unit tests)."""
98
+
99
+
100
+ # ---- per-modality handlers (private) ----
101
+
102
+
103
+ async def _aparse_pdf(raw_file: RawFile, *, llm: LLMClient) -> ParsedContent:
104
+ """PDF → ``ParsedContent`` via a single multimodal LLM call."""
105
+ message = ChatMessage(
106
+ role="user",
107
+ content=[
108
+ TextPart(text=PROMPT_FOR_FILE),
109
+ image_url_part_from_bytes(raw_file.content, _PDF_MIME),
110
+ ],
111
+ )
112
+ logger.debug("parsing PDF, bytes=%d", len(raw_file.content))
113
+ response = await llm.chat([message])
114
+ return ParsedContent(
115
+ text=response.content,
116
+ modality=Modality.PDF,
117
+ mime=raw_file.mime or _PDF_MIME,
118
+ metadata={
119
+ "model": response.model,
120
+ "finish_reason": response.finish_reason,
121
+ },
122
+ )
123
+
124
+
125
+ async def _aparse_html(raw_file: RawFile, *, llm: LLMClient) -> ParsedContent:
126
+ """HTML → ``ParsedContent`` via bs4 cleanup + LLM extraction."""
127
+ html_text = _decode_bytes(raw_file.content)
128
+ cleaned = clean_html_for_llm(html_text)
129
+ if not cleaned:
130
+ logger.warning("HTML content is empty after cleaning")
131
+ return ParsedContent(
132
+ text="",
133
+ modality=Modality.HTML,
134
+ mime=raw_file.mime or "text/html",
135
+ metadata={"warning": "empty after html cleanup"},
136
+ )
137
+ if len(cleaned) > _HTML_MAX_INPUT_CHARS:
138
+ logger.warning("HTML truncated from %d to %d chars", len(cleaned), _HTML_MAX_INPUT_CHARS)
139
+ cleaned = cleaned[:_HTML_MAX_INPUT_CHARS]
140
+
141
+ message = ChatMessage(
142
+ role="user",
143
+ content=[TextPart(text=f"{PROMPT_FOR_HTML}\n\n---\n{cleaned}")],
144
+ )
145
+ logger.debug("parsing HTML, raw_chars=%d cleaned_chars=%d", len(html_text), len(cleaned))
146
+ response = await llm.chat([message])
147
+ return ParsedContent(
148
+ text=response.content,
149
+ modality=Modality.HTML,
150
+ mime=raw_file.mime or "text/html",
151
+ metadata={
152
+ "model": response.model,
153
+ "finish_reason": response.finish_reason,
154
+ "raw_chars": len(html_text),
155
+ "cleaned_chars": len(cleaned),
156
+ },
157
+ )
158
+
159
+
160
+ async def _aparse_email(raw_file: RawFile, *, llm: LLMClient) -> ParsedContent:
161
+ """EML → ``ParsedContent`` via stdlib ``email`` + inline-image OCR.
162
+
163
+ Mirrors evermemos-multimodal/src/parser/email/eml_parser.py:
164
+
165
+ 1. Extract headers.
166
+ 2. Collect inline images (Content-ID → bytes).
167
+ 3. Extract body: prefer ``text/plain`` when there are no cid images
168
+ (cheaper); prefer ``text/html`` when cid images are present so we can
169
+ substitute placeholders at their original positions.
170
+ 4. If inline images exist, OCR each one via the LLM and substitute the placeholders.
171
+ Images without a cid get appended at end.
172
+ """
173
+ msg = email.message_from_bytes(raw_file.content, policy=email.policy.default)
174
+ parts: list[str] = []
175
+
176
+ headers = _extract_email_headers(msg)
177
+ if headers:
178
+ parts.append(headers)
179
+
180
+ inline_images = _collect_inline_images(msg)
181
+ has_cid = any(cid for cid, _, _, _ in inline_images.values())
182
+ body = _extract_email_body(msg, inline_images, prefer_html=has_cid)
183
+ if body:
184
+ parts.append(body)
185
+
186
+ metadata: dict[str, object] = {
187
+ "from": msg.get("From", ""),
188
+ "subject": msg.get("Subject", ""),
189
+ "inline_image_count": len(inline_images),
190
+ }
191
+
192
+ if not inline_images:
193
+ return ParsedContent(
194
+ text="\n\n".join(parts),
195
+ modality=Modality.EMAIL,
196
+ mime=raw_file.mime or "message/rfc822",
197
+ metadata=metadata,
198
+ )
199
+
200
+ ocr_results = await _ocr_inline_images(inline_images, client=llm)
201
+ result = "\n\n".join(parts)
202
+ appended: list[str] = []
203
+ for idx, ocr_text in ocr_results.items():
204
+ placeholder = f"[image:{idx}]"
205
+ if placeholder in result:
206
+ result = result.replace(placeholder, ocr_text)
207
+ else:
208
+ appended.append(ocr_text)
209
+ if appended:
210
+ result = result + "\n\n" + "\n\n".join(appended)
211
+
212
+ metadata["inline_image_ocr"] = "ok"
213
+ return ParsedContent(
214
+ text=result,
215
+ modality=Modality.EMAIL,
216
+ mime=raw_file.mime or "message/rfc822",
217
+ metadata=metadata,
218
+ )
219
+
220
+
221
+ async def _aparse_office(
222
+ raw_file: RawFile, *, llm: LLMClient
223
+ ) -> ParsedContent: # pragma: no cover (libreoffice binary, not in CI)
224
+ """Office docs (docx / xlsx / pptx / ...) → convert to PDF → reuse PDF path."""
225
+ soffice = _find_soffice() # pragma: no cover
226
+ pdf_bytes = await _convert_to_pdf_via_soffice(raw_file.content, raw_file.extension, soffice) # pragma: no cover
227
+ if not pdf_bytes: # pragma: no cover
228
+ return ParsedContent( # pragma: no cover
229
+ text="",
230
+ modality=Modality.DOCUMENT,
231
+ mime=raw_file.mime,
232
+ metadata={"warning": "LibreOffice conversion returned empty bytes"},
233
+ )
234
+
235
+ message = ChatMessage( # pragma: no cover
236
+ role="user",
237
+ content=[
238
+ TextPart(text=PROMPT_FOR_FILE),
239
+ image_url_part_from_bytes(pdf_bytes, _PDF_MIME),
240
+ ],
241
+ )
242
+ logger.debug( # pragma: no cover
243
+ "parsing Office doc extension=%s converted_pdf_bytes=%d",
244
+ raw_file.extension,
245
+ len(pdf_bytes),
246
+ )
247
+ response = await llm.chat([message]) # pragma: no cover
248
+ return ParsedContent( # pragma: no cover
249
+ text=response.content,
250
+ modality=Modality.DOCUMENT,
251
+ mime=raw_file.mime,
252
+ metadata={
253
+ "model": response.model,
254
+ "finish_reason": response.finish_reason,
255
+ "intermediate_pdf_bytes": len(pdf_bytes),
256
+ },
257
+ )
258
+
259
+
260
+ # ---- email helpers ----
261
+
262
+
263
+ def _extract_email_headers(msg: EmailMessage) -> str:
264
+ lines = []
265
+ for field in ("Subject", "From", "To", "Cc", "Date"):
266
+ value = msg.get(field)
267
+ if value:
268
+ lines.append(f"{field}: {value}")
269
+ return "\n".join(lines)
270
+
271
+
272
+ def _collect_inline_images(msg: EmailMessage) -> dict[int, tuple[str, bytes, str, str]]:
273
+ """Return ``{idx: (cid, image_bytes, mime, filename)}`` for inline images."""
274
+ images: dict[int, tuple[str, bytes, str, str]] = {}
275
+ idx = 1
276
+ for part in msg.walk():
277
+ content_type = part.get_content_type()
278
+ if content_type not in _INLINE_IMAGE_MIMETYPES:
279
+ continue
280
+ image_bytes = part.get_content()
281
+ if not isinstance(image_bytes, bytes) or not image_bytes:
282
+ continue
283
+ cid = part.get("Content-ID", "")
284
+ if cid:
285
+ cid = cid.strip("<>")
286
+ filename = part.get_filename() or f"image_{idx}"
287
+ images[idx] = (cid, image_bytes, content_type, filename)
288
+ idx += 1
289
+ return images
290
+
291
+
292
+ def _extract_email_body(
293
+ msg: EmailMessage,
294
+ inline_images: dict[int, tuple[str, bytes, str, str]],
295
+ *,
296
+ prefer_html: bool = False,
297
+ ) -> str:
298
+ """Extract body text, optionally replacing ``<img cid:>`` with placeholders."""
299
+ if not prefer_html:
300
+ body = msg.get_body(preferencelist=("plain",))
301
+ if body is not None:
302
+ content = body.get_content()
303
+ if isinstance(content, str) and content.strip():
304
+ return content.strip()
305
+
306
+ body = msg.get_body(preferencelist=("html",))
307
+ if body is not None:
308
+ content = body.get_content()
309
+ if isinstance(content, str) and content.strip():
310
+ cid_to_placeholder = {cid: f"[image:{idx}]" for idx, (cid, _, _, _) in inline_images.items() if cid}
311
+ html_with_placeholders = _replace_cid_images(content, cid_to_placeholder)
312
+ cleaned = clean_html_content(html_with_placeholders)
313
+ if cleaned:
314
+ return cleaned
315
+
316
+ if prefer_html:
317
+ # Fallback: prefer-html mode but HTML was empty/missing.
318
+ body = msg.get_body(preferencelist=("plain",))
319
+ if body is not None:
320
+ content = body.get_content()
321
+ if isinstance(content, str) and content.strip():
322
+ return content.strip()
323
+
324
+ return ""
325
+
326
+
327
+ def _replace_cid_images(html: str, cid_to_placeholder: dict[str, str]) -> str:
328
+ """Substitute ``<img src="cid:xxx">`` references with ``[image:N]`` markers."""
329
+ if not cid_to_placeholder:
330
+ return html
331
+
332
+ def _replace_match(match: re.Match[str]) -> str:
333
+ cid = match.group(1)
334
+ return cid_to_placeholder.get(cid, match.group(0))
335
+
336
+ return re.sub(
337
+ r'<img\b[^>]*\bsrc=["\']cid:([^"\']+)["\'][^>]*/?>',
338
+ _replace_match,
339
+ html,
340
+ flags=re.IGNORECASE,
341
+ )
342
+
343
+
344
+ async def _ocr_inline_images(
345
+ inline_images: dict[int, tuple[str, bytes, str, str]],
346
+ *,
347
+ client: LLMClient,
348
+ ) -> dict[int, str]:
349
+ """OCR every inline image with the same client; wrap results in markers."""
350
+ results: dict[int, str] = {}
351
+ for idx, (_, image_bytes, mime_type, filename) in inline_images.items():
352
+ logger.debug("OCR inline email image: %s (%s)", filename, mime_type)
353
+ try:
354
+ message = ChatMessage(
355
+ role="user",
356
+ content=[
357
+ TextPart(text=PROMPT_FOR_PICTURE),
358
+ image_url_part_from_bytes(image_bytes, mime_type),
359
+ ],
360
+ )
361
+ response = await client.chat([message], max_tokens=_INLINE_OCR_MAX_TOKENS)
362
+ text = (response.content or "").strip()
363
+ if text:
364
+ results[idx] = f"[image: {filename}, content follows]\n{text}\n[end of image]"
365
+ else:
366
+ results[idx] = f"[image: {filename}, no content recognised]"
367
+ except Exception as exc:
368
+ # Best-effort per-image: a single OCR failure should not abort the
369
+ # whole email parse — log and continue with a placeholder marker.
370
+ logger.warning("OCR failed for inline image %s: %s", filename, exc)
371
+ results[idx] = f"[image: {filename}, OCR failed]"
372
+ return results
373
+
374
+
375
+ # ---- generic helpers ----
376
+
377
+
378
+ def _decode_bytes(content: bytes) -> str:
379
+ """Try several encodings; fall back to utf-8 with replace."""
380
+ for encoding in ("utf-8", "gbk", "gb2312", "latin-1"):
381
+ try:
382
+ return content.decode(encoding)
383
+ except (UnicodeDecodeError, LookupError):
384
+ continue
385
+ return content.decode("utf-8", errors="replace")
386
+
387
+
388
+ def _find_soffice() -> str: # pragma: no cover (libreoffice binary, not in CI)
389
+ """Locate the LibreOffice ``soffice`` binary."""
390
+ path = shutil.which("soffice") # pragma: no cover
391
+ if path: # pragma: no cover
392
+ return path # pragma: no cover
393
+ if Path(_MACOS_SOFFICE).exists(): # pragma: no cover
394
+ return _MACOS_SOFFICE # pragma: no cover
395
+ raise RuntimeError( # pragma: no cover
396
+ "LibreOffice not found on PATH. Install it: "
397
+ "macOS: `brew install --cask libreoffice`, "
398
+ "Linux: `apt install libreoffice`."
399
+ )
400
+
401
+
402
+ async def _convert_to_pdf_via_soffice(
403
+ content: bytes, extension: str, soffice: str
404
+ ) -> bytes: # pragma: no cover (libreoffice binary, not in CI)
405
+ """Run LibreOffice headless to convert bytes → PDF bytes."""
406
+ with tempfile.TemporaryDirectory() as tmpdir: # pragma: no cover
407
+ in_path = Path(tmpdir) / f"input.{extension}" # pragma: no cover
408
+ in_path.write_bytes(content) # pragma: no cover
409
+ proc = await asyncio.create_subprocess_exec( # pragma: no cover
410
+ soffice,
411
+ "--headless",
412
+ "--convert-to",
413
+ "pdf",
414
+ "--outdir",
415
+ tmpdir,
416
+ str(in_path),
417
+ stdout=asyncio.subprocess.PIPE,
418
+ stderr=asyncio.subprocess.PIPE,
419
+ )
420
+ _, stderr = await proc.communicate() # pragma: no cover
421
+ if proc.returncode != 0: # pragma: no cover
422
+ raise RuntimeError( # pragma: no cover
423
+ f"LibreOffice conversion failed (code={proc.returncode}): {stderr.decode(errors='replace')[:500]}"
424
+ )
425
+ pdf_path = Path(tmpdir) / "input.pdf" # pragma: no cover
426
+ if not pdf_path.exists(): # pragma: no cover
427
+ return b"" # pragma: no cover
428
+ return pdf_path.read_bytes() # pragma: no cover
@@ -0,0 +1,266 @@
1
+ """Image parser — multimodal LLM OCR with tall-image splitting.
2
+
3
+ Mirrors ``evermemos-multimodal/src/parser/image/gemini_parser.py``:
4
+
5
+ - Normal-ratio images go through a single LLM call.
6
+ - Tall screenshots (height/width > 10) are split into overlapping slices
7
+ via ``_utils.split_image_with_overlap``; each slice is OCR'd independently
8
+ and the per-slice results are merged with a second LLM call using
9
+ ``PROMPT_FOR_MERGE`` to dedupe the overlap regions.
10
+ - BMP / TIFF are transcoded to PNG via Pillow (Gemini does not accept those
11
+ MIMEs natively).
12
+ - SVG is rasterised to PNG via ``cairosvg`` (optional extra ``everalgo-parser[svg]``).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import io
18
+ import logging
19
+ from typing import TYPE_CHECKING
20
+
21
+ from asgiref.sync import async_to_sync
22
+ from PIL import Image
23
+
24
+ from everalgo.llm import ChatMessage, LLMClient, TextPart, image_url_part_from_bytes
25
+ from everalgo.parser._utils import check_aspect_ratio, split_image_with_overlap
26
+ from everalgo.parser.prompts.en import PROMPT_FOR_MERGE, PROMPT_FOR_PICTURE
27
+ from everalgo.types import Modality, ParsedContent
28
+
29
+ if TYPE_CHECKING:
30
+ from everalgo.types import RawFile
31
+
32
+ __all__ = ["aparse", "parse"]
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ _MIME_MAP: dict[str, str] = {
38
+ "png": "image/png",
39
+ "jpg": "image/jpeg",
40
+ "jpeg": "image/jpeg",
41
+ "webp": "image/webp",
42
+ "bmp": "image/bmp",
43
+ "svg": "image/svg+xml",
44
+ "tif": "image/tiff",
45
+ "tiff": "image/tiff",
46
+ }
47
+
48
+ # Formats Gemini multimodal does not accept natively. We transcode to PNG via
49
+ # Pillow before sending.
50
+ _TRANSCODE_TO_PNG: frozenset[str] = frozenset({"bmp", "tif", "tiff"})
51
+
52
+ # SVG is vector; rasterise via cairosvg before sending.
53
+ _SVG_EXTENSIONS: frozenset[str] = frozenset({"svg"})
54
+
55
+ _TALL_IMAGE_RATIO_THRESHOLD = 10.0
56
+
57
+ # Token budgets — verbatim from
58
+ # ``evermemos-multimodal/src/parser/image/gemini_parser.py``. Per-slice OCR
59
+ # caps at 8K; the merge step gets 4x because it joins multiple OCR'd chunks
60
+ # and the merged text is longer than any individual slice.
61
+ _MAX_OCR_OUTPUT_TOKENS = 8_000
62
+ _MAX_MERGE_OUTPUT_TOKENS = 32_000
63
+
64
+
65
+ async def aparse(raw_file: RawFile, *, llm: LLMClient) -> ParsedContent:
66
+ """Parse an image ``RawFile`` into ``ParsedContent`` via multimodal LLM.
67
+
68
+ Tall images (height/width > 10) are split into overlapping vertical
69
+ slices and OCR'd in parallel; per-slice results are then merged with a
70
+ second LLM call. Normal-ratio images go through a single OCR call.
71
+ """
72
+ if not raw_file.content:
73
+ raise ValueError("RawFile.content is empty; cannot parse image")
74
+
75
+ extension = raw_file.extension.lower().lstrip(".")
76
+ mime = _MIME_MAP.get(extension)
77
+ if mime is None:
78
+ raise ValueError(
79
+ f"image.aparse: unsupported image extension {raw_file.extension!r}. Supported: {sorted(_MIME_MAP)}"
80
+ )
81
+
82
+ payload_bytes = raw_file.content
83
+ payload_mime = mime
84
+ if extension in _TRANSCODE_TO_PNG:
85
+ payload_bytes = _transcode_to_png(raw_file.content)
86
+ payload_mime = "image/png"
87
+ elif extension in _SVG_EXTENSIONS:
88
+ payload_bytes = _rasterise_svg_to_png(raw_file.content)
89
+ payload_mime = "image/png"
90
+
91
+ needs_split, ratio = check_aspect_ratio(payload_bytes, max_ratio=_TALL_IMAGE_RATIO_THRESHOLD)
92
+ if needs_split:
93
+ return await _aparse_tall(payload_bytes, payload_mime, raw_file, ratio, llm)
94
+ return await _aparse_single(payload_bytes, payload_mime, raw_file, llm)
95
+
96
+
97
+ parse = async_to_sync(aparse)
98
+ """Sync bridge — see ``document.parse`` for details."""
99
+
100
+
101
+ # ---- internals ----
102
+
103
+
104
+ async def _aparse_single(
105
+ payload_bytes: bytes,
106
+ payload_mime: str,
107
+ raw_file: RawFile,
108
+ client: LLMClient,
109
+ ) -> ParsedContent:
110
+ """Single-shot OCR call for normal-ratio images."""
111
+ message = ChatMessage(
112
+ role="user",
113
+ content=[
114
+ TextPart(text=PROMPT_FOR_PICTURE),
115
+ image_url_part_from_bytes(payload_bytes, payload_mime),
116
+ ],
117
+ )
118
+ logger.debug("parsing image (single) bytes=%d mime=%s", len(payload_bytes), payload_mime)
119
+ response = await client.chat([message], max_tokens=_MAX_OCR_OUTPUT_TOKENS)
120
+ return ParsedContent(
121
+ text=response.content,
122
+ modality=Modality.IMAGE,
123
+ mime=raw_file.mime or payload_mime,
124
+ metadata={
125
+ "model": response.model,
126
+ "finish_reason": response.finish_reason,
127
+ "tall_image_parts": 1,
128
+ },
129
+ )
130
+
131
+
132
+ async def _aparse_tall(
133
+ payload_bytes: bytes,
134
+ payload_mime: str,
135
+ raw_file: RawFile,
136
+ ratio: float,
137
+ client: LLMClient,
138
+ ) -> ParsedContent:
139
+ r"""Split-then-merge OCR for tall screenshots.
140
+
141
+ Mirrors ``evermemos-multimodal/src/parser/image/base.py:parse`` +
142
+ ``gemini_parser._merge_parts``:
143
+
144
+ 1. Split into N overlapping vertical slices.
145
+ 2. OCR each slice; **drop empty results** so the merge step sees only
146
+ real text (an all-empty slice from a blank PDF region would otherwise
147
+ confuse the merge LLM).
148
+ 3. ≤ 1 non-empty result → return that single text (or empty string when
149
+ all slices came back blank).
150
+ 4. Otherwise call the merge LLM with ``temperature=0.0`` and a 4x token
151
+ cap; on merge failure fall back to a deterministic ``\n\n`` join so
152
+ the caller at least gets the raw OCR rather than an exception.
153
+ """
154
+ parts = split_image_with_overlap(payload_bytes, max_ratio=_TALL_IMAGE_RATIO_THRESHOLD)
155
+ logger.info("tall image detected: ratio=%.2f, splitting into %d parts", ratio, len(parts))
156
+
157
+ ocr_results: list[str] = []
158
+ last_response = None
159
+ for i, part_bytes in enumerate(parts, 1):
160
+ message = ChatMessage(
161
+ role="user",
162
+ content=[
163
+ TextPart(text=PROMPT_FOR_PICTURE),
164
+ image_url_part_from_bytes(part_bytes, payload_mime),
165
+ ],
166
+ )
167
+ logger.debug("OCR tall image part %d/%d, bytes=%d", i, len(parts), len(part_bytes))
168
+ resp = await client.chat([message], max_tokens=_MAX_OCR_OUTPUT_TOKENS)
169
+ last_response = resp
170
+ # Drop empty per-slice OCR so merge prompt isn't padded with blank parts.
171
+ text = (resp.content or "").strip()
172
+ if text:
173
+ ocr_results.append(text)
174
+
175
+ # All slices came back empty — return an empty ParsedContent rather than
176
+ # call the merge LLM with no content (matches original ``parse()`` bailout).
177
+ if not ocr_results:
178
+ assert last_response is not None # at least one chat() call was made above
179
+ return ParsedContent(
180
+ text="",
181
+ modality=Modality.IMAGE,
182
+ mime=raw_file.mime or payload_mime,
183
+ metadata={
184
+ "model": last_response.model,
185
+ "finish_reason": last_response.finish_reason,
186
+ "tall_image_parts": len(parts),
187
+ "aspect_ratio": round(ratio, 2),
188
+ "warning": "all OCR slices returned empty",
189
+ },
190
+ )
191
+
192
+ # Single non-empty result — skip the merge LLM, return as-is (saves 1 call).
193
+ if len(ocr_results) == 1:
194
+ assert last_response is not None
195
+ return ParsedContent(
196
+ text=ocr_results[0],
197
+ modality=Modality.IMAGE,
198
+ mime=raw_file.mime or payload_mime,
199
+ metadata={
200
+ "model": last_response.model,
201
+ "finish_reason": last_response.finish_reason,
202
+ "tall_image_parts": len(parts),
203
+ "aspect_ratio": round(ratio, 2),
204
+ },
205
+ )
206
+
207
+ # Multi-slice merge via LLM. Algorithm-layer fallback (NOT engineering
208
+ # retry/fallback per ADR-012): if the merge call itself throws, fall back
209
+ # to a deterministic join so the caller never loses the raw OCR.
210
+ parts_text = "\n\n".join(f"=== Part {i} ===\n{p}" for i, p in enumerate(ocr_results, 1))
211
+ merge_prompt = PROMPT_FOR_MERGE.replace("{parts_text}", parts_text)
212
+ merge_message = ChatMessage(role="user", content=merge_prompt)
213
+ logger.debug("merging %d OCR parts", len(ocr_results))
214
+ merge_fallback_used = False
215
+ try:
216
+ merge_resp = await client.chat(
217
+ [merge_message],
218
+ temperature=0.0,
219
+ max_tokens=_MAX_MERGE_OUTPUT_TOKENS,
220
+ )
221
+ merged_text = merge_resp.content or "\n\n".join(ocr_results)
222
+ if not merge_resp.content:
223
+ merge_fallback_used = True
224
+ last_response = merge_resp
225
+ except Exception as exc:
226
+ logger.warning("LLM merge of tall-image OCR parts failed (%s); falling back to deterministic join", exc)
227
+ merged_text = "\n\n".join(ocr_results)
228
+ merge_fallback_used = True
229
+
230
+ assert last_response is not None, "tall-image OCR produced no responses"
231
+ metadata: dict[str, object] = {
232
+ "model": last_response.model,
233
+ "finish_reason": last_response.finish_reason,
234
+ "tall_image_parts": len(parts),
235
+ "aspect_ratio": round(ratio, 2),
236
+ }
237
+ if merge_fallback_used:
238
+ metadata["merge_fallback"] = "deterministic_join"
239
+ return ParsedContent(
240
+ text=merged_text,
241
+ modality=Modality.IMAGE,
242
+ mime=raw_file.mime or payload_mime,
243
+ metadata=metadata,
244
+ )
245
+
246
+
247
+ def _transcode_to_png(data: bytes) -> bytes:
248
+ """Decode arbitrary Pillow-readable bytes and re-encode as PNG."""
249
+ with Image.open(io.BytesIO(data)) as src:
250
+ img: Image.Image = (
251
+ src.convert("RGBA" if "A" in src.mode else "RGB") if src.mode not in ("RGB", "RGBA") else src.copy()
252
+ )
253
+ buf = io.BytesIO()
254
+ img.save(buf, format="PNG")
255
+ return buf.getvalue()
256
+
257
+
258
+ def _rasterise_svg_to_png(data: bytes) -> bytes: # pragma: no cover (cairosvg native, not in CI)
259
+ """Rasterise SVG bytes to PNG via cairosvg (optional extra)."""
260
+ try:
261
+ import cairosvg # type: ignore[import-not-found] # pragma: no cover
262
+ except ImportError as exc: # pragma: no cover
263
+ raise RuntimeError( # pragma: no cover
264
+ "SVG support requires the optional `cairosvg` dependency. Install via: pip install 'everalgo-parser[svg]'"
265
+ ) from exc
266
+ return cairosvg.svg2png(bytestring=data) # type: ignore[no-any-return] # pragma: no cover