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,148 @@
1
+ """Multimodal parser — top-level dispatch by modality.
2
+
3
+ Public surface (re-exports for ergonomic ``from everalgo.parser import ...``):
4
+
5
+ - Functions: ``aparse`` / ``parse`` — dispatch entry points.
6
+ - Submodules: ``image`` / ``audio`` / ``document`` / ``url`` / ``video``.
7
+ - Types: ``ParsedContent`` / ``Modality`` / ``RawFile`` (re-exported from
8
+ ``everalgo.types`` for one-stop import).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from typing import TYPE_CHECKING
15
+ from urllib.parse import urlparse
16
+
17
+ from asgiref.sync import async_to_sync
18
+
19
+ from everalgo.parser import audio, document, image, url, video
20
+ from everalgo.types import (
21
+ Modality,
22
+ ParsedContent,
23
+ RawFile,
24
+ get_extension_from_mime,
25
+ get_modality,
26
+ get_modality_from_mime,
27
+ )
28
+
29
+ if TYPE_CHECKING:
30
+ from everalgo.llm import LLMClient
31
+
32
+ __all__ = [
33
+ "Modality",
34
+ "ParsedContent",
35
+ "RawFile",
36
+ "aparse",
37
+ "audio",
38
+ "document",
39
+ "image",
40
+ "parse",
41
+ "url",
42
+ "video",
43
+ ]
44
+
45
+ logger = logging.getLogger(__name__)
46
+ logger.addHandler(logging.NullHandler())
47
+
48
+
49
+ async def aparse(raw_file: RawFile, *, llm: LLMClient) -> ParsedContent:
50
+ """Top-level dispatch: classify the input and route to a submodule.
51
+
52
+ Dispatch precedence:
53
+
54
+ 1. ``content`` empty + ``uri`` is ``http``/``https`` → ``url.aparse``
55
+ (the URL parser fetches and re-dispatches by the response Content-Type).
56
+ 2. ``content`` non-empty → resolve a ``Modality`` from
57
+ ``extension`` first, falling back to ``mime`` when extension is
58
+ missing or ``UNKNOWN``.
59
+ 3. Submodule routing by resolved ``Modality``:
60
+ - ``IMAGE`` → ``image.aparse``
61
+ - ``AUDIO`` → ``audio.aparse``
62
+ - ``PDF`` / ``DOCUMENT`` / ``HTML`` / ``EMAIL`` → ``document.aparse``
63
+ - ``DIRECT`` → no LLM; bytes are UTF-8-decoded inline.
64
+ - Anything else → ``ValueError``.
65
+
66
+ When dispatch resolves a modality via ``mime`` but the submodule
67
+ internally requires ``extension`` (image / audio / document all do for
68
+ their ``_MIME_MAP`` lookups), this function fills ``extension`` in
69
+ from the canonical extension table before forwarding.
70
+
71
+ Parameters
72
+ ----------
73
+ raw_file : RawFile
74
+ Hydrated ``content`` or ``uri`` (at least one is required).
75
+ llm : LLMClient
76
+ Per-call LLM client. Required for all LLM-backed modalities (IMAGE, AUDIO, PDF, etc.).
77
+
78
+ Returns:
79
+ -------
80
+ ParsedContent
81
+ Normalized parser output.
82
+ """
83
+ # URL path: only when caller did not pre-hydrate content.
84
+ if not raw_file.content and raw_file.uri and _is_http_uri(raw_file.uri):
85
+ return await url.aparse(raw_file, llm=llm)
86
+
87
+ if not raw_file.content:
88
+ raise ValueError(
89
+ "Cannot dispatch: RawFile has no content. "
90
+ "Provide either an http(s) uri (the URL parser fetches) or "
91
+ "hydrated bytes."
92
+ )
93
+
94
+ # Resolve modality: extension wins; fall back to mime.
95
+ modality = get_modality(raw_file.extension) if raw_file.extension else Modality.UNKNOWN
96
+ if modality is Modality.UNKNOWN and raw_file.mime:
97
+ modality = get_modality_from_mime(raw_file.mime)
98
+ if modality is Modality.UNKNOWN:
99
+ raise ValueError(
100
+ f"Cannot dispatch: extension={raw_file.extension!r} mime={raw_file.mime!r} do not map to a known Modality."
101
+ )
102
+
103
+ # Ensure ``extension`` is populated for downstream submodules that key
104
+ # their internal MIME tables off it (image / audio / document do).
105
+ routed = _ensure_extension(raw_file)
106
+
107
+ if modality is Modality.IMAGE:
108
+ return await image.aparse(routed, llm=llm)
109
+ if modality is Modality.AUDIO:
110
+ return await audio.aparse(routed, llm=llm)
111
+ if modality in (Modality.PDF, Modality.DOCUMENT, Modality.HTML, Modality.EMAIL):
112
+ return await document.aparse(routed, llm=llm)
113
+ if modality is Modality.DIRECT:
114
+ return ParsedContent(
115
+ text=routed.content.decode("utf-8", errors="replace"),
116
+ modality=Modality.DIRECT,
117
+ mime=routed.mime,
118
+ )
119
+ raise ValueError(
120
+ f"Unsupported modality {modality.value!r} (extension={raw_file.extension!r}, mime={raw_file.mime!r})"
121
+ )
122
+
123
+
124
+ parse = async_to_sync(aparse)
125
+ """Sync bridge for non-event-loop callers. See ``document.parse`` for details."""
126
+
127
+
128
+ # ---- helpers ----
129
+
130
+
131
+ def _is_http_uri(uri: str) -> bool:
132
+ """Return True for ``http`` / ``https`` schemes only."""
133
+ return urlparse(uri).scheme in ("http", "https")
134
+
135
+
136
+ def _ensure_extension(raw_file: RawFile) -> RawFile:
137
+ """Return a ``RawFile`` whose ``extension`` is populated.
138
+
139
+ If ``extension`` is missing but ``mime`` resolves via ``MIME_TO_EXTENSION``,
140
+ we hand the submodule a copy carrying the canonical extension. Returns
141
+ the original when ``extension`` is already set or no mime fallback exists.
142
+ """
143
+ if raw_file.extension:
144
+ return raw_file
145
+ derived = get_extension_from_mime(raw_file.mime)
146
+ if not derived:
147
+ return raw_file
148
+ return raw_file.model_copy(update={"extension": derived})
@@ -0,0 +1,216 @@
1
+ """Internal parser helpers.
2
+
3
+ Migrated (with English docstrings) from
4
+ ``evermemos-multimodal/src/parser/utils.py`` (prod-20260306-0331-v1):
5
+
6
+ - ``decode_bytes`` — multi-encoding decoder for unknown-charset bytes.
7
+ - ``check_aspect_ratio`` / ``split_image_with_overlap`` — long-image (tall
8
+ screenshot) handling for the image parser.
9
+ - ``clean_html_content`` — strict text extraction (drops all tags).
10
+ - ``clean_html_for_llm`` — preserves structural tags inside ``<body>``,
11
+ strips known-noise tags and useless attributes, returns HTML.
12
+ - ``fetch_uri`` — HTTP(S) fetch for the URL parser. Adapted from
13
+ ``evermemos-opensource/src/common_utils/url_extractor.py``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import math
20
+ import re
21
+ from io import BytesIO
22
+ from urllib.parse import urlparse
23
+
24
+ import httpx
25
+ from bs4 import BeautifulSoup, Comment
26
+ from PIL import Image
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ __all__ = [
31
+ "check_aspect_ratio",
32
+ "clean_html_content",
33
+ "clean_html_for_llm",
34
+ "decode_bytes",
35
+ "fetch_uri",
36
+ "split_image_with_overlap",
37
+ ]
38
+
39
+
40
+ _DEFAULT_USER_AGENT = "Mozilla/5.0 (compatible; everalgo-parser/0.1; +https://evermind.ai)"
41
+
42
+
43
+ async def fetch_uri(uri: str, *, timeout: float = 30.0) -> tuple[bytes, str]:
44
+ """Fetch ``http``/``https`` URI and return ``(content_bytes, content_type)``.
45
+
46
+ Adapted from ``evermemos-opensource/src/common_utils/url_extractor.py``;
47
+ rejects non-http(s) schemes per AGENTS.md §1 (no filesystem access).
48
+
49
+ Parameters
50
+ ----------
51
+ uri : str
52
+ Origin URI. Must be ``http://`` or ``https://``.
53
+ timeout : float, optional
54
+ Per-request timeout in seconds (default 30s).
55
+
56
+ Returns:
57
+ -------
58
+ tuple[bytes, str]
59
+ Response body and the upstream ``Content-Type`` (lower-cased,
60
+ param-stripped, e.g. ``text/html``). Empty string when the upstream
61
+ omits the header.
62
+
63
+ Raises:
64
+ ------
65
+ ValueError
66
+ URI is missing or uses a non-http(s) scheme (``file:`` / ``data:``
67
+ / ...).
68
+ httpx.HTTPError
69
+ Network failure surfaces unchanged so callers can apply their own
70
+ retry / fallback policy (the library does not retry — see ADR-012).
71
+ """
72
+ if not uri:
73
+ raise ValueError("fetch_uri: empty uri")
74
+ parsed = urlparse(uri)
75
+ if parsed.scheme not in ("http", "https"):
76
+ raise ValueError(
77
+ f"fetch_uri: only http/https supported, got scheme={parsed.scheme!r}. "
78
+ f"file:// and other local-filesystem URIs are rejected by design "
79
+ f"(AGENTS.md §1: stateless library, no filesystem access)."
80
+ )
81
+
82
+ headers = {"User-Agent": _DEFAULT_USER_AGENT}
83
+ async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, headers=headers) as client:
84
+ response = await client.get(uri)
85
+ response.raise_for_status()
86
+ # Strip the charset/boundary parameters: ``text/html; charset=utf-8`` → ``text/html``.
87
+ content_type = response.headers.get("content-type", "").split(";")[0].strip().lower()
88
+ logger.debug(
89
+ "fetched uri=%s status=%d bytes=%d type=%s", uri, response.status_code, len(response.content), content_type
90
+ )
91
+ return response.content, content_type
92
+
93
+
94
+ def decode_bytes(content: bytes) -> str:
95
+ """Try several encodings before falling back to lossy UTF-8."""
96
+ for encoding in ("utf-8", "gbk", "gb2312", "latin-1"):
97
+ try:
98
+ return content.decode(encoding)
99
+ except (UnicodeDecodeError, LookupError):
100
+ continue
101
+ return content.decode("utf-8", errors="ignore")
102
+
103
+
104
+ def check_aspect_ratio(image_bytes: bytes, max_ratio: float = 10.0) -> tuple[bool, float]:
105
+ """Return ``(needs_split, height_over_width_ratio)`` for a candidate image."""
106
+ img = Image.open(BytesIO(image_bytes))
107
+ width, height = img.size
108
+ img.close()
109
+ current_ratio = height / width if width > 0 else 0
110
+ return current_ratio > max_ratio, current_ratio
111
+
112
+
113
+ def split_image_with_overlap(
114
+ image_bytes: bytes,
115
+ *,
116
+ max_ratio: float = 10.0,
117
+ overlap_ratio: float = 0.1,
118
+ min_overlap_pixels: int = 50,
119
+ max_overlap_pixels: int = 200,
120
+ ) -> list[bytes]:
121
+ """Split a tall image into overlapping vertical slices.
122
+
123
+ Returns ``[image_bytes]`` unchanged when the height/width ratio is below
124
+ ``max_ratio``.
125
+ """
126
+ img = Image.open(BytesIO(image_bytes))
127
+ width, height = img.size
128
+ img_format = img.format or "JPEG"
129
+
130
+ current_ratio = height / width
131
+ if current_ratio <= max_ratio:
132
+ img.close()
133
+ return [image_bytes]
134
+
135
+ num_parts = math.ceil(current_ratio / max_ratio)
136
+ part_height = height // num_parts
137
+ overlap_pixels = int(part_height * overlap_ratio)
138
+ overlap_pixels = max(min_overlap_pixels, min(overlap_pixels, max_overlap_pixels))
139
+
140
+ split_parts: list[bytes] = []
141
+ for i in range(num_parts):
142
+ top = 0 if i == 0 else max(0, i * part_height - overlap_pixels)
143
+ bottom = height if i == num_parts - 1 else min(height, (i + 1) * part_height + overlap_pixels)
144
+ part = img.crop((0, top, width, bottom))
145
+ buffer = BytesIO()
146
+ part.save(buffer, format=img_format)
147
+ split_parts.append(buffer.getvalue())
148
+
149
+ img.close()
150
+ return split_parts
151
+
152
+
153
+ def clean_html_content(html_content: str) -> str:
154
+ """Strip all tags and return whitespace-normalised plain text."""
155
+ if not html_content or not html_content.strip():
156
+ return ""
157
+ soup = BeautifulSoup(html_content, "html.parser")
158
+ for tag in ("head", "style", "script", "noscript"):
159
+ for element in soup.find_all(tag):
160
+ element.decompose()
161
+ text = soup.get_text(separator="\n", strip=True)
162
+ lines = [line.strip() for line in text.split("\n") if line.strip()]
163
+ text = "\n".join(lines)
164
+ text = re.sub(r"\n{3,}", "\n\n", text)
165
+ return text.strip()
166
+
167
+
168
+ _REMOVE_TAGS = ("head", "style", "script", "noscript", "nav", "footer", "header", "iframe")
169
+ _REMOVE_ATTRS = (
170
+ "style",
171
+ "class",
172
+ "id",
173
+ "onclick",
174
+ "onload",
175
+ "onerror",
176
+ "data-src",
177
+ "data-id",
178
+ "data-track",
179
+ "role",
180
+ "aria-label",
181
+ "aria-hidden",
182
+ "tabindex",
183
+ "lang",
184
+ "dir",
185
+ )
186
+
187
+
188
+ def clean_html_for_llm(html_content: str) -> str:
189
+ """Remove noise tags and bloat attributes but keep structural HTML.
190
+
191
+ The output is still HTML (not plain text) so the LLM can see tables /
192
+ lists / headings as structured input. Drops ``<script>`` / ``<style>`` /
193
+ navigation / footer / iframe entirely and strips per-tag attributes that
194
+ rarely carry semantic meaning.
195
+ """
196
+ if not html_content or not html_content.strip():
197
+ return ""
198
+ soup = BeautifulSoup(html_content, "html.parser")
199
+
200
+ for tag_name in _REMOVE_TAGS:
201
+ for element in soup.find_all(tag_name):
202
+ element.decompose()
203
+
204
+ # ``find_all(True)`` is bs4's idiom for "every tag"; the literal is part of the
205
+ # library API, not a behaviour-toggling boolean flag, so FBT003 doesn't apply.
206
+ for tag in soup.find_all(True): # noqa: FBT003
207
+ for attr in _REMOVE_ATTRS:
208
+ tag.attrs.pop(attr, None)
209
+
210
+ for comment in soup.find_all(string=lambda s: isinstance(s, Comment)):
211
+ comment.extract()
212
+
213
+ body = soup.find("body")
214
+ html_str = str(body) if body else str(soup)
215
+ html_str = re.sub(r"\n{3,}", "\n\n", html_str)
216
+ return html_str.strip()
@@ -0,0 +1,72 @@
1
+ """Audio parser — ASR via multimodal LLM.
2
+
3
+ Mirrors ``evermemos-multimodal/src/parser/audio/gemini_parser.py``: send the
4
+ audio bytes as a single ``image_url`` data URI (OpenRouter / Gemini accepts
5
+ audio MIMEs through the same content-part shape) and the LLM transcribes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import TYPE_CHECKING
12
+
13
+ from asgiref.sync import async_to_sync
14
+
15
+ from everalgo.llm import ChatMessage, LLMClient, TextPart, image_url_part_from_bytes
16
+ from everalgo.parser.prompts.en import PROMPT_FOR_AUDIO
17
+ from everalgo.types import Modality, ParsedContent
18
+
19
+ if TYPE_CHECKING:
20
+ from everalgo.types import RawFile
21
+
22
+ __all__ = ["aparse", "parse"]
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ _MIME_MAP: dict[str, str] = {
28
+ "mp3": "audio/mpeg",
29
+ "wav": "audio/wav",
30
+ "m4a": "audio/mp4",
31
+ "amr": "audio/amr",
32
+ "aiff": "audio/aiff",
33
+ "aac": "audio/aac",
34
+ "ogg": "audio/ogg",
35
+ "flac": "audio/flac",
36
+ }
37
+
38
+
39
+ async def aparse(raw_file: RawFile, *, llm: LLMClient) -> ParsedContent:
40
+ """Parse an audio ``RawFile`` into ``ParsedContent`` (transcription)."""
41
+ if not raw_file.content:
42
+ raise ValueError("RawFile.content is empty; cannot parse audio")
43
+
44
+ extension = raw_file.extension.lower().lstrip(".")
45
+ mime = _MIME_MAP.get(extension)
46
+ if mime is None:
47
+ raise ValueError(
48
+ f"audio.aparse: unsupported audio extension {raw_file.extension!r}. Supported: {sorted(_MIME_MAP)}"
49
+ )
50
+
51
+ message = ChatMessage(
52
+ role="user",
53
+ content=[
54
+ TextPart(text=PROMPT_FOR_AUDIO),
55
+ image_url_part_from_bytes(raw_file.content, mime),
56
+ ],
57
+ )
58
+ logger.debug("parsing audio extension=%s bytes=%d", extension, len(raw_file.content))
59
+ response = await llm.chat([message])
60
+ return ParsedContent(
61
+ text=response.content,
62
+ modality=Modality.AUDIO,
63
+ mime=raw_file.mime or mime,
64
+ metadata={
65
+ "model": response.model,
66
+ "finish_reason": response.finish_reason,
67
+ },
68
+ )
69
+
70
+
71
+ parse = async_to_sync(aparse)
72
+ """Sync bridge — see ``document.parse`` for details."""