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