@saplingai/sapling-js 1.0.39 → 1.0.45

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
@@ -76,6 +76,24 @@ You should see something like the following output:
76
76
  }
77
77
  ```
78
78
 
79
+ ### Text classification
80
+
81
+ Zero-shot classification with your own labels (plain names or `{ name, description }` objects, 2–20):
82
+
83
+ ```javascript
84
+ client
85
+ .classify(
86
+ 'I was charged twice and the app crashes on launch.',
87
+ ['billing', { name: 'technical issue', description: 'Bugs, crashes, errors.' }, 'other'],
88
+ { multiLabel: true, threshold: 0.5, context: 'Support tickets for a SaaS billing product' },
89
+ )
90
+ .then(function (response) {
91
+ console.log(response.data);
92
+ // { label: 'billing', labels: ['billing', 'technical issue'],
93
+ // scores: [{ label: 'billing', score: 0.92 }, ...], rationale: '...', multi_label: true }
94
+ })
95
+ ```
96
+
79
97
  ## Frontend Quickstart
80
98
 
81
99
  The SDK provides a way to automatically apply edit suggestions to any HTML contenteditable and textarea elements.
@@ -108,8 +126,56 @@ function App() {
108
126
  }
109
127
  ```
110
128
 
129
+ ## Structured Data Extraction
130
+
131
+ Pull caller-named fields out of unstructured text — invoices, emails, resumes, support tickets.
132
+ Pass 1–20 fields (plain names, or `{ name, type, description, required }` objects):
133
+
134
+ ```javascript
135
+ client
136
+ .extract(
137
+ 'Invoice INV-1042 for Acme Corp, total $1,299.00, due March 5, 2026.',
138
+ [
139
+ 'invoice_number',
140
+ { name: 'total', type: 'number', description: 'Amount due', required: true },
141
+ { name: 'due_date', type: 'date' },
142
+ 'purchase_order',
143
+ ],
144
+ { context: 'A vendor invoice' },
145
+ )
146
+ .then(function (response) {
147
+ console.log(response.data);
148
+ // { data: { invoice_number: 'INV-1042', total: 1299.0, due_date: '2026-03-05', purchase_order: null },
149
+ // fields: [{ name: 'invoice_number', type: 'string', value: 'INV-1042',
150
+ // evidence: 'Invoice INV-1042', found: true }, ...],
151
+ // missing: ['purchase_order'] }
152
+ })
153
+ ```
154
+
155
+ `data` always contains every field you asked for, `null` where the text didn't state it, and each
156
+ entry in `fields` carries the verbatim `evidence` span its value came from. Values are coerced to
157
+ the declared `type` (`'string'` (default), `'number'`, `'integer'`, `'boolean'`, `'date'` returned
158
+ as `YYYY-MM-DD`, or `'list'`), and every value must be grounded in a quote from the text — this is
159
+ extraction, not inference, so a value that isn't stated is reported in `missing` rather than
160
+ guessed.
161
+
111
162
  ## Further Documentation
112
163
 
113
164
  - Full Sapling HTTP API and SDK [documentation](https://sapling.ai/docs).
114
165
  - Create a key by following [these instructions](https://sapling.ai/docs/api/api-access).
115
- - JavaScript, HTML, and Python [starter code](https://github.com/saplingai/sapling-samples).
166
+ - JavaScript, HTML, and Python [starter code](https://github.com/saplingai/sapling-samples).
167
+
168
+ ## Error handling
169
+
170
+ Every client method returns the axios promise, so API errors (a `400` for
171
+ over-limit input, `401` for a bad key, `429` for rate limits, network errors and
172
+ timeouts) **reject** with the [axios error](https://axios-http.com/docs/handling_errors);
173
+ inspect `error.response.status` / `error.response.data`:
174
+
175
+ ```javascript
176
+ try {
177
+ const response = await client.edits(text);
178
+ } catch (error) {
179
+ console.error(error.response?.status, error.response?.data ?? error.message);
180
+ }
181
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saplingai/sapling-js",
3
- "version": "1.0.39",
3
+ "version": "1.0.45",
4
4
  "description": "JavaScript library for using the Sapling.ai API.",
5
5
  "type": "module",
6
6
  "main": "./sapling/client.js",
@@ -29,7 +29,7 @@
29
29
  "LICENSE"
30
30
  ],
31
31
  "scripts": {
32
- "test": "node --test test/client.test.js test/pack.test.js",
32
+ "test": "node --test test/client.test.js test/errors.test.js test/pack.test.js",
33
33
  "prepublishOnly": "npm test"
34
34
  },
35
35
  "dependencies": {
@@ -43,6 +43,140 @@ export type LangdetectOptions = {
43
43
  segments?: boolean,
44
44
  }
45
45
 
46
+ export type ClassifyLabel = string | { name: string; description?: string };
47
+
48
+ export type ClassifyOptions = {
49
+ multiLabel?: boolean,
50
+ threshold?: number,
51
+ context?: string,
52
+ }
53
+
54
+ export type ClassifyResponse = {
55
+ label: string;
56
+ labels: string[];
57
+ scores: { label: string; score: number }[];
58
+ rationale: string;
59
+ multi_label: boolean;
60
+ }
61
+
62
+ export type SeoOptions = {
63
+ keywords?: string[],
64
+ suggestions?: boolean,
65
+ lang?: string,
66
+ }
67
+
68
+ export type SeoStats = {
69
+ chars: number;
70
+ words: number;
71
+ sentences: number;
72
+ paragraphs: number;
73
+ reading_time_min: number;
74
+ flesch_reading_ease: number | null;
75
+ flesch_kincaid_grade: number | null;
76
+ }
77
+
78
+ export type SeoKeyword = {
79
+ keyword: string;
80
+ count: number;
81
+ density: number;
82
+ in_first_100_words: boolean;
83
+ }
84
+
85
+ export type SeoTopTerm = {
86
+ term: string;
87
+ count: number;
88
+ }
89
+
90
+ export type SeoSuggestions = {
91
+ titles: string[];
92
+ meta_descriptions: string[];
93
+ slug: string | null;
94
+ keywords: string[];
95
+ }
96
+
97
+ export type SeoResponse = {
98
+ stats: SeoStats;
99
+ keywords: SeoKeyword[];
100
+ top_terms: SeoTopTerm[];
101
+ suggestions?: SeoSuggestions;
102
+ }
103
+
104
+ export type QualityOptions = {
105
+ sentence_scores?: boolean,
106
+ rubric?: boolean,
107
+ }
108
+
109
+ export type QualitySentenceScore = {
110
+ start: number;
111
+ end: number;
112
+ text: string;
113
+ score: number;
114
+ }
115
+
116
+ export type QualityRubricIssue = {
117
+ quote: string;
118
+ dimension: string;
119
+ note: string;
120
+ suggestion: string;
121
+ start: number;
122
+ end: number;
123
+ }
124
+
125
+ export type QualityRubric = {
126
+ overall: number;
127
+ dimensions: {
128
+ clarity: number;
129
+ coherence: number;
130
+ correctness: number;
131
+ concision: number;
132
+ };
133
+ summary: string;
134
+ issues: QualityRubricIssue[];
135
+ }
136
+
137
+ export type QualityResponse = {
138
+ score: number;
139
+ sentences?: QualitySentenceScore[];
140
+ rubric?: QualityRubric;
141
+ }
142
+
143
+ export type PIIType = 'email' | 'phone' | 'ssn' | 'credit_card' | 'ip_address' | 'iban' | 'us_bank_routing';
144
+
145
+ export type PIIOptions = {
146
+ types?: PIIType[],
147
+ redact?: boolean,
148
+ }
149
+
150
+ export type PIIEntity = {
151
+ type: PIIType;
152
+ text: string;
153
+ start: number;
154
+ end: number;
155
+ replacement: string;
156
+ }
157
+
158
+ export type PIIResponse = {
159
+ entities: PIIEntity[];
160
+ flagged: boolean;
161
+ types: PIIType[];
162
+ redacted_text?: string;
163
+ }
164
+
165
+ export type SafetyResponse = {
166
+ toxicity: number;
167
+ profanity: number;
168
+ harassment: number;
169
+ hate_speech: number;
170
+ self_harm: number;
171
+ sexual: number;
172
+ violence: number;
173
+ flagged: string[];
174
+ }
175
+
176
+ export type SafetyOptions = {
177
+ threshold?: number,
178
+ }
179
+
46
180
  export type SpellcheckOptions = {
47
181
  minLength?: number,
48
182
  multipleEdits?: boolean,
@@ -52,6 +186,35 @@ export type SpellcheckOptions = {
52
186
  userData?: any,
53
187
  }
54
188
 
189
+ export type ExtractFieldType = 'string' | 'number' | 'integer' | 'boolean' | 'date' | 'list';
190
+
191
+ export type ExtractField = string | {
192
+ name: string,
193
+ type?: ExtractFieldType,
194
+ description?: string,
195
+ required?: boolean,
196
+ }
197
+
198
+ export type ExtractOptions = {
199
+ context?: string,
200
+ }
201
+
202
+ export type ExtractedValue = string | number | boolean | string[] | null;
203
+
204
+ export type ExtractedField = {
205
+ name: string,
206
+ type: ExtractFieldType,
207
+ value: ExtractedValue,
208
+ evidence: string,
209
+ found: boolean,
210
+ }
211
+
212
+ export type ExtractResponse = {
213
+ data: Record<string, ExtractedValue>,
214
+ fields: ExtractedField[],
215
+ missing: string[],
216
+ }
217
+
55
218
  export type UserDetails = {
56
219
  userId?: string,
57
220
  anonUser?: boolean,
@@ -69,7 +232,13 @@ export declare class Client {
69
232
  complete(query: string, sessionId?: string): Promise<AxiosResponse>;
70
233
  acceptComplete(completeUUID: string, query: string, completion: string, sessionId?: string): Promise<AxiosResponse>;
71
234
  tone(text: string): Promise<AxiosResponse>;
235
+ extract(text: string, fields: ExtractField[], options?: ExtractOptions): Promise<AxiosResponse<ExtractResponse>>;
72
236
 
73
237
  aidetect(text: string, options?: AIDetectOptions): Promise<AxiosResponse>;
74
238
  langdetect(text: string, options?: LangdetectOptions): Promise<AxiosResponse>;
239
+ quality(text: string, options?: QualityOptions): Promise<AxiosResponse<QualityResponse>>;
240
+ pii(text: string, options?: PIIOptions): Promise<AxiosResponse<PIIResponse>>;
241
+ safety(text: string, options?: SafetyOptions): Promise<AxiosResponse<SafetyResponse>>;
242
+ classify(text: string, labels: ClassifyLabel[], options?: ClassifyOptions): Promise<AxiosResponse<ClassifyResponse>>;
243
+ seo(text: string, options?: SeoOptions): Promise<AxiosResponse<SeoResponse>>;
75
244
  }
package/sapling/client.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import axios from "axios";
2
2
  import { v4 as uuidv4 } from 'uuid';
3
3
 
4
+ // Value types an extract() field may declare; 'string' is the default.
5
+ const EXTRACT_FIELD_TYPES = ['string', 'number', 'integer', 'boolean', 'date', 'list'];
6
+
4
7
  export class Client {
5
8
  constructor(apiKey, options = {}) {
6
9
  this.apiKey = apiKey;
@@ -13,7 +16,7 @@ export class Client {
13
16
  }
14
17
 
15
18
  axiosPromise(url, method, data) {
16
- return new Promise((resolve) => {
19
+ return new Promise((resolve, reject) => {
17
20
  axios({
18
21
  url,
19
22
  method,
@@ -22,7 +25,8 @@ export class Client {
22
25
  })
23
26
  .then(function (response) {
24
27
  resolve(response);
25
- });
28
+ })
29
+ .catch(reject);
26
30
  });
27
31
  }
28
32
 
@@ -176,6 +180,112 @@ export class Client {
176
180
  return this.axiosPromise(url, 'post', data);
177
181
  }
178
182
 
183
+ /**
184
+ * Structured data extraction: pull caller-named fields out of unstructured text.
185
+ *
186
+ * `classify()` answers "what kind of text is this"; `extract()` answers "what's in it".
187
+ * Pass 1–20 fields — plain names, or `{name, type, description, required}` objects to
188
+ * declare the value type and sharpen ambiguous fields. Returns `data` (a
189
+ * `{field name: value}` map with every requested field present and `null` where the text
190
+ * didn't state it), `fields` (one entry per requested field, in request order, each with
191
+ * the verbatim `evidence` span the value came from) and `missing` (the names the text
192
+ * didn't yield).
193
+ *
194
+ * Values are coerced to the declared type server-side and must be grounded in a quote
195
+ * from the text, so this is extraction, not inference: a value that isn't stated is
196
+ * reported missing rather than guessed. Finding nothing is a normal 200.
197
+ *
198
+ * @param {string} text - The document to extract from, plain text or HTML (the API caps
199
+ * it at 10,000 characters).
200
+ * @param {Array<string|{name: string, type?: string, description?: string, required?: boolean}>} fields -
201
+ * The fields to pull out (1–20): field names, or objects. `type` is one of 'string'
202
+ * (default), 'number', 'integer', 'boolean', 'date' (returned as YYYY-MM-DD) or 'list'.
203
+ * Names must be unique case-insensitively; the API caps names at 50 characters and
204
+ * descriptions at 200.
205
+ * @param {Object} [options]
206
+ * @param {string} [options.context] - Optional guidance (the API caps it at 500
207
+ * characters): what the document is and how to read ambiguous fields, e.g.
208
+ * 'A vendor invoice'.
209
+ * @returns {Promise<AxiosResponse>}
210
+ */
211
+ extract(text, fields, options = {}) {
212
+ const url = `${this.urlEndpoint}extract`;
213
+
214
+ // Shape/type checks only. Size limits (text length, field name/description/
215
+ // context length) are server configuration and enforced there with a clear
216
+ // 400 — mirroring them here would drift the day they change (and JS
217
+ // `length` counts UTF-16 units, not the characters the API counts).
218
+ if (typeof text !== 'string' || text.trim().length === 0) {
219
+ throw new TypeError('extract: text must be a non-empty string.');
220
+ }
221
+
222
+ const isValidField = (field) => {
223
+ if (typeof field === 'string') {
224
+ return field.trim().length > 0;
225
+ }
226
+ if (field == null || typeof field !== 'object' || Array.isArray(field)) {
227
+ return false;
228
+ }
229
+ if (typeof field.name !== 'string' || field.name.trim().length === 0) {
230
+ return false;
231
+ }
232
+ if (field.type !== undefined && !EXTRACT_FIELD_TYPES.includes(field.type)) {
233
+ return false;
234
+ }
235
+ if (field.description !== undefined && typeof field.description !== 'string') {
236
+ return false;
237
+ }
238
+ if (field.required !== undefined && typeof field.required !== 'boolean') {
239
+ return false;
240
+ }
241
+ // Reject unknown keys so a typo like `{ name, typ: 'number' }` fails here
242
+ // rather than being forwarded to the API as a malformed field (the MCP
243
+ // schema is `.strict()` for the same reason).
244
+ const allowed = new Set(['name', 'type', 'description', 'required']);
245
+ if (Object.keys(field).some((key) => !allowed.has(key))) {
246
+ return false;
247
+ }
248
+ return true;
249
+ };
250
+
251
+ if (
252
+ !Array.isArray(fields)
253
+ || fields.length < 1
254
+ || fields.length > 20
255
+ // Array.from() materializes holes in sparse arrays as `undefined` (which
256
+ // isValidField rejects); a bare `.every()` would skip them and let a hole
257
+ // through to be serialized as `null`.
258
+ || !Array.from(fields).every(isValidField)
259
+ ) {
260
+ throw new TypeError('extract: fields must be an array of 1 to 20 non-empty strings or '
261
+ + '{name, type?, description?, required?} objects (string name, type one of '
262
+ + `${EXTRACT_FIELD_TYPES.join(', ')}, optional string description, optional boolean required).`);
263
+ }
264
+
265
+ const names = fields.map((field) => (typeof field === 'string' ? field : field.name).toLowerCase());
266
+ if (names.length !== new Set(names).size) {
267
+ throw new TypeError('extract: field names must be unique (case-insensitive).');
268
+ }
269
+
270
+ let data = {
271
+ text,
272
+ fields,
273
+ key: this.apiKey,
274
+ }
275
+
276
+ // `!== undefined` (not `!= null`) so an explicit `null` is rejected as a bad
277
+ // type rather than silently treated as "omitted", matching how a null
278
+ // `description` is rejected above.
279
+ if (options?.context !== undefined) {
280
+ if (typeof options.context !== 'string') {
281
+ throw new TypeError('extract: context must be a string.');
282
+ }
283
+ data.context = options.context;
284
+ }
285
+
286
+ return this.axiosPromise(url, 'post', data);
287
+ }
288
+
179
289
  langdetect(text, options = {}) {
180
290
  const url = `${this.urlEndpoint}langdetect`;
181
291
 
@@ -194,6 +304,232 @@ export class Client {
194
304
  return this.axiosPromise(url, 'post', data);
195
305
  }
196
306
 
307
+ /**
308
+ * Zero-shot text classification: assign one (or several) of your own labels to a text.
309
+ *
310
+ * No training data needed — pass 2–20 candidate labels (plain names, or
311
+ * `{name, description}` objects; a short description per label sharpens borderline
312
+ * decisions). Returns `label` (best match, always one of your labels), `labels`
313
+ * (single-label: `[label]`; multi-label: every label with `score >= threshold`, possibly
314
+ * empty), `scores` (one `{label, score}` per input label, descending), a one-sentence
315
+ * `rationale` and the echoed `multi_label` mode.
316
+ *
317
+ * @param {string} text - Text to classify, plain text or HTML (the API caps it at 10,000 chars).
318
+ * @param {Array<string|{name: string, description?: string}>} labels - Candidate labels
319
+ * (2–20). Names unique case-insensitively; the API caps names at 50 and descriptions at 200 chars.
320
+ * @param {Object} [options]
321
+ * @param {boolean} [options.multiLabel] - true: zero, one or several labels may apply
322
+ * (independent 0–1 scores). Defaults to false: exactly one label (scores sum to ~1).
323
+ * @param {number} [options.threshold] - Multi-label cut-off on the per-label score (0–1,
324
+ * default 0.5). Ignored in single-label mode.
325
+ * @param {string} [options.context] - Optional guidance (max 500 chars): what the texts
326
+ * are and how to decide borderline cases, e.g. 'Support tickets for a SaaS billing product'.
327
+ * @returns {Promise<AxiosResponse>}
328
+ */
329
+ classify(text, labels, options = {}) {
330
+ const url = `${this.urlEndpoint}classify`;
331
+
332
+ // Shape/type checks only. Size limits (text length, label/description/
333
+ // context length) are server configuration and enforced there with a
334
+ // clear 400 — mirroring them here would drift the day they change (and
335
+ // JS `length` counts UTF-16 units, not the characters the API counts).
336
+ if (typeof text !== 'string' || text.trim().length === 0) {
337
+ throw new TypeError('classify: text must be a non-empty string.');
338
+ }
339
+
340
+ const isValidLabel = (label) => {
341
+ if (typeof label === 'string') {
342
+ return label.trim().length > 0;
343
+ }
344
+ if (label == null || typeof label !== 'object' || Array.isArray(label)) {
345
+ return false;
346
+ }
347
+ if (typeof label.name !== 'string' || label.name.trim().length === 0) {
348
+ return false;
349
+ }
350
+ if (label.description !== undefined && typeof label.description !== 'string') {
351
+ return false;
352
+ }
353
+ // Reject unknown keys so a typo like `{ name, descrption: '...' }` fails
354
+ // here rather than being forwarded to the API as a malformed label (the
355
+ // MCP object schema is `.strict()` and `extract()` does the same).
356
+ const allowed = new Set(['name', 'description']);
357
+ if (Object.keys(label).some((key) => !allowed.has(key))) {
358
+ return false;
359
+ }
360
+ return true;
361
+ };
362
+
363
+ if (
364
+ !Array.isArray(labels)
365
+ || labels.length < 2
366
+ || labels.length > 20
367
+ // Array.from() materializes holes in sparse arrays as `undefined` (which
368
+ // isValidLabel rejects); a bare `.every()` would skip them and let a hole
369
+ // through to be serialized as `null`.
370
+ || !Array.from(labels).every(isValidLabel)
371
+ ) {
372
+ throw new TypeError('classify: labels must be an array of 2 to 20 non-empty strings or '
373
+ + '{name, description?} objects (string name, optional string description).');
374
+ }
375
+
376
+ const names = labels.map((label) => (typeof label === 'string' ? label : label.name).toLowerCase());
377
+ if (names.length !== new Set(names).size) {
378
+ throw new TypeError('classify: label names must be unique (case-insensitive).');
379
+ }
380
+
381
+ let data = {
382
+ text,
383
+ labels,
384
+ key: this.apiKey,
385
+ }
386
+
387
+ // `!== undefined` (not `!= null`) so an explicit `null` is rejected as a bad
388
+ // type rather than silently treated as "omitted", matching how a null
389
+ // `description` is rejected above.
390
+ if (options?.multiLabel !== undefined) {
391
+ if (typeof options.multiLabel !== 'boolean') {
392
+ throw new TypeError('classify: multiLabel must be a boolean.');
393
+ }
394
+ data.multi_label = options.multiLabel;
395
+ }
396
+ if (options?.threshold !== undefined) {
397
+ if (typeof options.threshold !== 'number' || !Number.isFinite(options.threshold)
398
+ || options.threshold < 0 || options.threshold > 1) {
399
+ throw new TypeError('classify: threshold must be a finite number between 0 and 1.');
400
+ }
401
+ data.threshold = options.threshold;
402
+ }
403
+ if (options?.context !== undefined) {
404
+ if (typeof options.context !== 'string') {
405
+ throw new TypeError('classify: context must be a string.');
406
+ }
407
+ data.context = options.context;
408
+ }
409
+
410
+ return this.axiosPromise(url, 'post', data);
411
+ }
412
+
413
+ /**
414
+ * SEO analysis and metadata generation for a page or article body (plain text or HTML).
415
+ *
416
+ * Always returns deterministic `stats` (word/sentence/paragraph counts, reading time,
417
+ * Flesch readability), `keywords` (count/density/in_first_100_words for each target
418
+ * keyword) and `top_terms` (most frequent unigrams/bigrams). By default also returns
419
+ * LLM-generated `suggestions` (titles, meta_descriptions, slug — null when no ASCII slug
420
+ * can be derived — and keywords) — pass
421
+ * `suggestions: false` for the free stats-only mode.
422
+ *
423
+ * @param {string} text - Page/article body, plain text or HTML (max 20,000 chars).
424
+ * @param {Object} [options]
425
+ * @param {string[]} [options.keywords] - Target keyword phrases, most important first
426
+ * (max 10, each 1–100 chars). Measured in the text and steered into the suggestions.
427
+ * @param {boolean} [options.suggestions] - Generate LLM suggestions. Defaults to true;
428
+ * false returns stats/keywords/top_terms only (unbilled).
429
+ * @param {string} [options.lang] - ISO 639-1 language code of the text (region subtag
430
+ * allowed, e.g. 'pt-BR'; or 'auto'). Defaults to 'en'. Only selects the readability
431
+ * formulas: supported for en/de/es/fr/it/nl/pl/ru, otherwise flesch_reading_ease /
432
+ * flesch_kincaid_grade are null.
433
+ * @returns {Promise<AxiosResponse>}
434
+ */
435
+ seo(text, options = {}) {
436
+ const url = `${this.urlEndpoint}seo`;
437
+
438
+ // Shape/type checks only. Size limits (text length, keyword count/length)
439
+ // are server configuration and enforced there with a clear 400 — mirroring
440
+ // them here would drift the day they change (and JS `length` counts UTF-16
441
+ // units, not the characters the API counts).
442
+ if (typeof text !== 'string' || text.trim().length === 0) {
443
+ throw new TypeError('seo: text must be a non-empty string.');
444
+ }
445
+
446
+ let data = {
447
+ text,
448
+ key: this.apiKey,
449
+ }
450
+
451
+ // `!== undefined` (not `!= null`) so an explicit `null` is rejected as a bad
452
+ // type rather than silently treated as "omitted", matching classify().
453
+ if (options?.keywords !== undefined) {
454
+ if (!Array.isArray(options.keywords)
455
+ || !Array.from(options.keywords).every((k) => typeof k === 'string' && k.trim().length > 0)) {
456
+ throw new TypeError('seo: keywords must be an array of non-empty strings.');
457
+ }
458
+ data.keywords = options.keywords;
459
+ }
460
+ if (options?.suggestions !== undefined) {
461
+ if (typeof options.suggestions !== 'boolean') {
462
+ throw new TypeError('seo: suggestions must be a boolean.');
463
+ }
464
+ data.suggestions = options.suggestions;
465
+ }
466
+ if (options?.lang !== undefined) {
467
+ if (typeof options.lang !== 'string') {
468
+ throw new TypeError('seo: lang must be a string.');
469
+ }
470
+ data.lang = options.lang;
471
+ }
472
+
473
+ return this.axiosPromise(url, 'post', data);
474
+ }
475
+
476
+ quality(text, options = {}) {
477
+ const url = `${this.urlEndpoint}quality`;
478
+
479
+ // Shape/type checks only; size limits are server configuration (see seo()).
480
+ if (typeof text !== 'string' || text.trim().length === 0) {
481
+ throw new TypeError('quality: text must be a non-empty string.');
482
+ }
483
+
484
+ let data = {
485
+ text,
486
+ key: this.apiKey,
487
+ }
488
+
489
+ // `!== undefined` (not `!= null`) so an explicit `null` is rejected as a bad
490
+ // type rather than silently treated as "omitted", matching classify()/seo().
491
+ if (options?.sentence_scores !== undefined) {
492
+ if (typeof options.sentence_scores !== 'boolean') {
493
+ throw new TypeError('quality: sentence_scores must be a boolean.');
494
+ }
495
+ data.sentence_scores = options.sentence_scores;
496
+ }
497
+ if (options?.rubric !== undefined) {
498
+ if (typeof options.rubric !== 'boolean') {
499
+ throw new TypeError('quality: rubric must be a boolean.');
500
+ }
501
+ data.rubric = options.rubric;
502
+ }
503
+
504
+ return this.axiosPromise(url, 'post', data);
505
+ }
506
+
507
+ safety(text, options = {}) {
508
+ const url = `${this.urlEndpoint}safety`;
509
+
510
+ // Shape/type checks only; size limits are server configuration (see seo()).
511
+ if (typeof text !== 'string' || text.trim().length === 0) {
512
+ throw new TypeError('safety: text must be a non-empty string.');
513
+ }
514
+
515
+ let data = {
516
+ text,
517
+ key: this.apiKey,
518
+ }
519
+
520
+ // `!== undefined` (not `!= null`) so an explicit `null` is rejected as a bad
521
+ // type rather than silently treated as "omitted", matching classify().
522
+ if (options?.threshold !== undefined) {
523
+ if (typeof options.threshold !== 'number' || !Number.isFinite(options.threshold)
524
+ || options.threshold < 0 || options.threshold > 1) {
525
+ throw new TypeError('safety: threshold must be a finite number between 0 and 1.');
526
+ }
527
+ data.threshold = options.threshold;
528
+ }
529
+
530
+ return this.axiosPromise(url, 'post', data);
531
+ }
532
+
197
533
  aidetect(text, options = {}) {
198
534
  const url = `${this.urlEndpoint}aidetect`;
199
535
 
@@ -208,4 +544,37 @@ export class Client {
208
544
 
209
545
  return this.axiosPromise(url, 'post', data);
210
546
  }
547
+
548
+ pii(text, options = {}) {
549
+ const url = `${this.urlEndpoint}pii`;
550
+
551
+ // Shape/type checks only; the set of valid PII types is server configuration
552
+ // and enforced there with a clear 400 (see seo()).
553
+ if (typeof text !== 'string' || text.trim().length === 0) {
554
+ throw new TypeError('pii: text must be a non-empty string.');
555
+ }
556
+
557
+ let data = {
558
+ text,
559
+ key: this.apiKey,
560
+ }
561
+
562
+ // `!== undefined` (not `!= null`) so an explicit `null` is rejected as a bad
563
+ // type rather than silently treated as "omitted", matching classify()/seo().
564
+ if (options?.types !== undefined) {
565
+ if (!Array.isArray(options.types)
566
+ || !Array.from(options.types).every((type) => typeof type === 'string' && type.trim().length > 0)) {
567
+ throw new TypeError('pii: types must be an array of non-empty strings.');
568
+ }
569
+ data.types = options.types;
570
+ }
571
+ if (options?.redact !== undefined) {
572
+ if (typeof options.redact !== 'boolean') {
573
+ throw new TypeError('pii: redact must be a boolean.');
574
+ }
575
+ data.redact = options.redact;
576
+ }
577
+
578
+ return this.axiosPromise(url, 'post', data);
579
+ }
211
580
  }