Soumik-404 commited on
Commit
abcd0c2
·
0 Parent(s):
.gitignore ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ dist/
13
+ downloads/
14
+ develop-eggs/
15
+ .eggs/
16
+ lib/
17
+ lib64/
18
+ parts/
19
+ sdist/
20
+ var/
21
+ wheels/
22
+ share/python-wheels/
23
+ *.egg-info/
24
+ .installed.cfg
25
+ *.egg
26
+
27
+ # Virtual environments
28
+ .env
29
+ .venv
30
+ venv/
31
+ ENV/
32
+ env/
33
+ env.bak/
34
+ venv.bak/
35
+
36
+ # FastAPI / Uvicorn
37
+ *.log
38
+ uvicorn.log
39
+
40
+ # Environment variables
41
+ .env.*
42
+ !.env.example
43
+
44
+ # IDEs and editors
45
+ .vscode/
46
+ .idea/
47
+ *.swp
48
+ *.swo
49
+
50
+ # OS files
51
+ .DS_Store
52
+ Thumbs.db
53
+
54
+ # Testing
55
+ .pytest_cache/
56
+ .coverage
57
+ coverage.xml
58
+ htmlcov/
59
+ .tox/
60
+ .nox/
61
+
62
+ # Type checking
63
+ .mypy_cache/
64
+ .pyre/
65
+ .pytype/
66
+
67
+ # Ruff / Lint
68
+ .ruff_cache/
69
+
70
+ # Jupyter Notebook
71
+ .ipynb_checkpoints/
72
+
73
+ # PyInstaller
74
+ *.manifest
75
+ *.spec
76
+
77
+ # SQLAlchemy / SQLite
78
+ *.db
79
+ *.sqlite3
80
+
81
+ # Logs
82
+ logs/
83
+ *.log
84
+
85
+ # Cache
86
+ .cache/
87
+
88
+ # Temporary files
89
+ tmp/
90
+ temp/
91
+
92
+ # Docker
93
+ docker-compose.override.yml
94
+
95
+ # Node (if frontend exists)
96
+ node_modules/
97
+
98
+ # Mac/Linux hidden files
99
+ .*.swp
100
+ *.pid
101
+
102
+ # Secrets
103
+ secrets/
104
+ credentials.json
105
+ service-account.json
106
+
107
+ # Alembic
108
+ alembic/versions/*.pyc
109
+
110
+ # Static build outputs
111
+ staticfiles/
112
+
113
+ # PyCharm
114
+ .idea/
115
+
116
+ # VSCode
117
+ .history/
118
+
119
+ # Poetry
120
+ poetry.lock
121
+
122
+ # Pipenv
123
+ Pipfile.lock
124
+ run_docker.py
125
+ docs
126
+ .claude
127
+ start_docker.txt
Dockerfile ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ─────────────────────────────────────────────────────────────
2
+ # MarkItDown API — Production Dockerfile
3
+ # Port: 7860
4
+ # Uses Microsoft MarkItDown and RapidOCR for document processing.
5
+ # JSON extraction: pandas for CSV/XLS/XLSX, spaCy NER for all other formats.
6
+ # ─────────────────────────────────────────────────────────────
7
+
8
+ FROM python:3.12-slim
9
+
10
+ LABEL maintainer="MarkItDown API"
11
+ LABEL description="Document-to-Markdown API with Microsoft MarkItDown and RapidOCR"
12
+ LABEL version="2.2.0"
13
+
14
+ # ── System dependencies ───────────────────────────────────────
15
+ RUN apt-get update && apt-get install -y --no-install-recommends \
16
+ curl \
17
+ ffmpeg \
18
+ libmagic1 \
19
+ && rm -rf /var/lib/apt/lists/*
20
+
21
+ # ── Non-root user ─────────────────────────────────────────────
22
+ RUN groupadd --gid 1000 appuser && \
23
+ useradd --uid 1000 --gid appuser --shell /bin/bash --create-home appuser
24
+
25
+ WORKDIR /app
26
+
27
+ # ── Python base dependencies ──────────────────────────────────
28
+ COPY requirements.txt .
29
+ RUN pip install --no-cache-dir --upgrade pip && \
30
+ pip install --no-cache-dir -r requirements.txt && \
31
+ python -m spacy download en_core_web_sm
32
+
33
+ # ── Application code ──────────────────────────────────────────
34
+ COPY --chown=appuser:appuser . .
35
+
36
+ # ── Ensure startup script exists and is executable ────────────
37
+ RUN test -f /app/start.sh || (echo "ERROR: start.sh not found" && exit 1) && \
38
+ chmod +x /app/start.sh
39
+
40
+ # ── Create necessary directories with proper permissions ───────
41
+ RUN mkdir -p /app/logs && \
42
+ chown -R appuser:appuser /app/logs
43
+
44
+ USER appuser
45
+
46
+ ENV PYTHONPATH=/app
47
+ ENV PYTHONUNBUFFERED=1
48
+
49
+ EXPOSE 7860
50
+
51
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
52
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/health')" || exit 1
53
+
54
+ CMD ["/bin/bash", "/app/start.sh"]
README.md ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: LLM Ready Data API — v2.1.0
3
+ emoji: ⚡
4
+ colorFrom: green
5
+ colorTo: purple
6
+ sdk: docker
7
+ pinned: false
8
+ app_port: 7860
9
+ ---
__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from .core import ConversionError, ConversionResult, DocumentConverter, SUPPORTED_EXTENSIONS
2
+
3
+ __all__ = [
4
+ "ConversionError",
5
+ "ConversionResult",
6
+ "DocumentConverter",
7
+ "SUPPORTED_EXTENSIONS",
8
+ ]
api/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .server import app, run_server
2
+
3
+ __all__ = ["app", "run_server"]
api/server.py ADDED
@@ -0,0 +1,675 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MarkItDown API — FastAPI server.
3
+
4
+ This module defines the FastAPI application, all request/response models,
5
+ route handlers, and the application lifespan. There is no browser-facing UI;
6
+ the application is a pure REST API intended for programmatic consumption.
7
+
8
+ Routes
9
+ ------
10
+ POST /convert/file Convert an uploaded file to Markdown.
11
+ POST /convert/url Convert a public URL to Markdown.
12
+ POST /batch/files Convert up to 10 files in a single request.
13
+ POST /batch/urls Convert up to 20 URLs in a single request.
14
+ GET /health Liveness check returning uptime and version.
15
+ GET /info Server metadata (version, platform, limits).
16
+ GET /formats Supported file extensions grouped by category.
17
+ GET /spacy-labels Available spaCy NER labels for field extraction.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import concurrent.futures
24
+ import datetime
25
+ import os
26
+ import threading
27
+ import time
28
+ import urllib.request
29
+ from contextlib import asynccontextmanager
30
+ from datetime import datetime, timezone
31
+ from pathlib import Path
32
+ from typing import Annotated, Any, Dict, List, Optional
33
+ from urllib.parse import urlparse
34
+
35
+ import httpx
36
+ from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
37
+ from fastapi.middleware.cors import CORSMiddleware
38
+ from fastapi.middleware.gzip import GZipMiddleware
39
+ from fastapi.responses import PlainTextResponse
40
+ from pydantic import BaseModel, field_validator
41
+
42
+ from core import ConversionError, ConversionResult, DocumentConverter, SUPPORTED_EXTENSIONS
43
+ from extraction.generic_json_extractor import extract
44
+ from logger import get_logger
45
+
46
+ logger = get_logger(__name__)
47
+
48
+ _START_TIME = time.time()
49
+
50
+ # Maximum accepted upload size (100 MB).
51
+ MAX_UPLOAD_BYTES = 100 * 1024 * 1024
52
+
53
+ # Thread pool for CPU-bound conversion work running alongside the async event loop.
54
+ MAX_WORKERS = min(32, (os.cpu_count() or 1) + 4)
55
+ _thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=MAX_WORKERS)
56
+
57
+ _converter = DocumentConverter()
58
+
59
+ logger.info("Thread pool initialised with %d workers", MAX_WORKERS)
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Self-ping
64
+ # ---------------------------------------------------------------------------
65
+
66
+ PING_URL = os.environ.get("PING_URL", "http://localhost:7860/health")
67
+ PING_INTERVAL_SECONDS = 30 * 60 # 30 minutes
68
+
69
+
70
+ def _ping_once() -> None:
71
+ """Send a single HTTP GET to PING_URL and log the outcome."""
72
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
73
+ try:
74
+ with urllib.request.urlopen(PING_URL, timeout=10) as resp:
75
+ logger.info("self_ping | status=%d | url=%s | ts=%s", resp.status, PING_URL, ts)
76
+ except Exception as exc:
77
+ logger.warning("self_ping | failed | url=%s | error=%s | ts=%s", PING_URL, exc, ts)
78
+
79
+
80
+ def _ping_loop() -> None:
81
+ """Background loop: sleep PING_INTERVAL_SECONDS, ping, repeat."""
82
+ logger.info("self_ping | scheduler started | interval_minutes=30 | url=%s", PING_URL)
83
+ while True:
84
+ time.sleep(PING_INTERVAL_SECONDS)
85
+ _ping_once()
86
+
87
+
88
+ def _start_ping_scheduler() -> None:
89
+ """Start the self-ping daemon thread. Called once from lifespan startup."""
90
+ thread = threading.Thread(target=_ping_loop, name="self-ping", daemon=True)
91
+ thread.start()
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Lifespan
96
+ # ---------------------------------------------------------------------------
97
+
98
+ @asynccontextmanager
99
+ async def lifespan(app: FastAPI):
100
+ """Application lifespan handler — runs startup and shutdown logic."""
101
+ logger.info(
102
+ "MarkItDown API starting | version=2.1.0 | host=0.0.0.0:7860 | started_at=%s",
103
+ datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"),
104
+ )
105
+ _start_ping_scheduler()
106
+ yield
107
+ logger.info("MarkItDown API shutting down")
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Application
112
+ # ---------------------------------------------------------------------------
113
+
114
+ app = FastAPI(
115
+ title="MarkItDown API",
116
+ description=(
117
+ "Document-to-Markdown conversion API powered by Microsoft MarkItDown "
118
+ "and RapidOCR. Accepts file uploads and public URLs; returns structured "
119
+ "Markdown with optional JSON field extraction."
120
+ ),
121
+ version="2.1.0",
122
+ docs_url="/docs",
123
+ redoc_url="/redoc",
124
+ openapi_tags=[
125
+ {"name": "Convert", "description": "Single-file or single-URL conversion"},
126
+ {"name": "Batch", "description": "Bulk conversion — up to 10 files or 20 URLs"},
127
+ {"name": "System", "description": "Health, server info, and supported formats"},
128
+ ],
129
+ lifespan=lifespan,
130
+ )
131
+
132
+ app.add_middleware(GZipMiddleware, minimum_size=1000)
133
+ app.add_middleware(
134
+ CORSMiddleware,
135
+ allow_origins=["*"],
136
+ allow_methods=["*"],
137
+ allow_headers=["*"],
138
+ )
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # Request / Response models
143
+ # ---------------------------------------------------------------------------
144
+
145
+ class ConversionMetadata(BaseModel):
146
+ """File-level statistics attached to every successful conversion."""
147
+
148
+ source: str
149
+ char_count: int
150
+ word_count: int
151
+ line_count: int
152
+ file_size_bytes: int
153
+ mime_type: str
154
+ content_hash: str
155
+ token_estimate: int
156
+
157
+
158
+ class ConversionResponse(BaseModel):
159
+ """Standard response envelope for single-item conversion endpoints."""
160
+
161
+ success: bool
162
+ time_ms: float
163
+ content: str
164
+ return_json: bool = False
165
+ json_content: Optional[Any] = None
166
+ metadata: Optional[ConversionMetadata] = None
167
+ error_message: Optional[str] = None
168
+
169
+
170
+ class UrlRequest(BaseModel):
171
+ """Request body for /convert/url."""
172
+
173
+ url: str
174
+ return_json: bool = False
175
+ mappings: Optional[Dict[str, Dict[str, Any]]] = None
176
+
177
+ model_config = {"populate_by_name": True}
178
+
179
+ @field_validator("url")
180
+ @classmethod
181
+ def validate_scheme(cls, v: str) -> str:
182
+ if not v.startswith(("http://", "https://")):
183
+ raise ValueError("Only http/https URLs are supported.")
184
+ return v
185
+
186
+
187
+ class BatchUrlRequest(BaseModel):
188
+ """Request body for /batch/urls."""
189
+
190
+ urls: List[str]
191
+
192
+ @field_validator("urls")
193
+ @classmethod
194
+ def validate_urls(cls, v: List[str]) -> List[str]:
195
+ for url in v:
196
+ if not url.startswith(("http://", "https://")):
197
+ raise ValueError(f"Invalid URL scheme: {url}")
198
+ if len(v) > 20:
199
+ raise ValueError("Maximum 20 URLs per batch request.")
200
+ return v
201
+
202
+
203
+ class BatchFileResult(BaseModel):
204
+ """Per-item result within a batch response."""
205
+
206
+ filename: str
207
+ success: bool
208
+ time_ms: float
209
+ content: Optional[str] = None
210
+ error: Optional[str] = None
211
+ metadata: Optional[ConversionMetadata] = None
212
+
213
+
214
+ class BatchResponse(BaseModel):
215
+ """Aggregate response for batch endpoints."""
216
+
217
+ total: int
218
+ succeeded: int
219
+ failed: int
220
+ total_time_ms: float
221
+ results: List[BatchFileResult]
222
+
223
+
224
+ # ---------------------------------------------------------------------------
225
+ # Internal helpers
226
+ # ---------------------------------------------------------------------------
227
+
228
+ def _build_metadata(result: ConversionResult) -> ConversionMetadata:
229
+ """Map a ConversionResult to its API metadata representation."""
230
+ return ConversionMetadata(
231
+ source=result.source,
232
+ char_count=result.char_count,
233
+ word_count=result.word_count,
234
+ line_count=result.line_count,
235
+ file_size_bytes=result.file_size_bytes,
236
+ mime_type=result.mime_type,
237
+ content_hash=result.content_hash,
238
+ token_estimate=result.token_estimate,
239
+ )
240
+
241
+
242
+ async def _build_response(
243
+ result: ConversionResult,
244
+ *,
245
+ return_json: bool = False,
246
+ filename: Optional[str] = None,
247
+ raw_data: Optional[bytes] = None,
248
+ mappings: Optional[Dict[str, Dict[str, Any]]] = None,
249
+ ) -> ConversionResponse:
250
+ """Construct a ConversionResponse, optionally running JSON extraction."""
251
+ json_content: Optional[Any] = None
252
+ error_message: Optional[str] = None
253
+
254
+ if return_json and filename:
255
+ loop = asyncio.get_running_loop()
256
+ json_result = await loop.run_in_executor(
257
+ _thread_pool, extract, filename, result.markdown, mappings, raw_data
258
+ )
259
+ if "error" in json_result:
260
+ error_message = json_result["error"]
261
+ else:
262
+ json_content = json_result
263
+
264
+ return ConversionResponse(
265
+ success=True,
266
+ time_ms=round(result.duration_ms, 3),
267
+ content=result.markdown,
268
+ return_json=return_json,
269
+ json_content=json_content,
270
+ metadata=_build_metadata(result),
271
+ error_message=error_message,
272
+ )
273
+
274
+
275
+ def _raise_for_error(outcome: ConversionError) -> None:
276
+ """Translate a ConversionError into an appropriate HTTPException."""
277
+ status_map = {
278
+ "FileNotFoundError": status.HTTP_404_NOT_FOUND,
279
+ "ValueError": status.HTTP_422_UNPROCESSABLE_ENTITY,
280
+ "PermissionError": status.HTTP_403_FORBIDDEN,
281
+ }
282
+ code = status_map.get(outcome.error_type, status.HTTP_500_INTERNAL_SERVER_ERROR)
283
+ raise HTTPException(
284
+ status_code=code,
285
+ detail={
286
+ "success": False,
287
+ "error_type": outcome.error_type,
288
+ "message": outcome.message,
289
+ "time_ms": round(outcome.duration_ms, 3),
290
+ },
291
+ )
292
+
293
+
294
+ def _batch_result_from_error(name: str, err: ConversionError) -> BatchFileResult:
295
+ return BatchFileResult(
296
+ filename=name,
297
+ success=False,
298
+ time_ms=round(err.duration_ms, 3),
299
+ error=err.message,
300
+ )
301
+
302
+
303
+ def _batch_result_from_ok(result: ConversionResult) -> BatchFileResult:
304
+ return BatchFileResult(
305
+ filename=result.source,
306
+ success=True,
307
+ time_ms=round(result.duration_ms, 3),
308
+ content=result.markdown,
309
+ metadata=_build_metadata(result),
310
+ )
311
+
312
+
313
+ # ---------------------------------------------------------------------------
314
+ # System endpoints
315
+ # ---------------------------------------------------------------------------
316
+
317
+ @app.get("/health", tags=["System"], summary="Liveness check")
318
+ async def health():
319
+ """Return server status and uptime in seconds."""
320
+ return {
321
+ "success": True,
322
+ "status": "ok",
323
+ "version": "2.1.0",
324
+ "uptime_seconds": round(time.time() - _START_TIME, 2),
325
+ "timestamp": datetime.now(timezone.utc).isoformat(),
326
+ }
327
+
328
+
329
+ @app.get("/info", tags=["System"], summary="Server and environment information")
330
+ async def info():
331
+ """Return application version, platform details, and operational limits."""
332
+ import platform
333
+
334
+ return {
335
+ "success": True,
336
+ "app": "MarkItDown API",
337
+ "version": "2.1.0",
338
+ "python_version": platform.python_version(),
339
+ "platform": platform.system(),
340
+ "uptime_seconds": round(time.time() - _START_TIME, 2),
341
+ "max_upload_mb": MAX_UPLOAD_BYTES // (1024 * 1024),
342
+ "supported_extensions": len(SUPPORTED_EXTENSIONS),
343
+ "timestamp": datetime.now(timezone.utc).isoformat(),
344
+ }
345
+
346
+
347
+ @app.get("/formats", tags=["System"], summary="Supported file formats by category")
348
+ async def list_formats():
349
+ """Return all supported file extensions, grouped by document category."""
350
+ by_category = {
351
+ "documents": [e for e in SUPPORTED_EXTENSIONS if e in {".pdf", ".docx", ".doc", ".epub"}],
352
+ "office": [e for e in SUPPORTED_EXTENSIONS if e in {".pptx", ".ppt", ".xlsx", ".xls"}],
353
+ "data": [e for e in SUPPORTED_EXTENSIONS if e in {".csv", ".json", ".xml"}],
354
+ "web": [e for e in SUPPORTED_EXTENSIONS if e in {".html", ".htm"}],
355
+ "text": [e for e in SUPPORTED_EXTENSIONS if e in {".txt", ".md", ".rst"}],
356
+ "images": [e for e in SUPPORTED_EXTENSIONS if e in {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff"}],
357
+ "audio": [e for e in SUPPORTED_EXTENSIONS if e in {".mp3", ".wav", ".ogg", ".flac"}],
358
+ "archives": [e for e in SUPPORTED_EXTENSIONS if e in {".zip"}],
359
+ }
360
+ return {
361
+ "success": True,
362
+ "total_count": len(SUPPORTED_EXTENSIONS),
363
+ "all_extensions": sorted(SUPPORTED_EXTENSIONS),
364
+ "by_category": {k: sorted(v) for k, v in by_category.items()},
365
+ }
366
+
367
+
368
+ @app.get("/spacy-labels", tags=["System"], summary="Available spaCy NER labels for field extraction")
369
+ async def list_spacy_labels():
370
+ """Return spaCy Named Entity Recognition labels available for structured extraction mappings."""
371
+ from extraction.spacy_extractor import VALID_SPACY_LABELS
372
+
373
+ return {
374
+ "success": True,
375
+ "spacy_labels": VALID_SPACY_LABELS,
376
+ "source_types": {
377
+ "entity": "Extract using spaCy NER labels (ORG, PERSON, DATE, etc.)",
378
+ "regex": "Extract using custom regular expressions",
379
+ "token_attr": "Extract using token attributes (text, pos_, tag_, etc.)",
380
+ },
381
+ "example_mappings": {
382
+ "company": {"source_type": "entity", "label": "ORG"},
383
+ "person": {"source_type": "entity", "label": "PERSON"},
384
+ "date": {"source_type": "entity", "label": "DATE"},
385
+ "money": {"source_type": "entity", "label": "MONEY"},
386
+ "email": {"source_type": "regex", "pattern": r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"},
387
+ "phone": {"source_type": "regex", "pattern": r"\b\d{3}-\d{3}-\d{4}\b"},
388
+ },
389
+ }
390
+
391
+
392
+ # ---------------------------------------------------------------------------
393
+ # Convert endpoints
394
+ # ---------------------------------------------------------------------------
395
+
396
+ @app.post(
397
+ "/convert/file",
398
+ response_model=ConversionResponse,
399
+ tags=["Convert"],
400
+ summary="Convert an uploaded file to Markdown",
401
+ )
402
+ async def convert_file(
403
+ file: Annotated[UploadFile, File(description="File to convert")],
404
+ plain_text: bool = Form(False),
405
+ return_json: bool = Form(False),
406
+ mappings: Optional[str] = Form(
407
+ None,
408
+ description=(
409
+ "JSON string defining spaCy field extraction rules. "
410
+ "Example: {\"company\": {\"source_type\": \"entity\", \"label\": \"ORG\"}}"
411
+ ),
412
+ ),
413
+ ):
414
+ """Convert a single uploaded file to Markdown.
415
+
416
+ Set ``return_json=true`` to also receive structured JSON extraction:
417
+ - CSV / XLS / XLSX files: automatic tabular extraction.
418
+ - All other files: provide ``mappings`` with spaCy extraction rules.
419
+
420
+ On success, ``json_content`` contains the extracted data.
421
+ On extraction failure, ``json_content`` is null and ``error_message`` is populated.
422
+ """
423
+ if file is None:
424
+ raise HTTPException(
425
+ status_code=400,
426
+ detail={"success": False, "message": "No file provided."},
427
+ )
428
+
429
+ parsed_mappings: Optional[Dict[str, Any]] = None
430
+ if mappings:
431
+ import json as _json
432
+ try:
433
+ parsed_mappings = _json.loads(mappings)
434
+ except _json.JSONDecodeError:
435
+ raise HTTPException(
436
+ status_code=400,
437
+ detail={"success": False, "message": "Invalid JSON in mappings parameter."},
438
+ )
439
+
440
+ logger.info("convert_file | filename=%s", file.filename)
441
+
442
+ raw = await file.read()
443
+ if len(raw) > MAX_UPLOAD_BYTES:
444
+ logger.warning("convert_file | file too large | filename=%s | size=%d", file.filename, len(raw))
445
+ raise HTTPException(
446
+ status_code=413,
447
+ detail={"success": False, "message": "File exceeds 100 MB limit."},
448
+ )
449
+
450
+ loop = asyncio.get_running_loop()
451
+ outcome = await loop.run_in_executor(
452
+ _thread_pool, _converter.convert_stream, raw, file.filename or "upload"
453
+ )
454
+
455
+ if isinstance(outcome, ConversionError):
456
+ logger.error("convert_file | conversion failed | filename=%s | error=%s", file.filename, outcome.message)
457
+ _raise_for_error(outcome)
458
+
459
+ logger.info(
460
+ "convert_file | success | filename=%s | chars=%d | time_ms=%.1f",
461
+ file.filename,
462
+ outcome.char_count,
463
+ outcome.duration_ms,
464
+ )
465
+
466
+ if plain_text:
467
+ return PlainTextResponse(outcome.markdown)
468
+
469
+ return await _build_response(
470
+ outcome,
471
+ return_json=return_json,
472
+ filename=file.filename,
473
+ raw_data=raw,
474
+ mappings=parsed_mappings,
475
+ )
476
+
477
+
478
+ @app.post(
479
+ "/convert/url",
480
+ response_model=ConversionResponse,
481
+ tags=["Convert"],
482
+ summary="Convert a public URL to Markdown",
483
+ )
484
+ async def convert_url(body: UrlRequest):
485
+ """Convert a public HTTP/HTTPS URL to Markdown.
486
+
487
+ When ``return_json=true``, the URL content is fetched as raw bytes first
488
+ to enable binary-aware extraction (e.g. Excel files served over HTTP).
489
+ """
490
+ logger.info("convert_url | url=%s", body.url)
491
+ parsed = urlparse(body.url)
492
+ filename = Path(parsed.path).name or "url_content"
493
+
494
+ loop = asyncio.get_running_loop()
495
+
496
+ if body.return_json:
497
+ # Fetch raw bytes so binary formats (XLSX, etc.) can be properly parsed.
498
+ try:
499
+ async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
500
+ resp = await client.get(body.url)
501
+ resp.raise_for_status()
502
+ except httpx.HTTPError as exc:
503
+ logger.error("convert_url | fetch failed | url=%s | error=%s", body.url, exc)
504
+ raise HTTPException(
505
+ status_code=400,
506
+ detail={"success": False, "message": f"Failed to fetch URL: {exc}"},
507
+ )
508
+
509
+ raw_data = resp.content
510
+ if len(raw_data) > MAX_UPLOAD_BYTES:
511
+ raise HTTPException(
512
+ status_code=413,
513
+ detail={"success": False, "message": "File exceeds 100 MB limit."},
514
+ )
515
+
516
+ outcome = await loop.run_in_executor(
517
+ _thread_pool, _converter.convert_stream, raw_data, filename
518
+ )
519
+ if isinstance(outcome, ConversionError):
520
+ logger.error("convert_url | conversion failed | url=%s | error=%s", body.url, outcome.message)
521
+ _raise_for_error(outcome)
522
+
523
+ logger.info(
524
+ "convert_url | success | url=%s | chars=%d | time_ms=%.1f",
525
+ body.url, outcome.char_count, outcome.duration_ms,
526
+ )
527
+ return await _build_response(
528
+ outcome,
529
+ return_json=body.return_json,
530
+ filename=filename,
531
+ raw_data=raw_data,
532
+ mappings=body.mappings,
533
+ )
534
+
535
+ # Standard conversion without binary fetch.
536
+ outcome = await loop.run_in_executor(_thread_pool, _converter.convert_url, body.url)
537
+ if isinstance(outcome, ConversionError):
538
+ logger.error("convert_url | conversion failed | url=%s | error=%s", body.url, outcome.message)
539
+ _raise_for_error(outcome)
540
+
541
+ logger.info(
542
+ "convert_url | success | url=%s | chars=%d | time_ms=%.1f",
543
+ body.url, outcome.char_count, outcome.duration_ms,
544
+ )
545
+ return await _build_response(
546
+ outcome,
547
+ return_json=body.return_json,
548
+ filename=filename,
549
+ mappings=body.mappings,
550
+ )
551
+
552
+
553
+ # ---------------------------------------------------------------------------
554
+ # Batch endpoints
555
+ # ---------------------------------------------------------------------------
556
+
557
+ @app.post(
558
+ "/batch/files",
559
+ response_model=BatchResponse,
560
+ tags=["Batch"],
561
+ summary="Convert multiple files (up to 10)",
562
+ )
563
+ async def batch_files(
564
+ files: Annotated[List[UploadFile], File(description="Files to convert — maximum 10")],
565
+ ):
566
+ """Convert up to 10 uploaded files in a single request.
567
+
568
+ Files are processed concurrently. Per-item results include success/error
569
+ details, timing, and content metadata.
570
+ """
571
+ if not files:
572
+ raise HTTPException(
573
+ status_code=400,
574
+ detail={"success": False, "message": "No files provided."},
575
+ )
576
+ if len(files) > 10:
577
+ raise HTTPException(
578
+ status_code=400,
579
+ detail={"success": False, "message": "Maximum 10 files per batch."},
580
+ )
581
+
582
+ batch_start = time.perf_counter()
583
+ logger.info("batch_files | count=%d", len(files))
584
+
585
+ async def _process_file(f: UploadFile) -> BatchFileResult:
586
+ if f is None:
587
+ return BatchFileResult(filename="unknown", success=False, time_ms=0, error="File object is None.")
588
+
589
+ raw = await f.read()
590
+ if len(raw) > MAX_UPLOAD_BYTES:
591
+ return BatchFileResult(
592
+ filename=f.filename or "unknown",
593
+ success=False,
594
+ time_ms=0,
595
+ error="File exceeds 100 MB limit.",
596
+ )
597
+
598
+ loop = asyncio.get_running_loop()
599
+ outcome = await loop.run_in_executor(
600
+ _thread_pool, _converter.convert_stream, raw, f.filename or "upload"
601
+ )
602
+ return (
603
+ _batch_result_from_error(f.filename or "unknown", outcome)
604
+ if isinstance(outcome, ConversionError)
605
+ else _batch_result_from_ok(outcome)
606
+ )
607
+
608
+ results = await asyncio.gather(*[_process_file(f) for f in files])
609
+
610
+ total_ms = round((time.perf_counter() - batch_start) * 1000, 3)
611
+ succeeded = sum(1 for r in results if r.success)
612
+ logger.info("batch_files | done | succeeded=%d | failed=%d | total_ms=%.1f", succeeded, len(results) - succeeded, total_ms)
613
+
614
+ return BatchResponse(
615
+ total=len(results),
616
+ succeeded=succeeded,
617
+ failed=len(results) - succeeded,
618
+ total_time_ms=total_ms,
619
+ results=results,
620
+ )
621
+
622
+
623
+ @app.post(
624
+ "/batch/urls",
625
+ response_model=BatchResponse,
626
+ tags=["Batch"],
627
+ summary="Convert multiple URLs (up to 20)",
628
+ )
629
+ async def batch_urls(body: BatchUrlRequest):
630
+ """Convert up to 20 public URLs in a single request.
631
+
632
+ URLs are processed concurrently. Per-item results include success/error
633
+ details, timing, and content metadata.
634
+ """
635
+ batch_start = time.perf_counter()
636
+ logger.info("batch_urls | count=%d", len(body.urls))
637
+
638
+ async def _process_url(url: str) -> BatchFileResult:
639
+ loop = asyncio.get_running_loop()
640
+ outcome = await loop.run_in_executor(_thread_pool, _converter.convert_url, url)
641
+ return (
642
+ _batch_result_from_error(url, outcome)
643
+ if isinstance(outcome, ConversionError)
644
+ else _batch_result_from_ok(outcome)
645
+ )
646
+
647
+ results = await asyncio.gather(*[_process_url(url) for url in body.urls])
648
+
649
+ total_ms = round((time.perf_counter() - batch_start) * 1000, 3)
650
+ succeeded = sum(1 for r in results if r.success)
651
+ logger.info("batch_urls | done | succeeded=%d | failed=%d | total_ms=%.1f", succeeded, len(results) - succeeded, total_ms)
652
+
653
+ return BatchResponse(
654
+ total=len(results),
655
+ succeeded=succeeded,
656
+ failed=len(results) - succeeded,
657
+ total_time_ms=total_ms,
658
+ results=results,
659
+ )
660
+
661
+
662
+ # ---------------------------------------------------------------------------
663
+ # Server runner (used when invoking this module directly)
664
+ # ---------------------------------------------------------------------------
665
+
666
+ def run_server(host: str = "0.0.0.0", port: int = 7860, reload: bool = False) -> None:
667
+ """Start the uvicorn server programmatically."""
668
+ import uvicorn
669
+
670
+ uvicorn.run(
671
+ "api.server:app",
672
+ host=host,
673
+ port=port,
674
+ reload=reload,
675
+ )
core/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .converter import ConversionError, ConversionResult, DocumentConverter, SUPPORTED_EXTENSIONS
2
+ from .batch import BatchProcessor, BatchReport
3
+ from .output import OutputWriter
4
+ from .ocr_engine import ocr_image
5
+
6
+ __all__ = [
7
+ "ConversionError",
8
+ "ConversionResult",
9
+ "DocumentConverter",
10
+ "SUPPORTED_EXTENSIONS",
11
+ "BatchProcessor",
12
+ "BatchReport",
13
+ "OutputWriter",
14
+ "ocr_image",
15
+ ]
core/batch.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Batch processing utilities for the MarkItDown API.
3
+
4
+ Provides BatchProcessor for converting multiple files concurrently using
5
+ a shared DocumentConverter instance, and BatchReport for aggregating results.
6
+
7
+ These classes are used internally by the CLI and server batch endpoints.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import concurrent.futures
13
+ import os
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Callable, Optional, Sequence
17
+
18
+ from .converter import ConversionError, ConversionResult, DocumentConverter, SUPPORTED_EXTENSIONS
19
+ from logger import get_logger
20
+
21
+ logger = get_logger(__name__)
22
+
23
+
24
+ @dataclass
25
+ class BatchReport:
26
+ """Aggregated results from a batch file conversion run."""
27
+
28
+ total: int
29
+ succeeded: int
30
+ failed: int
31
+ results: list[ConversionResult]
32
+ errors: list[ConversionError]
33
+ total_chars: int
34
+ total_words: int
35
+ total_duration_ms: float
36
+
37
+ @property
38
+ def success_rate(self) -> float:
39
+ """Percentage of files converted successfully."""
40
+ return (self.succeeded / self.total * 100) if self.total else 0.0
41
+
42
+
43
+ class BatchProcessor:
44
+ """Convert multiple files concurrently using a thread pool.
45
+
46
+ Parameters
47
+ ----------
48
+ converter:
49
+ Shared DocumentConverter instance.
50
+ max_workers:
51
+ Number of threads in the pool. Defaults to min(8, cpu_count + 4).
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ converter: DocumentConverter,
57
+ max_workers: int = min(8, (os.cpu_count() or 1) + 4),
58
+ ) -> None:
59
+ self._converter = converter
60
+ self._max_workers = max_workers
61
+
62
+ def process_files(
63
+ self,
64
+ paths: Sequence[str | Path],
65
+ progress_callback: Optional[Callable[[int, int, str], None]] = None,
66
+ ) -> BatchReport:
67
+ """Convert all files in *paths* and return a BatchReport.
68
+
69
+ Parameters
70
+ ----------
71
+ paths:
72
+ Iterable of file paths to convert.
73
+ progress_callback:
74
+ Optional callable invoked after each file completes.
75
+ Receives ``(completed_count, total_count, source_path)``.
76
+ """
77
+ results: list[ConversionResult] = []
78
+ errors: list[ConversionError] = []
79
+ total = len(paths)
80
+
81
+ with concurrent.futures.ThreadPoolExecutor(max_workers=self._max_workers) as executor:
82
+ future_to_path = {
83
+ executor.submit(self._converter.convert_file, p): p for p in paths
84
+ }
85
+ completed = 0
86
+ for future in concurrent.futures.as_completed(future_to_path):
87
+ completed += 1
88
+ outcome = future.result()
89
+ source = str(future_to_path[future])
90
+ if isinstance(outcome, ConversionResult):
91
+ results.append(outcome)
92
+ else:
93
+ errors.append(outcome)
94
+ if progress_callback:
95
+ progress_callback(completed, total, source)
96
+
97
+ logger.info(
98
+ "batch_processor | done | total=%d | succeeded=%d | failed=%d",
99
+ total, len(results), len(errors),
100
+ )
101
+ return BatchReport(
102
+ total=total,
103
+ succeeded=len(results),
104
+ failed=len(errors),
105
+ results=results,
106
+ errors=errors,
107
+ total_chars=sum(r.char_count for r in results),
108
+ total_words=sum(r.word_count for r in results),
109
+ total_duration_ms=sum(r.duration_ms for r in results),
110
+ )
111
+
112
+ def discover_files(
113
+ self,
114
+ directory: str | Path,
115
+ recursive: bool = True,
116
+ extensions: Optional[set[str]] = None,
117
+ ) -> list[Path]:
118
+ """Return all convertible files under *directory*.
119
+
120
+ Parameters
121
+ ----------
122
+ directory:
123
+ Root directory to scan.
124
+ recursive:
125
+ When True, scan subdirectories as well.
126
+ extensions:
127
+ Set of extensions to include. Defaults to SUPPORTED_EXTENSIONS.
128
+ """
129
+ root = Path(directory).resolve()
130
+ exts = extensions or SUPPORTED_EXTENSIONS
131
+ glob_pattern = "**/*" if recursive else "*"
132
+ return [
133
+ p for p in root.glob(glob_pattern)
134
+ if p.is_file() and p.suffix.lower() in exts
135
+ ]
core/converter.py ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Document converter for the MarkItDown API.
3
+
4
+ Wraps Microsoft MarkItDown and RapidOCR to provide a unified conversion
5
+ interface that accepts file paths, raw byte streams, and public URLs.
6
+
7
+ Supported extensions are declared in SUPPORTED_EXTENSIONS and imported by
8
+ the API layer for format listing and validation.
9
+
10
+ Public classes
11
+ --------------
12
+ ConversionResult
13
+ Immutable dataclass holding the converted Markdown and file statistics.
14
+
15
+ ConversionError
16
+ Immutable dataclass holding error details when conversion fails.
17
+
18
+ DocumentConverter
19
+ Main converter class. All convert_* methods return either a
20
+ ConversionResult or a ConversionError — they do not raise.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import mimetypes
27
+ import time
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+ from typing import Optional
31
+ from urllib.parse import urlparse
32
+
33
+ from markitdown import MarkItDown
34
+
35
+ from .ocr_engine import ocr_image, ocr_pdf
36
+ from logger import get_logger
37
+
38
+ logger = get_logger(__name__)
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Supported formats
43
+ # ---------------------------------------------------------------------------
44
+
45
+ SUPPORTED_EXTENSIONS = {
46
+ ".pdf", ".docx", ".doc", ".pptx", ".ppt",
47
+ ".xlsx", ".xls", ".csv", ".json", ".xml",
48
+ ".html", ".htm", ".txt", ".md", ".rst",
49
+ ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff",
50
+ ".mp3", ".wav", ".ogg", ".flac",
51
+ ".zip", ".epub",
52
+ }
53
+
54
+ # Extensions that route through RapidOCR rather than MarkItDown.
55
+ IMAGE_EXTENSIONS = {
56
+ ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff",
57
+ }
58
+
59
+ IMAGE_MIME_PREFIXES = {"image/"}
60
+
61
+
62
+ def _is_image(ext: str, mime: str) -> bool:
63
+ """Return True when the input should be routed through RapidOCR."""
64
+ return ext.lower() in IMAGE_EXTENSIONS or any(
65
+ mime.startswith(p) for p in IMAGE_MIME_PREFIXES
66
+ )
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Result and error types
71
+ # ---------------------------------------------------------------------------
72
+
73
+ @dataclass(frozen=True)
74
+ class ConversionResult:
75
+ """Successful conversion output."""
76
+
77
+ source: str
78
+ markdown: str
79
+ char_count: int
80
+ word_count: int
81
+ line_count: int
82
+ duration_ms: float
83
+ file_size_bytes: int
84
+ mime_type: str
85
+ content_hash: str
86
+ metadata: dict = field(default_factory=dict)
87
+
88
+ @property
89
+ def token_estimate(self) -> int:
90
+ """Rough LLM token estimate based on word count (4/3 words per token)."""
91
+ return max(1, self.word_count * 4 // 3)
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class ConversionError:
96
+ """Conversion failure details."""
97
+
98
+ source: str
99
+ error_type: str
100
+ message: str
101
+ duration_ms: float
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Converter
106
+ # ---------------------------------------------------------------------------
107
+
108
+ class DocumentConverter:
109
+ """Converts documents from various formats to Markdown.
110
+
111
+ All public methods return either a ConversionResult or a ConversionError
112
+ and never raise exceptions to callers.
113
+ """
114
+
115
+ def __init__(self, enable_plugins: bool = False) -> None:
116
+ self._engine = MarkItDown(enable_plugins=enable_plugins)
117
+
118
+ # ------------------------------------------------------------------
119
+ # Public conversion methods
120
+ # ------------------------------------------------------------------
121
+
122
+ def convert_file(self, path: str | Path) -> ConversionResult | ConversionError:
123
+ """Convert a local file identified by *path*."""
124
+ path = Path(path).resolve()
125
+ start = time.perf_counter()
126
+
127
+ if not path.exists():
128
+ return ConversionError(
129
+ source=str(path),
130
+ error_type="FileNotFoundError",
131
+ message=f"File does not exist: {path}",
132
+ duration_ms=0.0,
133
+ )
134
+
135
+ file_size = path.stat().st_size
136
+ mime_type, _ = mimetypes.guess_type(str(path))
137
+ mime_type = mime_type or "application/octet-stream"
138
+
139
+ try:
140
+ if _is_image(path.suffix, mime_type):
141
+ markdown = ocr_image(str(path))
142
+ else:
143
+ markdown = self._engine.convert(str(path)).text_content
144
+ if not markdown.strip() and path.suffix.lower() == ".pdf":
145
+ logger.info(
146
+ "convert_file | no text from PDF, falling back to OCR | file=%s",
147
+ path.name,
148
+ )
149
+ markdown = ocr_pdf(str(path))
150
+
151
+ elapsed = (time.perf_counter() - start) * 1000
152
+ return self._build_result(str(path), markdown, file_size, mime_type, elapsed)
153
+
154
+ except Exception as exc:
155
+ elapsed = (time.perf_counter() - start) * 1000
156
+ logger.error("convert_file | exception | file=%s | error=%s", path, exc, exc_info=True)
157
+ return ConversionError(
158
+ source=str(path),
159
+ error_type=type(exc).__name__,
160
+ message=str(exc),
161
+ duration_ms=elapsed,
162
+ )
163
+
164
+ def convert_url(self, url: str) -> ConversionResult | ConversionError:
165
+ """Fetch and convert a public HTTP/HTTPS URL."""
166
+ parsed = urlparse(url)
167
+ if parsed.scheme not in {"http", "https"}:
168
+ return ConversionError(
169
+ source=url,
170
+ error_type="ValueError",
171
+ message=f"Unsupported URL scheme: {parsed.scheme!r}",
172
+ duration_ms=0.0,
173
+ )
174
+
175
+ start = time.perf_counter()
176
+ try:
177
+ url_ext = Path(urlparse(url).path).suffix.lower()
178
+ if url_ext in IMAGE_EXTENSIONS:
179
+ markdown = ocr_image(url)
180
+ mime_type = mimetypes.guess_type(url)[0] or "image/jpeg"
181
+ else:
182
+ result = self._engine.convert(url)
183
+ markdown = result.text_content
184
+ mime_type = "text/html"
185
+
186
+ elapsed = (time.perf_counter() - start) * 1000
187
+ return self._build_result(url, markdown, 0, mime_type, elapsed)
188
+
189
+ except Exception as exc:
190
+ elapsed = (time.perf_counter() - start) * 1000
191
+ logger.error("convert_url | exception | url=%s | error=%s", url, exc, exc_info=True)
192
+ return ConversionError(
193
+ source=url,
194
+ error_type=type(exc).__name__,
195
+ message=str(exc),
196
+ duration_ms=elapsed,
197
+ )
198
+
199
+ def convert_stream(self, data: bytes, filename: str) -> ConversionResult | ConversionError:
200
+ """Convert raw bytes identified by *filename* (used for upload payloads)."""
201
+ import io
202
+
203
+ start = time.perf_counter()
204
+ mime_type, _ = mimetypes.guess_type(filename)
205
+ mime_type = mime_type or "application/octet-stream"
206
+ ext = Path(filename).suffix.lower()
207
+
208
+ try:
209
+ if _is_image(ext, mime_type):
210
+ markdown = ocr_image(data)
211
+ else:
212
+ result = self._engine.convert_stream(io.BytesIO(data), file_extension=ext)
213
+ markdown = result.text_content
214
+ if not markdown.strip() and ext == ".pdf":
215
+ logger.info(
216
+ "convert_stream | no text from PDF stream, falling back to OCR | filename=%s",
217
+ filename,
218
+ )
219
+ markdown = ocr_pdf(data)
220
+
221
+ elapsed = (time.perf_counter() - start) * 1000
222
+ return self._build_result(filename, markdown, len(data), mime_type, elapsed)
223
+
224
+ except Exception as exc:
225
+ elapsed = (time.perf_counter() - start) * 1000
226
+ logger.error("convert_stream | exception | filename=%s | error=%s", filename, exc, exc_info=True)
227
+ return ConversionError(
228
+ source=filename,
229
+ error_type=type(exc).__name__,
230
+ message=str(exc),
231
+ duration_ms=elapsed,
232
+ )
233
+
234
+ # ------------------------------------------------------------------
235
+ # Internal helpers
236
+ # ------------------------------------------------------------------
237
+
238
+ @staticmethod
239
+ def _build_result(
240
+ source: str,
241
+ markdown: str,
242
+ file_size: int,
243
+ mime_type: str,
244
+ elapsed: float,
245
+ ) -> ConversionResult:
246
+ lines = markdown.splitlines()
247
+ words = markdown.split()
248
+ content_hash = hashlib.sha256(markdown.encode()).hexdigest()
249
+ return ConversionResult(
250
+ source=source,
251
+ markdown=markdown,
252
+ char_count=len(markdown),
253
+ word_count=len(words),
254
+ line_count=len(lines),
255
+ duration_ms=elapsed,
256
+ file_size_bytes=file_size,
257
+ mime_type=mime_type,
258
+ content_hash=content_hash,
259
+ )
core/ocr_engine.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OCR utilities for the MarkItDown API.
3
+
4
+ Two engines are provided:
5
+
6
+ ocr_image(source)
7
+ RapidOCR singleton for raster images (JPEG, PNG, WEBP, etc.).
8
+ Accepts bytes, a local file path string, an HTTP/HTTPS URL string,
9
+ a numpy.ndarray, or a PIL.Image instance.
10
+
11
+ ocr_pdf(source, dpi=150)
12
+ Scanned-PDF fallback. Renders each page with pypdfium2, then feeds
13
+ the rendered PIL image through ocr_image. Returns all pages joined
14
+ with double newlines.
15
+
16
+ Both functions return a plain string and never raise; errors are logged and
17
+ an empty string is returned on failure.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import io
23
+ import threading
24
+ from typing import Union
25
+ from urllib.parse import urlparse
26
+
27
+ import numpy as np
28
+
29
+ from logger import get_logger
30
+
31
+ logger = get_logger(__name__)
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # RapidOCR singleton
36
+ # ---------------------------------------------------------------------------
37
+
38
+ _lock = threading.Lock()
39
+ _engine = None
40
+
41
+
42
+ def _get_engine():
43
+ """Return the shared RapidOCR instance, initialising it on first call."""
44
+ global _engine
45
+ if _engine is None:
46
+ with _lock:
47
+ if _engine is None:
48
+ from rapidocr_onnxruntime import RapidOCR
49
+ _engine = RapidOCR(
50
+ Det={"use_cuda": False, "use_dml": False},
51
+ Cls={"use_cuda": False, "use_dml": False},
52
+ Rec={"use_cuda": False, "use_dml": False},
53
+ print_verbose=False,
54
+ )
55
+ logger.info("RapidOCR engine initialised")
56
+ return _engine
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Input normalisation
61
+ # ---------------------------------------------------------------------------
62
+
63
+ def _to_numpy(source) -> Union[np.ndarray, str]:
64
+ """Normalise *source* to a numpy array or a local file path string.
65
+
66
+ Accepted input types:
67
+ PIL.Image — converted directly to ndarray.
68
+ bytes — decoded via PIL then converted to ndarray.
69
+ str — HTTP/HTTPS URL fetched then decoded; local paths returned as-is.
70
+ np.ndarray — returned unchanged.
71
+ """
72
+ from PIL import Image
73
+
74
+ def _pil_to_array(img: Image.Image) -> np.ndarray:
75
+ if img.mode not in ("RGB", "L", "RGBA"):
76
+ img = img.convert("RGB")
77
+ return np.array(img)
78
+
79
+ if isinstance(source, Image.Image):
80
+ return _pil_to_array(source)
81
+
82
+ if isinstance(source, (bytes, bytearray)):
83
+ return _pil_to_array(Image.open(io.BytesIO(source)))
84
+
85
+ if isinstance(source, str):
86
+ parsed = urlparse(source)
87
+ if parsed.scheme in {"http", "https"}:
88
+ import httpx
89
+ resp = httpx.get(source, follow_redirects=True, timeout=30)
90
+ resp.raise_for_status()
91
+ return _pil_to_array(Image.open(io.BytesIO(resp.content)))
92
+ # Local file path — RapidOCR accepts it directly.
93
+ return source
94
+
95
+ if isinstance(source, np.ndarray):
96
+ return source
97
+
98
+ raise TypeError(
99
+ f"ocr_image expects bytes, str (URL or path), numpy.ndarray, or PIL.Image; "
100
+ f"received {type(source).__name__!r}"
101
+ )
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Public API
106
+ # ---------------------------------------------------------------------------
107
+
108
+ def ocr_image(
109
+ source,
110
+ *,
111
+ use_det: bool = True,
112
+ use_cls: bool = True,
113
+ use_rec: bool = True,
114
+ text_score: float = 0.5,
115
+ ) -> str:
116
+ """Extract text from an image using RapidOCR.
117
+
118
+ Parameters
119
+ ----------
120
+ source:
121
+ Input image — bytes, URL string, local path string, numpy.ndarray,
122
+ or PIL.Image.
123
+ use_det, use_cls, use_rec:
124
+ RapidOCR pipeline stages (detection, classification, recognition).
125
+ text_score:
126
+ Minimum confidence threshold for accepted text lines.
127
+
128
+ Returns
129
+ -------
130
+ str
131
+ Recognised text lines joined by newlines, or an empty string when
132
+ no text is detected.
133
+ """
134
+ engine = _get_engine()
135
+ img = _to_numpy(source)
136
+
137
+ result, _ = engine(
138
+ img,
139
+ use_det=use_det,
140
+ use_cls=use_cls,
141
+ use_rec=use_rec,
142
+ text_score=text_score,
143
+ )
144
+
145
+ if not result:
146
+ return ""
147
+
148
+ return "\n".join(item[1] for item in result if len(item) > 1 and item[1])
149
+
150
+
151
+ def ocr_pdf(source: Union[str, bytes], *, dpi: int = 150) -> str:
152
+ """Extract text from a scanned (image-only) PDF using pypdfium2 and RapidOCR.
153
+
154
+ Each page is rendered to a PIL image in memory (no temporary files are
155
+ written), then passed through ocr_image. All page outputs are joined
156
+ with double newlines.
157
+
158
+ Parameters
159
+ ----------
160
+ source:
161
+ Local file path (str) or raw PDF bytes.
162
+ dpi:
163
+ Rendering resolution. 150 balances speed and OCR quality for most
164
+ document types. Increase to 200-300 for small or dense text.
165
+
166
+ Returns
167
+ -------
168
+ str
169
+ Concatenated OCR text from all pages, or an empty string on failure.
170
+ """
171
+ try:
172
+ import pypdfium2 as pdfium
173
+ except ImportError:
174
+ logger.error("ocr_pdf | pypdfium2 not installed; run: pip install pypdfium2")
175
+ return ""
176
+
177
+ try:
178
+ pdf = pdfium.PdfDocument(source)
179
+ scale = dpi / 72.0 # pypdfium2 native resolution is 72 dpi
180
+ page_texts: list[str] = []
181
+
182
+ for page_index in range(len(pdf)):
183
+ page = pdf[page_index]
184
+ bitmap = page.render(scale=scale, rotation=0)
185
+ pil_image = bitmap.to_pil()
186
+
187
+ logger.debug("ocr_pdf | processing page %d/%d", page_index + 1, len(pdf))
188
+ page_text = ocr_image(pil_image)
189
+ if page_text:
190
+ page_texts.append(page_text)
191
+
192
+ pdf.close()
193
+ return "\n\n".join(page_texts)
194
+
195
+ except Exception as exc:
196
+ logger.error("ocr_pdf | failed | error=%s", exc, exc_info=True)
197
+ return ""
core/output.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Output writer for the MarkItDown API.
3
+
4
+ Writes conversion results to disk in Markdown, plain text, or JSON format.
5
+ Intended for use by the CLI batch pipeline; the API server handles output
6
+ directly via HTTP responses and does not use this module.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Literal
14
+
15
+ from .converter import ConversionResult
16
+ from logger import get_logger
17
+
18
+ logger = get_logger(__name__)
19
+
20
+
21
+ OutputFormat = Literal["markdown", "json", "txt"]
22
+
23
+
24
+ class OutputWriter:
25
+ """Write ConversionResult objects to files in a specified format.
26
+
27
+ Parameters
28
+ ----------
29
+ output_dir:
30
+ Destination directory. Created automatically if it does not exist.
31
+ format:
32
+ Output format: ``"markdown"`` (default), ``"json"``, or ``"txt"``.
33
+ """
34
+
35
+ def __init__(self, output_dir: str | Path, format: OutputFormat = "markdown") -> None:
36
+ self._output_dir = Path(output_dir)
37
+ self._format = format
38
+ self._output_dir.mkdir(parents=True, exist_ok=True)
39
+
40
+ def write(self, result: ConversionResult) -> Path:
41
+ """Serialise *result* and write it to the output directory.
42
+
43
+ Collisions are resolved by appending an incrementing counter to the stem.
44
+
45
+ Returns
46
+ -------
47
+ Path
48
+ The path of the written file.
49
+ """
50
+ stem = Path(result.source).stem if not result.source.startswith("http") else "web_content"
51
+ suffix = ".md" if self._format == "markdown" else f".{self._format}"
52
+ output_path = self._resolve_collision(self._output_dir / f"{stem}{suffix}")
53
+
54
+ content = self._render(result)
55
+ output_path.write_text(content, encoding="utf-8")
56
+ logger.debug("write | path=%s | chars=%d", output_path, len(content))
57
+ return output_path
58
+
59
+ def write_batch_report(self, report, output_path: str | Path) -> None:
60
+ """Write a BatchReport summary as JSON to *output_path*."""
61
+ path = Path(output_path)
62
+ data = {
63
+ "summary": {
64
+ "total": report.total,
65
+ "succeeded": report.succeeded,
66
+ "failed": report.failed,
67
+ "success_rate_pct": round(report.success_rate, 2),
68
+ "total_chars": report.total_chars,
69
+ "total_words": report.total_words,
70
+ "total_duration_ms": round(report.total_duration_ms, 2),
71
+ },
72
+ "results": [
73
+ {
74
+ "source": r.source,
75
+ "char_count": r.char_count,
76
+ "word_count": r.word_count,
77
+ "line_count": r.line_count,
78
+ "duration_ms": round(r.duration_ms, 2),
79
+ "content_hash": r.content_hash,
80
+ "mime_type": r.mime_type,
81
+ }
82
+ for r in report.results
83
+ ],
84
+ "errors": [
85
+ {
86
+ "source": e.source,
87
+ "error_type": e.error_type,
88
+ "message": e.message,
89
+ "duration_ms": round(e.duration_ms, 2),
90
+ }
91
+ for e in report.errors
92
+ ],
93
+ }
94
+ path.write_text(json.dumps(data, indent=2), encoding="utf-8")
95
+ logger.debug("write_batch_report | path=%s | total=%d", path, report.total)
96
+
97
+ def _render(self, result: ConversionResult) -> str:
98
+ """Serialise *result* to the configured output format."""
99
+ if self._format == "json":
100
+ return json.dumps(
101
+ {
102
+ "source": result.source,
103
+ "markdown": result.markdown,
104
+ "meta": {
105
+ "char_count": result.char_count,
106
+ "word_count": result.word_count,
107
+ "line_count": result.line_count,
108
+ "duration_ms": round(result.duration_ms, 2),
109
+ "mime_type": result.mime_type,
110
+ "content_hash": result.content_hash,
111
+ },
112
+ },
113
+ indent=2,
114
+ )
115
+ return result.markdown
116
+
117
+ @staticmethod
118
+ def _resolve_collision(path: Path) -> Path:
119
+ """Return *path* if it does not exist, otherwise append a counter to the stem."""
120
+ if not path.exists():
121
+ return path
122
+ counter = 1
123
+ while True:
124
+ candidate = path.with_stem(f"{path.stem}_{counter}")
125
+ if not candidate.exists():
126
+ return candidate
127
+ counter += 1
extraction/__init__.py ADDED
File without changes
extraction/generic_json_extractor.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Dict, Optional, Union
5
+
6
+ from logger import get_logger
7
+ from .label_mapper import validate_mappings
8
+ from .spacy_extractor import extract_fields
9
+
10
+ logger = get_logger(__name__)
11
+
12
+ TABULAR_EXTENSIONS = {".csv", ".xls", ".xlsx"}
13
+
14
+
15
+ def is_tabular(filename: Union[str, Path]) -> bool:
16
+ return Path(filename).suffix.lower() in TABULAR_EXTENSIONS
17
+
18
+
19
+ def extract(
20
+ filename: Union[str, Path],
21
+ markdown_text: str,
22
+ mappings: Optional[Dict[str, Dict[str, Any]]],
23
+ file_data: Optional[bytes] = None,
24
+ ) -> Dict[str, Any]:
25
+ ext = Path(filename).suffix.lower()
26
+
27
+ if ext in TABULAR_EXTENSIONS:
28
+ from .json_extractor import extract_json_from_file
29
+ result = extract_json_from_file(filename, file_data)
30
+ if "error" not in result:
31
+ result["extractor"] = "pandas"
32
+ return result
33
+
34
+ if not mappings:
35
+ return {
36
+ "error": (
37
+ f"Cannot extract JSON from '{ext}' files without field mappings. "
38
+ "Provide a 'mappings' object with field extraction rules."
39
+ ),
40
+ "file_type": ext,
41
+ }
42
+
43
+ valid, mapper_error = validate_mappings(mappings)
44
+ if not valid:
45
+ return {
46
+ "error": "invalid_spacy_labels",
47
+ "label_mapper": mapper_error,
48
+ "file_type": ext,
49
+ }
50
+
51
+ try:
52
+ data = extract_fields(markdown_text, mappings)
53
+ logger.info("spaCy extraction completed for %s: %d fields", ext, len(data))
54
+ return {
55
+ "success": True,
56
+ "extractor": "spacy",
57
+ "file_type": ext,
58
+ "data": data,
59
+ }
60
+ except Exception as exc:
61
+ logger.exception("spaCy extraction failed for %s", ext)
62
+ return {
63
+ "error": f"spaCy extraction failed: {exc}",
64
+ "file_type": ext,
65
+ "exception_type": type(exc).__name__,
66
+ }
extraction/json_extractor.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ JSON extractor for structured data files.
3
+
4
+ Supports only CSV, XLS, XLSX file types for JSON extraction.
5
+ Returns error for unsupported file types.
6
+ """
7
+
8
+ import io
9
+ import warnings
10
+ from pathlib import Path
11
+ from typing import Any, Dict, Optional, Union
12
+
13
+ import pandas as pd
14
+
15
+ from logger import get_logger
16
+
17
+ logger = get_logger(__name__)
18
+
19
+ # Supported file extensions for JSON extraction
20
+ SUPPORTED_EXTENSIONS = {'.csv', '.xls', '.xlsx'}
21
+
22
+ # Resource limits to prevent abuse
23
+ MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024 # 100 MB
24
+ MAX_CSV_ROWS = 100000 # Limit rows for CSV
25
+ MAX_EXCEL_ROWS = 50000 # Limit rows for Excel
26
+ MAX_MEMORY_ROWS = 100000 # Global memory guard
27
+
28
+
29
+ def _validate_file_size(size: int) -> Optional[str]:
30
+ if size > MAX_FILE_SIZE_BYTES:
31
+ return f"File size {size} bytes exceeds limit of {MAX_FILE_SIZE_BYTES} bytes"
32
+ return None
33
+
34
+
35
+ def _check_memory_usage(rows: int, cols: int) -> Optional[str]:
36
+ approx_mb = (rows * cols * 50) / (1024 * 1024)
37
+ if rows * cols > MAX_MEMORY_ROWS * 20:
38
+ return f"Data size too large (approx {approx_mb:.1f} MB). Too many cells: {rows}x{cols}"
39
+ return None
40
+
41
+
42
+ def extract_json_from_file(
43
+ file_path: Union[str, Path],
44
+ file_data: Optional[bytes] = None
45
+ ) -> Dict[str, Any]:
46
+ """
47
+ Extract JSON data from structured files (CSV, XLS, XLSX).
48
+
49
+ Parameters
50
+ ----------
51
+ file_path : Union[str, Path]
52
+ Path to the file or filename with extension
53
+ file_data : Optional[bytes]
54
+ Raw file data (for stream processing)
55
+
56
+ Returns
57
+ -------
58
+ Dict[str, Any]
59
+ Extracted data or error information
60
+ """
61
+ ext = Path(file_path).suffix.lower()
62
+
63
+ if ext not in SUPPORTED_EXTENSIONS:
64
+ return {
65
+ "error": f"Unsupported file type: {ext}. Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}",
66
+ "file_type": ext
67
+ }
68
+
69
+ # Validate file size if we have raw data
70
+ if file_data is not None:
71
+ size_error = _validate_file_size(len(file_data))
72
+ if size_error:
73
+ return {"error": size_error, "file_type": ext}
74
+ elif Path(file_path).exists():
75
+ size_error = _validate_file_size(Path(file_path).stat().st_size)
76
+ if size_error:
77
+ return {"error": size_error, "file_type": ext}
78
+
79
+ try:
80
+ with warnings.catch_warnings():
81
+ warnings.simplefilter("ignore", UserWarning)
82
+
83
+ if ext == '.csv':
84
+ if file_data:
85
+ stream = io.BytesIO(file_data)
86
+ # Peek for encoding detection
87
+ sample = stream.read(1024)
88
+ stream.seek(0)
89
+ df = pd.read_csv(stream, nrows=MAX_CSV_ROWS + 1, low_memory=False)
90
+ else:
91
+ df = pd.read_csv(file_path, nrows=MAX_CSV_ROWS + 1, low_memory=False)
92
+ else:
93
+ if file_data:
94
+ df = pd.read_excel(io.BytesIO(file_data), engine='openpyxl' if ext == '.xlsx' else 'xlrd')
95
+ else:
96
+ df = pd.read_excel(file_path, engine='openpyxl' if ext == '.xlsx' else 'xlrd')
97
+
98
+ # Enforce row limits to prevent memory exhaustion
99
+ max_rows = MAX_EXCEL_ROWS if ext != '.csv' else MAX_CSV_ROWS
100
+ if len(df) > max_rows:
101
+ return {
102
+ "error": f"File contains {len(df)} rows, exceeds limit of {max_rows}",
103
+ "file_type": ext,
104
+ "row_count": len(df)
105
+ }
106
+
107
+ # Additional memory guard
108
+ mem_error = _check_memory_usage(len(df), len(df.columns))
109
+ if mem_error:
110
+ return {"error": mem_error, "file_type": ext}
111
+
112
+ # Efficient conversion to JSON-serializable format
113
+ result = {
114
+ "success": True,
115
+ "file_type": ext,
116
+ "data": {
117
+ "columns": list(df.columns),
118
+ "rows": df.where(pd.notnull(df), None).to_dict(orient='records'),
119
+ "shape": [len(df), len(df.columns)],
120
+ "dtypes": {col: str(dtype) for col, dtype in df.dtypes.items()}
121
+ }
122
+ }
123
+
124
+ logger.info("Extracted JSON from %s: %d rows, %d cols", ext, len(df), len(df.columns))
125
+ return result
126
+
127
+ except pd.errors.EmptyDataError:
128
+ return {
129
+ "error": f"File is empty or has no data",
130
+ "file_type": ext
131
+ }
132
+ except MemoryError:
133
+ return {
134
+ "error": "Out of memory processing file",
135
+ "file_type": ext
136
+ }
137
+ except Exception as exc:
138
+ logger.exception("JSON extraction failed for %s", ext)
139
+ return {
140
+ "error": f"Processing failed: {str(exc)}",
141
+ "file_type": ext,
142
+ "exception_type": type(exc).__name__
143
+ }
144
+
145
+
146
+
147
+
148
+
149
+ def is_supported_file_type(file_path: Union[str, Path]) -> bool:
150
+ """
151
+ Check if file type is supported for JSON extraction.
152
+
153
+ Parameters
154
+ ----------
155
+ file_path : Union[str, Path]
156
+ Path to the file or filename with extension
157
+
158
+ Returns
159
+ -------
160
+ bool
161
+ True if supported, False otherwise
162
+ """
163
+ extension = Path(file_path).suffix.lower()
164
+ return extension in SUPPORTED_EXTENSIONS
165
+
166
+
167
+ def get_supported_extensions() -> list:
168
+ """
169
+ Get list of supported file extensions for JSON extraction.
170
+
171
+ Returns
172
+ -------
173
+ list
174
+ Supported file extensions
175
+ """
176
+ return sorted(SUPPORTED_EXTENSIONS)
extraction/label_mapper.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, List, Optional, Tuple
4
+
5
+ from .spacy_extractor import VALID_SPACY_LABELS
6
+
7
+
8
+ def validate_mappings(fields: Dict[str, Dict[str, Any]]) -> Tuple[bool, Optional[Dict]]:
9
+ invalid: List[str] = []
10
+ for field_name, rule in fields.items():
11
+ if rule.get("source_type") == "entity":
12
+ label = rule.get("label", "")
13
+ if label not in VALID_SPACY_LABELS:
14
+ invalid.append(label)
15
+
16
+ if not invalid:
17
+ return True, None
18
+
19
+ return False, {
20
+ "invalid_labels": invalid,
21
+ "valid_labels": VALID_SPACY_LABELS,
22
+ "suggestion": (
23
+ "Use source_type 'regex' for custom patterns not covered by spaCy NER labels."
24
+ ),
25
+ }
extraction/spacy_extractor.py ADDED
@@ -0,0 +1,707 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import threading
5
+ from typing import Any, Callable, Dict, List, Optional, Tuple, Union
6
+
7
+ from logger import get_logger
8
+
9
+ logger = get_logger(__name__)
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # Type aliases
13
+ # ---------------------------------------------------------------------------
14
+
15
+ # A "schema node" is one of:
16
+ # • a leaf rule dict → has a "source_type" key (str leaf)
17
+ # • an array rule → has "type": "array"
18
+ # • an object rule → has "type": "object"
19
+ # Results mirror the shape: str | list | dict | None at any depth.
20
+
21
+ SchemaNode = Dict[str, Any]
22
+ ResultNode = Union[str, List[Any], Dict[str, Any], None]
23
+
24
+ VALID_SPACY_LABELS: Dict[str, str] = {
25
+ "ORG": "Companies, agencies, institutions",
26
+ "PERSON": "People, including fictional",
27
+ "DATE": "Absolute or relative dates or periods",
28
+ "MONEY": "Monetary values, including unit",
29
+ "GPE": "Countries, cities, states",
30
+ "LOC": "Non-GPE locations, mountain ranges, bodies of water",
31
+ "PRODUCT": "Objects, vehicles, foods, etc.",
32
+ "EVENT": "Named hurricanes, battles, wars, sports events",
33
+ "CARDINAL": "Numerals that do not fall under another type",
34
+ "PERCENT": "Percentage, including '%'",
35
+ "QUANTITY": "Measurements, as of weight or distance",
36
+ "TIME": "Times smaller than a day",
37
+ "NORP": "Nationalities or religious or political groups",
38
+ "FAC": "Buildings, airports, highways, bridges",
39
+ "WORK_OF_ART": "Titles of books, songs, etc.",
40
+ "LAW": "Named documents made into laws",
41
+ "LANGUAGE": "Any named language",
42
+ "ORDINAL": "'first', 'second', etc.",
43
+ }
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # spaCy singleton
48
+ # ---------------------------------------------------------------------------
49
+
50
+ _nlp_lock = threading.Lock()
51
+ _nlp = None
52
+
53
+
54
+ def _get_nlp():
55
+ global _nlp
56
+ if _nlp is not None:
57
+ return _nlp
58
+ with _nlp_lock:
59
+ if _nlp is None:
60
+ import spacy
61
+ _nlp = spacy.load(
62
+ "en_core_web_sm",
63
+ exclude=["tagger", "parser", "lemmatizer", "attribute_ruler"],
64
+ )
65
+ logger.info("spaCy en_core_web_sm loaded (singleton)")
66
+ return _nlp
67
+
68
+
69
+ def clean_text(text: str) -> str:
70
+ return text
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Normalizer registry
75
+ # ---------------------------------------------------------------------------
76
+
77
+ _norm_lock = threading.Lock()
78
+ _NORMALIZERS: Dict[str, Callable[[str], str]] = {
79
+ "strip": lambda s: s.strip(),
80
+ "upper": lambda s: s.upper(),
81
+ "lower": lambda s: s.lower(),
82
+ "remove_commas": lambda s: s.replace(",", ""),
83
+ "remove_spaces": lambda s: s.replace(" ", ""),
84
+ "remove_newlines": lambda s: s.replace("\n", " ").replace("\r", ""),
85
+ "collapse_whitespace": lambda s: re.sub(r"\s+", " ", s).strip(),
86
+ "remove_currency": lambda s: re.sub(r"[$€£¥₹]", "", s),
87
+ "remove_non_numeric": lambda s: re.sub(r"[^\d.]", "", s),
88
+ "normalize_date_sep": lambda s: re.sub(r"[/.]", "-", s),
89
+ }
90
+
91
+
92
+ def register_normalizer(name: str, fn: Callable[[str], str]) -> None:
93
+ """Register a custom normalizer. Thread-safe, overwrites silently."""
94
+ with _norm_lock:
95
+ _NORMALIZERS[name] = fn
96
+
97
+
98
+ def _apply_normalizers(value: Optional[str], normalize: Any) -> Optional[str]:
99
+ if not isinstance(value, str):
100
+ return None
101
+ if not normalize:
102
+ return value
103
+ if isinstance(normalize, str):
104
+ normalize = [normalize]
105
+ for key in normalize:
106
+ with _norm_lock:
107
+ fn = _NORMALIZERS.get(key)
108
+ if fn is None:
109
+ logger.warning("Unknown normalizer %r — skipped", key)
110
+ continue
111
+ try:
112
+ value = fn(value)
113
+ except Exception as exc:
114
+ logger.error("Normalizer %r raised on value %r: %s", key, value, exc)
115
+ return value if value else None
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Resolver registry
120
+ # ---------------------------------------------------------------------------
121
+
122
+ _resolver_lock = threading.Lock()
123
+ _RESOLVERS: Dict[str, Callable[[Dict[str, Any], Any, str], Optional[str]]] = {}
124
+
125
+
126
+ def register_resolver(
127
+ source_type: str,
128
+ fn: Callable[[Dict[str, Any], Any, str], Optional[str]],
129
+ ) -> None:
130
+ """Register a custom resolver for a source_type. Thread-safe."""
131
+ with _resolver_lock:
132
+ _RESOLVERS[source_type] = fn
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Regex resolver
137
+ # ---------------------------------------------------------------------------
138
+
139
+ def _build_flags(rule: Dict[str, Any]) -> re.RegexFlag:
140
+ flags = re.RegexFlag(0)
141
+ for name in rule.get("flags", []):
142
+ obj = getattr(re, name.upper(), None)
143
+ if obj is None:
144
+ logger.warning("Unknown re flag %r — skipped", name)
145
+ continue
146
+ flags |= obj
147
+ return flags
148
+
149
+
150
+ def _try_group(match: re.Match, capture_group: Any) -> Tuple[bool, Optional[str]]:
151
+ try:
152
+ return True, match.group(capture_group)
153
+ except (IndexError, re.error):
154
+ logger.warning(
155
+ "Group %r does not exist in pattern %r",
156
+ capture_group, match.re.pattern,
157
+ )
158
+ return False, None
159
+
160
+
161
+ def _resolve_regex(rule: Dict[str, Any], text: str) -> Optional[str]:
162
+ primary = rule.get("pattern", "")
163
+ if not primary:
164
+ logger.warning("Regex rule missing 'pattern': %s", rule)
165
+ return None
166
+
167
+ flags = _build_flags(rule)
168
+ capture_group = rule.get("capture_group", 0)
169
+ match_index = rule.get("match_index", 0)
170
+ normalize = rule.get("normalize", "")
171
+ strip_chars = rule.get("strip_chars", "")
172
+ fallbacks = rule.get("fallback_patterns", [])
173
+
174
+ for pat in [primary, *fallbacks]:
175
+ try:
176
+ matches = list(re.finditer(pat, text, flags))
177
+ except re.error as exc:
178
+ logger.error("Invalid regex %r: %s", pat, exc)
179
+ continue
180
+
181
+ if not matches:
182
+ continue
183
+
184
+ try:
185
+ target_matches = [matches[match_index]]
186
+ except IndexError:
187
+ target_matches = [matches[-1]]
188
+
189
+ for m in target_matches:
190
+ exists, result = _try_group(m, capture_group)
191
+ if not exists:
192
+ break
193
+ if result is None:
194
+ break
195
+ result = _apply_normalizers(result, normalize)
196
+ if result is None:
197
+ break
198
+ result = result.strip(strip_chars) if strip_chars else result.strip()
199
+ return result or None
200
+
201
+ return None
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # Regex-array resolver (all matches of a pattern → list of strings)
206
+ # ---------------------------------------------------------------------------
207
+
208
+ def _resolve_regex_all(rule: Dict[str, Any], text: str) -> List[Optional[str]]:
209
+ """
210
+ Like _resolve_regex but returns ALL matches as a list instead of one.
211
+
212
+ Extra rule keys versus the scalar regex rule:
213
+ max_items int Cap the number of results (default: unlimited).
214
+ """
215
+ primary = rule.get("pattern", "")
216
+ if not primary:
217
+ logger.warning("Regex-array rule missing 'pattern': %s", rule)
218
+ return []
219
+
220
+ flags = _build_flags(rule)
221
+ capture_group = rule.get("capture_group", 0)
222
+ normalize = rule.get("normalize", "")
223
+ strip_chars = rule.get("strip_chars", "")
224
+ max_items = rule.get("max_items")
225
+
226
+ try:
227
+ matches = list(re.finditer(primary, text, flags))
228
+ except re.error as exc:
229
+ logger.error("Invalid regex %r: %s", primary, exc)
230
+ return []
231
+
232
+ results: List[Optional[str]] = []
233
+ for m in matches:
234
+ exists, result = _try_group(m, capture_group)
235
+ if not exists or result is None:
236
+ continue
237
+ result = _apply_normalizers(result, normalize)
238
+ if result is None:
239
+ continue
240
+ result = result.strip(strip_chars) if strip_chars else result.strip()
241
+ if result:
242
+ results.append(result)
243
+ if max_items is not None and len(results) >= max_items:
244
+ break
245
+
246
+ return results
247
+
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # Entity resolver
251
+ # ---------------------------------------------------------------------------
252
+
253
+ def _resolve_entity(rule: Dict[str, Any], doc: Any) -> Optional[str]:
254
+ if doc is None:
255
+ logger.warning("Entity resolver received None doc — skipping")
256
+ return None
257
+
258
+ labels = rule.get("label")
259
+ if isinstance(labels, str):
260
+ labels = [labels]
261
+ labels = set(labels or [])
262
+
263
+ match_index = rule.get("match_index", 0)
264
+ min_length = rule.get("min_length", 1)
265
+ exclude_pat = rule.get("exclude_pattern", "")
266
+ exclude_flags = _build_flags({"flags": rule.get("exclude_flags", [])})
267
+ normalize = rule.get("normalize", "")
268
+
269
+ candidates = [
270
+ ent.text for ent in doc.ents
271
+ if ent.label_ in labels
272
+ and len(ent.text) >= min_length
273
+ and not (exclude_pat and re.search(exclude_pat, ent.text, exclude_flags))
274
+ ]
275
+
276
+ if not candidates:
277
+ return None
278
+
279
+ try:
280
+ result = candidates[match_index]
281
+ except IndexError:
282
+ result = candidates[-1]
283
+
284
+ return _apply_normalizers(result, normalize)
285
+
286
+
287
+ # ---------------------------------------------------------------------------
288
+ # Entity-array resolver (all matching entities → list)
289
+ # ---------------------------------------------------------------------------
290
+
291
+ def _resolve_entity_all(rule: Dict[str, Any], doc: Any) -> List[Optional[str]]:
292
+ """
293
+ Returns ALL entities matching the label filter as a list.
294
+
295
+ Extra rule key:
296
+ max_items int Cap the number of results (default: unlimited).
297
+ unique bool Deduplicate while preserving order (default: False).
298
+ """
299
+ if doc is None:
300
+ logger.warning("Entity-array resolver received None doc — skipping")
301
+ return []
302
+
303
+ labels = rule.get("label")
304
+ if isinstance(labels, str):
305
+ labels = [labels]
306
+ labels = set(labels or [])
307
+
308
+ min_length = rule.get("min_length", 1)
309
+ exclude_pat = rule.get("exclude_pattern", "")
310
+ exclude_flags = _build_flags({"flags": rule.get("exclude_flags", [])})
311
+ normalize = rule.get("normalize", "")
312
+ max_items = rule.get("max_items")
313
+ unique = rule.get("unique", False)
314
+
315
+ results: List[str] = []
316
+ seen: set = set()
317
+
318
+ for ent in doc.ents:
319
+ if ent.label_ not in labels:
320
+ continue
321
+ if len(ent.text) < min_length:
322
+ continue
323
+ if exclude_pat and re.search(exclude_pat, ent.text, exclude_flags):
324
+ continue
325
+
326
+ value = _apply_normalizers(ent.text, normalize)
327
+ if not value:
328
+ continue
329
+ if unique:
330
+ if value in seen:
331
+ continue
332
+ seen.add(value)
333
+
334
+ results.append(value)
335
+ if max_items is not None and len(results) >= max_items:
336
+ break
337
+
338
+ return results
339
+
340
+
341
+ # ---------------------------------------------------------------------------
342
+ # Token-attribute resolver
343
+ # ---------------------------------------------------------------------------
344
+
345
+ def _resolve_token_attr(rule: Dict[str, Any], doc: Any) -> Optional[str]:
346
+ if doc is None:
347
+ logger.warning("Token-attr resolver received None doc — skipping")
348
+ return None
349
+
350
+ attr = rule.get("attr", "")
351
+ match_index = rule.get("match_index", 0)
352
+ normalize = rule.get("normalize", "")
353
+
354
+ candidates = [t.text for t in doc if getattr(t, attr, False)]
355
+ if not candidates:
356
+ return None
357
+
358
+ try:
359
+ result = candidates[match_index]
360
+ except IndexError:
361
+ result = candidates[-1]
362
+
363
+ return _apply_normalizers(result, normalize)
364
+
365
+
366
+ # ---------------------------------------------------------------------------
367
+ # Built-in resolver registration
368
+ # ---------------------------------------------------------------------------
369
+
370
+ register_resolver("regex", lambda rule, doc, text: _resolve_regex(rule, text))
371
+ register_resolver("entity", lambda rule, doc, text: _resolve_entity(rule, doc))
372
+ register_resolver("token_attr", lambda rule, doc, text: _resolve_token_attr(rule, doc))
373
+ # Array-producing leaf resolvers (used internally by the array node path):
374
+ register_resolver("regex_all", lambda rule, doc, text: _resolve_regex_all(rule, text))
375
+ register_resolver("entity_all", lambda rule, doc, text: _resolve_entity_all(rule, doc))
376
+
377
+
378
+ # ---------------------------------------------------------------------------
379
+ # Scalar field dispatcher (returns str | None)
380
+ # ---------------------------------------------------------------------------
381
+
382
+ def _resolve_scalar_field(
383
+ rule: Dict[str, Any],
384
+ doc: Any,
385
+ text: str,
386
+ ) -> Optional[str]:
387
+ src = rule.get("source_type")
388
+ with _resolver_lock:
389
+ fn = _RESOLVERS.get(src)
390
+ if fn is None:
391
+ logger.warning("Unknown source_type %r — no resolver registered", src)
392
+ return None
393
+ return fn(rule, doc, text)
394
+
395
+
396
+ # ---------------------------------------------------------------------------
397
+ # Generic nested schema resolver
398
+ # ---------------------------------------------------------------------------
399
+ #
400
+ # Schema node shapes
401
+ # ──────────────────
402
+ #
403
+ # 1. LEAF (scalar string)
404
+ # {
405
+ # "source_type": "regex" | "entity" | "token_attr" | <custom>,
406
+ # ...resolver-specific keys...
407
+ # }
408
+ #
409
+ # 2. OBJECT (nested dict of named fields)
410
+ # {
411
+ # "type": "object",
412
+ # "fields": {
413
+ # "field_a": <schema_node>,
414
+ # "field_b": <schema_node>,
415
+ # ...
416
+ # }
417
+ # }
418
+ #
419
+ # 3. ARRAY (repeated items)
420
+ # {
421
+ # "type": "array",
422
+ #
423
+ # # --- how to split the text into per-item segments (optional) ---
424
+ # # If omitted the whole text is the single segment (useful when
425
+ # # the item schema itself fans out via regex_all / entity_all).
426
+ # "split_pattern": "<regex>", # splits text; each piece → one item
427
+ # "split_flags": ["DOTALL"], # re flags for split_pattern
428
+ #
429
+ # # --- what each item looks like ---
430
+ # "items": <schema_node>
431
+ # # Can be a leaf, an object, or even another array (any depth).
432
+ # }
433
+ #
434
+ # Results
435
+ # ───────
436
+ # LEAF → str | None
437
+ # OBJECT → {field: result, ...} (all keys always present, value may be None)
438
+ # ARRAY → [result, ...] (may be empty; each element mirrors item schema)
439
+
440
+
441
+ def _resolve_node(node: SchemaNode, doc: Any, text: str) -> ResultNode:
442
+ """
443
+ Recursively resolve a schema node against `text` / `doc`.
444
+ Dispatches on node["type"] or falls back to scalar leaf resolution.
445
+ """
446
+ node_type = node.get("type")
447
+
448
+ if node_type == "object":
449
+ return _resolve_object_node(node, doc, text)
450
+
451
+ if node_type == "array":
452
+ return _resolve_array_node(node, doc, text)
453
+
454
+ # No "type" key → treat as a scalar leaf rule
455
+ return _resolve_scalar_field(node, doc, text)
456
+
457
+
458
+ def _resolve_object_node(
459
+ node: SchemaNode,
460
+ doc: Any,
461
+ text: str,
462
+ ) -> Dict[str, ResultNode]:
463
+ """
464
+ Resolve every field in node["fields"] and return a dict.
465
+ Each field may itself be a leaf, object, or array — fully recursive.
466
+ """
467
+ fields: Dict[str, SchemaNode] = node.get("fields", {})
468
+ result: Dict[str, ResultNode] = {}
469
+
470
+ for field_name, child_node in fields.items():
471
+ try:
472
+ result[field_name] = _resolve_node(child_node, doc, text)
473
+ except Exception as exc:
474
+ logger.error(
475
+ "Object field %r raised unexpectedly: %s", field_name, exc,
476
+ exc_info=True,
477
+ )
478
+ result[field_name] = None
479
+
480
+ return result
481
+
482
+
483
+ def _resolve_array_node(
484
+ node: SchemaNode,
485
+ doc: Any,
486
+ text: str,
487
+ ) -> List[ResultNode]:
488
+ """
489
+ Resolve an array node:
490
+
491
+ Two operating modes, selected by whether "split_pattern" is present:
492
+
493
+ MODE A — split_pattern present
494
+ Split the text into N segments; resolve item schema against each
495
+ segment with its own spaCy doc. Good for table rows, repeated
496
+ blocks, delimited records, etc.
497
+
498
+ MODE B — no split_pattern
499
+ Resolve item schema against the full text once.
500
+ If the item schema is a leaf with source_type in {regex_all,
501
+ entity_all} it returns a list natively.
502
+ If the item schema returns a list → that IS the array.
503
+ If it returns a scalar → wrap in [scalar].
504
+ This handles "give me all ORG entities" without needing a split.
505
+ """
506
+ item_schema: SchemaNode = node.get("items", {})
507
+ split_pat: Optional[str] = node.get("split_pattern")
508
+ split_flags_rule = {"flags": node.get("split_flags", [])}
509
+ max_items: Optional[int] = node.get("max_items")
510
+
511
+ results: List[ResultNode] = []
512
+
513
+ if split_pat:
514
+ # ── MODE A: segment-per-item ────────────────────────────────────
515
+ try:
516
+ flags = _build_flags(split_flags_rule)
517
+ segments = re.split(split_pat, text, flags=flags)
518
+ except re.error as exc:
519
+ logger.error("Invalid split_pattern %r: %s", split_pat, exc)
520
+ return []
521
+
522
+ nlp = _get_nlp()
523
+
524
+ try:
525
+ segment_docs = list(nlp.pipe(segments))
526
+ except Exception as exc:
527
+ logger.error("spaCy pipe failed on array segments: %s", exc, exc_info=True)
528
+ segment_docs = [None] * len(segments)
529
+
530
+ for seg_doc, seg_text in zip(segment_docs, segments):
531
+ if not seg_text.strip():
532
+ continue
533
+ try:
534
+ item_result = _resolve_node(item_schema, seg_doc, seg_text)
535
+ except Exception as exc:
536
+ logger.error(
537
+ "Array item resolve raised: %s", exc, exc_info=True
538
+ )
539
+ item_result = None
540
+
541
+ results.append(item_result)
542
+ if max_items is not None and len(results) >= max_items:
543
+ break
544
+
545
+ else:
546
+ # ── MODE B: whole-text, native fan-out ─────────────────────────
547
+ try:
548
+ raw = _resolve_node(item_schema, doc, text)
549
+ except Exception as exc:
550
+ logger.error("Array item resolve raised: %s", exc, exc_info=True)
551
+ return []
552
+
553
+ if isinstance(raw, list):
554
+ results = raw
555
+ elif raw is not None:
556
+ results = [raw]
557
+
558
+ if max_items is not None:
559
+ results = results[:max_items]
560
+
561
+ return results
562
+
563
+
564
+ # ---------------------------------------------------------------------------
565
+ # Safe wrappers
566
+ # ---------------------------------------------------------------------------
567
+
568
+ def _safe_resolve_node(
569
+ path: str,
570
+ node: SchemaNode,
571
+ doc: Any,
572
+ text: str,
573
+ ) -> ResultNode:
574
+ """Resolve a node; isolate crashes so siblings still complete."""
575
+ try:
576
+ return _resolve_node(node, doc, text)
577
+ except Exception as exc:
578
+ logger.error(
579
+ "Schema path %r raised unexpectedly: %s", path, exc, exc_info=True
580
+ )
581
+ return None
582
+
583
+
584
+ # ---------------------------------------------------------------------------
585
+ # Public API
586
+ # ---------------------------------------------------------------------------
587
+
588
+ def extract_fields(
589
+ text: str,
590
+ fields: Dict[str, SchemaNode],
591
+ ) -> Dict[str, ResultNode]:
592
+ """
593
+ Extract fields from a single text string.
594
+
595
+ `fields` is a flat dict of {name: schema_node}. Each schema node may be
596
+ a scalar leaf, an object node, or an array node — nested to any depth.
597
+
598
+ Returns {field_name: result_or_None}.
599
+
600
+ Backward-compatible: callers that pass flat scalar rules unchanged still work.
601
+ """
602
+ nlp = _get_nlp()
603
+ cleaned = clean_text(text)
604
+
605
+ try:
606
+ doc = next(iter(nlp.pipe([cleaned])))
607
+ except Exception as exc:
608
+ logger.error("spaCy pipe failed: %s", exc, exc_info=True)
609
+ doc = None
610
+
611
+ return {
612
+ field: _safe_resolve_node(field, node, doc, cleaned)
613
+ for field, node in fields.items()
614
+ }
615
+
616
+
617
+ def extract_schema(
618
+ text: str,
619
+ schema: SchemaNode,
620
+ ) -> ResultNode:
621
+ """
622
+ Resolve a *single* schema node (which may be a leaf, object, or array)
623
+ against `text`.
624
+
625
+ Useful when the top-level result should itself be a list or a structured
626
+ object rather than a flat dict of fields.
627
+
628
+ Example
629
+ -------
630
+ schema = {
631
+ "type": "array",
632
+ "split_pattern": r"\\n\\n+",
633
+ "items": {
634
+ "type": "object",
635
+ "fields": {
636
+ "date": {"source_type": "entity", "label": "DATE"},
637
+ "amount": {"source_type": "regex", "pattern": r"\\$[\\d,]+"},
638
+ }
639
+ }
640
+ }
641
+ result = extract_schema(invoice_text, schema)
642
+ # → [{"date": "Jan 2024", "amount": "$1,200"}, ...]
643
+ """
644
+ nlp = _get_nlp()
645
+ cleaned = clean_text(text)
646
+
647
+ try:
648
+ doc = next(iter(nlp.pipe([cleaned])))
649
+ except Exception as exc:
650
+ logger.error("spaCy pipe failed: %s", exc, exc_info=True)
651
+ doc = None
652
+
653
+ return _safe_resolve_node("<root>", schema, doc, cleaned)
654
+
655
+
656
+ def extract_fields_batch(
657
+ texts: List[str],
658
+ fields: Dict[str, SchemaNode],
659
+ ) -> List[Dict[str, ResultNode]]:
660
+ """
661
+ Extract fields from a list of texts in a single top-level spaCy pipe pass.
662
+
663
+ Returns one result dict per input text, in the same order.
664
+
665
+ Note: array nodes with split_pattern trigger their own inner pipe call per
666
+ text; the outer pass still processes the top-level texts efficiently.
667
+ """
668
+ nlp = _get_nlp()
669
+ cleaned = [clean_text(t) for t in texts]
670
+
671
+ try:
672
+ docs = list(nlp.pipe(cleaned))
673
+ except Exception as exc:
674
+ logger.error("spaCy pipe failed: %s", exc, exc_info=True)
675
+ docs = [None] * len(cleaned)
676
+
677
+ return [
678
+ {
679
+ field: _safe_resolve_node(field, node, doc, text)
680
+ for field, node in fields.items()
681
+ }
682
+ for doc, text in zip(docs, cleaned)
683
+ ]
684
+
685
+
686
+ def extract_schema_batch(
687
+ texts: List[str],
688
+ schema: SchemaNode,
689
+ ) -> List[ResultNode]:
690
+ """
691
+ Like extract_schema but processes a list of texts efficiently.
692
+
693
+ Returns one ResultNode per input text, in the same order.
694
+ """
695
+ nlp = _get_nlp()
696
+ cleaned = [clean_text(t) for t in texts]
697
+
698
+ try:
699
+ docs = list(nlp.pipe(cleaned))
700
+ except Exception as exc:
701
+ logger.error("spaCy pipe failed: %s", exc, exc_info=True)
702
+ docs = [None] * len(cleaned)
703
+
704
+ return [
705
+ _safe_resolve_node(f"<root>[{i}]", schema, doc, text)
706
+ for i, (doc, text) in enumerate(zip(docs, cleaned))
707
+ ]
logger.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Logging configuration for the MarkItDown API.
3
+
4
+ Provides a structured plaintext formatter suitable for production log
5
+ aggregation pipelines (stdout + optional log file). No ANSI colour codes
6
+ are emitted, keeping output clean for log collectors and CI environments.
7
+
8
+ Public surface
9
+ --------------
10
+ get_logger(name) -> logging.Logger
11
+ Returns the named logger, bootstrapping from the root application
12
+ logger if the logger has not been configured yet.
13
+
14
+ setup_logger(...) -> logging.Logger
15
+ Full configuration path used once at application startup.
16
+
17
+ app_logger
18
+ Pre-configured root logger instance used by all modules.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import sys
25
+ from pathlib import Path
26
+ from typing import Optional
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Formatter
31
+ # ---------------------------------------------------------------------------
32
+
33
+ class PlainFormatter(logging.Formatter):
34
+ """Structured pipe-delimited log line.
35
+
36
+ Example output::
37
+ 2024-01-15 12:34:56 | INFO | llm_ready_data_extractor.api.server | message text
38
+ """
39
+
40
+ def format(self, record: logging.LogRecord) -> str:
41
+ timestamp = self.formatTime(record, self.datefmt)
42
+ line = (
43
+ f"{timestamp} | {record.levelname:<8} | "
44
+ f"{record.name} | {record.getMessage()}"
45
+ )
46
+ if record.exc_info:
47
+ line = f"{line}\n{self.formatException(record.exc_info)}"
48
+ return line
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Handler factories
53
+ # ---------------------------------------------------------------------------
54
+
55
+ def _build_console_handler() -> logging.StreamHandler:
56
+ """Return a StreamHandler writing structured lines to stdout."""
57
+ handler = logging.StreamHandler(sys.stdout)
58
+ handler.setFormatter(
59
+ PlainFormatter(datefmt="%Y-%m-%d %H:%M:%S")
60
+ )
61
+ return handler
62
+
63
+
64
+ def _build_file_handler(log_file: str) -> logging.FileHandler:
65
+ """Return a FileHandler writing to *log_file*, creating parent dirs as needed."""
66
+ log_path = Path(log_file)
67
+ log_path.parent.mkdir(parents=True, exist_ok=True)
68
+ handler = logging.FileHandler(log_file, encoding="utf-8")
69
+ handler.setFormatter(
70
+ PlainFormatter(datefmt="%Y-%m-%d %H:%M:%S")
71
+ )
72
+ return handler
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Public API
77
+ # ---------------------------------------------------------------------------
78
+
79
+ def setup_logger(
80
+ name: str,
81
+ level: str = "INFO",
82
+ log_file: Optional[str] = None,
83
+ enable_console: bool = True,
84
+ ) -> logging.Logger:
85
+ """Configure and return a named logger.
86
+
87
+ Parameters
88
+ ----------
89
+ name:
90
+ Logger name — pass ``__name__`` from the calling module.
91
+ level:
92
+ Minimum log level string, e.g. ``"DEBUG"``, ``"INFO"``, ``"WARNING"``.
93
+ log_file:
94
+ Optional path to a persistent log file. When supplied, records are
95
+ written to both stdout and the file.
96
+ enable_console:
97
+ Set to ``False`` to suppress stdout output (useful in test environments).
98
+ """
99
+ logger = logging.getLogger(name)
100
+ logger.setLevel(getattr(logging, level.upper(), logging.INFO))
101
+ logger.handlers.clear()
102
+ logger.propagate = False
103
+
104
+ if enable_console:
105
+ logger.addHandler(_build_console_handler())
106
+
107
+ if log_file:
108
+ logger.addHandler(_build_file_handler(log_file))
109
+
110
+ return logger
111
+
112
+
113
+ def get_logger(name: str) -> logging.Logger:
114
+ """Return the named logger, configuring it with defaults if not yet set up.
115
+
116
+ Intended usage in application modules::
117
+
118
+ from logger import get_logger
119
+ logger = get_logger(__name__)
120
+ """
121
+ existing = logging.getLogger(name)
122
+ if existing.handlers:
123
+ return existing
124
+ return setup_logger(name)
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Root application logger — configured once at import time
129
+ # ---------------------------------------------------------------------------
130
+
131
+ app_logger = setup_logger(
132
+ "llm_ready_data_extractor",
133
+ level="INFO",
134
+ log_file="logs/markitdown.log",
135
+ enable_console=True,
136
+ )
main.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MarkItDown API — entry point.
3
+
4
+ Run directly::
5
+ python main.py
6
+
7
+ Or via start.sh for production (environment variable overrides supported).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import uvicorn
12
+
13
+ from logger import get_logger
14
+
15
+ logger = get_logger(__name__)
16
+
17
+ if __name__ == "__main__":
18
+ logger.info("Starting MarkItDown API server")
19
+ uvicorn.run(
20
+ "api.server:app",
21
+ host="0.0.0.0",
22
+ port=7860,
23
+ reload=False,
24
+ workers=1,
25
+ log_level="info",
26
+ access_log=True,
27
+ )
requirements.txt ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MarkItDown API — Dependencies (Lightweight)
2
+ markitdown[all]>=0.1.5
3
+ fastapi>=0.111.0
4
+ uvicorn[standard]>=0.30.0
5
+ pydantic>=2.7.0
6
+ python-multipart>=0.0.9
7
+ httpx>=0.27.0
8
+
9
+ # OCR — RapidOCR with onnxruntime CPU backend (English + Chinese)
10
+ rapidocr-onnxruntime>=1.4.4
11
+ onnxruntime>=1.18.0
12
+ pillow>=10.0.0
13
+
14
+ # Scanned-PDF fallback (page rendering before OCR)
15
+ pypdfium2>=4.30.0
16
+
17
+ # Data processing for JSON extraction
18
+ pandas>=2.0.0
19
+
20
+ # spaCy NER extraction for non-tabular file formats
21
+ spacy>=3.7.0
self_ping.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Self-ping script that hits a URL."""
3
+
4
+ import datetime
5
+ import os
6
+ import urllib.request
7
+
8
+ PING_URL = os.environ.get("PING_URL", "https://your-url.com/health")
9
+
10
+ def main():
11
+ timestamp = datetime.datetime.now().isoformat()
12
+ try:
13
+ urllib.request.urlopen(PING_URL, timeout=10)
14
+ print(f"[{timestamp}] Pinged {PING_URL} - OK")
15
+ except Exception as e:
16
+ print(f"[{timestamp}] Ping failed: {e}")
17
+
18
+ if __name__ == "__main__":
19
+ main()
start.sh ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # MarkItDown API - Production Startup Script
3
+ #
4
+ # Environment variable overrides:
5
+ # LOG_DIR - directory for persistent log files (default: /app/logs)
6
+ # HOST - bind address (default: 0.0.0.0)
7
+ # PORT - listen port (default: 7860)
8
+ # WORKERS - uvicorn worker count (default: 1)
9
+ # LOG_LEVEL - uvicorn log level (default: info)
10
+
11
+ set -euo pipefail
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # Logging helpers
15
+ # ---------------------------------------------------------------------------
16
+ LOG_DIR="${LOG_DIR:-/app/logs}"
17
+ mkdir -p "$LOG_DIR"
18
+ LOG_FILE="$LOG_DIR/startup.log"
19
+
20
+ _log() {
21
+ local level="$1"
22
+ shift
23
+ local ts
24
+ ts="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
25
+ local line="[$ts] [$level] $*"
26
+ echo "$line"
27
+ echo "$line" >> "$LOG_FILE"
28
+ }
29
+
30
+ log_info() { _log "INFO " "$@"; }
31
+ log_ok() { _log "OK " "$@"; }
32
+ log_warn() { _log "WARN " "$@"; }
33
+ log_err() { _log "ERROR" "$@"; }
34
+ log_step() { echo ""; _log "STEP " "---- $* ----"; }
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Step 1: Verify core Python dependencies
38
+ # ---------------------------------------------------------------------------
39
+ log_step "1/2 Verifying Python dependencies"
40
+
41
+ if python -c "import markitdown, fastapi, httpx" 2>/dev/null; then
42
+ log_ok "Core dependencies verified."
43
+ else
44
+ log_err "Missing core dependencies. Ensure requirements.txt has been installed."
45
+ exit 1
46
+ fi
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Step 2: Smoke-test imports
50
+ # ---------------------------------------------------------------------------
51
+ log_step "2/2 Running import smoke test"
52
+
53
+ python >> "$LOG_FILE" 2>&1 << PYEOF
54
+ import sys
55
+ try:
56
+ import markitdown, fastapi, httpx, pandas
57
+ print("[smoke-test] All core imports successful.")
58
+ except ImportError as exc:
59
+ print(f"[smoke-test] Import error: {exc}")
60
+ sys.exit(1)
61
+ PYEOF
62
+
63
+ if [ $? -eq 0 ]; then
64
+ log_ok "Import smoke test passed."
65
+ else
66
+ log_warn "Import smoke test failed. Check $LOG_FILE for details. Continuing startup."
67
+ fi
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Start uvicorn
71
+ # ---------------------------------------------------------------------------
72
+ log_step "Starting uvicorn"
73
+
74
+ HOST="${HOST:-0.0.0.0}"
75
+ PORT="${PORT:-7860}"
76
+ WORKERS="${WORKERS:-1}"
77
+ LOG_LEVEL="${LOG_LEVEL:-info}"
78
+
79
+ log_info "Binding to $HOST:$PORT | workers=$WORKERS | log_level=$LOG_LEVEL"
80
+
81
+ exec python -m uvicorn api.server:app \
82
+ --host "$HOST" \
83
+ --port "$PORT" \
84
+ --workers "$WORKERS" \
85
+ --log-level "$LOG_LEVEL" \
86
+ --access-log