euredact 0.6.0 → 0.7.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/NOTICE +11 -0
- package/README.md +403 -39
- package/dist/cjs/cache.js +2 -8
- package/dist/cjs/cloud/account.js +180 -0
- package/dist/cjs/cloud/batches.js +469 -0
- package/dist/cjs/cloud/client.js +124 -14
- package/dist/cjs/cloud/config.js +28 -2
- package/dist/cjs/cloud/errors.js +39 -2
- package/dist/cjs/cloud/index.js +16 -1
- package/dist/cjs/index.js +9 -1
- package/dist/cjs/platform.browser.js +98 -0
- package/dist/cjs/platform.js +140 -0
- package/dist/cjs/rules/bicGleif.js +57 -0
- package/dist/cjs/rules/bicRegistry.js +37 -11
- package/dist/cjs/rules/countries/index.js +63 -10
- package/dist/cjs/rules/cues.js +29 -9
- package/dist/cjs/rules/engine.js +14 -6
- package/dist/cjs/rules/suppressors.js +292 -22
- package/dist/cjs/rules/validators.js +50 -0
- package/dist/cjs/sdk.js +18 -7
- package/dist/cjs/types.js +4 -1
- package/dist/esm/cache.js +2 -8
- package/dist/esm/cloud/account.d.ts +155 -0
- package/dist/esm/cloud/account.js +175 -0
- package/dist/esm/cloud/batches.d.ts +166 -0
- package/dist/esm/cloud/batches.js +464 -0
- package/dist/esm/cloud/client.d.ts +83 -1
- package/dist/esm/cloud/client.js +119 -22
- package/dist/esm/cloud/config.d.ts +6 -0
- package/dist/esm/cloud/config.js +27 -2
- package/dist/esm/cloud/errors.d.ts +26 -1
- package/dist/esm/cloud/errors.js +35 -1
- package/dist/esm/cloud/index.d.ts +4 -2
- package/dist/esm/cloud/index.js +4 -2
- package/dist/esm/index.d.ts +2 -2
- package/dist/esm/index.js +1 -1
- package/dist/esm/platform.browser.d.ts +27 -0
- package/dist/esm/platform.browser.js +92 -0
- package/dist/esm/platform.d.ts +20 -0
- package/dist/esm/platform.js +101 -0
- package/dist/esm/rules/bicGleif.d.ts +4 -0
- package/dist/esm/rules/bicGleif.js +53 -0
- package/dist/esm/rules/bicRegistry.d.ts +25 -11
- package/dist/esm/rules/bicRegistry.js +36 -11
- package/dist/esm/rules/countries/index.js +63 -10
- package/dist/esm/rules/cues.d.ts +1 -1
- package/dist/esm/rules/cues.js +29 -9
- package/dist/esm/rules/engine.js +14 -6
- package/dist/esm/rules/suppressors.d.ts +1 -0
- package/dist/esm/rules/suppressors.js +291 -21
- package/dist/esm/rules/validators.d.ts +14 -0
- package/dist/esm/rules/validators.js +47 -0
- package/dist/esm/sdk.d.ts +51 -4
- package/dist/esm/sdk.js +18 -9
- package/dist/esm/types.d.ts +58 -2
- package/dist/esm/types.js +4 -1
- package/package.json +12 -4
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* [CLOUD EXTENSION] Batches: cloud mode, with hours between masking and mapping
|
|
3
|
+
* (rules-engine#84; the gateway side is `docs/BATCHES.md` in euredact-inference).
|
|
4
|
+
*
|
|
5
|
+
* Cloud mode masks a document here, sends only the masked text and maps the
|
|
6
|
+
* service's answer back onto the original, all in one call. A batch returns up
|
|
7
|
+
* to 24 h later, so `create()` writes one **local batch file** per batch: each
|
|
8
|
+
* original, its local detections and the SHA-256 of the masked text that was
|
|
9
|
+
* sent. None of it is uploaded. `results()` maps the service's spans back from
|
|
10
|
+
* that file, checking the hash before placing a span, then wipes the originals
|
|
11
|
+
* and leaves a text-free receipt.
|
|
12
|
+
*
|
|
13
|
+
* The file format is shared with the Python SDK, offsets counted in code points,
|
|
14
|
+
* so a batch created by one can be resolved by the other.
|
|
15
|
+
*
|
|
16
|
+
* In Node the file is `~/.euredact/batches/<batchId>.json` (`0600` in a `0700`
|
|
17
|
+
* directory); `batchDir` moves it. A page has no filesystem, so there `store`
|
|
18
|
+
* is required: any object with async `read`, `write`, `delete` and `list`.
|
|
19
|
+
* `cipher` encrypts the file at rest with a key the caller holds.
|
|
20
|
+
*/
|
|
21
|
+
import { EuRedact, applyReplacements, maskForCloud, ontoOriginal } from "../sdk.js";
|
|
22
|
+
import { defaultBatchStore, gzip, sha256Hex } from "../platform.js";
|
|
23
|
+
import { cloudErrorFor, codePointOffsets, isInteger, toCloudInfo, toResult } from "./client.js";
|
|
24
|
+
import { getConfig, requireSecureBaseUrl } from "./config.js";
|
|
25
|
+
import { CloudError, NotConfiguredError } from "./errors.js";
|
|
26
|
+
/** The gateway's limits (docs/BATCHES.md §2-§3), checked before upload. */
|
|
27
|
+
export const MAX_DOCUMENTS = 5000;
|
|
28
|
+
export const MAX_CUSTOM_ID = 64;
|
|
29
|
+
/** The gateway's customId rule (euredact-inference gateway/batches.py), whole string. */
|
|
30
|
+
export const CUSTOM_ID = /^[A-Za-z0-9_.:\-]{1,64}$/;
|
|
31
|
+
export const MAX_BODY_BYTES = 128 * 1024 * 1024;
|
|
32
|
+
/**
|
|
33
|
+
* Per document, counted by the model's tokenizer on the gateway. The SDK has no
|
|
34
|
+
* copy of it and no character count decides it, so it is **not** checked before
|
|
35
|
+
* upload: a document over it comes back as an error with code `too_long`.
|
|
36
|
+
*/
|
|
37
|
+
export const MAX_DOCUMENT_TOKENS = 5000;
|
|
38
|
+
export const FILE_FORMAT = "euredact-batch/1";
|
|
39
|
+
export class BatchError extends CloudError {
|
|
40
|
+
constructor(message) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = "BatchError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const encoder = new TextEncoder();
|
|
46
|
+
const decoder = new TextDecoder();
|
|
47
|
+
function now() {
|
|
48
|
+
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
49
|
+
}
|
|
50
|
+
/** UTF-16 offset -> code point offset, for writing the shared file format. */
|
|
51
|
+
function utf16ToCodePoints(text) {
|
|
52
|
+
if (!/[\uD800-\uDBFF]/.test(text))
|
|
53
|
+
return unit => unit;
|
|
54
|
+
const map = new Map();
|
|
55
|
+
let cp = 0;
|
|
56
|
+
let unit = 0;
|
|
57
|
+
for (const ch of text) {
|
|
58
|
+
map.set(unit, cp);
|
|
59
|
+
unit += ch.length;
|
|
60
|
+
cp += 1;
|
|
61
|
+
}
|
|
62
|
+
map.set(unit, cp);
|
|
63
|
+
return u => map.get(u) ?? u;
|
|
64
|
+
}
|
|
65
|
+
function toStored(d, cp) {
|
|
66
|
+
return {
|
|
67
|
+
type: String(d.entityType), start: cp(d.start), end: cp(d.end), text: d.text,
|
|
68
|
+
source: String(d.source), country: d.country ?? null, confidence: d.confidence ?? "high",
|
|
69
|
+
country_confidence: d.countryConfidence ?? 0, out_of_scope: d.outOfScope ?? false,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function fromStored(raw, unit) {
|
|
73
|
+
return {
|
|
74
|
+
entityType: raw.type, start: unit(raw.start), end: unit(raw.end),
|
|
75
|
+
text: raw.text, source: raw.source, country: raw.country,
|
|
76
|
+
confidence: raw.confidence, countryConfidence: raw.country_confidence,
|
|
77
|
+
outOfScope: raw.out_of_scope,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
// ── the client ────────────────────────────────────────────────────────────
|
|
81
|
+
export class Batches {
|
|
82
|
+
config;
|
|
83
|
+
store;
|
|
84
|
+
cipher;
|
|
85
|
+
fetchImpl;
|
|
86
|
+
sdk;
|
|
87
|
+
constructor(options = {}) {
|
|
88
|
+
const config = options.config ?? getConfig();
|
|
89
|
+
if (config === null)
|
|
90
|
+
throw new NotConfiguredError();
|
|
91
|
+
requireSecureBaseUrl(config.baseUrl);
|
|
92
|
+
this.config = config;
|
|
93
|
+
if (options.batchDir !== undefined && options.store !== undefined) {
|
|
94
|
+
throw new Error("pass batchDir or store, not both");
|
|
95
|
+
}
|
|
96
|
+
const store = options.store ?? defaultBatchStore(options.batchDir);
|
|
97
|
+
if (store === null) {
|
|
98
|
+
throw new Error("no filesystem here: pass a store ({ read, write, delete, list }) to Batches");
|
|
99
|
+
}
|
|
100
|
+
this.store = store;
|
|
101
|
+
this.cipher = options.cipher ?? null;
|
|
102
|
+
this.fetchImpl = options.fetchImpl;
|
|
103
|
+
this.sdk = options.sdk ?? null;
|
|
104
|
+
}
|
|
105
|
+
// -- plumbing ---------------------------------------------------------
|
|
106
|
+
engine() {
|
|
107
|
+
if (this.sdk === null)
|
|
108
|
+
this.sdk = new EuRedact();
|
|
109
|
+
return this.sdk;
|
|
110
|
+
}
|
|
111
|
+
async request(method, path, body, headers = {}) {
|
|
112
|
+
const impl = this.fetchImpl ?? globalThis.fetch;
|
|
113
|
+
if (!impl)
|
|
114
|
+
throw new CloudError("no fetch implementation available");
|
|
115
|
+
try {
|
|
116
|
+
return await impl(`${this.config.baseUrl}${path}`, {
|
|
117
|
+
method,
|
|
118
|
+
body: body,
|
|
119
|
+
headers: {
|
|
120
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
121
|
+
"Idempotency-Key": headers["Idempotency-Key"] ?? crypto.randomUUID(),
|
|
122
|
+
...this.config.headers,
|
|
123
|
+
...headers,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
throw new CloudError(`batch request failed: ${err.message}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async json(resp) {
|
|
132
|
+
try {
|
|
133
|
+
return (await resp.json());
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return {};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
raiseFor(status, payload) {
|
|
140
|
+
throw cloudErrorFor(status, payload);
|
|
141
|
+
}
|
|
142
|
+
async load(batchId) {
|
|
143
|
+
let data = await this.store.read(batchId);
|
|
144
|
+
if (data === null)
|
|
145
|
+
return null;
|
|
146
|
+
if (this.cipher)
|
|
147
|
+
data = await this.cipher.decrypt(data);
|
|
148
|
+
const state = JSON.parse(decoder.decode(data));
|
|
149
|
+
if (state.format !== FILE_FORMAT) {
|
|
150
|
+
throw new BatchError(`local batch file ${batchId}: unknown format ${JSON.stringify(state.format)}`);
|
|
151
|
+
}
|
|
152
|
+
return state;
|
|
153
|
+
}
|
|
154
|
+
async save(state) {
|
|
155
|
+
let data = encoder.encode(JSON.stringify(state));
|
|
156
|
+
if (this.cipher)
|
|
157
|
+
data = await this.cipher.encrypt(data);
|
|
158
|
+
await this.store.write(state.batch_id, data);
|
|
159
|
+
}
|
|
160
|
+
static batch(raw) {
|
|
161
|
+
const str = (k) => (typeof raw[k] === "string" ? raw[k] : null);
|
|
162
|
+
return {
|
|
163
|
+
id: str("id") ?? "", status: str("status") ?? "",
|
|
164
|
+
createdAt: str("created_at"), expiresAt: str("expires_at"), endedAt: str("ended_at"),
|
|
165
|
+
resultsExpireAt: str("results_expire_at"),
|
|
166
|
+
counts: { ...(raw.counts ?? {}) },
|
|
167
|
+
documents: isInteger(raw.documents) ? raw.documents : null,
|
|
168
|
+
tokens: Object.fromEntries(Object.entries(raw.tokens && typeof raw.tokens === "object" ? raw.tokens : {}).filter((e) => isInteger(e[1]))),
|
|
169
|
+
creditsCharged: isInteger(raw.credits_charged) ? raw.credits_charged : null,
|
|
170
|
+
billingRate: typeof raw.billing_rate === "number" ? raw.billing_rate : null,
|
|
171
|
+
raw,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
// -- create -----------------------------------------------------------
|
|
175
|
+
/**
|
|
176
|
+
* Mask every document here, upload only the masked text, keep the rest in
|
|
177
|
+
* the local batch file. The document count, `customId`s, countries and body
|
|
178
|
+
* size are checked before anything is uploaded or written. The 5,000-token
|
|
179
|
+
* limit per document is **not**: it is counted by the model's tokenizer on
|
|
180
|
+
* the gateway, which the SDK does not have, so a document over it comes back
|
|
181
|
+
* from `results()` as an error with code `too_long` that says so.
|
|
182
|
+
*/
|
|
183
|
+
async create(documents, options = {}) {
|
|
184
|
+
if (documents.length === 0)
|
|
185
|
+
throw new BatchError("a batch needs at least one document");
|
|
186
|
+
if (documents.length > MAX_DOCUMENTS) {
|
|
187
|
+
throw new BatchError(`${documents.length.toLocaleString("en-US")} documents; a batch holds at most ` +
|
|
188
|
+
`${MAX_DOCUMENTS.toLocaleString("en-US")}`);
|
|
189
|
+
}
|
|
190
|
+
// Every document is checked before any is masked: masking 5,000 documents
|
|
191
|
+
// and then learning that the gateway refuses the batch for one bad customId
|
|
192
|
+
// wastes the whole pass (rules-engine#88).
|
|
193
|
+
const seen = new Set();
|
|
194
|
+
const checked = [];
|
|
195
|
+
documents.forEach((doc, index) => {
|
|
196
|
+
const number = index + 1;
|
|
197
|
+
const customId = doc.customId;
|
|
198
|
+
if (typeof customId !== "string" || !CUSTOM_ID.test(customId)) {
|
|
199
|
+
throw new BatchError(`document ${number}: customId must be 1-${MAX_CUSTOM_ID} characters of A-Z a-z 0-9 _ . : -`);
|
|
200
|
+
}
|
|
201
|
+
if (seen.has(customId)) {
|
|
202
|
+
throw new BatchError(`document ${number}: duplicate customId ${JSON.stringify(customId)}`);
|
|
203
|
+
}
|
|
204
|
+
seen.add(customId);
|
|
205
|
+
if (typeof doc.text !== "string")
|
|
206
|
+
throw new BatchError(`document ${number}: text must be a string`);
|
|
207
|
+
const countries = (doc.countries ?? []).map(c => c.toUpperCase());
|
|
208
|
+
if (countries.length !== 1) {
|
|
209
|
+
throw new BatchError(`document ${number}: exactly one country is needed, as in cloud mode`);
|
|
210
|
+
}
|
|
211
|
+
checked.push({ customId, text: doc.text, countries, language: doc.language ?? "" });
|
|
212
|
+
});
|
|
213
|
+
const lines = [];
|
|
214
|
+
const entries = {};
|
|
215
|
+
const engine = this.engine();
|
|
216
|
+
for (const { customId, text, countries, language } of checked) {
|
|
217
|
+
// The same local pass as redactAsync(..., { mode: "cloud" }): dates on, no
|
|
218
|
+
// allowlist, no tokens. What is sent is what cloud mode would send.
|
|
219
|
+
const local = engine.redact(text, { countries, detectDates: true, cache: false });
|
|
220
|
+
const [masked] = maskForCloud(text, local.detections);
|
|
221
|
+
const line = { custom_id: customId, text: masked, countries };
|
|
222
|
+
if (language)
|
|
223
|
+
line.language = language;
|
|
224
|
+
lines.push(JSON.stringify(line));
|
|
225
|
+
const cp = utf16ToCodePoints(text);
|
|
226
|
+
entries[customId] = {
|
|
227
|
+
text: text,
|
|
228
|
+
masked_sha256: sha256Hex([masked]),
|
|
229
|
+
countries,
|
|
230
|
+
language,
|
|
231
|
+
detections: local.detections.map(d => toStored(d, cp)),
|
|
232
|
+
detection_mode: local.detectionMode,
|
|
233
|
+
inferred_countries: local.inferredCountries,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
const body = encoder.encode(lines.join("\n") + "\n");
|
|
237
|
+
if (body.length > MAX_BODY_BYTES) {
|
|
238
|
+
throw new BatchError(`the masked batch is ${body.length.toLocaleString("en-US")} bytes; the limit is ` +
|
|
239
|
+
`${MAX_BODY_BYTES.toLocaleString("en-US")}`);
|
|
240
|
+
}
|
|
241
|
+
const compressed = gzip(body);
|
|
242
|
+
const headers = {
|
|
243
|
+
"Content-Type": "application/x-ndjson",
|
|
244
|
+
"Idempotency-Key": options.idempotencyKey ?? crypto.randomUUID(),
|
|
245
|
+
};
|
|
246
|
+
if (compressed !== null)
|
|
247
|
+
headers["Content-Encoding"] = "gzip";
|
|
248
|
+
const resp = await this.request("POST", "/v1/batches", compressed ?? body, headers);
|
|
249
|
+
const payload = await this.json(resp);
|
|
250
|
+
if (resp.status !== 200 && resp.status !== 201)
|
|
251
|
+
this.raiseFor(resp.status, payload);
|
|
252
|
+
const batch = Batches.batch(payload);
|
|
253
|
+
if (!batch.id)
|
|
254
|
+
throw new BatchError("the gateway accepted the batch but returned no id");
|
|
255
|
+
await this.save({
|
|
256
|
+
format: FILE_FORMAT,
|
|
257
|
+
batch_id: batch.id,
|
|
258
|
+
status: "pending",
|
|
259
|
+
created_at: batch.createdAt ?? now(),
|
|
260
|
+
expires_at: batch.expiresAt,
|
|
261
|
+
results_expire_at: null,
|
|
262
|
+
resolved_at: null,
|
|
263
|
+
engine: "euredact-ts",
|
|
264
|
+
engine_version: null,
|
|
265
|
+
counts: { documents: Object.keys(entries).length },
|
|
266
|
+
entries,
|
|
267
|
+
});
|
|
268
|
+
return batch;
|
|
269
|
+
}
|
|
270
|
+
// -- retrieve, cancel, pending, purge, sweep ------------------------------
|
|
271
|
+
/** The gateway's view: status, counts and timestamps. */
|
|
272
|
+
async retrieve(batchId) {
|
|
273
|
+
await this.sweep();
|
|
274
|
+
const resp = await this.request("GET", `/v1/batches/${encodeURIComponent(batchId)}`);
|
|
275
|
+
const payload = await this.json(resp);
|
|
276
|
+
if (resp.status !== 200)
|
|
277
|
+
this.raiseFor(resp.status, payload);
|
|
278
|
+
const batch = Batches.batch(payload);
|
|
279
|
+
const state = await this.load(batchId);
|
|
280
|
+
if (state !== null && batch.resultsExpireAt && state.results_expire_at !== batch.resultsExpireAt) {
|
|
281
|
+
state.results_expire_at = batch.resultsExpireAt;
|
|
282
|
+
await this.save(state);
|
|
283
|
+
}
|
|
284
|
+
return batch;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* This account's batches, newest first (1-100, the gateway's bound). The
|
|
288
|
+
* gateway's view only: `pending()` lists the ones with a local file here.
|
|
289
|
+
*/
|
|
290
|
+
async list(limit = 20) {
|
|
291
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
292
|
+
throw new RangeError("limit must be between 1 and 100");
|
|
293
|
+
}
|
|
294
|
+
const resp = await this.request("GET", `/v1/batches?limit=${limit}`);
|
|
295
|
+
const payload = await this.json(resp);
|
|
296
|
+
if (resp.status !== 200)
|
|
297
|
+
this.raiseFor(resp.status, payload);
|
|
298
|
+
const raw = Array.isArray(payload.batches) ? payload.batches : [];
|
|
299
|
+
return raw.filter((b) => !!b && typeof b === "object" && !Array.isArray(b))
|
|
300
|
+
.map(b => Batches.batch(b));
|
|
301
|
+
}
|
|
302
|
+
/** Cancel queued documents; the local file stays pending until the finished
|
|
303
|
+
* ones are mapped by `results()`. */
|
|
304
|
+
async cancel(batchId) {
|
|
305
|
+
const resp = await this.request("POST", `/v1/batches/${encodeURIComponent(batchId)}/cancel`);
|
|
306
|
+
const payload = await this.json(resp);
|
|
307
|
+
if (resp.status !== 200 && resp.status !== 202)
|
|
308
|
+
this.raiseFor(resp.status, payload);
|
|
309
|
+
return Batches.batch(payload);
|
|
310
|
+
}
|
|
311
|
+
/** Batch ids whose local file still waits to be mapped. Reads files only. */
|
|
312
|
+
async pending() {
|
|
313
|
+
await this.sweep();
|
|
314
|
+
const out = [];
|
|
315
|
+
for (const id of await this.store.list()) {
|
|
316
|
+
const state = await this.load(id);
|
|
317
|
+
if (state !== null && state.status === "pending")
|
|
318
|
+
out.push(id);
|
|
319
|
+
}
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
/** Delete the local file, whatever its status. */
|
|
323
|
+
async purge(batchId) {
|
|
324
|
+
await this.store.delete(batchId);
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Delete receipts and expire pending files past `results_expire_at`. Runs at
|
|
328
|
+
* the start of the calls that look in the store, so nothing has to be
|
|
329
|
+
* scheduled. A pending file past that time can never be mapped -- the gateway
|
|
330
|
+
* has swept the results -- so its originals are wiped.
|
|
331
|
+
*/
|
|
332
|
+
async sweep(at = new Date()) {
|
|
333
|
+
for (const id of await this.store.list()) {
|
|
334
|
+
let state;
|
|
335
|
+
try {
|
|
336
|
+
state = await this.load(id);
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (state === null || !state.results_expire_at)
|
|
342
|
+
continue;
|
|
343
|
+
if (at < new Date(state.results_expire_at))
|
|
344
|
+
continue;
|
|
345
|
+
if (state.status === "pending")
|
|
346
|
+
await this.wipe(state, "expired");
|
|
347
|
+
else
|
|
348
|
+
await this.store.delete(id);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
async wipe(state, status) {
|
|
352
|
+
state.entries = {};
|
|
353
|
+
state.status = status;
|
|
354
|
+
state.resolved_at = now();
|
|
355
|
+
await this.save(state);
|
|
356
|
+
}
|
|
357
|
+
// -- results ----------------------------------------------------------
|
|
358
|
+
/**
|
|
359
|
+
* Map an ended batch back onto the originals in its local file. Each
|
|
360
|
+
* document's masked text is rebuilt from the file and must hash to both the
|
|
361
|
+
* stored SHA-256 and the gateway's `masked_sha256` before a span is placed.
|
|
362
|
+
* Anything that cannot be mapped is a per-document error, never the masked
|
|
363
|
+
* text passed off as a result.
|
|
364
|
+
*/
|
|
365
|
+
async results(batchId) {
|
|
366
|
+
const state = await this.load(batchId);
|
|
367
|
+
if (state !== null && (state.status === "resolved" || state.status === "expired")) {
|
|
368
|
+
return {
|
|
369
|
+
batchId, documents: {}, batch: null,
|
|
370
|
+
status: state.status === "resolved" ? "already_resolved" : "expired",
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
const batch = await this.retrieve(batchId);
|
|
374
|
+
if (batch.status !== "ended")
|
|
375
|
+
return { batchId, status: "not_ended", documents: {}, batch };
|
|
376
|
+
const resp = await this.request("GET", `/v1/batches/${encodeURIComponent(batchId)}/results`);
|
|
377
|
+
if (resp.status === 410) {
|
|
378
|
+
if (state !== null)
|
|
379
|
+
await this.wipe(state, "expired");
|
|
380
|
+
return { batchId, status: "expired", documents: {}, batch };
|
|
381
|
+
}
|
|
382
|
+
if (resp.status === 409)
|
|
383
|
+
return { batchId, status: "not_ended", documents: {}, batch };
|
|
384
|
+
if (resp.status !== 200)
|
|
385
|
+
this.raiseFor(resp.status, await this.json(resp));
|
|
386
|
+
const entries = state?.entries ?? {};
|
|
387
|
+
const documents = {};
|
|
388
|
+
for (const line of (await resp.text()).split("\n")) {
|
|
389
|
+
if (!line.trim())
|
|
390
|
+
continue;
|
|
391
|
+
const row = JSON.parse(line);
|
|
392
|
+
const customId = row.custom_id ?? "";
|
|
393
|
+
documents[customId] = this.outcome(customId, row.result ?? {}, entries[customId], state !== null);
|
|
394
|
+
}
|
|
395
|
+
if (state !== null) {
|
|
396
|
+
const counts = {
|
|
397
|
+
documents: Object.keys(documents).length,
|
|
398
|
+
mapped: Object.values(documents).filter(o => o.ok).length,
|
|
399
|
+
};
|
|
400
|
+
for (const o of Object.values(documents)) {
|
|
401
|
+
if (o.error)
|
|
402
|
+
counts[o.error] = (counts[o.error] ?? 0) + 1;
|
|
403
|
+
}
|
|
404
|
+
state.counts = counts;
|
|
405
|
+
await this.wipe(state, "resolved");
|
|
406
|
+
}
|
|
407
|
+
return { batchId, status: "resolved", documents, batch };
|
|
408
|
+
}
|
|
409
|
+
outcome(customId, result, entry, fileExists) {
|
|
410
|
+
const fail = (error, message) => ({ customId, ok: false, result: null, error, message });
|
|
411
|
+
if (result.type !== "succeeded") {
|
|
412
|
+
const err = result.error ?? {};
|
|
413
|
+
const code = err.code ?? result.type ?? "errored";
|
|
414
|
+
let message = err.message ?? "";
|
|
415
|
+
if (code === "too_long") {
|
|
416
|
+
message = `${message}. The ${MAX_DOCUMENT_TOKENS.toLocaleString("en-US")}-token limit is ` +
|
|
417
|
+
"counted by the model's tokenizer on the gateway; the SDK cannot check it before " +
|
|
418
|
+
"upload. Split the document and submit the parts.";
|
|
419
|
+
message = message.replace(/^\. /, "");
|
|
420
|
+
}
|
|
421
|
+
return fail(code, message);
|
|
422
|
+
}
|
|
423
|
+
if (entry === undefined) {
|
|
424
|
+
return fail("missing_local_entry", fileExists
|
|
425
|
+
? "no entry for this document in the local batch file"
|
|
426
|
+
: "no local batch file on this machine");
|
|
427
|
+
}
|
|
428
|
+
const text = entry.text;
|
|
429
|
+
const unit = codePointOffsets(text);
|
|
430
|
+
const local = entry.detections.map(d => fromStored(d, unit));
|
|
431
|
+
const [masked, labels] = maskForCloud(text, local);
|
|
432
|
+
if (sha256Hex([masked]) !== entry.masked_sha256 || result.masked_sha256 !== entry.masked_sha256) {
|
|
433
|
+
return fail("local_mismatch", "the local batch file does not match what was sent");
|
|
434
|
+
}
|
|
435
|
+
// The gateway's offsets count code points of the masked text; toResult
|
|
436
|
+
// converts them to UTF-16 units, as the cloud client does for redactAsync.
|
|
437
|
+
const remote = toResult({ entities: result.entities }, masked);
|
|
438
|
+
let placed;
|
|
439
|
+
try {
|
|
440
|
+
placed = ontoOriginal(remote.detections, masked, text, labels, CloudError);
|
|
441
|
+
}
|
|
442
|
+
catch (err) {
|
|
443
|
+
return fail("local_mismatch", err.message);
|
|
444
|
+
}
|
|
445
|
+
const detections = [...local, ...placed].sort((a, b) => a.start - b.start || b.end - a.end);
|
|
446
|
+
return {
|
|
447
|
+
customId, ok: true, error: null, message: "",
|
|
448
|
+
result: {
|
|
449
|
+
redactedText: applyReplacements(text, detections, det => `[${det.entityType}]`),
|
|
450
|
+
detections,
|
|
451
|
+
source: "cloud",
|
|
452
|
+
degraded: false,
|
|
453
|
+
inferredCountries: entry.inferred_countries,
|
|
454
|
+
evidence: [],
|
|
455
|
+
detectionMode: entry.detection_mode,
|
|
456
|
+
tokens: {},
|
|
457
|
+
exempted: [],
|
|
458
|
+
// A batch line carries the model and anything unplaced; it has no job
|
|
459
|
+
// id and no usage of its own (the batch carries those).
|
|
460
|
+
cloud: toCloudInfo({ model_version: result.model_version, unlocated: result.unlocated }),
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
}
|
|
@@ -25,7 +25,33 @@
|
|
|
25
25
|
* own `fetch`.
|
|
26
26
|
*/
|
|
27
27
|
import { type CloudConfig } from "./config.js";
|
|
28
|
-
import {
|
|
28
|
+
import { CloudError } from "./errors.js";
|
|
29
|
+
import { type CloudInfo, type RedactResult, type Usage } from "../types.js";
|
|
30
|
+
/** @internal Shared with cloud/account.ts. */
|
|
31
|
+
export declare const RETRY_STATUS: Set<number>;
|
|
32
|
+
/** One span as the service reports it. */
|
|
33
|
+
interface WireSpan {
|
|
34
|
+
start: number;
|
|
35
|
+
end: number;
|
|
36
|
+
text: string;
|
|
37
|
+
type: string;
|
|
38
|
+
source: string;
|
|
39
|
+
match?: string;
|
|
40
|
+
confidence?: string;
|
|
41
|
+
}
|
|
42
|
+
interface WireResult {
|
|
43
|
+
job_id?: string;
|
|
44
|
+
status?: string;
|
|
45
|
+
redacted_text?: string;
|
|
46
|
+
entities?: WireSpan[];
|
|
47
|
+
unlocated?: unknown;
|
|
48
|
+
model_version?: string | null;
|
|
49
|
+
stats?: Record<string, unknown>;
|
|
50
|
+
usage?: unknown;
|
|
51
|
+
error?: string;
|
|
52
|
+
detail?: Record<string, unknown>;
|
|
53
|
+
location?: string;
|
|
54
|
+
}
|
|
29
55
|
export interface CloudRedactOptions {
|
|
30
56
|
country: string;
|
|
31
57
|
language?: string;
|
|
@@ -34,6 +60,59 @@ export interface CloudRedactOptions {
|
|
|
34
60
|
/** Injectable for tests. Defaults to the platform `fetch`. */
|
|
35
61
|
fetchImpl?: typeof fetch;
|
|
36
62
|
}
|
|
63
|
+
/** @internal */
|
|
64
|
+
export declare function requireFetch(supplied?: typeof fetch): typeof fetch;
|
|
65
|
+
/** @internal */
|
|
66
|
+
export declare function sleep(ms: number): Promise<void>;
|
|
67
|
+
/** @internal */
|
|
68
|
+
export declare function retryAfterMs(headers: Headers | undefined): number | null;
|
|
69
|
+
/**
|
|
70
|
+
* Full jitter. A synchronised retry storm from many clients is how a
|
|
71
|
+
* recovering service is knocked back over.
|
|
72
|
+
*/
|
|
73
|
+
/** @internal */
|
|
74
|
+
export declare function backoffMs(attempt: number, retryAfter: number | null): number;
|
|
75
|
+
/** @internal */
|
|
76
|
+
export declare function uuid(): string;
|
|
77
|
+
/**
|
|
78
|
+
* Map the service's offsets, which count code points, onto this SDK's, which
|
|
79
|
+
* count UTF-16 units.
|
|
80
|
+
*
|
|
81
|
+
* The two agree until the first character outside the BMP — an emoji, say —
|
|
82
|
+
* after which every service offset is short by one unit per such character.
|
|
83
|
+
* Returns the identity when the text has none, which is the common case and
|
|
84
|
+
* costs one regex test.
|
|
85
|
+
*/
|
|
86
|
+
/** @internal Shared with cloud/batches.ts. */
|
|
87
|
+
export declare function codePointOffsets(text: string): (cp: number) => number;
|
|
88
|
+
/** @internal */
|
|
89
|
+
export declare function isInteger(value: unknown): value is number;
|
|
90
|
+
/**
|
|
91
|
+
* The `usage` block, or undefined when absent or malformed. Shared rules with
|
|
92
|
+
* the Python SDK (`conformance/cloud_usage.json`): `tokens` and `credits` are
|
|
93
|
+
* integers, `billing_rate` a number, `factors` a list whose entries carry a
|
|
94
|
+
* string `code`. Anything else there drops the whole block rather than
|
|
95
|
+
* reporting a cost nobody can trust; unknown keys are ignored.
|
|
96
|
+
* @internal Exported for the conformance test.
|
|
97
|
+
*/
|
|
98
|
+
export declare function toUsage(raw: unknown): Usage | undefined;
|
|
99
|
+
/**
|
|
100
|
+
* The service's account of a request beside its spans. Shared rules with the
|
|
101
|
+
* Python SDK (`conformance/cloud_result.json`): a field of the wrong type reads
|
|
102
|
+
* as absent, and an unlocated entry without string `text` is skipped.
|
|
103
|
+
* @internal
|
|
104
|
+
*/
|
|
105
|
+
export declare function toCloudInfo(payload: Record<string, unknown>): CloudInfo;
|
|
106
|
+
/**
|
|
107
|
+
* A 429 from the gateway's daily quota rather than the edge's rate limit: the
|
|
108
|
+
* gateway answers in JSON with `detail.used` and `detail.limit`; nginx in HTML.
|
|
109
|
+
* @internal
|
|
110
|
+
*/
|
|
111
|
+
export declare function isQuota(payload: Record<string, unknown>): boolean;
|
|
112
|
+
/** The error for a non-success answer. @internal Shared with batches and account. */
|
|
113
|
+
export declare function cloudErrorFor(status: number, payload: Record<string, unknown>): CloudError;
|
|
114
|
+
/** @internal Shared with cloud/batches.ts. */
|
|
115
|
+
export declare function toResult(payload: WireResult, text: string): RedactResult;
|
|
37
116
|
export declare class CloudClient {
|
|
38
117
|
readonly config: CloudConfig;
|
|
39
118
|
constructor(config?: CloudConfig | null);
|
|
@@ -43,3 +122,6 @@ export declare class CloudClient {
|
|
|
43
122
|
private poll;
|
|
44
123
|
private withTimeout;
|
|
45
124
|
}
|
|
125
|
+
/** @internal */
|
|
126
|
+
export declare function readJson(response: Response): Promise<WireResult>;
|
|
127
|
+
export {};
|