@chatpanel/gateway 0.5.5 → 0.6.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/README.md CHANGED
@@ -78,18 +78,24 @@ export OPENAI_BASE_URL=http://127.0.0.1:4320/v1 # OpenAI / codex / aider / c
78
78
  export ANTHROPIC_BASE_URL=http://127.0.0.1:4320 # Claude Code / Anthropic SDK
79
79
  ```
80
80
 
81
- ## Name/org redaction is built in (bundled NER)
81
+ ## Name/org redaction is built in (in-process NER, no Python)
82
82
 
83
83
  Deterministic redaction (emails, phones, cards, SSNs, API keys, IPs) needs no
84
- setup. To also blind **names, organizations and locations**, the gateway bundles
85
- a local spaCy NER server under [`ner/`](ner) and with `ner.autostart` on (the
86
- default) **launches it for you on startup** and switches redaction to the
87
- `full` tier once it's healthy. First run creates a venv and downloads the model
88
- (needs `python3`); it's fail-open, so if Python isn't set up the gateway just runs
89
- deterministic-only.
90
-
91
- Prefer a local LLM or an external NER service? Set `redaction.detection` yourself
92
- and the gateway won't autostart the bundled one.
84
+ setup. To also blind **names, organizations and locations**, the gateway runs an
85
+ **in-process** entity detector an ONNX transformer model via transformers.js
86
+ with `ner.autostart` on (the default). There's **no Python, no second port, no
87
+ separate process**: the same model runs identically on macOS / Windows / Linux.
88
+ The model loads from `~/.chatpanel/models` and is downloaded once on first run if
89
+ absent (set `ner.allowDownload: false` to require it be pre-placed). It's
90
+ fail-open, so if the model can't load the gateway just runs deterministic-only.
91
+ Once the detector is ready, redaction switches to the `full` tier automatically.
92
+
93
+ The default model (`Xenova/bert-base-NER`) matches or beats spaCy's small model on
94
+ people/orgs/locations. Larger or alternative models can be installed from the
95
+ ChatPanel extension's **Gateway** settings.
96
+
97
+ Prefer a local LLM or your own external NER service? Set `redaction.detection`
98
+ yourself and the gateway won't load the bundled one (yours takes precedence).
93
99
 
94
100
  ## Configuration
95
101
 
@@ -106,9 +112,11 @@ env vars. See [`gateway.config.example.json`](gateway.config.example.json).
106
112
  | `upstreams.openai.baseUrl` | `OPENAI_BASE_URL` | `https://api.openai.com` | api backend only |
107
113
  | `upstreams.anthropic.baseUrl` | `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` | api backend only |
108
114
  | `redaction.tier` | `CHATPANEL_REDACTION_TIER` | `basic` | `basic` (regex) or `full` (+ NER + dictionary) |
109
- | `redaction.detection` | — | _(auto via NER)_ | local detector; set to override the bundled one |
115
+ | `redaction.detection` | — | _(off bundled engine)_ | external detector; set to override the bundled in-process one |
110
116
  | `redaction.dictionary` | — | `[]` | custom `{ value\|pattern, type, alias? }` entries |
111
- | `ner.autostart` / `ner.port` | — | `true` / `9009` | launch + wire the bundled spaCy NER |
117
+ | `ner.autostart` | — | `true` | load the bundled in-process NER on startup |
118
+ | `ner.model` | — | `Xenova/bert-base-NER` | model id under `~/.chatpanel/models` |
119
+ | `ner.allowDownload` | — | `true` | download the model on first run if absent |
112
120
 
113
121
  ## Endpoints
114
122
 
@@ -0,0 +1,24 @@
1
+ // Binary entry point for the Bun `--compile` standalone build (no Node required).
2
+ //
3
+ // A compiled single-file binary cannot embed onnxruntime-node's native dylib, so
4
+ // NER runs on the onnxruntime-web WASM runtime instead. We embed that runtime's
5
+ // two files INTO the executable here (Bun's `with { type: 'file' }` copies the
6
+ // asset in and yields a runtime path to it), then hand their paths to the NER
7
+ // engine via a global. src/ner-engine.js sees the global, forces the WASM backend,
8
+ // and points ORT at these files. Everything else is the normal CLI.
9
+ //
10
+ // The npm/Node package uses bin/chatpanel-gateway.js instead, which keeps the
11
+ // faster native onnxruntime-node runtime.
12
+
13
+ import { pathToFileURL } from 'node:url';
14
+ import wasmFile from '../assets/ort-wasm-simd-threaded.wasm' with { type: 'file' };
15
+ import mjsFile from '../assets/ort-wasm-simd-threaded.mjs' with { type: 'file' };
16
+
17
+ globalThis.__CHATPANEL_WASM_PATHS__ = {
18
+ wasm: pathToFileURL(wasmFile).href,
19
+ mjs: pathToFileURL(mjsFile).href,
20
+ };
21
+
22
+ // Delegate to the normal CLI (start / --install / --version / …). The global is
23
+ // already set, so when the gateway starts NER the engine uses the embedded WASM.
24
+ await import('./chatpanel-gateway.js');
@@ -25,7 +25,8 @@
25
25
 
26
26
  "ner": {
27
27
  "autostart": true,
28
- "port": 9009,
28
+ "model": "Xenova/bert-base-NER",
29
+ "allowDownload": true,
29
30
  "enableFullTier": true
30
31
  },
31
32
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.5.5",
3
+ "version": "0.6.0",
4
4
  "description": "Local privacy gateway — redacts PII out of OpenAI/Anthropic API traffic before it reaches a model, then restores it in the reply. Point opencode, codex, aider, Claude Code, etc. at it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,14 +14,11 @@
14
14
  "start": "node bin/chatpanel-gateway.js",
15
15
  "test": "node --test",
16
16
  "typecheck": "tsc -p tsconfig.json",
17
- "build:bin": "bash scripts/build-binaries.sh",
18
- "gen:ner": "node scripts/gen-ner-assets.mjs",
19
- "prepublishOnly": "node scripts/gen-ner-assets.mjs"
17
+ "build:bin": "bash scripts/build-binaries.sh"
20
18
  },
21
19
  "files": [
22
20
  "src",
23
21
  "bin",
24
- "ner",
25
22
  "gateway.config.example.json",
26
23
  "LICENSE",
27
24
  "README.md"
@@ -30,7 +27,9 @@
30
27
  "node": ">=18"
31
28
  },
32
29
  "dependencies": {
33
- "@chatpanel/pii": "^0.2.5"
30
+ "@chatpanel/pii": "^0.2.5",
31
+ "@huggingface/transformers": "^4.2.0",
32
+ "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c"
34
33
  },
35
34
  "homepage": "https://chatpanel.net",
36
35
  "repository": {
package/src/config.js CHANGED
@@ -73,20 +73,24 @@ const DEFAULTS = {
73
73
  // Local entity detector, passed straight to pii-detect.detectEntities.
74
74
  // backend: 'off' | 'endpoint' (POST {text}->{entities}) | 'openai' (local LLM)
75
75
  // url, model, timeoutMs, maxChars
76
- // When `ner.autostart` is on (below), the gateway launches the bundled spaCy
77
- // NER server and points detection at it automatically leave this `off`.
76
+ // Leave this `off` to use the bundled in-process NER (see `ner` below). Set it
77
+ // only to point at YOUR OWN external detector (a custom NER or local LLM); that
78
+ // takes precedence over the bundled engine.
78
79
  detection: { backend: 'off' },
79
80
  // Per-request convenience: also redact the system prompt / system blocks.
80
81
  redactSystem: true,
81
82
  },
82
- // Bundled local NER server (spaCy). When autostart is on, `npm start` also
83
- // launches ./ner on this port and wires `redaction.detection` to it, so name/
84
- // org redaction works out of the box with one command. Fails open: if Python/
85
- // spaCy isn't set up, the gateway logs a hint and runs deterministic-only.
83
+ // Bundled IN-PROCESS NER (ONNX via transformers.js). When autostart is on, the
84
+ // gateway loads the entity detector in-process no Python, no second port — so
85
+ // name/org redaction works out of the box. The model loads from
86
+ // ~/.chatpanel/models and is downloaded once on first run if absent. Larger /
87
+ // alternative models can be installed from the extension's Gateway settings.
88
+ // Fails open: if the model can't load, the gateway runs deterministic-only.
86
89
  ner: {
87
90
  autostart: true,
88
- port: 9009,
89
- // Auto-bump redaction.tier to 'full' once NER is up (names/orgs need it).
91
+ model: 'Xenova/bert-base-NER',
92
+ allowDownload: true,
93
+ // Auto-bump redaction.tier to 'full' once the detector is ready (names/orgs).
90
94
  enableFullTier: true,
91
95
  },
92
96
 
package/src/models.js ADDED
@@ -0,0 +1,38 @@
1
+ // Catalog of NER models the gateway can run, surfaced in the extension's Gateway
2
+ // settings so users can install a larger or multilingual detector. All are ONNX
3
+ // (transformers.js) token-classification models that emit PER/ORG/LOC — the only
4
+ // labels the redaction engine consumes. Sizes are the on-disk q8 footprint, approx.
5
+ //
6
+ // Adding a model: any Xenova/* token-classification model whose labels map to
7
+ // PER/ORG/LOC works (pii-detect normalizeEntities handles the label mapping).
8
+ // Verify it loads + detects before listing it here.
9
+
10
+ export const DEFAULT_MODEL = 'Xenova/bert-base-NER';
11
+
12
+ export const MODEL_CATALOG = [
13
+ {
14
+ id: 'Xenova/bert-base-NER',
15
+ label: 'English — standard',
16
+ lang: 'English',
17
+ approxMB: 105,
18
+ note: 'Default. Best English accuracy for people, organizations, and locations.',
19
+ },
20
+ {
21
+ id: 'Xenova/distilbert-base-multilingual-cased-ner-hrl',
22
+ label: 'Multilingual — compact',
23
+ lang: '10 languages',
24
+ approxMB: 150,
25
+ note: 'Covers en, es, fr, de, it, pt, nl, ar, zh, ru. Use for non-English text.',
26
+ },
27
+ {
28
+ id: 'Xenova/bert-base-multilingual-cased-ner-hrl',
29
+ label: 'Multilingual — large',
30
+ lang: '10 languages',
31
+ approxMB: 180,
32
+ note: 'Higher multilingual accuracy; larger download.',
33
+ },
34
+ ];
35
+
36
+ export function isKnownModel(id) {
37
+ return MODEL_CATALOG.some((m) => m.id === id);
38
+ }
@@ -0,0 +1,225 @@
1
+ // In-process Named Entity Recognition — zero Python, zero second port.
2
+ //
3
+ // Replaces the old bundled spaCy microservice (a Python venv + uvicorn on :9009)
4
+ // with an ONNX transformer model run IN-PROCESS via transformers.js. This is what
5
+ // makes the gateway self-contained: nothing to install, no interpreter, no second
6
+ // port — the same model runs identically on macOS / Windows / Linux, and offline
7
+ // once the weights are on disk.
8
+ //
9
+ // Accuracy: the default (Xenova/bert-base-NER) matches or beats spaCy's
10
+ // en_core_web_sm on PERSON / ORG / LOCATION. Numeric PII (phone/card/SSN) is NOT
11
+ // the model's job — the deterministic regex layer in @chatpanel/pii handles that,
12
+ // same as before.
13
+ //
14
+ // Fail-open by design: if the model can't load (e.g. first run with no network and
15
+ // no cached weights), the gateway logs one line and runs deterministic-only.
16
+
17
+ import os from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { existsSync, mkdirSync } from 'node:fs';
20
+
21
+ const DEFAULT_MODEL = 'Xenova/bert-base-NER';
22
+
23
+ // Download models from ChatPanel's own CDN (a branded, edge-cached proxy we
24
+ // control) rather than directly from Hugging Face — so a clean install depends only
25
+ // on chatpanel.net. Override with CHATPANEL_MODEL_BASE_URL (e.g. point at HF for
26
+ // dev, or an air-gapped mirror). Must end with '/' (transformers appends the model
27
+ // path template to it).
28
+ const MODEL_HOST = (process.env.CHATPANEL_MODEL_BASE_URL || 'https://dl.chatpanel.net/models/').replace(/\/*$/, '/');
29
+
30
+ let _state = 'off'; // 'off' | 'loading' | 'downloading' | 'ready' | 'error'
31
+ let _model = null; // active model id, e.g. 'Xenova/bert-base-NER'
32
+ let _pipe = null; // the loaded token-classification pipeline
33
+ let _err = null; // last error message (for /status)
34
+ let _initPromise = null; // single-flight init
35
+ let _lib = null; // memoized { env, pipeline } from transformers (imported once)
36
+ let _progress = null; // { model, file, pct } while a model is downloading, else null
37
+
38
+ // Where model weights live ON DISK. A real, writable, persistent user dir — NOT
39
+ // inside node_modules (wiped on reinstall, and absent entirely in a compiled
40
+ // binary). The settings "download larger models" flow writes here too.
41
+ export function modelRoot() {
42
+ return process.env.CHATPANEL_MODELS_DIR || join(os.homedir(), '.chatpanel', 'models');
43
+ }
44
+
45
+ // Is the model already on disk? (q8 = the quantized weights we load.) When present
46
+ // we load fully offline — no network, a hard privacy guarantee.
47
+ export function modelOnDisk(modelId = _model || DEFAULT_MODEL) {
48
+ const dir = join(modelRoot(), ...modelId.split('/'));
49
+ return existsSync(join(dir, 'onnx', 'model_quantized.onnx')) || existsSync(join(dir, 'onnx', 'model.onnx'));
50
+ }
51
+
52
+ export function state() { return _state; }
53
+ export function isReady() { return _state === 'ready' && !!_pipe; }
54
+
55
+ // Shape the /status `ner` block consumes. `url` is reported by the server as the
56
+ // public in-process contract (http://host:port/ner); we expose the model + state.
57
+ export function health() {
58
+ return {
59
+ configured: _state !== 'off',
60
+ ok: isReady(),
61
+ state: _state,
62
+ model: _model,
63
+ error: _err,
64
+ };
65
+ }
66
+
67
+ // PER/ORG/LOC/MISC come back as `entity_group`. We hand {value,type} straight to
68
+ // @chatpanel/pii, whose normalizeEntities() maps PER->PERSON, ORG->ORG,
69
+ // LOC->LOCATION and applies the user's category toggles — one source of truth.
70
+ //
71
+ // Subword healing: wordpiece tokenizers can occasionally split a name even with
72
+ // aggregation on (e.g. "Acme" -> "A" + "##cme"). A leaked "##" fragment or a
73
+ // 1-char head would NOT string-match the original text, leaving PII un-redacted.
74
+ // So we stitch a `##` continuation back onto the previous same-type span. This is
75
+ // belt-and-suspenders — most spans already arrive merged.
76
+ export async function detect(text, { signal } = {}) {
77
+ if (!isReady()) return [];
78
+ if (signal?.aborted) return [];
79
+ const out = await _pipe(String(text || ''), { aggregation_strategy: 'simple' });
80
+ const ents = [];
81
+ for (const e of out || []) {
82
+ let word = String(e.word ?? '');
83
+ const type = e.entity_group ?? e.entity ?? 'ENTITY';
84
+ if (word.startsWith('##') && ents.length && ents[ents.length - 1].type === type) {
85
+ // Continuation of the previous token (no space): "A" + "##cme Corp".
86
+ ents[ents.length - 1].value += word.slice(2);
87
+ continue;
88
+ }
89
+ word = word.replace(/##/g, '').trim();
90
+ if (!word) continue;
91
+ ents.push({ value: word, type });
92
+ }
93
+ // Drop noise: a lone 1-char span that didn't get stitched is never useful PII.
94
+ return ents.filter((e) => e.value.length > 1);
95
+ }
96
+
97
+ // Lets @chatpanel/pii's existing `endpoint` detection backend call us with NO real
98
+ // HTTP and NO second port: pii POSTs {text}->{entities} to a URL; we intercept by
99
+ // being passed as `fetchImpl`, run the model in-process, and return a Response.
100
+ // This reuses all of pii's caching / timeout / type-gating untouched.
101
+ export async function fetchAdapter(_url, opts = {}) {
102
+ let text = '';
103
+ try { text = JSON.parse(opts.body || '{}').text || ''; } catch { /* empty */ }
104
+ const entities = await detect(text, { signal: opts.signal });
105
+ return new Response(JSON.stringify({ entities }), {
106
+ status: 200,
107
+ headers: { 'content-type': 'application/json' },
108
+ });
109
+ }
110
+
111
+ // Live download progress for the model currently being fetched (or null). The
112
+ // extension's model manager polls this. { model, file, pct }.
113
+ export function progress() { return _progress; }
114
+
115
+ // Import transformers ONCE and configure its env (model dir + WASM in a binary).
116
+ async function ensureLib() {
117
+ if (_lib) return _lib;
118
+ const root = modelRoot();
119
+ try { mkdirSync(root, { recursive: true }); } catch { /* best effort */ }
120
+
121
+ // In a Bun --compile single-file binary there is no native onnxruntime-node (its
122
+ // dylib can't be embedded), so the binary entry embeds the onnxruntime-web WASM
123
+ // runtime and hands us its paths via this global. When present we force the WASM
124
+ // backend (transformers picks it when it doesn't see a Node env) BEFORE importing
125
+ // transformers. The npm/Node path leaves this unset and uses the faster native
126
+ // runtime.
127
+ const wasmPaths = globalThis.__CHATPANEL_WASM_PATHS__ || null;
128
+ if (wasmPaths) {
129
+ try { Object.defineProperty(process, 'release', { value: { ...process.release, name: 'bun' }, configurable: true }); } catch { /* ignore */ }
130
+ }
131
+
132
+ const { env, pipeline } = await import('@huggingface/transformers');
133
+ env.cacheDir = root; // where remote downloads are cached
134
+ env.localModelPath = root; // where local loads resolve — same dir, we control it
135
+ try { env.remoteHost = MODEL_HOST; } catch { /* optional */ }
136
+ try { env.backends.onnx.wasm.numThreads = 1; } catch { /* optional */ }
137
+ if (wasmPaths) {
138
+ // Single-thread + no proxy + preloaded wasm bytes — avoids the `blob:` ESM-scheme
139
+ // failure when ORT-web runs outside a browser.
140
+ try { env.backends.onnx.wasm.proxy = false; env.backends.onnx.wasm.wasmPaths = wasmPaths; } catch { /* optional */ }
141
+ }
142
+ _lib = { env, pipeline };
143
+ return _lib;
144
+ }
145
+
146
+ // (Re)load a specific model into _pipe. Downloads it first if missing (and allowed).
147
+ // Reusable by init() and setModel(). Fail-open: on error, state='error', _pipe stays
148
+ // whatever it was (so a failed SWITCH doesn't kill a working detector).
149
+ async function loadModel(modelId, { log = () => {}, allowDownload = true } = {}) {
150
+ const prevPipe = _pipe;
151
+ const prevModel = _model;
152
+ let lib;
153
+ try {
154
+ lib = await ensureLib();
155
+ } catch (e) {
156
+ _state = 'error'; _err = `engine load failed: ${e.message}`;
157
+ log(`[ner] transformers.js not available (${e.message}) — deterministic-only`);
158
+ return false;
159
+ }
160
+
161
+ const haveLocal = modelOnDisk(modelId);
162
+ // Offline if cached (fast + private); only reach the network when missing.
163
+ lib.env.allowRemoteModels = haveLocal ? false : !!allowDownload;
164
+ if (!haveLocal && !allowDownload) {
165
+ _state = 'error'; _err = 'model not on disk and downloads disabled';
166
+ log(`[ner] model ${modelId} not installed and downloads disabled — deterministic-only`);
167
+ return false;
168
+ }
169
+
170
+ _state = haveLocal ? 'loading' : 'downloading';
171
+ if (!haveLocal) { _progress = { model: modelId, file: null, pct: 0 }; log(`[ner] downloading model ${modelId} (one-time)…`); }
172
+
173
+ try {
174
+ const pipe = await lib.pipeline('token-classification', modelId, {
175
+ dtype: 'q8',
176
+ progress_callback: (p) => {
177
+ if (!p) return;
178
+ const pct = typeof p.progress === 'number' ? Math.round(p.progress) : (_progress?.pct ?? 0);
179
+ if (p.status === 'progress' || p.status === 'download' || p.status === 'initiate') {
180
+ _progress = { model: modelId, file: p.file || _progress?.file || null, pct };
181
+ } else if (p.status === 'done' && p.file) {
182
+ log(`[ner] fetched ${p.file}`);
183
+ }
184
+ },
185
+ });
186
+ // Swap in the new pipeline, dispose the old one (free its WASM/native session).
187
+ _pipe = pipe; _model = modelId; _state = 'ready'; _err = null; _progress = null;
188
+ if (prevPipe && prevPipe !== pipe) { try { await prevPipe.dispose?.(); } catch { /* ignore */ } }
189
+ log(`[ner] ready — model ${modelId} (in-process, no Python) — entity detection active`);
190
+ return true;
191
+ } catch (e) {
192
+ _err = e.message; _progress = null;
193
+ // Keep any previously-working detector rather than going dark on a bad switch.
194
+ if (prevPipe) { _pipe = prevPipe; _model = prevModel; _state = 'ready'; }
195
+ else { _state = 'error'; }
196
+ log(`[ner] model load failed (${e.message})${prevPipe ? ' — keeping previous model' : ' — deterministic-only'}`);
197
+ return false;
198
+ }
199
+ }
200
+
201
+ // Load the default/configured model once at startup. Returns a promise that
202
+ // resolves when ready (or after a fail-open error). `cfg` = { model?, allowDownload?, onLog? }.
203
+ export function init(cfg = {}) {
204
+ if (_initPromise) return _initPromise;
205
+ const log = typeof cfg.onLog === 'function' ? cfg.onLog : () => {};
206
+ _model = cfg.model || DEFAULT_MODEL;
207
+ _state = 'loading';
208
+ _initPromise = loadModel(_model, { log, allowDownload: cfg.allowDownload !== false });
209
+ return _initPromise;
210
+ }
211
+
212
+ // Switch to a different model (download it first if needed). Used by the model
213
+ // manager. Returns true on success. A failed switch keeps the current detector.
214
+ export async function setModel(modelId, opts = {}) {
215
+ const log = typeof opts.onLog === 'function' ? opts.onLog : () => {};
216
+ const allowDownload = opts.allowDownload !== false;
217
+ if (!modelId) return false;
218
+ if (modelId === _model && isReady()) return true;
219
+ return loadModel(modelId, { log, allowDownload });
220
+ }
221
+
222
+ // Test hook: reset module state (used by unit tests).
223
+ export function _reset() {
224
+ _state = 'off'; _model = null; _pipe = null; _err = null; _initPromise = null; _lib = null; _progress = null;
225
+ }
package/src/ner.js CHANGED
@@ -1,168 +1,43 @@
1
- // Managed local NER server. When cfg.ner.autostart is on, launching the gateway
2
- // also spins up the bundled spaCy detector (./ner) and wires redaction.detection
3
- // at it — so name/org redaction works with a single command, no second terminal.
1
+ // Managed in-process NER. When cfg.ner.autostart is on, launching the gateway
2
+ // loads the bundled ONNX entity detector (./ner-engine.js) and flips redaction to
3
+ // full tier once it's ready — name/org redaction with a single command, no second
4
+ // process, no second port, no Python.
4
5
  //
5
- // Fail-open by design: if Python/spaCy isn't set up, we log a one-line hint and
6
- // the gateway keeps running with deterministic-only redaction (the detector layer
7
- // is already cached + fail-open per request).
6
+ // Fail-open by design: if the model can't load (e.g. first run, no network, no
7
+ // cached weights), we log a one-line hint and the gateway keeps running with
8
+ // deterministic-only redaction. redact.js consults the engine directly, so we do
9
+ // NOT mutate cfg.redaction.detection here (that field is reserved for a user's own
10
+ // external detector, which takes precedence — see below).
8
11
 
9
- import { spawn } from 'node:child_process';
10
- import { fileURLToPath } from 'node:url';
11
- import { dirname, join } from 'node:path';
12
- import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync } from 'node:fs';
13
- import os from 'node:os';
14
- import { NER_ASSETS } from './ner-assets.js';
12
+ import * as engine from './ner-engine.js';
15
13
 
16
- // Where the NER server lives ON DISK. We always run from a real, writable user dir
17
- // (~/.chatpanel/ner) rather than the package's ner/ — because a STANDALONE BINARY
18
- // has no ner/ on the filesystem at all (it's inside the SEA snapshot), and a global
19
- // npm dir may be read-only. The venv + installed deps persist here across restarts.
20
- function materializeNerDir() {
21
- const dir = join(os.homedir(), '.chatpanel', 'ner');
22
- try {
23
- mkdirSync(dir, { recursive: true });
24
- for (const [name, b64] of Object.entries(NER_ASSETS)) {
25
- const target = join(dir, name);
26
- const want = Buffer.from(b64, 'base64');
27
- // Refresh the scripts if changed (pick up updates), but never touch .venv.
28
- const have = existsSync(target) ? readFileSync(target) : null;
29
- if (!have || !have.equals(want)) writeFileSync(target, want);
30
- if (name.endsWith('.sh')) { try { chmodSync(target, 0o755); } catch { /* best effort */ } }
31
- }
32
- return dir;
33
- } catch (e) {
34
- console.log(`[ner] could not materialize NER dir at ${dir} (${e.message})`);
35
- return null;
36
- }
37
- }
38
-
39
- // A login service (LaunchAgent / systemd) inherits a MINIMAL PATH — not your
40
- // shell's — so `bash` and a pyenv/homebrew `python3` aren't found. Resolve bash
41
- // absolutely and enrich PATH with the usual locations so run.sh + python3 work.
42
- function resolveBash() {
43
- for (const p of ['/bin/bash', '/usr/bin/bash', '/usr/local/bin/bash', '/opt/homebrew/bin/bash']) {
44
- if (existsSync(p)) return p;
45
- }
46
- return 'bash';
47
- }
48
-
49
- function enrichedPath(home = os.homedir(), base = process.env.PATH || '') {
50
- const extra = [
51
- '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin',
52
- join(home, '.pyenv', 'shims'), join(home, '.pyenv', 'bin'),
53
- join(home, '.local', 'bin'),
54
- ];
55
- return [...new Set([...base.split(':').filter(Boolean), ...extra])].join(':');
56
- }
57
-
58
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
59
-
60
- // Probe the ACTUAL /ner contract (POST {text} -> {entities}), not /health: many
61
- // NER servers (incl. a user's own) expose only /ner. If anything answers here, we
62
- // can use it as the detector.
63
- async function nerReachable(port, signal) {
64
- try {
65
- const res = await fetch(`http://127.0.0.1:${port}/ner`, {
66
- method: 'POST',
67
- headers: { 'content-type': 'application/json' },
68
- body: JSON.stringify({ text: 'ping' }),
69
- signal,
70
- });
71
- return res.ok;
72
- } catch {
73
- return false;
74
- }
75
- }
76
-
77
- // Launch + supervise the NER server (or adopt one already on the port). Returns
78
- // { stop() } or null if not started. Mutates cfg.redaction once NER answers.
79
14
  export function startNer(cfg) {
80
15
  const n = cfg.ner;
81
16
  if (!n || !n.autostart) return null;
82
17
 
83
- const port = n.port || 9009;
84
- const bundledUrl = `http://127.0.0.1:${port}/ner`;
85
-
86
- // Respect a USER-configured detector pointing elsewhere (a custom NER, a local
87
- // LLM) — don't relaunch — but still apply the full-tier bump. If detection is
88
- // off OR points at our own bundled NER, fall through and (re)launch/adopt it,
89
- // so a persisted "detection→bundled" config doesn't leave NER actually down.
18
+ // Respect a USER-configured external detector (a custom NER endpoint or a local
19
+ // LLM): don't load the bundled engine — just apply the full-tier bump so their
20
+ // detector is actually used.
90
21
  const det = cfg.redaction?.detection;
91
- const userDetector = det && det.backend && det.backend !== 'off' && det.url !== bundledUrl;
92
- if (userDetector) {
22
+ if (det && det.backend && det.backend !== 'off') {
93
23
  if (n.enableFullTier && cfg.redaction.tier !== 'full') cfg.redaction.tier = 'full';
94
24
  console.log(`[ner] using configured detector (${det.backend} ${det.url || ''}) — full tier ${cfg.redaction.tier === 'full' ? 'on' : 'off'}`);
95
25
  return null;
96
26
  }
97
- let stopped = false;
98
- let child = null;
99
- const ac = new AbortController();
100
-
101
- const wire = (how) => {
102
- cfg.redaction.detection = { backend: 'endpoint', url: `http://127.0.0.1:${port}/ner`, timeoutMs: 1500, maxChars: 8000 };
103
- if (n.enableFullTier && cfg.redaction.tier !== 'full') cfg.redaction.tier = 'full';
104
- console.log(`[ner] ${how} on http://127.0.0.1:${port}/ner — entity detection active (tier: ${cfg.redaction.tier})`);
105
- };
106
27
 
107
- (async () => {
108
- // 1) Adopt an existing NER already serving on the port (e.g. the user's own).
109
- if (await nerReachable(port, ac.signal)) { wire('using existing NER'); return; }
110
-
111
- // 2) Otherwise launch the bundled spaCy server — materializing run.sh +
112
- // server.py + requirements.txt to a real on-disk dir first (the binary has
113
- // no ner/ on the FS). run.sh then creates the venv, installs deps, and runs.
114
- const nerDir = materializeNerDir();
115
- if (!nerDir) { console.log('[ner] no writable NER dir — deterministic redaction only'); return; }
116
- try {
117
- child = spawn(resolveBash(), ['run.sh'], {
118
- cwd: nerDir,
119
- env: {
120
- ...process.env,
121
- PORT: String(port),
122
- PATH: enrichedPath(),
123
- // Force official PyPI: a machine pinned to a private/corp index (in
124
- // pip.conf) can't reach it off-VPN, which breaks the one-time install.
125
- PIP_INDEX_URL: process.env.CHATPANEL_PIP_INDEX_URL || 'https://pypi.org/simple',
126
- PIP_EXTRA_INDEX_URL: '',
127
- PIP_DISABLE_PIP_VERSION_CHECK: '1',
128
- },
129
- stdio: ['ignore', 'pipe', 'pipe'],
130
- });
131
- } catch (e) {
132
- console.log(`[ner] could not launch bundled NER (${e.message}) — deterministic redaction only`);
133
- return;
134
- }
135
- let firstRun = true;
136
- child.stdout?.on('data', (b) => {
137
- if (firstRun && /installing dependencies/i.test(b.toString())) {
138
- firstRun = false;
139
- console.log('[ner] first run: creating venv + installing spaCy (one-time, may take a minute)…');
140
- }
141
- });
142
- child.stderr?.on('data', () => { /* uvicorn logs to stderr; swallow */ });
143
- child.on('error', (e) => console.log(`[ner] failed to start (${e.message}). Is python3 installed? Falling back to deterministic redaction.`));
144
- child.on('exit', (code) => {
145
- if (code && code !== 0 && !stopped) {
146
- // The bundled one couldn't bind (often the port is taken by another NER).
147
- // If SOMETHING answers /ner there, adopt it instead of giving up.
148
- nerReachable(port, ac.signal).then((ok) => { if (ok && !stopped) wire('adopted NER'); else if (!stopped) console.log(`[ner] server exited (code ${code}); deterministic-only.`); });
149
- }
150
- });
151
-
152
- // 3) Poll for readiness; wire detection when up.
153
- const deadline = Date.now() + 300_000; // generous: first run installs deps
154
- while (Date.now() < deadline && !stopped) {
155
- if (await nerReachable(port, ac.signal)) { wire('ready'); return; }
156
- await sleep(1000);
28
+ let stopped = false;
29
+ engine.init({
30
+ model: n.model,
31
+ allowDownload: n.allowDownload !== false,
32
+ onLog: (m) => { if (!stopped) console.log(m); },
33
+ }).then(() => {
34
+ if (stopped) return;
35
+ if (engine.isReady() && n.enableFullTier && cfg.redaction.tier !== 'full') {
36
+ cfg.redaction.tier = 'full';
37
+ console.log(`[ner] full tier on — name/org redaction active`);
157
38
  }
158
- if (!stopped) console.log(`[ner] not ready after 300s continuing deterministic-only (run ${join(os.homedir(), '.chatpanel', 'ner', 'run.sh')} manually to debug).`);
159
- })();
39
+ }).catch((e) => { if (!stopped) console.log(`[ner] init error (${e.message}) — deterministic-only`); });
160
40
 
161
- const stop = () => {
162
- if (stopped) return;
163
- stopped = true;
164
- ac.abort();
165
- try { child?.kill('SIGTERM'); } catch { /* ignore */ }
166
- };
167
- return { stop };
41
+ // Nothing to kill (no child process); just stop logging after shutdown.
42
+ return { stop() { stopped = true; } };
168
43
  }
package/src/redact.js CHANGED
@@ -9,6 +9,7 @@
9
9
  // extension's pii-pipeline.)
10
10
 
11
11
  import { createVault, redactText, detectEntities, effectiveTier, gatedDictionary } from '@chatpanel/pii';
12
+ import * as engine from './ner-engine.js';
12
13
 
13
14
  // tier: 'basic' | 'full'. For 'full' we run the local detector over the combined
14
15
  // text to harvest names/orgs, then redact every segment against that entity set.
@@ -25,17 +26,27 @@ export async function redactSegments(segments, redactionCfg, { signal, isPro = t
25
26
  const tier = effectiveTier({ tier: redactionCfg.tier }, isPro);
26
27
  const dictionary = gatedDictionary(redactionCfg, isPro);
27
28
 
29
+ // Detection source: a USER-configured external detector takes precedence; else
30
+ // the bundled in-process engine (no second port — we hand pii-detect a fetchImpl
31
+ // that runs the model in-process instead of doing real HTTP). Either way we reuse
32
+ // @chatpanel/pii's caching / timeout / type-gating — one source of truth.
33
+ const det = redactionCfg.detection;
34
+ const useExternal = !!(det && det.backend && det.backend !== 'off');
35
+ const useEngine = !useExternal && engine.isReady();
36
+
28
37
  let entities = [];
29
- if (tier === 'full' && redactionCfg.detection?.backend && redactionCfg.detection.backend !== 'off') {
30
- // One detection pass over the joined text — the detector is cached + fail-open,
31
- // so a slow/broken NER service never blocks the request (deterministic layer
32
- // still runs). Give it a generous CEILING (matching the extension): a fast NER
33
- // returns in well under a second, but a slow/cold detector must be allowed to
34
- // finish — if it times out, the turn falls back to dictionary/deterministic-only
35
- // redaction (permanent pseudonyms the reply-restorer can't undo).
36
- const detection = { ...redactionCfg.detection, timeoutMs: Math.max(Number(redactionCfg.detection.timeoutMs) || 0, 30000) };
38
+ if (tier === 'full' && (useExternal || useEngine)) {
39
+ // One detection pass over the joined text — cached + fail-open, so a slow/broken
40
+ // detector never blocks the request (the deterministic layer still runs). Give it
41
+ // a generous CEILING (matching the extension): a fast detector returns in well
42
+ // under a second, but a cold one must be allowed to finish; on timeout the turn
43
+ // falls back to dictionary/deterministic-only redaction.
44
+ const detection = useEngine
45
+ ? { backend: 'endpoint', url: 'inproc:ner', timeoutMs: 30000, maxChars: 8000, types: det?.types }
46
+ : { ...det, timeoutMs: Math.max(Number(det.timeoutMs) || 0, 30000) };
47
+ const fetchImpl = useEngine ? engine.fetchAdapter : undefined;
37
48
  try {
38
- entities = await detectEntities(texts.join('\n\n'), { detection }, { signal });
49
+ entities = await detectEntities(texts.join('\n\n'), { detection }, { signal, fetchImpl });
39
50
  } catch {
40
51
  entities = [];
41
52
  }
package/src/server.js CHANGED
@@ -26,6 +26,8 @@ import { streamBridgeChat, readBridgeToken, openBridgeChat } from './bridge.js';
26
26
  import { createRelaySession, getRelaySession, endRelaySession, pumpBridgeStream, deliverToolResult, toolsToSpecs, parseToolCallId } from './toolrelay.js';
27
27
  import { shaperFor } from './shape.js';
28
28
  import { startNer } from './ner.js';
29
+ import * as nerEngine from './ner-engine.js';
30
+ import { MODEL_CATALOG, isKnownModel } from './models.js';
29
31
  import { resolvePro, meter, usage } from './freegate.js';
30
32
  import { publicConfig, applyConfigPatch, persistConfig, configPath } from './configstore.js';
31
33
  import { resolveDestination, aggregateModelsAsync } from './router.js';
@@ -33,7 +35,7 @@ import * as openai from './openai.js';
33
35
  import * as responses from './responses.js';
34
36
  import * as anthropic from './anthropic.js';
35
37
 
36
- export const VERSION = '0.5.5';
38
+ export const VERSION = '0.6.0';
37
39
 
38
40
  const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
39
41
 
@@ -41,7 +43,7 @@ const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'anti
41
43
  // the extension's AUTO mode via the SAME shared ranker. We narrow only tools whose
42
44
  // name looks like an MCP tool (server-prefixed) so a client's CORE tools (bash,
43
45
  // read, edit…) are never dropped — that would break agent clients like OpenCode.
44
- const DEFAULT_GATEWAY_TOOL_CAP = 8;
46
+ const DEFAULT_GATEWAY_TOOL_CAP = 16;
45
47
  const MCP_NAME_RE = /^mcp[_-]/i;
46
48
  const toolName = (t) => (t && t.function && t.function.name) || (t && t.name) || '';
47
49
  const toolDesc = (t) => (t && t.function && t.function.description) || (t && t.description) || '';
@@ -162,16 +164,30 @@ function sendJson(res, status, obj) {
162
164
  res.end(JSON.stringify(obj));
163
165
  }
164
166
 
165
- // The detector URL (…:9009/ner) and its sibling /health. Returns null when no
166
- // detector is wired (deterministic-only).
167
+ // A USER-configured external detector's URL and its sibling /health. Returns null
168
+ // when no external detector is wired (the bundled in-process engine is handled
169
+ // separately in probeNerHealth / the /ner route).
167
170
  function nerBaseUrl(cfg) {
168
171
  const url = cfg.redaction?.detection?.url;
169
172
  if (!url || cfg.redaction?.detection?.backend === 'off') return null;
170
173
  return url;
171
174
  }
172
175
 
173
- // Live health probe of the bundled/configured NER (GET /health { ok, model }).
176
+ // Health of the detector for /status. The bundled IN-PROCESS engine takes
177
+ // precedence; its public contract URL is the gateway's own /ner (no second port).
178
+ // A user-configured external detector is probed over HTTP as before.
174
179
  async function probeNerHealth(cfg) {
180
+ if (nerEngine.state() !== 'off') {
181
+ const h = nerEngine.health();
182
+ return {
183
+ configured: h.configured,
184
+ ok: h.ok,
185
+ state: h.state,
186
+ model: h.model,
187
+ error: h.error || null,
188
+ url: h.configured ? `http://${cfg.host}:${cfg.port}/ner` : null,
189
+ };
190
+ }
175
191
  const url = nerBaseUrl(cfg);
176
192
  if (!url) return { configured: false, ok: false, url: null, model: null };
177
193
  try {
@@ -381,16 +397,30 @@ export function createGateway(cfg = loadConfig()) {
381
397
  uptimeSeconds: Math.floor((Date.now() - STARTED_AT) / 1000),
382
398
  });
383
399
  }
384
- // Proxy the detector so it's checkable on the gateway's own port (the NER runs
385
- // on its own port, e.g. 9009 hitting :4320/ner used to 404). GET → health.
400
+ // The detector, on the gateway's own port (no second port). GET → health;
401
+ // POST {text} {entities}. The bundled engine runs IN-PROCESS; a user's own
402
+ // external detector (if configured) is proxied for back-compat.
386
403
  if (pathname === '/ner') {
387
- const url = nerBaseUrl(cfg);
388
- if (!url) return sendJson(res, 503, { error: { message: 'NER not configured — deterministic-only redaction', type: 'ner_off' } });
389
404
  if (req.method === 'GET') {
390
405
  const health = await probeNerHealth(cfg);
391
406
  return sendJson(res, health.ok ? 200 : 503, health);
392
407
  }
393
408
  if (req.method === 'POST') {
409
+ // In-process engine path.
410
+ if (nerEngine.state() !== 'off') {
411
+ try {
412
+ const body = await readBody(req, cfg.maxBodyBytes);
413
+ let text = '';
414
+ try { text = JSON.parse(body.toString('utf8'))?.text || ''; } catch { /* empty */ }
415
+ const entities = await nerEngine.detect(text);
416
+ return sendJson(res, 200, { entities });
417
+ } catch (e) {
418
+ return sendJson(res, 500, { error: { message: `NER error: ${e.message}`, type: 'ner_error' } });
419
+ }
420
+ }
421
+ // External detector proxy (user-configured endpoint).
422
+ const url = nerBaseUrl(cfg);
423
+ if (!url) return sendJson(res, 503, { error: { message: 'NER not configured — deterministic-only redaction', type: 'ner_off' } });
394
424
  try {
395
425
  const body = await readBody(req, cfg.maxBodyBytes);
396
426
  const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: AbortSignal.timeout(8000) });
@@ -402,6 +432,34 @@ export function createGateway(cfg = loadConfig()) {
402
432
  }
403
433
  }
404
434
  }
435
+ // Model manager (the extension's Gateway settings drive these). GET lists the
436
+ // catalog with install state + live download progress; POST switches the active
437
+ // model (downloading it first if needed) and persists the choice.
438
+ if (pathname === '/ner/models') {
439
+ if (req.method === 'GET') {
440
+ const available = MODEL_CATALOG.map((m) => ({ ...m, installed: nerEngine.modelOnDisk(m.id) }));
441
+ return sendJson(res, 200, {
442
+ active: nerEngine.health().model || cfg.ner?.model || null,
443
+ state: nerEngine.state(),
444
+ progress: nerEngine.progress(),
445
+ available,
446
+ });
447
+ }
448
+ if (req.method === 'POST') {
449
+ let body = null;
450
+ try { body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')); } catch { body = null; }
451
+ const id = body && typeof body.id === 'string' ? body.id : null;
452
+ if (!id || !isKnownModel(id)) return sendJson(res, 400, { error: { message: 'unknown model id', type: 'bad_model' } });
453
+ // Persist first so a restart keeps the choice, then (re)load. Don't block the
454
+ // response on a possibly-long download — the client polls GET for progress.
455
+ if (cfg.ner) cfg.ner.model = id; else cfg.ner = { autostart: true, model: id, allowDownload: true, enableFullTier: true };
456
+ try { persistConfig(cfg, configPath()); } catch { /* best effort */ }
457
+ nerEngine.setModel(id, { onLog: (m) => console.log(m) }).then((ok) => {
458
+ if (ok && cfg.ner?.enableFullTier && cfg.redaction.tier !== 'full') cfg.redaction.tier = 'full';
459
+ });
460
+ return sendJson(res, 202, { accepted: true, active: id, state: nerEngine.state(), progress: nerEngine.progress() });
461
+ }
462
+ }
405
463
  if (pathname === '/logs' && req.method === 'GET') {
406
464
  return sendJson(res, 200, { entries: [...recentRequests].reverse() }); // newest first; counts only
407
465
  }
package/ner/README.md DELETED
@@ -1,84 +0,0 @@
1
- # ChatPanel — local NER helper (spaCy)
2
-
3
- A ~30-line local service that lets ChatPanel **auto-redact names, organizations,
4
- and locations** before anything is sent to a chat model. Detection runs entirely
5
- on your machine; the model only ever sees placeholders like `[[PERSON_1]]`.
6
-
7
- ChatPanel's redaction contract is simple — any local HTTP service works:
8
-
9
- ```
10
- POST /ner { "text": "..." }
11
- → { "entities": [ { "value": "Alex", "type": "PERSON" }, ... ] }
12
- ```
13
-
14
- (spaCy's `{ "ents": [{ "text", "label" }] }` shape is also accepted, as is a
15
- local OpenAI-compatible LLM — see "Other detectors" below.)
16
-
17
- ## Quick start
18
-
19
- Most machines block installing Python packages globally, so use a virtual env:
20
-
21
- ```bash
22
- cd helpers/ner-server
23
-
24
- python3 -m venv .venv
25
- source .venv/bin/activate # Windows: .venv\Scripts\activate
26
- pip install -r requirements.txt
27
- python -m spacy download en_core_web_sm
28
-
29
- uvicorn server:app --port 9009 # the file is server.py → import path "server:app"
30
- ```
31
-
32
- …or just run the bundled script (does all of the above):
33
-
34
- ```bash
35
- ./run.sh # PORT=9100 ./run.sh to change the port
36
- ```
37
-
38
- Check it's up: `curl http://127.0.0.1:9009/health`
39
-
40
- ## Point ChatPanel at it
41
-
42
- **Settings → Privacy** (or the 🛡 button in the chat composer):
43
-
44
- | Field | Value |
45
- |------|-------|
46
- | Redaction | **On — + AI detection** |
47
- | Detector | **Local NER service (spaCy / Presidio)** |
48
- | Detector URL | `http://127.0.0.1:9009/ner` |
49
- | Redact types | People / Organizations / Locations / Numbers (your choice) |
50
-
51
- Now `my name is Alex from Denver` is sent to the model as
52
- `my name is [[PERSON_1]] from [[LOCATION_1]]`, and the reply is restored to the
53
- real values in your view.
54
-
55
- > Turning **Locations** off keeps city names readable (useful for "how far is X
56
- > from Y" questions) while still redacting people.
57
-
58
- ## Accuracy vs. speed
59
-
60
- `en_core_web_sm` is small and fast (good default). For fewer misses:
61
-
62
- ```bash
63
- python -m spacy download en_core_web_md # or en_core_web_trf (best, heavier)
64
- ```
65
-
66
- then change `MODEL` in `server.py`. Small models can over-tag short acronyms as
67
- `ORG`; ChatPanel drops noisy short numerics/dates automatically and lets you turn
68
- off whole categories.
69
-
70
- ## Other detectors
71
-
72
- ChatPanel doesn't care what's behind the URL, as long as it returns the entity
73
- shape above. Drop-in alternatives:
74
-
75
- - **Microsoft Presidio** — `presidio-analyzer` behind a small FastAPI wrapper
76
- (returns `{ "results": [...] }`, also accepted).
77
- - **A local LLM** — set the detector to **Local LLM (OpenAI-compatible)** and
78
- point it at Ollama / LM Studio / llama.cpp (e.g. `http://127.0.0.1:11434`);
79
- ChatPanel prompts it for strict JSON entities. Slower than spaCy, no extra
80
- service to run if you already have a local model. See `../local-model.md`.
81
-
82
- Everything is local and latency-guarded (cached + timed out + fail-open): if the
83
- detector is slow or down, ChatPanel falls back to deterministic redaction so chat
84
- never blocks.
@@ -1,3 +0,0 @@
1
- fastapi
2
- uvicorn[standard]
3
- spacy
package/ner/run.sh DELETED
@@ -1,21 +0,0 @@
1
- #!/usr/bin/env bash
2
- # One-shot: create the venv (if needed), install deps + the spaCy model, and serve.
3
- # Usage: ./run.sh (defaults to port 9009)
4
- # PORT=9100 ./run.sh
5
- set -euo pipefail
6
- cd "$(dirname "$0")"
7
-
8
- if [ ! -d .venv ]; then
9
- echo "→ creating virtual env (.venv)…"
10
- python3 -m venv .venv
11
- fi
12
- # shellcheck disable=SC1091
13
- source .venv/bin/activate
14
-
15
- echo "→ installing dependencies…"
16
- pip install -q --upgrade pip
17
- pip install -q -r requirements.txt
18
- python -c "import en_core_web_sm" >/dev/null 2>&1 || python -m spacy download en_core_web_sm
19
-
20
- echo "→ serving on http://127.0.0.1:${PORT:-9009}/ner (Ctrl-C to stop)"
21
- exec uvicorn server:app --port "${PORT:-9009}"
package/ner/server.py DELETED
@@ -1,95 +0,0 @@
1
- """
2
- ChatPanel local NER helper — a tiny on-device entity detector for PII redaction.
3
-
4
- ChatPanel's Privacy → "AI detection" can point at any LOCAL service that accepts
5
- POST {"text": "..."}
6
- and returns
7
- {"entities": [{"value": "...", "type": "PERSON|ORG|GPE|EMAIL|PHONE|..."}]}
8
-
9
- This wraps spaCy (people / organizations / locations) AND adds a regex pass for
10
- the structured identifiers spaCy doesn't emit (emails, phone numbers, SSNs, cards,
11
- IPs), so the detector is comprehensive on its own. Only the redacted placeholders
12
- (e.g. [[PERSON_1]]) ever reach the chat model — the raw text never leaves your box.
13
-
14
- --------------------------------------------------------------------------------
15
- Setup (most machines block global pip installs, so use a virtual env)
16
-
17
- python3 -m venv .venv
18
- source .venv/bin/activate # Windows: .venv\\Scripts\\activate
19
- pip install -r requirements.txt
20
- python -m spacy download en_core_web_sm
21
-
22
- Run (the file is server.py, so the uvicorn import path is "server:app")
23
-
24
- uvicorn server:app --port 9009
25
-
26
- Then in ChatPanel → Settings → Privacy:
27
- Redaction : On — + AI detection
28
- Detector : Local NER service (spaCy / Presidio)
29
- URL : http://127.0.0.1:9009/ner
30
-
31
- Tip: en_core_web_sm is small + fast. For better accuracy use en_core_web_md or
32
- en_core_web_trf (download the same way, then change the load below).
33
- --------------------------------------------------------------------------------
34
- """
35
- from fastapi import FastAPI
36
-
37
- import re
38
- import spacy
39
-
40
- MODEL = "en_core_web_sm"
41
- nlp = spacy.load(MODEL)
42
-
43
- app = FastAPI(title="ChatPanel NER helper")
44
-
45
- # spaCy's NER emits names / orgs / locations but NOT structured identifiers, so add
46
- # a regex pass for those. (ChatPanel also catches these on-device, but emitting them
47
- # here keeps the detector self-contained — what you test is what you get.) Order
48
- # matters: more specific patterns run first so a card / SSN isn't re-matched as a
49
- # phone number.
50
- _PATTERNS = [
51
- ("EMAIL", re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")),
52
- ("SSN", re.compile(r"\b\d{3}-\d{2}-\d{4}\b")),
53
- ("CREDIT_CARD", re.compile(r"\b(?:\d[ -]?){13,19}\b")),
54
- ("IP", re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")),
55
- ("PHONE", re.compile(r"(?<!\d)(?:\+?\d{1,2}[ .\-]?)?\(?\d{3}\)?[ .\-]?\d{3}[ .\-]?\d{4}(?!\d)")),
56
- ]
57
-
58
-
59
- def _regex_entities(text):
60
- out, taken = [], []
61
- for label, rx in _PATTERNS:
62
- for m in rx.finditer(text):
63
- start, end = m.start(), m.end()
64
- if any(start < te and ts < end for ts, te in taken):
65
- continue # span already claimed by a more specific pattern
66
- taken.append((start, end))
67
- out.append({"value": m.group(0).strip(), "type": label})
68
- return out
69
-
70
-
71
- @app.get("/health")
72
- def health():
73
- return {"ok": True, "model": MODEL}
74
-
75
-
76
- @app.post("/ner")
77
- def ner(payload: dict):
78
- """Return spaCy entities + regex identifiers in ChatPanel's expected shape.
79
-
80
- ChatPanel maps common labels itself (PER→PERSON, GPE→LOCATION, …) and keeps
81
- only the categories you enable in the Privacy tab, so it's fine to return all
82
- of them here.
83
- """
84
- text = (payload or {}).get("text", "") or ""
85
- doc = nlp(text)
86
- ents = [{"value": ent.text, "type": ent.label_} for ent in doc.ents]
87
- ents.extend(_regex_entities(text))
88
- # De-dup identical value+type (spaCy and a regex can both surface the same span).
89
- seen, out = set(), []
90
- for e in ents:
91
- key = (e["type"], e["value"].lower())
92
- if e["value"] and key not in seen:
93
- seen.add(key)
94
- out.append(e)
95
- return {"entities": out}
package/src/ner-assets.js DELETED
@@ -1,8 +0,0 @@
1
- // GENERATED by scripts/gen-ner-assets.mjs — do NOT edit. Edit ner/* and re-run.
2
- // Base64 of the bundled spaCy NER server, embedded so a standalone binary can
3
- // write a runnable ner/ directory to disk (the binary has no ner/ on the FS).
4
- export const NER_ASSETS = {
5
- "run.sh": "IyEvdXNyL2Jpbi9lbnYgYmFzaAojIE9uZS1zaG90OiBjcmVhdGUgdGhlIHZlbnYgKGlmIG5lZWRlZCksIGluc3RhbGwgZGVwcyArIHRoZSBzcGFDeSBtb2RlbCwgYW5kIHNlcnZlLgojIFVzYWdlOiAgLi9ydW4uc2ggICAgICAgIChkZWZhdWx0cyB0byBwb3J0IDkwMDkpCiMgICAgICAgICBQT1JUPTkxMDAgLi9ydW4uc2gKc2V0IC1ldW8gcGlwZWZhaWwKY2QgIiQoZGlybmFtZSAiJDAiKSIKCmlmIFsgISAtZCAudmVudiBdOyB0aGVuCiAgZWNobyAi4oaSIGNyZWF0aW5nIHZpcnR1YWwgZW52ICgudmVudinigKYiCiAgcHl0aG9uMyAtbSB2ZW52IC52ZW52CmZpCiMgc2hlbGxjaGVjayBkaXNhYmxlPVNDMTA5MQpzb3VyY2UgLnZlbnYvYmluL2FjdGl2YXRlCgplY2hvICLihpIgaW5zdGFsbGluZyBkZXBlbmRlbmNpZXPigKYiCnBpcCBpbnN0YWxsIC1xIC0tdXBncmFkZSBwaXAKcGlwIGluc3RhbGwgLXEgLXIgcmVxdWlyZW1lbnRzLnR4dApweXRob24gLWMgImltcG9ydCBlbl9jb3JlX3dlYl9zbSIgPi9kZXYvbnVsbCAyPiYxIHx8IHB5dGhvbiAtbSBzcGFjeSBkb3dubG9hZCBlbl9jb3JlX3dlYl9zbQoKZWNobyAi4oaSIHNlcnZpbmcgb24gaHR0cDovLzEyNy4wLjAuMToke1BPUlQ6LTkwMDl9L25lciAgKEN0cmwtQyB0byBzdG9wKSIKZXhlYyB1dmljb3JuIHNlcnZlcjphcHAgLS1wb3J0ICIke1BPUlQ6LTkwMDl9Igo=",
6
- "server.py": "IiIiCkNoYXRQYW5lbCBsb2NhbCBORVIgaGVscGVyIOKAlCBhIHRpbnkgb24tZGV2aWNlIGVudGl0eSBkZXRlY3RvciBmb3IgUElJIHJlZGFjdGlvbi4KCkNoYXRQYW5lbCdzIFByaXZhY3kg4oaSICJBSSBkZXRlY3Rpb24iIGNhbiBwb2ludCBhdCBhbnkgTE9DQUwgc2VydmljZSB0aGF0IGFjY2VwdHMKICAgIFBPU1QgeyJ0ZXh0IjogIi4uLiJ9CmFuZCByZXR1cm5zCiAgICB7ImVudGl0aWVzIjogW3sidmFsdWUiOiAiLi4uIiwgInR5cGUiOiAiUEVSU09OfE9SR3xHUEV8RU1BSUx8UEhPTkV8Li4uIn1dfQoKVGhpcyB3cmFwcyBzcGFDeSAocGVvcGxlIC8gb3JnYW5pemF0aW9ucyAvIGxvY2F0aW9ucykgQU5EIGFkZHMgYSByZWdleCBwYXNzIGZvcgp0aGUgc3RydWN0dXJlZCBpZGVudGlmaWVycyBzcGFDeSBkb2Vzbid0IGVtaXQgKGVtYWlscywgcGhvbmUgbnVtYmVycywgU1NOcywgY2FyZHMsCklQcyksIHNvIHRoZSBkZXRlY3RvciBpcyBjb21wcmVoZW5zaXZlIG9uIGl0cyBvd24uIE9ubHkgdGhlIHJlZGFjdGVkIHBsYWNlaG9sZGVycwooZS5nLiBbW1BFUlNPTl8xXV0pIGV2ZXIgcmVhY2ggdGhlIGNoYXQgbW9kZWwg4oCUIHRoZSByYXcgdGV4dCBuZXZlciBsZWF2ZXMgeW91ciBib3guCgotLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLQpTZXR1cCAgKG1vc3QgbWFjaGluZXMgYmxvY2sgZ2xvYmFsIHBpcCBpbnN0YWxscywgc28gdXNlIGEgdmlydHVhbCBlbnYpCgogICAgcHl0aG9uMyAtbSB2ZW52IC52ZW52CiAgICBzb3VyY2UgLnZlbnYvYmluL2FjdGl2YXRlICAgICAgICAgICMgV2luZG93czogLnZlbnZcXFNjcmlwdHNcXGFjdGl2YXRlCiAgICBwaXAgaW5zdGFsbCAtciByZXF1aXJlbWVudHMudHh0CiAgICBweXRob24gLW0gc3BhY3kgZG93bmxvYWQgZW5fY29yZV93ZWJfc20KClJ1biAgKHRoZSBmaWxlIGlzIHNlcnZlci5weSwgc28gdGhlIHV2aWNvcm4gaW1wb3J0IHBhdGggaXMgInNlcnZlcjphcHAiKQoKICAgIHV2aWNvcm4gc2VydmVyOmFwcCAtLXBvcnQgOTAwOQoKVGhlbiBpbiBDaGF0UGFuZWwg4oaSIFNldHRpbmdzIOKGkiBQcml2YWN5OgogICAgUmVkYWN0aW9uIDogT24g4oCUICsgQUkgZGV0ZWN0aW9uCiAgICBEZXRlY3RvciAgOiBMb2NhbCBORVIgc2VydmljZSAoc3BhQ3kgLyBQcmVzaWRpbykKICAgIFVSTCAgICAgICA6IGh0dHA6Ly8xMjcuMC4wLjE6OTAwOS9uZXIKClRpcDogZW5fY29yZV93ZWJfc20gaXMgc21hbGwgKyBmYXN0LiBGb3IgYmV0dGVyIGFjY3VyYWN5IHVzZSBlbl9jb3JlX3dlYl9tZCBvcgplbl9jb3JlX3dlYl90cmYgKGRvd25sb2FkIHRoZSBzYW1lIHdheSwgdGhlbiBjaGFuZ2UgdGhlIGxvYWQgYmVsb3cpLgotLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLQoiIiIKZnJvbSBmYXN0YXBpIGltcG9ydCBGYXN0QVBJCgppbXBvcnQgcmUKaW1wb3J0IHNwYWN5CgpNT0RFTCA9ICJlbl9jb3JlX3dlYl9zbSIKbmxwID0gc3BhY3kubG9hZChNT0RFTCkKCmFwcCA9IEZhc3RBUEkodGl0bGU9IkNoYXRQYW5lbCBORVIgaGVscGVyIikKCiMgc3BhQ3kncyBORVIgZW1pdHMgbmFtZXMgLyBvcmdzIC8gbG9jYXRpb25zIGJ1dCBOT1Qgc3RydWN0dXJlZCBpZGVudGlmaWVycywgc28gYWRkCiMgYSByZWdleCBwYXNzIGZvciB0aG9zZS4gKENoYXRQYW5lbCBhbHNvIGNhdGNoZXMgdGhlc2Ugb24tZGV2aWNlLCBidXQgZW1pdHRpbmcgdGhlbQojIGhlcmUga2VlcHMgdGhlIGRldGVjdG9yIHNlbGYtY29udGFpbmVkIOKAlCB3aGF0IHlvdSB0ZXN0IGlzIHdoYXQgeW91IGdldC4pIE9yZGVyCiMgbWF0dGVyczogbW9yZSBzcGVjaWZpYyBwYXR0ZXJucyBydW4gZmlyc3Qgc28gYSBjYXJkIC8gU1NOIGlzbid0IHJlLW1hdGNoZWQgYXMgYQojIHBob25lIG51bWJlci4KX1BBVFRFUk5TID0gWwogICAgKCJFTUFJTCIsIHJlLmNvbXBpbGUociJbQS1aYS16MC05Ll8lK1wtXStAW0EtWmEtejAtOS5cLV0rXC5bQS1aYS16XXsyLH0iKSksCiAgICAoIlNTTiIsIHJlLmNvbXBpbGUociJcYlxkezN9LVxkezJ9LVxkezR9XGIiKSksCiAgICAoIkNSRURJVF9DQVJEIiwgcmUuY29tcGlsZShyIlxiKD86XGRbIC1dPyl7MTMsMTl9XGIiKSksCiAgICAoIklQIiwgcmUuY29tcGlsZShyIlxiKD86XGR7MSwzfVwuKXszfVxkezEsM31cYiIpKSwKICAgICgiUEhPTkUiLCByZS5jb21waWxlKHIiKD88IVxkKSg/OlwrP1xkezEsMn1bIC5cLV0/KT9cKD9cZHszfVwpP1sgLlwtXT9cZHszfVsgLlwtXT9cZHs0fSg/IVxkKSIpKSwKXQoKCmRlZiBfcmVnZXhfZW50aXRpZXModGV4dCk6CiAgICBvdXQsIHRha2VuID0gW10sIFtdCiAgICBmb3IgbGFiZWwsIHJ4IGluIF9QQVRURVJOUzoKICAgICAgICBmb3IgbSBpbiByeC5maW5kaXRlcih0ZXh0KToKICAgICAgICAgICAgc3RhcnQsIGVuZCA9IG0uc3RhcnQoKSwgbS5lbmQoKQogICAgICAgICAgICBpZiBhbnkoc3RhcnQgPCB0ZSBhbmQgdHMgPCBlbmQgZm9yIHRzLCB0ZSBpbiB0YWtlbik6CiAgICAgICAgICAgICAgICBjb250aW51ZSAgIyBzcGFuIGFscmVhZHkgY2xhaW1lZCBieSBhIG1vcmUgc3BlY2lmaWMgcGF0dGVybgogICAgICAgICAgICB0YWtlbi5hcHBlbmQoKHN0YXJ0LCBlbmQpKQogICAgICAgICAgICBvdXQuYXBwZW5kKHsidmFsdWUiOiBtLmdyb3VwKDApLnN0cmlwKCksICJ0eXBlIjogbGFiZWx9KQogICAgcmV0dXJuIG91dAoKCkBhcHAuZ2V0KCIvaGVhbHRoIikKZGVmIGhlYWx0aCgpOgogICAgcmV0dXJuIHsib2siOiBUcnVlLCAibW9kZWwiOiBNT0RFTH0KCgpAYXBwLnBvc3QoIi9uZXIiKQpkZWYgbmVyKHBheWxvYWQ6IGRpY3QpOgogICAgIiIiUmV0dXJuIHNwYUN5IGVudGl0aWVzICsgcmVnZXggaWRlbnRpZmllcnMgaW4gQ2hhdFBhbmVsJ3MgZXhwZWN0ZWQgc2hhcGUuCgogICAgQ2hhdFBhbmVsIG1hcHMgY29tbW9uIGxhYmVscyBpdHNlbGYgKFBFUuKGklBFUlNPTiwgR1BF4oaSTE9DQVRJT04sIOKApikgYW5kIGtlZXBzCiAgICBvbmx5IHRoZSBjYXRlZ29yaWVzIHlvdSBlbmFibGUgaW4gdGhlIFByaXZhY3kgdGFiLCBzbyBpdCdzIGZpbmUgdG8gcmV0dXJuIGFsbAogICAgb2YgdGhlbSBoZXJlLgogICAgIiIiCiAgICB0ZXh0ID0gKHBheWxvYWQgb3Ige30pLmdldCgidGV4dCIsICIiKSBvciAiIgogICAgZG9jID0gbmxwKHRleHQpCiAgICBlbnRzID0gW3sidmFsdWUiOiBlbnQudGV4dCwgInR5cGUiOiBlbnQubGFiZWxffSBmb3IgZW50IGluIGRvYy5lbnRzXQogICAgZW50cy5leHRlbmQoX3JlZ2V4X2VudGl0aWVzKHRleHQpKQogICAgIyBEZS1kdXAgaWRlbnRpY2FsIHZhbHVlK3R5cGUgKHNwYUN5IGFuZCBhIHJlZ2V4IGNhbiBib3RoIHN1cmZhY2UgdGhlIHNhbWUgc3BhbikuCiAgICBzZWVuLCBvdXQgPSBzZXQoKSwgW10KICAgIGZvciBlIGluIGVudHM6CiAgICAgICAga2V5ID0gKGVbInR5cGUiXSwgZVsidmFsdWUiXS5sb3dlcigpKQogICAgICAgIGlmIGVbInZhbHVlIl0gYW5kIGtleSBub3QgaW4gc2VlbjoKICAgICAgICAgICAgc2Vlbi5hZGQoa2V5KQogICAgICAgICAgICBvdXQuYXBwZW5kKGUpCiAgICByZXR1cm4geyJlbnRpdGllcyI6IG91dH0K",
7
- "requirements.txt": "ZmFzdGFwaQp1dmljb3JuW3N0YW5kYXJkXQpzcGFjeQo=",
8
- };