@chatpanel/pii 0.8.0 → 0.10.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/detector-router.js +112 -0
- package/package.json +3 -1
- package/pii-detect.js +50 -4
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// DETECTOR ROUTING — which detectors to run on THIS machine, and why (privacy-routing.md §4.4, §6).
|
|
2
|
+
//
|
|
3
|
+
// One model cannot cover chat text (Privacy Filter finds a lowercase name and never a city;
|
|
4
|
+
// a NER model the reverse), and the best model is a 1 GB resident that would swap an 8 GB
|
|
5
|
+
// laptop with a browser and a coding agent open. So the choice is a judgement about the
|
|
6
|
+
// machine: how much memory detectors may take, which of the catalogued models fit, and what
|
|
7
|
+
// to offer when the good ones do not — a detector on a server, which is what a hosted tier
|
|
8
|
+
// is too. Pure: the catalogue, the machine and what is installed come in; a primary, a union,
|
|
9
|
+
// the reasons and the alternatives come out. The gateway applies it; the clients draw it.
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* How much resident memory detectors may take on a machine of this size: 8% of RAM, never
|
|
13
|
+
* under 300 MB (the small English model must always fit). 8 GB → 655 MB; 16 GB → 1.3 GB;
|
|
14
|
+
* 32 GB → 2.6 GB. The figure is a budget for the UNION, not one model: the sum of `ramMB`
|
|
15
|
+
* of everything loaded.
|
|
16
|
+
*/
|
|
17
|
+
export function detectorBudgetMB(totalRamMB) {
|
|
18
|
+
const total = Number(totalRamMB) || 0;
|
|
19
|
+
return Math.max(300, Math.round(total * 0.08));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resident cost of a catalogue entry — `ramMB` when declared, else ~2.3× the q8 download:
|
|
24
|
+
* measured 2026-09-19, bert-base-NER (105 MB on disk) added ~240 MB to the process; Privacy
|
|
25
|
+
* Filter (1.6 GB int8 on disk) ~700 MB, which is why it declares `ramMB` itself.
|
|
26
|
+
*/
|
|
27
|
+
export function residentMB(model) {
|
|
28
|
+
if (Number(model?.ramMB) > 0) return Number(model.ramMB);
|
|
29
|
+
return Math.round((Number(model?.approxMB) || 200) * 2.3);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Is this catalogue entry a general NER model — the kind that finds places and organisations?
|
|
34
|
+
* Privacy Filter is not (no LOC/ORG label, by design); the PII-specialised multilingual model
|
|
35
|
+
* is not either for this purpose: it tags a city but measured 0 of 7 lowercase names, so it is
|
|
36
|
+
* never the model chosen to complement Privacy Filter (privacy-routing.md §3.2).
|
|
37
|
+
*/
|
|
38
|
+
function findsPlaces(m) { return !/private|privacy|pii/i.test(String(m.id)); }
|
|
39
|
+
function isPrivacyModel(m) { return /privacy-filter/i.test(String(m.id)); }
|
|
40
|
+
function multilingual(m) { return /multilingual|multilang/i.test(String(m.id)) || /multi|languages/i.test(String(m.lang || '')); }
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Recommend a primary and a union for this machine.
|
|
44
|
+
*
|
|
45
|
+
* @param catalog [{ id, label, approxMB, ramMB?, minRamMB?, lang?, installed? }]
|
|
46
|
+
* @param machine { totalRamMB, langs?: ['en','de',…] (languages seen in this user's text), nonLatin?: bool }
|
|
47
|
+
* @param providers optional [{ id, name, reach, capabilities: ['detect'…] }] — servers that can
|
|
48
|
+
* detect for this machine (an org box, the hosted tier) — offered when the
|
|
49
|
+
* good models do not fit here.
|
|
50
|
+
* @returns { primary, union, budgetMB, usedMB, reason, skipped: [{ id, why }], alternatives: [{ kind, id?, why }] }
|
|
51
|
+
*/
|
|
52
|
+
export function recommendDetector(catalog = [], machine = {}, providers = []) {
|
|
53
|
+
const budgetMB = detectorBudgetMB(machine.totalRamMB);
|
|
54
|
+
const wantMulti = !!machine.nonLatin || (Array.isArray(machine.langs) && machine.langs.some((l) => l && l !== 'en'));
|
|
55
|
+
const byId = (id) => catalog.find((m) => m.id === id);
|
|
56
|
+
const small = catalog.find((m) => /Xenova\/bert-base-NER$/.test(m.id)) || catalog[0];
|
|
57
|
+
const privacy = catalog.find(isPrivacyModel);
|
|
58
|
+
const placesModel = catalog.filter(findsPlaces).filter((m) => wantMulti ? multilingual(m) : !multilingual(m))
|
|
59
|
+
// the biggest place-finder that is not the privacy model: accuracy over size within the budget
|
|
60
|
+
.sort((a, b) => residentMB(b) - residentMB(a))[0] || small;
|
|
61
|
+
|
|
62
|
+
const chosen = [];
|
|
63
|
+
const skipped = [];
|
|
64
|
+
let usedMB = 0;
|
|
65
|
+
const take = (m, role) => {
|
|
66
|
+
if (!m || chosen.some((c) => c.id === m.id)) return false;
|
|
67
|
+
const cost = residentMB(m);
|
|
68
|
+
if (usedMB + cost > budgetMB) { skipped.push({ id: m.id, why: `${role}: needs ~${cost} MB, ${budgetMB - usedMB} MB of the ${budgetMB} MB detector budget left` }); return false; }
|
|
69
|
+
chosen.push(m); usedMB += cost; return true;
|
|
70
|
+
};
|
|
71
|
+
// The privacy model first — it is the one that finds what redaction is for — then a
|
|
72
|
+
// place-finder beside it; if the privacy model does not fit, the place-finder alone.
|
|
73
|
+
const gotPrivacy = take(privacy, 'private people, addresses, secrets');
|
|
74
|
+
take(placesModel, 'places and organisations');
|
|
75
|
+
if (!chosen.length) take(small, 'the smallest model');
|
|
76
|
+
if (!chosen.length && small) { chosen.push(small); usedMB += residentMB(small); } // the floor: always something
|
|
77
|
+
|
|
78
|
+
const primary = chosen[0].id;
|
|
79
|
+
const union = chosen.slice(1).map((m) => m.id);
|
|
80
|
+
const reasons = [];
|
|
81
|
+
if (gotPrivacy) reasons.push(`${byId(primary).label || primary} finds private people (lowercase too), addresses and secrets`);
|
|
82
|
+
if (union.length) reasons.push(`${union.map((id) => byId(id)?.label || id).join(' + ')} add${union.length === 1 ? 's' : ''} places and organisations`);
|
|
83
|
+
if (!gotPrivacy && privacy) reasons.push(`Privacy Filter (~${residentMB(privacy)} MB) does not fit the ${budgetMB} MB this machine can spare for detectors`);
|
|
84
|
+
reasons.push(`~${usedMB} MB of ${budgetMB} MB (this machine: ${Math.round((machine.totalRamMB || 0) / 1024)} GB)`);
|
|
85
|
+
|
|
86
|
+
const alternatives = [];
|
|
87
|
+
if (!gotPrivacy && privacy) {
|
|
88
|
+
const servers = (providers || []).filter((p) => (p.capabilities || p.provides || []).includes('detect'));
|
|
89
|
+
for (const p of servers) alternatives.push({ kind: p.reach === 'device' ? 'local-server' : 'server', id: p.id, why: `${p.name || p.id} can run the detection for this machine (${p.reach || 'remote'})` });
|
|
90
|
+
if (!servers.length) alternatives.push({ kind: 'server', why: 'run detection on a server — an engine server on a bigger machine, or a hosted detector — and add it under Models' });
|
|
91
|
+
}
|
|
92
|
+
return { primary, union, budgetMB, usedMB, reason: reasons.join(' · '), skipped, alternatives };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Trim a CONFIGURED union to what fits this machine, in priority order — the guard a
|
|
97
|
+
* gateway applies before loading, so a config copied to a smaller laptop never swaps it.
|
|
98
|
+
* Returns the ids to load and the ones skipped with the reason.
|
|
99
|
+
*/
|
|
100
|
+
export function fitUnion(primaryId, unionIds, catalog = [], machine = {}) {
|
|
101
|
+
const budgetMB = detectorBudgetMB(machine.totalRamMB);
|
|
102
|
+
const cost = (id) => residentMB(catalog.find((m) => m.id === id) || { approxMB: 200 });
|
|
103
|
+
let usedMB = cost(primaryId); // the primary always loads — the user chose it
|
|
104
|
+
const load = [];
|
|
105
|
+
const skipped = [];
|
|
106
|
+
for (const id of unionIds || []) {
|
|
107
|
+
const c = cost(id);
|
|
108
|
+
if (usedMB + c > budgetMB) { skipped.push({ id, why: `needs ~${c} MB; ${Math.max(0, budgetMB - usedMB)} MB of the ${budgetMB} MB detector budget left on this machine` }); continue; }
|
|
109
|
+
load.push(id); usedMB += c;
|
|
110
|
+
}
|
|
111
|
+
return { load, skipped, budgetMB, usedMB };
|
|
112
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/pii",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "The canonical ChatPanel privacy engine \u2014 reversible PII redaction + pseudonymization with local entity detection. Pure, dependency-free ESM shared by the ChatPanel extension, gateway, and bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"./tool-harness.js": "./tool-harness.js",
|
|
14
14
|
"./sanitize.js": "./sanitize.js",
|
|
15
15
|
"./net.js": "./net.js",
|
|
16
|
+
"./detector-router.js": "./detector-router.js",
|
|
16
17
|
"./trust.js": "./trust.js"
|
|
17
18
|
},
|
|
18
19
|
"files": [
|
|
@@ -24,6 +25,7 @@
|
|
|
24
25
|
"tool-harness.js",
|
|
25
26
|
"sanitize.js",
|
|
26
27
|
"net.js",
|
|
28
|
+
"detector-router.js",
|
|
27
29
|
"trust.js",
|
|
28
30
|
"LICENSE",
|
|
29
31
|
"README.md"
|
package/pii-detect.js
CHANGED
|
@@ -56,6 +56,8 @@ export function withTimeout(promise, ms, signal) {
|
|
|
56
56
|
// • HF bert-base-NER PER, ORG, LOC, MISC
|
|
57
57
|
// • Presidio PERSON, PHONE_NUMBER, EMAIL_ADDRESS, US_SSN…
|
|
58
58
|
// • ai4privacy / multilang GIVENNAME, SURNAME, STREET, ZIPCODE, TELEPHONENUM…
|
|
59
|
+
// • OpenAI privacy-filter private_person, private_address, private_email, private_phone,
|
|
60
|
+
// private_date, private_url, account_number, secret
|
|
59
61
|
//
|
|
60
62
|
// When adding a model, run one sentence through it and map every label it returns. An
|
|
61
63
|
// unrecognised label is a hole, and it is an invisible one.
|
|
@@ -63,7 +65,7 @@ function normType(t) {
|
|
|
63
65
|
const s = String(t || 'ENTITY').toUpperCase().replace(/[^A-Z0-9]/g, '') || 'ENTITY';
|
|
64
66
|
const map = {
|
|
65
67
|
// People
|
|
66
|
-
PER: 'PERSON', PERSON: 'PERSON', PERSONNAME: 'PERSON',
|
|
68
|
+
PER: 'PERSON', PERSON: 'PERSON', PERSONNAME: 'PERSON', PRIVATEPERSON: 'PERSON',
|
|
67
69
|
GIVENNAME: 'PERSON', FIRSTNAME: 'PERSON', MIDDLENAME: 'PERSON',
|
|
68
70
|
SURNAME: 'PERSON', LASTNAME: 'PERSON', FULLNAME: 'PERSON',
|
|
69
71
|
// Organisations
|
|
@@ -74,10 +76,11 @@ function normType(t) {
|
|
|
74
76
|
CITY: 'LOCATION', STATE: 'LOCATION', COUNTY: 'LOCATION', COUNTRY: 'LOCATION',
|
|
75
77
|
STREET: 'ADDRESS', BUILDINGNUM: 'ADDRESS', BUILDINGNUMBER: 'ADDRESS',
|
|
76
78
|
ZIPCODE: 'ADDRESS', POSTCODE: 'ADDRESS', SECADDRESS: 'ADDRESS', ADDRESS: 'ADDRESS',
|
|
79
|
+
PRIVATEADDRESS: 'ADDRESS',
|
|
77
80
|
NORP: 'GROUP',
|
|
78
81
|
// Contact
|
|
79
|
-
EMAIL: 'EMAIL', EMAILADDRESS: 'EMAIL',
|
|
80
|
-
PHONE: 'PHONE', PHONENUMBER: 'PHONE', TELEPHONENUM: 'PHONE', PHONEIMEI: 'ID',
|
|
82
|
+
EMAIL: 'EMAIL', EMAILADDRESS: 'EMAIL', PRIVATEEMAIL: 'EMAIL',
|
|
83
|
+
PHONE: 'PHONE', PHONENUMBER: 'PHONE', TELEPHONENUM: 'PHONE', PRIVATEPHONE: 'PHONE', PHONEIMEI: 'ID',
|
|
81
84
|
// Numbers that identify a person. These are ALWAYS redacted (see ALWAYS_KEEP), which is
|
|
82
85
|
// the point of naming them rather than leaving them to the digit-count fallback.
|
|
83
86
|
SOCIALNUM: 'SSN', USSSN: 'SSN', SSN: 'SSN',
|
|
@@ -86,7 +89,9 @@ function normType(t) {
|
|
|
86
89
|
ACCOUNTNUM: 'ID', ACCOUNTNUMBER: 'ID', TAXNUM: 'ID', IDCARDNUM: 'ID',
|
|
87
90
|
DRIVERLICENSENUM: 'ID', PASSPORTNUM: 'ID', VEHICLEVRM: 'ID',
|
|
88
91
|
// A date of birth identifies; a plain date does not, and small models tag "today".
|
|
89
|
-
|
|
92
|
+
// privacy-filter's private_date is already the identifying kind (it tags "today" as O),
|
|
93
|
+
// and its private_url is a personal link — a profile, a shared doc — which identifies too.
|
|
94
|
+
DATEOFBIRTH: 'ID', DOB: 'ID', PRIVATEDATE: 'ID', PRIVATEURL: 'ID',
|
|
90
95
|
// Handles and secrets
|
|
91
96
|
USERNAME: 'ID', USERID: 'ID', IP: 'ID', IPADDRESS: 'ID', MAC: 'ID',
|
|
92
97
|
PASSWORD: 'SECRET', APIKEY: 'SECRET', SECRET: 'SECRET',
|
|
@@ -137,6 +142,47 @@ export function normalizeEntities(data, types) {
|
|
|
137
142
|
return out;
|
|
138
143
|
}
|
|
139
144
|
|
|
145
|
+
// CASE RECOVERY — the second pass for lowercase chat text.
|
|
146
|
+
//
|
|
147
|
+
// Every cased NER model is weak on a lowercase name: measured 2026-09-19, Privacy Filter
|
|
148
|
+
// returned nothing for `hi. I am suresh. last time I saw you in austin…` and 1.00 for the
|
|
149
|
+
// same sentence with `Suresh`; the multilingual NER tagged `Seattle` and missed `seattle`.
|
|
150
|
+
// People type chat in lowercase. So a detector runs twice when the draft is lowercase-heavy:
|
|
151
|
+
// once on the text, once on this re-cased copy, and the spans are unioned.
|
|
152
|
+
//
|
|
153
|
+
// The re-casing capitalises every word that is NOT a common word — the stop-list below —
|
|
154
|
+
// so `austin` becomes `Austin` but `am` does not become `Am` (capitalising everything
|
|
155
|
+
// dragged neighbours into spans: `Am Suresh`, `Priya Said`). Measured on the eight failing
|
|
156
|
+
// sentences: 2 of 3 misses recovered, zero false positives on neutral prose ("summarize
|
|
157
|
+
// this page about kubernetes" stays clean — `kubernetes` is capitalised and still nothing).
|
|
158
|
+
//
|
|
159
|
+
// LENGTH-PRESERVING BY CONSTRUCTION: only a single character is upper-cased, and only when
|
|
160
|
+
// its upper-case form is one character (`ß` → `SS` would not be). So an offset into the
|
|
161
|
+
// copy is the same offset into the original, and the caller takes the VALUE from the
|
|
162
|
+
// original — the redactor then sees `seattle`, not `Seattle`.
|
|
163
|
+
const RECASE_STOP = new Set(('a an the i am is are was were be been being to of in on at for and or but if so not no yes ok okay '
|
|
164
|
+
+ 'hi hello hey you your yours me my mine we us our ours they them their he him his she her it its this that these those '
|
|
165
|
+
+ 'what where when who whom why how which do does did done have has had having will would can could should may might must shall '
|
|
166
|
+
+ 'there here now then last next first time day days week month year today tomorrow yesterday see saw seen say said tell told '
|
|
167
|
+
+ 'go went gone come came get got give gave take took make made know knew think thought want need like just also very really '
|
|
168
|
+
+ 'with from into onto over under about after before between through during without within up down out off again still '
|
|
169
|
+
+ 'used use using live lived move moved work worked help please thanks thank sorry new old good bad more most some any all each '
|
|
170
|
+
+ 'because as than too so such only own same other another much many few both either neither every never always often').split(/\s+/));
|
|
171
|
+
|
|
172
|
+
export function recaseForDetection(text) {
|
|
173
|
+
const src = String(text || '');
|
|
174
|
+
let changed = false;
|
|
175
|
+
const out = src.replace(/(^|[^\p{L}\p{N}'’])(\p{Ll})(\p{L}*)/gu, (m, before, first, rest) => {
|
|
176
|
+
const word = first + rest;
|
|
177
|
+
if (RECASE_STOP.has(word)) return m;
|
|
178
|
+
const up = first.toUpperCase();
|
|
179
|
+
if (up.length !== 1 || up === first) return m;
|
|
180
|
+
changed = true;
|
|
181
|
+
return before + up + rest;
|
|
182
|
+
});
|
|
183
|
+
return { text: out, changed };
|
|
184
|
+
}
|
|
185
|
+
|
|
140
186
|
export function parseJsonLoose(s) {
|
|
141
187
|
if (!s) return null;
|
|
142
188
|
const a = String(s).indexOf('{');
|