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,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()
|
everalgo/parser/audio.py
ADDED
|
@@ -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."""
|