Spaces:
Sleeping
Sleeping
Commit ·
c5638b0
1
Parent(s): 461373d
feat: optimize code + add json key extractor
Browse files- __init__.py +7 -1
- api/server.py +20 -17
- app/core/logging.py +71 -16
- app/core/rate_limit.py +15 -12
- app/services/conversion.py +60 -44
- app/services/file_service.py +19 -23
- app/services/upload_orchestrator.py +28 -17
- app/utils/validators.py +16 -11
- banner.py +3 -3
- core/batch.py +16 -5
- core/converter.py +96 -115
- core/ocr_engine.py +5 -5
- extraction/json_extractor.py +69 -99
- extraction/spacy_extractor.py +62 -70
__init__.py
CHANGED
|
@@ -1,4 +1,10 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
__all__ = [
|
| 4 |
"ConversionError",
|
|
|
|
| 1 |
+
"""Public re-exports for the MarkItDown conversion core."""
|
| 2 |
+
from core import (
|
| 3 |
+
ConversionError,
|
| 4 |
+
ConversionResult,
|
| 5 |
+
DocumentConverter,
|
| 6 |
+
SUPPORTED_EXTENSIONS,
|
| 7 |
+
)
|
| 8 |
|
| 9 |
__all__ = [
|
| 10 |
"ConversionError",
|
api/server.py
CHANGED
|
@@ -41,7 +41,6 @@ import os
|
|
| 41 |
import time
|
| 42 |
import uuid
|
| 43 |
from contextlib import asynccontextmanager
|
| 44 |
-
from datetime import datetime, timezone
|
| 45 |
from pathlib import Path
|
| 46 |
from typing import Annotated, Any, Dict, List, Optional
|
| 47 |
from urllib.parse import urlparse
|
|
@@ -55,10 +54,6 @@ from fastapi.middleware.gzip import GZipMiddleware
|
|
| 55 |
from prometheus_client import make_asgi_app
|
| 56 |
from pydantic import BaseModel, Field, field_validator
|
| 57 |
|
| 58 |
-
from core import ConversionError, ConversionResult, DocumentConverter, SUPPORTED_EXTENSIONS
|
| 59 |
-
from extraction.generic_json_extractor import extract
|
| 60 |
-
from extraction.keys_extractor import Extractor as KeysExtractor
|
| 61 |
-
from logger import get_logger
|
| 62 |
from app.api.routes import router as pdf_router
|
| 63 |
from app.core.auth import require_api_key
|
| 64 |
from app.core.config import settings
|
|
@@ -67,30 +62,34 @@ from app.core.logging import configure_logging
|
|
| 67 |
from app.core.rate_limit import RateLimitMiddleware
|
| 68 |
from app.services.ping import start_self_ping
|
| 69 |
from app.utils.cleanup import cleanup_loop
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
configure_logging()
|
| 72 |
logger = get_logger(__name__)
|
| 73 |
|
| 74 |
-
_START_TIME = time.time()
|
| 75 |
APP_NAME = "reconciliation-file-processing-service"
|
|
|
|
| 76 |
|
| 77 |
MAX_UPLOAD_BYTES = int(os.getenv("MAX_UPLOAD_BYTES", str(100 * 1024 * 1024)))
|
| 78 |
-
|
| 79 |
MAX_WORKERS = min(32, (os.cpu_count() or 1) + 4)
|
| 80 |
-
_thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=MAX_WORKERS)
|
| 81 |
|
|
|
|
| 82 |
_converter = DocumentConverter()
|
| 83 |
|
| 84 |
logger.info("thread_pool_initialised", workers=MAX_WORKERS)
|
| 85 |
|
| 86 |
|
|
|
|
| 87 |
# ---------------------------------------------------------------------------
|
| 88 |
# Lifespan
|
| 89 |
# ---------------------------------------------------------------------------
|
| 90 |
|
| 91 |
@asynccontextmanager
|
| 92 |
async def lifespan(app: FastAPI):
|
| 93 |
-
logger.info("api_starting", version=
|
| 94 |
ping_task = await start_self_ping()
|
| 95 |
cleanup_task = asyncio.create_task(cleanup_loop())
|
| 96 |
yield
|
|
@@ -110,13 +109,13 @@ app = FastAPI(
|
|
| 110 |
"Unified API for document-to-Markdown conversion (Microsoft MarkItDown + RapidOCR) "
|
| 111 |
"and PDF-to-image conversion with async job support."
|
| 112 |
),
|
| 113 |
-
version=
|
| 114 |
docs_url="/docs",
|
| 115 |
redoc_url="/redoc",
|
| 116 |
lifespan=lifespan,
|
| 117 |
)
|
| 118 |
|
| 119 |
-
# -- Middleware --
|
| 120 |
app.add_middleware(RateLimitMiddleware)
|
| 121 |
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
| 122 |
app.add_middleware(
|
|
@@ -149,12 +148,16 @@ async def request_context_middleware(request: Request, call_next):
|
|
| 149 |
|
| 150 |
|
| 151 |
# -- Exception handlers --
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
@app.exception_handler(AppException)
|
| 153 |
async def app_exception_handler(request: Request, exc: AppException):
|
| 154 |
logger.warning("app_exception", detail=exc.detail, status_code=exc.status_code)
|
| 155 |
return JSONResponse(
|
| 156 |
status_code=exc.status_code,
|
| 157 |
-
content={"error": exc.detail, "request_id":
|
| 158 |
)
|
| 159 |
|
| 160 |
|
|
@@ -163,14 +166,14 @@ async def generic_exception_handler(request: Request, exc: Exception):
|
|
| 163 |
logger.exception("unhandled_exception", exc_info=exc)
|
| 164 |
return JSONResponse(
|
| 165 |
status_code=500,
|
| 166 |
-
content={"error": "Internal server error", "request_id":
|
| 167 |
)
|
| 168 |
|
| 169 |
|
| 170 |
# -- Root-level routes (only / , /ping) --
|
| 171 |
@app.get("/", tags=["System"], summary="Root", include_in_schema=False)
|
| 172 |
async def root():
|
| 173 |
-
return {"service": APP_NAME, "version":
|
| 174 |
|
| 175 |
|
| 176 |
@app.get("/ping", tags=["System"], summary="Ping", include_in_schema=False)
|
|
@@ -178,6 +181,7 @@ async def ping():
|
|
| 178 |
return {"message": f"{APP_NAME} is running..."}
|
| 179 |
|
| 180 |
|
|
|
|
| 181 |
# ---------------------------------------------------------------------------
|
| 182 |
# MarkItDown router — all routes under /api/v1/markitdown
|
| 183 |
# ---------------------------------------------------------------------------
|
|
@@ -398,10 +402,9 @@ async def convert_file(
|
|
| 398 |
|
| 399 |
parsed_mappings: Optional[Dict[str, Any]] = None
|
| 400 |
if mappings:
|
| 401 |
-
import json as _json
|
| 402 |
try:
|
| 403 |
-
parsed_mappings =
|
| 404 |
-
except
|
| 405 |
raise HTTPException(status_code=400, detail={"success": False, "message": "Invalid JSON in mappings parameter."})
|
| 406 |
|
| 407 |
logger.info("convert_file", filename=file.filename)
|
|
|
|
| 41 |
import time
|
| 42 |
import uuid
|
| 43 |
from contextlib import asynccontextmanager
|
|
|
|
| 44 |
from pathlib import Path
|
| 45 |
from typing import Annotated, Any, Dict, List, Optional
|
| 46 |
from urllib.parse import urlparse
|
|
|
|
| 54 |
from prometheus_client import make_asgi_app
|
| 55 |
from pydantic import BaseModel, Field, field_validator
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
from app.api.routes import router as pdf_router
|
| 58 |
from app.core.auth import require_api_key
|
| 59 |
from app.core.config import settings
|
|
|
|
| 62 |
from app.core.rate_limit import RateLimitMiddleware
|
| 63 |
from app.services.ping import start_self_ping
|
| 64 |
from app.utils.cleanup import cleanup_loop
|
| 65 |
+
from core import ConversionError, ConversionResult, DocumentConverter, SUPPORTED_EXTENSIONS
|
| 66 |
+
from extraction.generic_json_extractor import extract
|
| 67 |
+
from extraction.keys_extractor import Extractor as KeysExtractor
|
| 68 |
+
from logger import get_logger
|
| 69 |
|
| 70 |
configure_logging()
|
| 71 |
logger = get_logger(__name__)
|
| 72 |
|
|
|
|
| 73 |
APP_NAME = "reconciliation-file-processing-service"
|
| 74 |
+
APP_VERSION = "2.2.0"
|
| 75 |
|
| 76 |
MAX_UPLOAD_BYTES = int(os.getenv("MAX_UPLOAD_BYTES", str(100 * 1024 * 1024)))
|
|
|
|
| 77 |
MAX_WORKERS = min(32, (os.cpu_count() or 1) + 4)
|
|
|
|
| 78 |
|
| 79 |
+
_thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=MAX_WORKERS)
|
| 80 |
_converter = DocumentConverter()
|
| 81 |
|
| 82 |
logger.info("thread_pool_initialised", workers=MAX_WORKERS)
|
| 83 |
|
| 84 |
|
| 85 |
+
|
| 86 |
# ---------------------------------------------------------------------------
|
| 87 |
# Lifespan
|
| 88 |
# ---------------------------------------------------------------------------
|
| 89 |
|
| 90 |
@asynccontextmanager
|
| 91 |
async def lifespan(app: FastAPI):
|
| 92 |
+
logger.info("api_starting", version=APP_VERSION, host="0.0.0.0:7860")
|
| 93 |
ping_task = await start_self_ping()
|
| 94 |
cleanup_task = asyncio.create_task(cleanup_loop())
|
| 95 |
yield
|
|
|
|
| 109 |
"Unified API for document-to-Markdown conversion (Microsoft MarkItDown + RapidOCR) "
|
| 110 |
"and PDF-to-image conversion with async job support."
|
| 111 |
),
|
| 112 |
+
version=APP_VERSION,
|
| 113 |
docs_url="/docs",
|
| 114 |
redoc_url="/redoc",
|
| 115 |
lifespan=lifespan,
|
| 116 |
)
|
| 117 |
|
| 118 |
+
# -- Middleware (order matters: outermost first) --
|
| 119 |
app.add_middleware(RateLimitMiddleware)
|
| 120 |
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
| 121 |
app.add_middleware(
|
|
|
|
| 148 |
|
| 149 |
|
| 150 |
# -- Exception handlers --
|
| 151 |
+
def _request_id(request: Request) -> str:
|
| 152 |
+
return getattr(request.state, "request_id", "")
|
| 153 |
+
|
| 154 |
+
|
| 155 |
@app.exception_handler(AppException)
|
| 156 |
async def app_exception_handler(request: Request, exc: AppException):
|
| 157 |
logger.warning("app_exception", detail=exc.detail, status_code=exc.status_code)
|
| 158 |
return JSONResponse(
|
| 159 |
status_code=exc.status_code,
|
| 160 |
+
content={"error": exc.detail, "request_id": _request_id(request)},
|
| 161 |
)
|
| 162 |
|
| 163 |
|
|
|
|
| 166 |
logger.exception("unhandled_exception", exc_info=exc)
|
| 167 |
return JSONResponse(
|
| 168 |
status_code=500,
|
| 169 |
+
content={"error": "Internal server error", "request_id": _request_id(request)},
|
| 170 |
)
|
| 171 |
|
| 172 |
|
| 173 |
# -- Root-level routes (only / , /ping) --
|
| 174 |
@app.get("/", tags=["System"], summary="Root", include_in_schema=False)
|
| 175 |
async def root():
|
| 176 |
+
return {"service": APP_NAME, "version": APP_VERSION, "status": "running"}
|
| 177 |
|
| 178 |
|
| 179 |
@app.get("/ping", tags=["System"], summary="Ping", include_in_schema=False)
|
|
|
|
| 181 |
return {"message": f"{APP_NAME} is running..."}
|
| 182 |
|
| 183 |
|
| 184 |
+
|
| 185 |
# ---------------------------------------------------------------------------
|
| 186 |
# MarkItDown router — all routes under /api/v1/markitdown
|
| 187 |
# ---------------------------------------------------------------------------
|
|
|
|
| 402 |
|
| 403 |
parsed_mappings: Optional[Dict[str, Any]] = None
|
| 404 |
if mappings:
|
|
|
|
| 405 |
try:
|
| 406 |
+
parsed_mappings = json.loads(mappings)
|
| 407 |
+
except json.JSONDecodeError:
|
| 408 |
raise HTTPException(status_code=400, detail={"success": False, "message": "Invalid JSON in mappings parameter."})
|
| 409 |
|
| 410 |
logger.info("convert_file", filename=file.filename)
|
app/core/logging.py
CHANGED
|
@@ -2,19 +2,76 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import logging
|
| 4 |
import sys
|
|
|
|
| 5 |
|
| 6 |
import structlog
|
| 7 |
|
| 8 |
from app.core.config import settings
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
is_prod = settings.ENV == "production"
|
| 19 |
|
| 20 |
if is_prod:
|
|
@@ -26,6 +83,8 @@ def configure_logging() -> None:
|
|
| 26 |
structlog.configure(
|
| 27 |
processors=[
|
| 28 |
structlog.stdlib.filter_by_level,
|
|
|
|
|
|
|
| 29 |
*pre_chain,
|
| 30 |
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
| 31 |
],
|
|
@@ -38,6 +97,8 @@ def configure_logging() -> None:
|
|
| 38 |
foreign_pre_chain=pre_chain,
|
| 39 |
processors=[
|
| 40 |
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
|
|
|
|
|
|
| 41 |
renderer,
|
| 42 |
],
|
| 43 |
)
|
|
@@ -49,14 +110,8 @@ def configure_logging() -> None:
|
|
| 49 |
root_logger.handlers = [handler]
|
| 50 |
root_logger.setLevel(settings.LOG_LEVEL.upper())
|
| 51 |
|
| 52 |
-
for lib in
|
| 53 |
-
"uvicorn.access",
|
| 54 |
-
"uvicorn.error",
|
| 55 |
-
"multipart",
|
| 56 |
-
"httpx",
|
| 57 |
-
"httpcore",
|
| 58 |
-
"redis",
|
| 59 |
-
"PIL",
|
| 60 |
-
"asyncio",
|
| 61 |
-
):
|
| 62 |
logging.getLogger(lib).setLevel(logging.WARNING)
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import logging
|
| 4 |
import sys
|
| 5 |
+
from typing import Any, MutableMapping
|
| 6 |
|
| 7 |
import structlog
|
| 8 |
|
| 9 |
from app.core.config import settings
|
| 10 |
|
| 11 |
+
_NOISY_LOGGERS: tuple[str, ...] = (
|
| 12 |
+
"uvicorn.access",
|
| 13 |
+
"uvicorn.error",
|
| 14 |
+
"multipart",
|
| 15 |
+
"httpx",
|
| 16 |
+
"httpcore",
|
| 17 |
+
"redis",
|
| 18 |
+
"PIL",
|
| 19 |
+
"asyncio",
|
| 20 |
+
)
|
| 21 |
|
| 22 |
+
_BASE_PRE_CHAIN: tuple = (
|
| 23 |
+
structlog.contextvars.merge_contextvars,
|
| 24 |
+
structlog.stdlib.add_log_level,
|
| 25 |
+
structlog.processors.TimeStamper(fmt="iso"),
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _scrub_value(value: Any) -> Any:
|
| 30 |
+
"""Return a size placeholder for binary values, otherwise pass through."""
|
| 31 |
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
| 32 |
+
return f"<{len(bytes(value))} bytes>"
|
| 33 |
+
return value
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _scrub_binary(_logger: Any, _method: str, event_dict: MutableMapping[str, Any]) -> MutableMapping[str, Any]:
|
| 37 |
+
"""Replace any bytes / bytearray values in event_dict with a size placeholder.
|
| 38 |
+
|
| 39 |
+
structlog will happily serialise raw bytes to JSON (escaped) or to the
|
| 40 |
+
console renderer, but a single misrouted PDF or image stream can balloon
|
| 41 |
+
a log line into megabytes. This guard catches the leak at the boundary.
|
| 42 |
+
"""
|
| 43 |
+
for key, value in list(event_dict.items()):
|
| 44 |
+
event_dict[key] = _scrub_value(value)
|
| 45 |
+
return event_dict
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _consume_positional_args(_logger: Any, _method: str, event_dict: MutableMapping[str, Any]) -> MutableMapping[str, Any]:
|
| 49 |
+
"""Format `logger.info('msg %s', arg)` style calls safely.
|
| 50 |
|
| 51 |
+
structlog stores the trailing args under `positional_args` and leaves
|
| 52 |
+
the format string in `event` until a later renderer (e.g.
|
| 53 |
+
`PositionalArgumentsFormatter`) consumes it. We do that here, scrubbing
|
| 54 |
+
bytes so a stray PDF stream can never end up rendered as `b'\\x89PNG...'`.
|
| 55 |
+
"""
|
| 56 |
+
pos_args = event_dict.pop("positional_args", None)
|
| 57 |
+
if not pos_args:
|
| 58 |
+
return event_dict
|
| 59 |
+
|
| 60 |
+
event = event_dict.get("event")
|
| 61 |
+
if not isinstance(event, str) or "%" not in event:
|
| 62 |
+
return event_dict
|
| 63 |
+
|
| 64 |
+
safe_args = tuple(_scrub_value(a) for a in pos_args)
|
| 65 |
+
try:
|
| 66 |
+
event_dict["event"] = event % safe_args
|
| 67 |
+
except (TypeError, ValueError):
|
| 68 |
+
# Fall back to the raw event if the format string is malformed.
|
| 69 |
+
pass
|
| 70 |
+
return event_dict
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def configure_logging() -> None:
|
| 74 |
+
pre_chain = list(_BASE_PRE_CHAIN)
|
| 75 |
is_prod = settings.ENV == "production"
|
| 76 |
|
| 77 |
if is_prod:
|
|
|
|
| 83 |
structlog.configure(
|
| 84 |
processors=[
|
| 85 |
structlog.stdlib.filter_by_level,
|
| 86 |
+
_consume_positional_args,
|
| 87 |
+
_scrub_binary,
|
| 88 |
*pre_chain,
|
| 89 |
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
| 90 |
],
|
|
|
|
| 97 |
foreign_pre_chain=pre_chain,
|
| 98 |
processors=[
|
| 99 |
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
| 100 |
+
_consume_positional_args,
|
| 101 |
+
_scrub_binary,
|
| 102 |
renderer,
|
| 103 |
],
|
| 104 |
)
|
|
|
|
| 110 |
root_logger.handlers = [handler]
|
| 111 |
root_logger.setLevel(settings.LOG_LEVEL.upper())
|
| 112 |
|
| 113 |
+
for lib in _NOISY_LOGGERS:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
logging.getLogger(lib).setLevel(logging.WARNING)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
|
app/core/rate_limit.py
CHANGED
|
@@ -77,18 +77,21 @@ def _is_trusted_proxy(host: str) -> bool:
|
|
| 77 |
def _extract_client_ip(request: Request) -> str:
|
| 78 |
direct_host = request.client.host if request.client else None
|
| 79 |
|
| 80 |
-
if direct_host
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
|
| 94 |
class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
|
|
| 77 |
def _extract_client_ip(request: Request) -> str:
|
| 78 |
direct_host = request.client.host if request.client else None
|
| 79 |
|
| 80 |
+
if not direct_host or not _is_trusted_proxy(direct_host):
|
| 81 |
+
return direct_host or "unknown"
|
| 82 |
+
|
| 83 |
+
forwarded_for = request.headers.get("x-forwarded-for", "")
|
| 84 |
+
if forwarded_for:
|
| 85 |
+
# Trust the rightmost non-proxy IP; that's the closest hop to us.
|
| 86 |
+
for candidate in (ip.strip() for ip in reversed(forwarded_for.split(","))):
|
| 87 |
+
if candidate and not _is_trusted_proxy(candidate):
|
| 88 |
+
return candidate
|
| 89 |
+
|
| 90 |
+
real_ip = request.headers.get("x-real-ip", "").strip()
|
| 91 |
+
if real_ip and not _is_trusted_proxy(real_ip):
|
| 92 |
+
return real_ip
|
| 93 |
+
|
| 94 |
+
return direct_host
|
| 95 |
|
| 96 |
|
| 97 |
class RateLimitMiddleware(BaseHTTPMiddleware):
|
app/services/conversion.py
CHANGED
|
@@ -26,6 +26,24 @@ _render_pool = ThreadPoolExecutor(
|
|
| 26 |
)
|
| 27 |
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
class ConversionService:
|
| 30 |
def __init__(self) -> None:
|
| 31 |
self._output_dir = Path(settings.OUTPUT_DIR)
|
|
@@ -120,11 +138,11 @@ class ConversionService:
|
|
| 120 |
for page_idx in ordered_indices
|
| 121 |
]
|
| 122 |
|
|
|
|
|
|
|
| 123 |
results_by_idx: dict[int, PageResult] = {}
|
| 124 |
errors: list[str] = []
|
| 125 |
|
| 126 |
-
raw_results = await asyncio.gather(*render_coros, return_exceptions=True)
|
| 127 |
-
|
| 128 |
for page_idx, outcome in zip(ordered_indices, raw_results):
|
| 129 |
if isinstance(outcome, Exception):
|
| 130 |
logger.error("page_render_failed", job_id=job_id, page=page_idx + 1, error=str(outcome))
|
|
@@ -149,7 +167,7 @@ def _validate_and_get_indices(
|
|
| 149 |
from pypdf import PdfReader
|
| 150 |
from pypdf.errors import PdfReadError
|
| 151 |
|
| 152 |
-
fmt =
|
| 153 |
if fmt not in _SUPPORTED_FORMATS:
|
| 154 |
raise InvalidParameterError(
|
| 155 |
f"Unsupported format '{fmt}'. Only JPEG (JPG) and PNG are supported."
|
|
@@ -159,8 +177,7 @@ def _validate_and_get_indices(
|
|
| 159 |
reader = PdfReader(io.BytesIO(pdf_bytes))
|
| 160 |
if reader.is_encrypted:
|
| 161 |
from pypdf import PasswordType
|
| 162 |
-
|
| 163 |
-
if result == PasswordType.NOT_DECRYPTED:
|
| 164 |
raise ConversionError("PDF is password-protected. Please provide an unlocked PDF.")
|
| 165 |
total_pages = len(reader.pages)
|
| 166 |
except ConversionError:
|
|
@@ -197,6 +214,28 @@ def _split_pdf_pages(pdf_bytes: bytes, page_indices: List[int]) -> dict[int, byt
|
|
| 197 |
return blobs
|
| 198 |
|
| 199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
def _render_single_page(
|
| 201 |
page_idx: int,
|
| 202 |
page_pdf_bytes: bytes,
|
|
@@ -207,7 +246,7 @@ def _render_single_page(
|
|
| 207 |
from pdf2image import convert_from_bytes
|
| 208 |
from pdf2image.exceptions import PDFInfoNotInstalledError, PDFPageCountError, PDFSyntaxError
|
| 209 |
|
| 210 |
-
fmt =
|
| 211 |
|
| 212 |
try:
|
| 213 |
pil_images = convert_from_bytes(
|
|
@@ -225,33 +264,17 @@ def _render_single_page(
|
|
| 225 |
raise ConversionError(f"Page {page_idx + 1}: renderer returned no image")
|
| 226 |
|
| 227 |
img = pil_images[0]
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
if img.mode == "P":
|
| 231 |
-
img = img.convert("RGBA")
|
| 232 |
-
bg = Image.new("RGB", img.size, (255, 255, 255))
|
| 233 |
-
mask = img.split()[-1] if img.mode in ("RGBA", "LA") else None
|
| 234 |
-
bg.paste(img, mask=mask)
|
| 235 |
-
img = bg
|
| 236 |
-
elif fmt == "JPEG" and img.mode != "RGB":
|
| 237 |
-
img = img.convert("RGB")
|
| 238 |
-
|
| 239 |
if params.grayscale:
|
| 240 |
img = img.convert("L")
|
| 241 |
|
| 242 |
-
ext =
|
| 243 |
filename = f"page_{page_idx + 1:04d}.{ext}"
|
| 244 |
out_path = job_dir / filename
|
| 245 |
|
| 246 |
-
save_kwargs: dict = {}
|
| 247 |
-
if fmt == "JPEG":
|
| 248 |
-
save_kwargs["quality"] = params.quality
|
| 249 |
-
save_kwargs["optimize"] = True
|
| 250 |
-
if fmt == "PNG":
|
| 251 |
-
save_kwargs["optimize"] = True
|
| 252 |
-
|
| 253 |
try:
|
| 254 |
-
img.save(str(out_path), format=fmt, **
|
| 255 |
except OSError as exc:
|
| 256 |
raise ConversionError(f"Failed to write {filename}: {exc}") from exc
|
| 257 |
|
|
@@ -276,17 +299,17 @@ def _stitch_pages(
|
|
| 276 |
Called only when split_page=False.
|
| 277 |
Pages are placed top-to-bottom in document order with no gaps.
|
| 278 |
"""
|
| 279 |
-
fmt =
|
| 280 |
-
ext =
|
| 281 |
|
| 282 |
images: list[Image.Image] = []
|
| 283 |
for pr in sorted(page_results, key=lambda p: p.page_number):
|
| 284 |
path = job_dir / Path(pr.download_url).name
|
| 285 |
img = Image.open(str(path))
|
| 286 |
-
if fmt == "JPEG"
|
| 287 |
-
img =
|
| 288 |
-
|
| 289 |
-
img =
|
| 290 |
images.append(img)
|
| 291 |
|
| 292 |
if not images:
|
|
@@ -296,7 +319,8 @@ def _stitch_pages(
|
|
| 296 |
total_height = sum(im.height for im in images)
|
| 297 |
|
| 298 |
mode = images[0].mode
|
| 299 |
-
|
|
|
|
| 300 |
|
| 301 |
y_offset = 0
|
| 302 |
for img in images:
|
|
@@ -305,15 +329,7 @@ def _stitch_pages(
|
|
| 305 |
|
| 306 |
out_filename = f"stitched.{ext}"
|
| 307 |
out_path = job_dir / out_filename
|
| 308 |
-
|
| 309 |
-
save_kwargs: dict = {}
|
| 310 |
-
if fmt == "JPEG":
|
| 311 |
-
save_kwargs["quality"] = params.quality
|
| 312 |
-
save_kwargs["optimize"] = True
|
| 313 |
-
if fmt == "PNG":
|
| 314 |
-
save_kwargs["optimize"] = True
|
| 315 |
-
|
| 316 |
-
stitched.save(str(out_path), format=fmt, **save_kwargs)
|
| 317 |
|
| 318 |
stat = out_path.stat()
|
| 319 |
return PageResult(
|
|
@@ -327,8 +343,7 @@ def _stitch_pages(
|
|
| 327 |
|
| 328 |
|
| 329 |
def _guard_memory(page_count: int, dpi: int) -> None:
|
| 330 |
-
|
| 331 |
-
bytes_per_page = pixels_per_page * 3
|
| 332 |
estimated_mb = (bytes_per_page * page_count) / (1024 * 1024)
|
| 333 |
if estimated_mb > _MAX_MEMORY_ESTIMATE_MB:
|
| 334 |
raise InvalidParameterError(
|
|
@@ -339,3 +354,4 @@ def _guard_memory(page_count: int, dpi: int) -> None:
|
|
| 339 |
|
| 340 |
|
| 341 |
conversion_service = ConversionService()
|
|
|
|
|
|
| 26 |
)
|
| 27 |
|
| 28 |
|
| 29 |
+
def _format_str(fmt) -> str:
|
| 30 |
+
"""Normalise an ImageFormat enum / string to its raw uppercase value."""
|
| 31 |
+
return fmt.value if hasattr(fmt, "value") else str(fmt)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _save_kwargs(fmt: str, quality: int) -> dict:
|
| 35 |
+
"""PIL save kwargs for the requested output format."""
|
| 36 |
+
if fmt == "JPEG":
|
| 37 |
+
return {"quality": quality, "optimize": True}
|
| 38 |
+
if fmt == "PNG":
|
| 39 |
+
return {"optimize": True}
|
| 40 |
+
return {}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _ext_for(fmt: str) -> str:
|
| 44 |
+
return "jpg" if fmt == "JPEG" else "png"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
class ConversionService:
|
| 48 |
def __init__(self) -> None:
|
| 49 |
self._output_dir = Path(settings.OUTPUT_DIR)
|
|
|
|
| 138 |
for page_idx in ordered_indices
|
| 139 |
]
|
| 140 |
|
| 141 |
+
raw_results = await asyncio.gather(*render_coros, return_exceptions=True)
|
| 142 |
+
|
| 143 |
results_by_idx: dict[int, PageResult] = {}
|
| 144 |
errors: list[str] = []
|
| 145 |
|
|
|
|
|
|
|
| 146 |
for page_idx, outcome in zip(ordered_indices, raw_results):
|
| 147 |
if isinstance(outcome, Exception):
|
| 148 |
logger.error("page_render_failed", job_id=job_id, page=page_idx + 1, error=str(outcome))
|
|
|
|
| 167 |
from pypdf import PdfReader
|
| 168 |
from pypdf.errors import PdfReadError
|
| 169 |
|
| 170 |
+
fmt = _format_str(params.format)
|
| 171 |
if fmt not in _SUPPORTED_FORMATS:
|
| 172 |
raise InvalidParameterError(
|
| 173 |
f"Unsupported format '{fmt}'. Only JPEG (JPG) and PNG are supported."
|
|
|
|
| 177 |
reader = PdfReader(io.BytesIO(pdf_bytes))
|
| 178 |
if reader.is_encrypted:
|
| 179 |
from pypdf import PasswordType
|
| 180 |
+
if reader.decrypt("") == PasswordType.NOT_DECRYPTED:
|
|
|
|
| 181 |
raise ConversionError("PDF is password-protected. Please provide an unlocked PDF.")
|
| 182 |
total_pages = len(reader.pages)
|
| 183 |
except ConversionError:
|
|
|
|
| 214 |
return blobs
|
| 215 |
|
| 216 |
|
| 217 |
+
def _normalise_for_jpeg(img: Image.Image) -> Image.Image:
|
| 218 |
+
"""Return an RGB image suitable for JPEG output, flattening alpha onto white."""
|
| 219 |
+
mode = img.mode
|
| 220 |
+
if mode == "RGB":
|
| 221 |
+
return img
|
| 222 |
+
if mode in ("RGBA", "LA", "P"):
|
| 223 |
+
if mode == "P":
|
| 224 |
+
img = img.convert("RGBA")
|
| 225 |
+
bg = Image.new("RGB", img.size, (255, 255, 255))
|
| 226 |
+
mask = img.split()[-1] if mode in ("RGBA", "LA") else None
|
| 227 |
+
bg.paste(img, mask=mask)
|
| 228 |
+
return bg
|
| 229 |
+
return img.convert("RGB")
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def _normalise_for_png(img: Image.Image) -> Image.Image:
|
| 233 |
+
"""Return an image whose mode PIL accepts for PNG (RGB / RGBA / L)."""
|
| 234 |
+
if img.mode in ("RGB", "RGBA", "L"):
|
| 235 |
+
return img
|
| 236 |
+
return img.convert("RGB")
|
| 237 |
+
|
| 238 |
+
|
| 239 |
def _render_single_page(
|
| 240 |
page_idx: int,
|
| 241 |
page_pdf_bytes: bytes,
|
|
|
|
| 246 |
from pdf2image import convert_from_bytes
|
| 247 |
from pdf2image.exceptions import PDFInfoNotInstalledError, PDFPageCountError, PDFSyntaxError
|
| 248 |
|
| 249 |
+
fmt = _format_str(params.format)
|
| 250 |
|
| 251 |
try:
|
| 252 |
pil_images = convert_from_bytes(
|
|
|
|
| 264 |
raise ConversionError(f"Page {page_idx + 1}: renderer returned no image")
|
| 265 |
|
| 266 |
img = pil_images[0]
|
| 267 |
+
if fmt == "JPEG":
|
| 268 |
+
img = _normalise_for_jpeg(img)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
if params.grayscale:
|
| 270 |
img = img.convert("L")
|
| 271 |
|
| 272 |
+
ext = _ext_for(fmt)
|
| 273 |
filename = f"page_{page_idx + 1:04d}.{ext}"
|
| 274 |
out_path = job_dir / filename
|
| 275 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
try:
|
| 277 |
+
img.save(str(out_path), format=fmt, **_save_kwargs(fmt, params.quality))
|
| 278 |
except OSError as exc:
|
| 279 |
raise ConversionError(f"Failed to write {filename}: {exc}") from exc
|
| 280 |
|
|
|
|
| 299 |
Called only when split_page=False.
|
| 300 |
Pages are placed top-to-bottom in document order with no gaps.
|
| 301 |
"""
|
| 302 |
+
fmt = _format_str(params.format)
|
| 303 |
+
ext = _ext_for(fmt)
|
| 304 |
|
| 305 |
images: list[Image.Image] = []
|
| 306 |
for pr in sorted(page_results, key=lambda p: p.page_number):
|
| 307 |
path = job_dir / Path(pr.download_url).name
|
| 308 |
img = Image.open(str(path))
|
| 309 |
+
if fmt == "JPEG":
|
| 310 |
+
img = _normalise_for_jpeg(img)
|
| 311 |
+
else:
|
| 312 |
+
img = _normalise_for_png(img)
|
| 313 |
images.append(img)
|
| 314 |
|
| 315 |
if not images:
|
|
|
|
| 319 |
total_height = sum(im.height for im in images)
|
| 320 |
|
| 321 |
mode = images[0].mode
|
| 322 |
+
fill = (255, 255, 255) if mode == "RGB" else 255
|
| 323 |
+
stitched = Image.new(mode, (total_width, total_height), color=fill)
|
| 324 |
|
| 325 |
y_offset = 0
|
| 326 |
for img in images:
|
|
|
|
| 329 |
|
| 330 |
out_filename = f"stitched.{ext}"
|
| 331 |
out_path = job_dir / out_filename
|
| 332 |
+
stitched.save(str(out_path), format=fmt, **_save_kwargs(fmt, params.quality))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
|
| 334 |
stat = out_path.stat()
|
| 335 |
return PageResult(
|
|
|
|
| 343 |
|
| 344 |
|
| 345 |
def _guard_memory(page_count: int, dpi: int) -> None:
|
| 346 |
+
bytes_per_page = (dpi * 8.5) * (dpi * 11) * 3
|
|
|
|
| 347 |
estimated_mb = (bytes_per_page * page_count) / (1024 * 1024)
|
| 348 |
if estimated_mb > _MAX_MEMORY_ESTIMATE_MB:
|
| 349 |
raise InvalidParameterError(
|
|
|
|
| 354 |
|
| 355 |
|
| 356 |
conversion_service = ConversionService()
|
| 357 |
+
|
app/services/file_service.py
CHANGED
|
@@ -16,14 +16,16 @@ async def upload_file_to_service(
|
|
| 16 |
content_type: str = "application/octet-stream",
|
| 17 |
) -> UploadedFileInfo:
|
| 18 |
timeout = httpx.Timeout(settings.FILE_SERVICE_TIMEOUT, connect=settings.FILE_SERVICE_CONNECT_TIMEOUT)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
async with httpx.AsyncClient(timeout=timeout) as client:
|
| 20 |
try:
|
| 21 |
response = await client.post(
|
| 22 |
settings.FILE_SERVICE_URL,
|
| 23 |
-
headers=
|
| 24 |
-
"X-API-Key": settings.FILE_SERVICE_API_KEY,
|
| 25 |
-
"Authorization": f"Bearer {settings.FILE_SERVICE_BEARER_TOKEN}"
|
| 26 |
-
},
|
| 27 |
files={"files": (filename, file_bytes, content_type)},
|
| 28 |
)
|
| 29 |
response.raise_for_status()
|
|
@@ -40,8 +42,7 @@ async def upload_file_to_service(
|
|
| 40 |
logger.error("file_service_request_error", error=str(exc))
|
| 41 |
raise FileServiceError(f"File service unreachable: {exc}") from exc
|
| 42 |
|
| 43 |
-
|
| 44 |
-
uploaded = _extract_uploaded_file(payload, filename, len(file_bytes))
|
| 45 |
logger.info("file_uploaded", file_id=uploaded.file_id, filename=filename)
|
| 46 |
return uploaded
|
| 47 |
|
|
@@ -51,18 +52,16 @@ def _extract_uploaded_file(
|
|
| 51 |
filename: str,
|
| 52 |
size_bytes: int,
|
| 53 |
) -> UploadedFileInfo:
|
|
|
|
| 54 |
if isinstance(payload, list):
|
| 55 |
-
if not payload:
|
| 56 |
-
raise FileServiceError("File service returned an empty list")
|
| 57 |
-
|
| 58 |
-
if not isinstance(first, dict):
|
| 59 |
-
raise FileServiceError(f"Unexpected item type in file service list: {type(first)}")
|
| 60 |
-
entry = first.get("data") or first
|
| 61 |
return _entry_to_info(entry, filename, size_bytes)
|
| 62 |
|
| 63 |
if isinstance(payload, dict):
|
| 64 |
nested = payload.get("files") or payload.get("data")
|
| 65 |
-
if isinstance(nested, list) and nested:
|
| 66 |
entry = nested[0].get("data") or nested[0]
|
| 67 |
return _entry_to_info(entry, filename, size_bytes)
|
| 68 |
return _entry_to_info(payload, filename, size_bytes)
|
|
@@ -71,29 +70,26 @@ def _extract_uploaded_file(
|
|
| 71 |
|
| 72 |
|
| 73 |
def _entry_to_info(entry: dict, filename: str, size_bytes: int) -> UploadedFileInfo:
|
| 74 |
-
file_id = (
|
| 75 |
entry.get("fileid")
|
| 76 |
or entry.get("file_id")
|
| 77 |
or entry.get("id")
|
| 78 |
or entry.get("_id")
|
| 79 |
or ""
|
| 80 |
)
|
| 81 |
-
file_url = (
|
| 82 |
entry.get("file_url")
|
| 83 |
or entry.get("url")
|
| 84 |
or entry.get("path")
|
| 85 |
or ""
|
| 86 |
)
|
| 87 |
-
resolved_filename = (
|
| 88 |
-
entry.get("filename")
|
| 89 |
-
or entry.get("name")
|
| 90 |
-
or filename
|
| 91 |
-
)
|
| 92 |
resolved_size = int(entry.get("size") or entry.get("size_bytes") or size_bytes)
|
| 93 |
|
| 94 |
return UploadedFileInfo(
|
| 95 |
-
file_id=
|
| 96 |
-
file_url=
|
| 97 |
-
filename=
|
| 98 |
size_bytes=resolved_size,
|
| 99 |
)
|
|
|
|
|
|
| 16 |
content_type: str = "application/octet-stream",
|
| 17 |
) -> UploadedFileInfo:
|
| 18 |
timeout = httpx.Timeout(settings.FILE_SERVICE_TIMEOUT, connect=settings.FILE_SERVICE_CONNECT_TIMEOUT)
|
| 19 |
+
headers = {
|
| 20 |
+
"X-API-Key": settings.FILE_SERVICE_API_KEY,
|
| 21 |
+
"Authorization": f"Bearer {settings.FILE_SERVICE_BEARER_TOKEN}",
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
async with httpx.AsyncClient(timeout=timeout) as client:
|
| 25 |
try:
|
| 26 |
response = await client.post(
|
| 27 |
settings.FILE_SERVICE_URL,
|
| 28 |
+
headers=headers,
|
|
|
|
|
|
|
|
|
|
| 29 |
files={"files": (filename, file_bytes, content_type)},
|
| 30 |
)
|
| 31 |
response.raise_for_status()
|
|
|
|
| 42 |
logger.error("file_service_request_error", error=str(exc))
|
| 43 |
raise FileServiceError(f"File service unreachable: {exc}") from exc
|
| 44 |
|
| 45 |
+
uploaded = _extract_uploaded_file(response.json(), filename, len(file_bytes))
|
|
|
|
| 46 |
logger.info("file_uploaded", file_id=uploaded.file_id, filename=filename)
|
| 47 |
return uploaded
|
| 48 |
|
|
|
|
| 52 |
filename: str,
|
| 53 |
size_bytes: int,
|
| 54 |
) -> UploadedFileInfo:
|
| 55 |
+
"""Pick the first file entry out of the (variably-shaped) service response."""
|
| 56 |
if isinstance(payload, list):
|
| 57 |
+
if not payload or not isinstance(payload[0], dict):
|
| 58 |
+
raise FileServiceError("File service returned an empty or malformed list")
|
| 59 |
+
entry = payload[0].get("data") or payload[0]
|
|
|
|
|
|
|
|
|
|
| 60 |
return _entry_to_info(entry, filename, size_bytes)
|
| 61 |
|
| 62 |
if isinstance(payload, dict):
|
| 63 |
nested = payload.get("files") or payload.get("data")
|
| 64 |
+
if isinstance(nested, list) and nested and isinstance(nested[0], dict):
|
| 65 |
entry = nested[0].get("data") or nested[0]
|
| 66 |
return _entry_to_info(entry, filename, size_bytes)
|
| 67 |
return _entry_to_info(payload, filename, size_bytes)
|
|
|
|
| 70 |
|
| 71 |
|
| 72 |
def _entry_to_info(entry: dict, filename: str, size_bytes: int) -> UploadedFileInfo:
|
| 73 |
+
file_id = str(
|
| 74 |
entry.get("fileid")
|
| 75 |
or entry.get("file_id")
|
| 76 |
or entry.get("id")
|
| 77 |
or entry.get("_id")
|
| 78 |
or ""
|
| 79 |
)
|
| 80 |
+
file_url = str(
|
| 81 |
entry.get("file_url")
|
| 82 |
or entry.get("url")
|
| 83 |
or entry.get("path")
|
| 84 |
or ""
|
| 85 |
)
|
| 86 |
+
resolved_filename = str(entry.get("filename") or entry.get("name") or filename)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
resolved_size = int(entry.get("size") or entry.get("size_bytes") or size_bytes)
|
| 88 |
|
| 89 |
return UploadedFileInfo(
|
| 90 |
+
file_id=file_id,
|
| 91 |
+
file_url=file_url,
|
| 92 |
+
filename=resolved_filename,
|
| 93 |
size_bytes=resolved_size,
|
| 94 |
)
|
| 95 |
+
|
app/services/upload_orchestrator.py
CHANGED
|
@@ -34,6 +34,29 @@ from app.services.file_service import upload_file_to_service
|
|
| 34 |
logger = structlog.get_logger(__name__)
|
| 35 |
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
async def convert_and_upload(
|
| 38 |
pdf_bytes: bytes,
|
| 39 |
params: ConversionParams,
|
|
@@ -70,7 +93,7 @@ async def _upload_pages(
|
|
| 70 |
job_id: str,
|
| 71 |
) -> tuple[List[PageResult], UploadSummary]:
|
| 72 |
semaphore = asyncio.Semaphore(settings.UPLOAD_CONCURRENCY)
|
| 73 |
-
fmt =
|
| 74 |
ext = "jpg" if fmt == "JPEG" else "png"
|
| 75 |
content_type = "image/jpeg" if fmt == "JPEG" else "image/png"
|
| 76 |
|
|
@@ -83,11 +106,9 @@ async def _upload_pages(
|
|
| 83 |
return pr
|
| 84 |
try:
|
| 85 |
file_bytes = file_path.read_bytes()
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
filename = f"{job_id}_page_{pr.page_number:04d}.{ext}"
|
| 90 |
-
info = await upload_file_to_service(file_bytes, filename, content_type)
|
| 91 |
pr.file_id = info.file_id
|
| 92 |
pr.file_url = info.file_url
|
| 93 |
logger.debug("page_uploaded", job_id=job_id, page=pr.page_number, file_id=info.file_id)
|
|
@@ -109,11 +130,7 @@ async def _upload_pages(
|
|
| 109 |
UploadedFileInfo(
|
| 110 |
file_id=p.file_id or "",
|
| 111 |
file_url=p.file_url or "",
|
| 112 |
-
filename=(
|
| 113 |
-
f"{job_id}_stitched.{ext}"
|
| 114 |
-
if Path(_resolve_local_path(p.download_url)).stem == "stitched"
|
| 115 |
-
else f"{job_id}_page_{p.page_number:04d}.{ext}"
|
| 116 |
-
),
|
| 117 |
size_bytes=p.size_bytes,
|
| 118 |
)
|
| 119 |
for p in succeeded
|
|
@@ -123,9 +140,3 @@ async def _upload_pages(
|
|
| 123 |
logger.info("upload_complete", job_id=job_id, uploaded=len(succeeded), failed=len(failed))
|
| 124 |
return updated_pages, summary
|
| 125 |
|
| 126 |
-
|
| 127 |
-
def _resolve_local_path(download_url: str) -> Path:
|
| 128 |
-
parts = download_url.lstrip("/").split("/")
|
| 129 |
-
if len(parts) >= 5:
|
| 130 |
-
return Path(settings.OUTPUT_DIR) / parts[3] / parts[4]
|
| 131 |
-
raise ValueError(f"Cannot resolve local path from URL: {download_url}")
|
|
|
|
| 34 |
logger = structlog.get_logger(__name__)
|
| 35 |
|
| 36 |
|
| 37 |
+
def _format_str(fmt) -> str:
|
| 38 |
+
return fmt.value if hasattr(fmt, "value") else str(fmt)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _resolve_local_path(download_url: str) -> Path:
|
| 42 |
+
parts = download_url.lstrip("/").split("/")
|
| 43 |
+
if len(parts) >= 5:
|
| 44 |
+
return Path(settings.OUTPUT_DIR) / parts[3] / parts[4]
|
| 45 |
+
raise ValueError(f"Cannot resolve local path from URL: {download_url}")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _build_filename(job_id: str, page: PageResult, ext: str) -> str:
|
| 49 |
+
"""Public-facing filename for an uploaded page.
|
| 50 |
+
|
| 51 |
+
Stitched outputs are flattened to a single deterministic name; per-page
|
| 52 |
+
images use a zero-padded sequence number.
|
| 53 |
+
"""
|
| 54 |
+
local = _resolve_local_path(page.download_url)
|
| 55 |
+
if local.stem == "stitched":
|
| 56 |
+
return f"{job_id}_stitched.{ext}"
|
| 57 |
+
return f"{job_id}_page_{page.page_number:04d}.{ext}"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
async def convert_and_upload(
|
| 61 |
pdf_bytes: bytes,
|
| 62 |
params: ConversionParams,
|
|
|
|
| 93 |
job_id: str,
|
| 94 |
) -> tuple[List[PageResult], UploadSummary]:
|
| 95 |
semaphore = asyncio.Semaphore(settings.UPLOAD_CONCURRENCY)
|
| 96 |
+
fmt = _format_str(params.format)
|
| 97 |
ext = "jpg" if fmt == "JPEG" else "png"
|
| 98 |
content_type = "image/jpeg" if fmt == "JPEG" else "image/png"
|
| 99 |
|
|
|
|
| 106 |
return pr
|
| 107 |
try:
|
| 108 |
file_bytes = file_path.read_bytes()
|
| 109 |
+
info = await upload_file_to_service(
|
| 110 |
+
file_bytes, _build_filename(job_id, pr, ext), content_type
|
| 111 |
+
)
|
|
|
|
|
|
|
| 112 |
pr.file_id = info.file_id
|
| 113 |
pr.file_url = info.file_url
|
| 114 |
logger.debug("page_uploaded", job_id=job_id, page=pr.page_number, file_id=info.file_id)
|
|
|
|
| 130 |
UploadedFileInfo(
|
| 131 |
file_id=p.file_id or "",
|
| 132 |
file_url=p.file_url or "",
|
| 133 |
+
filename=_build_filename(job_id, p, ext),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
size_bytes=p.size_bytes,
|
| 135 |
)
|
| 136 |
for p in succeeded
|
|
|
|
| 140 |
logger.info("upload_complete", job_id=job_id, uploaded=len(succeeded), failed=len(failed))
|
| 141 |
return updated_pages, summary
|
| 142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/utils/validators.py
CHANGED
|
@@ -15,9 +15,12 @@ from app.core.exceptions import FileTooLargeError, InvalidFileTypeError, Invalid
|
|
| 15 |
logger = structlog.get_logger(__name__)
|
| 16 |
|
| 17 |
_PDF_MAGIC = b"%PDF"
|
|
|
|
| 18 |
_MAX_URL_REDIRECTS = 5
|
| 19 |
-
_ALLOWED_SCHEMES = {"http", "https"}
|
| 20 |
-
|
|
|
|
|
|
|
| 21 |
ipaddress.ip_network("10.0.0.0/8"),
|
| 22 |
ipaddress.ip_network("172.16.0.0/12"),
|
| 23 |
ipaddress.ip_network("192.168.0.0/16"),
|
|
@@ -25,15 +28,18 @@ _PRIVATE_RANGES = [
|
|
| 25 |
ipaddress.ip_network("169.254.0.0/16"),
|
| 26 |
ipaddress.ip_network("::1/128"),
|
| 27 |
ipaddress.ip_network("fc00::/7"),
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
async def read_and_validate_pdf(upload: UploadFile) -> bytes:
|
| 32 |
buffer = io.BytesIO()
|
| 33 |
total = 0
|
| 34 |
-
chunk_size = 64 * 1024
|
| 35 |
|
| 36 |
-
while chunk := await upload.read(
|
| 37 |
total += len(chunk)
|
| 38 |
if total > settings.MAX_FILE_SIZE_BYTES:
|
| 39 |
raise FileTooLargeError(settings.MAX_FILE_SIZE_MB)
|
|
@@ -74,7 +80,7 @@ async def fetch_pdf_from_url(url: str) -> bytes:
|
|
| 74 |
raise FileTooLargeError(settings.MAX_FILE_SIZE_MB)
|
| 75 |
|
| 76 |
pdf_bytes = b""
|
| 77 |
-
async for chunk in response.aiter_bytes(chunk_size=
|
| 78 |
pdf_bytes += chunk
|
| 79 |
if len(pdf_bytes) > settings.MAX_FILE_SIZE_BYTES:
|
| 80 |
raise FileTooLargeError(settings.MAX_FILE_SIZE_MB)
|
|
@@ -114,8 +120,7 @@ def _validate_url_host(url: str) -> None:
|
|
| 114 |
except (socket.gaierror, ValueError):
|
| 115 |
raise InvalidParameterError(f"Could not resolve hostname: {hostname}")
|
| 116 |
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
)
|
|
|
|
| 15 |
logger = structlog.get_logger(__name__)
|
| 16 |
|
| 17 |
_PDF_MAGIC = b"%PDF"
|
| 18 |
+
_CHUNK_SIZE = 64 * 1024
|
| 19 |
_MAX_URL_REDIRECTS = 5
|
| 20 |
+
_ALLOWED_SCHEMES = frozenset({"http", "https"})
|
| 21 |
+
|
| 22 |
+
# SSRF guard: pre-collapsed private/loopback/link-local networks.
|
| 23 |
+
_PRIVATE_RANGES: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...] = (
|
| 24 |
ipaddress.ip_network("10.0.0.0/8"),
|
| 25 |
ipaddress.ip_network("172.16.0.0/12"),
|
| 26 |
ipaddress.ip_network("192.168.0.0/16"),
|
|
|
|
| 28 |
ipaddress.ip_network("169.254.0.0/16"),
|
| 29 |
ipaddress.ip_network("::1/128"),
|
| 30 |
ipaddress.ip_network("fc00::/7"),
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _is_private_ip(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
| 35 |
+
return any(addr in net for net in _PRIVATE_RANGES)
|
| 36 |
|
| 37 |
|
| 38 |
async def read_and_validate_pdf(upload: UploadFile) -> bytes:
|
| 39 |
buffer = io.BytesIO()
|
| 40 |
total = 0
|
|
|
|
| 41 |
|
| 42 |
+
while chunk := await upload.read(_CHUNK_SIZE):
|
| 43 |
total += len(chunk)
|
| 44 |
if total > settings.MAX_FILE_SIZE_BYTES:
|
| 45 |
raise FileTooLargeError(settings.MAX_FILE_SIZE_MB)
|
|
|
|
| 80 |
raise FileTooLargeError(settings.MAX_FILE_SIZE_MB)
|
| 81 |
|
| 82 |
pdf_bytes = b""
|
| 83 |
+
async for chunk in response.aiter_bytes(chunk_size=_CHUNK_SIZE):
|
| 84 |
pdf_bytes += chunk
|
| 85 |
if len(pdf_bytes) > settings.MAX_FILE_SIZE_BYTES:
|
| 86 |
raise FileTooLargeError(settings.MAX_FILE_SIZE_MB)
|
|
|
|
| 120 |
except (socket.gaierror, ValueError):
|
| 121 |
raise InvalidParameterError(f"Could not resolve hostname: {hostname}")
|
| 122 |
|
| 123 |
+
if _is_private_ip(addr):
|
| 124 |
+
raise InvalidParameterError(
|
| 125 |
+
"Requests to private or loopback IP addresses are not permitted (SSRF protection)"
|
| 126 |
+
)
|
|
|
banner.py
CHANGED
|
@@ -10,14 +10,14 @@ _console = Console()
|
|
| 10 |
SERVICE_NAME = os.getenv("SERVICE_NAME", "reconciliation-file-processing-service")
|
| 11 |
API_VERSION = os.getenv("API_VERSION", "2.2.0")
|
| 12 |
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
def print_banner() -> None:
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
_console.print(Text(art, style="bold cyan"))
|
| 19 |
_console.print(f" [bold white]{'Service:':<14}[/bold white] [cyan]{SERVICE_NAME}[/cyan]")
|
| 20 |
_console.print(f" [bold white]{'Version:':<14}[/bold white] [cyan]v{API_VERSION}[/cyan]")
|
| 21 |
_console.print(f" [bold white]{'Environment:':<14}[/bold white] [cyan]{ENVIRONMENT}[/cyan]")
|
| 22 |
_console.print(Rule(style="dim cyan"))
|
| 23 |
_console.print()
|
|
|
|
|
|
| 10 |
SERVICE_NAME = os.getenv("SERVICE_NAME", "reconciliation-file-processing-service")
|
| 11 |
API_VERSION = os.getenv("API_VERSION", "2.2.0")
|
| 12 |
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
| 13 |
+
_BANNER_ART = pyfiglet.figlet_format("ValidOps", font="slant")
|
| 14 |
|
| 15 |
|
| 16 |
def print_banner() -> None:
|
| 17 |
+
_console.print(Text(_BANNER_ART, style="bold cyan"))
|
|
|
|
|
|
|
| 18 |
_console.print(f" [bold white]{'Service:':<14}[/bold white] [cyan]{SERVICE_NAME}[/cyan]")
|
| 19 |
_console.print(f" [bold white]{'Version:':<14}[/bold white] [cyan]v{API_VERSION}[/cyan]")
|
| 20 |
_console.print(f" [bold white]{'Environment:':<14}[/bold white] [cyan]{ENVIRONMENT}[/cyan]")
|
| 21 |
_console.print(Rule(style="dim cyan"))
|
| 22 |
_console.print()
|
| 23 |
+
|
core/batch.py
CHANGED
|
@@ -15,11 +15,19 @@ from dataclasses import dataclass
|
|
| 15 |
from pathlib import Path
|
| 16 |
from typing import Callable, Optional, Sequence
|
| 17 |
|
| 18 |
-
from .converter import ConversionError, ConversionResult, DocumentConverter, SUPPORTED_EXTENSIONS
|
| 19 |
from logger import get_logger
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
logger = get_logger(__name__)
|
| 22 |
|
|
|
|
|
|
|
| 23 |
|
| 24 |
@dataclass
|
| 25 |
class BatchReport:
|
|
@@ -54,7 +62,7 @@ class BatchProcessor:
|
|
| 54 |
def __init__(
|
| 55 |
self,
|
| 56 |
converter: DocumentConverter,
|
| 57 |
-
max_workers: int =
|
| 58 |
) -> None:
|
| 59 |
self._converter = converter
|
| 60 |
self._max_workers = max_workers
|
|
@@ -98,15 +106,18 @@ class BatchProcessor:
|
|
| 98 |
"batch_processor | done | total=%d | succeeded=%d | failed=%d",
|
| 99 |
total, len(results), len(errors),
|
| 100 |
)
|
|
|
|
|
|
|
|
|
|
| 101 |
return BatchReport(
|
| 102 |
total=total,
|
| 103 |
succeeded=len(results),
|
| 104 |
failed=len(errors),
|
| 105 |
results=results,
|
| 106 |
errors=errors,
|
| 107 |
-
total_chars=
|
| 108 |
-
total_words=
|
| 109 |
-
total_duration_ms=
|
| 110 |
)
|
| 111 |
|
| 112 |
def discover_files(
|
|
|
|
| 15 |
from pathlib import Path
|
| 16 |
from typing import Callable, Optional, Sequence
|
| 17 |
|
|
|
|
| 18 |
from logger import get_logger
|
| 19 |
|
| 20 |
+
from .converter import (
|
| 21 |
+
ConversionError,
|
| 22 |
+
ConversionResult,
|
| 23 |
+
DocumentConverter,
|
| 24 |
+
SUPPORTED_EXTENSIONS,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
logger = get_logger(__name__)
|
| 28 |
|
| 29 |
+
_DEFAULT_MAX_WORKERS = min(8, (os.cpu_count() or 1) + 4)
|
| 30 |
+
|
| 31 |
|
| 32 |
@dataclass
|
| 33 |
class BatchReport:
|
|
|
|
| 62 |
def __init__(
|
| 63 |
self,
|
| 64 |
converter: DocumentConverter,
|
| 65 |
+
max_workers: int = _DEFAULT_MAX_WORKERS,
|
| 66 |
) -> None:
|
| 67 |
self._converter = converter
|
| 68 |
self._max_workers = max_workers
|
|
|
|
| 106 |
"batch_processor | done | total=%d | succeeded=%d | failed=%d",
|
| 107 |
total, len(results), len(errors),
|
| 108 |
)
|
| 109 |
+
total_chars = sum(r.char_count for r in results)
|
| 110 |
+
total_words = sum(r.word_count for r in results)
|
| 111 |
+
total_duration = sum(r.duration_ms for r in results)
|
| 112 |
return BatchReport(
|
| 113 |
total=total,
|
| 114 |
succeeded=len(results),
|
| 115 |
failed=len(errors),
|
| 116 |
results=results,
|
| 117 |
errors=errors,
|
| 118 |
+
total_chars=total_chars,
|
| 119 |
+
total_words=total_words,
|
| 120 |
+
total_duration_ms=total_duration,
|
| 121 |
)
|
| 122 |
|
| 123 |
def discover_files(
|
core/converter.py
CHANGED
|
@@ -23,46 +23,42 @@ Public classes
|
|
| 23 |
from __future__ import annotations
|
| 24 |
|
| 25 |
import hashlib
|
|
|
|
| 26 |
import mimetypes
|
| 27 |
import time
|
|
|
|
| 28 |
from dataclasses import dataclass, field
|
| 29 |
from pathlib import Path
|
| 30 |
-
from typing import Optional
|
| 31 |
from urllib.parse import urlparse
|
| 32 |
|
| 33 |
from markitdown import MarkItDown
|
| 34 |
|
| 35 |
-
from .ocr_engine import ocr_image, ocr_pdf
|
| 36 |
from logger import get_logger
|
| 37 |
|
|
|
|
|
|
|
| 38 |
logger = get_logger(__name__)
|
| 39 |
|
| 40 |
-
|
| 41 |
-
IMAGE_EXTENSIONS = {
|
| 42 |
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff",
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
IMAGE_MIME_PREFIXES = {"image/"}
|
| 46 |
|
|
|
|
| 47 |
|
| 48 |
-
|
| 49 |
-
# Supported formats
|
| 50 |
-
# ---------------------------------------------------------------------------
|
| 51 |
-
|
| 52 |
-
SUPPORTED_EXTENSIONS = {
|
| 53 |
".pdf", ".docx", ".doc", ".pptx", ".ppt",
|
| 54 |
".xlsx", ".xls", ".csv", ".json", ".xml",
|
| 55 |
".html", ".htm", ".txt", ".md", ".rst",
|
| 56 |
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff",
|
| 57 |
".mp3", ".wav", ".ogg", ".flac",
|
| 58 |
".zip", ".epub",
|
| 59 |
-
}
|
|
|
|
| 60 |
|
| 61 |
def _is_image(ext: str, mime: str) -> bool:
|
| 62 |
"""Return True when the input should be routed through RapidOCR."""
|
| 63 |
-
return ext.lower() in IMAGE_EXTENSIONS or
|
| 64 |
-
mime.startswith(p) for p in IMAGE_MIME_PREFIXES
|
| 65 |
-
)
|
| 66 |
|
| 67 |
|
| 68 |
# ---------------------------------------------------------------------------
|
|
@@ -121,119 +117,87 @@ class DocumentConverter:
|
|
| 121 |
def convert_file(self, path: str | Path) -> ConversionResult | ConversionError:
|
| 122 |
"""Convert a local file identified by *path*."""
|
| 123 |
path = Path(path).resolve()
|
| 124 |
-
start = time.perf_counter()
|
| 125 |
|
| 126 |
if not path.exists():
|
| 127 |
-
return
|
| 128 |
-
|
| 129 |
-
error_type="FileNotFoundError",
|
| 130 |
-
message=f"File does not exist: {path}",
|
| 131 |
-
duration_ms=0.0,
|
| 132 |
-
)
|
| 133 |
|
| 134 |
file_size = path.stat().st_size
|
| 135 |
-
mime_type
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
markdown =
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
)
|
| 148 |
-
markdown = ocr_pdf(str(path))
|
| 149 |
-
|
| 150 |
-
elapsed = (time.perf_counter() - start) * 1000
|
| 151 |
-
return self._build_result(str(path), markdown, file_size, mime_type, elapsed)
|
| 152 |
-
|
| 153 |
-
except Exception as exc:
|
| 154 |
-
elapsed = (time.perf_counter() - start) * 1000
|
| 155 |
-
logger.error("convert_file | exception | file=%s | error=%s", path, exc, exc_info=True)
|
| 156 |
-
return ConversionError(
|
| 157 |
-
source=str(path),
|
| 158 |
-
error_type=type(exc).__name__,
|
| 159 |
-
message=str(exc),
|
| 160 |
-
duration_ms=elapsed,
|
| 161 |
-
)
|
| 162 |
|
| 163 |
def convert_url(self, url: str) -> ConversionResult | ConversionError:
|
| 164 |
"""Fetch and convert a public HTTP/HTTPS URL."""
|
| 165 |
parsed = urlparse(url)
|
| 166 |
if parsed.scheme not in {"http", "https"}:
|
| 167 |
-
return
|
| 168 |
-
|
| 169 |
-
error_type="ValueError",
|
| 170 |
-
message=f"Unsupported URL scheme: {parsed.scheme!r}",
|
| 171 |
-
duration_ms=0.0,
|
| 172 |
-
)
|
| 173 |
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
except Exception as exc:
|
| 189 |
-
elapsed = (time.perf_counter() - start) * 1000
|
| 190 |
-
logger.error("convert_url | exception | url=%s | error=%s", url, exc, exc_info=True)
|
| 191 |
-
return ConversionError(
|
| 192 |
-
source=url,
|
| 193 |
-
error_type=type(exc).__name__,
|
| 194 |
-
message=str(exc),
|
| 195 |
-
duration_ms=elapsed,
|
| 196 |
-
)
|
| 197 |
|
| 198 |
def convert_stream(self, data: bytes, filename: str) -> ConversionResult | ConversionError:
|
| 199 |
"""Convert raw bytes identified by *filename* (used for upload payloads)."""
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
start = time.perf_counter()
|
| 203 |
-
mime_type, _ = mimetypes.guess_type(filename)
|
| 204 |
-
mime_type = mime_type or "application/octet-stream"
|
| 205 |
ext = Path(filename).suffix.lower()
|
| 206 |
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
markdown =
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
filename,
|
| 217 |
-
)
|
| 218 |
-
markdown = ocr_pdf(data)
|
| 219 |
-
|
| 220 |
-
elapsed = (time.perf_counter() - start) * 1000
|
| 221 |
-
return self._build_result(filename, markdown, len(data), mime_type, elapsed)
|
| 222 |
-
|
| 223 |
-
except Exception as exc:
|
| 224 |
-
elapsed = (time.perf_counter() - start) * 1000
|
| 225 |
-
logger.error("convert_stream | exception | filename=%s | error=%s", filename, exc, exc_info=True)
|
| 226 |
-
return ConversionError(
|
| 227 |
-
source=filename,
|
| 228 |
-
error_type=type(exc).__name__,
|
| 229 |
-
message=str(exc),
|
| 230 |
-
duration_ms=elapsed,
|
| 231 |
-
)
|
| 232 |
|
| 233 |
# ------------------------------------------------------------------
|
| 234 |
# Internal helpers
|
| 235 |
# ------------------------------------------------------------------
|
| 236 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
@staticmethod
|
| 238 |
def _build_result(
|
| 239 |
source: str,
|
|
@@ -242,17 +206,34 @@ class DocumentConverter:
|
|
| 242 |
mime_type: str,
|
| 243 |
elapsed: float,
|
| 244 |
) -> ConversionResult:
|
| 245 |
-
lines = markdown.splitlines()
|
| 246 |
-
words = markdown.split()
|
| 247 |
-
content_hash = hashlib.sha256(markdown.encode()).hexdigest()
|
| 248 |
return ConversionResult(
|
| 249 |
source=source,
|
| 250 |
markdown=markdown,
|
| 251 |
char_count=len(markdown),
|
| 252 |
-
word_count=len(
|
| 253 |
-
line_count=len(
|
| 254 |
duration_ms=elapsed,
|
| 255 |
file_size_bytes=file_size,
|
| 256 |
mime_type=mime_type,
|
| 257 |
-
content_hash=
|
| 258 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
from __future__ import annotations
|
| 24 |
|
| 25 |
import hashlib
|
| 26 |
+
import io
|
| 27 |
import mimetypes
|
| 28 |
import time
|
| 29 |
+
from contextlib import contextmanager
|
| 30 |
from dataclasses import dataclass, field
|
| 31 |
from pathlib import Path
|
| 32 |
+
from typing import Iterator, Optional
|
| 33 |
from urllib.parse import urlparse
|
| 34 |
|
| 35 |
from markitdown import MarkItDown
|
| 36 |
|
|
|
|
| 37 |
from logger import get_logger
|
| 38 |
|
| 39 |
+
from .ocr_engine import ocr_image, ocr_pdf
|
| 40 |
+
|
| 41 |
logger = get_logger(__name__)
|
| 42 |
|
| 43 |
+
IMAGE_EXTENSIONS: frozenset[str] = frozenset({
|
|
|
|
| 44 |
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff",
|
| 45 |
+
})
|
|
|
|
|
|
|
| 46 |
|
| 47 |
+
IMAGE_MIME_PREFIXES: tuple[str, ...] = ("image/",)
|
| 48 |
|
| 49 |
+
SUPPORTED_EXTENSIONS: frozenset[str] = frozenset({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
".pdf", ".docx", ".doc", ".pptx", ".ppt",
|
| 51 |
".xlsx", ".xls", ".csv", ".json", ".xml",
|
| 52 |
".html", ".htm", ".txt", ".md", ".rst",
|
| 53 |
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff",
|
| 54 |
".mp3", ".wav", ".ogg", ".flac",
|
| 55 |
".zip", ".epub",
|
| 56 |
+
})
|
| 57 |
+
|
| 58 |
|
| 59 |
def _is_image(ext: str, mime: str) -> bool:
|
| 60 |
"""Return True when the input should be routed through RapidOCR."""
|
| 61 |
+
return ext.lower() in IMAGE_EXTENSIONS or mime.startswith(IMAGE_MIME_PREFIXES)
|
|
|
|
|
|
|
| 62 |
|
| 63 |
|
| 64 |
# ---------------------------------------------------------------------------
|
|
|
|
| 117 |
def convert_file(self, path: str | Path) -> ConversionResult | ConversionError:
|
| 118 |
"""Convert a local file identified by *path*."""
|
| 119 |
path = Path(path).resolve()
|
|
|
|
| 120 |
|
| 121 |
if not path.exists():
|
| 122 |
+
return self._error(str(path), "FileNotFoundError",
|
| 123 |
+
f"File does not exist: {path}", 0.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
file_size = path.stat().st_size
|
| 126 |
+
mime_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
|
| 127 |
+
ext = path.suffix
|
| 128 |
+
|
| 129 |
+
with self._timed() as elapsed:
|
| 130 |
+
try:
|
| 131 |
+
markdown = self._convert_to_markdown(ext, mime_type, str(path))
|
| 132 |
+
except Exception as exc:
|
| 133 |
+
logger.error("convert_file | exception | file=%s | error=%s",
|
| 134 |
+
path, exc, exc_info=True)
|
| 135 |
+
return self._error(str(path), type(exc).__name__, str(exc), elapsed())
|
| 136 |
+
|
| 137 |
+
return self._build_result(str(path), markdown, file_size, mime_type, elapsed())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
def convert_url(self, url: str) -> ConversionResult | ConversionError:
|
| 140 |
"""Fetch and convert a public HTTP/HTTPS URL."""
|
| 141 |
parsed = urlparse(url)
|
| 142 |
if parsed.scheme not in {"http", "https"}:
|
| 143 |
+
return self._error(url, "ValueError",
|
| 144 |
+
f"Unsupported URL scheme: {parsed.scheme!r}", 0.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
|
| 146 |
+
ext = Path(parsed.path).suffix.lower()
|
| 147 |
+
is_image = ext in IMAGE_EXTENSIONS
|
| 148 |
+
mime_type = mimetypes.guess_type(url)[0] or ("image/jpeg" if is_image else "text/html")
|
| 149 |
+
|
| 150 |
+
with self._timed() as elapsed:
|
| 151 |
+
try:
|
| 152 |
+
markdown = self._convert_to_markdown(ext, mime_type, url)
|
| 153 |
+
except Exception as exc:
|
| 154 |
+
logger.error("convert_url | exception | url=%s | error=%s",
|
| 155 |
+
url, exc, exc_info=True)
|
| 156 |
+
return self._error(url, type(exc).__name__, str(exc), elapsed())
|
| 157 |
+
|
| 158 |
+
return self._build_result(url, markdown, 0, mime_type, elapsed())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
|
| 160 |
def convert_stream(self, data: bytes, filename: str) -> ConversionResult | ConversionError:
|
| 161 |
"""Convert raw bytes identified by *filename* (used for upload payloads)."""
|
| 162 |
+
mime_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
ext = Path(filename).suffix.lower()
|
| 164 |
|
| 165 |
+
with self._timed() as elapsed:
|
| 166 |
+
try:
|
| 167 |
+
markdown = self._convert_to_markdown(ext, mime_type, data)
|
| 168 |
+
except Exception as exc:
|
| 169 |
+
logger.error("convert_stream | exception | filename=%s | error=%s",
|
| 170 |
+
filename, exc, exc_info=True)
|
| 171 |
+
return self._error(filename, type(exc).__name__, str(exc), elapsed())
|
| 172 |
+
|
| 173 |
+
return self._build_result(filename, markdown, len(data), mime_type, elapsed())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
|
| 175 |
# ------------------------------------------------------------------
|
| 176 |
# Internal helpers
|
| 177 |
# ------------------------------------------------------------------
|
| 178 |
|
| 179 |
+
def _convert_to_markdown(self, ext: str, mime_type: str, source) -> str:
|
| 180 |
+
"""Dispatch to the right backend (OCR or MarkItDown) and return text.
|
| 181 |
+
|
| 182 |
+
For PDFs, if MarkItDown yields nothing, fall back to OCR.
|
| 183 |
+
`source` is a path/URL for image inputs, or raw bytes for streams.
|
| 184 |
+
"""
|
| 185 |
+
if _is_image(ext, mime_type):
|
| 186 |
+
return ocr_image(source)
|
| 187 |
+
|
| 188 |
+
if isinstance(source, bytes):
|
| 189 |
+
markdown = self._engine.convert_stream(
|
| 190 |
+
io.BytesIO(source), file_extension=ext
|
| 191 |
+
).text_content
|
| 192 |
+
else:
|
| 193 |
+
markdown = self._engine.convert(source).text_content
|
| 194 |
+
|
| 195 |
+
if not markdown.strip() and ext.lower() == ".pdf":
|
| 196 |
+
source_label = f"<{len(source)} bytes>" if isinstance(source, (bytes, bytearray)) else source
|
| 197 |
+
logger.info("convert | PDF text empty, falling back to OCR | source=%s", source_label)
|
| 198 |
+
markdown = ocr_pdf(source)
|
| 199 |
+
return markdown
|
| 200 |
+
|
| 201 |
@staticmethod
|
| 202 |
def _build_result(
|
| 203 |
source: str,
|
|
|
|
| 206 |
mime_type: str,
|
| 207 |
elapsed: float,
|
| 208 |
) -> ConversionResult:
|
|
|
|
|
|
|
|
|
|
| 209 |
return ConversionResult(
|
| 210 |
source=source,
|
| 211 |
markdown=markdown,
|
| 212 |
char_count=len(markdown),
|
| 213 |
+
word_count=len(markdown.split()),
|
| 214 |
+
line_count=len(markdown.splitlines()),
|
| 215 |
duration_ms=elapsed,
|
| 216 |
file_size_bytes=file_size,
|
| 217 |
mime_type=mime_type,
|
| 218 |
+
content_hash=hashlib.sha256(markdown.encode()).hexdigest(),
|
| 219 |
)
|
| 220 |
+
|
| 221 |
+
@staticmethod
|
| 222 |
+
def _error(source: str, error_type: str, message: str, duration_ms: float) -> ConversionError:
|
| 223 |
+
return ConversionError(
|
| 224 |
+
source=source,
|
| 225 |
+
error_type=error_type,
|
| 226 |
+
message=message,
|
| 227 |
+
duration_ms=duration_ms,
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
@staticmethod
|
| 231 |
+
@contextmanager
|
| 232 |
+
def _timed() -> Iterator:
|
| 233 |
+
"""Context manager that yields a callable returning elapsed ms at any point."""
|
| 234 |
+
start = time.perf_counter()
|
| 235 |
+
|
| 236 |
+
def elapsed() -> float:
|
| 237 |
+
return (time.perf_counter() - start) * 1000.0
|
| 238 |
+
|
| 239 |
+
yield elapsed
|
core/ocr_engine.py
CHANGED
|
@@ -76,11 +76,14 @@ def _to_numpy(source) -> Union[np.ndarray, str]:
|
|
| 76 |
img = img.convert("RGB")
|
| 77 |
return np.array(img)
|
| 78 |
|
|
|
|
|
|
|
|
|
|
| 79 |
if isinstance(source, Image.Image):
|
| 80 |
return _pil_to_array(source)
|
| 81 |
|
| 82 |
-
if isinstance(source, (bytes, bytearray)):
|
| 83 |
-
return _pil_to_array(Image.open(io.BytesIO(source)))
|
| 84 |
|
| 85 |
if isinstance(source, str):
|
| 86 |
parsed = urlparse(source)
|
|
@@ -92,9 +95,6 @@ def _to_numpy(source) -> Union[np.ndarray, str]:
|
|
| 92 |
# Local file path — RapidOCR accepts it directly.
|
| 93 |
return source
|
| 94 |
|
| 95 |
-
if isinstance(source, np.ndarray):
|
| 96 |
-
return source
|
| 97 |
-
|
| 98 |
raise TypeError(
|
| 99 |
f"ocr_image expects bytes, str (URL or path), numpy.ndarray, or PIL.Image; "
|
| 100 |
f"received {type(source).__name__!r}"
|
|
|
|
| 76 |
img = img.convert("RGB")
|
| 77 |
return np.array(img)
|
| 78 |
|
| 79 |
+
if isinstance(source, np.ndarray):
|
| 80 |
+
return source
|
| 81 |
+
|
| 82 |
if isinstance(source, Image.Image):
|
| 83 |
return _pil_to_array(source)
|
| 84 |
|
| 85 |
+
if isinstance(source, (bytes, bytearray, memoryview)):
|
| 86 |
+
return _pil_to_array(Image.open(io.BytesIO(bytes(source))))
|
| 87 |
|
| 88 |
if isinstance(source, str):
|
| 89 |
parsed = urlparse(source)
|
|
|
|
| 95 |
# Local file path — RapidOCR accepts it directly.
|
| 96 |
return source
|
| 97 |
|
|
|
|
|
|
|
|
|
|
| 98 |
raise TypeError(
|
| 99 |
f"ocr_image expects bytes, str (URL or path), numpy.ndarray, or PIL.Image; "
|
| 100 |
f"received {type(source).__name__!r}"
|
extraction/json_extractor.py
CHANGED
|
@@ -5,6 +5,8 @@ Supports only CSV, XLS, XLSX file types for JSON extraction.
|
|
| 5 |
Returns error for unsupported file types.
|
| 6 |
"""
|
| 7 |
|
|
|
|
|
|
|
| 8 |
import io
|
| 9 |
import warnings
|
| 10 |
from pathlib import Path
|
|
@@ -16,15 +18,17 @@ from logger import get_logger
|
|
| 16 |
|
| 17 |
logger = get_logger(__name__)
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
-
|
| 23 |
-
from app.core.config import settings as _app_settings
|
| 24 |
-
MAX_FILE_SIZE_BYTES = _app_settings.MAX_FILE_SIZE_BYTES
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
MAX_MEMORY_ROWS = 100000
|
| 28 |
|
| 29 |
|
| 30 |
def _validate_file_size(size: int) -> Optional[str]:
|
|
@@ -34,15 +38,29 @@ def _validate_file_size(size: int) -> Optional[str]:
|
|
| 34 |
|
| 35 |
|
| 36 |
def _check_memory_usage(rows: int, cols: int) -> Optional[str]:
|
| 37 |
-
|
| 38 |
-
if
|
|
|
|
| 39 |
return f"Data size too large (approx {approx_mb:.1f} MB). Too many cells: {rows}x{cols}"
|
| 40 |
return None
|
| 41 |
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
def extract_json_from_file(
|
| 44 |
file_path: Union[str, Path],
|
| 45 |
-
file_data: Optional[bytes] = None
|
| 46 |
) -> Dict[str, Any]:
|
| 47 |
"""
|
| 48 |
Extract JSON data from structured files (CSV, XLS, XLSX).
|
|
@@ -64,114 +82,66 @@ def extract_json_from_file(
|
|
| 64 |
if ext not in SUPPORTED_EXTENSIONS:
|
| 65 |
return {
|
| 66 |
"error": f"Unsupported file type: {ext}. Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}",
|
| 67 |
-
"file_type": ext
|
| 68 |
}
|
| 69 |
|
| 70 |
-
# Validate file size
|
| 71 |
if file_data is not None:
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
|
| 80 |
try:
|
| 81 |
with warnings.catch_warnings():
|
| 82 |
warnings.simplefilter("ignore", UserWarning)
|
| 83 |
-
|
| 84 |
-
if ext == '.csv':
|
| 85 |
-
if file_data:
|
| 86 |
-
stream = io.BytesIO(file_data)
|
| 87 |
-
# Peek for encoding detection
|
| 88 |
-
sample = stream.read(1024)
|
| 89 |
-
stream.seek(0)
|
| 90 |
-
df = pd.read_csv(stream, nrows=MAX_CSV_ROWS + 1, low_memory=False)
|
| 91 |
-
else:
|
| 92 |
-
df = pd.read_csv(file_path, nrows=MAX_CSV_ROWS + 1, low_memory=False)
|
| 93 |
-
else:
|
| 94 |
-
if file_data:
|
| 95 |
-
df = pd.read_excel(io.BytesIO(file_data), engine='openpyxl' if ext == '.xlsx' else 'xlrd')
|
| 96 |
-
else:
|
| 97 |
-
df = pd.read_excel(file_path, engine='openpyxl' if ext == '.xlsx' else 'xlrd')
|
| 98 |
-
|
| 99 |
-
# Enforce row limits to prevent memory exhaustion
|
| 100 |
-
max_rows = MAX_EXCEL_ROWS if ext != '.csv' else MAX_CSV_ROWS
|
| 101 |
-
if len(df) > max_rows:
|
| 102 |
-
return {
|
| 103 |
-
"error": f"File contains {len(df)} rows, exceeds limit of {max_rows}",
|
| 104 |
-
"file_type": ext,
|
| 105 |
-
"row_count": len(df)
|
| 106 |
-
}
|
| 107 |
-
|
| 108 |
-
# Additional memory guard
|
| 109 |
-
mem_error = _check_memory_usage(len(df), len(df.columns))
|
| 110 |
-
if mem_error:
|
| 111 |
-
return {"error": mem_error, "file_type": ext}
|
| 112 |
-
|
| 113 |
-
# Efficient conversion to JSON-serializable format
|
| 114 |
-
result = {
|
| 115 |
-
"success": True,
|
| 116 |
-
"file_type": ext,
|
| 117 |
-
"data": {
|
| 118 |
-
"columns": list(df.columns),
|
| 119 |
-
"rows": df.where(pd.notnull(df), None).to_dict(orient='records'),
|
| 120 |
-
"shape": [len(df), len(df.columns)],
|
| 121 |
-
"dtypes": {col: str(dtype) for col, dtype in df.dtypes.items()}
|
| 122 |
-
}
|
| 123 |
-
}
|
| 124 |
-
|
| 125 |
-
logger.info("Extracted JSON from %s: %d rows, %d cols", ext, len(df), len(df.columns))
|
| 126 |
-
return result
|
| 127 |
-
|
| 128 |
except pd.errors.EmptyDataError:
|
| 129 |
-
return {
|
| 130 |
-
"error": f"File is empty or has no data",
|
| 131 |
-
"file_type": ext
|
| 132 |
-
}
|
| 133 |
except MemoryError:
|
| 134 |
-
return {
|
| 135 |
-
"error": "Out of memory processing file",
|
| 136 |
-
"file_type": ext
|
| 137 |
-
}
|
| 138 |
except Exception as exc:
|
| 139 |
logger.exception("JSON extraction failed for %s", ext)
|
| 140 |
return {
|
| 141 |
-
"error": f"Processing failed: {
|
| 142 |
"file_type": ext,
|
| 143 |
-
"exception_type": type(exc).__name__
|
| 144 |
}
|
| 145 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
|
|
|
|
|
|
|
|
|
|
| 147 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
|
| 150 |
def is_supported_file_type(file_path: Union[str, Path]) -> bool:
|
| 151 |
-
"""
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
Parameters
|
| 155 |
-
----------
|
| 156 |
-
file_path : Union[str, Path]
|
| 157 |
-
Path to the file or filename with extension
|
| 158 |
-
|
| 159 |
-
Returns
|
| 160 |
-
-------
|
| 161 |
-
bool
|
| 162 |
-
True if supported, False otherwise
|
| 163 |
-
"""
|
| 164 |
-
extension = Path(file_path).suffix.lower()
|
| 165 |
-
return extension in SUPPORTED_EXTENSIONS
|
| 166 |
|
| 167 |
|
| 168 |
-
def get_supported_extensions() -> list:
|
| 169 |
-
"""
|
| 170 |
-
Get list of supported file extensions for JSON extraction.
|
| 171 |
-
|
| 172 |
-
Returns
|
| 173 |
-
-------
|
| 174 |
-
list
|
| 175 |
-
Supported file extensions
|
| 176 |
-
"""
|
| 177 |
return sorted(SUPPORTED_EXTENSIONS)
|
|
|
|
|
|
| 5 |
Returns error for unsupported file types.
|
| 6 |
"""
|
| 7 |
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
import io
|
| 11 |
import warnings
|
| 12 |
from pathlib import Path
|
|
|
|
| 18 |
|
| 19 |
logger = get_logger(__name__)
|
| 20 |
|
| 21 |
+
SUPPORTED_EXTENSIONS: frozenset[str] = frozenset({".csv", ".xls", ".xlsx"})
|
| 22 |
+
|
| 23 |
+
MAX_CSV_ROWS = 100_000
|
| 24 |
+
MAX_EXCEL_ROWS = 50_000
|
| 25 |
+
MAX_CELL_COUNT = 2_000_000
|
| 26 |
|
| 27 |
+
try:
|
| 28 |
+
from app.core.config import settings as _app_settings
|
| 29 |
+
MAX_FILE_SIZE_BYTES: int = _app_settings.MAX_FILE_SIZE_BYTES
|
| 30 |
+
except Exception:
|
| 31 |
+
MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024
|
|
|
|
| 32 |
|
| 33 |
|
| 34 |
def _validate_file_size(size: int) -> Optional[str]:
|
|
|
|
| 38 |
|
| 39 |
|
| 40 |
def _check_memory_usage(rows: int, cols: int) -> Optional[str]:
|
| 41 |
+
cell_count = rows * cols
|
| 42 |
+
if cell_count > MAX_CELL_COUNT:
|
| 43 |
+
approx_mb = (cell_count * 50) / (1024 * 1024)
|
| 44 |
return f"Data size too large (approx {approx_mb:.1f} MB). Too many cells: {rows}x{cols}"
|
| 45 |
return None
|
| 46 |
|
| 47 |
|
| 48 |
+
def _read_dataframe(ext: str, file_path: Union[str, Path], file_data: Optional[bytes]) -> "pd.DataFrame":
|
| 49 |
+
"""Read a CSV/Excel file from either an in-memory buffer or a path."""
|
| 50 |
+
if ext == ".csv":
|
| 51 |
+
if file_data:
|
| 52 |
+
return pd.read_csv(io.BytesIO(file_data), nrows=MAX_CSV_ROWS + 1, low_memory=False)
|
| 53 |
+
return pd.read_csv(file_path, nrows=MAX_CSV_ROWS + 1, low_memory=False)
|
| 54 |
+
|
| 55 |
+
engine = "openpyxl" if ext == ".xlsx" else "xlrd"
|
| 56 |
+
if file_data:
|
| 57 |
+
return pd.read_excel(io.BytesIO(file_data), engine=engine)
|
| 58 |
+
return pd.read_excel(file_path, engine=engine)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
def extract_json_from_file(
|
| 62 |
file_path: Union[str, Path],
|
| 63 |
+
file_data: Optional[bytes] = None,
|
| 64 |
) -> Dict[str, Any]:
|
| 65 |
"""
|
| 66 |
Extract JSON data from structured files (CSV, XLS, XLSX).
|
|
|
|
| 82 |
if ext not in SUPPORTED_EXTENSIONS:
|
| 83 |
return {
|
| 84 |
"error": f"Unsupported file type: {ext}. Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}",
|
| 85 |
+
"file_type": ext,
|
| 86 |
}
|
| 87 |
|
| 88 |
+
# Validate file size
|
| 89 |
if file_data is not None:
|
| 90 |
+
source_bytes = len(file_data)
|
| 91 |
+
else:
|
| 92 |
+
path = Path(file_path)
|
| 93 |
+
source_bytes = path.stat().st_size if path.exists() else 0
|
| 94 |
+
size_error = _validate_file_size(source_bytes)
|
| 95 |
+
if size_error:
|
| 96 |
+
return {"error": size_error, "file_type": ext}
|
| 97 |
|
| 98 |
try:
|
| 99 |
with warnings.catch_warnings():
|
| 100 |
warnings.simplefilter("ignore", UserWarning)
|
| 101 |
+
df = _read_dataframe(ext, file_path, file_data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
except pd.errors.EmptyDataError:
|
| 103 |
+
return {"error": "File is empty or has no data", "file_type": ext}
|
|
|
|
|
|
|
|
|
|
| 104 |
except MemoryError:
|
| 105 |
+
return {"error": "Out of memory processing file", "file_type": ext}
|
|
|
|
|
|
|
|
|
|
| 106 |
except Exception as exc:
|
| 107 |
logger.exception("JSON extraction failed for %s", ext)
|
| 108 |
return {
|
| 109 |
+
"error": f"Processing failed: {exc}",
|
| 110 |
"file_type": ext,
|
| 111 |
+
"exception_type": type(exc).__name__,
|
| 112 |
}
|
| 113 |
|
| 114 |
+
max_rows = MAX_EXCEL_ROWS if ext != ".csv" else MAX_CSV_ROWS
|
| 115 |
+
if len(df) > max_rows:
|
| 116 |
+
return {
|
| 117 |
+
"error": f"File contains {len(df)} rows, exceeds limit of {max_rows}",
|
| 118 |
+
"file_type": ext,
|
| 119 |
+
"row_count": len(df),
|
| 120 |
+
}
|
| 121 |
|
| 122 |
+
mem_error = _check_memory_usage(len(df), len(df.columns))
|
| 123 |
+
if mem_error:
|
| 124 |
+
return {"error": mem_error, "file_type": ext}
|
| 125 |
|
| 126 |
+
logger.info("Extracted JSON from %s: %d rows, %d cols", ext, len(df), len(df.columns))
|
| 127 |
+
return {
|
| 128 |
+
"success": True,
|
| 129 |
+
"file_type": ext,
|
| 130 |
+
"data": {
|
| 131 |
+
"columns": list(df.columns),
|
| 132 |
+
"rows": df.where(pd.notnull(df), None).to_dict(orient="records"),
|
| 133 |
+
"shape": [len(df), len(df.columns)],
|
| 134 |
+
"dtypes": {col: str(dtype) for col, dtype in df.dtypes.items()},
|
| 135 |
+
},
|
| 136 |
+
}
|
| 137 |
|
| 138 |
|
| 139 |
def is_supported_file_type(file_path: Union[str, Path]) -> bool:
|
| 140 |
+
"""Check if file type is supported for JSON extraction."""
|
| 141 |
+
return Path(file_path).suffix.lower() in SUPPORTED_EXTENSIONS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
|
| 144 |
+
def get_supported_extensions() -> list[str]:
|
| 145 |
+
"""Get sorted list of supported file extensions for JSON extraction."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
return sorted(SUPPORTED_EXTENSIONS)
|
| 147 |
+
|
extraction/spacy_extractor.py
CHANGED
|
@@ -43,15 +43,21 @@ VALID_SPACY_LABELS: Dict[str, str] = {
|
|
| 43 |
}
|
| 44 |
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
# ---------------------------------------------------------------------------
|
| 47 |
# spaCy singleton
|
| 48 |
# ---------------------------------------------------------------------------
|
| 49 |
|
| 50 |
_nlp_lock = threading.Lock()
|
| 51 |
-
_nlp
|
| 52 |
|
| 53 |
|
| 54 |
-
def _get_nlp():
|
|
|
|
| 55 |
global _nlp
|
| 56 |
if _nlp is not None:
|
| 57 |
return _nlp
|
|
@@ -66,11 +72,20 @@ def _get_nlp():
|
|
| 66 |
return _nlp
|
| 67 |
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
# ---------------------------------------------------------------------------
|
| 70 |
# Normalizer registry
|
| 71 |
# ---------------------------------------------------------------------------
|
| 72 |
|
| 73 |
-
|
| 74 |
_NORMALIZERS: Dict[str, Callable[[str], str]] = {
|
| 75 |
"strip": lambda s: s.strip(),
|
| 76 |
"upper": lambda s: s.upper(),
|
|
@@ -78,16 +93,16 @@ _NORMALIZERS: Dict[str, Callable[[str], str]] = {
|
|
| 78 |
"remove_commas": lambda s: s.replace(",", ""),
|
| 79 |
"remove_spaces": lambda s: s.replace(" ", ""),
|
| 80 |
"remove_newlines": lambda s: s.replace("\n", " ").replace("\r", ""),
|
| 81 |
-
"collapse_whitespace": lambda s:
|
| 82 |
-
"remove_currency": lambda s:
|
| 83 |
-
"remove_non_numeric": lambda s:
|
| 84 |
-
"normalize_date_sep": lambda s:
|
| 85 |
}
|
| 86 |
|
| 87 |
|
| 88 |
def register_normalizer(name: str, fn: Callable[[str], str]) -> None:
|
| 89 |
"""Register a custom normalizer. Thread-safe, overwrites silently."""
|
| 90 |
-
with
|
| 91 |
_NORMALIZERS[name] = fn
|
| 92 |
|
| 93 |
|
|
@@ -99,8 +114,7 @@ def _apply_normalizers(value: Optional[str], normalize: Any) -> Optional[str]:
|
|
| 99 |
if isinstance(normalize, str):
|
| 100 |
normalize = [normalize]
|
| 101 |
for key in normalize:
|
| 102 |
-
|
| 103 |
-
fn = _NORMALIZERS.get(key)
|
| 104 |
if fn is None:
|
| 105 |
logger.warning("Unknown normalizer %r — skipped", key)
|
| 106 |
continue
|
|
@@ -132,8 +146,8 @@ def register_resolver(
|
|
| 132 |
# Regex resolver
|
| 133 |
# ---------------------------------------------------------------------------
|
| 134 |
|
| 135 |
-
def _build_flags(rule: Dict[str, Any]) ->
|
| 136 |
-
flags =
|
| 137 |
for name in rule.get("flags", []):
|
| 138 |
obj = getattr(re, name.upper(), None)
|
| 139 |
if obj is None:
|
|
@@ -167,7 +181,7 @@ def _resolve_regex(rule: Dict[str, Any], text: str) -> Optional[str]:
|
|
| 167 |
strip_chars = rule.get("strip_chars", "")
|
| 168 |
fallbacks = rule.get("fallback_patterns", [])
|
| 169 |
|
| 170 |
-
for pat in
|
| 171 |
try:
|
| 172 |
matches = list(re.finditer(pat, text, flags))
|
| 173 |
except re.error as exc:
|
|
@@ -177,22 +191,15 @@ def _resolve_regex(rule: Dict[str, Any], text: str) -> Optional[str]:
|
|
| 177 |
if not matches:
|
| 178 |
continue
|
| 179 |
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
if result is None:
|
| 190 |
-
break
|
| 191 |
-
result = _apply_normalizers(result, normalize)
|
| 192 |
-
if result is None:
|
| 193 |
-
break
|
| 194 |
-
result = result.strip(strip_chars) if strip_chars else result.strip()
|
| 195 |
-
return result or None
|
| 196 |
|
| 197 |
return None
|
| 198 |
|
|
@@ -254,7 +261,7 @@ def _resolve_entity(rule: Dict[str, Any], doc: Any) -> Optional[str]:
|
|
| 254 |
labels = rule.get("label")
|
| 255 |
if isinstance(labels, str):
|
| 256 |
labels = [labels]
|
| 257 |
-
|
| 258 |
|
| 259 |
match_index = rule.get("match_index", 0)
|
| 260 |
min_length = rule.get("min_length", 1)
|
|
@@ -264,7 +271,7 @@ def _resolve_entity(rule: Dict[str, Any], doc: Any) -> Optional[str]:
|
|
| 264 |
|
| 265 |
candidates = [
|
| 266 |
ent.text for ent in doc.ents
|
| 267 |
-
if ent.label_ in
|
| 268 |
and len(ent.text) >= min_length
|
| 269 |
and not (exclude_pat and re.search(exclude_pat, ent.text, exclude_flags))
|
| 270 |
]
|
|
@@ -272,11 +279,7 @@ def _resolve_entity(rule: Dict[str, Any], doc: Any) -> Optional[str]:
|
|
| 272 |
if not candidates:
|
| 273 |
return None
|
| 274 |
|
| 275 |
-
|
| 276 |
-
result = candidates[match_index]
|
| 277 |
-
except IndexError:
|
| 278 |
-
result = candidates[-1]
|
| 279 |
-
|
| 280 |
return _apply_normalizers(result, normalize)
|
| 281 |
|
| 282 |
|
|
@@ -299,7 +302,7 @@ def _resolve_entity_all(rule: Dict[str, Any], doc: Any) -> List[Optional[str]]:
|
|
| 299 |
labels = rule.get("label")
|
| 300 |
if isinstance(labels, str):
|
| 301 |
labels = [labels]
|
| 302 |
-
|
| 303 |
|
| 304 |
min_length = rule.get("min_length", 1)
|
| 305 |
exclude_pat = rule.get("exclude_pattern", "")
|
|
@@ -312,7 +315,7 @@ def _resolve_entity_all(rule: Dict[str, Any], doc: Any) -> List[Optional[str]]:
|
|
| 312 |
seen: set = set()
|
| 313 |
|
| 314 |
for ent in doc.ents:
|
| 315 |
-
if ent.label_ not in
|
| 316 |
continue
|
| 317 |
if len(ent.text) < min_length:
|
| 318 |
continue
|
|
@@ -351,11 +354,7 @@ def _resolve_token_attr(rule: Dict[str, Any], doc: Any) -> Optional[str]:
|
|
| 351 |
if not candidates:
|
| 352 |
return None
|
| 353 |
|
| 354 |
-
|
| 355 |
-
result = candidates[match_index]
|
| 356 |
-
except IndexError:
|
| 357 |
-
result = candidates[-1]
|
| 358 |
-
|
| 359 |
return _apply_normalizers(result, normalize)
|
| 360 |
|
| 361 |
|
|
@@ -381,8 +380,7 @@ def _resolve_scalar_field(
|
|
| 381 |
text: str,
|
| 382 |
) -> Optional[str]:
|
| 383 |
src = rule.get("source_type")
|
| 384 |
-
|
| 385 |
-
fn = _RESOLVERS.get(src)
|
| 386 |
if fn is None:
|
| 387 |
logger.warning("Unknown source_type %r — no resolver registered", src)
|
| 388 |
return None
|
|
@@ -501,7 +499,6 @@ def _resolve_array_node(
|
|
| 501 |
"""
|
| 502 |
item_schema: SchemaNode = node.get("items", {})
|
| 503 |
split_pat: Optional[str] = node.get("split_pattern")
|
| 504 |
-
split_flags_rule = {"flags": node.get("split_flags", [])}
|
| 505 |
max_items: Optional[int] = node.get("max_items")
|
| 506 |
|
| 507 |
results: List[ResultNode] = []
|
|
@@ -509,7 +506,7 @@ def _resolve_array_node(
|
|
| 509 |
if split_pat:
|
| 510 |
# ── MODE A: segment-per-item ────────────────────────────────────
|
| 511 |
try:
|
| 512 |
-
flags = _build_flags(
|
| 513 |
segments = re.split(split_pat, text, flags=flags)
|
| 514 |
except re.error as exc:
|
| 515 |
logger.error("Invalid split_pattern %r: %s", split_pat, exc)
|
|
@@ -577,6 +574,15 @@ def _safe_resolve_node(
|
|
| 577 |
return None
|
| 578 |
|
| 579 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 580 |
# ---------------------------------------------------------------------------
|
| 581 |
# Public API
|
| 582 |
# ---------------------------------------------------------------------------
|
|
@@ -595,17 +601,10 @@ def extract_fields(
|
|
| 595 |
|
| 596 |
Backward-compatible: callers that pass flat scalar rules unchanged still work.
|
| 597 |
"""
|
| 598 |
-
nlp
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
try:
|
| 602 |
-
doc = next(iter(nlp.pipe([cleaned])))
|
| 603 |
-
except Exception as exc:
|
| 604 |
-
logger.error("spaCy pipe failed: %s", exc, exc_info=True)
|
| 605 |
-
doc = None
|
| 606 |
-
|
| 607 |
return {
|
| 608 |
-
field: _safe_resolve_node(field, node, doc,
|
| 609 |
for field, node in fields.items()
|
| 610 |
}
|
| 611 |
|
|
@@ -637,16 +636,9 @@ def extract_schema(
|
|
| 637 |
result = extract_schema(invoice_text, schema)
|
| 638 |
# → [{"date": "Jan 2024", "amount": "$1,200"}, ...]
|
| 639 |
"""
|
| 640 |
-
nlp
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
try:
|
| 644 |
-
doc = next(iter(nlp.pipe([cleaned])))
|
| 645 |
-
except Exception as exc:
|
| 646 |
-
logger.error("spaCy pipe failed: %s", exc, exc_info=True)
|
| 647 |
-
doc = None
|
| 648 |
-
|
| 649 |
-
return _safe_resolve_node("<root>", schema, doc, cleaned)
|
| 650 |
|
| 651 |
|
| 652 |
def extract_fields_batch(
|
|
@@ -661,7 +653,7 @@ def extract_fields_batch(
|
|
| 661 |
Note: array nodes with split_pattern trigger their own inner pipe call per
|
| 662 |
text; the outer pass still processes the top-level texts efficiently.
|
| 663 |
"""
|
| 664 |
-
nlp
|
| 665 |
cleaned = [clean_text(t) for t in texts]
|
| 666 |
|
| 667 |
try:
|
|
@@ -688,7 +680,7 @@ def extract_schema_batch(
|
|
| 688 |
|
| 689 |
Returns one ResultNode per input text, in the same order.
|
| 690 |
"""
|
| 691 |
-
nlp
|
| 692 |
cleaned = [clean_text(t) for t in texts]
|
| 693 |
|
| 694 |
try:
|
|
@@ -700,4 +692,4 @@ def extract_schema_batch(
|
|
| 700 |
return [
|
| 701 |
_safe_resolve_node(f"<root>[{i}]", schema, doc, text)
|
| 702 |
for i, (doc, text) in enumerate(zip(docs, cleaned))
|
| 703 |
-
]
|
|
|
|
| 43 |
}
|
| 44 |
|
| 45 |
|
| 46 |
+
_WHITESPACE_RE = re.compile(r"\s+")
|
| 47 |
+
_CURRENCY_RE = re.compile(r"[$€£¥₹]")
|
| 48 |
+
_NON_NUMERIC_RE = re.compile(r"[^\d.]")
|
| 49 |
+
_DATE_SEP_RE = re.compile(r"[/.]")
|
| 50 |
+
|
| 51 |
# ---------------------------------------------------------------------------
|
| 52 |
# spaCy singleton
|
| 53 |
# ---------------------------------------------------------------------------
|
| 54 |
|
| 55 |
_nlp_lock = threading.Lock()
|
| 56 |
+
_nlp: Any = None
|
| 57 |
|
| 58 |
|
| 59 |
+
def _get_nlp() -> Any:
|
| 60 |
+
"""Return the shared spaCy pipeline, initialising it on first call."""
|
| 61 |
global _nlp
|
| 62 |
if _nlp is not None:
|
| 63 |
return _nlp
|
|
|
|
| 72 |
return _nlp
|
| 73 |
|
| 74 |
|
| 75 |
+
def clean_text(text: str) -> str:
|
| 76 |
+
"""Normalise whitespace on raw text before handing it to spaCy.
|
| 77 |
+
|
| 78 |
+
Defined as a module-level function so it is not shadowed by local
|
| 79 |
+
variables named `cleaned` in the public extract_* functions.
|
| 80 |
+
"""
|
| 81 |
+
return _WHITESPACE_RE.sub(" ", text).strip()
|
| 82 |
+
|
| 83 |
+
|
| 84 |
# ---------------------------------------------------------------------------
|
| 85 |
# Normalizer registry
|
| 86 |
# ---------------------------------------------------------------------------
|
| 87 |
|
| 88 |
+
_normalizer_lock = threading.Lock()
|
| 89 |
_NORMALIZERS: Dict[str, Callable[[str], str]] = {
|
| 90 |
"strip": lambda s: s.strip(),
|
| 91 |
"upper": lambda s: s.upper(),
|
|
|
|
| 93 |
"remove_commas": lambda s: s.replace(",", ""),
|
| 94 |
"remove_spaces": lambda s: s.replace(" ", ""),
|
| 95 |
"remove_newlines": lambda s: s.replace("\n", " ").replace("\r", ""),
|
| 96 |
+
"collapse_whitespace": lambda s: _WHITESPACE_RE.sub(" ", s).strip(),
|
| 97 |
+
"remove_currency": lambda s: _CURRENCY_RE.sub("", s),
|
| 98 |
+
"remove_non_numeric": lambda s: _NON_NUMERIC_RE.sub("", s),
|
| 99 |
+
"normalize_date_sep": lambda s: _DATE_SEP_RE.sub("-", s),
|
| 100 |
}
|
| 101 |
|
| 102 |
|
| 103 |
def register_normalizer(name: str, fn: Callable[[str], str]) -> None:
|
| 104 |
"""Register a custom normalizer. Thread-safe, overwrites silently."""
|
| 105 |
+
with _normalizer_lock:
|
| 106 |
_NORMALIZERS[name] = fn
|
| 107 |
|
| 108 |
|
|
|
|
| 114 |
if isinstance(normalize, str):
|
| 115 |
normalize = [normalize]
|
| 116 |
for key in normalize:
|
| 117 |
+
fn = _NORMALIZERS.get(key)
|
|
|
|
| 118 |
if fn is None:
|
| 119 |
logger.warning("Unknown normalizer %r — skipped", key)
|
| 120 |
continue
|
|
|
|
| 146 |
# Regex resolver
|
| 147 |
# ---------------------------------------------------------------------------
|
| 148 |
|
| 149 |
+
def _build_flags(rule: Dict[str, Any]) -> int:
|
| 150 |
+
flags = 0
|
| 151 |
for name in rule.get("flags", []):
|
| 152 |
obj = getattr(re, name.upper(), None)
|
| 153 |
if obj is None:
|
|
|
|
| 181 |
strip_chars = rule.get("strip_chars", "")
|
| 182 |
fallbacks = rule.get("fallback_patterns", [])
|
| 183 |
|
| 184 |
+
for pat in (primary, *fallbacks):
|
| 185 |
try:
|
| 186 |
matches = list(re.finditer(pat, text, flags))
|
| 187 |
except re.error as exc:
|
|
|
|
| 191 |
if not matches:
|
| 192 |
continue
|
| 193 |
|
| 194 |
+
target = matches[match_index] if match_index < len(matches) else matches[-1]
|
| 195 |
+
exists, result = _try_group(target, capture_group)
|
| 196 |
+
if not exists or result is None:
|
| 197 |
+
return None
|
| 198 |
+
result = _apply_normalizers(result, normalize)
|
| 199 |
+
if result is None:
|
| 200 |
+
return None
|
| 201 |
+
result = result.strip(strip_chars) if strip_chars else result.strip()
|
| 202 |
+
return result or None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
|
| 204 |
return None
|
| 205 |
|
|
|
|
| 261 |
labels = rule.get("label")
|
| 262 |
if isinstance(labels, str):
|
| 263 |
labels = [labels]
|
| 264 |
+
label_set = set(labels or [])
|
| 265 |
|
| 266 |
match_index = rule.get("match_index", 0)
|
| 267 |
min_length = rule.get("min_length", 1)
|
|
|
|
| 271 |
|
| 272 |
candidates = [
|
| 273 |
ent.text for ent in doc.ents
|
| 274 |
+
if ent.label_ in label_set
|
| 275 |
and len(ent.text) >= min_length
|
| 276 |
and not (exclude_pat and re.search(exclude_pat, ent.text, exclude_flags))
|
| 277 |
]
|
|
|
|
| 279 |
if not candidates:
|
| 280 |
return None
|
| 281 |
|
| 282 |
+
result = candidates[match_index] if match_index < len(candidates) else candidates[-1]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
return _apply_normalizers(result, normalize)
|
| 284 |
|
| 285 |
|
|
|
|
| 302 |
labels = rule.get("label")
|
| 303 |
if isinstance(labels, str):
|
| 304 |
labels = [labels]
|
| 305 |
+
label_set = set(labels or [])
|
| 306 |
|
| 307 |
min_length = rule.get("min_length", 1)
|
| 308 |
exclude_pat = rule.get("exclude_pattern", "")
|
|
|
|
| 315 |
seen: set = set()
|
| 316 |
|
| 317 |
for ent in doc.ents:
|
| 318 |
+
if ent.label_ not in label_set:
|
| 319 |
continue
|
| 320 |
if len(ent.text) < min_length:
|
| 321 |
continue
|
|
|
|
| 354 |
if not candidates:
|
| 355 |
return None
|
| 356 |
|
| 357 |
+
result = candidates[match_index] if match_index < len(candidates) else candidates[-1]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
return _apply_normalizers(result, normalize)
|
| 359 |
|
| 360 |
|
|
|
|
| 380 |
text: str,
|
| 381 |
) -> Optional[str]:
|
| 382 |
src = rule.get("source_type")
|
| 383 |
+
fn = _RESOLVERS.get(src)
|
|
|
|
| 384 |
if fn is None:
|
| 385 |
logger.warning("Unknown source_type %r — no resolver registered", src)
|
| 386 |
return None
|
|
|
|
| 499 |
"""
|
| 500 |
item_schema: SchemaNode = node.get("items", {})
|
| 501 |
split_pat: Optional[str] = node.get("split_pattern")
|
|
|
|
| 502 |
max_items: Optional[int] = node.get("max_items")
|
| 503 |
|
| 504 |
results: List[ResultNode] = []
|
|
|
|
| 506 |
if split_pat:
|
| 507 |
# ── MODE A: segment-per-item ────────────────────────────────────
|
| 508 |
try:
|
| 509 |
+
flags = _build_flags({"flags": node.get("split_flags", [])})
|
| 510 |
segments = re.split(split_pat, text, flags=flags)
|
| 511 |
except re.error as exc:
|
| 512 |
logger.error("Invalid split_pattern %r: %s", split_pat, exc)
|
|
|
|
| 574 |
return None
|
| 575 |
|
| 576 |
|
| 577 |
+
def _doc_for_text(nlp: Any, text: str) -> Any:
|
| 578 |
+
"""Run *text* through spaCy, returning None on failure instead of raising."""
|
| 579 |
+
try:
|
| 580 |
+
return next(iter(nlp.pipe([text])))
|
| 581 |
+
except Exception as exc:
|
| 582 |
+
logger.error("spaCy pipe failed: %s", exc, exc_info=True)
|
| 583 |
+
return None
|
| 584 |
+
|
| 585 |
+
|
| 586 |
# ---------------------------------------------------------------------------
|
| 587 |
# Public API
|
| 588 |
# ---------------------------------------------------------------------------
|
|
|
|
| 601 |
|
| 602 |
Backward-compatible: callers that pass flat scalar rules unchanged still work.
|
| 603 |
"""
|
| 604 |
+
nlp = _get_nlp()
|
| 605 |
+
doc = _doc_for_text(nlp, text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 606 |
return {
|
| 607 |
+
field: _safe_resolve_node(field, node, doc, text)
|
| 608 |
for field, node in fields.items()
|
| 609 |
}
|
| 610 |
|
|
|
|
| 636 |
result = extract_schema(invoice_text, schema)
|
| 637 |
# → [{"date": "Jan 2024", "amount": "$1,200"}, ...]
|
| 638 |
"""
|
| 639 |
+
nlp = _get_nlp()
|
| 640 |
+
doc = _doc_for_text(nlp, text)
|
| 641 |
+
return _safe_resolve_node("<root>", schema, doc, text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 642 |
|
| 643 |
|
| 644 |
def extract_fields_batch(
|
|
|
|
| 653 |
Note: array nodes with split_pattern trigger their own inner pipe call per
|
| 654 |
text; the outer pass still processes the top-level texts efficiently.
|
| 655 |
"""
|
| 656 |
+
nlp = _get_nlp()
|
| 657 |
cleaned = [clean_text(t) for t in texts]
|
| 658 |
|
| 659 |
try:
|
|
|
|
| 680 |
|
| 681 |
Returns one ResultNode per input text, in the same order.
|
| 682 |
"""
|
| 683 |
+
nlp = _get_nlp()
|
| 684 |
cleaned = [clean_text(t) for t in texts]
|
| 685 |
|
| 686 |
try:
|
|
|
|
| 692 |
return [
|
| 693 |
_safe_resolve_node(f"<root>[{i}]", schema, doc, text)
|
| 694 |
for i, (doc, text) in enumerate(zip(docs, cleaned))
|
| 695 |
+
]
|