Spaces:
Sleeping
Sleeping
Commit ·
abcd0c2
0
Parent(s):
ok
Browse files- .gitignore +127 -0
- Dockerfile +54 -0
- README.md +9 -0
- __init__.py +8 -0
- api/__init__.py +3 -0
- api/server.py +675 -0
- core/__init__.py +15 -0
- core/batch.py +135 -0
- core/converter.py +259 -0
- core/ocr_engine.py +197 -0
- core/output.py +127 -0
- extraction/__init__.py +0 -0
- extraction/generic_json_extractor.py +66 -0
- extraction/json_extractor.py +176 -0
- extraction/label_mapper.py +25 -0
- extraction/spacy_extractor.py +707 -0
- logger.py +136 -0
- main.py +27 -0
- requirements.txt +21 -0
- self_ping.py +19 -0
- start.sh +86 -0
.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
|