everalgo-parser 0.2.1__tar.gz → 0.3.0rc2__tar.gz

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.
Files changed (60) hide show
  1. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/.gitignore +3 -0
  2. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/CHANGELOG.md +22 -1
  3. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/PKG-INFO +12 -13
  4. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/README.md +8 -8
  5. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/docs/architecture.md +13 -7
  6. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/pyproject.toml +3 -6
  7. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/src/everalgo/parser/__init__.py +11 -9
  8. everalgo_parser-0.3.0rc2/src/everalgo/parser/_replies.py +51 -0
  9. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/src/everalgo/parser/_utils.py +7 -78
  10. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/src/everalgo/parser/audio.py +24 -22
  11. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/src/everalgo/parser/document.py +64 -66
  12. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/src/everalgo/parser/image.py +82 -88
  13. everalgo_parser-0.3.0rc2/src/everalgo/parser/prompts/_reply_format.py +15 -0
  14. everalgo_parser-0.3.0rc2/src/everalgo/parser/prompts/en/__init__.py +15 -0
  15. everalgo_parser-0.3.0rc2/src/everalgo/parser/prompts/en/audio.py +24 -0
  16. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/src/everalgo/parser/prompts/en/document.py +24 -3
  17. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/src/everalgo/parser/prompts/en/image.py +24 -5
  18. everalgo_parser-0.3.0rc2/src/everalgo/parser/prompts/zh/__init__.py +15 -0
  19. everalgo_parser-0.3.0rc2/src/everalgo/parser/prompts/zh/audio.py +24 -0
  20. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/src/everalgo/parser/prompts/zh/document.py +16 -3
  21. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/src/everalgo/parser/prompts/zh/image.py +17 -6
  22. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/src/everalgo/parser/url.py +24 -7
  23. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/tests/fixtures/sample.htm +1 -1
  24. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/tests/fixtures/sample.html +1 -1
  25. everalgo_parser-0.3.0rc2/tests/parser/test_audio.py +67 -0
  26. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/tests/parser/test_dispatch.py +6 -5
  27. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/tests/parser/test_document.py +132 -22
  28. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/tests/parser/test_e2e_all_formats.py +22 -13
  29. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/tests/parser/test_e2e_pdf_openrouter.py +7 -10
  30. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/tests/parser/test_image.py +63 -44
  31. everalgo_parser-0.3.0rc2/tests/parser/test_input_errors.py +43 -0
  32. everalgo_parser-0.3.0rc2/tests/parser/test_prompt_rendering.py +53 -0
  33. everalgo_parser-0.3.0rc2/tests/parser/test_prompt_split.py +337 -0
  34. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/tests/parser/test_url.py +4 -31
  35. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/tests/parser/test_video.py +1 -2
  36. everalgo_parser-0.2.1/src/everalgo/parser/prompts/en/__init__.py +0 -13
  37. everalgo_parser-0.2.1/src/everalgo/parser/prompts/en/audio.py +0 -11
  38. everalgo_parser-0.2.1/src/everalgo/parser/prompts/zh/__init__.py +0 -13
  39. everalgo_parser-0.2.1/src/everalgo/parser/prompts/zh/audio.py +0 -14
  40. everalgo_parser-0.2.1/tests/parser/test_audio.py +0 -48
  41. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/LICENSE +0 -0
  42. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/src/everalgo/parser/prompts/__init__.py +0 -0
  43. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/src/everalgo/parser/py.typed +0 -0
  44. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/src/everalgo/parser/video.py +0 -0
  45. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/tests/fixtures/sample.bmp +0 -0
  46. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/tests/fixtures/sample.docx +0 -0
  47. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/tests/fixtures/sample.eml +0 -0
  48. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/tests/fixtures/sample.jpg +0 -0
  49. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/tests/fixtures/sample.pdf +0 -0
  50. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/tests/fixtures/sample.png +0 -0
  51. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/tests/fixtures/sample.pptx +0 -0
  52. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/tests/fixtures/sample.svg +0 -0
  53. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/tests/fixtures/sample.tiff +0 -0
  54. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/tests/fixtures/sample.txt +0 -0
  55. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/tests/fixtures/sample.wav +0 -0
  56. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/tests/fixtures/sample.webp +0 -0
  57. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/tests/fixtures/sample.xlsx +0 -0
  58. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/tests/parser/__init__.py +0 -0
  59. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/tests/parser/test_public_api.py +0 -0
  60. {everalgo_parser-0.2.1 → everalgo_parser-0.3.0rc2}/tests/parser/test_utils.py +0 -0
@@ -58,5 +58,8 @@ local/
58
58
  .claude/
59
59
  benchmarks/claudecode
60
60
 
61
+ # Subagent-driven-development scratch (ledgers, task briefs, review packages)
62
+ .superpowers/
63
+
61
64
  # Git worktrees (isolated workspaces)
62
65
  .worktrees/
@@ -7,8 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  <!-- git-cliff-unreleased-start -->
9
9
  ## [Unreleased]
10
+
10
11
  <!-- git-cliff-unreleased-end -->
11
12
 
13
+ ## [0.3.0rc2] - 2026-10-13
14
+
15
+ ### Changed
16
+
17
+ - Invalid caller input now raises `everalgo.utils.InvalidInputError` (a `ValueError` subclass, so existing handlers keep working) instead of a bare `ValueError`: a `RawFile` with neither content nor uri, with empty content, with an unsupported extension or modality, a uri that returns an empty body or a content type the parser cannot read. Each message names the field and what to set instead; a uri it quotes is cut to `scheme://host[:port]/path`, so a signed URL's query string, fragment and credentials never reach the message.
18
+ - Requires `everalgo-core>=0.6.0rc2` (the floor was `0.6.0rc1`): the package imports `everalgo.utils.InvalidInputError`, which `0.6.0rc1` does not define.
19
+
20
+ ## [0.3.0rc1] - 2026-10-13
21
+
22
+ ### Changed
23
+
24
+ - **BREAKING:** every LLM call follows the new `LLMClient` contract: it passes `response_format=_TextReply`, a strict pydantic model of one field, `{"text": "..."}`, reads the `str` `achat` returns and validates it with `everalgo.llm.parse_reply`. A reply that is not that object — plain text, a missing or extra key, a non-string `text` — raises `everalgo.llm.LLMOutputError` from the image, PDF, HTML, Office and audio parsers. Two algorithm-level fallbacks keep their behaviour and now also cover an invalid reply: a tall-image merge that fails or comes back empty joins the slices' OCR (`metadata["merge_fallback"] = "deterministic_join"`), and an inline email image whose OCR fails gets the `[image: <name>, OCR failed]` placeholder. A custom client passed as `llm` must accept the mandatory keyword and return the reply text. See `everalgo-core` for the contract.
25
+ - **BREAKING:** every call sends a system message and a user message instead of one user message. The system message is the prompt's rules, unchanged, followed by a `<reply_format>` block asking for `{"text": "..."}` (shared by the English and Chinese sets, `prompts/_reply_format.py`); `PROMPT_FOR_PICTURE`, `PROMPT_FOR_MERGE`, `PROMPT_FOR_FILE`, `PROMPT_FOR_HTML` and `PROMPT_FOR_AUDIO` are now these system messages. The user message is the input alone: the image, PDF or audio part with no text beside it; for HTML the cleaned page in `<html_content>` (new `HTML_USER_PROMPT`, slot `{{html}}`) instead of after a `---` line; for the tall-image merge the OCR texts, each in a `<part number="N">`, in `<ocr_parts count="N">` (new `MERGE_USER_PROMPT`, slots `{{count}}` and `{{parts}}`) instead of `=== Part N ===` sections in `PROMPT_FOR_MERGE`'s removed `{{parts_text}}` slot.
26
+ - **BREAKING:** `ParsedContent.metadata` no longer carries `model` or `finish_reason`: `achat` returns only the reply text, and the model actually served is the host's to record. The image, audio, PDF, HTML and Office results keep their other keys.
27
+ - **BREAKING:** operators call `LLMClient.achat`, the new name of the protocol method; a custom client passed as `llm` must implement `achat` instead of `chat`. See `everalgo-core` for the contract.
28
+ - The URL parser fetches through `everalgo.utils.fetch_uri`, which moved to `everalgo-core`; the `ValueError` for a scheme other than `http` / `https` now reads `fetch_uri: only http and https are supported, got scheme 'file'` instead of `fetch_uri: only http/https supported, got scheme='file'. file:// and other local-filesystem URIs are rejected by design (...)`, so match on the exception type, not the text.
29
+ - `everalgo-parser` no longer declares `httpx` itself: it makes no HTTP call of its own any more, and `everalgo-core` declares `httpx>=0.27` for the fetch.
30
+
12
31
  ## [0.2.1] - 2026-06-15
13
32
 
14
33
  ### Changed
@@ -80,6 +99,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
80
99
  - Optional `cairosvg >= 2.7.0` via `[svg]` extra.
81
100
  - System: LibreOffice (`soffice` on PATH) for Office document parsing.
82
101
 
83
- [Unreleased]: https://github.com/EverMind-AI/EverAlgo/compare/everalgo-parser/v0.2.1...HEAD
102
+ [Unreleased]: https://github.com/EverMind-AI/EverAlgo/compare/everalgo-parser/v0.3.0rc2...HEAD
103
+ [0.3.0rc2]: https://github.com/EverMind-AI/EverAlgo/compare/everalgo-parser/v0.3.0rc1...everalgo-parser/v0.3.0rc2
104
+ [0.3.0rc1]: https://github.com/EverMind-AI/EverAlgo/compare/everalgo-parser/v0.2.1...everalgo-parser/v0.3.0rc1
84
105
  [0.2.1]: https://github.com/EverMind-AI/EverAlgo/compare/everalgo-parser/v0.2.0...everalgo-parser/v0.2.1
85
106
  [0.2.0]: https://github.com/EverMind-AI/EverAlgo/releases/tag/everalgo-parser/v0.2.0
@@ -1,7 +1,7 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: everalgo-parser
3
- Version: 0.2.1
4
- Summary: EverAlgo parser: multimodal raw-file parsing (image / audio / document / video / url) into ParsedContent.
3
+ Version: 0.3.0rc2
4
+ Summary: EverAlgo parser: image, audio, document, and URL parsing into ParsedContent.
5
5
  Project-URL: Homepage, https://github.com/EverMind-AI/EverAlgo
6
6
  Project-URL: Repository, https://github.com/EverMind-AI/EverAlgo
7
7
  Project-URL: Issues, https://github.com/EverMind-AI/EverAlgo/issues
@@ -20,8 +20,7 @@ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
20
  Requires-Python: >=3.12
21
21
  Requires-Dist: asgiref>=3.8.0
22
22
  Requires-Dist: beautifulsoup4>=4.12.0
23
- Requires-Dist: everalgo-core<2.0.0,>=0.2.0
24
- Requires-Dist: httpx>=0.27.0
23
+ Requires-Dist: everalgo-core<2.0.0,>=0.6.0rc2
25
24
  Requires-Dist: pillow>=10.0.0
26
25
  Provides-Extra: svg
27
26
  Requires-Dist: cairosvg>=2.7.0; extra == 'svg'
@@ -29,25 +28,24 @@ Description-Content-Type: text/markdown
29
28
 
30
29
  # everalgo-parser
31
30
 
32
- Multimodal parsing — image / audio / document / video / url raw inputs into `ParsedContent`. Used by `everalgo-knowledge` for file ingestion and by EverOS step 1 for inline parsing.
31
+ Multimodal parsing — image / audio / document / URL raw inputs into `ParsedContent`; video is deferred. Used by `everalgo-knowledge` for file ingestion and by EverOS step 1 for inline parsing.
33
32
 
34
33
  See the umbrella project: [EverAlgo monorepo](../../README.md) and the architecture document at [`docs/concepts/architecture.md`](../../docs/concepts/architecture.md).
35
34
 
36
35
  ## Quick start
37
36
 
38
37
  ```python
39
- from everalgo.llm.config import LLMConfig
40
- from everalgo.llm.providers.openai_compat import OpenAICompatClient
38
+ from everalgo.llm.openai_compat import OpenAICompatClient
41
39
  from everalgo.parser import aparse, RawFile
42
40
 
43
41
  # Create an LLM client per call (or share one instance). The parser uses
44
42
  # OpenAI-compatible clients; OpenRouter is the reference deployment
45
43
  # (Gemini multimodal via OpenRouter passthrough).
46
- client = OpenAICompatClient(LLMConfig(
44
+ client = OpenAICompatClient(
47
45
  model="google/gemini-3-flash-preview",
48
46
  api_key="sk-or-v1-...",
49
47
  base_url="https://openrouter.ai/api/v1",
50
- ))
48
+ )
51
49
 
52
50
  # Bytes-in: caller already hydrated the file.
53
51
  parsed = await aparse(RawFile(content=pdf_bytes, extension="pdf"), llm=client)
@@ -97,14 +95,15 @@ The parser detects `soffice` via `shutil.which("soffice")` and the canonical mac
97
95
 
98
96
  - `aparse(...)` is async; `parse(...)` is the sync bridge via `asgiref.async_to_sync`.
99
97
  - Prompts live as module-level string constants under `prompts/{en,zh}/<operator>.py` ([AGENTS.md §5](../../AGENTS.md#5-code-style)). Swap languages by re-binding the constant at startup.
100
- - The library is **stateless**: it never reads the filesystem and never owns business state. HTTP I/O (LLM calls, URL fetching) is explicitly allowed.
101
- - No retry / fallback / metrics inside operators — surface failures via `LLMError`, let the caller wrap.
98
+ - The parser owns no durable state and never reads caller-selected local paths. URL parsing performs HTTP(S) I/O, and Office conversion writes files only inside a parser-managed temporary directory that is removed before the call returns.
99
+ - Every LLM call sends the prompt's rules as the system message and the input — the file part, or the cleaned HTML / the OCR texts in XML-style tags — as the user message, and asks for one `{"text": "..."}` object (the `everalgo-core` LLM contract has no free-text tier). `ParsedContent.text` is that `text`.
100
+ - No retry / metrics inside operators — the client's errors propagate unchanged and an invalid reply raises `everalgo.llm.LLMOutputError`; let the caller wrap. The only fallbacks are algorithmic: a failed tall-image merge joins the slices' OCR, and a failed inline-image OCR leaves a placeholder in the email.
102
101
 
103
102
  ## Security notes for callers
104
103
 
105
104
  `everalgo-parser` is a stateless library; production safety properties belong to the calling service. The points below are easy to miss and worth wiring into your integration:
106
105
 
107
- - **SSRF — `url.aparse` fetches arbitrary HTTP(S) URIs.** `fetch_uri` rejects non-http(s) schemes (no `file://`, no `gopher://`) but does **not** block private / link-local / loopback IPs (e.g. `127.0.0.1`, `10.0.0.0/8`, `169.254.169.254` cloud metadata, IPv6 ULA). If callers may pass attacker-controlled URLs, resolve the hostname first and reject private ranges before invoking `aparse`, or front the parser with an egress proxy that does the filtering.
106
+ - **SSRF — `url.aparse` fetches arbitrary HTTP(S) URIs.** `everalgo.utils.fetch_uri` rejects non-http(s) schemes (no `file://`, no `gopher://`) but does **not** block private / link-local / loopback IPs (e.g. `127.0.0.1`, `10.0.0.0/8`, `169.254.169.254` cloud metadata, IPv6 ULA). If callers may pass attacker-controlled URLs, resolve the hostname first and reject private ranges before invoking `aparse`, or front the parser with an egress proxy that does the filtering.
108
107
  - **Decompression-bomb cap is set at import time.** `everalgo/parser/__init__.py` pins `PIL.Image.MAX_IMAGE_PIXELS = 100_000_000` (10000×10000) — Pillow will raise `DecompressionBombError` above that. Re-assign after import if your workload genuinely needs larger images.
109
108
  - **Office conversion runs LibreOffice as a subprocess.** `document._convert_to_pdf_via_soffice` validates the `extension` parameter (alphanumeric, ≤8 chars) before writing the temp file, but the LibreOffice attack surface itself is large. Run the host with a non-root user and treat untrusted office docs as you would any other complex binary input.
110
109
  - **`cairosvg` (optional `[svg]` extra) is LGPL-3.0-or-later.** EverAlgo itself is Apache-2.0. LGPLv3 is compatible with Apache-2.0 when `cairosvg` is consumed as an unmodified library through its public Python API (which is what the SVG path does); if you statically vendor or modify `cairosvg`, LGPLv3 §4 / §5 obligations apply to that derivative. Not installing the `[svg]` extra removes the dependency entirely.
@@ -1,24 +1,23 @@
1
1
  # everalgo-parser
2
2
 
3
- Multimodal parsing — image / audio / document / video / url raw inputs into `ParsedContent`. Used by `everalgo-knowledge` for file ingestion and by EverOS step 1 for inline parsing.
3
+ Multimodal parsing — image / audio / document / URL raw inputs into `ParsedContent`; video is deferred. Used by `everalgo-knowledge` for file ingestion and by EverOS step 1 for inline parsing.
4
4
 
5
5
  See the umbrella project: [EverAlgo monorepo](../../README.md) and the architecture document at [`docs/concepts/architecture.md`](../../docs/concepts/architecture.md).
6
6
 
7
7
  ## Quick start
8
8
 
9
9
  ```python
10
- from everalgo.llm.config import LLMConfig
11
- from everalgo.llm.providers.openai_compat import OpenAICompatClient
10
+ from everalgo.llm.openai_compat import OpenAICompatClient
12
11
  from everalgo.parser import aparse, RawFile
13
12
 
14
13
  # Create an LLM client per call (or share one instance). The parser uses
15
14
  # OpenAI-compatible clients; OpenRouter is the reference deployment
16
15
  # (Gemini multimodal via OpenRouter passthrough).
17
- client = OpenAICompatClient(LLMConfig(
16
+ client = OpenAICompatClient(
18
17
  model="google/gemini-3-flash-preview",
19
18
  api_key="sk-or-v1-...",
20
19
  base_url="https://openrouter.ai/api/v1",
21
- ))
20
+ )
22
21
 
23
22
  # Bytes-in: caller already hydrated the file.
24
23
  parsed = await aparse(RawFile(content=pdf_bytes, extension="pdf"), llm=client)
@@ -68,14 +67,15 @@ The parser detects `soffice` via `shutil.which("soffice")` and the canonical mac
68
67
 
69
68
  - `aparse(...)` is async; `parse(...)` is the sync bridge via `asgiref.async_to_sync`.
70
69
  - Prompts live as module-level string constants under `prompts/{en,zh}/<operator>.py` ([AGENTS.md §5](../../AGENTS.md#5-code-style)). Swap languages by re-binding the constant at startup.
71
- - The library is **stateless**: it never reads the filesystem and never owns business state. HTTP I/O (LLM calls, URL fetching) is explicitly allowed.
72
- - No retry / fallback / metrics inside operators — surface failures via `LLMError`, let the caller wrap.
70
+ - The parser owns no durable state and never reads caller-selected local paths. URL parsing performs HTTP(S) I/O, and Office conversion writes files only inside a parser-managed temporary directory that is removed before the call returns.
71
+ - Every LLM call sends the prompt's rules as the system message and the input — the file part, or the cleaned HTML / the OCR texts in XML-style tags — as the user message, and asks for one `{"text": "..."}` object (the `everalgo-core` LLM contract has no free-text tier). `ParsedContent.text` is that `text`.
72
+ - No retry / metrics inside operators — the client's errors propagate unchanged and an invalid reply raises `everalgo.llm.LLMOutputError`; let the caller wrap. The only fallbacks are algorithmic: a failed tall-image merge joins the slices' OCR, and a failed inline-image OCR leaves a placeholder in the email.
73
73
 
74
74
  ## Security notes for callers
75
75
 
76
76
  `everalgo-parser` is a stateless library; production safety properties belong to the calling service. The points below are easy to miss and worth wiring into your integration:
77
77
 
78
- - **SSRF — `url.aparse` fetches arbitrary HTTP(S) URIs.** `fetch_uri` rejects non-http(s) schemes (no `file://`, no `gopher://`) but does **not** block private / link-local / loopback IPs (e.g. `127.0.0.1`, `10.0.0.0/8`, `169.254.169.254` cloud metadata, IPv6 ULA). If callers may pass attacker-controlled URLs, resolve the hostname first and reject private ranges before invoking `aparse`, or front the parser with an egress proxy that does the filtering.
78
+ - **SSRF — `url.aparse` fetches arbitrary HTTP(S) URIs.** `everalgo.utils.fetch_uri` rejects non-http(s) schemes (no `file://`, no `gopher://`) but does **not** block private / link-local / loopback IPs (e.g. `127.0.0.1`, `10.0.0.0/8`, `169.254.169.254` cloud metadata, IPv6 ULA). If callers may pass attacker-controlled URLs, resolve the hostname first and reject private ranges before invoking `aparse`, or front the parser with an egress proxy that does the filtering.
79
79
  - **Decompression-bomb cap is set at import time.** `everalgo/parser/__init__.py` pins `PIL.Image.MAX_IMAGE_PIXELS = 100_000_000` (10000×10000) — Pillow will raise `DecompressionBombError` above that. Re-assign after import if your workload genuinely needs larger images.
80
80
  - **Office conversion runs LibreOffice as a subprocess.** `document._convert_to_pdf_via_soffice` validates the `extension` parameter (alphanumeric, ≤8 chars) before writing the temp file, but the LibreOffice attack surface itself is large. Run the host with a non-root user and treat untrusted office docs as you would any other complex binary input.
81
81
  - **`cairosvg` (optional `[svg]` extra) is LGPL-3.0-or-later.** EverAlgo itself is Apache-2.0. LGPLv3 is compatible with Apache-2.0 when `cairosvg` is consumed as an unmodified library through its public Python API (which is what the SVG path does); if you statically vendor or modify `cairosvg`, LGPLv3 §4 / §5 obligations apply to that derivative. Not installing the `[svg]` extra removes the dependency entirely.
@@ -12,7 +12,8 @@ GitHub 默认渲染下面的 Mermaid 图。
12
12
  packages/everalgo-parser/
13
13
  ├── src/everalgo/parser/
14
14
  │ ├── __init__.py ← 顶层 facade:dispatch + re-export 公开类型
15
- │ ├── _utils.py ← 私有助手:fetch_uri / decode_bytes / aspect-ratio / html-clean
15
+ │ ├── _utils.py ← 私有助手:aspect-ratio / html-clean
16
+ │ ├── _replies.py ← 回复模型 `_TextReply{text}` + 唯一 LLM 调用点 `_call_llm_for_text`
16
17
  │ ├── image.py ← IMAGE modality 算子(含长图分块 + BMP/TIFF 转码 + SVG 栅格化)
17
18
  │ ├── audio.py ← AUDIO modality 算子
18
19
  │ ├── document.py ← PDF / HTML / EMAIL / Office(LibreOffice)四合一
@@ -20,8 +21,9 @@ packages/everalgo-parser/
20
21
  │ ├── video.py ← stub,等 ADR
21
22
  │ └── prompts/
22
23
  │ ├── __init__.py
23
- │ ├── en/{image,audio,document}.py ← PROMPT_FOR_{PICTURE,MERGE,AUDIO,FILE,HTML}
24
- │ └── zh/{image,audio,document}.py ← 中文对偶
24
+ │ ├── _reply_format.py ← 所有 system prompt 末尾共用的 `<reply_format>` 块
25
+ │ ├── en/{image,audio,document}.py ← system:PROMPT_FOR_{PICTURE,MERGE,AUDIO,FILE,HTML};user:MERGE_USER_PROMPT / HTML_USER_PROMPT
26
+ │ └── zh/{image,audio,document}.py ← 中文对偶(同名常量、同样拆分)
25
27
  ├── tests/
26
28
  │ ├── fixtures/ ← 17 个真实 sample.* 文件
27
29
  │ └── parser/
@@ -31,6 +33,8 @@ packages/everalgo-parser/
31
33
  │ ├── test_e2e_all_formats.py
32
34
  │ ├── test_e2e_pdf_openrouter.py
33
35
  │ ├── test_image.py
36
+ │ ├── test_prompt_rendering.py
37
+ │ ├── test_prompt_split.py
34
38
  │ ├── test_public_api.py
35
39
  │ ├── test_url.py
36
40
  │ ├── test_utils.py
@@ -86,7 +90,7 @@ flowchart TD
86
90
  - `uri=http(s)://` + `content=b""` → 走 `url.aparse`(内部抓取 + 按 Content-Type 再分派)。
87
91
  - 已经有 `content`:**extension 优先**,extension 解析不到时**用 mime 兜底**(`get_modality_from_mime`)。这样 design.md §2.1 的 `RawFile(uri=..., mime="application/pdf")` 走 URL 抓取后能再走到 PDF handler。
88
92
  - 子模块内部派发用 extension(`_MIME_MAP[ext]` 查 mime)。当顶层用 mime 解出 modality 时,自动 `model_copy(extension=get_extension_from_mime(mime))` 给子模块。
89
- - `file://` 在 `_utils.fetch_uri` 里直接拒绝(AGENTS.md §1)。
93
+ - `file://` 在 `everalgo.utils.fetch_uri` 里直接拒绝(AGENTS.md §1)。
90
94
  - `Modality.URL` 是顶层 dispatch 产生的"来源标签",不出现在 extension 派发表里。
91
95
 
92
96
  ---
@@ -149,7 +153,7 @@ graph LR
149
153
  | `text` | `str` | 提取后的主文本(Markdown / 纯文本) |
150
154
  | `modality` | `Modality` | 来源分类 |
151
155
  | `mime` | `str` | 源 MIME(无则空串) |
152
- | `metadata` | `dict[str, Any]` | 按模态填:`model` / `finish_reason` / `tall_image_parts` / `inline_image_ocr` / `og_tags` / `intermediate_pdf_bytes` 等 |
156
+ | `metadata` | `dict[str, Any]` | 按模态填:`tall_image_parts` / `merge_fallback` / `inline_image_ocr` / `og_tags` / `intermediate_pdf_bytes` 等。不含 `model` / `finish_reason`:`achat` 只返回文本,实际服务的模型由宿主记录(core `docs/concepts/llm-contract.md` §6.4) |
153
157
 
154
158
  ### `Modality` 枚举(StrEnum,9 值)
155
159
 
@@ -163,6 +167,8 @@ IMAGE PDF AUDIO DOCUMENT HTML EMAIL URL DIRECT UNKNOWN
163
167
 
164
168
  ## 6. 各 modality 调用链
165
169
 
170
+ 所有 LLM 调用同一形态:system = 规则 + `<reply_format>`(要求回复 `{"text": "..."}`),user = 文件 part(图片 / PDF / 音频)或带标签的文本(`<html_content>` / `<ocr_parts>`);回复经 `parse_reply(_TextReply, ...)` 校验后取 `text`,坏回复抛 `LLMOutputError`。例外:长图 merge 与邮件内嵌图 OCR 失败(含坏回复)走算法层兜底(拼接 / 占位符),不抛。
171
+
166
172
  ### 6.1 IMAGE(`image.aparse`)
167
173
 
168
174
  ```mermaid
@@ -181,7 +187,7 @@ flowchart LR
181
187
  J --> K[per-part OCR<br/>N x LLM calls]
182
188
  K --> L{N > 1?}
183
189
  L -- no --> M[return part]
184
- L -- yes --> N[LLM merge<br/>PROMPT_FOR_MERGE]
190
+ L -- yes --> N[LLM merge<br/>PROMPT_FOR_MERGE + ocr_parts<br/>失败 / 空 → 确定性拼接]
185
191
  I --> R[ParsedContent]
186
192
  M --> R
187
193
  N --> R
@@ -193,7 +199,7 @@ flowchart LR
193
199
  flowchart TD
194
200
  A[bytes + ext] --> M{Modality from ext}
195
201
  M -- PDF --> P[image_url part + PROMPT_FOR_FILE → 1 LLM call]
196
- M -- HTML --> H[decode_bytes → clean_html_for_llm bs4 → text part + PROMPT_FOR_HTML → 1 LLM call]
202
+ M -- HTML --> H[decode_bytes → clean_html_for_llm bs4 → html_content + PROMPT_FOR_HTML → 1 LLM call]
197
203
  M -- EMAIL --> E[stdlib email parse → headers + body]
198
204
  E --> EI{inline images?}
199
205
  EI -- yes --> EOCR[per-image OCR via LLM<br/>PROMPT_FOR_PICTURE]
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "everalgo-parser"
7
- version = "0.2.1"
8
- description = "EverAlgo parser: multimodal raw-file parsing (image / audio / document / video / url) into ParsedContent."
7
+ version = "0.3.0rc2"
8
+ description = "EverAlgo parser: image, audio, document, and URL parsing into ParsedContent."
9
9
  requires-python = ">=3.12"
10
10
  license = "Apache-2.0"
11
11
  readme = "README.md"
@@ -20,13 +20,10 @@ classifiers = [
20
20
  "Topic :: Scientific/Engineering :: Artificial Intelligence",
21
21
  ]
22
22
  dependencies = [
23
- "everalgo-core>=0.2.0,<2.0.0",
23
+ "everalgo-core>=0.6.0rc2,<2.0.0",
24
24
  "asgiref>=3.8.0",
25
25
  "pillow>=10.0.0",
26
26
  "beautifulsoup4>=4.12.0",
27
- # The URL parser uses httpx directly for ``http``/``https`` fetches; declare
28
- # explicitly rather than relying on the transitive pull-in via openai SDK.
29
- "httpx>=0.27.0",
30
27
  ]
31
28
 
32
29
  [project.urls]
@@ -25,6 +25,7 @@ from everalgo.types import (
25
25
  get_modality,
26
26
  get_modality_from_mime,
27
27
  )
28
+ from everalgo.utils import InvalidInputError
28
29
 
29
30
  if TYPE_CHECKING:
30
31
  from everalgo.llm import LLMClient
@@ -63,7 +64,7 @@ async def aparse(raw_file: RawFile, *, llm: LLMClient) -> ParsedContent:
63
64
  - ``AUDIO`` → ``audio.aparse``
64
65
  - ``PDF`` / ``DOCUMENT`` / ``HTML`` / ``EMAIL`` → ``document.aparse``
65
66
  - ``DIRECT`` → no LLM; bytes are UTF-8-decoded inline.
66
- - Anything else → ``ValueError``.
67
+ - Anything else → ``InvalidInputError``.
67
68
 
68
69
  When dispatch resolves a modality via ``mime`` but the submodule
69
70
  internally requires ``extension`` (image / audio / document all do for
@@ -87,10 +88,9 @@ async def aparse(raw_file: RawFile, *, llm: LLMClient) -> ParsedContent:
87
88
  return await url.aparse(raw_file, llm=llm)
88
89
 
89
90
  if not raw_file.content:
90
- raise ValueError(
91
- "Cannot dispatch: RawFile has no content. "
92
- "Provide either an http(s) uri (the URL parser fetches) or "
93
- "hydrated bytes."
91
+ raise InvalidInputError(
92
+ "RawFile has neither content nor an http(s) uri, so there is nothing to parse: "
93
+ "pass the file bytes in content, or an http(s) uri for the URL parser to fetch"
94
94
  )
95
95
 
96
96
  # Resolve modality: extension wins; fall back to mime.
@@ -98,8 +98,9 @@ async def aparse(raw_file: RawFile, *, llm: LLMClient) -> ParsedContent:
98
98
  if modality is Modality.UNKNOWN and raw_file.mime:
99
99
  modality = get_modality_from_mime(raw_file.mime)
100
100
  if modality is Modality.UNKNOWN:
101
- raise ValueError(
102
- f"Cannot dispatch: extension={raw_file.extension!r} mime={raw_file.mime!r} do not map to a known Modality."
101
+ raise InvalidInputError(
102
+ f"RawFile extension={raw_file.extension!r} mime={raw_file.mime!r} map to no known modality: "
103
+ "set a supported extension or a matching mime before calling"
103
104
  )
104
105
 
105
106
  # Ensure ``extension`` is populated for downstream submodules that key
@@ -118,8 +119,9 @@ async def aparse(raw_file: RawFile, *, llm: LLMClient) -> ParsedContent:
118
119
  modality=Modality.DIRECT,
119
120
  mime=routed.mime,
120
121
  )
121
- raise ValueError(
122
- f"Unsupported modality {modality.value!r} (extension={raw_file.extension!r}, mime={raw_file.mime!r})"
122
+ raise InvalidInputError(
123
+ f"RawFile modality {modality.value!r} (extension={raw_file.extension!r}, mime={raw_file.mime!r}) "
124
+ "is not parseable: this parser handles image, audio, document and plain-text files only"
123
125
  )
124
126
 
125
127
 
@@ -0,0 +1,51 @@
1
+ """The reply model every parser prompt asks for, and the one LLM callsite every parser call goes through.
2
+
3
+ Each parser output is free text (an OCR, a transcription, a merged OCR text). The contract has no free-text tier, so
4
+ every prompt asks for the ``{"text": "..."}`` object instead (``docs/concepts/llm-contract.md`` §6.1). One call, one
5
+ validation, no retry: the host decides (§4, §5).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ from everalgo.llm import LLMMessage, StrictOutputModel, parse_reply
13
+
14
+ if TYPE_CHECKING:
15
+ from everalgo.llm import ContentPart, LLMClient
16
+
17
+
18
+ class _TextReply(StrictOutputModel):
19
+ """The ``{"text": "..."}`` skeleton every parser prompt's ``<reply_format>`` shows."""
20
+
21
+ text: str
22
+
23
+
24
+ async def _call_llm_for_text(
25
+ llm: LLMClient,
26
+ *,
27
+ system: str,
28
+ user: str | list[ContentPart],
29
+ operator: str,
30
+ temperature: float | None = None,
31
+ max_tokens: int | None = None,
32
+ ) -> str:
33
+ """Send the rules as the system message and the input as the user message; return the reply's ``text``.
34
+
35
+ Args:
36
+ llm: The client to call.
37
+ system: The prompt's rules, ending with its ``<reply_format>``.
38
+ user: The input: the tagged text, or the file's content parts.
39
+ operator: Names the call in a parse error, e.g. ``"parser pdf"``.
40
+ temperature: Passed through; None leaves it to the host.
41
+ max_tokens: Passed through; None leaves it to the host.
42
+
43
+ Returns:
44
+ The reply's ``text``, verbatim; empty when the model returned an empty string.
45
+
46
+ Raises:
47
+ LLMOutputError: If the reply is not a valid ``{"text": "..."}`` object.
48
+ """
49
+ messages = [LLMMessage(role="system", content=system), LLMMessage(role="user", content=user)]
50
+ reply = await llm.achat(messages, response_format=_TextReply, temperature=temperature, max_tokens=max_tokens)
51
+ return parse_reply(_TextReply, reply, operator=operator).text
@@ -1,101 +1,30 @@
1
1
  """Internal parser helpers.
2
2
 
3
- - ``decode_bytes`` — multi-encoding decoder for unknown-charset bytes.
4
3
  - ``check_aspect_ratio`` / ``split_image_with_overlap`` — long-image (tall
5
4
  screenshot) handling for the image parser.
6
5
  - ``clean_html_content`` — strict text extraction (drops all tags).
7
6
  - ``clean_html_for_llm`` — preserves structural tags inside ``<body>``,
8
7
  strips known-noise tags and useless attributes, returns HTML.
9
- - ``fetch_uri`` — HTTP(S) fetch for the URL parser. Adapted from an
10
- upstream internal URL extractor reference implementation.
11
8
  """
12
9
 
13
10
  from __future__ import annotations
14
11
 
15
- import logging
16
- import math
17
- import re
18
- from io import BytesIO
19
- from urllib.parse import urlparse
20
-
21
- import httpx
22
- from bs4 import BeautifulSoup, Comment
23
- from PIL import Image
24
-
25
- logger = logging.getLogger(__name__)
26
-
27
12
  __all__ = [
28
13
  "check_aspect_ratio",
29
14
  "clean_html_content",
30
15
  "clean_html_for_llm",
31
- "decode_bytes",
32
- "fetch_uri",
33
16
  "split_image_with_overlap",
34
17
  ]
35
18
 
19
+ import logging
20
+ import math
21
+ import re
22
+ from io import BytesIO
36
23
 
37
- _DEFAULT_USER_AGENT = "Mozilla/5.0 (compatible; everalgo-parser/0.1; +https://evermind.ai)"
38
-
39
-
40
- async def fetch_uri(uri: str, *, timeout: float = 30.0) -> tuple[bytes, str]:
41
- """Fetch ``http``/``https`` URI and return ``(content_bytes, content_type)``.
42
-
43
- Adapted from an upstream internal URL extractor reference implementation;
44
- rejects non-http(s) schemes per AGENTS.md §1 (no filesystem access).
45
-
46
- Parameters
47
- ----------
48
- uri : str
49
- Origin URI. Must be ``http://`` or ``https://``.
50
- timeout : float, optional
51
- Per-request timeout in seconds (default 30s).
52
-
53
- Returns:
54
- -------
55
- tuple[bytes, str]
56
- Response body and the upstream ``Content-Type`` (lower-cased,
57
- param-stripped, e.g. ``text/html``). Empty string when the upstream
58
- omits the header.
24
+ from bs4 import BeautifulSoup, Comment
25
+ from PIL import Image
59
26
 
60
- Raises:
61
- ------
62
- ValueError
63
- URI is missing or uses a non-http(s) scheme (``file:`` / ``data:``
64
- / ...).
65
- httpx.HTTPError
66
- Network failure surfaces unchanged so callers can apply their own
67
- retry / fallback policy (the library does not retry — see ADR-012).
68
- """
69
- if not uri:
70
- raise ValueError("fetch_uri: empty uri")
71
- parsed = urlparse(uri)
72
- if parsed.scheme not in ("http", "https"):
73
- raise ValueError(
74
- f"fetch_uri: only http/https supported, got scheme={parsed.scheme!r}. "
75
- f"file:// and other local-filesystem URIs are rejected by design "
76
- f"(AGENTS.md §1: stateless library, no filesystem access)."
77
- )
78
-
79
- headers = {"User-Agent": _DEFAULT_USER_AGENT}
80
- async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, headers=headers) as client:
81
- response = await client.get(uri)
82
- response.raise_for_status()
83
- # Strip the charset/boundary parameters: ``text/html; charset=utf-8`` → ``text/html``.
84
- content_type = response.headers.get("content-type", "").split(";")[0].strip().lower()
85
- logger.debug(
86
- "fetched uri=%s status=%d bytes=%d type=%s", uri, response.status_code, len(response.content), content_type
87
- )
88
- return response.content, content_type
89
-
90
-
91
- def decode_bytes(content: bytes) -> str:
92
- """Try several encodings before falling back to lossy UTF-8."""
93
- for encoding in ("utf-8", "gbk", "gb2312", "latin-1"):
94
- try:
95
- return content.decode(encoding)
96
- except (UnicodeDecodeError, LookupError):
97
- continue
98
- return content.decode("utf-8", errors="ignore")
27
+ logger = logging.getLogger(__name__)
99
28
 
100
29
 
101
30
  def check_aspect_ratio(image_bytes: bytes, max_ratio: float = 10.0) -> tuple[bool, float]:
@@ -1,7 +1,8 @@
1
1
  """Audio parser — ASR via multimodal LLM.
2
2
 
3
3
  Sends the audio bytes as a single ``image_url`` data URI (OpenRouter / Gemini accepts
4
- audio MIMEs through the same content-part shape) and the LLM transcribes.
4
+ audio MIMEs through the same content-part shape) as the user message, the rules as
5
+ the system message, and reads the transcription from the reply's ``text``.
5
6
  """
6
7
 
7
8
  from __future__ import annotations
@@ -11,11 +12,14 @@ from typing import TYPE_CHECKING
11
12
 
12
13
  from asgiref.sync import async_to_sync
13
14
 
14
- from everalgo.llm import ChatMessage, LLMClient, TextPart, image_url_part_from_bytes
15
+ from everalgo.llm import image_url_part_from_bytes
16
+ from everalgo.parser._replies import _call_llm_for_text
15
17
  from everalgo.parser.prompts.en import PROMPT_FOR_AUDIO
16
18
  from everalgo.types import Modality, ParsedContent
19
+ from everalgo.utils import InvalidInputError
17
20
 
18
21
  if TYPE_CHECKING:
22
+ from everalgo.llm import LLMClient
19
23
  from everalgo.types import RawFile
20
24
 
21
25
  __all__ = ["aparse", "parse"]
@@ -36,35 +40,33 @@ _MIME_MAP: dict[str, str] = {
36
40
 
37
41
 
38
42
  async def aparse(raw_file: RawFile, *, llm: LLMClient) -> ParsedContent:
39
- """Parse an audio ``RawFile`` into ``ParsedContent`` (transcription)."""
43
+ """Parse an audio ``RawFile`` into ``ParsedContent`` (transcription).
44
+
45
+ Raises:
46
+ InvalidInputError: If ``raw_file`` has no content or an unsupported extension.
47
+ LLMOutputError: If the reply is not a valid ``{"text": "..."}`` object.
48
+ """
40
49
  if not raw_file.content:
41
- raise ValueError("RawFile.content is empty; cannot parse audio")
50
+ raise InvalidInputError(
51
+ "RawFile.content is empty, there is nothing to transcribe: pass the audio bytes in content"
52
+ )
42
53
 
43
54
  extension = raw_file.extension.lower().lstrip(".")
44
55
  mime = _MIME_MAP.get(extension)
45
56
  if mime is None:
46
- raise ValueError(
47
- f"audio.aparse: unsupported audio extension {raw_file.extension!r}. Supported: {sorted(_MIME_MAP)}"
57
+ raise InvalidInputError(
58
+ f"RawFile.extension {raw_file.extension!r} is not a supported audio extension: "
59
+ f"set it to one of {sorted(_MIME_MAP)}"
48
60
  )
49
61
 
50
- message = ChatMessage(
51
- role="user",
52
- content=[
53
- TextPart(text=PROMPT_FOR_AUDIO),
54
- image_url_part_from_bytes(raw_file.content, mime),
55
- ],
56
- )
57
62
  logger.debug("parsing audio extension=%s bytes=%d", extension, len(raw_file.content))
58
- response = await llm.chat([message])
59
- return ParsedContent(
60
- text=response.content,
61
- modality=Modality.AUDIO,
62
- mime=raw_file.mime or mime,
63
- metadata={
64
- "model": response.model,
65
- "finish_reason": response.finish_reason,
66
- },
63
+ text = await _call_llm_for_text(
64
+ llm,
65
+ system=PROMPT_FOR_AUDIO,
66
+ user=[image_url_part_from_bytes(raw_file.content, mime)],
67
+ operator="parser audio",
67
68
  )
69
+ return ParsedContent(text=text, modality=Modality.AUDIO, mime=raw_file.mime or mime)
68
70
 
69
71
 
70
72
  parse = async_to_sync(aparse)