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.
@@ -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,6 @@
1
+ """OCR providers — the pluggable, engine-agnostic seam for scanned documents."""
2
+ from __future__ import annotations
3
+
4
+ from .ocr import DoclingOCRProvider, MistralOCRProvider, OCRProvider
5
+
6
+ __all__ = ["OCRProvider", "MistralOCRProvider", "DoclingOCRProvider"]
@@ -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)
@@ -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"