@dszp/csl-screen 0.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/CHANGELOG.md +51 -0
- package/LICENSE +21 -0
- package/README.md +126 -0
- package/dist/adapters/autotask.d.ts +16 -0
- package/dist/adapters/autotask.js +11 -0
- package/dist/adapters/onebill.d.ts +17 -0
- package/dist/adapters/onebill.js +14 -0
- package/dist/canonical.d.ts +3 -0
- package/dist/canonical.js +22 -0
- package/dist/decisions.d.ts +28 -0
- package/dist/decisions.js +118 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +8 -0
- package/dist/jaroWinkler.d.ts +2 -0
- package/dist/jaroWinkler.js +41 -0
- package/dist/model.d.ts +86 -0
- package/dist/model.js +1 -0
- package/dist/normalize.d.ts +17 -0
- package/dist/normalize.js +66 -0
- package/dist/render.d.ts +12 -0
- package/dist/render.js +93 -0
- package/dist/screen.d.ts +11 -0
- package/dist/screen.js +181 -0
- package/dist/snapshot.d.ts +59 -0
- package/dist/snapshot.js +130 -0
- package/package.json +79 -0
- package/src/adapters/autotask.ts +23 -0
- package/src/adapters/onebill.ts +29 -0
- package/src/canonical.ts +22 -0
- package/src/decisions.ts +117 -0
- package/src/index.ts +11 -0
- package/src/jaroWinkler.ts +39 -0
- package/src/model.ts +75 -0
- package/src/normalize.ts +68 -0
- package/src/render.ts +142 -0
- package/src/screen.ts +185 -0
- package/src/snapshot.ts +186 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Party } from '../model.js';
|
|
2
|
+
|
|
3
|
+
/** The fields of a OneBill subscriber record the adapter reads. */
|
|
4
|
+
export interface OneBillSubscriberLike {
|
|
5
|
+
accountNumber: string;
|
|
6
|
+
companyName?: string;
|
|
7
|
+
accountName?: string;
|
|
8
|
+
address?: { country?: string }[] | { country?: string };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const ISO2 = /^[A-Za-z]{2}$/;
|
|
12
|
+
|
|
13
|
+
function defaultCountry(r: OneBillSubscriberLike): string | undefined {
|
|
14
|
+
const addrs = Array.isArray(r.address) ? r.address : r.address ? [r.address] : [];
|
|
15
|
+
const hit = addrs.find((a) => typeof a?.country === 'string' && ISO2.test(a.country));
|
|
16
|
+
return hit?.country?.toUpperCase();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Maps OneBill subscribers to screening parties. Only a two-letter country code is kept; other values are dropped. */
|
|
20
|
+
export function fromOneBillSubscribers<T extends OneBillSubscriberLike>(
|
|
21
|
+
records: readonly T[],
|
|
22
|
+
opts: { source?: string; country?: (r: T) => string | undefined } = {},
|
|
23
|
+
): Party[] {
|
|
24
|
+
const source = opts.source ?? 'onebill';
|
|
25
|
+
return records.map((r) => {
|
|
26
|
+
const country = opts.country ? opts.country(r) : defaultCountry(r);
|
|
27
|
+
return { id: r.accountNumber, name: r.companyName?.trim() || r.accountName || '', source, ...(country ? { country } : {}) };
|
|
28
|
+
});
|
|
29
|
+
}
|
package/src/canonical.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** JSON.stringify with object keys sorted recursively; arrays keep their order. */
|
|
2
|
+
export function canonicalJson(value: unknown): string {
|
|
3
|
+
return JSON.stringify(sortKeys(value));
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function sortKeys(value: unknown): unknown {
|
|
7
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
8
|
+
if (value !== null && typeof value === 'object') {
|
|
9
|
+
const src = value as Record<string, unknown>;
|
|
10
|
+
// Null prototype: a field named "__proto__" stays an own key instead of setting the prototype and vanishing.
|
|
11
|
+
const out: Record<string, unknown> = Object.create(null);
|
|
12
|
+
for (const k of Object.keys(src).sort()) out[k] = sortKeys(src[k]);
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function sha256Hex(data: Uint8Array | string): Promise<string> {
|
|
19
|
+
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data;
|
|
20
|
+
const digest = await crypto.subtle.digest('SHA-256', bytes as BufferSource);
|
|
21
|
+
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
22
|
+
}
|
package/src/decisions.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { Decision, Match, PartyResult, ScreenResult } from './model.js';
|
|
2
|
+
|
|
3
|
+
/** Unambiguous: both parts are JSON-encoded, so no separator inside an id can shift the boundary. */
|
|
4
|
+
export function decisionKey(partyId: string, entryKey: string): string {
|
|
5
|
+
return JSON.stringify([partyId, entryKey]);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface DecisionInput {
|
|
9
|
+
partyId: string;
|
|
10
|
+
entryKey: string;
|
|
11
|
+
decision: 'cleared' | 'escalated';
|
|
12
|
+
reason: string;
|
|
13
|
+
reviewer: string;
|
|
14
|
+
decidedAt: string;
|
|
15
|
+
/** Identifies the run whose result the reviewer read; copied into the Decision. */
|
|
16
|
+
runId?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const nonEmpty = (v: unknown): v is string => typeof v === 'string' && v !== '';
|
|
20
|
+
|
|
21
|
+
/** Records a ruling bound to the party name and CSL entry content the reviewer actually saw. */
|
|
22
|
+
export function makeDecision(result: ScreenResult, input: DecisionInput): Decision {
|
|
23
|
+
const reason = input.reason.trim();
|
|
24
|
+
const reviewer = input.reviewer.trim();
|
|
25
|
+
if (reason === '') throw new Error('A decision needs a non-blank reason');
|
|
26
|
+
if (reviewer === '') throw new Error('A decision needs a non-blank reviewer');
|
|
27
|
+
const decidedAtMs = Date.parse(input.decidedAt);
|
|
28
|
+
if (!Number.isFinite(decidedAtMs)) throw new Error(`decidedAt is not a valid timestamp: ${JSON.stringify(input.decidedAt)}`);
|
|
29
|
+
if (input.runId !== undefined && !nonEmpty(input.runId)) throw new Error('runId, when given, must be a non-empty string');
|
|
30
|
+
const party = result.parties.find((r) => r.party.id === input.partyId);
|
|
31
|
+
const match = party?.status === 'flagged' ? party.matches.find((m) => m.entryKey === input.entryKey) : undefined;
|
|
32
|
+
if (!party || !match) throw new Error(`Party ${input.partyId} is not flagged against entry ${input.entryKey}`);
|
|
33
|
+
return {
|
|
34
|
+
check: 'csl',
|
|
35
|
+
partyId: input.partyId,
|
|
36
|
+
entryKey: input.entryKey,
|
|
37
|
+
partyNameKey: party.partyNameKey,
|
|
38
|
+
entryFingerprint: match.entryFingerprint,
|
|
39
|
+
decision: input.decision,
|
|
40
|
+
reason,
|
|
41
|
+
reviewer,
|
|
42
|
+
decidedAt: new Date(decidedAtMs).toISOString(),
|
|
43
|
+
...(input.runId !== undefined ? { runId: input.runId } : {}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Returns a new result with each match's `state` and `decision` set from the latest decision for its
|
|
49
|
+
* (party, entry) key. A decision applies only if the party name and CSL entry are unchanged since it was
|
|
50
|
+
* made; otherwise the match is `new` again. Only the latest decision is considered, so a stale one is not
|
|
51
|
+
* bypassed by an older valid one. "Latest" compares parsed instants, so offsets and precision do not matter;
|
|
52
|
+
* a decision whose `decidedAt` does not parse is never latest and never applies. On an exact tie,
|
|
53
|
+
* `escalated` wins over `cleared`, whatever the array order.
|
|
54
|
+
*/
|
|
55
|
+
export function applyDecisions(result: ScreenResult, decisions: readonly Decision[]): ScreenResult {
|
|
56
|
+
const latest = new Map<string, { d: Decision; at: number }>();
|
|
57
|
+
for (const d of decisions) {
|
|
58
|
+
// Defensive: a record that is not a real ruling must never clear or escalate anything.
|
|
59
|
+
if (d.check !== 'csl') continue;
|
|
60
|
+
if (d.decision !== 'cleared' && d.decision !== 'escalated') continue;
|
|
61
|
+
if (typeof d.reason !== 'string' || d.reason.trim() === '') continue;
|
|
62
|
+
const at = Date.parse(d.decidedAt);
|
|
63
|
+
if (!Number.isFinite(at)) continue;
|
|
64
|
+
const key = decisionKey(d.partyId, d.entryKey);
|
|
65
|
+
const cur = latest.get(key);
|
|
66
|
+
if (!cur || at > cur.at || (at === cur.at && d.decision === 'escalated')) latest.set(key, { d, at });
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
...result,
|
|
70
|
+
parties: result.parties.map((r): PartyResult => {
|
|
71
|
+
if (r.status !== 'flagged') return r;
|
|
72
|
+
return {
|
|
73
|
+
...r,
|
|
74
|
+
matches: r.matches.map((m): Match => {
|
|
75
|
+
const { decision: _prior, ...rest } = m;
|
|
76
|
+
const d = latest.get(decisionKey(r.party.id, m.entryKey))?.d;
|
|
77
|
+
if (!d || d.partyNameKey !== r.partyNameKey || d.entryFingerprint !== m.entryFingerprint) {
|
|
78
|
+
return { ...rest, state: 'new' };
|
|
79
|
+
}
|
|
80
|
+
return { ...rest, state: d.decision, decision: d };
|
|
81
|
+
}),
|
|
82
|
+
};
|
|
83
|
+
}),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const nonBlank = (v: unknown): v is string => typeof v === 'string' && v.trim() !== '';
|
|
88
|
+
|
|
89
|
+
/** Validates persisted decisions (for example a decisions file read from disk). Throws a TypeError naming the index and field. */
|
|
90
|
+
export function parseDecisions(value: unknown): Decision[] {
|
|
91
|
+
if (!Array.isArray(value)) throw new TypeError('decisions must be an array');
|
|
92
|
+
value.forEach((d: unknown, i) => {
|
|
93
|
+
if (d === null || typeof d !== 'object' || Array.isArray(d)) throw new TypeError(`decisions[${i}] is not an object`);
|
|
94
|
+
const r = d as Record<string, unknown>;
|
|
95
|
+
if (r.check !== 'csl') throw new TypeError(`decisions[${i}].check must be "csl"`);
|
|
96
|
+
for (const f of ['partyId', 'entryKey', 'partyNameKey', 'entryFingerprint']) {
|
|
97
|
+
if (!nonEmpty(r[f])) throw new TypeError(`decisions[${i}].${f} must be a non-empty string`);
|
|
98
|
+
}
|
|
99
|
+
if (r.decision !== 'cleared' && r.decision !== 'escalated') throw new TypeError(`decisions[${i}].decision must be "cleared" or "escalated"`);
|
|
100
|
+
if (!nonBlank(r.reason)) throw new TypeError(`decisions[${i}].reason must not be blank`);
|
|
101
|
+
if (!nonBlank(r.reviewer)) throw new TypeError(`decisions[${i}].reviewer must not be blank`);
|
|
102
|
+
if (typeof r.decidedAt !== 'string' || !Number.isFinite(Date.parse(r.decidedAt))) {
|
|
103
|
+
throw new TypeError(`decisions[${i}].decidedAt must be a valid timestamp`);
|
|
104
|
+
}
|
|
105
|
+
if (r.runId !== undefined && !nonEmpty(r.runId)) throw new TypeError(`decisions[${i}].runId must be a non-empty string when present`);
|
|
106
|
+
});
|
|
107
|
+
return value as Decision[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type PartyOutcome = 'clear' | 'unscreenable' | 'flagged-new' | 'flagged-escalated' | 'flagged-cleared';
|
|
111
|
+
|
|
112
|
+
export function partyOutcome(r: PartyResult): PartyOutcome {
|
|
113
|
+
if (r.status !== 'flagged') return r.status;
|
|
114
|
+
if (r.matches.some((m) => m.state === 'new')) return 'flagged-new';
|
|
115
|
+
if (r.matches.some((m) => m.state === 'escalated')) return 'flagged-escalated';
|
|
116
|
+
return 'flagged-cleared';
|
|
117
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { DEFAULT_STOP_WORDS, hasUnreadableCharacters, nameTokens, normalizeName } from './normalize.js';
|
|
2
|
+
export { jaroWinkler } from './jaroWinkler.js';
|
|
3
|
+
export { canonicalJson, sha256Hex } from './canonical.js';
|
|
4
|
+
export { loadSnapshot, contentHashOf, CslSnapshotError } from './snapshot.js';
|
|
5
|
+
export type { Snapshot, SlimEntry, SnapshotIdentity, LoadSnapshotOptions } from './snapshot.js';
|
|
6
|
+
export { DEFAULT_GENERIC_TOKENS, screen } from './screen.js';
|
|
7
|
+
export { MATCHER_VERSION } from './model.js';
|
|
8
|
+
export type { Party, MatchState, Match, Decision, PartyResult, ScreenOptions, ScreenResult } from './model.js';
|
|
9
|
+
export { decisionKey, makeDecision, applyDecisions, partyOutcome, parseDecisions } from './decisions.js';
|
|
10
|
+
export type { DecisionInput, PartyOutcome } from './decisions.js';
|
|
11
|
+
export { toCsv, toMarkdown } from './render.js';
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Jaro-Winkler similarity in [0, 1]: match window floor(max/2) - 1, common prefix capped at 4, scale 0.1. */
|
|
2
|
+
export function jaroWinkler(a: string, b: string): number {
|
|
3
|
+
if (a === b) return a.length === 0 ? 0 : 1;
|
|
4
|
+
if (a.length === 0 || b.length === 0) return 0;
|
|
5
|
+
|
|
6
|
+
const window = Math.max(0, Math.floor(Math.max(a.length, b.length) / 2) - 1);
|
|
7
|
+
const aMatched: boolean[] = new Array(a.length).fill(false);
|
|
8
|
+
const bMatched: boolean[] = new Array(b.length).fill(false);
|
|
9
|
+
|
|
10
|
+
let matches = 0;
|
|
11
|
+
for (let i = 0; i < a.length; i++) {
|
|
12
|
+
const lo = Math.max(0, i - window);
|
|
13
|
+
const hi = Math.min(b.length - 1, i + window);
|
|
14
|
+
for (let j = lo; j <= hi; j++) {
|
|
15
|
+
if (!bMatched[j] && a[i] === b[j]) {
|
|
16
|
+
aMatched[i] = true;
|
|
17
|
+
bMatched[j] = true;
|
|
18
|
+
matches++;
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (matches === 0) return 0;
|
|
24
|
+
|
|
25
|
+
let transpositions = 0;
|
|
26
|
+
let k = 0;
|
|
27
|
+
for (let i = 0; i < a.length; i++) {
|
|
28
|
+
if (!aMatched[i]) continue;
|
|
29
|
+
while (!bMatched[k]) k++;
|
|
30
|
+
if (a[i] !== b[k]) transpositions++;
|
|
31
|
+
k++;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const jaro = (matches / a.length + matches / b.length + (matches - transpositions / 2) / matches) / 3;
|
|
35
|
+
|
|
36
|
+
let prefix = 0;
|
|
37
|
+
while (prefix < 4 && prefix < a.length && prefix < b.length && a[prefix] === b[prefix]) prefix++;
|
|
38
|
+
return jaro + prefix * 0.1 * (1 - jaro);
|
|
39
|
+
}
|
package/src/model.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { SnapshotIdentity } from './snapshot.js';
|
|
2
|
+
|
|
3
|
+
export interface Party {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
/** ISO 3166-1 alpha-2 when known. Only annotates matches; never suppresses one. */
|
|
7
|
+
country?: string;
|
|
8
|
+
source: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type MatchState = 'new' | 'cleared' | 'escalated';
|
|
12
|
+
|
|
13
|
+
/** A reviewer's recorded ruling on one party/entry pair, bound to the name and entry content it was made against. */
|
|
14
|
+
export interface Decision {
|
|
15
|
+
check: 'csl';
|
|
16
|
+
partyId: string;
|
|
17
|
+
entryKey: string;
|
|
18
|
+
partyNameKey: string;
|
|
19
|
+
entryFingerprint: string;
|
|
20
|
+
decision: 'cleared' | 'escalated';
|
|
21
|
+
reason: string;
|
|
22
|
+
reviewer: string;
|
|
23
|
+
decidedAt: string;
|
|
24
|
+
/** The screening run whose result the reviewer read when making this ruling. Optional; the caller names it. */
|
|
25
|
+
runId?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface Match {
|
|
29
|
+
/** `${source}|${id}`; unique within a snapshot. */
|
|
30
|
+
entryKey: string;
|
|
31
|
+
/** The raw CSL id, which is not unique on its own. */
|
|
32
|
+
entryId: string;
|
|
33
|
+
entryFingerprint: string;
|
|
34
|
+
matchedName: string;
|
|
35
|
+
listSource: string;
|
|
36
|
+
programs: string[];
|
|
37
|
+
entryType: string | null;
|
|
38
|
+
entryCountries: string[];
|
|
39
|
+
countryMismatch: boolean;
|
|
40
|
+
rule: 'exact' | 'contains' | 'within' | 'similar';
|
|
41
|
+
score: number;
|
|
42
|
+
reason: string;
|
|
43
|
+
state: MatchState;
|
|
44
|
+
decision?: Decision;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type PartyResult = { party: Party; partyNameKey: string } & (
|
|
48
|
+
| { status: 'clear' }
|
|
49
|
+
| { status: 'unscreenable'; reason: 'empty-name' | 'unreadable-characters' }
|
|
50
|
+
| { status: 'flagged'; matches: Match[] }
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
export interface ScreenOptions {
|
|
54
|
+
/** Minimum similarity for the `similar` rule. Default 0.9. */
|
|
55
|
+
threshold?: number;
|
|
56
|
+
/** Replaces DEFAULT_STOP_WORDS. Entries are normalised like names, so 'Inc' or 'L.L.C.' work. */
|
|
57
|
+
stopWords?: readonly string[];
|
|
58
|
+
/** Shortest token that is indexed and used to find candidates. Default 3. */
|
|
59
|
+
minTokenLength?: number;
|
|
60
|
+
/**
|
|
61
|
+
* Replaces DEFAULT_GENERIC_TOKENS: one-word listed entity names that match a party only as its first
|
|
62
|
+
* token. Normalised like names.
|
|
63
|
+
*/
|
|
64
|
+
genericTokens?: readonly string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const MATCHER_VERSION = '2';
|
|
68
|
+
|
|
69
|
+
export interface ScreenResult {
|
|
70
|
+
check: 'csl';
|
|
71
|
+
matcher: { version: string; threshold: number; minTokenLength: number; stopWords: string[]; genericTokens: string[] };
|
|
72
|
+
snapshot: SnapshotIdentity;
|
|
73
|
+
counts: { parties: number; screened: number; unscreenable: number; clear: number; flagged: number };
|
|
74
|
+
parties: PartyResult[];
|
|
75
|
+
}
|
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Legal-form suffixes and filler words that carry no identity for name matching. */
|
|
2
|
+
export const DEFAULT_STOP_WORDS: readonly string[] = [
|
|
3
|
+
'llc', 'inc', 'incorporated', 'co', 'corp', 'corporation', 'company',
|
|
4
|
+
'ltd', 'limited', 'plc', 'pc', 'pllc', 'lp', 'llp', 'the', 'and', 'of',
|
|
5
|
+
'sa', 'ag', 'gmbh', 'srl', 'bv', 'nv', 'oao', 'ooo', 'zao', 'jsc', 'pjsc',
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
/** Latin letters with no NFKD decomposition, and their ASCII spelling. */
|
|
9
|
+
const LATIN_FOLD: Readonly<Record<string, string>> = {
|
|
10
|
+
'ß': 'ss', 'ẞ': 'SS', 'æ': 'ae', 'Æ': 'AE', 'œ': 'oe', 'Œ': 'OE', 'ø': 'o', 'Ø': 'O',
|
|
11
|
+
'ł': 'l', 'Ł': 'L', 'đ': 'd', 'Đ': 'D', 'ð': 'd', 'Ð': 'D', 'þ': 'th', 'Þ': 'TH', 'ı': 'i',
|
|
12
|
+
};
|
|
13
|
+
const LATIN_FOLD_RE = new RegExp(`[${Object.keys(LATIN_FOLD).join('')}]`, 'g');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* NFKD, every combining mark (\p{M}) and format character (\p{Cf}) deleted, then the Latin fold:
|
|
17
|
+
* the form every name is compared in. Deleting rather than separating means an invisible character
|
|
18
|
+
* typed inside a word (a zero-width space, soft hyphen, bidi mark or variation selector in "Ros\u200Bneft")
|
|
19
|
+
* leaves the word whole instead of splitting it into fragments that match nothing.
|
|
20
|
+
*/
|
|
21
|
+
function foldLatin(name: string): string {
|
|
22
|
+
return name
|
|
23
|
+
.normalize('NFKD')
|
|
24
|
+
.replace(/[\p{M}\p{Cf}]/gu, '')
|
|
25
|
+
.replace(LATIN_FOLD_RE, (c) => LATIN_FOLD[c]!);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* True when, after the fold, the name still has a letter outside A-Z/a-z, or a control (other than tab,
|
|
30
|
+
* LF and CR), private-use or unassigned character. Such a name cannot be tokenised safely: a look-alike
|
|
31
|
+
* letter from another script (Cyrillic о in "Rоsneft") or an unreadable character would split the word
|
|
32
|
+
* and hide a listed entity, so the caller must screen it by hand.
|
|
33
|
+
*/
|
|
34
|
+
export function hasUnreadableCharacters(name: string): boolean {
|
|
35
|
+
return /[^\P{L}A-Za-z]|[^\P{Cc}\t\n\r]|[\p{Co}\p{Cn}]/u.test(foldLatin(name));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Lowercase ASCII tokens of a name: diacritics stripped, `&` read as "and",
|
|
40
|
+
* everything outside [a-z0-9] treated as a separator, runs of single-character
|
|
41
|
+
* tokens joined ("L.L.C." is `llc`, "D. K. Smith" is `dk smith`), stop words dropped.
|
|
42
|
+
* Letters outside the Latin alphabet and other unreadable characters act as separators; see `hasUnreadableCharacters`.
|
|
43
|
+
*/
|
|
44
|
+
export function nameTokens(name: string, stopWords: ReadonlySet<string>): string[] {
|
|
45
|
+
const raw = foldLatin(name)
|
|
46
|
+
.toLowerCase()
|
|
47
|
+
.replace(/&/g, ' and ')
|
|
48
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
49
|
+
.split(' ')
|
|
50
|
+
.filter((t) => t !== '');
|
|
51
|
+
// Join before dropping stop words, so initials ("L.L.C.") become the word they spell.
|
|
52
|
+
const joined: string[] = [];
|
|
53
|
+
let run = '';
|
|
54
|
+
for (const t of raw) {
|
|
55
|
+
if (t.length === 1) run += t;
|
|
56
|
+
else {
|
|
57
|
+
if (run) joined.push(run);
|
|
58
|
+
run = '';
|
|
59
|
+
joined.push(t);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (run) joined.push(run);
|
|
63
|
+
return joined.filter((t) => !stopWords.has(t));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function normalizeName(name: string, stopWords: ReadonlySet<string>): string {
|
|
67
|
+
return nameTokens(name, stopWords).join(' ');
|
|
68
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { partyOutcome } from './decisions.js';
|
|
2
|
+
import type { Match, PartyResult, ScreenResult } from './model.js';
|
|
3
|
+
|
|
4
|
+
const COLUMNS = [
|
|
5
|
+
'party_id', 'party_name', 'source', 'result', 'matched_name', 'list', 'entry_key', 'rule', 'score',
|
|
6
|
+
'reason', 'entry_countries', 'party_country', 'decision', 'decided_by', 'decided_at', 'run_date',
|
|
7
|
+
] as const;
|
|
8
|
+
|
|
9
|
+
/** A leading = + - @ (or tab/CR/LF) makes a spreadsheet read the cell as a formula; a leading apostrophe stops that. */
|
|
10
|
+
function guardFormula(s: string): string {
|
|
11
|
+
return /^[=+\-@\t\r\n]/.test(s) ? `'${s}` : s;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Guard first, then RFC 4180 quoting, so the apostrophe sits inside the quotes. */
|
|
15
|
+
function csvCell(s: string): string {
|
|
16
|
+
const g = guardFormula(s);
|
|
17
|
+
return /[",\r\n]/.test(g) ? `"${g.replace(/"/g, '""')}"` : g;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function toCsv(result: ScreenResult, opts: { runDate: string }): string {
|
|
21
|
+
const lines: string[] = [COLUMNS.join(',')];
|
|
22
|
+
const row = (cells: string[]) => lines.push(cells.map(csvCell).join(','));
|
|
23
|
+
for (const r of result.parties) {
|
|
24
|
+
const outcome = partyOutcome(r);
|
|
25
|
+
const head = [r.party.id, r.party.name, r.party.source, outcome];
|
|
26
|
+
if (r.status !== 'flagged') {
|
|
27
|
+
row([...head, '', '', '', '', '', '', '', '', '', '', opts.runDate]);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
for (const m of r.matches) {
|
|
31
|
+
row([
|
|
32
|
+
...head, m.matchedName, m.listSource, m.entryKey, m.rule, m.score.toFixed(2), m.reason,
|
|
33
|
+
m.entryCountries.join(';'), r.party.country ?? '',
|
|
34
|
+
m.decision?.decision ?? '', m.decision?.reviewer ?? '', m.decision?.decidedAt ?? '', opts.runDate,
|
|
35
|
+
]);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return lines.join('\r\n') + '\r\n';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Names come from client records and the public list, so they are untrusted. Line breaks become spaces
|
|
43
|
+
* (a table row or list item must stay on one line), then every character Markdown or inline HTML could
|
|
44
|
+
* act on is backslash-escaped. `&` is included so an entity such as `<` stays literal text.
|
|
45
|
+
*/
|
|
46
|
+
function md(s: string): string {
|
|
47
|
+
return s
|
|
48
|
+
.replace(/\r\n|\r|\n/g, ' ')
|
|
49
|
+
.replace(/[\\|`*_~<>[\]&]/g, '\\$&')
|
|
50
|
+
.replace(/^(\s*)#/, '$1\\#');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const UNSCREENABLE_REASON = {
|
|
54
|
+
'empty-name': 'name is empty after normalisation',
|
|
55
|
+
'unreadable-characters': 'name contains non-Latin or unreadable characters; screen it by hand',
|
|
56
|
+
} as const;
|
|
57
|
+
|
|
58
|
+
const list = (xs: readonly string[]) => (xs.length ? md(xs.join(', ')) : 'none');
|
|
59
|
+
|
|
60
|
+
function matchLine(r: Extract<PartyResult, { status: 'flagged' }>, m: Match): string {
|
|
61
|
+
const p = r.party;
|
|
62
|
+
const d = m.decision;
|
|
63
|
+
const decision = d ? `${d.decision} by ${md(d.reviewer)} at ${md(d.decidedAt)}: ${md(d.reason)}` : 'none';
|
|
64
|
+
const partyCountry = p.country ? md(p.country) : 'not provided';
|
|
65
|
+
const mismatch = m.countryMismatch ? ' (country mismatch)' : '';
|
|
66
|
+
return [
|
|
67
|
+
`- **${md(p.name)}** (id ${md(p.id)}, source ${md(p.source)}) matched **${md(m.matchedName)}**`,
|
|
68
|
+
` - List: ${md(m.listSource)}; programs: ${list(m.programs)}; entry type: ${m.entryType ? md(m.entryType) : 'unknown'}`,
|
|
69
|
+
` - Countries: entry ${list(m.entryCountries)}; party ${partyCountry}${mismatch}`,
|
|
70
|
+
` - Rule: ${m.rule}, score ${m.score.toFixed(2)}; reason: ${md(m.reason)}`,
|
|
71
|
+
` - Decision: ${decision}`,
|
|
72
|
+
].join('\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function toMarkdown(
|
|
76
|
+
result: ScreenResult,
|
|
77
|
+
opts: {
|
|
78
|
+
runDate: string;
|
|
79
|
+
title?: string;
|
|
80
|
+
expected?: number;
|
|
81
|
+
sourceRecords?: number;
|
|
82
|
+
/** Caller lines (for example where the parties and the list came from), printed as list items under the title. */
|
|
83
|
+
preamble?: readonly string[];
|
|
84
|
+
},
|
|
85
|
+
): string {
|
|
86
|
+
const { counts, snapshot: s, matcher } = result;
|
|
87
|
+
const out: string[] = [];
|
|
88
|
+
if (opts.expected !== undefined && opts.expected !== counts.parties) {
|
|
89
|
+
out.push(`INCOMPLETE: ${counts.parties} parties in run of expected ${opts.expected}`, '');
|
|
90
|
+
}
|
|
91
|
+
out.push(`# ${md(opts.title || 'Restricted-party screening report')}`, '');
|
|
92
|
+
if (opts.preamble?.length) out.push(...opts.preamble.map((l) => `- ${md(l)}`), '');
|
|
93
|
+
out.push(`Run date: ${md(opts.runDate)}`, '');
|
|
94
|
+
const excluded = opts.sourceRecords !== undefined ? opts.sourceRecords - counts.parties : 0;
|
|
95
|
+
out.push(
|
|
96
|
+
...(opts.sourceRecords !== undefined ? [`- Source records: ${opts.sourceRecords}`] : []),
|
|
97
|
+
opts.expected !== undefined
|
|
98
|
+
? `- Parties in run: ${counts.parties} of ${opts.expected} expected`
|
|
99
|
+
: `- Parties in run: ${counts.parties}`,
|
|
100
|
+
...(excluded > 0 ? [`- Excluded by scope: ${excluded}`] : []),
|
|
101
|
+
`- Screened: ${counts.screened}`,
|
|
102
|
+
`- Clear: ${counts.clear}`,
|
|
103
|
+
`- Flagged: ${counts.flagged}`,
|
|
104
|
+
`- Unscreenable: ${counts.unscreenable}`,
|
|
105
|
+
'',
|
|
106
|
+
'## Consolidated Screening List snapshot',
|
|
107
|
+
'',
|
|
108
|
+
`- Search performed at: ${md(s.searchPerformedAt || 'unknown')}`,
|
|
109
|
+
`- Entries: ${s.entryCount}`,
|
|
110
|
+
`- SHA-256: ${s.sha256}`,
|
|
111
|
+
'- Sources:',
|
|
112
|
+
...s.sources.map((x) => ` - ${md(x.source)}: last updated ${md(x.sourceLastUpdated ?? 'unknown')}`),
|
|
113
|
+
'',
|
|
114
|
+
'## Matcher',
|
|
115
|
+
'',
|
|
116
|
+
`- Version ${md(matcher.version)}`,
|
|
117
|
+
`- Threshold: ${matcher.threshold}`,
|
|
118
|
+
'',
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const flagged = result.parties.filter((r): r is Extract<PartyResult, { status: 'flagged' }> => r.status === 'flagged');
|
|
122
|
+
const section = (title: string, state: Match['state']) => {
|
|
123
|
+
const lines = flagged.flatMap((r) => r.matches.filter((m) => m.state === state).map((m) => matchLine(r, m)));
|
|
124
|
+
if (lines.length) out.push(`## ${title}`, '', ...lines, '');
|
|
125
|
+
};
|
|
126
|
+
section('New flags', 'new');
|
|
127
|
+
section('Escalated (open)', 'escalated');
|
|
128
|
+
const unscreenable = result.parties.filter((r): r is Extract<PartyResult, { status: 'unscreenable' }> => r.status === 'unscreenable');
|
|
129
|
+
if (unscreenable.length) {
|
|
130
|
+
out.push(
|
|
131
|
+
'## Unscreenable parties',
|
|
132
|
+
'',
|
|
133
|
+
...unscreenable.map(
|
|
134
|
+
(r) =>
|
|
135
|
+
`- ${r.party.name.trim() ? md(r.party.name) : '(blank name)'} (id ${md(r.party.id)}, source ${md(r.party.source)}): ${UNSCREENABLE_REASON[r.reason]}`,
|
|
136
|
+
),
|
|
137
|
+
'',
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
section('Cleared (still matching)', 'cleared');
|
|
141
|
+
return out.join('\n');
|
|
142
|
+
}
|