document-ocr 0.0.0-stage → 3.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +260 -2
- package/dist/index.cjs +659 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +293 -0
- package/dist/index.d.ts +293 -0
- package/dist/index.js +616 -0
- package/dist/index.js.map +1 -0
- package/hosting/.dockerignore +17 -0
- package/hosting/.gcloudignore +14 -0
- package/hosting/FEATURES.md +172 -0
- package/hosting/HOSTING.md +268 -0
- package/hosting/LICENSE +21 -0
- package/hosting/README.md +369 -0
- package/hosting/core/__init__.py +0 -0
- package/hosting/core/aadhaar_extractor.py +298 -0
- package/hosting/core/back_page_extractor.py +492 -0
- package/hosting/core/barcodes.py +125 -0
- package/hosting/core/card_recovery.py +244 -0
- package/hosting/core/document_bundle.py +82 -0
- package/hosting/core/document_classifier.py +233 -0
- package/hosting/core/document_input.py +135 -0
- package/hosting/core/document_registry.py +143 -0
- package/hosting/core/driving_licence_extractor.py +510 -0
- package/hosting/core/ead_recovery.py +54 -0
- package/hosting/core/evidence.py +54 -0
- package/hosting/core/form_recovery.py +61 -0
- package/hosting/core/jobs.py +232 -0
- package/hosting/core/kyc_ocr.py +132 -0
- package/hosting/core/kyc_validation.py +411 -0
- package/hosting/core/model_setup.py +13 -0
- package/hosting/core/mrz_parser.py +336 -0
- package/hosting/core/mrz_recovery.py +193 -0
- package/hosting/core/npr_extractor.py +410 -0
- package/hosting/core/nrega_extractor.py +827 -0
- package/hosting/core/ocr_engine.py +207 -0
- package/hosting/core/page_classifier.py +137 -0
- package/hosting/core/pan_extractor.py +183 -0
- package/hosting/core/pipeline.py +712 -0
- package/hosting/core/preprocessor.py +297 -0
- package/hosting/core/recovery_budget.py +67 -0
- package/hosting/core/redaction.py +48 -0
- package/hosting/core/review.html +244 -0
- package/hosting/core/structured_documents.py +422 -0
- package/hosting/core/travel_mrz.py +164 -0
- package/hosting/core/travel_recovery.py +187 -0
- package/hosting/core/validator.py +396 -0
- package/hosting/core/validators.py +179 -0
- package/hosting/core/voter_id_extractor.py +363 -0
- package/hosting/deploy/cloudflare/deploy.sh +44 -0
- package/hosting/deploy/cloudflare/package-lock.json +3088 -0
- package/hosting/deploy/cloudflare/package.json +29 -0
- package/hosting/deploy/cloudflare/src/gateway.ts +42 -0
- package/hosting/deploy/cloudflare/src/index.ts +21 -0
- package/hosting/deploy/cloudflare/test/gateway.test.ts +59 -0
- package/hosting/deploy/cloudflare/tsconfig.json +12 -0
- package/hosting/deploy/cloudflare/worker-configuration.d.ts +16253 -0
- package/hosting/deploy/cloudflare/wrangler.jsonc +26 -0
- package/hosting/deploy/cloudrun/cloudbuild.yaml +18 -0
- package/hosting/deploy/cloudrun/deploy.sh +71 -0
- package/hosting/deploy/cloudrun/render_service.py +47 -0
- package/hosting/deploy/common.sh +14 -0
- package/hosting/deploy/docker/Dockerfile +16 -0
- package/hosting/deploy/docker/server.py +313 -0
- package/hosting/deploy/lambda/Dockerfile.lambda +11 -0
- package/hosting/deploy/lambda/deploy.sh +22 -0
- package/hosting/deploy/lambda/handler.py +132 -0
- package/hosting/deploy/lambda/template.yaml +59 -0
- package/hosting/deploy/local/dev.sh +18 -0
- package/hosting/deploy/server/Caddyfile +5 -0
- package/hosting/deploy/server/compose.jobs.yaml +36 -0
- package/hosting/deploy/server/compose.yaml +38 -0
- package/hosting/deploy/server/deploy.sh +23 -0
- package/hosting/infra/cloudrun/main.tf +104 -0
- package/hosting/infra/cloudrun/terraform.tfvars.example +7 -0
- package/hosting/infra/cloudrun/variables.tf +55 -0
- package/hosting/infra/lambda/main.tf +63 -0
- package/hosting/infra/lambda/terraform.tfvars.example +6 -0
- package/hosting/infra/lambda/variables.tf +37 -0
- package/hosting/pyproject.toml +65 -0
- package/hosting/requirements.lock +134 -0
- package/package.json +76 -4
- package/python/core/__init__.py +0 -0
- package/python/core/aadhaar_extractor.py +298 -0
- package/python/core/back_page_extractor.py +492 -0
- package/python/core/barcodes.py +125 -0
- package/python/core/card_recovery.py +244 -0
- package/python/core/document_bundle.py +82 -0
- package/python/core/document_classifier.py +233 -0
- package/python/core/document_input.py +135 -0
- package/python/core/document_registry.py +143 -0
- package/python/core/driving_licence_extractor.py +510 -0
- package/python/core/ead_recovery.py +54 -0
- package/python/core/evidence.py +54 -0
- package/python/core/form_recovery.py +61 -0
- package/python/core/jobs.py +232 -0
- package/python/core/kyc_ocr.py +132 -0
- package/python/core/kyc_validation.py +411 -0
- package/python/core/model_setup.py +13 -0
- package/python/core/mrz_parser.py +336 -0
- package/python/core/mrz_recovery.py +193 -0
- package/python/core/npr_extractor.py +410 -0
- package/python/core/nrega_extractor.py +827 -0
- package/python/core/ocr_engine.py +207 -0
- package/python/core/page_classifier.py +137 -0
- package/python/core/pan_extractor.py +183 -0
- package/python/core/pipeline.py +712 -0
- package/python/core/preprocessor.py +297 -0
- package/python/core/recovery_budget.py +67 -0
- package/python/core/redaction.py +48 -0
- package/python/core/review.html +244 -0
- package/python/core/structured_documents.py +422 -0
- package/python/core/travel_mrz.py +164 -0
- package/python/core/travel_recovery.py +187 -0
- package/python/core/validator.py +396 -0
- package/python/core/validators.py +179 -0
- package/python/core/voter_id_extractor.py +363 -0
- package/python/pyproject.toml +34 -0
- package/python/requirements.lock +134 -0
- package/python/server.py +313 -0
- package/scripts/dococr.mjs +163 -0
- package/scripts/postinstall.mjs +173 -0
- package/scripts/sync-hosting.mjs +35 -0
- package/scripts/sync-python.sh +20 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# This file was autogenerated by uv via the following command:
|
|
2
|
+
# uv pip compile pyproject.toml -o requirements.lock --python-version 3.14 --universal
|
|
3
|
+
annotated-doc==0.0.5
|
|
4
|
+
# via fastapi
|
|
5
|
+
annotated-types==0.8.0
|
|
6
|
+
# via pydantic
|
|
7
|
+
anyio==4.15.1
|
|
8
|
+
# via
|
|
9
|
+
# starlette
|
|
10
|
+
# watchfiles
|
|
11
|
+
certifi==2026.7.22
|
|
12
|
+
# via requests
|
|
13
|
+
cffi==2.1.1 ; platform_python_implementation != 'PyPy'
|
|
14
|
+
# via cryptography
|
|
15
|
+
charset-normalizer==3.5.2
|
|
16
|
+
# via requests
|
|
17
|
+
click==8.5.0
|
|
18
|
+
# via
|
|
19
|
+
# india-document-ocr (pyproject.toml)
|
|
20
|
+
# uvicorn
|
|
21
|
+
colorama==0.4.6 ; sys_platform == 'win32'
|
|
22
|
+
# via
|
|
23
|
+
# colorlog
|
|
24
|
+
# tqdm
|
|
25
|
+
colorlog==6.12.0
|
|
26
|
+
# via rapidocr
|
|
27
|
+
cryptography==50.0.2
|
|
28
|
+
# via india-document-ocr (pyproject.toml)
|
|
29
|
+
fastapi==0.143.0
|
|
30
|
+
# via india-document-ocr (pyproject.toml)
|
|
31
|
+
flatbuffers==25.12.19
|
|
32
|
+
# via onnxruntime
|
|
33
|
+
h11==0.16.0
|
|
34
|
+
# via uvicorn
|
|
35
|
+
httptools==0.9.0
|
|
36
|
+
# via uvicorn
|
|
37
|
+
idna==3.20
|
|
38
|
+
# via
|
|
39
|
+
# india-document-ocr (pyproject.toml)
|
|
40
|
+
# anyio
|
|
41
|
+
# requests
|
|
42
|
+
numpy==2.5.4
|
|
43
|
+
# via
|
|
44
|
+
# india-document-ocr (pyproject.toml)
|
|
45
|
+
# onnxruntime
|
|
46
|
+
# opencv-python
|
|
47
|
+
# rapidocr
|
|
48
|
+
# shapely
|
|
49
|
+
omegaconf==2.4.0
|
|
50
|
+
# via rapidocr
|
|
51
|
+
onnxruntime==1.31.0
|
|
52
|
+
# via india-document-ocr (pyproject.toml)
|
|
53
|
+
opencv-python==4.13.0.92
|
|
54
|
+
# via
|
|
55
|
+
# india-document-ocr (pyproject.toml)
|
|
56
|
+
# rapidocr
|
|
57
|
+
opentelemetry-api==1.45.1
|
|
58
|
+
# via fastapi
|
|
59
|
+
packaging==26.3
|
|
60
|
+
# via onnxruntime
|
|
61
|
+
pillow==12.3.0
|
|
62
|
+
# via
|
|
63
|
+
# india-document-ocr (pyproject.toml)
|
|
64
|
+
# pillow-heif
|
|
65
|
+
# rapidocr
|
|
66
|
+
pillow-heif==1.8.1
|
|
67
|
+
# via india-document-ocr (pyproject.toml)
|
|
68
|
+
protobuf==7.36.2
|
|
69
|
+
# via onnxruntime
|
|
70
|
+
pyclipper==1.4.0
|
|
71
|
+
# via rapidocr
|
|
72
|
+
pycountry==26.2.16
|
|
73
|
+
# via india-document-ocr (pyproject.toml)
|
|
74
|
+
pycparser==3.11 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'
|
|
75
|
+
# via cffi
|
|
76
|
+
pydantic==2.14.1
|
|
77
|
+
# via fastapi
|
|
78
|
+
pydantic-core==2.50.1
|
|
79
|
+
# via pydantic
|
|
80
|
+
pypdfium2==5.14.0
|
|
81
|
+
# via india-document-ocr (pyproject.toml)
|
|
82
|
+
python-dotenv==1.2.4
|
|
83
|
+
# via uvicorn
|
|
84
|
+
python-multipart==0.0.32
|
|
85
|
+
# via india-document-ocr (pyproject.toml)
|
|
86
|
+
pyyaml==6.0.3
|
|
87
|
+
# via
|
|
88
|
+
# omegaconf
|
|
89
|
+
# rapidocr
|
|
90
|
+
# uvicorn
|
|
91
|
+
rapidfuzz==3.14.6
|
|
92
|
+
# via india-document-ocr (pyproject.toml)
|
|
93
|
+
rapidocr==3.9.2
|
|
94
|
+
# via india-document-ocr (pyproject.toml)
|
|
95
|
+
requests==2.34.2
|
|
96
|
+
# via
|
|
97
|
+
# india-document-ocr (pyproject.toml)
|
|
98
|
+
# rapidocr
|
|
99
|
+
shapely==2.2.0
|
|
100
|
+
# via rapidocr
|
|
101
|
+
six==1.17.0
|
|
102
|
+
# via rapidocr
|
|
103
|
+
starlette==1.7.0
|
|
104
|
+
# via
|
|
105
|
+
# india-document-ocr (pyproject.toml)
|
|
106
|
+
# fastapi
|
|
107
|
+
tqdm==4.70.1
|
|
108
|
+
# via rapidocr
|
|
109
|
+
typing-extensions==4.16.0
|
|
110
|
+
# via
|
|
111
|
+
# anyio
|
|
112
|
+
# fastapi
|
|
113
|
+
# opentelemetry-api
|
|
114
|
+
# pydantic
|
|
115
|
+
# pydantic-core
|
|
116
|
+
# typing-inspection
|
|
117
|
+
typing-inspection==0.4.4
|
|
118
|
+
# via
|
|
119
|
+
# fastapi
|
|
120
|
+
# pydantic
|
|
121
|
+
urllib3==2.8.0
|
|
122
|
+
# via
|
|
123
|
+
# india-document-ocr (pyproject.toml)
|
|
124
|
+
# requests
|
|
125
|
+
uvicorn==0.54.0
|
|
126
|
+
# via india-document-ocr (pyproject.toml)
|
|
127
|
+
uvloop==0.23.0 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'
|
|
128
|
+
# via uvicorn
|
|
129
|
+
watchfiles==1.3.0
|
|
130
|
+
# via uvicorn
|
|
131
|
+
websockets==17.2
|
|
132
|
+
# via uvicorn
|
|
133
|
+
zxing-cpp==3.1.1
|
|
134
|
+
# via india-document-ocr (pyproject.toml)
|
package/python/server.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI server wrapping the passport OCR pipeline.
|
|
3
|
+
|
|
4
|
+
Used by the document-ocr npm package to run OCR locally.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from contextlib import asynccontextmanager
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import secrets
|
|
14
|
+
import uuid
|
|
15
|
+
from functools import partial
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from fastapi import FastAPI, File, Form, Header, UploadFile, HTTPException
|
|
19
|
+
from fastapi.responses import JSONResponse, HTMLResponse, Response
|
|
20
|
+
|
|
21
|
+
from core.kyc_ocr import KycOCRConfigError
|
|
22
|
+
from core.ocr_engine import OCRModelInitError
|
|
23
|
+
from core.pipeline import scan
|
|
24
|
+
from core.document_bundle import scan_batch, scan_document
|
|
25
|
+
from core.document_registry import list_document_profiles, validate_document_hint
|
|
26
|
+
from core.barcodes import barcode_decoder_available
|
|
27
|
+
from core.jobs import configured_store
|
|
28
|
+
from core.redaction import redact, render_preview
|
|
29
|
+
from core.preprocessor import ImageQualityError
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Config
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
|
|
36
|
+
SCAN_TIMEOUT_SECONDS = 60.0
|
|
37
|
+
logger = logging.getLogger("document-ocr")
|
|
38
|
+
# uvicorn configures only its own loggers, so without a handler these per-scan
|
|
39
|
+
# INFO lines (status, page type, confidence, timing; never document text) were
|
|
40
|
+
# dropped. Scoped to this logger so core modules keep their default level.
|
|
41
|
+
if not logger.handlers:
|
|
42
|
+
_handler = logging.StreamHandler()
|
|
43
|
+
_handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
|
|
44
|
+
logger.addHandler(_handler)
|
|
45
|
+
logger.setLevel(logging.INFO)
|
|
46
|
+
logger.propagate = False
|
|
47
|
+
# Matches the sibling pdf-worker service so a deployment sets one variable name
|
|
48
|
+
# everywhere. Unset means no auth is enforced, which is what local development wants.
|
|
49
|
+
API_TOKEN = os.getenv("API_TOKEN") or None
|
|
50
|
+
|
|
51
|
+
_ocr_semaphore = asyncio.Semaphore(1)
|
|
52
|
+
_models_ready = False
|
|
53
|
+
_model_init_error: str | None = None
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# App lifecycle
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
@asynccontextmanager
|
|
60
|
+
async def _lifespan(app: FastAPI):
|
|
61
|
+
global _models_ready, _model_init_error
|
|
62
|
+
_models_ready = False
|
|
63
|
+
_model_init_error = None
|
|
64
|
+
logger.info("Loading OCR models...")
|
|
65
|
+
loop = asyncio.get_running_loop()
|
|
66
|
+
try:
|
|
67
|
+
await loop.run_in_executor(None, _warm_up_ocr)
|
|
68
|
+
except (OCRModelInitError, KycOCRConfigError) as exc:
|
|
69
|
+
# Stay up so /ready and /scan can report a clear error instead of the
|
|
70
|
+
# process crash-looping. Liveness (/health) remains green.
|
|
71
|
+
_model_init_error = str(exc)
|
|
72
|
+
logger.error("OCR model initialisation failed: %s", exc)
|
|
73
|
+
else:
|
|
74
|
+
_models_ready = True
|
|
75
|
+
logger.info("OCR models loaded.")
|
|
76
|
+
yield
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
app = FastAPI(title="Document OCR", version="3.1.2", lifespan=_lifespan)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.middleware("http")
|
|
83
|
+
async def _private_responses(request, call_next):
|
|
84
|
+
response = await call_next(request)
|
|
85
|
+
response.headers["Cache-Control"] = "no-store"
|
|
86
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
87
|
+
return response
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _warm_up_ocr():
|
|
91
|
+
from core.kyc_ocr import configured_kyc_languages
|
|
92
|
+
from core.ocr_engine import _get_ocr
|
|
93
|
+
|
|
94
|
+
for language in dict.fromkeys(("en", *configured_kyc_languages())):
|
|
95
|
+
_get_ocr(language)
|
|
96
|
+
_get_ocr("en", band=True) # MRZ band re-reads; avoid loading on a live scan
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.get("/health")
|
|
100
|
+
async def health():
|
|
101
|
+
return {"status": "ok"}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.get("/ready")
|
|
105
|
+
async def ready():
|
|
106
|
+
if _model_init_error is not None:
|
|
107
|
+
return JSONResponse(
|
|
108
|
+
status_code=503,
|
|
109
|
+
content={"status": "model_init_failed", "error": _model_init_error},
|
|
110
|
+
)
|
|
111
|
+
if not _models_ready:
|
|
112
|
+
return JSONResponse(status_code=503, content={"status": "loading"})
|
|
113
|
+
return {"status": "ready"}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _scan_finished(future: asyncio.Future) -> None:
|
|
117
|
+
_ocr_semaphore.release()
|
|
118
|
+
# Consume late exceptions after a disconnected/timed-out caller has gone.
|
|
119
|
+
if not future.cancelled():
|
|
120
|
+
future.exception()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _authorize(authorization):
|
|
124
|
+
if API_TOKEN is not None:
|
|
125
|
+
expected = f"Bearer {API_TOKEN}"
|
|
126
|
+
if authorization is None or not secrets.compare_digest(authorization, expected):
|
|
127
|
+
raise HTTPException(status_code=401, detail="UNAUTHORIZED", headers={"WWW-Authenticate": "Bearer"})
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def _upload(image: UploadFile) -> bytes:
|
|
131
|
+
content_type = image.content_type or ""
|
|
132
|
+
if not content_type.startswith("image/") and content_type != "application/pdf":
|
|
133
|
+
raise HTTPException(status_code=400, detail="INVALID_CONTENT_TYPE")
|
|
134
|
+
data = await image.read(MAX_UPLOAD_SIZE + 1)
|
|
135
|
+
if len(data) > MAX_UPLOAD_SIZE:
|
|
136
|
+
raise HTTPException(status_code=400, detail="FILE_TOO_LARGE")
|
|
137
|
+
if not data:
|
|
138
|
+
raise HTTPException(status_code=400, detail="EMPTY_FILE")
|
|
139
|
+
return data
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def _uploads(images: list[UploadFile]) -> list[bytes]:
|
|
143
|
+
if not 1 <= len(images) <= 10:
|
|
144
|
+
raise HTTPException(status_code=400, detail="INVALID_BATCH_SIZE")
|
|
145
|
+
output, size = [], 0
|
|
146
|
+
for image in images:
|
|
147
|
+
data = await _upload(image)
|
|
148
|
+
size += len(data)
|
|
149
|
+
if size > MAX_UPLOAD_SIZE:
|
|
150
|
+
raise HTTPException(status_code=400, detail="BATCH_TOO_LARGE")
|
|
151
|
+
output.append(data)
|
|
152
|
+
return output
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _options(document_type, country, include_evidence):
|
|
156
|
+
try:
|
|
157
|
+
document_type, country = validate_document_hint(document_type, country)
|
|
158
|
+
except ValueError as exc:
|
|
159
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
160
|
+
# Omit defaults for compatibility with one-argument pipeline integrations.
|
|
161
|
+
return {key: value for key, value in {
|
|
162
|
+
'document_type': document_type, 'country': country,
|
|
163
|
+
'include_evidence': include_evidence,
|
|
164
|
+
}.items() if value}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
async def _execute(function, *args, **kwargs):
|
|
168
|
+
request_id = str(uuid.uuid4())[:8]
|
|
169
|
+
try:
|
|
170
|
+
deadline = asyncio.get_running_loop().time() + SCAN_TIMEOUT_SECONDS
|
|
171
|
+
await asyncio.wait_for(_ocr_semaphore.acquire(), timeout=SCAN_TIMEOUT_SECONDS)
|
|
172
|
+
try:
|
|
173
|
+
future = asyncio.get_running_loop().run_in_executor(None, partial(function, *args, **kwargs))
|
|
174
|
+
except BaseException:
|
|
175
|
+
_ocr_semaphore.release()
|
|
176
|
+
raise
|
|
177
|
+
future.add_done_callback(_scan_finished)
|
|
178
|
+
return await asyncio.wait_for(asyncio.shield(future), timeout=max(0, deadline - asyncio.get_running_loop().time()))
|
|
179
|
+
except asyncio.TimeoutError:
|
|
180
|
+
logger.warning("[%s] scan_timeout", request_id)
|
|
181
|
+
return JSONResponse(status_code=504, content={"error": "SCAN_TIMEOUT"})
|
|
182
|
+
except OCRModelInitError:
|
|
183
|
+
logger.error("[%s] model_init_failed", request_id)
|
|
184
|
+
return JSONResponse(status_code=503, content={"error": "MODEL_INIT_FAILED"})
|
|
185
|
+
except KycOCRConfigError:
|
|
186
|
+
logger.error("[%s] invalid_kyc_ocr_config", request_id)
|
|
187
|
+
return JSONResponse(status_code=503, content={"error": "INVALID_KYC_OCR_CONFIG"})
|
|
188
|
+
except (ImageQualityError, ValueError) as exc:
|
|
189
|
+
return JSONResponse(status_code=400, content={"error": str(exc)})
|
|
190
|
+
except Exception:
|
|
191
|
+
logger.exception("[%s] internal_error", request_id)
|
|
192
|
+
return JSONResponse(status_code=500, content={"error": "INTERNAL_ERROR"})
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@app.post("/scan")
|
|
196
|
+
async def scan_passport(
|
|
197
|
+
image: UploadFile = File(...), authorization: str | None = Header(default=None),
|
|
198
|
+
document_type: str | None = Form(default=None), country: str | None = Form(default=None),
|
|
199
|
+
include_evidence: bool = Form(default=False),
|
|
200
|
+
):
|
|
201
|
+
_authorize(authorization)
|
|
202
|
+
options = _options(document_type, country, include_evidence)
|
|
203
|
+
result = await _execute(scan, await _upload(image), **options)
|
|
204
|
+
if isinstance(result, Response):
|
|
205
|
+
return result
|
|
206
|
+
logger.info("status=%s page_type=%s confidence=%s processing_ms=%s", result.status,
|
|
207
|
+
result.page_type, result.confidence, result.processing_ms)
|
|
208
|
+
return JSONResponse(status_code=422 if result.status == 'failure' else 200, content=result.to_dict())
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@app.get("/documents")
|
|
212
|
+
async def documents(authorization: str | None = Header(default=None)):
|
|
213
|
+
_authorize(authorization)
|
|
214
|
+
return {"schemaVersion": 1, "documents": list_document_profiles(),
|
|
215
|
+
"capabilities": {"barcode": barcode_decoder_available(), "jobs": await asyncio.to_thread(_job_store, required=False) is not None,
|
|
216
|
+
"review": True, "multipage": True}}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@app.post("/scan/batch")
|
|
220
|
+
async def batch(
|
|
221
|
+
images: list[UploadFile] = File(...), authorization: str | None = Header(default=None),
|
|
222
|
+
document_type: str | None = Form(default=None), country: str | None = Form(default=None),
|
|
223
|
+
include_evidence: bool = Form(default=False),
|
|
224
|
+
):
|
|
225
|
+
_authorize(authorization)
|
|
226
|
+
options = _options(document_type, country, include_evidence)
|
|
227
|
+
return await _execute(scan_batch, await _uploads(images), **options)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@app.post("/scan/document")
|
|
231
|
+
async def grouped_document(
|
|
232
|
+
images: list[UploadFile] = File(...), authorization: str | None = Header(default=None),
|
|
233
|
+
document_type: str | None = Form(default=None), country: str | None = Form(default=None),
|
|
234
|
+
include_evidence: bool = Form(default=False),
|
|
235
|
+
):
|
|
236
|
+
_authorize(authorization)
|
|
237
|
+
options = _options(document_type, country, include_evidence)
|
|
238
|
+
return await _execute(scan_document, await _uploads(images), **options)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@app.get("/review", response_class=HTMLResponse)
|
|
242
|
+
async def review():
|
|
243
|
+
import core
|
|
244
|
+
path = Path(core.__file__).with_name('review.html')
|
|
245
|
+
return HTMLResponse(path.read_text(), headers={'Cache-Control': 'no-store',
|
|
246
|
+
'Content-Security-Policy': "default-src 'self'; img-src 'self' blob: data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'"})
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@app.post("/preview")
|
|
250
|
+
async def preview(image: UploadFile = File(...), authorization: str | None = Header(default=None)):
|
|
251
|
+
_authorize(authorization)
|
|
252
|
+
result = await _execute(render_preview, await _upload(image))
|
|
253
|
+
if isinstance(result, Response):
|
|
254
|
+
return result
|
|
255
|
+
return Response(result, media_type='image/png', headers={'Cache-Control': 'no-store'})
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@app.post("/redact")
|
|
259
|
+
async def redacted(image: UploadFile = File(...), boxes: str = Form(...), authorization: str | None = Header(default=None)):
|
|
260
|
+
_authorize(authorization)
|
|
261
|
+
if len(boxes) > 50000:
|
|
262
|
+
raise HTTPException(status_code=400, detail='INVALID_REDACTION_BOXES')
|
|
263
|
+
result = await _execute(redact, await _upload(image), boxes)
|
|
264
|
+
if isinstance(result, Response):
|
|
265
|
+
return result
|
|
266
|
+
return Response(result, media_type='image/png', headers={'Cache-Control': 'no-store',
|
|
267
|
+
'Content-Disposition': 'attachment; filename="redacted.png"'})
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _job_store(*, required=True):
|
|
271
|
+
try:
|
|
272
|
+
store = configured_store()
|
|
273
|
+
except ValueError as exc:
|
|
274
|
+
raise HTTPException(status_code=503, detail='INVALID_JOB_CONFIGURATION') from exc
|
|
275
|
+
if required and store is None:
|
|
276
|
+
raise HTTPException(status_code=503, detail='JOBS_NOT_CONFIGURED')
|
|
277
|
+
return store
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@app.post('/jobs', status_code=202)
|
|
281
|
+
async def enqueue_job(
|
|
282
|
+
images: list[UploadFile] = File(...), authorization: str | None = Header(default=None),
|
|
283
|
+
document_type: str | None = Form(default=None), country: str | None = Form(default=None),
|
|
284
|
+
include_evidence: bool = Form(default=False), grouped: bool = Form(default=False), notify: bool = Form(default=False),
|
|
285
|
+
):
|
|
286
|
+
_authorize(authorization)
|
|
287
|
+
options = _options(document_type, country, include_evidence)
|
|
288
|
+
store = await asyncio.to_thread(_job_store)
|
|
289
|
+
data = await _uploads(images)
|
|
290
|
+
try:
|
|
291
|
+
return await asyncio.to_thread(store.enqueue, data, options, grouped=grouped, notify=notify)
|
|
292
|
+
except ValueError as exc:
|
|
293
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@app.get('/jobs/{identifier}')
|
|
297
|
+
async def job(identifier: str, authorization: str | None = Header(default=None)):
|
|
298
|
+
_authorize(authorization)
|
|
299
|
+
store = await asyncio.to_thread(_job_store)
|
|
300
|
+
result = await asyncio.to_thread(store.get, identifier)
|
|
301
|
+
if result is None:
|
|
302
|
+
raise HTTPException(status_code=404, detail='JOB_NOT_FOUND')
|
|
303
|
+
return result
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@app.delete('/jobs/{identifier}')
|
|
307
|
+
async def delete_job(identifier: str, authorization: str | None = Header(default=None)):
|
|
308
|
+
_authorize(authorization)
|
|
309
|
+
store = await asyncio.to_thread(_job_store)
|
|
310
|
+
removed = await asyncio.to_thread(store.delete, identifier)
|
|
311
|
+
if not removed:
|
|
312
|
+
raise HTTPException(status_code=404, detail='JOB_NOT_FOUND')
|
|
313
|
+
return {'deleted': True}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from 'node:child_process'
|
|
3
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { dirname, join, resolve } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
const templates = resolve(dirname(fileURLToPath(import.meta.url)), '../hosting')
|
|
8
|
+
const targets = ['cloudrun', 'lambda', 'cloudflare', 'server']
|
|
9
|
+
const usage = `Usage:
|
|
10
|
+
document-ocr init --target cloudrun|lambda|cloudflare|server [--directory DIR]
|
|
11
|
+
[--name NAME] [--profile economy|warm] [--languages en,devanagari]
|
|
12
|
+
[--project PROJECT] [--region REGION] [--image IMAGE_URI] [--domain DOMAIN]
|
|
13
|
+
[--bucket BUCKET] [--prefix uploads/]
|
|
14
|
+
document-ocr doctor [--directory DIR] [--endpoint URL] [--json]
|
|
15
|
+
document-ocr deploy [--directory DIR] [--dry-run]
|
|
16
|
+
|
|
17
|
+
init writes dococr.json and source/templates; existing files are never replaced.
|
|
18
|
+
Credentials and API_TOKEN come from your environment, never dococr.json.
|
|
19
|
+
Run doctor before deploying; deploy executes the target's bundled wrapper.`
|
|
20
|
+
|
|
21
|
+
function parse(args) {
|
|
22
|
+
const [command, ...rest] = args
|
|
23
|
+
const options = {}
|
|
24
|
+
const booleans = new Set(['dry-run', 'json', 'help'])
|
|
25
|
+
const values = new Set(['target', 'directory', 'name', 'profile', 'languages', 'project', 'region', 'image', 'domain', 'bucket', 'prefix', 'endpoint'])
|
|
26
|
+
for (let i = 0; i < rest.length; i++) {
|
|
27
|
+
const token = rest[i]
|
|
28
|
+
if (!token.startsWith('--')) throw new Error(`Unexpected argument: ${token}`)
|
|
29
|
+
const key = token.slice(2)
|
|
30
|
+
if (booleans.has(key)) options[key] = true
|
|
31
|
+
else if (values.has(key) && rest[i + 1] && !rest[i + 1].startsWith('--')) options[key] = rest[++i]
|
|
32
|
+
else throw new Error(`Unknown option or missing value: ${token}`)
|
|
33
|
+
}
|
|
34
|
+
return { command, options }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function validate(config) {
|
|
38
|
+
if (!config || config.version !== 1 || !targets.includes(config.target)) throw new Error('Invalid dococr.json: version 1 and a supported target are required')
|
|
39
|
+
if (!/^[a-z][a-z0-9-]{0,39}$/.test(config.name)) throw new Error('name must start with a lowercase letter and contain at most 40 lowercase letters, digits, or hyphens')
|
|
40
|
+
if (!['economy', 'warm'].includes(config.profile)) throw new Error('profile must be economy or warm')
|
|
41
|
+
if (!Array.isArray(config.languages) || config.languages.length < 1 || config.languages.length > 4
|
|
42
|
+
|| config.languages.some((language) => !['en', 'latin', 'devanagari', 'ka', 'ta', 'te'].includes(language))
|
|
43
|
+
|| new Set(config.languages).size !== config.languages.length) throw new Error('languages must list one to four unique supported OCR languages')
|
|
44
|
+
const allowed = new Set(['version', 'target', 'name', 'profile', 'languages', 'project', 'region', 'image', 'domain', 'bucket', 'prefix', 'endpoint'])
|
|
45
|
+
for (const key of Object.keys(config)) {
|
|
46
|
+
if (!allowed.has(key)) throw new Error(`Unknown dococr.json field: ${key}. Keep credentials in environment variables.`)
|
|
47
|
+
if (['project', 'region', 'image', 'domain', 'bucket', 'prefix', 'endpoint'].includes(key)
|
|
48
|
+
&& (typeof config[key] !== 'string' || !config[key].trim())) throw new Error(`${key} must be a non-empty string`)
|
|
49
|
+
}
|
|
50
|
+
return config
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function environment(config, env) {
|
|
54
|
+
const result = { ...env, DOCUMENT_OCR_NAME: config.name, DOCUMENT_OCR_PROFILE: config.profile, DOCUMENT_OCR_KYC_LANGS: config.languages.join(',') }
|
|
55
|
+
const mapping = { project: 'GCP_PROJECT', image: 'IMAGE_URI', domain: 'DOMAIN', bucket: 'DOCUMENT_OCR_S3_BUCKET', prefix: 'DOCUMENT_OCR_S3_PREFIX' }
|
|
56
|
+
for (const [key, variable] of Object.entries(mapping)) if (config[key]) result[variable] = config[key]
|
|
57
|
+
if (config.region) result[config.target === 'lambda' ? 'AWS_REGION' : 'GCP_REGION'] = config.region
|
|
58
|
+
return result
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function invocation(config) {
|
|
62
|
+
return config.target === 'cloudflare'
|
|
63
|
+
? { command: 'npm', args: ['--prefix', 'deploy/cloudflare', 'run', 'deploy'] }
|
|
64
|
+
: { command: 'bash', args: [`deploy/${config.target}/deploy.sh`] }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function run(command, args, options) {
|
|
68
|
+
const result = spawnSync(command, args, options)
|
|
69
|
+
return { status: result.status ?? 1, error: result.error?.message }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Injectable command runner keeps CLI tests local and never deploys infrastructure. */
|
|
73
|
+
export async function main(args = process.argv.slice(2), dependencies = {}) {
|
|
74
|
+
const log = dependencies.log ?? console.log
|
|
75
|
+
const execute = dependencies.run ?? run
|
|
76
|
+
const env = dependencies.env ?? process.env
|
|
77
|
+
const parsed = parse(args)
|
|
78
|
+
const { command, options } = parsed
|
|
79
|
+
if (!command || command === '--help' || command === 'help' || options.help) { log(usage); return 0 }
|
|
80
|
+
if (!['init', 'doctor', 'deploy'].includes(command)) throw new Error(`Unknown command: ${command}`)
|
|
81
|
+
const acceptedOptions = {
|
|
82
|
+
init: ['target', 'directory', 'name', 'profile', 'languages', 'project', 'region', 'image', 'domain', 'bucket', 'prefix'],
|
|
83
|
+
doctor: ['directory', 'endpoint', 'json'],
|
|
84
|
+
deploy: ['directory', 'dry-run'],
|
|
85
|
+
}[command]
|
|
86
|
+
for (const key of Object.keys(options)) {
|
|
87
|
+
if (!acceptedOptions.includes(key)) throw new Error(`--${key} is not supported by ${command}`)
|
|
88
|
+
}
|
|
89
|
+
const cwd = resolve(dependencies.cwd ?? process.cwd(), options.directory ?? '.')
|
|
90
|
+
const configFile = join(cwd, 'dococr.json')
|
|
91
|
+
|
|
92
|
+
if (command === 'init') {
|
|
93
|
+
const config = validate({
|
|
94
|
+
version: 1, target: options.target, name: options.name ?? 'document-ocr', profile: options.profile ?? 'economy',
|
|
95
|
+
languages: (options.languages ?? 'en').split(','),
|
|
96
|
+
...Object.fromEntries(['project', 'region', 'image', 'domain', 'bucket', 'prefix'].filter((key) => options[key]).map((key) => [key, options[key]])),
|
|
97
|
+
})
|
|
98
|
+
const source = dependencies.templates ?? templates
|
|
99
|
+
if (!existsSync(join(source, `deploy/${config.target}/deploy.sh`))) throw new Error('Hosting templates are missing; install a built document-ocr package')
|
|
100
|
+
const entries = readdirSync(source)
|
|
101
|
+
const collisions = ['dococr.json', ...entries].filter((name) => existsSync(join(cwd, name)))
|
|
102
|
+
if (collisions.length) throw new Error(`Refusing to overwrite existing paths: ${collisions.join(', ')}`)
|
|
103
|
+
mkdirSync(cwd, { recursive: true })
|
|
104
|
+
for (const name of entries) cpSync(join(source, name), join(cwd, name), { recursive: true, errorOnExist: true, force: false })
|
|
105
|
+
writeFileSync(configFile, `${JSON.stringify(config, null, 2)}\n`, { flag: 'wx' })
|
|
106
|
+
log(`Initialized ${config.target} in ${cwd}. Configure credentials, then run document-ocr doctor.`)
|
|
107
|
+
return 0
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!existsSync(configFile)) throw new Error('dococr.json is missing; run document-ocr init --target TARGET first')
|
|
111
|
+
const config = validate(JSON.parse(readFileSync(configFile, 'utf8')))
|
|
112
|
+
const effectiveEnv = environment(config, env)
|
|
113
|
+
const selected = invocation(config)
|
|
114
|
+
const wrapper = join(cwd, `deploy/${config.target}/deploy.sh`)
|
|
115
|
+
if (!existsSync(wrapper)) throw new Error(`Missing deployment wrapper: deploy/${config.target}/deploy.sh`)
|
|
116
|
+
if (command === 'deploy') {
|
|
117
|
+
if (options['dry-run']) { log(JSON.stringify({ ...selected, cwd, target: config.target, name: config.name, profile: config.profile }, null, 2)); return 0 }
|
|
118
|
+
const result = execute(selected.command, selected.args, { cwd, env: effectiveEnv, stdio: 'inherit', shell: false })
|
|
119
|
+
if (result.error) throw new Error(result.error)
|
|
120
|
+
return result.status ?? 1
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const requirements = {
|
|
124
|
+
cloudrun: ['bash', 'gcloud', 'python3'],
|
|
125
|
+
lambda: ['bash', 'aws', 'sam', ...(effectiveEnv.IMAGE_URI ? [] : ['docker'])],
|
|
126
|
+
cloudflare: ['bash', 'node', 'npm', ...(effectiveEnv.IMAGE_URI ? [] : ['docker'])],
|
|
127
|
+
server: ['bash', 'docker'],
|
|
128
|
+
}[config.target]
|
|
129
|
+
const checks = requirements.map((tool) => {
|
|
130
|
+
const result = execute(tool, ['--version'], { cwd, env: effectiveEnv, stdio: 'pipe', encoding: 'utf8', timeout: 10000, shell: false })
|
|
131
|
+
return { check: `${tool} installed`, passed: result.status === 0 }
|
|
132
|
+
})
|
|
133
|
+
if (config.target === 'server') {
|
|
134
|
+
const compose = execute('docker', ['compose', 'version'], { cwd, env: effectiveEnv, stdio: 'pipe', timeout: 10000, shell: false })
|
|
135
|
+
checks.push({ check: 'Docker Compose installed', passed: compose.status === 0 })
|
|
136
|
+
checks.push({ check: 'DOMAIN configured', passed: Boolean(effectiveEnv.DOMAIN) })
|
|
137
|
+
checks.push({ check: 'API_TOKEN configured in environment', passed: Boolean(effectiveEnv.API_TOKEN) })
|
|
138
|
+
}
|
|
139
|
+
if (config.target === 'cloudrun') checks.push({ check: 'GCP_PROJECT configured', passed: Boolean(effectiveEnv.GCP_PROJECT) })
|
|
140
|
+
if (!effectiveEnv.IMAGE_URI) checks.push({ check: 'Source and locked runtime bundled', passed: existsSync(join(cwd, 'core/pipeline.py')) && existsSync(join(cwd, 'requirements.lock')) })
|
|
141
|
+
const endpoint = options.endpoint ?? config.endpoint
|
|
142
|
+
if (endpoint) {
|
|
143
|
+
const url = new URL(endpoint)
|
|
144
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('endpoint must be an HTTP(S) URL without credentials')
|
|
145
|
+
url.pathname = `${url.pathname.replace(/\/+$/, '')}/health`
|
|
146
|
+
try {
|
|
147
|
+
const response = await (dependencies.fetch ?? fetch)(url, { signal: AbortSignal.timeout(10000), headers: effectiveEnv.API_TOKEN ? { Authorization: `Bearer ${effectiveEnv.API_TOKEN}` } : {} })
|
|
148
|
+
const health = await response.json()
|
|
149
|
+
checks.push({ check: 'Endpoint healthy', passed: response.ok && health.status === 'ok' })
|
|
150
|
+
} catch { checks.push({ check: 'Endpoint healthy', passed: false }) }
|
|
151
|
+
}
|
|
152
|
+
const passed = checks.every((check) => check.passed)
|
|
153
|
+
if (options.json) log(JSON.stringify({ passed, target: config.target, checks }, null, 2))
|
|
154
|
+
else for (const check of checks) log(`${check.passed ? 'OK' : 'FAIL'} ${check.check}`)
|
|
155
|
+
return passed ? 0 : 1
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (process.argv[1] && existsSync(process.argv[1]) && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
159
|
+
main().then((status) => { process.exitCode = status }).catch((error) => {
|
|
160
|
+
console.error(`document-ocr: ${error.message}`)
|
|
161
|
+
process.exitCode = 1
|
|
162
|
+
})
|
|
163
|
+
}
|