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.
- everalgo/parser/__init__.py +148 -0
- everalgo/parser/_utils.py +216 -0
- everalgo/parser/audio.py +72 -0
- everalgo/parser/document.py +428 -0
- everalgo/parser/image.py +266 -0
- everalgo/parser/prompts/__init__.py +12 -0
- everalgo/parser/prompts/en/__init__.py +13 -0
- everalgo/parser/prompts/en/audio.py +15 -0
- everalgo/parser/prompts/en/document.py +40 -0
- everalgo/parser/prompts/en/image.py +36 -0
- everalgo/parser/prompts/zh/__init__.py +13 -0
- everalgo/parser/prompts/zh/audio.py +14 -0
- everalgo/parser/prompts/zh/document.py +40 -0
- everalgo/parser/prompts/zh/image.py +37 -0
- everalgo/parser/py.typed +0 -0
- everalgo/parser/url.py +270 -0
- everalgo/parser/video.py +38 -0
- everalgo_parser-0.1.0.dist-info/METADATA +104 -0
- everalgo_parser-0.1.0.dist-info/RECORD +21 -0
- everalgo_parser-0.1.0.dist-info/WHEEL +4 -0
- everalgo_parser-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
everalgo/parser/image.py
ADDED
|
@@ -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
|