validops-east-1 commited on
Commit
c5638b0
·
1 Parent(s): 461373d

feat: optimize code + add json key extractor

Browse files
__init__.py CHANGED
@@ -1,4 +1,10 @@
1
- from core.converter import ConversionError, ConversionResult, DocumentConverter, SUPPORTED_EXTENSIONS
 
 
 
 
 
 
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="2.2.0", host="0.0.0.0:7860")
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="2.2.0",
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": getattr(request.state, "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": getattr(request.state, "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": "2.2.0", "status": "running"}
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 = _json.loads(mappings)
404
- except _json.JSONDecodeError:
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
- def configure_logging() -> None:
12
- pre_chain = [
13
- structlog.contextvars.merge_contextvars,
14
- structlog.stdlib.add_log_level,
15
- structlog.processors.TimeStamper(fmt="iso"),
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 and _is_trusted_proxy(direct_host):
81
- forwarded_for = request.headers.get("x-forwarded-for", "")
82
- if forwarded_for:
83
- for candidate in reversed([ip.strip() for ip in forwarded_for.split(",")]):
84
- if candidate and not _is_trusted_proxy(candidate):
85
- return candidate
86
-
87
- real_ip = request.headers.get("x-real-ip", "").strip()
88
- if real_ip and not _is_trusted_proxy(real_ip):
89
- return real_ip
90
-
91
- return direct_host or "unknown"
 
 
 
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 = params.format.value if hasattr(params.format, "value") else str(params.format)
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
- result = reader.decrypt("")
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 = params.format.value if hasattr(params.format, "value") else params.format
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
- if fmt == "JPEG" and img.mode in ("RGBA", "LA", "P"):
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 = "jpg" if fmt == "JPEG" else "png"
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, **save_kwargs)
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 = params.format.value if hasattr(params.format, "value") else params.format
280
- ext = "jpg" if fmt == "JPEG" else "png"
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" and img.mode != "RGB":
287
- img = img.convert("RGB")
288
- elif fmt == "PNG" and img.mode not in ("RGB", "RGBA", "L"):
289
- img = img.convert("RGB")
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
- stitched = Image.new(mode, (total_width, total_height), color=(255, 255, 255) if mode == "RGB" else 255)
 
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
- pixels_per_page = (dpi * 8.5) * (dpi * 11)
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
- payload = response.json()
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
- first = payload[0]
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=str(file_id),
96
- file_url=str(file_url),
97
- filename=str(resolved_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 = params.format.value if hasattr(params.format, "value") else str(params.format)
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
- if file_path.stem == "stitched":
87
- filename = f"{job_id}_stitched.{ext}"
88
- else:
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
- _PRIVATE_RANGES = [
 
 
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(chunk_size):
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=64 * 1024):
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
- for private_range in _PRIVATE_RANGES:
118
- if addr in private_range:
119
- raise InvalidParameterError(
120
- "Requests to private or loopback IP addresses are not permitted (SSRF protection)"
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
- art = pyfiglet.figlet_format("ValidOps", font="slant")
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 = min(8, (os.cpu_count() or 1) + 4),
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=sum(r.char_count for r in results),
108
- total_words=sum(r.word_count for r in results),
109
- total_duration_ms=sum(r.duration_ms for r in results),
110
  )
111
 
112
  def discover_files(
 
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
- # Extensions that route through RapidOCR rather than MarkItDown.
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 any(
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 ConversionError(
128
- source=str(path),
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, _ = mimetypes.guess_type(str(path))
136
- mime_type = mime_type or "application/octet-stream"
137
-
138
- try:
139
- if _is_image(path.suffix, mime_type):
140
- markdown = ocr_image(str(path))
141
- else:
142
- markdown = self._engine.convert(str(path)).text_content
143
- if not markdown.strip() and path.suffix.lower() == ".pdf":
144
- logger.info(
145
- "convert_file | no text from PDF, falling back to OCR | file=%s",
146
- path.name,
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 ConversionError(
168
- source=url,
169
- error_type="ValueError",
170
- message=f"Unsupported URL scheme: {parsed.scheme!r}",
171
- duration_ms=0.0,
172
- )
173
 
174
- start = time.perf_counter()
175
- try:
176
- url_ext = Path(urlparse(url).path).suffix.lower()
177
- if url_ext in IMAGE_EXTENSIONS:
178
- markdown = ocr_image(url)
179
- mime_type = mimetypes.guess_type(url)[0] or "image/jpeg"
180
- else:
181
- result = self._engine.convert(url)
182
- markdown = result.text_content
183
- mime_type = "text/html"
184
-
185
- elapsed = (time.perf_counter() - start) * 1000
186
- return self._build_result(url, markdown, 0, mime_type, elapsed)
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
- import io
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
- try:
208
- if _is_image(ext, mime_type):
209
- markdown = ocr_image(data)
210
- else:
211
- result = self._engine.convert_stream(io.BytesIO(data), file_extension=ext)
212
- markdown = result.text_content
213
- if not markdown.strip() and ext == ".pdf":
214
- logger.info(
215
- "convert_stream | no text from PDF stream, falling back to OCR | filename=%s",
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(words),
253
- line_count=len(lines),
254
  duration_ms=elapsed,
255
  file_size_bytes=file_size,
256
  mime_type=mime_type,
257
- content_hash=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
- # Supported file extensions for JSON extraction
20
- SUPPORTED_EXTENSIONS = {'.csv', '.xls', '.xlsx'}
 
 
 
21
 
22
- # Resource limits to prevent abuse
23
- from app.core.config import settings as _app_settings
24
- MAX_FILE_SIZE_BYTES = _app_settings.MAX_FILE_SIZE_BYTES
25
- MAX_CSV_ROWS = 100000
26
- MAX_EXCEL_ROWS = 50000
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
- approx_mb = (rows * cols * 50) / (1024 * 1024)
38
- if rows * cols > MAX_MEMORY_ROWS * 20:
 
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 if we have raw data
71
  if file_data is not None:
72
- size_error = _validate_file_size(len(file_data))
73
- if size_error:
74
- return {"error": size_error, "file_type": ext}
75
- elif Path(file_path).exists():
76
- size_error = _validate_file_size(Path(file_path).stat().st_size)
77
- if size_error:
78
- return {"error": size_error, "file_type": ext}
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: {str(exc)}",
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
- Check if file type is supported for JSON extraction.
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 = None
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
- _norm_lock = threading.Lock()
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: re.sub(r"\s+", " ", s).strip(),
82
- "remove_currency": lambda s: re.sub(r"[$€£¥₹]", "", s),
83
- "remove_non_numeric": lambda s: re.sub(r"[^\d.]", "", s),
84
- "normalize_date_sep": lambda s: re.sub(r"[/.]", "-", 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 _norm_lock:
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
- with _norm_lock:
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]) -> re.RegexFlag:
136
- flags = re.RegexFlag(0)
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 [primary, *fallbacks]:
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
- try:
181
- target_matches = [matches[match_index]]
182
- except IndexError:
183
- target_matches = [matches[-1]]
184
-
185
- for m in target_matches:
186
- exists, result = _try_group(m, capture_group)
187
- if not exists:
188
- break
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
- labels = set(labels or [])
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 labels
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
- try:
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
- labels = set(labels or [])
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 labels:
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
- try:
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
- with _resolver_lock:
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(split_flags_rule)
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 = _get_nlp()
599
- cleaned = text
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, cleaned)
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 = _get_nlp()
641
- cleaned = text
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 = _get_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 = _get_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
+ ]