From 7371ef38d2021aa7757715c728eeb946c1b53c71 Mon Sep 17 00:00:00 2001 From: safenestdev Date: Tue, 25 Aug 2026 22:30:50 +0200 Subject: [PATCH 1/4] feat: add flagProfanity to detectBullying/detectUnsafe, plus escalation_capped The API added options.flag_profanity (free, deterministic, additive word-list flag -- never affects is_bullying/unsafe/severity/risk_score/recommended_action) and an account-level default_flag_profanity setting, plus escalation_capped on the coded-term corroboration cap, but neither reached this SDK's types or request body. A customer testing default_flag_profanity through the SDK had no typed way to override it per-request and no `profanity` field on the result, even though the API already supported both. - DetectBullyingInput.flagProfanity / DetectUnsafeInput.flagProfanity: new input properties, wired to options.flag_profanity in the request body. Explicit true AND false both reach the API (not just truthy true) -- an explicit false has to override the account default, same precedence the API itself uses. - BullyingResult.profanity / UnsafeResult.profanity: new response field, { detected, matches } | null. - BullyingResult.escalation_capped / escalation_capped_reason and the same on UnsafeResult: existed on the API since PR #117, never added here. Not yet forwarded through the combined analyze() method (client-side fan-out to detectBullying/detectUnsafe) -- only the two direct detection methods. 8 new tests asserting on the actual request body sent to fetch (mirrors verdictOnlyIncludeEvidence.test.ts's pattern), not just the TS types. 115/115 tests passing, clean build. Version bump: 2.26.0 -> 2.27.0. --- CHANGELOG.md | 8 +++ package-lock.json | 4 +- package.json | 2 +- src/client.ts | 2 + src/types/safety.ts | 54 +++++++++++++++ tests/flagProfanity.test.ts | 131 ++++++++++++++++++++++++++++++++++++ 6 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 tests/flagProfanity.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 61d2283..df386ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.27.0] - 2026-08-25 + +### Added + +- **`flagProfanity` on `detectBullying` / `detectUnsafe`, and `profanity` / `escalation_capped` / `escalation_capped_reason` on `BullyingResult` / `UnsafeResult`.** The API added `options.flag_profanity` (a free, deterministic, additive word-list flag — never affects `is_bullying`/`unsafe`/`severity`/`risk_score`/`recommended_action`) and an account-level `default_flag_profanity` setting, plus `escalation_capped` on the coded-term corroboration cap, but neither reached the SDK's types or request body. A customer testing `default_flag_profanity` through the SDK had no typed way to override it per-request and no `profanity` field on the result, even though the API already supported both. Explicit `flagProfanity: true` or `flagProfanity: false` both reach the API (an explicit `false` overrides the account default, same precedence as the API itself); omit it to use the account default. **Requires the API deployed on or after 2026-08-25.** + + Not yet forwarded through the combined `analyze()` method, which fans out to `detectBullying`/`detectUnsafe` client-side — only the two direct detection methods. + ## [2.26.0] - 2026-08-24 ### Changed diff --git a/package-lock.json b/package-lock.json index 67a4d9c..f05ea36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tuteliq/sdk", - "version": "2.26.0", + "version": "2.27.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tuteliq/sdk", - "version": "2.26.0", + "version": "2.27.0", "license": "MIT", "devDependencies": { "@types/node": "^26.2.0", diff --git a/package.json b/package.json index f68e1a4..1facc78 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tuteliq/sdk", - "version": "2.26.0", + "version": "2.27.0", "description": "TypeScript SDK for Tuteliq AI child safety API - Detect bullying, grooming, and unsafe content", "type": "module", "main": "./dist/index.js", diff --git a/src/client.ts b/src/client.ts index 0f6f4b8..f590ad2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -718,6 +718,7 @@ export class Tuteliq { const options: Record = {}; if (input.supportThreshold) options.support_threshold = input.supportThreshold; if (input.verdictOnly) options.verdict_only = true; + if (input.flagProfanity !== undefined) options.flag_profanity = input.flagProfanity; return this.requestWithRetry( 'POST', @@ -819,6 +820,7 @@ export class Tuteliq { const options: Record = {}; if (input.supportThreshold) options.support_threshold = input.supportThreshold; if (input.verdictOnly) options.verdict_only = true; + if (input.flagProfanity !== undefined) options.flag_profanity = input.flagProfanity; return this.requestWithRetry( 'POST', diff --git a/src/types/safety.ts b/src/types/safety.ts index c75baa7..f301f43 100644 --- a/src/types/safety.ts +++ b/src/types/safety.ts @@ -202,6 +202,17 @@ export interface DetectBullyingInput extends TrackingFields { * conversation. Useful when starting a new chat in the same session. */ resetConversation?: boolean; + /** + * Additive, deterministic word-list flag for plain profanity/vulgarity. + * When true, adds a `profanity` field to the response — never affects + * `is_bullying`, `severity`, `risk_score`, or `recommended_action`. Free — + * no extra credits. Not a harm classifier: does not cover slurs or hate + * speech, which the detector itself already handles with full context. + * Explicit `true`/`false` here always overrides your account's + * `default_flag_profanity` setting for this call; omit to use the + * account default. **Requires the API deployed on or after 2026-08-25.** + */ + flagProfanity?: boolean; } export interface BullyingResult { @@ -285,6 +296,22 @@ export interface BullyingResult { * why a benign-looking message arrived with elevated conversation risk. */ severity_series?: number[]; + /** + * True when a coded-term match pushed severity toward critical but this + * endpoint's corroboration-cap logic held `recommended_action` below + * `immediate_intervention` pending independent confirmation. Absent when + * no cap applied. + */ + escalation_capped?: boolean; + /** Human-readable explanation of the cap. Present only when `escalation_capped` is true. */ + escalation_capped_reason?: string; + /** + * Present only when `flagProfanity` on this request (or the account-level + * `default_flag_profanity` setting) is true. Deterministic word-list + * result — additive, never affects `is_bullying`/`severity`/`risk_score`/ + * `recommended_action`. + */ + profanity?: { detected: boolean; matches: string[] } | null; /** * Crisis support resources, present only when the result meets the * request's `supportThreshold`. Localised to `context.country`. @@ -454,6 +481,17 @@ export interface DetectUnsafeInput extends TrackingFields { * and a smaller payload for real-time screening; the verdict is unchanged. */ verdictOnly?: boolean; + /** + * Additive, deterministic word-list flag for plain profanity/vulgarity. + * When true, adds a `profanity` field to the response — never affects + * `unsafe`, `severity`, `risk_score`, or `recommended_action`. Free — no + * extra credits. Not a harm classifier: does not cover slurs or hate + * speech, which the detector itself already handles with full context. + * Explicit `true`/`false` here always overrides your account's + * `default_flag_profanity` setting for this call; omit to use the + * account default. **Requires the API deployed on or after 2026-08-25.** + */ + flagProfanity?: boolean; } export interface UnsafeResult { @@ -497,6 +535,22 @@ export interface UnsafeResult { customer_id?: string; /** Echo of provided metadata (if any) */ metadata?: Record; + /** + * True when a coded-term match pushed severity toward critical but this + * endpoint's corroboration-cap logic held `recommended_action` below + * `immediate_intervention` pending independent confirmation. Absent when + * no cap applied. + */ + escalation_capped?: boolean; + /** Human-readable explanation of the cap. Present only when `escalation_capped` is true. */ + escalation_capped_reason?: string; + /** + * Present only when `flagProfanity` on this request (or the account-level + * `default_flag_profanity` setting) is true. Deterministic word-list + * result — additive, never affects `unsafe`/`severity`/`risk_score`/ + * `recommended_action`. + */ + profanity?: { detected: boolean; matches: string[] } | null; /** * Crisis support resources, present only when the result meets the * request's `supportThreshold`. Localised to `context.country`. diff --git a/tests/flagProfanity.test.ts b/tests/flagProfanity.test.ts new file mode 100644 index 0000000..497d254 --- /dev/null +++ b/tests/flagProfanity.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { Tuteliq } from '../src/client.js'; + +// --------------------------------------------------------------------------- +// flagProfanity was added to the API's DetectBullyingInput/DetectUnsafeInput +// (options.flag_profanity) and BullyingResult/UnsafeResult (profanity field) +// but never made it into the SDK's types or request-building — a client +// testing default_flag_profanity through the SDK saw no way to override it +// per-request and no `profanity` field on the typed result, even though the +// API supported both. Mirrors verdictOnlyIncludeEvidence.test.ts's pattern: +// assert on the actual request body sent to fetch, not just the TS types. +// +// Explicit true/false must both reach the API (not just truthy `true`) -- +// the server's precedence is "explicit per-request value always wins over +// the account's default_flag_profanity setting", so an explicit `false` +// has to be distinguishable from "not sent at all". +// --------------------------------------------------------------------------- + +function mockFetchResponse(data: unknown) { + return { + ok: true, + status: 200, + json: async () => data, + headers: { get: () => null }, + } as Response; +} + +function bodySentIn(fetchSpy: ReturnType): Record { + const init = fetchSpy.mock.calls[0][1] as RequestInit; + return JSON.parse(init.body as string); +} + +const bullyingResponse = { + is_bullying: false, + bullying_type: [], + confidence: 0.9, + severity: 'low', + recommended_action: 'none', + risk_score: 0.1, +}; + +const unsafeResponse = { + unsafe: false, + categories: [], + severity: 'low', + confidence: 0.9, + risk_score: 0.1, + recommended_action: 'none', +}; + +describe('flagProfanity forwarding', () => { + let client: Tuteliq; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('detectBullying: sends nothing when flagProfanity is not set (server applies the account default)', async () => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(bullyingResponse)); + await client.detectBullying({ content: 'hello' }); + const body = bodySentIn(fetchSpy); + expect(body.options).toBeUndefined(); + }); + + it('detectBullying: forwards flagProfanity: true as options.flag_profanity', async () => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(bullyingResponse)); + await client.detectBullying({ content: 'fucks sake', flagProfanity: true }); + const body = bodySentIn(fetchSpy); + expect((body.options as Record).flag_profanity).toBe(true); + }); + + it('detectBullying: forwards an explicit flagProfanity: false (does not drop it like the includeEvidence bug did)', async () => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(bullyingResponse)); + await client.detectBullying({ content: 'hello', flagProfanity: false }); + const body = bodySentIn(fetchSpy); + expect((body.options as Record).flag_profanity).toBe(false); + }); + + it('detectBullying: types and passes through the profanity field on the result', async () => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ ...bullyingResponse, profanity: { detected: true, matches: ['fuck'] } }), + ); + const result = await client.detectBullying({ content: 'fucks sake', flagProfanity: true }); + expect(result.profanity).toEqual({ detected: true, matches: ['fuck'] }); + // Additive only -- untouched by the profanity match. + expect(result.is_bullying).toBe(false); + expect(result.recommended_action).toBe('none'); + }); + + it('detectUnsafe: forwards flagProfanity: true as options.flag_profanity', async () => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(unsafeResponse)); + await client.detectUnsafe({ content: 'fucks sake', flagProfanity: true }); + const body = bodySentIn(fetchSpy); + expect((body.options as Record).flag_profanity).toBe(true); + }); + + it('detectUnsafe: forwards an explicit flagProfanity: false', async () => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(unsafeResponse)); + await client.detectUnsafe({ content: 'hello', flagProfanity: false }); + const body = bodySentIn(fetchSpy); + expect((body.options as Record).flag_profanity).toBe(false); + }); + + it('detectUnsafe: types and passes through the profanity field on the result', async () => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ ...unsafeResponse, profanity: { detected: true, matches: ['fuck'] } }), + ); + const result = await client.detectUnsafe({ content: 'fucks sake', flagProfanity: true }); + expect(result.profanity).toEqual({ detected: true, matches: ['fuck'] }); + expect(result.unsafe).toBe(false); + expect(result.recommended_action).toBe('none'); + }); + + it('detectBullying: coexists correctly with other options (supportThreshold, verdictOnly)', async () => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(bullyingResponse)); + await client.detectBullying({ content: 'hello', flagProfanity: true, verdictOnly: true, supportThreshold: 'high' }); + const body = bodySentIn(fetchSpy); + const options = body.options as Record; + expect(options.flag_profanity).toBe(true); + expect(options.verdict_only).toBe(true); + expect(options.support_threshold).toBe('high'); + }); +}); From 6ce679e7f87fd62a3221b2d8e04a1fd7c846d5fa Mon Sep 17 00:00:00 2001 From: safenestdev Date: Tue, 25 Aug 2026 23:50:26 +0200 Subject: [PATCH 2/4] feat: add flagProfanity to analyzeImage, bump to 2.28.0 Extends the flagProfanity/profanity pair from 2.27.0 (detectBullying/ detectUnsafe) to analyzeImage, matching the API's new options.flag_profanity support on the image OCR text. Video was evaluated and left out -- the API has no OCR/text-extraction path on video to attach it to. --- CHANGELOG.md | 6 ++++ package.json | 2 +- src/client.ts | 1 + src/types/media.ts | 20 +++++++++++++ tests/flagProfanity.test.ts | 60 +++++++++++++++++++++++++++++++++++++ 5 files changed, 88 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df386ec..fc5a043 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.28.0] - 2026-08-25 + +### Added + +- **`flagProfanity` on `analyzeImage`, and `profanity` on `ImageAnalysisResult`.** Extends the `flagProfanity`/`profanity` pair added to `detectBullying`/`detectUnsafe` in 2.27.0 to the image endpoint: the API now runs the same free, deterministic, additive word-list check over an image's OCR'd text (`vision.extracted_text`) when `flag_profanity` is set on the request or the account's `default_flag_profanity` setting is on. Only meaningful when the image actually contains OCR text — `vision.contains_text: false` never produces a `profanity` field regardless of the flag. Never affects `overall_severity`, `recommended_action`, or any `text_analysis` result. Video was evaluated and left out: the API's video analysis has no OCR/text-extraction path to attach this to. **Requires the API deployed on or after 2026-08-25.** + ## [2.27.0] - 2026-08-25 ### Added diff --git a/package.json b/package.json index 1facc78..8b781a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tuteliq/sdk", - "version": "2.27.0", + "version": "2.28.0", "description": "TypeScript SDK for Tuteliq AI child safety API - Detect bullying, grooming, and unsafe content", "type": "module", "main": "./dist/index.js", diff --git a/src/client.ts b/src/client.ts index f590ad2..6149bfe 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1862,6 +1862,7 @@ export class Tuteliq { if (input.ageGroup) formData.append('age_group', input.ageGroup); formData.append('platform', Tuteliq.resolvePlatform(input.platform)); if (input.metadata) formData.append('metadata', JSON.stringify(input.metadata)); + if (input.flagProfanity !== undefined) formData.append('flag_profanity', String(input.flagProfanity)); return withRetry( () => this.multipartRequest( diff --git a/src/types/media.ts b/src/types/media.ts index 3280c89..8bee5b9 100644 --- a/src/types/media.ts +++ b/src/types/media.ts @@ -223,6 +223,18 @@ export interface AnalyzeImageInput extends TrackingFields { ageGroup?: string; /** Platform name */ platform?: string; + /** + * Additive, deterministic word-list flag for plain profanity/vulgarity + * found in the image's OCR'd text. When true, adds a `profanity` field to + * the response — never affects `overall_severity`, `recommended_action`, + * or any `text_analysis` result. Free — no extra credits. Only meaningful + * when the image actually contains OCR text (`vision.contains_text`); + * a no-text image never gets a `profanity` field regardless of this flag. + * Explicit `true`/`false` here always overrides your account's + * `default_flag_profanity` setting for this call; omit to use the + * account default. **Requires the API deployed on or after 2026-08-25.** + */ + flagProfanity?: boolean; } export interface ImageAnalysisResult { @@ -270,6 +282,14 @@ export interface ImageAnalysisResult { customer_id?: string; /** Echo of provided metadata */ metadata?: Record; + /** + * Present only when `flagProfanity` on this request (or the account-level + * `default_flag_profanity` setting) is true AND the image contained OCR + * text (`vision.contains_text`). Deterministic word-list result over the + * OCR'd text — additive, never affects `overall_severity`, + * `recommended_action`, or any `text_analysis` result. + */ + profanity?: { detected: boolean; matches: string[] } | null; } // ============================================================================= diff --git a/tests/flagProfanity.test.ts b/tests/flagProfanity.test.ts index 497d254..9f77288 100644 --- a/tests/flagProfanity.test.ts +++ b/tests/flagProfanity.test.ts @@ -48,6 +48,24 @@ const unsafeResponse = { recommended_action: 'none', }; +const imageResponse = { + vision: { + extracted_text: 'some text', + visual_categories: [], + visual_severity: 'low', + visual_confidence: 0.9, + visual_description: 'a screenshot', + contains_text: true, + contains_faces: false, + }, + overall_risk_score: 0.1, + overall_severity: 'low', + detected: false, + confidence: 0.9, + recommended_action: 'none', + rationale: 'Safe', +}; + describe('flagProfanity forwarding', () => { let client: Tuteliq; @@ -128,4 +146,46 @@ describe('flagProfanity forwarding', () => { expect(options.verdict_only).toBe(true); expect(options.support_threshold).toBe('high'); }); + + // ------------------------------------------------------------------------- + // analyzeImage -- multipart/form-data request, not a JSON body, so + // flag_profanity is asserted on the FormData sent to fetch rather than a + // parsed JSON body. + // ------------------------------------------------------------------------- + + it('analyzeImage: sends nothing when flagProfanity is not set (server applies the account default)', async () => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(imageResponse)); + await client.analyzeImage({ file: Buffer.from('fake image'), filename: 'screenshot.png' }); + const body = fetchSpy.mock.calls[0][1]?.body as FormData; + expect(body.get('flag_profanity')).toBeNull(); + }); + + it('analyzeImage: forwards flagProfanity: true as flag_profanity', async () => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(imageResponse)); + await client.analyzeImage({ file: Buffer.from('fake image'), filename: 'screenshot.png', flagProfanity: true }); + const body = fetchSpy.mock.calls[0][1]?.body as FormData; + expect(body.get('flag_profanity')).toBe('true'); + }); + + it('analyzeImage: forwards an explicit flagProfanity: false (does not drop it)', async () => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(imageResponse)); + await client.analyzeImage({ file: Buffer.from('fake image'), filename: 'screenshot.png', flagProfanity: false }); + const body = fetchSpy.mock.calls[0][1]?.body as FormData; + expect(body.get('flag_profanity')).toBe('false'); + }); + + it('analyzeImage: types and passes through the profanity field on the result', async () => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ ...imageResponse, profanity: { detected: true, matches: ['fuck'] } }), + ); + const result = await client.analyzeImage({ file: Buffer.from('fake image'), filename: 'screenshot.png', flagProfanity: true }); + expect(result.profanity).toEqual({ detected: true, matches: ['fuck'] }); + // Additive only -- untouched by the profanity match. + expect(result.detected).toBe(false); + expect(result.recommended_action).toBe('none'); + }); }); From 493815d05832b8238a1b5d2d737d422512793388 Mon Sep 17 00:00:00 2001 From: safenestdev Date: Wed, 26 Aug 2026 06:46:03 +0200 Subject: [PATCH 3/4] fix: forward flagProfanity through analyze(), bump to 2.29.0 Same gap verdictOnly had in 2.19, fixed in 2.20 -- AnalyzeInput accepted flagProfanity but analyze() never passed it to the detectBullying/ detectUnsafe calls it fans out to. Didn't block anyone since the account default still applied, but per-request override wasn't reachable through the combined method. --- CHANGELOG.md | 6 ++++ package.json | 2 +- src/client.ts | 4 +++ src/types/safety.ts | 9 ++++++ tests/flagProfanity.test.ts | 56 +++++++++++++++++++++++++++++++++++++ 5 files changed, 76 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc5a043..6169893 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.29.0] - 2026-08-26 + +### Fixed + +- **`flagProfanity` is now forwarded through `analyze()`.** Same gap `verdictOnly` had in 2.19, fixed in 2.20: `AnalyzeInput` accepted `flagProfanity`, but `analyze()` never passed it to the `detectBullying`/`detectUnsafe` calls it fans out to. Didn't block anyone — the account's `default_flag_profanity` setting still applied — but per-request override wasn't reachable through the combined method. `result.bullying.profanity`/`result.unsafe.profanity` were already typed correctly and now populate as expected. + ## [2.28.0] - 2026-08-25 ### Added diff --git a/package.json b/package.json index 8b781a3..a1d182a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tuteliq/sdk", - "version": "2.28.0", + "version": "2.29.0", "description": "TypeScript SDK for Tuteliq AI child safety API - Detect bullying, grooming, and unsafe content", "type": "module", "main": "./dist/index.js", diff --git a/src/client.ts b/src/client.ts index 6149bfe..56b373d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -880,6 +880,9 @@ export class Tuteliq { // and then copy it into its own result — so `false` read back // as honoured while both sub-calls still logged an incident. incident_moderation_enabled: input.incident_moderation_enabled, + // Same gap verdictOnly had before it: accepted on AnalyzeInput + // but never reached the detectors it fans out to. + flagProfanity: input.flagProfanity, })); } @@ -893,6 +896,7 @@ export class Tuteliq { customer_id: input.customer_id, metadata: input.metadata, incident_moderation_enabled: input.incident_moderation_enabled, + flagProfanity: input.flagProfanity, })); } diff --git a/src/types/safety.ts b/src/types/safety.ts index f301f43..d89f18c 100644 --- a/src/types/safety.ts +++ b/src/types/safety.ts @@ -582,6 +582,15 @@ export interface AnalyzeInput extends TrackingFields { * on the slower detector rather than the full per-call saving. */ verdictOnly?: boolean; + /** + * Forwarded to each detector this call fans out to, same semantics as + * `flagProfanity` on `detectBullying`/`detectUnsafe` directly: an + * additive, deterministic word-list flag that adds a `profanity` field to + * `result.bullying`/`result.unsafe`, never affecting risk scoring or + * `recommended_action`. Omit to use the account's `default_flag_profanity` + * setting. **Requires the API deployed on or after 2026-08-25.** + */ + flagProfanity?: boolean; } export interface AnalyzeResult { diff --git a/tests/flagProfanity.test.ts b/tests/flagProfanity.test.ts index 9f77288..664b7e0 100644 --- a/tests/flagProfanity.test.ts +++ b/tests/flagProfanity.test.ts @@ -188,4 +188,60 @@ describe('flagProfanity forwarding', () => { expect(result.detected).toBe(false); expect(result.recommended_action).toBe('none'); }); + + // ------------------------------------------------------------------------- + // analyze() -- same gap verdictOnly had (accepted on AnalyzeInput, never + // forwarded to the detectBullying/detectUnsafe calls it fans out to). + // Account default still applied without this, so it never blocked anyone, + // but per-request override wasn't reachable through analyze(). + // ------------------------------------------------------------------------- + + it('analyze(): forwards flagProfanity to both detectBullying and detectUnsafe', async () => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(mockFetchResponse(bullyingResponse)); + + await client.analyze({ content: 'fucks sake', flagProfanity: true }); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + for (const call of fetchSpy.mock.calls) { + const body = JSON.parse((call[1] as RequestInit).body as string); + expect((body.options as Record).flag_profanity).toBe(true); + } + }); + + it('analyze(): forwards an explicit flagProfanity: false to both detectors', async () => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(mockFetchResponse(bullyingResponse)); + + await client.analyze({ content: 'hello', flagProfanity: false }); + + for (const call of fetchSpy.mock.calls) { + const body = JSON.parse((call[1] as RequestInit).body as string); + expect((body.options as Record).flag_profanity).toBe(false); + } + }); + + it('analyze(): sends nothing when flagProfanity is not set (server applies the account default)', async () => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(mockFetchResponse(bullyingResponse)); + + await client.analyze({ content: 'hello' }); + + for (const call of fetchSpy.mock.calls) { + const body = JSON.parse((call[1] as RequestInit).body as string); + expect(body.options).toBeUndefined(); + } + }); + + it('analyze(): result.bullying/result.unsafe carry the profanity field through', async () => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + vi.spyOn(global, 'fetch').mockResolvedValue( + mockFetchResponse({ ...bullyingResponse, profanity: { detected: true, matches: ['fuck'] } }), + ); + + const result = await client.analyze({ content: 'fucks sake', flagProfanity: true }); + + expect(result.bullying?.profanity).toEqual({ detected: true, matches: ['fuck'] }); + expect(result.unsafe?.profanity).toEqual({ detected: true, matches: ['fuck'] }); + }); }); From 3f3b4e2ecd23289907d31d4b1518f53f84ce58db Mon Sep 17 00:00:00 2001 From: safenestdev Date: Wed, 26 Aug 2026 07:02:50 +0200 Subject: [PATCH 4/4] feat: add categories + flagProfanity to analyzeVideo, bump to 2.30.0 categories was already computed server-side but only folded into the free-text rationale string -- no field existed to read it from. flagProfanity closes the evasion path left open by shipping profanity on analyzeImage alone: burning the same text into a video frame instead of a still previously never reached the word-list check. The API already OCRs text per frame for video (same vision call as image), it just wasn't aggregated or checked. --- CHANGELOG.md | 8 ++++++ package.json | 2 +- src/client.ts | 1 + src/types/media.ts | 30 +++++++++++++++++++ tests/flagProfanity.test.ts | 57 +++++++++++++++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6169893..123ce78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.30.0] - 2026-08-26 + +### Added + +- **`categories` on `VideoAnalysisResult`.** `analyzeVideo` already computed a deduped list of visual harm categories across every flagged frame server-side, but only folded it into the free-text `rationale` string — no field existed to read it from without parsing `frame_results` yourself. A client integration that expected a top-level `categories` field (matching every other detection endpoint) got silent empty results instead of an error. **Requires the API deployed on or after 2026-08-26.** + +- **`flagProfanity` on `analyzeVideo`, and `profanity` on `VideoAnalysisResult`.** Closes the evasion path left open by shipping profanity on `analyzeImage` alone (2.28.0): burning the same text into a video frame instead of a still image previously never reached the word-list check. The API already runs OCR per frame for video (same vision call as the image endpoint) — it's now aggregated across every frame that has text and checked the same way, with identical precedence to `flagProfanity` elsewhere: explicit per-request value wins, otherwise the account's `default_flag_profanity` setting applies. Only meaningful when at least one frame actually contains OCR text — a video with no on-screen text never gets a `profanity` field regardless of this flag. **Requires the API deployed on or after 2026-08-26.** + ## [2.29.0] - 2026-08-26 ### Fixed diff --git a/package.json b/package.json index a1d182a..c976a30 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tuteliq/sdk", - "version": "2.29.0", + "version": "2.30.0", "description": "TypeScript SDK for Tuteliq AI child safety API - Detect bullying, grooming, and unsafe content", "type": "module", "main": "./dist/index.js", diff --git a/src/client.ts b/src/client.ts index 56b373d..0c0c912 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2098,6 +2098,7 @@ export class Tuteliq { if (input.ageGroup) formData.append('age_group', input.ageGroup); formData.append('platform', Tuteliq.resolvePlatform(input.platform)); if (input.metadata) formData.append('metadata', JSON.stringify(input.metadata)); + if (input.flagProfanity !== undefined) formData.append('flag_profanity', String(input.flagProfanity)); return withRetry( () => this.multipartRequest( diff --git a/src/types/media.ts b/src/types/media.ts index 8bee5b9..c80ac72 100644 --- a/src/types/media.ts +++ b/src/types/media.ts @@ -140,6 +140,20 @@ export interface AnalyzeVideoInput extends TrackingFields { ageGroup?: string; /** Platform name */ platform?: string; + /** + * Additive, deterministic word-list flag for plain profanity/vulgarity + * found in the video's OCR'd text (aggregated across every frame that has + * any). When true, adds a `profanity` field to the response — never + * affects `frame_results`, `overall_severity`, or `recommended_action`. + * Free — no extra credits. Only meaningful when at least one frame + * actually contains OCR text (see `contains_text` semantics on the image + * endpoint) — a video with no on-screen text never gets a `profanity` + * field regardless of this flag. Explicit `true`/`false` here always + * overrides your account's `default_flag_profanity` setting for this + * call; omit to use the account default. **Requires the API deployed on + * or after 2026-08-26.** + */ + flagProfanity?: boolean; } export interface VideoAnalysisResult { @@ -151,6 +165,14 @@ export interface VideoAnalysisResult { duration_seconds: number; /** Per-frame analysis, one entry per sampled frame */ frame_results: VideoFrameResult[]; + /** + * Visual harm categories across every flagged frame, deduped. Was + * previously computed server-side but only folded into the free-text + * `rationale` string, with no field to read it from without parsing + * `frame_results` yourself. **Requires the API deployed on or after + * 2026-08-26.** + */ + categories: string[]; /** Points in the video that exceeded the reporting threshold */ flagged_timestamps: VideoFlaggedTimestamp[]; /** Maximum risk score across all findings (0-1) */ @@ -187,6 +209,14 @@ export interface VideoAnalysisResult { customer_id?: string; /** Echo of provided metadata */ metadata?: Record; + /** + * Present only when `flagProfanity` on this request (or the account-level + * `default_flag_profanity` setting) is true AND at least one frame + * contained OCR text. Deterministic word-list result over the OCR text + * aggregated across frames — additive, never affects `frame_results`, + * `overall_severity`, or `recommended_action`. + */ + profanity?: { detected: boolean; matches: string[] } | null; } // ============================================================================= diff --git a/tests/flagProfanity.test.ts b/tests/flagProfanity.test.ts index 664b7e0..248803c 100644 --- a/tests/flagProfanity.test.ts +++ b/tests/flagProfanity.test.ts @@ -66,6 +66,19 @@ const imageResponse = { rationale: 'Safe', }; +const videoResponse = { + frames_analyzed: 3, + duration_seconds: 15, + frame_results: [], + categories: [], + overall_risk_score: 0.1, + overall_severity: 'low', + detected: false, + confidence: 0.9, + recommended_action: 'none', + rationale: 'Safe', +}; + describe('flagProfanity forwarding', () => { let client: Tuteliq; @@ -189,6 +202,50 @@ describe('flagProfanity forwarding', () => { expect(result.recommended_action).toBe('none'); }); + // ------------------------------------------------------------------------- + // analyzeVideo -- same shape as analyzeImage's flag_profanity forwarding, + // but over OCR text aggregated across every frame that has any. Closes + // the evasion path opened by shipping profanity on image alone: burn the + // same text into a video frame instead of a still and it would otherwise + // never reach the word list. + // ------------------------------------------------------------------------- + + it('analyzeVideo: sends nothing when flagProfanity is not set (server applies the account default)', async () => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(videoResponse)); + await client.analyzeVideo({ file: Buffer.from('fake video'), filename: 'clip.mp4' }); + const body = fetchSpy.mock.calls[0][1]?.body as FormData; + expect(body.get('flag_profanity')).toBeNull(); + }); + + it('analyzeVideo: forwards flagProfanity: true as flag_profanity', async () => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(videoResponse)); + await client.analyzeVideo({ file: Buffer.from('fake video'), filename: 'clip.mp4', flagProfanity: true }); + const body = fetchSpy.mock.calls[0][1]?.body as FormData; + expect(body.get('flag_profanity')).toBe('true'); + }); + + it('analyzeVideo: forwards an explicit flagProfanity: false (does not drop it)', async () => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(videoResponse)); + await client.analyzeVideo({ file: Buffer.from('fake video'), filename: 'clip.mp4', flagProfanity: false }); + const body = fetchSpy.mock.calls[0][1]?.body as FormData; + expect(body.get('flag_profanity')).toBe('false'); + }); + + it('analyzeVideo: types and passes through the profanity field on the result', async () => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ ...videoResponse, profanity: { detected: true, matches: ['fuck'] } }), + ); + const result = await client.analyzeVideo({ file: Buffer.from('fake video'), filename: 'clip.mp4', flagProfanity: true }); + expect(result.profanity).toEqual({ detected: true, matches: ['fuck'] }); + // Additive only -- untouched by the profanity match. + expect(result.detected).toBe(false); + expect(result.recommended_action).toBe('none'); + }); + // ------------------------------------------------------------------------- // analyze() -- same gap verdictOnly had (accepted on AnalyzeInput, never // forwarded to the detectBullying/detectUnsafe calls it fans out to).