rakam-systems-documents 0.1.0__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.
- rakam_systems_documents-0.1.0/PKG-INFO +68 -0
- rakam_systems_documents-0.1.0/README.md +52 -0
- rakam_systems_documents-0.1.0/pyproject.toml +52 -0
- rakam_systems_documents-0.1.0/src/rakam_systems_documents/__init__.py +24 -0
- rakam_systems_documents-0.1.0/src/rakam_systems_documents/prepare.py +184 -0
- rakam_systems_documents-0.1.0/src/rakam_systems_documents/providers/__init__.py +6 -0
- rakam_systems_documents-0.1.0/src/rakam_systems_documents/providers/ocr.py +96 -0
- rakam_systems_documents-0.1.0/src/rakam_systems_documents/schema.py +41 -0
- rakam_systems_documents-0.1.0/src/rakam_systems_documents/tests/__init__.py +0 -0
- rakam_systems_documents-0.1.0/src/rakam_systems_documents/tests/test_prepare.py +146 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: rakam-systems-documents
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Generic document preparation: any source -> PreparedContent (markdown + structured rows + provenance)
|
|
5
|
+
Author: Roman Grebnev
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Dist: pydantic>=2.10.6
|
|
8
|
+
Requires-Dist: pymupdf4llm>=0.0.17,<0.0.18
|
|
9
|
+
Requires-Dist: pymupdf>=1.24.0
|
|
10
|
+
Requires-Dist: openpyxl>=3.1.0
|
|
11
|
+
Requires-Dist: httpx>=0.27.0
|
|
12
|
+
Requires-Dist: docling==2.62.0 ; extra == 'docling'
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Provides-Extra: docling
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# rakam-systems-documents
|
|
18
|
+
|
|
19
|
+
Generic **document preparation**: turn any source document into clean, structured,
|
|
20
|
+
provenance-tagged content the rest of the stack can reason over.
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from rakam_systems_documents import prepare, MistralOCRProvider
|
|
24
|
+
|
|
25
|
+
pc = prepare(raw_bytes, mime="application/pdf", filename="invoice.pdf",
|
|
26
|
+
ocr=MistralOCRProvider()) # ocr optional; native PDFs skip it
|
|
27
|
+
pc.markdown # what a model reads
|
|
28
|
+
pc.rows # structured rows (tabular sources), header-keyed + provenance
|
|
29
|
+
pc.provenance # page/sheet/row refs
|
|
30
|
+
pc.provider # pdf_text | mistral_ocr | docling_ocr | tabular | text | none
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## What it is (and isn't)
|
|
34
|
+
|
|
35
|
+
**One job:** `bytes -> PreparedContent`. Format dispatch, parse/decode, hybrid-OCR
|
|
36
|
+
routing, provenance normalization, truncation. A pure function — the only I/O is the
|
|
37
|
+
injected OCR provider.
|
|
38
|
+
|
|
39
|
+
**Not** its job: storage, chunking, embedding, indexing, async/status, or any business
|
|
40
|
+
meaning (extraction, matching, classification). Those belong to the caller — the library
|
|
41
|
+
gives every caller one document→content primitive so none reimplements parsing/OCR.
|
|
42
|
+
|
|
43
|
+
## Formats
|
|
44
|
+
|
|
45
|
+
| Source | Handling |
|
|
46
|
+
|---|---|
|
|
47
|
+
| Native PDF | `pymupdf4llm` → markdown (text layer, exact, no egress) |
|
|
48
|
+
| Scanned PDF / image | **hybrid gate** → OCR provider (see below) |
|
|
49
|
+
| Excel / CSV | `openpyxl` / `csv` → markdown table **+ structured rows + (sheet,row) provenance** |
|
|
50
|
+
| Email / text | rfc822 + multi-encoding decode (binary rejected) |
|
|
51
|
+
|
|
52
|
+
## OCR is pluggable
|
|
53
|
+
|
|
54
|
+
Native-text PDFs never hit OCR. Scanned pages / images route to an `OCRProvider`:
|
|
55
|
+
|
|
56
|
+
- **`MistralOCRProvider`** (default) — the Mistral hosted OCR API (`/v1/ocr`). Needs `MISTRAL_API_KEY`.
|
|
57
|
+
- **`DoclingOCRProvider`** — on-prem, no egress. Requires the optional extra:
|
|
58
|
+
`pip install rakam-systems-documents[docling]`.
|
|
59
|
+
|
|
60
|
+
A missing/unavailable engine degrades gracefully (native text still works; a scan yields
|
|
61
|
+
`provider="none"` + a help hint, never an exception).
|
|
62
|
+
|
|
63
|
+
## Install
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
pip install rakam-systems-documents # Mistral OCR path
|
|
67
|
+
pip install rakam-systems-documents[docling] # + on-prem OCR
|
|
68
|
+
```
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# rakam-systems-documents
|
|
2
|
+
|
|
3
|
+
Generic **document preparation**: turn any source document into clean, structured,
|
|
4
|
+
provenance-tagged content the rest of the stack can reason over.
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
from rakam_systems_documents import prepare, MistralOCRProvider
|
|
8
|
+
|
|
9
|
+
pc = prepare(raw_bytes, mime="application/pdf", filename="invoice.pdf",
|
|
10
|
+
ocr=MistralOCRProvider()) # ocr optional; native PDFs skip it
|
|
11
|
+
pc.markdown # what a model reads
|
|
12
|
+
pc.rows # structured rows (tabular sources), header-keyed + provenance
|
|
13
|
+
pc.provenance # page/sheet/row refs
|
|
14
|
+
pc.provider # pdf_text | mistral_ocr | docling_ocr | tabular | text | none
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## What it is (and isn't)
|
|
18
|
+
|
|
19
|
+
**One job:** `bytes -> PreparedContent`. Format dispatch, parse/decode, hybrid-OCR
|
|
20
|
+
routing, provenance normalization, truncation. A pure function — the only I/O is the
|
|
21
|
+
injected OCR provider.
|
|
22
|
+
|
|
23
|
+
**Not** its job: storage, chunking, embedding, indexing, async/status, or any business
|
|
24
|
+
meaning (extraction, matching, classification). Those belong to the caller — the library
|
|
25
|
+
gives every caller one document→content primitive so none reimplements parsing/OCR.
|
|
26
|
+
|
|
27
|
+
## Formats
|
|
28
|
+
|
|
29
|
+
| Source | Handling |
|
|
30
|
+
|---|---|
|
|
31
|
+
| Native PDF | `pymupdf4llm` → markdown (text layer, exact, no egress) |
|
|
32
|
+
| Scanned PDF / image | **hybrid gate** → OCR provider (see below) |
|
|
33
|
+
| Excel / CSV | `openpyxl` / `csv` → markdown table **+ structured rows + (sheet,row) provenance** |
|
|
34
|
+
| Email / text | rfc822 + multi-encoding decode (binary rejected) |
|
|
35
|
+
|
|
36
|
+
## OCR is pluggable
|
|
37
|
+
|
|
38
|
+
Native-text PDFs never hit OCR. Scanned pages / images route to an `OCRProvider`:
|
|
39
|
+
|
|
40
|
+
- **`MistralOCRProvider`** (default) — the Mistral hosted OCR API (`/v1/ocr`). Needs `MISTRAL_API_KEY`.
|
|
41
|
+
- **`DoclingOCRProvider`** — on-prem, no egress. Requires the optional extra:
|
|
42
|
+
`pip install rakam-systems-documents[docling]`.
|
|
43
|
+
|
|
44
|
+
A missing/unavailable engine degrades gracefully (native text still works; a scan yields
|
|
45
|
+
`provider="none"` + a help hint, never an exception).
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
pip install rakam-systems-documents # Mistral OCR path
|
|
51
|
+
pip install rakam-systems-documents[docling] # + on-prem OCR
|
|
52
|
+
```
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build >= 0.7.19, <0.9.0"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rakam-systems-documents"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Generic document preparation: any source -> PreparedContent (markdown + structured rows + provenance)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Roman Grebnev" }
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"pydantic>=2.10.6",
|
|
17
|
+
# Cap below 0.0.18: that release pulls in `pymupdf-layout` -> `onnxruntime`,
|
|
18
|
+
# a heavy ML dep whose py<3.11 pin (onnxruntime 1.24.x) has no cp310 wheels —
|
|
19
|
+
# it breaks the 3.10 build and defeats this package's light, no-egress goal.
|
|
20
|
+
"pymupdf4llm>=0.0.17,<0.0.18",
|
|
21
|
+
"pymupdf>=1.24.0",
|
|
22
|
+
"openpyxl>=3.1.0",
|
|
23
|
+
"httpx>=0.27.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
# On-prem, no-egress OCR. Optional — the default (hosted) OCR path needs none of this.
|
|
28
|
+
docling = ["docling==2.62.0"]
|
|
29
|
+
|
|
30
|
+
[dependency-groups]
|
|
31
|
+
dev = [
|
|
32
|
+
"build>=1.2.2.post1",
|
|
33
|
+
"pytest>=8.3.5",
|
|
34
|
+
"pytest-cov>=5.0.0",
|
|
35
|
+
"twine>=6.1.0",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[tool.isort]
|
|
39
|
+
profile = "black"
|
|
40
|
+
line_length = 88
|
|
41
|
+
|
|
42
|
+
[tool.black]
|
|
43
|
+
line-length = 88
|
|
44
|
+
target-version = ["py311"]
|
|
45
|
+
|
|
46
|
+
[tool.uv.build-backend]
|
|
47
|
+
module-root = "src"
|
|
48
|
+
exclude = ["**/tests/**"]
|
|
49
|
+
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
testpaths = ["src/rakam_systems_documents/tests"]
|
|
52
|
+
python_files = "test_*.py"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""rakam-systems-documents — generic document preparation.
|
|
2
|
+
|
|
3
|
+
``prepare(bytes, mime, filename) -> PreparedContent``: any source (native/scanned
|
|
4
|
+
PDF, image, Excel/CSV, email/text) to markdown + structured rows + provenance.
|
|
5
|
+
A pure, side-effect-free primitive; storage / chunking / embedding / extraction
|
|
6
|
+
belong to the caller.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .prepare import prepare
|
|
11
|
+
from .providers import DoclingOCRProvider, MistralOCRProvider, OCRProvider
|
|
12
|
+
from .schema import PreparedContent, SourceRef, TableRow
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"prepare",
|
|
18
|
+
"PreparedContent",
|
|
19
|
+
"TableRow",
|
|
20
|
+
"SourceRef",
|
|
21
|
+
"OCRProvider",
|
|
22
|
+
"MistralOCRProvider",
|
|
23
|
+
"DoclingOCRProvider",
|
|
24
|
+
]
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""The single entry point: ``prepare(bytes, mime, filename) -> PreparedContent``.
|
|
2
|
+
|
|
3
|
+
A pure orchestrator — format dispatch + parse/decode + hybrid-OCR routing +
|
|
4
|
+
provenance normalization + truncation. No storage, no chunking, no embedding, no
|
|
5
|
+
async, no business meaning. The only I/O is the injected OCR provider.
|
|
6
|
+
|
|
7
|
+
Parsing mirrors the ``rakam_systems`` vectorstore loaders (``pymupdf4llm`` for
|
|
8
|
+
PDFs, ``openpyxl`` for spreadsheets) without importing that heavy package, so
|
|
9
|
+
this stays a light dependency.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import base64 # noqa: F401 (kept for symmetry with providers; harmless)
|
|
14
|
+
import csv
|
|
15
|
+
import io
|
|
16
|
+
|
|
17
|
+
from .providers import OCRProvider
|
|
18
|
+
from .schema import PreparedContent, SourceRef, TableRow
|
|
19
|
+
|
|
20
|
+
# Below this many stripped characters a PDF page is treated as "no text layer"
|
|
21
|
+
# (i.e. scanned) and routed to OCR.
|
|
22
|
+
_PDF_TEXT_MIN = 20
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def prepare(
|
|
26
|
+
content: bytes,
|
|
27
|
+
mime: str,
|
|
28
|
+
filename: str,
|
|
29
|
+
ocr: OCRProvider | None = None,
|
|
30
|
+
max_chars: int = 8000,
|
|
31
|
+
) -> PreparedContent:
|
|
32
|
+
"""Turn one document into ``PreparedContent``. Never raises — an unreadable
|
|
33
|
+
file yields ``provider="none"`` + a ``meta['help']`` hint."""
|
|
34
|
+
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
|
35
|
+
try:
|
|
36
|
+
if mime == "application/pdf" or ext == "pdf" or content[:5] == b"%PDF-":
|
|
37
|
+
pc = _prepare_pdf(content, mime, ocr)
|
|
38
|
+
elif mime.startswith("image/") or ext in ("png", "jpg", "jpeg", "tiff", "webp"):
|
|
39
|
+
pc = _prepare_image(content, mime, ocr)
|
|
40
|
+
elif ext in ("xlsx", "xlsm") or "spreadsheetml" in mime or "ms-excel" in mime:
|
|
41
|
+
pc = _prepare_xlsx(content)
|
|
42
|
+
elif ext == "csv" or mime == "text/csv":
|
|
43
|
+
pc = _prepare_csv(content)
|
|
44
|
+
elif ext == "eml" or mime == "message/rfc822":
|
|
45
|
+
pc = _prepare_email(content)
|
|
46
|
+
else:
|
|
47
|
+
text = _decode_text(content)
|
|
48
|
+
pc = PreparedContent(
|
|
49
|
+
markdown=text or "",
|
|
50
|
+
provider="text" if text else "none",
|
|
51
|
+
meta={} if text else {"help": "could not read this file (binary?)"},
|
|
52
|
+
)
|
|
53
|
+
except Exception as exc: # noqa: BLE001 — best-effort: a bad file must never raise
|
|
54
|
+
pc = PreparedContent(provider="none", meta={"help": f"processing failed: {exc}"})
|
|
55
|
+
|
|
56
|
+
if len(pc.markdown) > max_chars:
|
|
57
|
+
pc.markdown = pc.markdown[:max_chars]
|
|
58
|
+
pc.truncated = True
|
|
59
|
+
return pc
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── PDF (hybrid: native text first, OCR for scans) ─────────────────────────
|
|
63
|
+
def _pdf_text_len(content: bytes) -> tuple[int, int]:
|
|
64
|
+
import fitz
|
|
65
|
+
|
|
66
|
+
with fitz.open(stream=content, filetype="pdf") as doc:
|
|
67
|
+
total = sum(len(page.get_text().strip()) for page in doc)
|
|
68
|
+
return total, doc.page_count
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _prepare_pdf(content: bytes, mime: str, ocr: OCRProvider | None) -> PreparedContent:
|
|
72
|
+
text_len, pages = _pdf_text_len(content)
|
|
73
|
+
if text_len >= _PDF_TEXT_MIN:
|
|
74
|
+
import fitz
|
|
75
|
+
import pymupdf4llm
|
|
76
|
+
|
|
77
|
+
with fitz.open(stream=content, filetype="pdf") as doc:
|
|
78
|
+
markdown = pymupdf4llm.to_markdown(doc)
|
|
79
|
+
return PreparedContent(
|
|
80
|
+
markdown=markdown,
|
|
81
|
+
provider="pdf_text",
|
|
82
|
+
provenance=[SourceRef(page=i + 1) for i in range(pages)],
|
|
83
|
+
meta={"page_count": pages},
|
|
84
|
+
)
|
|
85
|
+
if ocr and ocr.available():
|
|
86
|
+
markdown, provenance = ocr.ocr(content, mime)
|
|
87
|
+
return PreparedContent(
|
|
88
|
+
markdown=markdown,
|
|
89
|
+
provider=f"{ocr.name}_ocr",
|
|
90
|
+
provenance=provenance,
|
|
91
|
+
meta={"page_count": len(provenance) or pages},
|
|
92
|
+
)
|
|
93
|
+
return PreparedContent(
|
|
94
|
+
provider="none",
|
|
95
|
+
meta={"page_count": pages, "help": "no text layer and OCR unavailable"},
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _prepare_image(content: bytes, mime: str, ocr: OCRProvider | None) -> PreparedContent:
|
|
100
|
+
if ocr and ocr.available():
|
|
101
|
+
markdown, provenance = ocr.ocr(content, mime)
|
|
102
|
+
return PreparedContent(markdown=markdown, provider=f"{ocr.name}_ocr", provenance=provenance)
|
|
103
|
+
return PreparedContent(provider="none", meta={"help": "image needs OCR; provider unavailable"})
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ── tabular (markdown table + structured rows + provenance) ────────────────
|
|
107
|
+
def _rows_to_markdown(headers: list[str], data_rows: list[list[str]]) -> str:
|
|
108
|
+
lines = ["| " + " | ".join(headers) + " |", "| " + " | ".join("---" for _ in headers) + " |"]
|
|
109
|
+
lines += ["| " + " | ".join(r) + " |" for r in data_rows]
|
|
110
|
+
return "\n".join(lines)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _prepare_xlsx(content: bytes) -> PreparedContent:
|
|
114
|
+
import openpyxl
|
|
115
|
+
|
|
116
|
+
wb = openpyxl.load_workbook(io.BytesIO(content), read_only=True, data_only=True)
|
|
117
|
+
sheet_names = [ws.title for ws in wb.worksheets]
|
|
118
|
+
md_blocks: list[str] = []
|
|
119
|
+
rows_out: list[TableRow] = []
|
|
120
|
+
for ws in wb.worksheets:
|
|
121
|
+
headers: list[str] | None = None
|
|
122
|
+
data_rows: list[list[str]] = []
|
|
123
|
+
for r_idx, row in enumerate(ws.iter_rows(values_only=True), start=1):
|
|
124
|
+
vals = ["" if c is None else str(c) for c in row]
|
|
125
|
+
if headers is None:
|
|
126
|
+
headers = vals
|
|
127
|
+
continue
|
|
128
|
+
data_rows.append(vals)
|
|
129
|
+
cells = {(headers[i] if i < len(headers) else f"col{i}"): vals[i] for i in range(len(vals))}
|
|
130
|
+
rows_out.append(TableRow(cells=cells, source=SourceRef(sheet=ws.title, row=r_idx)))
|
|
131
|
+
if headers:
|
|
132
|
+
md_blocks.append(f"### {ws.title}\n" + _rows_to_markdown(headers, data_rows))
|
|
133
|
+
wb.close()
|
|
134
|
+
return PreparedContent(
|
|
135
|
+
markdown="\n\n".join(md_blocks),
|
|
136
|
+
rows=rows_out,
|
|
137
|
+
provider="tabular",
|
|
138
|
+
meta={"sheet_names": sheet_names},
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _prepare_csv(content: bytes) -> PreparedContent:
|
|
143
|
+
text = _decode_text(content) or ""
|
|
144
|
+
reader = list(csv.reader(io.StringIO(text)))
|
|
145
|
+
if not reader:
|
|
146
|
+
return PreparedContent(provider="text")
|
|
147
|
+
headers, data = reader[0], reader[1:]
|
|
148
|
+
rows_out = [
|
|
149
|
+
TableRow(
|
|
150
|
+
cells={(headers[i] if i < len(headers) else f"col{i}"): vals[i] for i in range(len(vals))},
|
|
151
|
+
source=SourceRef(row=r_idx),
|
|
152
|
+
)
|
|
153
|
+
for r_idx, vals in enumerate(data, start=2)
|
|
154
|
+
]
|
|
155
|
+
return PreparedContent(
|
|
156
|
+
markdown=_rows_to_markdown(headers, data),
|
|
157
|
+
rows=rows_out,
|
|
158
|
+
provider="tabular",
|
|
159
|
+
meta={"row_count": len(rows_out)},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ── email / text ───────────────────────────────────────────────────────────
|
|
164
|
+
def _decode_text(content: bytes) -> str | None:
|
|
165
|
+
if b"\x00" in content[:4096]: # reject binary masquerading as text
|
|
166
|
+
return None
|
|
167
|
+
for encoding in ("utf-8-sig", "utf-8", "latin-1"):
|
|
168
|
+
try:
|
|
169
|
+
return content.decode(encoding)
|
|
170
|
+
except UnicodeDecodeError:
|
|
171
|
+
continue
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _prepare_email(content: bytes) -> PreparedContent:
|
|
176
|
+
import email
|
|
177
|
+
from email import policy
|
|
178
|
+
|
|
179
|
+
msg = email.message_from_bytes(content, policy=policy.default)
|
|
180
|
+
body = msg.get_body(preferencelist=("plain", "html"))
|
|
181
|
+
text = body.get_content() if body else ""
|
|
182
|
+
subject = msg.get("subject", "")
|
|
183
|
+
markdown = f"# {subject}\n\n**From:** {msg.get('from', '')}\n\n{text}".strip()
|
|
184
|
+
return PreparedContent(markdown=markdown, provider="text", meta={"subject": subject})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""OCR for scanned pages / images — behind one interface so the engine is a
|
|
2
|
+
config choice, never a fork. Mistral is the default (hosted API); Docling is the
|
|
3
|
+
on-prem, no-egress swap (optional extra ``rakam-systems-documents[docling]``).
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import os
|
|
9
|
+
from typing import Protocol, runtime_checkable
|
|
10
|
+
|
|
11
|
+
from ..schema import SourceRef
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@runtime_checkable
|
|
15
|
+
class OCRProvider(Protocol):
|
|
16
|
+
"""An OCR engine. ``ocr`` returns ``(markdown, provenance)`` and must be
|
|
17
|
+
best-effort — the caller treats an empty return as "unreadable", never an
|
|
18
|
+
exception path."""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
|
|
22
|
+
def available(self) -> bool: ...
|
|
23
|
+
|
|
24
|
+
def ocr(self, content: bytes, mime: str) -> tuple[str, list[SourceRef]]: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MistralOCRProvider:
|
|
28
|
+
"""Default engine — the Mistral hosted OCR API (``/v1/ocr``)."""
|
|
29
|
+
|
|
30
|
+
name = "mistral"
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
api_key: str | None = None,
|
|
35
|
+
model: str = "mistral-ocr-latest",
|
|
36
|
+
timeout: float = 90.0,
|
|
37
|
+
) -> None:
|
|
38
|
+
self.api_key = api_key or os.getenv("MISTRAL_API_KEY")
|
|
39
|
+
self.model = os.getenv("OCR_MODEL", model)
|
|
40
|
+
self.timeout = timeout
|
|
41
|
+
|
|
42
|
+
def available(self) -> bool:
|
|
43
|
+
return bool(self.api_key)
|
|
44
|
+
|
|
45
|
+
def ocr(self, content: bytes, mime: str) -> tuple[str, list[SourceRef]]:
|
|
46
|
+
import httpx
|
|
47
|
+
|
|
48
|
+
is_pdf = mime == "application/pdf" or content[:5] == b"%PDF-"
|
|
49
|
+
field = "document_url" if is_pdf else "image_url"
|
|
50
|
+
data_url = f"data:{mime};base64,{base64.b64encode(content).decode()}"
|
|
51
|
+
payload = {"model": self.model, "document": {"type": field, field: data_url}}
|
|
52
|
+
resp = httpx.post(
|
|
53
|
+
"https://api.mistral.ai/v1/ocr",
|
|
54
|
+
json=payload,
|
|
55
|
+
headers={"Authorization": f"Bearer {self.api_key}"},
|
|
56
|
+
timeout=self.timeout,
|
|
57
|
+
)
|
|
58
|
+
resp.raise_for_status()
|
|
59
|
+
pages = resp.json().get("pages", [])
|
|
60
|
+
markdown = "\n\n".join(p.get("markdown", "") for p in pages)
|
|
61
|
+
return markdown, [SourceRef(page=i + 1) for i in range(len(pages))]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class DoclingOCRProvider:
|
|
65
|
+
"""On-prem, no-egress OCR via Docling. Requires the ``docling`` extra; when
|
|
66
|
+
absent, ``available()`` is False and the caller falls back to text-only."""
|
|
67
|
+
|
|
68
|
+
name = "docling"
|
|
69
|
+
|
|
70
|
+
def available(self) -> bool:
|
|
71
|
+
try:
|
|
72
|
+
import docling # noqa: F401
|
|
73
|
+
|
|
74
|
+
return True
|
|
75
|
+
except ImportError:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
def ocr(self, content: bytes, mime: str) -> tuple[str, list[SourceRef]]:
|
|
79
|
+
import os as _os
|
|
80
|
+
import tempfile
|
|
81
|
+
|
|
82
|
+
from docling.document_converter import DocumentConverter
|
|
83
|
+
|
|
84
|
+
is_pdf = mime == "application/pdf" or content[:5] == b"%PDF-"
|
|
85
|
+
suffix = ".pdf" if is_pdf else ".png"
|
|
86
|
+
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tf:
|
|
87
|
+
tf.write(content)
|
|
88
|
+
path = tf.name
|
|
89
|
+
try:
|
|
90
|
+
document = DocumentConverter().convert(path).document
|
|
91
|
+
markdown = document.export_to_markdown()
|
|
92
|
+
finally:
|
|
93
|
+
_os.unlink(path)
|
|
94
|
+
# Docling exposes page count; provenance stays page-level (best-effort).
|
|
95
|
+
pages = getattr(document, "num_pages", None) or 1
|
|
96
|
+
return markdown, [SourceRef(page=i + 1) for i in range(pages)]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""The neutral currency the prepare core moves: ``PreparedContent``.
|
|
2
|
+
|
|
3
|
+
Downstream-agnostic on purpose — it names *content* (markdown, structured rows,
|
|
4
|
+
provenance), never business meaning. Extraction / matching / classification are
|
|
5
|
+
the consumer's job, not this package's.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SourceRef(BaseModel):
|
|
13
|
+
"""Where a fragment came from. Best-effort per provider: exact for native
|
|
14
|
+
PDFs / spreadsheets, page-level for scan-OCR, empty when unknown."""
|
|
15
|
+
|
|
16
|
+
page: int | None = None # PDF / scan (1-indexed)
|
|
17
|
+
sheet: str | None = None # spreadsheet
|
|
18
|
+
row: int | None = None # spreadsheet / table (1-indexed)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TableRow(BaseModel):
|
|
22
|
+
"""One row of a tabular source, header-keyed when a header is detected."""
|
|
23
|
+
|
|
24
|
+
cells: dict[str, str]
|
|
25
|
+
source: SourceRef = Field(default_factory=SourceRef)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PreparedContent(BaseModel):
|
|
29
|
+
"""The result of preparing one document.
|
|
30
|
+
|
|
31
|
+
``markdown`` is what a model reads; ``rows`` carries structured tabular data
|
|
32
|
+
for precise downstream mapping; ``provenance`` traces prose/scan fragments to
|
|
33
|
+
their source. ``meta`` holds light, non-business signals only
|
|
34
|
+
(page_count / sheet_names / encoding / help)."""
|
|
35
|
+
|
|
36
|
+
markdown: str = ""
|
|
37
|
+
rows: list[TableRow] = Field(default_factory=list)
|
|
38
|
+
provenance: list[SourceRef] = Field(default_factory=list)
|
|
39
|
+
provider: str = "none" # pdf_text | mistral_ocr | docling_ocr | tabular | text | none
|
|
40
|
+
truncated: bool = False
|
|
41
|
+
meta: dict = Field(default_factory=dict)
|
|
File without changes
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Behavioural tests for prepare() — one per dispatch/route, using in-test
|
|
2
|
+
sample bytes (native PDF, scanned PDF, xlsx, csv, email, binary).
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import io
|
|
7
|
+
from email.message import EmailMessage
|
|
8
|
+
|
|
9
|
+
import fitz # pymupdf
|
|
10
|
+
import openpyxl
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from rakam_systems_documents import PreparedContent, SourceRef, prepare
|
|
14
|
+
|
|
15
|
+
PDF = "application/pdf"
|
|
16
|
+
XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ── sample builders ─────────────────────────────────────────────────────────
|
|
20
|
+
def _native_pdf() -> bytes:
|
|
21
|
+
doc = fitz.open()
|
|
22
|
+
doc.new_page().insert_text((72, 72), "OFFRE FOURNISSEUR ACME\nTube acier E24 50x50\n1200 ml", fontsize=11)
|
|
23
|
+
return doc.tobytes()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _scanned_pdf() -> bytes:
|
|
27
|
+
src = fitz.open()
|
|
28
|
+
src.new_page().insert_text((72, 72), "FACTURE scan\nTotal 5040 EUR", fontsize=14)
|
|
29
|
+
pix = src[0].get_pixmap(dpi=120)
|
|
30
|
+
out = fitz.open()
|
|
31
|
+
page = out.new_page(width=src[0].rect.width, height=src[0].rect.height)
|
|
32
|
+
page.insert_image(page.rect, pixmap=pix) # image only → no text layer
|
|
33
|
+
return out.tobytes()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _catalogue_xlsx() -> bytes:
|
|
37
|
+
wb = openpyxl.Workbook()
|
|
38
|
+
ws = wb.active
|
|
39
|
+
ws.title = "Catalogue"
|
|
40
|
+
ws.append(["Code", "Designation", "Grade"])
|
|
41
|
+
ws.append(["ST-E24-5050", "Tube acier 50x50x3", "E24"])
|
|
42
|
+
ws.append(["ST-S235-6060", "Tube acier 60x60x4", "S235"])
|
|
43
|
+
buf = io.BytesIO()
|
|
44
|
+
wb.save(buf)
|
|
45
|
+
return buf.getvalue()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _email() -> bytes:
|
|
49
|
+
msg = EmailMessage()
|
|
50
|
+
msg["Subject"] = "Demande de prix"
|
|
51
|
+
msg["From"] = "achat@client.example"
|
|
52
|
+
msg.set_content("Merci de chiffrer 1200 ml de tube acier E24.")
|
|
53
|
+
return bytes(msg)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _MockOCR:
|
|
57
|
+
name = "mistral"
|
|
58
|
+
|
|
59
|
+
def available(self) -> bool:
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
def ocr(self, content: bytes, mime: str):
|
|
63
|
+
return "# (OCR) FACTURE\n\nTotal 5040 EUR", [SourceRef(page=1)]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ── tests ────────────────────────────────────────────────────────────────────
|
|
67
|
+
def test_native_pdf_uses_text_layer_not_ocr():
|
|
68
|
+
called = {"ocr": False}
|
|
69
|
+
|
|
70
|
+
class _Spy(_MockOCR):
|
|
71
|
+
def ocr(self, content, mime):
|
|
72
|
+
called["ocr"] = True
|
|
73
|
+
return "should-not-be-used", []
|
|
74
|
+
|
|
75
|
+
pc = prepare(_native_pdf(), PDF, "offer.pdf", ocr=_Spy())
|
|
76
|
+
assert pc.provider == "pdf_text"
|
|
77
|
+
assert called["ocr"] is False
|
|
78
|
+
assert "ACME" in pc.markdown
|
|
79
|
+
assert pc.provenance and pc.provenance[0].page == 1
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_scanned_pdf_routes_to_ocr():
|
|
83
|
+
pc = prepare(_scanned_pdf(), PDF, "invoice.pdf", ocr=_MockOCR())
|
|
84
|
+
assert pc.provider == "mistral_ocr"
|
|
85
|
+
assert "5040" in pc.markdown
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_scanned_pdf_without_ocr_degrades_gracefully():
|
|
89
|
+
pc = prepare(_scanned_pdf(), PDF, "invoice.pdf", ocr=None)
|
|
90
|
+
assert pc.provider == "none"
|
|
91
|
+
assert "help" in pc.meta
|
|
92
|
+
assert pc.markdown == ""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_xlsx_emits_markdown_table_and_structured_rows_with_provenance():
|
|
96
|
+
pc = prepare(_catalogue_xlsx(), XLSX, "catalogue.xlsx")
|
|
97
|
+
assert pc.provider == "tabular"
|
|
98
|
+
assert "| Code | Designation | Grade |" in pc.markdown
|
|
99
|
+
assert len(pc.rows) == 2
|
|
100
|
+
first = pc.rows[0]
|
|
101
|
+
assert first.cells["Code"] == "ST-E24-5050"
|
|
102
|
+
assert first.cells["Grade"] == "E24"
|
|
103
|
+
assert first.source.sheet == "Catalogue"
|
|
104
|
+
assert first.source.row == 2
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_csv_emits_rows_with_row_provenance():
|
|
108
|
+
csv_bytes = b"produit,quantite\nTube E24,1200\nEPI,30\n"
|
|
109
|
+
pc = prepare(csv_bytes, "text/csv", "req.csv")
|
|
110
|
+
assert pc.provider == "tabular"
|
|
111
|
+
assert len(pc.rows) == 2
|
|
112
|
+
assert pc.rows[0].cells == {"produit": "Tube E24", "quantite": "1200"}
|
|
113
|
+
assert pc.rows[0].source.row == 2
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_email_decodes_body_and_keeps_subject():
|
|
117
|
+
pc = prepare(_email(), "message/rfc822", "req.eml")
|
|
118
|
+
assert pc.provider == "text"
|
|
119
|
+
assert pc.meta["subject"] == "Demande de prix"
|
|
120
|
+
assert "tube acier E24" in pc.markdown
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_binary_masquerading_as_text_is_rejected():
|
|
124
|
+
pc = prepare(b"\x89PNG\r\n\x00\x00garbage", "text/plain", "fake.txt")
|
|
125
|
+
assert pc.provider == "none"
|
|
126
|
+
assert "help" in pc.meta
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_plain_text_multi_encoding():
|
|
130
|
+
# latin-1-encodable accents (em-dash is not latin-1) — exercises the fallback decode
|
|
131
|
+
pc = prepare("café déjà vu à Genève".encode("latin-1"), "text/plain", "note.txt")
|
|
132
|
+
assert pc.provider == "text"
|
|
133
|
+
assert "café" in pc.markdown
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_markdown_truncated_to_max_chars():
|
|
137
|
+
big = ("# title\n" + "x" * 20000).encode()
|
|
138
|
+
pc = prepare(big, "text/markdown", "big.md", max_chars=100)
|
|
139
|
+
assert pc.truncated is True
|
|
140
|
+
assert len(pc.markdown) == 100
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_prepare_never_raises_returns_prepared_content():
|
|
144
|
+
pc = prepare(b"", "application/octet-stream", "empty.bin")
|
|
145
|
+
assert isinstance(pc, PreparedContent)
|
|
146
|
+
assert pc.provider == "none"
|