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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +260 -2
  3. package/dist/index.cjs +659 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +293 -0
  6. package/dist/index.d.ts +293 -0
  7. package/dist/index.js +616 -0
  8. package/dist/index.js.map +1 -0
  9. package/hosting/.dockerignore +17 -0
  10. package/hosting/.gcloudignore +14 -0
  11. package/hosting/FEATURES.md +172 -0
  12. package/hosting/HOSTING.md +268 -0
  13. package/hosting/LICENSE +21 -0
  14. package/hosting/README.md +369 -0
  15. package/hosting/core/__init__.py +0 -0
  16. package/hosting/core/aadhaar_extractor.py +298 -0
  17. package/hosting/core/back_page_extractor.py +492 -0
  18. package/hosting/core/barcodes.py +125 -0
  19. package/hosting/core/card_recovery.py +244 -0
  20. package/hosting/core/document_bundle.py +82 -0
  21. package/hosting/core/document_classifier.py +233 -0
  22. package/hosting/core/document_input.py +135 -0
  23. package/hosting/core/document_registry.py +143 -0
  24. package/hosting/core/driving_licence_extractor.py +510 -0
  25. package/hosting/core/ead_recovery.py +54 -0
  26. package/hosting/core/evidence.py +54 -0
  27. package/hosting/core/form_recovery.py +61 -0
  28. package/hosting/core/jobs.py +232 -0
  29. package/hosting/core/kyc_ocr.py +132 -0
  30. package/hosting/core/kyc_validation.py +411 -0
  31. package/hosting/core/model_setup.py +13 -0
  32. package/hosting/core/mrz_parser.py +336 -0
  33. package/hosting/core/mrz_recovery.py +193 -0
  34. package/hosting/core/npr_extractor.py +410 -0
  35. package/hosting/core/nrega_extractor.py +827 -0
  36. package/hosting/core/ocr_engine.py +207 -0
  37. package/hosting/core/page_classifier.py +137 -0
  38. package/hosting/core/pan_extractor.py +183 -0
  39. package/hosting/core/pipeline.py +712 -0
  40. package/hosting/core/preprocessor.py +297 -0
  41. package/hosting/core/recovery_budget.py +67 -0
  42. package/hosting/core/redaction.py +48 -0
  43. package/hosting/core/review.html +244 -0
  44. package/hosting/core/structured_documents.py +422 -0
  45. package/hosting/core/travel_mrz.py +164 -0
  46. package/hosting/core/travel_recovery.py +187 -0
  47. package/hosting/core/validator.py +396 -0
  48. package/hosting/core/validators.py +179 -0
  49. package/hosting/core/voter_id_extractor.py +363 -0
  50. package/hosting/deploy/cloudflare/deploy.sh +44 -0
  51. package/hosting/deploy/cloudflare/package-lock.json +3088 -0
  52. package/hosting/deploy/cloudflare/package.json +29 -0
  53. package/hosting/deploy/cloudflare/src/gateway.ts +42 -0
  54. package/hosting/deploy/cloudflare/src/index.ts +21 -0
  55. package/hosting/deploy/cloudflare/test/gateway.test.ts +59 -0
  56. package/hosting/deploy/cloudflare/tsconfig.json +12 -0
  57. package/hosting/deploy/cloudflare/worker-configuration.d.ts +16253 -0
  58. package/hosting/deploy/cloudflare/wrangler.jsonc +26 -0
  59. package/hosting/deploy/cloudrun/cloudbuild.yaml +18 -0
  60. package/hosting/deploy/cloudrun/deploy.sh +71 -0
  61. package/hosting/deploy/cloudrun/render_service.py +47 -0
  62. package/hosting/deploy/common.sh +14 -0
  63. package/hosting/deploy/docker/Dockerfile +16 -0
  64. package/hosting/deploy/docker/server.py +313 -0
  65. package/hosting/deploy/lambda/Dockerfile.lambda +11 -0
  66. package/hosting/deploy/lambda/deploy.sh +22 -0
  67. package/hosting/deploy/lambda/handler.py +132 -0
  68. package/hosting/deploy/lambda/template.yaml +59 -0
  69. package/hosting/deploy/local/dev.sh +18 -0
  70. package/hosting/deploy/server/Caddyfile +5 -0
  71. package/hosting/deploy/server/compose.jobs.yaml +36 -0
  72. package/hosting/deploy/server/compose.yaml +38 -0
  73. package/hosting/deploy/server/deploy.sh +23 -0
  74. package/hosting/infra/cloudrun/main.tf +104 -0
  75. package/hosting/infra/cloudrun/terraform.tfvars.example +7 -0
  76. package/hosting/infra/cloudrun/variables.tf +55 -0
  77. package/hosting/infra/lambda/main.tf +63 -0
  78. package/hosting/infra/lambda/terraform.tfvars.example +6 -0
  79. package/hosting/infra/lambda/variables.tf +37 -0
  80. package/hosting/pyproject.toml +65 -0
  81. package/hosting/requirements.lock +134 -0
  82. package/package.json +76 -4
  83. package/python/core/__init__.py +0 -0
  84. package/python/core/aadhaar_extractor.py +298 -0
  85. package/python/core/back_page_extractor.py +492 -0
  86. package/python/core/barcodes.py +125 -0
  87. package/python/core/card_recovery.py +244 -0
  88. package/python/core/document_bundle.py +82 -0
  89. package/python/core/document_classifier.py +233 -0
  90. package/python/core/document_input.py +135 -0
  91. package/python/core/document_registry.py +143 -0
  92. package/python/core/driving_licence_extractor.py +510 -0
  93. package/python/core/ead_recovery.py +54 -0
  94. package/python/core/evidence.py +54 -0
  95. package/python/core/form_recovery.py +61 -0
  96. package/python/core/jobs.py +232 -0
  97. package/python/core/kyc_ocr.py +132 -0
  98. package/python/core/kyc_validation.py +411 -0
  99. package/python/core/model_setup.py +13 -0
  100. package/python/core/mrz_parser.py +336 -0
  101. package/python/core/mrz_recovery.py +193 -0
  102. package/python/core/npr_extractor.py +410 -0
  103. package/python/core/nrega_extractor.py +827 -0
  104. package/python/core/ocr_engine.py +207 -0
  105. package/python/core/page_classifier.py +137 -0
  106. package/python/core/pan_extractor.py +183 -0
  107. package/python/core/pipeline.py +712 -0
  108. package/python/core/preprocessor.py +297 -0
  109. package/python/core/recovery_budget.py +67 -0
  110. package/python/core/redaction.py +48 -0
  111. package/python/core/review.html +244 -0
  112. package/python/core/structured_documents.py +422 -0
  113. package/python/core/travel_mrz.py +164 -0
  114. package/python/core/travel_recovery.py +187 -0
  115. package/python/core/validator.py +396 -0
  116. package/python/core/validators.py +179 -0
  117. package/python/core/voter_id_extractor.py +363 -0
  118. package/python/pyproject.toml +34 -0
  119. package/python/requirements.lock +134 -0
  120. package/python/server.py +313 -0
  121. package/scripts/dococr.mjs +163 -0
  122. package/scripts/postinstall.mjs +173 -0
  123. package/scripts/sync-hosting.mjs +35 -0
  124. 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)
@@ -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
+ }