Spaces:
Running
Running
Soumik Bose commited on
Commit ·
6c24b50
0
Parent(s):
ok
Browse files- .gitignore +119 -0
- Dockerfile +40 -0
- README.md +9 -0
- __init__.py +4 -0
- app/__init__.py +1 -0
- app/api/__init__.py +1 -0
- app/api/deps.py +29 -0
- app/api/server.py +92 -0
- app/api/v1/__init__.py +1 -0
- app/api/v1/batch.py +150 -0
- app/api/v1/convert.py +217 -0
- app/api/v1/router.py +10 -0
- app/api/v1/system.py +110 -0
- app/config.py +46 -0
- app/core/__init__.py +1 -0
- app/core/banner.py +21 -0
- app/core/constants.py +37 -0
- app/core/logger.py +66 -0
- app/core/security.py +33 -0
- app/models/__init__.py +30 -0
- app/models/domain.py +31 -0
- app/models/schemas.py +106 -0
- app/services/__init__.py +13 -0
- app/services/auth_service.py +11 -0
- app/services/converter_service.py +145 -0
- app/services/extraction_service.py +537 -0
- app/services/ocr_service.py +127 -0
- main.py +19 -0
- postman_collection.json +321 -0
- pyproject.toml +37 -0
- requirements.txt +16 -0
- start.sh +78 -0
.gitignore
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
*$py.class
|
| 4 |
+
|
| 5 |
+
*.so
|
| 6 |
+
|
| 7 |
+
.Python
|
| 8 |
+
build/
|
| 9 |
+
develop-eggs/
|
| 10 |
+
dist/
|
| 11 |
+
downloads/
|
| 12 |
+
eggs/
|
| 13 |
+
.eggs/
|
| 14 |
+
lib/
|
| 15 |
+
lib64/
|
| 16 |
+
parts/
|
| 17 |
+
sdist/
|
| 18 |
+
var/
|
| 19 |
+
wheels/
|
| 20 |
+
pip-wheel-metadata/
|
| 21 |
+
share/python-wheels/
|
| 22 |
+
*.egg-info/
|
| 23 |
+
.installed.cfg
|
| 24 |
+
*.egg
|
| 25 |
+
MANIFEST
|
| 26 |
+
|
| 27 |
+
*.manifest
|
| 28 |
+
*.spec
|
| 29 |
+
|
| 30 |
+
pip-log.txt
|
| 31 |
+
pip-delete-this-directory.txt
|
| 32 |
+
|
| 33 |
+
htmlcov/
|
| 34 |
+
.tox/
|
| 35 |
+
.nox/
|
| 36 |
+
.coverage
|
| 37 |
+
.coverage.*
|
| 38 |
+
.cache
|
| 39 |
+
nosetests.xml
|
| 40 |
+
coverage.xml
|
| 41 |
+
*.cover
|
| 42 |
+
*.py,cover
|
| 43 |
+
.hypothesis/
|
| 44 |
+
.pytest_cache/
|
| 45 |
+
|
| 46 |
+
*.mo
|
| 47 |
+
*.pot
|
| 48 |
+
|
| 49 |
+
*.log
|
| 50 |
+
local_settings.py
|
| 51 |
+
db.sqlite3
|
| 52 |
+
db.sqlite3-journal
|
| 53 |
+
|
| 54 |
+
instance/
|
| 55 |
+
.webassets-cache
|
| 56 |
+
|
| 57 |
+
.scrapy
|
| 58 |
+
|
| 59 |
+
docs/_build/
|
| 60 |
+
|
| 61 |
+
target/
|
| 62 |
+
|
| 63 |
+
.ipynb_checkpoints
|
| 64 |
+
|
| 65 |
+
profile_default/
|
| 66 |
+
ipython_config.py
|
| 67 |
+
|
| 68 |
+
.python-version
|
| 69 |
+
|
| 70 |
+
__pypackages__/
|
| 71 |
+
|
| 72 |
+
celerybeat-schedule
|
| 73 |
+
celerybeat.pid
|
| 74 |
+
|
| 75 |
+
*.sage.py
|
| 76 |
+
|
| 77 |
+
.env
|
| 78 |
+
.venv
|
| 79 |
+
env/
|
| 80 |
+
venv/
|
| 81 |
+
ENV/
|
| 82 |
+
env.bak/
|
| 83 |
+
venv.bak/
|
| 84 |
+
|
| 85 |
+
.spyderproject
|
| 86 |
+
.spyproject
|
| 87 |
+
|
| 88 |
+
.ropeproject
|
| 89 |
+
|
| 90 |
+
/site
|
| 91 |
+
|
| 92 |
+
.mypy_cache/
|
| 93 |
+
.dmypy.json
|
| 94 |
+
dmypy.json
|
| 95 |
+
|
| 96 |
+
.pyre/
|
| 97 |
+
|
| 98 |
+
.vscode/
|
| 99 |
+
.idea/
|
| 100 |
+
*.swp
|
| 101 |
+
*.swo
|
| 102 |
+
*~
|
| 103 |
+
|
| 104 |
+
.DS_Store
|
| 105 |
+
.DS_Store?
|
| 106 |
+
._*
|
| 107 |
+
.Spotlight-V100
|
| 108 |
+
.Trashes
|
| 109 |
+
ehthumbs.db
|
| 110 |
+
Thumbs.db
|
| 111 |
+
|
| 112 |
+
logs/
|
| 113 |
+
*.log
|
| 114 |
+
|
| 115 |
+
*.db
|
| 116 |
+
*.sqlite3
|
| 117 |
+
|
| 118 |
+
*.tmp
|
| 119 |
+
*.temp
|
Dockerfile
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
LABEL maintainer="reconciliation-non-ai-extractor"
|
| 4 |
+
LABEL description="Enterprise document extraction API with Microsoft MarkItDown, RapidOCR, and spaCy"
|
| 5 |
+
LABEL version="1.0.0"
|
| 6 |
+
|
| 7 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 8 |
+
curl \
|
| 9 |
+
ffmpeg \
|
| 10 |
+
libmagic1 \
|
| 11 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 12 |
+
|
| 13 |
+
RUN groupadd --gid 1000 appuser && \
|
| 14 |
+
useradd --uid 1000 --gid appuser --shell /bin/bash --create-home appuser
|
| 15 |
+
|
| 16 |
+
WORKDIR /app
|
| 17 |
+
|
| 18 |
+
COPY requirements.txt .
|
| 19 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 20 |
+
pip install --no-cache-dir -r requirements.txt && \
|
| 21 |
+
python -m spacy download en_core_web_sm
|
| 22 |
+
|
| 23 |
+
COPY --chown=appuser:appuser . .
|
| 24 |
+
|
| 25 |
+
RUN mkdir -p /app/logs && \
|
| 26 |
+
chown -R appuser:appuser /app/logs
|
| 27 |
+
|
| 28 |
+
RUN chmod +x /app/start.sh
|
| 29 |
+
|
| 30 |
+
USER appuser
|
| 31 |
+
|
| 32 |
+
ENV PYTHONPATH=/app
|
| 33 |
+
ENV PYTHONUNBUFFERED=1
|
| 34 |
+
|
| 35 |
+
EXPOSE 7860
|
| 36 |
+
|
| 37 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
| 38 |
+
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/health')" || exit 1
|
| 39 |
+
|
| 40 |
+
CMD ["/bin/bash", "/app/start.sh"]
|
README.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: MarkItDown API — v2.1.0
|
| 3 |
+
emoji: ⚡
|
| 4 |
+
colorFrom: red
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
app_port: 7860
|
| 9 |
+
---
|
__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
__version__ = "1.0.0"
|
| 4 |
+
__app_name__ = "reconciliation-non-ai-extractor"
|
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
app/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
app/api/deps.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import Depends
|
| 4 |
+
|
| 5 |
+
from app.core.security import require_api_key
|
| 6 |
+
from app.services.auth_service import AuthService
|
| 7 |
+
from app.services.converter_service import ConverterService
|
| 8 |
+
from app.services.extraction_service import ExtractionService
|
| 9 |
+
from app.services.ocr_service import OCRService
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def get_auth_service() -> AuthService:
|
| 13 |
+
return AuthService()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def get_converter_service() -> ConverterService:
|
| 17 |
+
return ConverterService()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def get_ocr_service() -> OCRService:
|
| 21 |
+
return OCRService()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def get_extraction_service() -> ExtractionService:
|
| 25 |
+
return ExtractionService()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def require_auth(token: str = Depends(require_api_key)) -> str:
|
| 29 |
+
return token
|
app/api/server.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import os
|
| 5 |
+
import time
|
| 6 |
+
from contextlib import asynccontextmanager
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
|
| 9 |
+
from fastapi import FastAPI
|
| 10 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
+
from fastapi.middleware.gzip import GZipMiddleware
|
| 12 |
+
|
| 13 |
+
from app.config import get_settings
|
| 14 |
+
from app.core.banner import print_banner
|
| 15 |
+
from app.core.constants import SUPPORTED_EXTENSIONS
|
| 16 |
+
from app.core.logger import get_logger
|
| 17 |
+
from app.api.v1.router import api_v1_router
|
| 18 |
+
|
| 19 |
+
_logger = get_logger(__name__)
|
| 20 |
+
_settings = get_settings()
|
| 21 |
+
_START_TIME = time.time()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
async def _self_ping():
|
| 25 |
+
import httpx
|
| 26 |
+
health_url = f"http://{_settings.host}:{_settings.port}/health"
|
| 27 |
+
while True:
|
| 28 |
+
try:
|
| 29 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 30 |
+
response = await client.get(health_url)
|
| 31 |
+
if response.status_code == 200:
|
| 32 |
+
_logger.info("Self-ping successful: %s", health_url)
|
| 33 |
+
else:
|
| 34 |
+
_logger.warning("Self-ping returned: %s - %s", health_url, response.status_code)
|
| 35 |
+
except Exception as exc:
|
| 36 |
+
_logger.error("Self-ping error: %s", exc)
|
| 37 |
+
await asyncio.sleep(3600)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@asynccontextmanager
|
| 41 |
+
async def lifespan(app: FastAPI):
|
| 42 |
+
print_banner()
|
| 43 |
+
_logger.info("Server ready at http://%s:%s", _settings.host, _settings.port)
|
| 44 |
+
_logger.info("Swagger UI : http://%s:%s/docs", _settings.host, _settings.port)
|
| 45 |
+
_logger.info("ReDoc : http://%s:%s/redoc", _settings.host, _settings.port)
|
| 46 |
+
_logger.info("Started at : %s", datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"))
|
| 47 |
+
asyncio.create_task(_self_ping())
|
| 48 |
+
_logger.info("Self-ping task started (every hour)")
|
| 49 |
+
yield
|
| 50 |
+
_logger.info("Application shutting down gracefully")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def create_application() -> FastAPI:
|
| 54 |
+
app = FastAPI(
|
| 55 |
+
title=_settings.app_name,
|
| 56 |
+
description="Document-to-Markdown and structured data extraction API powered by Microsoft MarkItDown, RapidOCR, and spaCy.",
|
| 57 |
+
version=_settings.app_version,
|
| 58 |
+
docs_url="/docs",
|
| 59 |
+
redoc_url="/redoc",
|
| 60 |
+
openapi_tags=[
|
| 61 |
+
{"name": "Convert", "description": "Single-file and single-URL conversion"},
|
| 62 |
+
{"name": "Batch", "description": "Bulk conversion of files and URLs"},
|
| 63 |
+
{"name": "System", "description": "Health, info, and supported formats"},
|
| 64 |
+
],
|
| 65 |
+
lifespan=lifespan,
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
| 69 |
+
app.add_middleware(
|
| 70 |
+
CORSMiddleware,
|
| 71 |
+
allow_origins=["*"],
|
| 72 |
+
allow_methods=["*"],
|
| 73 |
+
allow_headers=["*"],
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
app.include_router(api_v1_router, prefix="/v1")
|
| 77 |
+
|
| 78 |
+
@app.get("/health", include_in_schema=False)
|
| 79 |
+
async def root_health():
|
| 80 |
+
return {"status": "ok", "version": _settings.app_version}
|
| 81 |
+
|
| 82 |
+
@app.get("/ping", include_in_schema=False)
|
| 83 |
+
async def ping():
|
| 84 |
+
return {"message": f"{_settings.app_name} is running..."}
|
| 85 |
+
|
| 86 |
+
_max_workers = min(32, (os.cpu_count() or 1) + 4)
|
| 87 |
+
_logger.info("Initialized thread pool with %s workers", _max_workers)
|
| 88 |
+
|
| 89 |
+
return app
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
app = create_application()
|
app/api/v1/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
app/api/v1/batch.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import concurrent.futures
|
| 5 |
+
import os
|
| 6 |
+
import time
|
| 7 |
+
from typing import Annotated, List
|
| 8 |
+
|
| 9 |
+
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
| 10 |
+
|
| 11 |
+
from app.api.deps import get_converter_service, require_auth
|
| 12 |
+
from app.config import get_settings
|
| 13 |
+
from app.core.logger import get_logger
|
| 14 |
+
from app.models.domain import ConversionError
|
| 15 |
+
from app.models.schemas import (
|
| 16 |
+
BatchFileResult,
|
| 17 |
+
BatchResponse,
|
| 18 |
+
BatchUrlRequest,
|
| 19 |
+
ConversionMetadata,
|
| 20 |
+
)
|
| 21 |
+
from app.services.converter_service import ConverterService
|
| 22 |
+
|
| 23 |
+
router = APIRouter()
|
| 24 |
+
_logger = get_logger(__name__)
|
| 25 |
+
_settings = get_settings()
|
| 26 |
+
_MAX_UPLOAD_BYTES = _settings.max_upload_bytes
|
| 27 |
+
_MAX_BATCH_FILES = _settings.max_batch_files
|
| 28 |
+
_MAX_WORKERS = min(32, (os.cpu_count() or 1) + 4)
|
| 29 |
+
_thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=_MAX_WORKERS)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _build_metadata(result) -> ConversionMetadata:
|
| 33 |
+
return ConversionMetadata(
|
| 34 |
+
source=result.source,
|
| 35 |
+
char_count=result.char_count,
|
| 36 |
+
word_count=result.word_count,
|
| 37 |
+
line_count=result.line_count,
|
| 38 |
+
file_size_bytes=result.file_size_bytes,
|
| 39 |
+
mime_type=result.mime_type,
|
| 40 |
+
content_hash=result.content_hash,
|
| 41 |
+
token_estimate=result.token_estimate,
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _batch_result_from_error(name: str, err: ConversionError) -> BatchFileResult:
|
| 46 |
+
return BatchFileResult(
|
| 47 |
+
filename=name,
|
| 48 |
+
success=False,
|
| 49 |
+
time_ms=round(err.duration_ms, 3),
|
| 50 |
+
error=err.message,
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _batch_result_from_ok(result) -> BatchFileResult:
|
| 55 |
+
return BatchFileResult(
|
| 56 |
+
filename=result.source,
|
| 57 |
+
success=True,
|
| 58 |
+
time_ms=round(result.duration_ms, 3),
|
| 59 |
+
content=result.markdown,
|
| 60 |
+
metadata=_build_metadata(result),
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@router.post(
|
| 65 |
+
"/batch/files",
|
| 66 |
+
response_model=BatchResponse,
|
| 67 |
+
summary="Convert multiple files (up to 10)",
|
| 68 |
+
)
|
| 69 |
+
async def batch_files(
|
| 70 |
+
files: Annotated[List[UploadFile], File(description="Files to convert")],
|
| 71 |
+
token: str = require_auth,
|
| 72 |
+
converter_service: ConverterService = Depends(get_converter_service),
|
| 73 |
+
):
|
| 74 |
+
if not files:
|
| 75 |
+
raise HTTPException(status_code=400, detail={"success": False, "message": "No files provided."})
|
| 76 |
+
if len(files) > _MAX_BATCH_FILES:
|
| 77 |
+
raise HTTPException(status_code=400, detail={"success": False, "message": f"Maximum {_MAX_BATCH_FILES} files per batch."})
|
| 78 |
+
|
| 79 |
+
batch_start = time.perf_counter()
|
| 80 |
+
|
| 81 |
+
async def process_single_file(f: UploadFile) -> BatchFileResult:
|
| 82 |
+
if f is None:
|
| 83 |
+
return BatchFileResult(filename="unknown", success=False, time_ms=0, error="File object is None.")
|
| 84 |
+
_logger.info("Batch processing file: %s", f.filename)
|
| 85 |
+
raw = await f.read()
|
| 86 |
+
if len(raw) > _MAX_UPLOAD_BYTES:
|
| 87 |
+
return BatchFileResult(
|
| 88 |
+
filename=f.filename or "unknown",
|
| 89 |
+
success=False,
|
| 90 |
+
time_ms=0,
|
| 91 |
+
error=f"File exceeds {_settings.max_upload_mb} MB limit.",
|
| 92 |
+
)
|
| 93 |
+
loop = asyncio.get_running_loop()
|
| 94 |
+
outcome = await loop.run_in_executor(_thread_pool, converter_service.convert_stream, raw, f.filename or "upload")
|
| 95 |
+
return (
|
| 96 |
+
_batch_result_from_error(f.filename or "unknown", outcome)
|
| 97 |
+
if isinstance(outcome, ConversionError)
|
| 98 |
+
else _batch_result_from_ok(outcome)
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
tasks = [process_single_file(f) for f in files]
|
| 102 |
+
results = await asyncio.gather(*tasks)
|
| 103 |
+
|
| 104 |
+
total_ms = round((time.perf_counter() - batch_start) * 1000, 3)
|
| 105 |
+
succeeded = sum(1 for r in results if r.success)
|
| 106 |
+
_logger.info("Batch files completed. Succeeded: %s/%s", succeeded, len(results))
|
| 107 |
+
return BatchResponse(
|
| 108 |
+
total=len(results),
|
| 109 |
+
succeeded=succeeded,
|
| 110 |
+
failed=len(results) - succeeded,
|
| 111 |
+
total_time_ms=total_ms,
|
| 112 |
+
results=results,
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@router.post(
|
| 117 |
+
"/batch/urls",
|
| 118 |
+
response_model=BatchResponse,
|
| 119 |
+
summary="Convert multiple URLs (up to 20)",
|
| 120 |
+
)
|
| 121 |
+
async def batch_urls(
|
| 122 |
+
body: BatchUrlRequest,
|
| 123 |
+
token: str = require_auth,
|
| 124 |
+
converter_service: ConverterService = Depends(get_converter_service),
|
| 125 |
+
):
|
| 126 |
+
batch_start = time.perf_counter()
|
| 127 |
+
|
| 128 |
+
async def process_single_url(url: str) -> BatchFileResult:
|
| 129 |
+
_logger.info("Batch processing URL: %s", url)
|
| 130 |
+
loop = asyncio.get_running_loop()
|
| 131 |
+
outcome = await loop.run_in_executor(_thread_pool, converter_service.convert_url, url)
|
| 132 |
+
return (
|
| 133 |
+
_batch_result_from_error(url, outcome)
|
| 134 |
+
if isinstance(outcome, ConversionError)
|
| 135 |
+
else _batch_result_from_ok(outcome)
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
tasks = [process_single_url(url) for url in body.urls]
|
| 139 |
+
results = await asyncio.gather(*tasks)
|
| 140 |
+
|
| 141 |
+
total_ms = round((time.perf_counter() - batch_start) * 1000, 3)
|
| 142 |
+
succeeded = sum(1 for r in results if r.success)
|
| 143 |
+
_logger.info("Batch URLs completed. Succeeded: %s/%s", succeeded, len(results))
|
| 144 |
+
return BatchResponse(
|
| 145 |
+
total=len(results),
|
| 146 |
+
succeeded=succeeded,
|
| 147 |
+
failed=len(results) - succeeded,
|
| 148 |
+
total_time_ms=total_ms,
|
| 149 |
+
results=results,
|
| 150 |
+
)
|
app/api/v1/convert.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import concurrent.futures
|
| 5 |
+
import json as json_mod
|
| 6 |
+
import os
|
| 7 |
+
from typing import Annotated, Any, Dict, Optional
|
| 8 |
+
from urllib.parse import urlparse
|
| 9 |
+
|
| 10 |
+
import httpx
|
| 11 |
+
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
| 12 |
+
from pydantic import BaseModel
|
| 13 |
+
|
| 14 |
+
from app.config import get_settings
|
| 15 |
+
from app.api.deps import (
|
| 16 |
+
get_converter_service,
|
| 17 |
+
get_extraction_service,
|
| 18 |
+
require_auth,
|
| 19 |
+
)
|
| 20 |
+
from app.core.logger import get_logger
|
| 21 |
+
from app.models.domain import ConversionError
|
| 22 |
+
from app.models.schemas import ConversionMetadata, ConversionResponse, UrlRequest
|
| 23 |
+
from app.services.converter_service import ConverterService
|
| 24 |
+
from app.services.extraction_service import ExtractionService
|
| 25 |
+
|
| 26 |
+
router = APIRouter()
|
| 27 |
+
_logger = get_logger(__name__)
|
| 28 |
+
_settings = get_settings()
|
| 29 |
+
_MAX_UPLOAD_BYTES = _settings.max_upload_bytes
|
| 30 |
+
_MAX_WORKERS = min(32, (os.cpu_count() or 1) + 4)
|
| 31 |
+
_thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=_MAX_WORKERS)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _build_metadata(result) -> ConversionMetadata:
|
| 35 |
+
return ConversionMetadata(
|
| 36 |
+
source=result.source,
|
| 37 |
+
char_count=result.char_count,
|
| 38 |
+
word_count=result.word_count,
|
| 39 |
+
line_count=result.line_count,
|
| 40 |
+
file_size_bytes=result.file_size_bytes,
|
| 41 |
+
mime_type=result.mime_type,
|
| 42 |
+
content_hash=result.content_hash,
|
| 43 |
+
token_estimate=result.token_estimate,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
async def _build_response(
|
| 48 |
+
result,
|
| 49 |
+
*,
|
| 50 |
+
return_json: bool = False,
|
| 51 |
+
filename: Optional[str] = None,
|
| 52 |
+
raw_data: Optional[bytes] = None,
|
| 53 |
+
mappings: Optional[Dict[str, Dict[str, Any]]] = None,
|
| 54 |
+
extraction_service: ExtractionService = None,
|
| 55 |
+
) -> ConversionResponse:
|
| 56 |
+
json_content: Optional[Any] = None
|
| 57 |
+
error_message: Optional[str] = None
|
| 58 |
+
|
| 59 |
+
if return_json and filename and extraction_service:
|
| 60 |
+
loop = asyncio.get_running_loop()
|
| 61 |
+
json_result = await loop.run_in_executor(
|
| 62 |
+
_thread_pool,
|
| 63 |
+
extraction_service.extract_structured,
|
| 64 |
+
filename,
|
| 65 |
+
result.markdown,
|
| 66 |
+
mappings,
|
| 67 |
+
raw_data,
|
| 68 |
+
)
|
| 69 |
+
if "error" in json_result:
|
| 70 |
+
error_message = json_result["error"]
|
| 71 |
+
else:
|
| 72 |
+
json_content = json_result
|
| 73 |
+
|
| 74 |
+
return ConversionResponse(
|
| 75 |
+
success=True,
|
| 76 |
+
time_ms=round(result.duration_ms, 3),
|
| 77 |
+
content=result.markdown,
|
| 78 |
+
return_json=return_json,
|
| 79 |
+
json_content=json_content,
|
| 80 |
+
metadata=_build_metadata(result),
|
| 81 |
+
error_message=error_message,
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _raise_for_error(outcome: ConversionError) -> None:
|
| 86 |
+
status_map = {
|
| 87 |
+
"FileNotFoundError": status.HTTP_404_NOT_FOUND,
|
| 88 |
+
"ValueError": status.HTTP_422_UNPROCESSABLE_ENTITY,
|
| 89 |
+
"PermissionError": status.HTTP_403_FORBIDDEN,
|
| 90 |
+
}
|
| 91 |
+
code = status_map.get(outcome.error_type, status.HTTP_500_INTERNAL_SERVER_ERROR)
|
| 92 |
+
raise HTTPException(
|
| 93 |
+
status_code=code,
|
| 94 |
+
detail={
|
| 95 |
+
"success": False,
|
| 96 |
+
"error_type": outcome.error_type,
|
| 97 |
+
"message": outcome.message,
|
| 98 |
+
"time_ms": round(outcome.duration_ms, 3),
|
| 99 |
+
},
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@router.post(
|
| 104 |
+
"/convert/file",
|
| 105 |
+
response_model=ConversionResponse,
|
| 106 |
+
summary="Convert uploaded file to Markdown",
|
| 107 |
+
)
|
| 108 |
+
async def convert_file(
|
| 109 |
+
file: Annotated[UploadFile, File(description="File to convert")],
|
| 110 |
+
plain_text: bool = Form(False),
|
| 111 |
+
return_json: bool = Form(False),
|
| 112 |
+
mappings: Optional[str] = Form(None, description="JSON string with field mappings"),
|
| 113 |
+
token: str = require_auth,
|
| 114 |
+
converter_service: ConverterService = Depends(get_converter_service),
|
| 115 |
+
extraction_service: ExtractionService = Depends(get_extraction_service),
|
| 116 |
+
):
|
| 117 |
+
if file is None:
|
| 118 |
+
raise HTTPException(status_code=400, detail={"success": False, "message": "No file provided."})
|
| 119 |
+
|
| 120 |
+
parsed_mappings = None
|
| 121 |
+
if mappings:
|
| 122 |
+
try:
|
| 123 |
+
parsed_mappings = json_mod.loads(mappings)
|
| 124 |
+
except json_mod.JSONDecodeError:
|
| 125 |
+
raise HTTPException(status_code=400, detail={"success": False, "message": "Invalid JSON in mappings parameter."})
|
| 126 |
+
|
| 127 |
+
_logger.info("Received request to convert file: %s", file.filename)
|
| 128 |
+
raw = await file.read()
|
| 129 |
+
if len(raw) > _MAX_UPLOAD_BYTES:
|
| 130 |
+
_logger.error("File %s exceeds %s MB limit", file.filename, _settings.max_upload_mb)
|
| 131 |
+
raise HTTPException(status_code=413, detail={"success": False, "message": f"File exceeds {_settings.max_upload_mb} MB limit."})
|
| 132 |
+
|
| 133 |
+
loop = asyncio.get_running_loop()
|
| 134 |
+
outcome = await loop.run_in_executor(_thread_pool, converter_service.convert_stream, raw, file.filename or "upload")
|
| 135 |
+
if isinstance(outcome, ConversionError):
|
| 136 |
+
_logger.error("Conversion failed for %s: %s", file.filename, outcome.message)
|
| 137 |
+
_raise_for_error(outcome)
|
| 138 |
+
|
| 139 |
+
_logger.info("Conversion successful for %s", file.filename)
|
| 140 |
+
if plain_text:
|
| 141 |
+
from fastapi.responses import PlainTextResponse
|
| 142 |
+
return PlainTextResponse(outcome.markdown)
|
| 143 |
+
|
| 144 |
+
response = await _build_response(
|
| 145 |
+
outcome,
|
| 146 |
+
return_json=return_json,
|
| 147 |
+
filename=file.filename,
|
| 148 |
+
raw_data=raw,
|
| 149 |
+
mappings=parsed_mappings,
|
| 150 |
+
extraction_service=extraction_service,
|
| 151 |
+
)
|
| 152 |
+
_logger.info("Request completed for %s", file.filename)
|
| 153 |
+
return response
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
@router.post(
|
| 157 |
+
"/convert/url",
|
| 158 |
+
response_model=ConversionResponse,
|
| 159 |
+
summary="Convert a URL to Markdown",
|
| 160 |
+
)
|
| 161 |
+
async def convert_url(
|
| 162 |
+
body: UrlRequest,
|
| 163 |
+
token: str = require_auth,
|
| 164 |
+
converter_service: ConverterService = Depends(get_converter_service),
|
| 165 |
+
extraction_service: ExtractionService = Depends(get_extraction_service),
|
| 166 |
+
):
|
| 167 |
+
_logger.info("Received request to convert URL: %s", body.url)
|
| 168 |
+
parsed = urlparse(body.url)
|
| 169 |
+
filename = parsed.path.split("/")[-1] or "url_content"
|
| 170 |
+
|
| 171 |
+
if body.return_json:
|
| 172 |
+
try:
|
| 173 |
+
_logger.info("Fetching URL for JSON extraction: %s", body.url)
|
| 174 |
+
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
| 175 |
+
resp = await client.get(body.url)
|
| 176 |
+
resp.raise_for_status()
|
| 177 |
+
raw_data = resp.content
|
| 178 |
+
if len(raw_data) > _MAX_UPLOAD_BYTES:
|
| 179 |
+
raise HTTPException(status_code=413, detail={"success": False, "message": f"File exceeds {_settings.max_upload_mb} MB limit."})
|
| 180 |
+
|
| 181 |
+
loop = asyncio.get_running_loop()
|
| 182 |
+
outcome = await loop.run_in_executor(_thread_pool, converter_service.convert_stream, raw_data, filename)
|
| 183 |
+
if isinstance(outcome, ConversionError):
|
| 184 |
+
_logger.error("Conversion failed for URL %s: %s", body.url, outcome.message)
|
| 185 |
+
_raise_for_error(outcome)
|
| 186 |
+
|
| 187 |
+
_logger.info("Conversion successful for URL %s", body.url)
|
| 188 |
+
response = await _build_response(
|
| 189 |
+
outcome,
|
| 190 |
+
return_json=body.return_json,
|
| 191 |
+
filename=filename,
|
| 192 |
+
raw_data=raw_data,
|
| 193 |
+
mappings=body.mappings,
|
| 194 |
+
extraction_service=extraction_service,
|
| 195 |
+
)
|
| 196 |
+
_logger.info("Request completed for URL %s", body.url)
|
| 197 |
+
return response
|
| 198 |
+
except httpx.HTTPError as exc:
|
| 199 |
+
_logger.error("Failed to fetch URL %s: %s", body.url, exc)
|
| 200 |
+
raise HTTPException(status_code=400, detail={"success": False, "message": f"Failed to fetch URL: {exc}"})
|
| 201 |
+
|
| 202 |
+
loop = asyncio.get_running_loop()
|
| 203 |
+
outcome = await loop.run_in_executor(_thread_pool, converter_service.convert_url, body.url)
|
| 204 |
+
if isinstance(outcome, ConversionError):
|
| 205 |
+
_logger.error("Conversion failed for URL %s: %s", body.url, outcome.message)
|
| 206 |
+
_raise_for_error(outcome)
|
| 207 |
+
|
| 208 |
+
_logger.info("Conversion successful for URL %s", body.url)
|
| 209 |
+
response = await _build_response(
|
| 210 |
+
outcome,
|
| 211 |
+
return_json=body.return_json,
|
| 212 |
+
filename=filename,
|
| 213 |
+
mappings=body.mappings,
|
| 214 |
+
extraction_service=extraction_service,
|
| 215 |
+
)
|
| 216 |
+
_logger.info("Request completed for URL %s", body.url)
|
| 217 |
+
return response
|
app/api/v1/router.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter
|
| 4 |
+
|
| 5 |
+
from app.api.v1 import batch, convert, system
|
| 6 |
+
|
| 7 |
+
api_v1_router = APIRouter()
|
| 8 |
+
api_v1_router.include_router(convert.router, tags=["Convert"])
|
| 9 |
+
api_v1_router.include_router(batch.router, tags=["Batch"])
|
| 10 |
+
api_v1_router.include_router(system.router, tags=["System"])
|
app/api/v1/system.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import platform
|
| 4 |
+
import time
|
| 5 |
+
from datetime import datetime, timezone
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends
|
| 8 |
+
|
| 9 |
+
from app.api.deps import get_extraction_service, require_auth
|
| 10 |
+
from app.config import get_settings
|
| 11 |
+
from app.core.constants import (
|
| 12 |
+
ARCHIVE_EXTENSIONS,
|
| 13 |
+
AUDIO_EXTENSIONS,
|
| 14 |
+
DOCUMENT_EXTENSIONS,
|
| 15 |
+
IMAGE_EXTENSIONS,
|
| 16 |
+
OFFICE_EXTENSIONS,
|
| 17 |
+
SUPPORTED_EXTENSIONS,
|
| 18 |
+
TEXT_EXTENSIONS,
|
| 19 |
+
WEB_EXTENSIONS,
|
| 20 |
+
)
|
| 21 |
+
from app.models.schemas import (
|
| 22 |
+
HealthResponse,
|
| 23 |
+
InfoResponse,
|
| 24 |
+
SpacyLabelsResponse,
|
| 25 |
+
SupportedFormatsResponse,
|
| 26 |
+
)
|
| 27 |
+
from app.services.extraction_service import ExtractionService
|
| 28 |
+
|
| 29 |
+
router = APIRouter()
|
| 30 |
+
_settings = get_settings()
|
| 31 |
+
_START_TIME = time.time()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@router.get("/health", response_model=HealthResponse, summary="Health check")
|
| 35 |
+
async def health(
|
| 36 |
+
token: str = require_auth,
|
| 37 |
+
):
|
| 38 |
+
return HealthResponse(
|
| 39 |
+
success=True,
|
| 40 |
+
status="ok",
|
| 41 |
+
version=_settings.app_version,
|
| 42 |
+
uptime_seconds=round(time.time() - _START_TIME, 2),
|
| 43 |
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@router.get("/info", response_model=InfoResponse, summary="Server and environment information")
|
| 48 |
+
async def info(
|
| 49 |
+
token: str = require_auth,
|
| 50 |
+
):
|
| 51 |
+
return InfoResponse(
|
| 52 |
+
success=True,
|
| 53 |
+
app=_settings.app_name,
|
| 54 |
+
version=_settings.app_version,
|
| 55 |
+
python_version=platform.python_version(),
|
| 56 |
+
platform=platform.system(),
|
| 57 |
+
uptime_seconds=round(time.time() - _START_TIME, 2),
|
| 58 |
+
max_upload_mb=_settings.max_upload_mb,
|
| 59 |
+
supported_extensions=len(SUPPORTED_EXTENSIONS),
|
| 60 |
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@router.get("/formats", response_model=SupportedFormatsResponse, summary="List supported file formats")
|
| 65 |
+
async def list_formats(
|
| 66 |
+
token: str = require_auth,
|
| 67 |
+
):
|
| 68 |
+
by_category = {
|
| 69 |
+
"documents": [e for e in SUPPORTED_EXTENSIONS if e in DOCUMENT_EXTENSIONS],
|
| 70 |
+
"office": [e for e in SUPPORTED_EXTENSIONS if e in OFFICE_EXTENSIONS],
|
| 71 |
+
"data": [e for e in SUPPORTED_EXTENSIONS if e in {".csv", ".json", ".xml"}],
|
| 72 |
+
"web": [e for e in SUPPORTED_EXTENSIONS if e in WEB_EXTENSIONS],
|
| 73 |
+
"text": [e for e in SUPPORTED_EXTENSIONS if e in TEXT_EXTENSIONS],
|
| 74 |
+
"images": [e for e in SUPPORTED_EXTENSIONS if e in IMAGE_EXTENSIONS],
|
| 75 |
+
"audio": [e for e in SUPPORTED_EXTENSIONS if e in AUDIO_EXTENSIONS],
|
| 76 |
+
"archives": [e for e in SUPPORTED_EXTENSIONS if e in ARCHIVE_EXTENSIONS],
|
| 77 |
+
}
|
| 78 |
+
return SupportedFormatsResponse(
|
| 79 |
+
success=True,
|
| 80 |
+
total_count=len(SUPPORTED_EXTENSIONS),
|
| 81 |
+
all_extensions=sorted(SUPPORTED_EXTENSIONS),
|
| 82 |
+
by_category={k: sorted(v) for k, v in by_category.items()},
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@router.get("/spacy-labels", response_model=SpacyLabelsResponse, summary="List available spaCy NER labels")
|
| 87 |
+
async def list_spacy_labels(
|
| 88 |
+
token: str = require_auth,
|
| 89 |
+
extraction_service: ExtractionService = Depends(get_extraction_service),
|
| 90 |
+
):
|
| 91 |
+
return SpacyLabelsResponse(
|
| 92 |
+
success=True,
|
| 93 |
+
spacy_labels=extraction_service.get_spacy_labels(),
|
| 94 |
+
source_types={
|
| 95 |
+
"entity": "Extract using spaCy NER labels (ORG, PERSON, DATE, etc.)",
|
| 96 |
+
"regex": "Extract using custom regular expressions",
|
| 97 |
+
"token_attr": "Extract using token attributes (text, pos_, tag_, etc.)",
|
| 98 |
+
},
|
| 99 |
+
example_mappings={
|
| 100 |
+
"company": {"source_type": "entity", "label": "ORG"},
|
| 101 |
+
"person": {"source_type": "entity", "label": "PERSON"},
|
| 102 |
+
"date": {"source_type": "entity", "label": "DATE"},
|
| 103 |
+
"money": {"source_type": "entity", "label": "MONEY"},
|
| 104 |
+
"email": {
|
| 105 |
+
"source_type": "regex",
|
| 106 |
+
"pattern": r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b",
|
| 107 |
+
},
|
| 108 |
+
"phone": {"source_type": "regex", "pattern": r"\b\d{3}-\d{3}-\d{4}\b"},
|
| 109 |
+
},
|
| 110 |
+
)
|
app/config.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from functools import lru_cache
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class Settings(BaseSettings):
|
| 10 |
+
model_config = SettingsConfigDict(
|
| 11 |
+
env_file=".env",
|
| 12 |
+
env_file_encoding="utf-8",
|
| 13 |
+
extra="ignore",
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
app_name: str = "reconciliation-non-ai-extractor"
|
| 17 |
+
app_version: str = "1.0.0"
|
| 18 |
+
environment: str = "production"
|
| 19 |
+
host: str = "0.0.0.0"
|
| 20 |
+
port: int = 7860
|
| 21 |
+
workers: int = 1
|
| 22 |
+
log_level: str = "INFO"
|
| 23 |
+
enable_colors: bool = True
|
| 24 |
+
|
| 25 |
+
api_key: str = "changeme"
|
| 26 |
+
max_upload_bytes: int = 100 * 1024 * 1024
|
| 27 |
+
max_batch_files: int = 10
|
| 28 |
+
max_batch_urls: int = 20
|
| 29 |
+
|
| 30 |
+
ocr_det_cuda: bool = False
|
| 31 |
+
ocr_det_dml: bool = False
|
| 32 |
+
ocr_cls_cuda: bool = False
|
| 33 |
+
ocr_cls_dml: bool = False
|
| 34 |
+
ocr_rec_cuda: bool = False
|
| 35 |
+
ocr_rec_dml: bool = False
|
| 36 |
+
|
| 37 |
+
spacy_model: str = "en_core_web_sm"
|
| 38 |
+
|
| 39 |
+
@property
|
| 40 |
+
def max_upload_mb(self) -> int:
|
| 41 |
+
return self.max_upload_bytes // (1024 * 1024)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@lru_cache(maxsize=1)
|
| 45 |
+
def get_settings() -> Settings:
|
| 46 |
+
return Settings()
|
app/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
app/core/banner.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import pyfiglet
|
| 4 |
+
from rich.console import Console
|
| 5 |
+
from rich.rule import Rule
|
| 6 |
+
from rich.text import Text
|
| 7 |
+
|
| 8 |
+
from app.config import get_settings
|
| 9 |
+
|
| 10 |
+
_console = Console()
|
| 11 |
+
_settings = get_settings()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def print_banner() -> None:
|
| 15 |
+
art = pyfiglet.figlet_format("ValidOps", font="slant")
|
| 16 |
+
_console.print(Text(art, style="bold cyan"))
|
| 17 |
+
_console.print(f" [bold white]{'Service:':<14}[/bold white] [cyan]{_settings.app_name}[/cyan]")
|
| 18 |
+
_console.print(f" [bold white]{'Version:':<14}[/bold white] [cyan]v{_settings.app_version}[/cyan]")
|
| 19 |
+
_console.print(f" [bold white]{'Environment:':<14}[/bold white] [cyan]{_settings.environment}[/cyan]")
|
| 20 |
+
_console.print(Rule(style="dim cyan"))
|
| 21 |
+
_console.print()
|
app/core/constants.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
SUPPORTED_EXTENSIONS = {
|
| 4 |
+
".pdf", ".docx", ".doc", ".pptx", ".ppt",
|
| 5 |
+
".xlsx", ".xls", ".csv", ".json", ".xml",
|
| 6 |
+
".html", ".htm", ".txt", ".md", ".rst",
|
| 7 |
+
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff",
|
| 8 |
+
".mp3", ".wav", ".ogg", ".flac",
|
| 9 |
+
".zip", ".epub",
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
IMAGE_EXTENSIONS = {
|
| 13 |
+
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff",
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
IMAGE_MIME_PREFIXES = {"image/"}
|
| 17 |
+
|
| 18 |
+
TABULAR_EXTENSIONS = {".csv", ".xls", ".xlsx"}
|
| 19 |
+
|
| 20 |
+
AUDIO_EXTENSIONS = {".mp3", ".wav", ".ogg", ".flac"}
|
| 21 |
+
|
| 22 |
+
DOCUMENT_EXTENSIONS = {".pdf", ".docx", ".doc", ".epub"}
|
| 23 |
+
|
| 24 |
+
OFFICE_EXTENSIONS = {".pptx", ".ppt", ".xlsx", ".xls"}
|
| 25 |
+
|
| 26 |
+
WEB_EXTENSIONS = {".html", ".htm"}
|
| 27 |
+
|
| 28 |
+
TEXT_EXTENSIONS = {".txt", ".md", ".rst"}
|
| 29 |
+
|
| 30 |
+
ARCHIVE_EXTENSIONS = {".zip"}
|
| 31 |
+
|
| 32 |
+
MAX_CSV_ROWS = 100_000
|
| 33 |
+
MAX_EXCEL_ROWS = 50_000
|
| 34 |
+
MAX_MEMORY_CELLS = 2_000_000
|
| 35 |
+
|
| 36 |
+
OCR_TEXT_SCORE = 0.5
|
| 37 |
+
OCR_DPI = 150
|
app/core/logger.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from rich.console import Console
|
| 9 |
+
from rich.logging import RichHandler
|
| 10 |
+
|
| 11 |
+
from app.config import get_settings
|
| 12 |
+
|
| 13 |
+
_settings = get_settings()
|
| 14 |
+
_console = Console()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class AppLogger:
|
| 18 |
+
def __init__(self) -> None:
|
| 19 |
+
self._loggers: dict[str, logging.Logger] = {}
|
| 20 |
+
|
| 21 |
+
def get_logger(self, name: str) -> logging.Logger:
|
| 22 |
+
if name in self._loggers:
|
| 23 |
+
return self._loggers[name]
|
| 24 |
+
|
| 25 |
+
logger = logging.getLogger(name)
|
| 26 |
+
logger.setLevel(getattr(logging, _settings.log_level.upper(), logging.INFO))
|
| 27 |
+
logger.handlers.clear()
|
| 28 |
+
logger.propagate = False
|
| 29 |
+
|
| 30 |
+
rich_handler = RichHandler(
|
| 31 |
+
console=_console,
|
| 32 |
+
rich_tracebacks=True,
|
| 33 |
+
show_path=False,
|
| 34 |
+
show_time=True,
|
| 35 |
+
)
|
| 36 |
+
rich_handler.setFormatter(logging.Formatter("%(message)s"))
|
| 37 |
+
logger.addHandler(rich_handler)
|
| 38 |
+
|
| 39 |
+
file_handler = self._build_file_handler()
|
| 40 |
+
if file_handler:
|
| 41 |
+
logger.addHandler(file_handler)
|
| 42 |
+
|
| 43 |
+
self._loggers[name] = logger
|
| 44 |
+
return logger
|
| 45 |
+
|
| 46 |
+
def _build_file_handler(self) -> Optional[logging.FileHandler]:
|
| 47 |
+
log_dir = Path("logs")
|
| 48 |
+
log_dir.mkdir(parents=True, exist_ok=True)
|
| 49 |
+
file_handler = logging.FileHandler(
|
| 50 |
+
log_dir / f"{_settings.app_name}.log",
|
| 51 |
+
encoding="utf-8",
|
| 52 |
+
)
|
| 53 |
+
file_handler.setFormatter(
|
| 54 |
+
logging.Formatter(
|
| 55 |
+
fmt="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
| 56 |
+
datefmt="%Y-%m-%d %H:%M:%S",
|
| 57 |
+
)
|
| 58 |
+
)
|
| 59 |
+
return file_handler
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
_logger_factory = AppLogger()
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def get_logger(name: str) -> logging.Logger:
|
| 66 |
+
return _logger_factory.get_logger(name)
|
app/core/security.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import HTTPException, Security, status
|
| 4 |
+
from fastapi.security import APIKeyHeader
|
| 5 |
+
|
| 6 |
+
from app.config import get_settings
|
| 7 |
+
from app.core.logger import get_logger
|
| 8 |
+
|
| 9 |
+
_logger = get_logger(__name__)
|
| 10 |
+
_security = APIKeyHeader(name="x-api-key", auto_error=False)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
async def require_api_key(
|
| 14 |
+
api_key: str = Security(_security),
|
| 15 |
+
) -> str:
|
| 16 |
+
settings = get_settings()
|
| 17 |
+
token = settings.API_KEY
|
| 18 |
+
|
| 19 |
+
if not api_key:
|
| 20 |
+
_logger.warning("Missing x-api-key header")
|
| 21 |
+
raise HTTPException(
|
| 22 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 23 |
+
detail="Missing x-api-key header",
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
if api_key != token:
|
| 27 |
+
_logger.warning("Invalid API key provided")
|
| 28 |
+
raise HTTPException(
|
| 29 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 30 |
+
detail="Invalid API key",
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
return api_key
|
app/models/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from app.models.domain import ConversionError, ConversionResult
|
| 4 |
+
from app.models.schemas import (
|
| 5 |
+
BatchFileResult,
|
| 6 |
+
BatchResponse,
|
| 7 |
+
BatchUrlRequest,
|
| 8 |
+
ConversionMetadata,
|
| 9 |
+
ConversionResponse,
|
| 10 |
+
HealthResponse,
|
| 11 |
+
InfoResponse,
|
| 12 |
+
SpacyLabelsResponse,
|
| 13 |
+
SupportedFormatsResponse,
|
| 14 |
+
UrlRequest,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
__all__ = [
|
| 18 |
+
"ConversionError",
|
| 19 |
+
"ConversionResult",
|
| 20 |
+
"ConversionMetadata",
|
| 21 |
+
"ConversionResponse",
|
| 22 |
+
"UrlRequest",
|
| 23 |
+
"BatchUrlRequest",
|
| 24 |
+
"BatchFileResult",
|
| 25 |
+
"BatchResponse",
|
| 26 |
+
"HealthResponse",
|
| 27 |
+
"InfoResponse",
|
| 28 |
+
"SupportedFormatsResponse",
|
| 29 |
+
"SpacyLabelsResponse",
|
| 30 |
+
]
|
app/models/domain.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
from dataclasses import dataclass, field
|
| 5 |
+
from typing import Any, Dict, Optional
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@dataclass(frozen=True)
|
| 9 |
+
class ConversionResult:
|
| 10 |
+
source: str
|
| 11 |
+
markdown: str
|
| 12 |
+
char_count: int
|
| 13 |
+
word_count: int
|
| 14 |
+
line_count: int
|
| 15 |
+
duration_ms: float
|
| 16 |
+
file_size_bytes: int
|
| 17 |
+
mime_type: str
|
| 18 |
+
content_hash: str
|
| 19 |
+
metadata: dict = field(default_factory=dict)
|
| 20 |
+
|
| 21 |
+
@property
|
| 22 |
+
def token_estimate(self) -> int:
|
| 23 |
+
return max(1, self.word_count * 4 // 3)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass(frozen=True)
|
| 27 |
+
class ConversionError:
|
| 28 |
+
source: str
|
| 29 |
+
error_type: str
|
| 30 |
+
message: str
|
| 31 |
+
duration_ms: float
|
app/models/schemas.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any, Dict, List, Optional
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel, Field, field_validator
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class ConversionMetadata(BaseModel):
|
| 9 |
+
source: str
|
| 10 |
+
char_count: int
|
| 11 |
+
word_count: int
|
| 12 |
+
line_count: int
|
| 13 |
+
file_size_bytes: int
|
| 14 |
+
mime_type: str
|
| 15 |
+
content_hash: str
|
| 16 |
+
token_estimate: int
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class ConversionResponse(BaseModel):
|
| 20 |
+
success: bool
|
| 21 |
+
time_ms: float
|
| 22 |
+
content: str
|
| 23 |
+
return_json: bool = False
|
| 24 |
+
json_content: Optional[Any] = None
|
| 25 |
+
metadata: Optional[ConversionMetadata] = None
|
| 26 |
+
error_message: Optional[str] = None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class UrlRequest(BaseModel):
|
| 30 |
+
url: str
|
| 31 |
+
return_json: bool = False
|
| 32 |
+
mappings: Optional[Dict[str, Dict[str, Any]]] = None
|
| 33 |
+
|
| 34 |
+
model_config = {"populate_by_name": True}
|
| 35 |
+
|
| 36 |
+
@field_validator("url")
|
| 37 |
+
@classmethod
|
| 38 |
+
def validate_scheme(cls, v: str) -> str:
|
| 39 |
+
if not v.startswith(("http://", "https://")):
|
| 40 |
+
raise ValueError("Only http/https URLs are supported.")
|
| 41 |
+
return v
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class BatchUrlRequest(BaseModel):
|
| 45 |
+
urls: List[str]
|
| 46 |
+
|
| 47 |
+
@field_validator("urls")
|
| 48 |
+
@classmethod
|
| 49 |
+
def validate_urls(cls, v: List[str]) -> List[str]:
|
| 50 |
+
for url in v:
|
| 51 |
+
if not url.startswith(("http://", "https://")):
|
| 52 |
+
raise ValueError(f"Invalid URL scheme: {url}")
|
| 53 |
+
if len(v) > 20:
|
| 54 |
+
raise ValueError("Maximum 20 URLs per batch request.")
|
| 55 |
+
return v
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class BatchFileResult(BaseModel):
|
| 59 |
+
filename: str
|
| 60 |
+
success: bool
|
| 61 |
+
time_ms: float
|
| 62 |
+
content: Optional[str] = None
|
| 63 |
+
error: Optional[str] = None
|
| 64 |
+
metadata: Optional[ConversionMetadata] = None
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class BatchResponse(BaseModel):
|
| 68 |
+
total: int
|
| 69 |
+
succeeded: int
|
| 70 |
+
failed: int
|
| 71 |
+
total_time_ms: float
|
| 72 |
+
results: List[BatchFileResult]
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class HealthResponse(BaseModel):
|
| 76 |
+
success: bool
|
| 77 |
+
status: str
|
| 78 |
+
version: str
|
| 79 |
+
uptime_seconds: float
|
| 80 |
+
timestamp: str
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class InfoResponse(BaseModel):
|
| 84 |
+
success: bool
|
| 85 |
+
app: str
|
| 86 |
+
version: str
|
| 87 |
+
python_version: str
|
| 88 |
+
platform: str
|
| 89 |
+
uptime_seconds: float
|
| 90 |
+
max_upload_mb: int
|
| 91 |
+
supported_extensions: int
|
| 92 |
+
timestamp: str
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class SupportedFormatsResponse(BaseModel):
|
| 96 |
+
success: bool
|
| 97 |
+
total_count: int
|
| 98 |
+
all_extensions: List[str]
|
| 99 |
+
by_category: Dict[str, List[str]]
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class SpacyLabelsResponse(BaseModel):
|
| 103 |
+
success: bool
|
| 104 |
+
spacy_labels: Dict[str, str]
|
| 105 |
+
source_types: Dict[str, str]
|
| 106 |
+
example_mappings: Dict[str, Dict[str, Any]]
|
app/services/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from app.services.auth_service import AuthService
|
| 4 |
+
from app.services.converter_service import ConverterService
|
| 5 |
+
from app.services.extraction_service import ExtractionService
|
| 6 |
+
from app.services.ocr_service import OCRService
|
| 7 |
+
|
| 8 |
+
__all__ = [
|
| 9 |
+
"AuthService",
|
| 10 |
+
"ConverterService",
|
| 11 |
+
"ExtractionService",
|
| 12 |
+
"OCRService",
|
| 13 |
+
]
|
app/services/auth_service.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from app.config import get_settings
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class AuthService:
|
| 7 |
+
def __init__(self) -> None:
|
| 8 |
+
self._settings = get_settings()
|
| 9 |
+
|
| 10 |
+
def validate_token(self, token: str) -> bool:
|
| 11 |
+
return token == self._settings.api_bearer_token
|
app/services/converter_service.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import io
|
| 5 |
+
import mimetypes
|
| 6 |
+
import time
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Optional
|
| 9 |
+
from urllib.parse import urlparse
|
| 10 |
+
|
| 11 |
+
from markitdown import MarkItDown
|
| 12 |
+
|
| 13 |
+
from app.config import get_settings
|
| 14 |
+
from app.core.constants import IMAGE_EXTENSIONS, IMAGE_MIME_PREFIXES
|
| 15 |
+
from app.core.logger import get_logger
|
| 16 |
+
from app.models.domain import ConversionError, ConversionResult
|
| 17 |
+
from app.services.ocr_service import ocr_image, ocr_pdf
|
| 18 |
+
|
| 19 |
+
_logger = get_logger(__name__)
|
| 20 |
+
_settings = get_settings()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _is_image(ext: str, mime: str) -> bool:
|
| 24 |
+
return ext.lower() in IMAGE_EXTENSIONS or any(mime.startswith(p) for p in IMAGE_MIME_PREFIXES)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _build_result(
|
| 28 |
+
source: str,
|
| 29 |
+
markdown: str,
|
| 30 |
+
file_size: int,
|
| 31 |
+
mime_type: str,
|
| 32 |
+
elapsed: float,
|
| 33 |
+
) -> ConversionResult:
|
| 34 |
+
lines = markdown.splitlines()
|
| 35 |
+
words = markdown.split()
|
| 36 |
+
content_hash = hashlib.sha256(markdown.encode()).hexdigest()
|
| 37 |
+
return ConversionResult(
|
| 38 |
+
source=source,
|
| 39 |
+
markdown=markdown,
|
| 40 |
+
char_count=len(markdown),
|
| 41 |
+
word_count=len(words),
|
| 42 |
+
line_count=len(lines),
|
| 43 |
+
duration_ms=elapsed,
|
| 44 |
+
file_size_bytes=file_size,
|
| 45 |
+
mime_type=mime_type,
|
| 46 |
+
content_hash=content_hash,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class ConverterService:
|
| 51 |
+
def __init__(self, enable_plugins: bool = False) -> None:
|
| 52 |
+
kwargs: dict = {"enable_plugins": enable_plugins}
|
| 53 |
+
self._engine = MarkItDown(**kwargs)
|
| 54 |
+
|
| 55 |
+
def convert_file(self, path: str | Path) -> ConversionResult | ConversionError:
|
| 56 |
+
path = Path(path).resolve()
|
| 57 |
+
start = time.perf_counter()
|
| 58 |
+
|
| 59 |
+
if not path.exists():
|
| 60 |
+
return ConversionError(
|
| 61 |
+
source=str(path),
|
| 62 |
+
error_type="FileNotFoundError",
|
| 63 |
+
message=f"File does not exist: {path}",
|
| 64 |
+
duration_ms=0.0,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
file_size = path.stat().st_size
|
| 68 |
+
mime_type, _ = mimetypes.guess_type(str(path))
|
| 69 |
+
mime_type = mime_type or "application/octet-stream"
|
| 70 |
+
|
| 71 |
+
try:
|
| 72 |
+
if _is_image(path.suffix, mime_type):
|
| 73 |
+
markdown = ocr_image(str(path))
|
| 74 |
+
else:
|
| 75 |
+
markdown = self._engine.convert(str(path)).text_content
|
| 76 |
+
if not markdown.strip() and path.suffix.lower() == ".pdf":
|
| 77 |
+
_logger.info("No text from PDF, falling back to OCR")
|
| 78 |
+
markdown = ocr_pdf(str(path))
|
| 79 |
+
elapsed = (time.perf_counter() - start) * 1000
|
| 80 |
+
return _build_result(str(path), markdown, file_size, mime_type, elapsed)
|
| 81 |
+
except Exception as exc:
|
| 82 |
+
elapsed = (time.perf_counter() - start) * 1000
|
| 83 |
+
return ConversionError(
|
| 84 |
+
source=str(path),
|
| 85 |
+
error_type=type(exc).__name__,
|
| 86 |
+
message=str(exc),
|
| 87 |
+
duration_ms=elapsed,
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
def convert_url(self, url: str) -> ConversionResult | ConversionError:
|
| 91 |
+
parsed = urlparse(url)
|
| 92 |
+
if parsed.scheme not in {"http", "https"}:
|
| 93 |
+
return ConversionError(
|
| 94 |
+
source=url,
|
| 95 |
+
error_type="ValueError",
|
| 96 |
+
message=f"Unsupported URL scheme: {parsed.scheme!r}",
|
| 97 |
+
duration_ms=0.0,
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
start = time.perf_counter()
|
| 101 |
+
try:
|
| 102 |
+
url_ext = Path(urlparse(url).path).suffix.lower()
|
| 103 |
+
if url_ext in IMAGE_EXTENSIONS:
|
| 104 |
+
markdown = ocr_image(url)
|
| 105 |
+
mime_type = mimetypes.guess_type(url)[0] or "image/jpeg"
|
| 106 |
+
else:
|
| 107 |
+
result = self._engine.convert(url)
|
| 108 |
+
markdown = result.text_content
|
| 109 |
+
mime_type = "text/html"
|
| 110 |
+
elapsed = (time.perf_counter() - start) * 1000
|
| 111 |
+
return _build_result(url, markdown, 0, mime_type, elapsed)
|
| 112 |
+
except Exception as exc:
|
| 113 |
+
elapsed = (time.perf_counter() - start) * 1000
|
| 114 |
+
return ConversionError(
|
| 115 |
+
source=url,
|
| 116 |
+
error_type=type(exc).__name__,
|
| 117 |
+
message=str(exc),
|
| 118 |
+
duration_ms=elapsed,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
def convert_stream(self, data: bytes, filename: str) -> ConversionResult | ConversionError:
|
| 122 |
+
start = time.perf_counter()
|
| 123 |
+
mime_type, _ = mimetypes.guess_type(filename)
|
| 124 |
+
mime_type = mime_type or "application/octet-stream"
|
| 125 |
+
ext = Path(filename).suffix.lower()
|
| 126 |
+
|
| 127 |
+
try:
|
| 128 |
+
if _is_image(ext, mime_type):
|
| 129 |
+
markdown = ocr_image(data)
|
| 130 |
+
else:
|
| 131 |
+
result = self._engine.convert_stream(io.BytesIO(data), file_extension=ext)
|
| 132 |
+
markdown = result.text_content
|
| 133 |
+
if not markdown.strip() and ext == ".pdf":
|
| 134 |
+
_logger.info("No text from PDF stream, falling back to OCR")
|
| 135 |
+
markdown = ocr_pdf(data)
|
| 136 |
+
elapsed = (time.perf_counter() - start) * 1000
|
| 137 |
+
return _build_result(filename, markdown, len(data), mime_type, elapsed)
|
| 138 |
+
except Exception as exc:
|
| 139 |
+
elapsed = (time.perf_counter() - start) * 1000
|
| 140 |
+
return ConversionError(
|
| 141 |
+
source=filename,
|
| 142 |
+
error_type=type(exc).__name__,
|
| 143 |
+
message=str(exc),
|
| 144 |
+
duration_ms=elapsed,
|
| 145 |
+
)
|
app/services/extraction_service.py
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import io
|
| 4 |
+
import re
|
| 5 |
+
import threading
|
| 6 |
+
import warnings
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
| 9 |
+
|
| 10 |
+
import pandas as pd
|
| 11 |
+
|
| 12 |
+
from app.config import get_settings
|
| 13 |
+
from app.core.constants import (
|
| 14 |
+
MAX_CSV_ROWS,
|
| 15 |
+
MAX_EXCEL_ROWS,
|
| 16 |
+
MAX_MEMORY_CELLS,
|
| 17 |
+
TABULAR_EXTENSIONS,
|
| 18 |
+
)
|
| 19 |
+
from app.core.logger import get_logger
|
| 20 |
+
|
| 21 |
+
_logger = get_logger(__name__)
|
| 22 |
+
_settings = get_settings()
|
| 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 |
+
SchemaNode = Dict[str, Any]
|
| 46 |
+
ResultNode = Union[str, List[Any], Dict[str, Any], None]
|
| 47 |
+
|
| 48 |
+
_norm_lock = threading.Lock()
|
| 49 |
+
_NORMALIZERS: Dict[str, Callable[[str], str]] = {
|
| 50 |
+
"strip": lambda s: s.strip(),
|
| 51 |
+
"upper": lambda s: s.upper(),
|
| 52 |
+
"lower": lambda s: s.lower(),
|
| 53 |
+
"remove_commas": lambda s: s.replace(",", ""),
|
| 54 |
+
"remove_spaces": lambda s: s.replace(" ", ""),
|
| 55 |
+
"remove_newlines": lambda s: s.replace("\n", " ").replace("\r", ""),
|
| 56 |
+
"collapse_whitespace": lambda s: re.sub(r"\s+", " ", s).strip(),
|
| 57 |
+
"remove_currency": lambda s: re.sub(r"[$\u20ac\u00a3\u00a5\u20b9]", "", s),
|
| 58 |
+
"remove_non_numeric": lambda s: re.sub(r"[^\d.]", "", s),
|
| 59 |
+
"normalize_date_sep": lambda s: re.sub(r"[/.]", "-", s),
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
_resolver_lock = threading.Lock()
|
| 63 |
+
_RESOLVERS: Dict[str, Callable[[Dict[str, Any], Any, str], Optional[str]]] = {}
|
| 64 |
+
|
| 65 |
+
_nlp_lock = threading.Lock()
|
| 66 |
+
_nlp = None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _get_nlp():
|
| 70 |
+
global _nlp
|
| 71 |
+
if _nlp is not None:
|
| 72 |
+
return _nlp
|
| 73 |
+
with _nlp_lock:
|
| 74 |
+
if _nlp is None:
|
| 75 |
+
import spacy
|
| 76 |
+
_nlp = spacy.load(
|
| 77 |
+
_settings.spacy_model,
|
| 78 |
+
exclude=["tagger", "parser", "lemmatizer", "attribute_ruler"],
|
| 79 |
+
)
|
| 80 |
+
_logger.info("spaCy %s loaded", _settings.spacy_model)
|
| 81 |
+
return _nlp
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _validate_file_size(size: int) -> Optional[str]:
|
| 85 |
+
max_size = _settings.max_upload_bytes
|
| 86 |
+
if size > max_size:
|
| 87 |
+
return f"File size {size} bytes exceeds limit of {max_size} bytes"
|
| 88 |
+
return None
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _check_memory_usage(rows: int, cols: int) -> Optional[str]:
|
| 92 |
+
approx_mb = (rows * cols * 50) / (1024 * 1024)
|
| 93 |
+
if rows * cols > MAX_MEMORY_CELLS:
|
| 94 |
+
return f"Data size too large (approx {approx_mb:.1f} MB). Too many cells: {rows}x{cols}"
|
| 95 |
+
return None
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _build_flags(rule: Dict[str, Any]) -> re.RegexFlag:
|
| 99 |
+
flags = re.RegexFlag(0)
|
| 100 |
+
for name in rule.get("flags", []):
|
| 101 |
+
obj = getattr(re, name.upper(), None)
|
| 102 |
+
if obj is None:
|
| 103 |
+
_logger.warning("Unknown re flag %s", name)
|
| 104 |
+
continue
|
| 105 |
+
flags |= obj
|
| 106 |
+
return flags
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _apply_normalizers(value: Optional[str], normalize: Any) -> Optional[str]:
|
| 110 |
+
if not isinstance(value, str):
|
| 111 |
+
return None
|
| 112 |
+
if not normalize:
|
| 113 |
+
return value
|
| 114 |
+
if isinstance(normalize, str):
|
| 115 |
+
normalize = [normalize]
|
| 116 |
+
for key in normalize:
|
| 117 |
+
with _norm_lock:
|
| 118 |
+
fn = _NORMALIZERS.get(key)
|
| 119 |
+
if fn is None:
|
| 120 |
+
_logger.warning("Unknown normalizer %s", key)
|
| 121 |
+
continue
|
| 122 |
+
try:
|
| 123 |
+
value = fn(value)
|
| 124 |
+
except Exception as exc:
|
| 125 |
+
_logger.error("Normalizer %s raised on value %s: %s", key, value, exc)
|
| 126 |
+
return value if value else None
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _try_group(match: re.Match, capture_group: Any) -> Tuple[bool, Optional[str]]:
|
| 130 |
+
try:
|
| 131 |
+
return True, match.group(capture_group)
|
| 132 |
+
except (IndexError, re.error):
|
| 133 |
+
_logger.warning("Group %s does not exist in pattern", capture_group)
|
| 134 |
+
return False, None
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _resolve_regex(rule: Dict[str, Any], text: str) -> Optional[str]:
|
| 138 |
+
primary = rule.get("pattern", "")
|
| 139 |
+
if not primary:
|
| 140 |
+
_logger.warning("Regex rule missing pattern")
|
| 141 |
+
return None
|
| 142 |
+
flags = _build_flags(rule)
|
| 143 |
+
capture_group = rule.get("capture_group", 0)
|
| 144 |
+
match_index = rule.get("match_index", 0)
|
| 145 |
+
normalize = rule.get("normalize", "")
|
| 146 |
+
strip_chars = rule.get("strip_chars", "")
|
| 147 |
+
fallbacks = rule.get("fallback_patterns", [])
|
| 148 |
+
|
| 149 |
+
for pat in [primary, *fallbacks]:
|
| 150 |
+
try:
|
| 151 |
+
matches = list(re.finditer(pat, text, flags))
|
| 152 |
+
except re.error as exc:
|
| 153 |
+
_logger.error("Invalid regex %s: %s", pat, exc)
|
| 154 |
+
continue
|
| 155 |
+
if not matches:
|
| 156 |
+
continue
|
| 157 |
+
try:
|
| 158 |
+
target_matches = [matches[match_index]]
|
| 159 |
+
except IndexError:
|
| 160 |
+
target_matches = [matches[-1]]
|
| 161 |
+
for m in target_matches:
|
| 162 |
+
exists, result = _try_group(m, capture_group)
|
| 163 |
+
if not exists or result is None:
|
| 164 |
+
break
|
| 165 |
+
result = _apply_normalizers(result, normalize)
|
| 166 |
+
if result is None:
|
| 167 |
+
break
|
| 168 |
+
result = result.strip(strip_chars) if strip_chars else result.strip()
|
| 169 |
+
return result or None
|
| 170 |
+
return None
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _resolve_regex_all(rule: Dict[str, Any], text: str) -> List[Optional[str]]:
|
| 174 |
+
primary = rule.get("pattern", "")
|
| 175 |
+
if not primary:
|
| 176 |
+
_logger.warning("Regex-array rule missing pattern")
|
| 177 |
+
return []
|
| 178 |
+
flags = _build_flags(rule)
|
| 179 |
+
capture_group = rule.get("capture_group", 0)
|
| 180 |
+
normalize = rule.get("normalize", "")
|
| 181 |
+
strip_chars = rule.get("strip_chars", "")
|
| 182 |
+
max_items = rule.get("max_items")
|
| 183 |
+
|
| 184 |
+
try:
|
| 185 |
+
matches = list(re.finditer(primary, text, flags))
|
| 186 |
+
except re.error as exc:
|
| 187 |
+
_logger.error("Invalid regex %s: %s", primary, exc)
|
| 188 |
+
return []
|
| 189 |
+
|
| 190 |
+
results: List[Optional[str]] = []
|
| 191 |
+
for m in matches:
|
| 192 |
+
exists, result = _try_group(m, capture_group)
|
| 193 |
+
if not exists or result is None:
|
| 194 |
+
continue
|
| 195 |
+
result = _apply_normalizers(result, normalize)
|
| 196 |
+
if result is None:
|
| 197 |
+
continue
|
| 198 |
+
result = result.strip(strip_chars) if strip_chars else result.strip()
|
| 199 |
+
if result:
|
| 200 |
+
results.append(result)
|
| 201 |
+
if max_items is not None and len(results) >= max_items:
|
| 202 |
+
break
|
| 203 |
+
return results
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def _resolve_entity(rule: Dict[str, Any], doc: Any) -> Optional[str]:
|
| 207 |
+
if doc is None:
|
| 208 |
+
_logger.warning("Entity resolver received None doc")
|
| 209 |
+
return None
|
| 210 |
+
labels = rule.get("label")
|
| 211 |
+
if isinstance(labels, str):
|
| 212 |
+
labels = [labels]
|
| 213 |
+
labels = set(labels or [])
|
| 214 |
+
match_index = rule.get("match_index", 0)
|
| 215 |
+
min_length = rule.get("min_length", 1)
|
| 216 |
+
exclude_pat = rule.get("exclude_pattern", "")
|
| 217 |
+
exclude_flags = _build_flags({"flags": rule.get("exclude_flags", [])})
|
| 218 |
+
normalize = rule.get("normalize", "")
|
| 219 |
+
|
| 220 |
+
candidates = [
|
| 221 |
+
ent.text for ent in doc.ents
|
| 222 |
+
if ent.label_ in labels
|
| 223 |
+
and len(ent.text) >= min_length
|
| 224 |
+
and not (exclude_pat and re.search(exclude_pat, ent.text, exclude_flags))
|
| 225 |
+
]
|
| 226 |
+
if not candidates:
|
| 227 |
+
return None
|
| 228 |
+
try:
|
| 229 |
+
result = candidates[match_index]
|
| 230 |
+
except IndexError:
|
| 231 |
+
result = candidates[-1]
|
| 232 |
+
return _apply_normalizers(result, normalize)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _resolve_entity_all(rule: Dict[str, Any], doc: Any) -> List[Optional[str]]:
|
| 236 |
+
if doc is None:
|
| 237 |
+
_logger.warning("Entity-array resolver received None doc")
|
| 238 |
+
return []
|
| 239 |
+
labels = rule.get("label")
|
| 240 |
+
if isinstance(labels, str):
|
| 241 |
+
labels = [labels]
|
| 242 |
+
labels = set(labels or [])
|
| 243 |
+
min_length = rule.get("min_length", 1)
|
| 244 |
+
exclude_pat = rule.get("exclude_pattern", "")
|
| 245 |
+
exclude_flags = _build_flags({"flags": rule.get("exclude_flags", [])})
|
| 246 |
+
normalize = rule.get("normalize", "")
|
| 247 |
+
max_items = rule.get("max_items")
|
| 248 |
+
unique = rule.get("unique", False)
|
| 249 |
+
|
| 250 |
+
results: List[str] = []
|
| 251 |
+
seen: set = set()
|
| 252 |
+
for ent in doc.ents:
|
| 253 |
+
if ent.label_ not in labels:
|
| 254 |
+
continue
|
| 255 |
+
if len(ent.text) < min_length:
|
| 256 |
+
continue
|
| 257 |
+
if exclude_pat and re.search(exclude_pat, ent.text, exclude_flags):
|
| 258 |
+
continue
|
| 259 |
+
value = _apply_normalizers(ent.text, normalize)
|
| 260 |
+
if not value:
|
| 261 |
+
continue
|
| 262 |
+
if unique:
|
| 263 |
+
if value in seen:
|
| 264 |
+
continue
|
| 265 |
+
seen.add(value)
|
| 266 |
+
results.append(value)
|
| 267 |
+
if max_items is not None and len(results) >= max_items:
|
| 268 |
+
break
|
| 269 |
+
return results
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def _resolve_token_attr(rule: Dict[str, Any], doc: Any) -> Optional[str]:
|
| 273 |
+
if doc is None:
|
| 274 |
+
_logger.warning("Token-attr resolver received None doc")
|
| 275 |
+
return None
|
| 276 |
+
attr = rule.get("attr", "")
|
| 277 |
+
match_index = rule.get("match_index", 0)
|
| 278 |
+
normalize = rule.get("normalize", "")
|
| 279 |
+
|
| 280 |
+
candidates = [t.text for t in doc if getattr(t, attr, False)]
|
| 281 |
+
if not candidates:
|
| 282 |
+
return None
|
| 283 |
+
try:
|
| 284 |
+
result = candidates[match_index]
|
| 285 |
+
except IndexError:
|
| 286 |
+
result = candidates[-1]
|
| 287 |
+
return _apply_normalizers(result, normalize)
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def _register_builtin_resolvers() -> None:
|
| 291 |
+
with _resolver_lock:
|
| 292 |
+
_RESOLVERS["regex"] = lambda rule, doc, text: _resolve_regex(rule, text)
|
| 293 |
+
_RESOLVERS["entity"] = lambda rule, doc, text: _resolve_entity(rule, doc)
|
| 294 |
+
_RESOLVERS["token_attr"] = lambda rule, doc, text: _resolve_token_attr(rule, doc)
|
| 295 |
+
_RESOLVERS["regex_all"] = lambda rule, doc, text: _resolve_regex_all(rule, text)
|
| 296 |
+
_RESOLVERS["entity_all"] = lambda rule, doc, text: _resolve_entity_all(rule, doc)
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
_register_builtin_resolvers()
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def _resolve_scalar_field(rule: Dict[str, Any], doc: Any, text: str) -> Optional[str]:
|
| 303 |
+
src = rule.get("source_type")
|
| 304 |
+
with _resolver_lock:
|
| 305 |
+
fn = _RESOLVERS.get(src)
|
| 306 |
+
if fn is None:
|
| 307 |
+
_logger.warning("Unknown source_type %s", src)
|
| 308 |
+
return None
|
| 309 |
+
return fn(rule, doc, text)
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def _resolve_node(node: SchemaNode, doc: Any, text: str) -> ResultNode:
|
| 313 |
+
node_type = node.get("type")
|
| 314 |
+
if node_type == "object":
|
| 315 |
+
return _resolve_object_node(node, doc, text)
|
| 316 |
+
if node_type == "array":
|
| 317 |
+
return _resolve_array_node(node, doc, text)
|
| 318 |
+
return _resolve_scalar_field(node, doc, text)
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def _resolve_object_node(node: SchemaNode, doc: Any, text: str) -> Dict[str, ResultNode]:
|
| 322 |
+
fields: Dict[str, SchemaNode] = node.get("fields", {})
|
| 323 |
+
result: Dict[str, ResultNode] = {}
|
| 324 |
+
for field_name, child_node in fields.items():
|
| 325 |
+
try:
|
| 326 |
+
result[field_name] = _resolve_node(child_node, doc, text)
|
| 327 |
+
except Exception as exc:
|
| 328 |
+
_logger.error("Object field %s raised: %s", field_name, exc)
|
| 329 |
+
result[field_name] = None
|
| 330 |
+
return result
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def _resolve_array_node(node: SchemaNode, doc: Any, text: str) -> List[ResultNode]:
|
| 334 |
+
item_schema: SchemaNode = node.get("items", {})
|
| 335 |
+
split_pat: Optional[str] = node.get("split_pattern")
|
| 336 |
+
split_flags_rule = {"flags": node.get("split_flags", [])}
|
| 337 |
+
max_items: Optional[int] = node.get("max_items")
|
| 338 |
+
results: List[ResultNode] = []
|
| 339 |
+
|
| 340 |
+
if split_pat:
|
| 341 |
+
try:
|
| 342 |
+
flags = _build_flags(split_flags_rule)
|
| 343 |
+
segments = re.split(split_pat, text, flags=flags)
|
| 344 |
+
except re.error as exc:
|
| 345 |
+
_logger.error("Invalid split_pattern %s: %s", split_pat, exc)
|
| 346 |
+
return []
|
| 347 |
+
nlp = _get_nlp()
|
| 348 |
+
try:
|
| 349 |
+
segment_docs = list(nlp.pipe(segments))
|
| 350 |
+
except Exception as exc:
|
| 351 |
+
_logger.error("spaCy pipe failed on array segments: %s", exc)
|
| 352 |
+
segment_docs = [None] * len(segments)
|
| 353 |
+
for seg_doc, seg_text in zip(segment_docs, segments):
|
| 354 |
+
if not seg_text.strip():
|
| 355 |
+
continue
|
| 356 |
+
try:
|
| 357 |
+
item_result = _resolve_node(item_schema, seg_doc, seg_text)
|
| 358 |
+
except Exception as exc:
|
| 359 |
+
_logger.error("Array item resolve raised: %s", exc)
|
| 360 |
+
item_result = None
|
| 361 |
+
results.append(item_result)
|
| 362 |
+
if max_items is not None and len(results) >= max_items:
|
| 363 |
+
break
|
| 364 |
+
else:
|
| 365 |
+
try:
|
| 366 |
+
raw = _resolve_node(item_schema, doc, text)
|
| 367 |
+
except Exception as exc:
|
| 368 |
+
_logger.error("Array item resolve raised: %s", exc)
|
| 369 |
+
return []
|
| 370 |
+
if isinstance(raw, list):
|
| 371 |
+
results = raw
|
| 372 |
+
elif raw is not None:
|
| 373 |
+
results = [raw]
|
| 374 |
+
if max_items is not None:
|
| 375 |
+
results = results[:max_items]
|
| 376 |
+
return results
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
def _safe_resolve_node(path: str, node: SchemaNode, doc: Any, text: str) -> ResultNode:
|
| 380 |
+
try:
|
| 381 |
+
return _resolve_node(node, doc, text)
|
| 382 |
+
except Exception as exc:
|
| 383 |
+
_logger.error("Schema path %s raised: %s", path, exc)
|
| 384 |
+
return None
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
def _extract_spacy_fields(text: str, fields: Dict[str, SchemaNode]) -> Dict[str, ResultNode]:
|
| 388 |
+
nlp = _get_nlp()
|
| 389 |
+
try:
|
| 390 |
+
doc = next(iter(nlp.pipe([text])))
|
| 391 |
+
except Exception as exc:
|
| 392 |
+
_logger.error("spaCy pipe failed: %s", exc)
|
| 393 |
+
doc = None
|
| 394 |
+
return {
|
| 395 |
+
field: _safe_resolve_node(field, node, doc, text)
|
| 396 |
+
for field, node in fields.items()
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def _extract_tabular(file_path: Union[str, Path], file_data: Optional[bytes] = None) -> Dict[str, Any]:
|
| 401 |
+
ext = Path(file_path).suffix.lower()
|
| 402 |
+
|
| 403 |
+
if file_data is not None:
|
| 404 |
+
size_error = _validate_file_size(len(file_data))
|
| 405 |
+
if size_error:
|
| 406 |
+
return {"error": size_error, "file_type": ext}
|
| 407 |
+
elif Path(file_path).exists():
|
| 408 |
+
size_error = _validate_file_size(Path(file_path).stat().st_size)
|
| 409 |
+
if size_error:
|
| 410 |
+
return {"error": size_error, "file_type": ext}
|
| 411 |
+
|
| 412 |
+
try:
|
| 413 |
+
with warnings.catch_warnings():
|
| 414 |
+
warnings.simplefilter("ignore", UserWarning)
|
| 415 |
+
if ext == ".csv":
|
| 416 |
+
if file_data:
|
| 417 |
+
df = pd.read_csv(io.BytesIO(file_data), nrows=MAX_CSV_ROWS + 1, low_memory=False)
|
| 418 |
+
else:
|
| 419 |
+
df = pd.read_csv(file_path, nrows=MAX_CSV_ROWS + 1, low_memory=False)
|
| 420 |
+
else:
|
| 421 |
+
if file_data:
|
| 422 |
+
df = pd.read_excel(
|
| 423 |
+
io.BytesIO(file_data),
|
| 424 |
+
engine="openpyxl" if ext == ".xlsx" else "xlrd",
|
| 425 |
+
)
|
| 426 |
+
else:
|
| 427 |
+
df = pd.read_excel(
|
| 428 |
+
file_path,
|
| 429 |
+
engine="openpyxl" if ext == ".xlsx" else "xlrd",
|
| 430 |
+
)
|
| 431 |
+
|
| 432 |
+
max_rows = MAX_EXCEL_ROWS if ext != ".csv" else MAX_CSV_ROWS
|
| 433 |
+
if len(df) > max_rows:
|
| 434 |
+
return {
|
| 435 |
+
"error": f"File contains {len(df)} rows, exceeds limit of {max_rows}",
|
| 436 |
+
"file_type": ext,
|
| 437 |
+
"row_count": len(df),
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
mem_error = _check_memory_usage(len(df), len(df.columns))
|
| 441 |
+
if mem_error:
|
| 442 |
+
return {"error": mem_error, "file_type": ext}
|
| 443 |
+
|
| 444 |
+
result = {
|
| 445 |
+
"success": True,
|
| 446 |
+
"file_type": ext,
|
| 447 |
+
"data": {
|
| 448 |
+
"columns": list(df.columns),
|
| 449 |
+
"rows": df.where(pd.notnull(df), None).to_dict(orient="records"),
|
| 450 |
+
"shape": [len(df), len(df.columns)],
|
| 451 |
+
"dtypes": {col: str(dtype) for col, dtype in df.dtypes.items()},
|
| 452 |
+
},
|
| 453 |
+
}
|
| 454 |
+
_logger.info("Extracted JSON from %s: %d rows, %d cols", ext, len(df), len(df.columns))
|
| 455 |
+
return result
|
| 456 |
+
|
| 457 |
+
except pd.errors.EmptyDataError:
|
| 458 |
+
return {"error": "File is empty or has no data", "file_type": ext}
|
| 459 |
+
except MemoryError:
|
| 460 |
+
return {"error": "Out of memory processing file", "file_type": ext}
|
| 461 |
+
except Exception as exc:
|
| 462 |
+
_logger.exception("JSON extraction failed for %s", ext)
|
| 463 |
+
return {
|
| 464 |
+
"error": f"Processing failed: {exc}",
|
| 465 |
+
"file_type": ext,
|
| 466 |
+
"exception_type": type(exc).__name__,
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
class ExtractionService:
|
| 471 |
+
def __init__(self) -> None:
|
| 472 |
+
self._spacy_labels = VALID_SPACY_LABELS
|
| 473 |
+
|
| 474 |
+
def extract_structured(
|
| 475 |
+
self,
|
| 476 |
+
filename: Union[str, Path],
|
| 477 |
+
markdown_text: str,
|
| 478 |
+
mappings: Optional[Dict[str, Dict[str, Any]]] = None,
|
| 479 |
+
file_data: Optional[bytes] = None,
|
| 480 |
+
) -> Dict[str, Any]:
|
| 481 |
+
ext = Path(filename).suffix.lower()
|
| 482 |
+
|
| 483 |
+
if ext in TABULAR_EXTENSIONS:
|
| 484 |
+
result = _extract_tabular(filename, file_data)
|
| 485 |
+
if "error" not in result:
|
| 486 |
+
result["extractor"] = "pandas"
|
| 487 |
+
return result
|
| 488 |
+
|
| 489 |
+
if not mappings:
|
| 490 |
+
return {
|
| 491 |
+
"error": (
|
| 492 |
+
f"Cannot extract JSON from '{ext}' files without field mappings. "
|
| 493 |
+
"Provide a 'mappings' object with field extraction rules."
|
| 494 |
+
),
|
| 495 |
+
"file_type": ext,
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
valid, mapper_error = self.validate_mappings(mappings)
|
| 499 |
+
if not valid:
|
| 500 |
+
return {
|
| 501 |
+
"error": "invalid_spacy_labels",
|
| 502 |
+
"label_mapper": mapper_error,
|
| 503 |
+
"file_type": ext,
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
try:
|
| 507 |
+
data = _extract_spacy_fields(markdown_text, mappings)
|
| 508 |
+
_logger.info("spaCy extraction completed for %s: %d fields", ext, len(data))
|
| 509 |
+
return {
|
| 510 |
+
"success": True,
|
| 511 |
+
"extractor": "spacy",
|
| 512 |
+
"file_type": ext,
|
| 513 |
+
"data": data,
|
| 514 |
+
}
|
| 515 |
+
except Exception as exc:
|
| 516 |
+
_logger.exception("spaCy extraction failed for %s", ext)
|
| 517 |
+
return {
|
| 518 |
+
"error": f"spaCy extraction failed: {exc}",
|
| 519 |
+
"file_type": ext,
|
| 520 |
+
"exception_type": type(exc).__name__,
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
def validate_mappings(self, mappings: Dict[str, Dict[str, Any]]) -> Tuple[bool, Optional[str]]:
|
| 524 |
+
for key, rule in mappings.items():
|
| 525 |
+
source_type = rule.get("source_type")
|
| 526 |
+
if source_type == "entity":
|
| 527 |
+
label = rule.get("label")
|
| 528 |
+
if isinstance(label, str) and label not in self._spacy_labels:
|
| 529 |
+
return False, f"Invalid spaCy label '{label}' in field '{key}'"
|
| 530 |
+
if isinstance(label, list):
|
| 531 |
+
for lbl in label:
|
| 532 |
+
if lbl not in self._spacy_labels:
|
| 533 |
+
return False, f"Invalid spaCy label '{lbl}' in field '{key}'"
|
| 534 |
+
return True, None
|
| 535 |
+
|
| 536 |
+
def get_spacy_labels(self) -> Dict[str, str]:
|
| 537 |
+
return dict(self._spacy_labels)
|
app/services/ocr_service.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import io
|
| 4 |
+
import threading
|
| 5 |
+
from typing import Union
|
| 6 |
+
from urllib.parse import urlparse
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
from PIL import Image
|
| 10 |
+
|
| 11 |
+
from app.config import get_settings
|
| 12 |
+
from app.core.logger import get_logger
|
| 13 |
+
|
| 14 |
+
_logger = get_logger(__name__)
|
| 15 |
+
_settings = get_settings()
|
| 16 |
+
|
| 17 |
+
_lock = threading.Lock()
|
| 18 |
+
_engine = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _get_engine():
|
| 22 |
+
global _engine
|
| 23 |
+
if _engine is None:
|
| 24 |
+
with _lock:
|
| 25 |
+
if _engine is None:
|
| 26 |
+
from rapidocr_onnxruntime import RapidOCR
|
| 27 |
+
_engine = RapidOCR(
|
| 28 |
+
Det={"use_cuda": _settings.ocr_det_cuda, "use_dml": _settings.ocr_det_dml},
|
| 29 |
+
Cls={"use_cuda": _settings.ocr_cls_cuda, "use_dml": _settings.ocr_cls_dml},
|
| 30 |
+
Rec={"use_cuda": _settings.ocr_rec_cuda, "use_dml": _settings.ocr_rec_dml},
|
| 31 |
+
print_verbose=False,
|
| 32 |
+
)
|
| 33 |
+
return _engine
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _to_numpy(source) -> Union[np.ndarray, str]:
|
| 37 |
+
if isinstance(source, Image.Image):
|
| 38 |
+
img = source
|
| 39 |
+
if img.mode not in ("RGB", "L", "RGBA"):
|
| 40 |
+
img = img.convert("RGB")
|
| 41 |
+
return np.array(img)
|
| 42 |
+
|
| 43 |
+
if isinstance(source, (bytes, bytearray)):
|
| 44 |
+
img = Image.open(io.BytesIO(source))
|
| 45 |
+
if img.mode not in ("RGB", "L", "RGBA"):
|
| 46 |
+
img = img.convert("RGB")
|
| 47 |
+
return np.array(img)
|
| 48 |
+
|
| 49 |
+
if isinstance(source, str):
|
| 50 |
+
parsed = urlparse(source)
|
| 51 |
+
if parsed.scheme in {"http", "https"}:
|
| 52 |
+
import httpx
|
| 53 |
+
resp = httpx.get(source, follow_redirects=True, timeout=30)
|
| 54 |
+
resp.raise_for_status()
|
| 55 |
+
img = Image.open(io.BytesIO(resp.content))
|
| 56 |
+
if img.mode not in ("RGB", "L", "RGBA"):
|
| 57 |
+
img = img.convert("RGB")
|
| 58 |
+
return np.array(img)
|
| 59 |
+
return source
|
| 60 |
+
|
| 61 |
+
if isinstance(source, np.ndarray):
|
| 62 |
+
return source
|
| 63 |
+
|
| 64 |
+
raise TypeError(
|
| 65 |
+
f"ocr_image expects bytes, str, numpy.ndarray or PIL.Image; got {type(source).__name__}"
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def ocr_image(
|
| 70 |
+
source,
|
| 71 |
+
*,
|
| 72 |
+
use_det: bool = True,
|
| 73 |
+
use_cls: bool = True,
|
| 74 |
+
use_rec: bool = True,
|
| 75 |
+
text_score: float = 0.5,
|
| 76 |
+
) -> str:
|
| 77 |
+
engine = _get_engine()
|
| 78 |
+
img = _to_numpy(source)
|
| 79 |
+
result, _ = engine(
|
| 80 |
+
img,
|
| 81 |
+
use_det=use_det,
|
| 82 |
+
use_cls=use_cls,
|
| 83 |
+
use_rec=use_rec,
|
| 84 |
+
text_score=text_score,
|
| 85 |
+
)
|
| 86 |
+
if not result:
|
| 87 |
+
return ""
|
| 88 |
+
lines = [item[1] for item in result if len(item) > 1 and item[1]]
|
| 89 |
+
return "\n".join(lines)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def ocr_pdf(source: Union[str, bytes], *, dpi: int = 150) -> str:
|
| 93 |
+
try:
|
| 94 |
+
import pypdfium2 as pdfium
|
| 95 |
+
except ImportError:
|
| 96 |
+
_logger.error("pypdfium2 not installed")
|
| 97 |
+
return ""
|
| 98 |
+
|
| 99 |
+
try:
|
| 100 |
+
pdf = pdfium.PdfDocument(source)
|
| 101 |
+
scale = dpi / 72.0
|
| 102 |
+
page_texts: list[str] = []
|
| 103 |
+
|
| 104 |
+
for page_index in range(len(pdf)):
|
| 105 |
+
page = pdf[page_index]
|
| 106 |
+
bitmap = page.render(scale=scale, rotation=0)
|
| 107 |
+
pil_image = bitmap.to_pil()
|
| 108 |
+
page_text = ocr_image(pil_image)
|
| 109 |
+
if page_text:
|
| 110 |
+
page_texts.append(page_text)
|
| 111 |
+
|
| 112 |
+
pdf.close()
|
| 113 |
+
return "\n\n".join(page_texts)
|
| 114 |
+
except Exception as exc:
|
| 115 |
+
_logger.error("Failed to OCR PDF: %s", exc)
|
| 116 |
+
return ""
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
class OCRService:
|
| 120 |
+
def __init__(self) -> None:
|
| 121 |
+
self._engine = _get_engine()
|
| 122 |
+
|
| 123 |
+
def image_to_text(self, source, text_score: float = 0.5) -> str:
|
| 124 |
+
return ocr_image(source, text_score=text_score)
|
| 125 |
+
|
| 126 |
+
def pdf_to_text(self, source: Union[str, bytes], dpi: int = 150) -> str:
|
| 127 |
+
return ocr_pdf(source, dpi=dpi)
|
main.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import uvicorn
|
| 4 |
+
|
| 5 |
+
from app.core.logger import get_logger
|
| 6 |
+
|
| 7 |
+
logger = get_logger(__name__)
|
| 8 |
+
|
| 9 |
+
if __name__ == "__main__":
|
| 10 |
+
logger.info("Starting %s server", "reconciliation-non-ai-extractor")
|
| 11 |
+
uvicorn.run(
|
| 12 |
+
"app.api.server:app",
|
| 13 |
+
host="0.0.0.0",
|
| 14 |
+
port=7860,
|
| 15 |
+
reload=False,
|
| 16 |
+
workers=1,
|
| 17 |
+
log_level="info",
|
| 18 |
+
access_log=True,
|
| 19 |
+
)
|
postman_collection.json
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"info": {
|
| 3 |
+
"_postman_id": "reconciliation-not-ai-extractor-service",
|
| 4 |
+
"name": "Reconciliation-not-ai-extractor-service",
|
| 5 |
+
"description": "Production-ready Postman collection for the Document-to-Markdown and structured data extraction API powered by Microsoft MarkItDown, RapidOCR, and spaCy.\n\n**Base URL:** `{{base_url}}`\n**Authentication:** Bearer token via `{{api_bearer_token}}`\n\n## Endpoints\n- **System:** Health, info, supported formats, spaCy labels\n- **Convert:** Single file and single URL to Markdown\n- **Batch:** Bulk conversion of files and URLs",
|
| 6 |
+
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
| 7 |
+
},
|
| 8 |
+
"item": [
|
| 9 |
+
{
|
| 10 |
+
"name": "System",
|
| 11 |
+
"description": "Health checks, server metadata, supported formats, and spaCy NER labels.",
|
| 12 |
+
"item": [
|
| 13 |
+
{
|
| 14 |
+
"name": "Root Health (No Auth)",
|
| 15 |
+
"request": {
|
| 16 |
+
"method": "GET",
|
| 17 |
+
"header": [],
|
| 18 |
+
"url": {
|
| 19 |
+
"raw": "{{base_url}}/health",
|
| 20 |
+
"host": ["{{base_url}}"],
|
| 21 |
+
"path": ["health"]
|
| 22 |
+
},
|
| 23 |
+
"description": "Quick health check without authentication. Returns `{\"status\": \"ok\", \"version\": \"1.0.0\"}`."
|
| 24 |
+
},
|
| 25 |
+
"response": []
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
"name": "Health",
|
| 29 |
+
"request": {
|
| 30 |
+
"method": "GET",
|
| 31 |
+
"header": [
|
| 32 |
+
{
|
| 33 |
+
"key": "Authorization",
|
| 34 |
+
"value": "Bearer {{api_bearer_token}}",
|
| 35 |
+
"type": "text"
|
| 36 |
+
}
|
| 37 |
+
],
|
| 38 |
+
"url": {
|
| 39 |
+
"raw": "{{base_url}}/v1/health",
|
| 40 |
+
"host": ["{{base_url}}"],
|
| 41 |
+
"path": ["v1", "health"]
|
| 42 |
+
},
|
| 43 |
+
"description": "Authenticated health check with uptime and timestamp."
|
| 44 |
+
},
|
| 45 |
+
"response": []
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"name": "Info",
|
| 49 |
+
"request": {
|
| 50 |
+
"method": "GET",
|
| 51 |
+
"header": [
|
| 52 |
+
{
|
| 53 |
+
"key": "Authorization",
|
| 54 |
+
"value": "Bearer {{api_bearer_token}}",
|
| 55 |
+
"type": "text"
|
| 56 |
+
}
|
| 57 |
+
],
|
| 58 |
+
"url": {
|
| 59 |
+
"raw": "{{base_url}}/v1/info",
|
| 60 |
+
"host": ["{{base_url}}"],
|
| 61 |
+
"path": ["v1", "info"]
|
| 62 |
+
},
|
| 63 |
+
"description": "Server and environment information including app name, version, Python version, platform, max upload size, and supported extension count."
|
| 64 |
+
},
|
| 65 |
+
"response": []
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
"name": "Supported Formats",
|
| 69 |
+
"request": {
|
| 70 |
+
"method": "GET",
|
| 71 |
+
"header": [
|
| 72 |
+
{
|
| 73 |
+
"key": "Authorization",
|
| 74 |
+
"value": "Bearer {{api_bearer_token}}",
|
| 75 |
+
"type": "text"
|
| 76 |
+
}
|
| 77 |
+
],
|
| 78 |
+
"url": {
|
| 79 |
+
"raw": "{{base_url}}/v1/formats",
|
| 80 |
+
"host": ["{{base_url}}"],
|
| 81 |
+
"path": ["v1", "formats"]
|
| 82 |
+
},
|
| 83 |
+
"description": "List all supported file formats grouped by category: documents, office, data, web, text, images, audio, archives."
|
| 84 |
+
},
|
| 85 |
+
"response": []
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"name": "spaCy Labels",
|
| 89 |
+
"request": {
|
| 90 |
+
"method": "GET",
|
| 91 |
+
"header": [
|
| 92 |
+
{
|
| 93 |
+
"key": "Authorization",
|
| 94 |
+
"value": "Bearer {{api_bearer_token}}",
|
| 95 |
+
"type": "text"
|
| 96 |
+
}
|
| 97 |
+
],
|
| 98 |
+
"url": {
|
| 99 |
+
"raw": "{{base_url}}/v1/spacy-labels",
|
| 100 |
+
"host": ["{{base_url}}"],
|
| 101 |
+
"path": ["v1", "spacy-labels"]
|
| 102 |
+
},
|
| 103 |
+
"description": "Available spaCy NER labels, source types (entity, regex, token_attr), and example field mappings for structured JSON extraction."
|
| 104 |
+
},
|
| 105 |
+
"response": []
|
| 106 |
+
}
|
| 107 |
+
]
|
| 108 |
+
},
|
| 109 |
+
{
|
| 110 |
+
"name": "Convert",
|
| 111 |
+
"description": "Single-file and single-URL conversion to Markdown with optional structured JSON extraction.",
|
| 112 |
+
"item": [
|
| 113 |
+
{
|
| 114 |
+
"name": "Convert File",
|
| 115 |
+
"request": {
|
| 116 |
+
"method": "POST",
|
| 117 |
+
"header": [
|
| 118 |
+
{
|
| 119 |
+
"key": "Authorization",
|
| 120 |
+
"value": "Bearer {{api_bearer_token}}",
|
| 121 |
+
"type": "text"
|
| 122 |
+
}
|
| 123 |
+
],
|
| 124 |
+
"body": {
|
| 125 |
+
"mode": "formdata",
|
| 126 |
+
"formdata": [
|
| 127 |
+
{
|
| 128 |
+
"key": "file",
|
| 129 |
+
"type": "file",
|
| 130 |
+
"src": "/path/to/your/document.pdf",
|
| 131 |
+
"description": "The file to convert (PDF, DOCX, XLSX, PPTX, etc.)"
|
| 132 |
+
},
|
| 133 |
+
{
|
| 134 |
+
"key": "plain_text",
|
| 135 |
+
"value": "false",
|
| 136 |
+
"type": "text",
|
| 137 |
+
"description": "Return raw Markdown text instead of JSON wrapper (true/false)"
|
| 138 |
+
},
|
| 139 |
+
{
|
| 140 |
+
"key": "return_json",
|
| 141 |
+
"value": "false",
|
| 142 |
+
"type": "text",
|
| 143 |
+
"description": "Also extract structured JSON using spaCy NER and regex (true/false)"
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
"key": "mappings",
|
| 147 |
+
"value": "{\"company\": {\"source_type\": \"entity\", \"label\": \"ORG\"}, \"person\": {\"source_type\": \"entity\", \"label\": \"PERSON\"}}",
|
| 148 |
+
"type": "text",
|
| 149 |
+
"description": "Optional JSON string defining field extraction mappings"
|
| 150 |
+
}
|
| 151 |
+
]
|
| 152 |
+
},
|
| 153 |
+
"url": {
|
| 154 |
+
"raw": "{{base_url}}/v1/convert/file",
|
| 155 |
+
"host": ["{{base_url}}"],
|
| 156 |
+
"path": ["v1", "convert", "file"]
|
| 157 |
+
},
|
| 158 |
+
"description": "Upload a single file and convert it to Markdown. Optionally enable structured JSON extraction with custom field mappings."
|
| 159 |
+
},
|
| 160 |
+
"response": []
|
| 161 |
+
},
|
| 162 |
+
{
|
| 163 |
+
"name": "Convert URL",
|
| 164 |
+
"request": {
|
| 165 |
+
"method": "POST",
|
| 166 |
+
"header": [
|
| 167 |
+
{
|
| 168 |
+
"key": "Authorization",
|
| 169 |
+
"value": "Bearer {{api_bearer_token}}",
|
| 170 |
+
"type": "text"
|
| 171 |
+
},
|
| 172 |
+
{
|
| 173 |
+
"key": "Content-Type",
|
| 174 |
+
"value": "application/json",
|
| 175 |
+
"type": "text"
|
| 176 |
+
}
|
| 177 |
+
],
|
| 178 |
+
"body": {
|
| 179 |
+
"mode": "raw",
|
| 180 |
+
"raw": "{\n \"url\": \"https://example.com/document.pdf\",\n \"return_json\": false,\n \"mappings\": {\n \"company\": {\"source_type\": \"entity\", \"label\": \"ORG\"},\n \"email\": {\"source_type\": \"regex\", \"pattern\": \"\\\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\\\.[A-Z|a-z]{2,}\\\\b\"}\n }\n}",
|
| 181 |
+
"options": {
|
| 182 |
+
"raw": {
|
| 183 |
+
"language": "json"
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
},
|
| 187 |
+
"url": {
|
| 188 |
+
"raw": "{{base_url}}/v1/convert/url",
|
| 189 |
+
"host": ["{{base_url}}"],
|
| 190 |
+
"path": ["v1", "convert", "url"]
|
| 191 |
+
},
|
| 192 |
+
"description": "Convert a remote document URL to Markdown. Optionally enable structured JSON extraction with custom field mappings."
|
| 193 |
+
},
|
| 194 |
+
"response": []
|
| 195 |
+
}
|
| 196 |
+
]
|
| 197 |
+
},
|
| 198 |
+
{
|
| 199 |
+
"name": "Batch",
|
| 200 |
+
"description": "Bulk conversion of multiple files and URLs.",
|
| 201 |
+
"item": [
|
| 202 |
+
{
|
| 203 |
+
"name": "Batch Files",
|
| 204 |
+
"request": {
|
| 205 |
+
"method": "POST",
|
| 206 |
+
"header": [
|
| 207 |
+
{
|
| 208 |
+
"key": "Authorization",
|
| 209 |
+
"value": "Bearer {{api_bearer_token}}",
|
| 210 |
+
"type": "text"
|
| 211 |
+
}
|
| 212 |
+
],
|
| 213 |
+
"body": {
|
| 214 |
+
"mode": "formdata",
|
| 215 |
+
"formdata": [
|
| 216 |
+
{
|
| 217 |
+
"key": "files",
|
| 218 |
+
"type": "file",
|
| 219 |
+
"src": "/path/to/your/document1.pdf",
|
| 220 |
+
"description": "First file (up to 10 files max)"
|
| 221 |
+
},
|
| 222 |
+
{
|
| 223 |
+
"key": "files",
|
| 224 |
+
"type": "file",
|
| 225 |
+
"src": "/path/to/your/document2.docx",
|
| 226 |
+
"description": "Second file"
|
| 227 |
+
}
|
| 228 |
+
]
|
| 229 |
+
},
|
| 230 |
+
"url": {
|
| 231 |
+
"raw": "{{base_url}}/v1/batch/files",
|
| 232 |
+
"host": ["{{base_url}}"],
|
| 233 |
+
"path": ["v1", "batch", "files"]
|
| 234 |
+
},
|
| 235 |
+
"description": "Upload and convert up to 10 files in a single request. Returns aggregated results with per-file success/failure status."
|
| 236 |
+
},
|
| 237 |
+
"response": []
|
| 238 |
+
},
|
| 239 |
+
{
|
| 240 |
+
"name": "Batch URLs",
|
| 241 |
+
"request": {
|
| 242 |
+
"method": "POST",
|
| 243 |
+
"header": [
|
| 244 |
+
{
|
| 245 |
+
"key": "Authorization",
|
| 246 |
+
"value": "Bearer {{api_bearer_token}}",
|
| 247 |
+
"type": "text"
|
| 248 |
+
},
|
| 249 |
+
{
|
| 250 |
+
"key": "Content-Type",
|
| 251 |
+
"value": "application/json",
|
| 252 |
+
"type": "text"
|
| 253 |
+
}
|
| 254 |
+
],
|
| 255 |
+
"body": {
|
| 256 |
+
"mode": "raw",
|
| 257 |
+
"raw": "{\n \"urls\": [\n \"https://example.com/report1.pdf\",\n \"https://example.com/report2.docx\"\n ]\n}",
|
| 258 |
+
"options": {
|
| 259 |
+
"raw": {
|
| 260 |
+
"language": "json"
|
| 261 |
+
}
|
| 262 |
+
}
|
| 263 |
+
},
|
| 264 |
+
"url": {
|
| 265 |
+
"raw": "{{base_url}}/v1/batch/urls",
|
| 266 |
+
"host": ["{{base_url}}"],
|
| 267 |
+
"path": ["v1", "batch", "urls"]
|
| 268 |
+
},
|
| 269 |
+
"description": "Convert up to 20 URLs in a single request. Returns aggregated results with per-URL success/failure status."
|
| 270 |
+
},
|
| 271 |
+
"response": []
|
| 272 |
+
}
|
| 273 |
+
]
|
| 274 |
+
}
|
| 275 |
+
],
|
| 276 |
+
"event": [
|
| 277 |
+
{
|
| 278 |
+
"listen": "prerequest",
|
| 279 |
+
"script": {
|
| 280 |
+
"type": "text/javascript",
|
| 281 |
+
"exec": [
|
| 282 |
+
"// Validate required environment variables before each request",
|
| 283 |
+
"const baseUrl = pm.environment.get('base_url');",
|
| 284 |
+
"const token = pm.environment.get('api_bearer_token');",
|
| 285 |
+
"",
|
| 286 |
+
"if (!baseUrl) {",
|
| 287 |
+
" pm.expect.fail('Missing environment variable: base_url');",
|
| 288 |
+
"}",
|
| 289 |
+
"",
|
| 290 |
+
"if (!token && pm.request.url.path.join('/').startsWith('v1')) {",
|
| 291 |
+
" pm.expect.fail('Missing environment variable: api_bearer_token');",
|
| 292 |
+
"}"
|
| 293 |
+
]
|
| 294 |
+
}
|
| 295 |
+
}
|
| 296 |
+
],
|
| 297 |
+
"variable": [
|
| 298 |
+
{
|
| 299 |
+
"key": "base_url",
|
| 300 |
+
"value": "http://localhost:7860",
|
| 301 |
+
"type": "string",
|
| 302 |
+
"description": "Base URL of the Reconciliation-not-ai-extractor-service (e.g., http://localhost:7860 or your deployed URL)"
|
| 303 |
+
},
|
| 304 |
+
{
|
| 305 |
+
"key": "api_bearer_token",
|
| 306 |
+
"value": "changeme",
|
| 307 |
+
"type": "string",
|
| 308 |
+
"description": "API bearer token for authenticated endpoints. Must match the service's API_BEARER_TOKEN environment variable."
|
| 309 |
+
}
|
| 310 |
+
],
|
| 311 |
+
"auth": {
|
| 312 |
+
"type": "bearer",
|
| 313 |
+
"bearer": [
|
| 314 |
+
{
|
| 315 |
+
"key": "token",
|
| 316 |
+
"value": "{{api_bearer_token}}",
|
| 317 |
+
"type": "string"
|
| 318 |
+
}
|
| 319 |
+
]
|
| 320 |
+
}
|
| 321 |
+
}
|
pyproject.toml
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=70", "wheel"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "reconciliation-non-ai-extractor"
|
| 7 |
+
version = "1.0.0"
|
| 8 |
+
description = "Enterprise document extraction API powered by Microsoft MarkItDown, RapidOCR, and spaCy"
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
requires-python = ">=3.10"
|
| 11 |
+
license = { text = "MIT" }
|
| 12 |
+
|
| 13 |
+
dependencies = [
|
| 14 |
+
"markitdown[all]>=0.1.5",
|
| 15 |
+
"fastapi>=0.111",
|
| 16 |
+
"uvicorn[standard]>=0.30",
|
| 17 |
+
"pydantic>=2.7",
|
| 18 |
+
"pydantic-settings>=2.0.0",
|
| 19 |
+
"python-multipart>=0.0.9",
|
| 20 |
+
"httpx>=0.27",
|
| 21 |
+
"pyfiglet>=1.0.0",
|
| 22 |
+
"rich>=13.7",
|
| 23 |
+
"numpy>=1.26.0",
|
| 24 |
+
"rapidocr-onnxruntime>=1.4.4",
|
| 25 |
+
"onnxruntime>=1.18.0",
|
| 26 |
+
"pillow>=10.0.0",
|
| 27 |
+
"pypdfium2>=4.30.0",
|
| 28 |
+
"pandas>=2.0.0",
|
| 29 |
+
"spacy>=3.7.0",
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
[project.optional-dependencies]
|
| 33 |
+
dev = ["pytest>=8", "pytest-asyncio>=0.23"]
|
| 34 |
+
|
| 35 |
+
[tool.setuptools.packages.find]
|
| 36 |
+
where = ["."]
|
| 37 |
+
include = ["reconciliation_non_ai_extractor*"]
|
requirements.txt
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
markitdown[all]>=0.1.5
|
| 2 |
+
fastapi>=0.111.0
|
| 3 |
+
uvicorn[standard]>=0.30.0
|
| 4 |
+
pydantic>=2.7.0
|
| 5 |
+
pydantic-settings>=2.0.0
|
| 6 |
+
python-multipart>=0.0.9
|
| 7 |
+
httpx>=0.27.0
|
| 8 |
+
pyfiglet>=1.0.0
|
| 9 |
+
rich>=13.7.0
|
| 10 |
+
numpy>=1.26.0
|
| 11 |
+
rapidocr-onnxruntime>=1.4.4
|
| 12 |
+
onnxruntime>=1.18.0
|
| 13 |
+
pillow>=10.0.0
|
| 14 |
+
pypdfium2>=4.30.0
|
| 15 |
+
pandas>=2.0.0
|
| 16 |
+
spacy>=3.7.0
|
start.sh
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
LOG_DIR="${LOG_DIR:-/app/logs}"
|
| 5 |
+
mkdir -p "$LOG_DIR"
|
| 6 |
+
LOG_FILE="$LOG_DIR/startup.log"
|
| 7 |
+
|
| 8 |
+
log() {
|
| 9 |
+
local level="$1"
|
| 10 |
+
shift
|
| 11 |
+
local ts
|
| 12 |
+
ts="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
| 13 |
+
local msg="[$ts] [$level] $*"
|
| 14 |
+
echo "$msg"
|
| 15 |
+
echo "$msg" >> "$LOG_FILE"
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
info() { log "INFO " "$@"; }
|
| 19 |
+
ok() { log "OK " "$@"; }
|
| 20 |
+
warn() { log "WARN " "$@"; }
|
| 21 |
+
err() { log "ERROR" "$@"; }
|
| 22 |
+
step() { echo ""; log "STEP " "──── $* ────"; }
|
| 23 |
+
|
| 24 |
+
info "========================================================"
|
| 25 |
+
info " Reconciliation Non-AI Extractor v1.0.0 — Production Startup"
|
| 26 |
+
info " $(date -u)"
|
| 27 |
+
info "========================================================"
|
| 28 |
+
|
| 29 |
+
step "1/2 Python dependencies"
|
| 30 |
+
|
| 31 |
+
if python -c "import markitdown, fastapi, httpx" 2>/dev/null; then
|
| 32 |
+
ok "Core dependencies verified."
|
| 33 |
+
else
|
| 34 |
+
err "Missing core dependencies. Check requirements.txt installation."
|
| 35 |
+
exit 1
|
| 36 |
+
fi
|
| 37 |
+
|
| 38 |
+
step "2/2 Core functionality check"
|
| 39 |
+
|
| 40 |
+
python - << 'PYEOF' >> "$LOG_FILE" 2>&1
|
| 41 |
+
import sys
|
| 42 |
+
print("[functionality-test] Testing core imports...")
|
| 43 |
+
try:
|
| 44 |
+
import markitdown
|
| 45 |
+
import fastapi
|
| 46 |
+
import httpx
|
| 47 |
+
import pandas
|
| 48 |
+
print("[functionality-test] All core dependencies imported successfully.")
|
| 49 |
+
except ImportError as e:
|
| 50 |
+
print(f"[functionality-test] Import error: {e}")
|
| 51 |
+
sys.exit(1)
|
| 52 |
+
print("[functionality-test] Core functionality check completed.")
|
| 53 |
+
PYEOF
|
| 54 |
+
|
| 55 |
+
if [ $? -eq 0 ]; then
|
| 56 |
+
ok "Core functionality check completed."
|
| 57 |
+
else
|
| 58 |
+
warn "Core functionality check failed. Continuing startup..."
|
| 59 |
+
fi
|
| 60 |
+
|
| 61 |
+
step "Starting FastAPI server"
|
| 62 |
+
|
| 63 |
+
HOST="${HOST:-0.0.0.0}"
|
| 64 |
+
PORT="${PORT:-7860}"
|
| 65 |
+
WORKERS="${WORKERS:-1}"
|
| 66 |
+
LOG_LEVEL="${LOG_LEVEL:-info}"
|
| 67 |
+
|
| 68 |
+
info "Starting uvicorn on $HOST:$PORT (workers=$WORKERS, log_level=$LOG_LEVEL) ..."
|
| 69 |
+
info "Swagger UI : http://$HOST:$PORT/docs"
|
| 70 |
+
info "ReDoc : http://$HOST:$PORT/redoc"
|
| 71 |
+
info "========================================================"
|
| 72 |
+
|
| 73 |
+
exec python -m uvicorn app.api.server:app \
|
| 74 |
+
--host "$HOST" \
|
| 75 |
+
--port "$PORT" \
|
| 76 |
+
--workers "$WORKERS" \
|
| 77 |
+
--log-level "$LOG_LEVEL" \
|
| 78 |
+
--access-log
|