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,135 @@
|
|
|
1
|
+
"""Bounded PDF page loading and native text geometry in rendered-page coordinates.
|
|
2
|
+
|
|
3
|
+
PDFium is not thread-safe. Every PDF operation, including object disposal, runs
|
|
4
|
+
under the same process-wide lock. Native text is extraction evidence, not a
|
|
5
|
+
claim that a PDF is authentic or that its hidden text matches its visible ink.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import threading
|
|
12
|
+
import math
|
|
13
|
+
|
|
14
|
+
import cv2
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from .ocr_engine import TextRegion
|
|
18
|
+
|
|
19
|
+
PDF_LOCK = threading.RLock()
|
|
20
|
+
MAX_PAGES = 10
|
|
21
|
+
MAX_PAGE_PIXELS = 20_000_000
|
|
22
|
+
MAX_DOCUMENT_PIXELS = 40_000_000
|
|
23
|
+
MAX_TEXT_CHARS = 100_000
|
|
24
|
+
PDF_WIDTH = 1600
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class InputPage:
|
|
29
|
+
image: np.ndarray
|
|
30
|
+
regions: list[TextRegion]
|
|
31
|
+
number: int
|
|
32
|
+
total: int
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def input_bytes(source: str | bytes | Path) -> bytes:
|
|
36
|
+
return Path(source).read_bytes() if isinstance(source, (str, Path)) else source
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def pdf_pages(data: bytes, *, first_only: bool = False) -> list[InputPage]:
|
|
40
|
+
from .preprocessor import ImageQualityError
|
|
41
|
+
import pypdfium2 as pdfium
|
|
42
|
+
|
|
43
|
+
with PDF_LOCK:
|
|
44
|
+
try:
|
|
45
|
+
doc = pdfium.PdfDocument(data)
|
|
46
|
+
except pdfium.PdfiumError as exc:
|
|
47
|
+
raise ImageQualityError('INVALID_PDF') from exc
|
|
48
|
+
try:
|
|
49
|
+
total = len(doc)
|
|
50
|
+
if not total:
|
|
51
|
+
raise ImageQualityError('PDF_HAS_NO_PAGES')
|
|
52
|
+
if total > MAX_PAGES and not first_only:
|
|
53
|
+
raise ImageQualityError('TOO_MANY_PAGES')
|
|
54
|
+
output = []
|
|
55
|
+
total_pixels = 0
|
|
56
|
+
for index in range(1 if first_only else total):
|
|
57
|
+
page = doc[index]
|
|
58
|
+
textpage = bitmap = None
|
|
59
|
+
try:
|
|
60
|
+
width, height = page.get_size()
|
|
61
|
+
if width <= 0 or height <= 0:
|
|
62
|
+
raise ImageQualityError('INVALID_PDF_PAGE_SIZE')
|
|
63
|
+
scale = PDF_WIDTH / width
|
|
64
|
+
pixels = math.ceil(width * scale) * math.ceil(height * scale)
|
|
65
|
+
if pixels > MAX_PAGE_PIXELS:
|
|
66
|
+
raise ImageQualityError('PDF_PAGE_TOO_LARGE')
|
|
67
|
+
total_pixels += pixels
|
|
68
|
+
if total_pixels > MAX_DOCUMENT_PIXELS:
|
|
69
|
+
raise ImageQualityError('PDF_DOCUMENT_TOO_LARGE')
|
|
70
|
+
bitmap = page.render(scale=scale)
|
|
71
|
+
image = cv2.cvtColor(np.array(bitmap.to_pil().convert('RGB')), cv2.COLOR_RGB2BGR)
|
|
72
|
+
textpage = page.get_textpage()
|
|
73
|
+
regions = _native_regions(textpage, bitmap.get_posconv(page), image.shape[1], image.shape[0])
|
|
74
|
+
# Character boxes are in unrotated PDF space. On a rotated
|
|
75
|
+
# page fall back to rendered OCR until its transform is known.
|
|
76
|
+
if page.get_rotation():
|
|
77
|
+
regions = []
|
|
78
|
+
output.append(InputPage(image, regions, index + 1, total))
|
|
79
|
+
finally:
|
|
80
|
+
if textpage is not None:
|
|
81
|
+
textpage.close()
|
|
82
|
+
if bitmap is not None:
|
|
83
|
+
bitmap.close()
|
|
84
|
+
page.close()
|
|
85
|
+
return output
|
|
86
|
+
except pdfium.PdfiumError as exc:
|
|
87
|
+
raise ImageQualityError('INVALID_PDF') from exc
|
|
88
|
+
finally:
|
|
89
|
+
doc.close()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _native_regions(textpage, converter, width: int, height: int) -> list[TextRegion]:
|
|
93
|
+
from .preprocessor import ImageQualityError
|
|
94
|
+
count = textpage.count_chars()
|
|
95
|
+
if count > MAX_TEXT_CHARS:
|
|
96
|
+
raise ImageQualityError('PDF_TEXT_TOO_LARGE')
|
|
97
|
+
regions, chars, boxes = [], [], []
|
|
98
|
+
line_top = line_bottom = None
|
|
99
|
+
|
|
100
|
+
def flush():
|
|
101
|
+
nonlocal line_top, line_bottom
|
|
102
|
+
text = ''.join(chars).strip()
|
|
103
|
+
if text and boxes:
|
|
104
|
+
x0, y0 = min(b[0] for b in boxes), min(b[1] for b in boxes)
|
|
105
|
+
x1, y1 = max(b[2] for b in boxes), max(b[3] for b in boxes)
|
|
106
|
+
regions.append(TextRegion(text, [[x0,y0],[x1,y0],[x1,y1],[x0,y1]], 1.0))
|
|
107
|
+
chars.clear()
|
|
108
|
+
boxes.clear()
|
|
109
|
+
line_top = line_bottom = None
|
|
110
|
+
|
|
111
|
+
for index in range(count):
|
|
112
|
+
char = textpage.get_text_range(index, 1)
|
|
113
|
+
if not char or char in '\r\n':
|
|
114
|
+
flush()
|
|
115
|
+
continue
|
|
116
|
+
if char.isspace():
|
|
117
|
+
chars.append(' ')
|
|
118
|
+
continue
|
|
119
|
+
left, bottom, right, top = textpage.get_charbox(index)
|
|
120
|
+
# PDFium's own transform handles CropBox/MediaBox offsets and bitmap
|
|
121
|
+
# rounding. Scaling page coordinates ourselves misaligns redaction.
|
|
122
|
+
corners = [converter.to_bitmap(x, y) for x, y in
|
|
123
|
+
((left, bottom), (right, bottom), (right, top), (left, top))]
|
|
124
|
+
box = [max(0, min(p[0] for p in corners)), max(0, min(p[1] for p in corners)),
|
|
125
|
+
min(width, max(p[0] for p in corners)), min(height, max(p[1] for p in corners))]
|
|
126
|
+
if box[0] >= box[2] or box[1] >= box[3]:
|
|
127
|
+
continue
|
|
128
|
+
if boxes and (box[1] > line_bottom+3 or box[3] < line_top-3 or box[0] < boxes[-1][0]-5):
|
|
129
|
+
flush()
|
|
130
|
+
chars.append(char)
|
|
131
|
+
boxes.append(box)
|
|
132
|
+
line_top = min(line_top, box[1]) if line_top is not None else box[1]
|
|
133
|
+
line_bottom = max(line_bottom, box[3]) if line_bottom is not None else box[3]
|
|
134
|
+
flush()
|
|
135
|
+
return regions
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Explicit document capabilities and normalized country/type hints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import pycountry
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# ISO 3166-1 plus Doc 9303 Part 3, section 5 A–G (2024 consolidated edition):
|
|
11
|
+
# https://www.icao.int/sites/default/files/publications/DocSeries/9303_p3_cons_en.pdf
|
|
12
|
+
# Includes unspecified nationality XXX and deprecated codes for old documents.
|
|
13
|
+
# Part H's IAO signs ICAO master lists; it is not a travel-document country.
|
|
14
|
+
MRZ_COUNTRY_CODES = frozenset(country.alpha_3 for country in pycountry.countries) | {
|
|
15
|
+
"D", "GBD", "GBN", "GBO", "GBP", "GBS", "RKS", "EUE",
|
|
16
|
+
"UNO", "UNA", "UNK", "XBA", "XIM", "XCC", "XCE", "XCO", "XEC",
|
|
17
|
+
"XPO", "XES", "XMP", "XOM", "XDC", "XXA", "XXB", "XXC", "XXX",
|
|
18
|
+
"ANT", "NTZ", "UTO",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_known_mrz_country(code: str | None) -> bool:
|
|
23
|
+
"""Validate an observed MRZ code without substituting a different code."""
|
|
24
|
+
return isinstance(code, str) and code.upper().rstrip("<") in MRZ_COUNTRY_CODES
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class DocumentProfile:
|
|
29
|
+
document_type: str
|
|
30
|
+
name: str
|
|
31
|
+
countries: tuple[str, ...]
|
|
32
|
+
fields: tuple[str, ...]
|
|
33
|
+
required_fields: tuple[str, ...]
|
|
34
|
+
extraction_methods: tuple[str, ...]
|
|
35
|
+
experimental: bool = True
|
|
36
|
+
legacy: bool = False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_IDENTITY = ("surname", "given_names", "date_of_birth", "document_number", "expiry_date")
|
|
40
|
+
_ADDRESS = ("address", "city", "state", "postal_code")
|
|
41
|
+
_MRZ = ("country_code", "mrz_raw")
|
|
42
|
+
_PROFILES = (
|
|
43
|
+
DocumentProfile("passport", "Passport book", ("*",), ("surname", "given_names", "full_name", "passport_number", "nationality", "date_of_birth", "sex", "expiry_date", "issue_date", "place_of_birth", "country_code"), ("surname", "passport_number"), ("mrz", "ocr"), False, True),
|
|
44
|
+
DocumentProfile("pan", "Indian PAN card", ("IN",), ("pan_number", "name", "father_name", "date_of_birth"), ("pan_number", "name", "date_of_birth"), ("ocr",), False, True),
|
|
45
|
+
DocumentProfile("aadhaar", "Indian Aadhaar card", ("IN",), ("aadhaar_number", "name", "date_of_birth", "year_of_birth", "gender", "address", "pincode", "checksum_valid", "aadhaar_masked", "aadhaar_last4", "vid"), ("aadhaar_number_or_masked_last4", "name", "date_or_year_of_birth", "gender"), ("ocr",), False, True),
|
|
46
|
+
DocumentProfile("driving_licence", "Indian driving licence", ("IN",), ("dl_number", "name", "date_of_birth", "issue_date", "validity_date", "address", "relation_name", "blood_group", "class_of_vehicle", "validity_date_transport"), ("dl_number", "name", "date_of_birth", "issue_date", "validity_date"), ("ocr",), False, True),
|
|
47
|
+
DocumentProfile("voter_id", "Indian voter ID", ("IN",), ("epic_number", "name", "relation_name", "relation_type", "gender", "date_of_birth", "age"), ("epic_number", "name", "date_of_birth_or_age"), ("ocr",), False, True),
|
|
48
|
+
DocumentProfile("nrega_job_card", "Indian NREGA job card", ("IN",), ("job_card_number", "head_of_household", "category", "registration_date", "validity_from", "validity_to", "address", "village", "gram_panchayat", "block", "district", "state", "bpl_status", "family_id", "members"), ("job_card_number", "household_or_member_name", "location"), ("ocr",), True, True),
|
|
49
|
+
DocumentProfile("npr_letter", "Indian NPR letter", ("IN",), ("reference_number", "name", "address", "pincode", "issue_date"), ("name", "address"), ("ocr",), True, True),
|
|
50
|
+
DocumentProfile("us_driver_license", "US driver license", ("US",), _IDENTITY + _ADDRESS + ("first_name", "middle_names", "issue_date", "sex", "license_class", "restrictions", "endorsements"), ("surname", "document_number", "date_of_birth", "expiry_date"), ("pdf417", "ocr")),
|
|
51
|
+
DocumentProfile("us_state_id", "US state identification card", ("US",), _IDENTITY + _ADDRESS + ("first_name", "middle_names", "issue_date", "sex"), ("surname", "document_number", "date_of_birth", "expiry_date"), ("pdf417", "ocr")),
|
|
52
|
+
DocumentProfile("passport_card", "Passport card", ("*",), _IDENTITY + ("nationality", "sex", "issue_date", "address") + _MRZ, ("surname", "document_number", "date_of_birth", "expiry_date"), ("mrz", "ocr")),
|
|
53
|
+
DocumentProfile("us_green_card", "US Permanent Resident Card (I-551)", ("US",), ("surname", "given_names", "uscis_number", "card_number", "date_of_birth", "expiry_date", "category", "resident_since", "country_of_birth", "sex") + _MRZ, ("surname", "uscis_number", "card_number", "date_of_birth"), ("mrz", "ocr")),
|
|
54
|
+
DocumentProfile("us_ead", "US Employment Authorization Document (I-766)", ("US",), ("surname", "given_names", "uscis_number", "card_number", "date_of_birth", "expiry_date", "category", "valid_from", "country_of_birth", "sex") + _MRZ, ("surname", "uscis_number", "card_number", "date_of_birth", "expiry_date"), ("mrz", "ocr")),
|
|
55
|
+
DocumentProfile("visa", "Machine-readable visa (MRV-A / MRV-B)", ("*",), _IDENTITY + ("nationality", "sex", "mrz_format") + _MRZ, ("surname", "document_number", "date_of_birth", "expiry_date"), ("mrz", "ocr")),
|
|
56
|
+
DocumentProfile("us_i94", "US I-94 arrival/departure record", ("US",), ("i94_number", "surname", "given_names", "date_of_birth", "admission_date", "admit_until", "class_of_admission", "passport_number", "country_of_citizenship"), ("i94_number", "surname", "class_of_admission", "admit_until"), ("ocr",)),
|
|
57
|
+
DocumentProfile("us_w9", "US Form W-9", ("US",), ("name", "business_name", "taxpayer_id", "taxpayer_id_type", "address", "city_state_postal_code"), ("name", "taxpayer_id", "taxpayer_id_type"), ("ocr",)),
|
|
58
|
+
)
|
|
59
|
+
DOCUMENT_PROFILES = {profile.document_type: profile for profile in _PROFILES}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def normalize_country(country: str | None) -> str | None:
|
|
63
|
+
if country is None:
|
|
64
|
+
return None
|
|
65
|
+
if not isinstance(country, str):
|
|
66
|
+
raise ValueError("INVALID_COUNTRY_HINT_TYPE")
|
|
67
|
+
value = country.strip().upper()
|
|
68
|
+
aliases = {"USA": "US", "UNITED STATES": "US", "UNITED STATES OF AMERICA": "US", "IND": "IN", "INDIA": "IN", "UK": "GB"}
|
|
69
|
+
value = aliases.get(value, value)
|
|
70
|
+
result = pycountry.countries.get(alpha_2=value) if len(value) == 2 else pycountry.countries.get(alpha_3=value)
|
|
71
|
+
if result is None:
|
|
72
|
+
raise ValueError(f"UNSUPPORTED_COUNTRY: {country}")
|
|
73
|
+
return result.alpha_2
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def validate_document_hint(document_type: str | None, country: str | None = None) -> tuple[str | None, str | None]:
|
|
77
|
+
"""Return canonical hints or fail explicitly instead of ignoring a conflict."""
|
|
78
|
+
country = normalize_country(country)
|
|
79
|
+
if document_type is None:
|
|
80
|
+
return None, country
|
|
81
|
+
if not isinstance(document_type, str):
|
|
82
|
+
raise ValueError("INVALID_DOCUMENT_TYPE_HINT_TYPE")
|
|
83
|
+
value = document_type.strip().lower().replace("-", "_").replace(" ", "_")
|
|
84
|
+
aliases = {
|
|
85
|
+
"driver_license": "driving_licence", "driving_license": "driving_licence",
|
|
86
|
+
"drivers_license": "driving_licence", "state_id": "us_state_id",
|
|
87
|
+
"green_card": "us_green_card", "i551": "us_green_card", "i_551": "us_green_card",
|
|
88
|
+
"ead": "us_ead", "i766": "us_ead", "i_766": "us_ead",
|
|
89
|
+
"i94": "us_i94", "i_94": "us_i94", "w9": "us_w9", "w_9": "us_w9",
|
|
90
|
+
"us_passport": "passport", "passport_book": "passport",
|
|
91
|
+
}
|
|
92
|
+
value = aliases.get(value, value)
|
|
93
|
+
if value == "driving_licence" and country == "US":
|
|
94
|
+
value = "us_driver_license"
|
|
95
|
+
profile = DOCUMENT_PROFILES.get(value)
|
|
96
|
+
if profile is None:
|
|
97
|
+
raise ValueError(f"UNSUPPORTED_DOCUMENT_TYPE: {document_type}")
|
|
98
|
+
if country is not None and "*" not in profile.countries and country not in profile.countries:
|
|
99
|
+
raise ValueError(f"DOCUMENT_COUNTRY_MISMATCH: {value}/{country}")
|
|
100
|
+
if country is None and len(profile.countries) == 1 and profile.countries[0] != "*":
|
|
101
|
+
country = profile.countries[0]
|
|
102
|
+
return value, country
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def list_document_profiles() -> list[dict]:
|
|
106
|
+
"""Return JSON-safe metadata, including experimental support boundaries."""
|
|
107
|
+
def camel(value: str) -> str:
|
|
108
|
+
head, *tail = value.split("_")
|
|
109
|
+
return head + "".join(part.title() for part in tail)
|
|
110
|
+
|
|
111
|
+
profiles = [
|
|
112
|
+
{
|
|
113
|
+
"documentType": profile.document_type,
|
|
114
|
+
"label": profile.name,
|
|
115
|
+
"countries": list(profile.countries),
|
|
116
|
+
"fields": [camel(name) for name in profile.fields],
|
|
117
|
+
"requiredFields": [camel(name) for name in profile.required_fields],
|
|
118
|
+
"sources": list(profile.extraction_methods),
|
|
119
|
+
"experimental": profile.experimental,
|
|
120
|
+
}
|
|
121
|
+
for profile in _PROFILES
|
|
122
|
+
]
|
|
123
|
+
# The legacy completion rules contain alternatives and, for Aadhaar,
|
|
124
|
+
# different front/address-side requirements. Expose those explicitly rather
|
|
125
|
+
# than pretending every logical requirement is one physical output field.
|
|
126
|
+
for profile in profiles:
|
|
127
|
+
if profile["documentType"] == "aadhaar":
|
|
128
|
+
profile["requiredFieldsByVariant"] = {
|
|
129
|
+
"front": profile["requiredFields"].copy(),
|
|
130
|
+
"address": ["aadhaarNumberOrMaskedLast4", "address", "pincode"],
|
|
131
|
+
}
|
|
132
|
+
profile["requirementAlternatives"] = {
|
|
133
|
+
"aadhaarNumberOrMaskedLast4": [["aadhaarNumber"], ["aadhaarMasked", "aadhaarLast4"]],
|
|
134
|
+
"dateOrYearOfBirth": [["dateOfBirth"], ["yearOfBirth"]],
|
|
135
|
+
}
|
|
136
|
+
elif profile["documentType"] == "voter_id":
|
|
137
|
+
profile["requirementAlternatives"] = {"dateOfBirthOrAge": [["dateOfBirth"], ["age"]]}
|
|
138
|
+
elif profile["documentType"] == "nrega_job_card":
|
|
139
|
+
profile["requirementAlternatives"] = {
|
|
140
|
+
"householdOrMemberName": [["headOfHousehold"], ["members[].name"]],
|
|
141
|
+
"location": [["village"], ["gramPanchayat"], ["district"], ["address"]],
|
|
142
|
+
}
|
|
143
|
+
return profiles
|