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,411 @@
1
+ """Validation and confidence assessment for non-passport document extraction.
2
+
3
+ This module deliberately separates three different questions:
4
+
5
+ * did OCR classify the document family?
6
+ * did the extractor return the minimum fields needed for that family?
7
+ * does the printed identifier pass the validation that is possible offline?
8
+
9
+ It does not establish document authenticity. Format checks and Aadhaar's
10
+ Verhoeff checksum can reject obvious OCR errors, but issuer verification
11
+ requires the appropriate signed QR, digital signature, or issuer service.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from datetime import date, datetime
18
+ from statistics import fmean
19
+ from typing import Any, Optional
20
+
21
+ from .ocr_engine import TextRegion
22
+ from .validators import (
23
+ is_valid_aadhaar,
24
+ is_valid_dl,
25
+ is_valid_epic,
26
+ is_valid_nrega_job_card,
27
+ is_valid_pan,
28
+ )
29
+
30
+ MIN_OCR_GEOMETRY_CONFIDENCE_FOR_SUCCESS = 0.5
31
+
32
+
33
+ @dataclass
34
+ class KycExtractionAssessment:
35
+ """Outcome of document-specific extraction validation."""
36
+
37
+ complete: bool
38
+ confidence: float
39
+ identifier_valid: Optional[bool]
40
+ missing_required_fields: list[str] = field(default_factory=list)
41
+ errors: list[str] = field(default_factory=list)
42
+ warnings: list[str] = field(default_factory=list)
43
+
44
+
45
+ def assess_kyc_extraction(
46
+ document_type: str,
47
+ extracted: Any,
48
+ regions: list[TextRegion],
49
+ ) -> KycExtractionAssessment:
50
+ """Validate one non-passport extraction and estimate extraction confidence.
51
+
52
+ Confidence is an explicit heuristic built from required-field completeness,
53
+ identifier validation, and OCR-region confidence. It is intentionally
54
+ capped for partial or invalid records and must not be interpreted as a
55
+ calibrated probability until measured against a private evaluation set.
56
+ """
57
+
58
+ if document_type == "pan":
59
+ required = {
60
+ "panNumber": _value(extracted, "pan_number"),
61
+ "name": _value(extracted, "name"),
62
+ "dateOfBirth": _value(extracted, "date_of_birth"),
63
+ }
64
+ identifier = _value(extracted, "pan_number")
65
+ identifier_valid: Optional[bool] = bool(
66
+ identifier and is_valid_pan(identifier)
67
+ )
68
+ warnings: list[str] = []
69
+ elif document_type == "aadhaar":
70
+ return _assess_aadhaar(extracted, regions)
71
+ elif document_type == "driving_licence":
72
+ required = {
73
+ "dlNumber": _value(extracted, "dl_number"),
74
+ "name": _value(extracted, "name"),
75
+ "dateOfBirth": _value(extracted, "date_of_birth"),
76
+ "issueDate": _value(extracted, "issue_date"),
77
+ "validityDate": _value(extracted, "validity_date"),
78
+ }
79
+ identifier = _value(extracted, "dl_number")
80
+ identifier_valid = bool(identifier and is_valid_dl(identifier))
81
+ warnings = []
82
+ elif document_type == "voter_id":
83
+ required = {
84
+ "epicNumber": _value(extracted, "epic_number"),
85
+ "name": _value(extracted, "name"),
86
+ "dateOfBirthOrAge": (
87
+ _value(extracted, "date_of_birth") or _value(extracted, "age")
88
+ ),
89
+ }
90
+ identifier = _value(extracted, "epic_number")
91
+ identifier_valid = bool(identifier and is_valid_epic(identifier))
92
+ warnings = []
93
+ elif document_type == "nrega_job_card":
94
+ members = _value(extracted, "members") or []
95
+ member_names = [
96
+ _value(member, "name")
97
+ for member in members
98
+ if _value(member, "name")
99
+ ]
100
+ job_card_number = _value(extracted, "job_card_number")
101
+ required = {
102
+ "jobCardNumber": job_card_number,
103
+ "householdOrMemberName": (
104
+ _value(extracted, "head_of_household") or member_names
105
+ ),
106
+ "location": (
107
+ _value(extracted, "village")
108
+ or _value(extracted, "gram_panchayat")
109
+ or _value(extracted, "district")
110
+ or _value(extracted, "address")
111
+ ),
112
+ }
113
+ identifier_valid = bool(
114
+ job_card_number and is_valid_nrega_job_card(job_card_number)
115
+ )
116
+ warnings = (
117
+ ["IDENTIFIER_FORMAT_ONLY_NOT_AUTHENTICITY"]
118
+ if identifier_valid
119
+ else []
120
+ )
121
+ elif document_type == "npr_letter":
122
+ required = {
123
+ "name": _value(extracted, "name"),
124
+ "address": _value(extracted, "address"),
125
+ }
126
+ # RBI defines the NPR name/address letter as an OVD but there is no
127
+ # public universal checksum for a printed reference number.
128
+ identifier_valid = None
129
+ warnings = ["IDENTIFIER_NOT_OFFLINE_VERIFIABLE"]
130
+ else:
131
+ return KycExtractionAssessment(
132
+ complete=False,
133
+ confidence=0.0,
134
+ identifier_valid=False,
135
+ errors=["UNSUPPORTED_KYC_DOCUMENT_TYPE"],
136
+ )
137
+
138
+ assessment = _build_assessment(required, identifier_valid, regions, warnings)
139
+ return _apply_semantic_checks(document_type, extracted, assessment)
140
+
141
+
142
+ def _assess_aadhaar(
143
+ extracted: Any,
144
+ regions: list[TextRegion],
145
+ ) -> KycExtractionAssessment:
146
+ number = _value(extracted, "aadhaar_number")
147
+ masked = bool(_value(extracted, "aadhaar_masked"))
148
+ last4 = _value(extracted, "aadhaar_last4")
149
+ identifier_present = number or (masked and last4)
150
+
151
+ # A back/address side usually omits the holder name and birth details.
152
+ address = _value(extracted, "address")
153
+ pincode = _value(extracted, "pincode")
154
+ has_front_demographics = bool(
155
+ _value(extracted, "name")
156
+ or _value(extracted, "date_of_birth")
157
+ or _value(extracted, "year_of_birth")
158
+ or _value(extracted, "gender")
159
+ )
160
+
161
+ if (address or pincode) and not has_front_demographics:
162
+ required = {
163
+ "aadhaarNumberOrMaskedLast4": identifier_present,
164
+ "address": address,
165
+ "pincode": pincode,
166
+ }
167
+ else:
168
+ required = {
169
+ "aadhaarNumberOrMaskedLast4": identifier_present,
170
+ "name": _value(extracted, "name"),
171
+ "dateOrYearOfBirth": (
172
+ _value(extracted, "date_of_birth")
173
+ or _value(extracted, "year_of_birth")
174
+ ),
175
+ "gender": _value(extracted, "gender"),
176
+ }
177
+
178
+ warnings: list[str] = []
179
+ if number:
180
+ identifier_valid: Optional[bool] = is_valid_aadhaar(number)
181
+ elif masked and last4:
182
+ identifier_valid = None
183
+ warnings.append("MASKED_AADHAAR_CHECKSUM_UNAVAILABLE")
184
+ else:
185
+ identifier_valid = False
186
+
187
+ assessment = _build_assessment(required, identifier_valid, regions, warnings)
188
+ return _apply_semantic_checks("aadhaar", extracted, assessment)
189
+
190
+
191
+ def _build_assessment(
192
+ required: dict[str, Any],
193
+ identifier_valid: Optional[bool],
194
+ regions: list[TextRegion],
195
+ warnings: list[str],
196
+ ) -> KycExtractionAssessment:
197
+ missing = [name for name, value in required.items() if not _present(value)]
198
+ completeness = (len(required) - len(missing)) / len(required) if required else 0.0
199
+ geometry_confidences = _ocr_geometry_confidences(regions)
200
+ ocr_confidence = (
201
+ fmean(geometry_confidences)
202
+ if geometry_confidences
203
+ else 0.0
204
+ )
205
+ minimum_ocr_confidence = (
206
+ min(geometry_confidences)
207
+ if geometry_confidences
208
+ else 0.0
209
+ )
210
+ if identifier_valid is True:
211
+ identifier_score = 1.0
212
+ elif identifier_valid is None:
213
+ identifier_score = 0.6
214
+ else:
215
+ identifier_score = 0.0
216
+
217
+ confidence = (
218
+ (0.5 * completeness)
219
+ + (0.3 * identifier_score)
220
+ + (0.2 * ocr_confidence)
221
+ )
222
+ errors: list[str] = []
223
+
224
+ if identifier_valid is False:
225
+ errors.append("INVALID_OR_MISSING_DOCUMENT_IDENTIFIER")
226
+ confidence = min(confidence, 0.39)
227
+ if missing:
228
+ errors.append("MISSING_REQUIRED_FIELDS")
229
+ confidence = min(confidence, 0.69)
230
+ if minimum_ocr_confidence < MIN_OCR_GEOMETRY_CONFIDENCE_FOR_SUCCESS:
231
+ errors.append("LOW_OCR_CONFIDENCE")
232
+ confidence = min(confidence, 0.49)
233
+
234
+ complete = (
235
+ not missing
236
+ and identifier_valid is not False
237
+ and minimum_ocr_confidence
238
+ >= MIN_OCR_GEOMETRY_CONFIDENCE_FOR_SUCCESS
239
+ )
240
+ return KycExtractionAssessment(
241
+ complete=complete,
242
+ confidence=round(max(0.0, min(confidence, 1.0)), 3),
243
+ identifier_valid=identifier_valid,
244
+ missing_required_fields=missing,
245
+ errors=errors,
246
+ warnings=warnings,
247
+ )
248
+
249
+
250
+ def _ocr_geometry_confidences(regions: list[TextRegion]) -> list[float]:
251
+ # Multiple configured recognizers can emit different script readings for
252
+ # the same physical box. Count that geometry once so adding a model does
253
+ # not multiply-weight a region or turn an otherwise identical extraction
254
+ # into a low-confidence failure.
255
+ confidence_by_geometry: dict[tuple[tuple[int, int], ...], float] = {}
256
+ for region in regions:
257
+ geometry = tuple(tuple(point) for point in region.bbox)
258
+ score = max(0.0, min(float(region.confidence), 1.0))
259
+ confidence_by_geometry[geometry] = max(
260
+ score,
261
+ confidence_by_geometry.get(geometry, 0.0),
262
+ )
263
+ return list(confidence_by_geometry.values())
264
+
265
+
266
+ def _apply_semantic_checks(
267
+ document_type: str,
268
+ extracted: Any,
269
+ assessment: KycExtractionAssessment,
270
+ ) -> KycExtractionAssessment:
271
+ semantic_errors: list[str] = []
272
+
273
+ if document_type == "pan":
274
+ _require_past_date(
275
+ _value(extracted, "date_of_birth"),
276
+ "INVALID_DATE_OF_BIRTH",
277
+ semantic_errors,
278
+ )
279
+ elif document_type == "aadhaar":
280
+ dob = _value(extracted, "date_of_birth")
281
+ yob = _value(extracted, "year_of_birth")
282
+ if dob:
283
+ _require_past_date(dob, "INVALID_DATE_OF_BIRTH", semantic_errors)
284
+ elif yob:
285
+ try:
286
+ year = int(yob)
287
+ except (TypeError, ValueError):
288
+ semantic_errors.append("INVALID_YEAR_OF_BIRTH")
289
+ else:
290
+ if not 1900 <= year <= date.today().year:
291
+ semantic_errors.append("INVALID_YEAR_OF_BIRTH")
292
+ elif document_type == "driving_licence":
293
+ dob = _parse_printed_date(_value(extracted, "date_of_birth"))
294
+ issue = _parse_printed_date(_value(extracted, "issue_date"))
295
+ validity = _parse_printed_date(_value(extracted, "validity_date"))
296
+ if dob is None:
297
+ semantic_errors.append("INVALID_DATE_OF_BIRTH")
298
+ if issue is None:
299
+ semantic_errors.append("INVALID_ISSUE_DATE")
300
+ if validity is None:
301
+ semantic_errors.append("INVALID_VALIDITY_DATE")
302
+ if issue and issue > date.today():
303
+ semantic_errors.append("INVALID_ISSUE_DATE")
304
+ if dob and issue and dob >= issue:
305
+ semantic_errors.append("INVALID_DL_DATE_CHRONOLOGY")
306
+ if issue and validity and issue >= validity:
307
+ semantic_errors.append("INVALID_DL_DATE_CHRONOLOGY")
308
+ elif document_type == "voter_id":
309
+ dob = _value(extracted, "date_of_birth")
310
+ age = _value(extracted, "age")
311
+ if dob:
312
+ _require_past_date(dob, "INVALID_DATE_OF_BIRTH", semantic_errors)
313
+ elif age:
314
+ try:
315
+ age_number = int(age)
316
+ except (TypeError, ValueError):
317
+ semantic_errors.append("INVALID_AGE")
318
+ else:
319
+ if not 18 <= age_number <= 130:
320
+ semantic_errors.append("INVALID_AGE")
321
+ elif document_type == "nrega_job_card":
322
+ for value, error in (
323
+ (_value(extracted, "registration_date"), "INVALID_REGISTRATION_DATE"),
324
+ (_value(extracted, "validity_from"), "INVALID_VALIDITY_FROM"),
325
+ (_value(extracted, "validity_to"), "INVALID_VALIDITY_TO"),
326
+ ):
327
+ if value and _parse_printed_date(value) is None:
328
+ semantic_errors.append(error)
329
+ registration = _parse_printed_date(
330
+ _value(extracted, "registration_date")
331
+ )
332
+ if registration and registration > date.today():
333
+ semantic_errors.append("INVALID_REGISTRATION_DATE")
334
+ valid_from = _parse_printed_date(_value(extracted, "validity_from"))
335
+ valid_to = _parse_printed_date(_value(extracted, "validity_to"))
336
+ if valid_from and valid_to and valid_from >= valid_to:
337
+ semantic_errors.append("INVALID_NREGA_VALIDITY_CHRONOLOGY")
338
+ elif document_type == "npr_letter":
339
+ issue_date = _value(extracted, "issue_date")
340
+ if issue_date:
341
+ _require_not_future_date(
342
+ issue_date,
343
+ "INVALID_ISSUE_DATE",
344
+ semantic_errors,
345
+ )
346
+ pincode = _value(extracted, "pincode")
347
+ if pincode and not (
348
+ isinstance(pincode, str)
349
+ and len(pincode) == 6
350
+ and pincode.isdigit()
351
+ and not pincode.startswith("0")
352
+ ):
353
+ semantic_errors.append("INVALID_PINCODE")
354
+
355
+ if semantic_errors:
356
+ assessment.complete = False
357
+ assessment.confidence = min(assessment.confidence, 0.49)
358
+ assessment.errors.extend(
359
+ error for error in semantic_errors if error not in assessment.errors
360
+ )
361
+ return assessment
362
+
363
+
364
+ def _require_past_date(
365
+ value: Any,
366
+ error: str,
367
+ errors: list[str],
368
+ ) -> None:
369
+ parsed = _parse_printed_date(value)
370
+ if parsed is None or parsed >= date.today():
371
+ errors.append(error)
372
+
373
+
374
+ def _require_not_future_date(
375
+ value: Any,
376
+ error: str,
377
+ errors: list[str],
378
+ ) -> None:
379
+ parsed = _parse_printed_date(value)
380
+ if parsed is None or parsed > date.today():
381
+ errors.append(error)
382
+
383
+
384
+ def _parse_printed_date(value: Any) -> Optional[date]:
385
+ if not isinstance(value, str) or not value.strip():
386
+ return None
387
+ normalised = value.strip().replace("/", "-").replace(".", "-")
388
+ for fmt in (
389
+ "%d-%m-%Y",
390
+ "%d-%m-%y",
391
+ "%Y-%m-%d",
392
+ "%d %B %Y",
393
+ "%d %b %Y",
394
+ ):
395
+ try:
396
+ return datetime.strptime(normalised, fmt).date()
397
+ except ValueError:
398
+ continue
399
+ return None
400
+
401
+
402
+ def _value(obj: Any, name: str) -> Any:
403
+ return getattr(obj, name, None)
404
+
405
+
406
+ def _present(value: Any) -> bool:
407
+ if isinstance(value, str):
408
+ return bool(value.strip())
409
+ if isinstance(value, (list, tuple, set, dict)):
410
+ return bool(value)
411
+ return bool(value)
@@ -0,0 +1,13 @@
1
+ """Fetch configured models at image build time or warm them before serving."""
2
+ from .kyc_ocr import configured_kyc_languages
3
+ from .ocr_engine import _get_ocr
4
+
5
+
6
+ def warm_up() -> None:
7
+ for language in dict.fromkeys(("en", *configured_kyc_languages())):
8
+ _get_ocr(language)
9
+ _get_ocr("en", band=True)
10
+
11
+
12
+ if __name__ == "__main__":
13
+ warm_up()