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,232 @@
1
+ """Optional encrypted SQLite queue for a persistent server volume.
2
+
3
+ Run a separate worker: python -m core.jobs worker. This is deliberately disabled
4
+ unless DOCUMENT_OCR_JOBS_DIR and DOCUMENT_OCR_JOB_KEY are configured. Do not put
5
+ the database on ephemeral/serverless storage or a network filesystem.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import base64
11
+ from contextlib import contextmanager
12
+ import hashlib
13
+ import hmac
14
+ import json
15
+ import os
16
+ from pathlib import Path
17
+ import signal
18
+ import sqlite3
19
+ import time
20
+ import uuid
21
+
22
+ from cryptography.fernet import Fernet, InvalidToken
23
+
24
+ MAX_JOBS = 1000
25
+ MAX_ATTEMPTS = 3
26
+ LEASE_SECONDS = 3600
27
+
28
+
29
+ class JobStore:
30
+ def __init__(self, directory: str | Path, key: str, *, retention_seconds: int = 86400):
31
+ if not 60 <= retention_seconds <= 604800:
32
+ raise ValueError('INVALID_JOB_RETENTION')
33
+ self.cipher = Fernet(key.encode())
34
+ self.retention = retention_seconds
35
+ directory = Path(directory)
36
+ directory.mkdir(parents=True, exist_ok=True, mode=0o700)
37
+ self.path = directory / 'jobs.sqlite3'
38
+ with self.connection() as db:
39
+ db.execute('PRAGMA journal_mode=WAL')
40
+ db.execute('''CREATE TABLE IF NOT EXISTS jobs (
41
+ id TEXT PRIMARY KEY, status TEXT NOT NULL, created REAL NOT NULL,
42
+ expires REAL NOT NULL, payload BLOB NOT NULL, result BLOB,
43
+ attempts INTEGER NOT NULL DEFAULT 0, lease_until REAL, lease_token TEXT,
44
+ error TEXT, notify INTEGER NOT NULL DEFAULT 0,
45
+ notification_attempts INTEGER NOT NULL DEFAULT 0,
46
+ notify_after REAL NOT NULL DEFAULT 0, notified INTEGER NOT NULL DEFAULT 0
47
+ )''')
48
+ db.execute('CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value BLOB NOT NULL)')
49
+ db.execute('BEGIN IMMEDIATE')
50
+ sentinel = db.execute("SELECT value FROM metadata WHERE key='encryption_key_check'").fetchone()
51
+ try:
52
+ if sentinel is not None:
53
+ if self.cipher.decrypt(sentinel[0]) != b'document-ocr-jobs-v1':
54
+ raise ValueError('JOB_KEY_MISMATCH')
55
+ else:
56
+ # Validate any pre-sentinel queue before migrating it.
57
+ previous = db.execute("SELECT payload,result FROM jobs WHERE length(payload)>0 OR result IS NOT NULL LIMIT 1").fetchone()
58
+ if previous is not None:
59
+ self.cipher.decrypt(previous['payload'] or previous['result'])
60
+ db.execute("INSERT INTO metadata (key,value) VALUES ('encryption_key_check',?)",
61
+ (self.cipher.encrypt(b'document-ocr-jobs-v1'),))
62
+ except InvalidToken as exc:
63
+ raise ValueError('JOB_KEY_MISMATCH') from exc
64
+ os.chmod(self.path, 0o600)
65
+
66
+ @contextmanager
67
+ def connection(self):
68
+ db = sqlite3.connect(self.path, timeout=15)
69
+ db.row_factory = sqlite3.Row
70
+ try:
71
+ with db:
72
+ yield db
73
+ finally:
74
+ db.close()
75
+
76
+ def _encrypt(self, value) -> bytes:
77
+ return self.cipher.encrypt(json.dumps(value, separators=(',', ':')).encode())
78
+
79
+ def _decrypt(self, value):
80
+ return json.loads(self.cipher.decrypt(value))
81
+
82
+ def enqueue(self, images: list[bytes], options: dict, *, grouped: bool = False, notify: bool = False) -> dict:
83
+ if not 1 <= len(images) <= 10 or sum(map(len, images)) > 10 * 1024 * 1024:
84
+ raise ValueError('INVALID_JOB_SIZE')
85
+ if notify and not webhook_configuration():
86
+ raise ValueError('WEBHOOK_NOT_CONFIGURED')
87
+ payload = self._encrypt({'images': [base64.b64encode(i).decode() for i in images],
88
+ 'options': options, 'grouped': grouped})
89
+ now, identifier = time.time(), str(uuid.uuid4())
90
+ with self.connection() as db:
91
+ db.execute('BEGIN IMMEDIATE')
92
+ db.execute('DELETE FROM jobs WHERE expires <= ?', (now,))
93
+ if db.execute('SELECT count(*) FROM jobs').fetchone()[0] >= MAX_JOBS:
94
+ raise ValueError('JOB_QUEUE_FULL')
95
+ db.execute('INSERT INTO jobs (id,status,created,expires,payload,notify) VALUES (?,?,?,?,?,?)',
96
+ (identifier, 'queued', now, now + self.retention, payload, int(notify)))
97
+ return self.get(identifier)
98
+
99
+ def get(self, identifier: str) -> dict | None:
100
+ with self.connection() as db:
101
+ row = db.execute('SELECT * FROM jobs WHERE id=? AND expires>?', (identifier, time.time())).fetchone()
102
+ if row is None:
103
+ return None
104
+ result = {'id': row['id'], 'status': row['status'], 'createdAt': row['created'],
105
+ 'expiresAt': row['expires'], 'attempts': row['attempts'], 'error': row['error']}
106
+ if row['result']:
107
+ result['result'] = self._decrypt(row['result'])
108
+ return result
109
+
110
+ def delete(self, identifier: str) -> bool:
111
+ with self.connection() as db:
112
+ return db.execute('DELETE FROM jobs WHERE id=?', (identifier,)).rowcount > 0
113
+
114
+ def claim(self):
115
+ now = time.time()
116
+ with self.connection() as db:
117
+ db.execute('BEGIN IMMEDIATE')
118
+ db.execute('DELETE FROM jobs WHERE expires<=?', (now,))
119
+ db.execute("UPDATE jobs SET status='failed',error='WORKER_RETRIES_EXHAUSTED',payload=? WHERE status='running' AND lease_until<=? AND attempts>=?", (b'', now, MAX_ATTEMPTS))
120
+ row = db.execute("SELECT * FROM jobs WHERE (status='queued' OR (status='running' AND lease_until<=?)) AND attempts<? ORDER BY created LIMIT 1", (now, MAX_ATTEMPTS)).fetchone()
121
+ if row is None:
122
+ return None
123
+ token = str(uuid.uuid4())
124
+ db.execute("UPDATE jobs SET status='running',attempts=attempts+1,lease_until=?,lease_token=? WHERE id=?", (now+LEASE_SECONDS, token, row['id']))
125
+ return row['id'], token, row['payload']
126
+
127
+ def finish(self, identifier: str, token: str, result=None, *, error: str | None = None):
128
+ encrypted = self._encrypt(result) if result is not None else None
129
+ with self.connection() as db:
130
+ db.execute("UPDATE jobs SET status=?,result=?,payload=?,error=?,lease_until=NULL WHERE id=? AND lease_token=? AND status='running' AND expires>?",
131
+ ('failed' if error else 'succeeded', encrypted, b'', error, identifier, token, time.time()))
132
+
133
+ def run_once(self, processor=None) -> bool:
134
+ claim = self.claim()
135
+ if not claim:
136
+ return False
137
+ identifier, token, encrypted = claim
138
+ try:
139
+ payload = self._decrypt(encrypted)
140
+ if processor is None:
141
+ from .document_bundle import scan_batch, scan_document
142
+ processor = scan_document if payload['grouped'] else scan_batch
143
+ images = [base64.b64decode(i, validate=True) for i in payload['images']]
144
+ result = processor(images, **payload['options'])
145
+ self.finish(identifier, token, result)
146
+ except InvalidToken:
147
+ with self.connection() as db:
148
+ db.execute("UPDATE jobs SET status='failed',error='JOB_PAYLOAD_DECRYPTION_FAILED',lease_until=NULL WHERE id=? AND lease_token=?", (identifier, token))
149
+ except Exception:
150
+ # Stored errors never include input text, paths, secrets or model output.
151
+ self.finish(identifier, token, error='JOB_PROCESSING_FAILED')
152
+ return True
153
+
154
+ def deliver_notification(self, sender=None) -> bool:
155
+ config = webhook_configuration()
156
+ if not config:
157
+ return False
158
+ now = time.time()
159
+ with self.connection() as db:
160
+ db.execute('BEGIN IMMEDIATE')
161
+ row = db.execute("SELECT id,status FROM jobs WHERE notify=1 AND notified=0 AND notification_attempts<6 AND notify_after<=? AND expires>? AND status IN ('succeeded','failed') ORDER BY created LIMIT 1", (now, now)).fetchone()
162
+ if row is None:
163
+ return False
164
+ # Claim notification before network IO. Crash-safe at-least-once;
165
+ # consumers deduplicate the stable id/status event ID.
166
+ db.execute('UPDATE jobs SET notification_attempts=notification_attempts+1,notify_after=? WHERE id=?', (now+300, row['id']))
167
+ url, secret = config
168
+ body = json.dumps({'id': row['id'], 'status': row['status']}, separators=(',', ':')).encode()
169
+ timestamp = str(int(now))
170
+ digest = hmac.new(secret.encode(), timestamp.encode()+b'.'+body, hashlib.sha256).hexdigest()
171
+ try:
172
+ if sender is None:
173
+ import requests
174
+ sender = requests.post
175
+ response = sender(url, data=body, headers={'Content-Type': 'application/json',
176
+ 'X-Document-OCR-Timestamp': timestamp, 'X-Document-OCR-Signature': 'sha256='+digest,
177
+ 'X-Document-OCR-Event': row['id']+':'+row['status']}, timeout=10, allow_redirects=False)
178
+ if 200 <= response.status_code < 300:
179
+ with self.connection() as db:
180
+ db.execute('UPDATE jobs SET notified=1 WHERE id=?', (row['id'],))
181
+ except Exception:
182
+ pass # Persisted retry schedule above survives process restarts.
183
+ return True
184
+
185
+
186
+ def webhook_configuration():
187
+ from urllib.parse import urlsplit
188
+ url, secret = os.getenv('DOCUMENT_OCR_WEBHOOK_URL'), os.getenv('DOCUMENT_OCR_WEBHOOK_SECRET')
189
+ if not url and not secret:
190
+ return None
191
+ if not url or not secret or len(secret) < 32:
192
+ raise ValueError('INVALID_WEBHOOK_CONFIGURATION')
193
+ parsed = urlsplit(url)
194
+ if parsed.scheme != 'https' or not parsed.hostname or parsed.username or parsed.password or parsed.fragment:
195
+ raise ValueError('INVALID_WEBHOOK_URL')
196
+ return url, secret
197
+
198
+
199
+ def configured_store() -> JobStore | None:
200
+ directory, key = os.getenv('DOCUMENT_OCR_JOBS_DIR'), os.getenv('DOCUMENT_OCR_JOB_KEY')
201
+ if not directory:
202
+ return None
203
+ if not key:
204
+ raise ValueError('MISSING_DOCUMENT_OCR_JOB_KEY')
205
+ return JobStore(directory, key, retention_seconds=int(os.getenv('DOCUMENT_OCR_JOB_RETENTION_SECONDS', '86400')))
206
+
207
+
208
+ def main():
209
+ parser = argparse.ArgumentParser(description=__doc__)
210
+ parser.add_argument('command', choices=['worker'])
211
+ parser.parse_args()
212
+ store = configured_store()
213
+ if store is None:
214
+ parser.error('Configure DOCUMENT_OCR_JOBS_DIR and DOCUMENT_OCR_JOB_KEY')
215
+ webhook_configuration()
216
+ from .model_setup import warm_up
217
+ warm_up()
218
+ stopping = False
219
+ def stop(*_args):
220
+ nonlocal stopping
221
+ stopping = True
222
+ signal.signal(signal.SIGTERM, stop)
223
+ signal.signal(signal.SIGINT, stop)
224
+ while not stopping:
225
+ worked = store.run_once()
226
+ store.deliver_notification()
227
+ if not worked:
228
+ time.sleep(1)
229
+
230
+
231
+ if __name__ == '__main__':
232
+ main()
@@ -0,0 +1,132 @@
1
+ """KYC-only OCR language selection and result merging.
2
+
3
+ Passport OCR continues to use the existing ``run_ocr`` behavior. Operators
4
+ with a known non-passport document population can opt into additional
5
+ recognition models with ``DOCUMENT_OCR_KYC_LANGS``:
6
+
7
+ DOCUMENT_OCR_KYC_LANGS=en,devanagari
8
+
9
+ Running several recognizers increases latency and memory, so the default is the
10
+ existing automatic OCR pass. This module does not guess a script from an
11
+ English model's failed output; accuracy by language must be measured with the
12
+ private KYC benchmark.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ from collections.abc import Iterable
19
+
20
+ import numpy as np
21
+
22
+ from .ocr_engine import TextRegion, run_ocr
23
+
24
+ KYC_LANGS_ENV = "DOCUMENT_OCR_KYC_LANGS"
25
+ SUPPORTED_KYC_LANGS = frozenset(
26
+ {
27
+ "en",
28
+ "latin",
29
+ "devanagari",
30
+ "ka",
31
+ "ta",
32
+ "te",
33
+ }
34
+ )
35
+ MAX_CONFIGURED_LANGUAGES = 4
36
+
37
+
38
+ class KycOCRConfigError(ValueError):
39
+ """Raised when KYC recognition-model configuration is invalid."""
40
+
41
+
42
+ def configured_kyc_languages(raw: str | None = None) -> tuple[str, ...]:
43
+ """Parse and validate the optional KYC recognition-model list."""
44
+ configured = os.getenv(KYC_LANGS_ENV) if raw is None else raw
45
+ if not configured or not configured.strip():
46
+ return ()
47
+
48
+ requested = [
49
+ token.strip().lower()
50
+ for token in configured.split(",")
51
+ if token.strip()
52
+ ]
53
+ invalid = sorted(set(requested) - SUPPORTED_KYC_LANGS)
54
+ if invalid:
55
+ raise KycOCRConfigError(
56
+ f"Unsupported KYC OCR languages: {invalid}"
57
+ )
58
+
59
+ languages = list(dict.fromkeys(requested))
60
+ if len(languages) > MAX_CONFIGURED_LANGUAGES:
61
+ raise KycOCRConfigError(
62
+ f"At most {MAX_CONFIGURED_LANGUAGES} KYC OCR languages may be used"
63
+ )
64
+ return tuple(languages)
65
+
66
+
67
+ def run_kyc_ocr(
68
+ image: np.ndarray,
69
+ *,
70
+ languages: Iterable[str] | None = None,
71
+ ) -> list[TextRegion]:
72
+ """Run the default or explicitly configured OCR passes for KYC documents."""
73
+ selected = (
74
+ tuple(
75
+ dict.fromkeys(
76
+ str(language).strip().lower()
77
+ for language in languages
78
+ if str(language).strip()
79
+ )
80
+ )
81
+ if languages is not None
82
+ else configured_kyc_languages()
83
+ )
84
+ if not selected:
85
+ return run_ocr(image)
86
+
87
+ invalid = [
88
+ language
89
+ for language in selected
90
+ if language not in SUPPORTED_KYC_LANGS
91
+ ]
92
+ if invalid:
93
+ raise KycOCRConfigError(
94
+ f"Unsupported KYC OCR languages: {sorted(set(invalid))}"
95
+ )
96
+ if len(selected) > MAX_CONFIGURED_LANGUAGES:
97
+ raise KycOCRConfigError(
98
+ f"At most {MAX_CONFIGURED_LANGUAGES} KYC OCR languages may be used"
99
+ )
100
+
101
+ regions: list[TextRegion] = []
102
+ for language in selected:
103
+ regions.extend(run_ocr(image, lang=language))
104
+ return _deduplicate_regions(regions)
105
+
106
+
107
+ def _deduplicate_regions(regions: list[TextRegion]) -> list[TextRegion]:
108
+ """Deduplicate repeated readings without discarding another script's text.
109
+
110
+ Confidence values produced by different recognition models are not
111
+ calibrated against one another. Keep distinct text alternatives for the
112
+ same box, while retaining only the highest-confidence copy of an identical
113
+ normalized reading.
114
+ """
115
+ deduplicated: dict[
116
+ tuple[tuple[tuple[int, int], ...], str],
117
+ TextRegion,
118
+ ] = {}
119
+ order: list[tuple[tuple[tuple[int, int], ...], str]] = []
120
+
121
+ for region in regions:
122
+ geometry = tuple(tuple(point) for point in region.bbox)
123
+ normalized_text = " ".join(region.text.split()).casefold()
124
+ key = (geometry, normalized_text)
125
+ previous = deduplicated.get(key)
126
+ if previous is None:
127
+ order.append(key)
128
+ deduplicated[key] = region
129
+ elif region.confidence > previous.confidence:
130
+ deduplicated[key] = region
131
+
132
+ return [deduplicated[key] for key in order]