@arkyc/ocr 1.2.11 → 2.1.0
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/dist/checks.d.mts +43 -0
- package/dist/checks.d.mts.map +1 -0
- package/dist/checks.mjs +91 -0
- package/dist/checks.mjs.map +1 -0
- package/dist/drivers/ai.d.mts +48 -15
- package/dist/drivers/ai.d.mts.map +1 -1
- package/dist/drivers/ai.mjs +112 -14
- package/dist/drivers/ai.mjs.map +1 -1
- package/dist/drivers/external.d.mts +1 -3
- package/dist/drivers/external.d.mts.map +1 -1
- package/dist/drivers/mock.d.mts +1 -3
- package/dist/drivers/mock.d.mts.map +1 -1
- package/dist/drivers/mock.mjs +7 -5
- package/dist/drivers/mock.mjs.map +1 -1
- package/dist/drivers/preprocess.d.mts +6 -6
- package/dist/drivers/preprocess.d.mts.map +1 -1
- package/dist/drivers/preprocess.mjs.map +1 -1
- package/dist/drivers/tesseract.d.mts +4 -6
- package/dist/drivers/tesseract.d.mts.map +1 -1
- package/dist/index.d.mts +3 -2
- package/dist/index.mjs +3 -2
- package/dist/parsers/generic.d.mts +1 -3
- package/dist/parsers/generic.d.mts.map +1 -1
- package/dist/parsers/mrz.d.mts +1 -3
- package/dist/parsers/mrz.d.mts.map +1 -1
- package/dist/parsers/registry.d.mts +8 -5
- package/dist/parsers/registry.d.mts.map +1 -1
- package/dist/parsers/registry.mjs +22 -1
- package/dist/parsers/registry.mjs.map +1 -1
- package/dist/parsers/types.d.mts +3 -5
- package/dist/parsers/types.d.mts.map +1 -1
- package/dist/registry.d.mts +1 -3
- package/dist/registry.d.mts.map +1 -1
- package/dist/registry.mjs +2 -0
- package/dist/registry.mjs.map +1 -1
- package/dist/types.d.mts +13 -7
- package/dist/types.d.mts.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { OcrFields } from "@arkyc/types";
|
|
2
|
+
//#region src/checks.d.ts
|
|
3
|
+
/** A deterministic inconsistency found on a document. */
|
|
4
|
+
export type DocumentIssueCode = 'mrz_check_digit' | 'mrz_visual_mismatch' | 'issue_after_expiry' | 'birth_after_issue' | 'birth_in_future' | 'implausible_age';
|
|
5
|
+
export interface DocumentIssue {
|
|
6
|
+
code: DocumentIssueCode;
|
|
7
|
+
/** The fields involved. */
|
|
8
|
+
fields: string[];
|
|
9
|
+
}
|
|
10
|
+
/** The parts of an OCR result the checks read. */
|
|
11
|
+
export interface DocumentCheckInput {
|
|
12
|
+
fields: OcrFields;
|
|
13
|
+
/** The driver's raw payload; MRZ check results and visual-zone fields live here. */
|
|
14
|
+
raw?: unknown;
|
|
15
|
+
now?: Date;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Deterministic document checks: MRZ check digits, MRZ against the visual zone,
|
|
19
|
+
* and dates that cannot happen.
|
|
20
|
+
*/
|
|
21
|
+
export declare class DocumentChecks {
|
|
22
|
+
/**
|
|
23
|
+
* Every issue found on the document. An empty list means nothing was provably wrong.
|
|
24
|
+
*
|
|
25
|
+
* @param input
|
|
26
|
+
* @returns
|
|
27
|
+
*/
|
|
28
|
+
static inspect(input: DocumentCheckInput): DocumentIssue[];
|
|
29
|
+
private static mrz;
|
|
30
|
+
private static dates;
|
|
31
|
+
/** The MRZ parse detail, from a parser result or a driver payload that nests it. */
|
|
32
|
+
private static mrzDetail;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Age in whole years on `on`, from an ISO date of birth.
|
|
36
|
+
*
|
|
37
|
+
* @param dateOfBirth
|
|
38
|
+
* @param on
|
|
39
|
+
* @returns
|
|
40
|
+
*/
|
|
41
|
+
export declare function ageOn(dateOfBirth: string, on?: Date): number | null;
|
|
42
|
+
//#endregion
|
|
43
|
+
//# sourceMappingURL=checks.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"checks.d.mts","names":[],"sources":["../src/checks.ts"],"mappings":";;;YAGY;iBAQK;EACf,MAAM;;EAEN;;;iBAIe;EACf,QAAQ;;EAER;EACA,MAAM;;;;;;qBAeK;;;;;;;SAOJ,QAAQ,OAAO,qBAAqB;iBAI5B;iBAwBA;;iBAsBA;;;;;;;;;wBAqBD,MAAM,qBAAqB,KAAI"}
|
package/dist/checks.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
//#region src/checks.ts
|
|
2
|
+
const MAX_AGE_YEARS = 120;
|
|
3
|
+
/**
|
|
4
|
+
* Deterministic document checks: MRZ check digits, MRZ against the visual zone,
|
|
5
|
+
* and dates that cannot happen.
|
|
6
|
+
*/
|
|
7
|
+
var DocumentChecks = class DocumentChecks {
|
|
8
|
+
/**
|
|
9
|
+
* Every issue found on the document. An empty list means nothing was provably wrong.
|
|
10
|
+
*
|
|
11
|
+
* @param input
|
|
12
|
+
* @returns
|
|
13
|
+
*/
|
|
14
|
+
static inspect(input) {
|
|
15
|
+
return [...DocumentChecks.mrz(input), ...DocumentChecks.dates(input.fields, input.now ?? /* @__PURE__ */ new Date())];
|
|
16
|
+
}
|
|
17
|
+
static mrz(input) {
|
|
18
|
+
const detail = DocumentChecks.mrzDetail(input.raw);
|
|
19
|
+
if (!detail) return [];
|
|
20
|
+
const issues = [];
|
|
21
|
+
const failed = Object.entries(detail.checks ?? {}).filter(([, ok]) => ok === false).map(([field]) => field);
|
|
22
|
+
if (failed.length) issues.push({
|
|
23
|
+
code: "mrz_check_digit",
|
|
24
|
+
fields: failed
|
|
25
|
+
});
|
|
26
|
+
if (detail.visual) {
|
|
27
|
+
const mismatched = [
|
|
28
|
+
"documentNumber",
|
|
29
|
+
"dateOfBirth",
|
|
30
|
+
"expiryDate"
|
|
31
|
+
].filter((field) => {
|
|
32
|
+
const mrz = input.fields[field];
|
|
33
|
+
const visual = detail.visual[field];
|
|
34
|
+
return !!mrz && !!visual && comparable(mrz) !== comparable(visual);
|
|
35
|
+
});
|
|
36
|
+
if (mismatched.length) issues.push({
|
|
37
|
+
code: "mrz_visual_mismatch",
|
|
38
|
+
fields: [...mismatched]
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return issues;
|
|
42
|
+
}
|
|
43
|
+
static dates(fields, now) {
|
|
44
|
+
const issues = [];
|
|
45
|
+
const today = now.toISOString().slice(0, 10);
|
|
46
|
+
const { dateOfBirth, issueDate, expiryDate } = fields;
|
|
47
|
+
if (issueDate && expiryDate && issueDate > expiryDate) issues.push({
|
|
48
|
+
code: "issue_after_expiry",
|
|
49
|
+
fields: ["issueDate", "expiryDate"]
|
|
50
|
+
});
|
|
51
|
+
if (dateOfBirth && issueDate && dateOfBirth > issueDate) issues.push({
|
|
52
|
+
code: "birth_after_issue",
|
|
53
|
+
fields: ["dateOfBirth", "issueDate"]
|
|
54
|
+
});
|
|
55
|
+
if (dateOfBirth && dateOfBirth > today) issues.push({
|
|
56
|
+
code: "birth_in_future",
|
|
57
|
+
fields: ["dateOfBirth"]
|
|
58
|
+
});
|
|
59
|
+
if (dateOfBirth && Number(today.slice(0, 4)) - Number(dateOfBirth.slice(0, 4)) > MAX_AGE_YEARS) issues.push({
|
|
60
|
+
code: "implausible_age",
|
|
61
|
+
fields: ["dateOfBirth"]
|
|
62
|
+
});
|
|
63
|
+
return issues;
|
|
64
|
+
}
|
|
65
|
+
/** The MRZ parse detail, from a parser result or a driver payload that nests it. */
|
|
66
|
+
static mrzDetail(raw) {
|
|
67
|
+
if (!raw || typeof raw !== "object") return null;
|
|
68
|
+
const candidates = [raw, raw.parser];
|
|
69
|
+
for (const candidate of candidates) if (candidate && typeof candidate === "object" && candidate.format === "mrz") return candidate;
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Age in whole years on `on`, from an ISO date of birth.
|
|
75
|
+
*
|
|
76
|
+
* @param dateOfBirth
|
|
77
|
+
* @param on
|
|
78
|
+
* @returns
|
|
79
|
+
*/
|
|
80
|
+
function ageOn(dateOfBirth, on = /* @__PURE__ */ new Date()) {
|
|
81
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(dateOfBirth);
|
|
82
|
+
if (!match) return null;
|
|
83
|
+
const today = on.toISOString().slice(0, 10);
|
|
84
|
+
const years = Number(today.slice(0, 4)) - Number(match[1]);
|
|
85
|
+
return today.slice(5) >= `${match[2]}-${match[3]}` ? years : years - 1;
|
|
86
|
+
}
|
|
87
|
+
const comparable = (value) => value.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
|
88
|
+
//#endregion
|
|
89
|
+
export { DocumentChecks, ageOn };
|
|
90
|
+
|
|
91
|
+
//# sourceMappingURL=checks.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"checks.mjs","names":[],"sources":["../src/checks.ts"],"sourcesContent":["import type { OcrFields } from '@arkyc/types'\n\n/** A deterministic inconsistency found on a document. */\nexport type DocumentIssueCode =\n | 'mrz_check_digit'\n | 'mrz_visual_mismatch'\n | 'issue_after_expiry'\n | 'birth_after_issue'\n | 'birth_in_future'\n | 'implausible_age'\n\nexport interface DocumentIssue {\n code: DocumentIssueCode\n /** The fields involved. */\n fields: string[]\n}\n\n/** The parts of an OCR result the checks read. */\nexport interface DocumentCheckInput {\n fields: OcrFields\n /** The driver's raw payload; MRZ check results and visual-zone fields live here. */\n raw?: unknown\n now?: Date\n}\n\ninterface MrzDetail {\n format?: string\n checks?: Record<string, boolean>\n visual?: OcrFields\n}\n\nconst MAX_AGE_YEARS = 120\n\n/**\n * Deterministic document checks: MRZ check digits, MRZ against the visual zone,\n * and dates that cannot happen.\n */\nexport class DocumentChecks {\n /**\n * Every issue found on the document. An empty list means nothing was provably wrong.\n *\n * @param input\n * @returns\n */\n static inspect(input: DocumentCheckInput): DocumentIssue[] {\n return [...DocumentChecks.mrz(input), ...DocumentChecks.dates(input.fields, input.now ?? new Date())]\n }\n\n private static mrz(input: DocumentCheckInput): DocumentIssue[] {\n const detail = DocumentChecks.mrzDetail(input.raw)\n if (!detail) return []\n\n const issues: DocumentIssue[] = []\n\n const failed = Object.entries(detail.checks ?? {})\n .filter(([, ok]) => ok === false)\n .map(([field]) => field)\n if (failed.length) issues.push({ code: 'mrz_check_digit', fields: failed })\n\n if (detail.visual) {\n const mismatched = (['documentNumber', 'dateOfBirth', 'expiryDate'] as const).filter((field) => {\n const mrz = input.fields[field]\n const visual = detail.visual![field]\n\n return !!mrz && !!visual && comparable(mrz) !== comparable(visual)\n })\n if (mismatched.length) issues.push({ code: 'mrz_visual_mismatch', fields: [...mismatched] })\n }\n\n return issues\n }\n\n private static dates(fields: OcrFields, now: Date): DocumentIssue[] {\n const issues: DocumentIssue[] = []\n const today = now.toISOString().slice(0, 10)\n const { dateOfBirth, issueDate, expiryDate } = fields\n\n if (issueDate && expiryDate && issueDate > expiryDate) {\n issues.push({ code: 'issue_after_expiry', fields: ['issueDate', 'expiryDate'] })\n }\n if (dateOfBirth && issueDate && dateOfBirth > issueDate) {\n issues.push({ code: 'birth_after_issue', fields: ['dateOfBirth', 'issueDate'] })\n }\n if (dateOfBirth && dateOfBirth > today) {\n issues.push({ code: 'birth_in_future', fields: ['dateOfBirth'] })\n }\n if (dateOfBirth && Number(today.slice(0, 4)) - Number(dateOfBirth.slice(0, 4)) > MAX_AGE_YEARS) {\n issues.push({ code: 'implausible_age', fields: ['dateOfBirth'] })\n }\n\n return issues\n }\n\n /** The MRZ parse detail, from a parser result or a driver payload that nests it. */\n private static mrzDetail(raw: unknown): MrzDetail | null {\n if (!raw || typeof raw !== 'object') return null\n\n const candidates = [raw, (raw as { parser?: unknown }).parser]\n for (const candidate of candidates) {\n if (candidate && typeof candidate === 'object' && (candidate as MrzDetail).format === 'mrz') {\n return candidate as MrzDetail\n }\n }\n\n return null\n }\n}\n\n/**\n * Age in whole years on `on`, from an ISO date of birth.\n *\n * @param dateOfBirth\n * @param on\n * @returns\n */\nexport function ageOn(dateOfBirth: string, on: Date = new Date()): number | null {\n const match = /^(\\d{4})-(\\d{2})-(\\d{2})/.exec(dateOfBirth)\n if (!match) return null\n\n const today = on.toISOString().slice(0, 10)\n const years = Number(today.slice(0, 4)) - Number(match[1])\n const hadBirthday = today.slice(5) >= `${match[2]}-${match[3]}`\n\n return hadBirthday ? years : years - 1\n}\n\nconst comparable = (value: string): string => value.toUpperCase().replace(/[^A-Z0-9]/g, '')\n"],"mappings":";AA+BA,MAAM,gBAAgB;;;;;AAMtB,IAAa,iBAAb,MAAa,eAAe;;;;;;;CAO1B,OAAO,QAAQ,OAA4C;EACzD,OAAO,CAAC,GAAG,eAAe,IAAI,KAAK,GAAG,GAAG,eAAe,MAAM,MAAM,QAAQ,MAAM,uBAAO,IAAI,KAAK,CAAC,CAAC;CACtG;CAEA,OAAe,IAAI,OAA4C;EAC7D,MAAM,SAAS,eAAe,UAAU,MAAM,GAAG;EACjD,IAAI,CAAC,QAAQ,OAAO,CAAC;EAErB,MAAM,SAA0B,CAAC;EAEjC,MAAM,SAAS,OAAO,QAAQ,OAAO,UAAU,CAAC,CAAC,CAAC,CAC/C,QAAQ,GAAG,QAAQ,OAAO,KAAK,CAAC,CAChC,KAAK,CAAC,WAAW,KAAK;EACzB,IAAI,OAAO,QAAQ,OAAO,KAAK;GAAE,MAAM;GAAmB,QAAQ;EAAO,CAAC;EAE1E,IAAI,OAAO,QAAQ;GACjB,MAAM,aAAc;IAAC;IAAkB;IAAe;GAAY,CAAC,CAAW,QAAQ,UAAU;IAC9F,MAAM,MAAM,MAAM,OAAO;IACzB,MAAM,SAAS,OAAO,OAAQ;IAE9B,OAAO,CAAC,CAAC,OAAO,CAAC,CAAC,UAAU,WAAW,GAAG,MAAM,WAAW,MAAM;GACnE,CAAC;GACD,IAAI,WAAW,QAAQ,OAAO,KAAK;IAAE,MAAM;IAAuB,QAAQ,CAAC,GAAG,UAAU;GAAE,CAAC;EAC7F;EAEA,OAAO;CACT;CAEA,OAAe,MAAM,QAAmB,KAA4B;EAClE,MAAM,SAA0B,CAAC;EACjC,MAAM,QAAQ,IAAI,YAAY,CAAC,CAAC,MAAM,GAAG,EAAE;EAC3C,MAAM,EAAE,aAAa,WAAW,eAAe;EAE/C,IAAI,aAAa,cAAc,YAAY,YACzC,OAAO,KAAK;GAAE,MAAM;GAAsB,QAAQ,CAAC,aAAa,YAAY;EAAE,CAAC;EAEjF,IAAI,eAAe,aAAa,cAAc,WAC5C,OAAO,KAAK;GAAE,MAAM;GAAqB,QAAQ,CAAC,eAAe,WAAW;EAAE,CAAC;EAEjF,IAAI,eAAe,cAAc,OAC/B,OAAO,KAAK;GAAE,MAAM;GAAmB,QAAQ,CAAC,aAAa;EAAE,CAAC;EAElE,IAAI,eAAe,OAAO,MAAM,MAAM,GAAG,CAAC,CAAC,IAAI,OAAO,YAAY,MAAM,GAAG,CAAC,CAAC,IAAI,eAC/E,OAAO,KAAK;GAAE,MAAM;GAAmB,QAAQ,CAAC,aAAa;EAAE,CAAC;EAGlE,OAAO;CACT;;CAGA,OAAe,UAAU,KAAgC;EACvD,IAAI,CAAC,OAAO,OAAO,QAAQ,UAAU,OAAO;EAE5C,MAAM,aAAa,CAAC,KAAM,IAA6B,MAAM;EAC7D,KAAK,MAAM,aAAa,YACtB,IAAI,aAAa,OAAO,cAAc,YAAa,UAAwB,WAAW,OACpF,OAAO;EAIX,OAAO;CACT;AACF;;;;;;;;AASA,SAAgB,MAAM,aAAqB,qBAAW,IAAI,KAAK,GAAkB;CAC/E,MAAM,QAAQ,2BAA2B,KAAK,WAAW;CACzD,IAAI,CAAC,OAAO,OAAO;CAEnB,MAAM,QAAQ,GAAG,YAAY,CAAC,CAAC,MAAM,GAAG,EAAE;CAC1C,MAAM,QAAQ,OAAO,MAAM,MAAM,GAAG,CAAC,CAAC,IAAI,OAAO,MAAM,EAAE;CAGzD,OAFoB,MAAM,MAAM,CAAC,KAAK,GAAG,MAAM,GAAG,GAAG,MAAM,OAEtC,QAAQ,QAAQ;AACvC;AAEA,MAAM,cAAc,UAA0B,MAAM,YAAY,CAAC,CAAC,QAAQ,cAAc,EAAE"}
|
package/dist/drivers/ai.d.mts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { OcrDriver, OcrRequest } from "../types.mjs";
|
|
2
2
|
import { DocumentType, OcrAuthenticity, OcrFields, OcrResultData } from "@arkyc/types";
|
|
3
|
-
|
|
4
3
|
//#region src/drivers/ai.d.ts
|
|
5
4
|
/**
|
|
6
5
|
* A small, cheap vision model is plenty for reading printed/MRZ document text.
|
|
7
6
|
*/
|
|
8
|
-
declare const DEFAULT_AI_MODEL = "claude-haiku-4-5-20251001";
|
|
7
|
+
export declare const DEFAULT_AI_MODEL = "claude-haiku-4-5-20251001";
|
|
8
|
+
/**
|
|
9
|
+
* Default Cloudflare Workers AI model: Gemma 4 accepts image input and tool calls.
|
|
10
|
+
*/
|
|
11
|
+
export declare const DEFAULT_WORKERS_AI_MODEL = "@cf/google/gemma-4-26b-a4b-it";
|
|
12
|
+
/**
|
|
13
|
+
* Which hosted vision API backs the `ai` driver.
|
|
14
|
+
*/
|
|
15
|
+
export type AiOcrProvider = 'anthropic' | 'workers-ai';
|
|
9
16
|
/**
|
|
10
17
|
* A base64 image ready for the messages API, with its detected media type.
|
|
11
18
|
*/
|
|
@@ -16,7 +23,7 @@ interface PreparedImage {
|
|
|
16
23
|
/**
|
|
17
24
|
* Raw fields the vision model returned, before validation/scoring.
|
|
18
25
|
*/
|
|
19
|
-
interface AiExtraction {
|
|
26
|
+
export interface AiExtraction {
|
|
20
27
|
fields: OcrFields;
|
|
21
28
|
/** The model's own legibility self-assessment in [0, 1], if it gave one. */
|
|
22
29
|
legibility?: number;
|
|
@@ -29,12 +36,21 @@ interface AiExtraction {
|
|
|
29
36
|
* driver's scoring can be unit-tested without a network call; the default
|
|
30
37
|
* implementation talks to the Anthropic messages API.
|
|
31
38
|
*/
|
|
32
|
-
type AiVisionExtract = (request: OcrRequest, images: PreparedImage[]) => Promise<AiExtraction>;
|
|
33
|
-
interface AnthropicOcrOptions {
|
|
39
|
+
export type AiVisionExtract = (request: OcrRequest, images: PreparedImage[]) => Promise<AiExtraction>;
|
|
40
|
+
export interface AnthropicOcrOptions {
|
|
41
|
+
/** Vision backend; defaults to `anthropic`. */
|
|
42
|
+
provider?: AiOcrProvider;
|
|
43
|
+
/** Anthropic API key, or the Cloudflare API token for `workers-ai`. */
|
|
34
44
|
apiKey?: string;
|
|
35
|
-
/**
|
|
45
|
+
/** Cloudflare account id; required for `workers-ai` unless `baseUrl` is set. */
|
|
46
|
+
accountId?: string;
|
|
47
|
+
/** Model id; defaults to {@link DEFAULT_AI_MODEL} or {@link DEFAULT_WORKERS_AI_MODEL}. */
|
|
36
48
|
model?: string;
|
|
37
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* API base URL. Anthropic: defaults to {@link DEFAULT_BASE_URL}. Workers AI: the
|
|
51
|
+
* OpenAI-compatible base ending in `/v1` (e.g. an AI Gateway URL), defaulting to
|
|
52
|
+
* the account's `/ai/v1` endpoint.
|
|
53
|
+
*/
|
|
38
54
|
baseUrl?: string;
|
|
39
55
|
/** Longest image edge (px) to upload; defaults to {@link DEFAULT_MAX_EDGE}. */
|
|
40
56
|
maxEdge?: number;
|
|
@@ -46,8 +62,8 @@ interface AnthropicOcrOptions {
|
|
|
46
62
|
extract?: AiVisionExtract;
|
|
47
63
|
}
|
|
48
64
|
/**
|
|
49
|
-
* AI OCR driver: hands the document image(s) to a vision LLM (Claude by default
|
|
50
|
-
* and maps the structured response onto {@link OcrResultData}.
|
|
65
|
+
* AI OCR driver: hands the document image(s) to a vision LLM (Claude by default,
|
|
66
|
+
* or a Cloudflare Workers AI model with `provider: 'workers-ai'`) and maps the structured response onto {@link OcrResultData}.
|
|
51
67
|
*
|
|
52
68
|
* Confidence is **not** taken from the model's self-report — LLMs are poorly
|
|
53
69
|
* calibrated and will state high confidence on hallucinated fields. Instead it's
|
|
@@ -62,10 +78,10 @@ interface AnthropicOcrOptions {
|
|
|
62
78
|
* {@link applyAuthenticity}) so a suspicious document routes to manual review —
|
|
63
79
|
* an LLM's authenticity guess never auto-rejects a user on its own.
|
|
64
80
|
*/
|
|
65
|
-
declare class AnthropicOcrDriver implements OcrDriver {
|
|
81
|
+
export declare class AnthropicOcrDriver implements OcrDriver {
|
|
66
82
|
readonly name = "ai";
|
|
83
|
+
private readonly provider;
|
|
67
84
|
private readonly model;
|
|
68
|
-
private readonly baseUrl;
|
|
69
85
|
private readonly maxEdge;
|
|
70
86
|
private readonly vision;
|
|
71
87
|
constructor(options?: AnthropicOcrOptions);
|
|
@@ -88,7 +104,7 @@ declare class AnthropicOcrDriver implements OcrDriver {
|
|
|
88
104
|
* @param legibility
|
|
89
105
|
* @returns
|
|
90
106
|
*/
|
|
91
|
-
declare function scoreConfidence(fields: OcrFields, legibility?: number): number;
|
|
107
|
+
export declare function scoreConfidence(fields: OcrFields, legibility?: number): number;
|
|
92
108
|
/**
|
|
93
109
|
* Fold the authenticity read into the field-derived confidence. Only ever lowers
|
|
94
110
|
* the score, and only when a concrete flag fired — a genuine (or unassessed)
|
|
@@ -101,20 +117,37 @@ declare function scoreConfidence(fields: OcrFields, legibility?: number): number
|
|
|
101
117
|
* @param authenticity
|
|
102
118
|
* @returns
|
|
103
119
|
*/
|
|
104
|
-
declare function applyAuthenticity(fieldScore: number, authenticity?: OcrAuthenticity): number;
|
|
120
|
+
export declare function applyAuthenticity(fieldScore: number, authenticity?: OcrAuthenticity): number;
|
|
105
121
|
/**
|
|
106
122
|
* Build the default vision call against the Anthropic messages API.
|
|
107
123
|
*
|
|
108
124
|
* @param input
|
|
109
125
|
* @returns
|
|
110
126
|
*/
|
|
111
|
-
declare function anthropicVision(opts: {
|
|
127
|
+
export declare function anthropicVision(opts: {
|
|
128
|
+
apiKey: string;
|
|
129
|
+
model: string;
|
|
130
|
+
baseUrl: string;
|
|
131
|
+
timeoutMs?: number;
|
|
132
|
+
maxRetries?: number;
|
|
133
|
+
}): AiVisionExtract;
|
|
134
|
+
/**
|
|
135
|
+
* Build the vision call against Cloudflare Workers AI's OpenAI-compatible chat
|
|
136
|
+
* completions endpoint. Images go in as base64 data URLs and the model is forced
|
|
137
|
+
* to call `read_document`; when a model answers in plain text instead, a JSON
|
|
138
|
+
* object in the reply is accepted as the tool input.
|
|
139
|
+
*
|
|
140
|
+
* @param opts
|
|
141
|
+
* @returns
|
|
142
|
+
*/
|
|
143
|
+
export declare function workersAiVision(opts: {
|
|
112
144
|
apiKey: string;
|
|
113
145
|
model: string;
|
|
146
|
+
/** OpenAI-compatible base ending in `/v1`. */
|
|
114
147
|
baseUrl: string;
|
|
115
148
|
timeoutMs?: number;
|
|
116
149
|
maxRetries?: number;
|
|
117
150
|
}): AiVisionExtract;
|
|
118
151
|
//#endregion
|
|
119
|
-
export {
|
|
152
|
+
export {};
|
|
120
153
|
//# sourceMappingURL=ai.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ai.d.mts","names":[],"sources":["../../src/drivers/ai.ts"],"mappings":";;;;;;
|
|
1
|
+
{"version":3,"file":"ai.d.mts","names":[],"sources":["../../src/drivers/ai.ts"],"mappings":";;;;;;qBAaa;;;;qBAKA;;;;YAKD;;;;UAsBF;EACR;EACA;;;;;iBAMe;EACf,QAAQ;;EAER;EACA,eAAe;;EAEf,eAAe;;;;;;;YAQL,mBAAmB,SAAS,YAAY,QAAQ,oBAAoB,QAAQ;iBAEvE;;EAEf,WAAW;;EAEX;;EAEA;;EAEA;;;;;;EAMA;;EAEA;;EAEA;;EAEA;;EAEA,UAAU;;;;;;;;;;;;;;;;;;;qBAoBC,8BAA8B;WAChC;mBAEQ;mBACA;mBACA;mBACA;EAEL,YAAA,UAAS;EAoCf,QAAQ,SAAS,aAAa,QAAQ;;;;;;;UA4B9B;;;;;;;;;;;;wBAyCA,gBAAgB,QAAQ,WAAW;;;;;;;;;;;;;wBAiCnC,kBAAkB,oBAAoB,eAAe;;;;;;;wBAkKrD,gBAAgB;EAC9B;EACA;EACA;EACA;EACA;IACE;;;;;;;;;;wBA4FY,gBAAgB;EAC9B;EACA;;EAEA;EACA;EACA;IACE"}
|
package/dist/drivers/ai.mjs
CHANGED
|
@@ -9,6 +9,10 @@ const DEFAULT_BASE_URL = "https://api.anthropic.com";
|
|
|
9
9
|
*/
|
|
10
10
|
const DEFAULT_AI_MODEL = "claude-haiku-4-5-20251001";
|
|
11
11
|
/**
|
|
12
|
+
* Default Cloudflare Workers AI model: Gemma 4 accepts image input and tool calls.
|
|
13
|
+
*/
|
|
14
|
+
const DEFAULT_WORKERS_AI_MODEL = "@cf/google/gemma-4-26b-a4b-it";
|
|
15
|
+
/**
|
|
12
16
|
* Longest edge (px) we upload. The API downsamples anything larger anyway and
|
|
13
17
|
* bills by the resampled size, so this is a cost/bandwidth bound, not a quality
|
|
14
18
|
* one — small enough to be cheap, large enough to keep the MRZ legible.
|
|
@@ -23,8 +27,8 @@ const DEFAULT_TIMEOUT_MS = 3e4;
|
|
|
23
27
|
*/
|
|
24
28
|
const DEFAULT_MAX_RETRIES = 2;
|
|
25
29
|
/**
|
|
26
|
-
* AI OCR driver: hands the document image(s) to a vision LLM (Claude by default
|
|
27
|
-
* and maps the structured response onto {@link OcrResultData}.
|
|
30
|
+
* AI OCR driver: hands the document image(s) to a vision LLM (Claude by default,
|
|
31
|
+
* or a Cloudflare Workers AI model with `provider: 'workers-ai'`) and maps the structured response onto {@link OcrResultData}.
|
|
28
32
|
*
|
|
29
33
|
* Confidence is **not** taken from the model's self-report — LLMs are poorly
|
|
30
34
|
* calibrated and will state high confidence on hallucinated fields. Instead it's
|
|
@@ -41,21 +45,33 @@ const DEFAULT_MAX_RETRIES = 2;
|
|
|
41
45
|
*/
|
|
42
46
|
var AnthropicOcrDriver = class {
|
|
43
47
|
name = "ai";
|
|
48
|
+
provider;
|
|
44
49
|
model;
|
|
45
|
-
baseUrl;
|
|
46
50
|
maxEdge;
|
|
47
51
|
vision;
|
|
48
52
|
constructor(options = {}) {
|
|
53
|
+
this.provider = options.provider ?? "anthropic";
|
|
49
54
|
if (!options.apiKey && !options.extract) throw new Error("AnthropicOcrDriver requires config.apiKey");
|
|
50
|
-
this.model = options.model ?? "claude-haiku-4-5-20251001";
|
|
51
|
-
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
55
|
+
this.model = options.model ?? (this.provider === "workers-ai" ? "@cf/google/gemma-4-26b-a4b-it" : "claude-haiku-4-5-20251001");
|
|
52
56
|
this.maxEdge = options.maxEdge ?? DEFAULT_MAX_EDGE;
|
|
53
|
-
|
|
57
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
58
|
+
const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
59
|
+
if (options.extract) this.vision = options.extract;
|
|
60
|
+
else if (this.provider === "workers-ai") {
|
|
61
|
+
if (!options.accountId && !options.baseUrl) throw new Error("AnthropicOcrDriver (workers-ai) requires config.accountId");
|
|
62
|
+
this.vision = workersAiVision({
|
|
63
|
+
apiKey: options.apiKey,
|
|
64
|
+
model: this.model,
|
|
65
|
+
baseUrl: (options.baseUrl ?? `https://api.cloudflare.com/client/v4/accounts/${options.accountId}/ai/v1`).replace(/\/$/, ""),
|
|
66
|
+
timeoutMs,
|
|
67
|
+
maxRetries
|
|
68
|
+
});
|
|
69
|
+
} else this.vision = anthropicVision({
|
|
54
70
|
apiKey: options.apiKey,
|
|
55
71
|
model: this.model,
|
|
56
|
-
baseUrl:
|
|
57
|
-
timeoutMs
|
|
58
|
-
maxRetries
|
|
72
|
+
baseUrl: (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, ""),
|
|
73
|
+
timeoutMs,
|
|
74
|
+
maxRetries
|
|
59
75
|
});
|
|
60
76
|
}
|
|
61
77
|
async extract(request) {
|
|
@@ -69,6 +85,7 @@ var AnthropicOcrDriver = class {
|
|
|
69
85
|
...authenticity ? { authenticity } : {},
|
|
70
86
|
raw: {
|
|
71
87
|
provider: "ai",
|
|
88
|
+
backend: this.provider,
|
|
72
89
|
model: this.model,
|
|
73
90
|
legibility: raw.legibility ?? null,
|
|
74
91
|
documentType: raw.documentType ?? request.documentType ?? null,
|
|
@@ -106,6 +123,7 @@ function normalizeFields(fields) {
|
|
|
106
123
|
set("dateOfBirth", fields.dateOfBirth);
|
|
107
124
|
set("documentNumber", fields.documentNumber);
|
|
108
125
|
set("expiryDate", fields.expiryDate);
|
|
126
|
+
set("issueDate", fields.issueDate);
|
|
109
127
|
set("nationality", fields.nationality);
|
|
110
128
|
return out;
|
|
111
129
|
}
|
|
@@ -120,13 +138,15 @@ function normalizeFields(fields) {
|
|
|
120
138
|
* @returns
|
|
121
139
|
*/
|
|
122
140
|
function scoreConfidence(fields, legibility) {
|
|
123
|
-
|
|
141
|
+
const base = [
|
|
124
142
|
[present(fields.firstName) && present(fields.lastName) || present(fields.fullName), .28],
|
|
125
143
|
[isIsoDate(fields.dateOfBirth), .22],
|
|
126
144
|
[present(fields.documentNumber) && fields.documentNumber.trim().length >= 4, .22],
|
|
127
145
|
[isIsoDate(fields.expiryDate), .16],
|
|
128
146
|
[present(fields.nationality), .12]
|
|
129
|
-
].reduce((sum, [ok, weight]) => ok ? sum + weight : sum, 0)
|
|
147
|
+
].reduce((sum, [ok, weight]) => ok ? sum + weight : sum, 0);
|
|
148
|
+
const legible = clamp01(legibility ?? 1);
|
|
149
|
+
return clamp01(base * (.85 + .15 * legible));
|
|
130
150
|
}
|
|
131
151
|
/**
|
|
132
152
|
* Hard ceiling on OCR confidence once any authenticity flag fires. Below the
|
|
@@ -237,9 +257,13 @@ const DOCUMENT_TYPES = [
|
|
|
237
257
|
"residence_permit"
|
|
238
258
|
];
|
|
239
259
|
/**
|
|
240
|
-
* HTTP statuses worth a bounded retry: rate-limited / overloaded.
|
|
260
|
+
* HTTP statuses worth a bounded retry: rate-limited / overloaded / unavailable.
|
|
241
261
|
*/
|
|
242
|
-
const RETRYABLE_STATUS = /* @__PURE__ */ new Set([
|
|
262
|
+
const RETRYABLE_STATUS = /* @__PURE__ */ new Set([
|
|
263
|
+
429,
|
|
264
|
+
503,
|
|
265
|
+
529
|
|
266
|
+
]);
|
|
243
267
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
244
268
|
/**
|
|
245
269
|
* Exponential backoff (ms) for attempt 0, 1, 2…, capped at 8s.
|
|
@@ -335,6 +359,80 @@ function anthropicVision(opts) {
|
|
|
335
359
|
};
|
|
336
360
|
}
|
|
337
361
|
/**
|
|
362
|
+
* Parse a JSON object out of model text, tolerating a ```json fence. Null when
|
|
363
|
+
* the text holds no object.
|
|
364
|
+
*
|
|
365
|
+
* @param text
|
|
366
|
+
* @returns
|
|
367
|
+
*/
|
|
368
|
+
function parseJsonObject(text) {
|
|
369
|
+
if (!text) return null;
|
|
370
|
+
const start = text.indexOf("{");
|
|
371
|
+
const end = text.lastIndexOf("}");
|
|
372
|
+
if (start < 0 || end <= start) return null;
|
|
373
|
+
try {
|
|
374
|
+
const parsed = JSON.parse(text.slice(start, end + 1));
|
|
375
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
376
|
+
} catch {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Build the vision call against Cloudflare Workers AI's OpenAI-compatible chat
|
|
382
|
+
* completions endpoint. Images go in as base64 data URLs and the model is forced
|
|
383
|
+
* to call `read_document`; when a model answers in plain text instead, a JSON
|
|
384
|
+
* object in the reply is accepted as the tool input.
|
|
385
|
+
*
|
|
386
|
+
* @param opts
|
|
387
|
+
* @returns
|
|
388
|
+
*/
|
|
389
|
+
function workersAiVision(opts) {
|
|
390
|
+
return async (request, images) => {
|
|
391
|
+
const hint = request.documentType ? ` The document is a ${request.documentType}.` : "";
|
|
392
|
+
const country = request.country ? ` Issuing country hint: ${request.country}.` : "";
|
|
393
|
+
const res = await postWithRetry(`${opts.baseUrl}/chat/completions`, {
|
|
394
|
+
method: "POST",
|
|
395
|
+
headers: {
|
|
396
|
+
authorization: `Bearer ${opts.apiKey}`,
|
|
397
|
+
"content-type": "application/json"
|
|
398
|
+
},
|
|
399
|
+
body: JSON.stringify({
|
|
400
|
+
model: opts.model,
|
|
401
|
+
max_completion_tokens: 2048,
|
|
402
|
+
temperature: 0,
|
|
403
|
+
tools: [{
|
|
404
|
+
type: "function",
|
|
405
|
+
function: {
|
|
406
|
+
name: READ_TOOL.name,
|
|
407
|
+
description: READ_TOOL.description,
|
|
408
|
+
parameters: READ_TOOL.input_schema
|
|
409
|
+
}
|
|
410
|
+
}],
|
|
411
|
+
tool_choice: "required",
|
|
412
|
+
messages: [{
|
|
413
|
+
role: "system",
|
|
414
|
+
content: SYSTEM_PROMPT
|
|
415
|
+
}, {
|
|
416
|
+
role: "user",
|
|
417
|
+
content: [...images.map((img) => ({
|
|
418
|
+
type: "image_url",
|
|
419
|
+
image_url: { url: `data:${img.mediaType};base64,${img.data}` }
|
|
420
|
+
})), {
|
|
421
|
+
type: "text",
|
|
422
|
+
text: `Read this identity document and return its fields.${hint}${country}`
|
|
423
|
+
}]
|
|
424
|
+
}]
|
|
425
|
+
})
|
|
426
|
+
}, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, opts.maxRetries ?? DEFAULT_MAX_RETRIES);
|
|
427
|
+
if (!res.ok) throw new Error(`AnthropicOcrDriver (workers-ai) request failed with status ${res.status}`);
|
|
428
|
+
const message = (await res.json()).choices?.[0]?.message;
|
|
429
|
+
const args = (message?.tool_calls?.find((c) => c.function?.name === READ_TOOL.name) ?? message?.tool_calls?.[0])?.function?.arguments;
|
|
430
|
+
const input = typeof args === "string" ? parseJsonObject(args) : args ?? parseJsonObject(message?.content);
|
|
431
|
+
if (!input) throw new Error("AnthropicOcrDriver (workers-ai): model returned no structured fields");
|
|
432
|
+
return mapToolInput(input);
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
338
436
|
* Coerce the model's tool input into an {@link AiExtraction} (string-typed fields).
|
|
339
437
|
*
|
|
340
438
|
* @param input
|
|
@@ -449,6 +547,6 @@ async function prepareImage(bytes, maxEdge) {
|
|
|
449
547
|
};
|
|
450
548
|
}
|
|
451
549
|
//#endregion
|
|
452
|
-
export { AnthropicOcrDriver, DEFAULT_AI_MODEL, anthropicVision, applyAuthenticity, scoreConfidence };
|
|
550
|
+
export { AnthropicOcrDriver, DEFAULT_AI_MODEL, DEFAULT_WORKERS_AI_MODEL, anthropicVision, applyAuthenticity, scoreConfidence, workersAiVision };
|
|
453
551
|
|
|
454
552
|
//# sourceMappingURL=ai.mjs.map
|
package/dist/drivers/ai.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ai.mjs","names":[],"sources":["../../src/drivers/ai.ts"],"sourcesContent":["import type { DocumentType, OcrAuthenticity, OcrFields, OcrResultData } from '@arkyc/types'\nimport type { OcrDriver, OcrRequest } from '../types'\n\nconst clamp01 = (n: number): number => Math.min(1, Math.max(0, n))\n\n/**\n * Anthropic's default API base; override for a gateway/proxy.\n */\nconst DEFAULT_BASE_URL = 'https://api.anthropic.com'\n\n/**\n * A small, cheap vision model is plenty for reading printed/MRZ document text.\n */\nexport const DEFAULT_AI_MODEL = 'claude-haiku-4-5-20251001'\n\n/**\n * Longest edge (px) we upload. The API downsamples anything larger anyway and\n * bills by the resampled size, so this is a cost/bandwidth bound, not a quality\n * one — small enough to be cheap, large enough to keep the MRZ legible.\n */\nconst DEFAULT_MAX_EDGE = 1568\n\n/**\n * Per-attempt request timeout (ms) — a hung connection must not stall a worker.\n */\nconst DEFAULT_TIMEOUT_MS = 30_000\n\n/**\n * Bounded in-driver retries on rate-limit/overload; the job queue retries beyond.\n */\nconst DEFAULT_MAX_RETRIES = 2\n\n/**\n * A base64 image ready for the messages API, with its detected media type.\n */\ninterface PreparedImage {\n mediaType: 'image/jpeg' | 'image/png' | 'image/webp'\n data: string\n}\n\n/**\n * Raw fields the vision model returned, before validation/scoring.\n */\nexport interface AiExtraction {\n fields: OcrFields\n /** The model's own legibility self-assessment in [0, 1], if it gave one. */\n legibility?: number\n documentType?: DocumentType | null\n /** Best-effort tamper/replay read; omitted when the model reported nothing. */\n authenticity?: OcrAuthenticity\n}\n\n/**\n * Calls a vision model and returns the raw extracted fields. Injected so the\n * driver's scoring can be unit-tested without a network call; the default\n * implementation talks to the Anthropic messages API.\n */\nexport type AiVisionExtract = (request: OcrRequest, images: PreparedImage[]) => Promise<AiExtraction>\n\nexport interface AnthropicOcrOptions {\n apiKey?: string\n /** Model id; defaults to {@link DEFAULT_AI_MODEL}. */\n model?: string\n /** API base URL; defaults to {@link DEFAULT_BASE_URL}. */\n baseUrl?: string\n /** Longest image edge (px) to upload; defaults to {@link DEFAULT_MAX_EDGE}. */\n maxEdge?: number\n /** Per-attempt request timeout (ms); defaults to {@link DEFAULT_TIMEOUT_MS}. */\n timeoutMs?: number\n /** Bounded retries on 429/529; defaults to {@link DEFAULT_MAX_RETRIES}. */\n maxRetries?: number\n /** Override the vision call (tests). When set, `apiKey` is not required. */\n extract?: AiVisionExtract\n}\n\n/**\n * AI OCR driver: hands the document image(s) to a vision LLM (Claude by default)\n * and maps the structured response onto {@link OcrResultData}.\n *\n * Confidence is **not** taken from the model's self-report — LLMs are poorly\n * calibrated and will state high confidence on hallucinated fields. Instead it's\n * derived deterministically from which fields came back and whether they're\n * structurally valid (see {@link scoreConfidence}), so the value feeding the\n * decision engine's `ocrConfidenceThreshold` is meaningful. The model's own\n * legibility read is folded in only as a small soft penalty.\n *\n * It also asks the model for a best-effort, image-only authenticity read\n * (screen-replay, photocopy, digital/physical tampering). That signal is\n * advisory: a fired flag only *caps* the OCR confidence (see\n * {@link applyAuthenticity}) so a suspicious document routes to manual review —\n * an LLM's authenticity guess never auto-rejects a user on its own.\n */\nexport class AnthropicOcrDriver implements OcrDriver {\n readonly name = 'ai'\n\n private readonly model: string\n private readonly baseUrl: string\n private readonly maxEdge: number\n private readonly vision: AiVisionExtract\n\n constructor(options: AnthropicOcrOptions = {}) {\n if (!options.apiKey && !options.extract) {\n throw new Error('AnthropicOcrDriver requires config.apiKey')\n }\n this.model = options.model ?? DEFAULT_AI_MODEL\n this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/$/, '')\n this.maxEdge = options.maxEdge ?? DEFAULT_MAX_EDGE\n this.vision =\n options.extract ??\n anthropicVision({\n apiKey: options.apiKey as string,\n model: this.model,\n baseUrl: this.baseUrl,\n timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,\n maxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES,\n })\n }\n\n async extract(request: OcrRequest): Promise<OcrResultData> {\n const images = await this.prepareImages(request)\n const raw = await this.vision(request, images)\n const fields = normalizeFields(raw.fields)\n const authenticity = raw.authenticity\n const confidence = applyAuthenticity(scoreConfidence(fields, raw.legibility), authenticity)\n\n return {\n fields,\n confidence,\n ...(authenticity ? { authenticity } : {}),\n raw: {\n provider: 'ai',\n model: this.model,\n legibility: raw.legibility ?? null,\n documentType: raw.documentType ?? request.documentType ?? null,\n authenticity: authenticity ?? null,\n },\n }\n }\n\n /**\n * Resize (best-effort) and base64-encode the front + optional back images.\n *\n * @param input\n * @returns\n */\n private async prepareImages(request: OcrRequest): Promise<PreparedImage[]> {\n const sources = [request.image, request.backImage].filter((b): b is Uint8Array => !!b && b.length > 0)\n return Promise.all(sources.map((bytes) => prepareImage(bytes, this.maxEdge)))\n }\n}\n\nconst present = (v: unknown): v is string => typeof v === 'string' && v.trim().length > 0\nconst isIsoDate = (v: unknown): boolean => present(v) && /^\\d{4}-\\d{2}-\\d{2}$/.test(v) && !Number.isNaN(Date.parse(v))\n\n/**\n * Trim/blank-out the model's fields; drop anything empty so callers see `undefined`.\n *\n * @param input\n * @returns\n */\nfunction normalizeFields(fields: OcrFields): OcrFields {\n const out: OcrFields = {}\n const set = (key: keyof OcrFields, value: string | undefined) => {\n if (present(value)) out[key] = value.trim()\n }\n set('firstName', fields.firstName)\n set('lastName', fields.lastName)\n set('fullName', fields.fullName)\n set('dateOfBirth', fields.dateOfBirth)\n set('documentNumber', fields.documentNumber)\n set('expiryDate', fields.expiryDate)\n set('nationality', fields.nationality)\n return out\n}\n\n/**\n * Deterministic confidence in [0, 1] from field completeness + structural\n * validity. Weights sum to 1.0 when every field is present and well-formed; the\n * model's self-reported legibility can only pull the score down by up to 15%, so\n * trustworthy structure dominates an unreliable self-assessment.\n *\n * @param fields\n * @param legibility\n * @returns\n */\nexport function scoreConfidence(fields: OcrFields, legibility?: number): number {\n const hasName = (present(fields.firstName) && present(fields.lastName)) || present(fields.fullName)\n const checks: Array<[boolean, number]> = [\n [hasName, 0.28],\n [isIsoDate(fields.dateOfBirth), 0.22],\n [present(fields.documentNumber) && fields.documentNumber!.trim().length >= 4, 0.22],\n [isIsoDate(fields.expiryDate), 0.16],\n [present(fields.nationality), 0.12],\n ]\n const base = checks.reduce((sum, [ok, weight]) => (ok ? sum + weight : sum), 0)\n const legible = clamp01(legibility ?? 1)\n return clamp01(base * (0.85 + 0.15 * legible))\n}\n\n/**\n * Hard ceiling on OCR confidence once any authenticity flag fires. Below the\n * default `ocrConfidenceThreshold` (0.8), so a flagged document routes to manual\n * review regardless of how cleanly its fields read.\n */\nconst SUSPECT_CONFIDENCE_CEILING = 0.5\n\n/**\n * Fold the authenticity read into the field-derived confidence. Only ever lowers\n * the score, and only when a concrete flag fired — a genuine (or unassessed)\n * document is returned untouched. A flagged document is capped at both the\n * model's own authenticity confidence and {@link SUSPECT_CONFIDENCE_CEILING},\n * which lands it in manual review rather than auto-approval. This is deliberately\n * conservative: the LLM's authenticity guess can demote, never reject outright.\n *\n * @param fieldScore\n * @param authenticity\n * @returns\n */\nexport function applyAuthenticity(fieldScore: number, authenticity?: OcrAuthenticity): number {\n if (!authenticity || authenticity.genuine) return fieldScore\n return Math.min(fieldScore, clamp01(authenticity.confidence), SUSPECT_CONFIDENCE_CEILING)\n}\n\nconst SYSTEM_PROMPT =\n 'You are an OCR engine for identity documents. Read the visible printed text and ' +\n 'the machine-readable zone (MRZ) if present, and return the fields exactly as ' +\n 'printed. Do not guess, infer, or correct values you cannot clearly read — omit a ' +\n 'field rather than fabricate it. Dates must be ISO 8601 (YYYY-MM-DD).\\n\\n' +\n 'Identity documents print BOTH a date of issue and a date of expiry — never ' +\n 'confuse them. `expiryDate` is only the expiry/expiration/\"valid until\"/\"date of ' +\n 'expiry\" value (in an MRZ, the second date field, not the first). `issueDate` is ' +\n 'the \"date of issue\"/\"issued\"/\"valid from\" value. The expiry is later than the ' +\n 'issue date. If a document shows only an issue date and no expiry, return the ' +\n 'issue date in `issueDate` and leave `expiryDate` empty — do not put the issue ' +\n 'date in `expiryDate`.\\n\\n' +\n 'ALSO assess document authenticity, to the best of your ability, from the image ' +\n 'alone — and report it. Be conservative: raise a flag only on a clear, visible ' +\n 'sign. A genuine document that was merely photographed imperfectly (blur, glare ' +\n 'on a glossy original, a shadow, a crop) must NOT be flagged. Set `screenReplay` ' +\n 'when the image looks like a photo of a screen — moiré or a visible pixel grid, ' +\n 'backlit glow, refresh banding, or a device bezel (a replay attack). Set ' +\n '`photocopy` when it looks like a photo or scan of a printout/photocopy rather ' +\n 'than the physical document — flat matte paper, halftone dot printing, no ' +\n 'security features or hologram. Set `digitalTampering` for signs of digital ' +\n 'editing — mismatched fonts or kerning, misaligned or recoloured text, smudged ' +\n 'or cloned regions, a pasted-in portrait, or altered dates. Set ' +\n '`physicalTampering` for a substituted photo, peeled or lifted laminate, or ' +\n 'scratched-out / overwritten fields. Give `authenticityConfidence` from 0 ' +\n '(clearly fake or replayed) to 1 (clearly an authentic original), and list brief ' +\n 'supporting notes in `authenticityObservations`. You read and assess the ' +\n 'document; you do not make the final accept/reject decision.'\n\nconst READ_TOOL = {\n name: 'read_document',\n description: 'Return the identity fields read from the document image(s).',\n input_schema: {\n type: 'object',\n properties: {\n firstName: { type: 'string', description: 'Given name(s) as printed.' },\n lastName: { type: 'string', description: 'Surname / family name as printed.' },\n fullName: { type: 'string', description: 'Full name if given/surname cannot be separated.' },\n dateOfBirth: { type: 'string', description: 'Date of birth, ISO 8601 YYYY-MM-DD.' },\n documentNumber: { type: 'string', description: 'Document/serial number as printed.' },\n issueDate: {\n type: 'string',\n description: 'Date of issue (\"issued\"/\"valid from\"/\"issue date\"), ISO 8601 YYYY-MM-DD. NOT the expiry.',\n },\n expiryDate: {\n type: 'string',\n description:\n 'Expiry date (\"date of expiry\"/\"valid until\"/\"expiry date\"), ISO 8601 YYYY-MM-DD. Must be the expiry, never the issue date; omit if the document shows no expiry.',\n },\n nationality: { type: 'string', description: 'ISO 3166-1 alpha-3 country code if determinable.' },\n documentType: {\n type: 'string',\n enum: ['passport', 'id_card', 'drivers_license', 'residence_permit'],\n },\n legibility: {\n type: 'number',\n description: 'How clearly the document text could be read, 0 (illegible) to 1 (crisp).',\n },\n screenReplay: {\n type: 'boolean',\n description: 'Looks like a photo of a screen (moiré, pixel grid, backlit glow, refresh banding, bezel).',\n },\n photocopy: {\n type: 'boolean',\n description: 'Looks like a photo/scan of a printout or photocopy rather than the physical document.',\n },\n digitalTampering: {\n type: 'boolean',\n description:\n 'Signs of digital editing: mismatched fonts, misaligned/recoloured text, cloned regions, edited photo/dates.',\n },\n physicalTampering: {\n type: 'boolean',\n description:\n 'Signs of physical tampering: substituted photo, peeled laminate, scratched-out/overwritten fields.',\n },\n authenticityConfidence: {\n type: 'number',\n description: 'Authenticity confidence, 0 (clearly fake/replayed) to 1 (clearly an authentic original).',\n },\n authenticityObservations: {\n type: 'array',\n items: { type: 'string' },\n description: 'Brief notes supporting any authenticity flag raised.',\n },\n },\n },\n} as const\n\n/**\n * Minimal shape of the messages-API response we read.\n *\n */\ninterface AnthropicResponse {\n content?: Array<{ type: string; name?: string; input?: Record<string, unknown> }>\n}\n\nconst DOCUMENT_TYPES: readonly DocumentType[] = ['passport', 'id_card', 'drivers_license', 'residence_permit']\n\n/**\n * HTTP statuses worth a bounded retry: rate-limited / overloaded.\n */\nconst RETRYABLE_STATUS = new Set([429, 529])\n\nconst sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))\n\n/**\n * Exponential backoff (ms) for attempt 0, 1, 2…, capped at 8s.\n */\nconst backoffMs = (attempt: number): number => Math.min(8_000, 500 * 2 ** attempt)\n\n/**\n * Parse a `retry-after` header (seconds) into ms; null when absent/invalid.\n */\nfunction retryAfterMs(res: Response): number | null {\n const header = res.headers.get('retry-after')\n if (!header) return null\n const seconds = Number(header)\n return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : null\n}\n\n/**\n * POST with a per-attempt timeout and a bounded retry on 429/529 — honouring\n * `retry-after`, else exponential backoff. Timeouts/network errors retry too;\n * any other response is returned as-is for the caller to handle. The durable job\n * queue is the longer-term retry beyond {@link DEFAULT_MAX_RETRIES}.\n *\n * @param url\n * @param init\n * @param timeoutMs\n * @param maxRetries\n * @returns\n */\nasync function postWithRetry(url: string, init: RequestInit, timeoutMs: number, maxRetries: number): Promise<Response> {\n for (let attempt = 0; ; attempt++) {\n let res: Response\n try {\n res = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) })\n } catch (error) {\n if (attempt >= maxRetries) throw error\n await sleep(backoffMs(attempt))\n continue\n }\n if (RETRYABLE_STATUS.has(res.status) && attempt < maxRetries) {\n await sleep(retryAfterMs(res) ?? backoffMs(attempt))\n continue\n }\n return res\n }\n}\n\n/**\n * Build the default vision call against the Anthropic messages API.\n *\n * @param input\n * @returns\n */\nexport function anthropicVision(opts: {\n apiKey: string\n model: string\n baseUrl: string\n timeoutMs?: number\n maxRetries?: number\n}): AiVisionExtract {\n return async (request, images) => {\n const hint = request.documentType ? ` The document is a ${request.documentType}.` : ''\n const country = request.country ? ` Issuing country hint: ${request.country}.` : ''\n const res = await postWithRetry(\n `${opts.baseUrl}/v1/messages`,\n {\n method: 'POST',\n headers: {\n 'x-api-key': opts.apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json',\n },\n body: JSON.stringify({\n model: opts.model,\n max_tokens: 1024,\n system: SYSTEM_PROMPT,\n tools: [READ_TOOL],\n tool_choice: { type: 'tool', name: 'read_document' },\n messages: [\n {\n role: 'user',\n content: [\n ...images.map((img) => ({\n type: 'image',\n source: { type: 'base64', media_type: img.mediaType, data: img.data },\n })),\n { type: 'text', text: `Read this identity document and return its fields.${hint}${country}` },\n ],\n },\n ],\n }),\n },\n opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,\n opts.maxRetries ?? DEFAULT_MAX_RETRIES,\n )\n\n if (!res.ok) {\n throw new Error(`AnthropicOcrDriver request failed with status ${res.status}`)\n }\n\n const json = (await res.json()) as AnthropicResponse\n const tool = json.content?.find((block) => block.type === 'tool_use' && block.name === 'read_document')\n if (!tool?.input) {\n throw new Error('AnthropicOcrDriver: model returned no structured fields')\n }\n\n return mapToolInput(tool.input)\n }\n}\n\n/**\n * Coerce the model's tool input into an {@link AiExtraction} (string-typed fields).\n *\n * @param input\n * @returns\n */\nfunction mapToolInput(input: Record<string, unknown>): AiExtraction {\n const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined)\n const docType = str(input.documentType)\n return {\n fields: {\n firstName: str(input.firstName),\n lastName: str(input.lastName),\n fullName: str(input.fullName),\n dateOfBirth: str(input.dateOfBirth),\n documentNumber: str(input.documentNumber),\n expiryDate: str(input.expiryDate),\n nationality: str(input.nationality),\n },\n legibility: typeof input.legibility === 'number' ? input.legibility : undefined,\n documentType: docType && (DOCUMENT_TYPES as readonly string[]).includes(docType) ? (docType as DocumentType) : null,\n authenticity: mapAuthenticity(input),\n }\n}\n\n/**\n * Build the {@link OcrAuthenticity} read from the model's tool input. Returns\n * `undefined` when the model reported nothing assessable (no flags, no\n * confidence, no notes) so a driver that can't see authenticity stays silent.\n *\n * @param input\n * @returns\n */\nfunction mapAuthenticity(input: Record<string, unknown>): OcrAuthenticity | undefined {\n const bool = (v: unknown): boolean => v === true\n const screenReplay = bool(input.screenReplay)\n const photocopy = bool(input.photocopy)\n const digitalTampering = bool(input.digitalTampering)\n const physicalTampering = bool(input.physicalTampering)\n const flagged = screenReplay || photocopy || digitalTampering || physicalTampering\n\n const observations = Array.isArray(input.authenticityObservations)\n ? input.authenticityObservations\n .filter((o): o is string => typeof o === 'string' && o.trim().length > 0)\n .map((o) => o.trim())\n .slice(0, 6)\n : []\n\n const reported = typeof input.authenticityConfidence === 'number'\n // The model said nothing about authenticity at all — don't fabricate a verdict.\n if (!flagged && !reported && observations.length === 0) return undefined\n\n // A flagged document with no explicit score defaults to clearly-suspect (0.2);\n // an unflagged read defaults to clearly-genuine (1).\n const confidence = clamp01(reported ? (input.authenticityConfidence as number) : flagged ? 0.2 : 1)\n\n return { genuine: !flagged, confidence, screenReplay, photocopy, digitalTampering, physicalTampering, observations }\n}\n\n/**\n * Detect the media type from magic bytes; default to JPEG.\n *\n * @param bytes\n * @returns\n */\nfunction detectMediaType(bytes: Uint8Array): PreparedImage['mediaType'] {\n if (bytes[0] === 0xff && bytes[1] === 0xd8) return 'image/jpeg'\n if (bytes[0] === 0x89 && bytes[1] === 0x50) return 'image/png'\n if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[8] === 0x57) return 'image/webp'\n return 'image/jpeg'\n}\n\ninterface SharpInstance {\n metadata(): Promise<{ width?: number; height?: number }>\n resize(options: { width?: number; height?: number; fit: string; withoutEnlargement: boolean }): SharpInstance\n jpeg(options: { quality: number }): SharpInstance\n toBuffer(): Promise<Buffer>\n}\ntype SharpFactory = (input: Buffer) => SharpInstance\n\nlet sharpFactory: SharpFactory | null | undefined\n\n/** Lazily resolve the optional `sharp` factory once; null when not installed. */\nasync function loadSharp(): Promise<SharpFactory | null> {\n if (sharpFactory !== undefined) return sharpFactory\n const moduleName = 'sharp'\n try {\n const mod = (await import(/* @vite-ignore */ moduleName)) as unknown as { default: SharpFactory }\n sharpFactory = mod.default\n } catch {\n sharpFactory = null\n }\n return sharpFactory\n}\n\n/**\n * Downscale an image to `maxEdge` and re-encode as JPEG to bound the upload (and\n * therefore the model's image-token cost). Falls back to the original bytes when\n * `sharp` isn't installed or fails — the API caps oversized images itself.\n *\n * @param url\n * @param init\n * @param timeoutMs\n * @param maxRetries\n * @returns\n */\nasync function prepareImage(bytes: Uint8Array, maxEdge: number): Promise<PreparedImage> {\n const sharp = await loadSharp()\n if (sharp) {\n try {\n const input = Buffer.from(bytes)\n const { width, height } = await sharp(input).metadata()\n const longest = Math.max(width ?? 0, height ?? 0)\n let pipe = sharp(input)\n if (longest > maxEdge) {\n pipe = pipe.resize({ width: maxEdge, height: maxEdge, fit: 'inside', withoutEnlargement: true })\n }\n const out = await pipe.jpeg({ quality: 85 }).toBuffer()\n return { mediaType: 'image/jpeg', data: out.toString('base64') }\n } catch {\n // fall through to raw bytes\n }\n }\n return { mediaType: detectMediaType(bytes), data: Buffer.from(bytes).toString('base64') }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ai.mjs","names":[],"sources":["../../src/drivers/ai.ts"],"sourcesContent":["import type { DocumentType, OcrAuthenticity, OcrFields, OcrResultData } from '@arkyc/types'\nimport type { OcrDriver, OcrRequest } from '../types'\n\nconst clamp01 = (n: number): number => Math.min(1, Math.max(0, n))\n\n/**\n * Anthropic's default API base; override for a gateway/proxy.\n */\nconst DEFAULT_BASE_URL = 'https://api.anthropic.com'\n\n/**\n * A small, cheap vision model is plenty for reading printed/MRZ document text.\n */\nexport const DEFAULT_AI_MODEL = 'claude-haiku-4-5-20251001'\n\n/**\n * Default Cloudflare Workers AI model: Gemma 4 accepts image input and tool calls.\n */\nexport const DEFAULT_WORKERS_AI_MODEL = '@cf/google/gemma-4-26b-a4b-it'\n\n/**\n * Which hosted vision API backs the `ai` driver.\n */\nexport type AiOcrProvider = 'anthropic' | 'workers-ai'\n\n/**\n * Longest edge (px) we upload. The API downsamples anything larger anyway and\n * bills by the resampled size, so this is a cost/bandwidth bound, not a quality\n * one — small enough to be cheap, large enough to keep the MRZ legible.\n */\nconst DEFAULT_MAX_EDGE = 1568\n\n/**\n * Per-attempt request timeout (ms) — a hung connection must not stall a worker.\n */\nconst DEFAULT_TIMEOUT_MS = 30_000\n\n/**\n * Bounded in-driver retries on rate-limit/overload; the job queue retries beyond.\n */\nconst DEFAULT_MAX_RETRIES = 2\n\n/**\n * A base64 image ready for the messages API, with its detected media type.\n */\ninterface PreparedImage {\n mediaType: 'image/jpeg' | 'image/png' | 'image/webp'\n data: string\n}\n\n/**\n * Raw fields the vision model returned, before validation/scoring.\n */\nexport interface AiExtraction {\n fields: OcrFields\n /** The model's own legibility self-assessment in [0, 1], if it gave one. */\n legibility?: number\n documentType?: DocumentType | null\n /** Best-effort tamper/replay read; omitted when the model reported nothing. */\n authenticity?: OcrAuthenticity\n}\n\n/**\n * Calls a vision model and returns the raw extracted fields. Injected so the\n * driver's scoring can be unit-tested without a network call; the default\n * implementation talks to the Anthropic messages API.\n */\nexport type AiVisionExtract = (request: OcrRequest, images: PreparedImage[]) => Promise<AiExtraction>\n\nexport interface AnthropicOcrOptions {\n /** Vision backend; defaults to `anthropic`. */\n provider?: AiOcrProvider\n /** Anthropic API key, or the Cloudflare API token for `workers-ai`. */\n apiKey?: string\n /** Cloudflare account id; required for `workers-ai` unless `baseUrl` is set. */\n accountId?: string\n /** Model id; defaults to {@link DEFAULT_AI_MODEL} or {@link DEFAULT_WORKERS_AI_MODEL}. */\n model?: string\n /**\n * API base URL. Anthropic: defaults to {@link DEFAULT_BASE_URL}. Workers AI: the\n * OpenAI-compatible base ending in `/v1` (e.g. an AI Gateway URL), defaulting to\n * the account's `/ai/v1` endpoint.\n */\n baseUrl?: string\n /** Longest image edge (px) to upload; defaults to {@link DEFAULT_MAX_EDGE}. */\n maxEdge?: number\n /** Per-attempt request timeout (ms); defaults to {@link DEFAULT_TIMEOUT_MS}. */\n timeoutMs?: number\n /** Bounded retries on 429/529; defaults to {@link DEFAULT_MAX_RETRIES}. */\n maxRetries?: number\n /** Override the vision call (tests). When set, `apiKey` is not required. */\n extract?: AiVisionExtract\n}\n\n/**\n * AI OCR driver: hands the document image(s) to a vision LLM (Claude by default,\n * or a Cloudflare Workers AI model with `provider: 'workers-ai'`) and maps the structured response onto {@link OcrResultData}.\n *\n * Confidence is **not** taken from the model's self-report — LLMs are poorly\n * calibrated and will state high confidence on hallucinated fields. Instead it's\n * derived deterministically from which fields came back and whether they're\n * structurally valid (see {@link scoreConfidence}), so the value feeding the\n * decision engine's `ocrConfidenceThreshold` is meaningful. The model's own\n * legibility read is folded in only as a small soft penalty.\n *\n * It also asks the model for a best-effort, image-only authenticity read\n * (screen-replay, photocopy, digital/physical tampering). That signal is\n * advisory: a fired flag only *caps* the OCR confidence (see\n * {@link applyAuthenticity}) so a suspicious document routes to manual review —\n * an LLM's authenticity guess never auto-rejects a user on its own.\n */\nexport class AnthropicOcrDriver implements OcrDriver {\n readonly name = 'ai'\n\n private readonly provider: AiOcrProvider\n private readonly model: string\n private readonly maxEdge: number\n private readonly vision: AiVisionExtract\n\n constructor(options: AnthropicOcrOptions = {}) {\n this.provider = options.provider ?? 'anthropic'\n if (!options.apiKey && !options.extract) {\n throw new Error('AnthropicOcrDriver requires config.apiKey')\n }\n this.model = options.model ?? (this.provider === 'workers-ai' ? DEFAULT_WORKERS_AI_MODEL : DEFAULT_AI_MODEL)\n this.maxEdge = options.maxEdge ?? DEFAULT_MAX_EDGE\n\n const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS\n const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES\n if (options.extract) {\n this.vision = options.extract\n } else if (this.provider === 'workers-ai') {\n if (!options.accountId && !options.baseUrl) {\n throw new Error('AnthropicOcrDriver (workers-ai) requires config.accountId')\n }\n this.vision = workersAiVision({\n apiKey: options.apiKey as string,\n model: this.model,\n baseUrl: (\n options.baseUrl ?? `https://api.cloudflare.com/client/v4/accounts/${options.accountId}/ai/v1`\n ).replace(/\\/$/, ''),\n timeoutMs,\n maxRetries,\n })\n } else {\n this.vision = anthropicVision({\n apiKey: options.apiKey as string,\n model: this.model,\n baseUrl: (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/$/, ''),\n timeoutMs,\n maxRetries,\n })\n }\n }\n\n async extract(request: OcrRequest): Promise<OcrResultData> {\n const images = await this.prepareImages(request)\n const raw = await this.vision(request, images)\n const fields = normalizeFields(raw.fields)\n const authenticity = raw.authenticity\n const confidence = applyAuthenticity(scoreConfidence(fields, raw.legibility), authenticity)\n\n return {\n fields,\n confidence,\n ...(authenticity ? { authenticity } : {}),\n raw: {\n provider: 'ai',\n backend: this.provider,\n model: this.model,\n legibility: raw.legibility ?? null,\n documentType: raw.documentType ?? request.documentType ?? null,\n authenticity: authenticity ?? null,\n },\n }\n }\n\n /**\n * Resize (best-effort) and base64-encode the front + optional back images.\n *\n * @param input\n * @returns\n */\n private async prepareImages(request: OcrRequest): Promise<PreparedImage[]> {\n const sources = [request.image, request.backImage].filter((b): b is Uint8Array => !!b && b.length > 0)\n return Promise.all(sources.map((bytes) => prepareImage(bytes, this.maxEdge)))\n }\n}\n\nconst present = (v: unknown): v is string => typeof v === 'string' && v.trim().length > 0\nconst isIsoDate = (v: unknown): boolean => present(v) && /^\\d{4}-\\d{2}-\\d{2}$/.test(v) && !Number.isNaN(Date.parse(v))\n\n/**\n * Trim/blank-out the model's fields; drop anything empty so callers see `undefined`.\n *\n * @param input\n * @returns\n */\nfunction normalizeFields(fields: OcrFields): OcrFields {\n const out: OcrFields = {}\n const set = (key: keyof OcrFields, value: string | undefined) => {\n if (present(value)) out[key] = value.trim()\n }\n set('firstName', fields.firstName)\n set('lastName', fields.lastName)\n set('fullName', fields.fullName)\n set('dateOfBirth', fields.dateOfBirth)\n set('documentNumber', fields.documentNumber)\n set('expiryDate', fields.expiryDate)\n set('issueDate', fields.issueDate)\n set('nationality', fields.nationality)\n return out\n}\n\n/**\n * Deterministic confidence in [0, 1] from field completeness + structural\n * validity. Weights sum to 1.0 when every field is present and well-formed; the\n * model's self-reported legibility can only pull the score down by up to 15%, so\n * trustworthy structure dominates an unreliable self-assessment.\n *\n * @param fields\n * @param legibility\n * @returns\n */\nexport function scoreConfidence(fields: OcrFields, legibility?: number): number {\n const hasName = (present(fields.firstName) && present(fields.lastName)) || present(fields.fullName)\n const checks: Array<[boolean, number]> = [\n [hasName, 0.28],\n [isIsoDate(fields.dateOfBirth), 0.22],\n [present(fields.documentNumber) && fields.documentNumber!.trim().length >= 4, 0.22],\n [isIsoDate(fields.expiryDate), 0.16],\n [present(fields.nationality), 0.12],\n ]\n const base = checks.reduce((sum, [ok, weight]) => (ok ? sum + weight : sum), 0)\n const legible = clamp01(legibility ?? 1)\n return clamp01(base * (0.85 + 0.15 * legible))\n}\n\n/**\n * Hard ceiling on OCR confidence once any authenticity flag fires. Below the\n * default `ocrConfidenceThreshold` (0.8), so a flagged document routes to manual\n * review regardless of how cleanly its fields read.\n */\nconst SUSPECT_CONFIDENCE_CEILING = 0.5\n\n/**\n * Fold the authenticity read into the field-derived confidence. Only ever lowers\n * the score, and only when a concrete flag fired — a genuine (or unassessed)\n * document is returned untouched. A flagged document is capped at both the\n * model's own authenticity confidence and {@link SUSPECT_CONFIDENCE_CEILING},\n * which lands it in manual review rather than auto-approval. This is deliberately\n * conservative: the LLM's authenticity guess can demote, never reject outright.\n *\n * @param fieldScore\n * @param authenticity\n * @returns\n */\nexport function applyAuthenticity(fieldScore: number, authenticity?: OcrAuthenticity): number {\n if (!authenticity || authenticity.genuine) return fieldScore\n return Math.min(fieldScore, clamp01(authenticity.confidence), SUSPECT_CONFIDENCE_CEILING)\n}\n\nconst SYSTEM_PROMPT =\n 'You are an OCR engine for identity documents. Read the visible printed text and ' +\n 'the machine-readable zone (MRZ) if present, and return the fields exactly as ' +\n 'printed. Do not guess, infer, or correct values you cannot clearly read — omit a ' +\n 'field rather than fabricate it. Dates must be ISO 8601 (YYYY-MM-DD).\\n\\n' +\n 'Identity documents print BOTH a date of issue and a date of expiry — never ' +\n 'confuse them. `expiryDate` is only the expiry/expiration/\"valid until\"/\"date of ' +\n 'expiry\" value (in an MRZ, the second date field, not the first). `issueDate` is ' +\n 'the \"date of issue\"/\"issued\"/\"valid from\" value. The expiry is later than the ' +\n 'issue date. If a document shows only an issue date and no expiry, return the ' +\n 'issue date in `issueDate` and leave `expiryDate` empty — do not put the issue ' +\n 'date in `expiryDate`.\\n\\n' +\n 'ALSO assess document authenticity, to the best of your ability, from the image ' +\n 'alone — and report it. Be conservative: raise a flag only on a clear, visible ' +\n 'sign. A genuine document that was merely photographed imperfectly (blur, glare ' +\n 'on a glossy original, a shadow, a crop) must NOT be flagged. Set `screenReplay` ' +\n 'when the image looks like a photo of a screen — moiré or a visible pixel grid, ' +\n 'backlit glow, refresh banding, or a device bezel (a replay attack). Set ' +\n '`photocopy` when it looks like a photo or scan of a printout/photocopy rather ' +\n 'than the physical document — flat matte paper, halftone dot printing, no ' +\n 'security features or hologram. Set `digitalTampering` for signs of digital ' +\n 'editing — mismatched fonts or kerning, misaligned or recoloured text, smudged ' +\n 'or cloned regions, a pasted-in portrait, or altered dates. Set ' +\n '`physicalTampering` for a substituted photo, peeled or lifted laminate, or ' +\n 'scratched-out / overwritten fields. Give `authenticityConfidence` from 0 ' +\n '(clearly fake or replayed) to 1 (clearly an authentic original), and list brief ' +\n 'supporting notes in `authenticityObservations`. You read and assess the ' +\n 'document; you do not make the final accept/reject decision.'\n\nconst READ_TOOL = {\n name: 'read_document',\n description: 'Return the identity fields read from the document image(s).',\n input_schema: {\n type: 'object',\n properties: {\n firstName: { type: 'string', description: 'Given name(s) as printed.' },\n lastName: { type: 'string', description: 'Surname / family name as printed.' },\n fullName: { type: 'string', description: 'Full name if given/surname cannot be separated.' },\n dateOfBirth: { type: 'string', description: 'Date of birth, ISO 8601 YYYY-MM-DD.' },\n documentNumber: { type: 'string', description: 'Document/serial number as printed.' },\n issueDate: {\n type: 'string',\n description: 'Date of issue (\"issued\"/\"valid from\"/\"issue date\"), ISO 8601 YYYY-MM-DD. NOT the expiry.',\n },\n expiryDate: {\n type: 'string',\n description:\n 'Expiry date (\"date of expiry\"/\"valid until\"/\"expiry date\"), ISO 8601 YYYY-MM-DD. Must be the expiry, never the issue date; omit if the document shows no expiry.',\n },\n nationality: { type: 'string', description: 'ISO 3166-1 alpha-3 country code if determinable.' },\n documentType: {\n type: 'string',\n enum: ['passport', 'id_card', 'drivers_license', 'residence_permit'],\n },\n legibility: {\n type: 'number',\n description: 'How clearly the document text could be read, 0 (illegible) to 1 (crisp).',\n },\n screenReplay: {\n type: 'boolean',\n description: 'Looks like a photo of a screen (moiré, pixel grid, backlit glow, refresh banding, bezel).',\n },\n photocopy: {\n type: 'boolean',\n description: 'Looks like a photo/scan of a printout or photocopy rather than the physical document.',\n },\n digitalTampering: {\n type: 'boolean',\n description:\n 'Signs of digital editing: mismatched fonts, misaligned/recoloured text, cloned regions, edited photo/dates.',\n },\n physicalTampering: {\n type: 'boolean',\n description:\n 'Signs of physical tampering: substituted photo, peeled laminate, scratched-out/overwritten fields.',\n },\n authenticityConfidence: {\n type: 'number',\n description: 'Authenticity confidence, 0 (clearly fake/replayed) to 1 (clearly an authentic original).',\n },\n authenticityObservations: {\n type: 'array',\n items: { type: 'string' },\n description: 'Brief notes supporting any authenticity flag raised.',\n },\n },\n },\n} as const\n\n/**\n * Minimal shape of the messages-API response we read.\n *\n */\ninterface AnthropicResponse {\n content?: Array<{ type: string; name?: string; input?: Record<string, unknown> }>\n}\n\nconst DOCUMENT_TYPES: readonly DocumentType[] = ['passport', 'id_card', 'drivers_license', 'residence_permit']\n\n/**\n * HTTP statuses worth a bounded retry: rate-limited / overloaded / unavailable.\n */\nconst RETRYABLE_STATUS = new Set([429, 503, 529])\n\nconst sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))\n\n/**\n * Exponential backoff (ms) for attempt 0, 1, 2…, capped at 8s.\n */\nconst backoffMs = (attempt: number): number => Math.min(8_000, 500 * 2 ** attempt)\n\n/**\n * Parse a `retry-after` header (seconds) into ms; null when absent/invalid.\n */\nfunction retryAfterMs(res: Response): number | null {\n const header = res.headers.get('retry-after')\n if (!header) return null\n const seconds = Number(header)\n return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : null\n}\n\n/**\n * POST with a per-attempt timeout and a bounded retry on 429/529 — honouring\n * `retry-after`, else exponential backoff. Timeouts/network errors retry too;\n * any other response is returned as-is for the caller to handle. The durable job\n * queue is the longer-term retry beyond {@link DEFAULT_MAX_RETRIES}.\n *\n * @param url\n * @param init\n * @param timeoutMs\n * @param maxRetries\n * @returns\n */\nasync function postWithRetry(url: string, init: RequestInit, timeoutMs: number, maxRetries: number): Promise<Response> {\n for (let attempt = 0; ; attempt++) {\n let res: Response\n try {\n res = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) })\n } catch (error) {\n if (attempt >= maxRetries) throw error\n await sleep(backoffMs(attempt))\n continue\n }\n if (RETRYABLE_STATUS.has(res.status) && attempt < maxRetries) {\n await sleep(retryAfterMs(res) ?? backoffMs(attempt))\n continue\n }\n return res\n }\n}\n\n/**\n * Build the default vision call against the Anthropic messages API.\n *\n * @param input\n * @returns\n */\nexport function anthropicVision(opts: {\n apiKey: string\n model: string\n baseUrl: string\n timeoutMs?: number\n maxRetries?: number\n}): AiVisionExtract {\n return async (request, images) => {\n const hint = request.documentType ? ` The document is a ${request.documentType}.` : ''\n const country = request.country ? ` Issuing country hint: ${request.country}.` : ''\n const res = await postWithRetry(\n `${opts.baseUrl}/v1/messages`,\n {\n method: 'POST',\n headers: {\n 'x-api-key': opts.apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json',\n },\n body: JSON.stringify({\n model: opts.model,\n max_tokens: 1024,\n system: SYSTEM_PROMPT,\n tools: [READ_TOOL],\n tool_choice: { type: 'tool', name: 'read_document' },\n messages: [\n {\n role: 'user',\n content: [\n ...images.map((img) => ({\n type: 'image',\n source: { type: 'base64', media_type: img.mediaType, data: img.data },\n })),\n { type: 'text', text: `Read this identity document and return its fields.${hint}${country}` },\n ],\n },\n ],\n }),\n },\n opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,\n opts.maxRetries ?? DEFAULT_MAX_RETRIES,\n )\n\n if (!res.ok) {\n throw new Error(`AnthropicOcrDriver request failed with status ${res.status}`)\n }\n\n const json = (await res.json()) as AnthropicResponse\n const tool = json.content?.find((block) => block.type === 'tool_use' && block.name === 'read_document')\n if (!tool?.input) {\n throw new Error('AnthropicOcrDriver: model returned no structured fields')\n }\n\n return mapToolInput(tool.input)\n }\n}\n\n/**\n * Minimal shape of the OpenAI-compatible chat-completions response we read.\n */\ninterface ChatCompletionResponse {\n choices?: Array<{\n message?: {\n content?: string | null\n tool_calls?: Array<{ function?: { name?: string; arguments?: string | Record<string, unknown> } }>\n }\n }>\n}\n\n/**\n * Parse a JSON object out of model text, tolerating a ```json fence. Null when\n * the text holds no object.\n *\n * @param text\n * @returns\n */\nfunction parseJsonObject(text: string | null | undefined): Record<string, unknown> | null {\n if (!text) return null\n const start = text.indexOf('{')\n const end = text.lastIndexOf('}')\n if (start < 0 || end <= start) return null\n try {\n const parsed = JSON.parse(text.slice(start, end + 1)) as unknown\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null\n } catch {\n return null\n }\n}\n\n/**\n * Build the vision call against Cloudflare Workers AI's OpenAI-compatible chat\n * completions endpoint. Images go in as base64 data URLs and the model is forced\n * to call `read_document`; when a model answers in plain text instead, a JSON\n * object in the reply is accepted as the tool input.\n *\n * @param opts\n * @returns\n */\nexport function workersAiVision(opts: {\n apiKey: string\n model: string\n /** OpenAI-compatible base ending in `/v1`. */\n baseUrl: string\n timeoutMs?: number\n maxRetries?: number\n}): AiVisionExtract {\n return async (request, images) => {\n const hint = request.documentType ? ` The document is a ${request.documentType}.` : ''\n const country = request.country ? ` Issuing country hint: ${request.country}.` : ''\n const res = await postWithRetry(\n `${opts.baseUrl}/chat/completions`,\n {\n method: 'POST',\n headers: {\n authorization: `Bearer ${opts.apiKey}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify({\n model: opts.model,\n max_completion_tokens: 2048,\n temperature: 0,\n tools: [\n {\n type: 'function',\n function: {\n name: READ_TOOL.name,\n description: READ_TOOL.description,\n parameters: READ_TOOL.input_schema,\n },\n },\n ],\n tool_choice: 'required',\n messages: [\n { role: 'system', content: SYSTEM_PROMPT },\n {\n role: 'user',\n content: [\n ...images.map((img) => ({\n type: 'image_url',\n image_url: { url: `data:${img.mediaType};base64,${img.data}` },\n })),\n { type: 'text', text: `Read this identity document and return its fields.${hint}${country}` },\n ],\n },\n ],\n }),\n },\n opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,\n opts.maxRetries ?? DEFAULT_MAX_RETRIES,\n )\n\n if (!res.ok) {\n throw new Error(`AnthropicOcrDriver (workers-ai) request failed with status ${res.status}`)\n }\n\n const message = ((await res.json()) as ChatCompletionResponse).choices?.[0]?.message\n const call = message?.tool_calls?.find((c) => c.function?.name === READ_TOOL.name) ?? message?.tool_calls?.[0]\n const args = call?.function?.arguments\n const input = typeof args === 'string' ? parseJsonObject(args) : (args ?? parseJsonObject(message?.content))\n if (!input) {\n throw new Error('AnthropicOcrDriver (workers-ai): model returned no structured fields')\n }\n\n return mapToolInput(input)\n }\n}\n\n/**\n * Coerce the model's tool input into an {@link AiExtraction} (string-typed fields).\n *\n * @param input\n * @returns\n */\nfunction mapToolInput(input: Record<string, unknown>): AiExtraction {\n const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined)\n const docType = str(input.documentType)\n return {\n fields: {\n firstName: str(input.firstName),\n lastName: str(input.lastName),\n fullName: str(input.fullName),\n dateOfBirth: str(input.dateOfBirth),\n documentNumber: str(input.documentNumber),\n expiryDate: str(input.expiryDate),\n nationality: str(input.nationality),\n },\n legibility: typeof input.legibility === 'number' ? input.legibility : undefined,\n documentType: docType && (DOCUMENT_TYPES as readonly string[]).includes(docType) ? (docType as DocumentType) : null,\n authenticity: mapAuthenticity(input),\n }\n}\n\n/**\n * Build the {@link OcrAuthenticity} read from the model's tool input. Returns\n * `undefined` when the model reported nothing assessable (no flags, no\n * confidence, no notes) so a driver that can't see authenticity stays silent.\n *\n * @param input\n * @returns\n */\nfunction mapAuthenticity(input: Record<string, unknown>): OcrAuthenticity | undefined {\n const bool = (v: unknown): boolean => v === true\n const screenReplay = bool(input.screenReplay)\n const photocopy = bool(input.photocopy)\n const digitalTampering = bool(input.digitalTampering)\n const physicalTampering = bool(input.physicalTampering)\n const flagged = screenReplay || photocopy || digitalTampering || physicalTampering\n\n const observations = Array.isArray(input.authenticityObservations)\n ? input.authenticityObservations\n .filter((o): o is string => typeof o === 'string' && o.trim().length > 0)\n .map((o) => o.trim())\n .slice(0, 6)\n : []\n\n const reported = typeof input.authenticityConfidence === 'number'\n // The model said nothing about authenticity at all — don't fabricate a verdict.\n if (!flagged && !reported && observations.length === 0) return undefined\n\n // A flagged document with no explicit score defaults to clearly-suspect (0.2);\n // an unflagged read defaults to clearly-genuine (1).\n const confidence = clamp01(reported ? (input.authenticityConfidence as number) : flagged ? 0.2 : 1)\n\n return { genuine: !flagged, confidence, screenReplay, photocopy, digitalTampering, physicalTampering, observations }\n}\n\n/**\n * Detect the media type from magic bytes; default to JPEG.\n *\n * @param bytes\n * @returns\n */\nfunction detectMediaType(bytes: Uint8Array): PreparedImage['mediaType'] {\n if (bytes[0] === 0xff && bytes[1] === 0xd8) return 'image/jpeg'\n if (bytes[0] === 0x89 && bytes[1] === 0x50) return 'image/png'\n if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[8] === 0x57) return 'image/webp'\n return 'image/jpeg'\n}\n\ninterface SharpInstance {\n metadata(): Promise<{ width?: number; height?: number }>\n resize(options: { width?: number; height?: number; fit: string; withoutEnlargement: boolean }): SharpInstance\n jpeg(options: { quality: number }): SharpInstance\n toBuffer(): Promise<Buffer>\n}\ntype SharpFactory = (input: Buffer) => SharpInstance\n\nlet sharpFactory: SharpFactory | null | undefined\n\n/** Lazily resolve the optional `sharp` factory once; null when not installed. */\nasync function loadSharp(): Promise<SharpFactory | null> {\n if (sharpFactory !== undefined) return sharpFactory\n const moduleName = 'sharp'\n try {\n const mod = (await import(/* @vite-ignore */ moduleName)) as unknown as { default: SharpFactory }\n sharpFactory = mod.default\n } catch {\n sharpFactory = null\n }\n return sharpFactory\n}\n\n/**\n * Downscale an image to `maxEdge` and re-encode as JPEG to bound the upload (and\n * therefore the model's image-token cost). Falls back to the original bytes when\n * `sharp` isn't installed or fails — the API caps oversized images itself.\n *\n * @param url\n * @param init\n * @param timeoutMs\n * @param maxRetries\n * @returns\n */\nasync function prepareImage(bytes: Uint8Array, maxEdge: number): Promise<PreparedImage> {\n const sharp = await loadSharp()\n if (sharp) {\n try {\n const input = Buffer.from(bytes)\n const { width, height } = await sharp(input).metadata()\n const longest = Math.max(width ?? 0, height ?? 0)\n let pipe = sharp(input)\n if (longest > maxEdge) {\n pipe = pipe.resize({ width: maxEdge, height: maxEdge, fit: 'inside', withoutEnlargement: true })\n }\n const out = await pipe.jpeg({ quality: 85 }).toBuffer()\n return { mediaType: 'image/jpeg', data: out.toString('base64') }\n } catch {\n // fall through to raw bytes\n }\n }\n return { mediaType: detectMediaType(bytes), data: Buffer.from(bytes).toString('base64') }\n}\n"],"mappings":"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"}
|
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
import { OcrConfig, OcrDriver, OcrRequest } from "../types.mjs";
|
|
2
2
|
import { OcrResultData } from "@arkyc/types";
|
|
3
|
-
|
|
4
3
|
//#region src/drivers/external.d.ts
|
|
5
4
|
/**
|
|
6
5
|
* Generic HTTP OCR driver: POSTs the base64 image to a configured endpoint and
|
|
7
6
|
* expects an {@link OcrResultData}-shaped JSON response.
|
|
8
7
|
*/
|
|
9
|
-
declare class ExternalOcrDriver implements OcrDriver {
|
|
8
|
+
export declare class ExternalOcrDriver implements OcrDriver {
|
|
10
9
|
private readonly config;
|
|
11
10
|
readonly name = "external";
|
|
12
11
|
constructor(config: OcrConfig);
|
|
13
12
|
extract(request: OcrRequest): Promise<OcrResultData>;
|
|
14
13
|
}
|
|
15
14
|
//#endregion
|
|
16
|
-
export { ExternalOcrDriver };
|
|
17
15
|
//# sourceMappingURL=external.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"external.d.mts","names":[],"sources":["../../src/drivers/external.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"external.d.mts","names":[],"sources":["../../src/drivers/external.ts"],"mappings":";;;;;;;qBAOa,6BAA6B;mBAGX;WAFpB;EAEoB,YAAA,QAAQ;EAI/B,QAAQ,SAAS,aAAa,QAAQ"}
|
package/dist/drivers/mock.d.mts
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
import { OcrDriver, OcrRequest } from "../types.mjs";
|
|
2
2
|
import { OcrResultData } from "@arkyc/types";
|
|
3
|
-
|
|
4
3
|
//#region src/drivers/mock.d.ts
|
|
5
4
|
/**
|
|
6
5
|
* Deterministic OCR driver for development + tests. Returns fixed identity
|
|
7
6
|
* fields; `hints` steer the confidence and expiry so a caller can drive a
|
|
8
7
|
* session toward any decision.
|
|
9
8
|
*/
|
|
10
|
-
declare class MockOcrDriver implements OcrDriver {
|
|
9
|
+
export declare class MockOcrDriver implements OcrDriver {
|
|
11
10
|
readonly name = "mock";
|
|
12
11
|
extract(request: OcrRequest): Promise<OcrResultData>;
|
|
13
12
|
}
|
|
14
13
|
//#endregion
|
|
15
|
-
export { MockOcrDriver };
|
|
16
14
|
//# sourceMappingURL=mock.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mock.d.mts","names":[],"sources":["../../src/drivers/mock.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"mock.d.mts","names":[],"sources":["../../src/drivers/mock.ts"],"mappings":";;;;;;;;qBAUa,yBAAyB;WAC3B;EAEH,QAAQ,SAAS,aAAa,QAAQ"}
|
package/dist/drivers/mock.mjs
CHANGED
|
@@ -9,13 +9,15 @@ var MockOcrDriver = class {
|
|
|
9
9
|
name = "mock";
|
|
10
10
|
async extract(request) {
|
|
11
11
|
const confidence = clamp01(request.hints?.confidence ?? .92);
|
|
12
|
+
const fullName = request.hints?.fullName ?? "Ada Lovelace";
|
|
13
|
+
const [firstName, ...rest] = fullName.split(/\s+/);
|
|
12
14
|
return {
|
|
13
15
|
fields: {
|
|
14
|
-
firstName
|
|
15
|
-
lastName: "
|
|
16
|
-
fullName
|
|
17
|
-
dateOfBirth: "1990-01-01",
|
|
18
|
-
documentNumber: "X1234567",
|
|
16
|
+
firstName,
|
|
17
|
+
lastName: rest.join(" ") || void 0,
|
|
18
|
+
fullName,
|
|
19
|
+
dateOfBirth: request.hints?.dateOfBirth ?? "1990-01-01",
|
|
20
|
+
documentNumber: request.hints?.documentNumber ?? "X1234567",
|
|
19
21
|
expiryDate: request.hints?.expired ? "2000-01-01" : "2035-01-01",
|
|
20
22
|
nationality: request.country ?? "GB"
|
|
21
23
|
},
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mock.mjs","names":[],"sources":["../../src/drivers/mock.ts"],"sourcesContent":["import type { OcrResultData } from '@arkyc/types'\nimport type { OcrDriver, OcrRequest } from '../types'\n\nconst clamp01 = (n: number): number => Math.min(1, Math.max(0, n))\n\n/**\n * Deterministic OCR driver for development + tests. Returns fixed identity\n * fields; `hints` steer the confidence and expiry so a caller can drive a\n * session toward any decision.\n */\nexport class MockOcrDriver implements OcrDriver {\n readonly name = 'mock'\n\n async extract(request: OcrRequest): Promise<OcrResultData> {\n const confidence = clamp01(request.hints?.confidence ?? 0.92)\n\n return {\n fields: {\n firstName
|
|
1
|
+
{"version":3,"file":"mock.mjs","names":[],"sources":["../../src/drivers/mock.ts"],"sourcesContent":["import type { OcrResultData } from '@arkyc/types'\nimport type { OcrDriver, OcrRequest } from '../types'\n\nconst clamp01 = (n: number): number => Math.min(1, Math.max(0, n))\n\n/**\n * Deterministic OCR driver for development + tests. Returns fixed identity\n * fields; `hints` steer the confidence and expiry so a caller can drive a\n * session toward any decision.\n */\nexport class MockOcrDriver implements OcrDriver {\n readonly name = 'mock'\n\n async extract(request: OcrRequest): Promise<OcrResultData> {\n const confidence = clamp01(request.hints?.confidence ?? 0.92)\n const fullName = request.hints?.fullName ?? 'Ada Lovelace'\n const [firstName, ...rest] = fullName.split(/\\s+/)\n\n return {\n fields: {\n firstName,\n lastName: rest.join(' ') || undefined,\n fullName,\n dateOfBirth: request.hints?.dateOfBirth ?? '1990-01-01',\n documentNumber: request.hints?.documentNumber ?? 'X1234567',\n expiryDate: request.hints?.expired ? '2000-01-01' : '2035-01-01',\n nationality: request.country ?? 'GB',\n },\n confidence,\n raw: { provider: 'mock', confidence, documentType: request.documentType ?? null },\n }\n }\n}\n"],"mappings":";AAGA,MAAM,WAAW,MAAsB,KAAK,IAAI,GAAG,KAAK,IAAI,GAAG,CAAC,CAAC;;;;;;AAOjE,IAAa,gBAAb,MAAgD;CAC9C,OAAgB;CAEhB,MAAM,QAAQ,SAA6C;EACzD,MAAM,aAAa,QAAQ,QAAQ,OAAO,cAAc,GAAI;EAC5D,MAAM,WAAW,QAAQ,OAAO,YAAY;EAC5C,MAAM,CAAC,WAAW,GAAG,QAAQ,SAAS,MAAM,KAAK;EAEjD,OAAO;GACL,QAAQ;IACN;IACA,UAAU,KAAK,KAAK,GAAG,KAAK,KAAA;IAC5B;IACA,aAAa,QAAQ,OAAO,eAAe;IAC3C,gBAAgB,QAAQ,OAAO,kBAAkB;IACjD,YAAY,QAAQ,OAAO,UAAU,eAAe;IACpD,aAAa,QAAQ,WAAW;GAClC;GACA;GACA,KAAK;IAAE,UAAU;IAAQ;IAAY,cAAc,QAAQ,gBAAgB;GAAK;EAClF;CACF;AACF"}
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* unchanged) bytes — a preprocessor must never throw; on failure it returns the
|
|
5
5
|
* input so recognition still runs.
|
|
6
6
|
*/
|
|
7
|
-
type OcrPreprocessor = (image: Uint8Array) => Promise<Uint8Array>;
|
|
7
|
+
export type OcrPreprocessor = (image: Uint8Array) => Promise<Uint8Array>;
|
|
8
8
|
/** Tunables for the default {@link sharpPreprocessor}. */
|
|
9
|
-
interface PreprocessOptions {
|
|
9
|
+
export interface PreprocessOptions {
|
|
10
10
|
/**
|
|
11
11
|
* Upscale images narrower than this (px) to give the engine more pixels per
|
|
12
12
|
* glyph — the single biggest win for the small OCR-B MRZ band. Default 1600.
|
|
@@ -14,7 +14,7 @@ interface PreprocessOptions {
|
|
|
14
14
|
minWidth?: number;
|
|
15
15
|
}
|
|
16
16
|
/** A no-op preprocessor: passes the original bytes through unchanged. */
|
|
17
|
-
declare const passthrough: OcrPreprocessor;
|
|
17
|
+
export declare const passthrough: OcrPreprocessor;
|
|
18
18
|
/** Minimal structural type for the lazily-imported `sharp` module. */
|
|
19
19
|
interface SharpInstance {
|
|
20
20
|
metadata(): Promise<{
|
|
@@ -39,13 +39,13 @@ type SharpFactory = (input: Buffer) => SharpInstance;
|
|
|
39
39
|
* document text and the OCR-B MRZ band without the aggressive binarisation that
|
|
40
40
|
* destroys detail under uneven lighting. Any failure falls back to the original.
|
|
41
41
|
*/
|
|
42
|
-
declare function buildSharpPreprocessor(sharp: SharpFactory, options?: PreprocessOptions): OcrPreprocessor;
|
|
42
|
+
export declare function buildSharpPreprocessor(sharp: SharpFactory, options?: PreprocessOptions): OcrPreprocessor;
|
|
43
43
|
/**
|
|
44
44
|
* The default preprocessor, backed by the optional `sharp` package and resolved
|
|
45
45
|
* once. If `sharp` isn't installed it degrades to {@link passthrough}, so OCR
|
|
46
46
|
* still runs on the raw bytes (just without the legibility boost).
|
|
47
47
|
*/
|
|
48
|
-
declare function defaultPreprocessor(options?: PreprocessOptions): Promise<OcrPreprocessor>;
|
|
48
|
+
export declare function defaultPreprocessor(options?: PreprocessOptions): Promise<OcrPreprocessor>;
|
|
49
49
|
//#endregion
|
|
50
|
-
export {
|
|
50
|
+
export {};
|
|
51
51
|
//# sourceMappingURL=preprocess.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"preprocess.d.mts","names":[],"sources":["../../src/drivers/preprocess.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"preprocess.d.mts","names":[],"sources":["../../src/drivers/preprocess.ts"],"mappings":";;;;;;YAKY,mBAAmB,OAAO,eAAe,QAAQ;;iBAG5C;;;;;EAKf;;;qBAIW,aAAa;;UAGhB;EACR,YAAY;IAAU;IAAgB;;EACtC,aAAa;EACb,aAAa;EACb,OAAO,eAAe;EACtB,WAAW;EACX,OAAO;IAAW;IAAe;MAAgC;EACjE,OAAO;EACP,YAAY,QAAQ;;KAEjB,gBAAgB,OAAO,WAAW;;;;;;;wBAQvB,uBAAuB,OAAO,cAAc,UAAS,oBAAyB;;;;;;wBA0BxE,oBAAoB,UAAS,oBAAyB,QAAQ"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"preprocess.mjs","names":[],"sources":["../../src/drivers/preprocess.ts"],"sourcesContent":["/**\n * Transforms raw image bytes to improve OCR legibility. Returns the (possibly\n * unchanged) bytes — a preprocessor must never throw; on failure it returns the\n * input so recognition still runs.\n */\nexport type OcrPreprocessor = (image: Uint8Array) => Promise<Uint8Array>\n\n/** Tunables for the default {@link sharpPreprocessor}. */\nexport interface PreprocessOptions {\n /**\n * Upscale images narrower than this (px) to give the engine more pixels per\n * glyph — the single biggest win for the small OCR-B MRZ band. Default 1600.\n */\n minWidth?: number\n}\n\n/** A no-op preprocessor: passes the original bytes through unchanged. */\nexport const passthrough: OcrPreprocessor = async (image) => image\n\n/** Minimal structural type for the lazily-imported `sharp` module. */\ninterface SharpInstance {\n metadata(): Promise<{ width?: number; height?: number }>\n grayscale(): SharpInstance\n normalise(): SharpInstance\n median(size: number): SharpInstance\n sharpen(): SharpInstance\n resize(options: { width: number; withoutEnlargement: boolean }): SharpInstance\n png(): SharpInstance\n toBuffer(): Promise<Buffer>\n}\ntype SharpFactory = (input: Buffer) => SharpInstance\n\n/**\n * Build a preprocessor over an injected `sharp` factory: grayscale → contrast\n * normalise → light denoise → sharpen → upscale small images. This lifts faint\n * document text and the OCR-B MRZ band without the aggressive binarisation that\n * destroys detail under uneven lighting. Any failure falls back to the original.\n */\nexport function buildSharpPreprocessor(sharp: SharpFactory, options: PreprocessOptions = {}): OcrPreprocessor {\n const minWidth = options.minWidth ?? 1600\n return async (image) => {\n try {\n const input = Buffer.from(image)\n const base = sharp(input)\n const { width } = await base.metadata()\n let pipe = sharp(input).grayscale().normalise().median(1).sharpen()\n if (width && width < minWidth) {\n pipe = pipe.resize({ width: minWidth, withoutEnlargement: false })\n }\n const out = await pipe.png().toBuffer()\n return new Uint8Array(out)\n } catch {\n return image\n }\n }\n}\n\nlet cached: OcrPreprocessor | undefined\n\n/**\n * The default preprocessor, backed by the optional `sharp` package and resolved\n * once. If `sharp` isn't installed it degrades to {@link passthrough}, so OCR\n * still runs on the raw bytes (just without the legibility boost).\n */\nexport async function defaultPreprocessor(options: PreprocessOptions = {}): Promise<OcrPreprocessor> {\n if (cached) return cached\n const moduleName = 'sharp'\n try {\n const mod = (await import(/* @vite-ignore */ moduleName)) as unknown as { default: SharpFactory }\n cached = buildSharpPreprocessor(mod.default, options)\n } catch {\n cached = passthrough\n }\n return cached\n}\n"],"mappings":";;AAiBA,MAAa,cAA+B,OAAO,UAAU;;;;;;;AAqB7D,SAAgB,uBAAuB,OAAqB,UAA6B,CAAC,GAAoB;CAC5G,MAAM,WAAW,QAAQ,YAAY;CACrC,OAAO,OAAO,UAAU;EACtB,IAAI;GACF,MAAM,QAAQ,OAAO,KAAK,KAAK;GAE/B,MAAM,EAAE,UAAU,MADL,MAAM,KACQ,CAAC,CAAC,SAAS;GACtC,IAAI,OAAO,MAAM,KAAK,CAAC,CAAC,UAAU,CAAC,CAAC,UAAU,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,QAAQ;GAClE,IAAI,SAAS,QAAQ,UACnB,OAAO,KAAK,OAAO;IAAE,OAAO;IAAU,oBAAoB;GAAM,CAAC;GAEnE,MAAM,MAAM,MAAM,KAAK,IAAI,CAAC,CAAC,SAAS;GACtC,OAAO,IAAI,WAAW,GAAG;EAC3B,QAAQ;GACN,OAAO;EACT;CACF;AACF;AAEA,IAAI;;;;;;AAOJ,eAAsB,oBAAoB,UAA6B,CAAC,GAA6B;CACnG,IAAI,QAAQ,OAAO;CACnB,MAAM,aAAa;CACnB,IAAI;EAEF,SAAS,wBAAuB,MADb;;GAA0B;
|
|
1
|
+
{"version":3,"file":"preprocess.mjs","names":[],"sources":["../../src/drivers/preprocess.ts"],"sourcesContent":["/**\n * Transforms raw image bytes to improve OCR legibility. Returns the (possibly\n * unchanged) bytes — a preprocessor must never throw; on failure it returns the\n * input so recognition still runs.\n */\nexport type OcrPreprocessor = (image: Uint8Array) => Promise<Uint8Array>\n\n/** Tunables for the default {@link sharpPreprocessor}. */\nexport interface PreprocessOptions {\n /**\n * Upscale images narrower than this (px) to give the engine more pixels per\n * glyph — the single biggest win for the small OCR-B MRZ band. Default 1600.\n */\n minWidth?: number\n}\n\n/** A no-op preprocessor: passes the original bytes through unchanged. */\nexport const passthrough: OcrPreprocessor = async (image) => image\n\n/** Minimal structural type for the lazily-imported `sharp` module. */\ninterface SharpInstance {\n metadata(): Promise<{ width?: number; height?: number }>\n grayscale(): SharpInstance\n normalise(): SharpInstance\n median(size: number): SharpInstance\n sharpen(): SharpInstance\n resize(options: { width: number; withoutEnlargement: boolean }): SharpInstance\n png(): SharpInstance\n toBuffer(): Promise<Buffer>\n}\ntype SharpFactory = (input: Buffer) => SharpInstance\n\n/**\n * Build a preprocessor over an injected `sharp` factory: grayscale → contrast\n * normalise → light denoise → sharpen → upscale small images. This lifts faint\n * document text and the OCR-B MRZ band without the aggressive binarisation that\n * destroys detail under uneven lighting. Any failure falls back to the original.\n */\nexport function buildSharpPreprocessor(sharp: SharpFactory, options: PreprocessOptions = {}): OcrPreprocessor {\n const minWidth = options.minWidth ?? 1600\n return async (image) => {\n try {\n const input = Buffer.from(image)\n const base = sharp(input)\n const { width } = await base.metadata()\n let pipe = sharp(input).grayscale().normalise().median(1).sharpen()\n if (width && width < minWidth) {\n pipe = pipe.resize({ width: minWidth, withoutEnlargement: false })\n }\n const out = await pipe.png().toBuffer()\n return new Uint8Array(out)\n } catch {\n return image\n }\n }\n}\n\nlet cached: OcrPreprocessor | undefined\n\n/**\n * The default preprocessor, backed by the optional `sharp` package and resolved\n * once. If `sharp` isn't installed it degrades to {@link passthrough}, so OCR\n * still runs on the raw bytes (just without the legibility boost).\n */\nexport async function defaultPreprocessor(options: PreprocessOptions = {}): Promise<OcrPreprocessor> {\n if (cached) return cached\n const moduleName = 'sharp'\n try {\n const mod = (await import(/* @vite-ignore */ moduleName)) as unknown as { default: SharpFactory }\n cached = buildSharpPreprocessor(mod.default, options)\n } catch {\n cached = passthrough\n }\n return cached\n}\n"],"mappings":";;AAiBA,MAAa,cAA+B,OAAO,UAAU;;;;;;;AAqB7D,SAAgB,uBAAuB,OAAqB,UAA6B,CAAC,GAAoB;CAC5G,MAAM,WAAW,QAAQ,YAAY;CACrC,OAAO,OAAO,UAAU;EACtB,IAAI;GACF,MAAM,QAAQ,OAAO,KAAK,KAAK;GAE/B,MAAM,EAAE,UAAU,MADL,MAAM,KACQ,CAAC,CAAC,SAAS;GACtC,IAAI,OAAO,MAAM,KAAK,CAAC,CAAC,UAAU,CAAC,CAAC,UAAU,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,QAAQ;GAClE,IAAI,SAAS,QAAQ,UACnB,OAAO,KAAK,OAAO;IAAE,OAAO;IAAU,oBAAoB;GAAM,CAAC;GAEnE,MAAM,MAAM,MAAM,KAAK,IAAI,CAAC,CAAC,SAAS;GACtC,OAAO,IAAI,WAAW,GAAG;EAC3B,QAAQ;GACN,OAAO;EACT;CACF;AACF;AAEA,IAAI;;;;;;AAOJ,eAAsB,oBAAoB,UAA6B,CAAC,GAA6B;CACnG,IAAI,QAAQ,OAAO;CACnB,MAAM,aAAa;CACnB,IAAI;EAEF,SAAS,wBAAuB,MADb;;GAA0B;EACV,CAAC,SAAS,OAAO;CACtD,QAAQ;EACN,SAAS;CACX;CACA,OAAO;AACT"}
|
|
@@ -2,10 +2,9 @@ import { OcrDriver, OcrRequest } from "../types.mjs";
|
|
|
2
2
|
import { DocumentParserRegistry } from "../parsers/registry.mjs";
|
|
3
3
|
import { OcrPreprocessor } from "./preprocess.mjs";
|
|
4
4
|
import { OcrResultData } from "@arkyc/types";
|
|
5
|
-
|
|
6
5
|
//#region src/drivers/tesseract.d.ts
|
|
7
6
|
/** Options for a single recognition pass. */
|
|
8
|
-
interface RecognizeOptions {
|
|
7
|
+
export interface RecognizeOptions {
|
|
9
8
|
/**
|
|
10
9
|
* Constrain recognition to the {@link MRZ_CHARSET}. This forces ambiguous OCR-B
|
|
11
10
|
* glyphs into the machine-readable zone's alphabet (e.g. `O`→`0`, `I`→`1`),
|
|
@@ -14,11 +13,11 @@ interface RecognizeOptions {
|
|
|
14
13
|
mrz?: boolean;
|
|
15
14
|
}
|
|
16
15
|
/** Reads text from an image; returns text + an engine confidence in [0, 100]. */
|
|
17
|
-
type TesseractRecognize = (image: Uint8Array, language: string, options?: RecognizeOptions) => Promise<{
|
|
16
|
+
export type TesseractRecognize = (image: Uint8Array, language: string, options?: RecognizeOptions) => Promise<{
|
|
18
17
|
text: string;
|
|
19
18
|
confidence: number;
|
|
20
19
|
}>;
|
|
21
|
-
interface TesseractOcrOptions {
|
|
20
|
+
export interface TesseractOcrOptions {
|
|
22
21
|
/** Recognition language(s), default `eng`. */
|
|
23
22
|
language?: string;
|
|
24
23
|
/**
|
|
@@ -40,7 +39,7 @@ interface TesseractOcrOptions {
|
|
|
40
39
|
* runs it through the {@link DocumentParserRegistry} to extract structured fields.
|
|
41
40
|
* `tesseract.js` is imported lazily so it is only loaded when this driver runs.
|
|
42
41
|
*/
|
|
43
|
-
declare class TesseractOcrDriver implements OcrDriver {
|
|
42
|
+
export declare class TesseractOcrDriver implements OcrDriver {
|
|
44
43
|
readonly name = "tesseract";
|
|
45
44
|
private readonly language;
|
|
46
45
|
private readonly registry;
|
|
@@ -71,5 +70,4 @@ declare class TesseractOcrDriver implements OcrDriver {
|
|
|
71
70
|
private loadRecognizer;
|
|
72
71
|
}
|
|
73
72
|
//#endregion
|
|
74
|
-
export { RecognizeOptions, TesseractOcrDriver, TesseractOcrOptions, TesseractRecognize };
|
|
75
73
|
//# sourceMappingURL=tesseract.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tesseract.d.mts","names":[],"sources":["../../src/drivers/tesseract.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"tesseract.d.mts","names":[],"sources":["../../src/drivers/tesseract.ts"],"mappings":";;;;;;iBAeiB;;;;;;EAMf;;;YAIU,sBACV,OAAO,YACP,kBACA,UAAU,qBACP;EAAU;EAAc;;iBA0BZ;;EAEf;;;;;EAKA,WAAW;;EAEX,YAAY;;;;;;EAMZ,aAAa;;;;;;;qBAQF,8BAA8B;WAChC;mBACQ;mBACA;mBACA;mBACA;UACT;EAEI,YAAA,UAAS;EAOf,QAAQ,SAAS,aAAa,QAAQ;;UAoDpC;;UAYA;;;;;;;;;;UA2BA;;UAOM;;UAcA;;UASA"}
|
package/dist/index.d.mts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AiExtraction, AiOcrProvider, AiVisionExtract, AnthropicOcrDriver, AnthropicOcrOptions, DEFAULT_AI_MODEL, DEFAULT_WORKERS_AI_MODEL, anthropicVision, applyAuthenticity, scoreConfidence, workersAiVision } from "./drivers/ai.mjs";
|
|
1
2
|
import { OcrConfig, OcrDriver, OcrDriverName, OcrRequest } from "./types.mjs";
|
|
2
3
|
import { OcrDriverFactory } from "./registry.mjs";
|
|
3
4
|
import { MockOcrDriver } from "./drivers/mock.mjs";
|
|
@@ -6,7 +7,7 @@ import { DocumentParser, ParseInput, ParseOutput } from "./parsers/types.mjs";
|
|
|
6
7
|
import { DocumentParserRegistry, ParseStage, createDocumentParserRegistry } from "./parsers/registry.mjs";
|
|
7
8
|
import { OcrPreprocessor, PreprocessOptions, buildSharpPreprocessor, defaultPreprocessor, passthrough } from "./drivers/preprocess.mjs";
|
|
8
9
|
import { RecognizeOptions, TesseractOcrDriver, TesseractOcrOptions, TesseractRecognize } from "./drivers/tesseract.mjs";
|
|
9
|
-
import { AiExtraction, AiVisionExtract, AnthropicOcrDriver, AnthropicOcrOptions, DEFAULT_AI_MODEL, anthropicVision, applyAuthenticity, scoreConfidence } from "./drivers/ai.mjs";
|
|
10
10
|
import { mrzParser } from "./parsers/mrz.mjs";
|
|
11
11
|
import { genericTextParser } from "./parsers/generic.mjs";
|
|
12
|
-
|
|
12
|
+
import { DocumentCheckInput, DocumentChecks, DocumentIssue, DocumentIssueCode, ageOn } from "./checks.mjs";
|
|
13
|
+
export { type AiExtraction, type AiOcrProvider, type AiVisionExtract, AnthropicOcrDriver, type AnthropicOcrOptions, DEFAULT_AI_MODEL, DEFAULT_WORKERS_AI_MODEL, type DocumentCheckInput, DocumentChecks, type DocumentIssue, type DocumentIssueCode, type DocumentParser, DocumentParserRegistry, ExternalOcrDriver, MockOcrDriver, OcrConfig, OcrDriver, OcrDriverFactory, OcrDriverName, type OcrPreprocessor, OcrRequest, type ParseInput, type ParseOutput, type ParseStage, type PreprocessOptions, type RecognizeOptions, TesseractOcrDriver, type TesseractOcrOptions, type TesseractRecognize, ageOn, anthropicVision, applyAuthenticity, buildSharpPreprocessor, createDocumentParserRegistry, defaultPreprocessor, genericTextParser, mrzParser, passthrough, scoreConfidence, workersAiVision };
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AnthropicOcrDriver, DEFAULT_AI_MODEL, anthropicVision, applyAuthenticity, scoreConfidence } from "./drivers/ai.mjs";
|
|
1
|
+
import { AnthropicOcrDriver, DEFAULT_AI_MODEL, DEFAULT_WORKERS_AI_MODEL, anthropicVision, applyAuthenticity, scoreConfidence, workersAiVision } from "./drivers/ai.mjs";
|
|
2
2
|
import { ExternalOcrDriver } from "./drivers/external.mjs";
|
|
3
3
|
import { MockOcrDriver } from "./drivers/mock.mjs";
|
|
4
4
|
import { mrzParser } from "./parsers/mrz.mjs";
|
|
@@ -7,4 +7,5 @@ import { DocumentParserRegistry, createDocumentParserRegistry } from "./parsers/
|
|
|
7
7
|
import { buildSharpPreprocessor, defaultPreprocessor, passthrough } from "./drivers/preprocess.mjs";
|
|
8
8
|
import { TesseractOcrDriver } from "./drivers/tesseract.mjs";
|
|
9
9
|
import { OcrDriverFactory } from "./registry.mjs";
|
|
10
|
-
|
|
10
|
+
import { DocumentChecks, ageOn } from "./checks.mjs";
|
|
11
|
+
export { AnthropicOcrDriver, DEFAULT_AI_MODEL, DEFAULT_WORKERS_AI_MODEL, DocumentChecks, DocumentParserRegistry, ExternalOcrDriver, MockOcrDriver, OcrDriverFactory, TesseractOcrDriver, ageOn, anthropicVision, applyAuthenticity, buildSharpPreprocessor, createDocumentParserRegistry, defaultPreprocessor, genericTextParser, mrzParser, passthrough, scoreConfidence, workersAiVision };
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { DocumentParser } from "./types.mjs";
|
|
2
|
-
|
|
3
2
|
//#region src/parsers/generic.d.ts
|
|
4
3
|
/** The generic best-effort text parser. */
|
|
5
|
-
declare function genericTextParser(): DocumentParser;
|
|
4
|
+
export declare function genericTextParser(): DocumentParser;
|
|
6
5
|
//#endregion
|
|
7
|
-
export { genericTextParser };
|
|
8
6
|
//# sourceMappingURL=generic.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generic.d.mts","names":[],"sources":["../../src/parsers/generic.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"generic.d.mts","names":[],"sources":["../../src/parsers/generic.ts"],"mappings":";;;wBA0FgB,qBAAqB"}
|
package/dist/parsers/mrz.d.mts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { DocumentParser } from "./types.mjs";
|
|
2
|
-
|
|
3
2
|
//#region src/parsers/mrz.d.ts
|
|
4
3
|
/** The MRZ parser — a country-agnostic default for machine-readable documents. */
|
|
5
|
-
declare function mrzParser(): DocumentParser;
|
|
4
|
+
export declare function mrzParser(): DocumentParser;
|
|
6
5
|
//#endregion
|
|
7
|
-
export { mrzParser };
|
|
8
6
|
//# sourceMappingURL=mrz.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mrz.d.mts","names":[],"sources":["../../src/parsers/mrz.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"mrz.d.mts","names":[],"sources":["../../src/parsers/mrz.ts"],"mappings":";;;wBAkKgB,aAAa"}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { DocumentParser, ParseInput, ParseOutput } from "./types.mjs";
|
|
2
2
|
import { DocumentType } from "@arkyc/types";
|
|
3
|
-
|
|
4
3
|
//#region src/parsers/registry.d.ts
|
|
5
4
|
/** Which stage of the pipeline produced a parse result. */
|
|
6
|
-
type ParseStage = 'mrz' | 'custom' | 'generic';
|
|
5
|
+
export type ParseStage = 'mrz' | 'custom' | 'generic';
|
|
7
6
|
/**
|
|
8
7
|
* A registry of document parsers, resolved in three stages for each document:
|
|
9
8
|
*
|
|
@@ -15,7 +14,7 @@ type ParseStage = 'mrz' | 'custom' | 'generic';
|
|
|
15
14
|
* Custom parsers declare the countries and document types they apply to; register
|
|
16
15
|
* one per document layout the MRZ + generic stages can't read.
|
|
17
16
|
*/
|
|
18
|
-
declare class DocumentParserRegistry {
|
|
17
|
+
export declare class DocumentParserRegistry {
|
|
19
18
|
private readonly primary;
|
|
20
19
|
private readonly fallback;
|
|
21
20
|
private readonly parsers;
|
|
@@ -28,6 +27,11 @@ declare class DocumentParserRegistry {
|
|
|
28
27
|
register(parser: DocumentParser): this;
|
|
29
28
|
/** The applicable custom parsers for a selection, most specific first. */
|
|
30
29
|
candidates(country?: string | null, documentType?: DocumentType | null): DocumentParser[];
|
|
30
|
+
/**
|
|
31
|
+
* The visual-zone fields a custom parser reads next to an MRZ, kept so the two
|
|
32
|
+
* zones can be compared.
|
|
33
|
+
*/
|
|
34
|
+
private visualZone;
|
|
31
35
|
/**
|
|
32
36
|
* Parse fields from OCR text: MRZ first, then the matched custom parsers, then
|
|
33
37
|
* the generic fallback. Always returns a result (empty fields, confidence 0,
|
|
@@ -40,10 +44,9 @@ declare class DocumentParserRegistry {
|
|
|
40
44
|
* parser as the fallback. Register country/document-type-specific custom parsers
|
|
41
45
|
* on top for documents the MRZ + generic stages can't read.
|
|
42
46
|
*/
|
|
43
|
-
declare function createDocumentParserRegistry(options?: {
|
|
47
|
+
export declare function createDocumentParserRegistry(options?: {
|
|
44
48
|
primary?: DocumentParser;
|
|
45
49
|
fallback?: DocumentParser;
|
|
46
50
|
}): DocumentParserRegistry;
|
|
47
51
|
//#endregion
|
|
48
|
-
export { DocumentParserRegistry, ParseStage, createDocumentParserRegistry };
|
|
49
52
|
//# sourceMappingURL=registry.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registry.d.mts","names":[],"sources":["../../src/parsers/registry.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"registry.d.mts","names":[],"sources":["../../src/parsers/registry.ts"],"mappings":";;;;YA0BY;;;;;;;;;;;;qBAaC;mBAQQ;mBACA;mBARF;;;;;EAOE,YAAA,SAAS,gBACT,UAAU;;EAI7B,SAAS,QAAQ;;EAMjB,WAAW,yBAAyB,eAAe,sBAAsB;;;;;UAYjE;;;;;;EAcR,MAAM,OAAO,aAAa;;;;;;;wBAmCZ,6BACd;EAAW,UAAU;EAAgB,WAAW;IAC/C"}
|
|
@@ -54,13 +54,34 @@ var DocumentParserRegistry = class {
|
|
|
54
54
|
})).filter((entry) => entry.score >= 0).sort((a, b) => b.score - a.score || a.index - b.index).map((entry) => entry.parser);
|
|
55
55
|
}
|
|
56
56
|
/**
|
|
57
|
+
* The visual-zone fields a custom parser reads next to an MRZ, kept so the two
|
|
58
|
+
* zones can be compared.
|
|
59
|
+
*/
|
|
60
|
+
visualZone(input) {
|
|
61
|
+
for (const parser of this.candidates(input.country, input.documentType)) {
|
|
62
|
+
const out = parser.parse(input);
|
|
63
|
+
if (out) return out.fields;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
57
68
|
* Parse fields from OCR text: MRZ first, then the matched custom parsers, then
|
|
58
69
|
* the generic fallback. Always returns a result (empty fields, confidence 0,
|
|
59
70
|
* when nothing could parse).
|
|
60
71
|
*/
|
|
61
72
|
parse(input) {
|
|
62
73
|
const fromMrz = this.primary.parse(input);
|
|
63
|
-
if (fromMrz)
|
|
74
|
+
if (fromMrz) {
|
|
75
|
+
const tagged = tag(fromMrz, this.primary.name, "mrz");
|
|
76
|
+
const visual = this.visualZone(input);
|
|
77
|
+
return visual ? {
|
|
78
|
+
...tagged,
|
|
79
|
+
raw: {
|
|
80
|
+
...tagged.raw,
|
|
81
|
+
visual
|
|
82
|
+
}
|
|
83
|
+
} : tagged;
|
|
84
|
+
}
|
|
64
85
|
for (const parser of this.candidates(input.country, input.documentType)) {
|
|
65
86
|
const out = parser.parse(input);
|
|
66
87
|
if (out) return tag(out, parser.name, "custom");
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registry.mjs","names":[],"sources":["../../src/parsers/registry.ts"],"sourcesContent":["import type { DocumentType } from '@arkyc/types'\nimport type { DocumentParser, ParseInput, ParseOutput } from './types'\nimport { mrzParser } from './mrz'\nimport { genericTextParser } from './generic'\n\nfunction eqCountry(a: string, b: string): boolean {\n return a.trim().toUpperCase() === b.trim().toUpperCase()\n}\n\n/**\n * Specificity score for a parser against the user's selection, or -1 if it does\n * not apply. A parser scoped to a country scores higher than one scoped only to a\n * document type, so the most specific applicable parser wins.\n */\nfunction matchScore(parser: DocumentParser, country?: string | null, documentType?: DocumentType | null): number {\n const scopedCountries = parser.countries?.length ? parser.countries : null\n const scopedTypes = parser.documentTypes?.length ? parser.documentTypes : null\n\n const countryOk = !scopedCountries || (!!country && scopedCountries.some((c) => eqCountry(c, country)))\n const typeOk = !scopedTypes || (!!documentType && scopedTypes.includes(documentType))\n if (!countryOk || !typeOk) return -1\n\n return (scopedCountries ? 2 : 0) + (scopedTypes ? 1 : 0)\n}\n\n/** Which stage of the pipeline produced a parse result. */\nexport type ParseStage = 'mrz' | 'custom' | 'generic'\n\n/**\n * A registry of document parsers, resolved in three stages for each document:\n *\n * 1. **MRZ** — the machine-readable zone (reliable, check-digit-verified) when present.\n * 2. **Custom** — country/document-type-specific parsers (registered via {@link register}),\n * matched against the user's selection (most specific first) when the MRZ fails.\n * 3. **Generic** — a best-effort text scraper, the last resort.\n *\n * Custom parsers declare the countries and document types they apply to; register\n * one per document layout the MRZ + generic stages can't read.\n */\nexport class DocumentParserRegistry {\n private readonly parsers: DocumentParser[] = []\n\n /**\n * @param primary Tried first for every document (the MRZ parser).\n * @param fallback Tried last when nothing else extracts anything (generic).\n */\n constructor(\n private readonly primary: DocumentParser,\n private readonly fallback: DocumentParser,\n ) {}\n\n /** Register a custom country/type-specific parser. Returns `this` for chaining. */\n register(parser: DocumentParser): this {\n this.parsers.push(parser)\n return this\n }\n\n /** The applicable custom parsers for a selection, most specific first. */\n candidates(country?: string | null, documentType?: DocumentType | null): DocumentParser[] {\n return this.parsers\n .map((parser, index) => ({ parser, index, score: matchScore(parser, country, documentType) }))\n .filter((entry) => entry.score >= 0)\n .sort((a, b) => b.score - a.score || a.index - b.index)\n .map((entry) => entry.parser)\n }\n\n /**\n * Parse fields from OCR text: MRZ first, then the matched custom parsers, then\n * the generic fallback. Always returns a result (empty fields, confidence 0,\n * when nothing could parse).\n */\n parse(input: ParseInput): ParseOutput {\n // 1. MRZ — the gold standard when a machine-readable zone is present.\n const fromMrz = this.primary.parse(input)\n if (fromMrz)
|
|
1
|
+
{"version":3,"file":"registry.mjs","names":[],"sources":["../../src/parsers/registry.ts"],"sourcesContent":["import type { DocumentType } from '@arkyc/types'\nimport type { DocumentParser, ParseInput, ParseOutput } from './types'\nimport { mrzParser } from './mrz'\nimport { genericTextParser } from './generic'\n\nfunction eqCountry(a: string, b: string): boolean {\n return a.trim().toUpperCase() === b.trim().toUpperCase()\n}\n\n/**\n * Specificity score for a parser against the user's selection, or -1 if it does\n * not apply. A parser scoped to a country scores higher than one scoped only to a\n * document type, so the most specific applicable parser wins.\n */\nfunction matchScore(parser: DocumentParser, country?: string | null, documentType?: DocumentType | null): number {\n const scopedCountries = parser.countries?.length ? parser.countries : null\n const scopedTypes = parser.documentTypes?.length ? parser.documentTypes : null\n\n const countryOk = !scopedCountries || (!!country && scopedCountries.some((c) => eqCountry(c, country)))\n const typeOk = !scopedTypes || (!!documentType && scopedTypes.includes(documentType))\n if (!countryOk || !typeOk) return -1\n\n return (scopedCountries ? 2 : 0) + (scopedTypes ? 1 : 0)\n}\n\n/** Which stage of the pipeline produced a parse result. */\nexport type ParseStage = 'mrz' | 'custom' | 'generic'\n\n/**\n * A registry of document parsers, resolved in three stages for each document:\n *\n * 1. **MRZ** — the machine-readable zone (reliable, check-digit-verified) when present.\n * 2. **Custom** — country/document-type-specific parsers (registered via {@link register}),\n * matched against the user's selection (most specific first) when the MRZ fails.\n * 3. **Generic** — a best-effort text scraper, the last resort.\n *\n * Custom parsers declare the countries and document types they apply to; register\n * one per document layout the MRZ + generic stages can't read.\n */\nexport class DocumentParserRegistry {\n private readonly parsers: DocumentParser[] = []\n\n /**\n * @param primary Tried first for every document (the MRZ parser).\n * @param fallback Tried last when nothing else extracts anything (generic).\n */\n constructor(\n private readonly primary: DocumentParser,\n private readonly fallback: DocumentParser,\n ) {}\n\n /** Register a custom country/type-specific parser. Returns `this` for chaining. */\n register(parser: DocumentParser): this {\n this.parsers.push(parser)\n return this\n }\n\n /** The applicable custom parsers for a selection, most specific first. */\n candidates(country?: string | null, documentType?: DocumentType | null): DocumentParser[] {\n return this.parsers\n .map((parser, index) => ({ parser, index, score: matchScore(parser, country, documentType) }))\n .filter((entry) => entry.score >= 0)\n .sort((a, b) => b.score - a.score || a.index - b.index)\n .map((entry) => entry.parser)\n }\n\n /**\n * The visual-zone fields a custom parser reads next to an MRZ, kept so the two\n * zones can be compared.\n */\n private visualZone(input: ParseInput): ParseOutput['fields'] | null {\n for (const parser of this.candidates(input.country, input.documentType)) {\n const out = parser.parse(input)\n if (out) return out.fields\n }\n\n return null\n }\n\n /**\n * Parse fields from OCR text: MRZ first, then the matched custom parsers, then\n * the generic fallback. Always returns a result (empty fields, confidence 0,\n * when nothing could parse).\n */\n parse(input: ParseInput): ParseOutput {\n // 1. MRZ — the gold standard when a machine-readable zone is present.\n const fromMrz = this.primary.parse(input)\n if (fromMrz) {\n const tagged = tag(fromMrz, this.primary.name, 'mrz')\n const visual = this.visualZone(input)\n\n return visual ? { ...tagged, raw: { ...(tagged.raw as object), visual } } : tagged\n }\n\n // 2. Custom country/type-specific parsers, most specific first.\n for (const parser of this.candidates(input.country, input.documentType)) {\n const out = parser.parse(input)\n if (out) return tag(out, parser.name, 'custom')\n }\n\n // 3. Generic best-effort extraction.\n const fromGeneric = this.fallback.parse(input)\n if (fromGeneric) return tag(fromGeneric, this.fallback.name, 'generic')\n\n return { fields: {}, confidence: 0, raw: { parser: 'none' } }\n }\n}\n\n/** Annotate a parse result with which parser + stage produced it. */\nfunction tag(out: ParseOutput, parser: string, stage: ParseStage): ParseOutput {\n const detail = out.raw && typeof out.raw === 'object' ? out.raw : { value: out.raw }\n return { ...out, raw: { parser, stage, ...detail } }\n}\n\n/**\n * Create a registry with the MRZ parser as the primary stage and the generic text\n * parser as the fallback. Register country/document-type-specific custom parsers\n * on top for documents the MRZ + generic stages can't read.\n */\nexport function createDocumentParserRegistry(\n options: { primary?: DocumentParser; fallback?: DocumentParser } = {},\n): DocumentParserRegistry {\n return new DocumentParserRegistry(options.primary ?? mrzParser(), options.fallback ?? genericTextParser())\n}\n"],"mappings":";;;AAKA,SAAS,UAAU,GAAW,GAAoB;CAChD,OAAO,EAAE,KAAK,CAAC,CAAC,YAAY,MAAM,EAAE,KAAK,CAAC,CAAC,YAAY;AACzD;;;;;;AAOA,SAAS,WAAW,QAAwB,SAAyB,cAA4C;CAC/G,MAAM,kBAAkB,OAAO,WAAW,SAAS,OAAO,YAAY;CACtE,MAAM,cAAc,OAAO,eAAe,SAAS,OAAO,gBAAgB;CAE1E,MAAM,YAAY,CAAC,mBAAoB,CAAC,CAAC,WAAW,gBAAgB,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC;CACrG,MAAM,SAAS,CAAC,eAAgB,CAAC,CAAC,gBAAgB,YAAY,SAAS,YAAY;CACnF,IAAI,CAAC,aAAa,CAAC,QAAQ,OAAO;CAElC,QAAQ,kBAAkB,IAAI,MAAM,cAAc,IAAI;AACxD;;;;;;;;;;;;AAgBA,IAAa,yBAAb,MAAoC;CAQf;CACA;CARnB,UAA6C,CAAC;;;;;CAM9C,YACE,SACA,UACA;EAFiB,KAAA,UAAA;EACA,KAAA,WAAA;CAChB;;CAGH,SAAS,QAA8B;EACrC,KAAK,QAAQ,KAAK,MAAM;EACxB,OAAO;CACT;;CAGA,WAAW,SAAyB,cAAsD;EACxF,OAAO,KAAK,QACT,KAAK,QAAQ,WAAW;GAAE;GAAQ;GAAO,OAAO,WAAW,QAAQ,SAAS,YAAY;EAAE,EAAE,CAAC,CAC7F,QAAQ,UAAU,MAAM,SAAS,CAAC,CAAC,CACnC,MAAM,GAAG,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,QAAQ,EAAE,KAAK,CAAC,CACtD,KAAK,UAAU,MAAM,MAAM;CAChC;;;;;CAMA,WAAmB,OAAiD;EAClE,KAAK,MAAM,UAAU,KAAK,WAAW,MAAM,SAAS,MAAM,YAAY,GAAG;GACvE,MAAM,MAAM,OAAO,MAAM,KAAK;GAC9B,IAAI,KAAK,OAAO,IAAI;EACtB;EAEA,OAAO;CACT;;;;;;CAOA,MAAM,OAAgC;EAEpC,MAAM,UAAU,KAAK,QAAQ,MAAM,KAAK;EACxC,IAAI,SAAS;GACX,MAAM,SAAS,IAAI,SAAS,KAAK,QAAQ,MAAM,KAAK;GACpD,MAAM,SAAS,KAAK,WAAW,KAAK;GAEpC,OAAO,SAAS;IAAE,GAAG;IAAQ,KAAK;KAAE,GAAI,OAAO;KAAgB;IAAO;GAAE,IAAI;EAC9E;EAGA,KAAK,MAAM,UAAU,KAAK,WAAW,MAAM,SAAS,MAAM,YAAY,GAAG;GACvE,MAAM,MAAM,OAAO,MAAM,KAAK;GAC9B,IAAI,KAAK,OAAO,IAAI,KAAK,OAAO,MAAM,QAAQ;EAChD;EAGA,MAAM,cAAc,KAAK,SAAS,MAAM,KAAK;EAC7C,IAAI,aAAa,OAAO,IAAI,aAAa,KAAK,SAAS,MAAM,SAAS;EAEtE,OAAO;GAAE,QAAQ,CAAC;GAAG,YAAY;GAAG,KAAK,EAAE,QAAQ,OAAO;EAAE;CAC9D;AACF;;AAGA,SAAS,IAAI,KAAkB,QAAgB,OAAgC;CAC7E,MAAM,SAAS,IAAI,OAAO,OAAO,IAAI,QAAQ,WAAW,IAAI,MAAM,EAAE,OAAO,IAAI,IAAI;CACnF,OAAO;EAAE,GAAG;EAAK,KAAK;GAAE;GAAQ;GAAO,GAAG;EAAO;CAAE;AACrD;;;;;;AAOA,SAAgB,6BACd,UAAmE,CAAC,GAC5C;CACxB,OAAO,IAAI,uBAAuB,QAAQ,WAAW,UAAU,GAAG,QAAQ,YAAY,kBAAkB,CAAC;AAC3G"}
|
package/dist/parsers/types.d.mts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { DocumentType, OcrFields } from "@arkyc/types";
|
|
2
|
-
|
|
3
2
|
//#region src/parsers/types.d.ts
|
|
4
3
|
/** The OCR text + context handed to a document parser. */
|
|
5
|
-
interface ParseInput {
|
|
4
|
+
export interface ParseInput {
|
|
6
5
|
/** Full OCR text for the document (raw engine output). */
|
|
7
6
|
text: string;
|
|
8
7
|
/** Pre-split lines; derived from `text` when omitted. */
|
|
@@ -13,7 +12,7 @@ interface ParseInput {
|
|
|
13
12
|
country?: string | null;
|
|
14
13
|
}
|
|
15
14
|
/** A parser's structured result. */
|
|
16
|
-
interface ParseOutput {
|
|
15
|
+
export interface ParseOutput {
|
|
17
16
|
fields: OcrFields;
|
|
18
17
|
/** Confidence in this parse, 0–1. */
|
|
19
18
|
confidence: number;
|
|
@@ -26,7 +25,7 @@ interface ParseOutput {
|
|
|
26
25
|
* those against the user's selection to pick the most specific parser, and falls
|
|
27
26
|
* back to the default when none match (or none can parse the text).
|
|
28
27
|
*/
|
|
29
|
-
interface DocumentParser {
|
|
28
|
+
export interface DocumentParser {
|
|
30
29
|
readonly name: string;
|
|
31
30
|
/**
|
|
32
31
|
* ISO-3166 country codes (alpha-2 or alpha-3) this parser applies to. Omit (or
|
|
@@ -39,5 +38,4 @@ interface DocumentParser {
|
|
|
39
38
|
parse(input: ParseInput): ParseOutput | null;
|
|
40
39
|
}
|
|
41
40
|
//#endregion
|
|
42
|
-
export { DocumentParser, ParseInput, ParseOutput };
|
|
43
41
|
//# sourceMappingURL=types.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.mts","names":[],"sources":["../../src/parsers/types.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"types.d.mts","names":[],"sources":["../../src/parsers/types.ts"],"mappings":";;;iBAGiB;;EAEf;;EAEA;;EAEA,eAAe;;EAEf;;;iBAIe;EACf,QAAQ;;EAER;;EAEA;;;;;;;;iBASe;WACN;;;;;WAKA;;WAEA,yBAAyB;;EAElC,MAAM,OAAO,aAAa"}
|
package/dist/registry.d.mts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { OcrConfig, OcrDriver } from "./types.mjs";
|
|
2
|
-
|
|
3
2
|
//#region src/registry.d.ts
|
|
4
3
|
/**
|
|
5
4
|
* Selects an OCR driver from config. Call sites depend only on the
|
|
6
5
|
* {@link OcrDriver} interface, so swapping `config.driver` changes behaviour
|
|
7
6
|
* with no other changes.
|
|
8
7
|
*/
|
|
9
|
-
declare class OcrDriverFactory {
|
|
8
|
+
export declare class OcrDriverFactory {
|
|
10
9
|
/**
|
|
11
10
|
* Resolve the OCR driver named by `config`.
|
|
12
11
|
*
|
|
@@ -16,5 +15,4 @@ declare class OcrDriverFactory {
|
|
|
16
15
|
static create(config: OcrConfig): OcrDriver;
|
|
17
16
|
}
|
|
18
17
|
//#endregion
|
|
19
|
-
export { OcrDriverFactory };
|
|
20
18
|
//# sourceMappingURL=registry.d.mts.map
|
package/dist/registry.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registry.d.mts","names":[],"sources":["../src/registry.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"registry.d.mts","names":[],"sources":["../src/registry.ts"],"mappings":";;;;;;;qBAYa;;;;;;;SAOJ,OAAO,QAAQ,YAAY"}
|
package/dist/registry.mjs
CHANGED
|
@@ -21,6 +21,8 @@ var OcrDriverFactory = class {
|
|
|
21
21
|
case "tesseract": return new TesseractOcrDriver({ language: config.language });
|
|
22
22
|
case "external": return new ExternalOcrDriver(config);
|
|
23
23
|
case "ai": return new AnthropicOcrDriver({
|
|
24
|
+
provider: config.aiProvider,
|
|
25
|
+
accountId: config.accountId,
|
|
24
26
|
apiKey: config.apiKey,
|
|
25
27
|
model: config.model,
|
|
26
28
|
baseUrl: config.endpoint,
|
package/dist/registry.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registry.mjs","names":[],"sources":["../src/registry.ts"],"sourcesContent":["import type { OcrConfig, OcrDriver } from './types'\n\nimport { AnthropicOcrDriver } from './drivers/ai'\nimport { ExternalOcrDriver } from './drivers/external'\nimport { MockOcrDriver } from './drivers/mock'\nimport { TesseractOcrDriver } from './drivers/tesseract'\n\n/**\n * Selects an OCR driver from config. Call sites depend only on the\n * {@link OcrDriver} interface, so swapping `config.driver` changes behaviour\n * with no other changes.\n */\nexport class OcrDriverFactory {\n /**\n * Resolve the OCR driver named by `config`.\n *\n * @param config\n * @returns\n */\n static create(config: OcrConfig): OcrDriver {\n switch (config.driver) {\n case 'mock':\n return new MockOcrDriver()\n case 'tesseract':\n return new TesseractOcrDriver({ language: config.language })\n case 'external':\n return new ExternalOcrDriver(config)\n case 'ai':\n return new AnthropicOcrDriver({\n apiKey: config.apiKey,\n model: config.model,\n baseUrl: config.endpoint,\n maxEdge: config.maxEdge,\n })\n default:\n throw new Error(`Unknown OCR driver: ${(config as OcrConfig).driver}`)\n }\n }\n}\n"],"mappings":";;;;;;;;;;AAYA,IAAa,mBAAb,MAA8B;;;;;;;CAO5B,OAAO,OAAO,QAA8B;EAC1C,QAAQ,OAAO,QAAf;GACE,KAAK,QACH,OAAO,IAAI,cAAc;GAC3B,KAAK,aACH,OAAO,IAAI,mBAAmB,EAAE,UAAU,OAAO,SAAS,CAAC;GAC7D,KAAK,YACH,OAAO,IAAI,kBAAkB,MAAM;GACrC,KAAK,MACH,OAAO,IAAI,mBAAmB;IAC5B,QAAQ,OAAO;IACf,OAAO,OAAO;IACd,SAAS,OAAO;IAChB,SAAS,OAAO;GAClB,CAAC;GACH,SACE,MAAM,IAAI,MAAM,uBAAwB,OAAqB,QAAQ;EACzE;CACF;AACF"}
|
|
1
|
+
{"version":3,"file":"registry.mjs","names":[],"sources":["../src/registry.ts"],"sourcesContent":["import type { OcrConfig, OcrDriver } from './types'\n\nimport { AnthropicOcrDriver } from './drivers/ai'\nimport { ExternalOcrDriver } from './drivers/external'\nimport { MockOcrDriver } from './drivers/mock'\nimport { TesseractOcrDriver } from './drivers/tesseract'\n\n/**\n * Selects an OCR driver from config. Call sites depend only on the\n * {@link OcrDriver} interface, so swapping `config.driver` changes behaviour\n * with no other changes.\n */\nexport class OcrDriverFactory {\n /**\n * Resolve the OCR driver named by `config`.\n *\n * @param config\n * @returns\n */\n static create(config: OcrConfig): OcrDriver {\n switch (config.driver) {\n case 'mock':\n return new MockOcrDriver()\n case 'tesseract':\n return new TesseractOcrDriver({ language: config.language })\n case 'external':\n return new ExternalOcrDriver(config)\n case 'ai':\n return new AnthropicOcrDriver({\n provider: config.aiProvider,\n accountId: config.accountId,\n apiKey: config.apiKey,\n model: config.model,\n baseUrl: config.endpoint,\n maxEdge: config.maxEdge,\n })\n default:\n throw new Error(`Unknown OCR driver: ${(config as OcrConfig).driver}`)\n }\n }\n}\n"],"mappings":";;;;;;;;;;AAYA,IAAa,mBAAb,MAA8B;;;;;;;CAO5B,OAAO,OAAO,QAA8B;EAC1C,QAAQ,OAAO,QAAf;GACE,KAAK,QACH,OAAO,IAAI,cAAc;GAC3B,KAAK,aACH,OAAO,IAAI,mBAAmB,EAAE,UAAU,OAAO,SAAS,CAAC;GAC7D,KAAK,YACH,OAAO,IAAI,kBAAkB,MAAM;GACrC,KAAK,MACH,OAAO,IAAI,mBAAmB;IAC5B,UAAU,OAAO;IACjB,WAAW,OAAO;IAClB,QAAQ,OAAO;IACf,OAAO,OAAO;IACd,SAAS,OAAO;IAChB,SAAS,OAAO;GAClB,CAAC;GACH,SACE,MAAM,IAAI,MAAM,uBAAwB,OAAqB,QAAQ;EACzE;CACF;AACF"}
|
package/dist/types.d.mts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
+
import { AiOcrProvider } from "./drivers/ai.mjs";
|
|
1
2
|
import { DocumentType, OcrResultData } from "@arkyc/types";
|
|
2
|
-
|
|
3
3
|
//#region src/types.d.ts
|
|
4
4
|
/** The bytes + context handed to an OCR driver for extraction. */
|
|
5
|
-
interface OcrRequest {
|
|
5
|
+
export interface OcrRequest {
|
|
6
6
|
/** Raw document-front image bytes. */
|
|
7
7
|
image: Uint8Array;
|
|
8
8
|
/**
|
|
@@ -16,33 +16,39 @@ interface OcrRequest {
|
|
|
16
16
|
country?: string | null;
|
|
17
17
|
/**
|
|
18
18
|
* Optional deterministic signals (used by the `mock` driver and tests to
|
|
19
|
-
* steer the extracted confidence
|
|
19
|
+
* steer the extracted identity, confidence and expiry). Ignored by real drivers.
|
|
20
20
|
*/
|
|
21
21
|
hints?: {
|
|
22
22
|
confidence?: number;
|
|
23
23
|
expired?: boolean;
|
|
24
|
+
fullName?: string;
|
|
25
|
+
dateOfBirth?: string;
|
|
26
|
+
documentNumber?: string;
|
|
24
27
|
};
|
|
25
28
|
}
|
|
26
29
|
/** A pluggable OCR provider. */
|
|
27
|
-
interface OcrDriver {
|
|
30
|
+
export interface OcrDriver {
|
|
28
31
|
readonly name: string;
|
|
29
32
|
extract(request: OcrRequest): Promise<OcrResultData>;
|
|
30
33
|
}
|
|
31
34
|
/** Identifier for a registered OCR driver. */
|
|
32
|
-
type OcrDriverName = 'mock' | 'tesseract' | 'external' | 'ai';
|
|
35
|
+
export type OcrDriverName = 'mock' | 'tesseract' | 'external' | 'ai';
|
|
33
36
|
/** Configuration selecting + parameterising the active OCR driver. */
|
|
34
|
-
interface OcrConfig {
|
|
37
|
+
export interface OcrConfig {
|
|
35
38
|
driver: OcrDriverName;
|
|
36
39
|
/** Base URL for the `external` driver, or API base override for `ai`. */
|
|
37
40
|
endpoint?: string;
|
|
38
41
|
apiKey?: string;
|
|
39
42
|
/** Tesseract language(s), e.g. `eng` (the `tesseract` driver). */
|
|
40
43
|
language?: string;
|
|
44
|
+
/** Vision backend for the `ai` driver (`anthropic` default, or `workers-ai`). */
|
|
45
|
+
aiProvider?: AiOcrProvider;
|
|
46
|
+
/** Cloudflare account id (the `ai` driver with `aiProvider: 'workers-ai'`). */
|
|
47
|
+
accountId?: string;
|
|
41
48
|
/** Vision model id (the `ai` driver). */
|
|
42
49
|
model?: string;
|
|
43
50
|
/** Longest image edge (px) uploaded by the `ai` driver. */
|
|
44
51
|
maxEdge?: number;
|
|
45
52
|
}
|
|
46
53
|
//#endregion
|
|
47
|
-
export { OcrConfig, OcrDriver, OcrDriverName, OcrRequest };
|
|
48
54
|
//# sourceMappingURL=types.d.mts.map
|
package/dist/types.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.mts","names":[],"sources":["../src/types.ts"],"mappings":";;;;
|
|
1
|
+
{"version":3,"file":"types.d.mts","names":[],"sources":["../src/types.ts"],"mappings":";;;;iBAIiB;;EAEf,OAAO;;;;;EAKP,YAAY;;EAEZ,eAAe;;EAEf;;;;;EAKA;IACE;IACA;IACA;IACA;IACA;;;;iBAKa;WACN;EACT,QAAQ,SAAS,aAAa,QAAQ;;;YAI5B;;iBAGK;EACf,QAAQ;;EAER;EACA;;EAEA;;EAEA,aAAa;;EAEb;;EAEA;;EAEA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arkyc/ocr",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Driver-based OCR extraction",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"dist"
|
|
38
38
|
],
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@arkyc/types": "^1.
|
|
40
|
+
"@arkyc/types": "^2.1.0"
|
|
41
41
|
},
|
|
42
42
|
"optionalDependencies": {
|
|
43
43
|
"sharp": "^0.33.5",
|