diff --git a/CHANGELOG.md b/CHANGELOG.md index 759c09f..61d2283 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,72 @@ 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.26.0] - 2026-08-24 + +### Changed + +- **`verdictOnly` no longer omits `rationale` on `detectBullying` / `detectUnsafe` — it omits `action_detail` instead.** Two accounts on this SDK found every persisted bullying incident summary reading as the same flat generic string, because `rationale` — the one field a moderator reads to triage an incident — was the field fast mode cut server-side. `action_detail` (the secondary, comparably-sized moderator-guidance field) was left unconditional, backwards from what fast mode should prioritise. `detectGrooming` already had this right; bullying and unsafe are now consistent with it. `GroomingResult.rationale` is correspondingly now typed as required (`string`, not `string?`) — it was always unconditional at runtime, the type just hadn't caught up. `BullyingResult.rationale` / `UnsafeResult.rationale` stay optional at the type level as a defensive measure (a malformed LLM response could still theoretically omit it), but are documented as always generated. **Requires the API deployed on or after 2026-08-24**; against an older deployment, `verdictOnly` continues to omit `rationale` as before. + +- **`verdictOnly` now implies `includeEvidence: false` on the fraud/safety-extended endpoints, unless `includeEvidence` is set explicitly.** Previously `verdictOnly` had no effect at all on `detectRomanceScam`, `detectSocialEngineering`, `detectAppFraud`, `detectMuleRecruitment`, `detectGamblingHarm`, `detectCoerciveControl`, `detectVulnerabilityExploitation`, `detectRadicalisation`, `detectDistressSignals`, `detectTFGBV` and `detectSyntheticContent` — the full `evidence[]` array, including quoted excerpts from the input, was always returned regardless. `evidence` is this endpoint family's equivalent of `action_detail`: the expensive, skippable field. `rationale` was already unconditional here and is untouched. Pass `includeEvidence: true` alongside `verdictOnly: true` if you want fast mode's other savings without losing evidence. **Requires the API deployed on or after 2026-08-24**; against an older deployment, `verdictOnly` continues to have no effect on evidence. + +### Fixed + +- **`includeEvidence: false` was silently dropped and never reached the API.** `buildDetectionBody` only forwarded `includeEvidence` when it was truthy (`if (input.includeEvidence) ...`), so an explicit `includeEvidence: false` — the caller's choice to exclude evidence entirely — was indistinguishable from not setting it at all, and the server's default (`true`) applied instead. Found auditing the `verdictOnly` change above. Now forwards `true` and `false` alike, and omits the field only when the caller truly didn't set it (letting the server apply its own default, including the new `verdictOnly` inference above). + +## [2.25.0] - 2026-08-20 + +### Added + +- **Conversation-level risk: `trajectory_risk`, `trajectory` and `severity_series`.** `risk_score` has only ever scored the message in the current request. An external reviewer fed a six-turn bullying escalation and watched the scores go 5, 10, 65, 5, 75, 5 — the final "see you tomorrow :)", sent immediately after two flagged messages, came back described as a positive social interaction. Correct per message; useless for a child who had just been excluded. Slow-burn exclusion cannot be seen one message at a time. + + The API now returns a conversation-level view alongside the continuation token, and it is typed here on `BullyingResult`, `GroomingResult` and `DetectionResult` — the same three result types that carry `continuation_token`: + + - `trajectory_risk` (0-1) — risk for the conversation rather than for the turn. Anchored on the highest severity seen so far, decaying slowly across benign turns and never falling below the current turn, so a friendly message straight after an escalation does not reset it. On the reviewer's conversation it reads `0.74` where `risk_score` reads `0.10`. + - `trajectory` — `rising` | `stable` | `declining` | `none`, exported as the `ConversationTrajectory` type. + - `severity_series` — per-turn severity, oldest first: the evidence behind the other two, so the number can be shown rather than asserted. + + All three are optional and absent on the first turn of a fresh conversation, where they would only restate `risk_score`. They require a `continuationToken` to be threaded through the conversation; without one, every call is a first turn. Branch on the higher of `risk_score` and `trajectory_risk`, not on `risk_score` alone. + +### Fixed + +- **`analyze()` accepted `incident_moderation_enabled`, dropped it, and reported it as applied.** The flag lives on the shared `TrackingFields`, so `analyze()` accepted it — but it was never forwarded to the `detectBullying` / `detectUnsafe` calls the method fans out to, while being copied verbatim into the returned result. A caller passing `false` to suppress incident persistence got incidents persisted by both sub-calls and a response claiming otherwise. It is now forwarded to both, and declared on `AnalyzeResult` instead of being an untyped extra field. + +### Note + +- `analyze()` still cannot report a trajectory: it accepts no `continuationToken`, so every call is a fresh first turn and its combined `risk_score` remains the maximum of the per-message scores. Where a sub-result does carry conversation state it is preserved in full under `result.bullying`. For multi-turn work call `detectBullying` directly. + +## [2.24.0] - 2026-08-20 + +### Fixed + +- **`batch()` sent a request shape the API has never accepted.** `POST /api/v1/batch/analyze` requires each item to be `{ id, type, data }`; the SDK sent `{ type, text, context, external_id }`, so every batch call was rejected with `body/items/0 must have required property 'id'`. Three separate mismatches: the missing `id`, `text`/`messages` sitting on the item instead of inside `data`, and `parallel` nested under an `options` object the route never reads — so `parallel: false` was silently ignored even had the rest been valid. Batch analysis did not work through the SDK, or through the MCP server that calls it. + + Items now carry an optional `id`; one is generated positionally (`item-0`, `item-1`, …) when you do not supply it. `id` addresses an item within the request and is echoed on its result — it is not `external_id`, which is your own record's identifier and is still returned alongside it. + +- **`batch()` returned a result shape that did not match its own type.** The API keys results by `id` and reports timing as `summary.processingTimeMs`; `BatchAnalyzeResult` declares `results[].index` and a top-level `processing_time_ms`. Both were `undefined` at runtime, so `items[r.index]` never resolved. The response is now mapped back: positional `index` restored by id, `external_id` re-attached from the request, `processing_time_ms` and `summary.total_credits_used` populated. + +- **`createVerificationSession()` discarded `recommended_image_width` and `verification_mode`.** The API returns both; the SDK projected the response down to four fields. `recommended_image_width` is the capture width at which document small print (document number, issuing authority, issue date) survives OCR, so dropping it left callers guessing at a value the API had already supplied. `expires_at` is also now correctly typed as `number` (epoch milliseconds), which is what the API sends. + +### Added + +- **All twelve batch analysis types.** `batch()` previously typed only `bullying`, `unsafe`, `emotions` and `grooming`. It now accepts the full set the route supports: those four plus `social_engineering`, `app_fraud`, `romance_scam`, `mule_recruitment`, `gambling_harm`, `coercive_control`, `vulnerability_exploitation` and `radicalisation`. + +- **Batch emotions items take `messages`.** The emotions endpoint is message-based and uses `sender`/`text`, not grooming's `sender_role`/`text`. Pass `messages: [{ sender, content }]`, or keep passing `content` and the SDK wraps it into a one-message conversation. + +- **`continuationToken` / `resetConversation` on the unified detection endpoints.** `detectCoerciveControl`, `detectVulnerabilityExploitation` and `detectDistressSignals` maintain conversation state server-side and return a fresh `continuation_token` on every result, but there was no way to send one back — multi-turn trajectory on those endpoints was unreachable from the SDK. The fields are accepted on every `DetectionInput`; endpoints that do not track state ignore them. + +- **`SupportData` / `SupportHelpline` / `SupportResponseGuide` types.** The `support` block attached to a positive detection was undeclared, so every consumer cast through `any`. `support` is now typed on `BullyingResult`, `GroomingResult`, `UnsafeResult` and `DetectionResult`, alongside `continuation_token`, `continuation_expires_at` and `state_source`. + +### Changed + +- **`DetectionResult.rationale` is now optional.** `verdictOnly: true` suppresses rationale generation server-side, so the field was already absent at runtime while the type promised a `string`. Callers interpolating it printed the literal string `undefined`. This is a type-level breaking change for TypeScript consumers who read `rationale` unguarded; it matches the runtime behaviour and the already-optional `rationale` on `BullyingResult` and `GroomingResult`. + +## [2.23.0] - 2026-08-12 + +### Added + +- **Per-call incident logging control** — every detection method now accepts an optional `incident_moderation_enabled` (via the shared `TrackingFields`). It overrides your account-level incident-logging setting for that single request: `true` forces the incident to be persisted, `false` suppresses persistence, and omitting it defers to your account default (which itself defaults to enabled). Useful for suppressing logging on test traffic or opting specific calls in or out. `false` is passed through correctly, not treated as "unset". + ## [2.21.0] - 2026-08-05 ### Fixed diff --git a/package-lock.json b/package-lock.json index 38b7b3f..6f81854 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tuteliq/sdk", - "version": "2.22.0", + "version": "2.26.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tuteliq/sdk", - "version": "2.22.0", + "version": "2.26.0", "license": "MIT", "devDependencies": { "@types/node": "^25.9.1", @@ -137,9 +137,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -157,9 +154,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -177,9 +171,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -197,9 +188,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -217,9 +205,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -237,9 +222,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -712,9 +694,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -736,9 +715,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -760,9 +736,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -784,9 +757,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index 1c982a8..94cdc67 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tuteliq/sdk", - "version": "2.22.0", + "version": "2.26.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 4ddf611..0f6f4b8 100644 --- a/src/client.ts +++ b/src/client.ts @@ -31,6 +31,9 @@ import { // Batch types BatchAnalyzeInput, BatchAnalyzeResult, + BatchAnalysisType, + BatchItem, + BatchResultItem, // Account types AccountDeletionResult, AccountExportResult, @@ -228,6 +231,32 @@ function typedBlob(file: Buffer, filename: string): Blob { return new Blob([file as unknown as BlobPart], type ? { type } : undefined); } +/** + * Wire shape of POST /api/v1/batch/analyze. + * + * Deliberately separate from the public `BatchAnalyzeResult`: the API keys + * results by the item `id` and reports timing inside `summary`, while callers + * work in positional indexes and their own `external_id`s. `batch()` maps + * between the two. + */ +interface BatchApiResponse { + results?: Array<{ + id: string; + type: BatchAnalysisType; + success: boolean; + result?: unknown; + error?: string; + credits_used?: number; + }>; + summary?: { + total: number; + successful: number; + failed: number; + processingTimeMs: number; + total_credits_used?: number; + }; +} + export class Tuteliq { private readonly apiKey: string; private readonly timeout: number; @@ -372,18 +401,33 @@ export class Tuteliq { * Build request body for unified detection endpoints */ private buildDetectionBody(input: DetectionInput): Record { + // `includeEvidence` must be forwarded on BOTH true and false — a + // truthy-only check (the previous `if (input.includeEvidence)`) + // silently dropped an explicit `includeEvidence: false`, so the + // caller's choice to exclude evidence never reached the API at all. + // Leaving the field out entirely (the `undefined` case) is + // deliberate: it lets the server apply its own default, which is + // "true" normally but "false" when `verdictOnly` is set — see + // `DetectionInput.verdictOnly`. Replicating that inference here too + // would just be a second place for the two to drift apart. const options: Record = {}; if (input.supportThreshold) options.support_threshold = input.supportThreshold; - if (input.includeEvidence) options.include_evidence = true; + if (input.includeEvidence !== undefined) options.include_evidence = input.includeEvidence; if (input.verdictOnly) options.verdict_only = true; return { text: input.content, context: this.normalizeContext(input.context), - ...(input.includeEvidence && { include_evidence: true }), + ...(input.includeEvidence !== undefined && { include_evidence: input.includeEvidence }), + // Every unified detection endpoint accepts these (see the shared + // body schema); coercive-control, vulnerability-exploitation and + // distress-signals additionally issue a fresh token back. + ...(input.continuationToken && { continuation_token: input.continuationToken }), + ...(input.resetConversation && { reset_conversation: true }), ...(input.external_id && { external_id: input.external_id }), ...(input.customer_id && { customer_id: input.customer_id }), ...(input.metadata && { metadata: input.metadata }), + ...(input.incident_moderation_enabled !== undefined && { incident_moderation_enabled: input.incident_moderation_enabled }), ...(Object.keys(options).length > 0 && { options }), }; } @@ -684,6 +728,7 @@ export class Tuteliq { ...(input.external_id && { external_id: input.external_id }), ...(input.customer_id && { customer_id: input.customer_id }), ...(input.metadata && { metadata: input.metadata }), + ...(input.incident_moderation_enabled !== undefined && { incident_moderation_enabled: input.incident_moderation_enabled }), ...(input.continuationToken && { continuation_token: input.continuationToken }), ...(input.resetConversation && { reset_conversation: true }), ...(Object.keys(options).length > 0 && { options }), @@ -739,6 +784,7 @@ export class Tuteliq { ...(input.external_id && { external_id: input.external_id }), ...(input.customer_id && { customer_id: input.customer_id }), ...(input.metadata && { metadata: input.metadata }), + ...(input.incident_moderation_enabled !== undefined && { incident_moderation_enabled: input.incident_moderation_enabled }), ...(input.continuationToken && { continuation_token: input.continuationToken }), ...(input.resetConversation && { reset_conversation: true }), ...(Object.keys(options).length > 0 && { options }), @@ -783,6 +829,7 @@ export class Tuteliq { ...(input.external_id && { external_id: input.external_id }), ...(input.customer_id && { customer_id: input.customer_id }), ...(input.metadata && { metadata: input.metadata }), + ...(input.incident_moderation_enabled !== undefined && { incident_moderation_enabled: input.incident_moderation_enabled }), ...(Object.keys(options).length > 0 && { options }), } ); @@ -826,6 +873,11 @@ export class Tuteliq { external_id: input.external_id, customer_id: input.customer_id, metadata: input.metadata, + // Forwarded, not just echoed. `analyze()` used to accept this + // (it is on the shared TrackingFields), drop it on the way out, + // 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, })); } @@ -838,6 +890,7 @@ export class Tuteliq { external_id: input.external_id, customer_id: input.customer_id, metadata: input.metadata, + incident_moderation_enabled: input.incident_moderation_enabled, })); } @@ -904,6 +957,7 @@ export class Tuteliq { ...(input.external_id && { external_id: input.external_id }), ...(input.customer_id && { customer_id: input.customer_id }), ...(input.metadata && { metadata: input.metadata }), + ...(input.incident_moderation_enabled !== undefined && { incident_moderation_enabled: input.incident_moderation_enabled }), }; } @@ -1000,6 +1054,7 @@ export class Tuteliq { ...(input.external_id && { external_id: input.external_id }), ...(input.customer_id && { customer_id: input.customer_id }), ...(input.metadata && { metadata: input.metadata }), + ...(input.incident_moderation_enabled !== undefined && { incident_moderation_enabled: input.incident_moderation_enabled }), } ); } @@ -1044,6 +1099,7 @@ export class Tuteliq { ...(input.external_id && { external_id: input.external_id }), ...(input.customer_id && { customer_id: input.customer_id }), ...(input.metadata && { metadata: input.metadata }), + ...(input.incident_moderation_enabled !== undefined && { incident_moderation_enabled: input.incident_moderation_enabled }), } ); } @@ -1298,15 +1354,62 @@ export class Tuteliq { // Batch Methods // ========================================================================= + /** + * Build the `data` payload for one batch item. + * + * POST /api/v1/batch/analyze takes `{ id, type, data }` and reads the + * per-type payload out of `data` — `data.text` for the single-text + * endpoints, `data.messages` for grooming and emotions, each with its own + * per-message field names. This used to send `{ type, text, context }` at + * the item's top level, which the route's schema rejected outright + * ("must have required property 'id'"). + */ + private buildBatchItemData(item: BatchItem): Record { + if (item.type === 'grooming') { + return { + messages: item.messages.map(m => ({ + sender_role: m.role, + text: m.content, + })), + context: { + ...(item.childAge != null && { child_age: item.childAge }), + ...this.normalizeContext(item.context), + }, + }; + } + + if (item.type === 'emotions') { + // The emotions endpoint is message-based and uses `sender`, not + // `sender_role`. A bare `content` is a one-message conversation. + const messages = item.messages?.length + ? item.messages.map(m => ({ sender: m.sender, text: m.content })) + : [{ sender: 'user', text: item.content ?? '' }]; + return { + messages, + context: this.normalizeContext(item.context), + }; + } + + return { + text: item.content, + context: this.normalizeContext(item.context), + }; + } + /** * Analyze multiple items in a single batch request * + * Each item may carry an `id`; when omitted the SDK generates `item-N` from + * the item's position. `id` addresses the item within this request and is + * echoed on the matching result — it is not `external_id`, which is your + * own record's identifier and is returned alongside it. + * * @example * ```typescript * const result = await tuteliq.batch({ * items: [ - * { type: 'bullying', content: 'Message 1' }, - * { type: 'unsafe', content: 'Message 2' }, + * { id: 'msg-1', type: 'bullying', content: 'Message 1' }, + * { id: 'msg-2', type: 'unsafe', content: 'Message 2' }, * ], * parallel: true * }) @@ -1323,38 +1426,60 @@ export class Tuteliq { throw new ValidationError('Maximum 50 items per batch request'); } - return this.requestWithRetry( + const ids = input.items.map((item, i) => item.id ?? `item-${i}`); + + // First occurrence wins, so a caller who reuses an id still gets a + // stable mapping instead of an undefined index. + const indexById = new Map(); + ids.forEach((id, i) => { + if (!indexById.has(id)) indexById.set(id, i); + }); + + const raw = await this.requestWithRetry( 'POST', '/api/v1/batch/analyze', { - items: input.items.map(item => { - if (item.type === 'grooming') { - return { - type: item.type, - messages: item.messages.map(m => ({ - sender_role: m.role, - text: m.content, - })), - context: { - ...(item.childAge != null && { child_age: item.childAge }), - ...this.normalizeContext(item.context), - }, - external_id: item.external_id, - }; - } - return { - type: item.type, - text: item.content, - context: this.normalizeContext(item.context), - external_id: item.external_id, - }; - }), - options: { - parallel: input.parallel ?? true, - continue_on_error: input.continueOnError ?? true, - }, + items: input.items.map((item, i) => ({ + id: ids[i], + type: item.type, + data: this.buildBatchItemData(item), + })), + // The route reads `parallel` off the request body, not off an + // `options` object — nesting it meant it was always defaulted. + parallel: input.parallel ?? true, } ); + + // The API keys results by `id` and does not echo `external_id`. Restore + // both the caller's positional `index` and their `external_id` here so + // a result can be tied back to the item that produced it. + const results: BatchResultItem[] = (raw.results ?? []).map((r, position) => { + const index = indexById.get(r.id) ?? position; + const item = input.items[index]; + return { + index, + id: r.id, + type: r.type, + success: r.success, + ...(r.result !== undefined && { result: r.result }), + ...(r.error !== undefined && { error: r.error }), + ...(r.credits_used !== undefined && { credits_used: r.credits_used }), + ...(item?.external_id !== undefined && { external_id: item.external_id }), + }; + }); + + return { + results, + summary: { + total: raw.summary?.total ?? results.length, + successful: raw.summary?.successful ?? results.filter(r => r.success).length, + failed: raw.summary?.failed ?? results.filter(r => !r.success).length, + ...(raw.summary?.total_credits_used !== undefined && { + total_credits_used: raw.summary.total_credits_used, + }), + }, + processing_time_ms: raw.summary?.processingTimeMs ?? 0, + }; } // ========================================================================= @@ -1916,6 +2041,7 @@ export class Tuteliq { ...(input.external_id && { external_id: input.external_id }), ...(input.customer_id && { customer_id: input.customer_id }), ...(input.metadata && { metadata: input.metadata }), + ...(input.incident_moderation_enabled !== undefined && { incident_moderation_enabled: input.incident_moderation_enabled }), } ); } @@ -2303,6 +2429,7 @@ export class Tuteliq { ...(input.external_id && { external_id: input.external_id }), ...(input.customer_id && { customer_id: input.customer_id }), ...(input.metadata && { metadata: input.metadata }), + ...(input.incident_moderation_enabled !== undefined && { incident_moderation_enabled: input.incident_moderation_enabled }), ...(input.bypassCache && { bypass_cache: true }), ...(Object.keys(options).length > 0 && { options }), } @@ -2531,8 +2658,10 @@ export class Tuteliq { const response = await this.requestWithRetry<{ session_id: string; mobile_url: string; - expires_at: string; + expires_at: number; mode: VerificationSession['mode']; + verification_mode?: string; + recommended_image_width?: number; }>( 'POST', '/api/v1/verify/session', @@ -2546,11 +2675,17 @@ export class Tuteliq { } ); + // `mobile_url` is renamed to `url`; everything else is passed straight + // through. `recommended_image_width` in particular used to be dropped + // here, which left callers guessing at a capture resolution the API had + // already told them. return { session_id: response.session_id, url: response.mobile_url, expires_at: response.expires_at, mode: response.mode, + ...(response.verification_mode !== undefined && { verification_mode: response.verification_mode }), + ...(response.recommended_image_width !== undefined && { recommended_image_width: response.recommended_image_width }), }; } diff --git a/src/index.ts b/src/index.ts index f570326..9e8d392 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,6 +47,7 @@ export type { // Safety types RecommendedAction, + ConversationTrajectory, ContextInput, DetectBullyingInput, BullyingResult, diff --git a/src/types/detection.ts b/src/types/detection.ts index 75adf7f..893f8bd 100644 --- a/src/types/detection.ts +++ b/src/types/detection.ts @@ -1,5 +1,5 @@ import { TrackingFields } from './index.js'; -import { ContextInput, RecommendedAction } from './safety.js'; +import { ContextInput, ConversationTrajectory, RecommendedAction, SupportData } from './safety.js'; import { LanguageStatus } from '../constants.js'; export { LanguageStatus }; @@ -19,17 +19,35 @@ export interface DetectionInput extends TrackingFields { content: string; /** Context for better analysis */ context?: ContextInput; - /** Include evidence excerpts in the response */ + /** + * Include evidence excerpts in the response. Default: true. Explicitly + * setting this always wins over the inference `verdictOnly` makes below — + * pass `includeEvidence: true` alongside `verdictOnly: true` if you want + * fast mode's other savings without losing evidence. + */ includeEvidence?: boolean; /** Minimum severity to show crisis support resources (default: 'high'). Critical always shows. */ supportThreshold?: 'low' | 'medium' | 'high' | 'critical'; /** - * Fast mode. When true, the response omits the per-message - * `message_analysis` breakdown and returns only the verdict (level, - * categories, recommended action). Lower latency and a smaller payload for - * real-time screening; the verdict itself is unchanged. + * Fast mode. When true and `includeEvidence` isn't explicitly set, this + * also implies `includeEvidence: false` — `evidence` (with quoted + * excerpts from the input) is the expensive field on these endpoints, + * the equivalent of `action_detail` on the safety endpoints. The response + * also omits the per-message `message_analysis` breakdown. `rationale` + * and the verdict itself (level, categories, recommended action) are + * always generated regardless of this flag. */ verdictOnly?: boolean; + /** + * Opaque signed token returned by a previous call to the same endpoint. + * Pass it back to continue the analysis with prior trajectory state; no + * message content is stored server-side. The endpoints that maintain + * conversation state (coercive-control, vulnerability-exploitation, + * distress-signals) return a fresh `continuation_token` on every result. + */ + continuationToken?: string; + /** Discard any `continuationToken` and treat this call as a fresh conversation. */ + resetConversation?: boolean; } /** @@ -100,7 +118,11 @@ export interface DetectionResult { level: 'none' | 'low' | 'medium' | 'high' | 'critical'; /** Detected categories */ categories: DetectionCategory[]; - /** Evidence excerpts (if include_evidence was true) */ + /** + * Evidence excerpts, with quoted spans from the input. Omitted when + * `includeEvidence` is false, including implicitly via `verdictOnly` + * (see `DetectionInput.verdictOnly`). + */ evidence?: DetectionEvidence[]; /** Age calibration details */ age_calibration?: AgeCalibration; @@ -115,9 +137,14 @@ export interface DetectionResult { * a moderator UI. Free text: do not branch on it. */ action_detail?: string; - /** Explanation of the analysis */ - rationale: string; - /** Per-message analysis (conversation-aware endpoints) */ + /** + * Explanation of the analysis — this is what a moderator reads to triage + * the incident, and always generated, regardless of `verdictOnly` or + * `includeEvidence`. + */ + rationale?: string; + /** Per-message analysis (conversation-aware endpoints). Omitted when + * `verdictOnly` is set. */ message_analysis?: MessageAnalysis[]; /** Language code used for analysis */ language: string; @@ -133,6 +160,52 @@ export interface DetectionResult { customer_id?: string; /** Echo of provided metadata */ metadata?: Record; + /** + * Opaque signed token carrying derived trajectory state to the next call. + * Returned by the conversation-aware endpoints (coercive-control, + * vulnerability-exploitation, distress-signals); pass it back as + * `continuationToken` to keep multi-turn awareness with no content stored + * server-side. + */ + continuation_token?: string; + /** ISO 8601 expiry timestamp of the continuation_token. */ + continuation_expires_at?: string; + /** + * How prior state was sourced: "token" (decoded from a continuation_token), + * "fresh" (no prior state), "reset" (reset_conversation forced a restart). + */ + state_source?: 'token' | 'fresh' | 'reset'; + /** + * Conversation-level risk (0-1). Distinct from `risk_score`, which scores + * only the content in this request. + * + * Returned by the same endpoints that issue a `continuation_token` + * (coercive-control, vulnerability-exploitation, distress-signals) once + * there is more than one turn to reason across. Anchored on the highest + * severity seen so far, decaying slowly across benign turns and never + * falling below the current turn, so a calm message after an escalation + * does not reset the picture. Derived from the signed token: no message + * content is stored to produce it. + * + * Absent on the first turn, and on endpoints that do not track state. + */ + trajectory_risk?: number; + /** + * Direction of travel across the conversation so far. Absent on the first + * turn, alongside `trajectory_risk`. + */ + trajectory?: ConversationTrajectory; + /** + * Per-turn severity, oldest first — the evidence behind `trajectory_risk`, + * so a moderator can see why a benign-looking turn carries elevated + * conversation risk. + */ + severity_series?: number[]; + /** + * Crisis support resources, present only when the result meets the + * request's `supportThreshold`. Localised to `context.country`. + */ + support?: SupportData; } // ============================================================================= diff --git a/src/types/index.ts b/src/types/index.ts index f3c0af0..6eb4b94 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -43,6 +43,14 @@ export interface TrackingFields { * Stored with detection results and included in webhooks. */ metadata?: Record; + /** + * Per-call override of your account's incident logging setting. + * When set, it takes precedence over the account-level flag for THIS request: + * `true` forces the incident to be persisted, `false` suppresses persistence. + * Omit to use your account default (which itself defaults to enabled). + * Useful to suppress logging for test traffic or to opt specific calls in or out. + */ + incident_moderation_enabled?: boolean; } export interface ApiError { @@ -188,13 +196,48 @@ export interface UsageMonthlyResult { // Batch Types // ============================================================================= +/** + * Analysis types accepted by POST /api/v1/batch/analyze. + * + * All of them take a single `text` except `grooming` and `emotions`, which + * take a message array (with different per-message field names — see + * `BatchGroomingItem` / `BatchEmotionsItem`). + */ +export const BATCH_TEXT_TYPES = [ + 'bullying', + 'unsafe', + 'social_engineering', + 'app_fraud', + 'romance_scam', + 'mule_recruitment', + 'gambling_harm', + 'coercive_control', + 'vulnerability_exploitation', + 'radicalisation', +] as const; + +export type BatchTextType = (typeof BATCH_TEXT_TYPES)[number]; + +export type BatchAnalysisType = BatchTextType | 'grooming' | 'emotions'; + export interface BatchItemBase { + /** + * Your identifier for this item. The API requires one per item; when it is + * omitted the SDK generates a positional id (`item-0`, `item-1`, …) so the + * request stays valid. It is what the API echoes back on each result. + * + * This is NOT `external_id`: `id` addresses the item inside this one batch + * request, `external_id` is your own record's identifier for correlation + * and is echoed back untouched. + */ + id?: string; /** Optional context - string shorthand or detailed object */ context?: string | { language?: string; ageGroup?: string; relationship?: string; platform?: string; + country?: string; }; /** Optional external ID for correlation */ external_id?: string; @@ -202,7 +245,7 @@ export interface BatchItemBase { export interface BatchTextItem extends BatchItemBase { /** Analysis type to perform */ - type: 'bullying' | 'unsafe' | 'emotions'; + type: BatchTextType; /** Content to analyze */ content: string; } @@ -216,7 +259,19 @@ export interface BatchGroomingItem extends BatchItemBase { childAge?: number; } -export type BatchItem = BatchTextItem | BatchGroomingItem; +export interface BatchEmotionsItem extends BatchItemBase { + /** Emotion analysis type */ + type: 'emotions'; + /** + * Conversation to analyze. The emotions endpoint is message-based; pass + * `content` instead for a single message and the SDK wraps it. + */ + messages?: Array<{ sender: string; content: string }>; + /** Single message to analyze, wrapped into a one-message conversation. */ + content?: string; +} + +export type BatchItem = BatchTextItem | BatchGroomingItem | BatchEmotionsItem; export interface BatchAnalyzeInput { /** Items to analyze (max 50) */ @@ -230,12 +285,18 @@ export interface BatchAnalyzeInput { export interface BatchResultItem { /** Index of the item in the original array */ index: number; + /** The item id (yours if you supplied one, otherwise the generated `item-N`) */ + id: string; + /** Analysis type that was run */ + type: BatchAnalysisType; /** Whether analysis succeeded */ success: boolean; /** Analysis result (if successful) */ result?: unknown; /** Error message (if failed) */ error?: string; + /** Credits consumed by this item (if successful) */ + credits_used?: number; /** External ID (if provided) */ external_id?: string; } @@ -248,6 +309,8 @@ export interface BatchAnalyzeResult { total: number; successful: number; failed: number; + /** Total credits consumed across all items */ + total_credits_used?: number; }; /** Total processing time in ms */ processing_time_ms: number; diff --git a/src/types/safety.ts b/src/types/safety.ts index bda1c8e..c75baa7 100644 --- a/src/types/safety.ts +++ b/src/types/safety.ts @@ -28,6 +28,20 @@ export type RecommendedAction = | 'block' | 'immediate_intervention'; +/** + * Direction of travel across a conversation, reported alongside + * `trajectory_risk` by the endpoints that maintain continuation state. + * + * - `rising` — severity is trending upward across turns + * - `stable` — severity is holding + * - `declining` — severity is trending downward + * - `none` — not enough signal to call a direction + * + * `declining` is not the same as safe: `trajectory_risk` stays anchored on the + * worst turn seen and decays only slowly, which is the point. + */ +export type ConversationTrajectory = 'rising' | 'stable' | 'declining' | 'none'; + /** Weakest to strongest. Used to compare and combine actions. */ const ACTION_RANK: Record = { none: 0, @@ -102,6 +116,60 @@ export type ContextInput = string | { country?: string; }; +// ============================================================================= +// Crisis Support +// ============================================================================= + +/** A single crisis helpline entry. */ +export interface SupportHelpline { + /** Organisation name */ + name: string; + /** Phone number or short code, as dialled locally */ + number: string; + /** What the line covers */ + description?: string; + /** + * Coarse topic of the line, e.g. `childProtection`, `mentalHealth`, + * `domesticViolence`, `fraudPrevention`, `gambling`, `crisis`, `general`. + * Use it to tell a topical line apart from a general one. + */ + category?: string; + /** Opening hours, e.g. "24/7" */ + available?: string; +} + +/** Guidance shown alongside the helplines. */ +export interface SupportResponseGuide { + category?: string; + immediateActions: string[]; + childSpecificActions?: string[]; + resources: Array<{ name: string; description?: string; url?: string }>; + confidential?: boolean; + language?: string; +} + +/** + * Crisis support block attached to a positive detection once the result meets + * the request's `supportThreshold`. Localised to `context.country` when one is + * supplied, otherwise to the account's country, otherwise inferred from the + * detected language. + */ +export interface SupportData { + /** ISO 3166-1 alpha-2 code the helplines were localised for */ + country?: string; + /** Display name of that country */ + country_name?: string; + /** Local emergency number */ + emergency_number?: string; + helplines: SupportHelpline[]; + /** Highest-priority guide */ + response_guide?: SupportResponseGuide; + /** All matching guides, highest priority first */ + response_guides?: SupportResponseGuide[]; + /** BCP-47 language of the guide content */ + language?: string; +} + // ============================================================================= // Bullying Detection // ============================================================================= @@ -145,7 +213,10 @@ export interface BullyingResult { confidence: number; /** Severity of the bullying */ severity: Severity; - /** Explanation of the analysis. Omitted when `verdictOnly` was set. */ + /** + * Explanation of the analysis — this is what a moderator reads to triage + * the incident, and always generated, including when `verdictOnly` is set. + */ rationale?: string; /** * Recommended action, as a stable enum. Branch on this (or `isActionable`) @@ -154,7 +225,8 @@ export interface BullyingResult { recommended_action: RecommendedAction; /** * Optional human-readable expansion of `recommended_action`, for display in - * a moderator UI. Free text: do not branch on it. + * a moderator UI. Free text: do not branch on it. Omitted when + * `verdictOnly` is set — this is the field fast mode cuts, not `rationale`. */ action_detail?: string; /** Risk score (0-1) */ @@ -179,6 +251,45 @@ export interface BullyingResult { continuation_token?: string; /** ISO 8601 expiry timestamp of the continuation_token. */ continuation_expires_at?: string; + /** + * How prior state was sourced: "token" (decoded from a continuation_token), + * "fresh" (no prior state), "reset" (reset_conversation forced a restart). + */ + state_source?: 'token' | 'fresh' | 'reset'; + /** + * Conversation-level risk (0-1). Distinct from `risk_score`, which scores + * only the message in this request. + * + * `risk_score` answers "should I action this message"; `trajectory_risk` + * answers "is this conversation going badly". They diverge exactly where it + * matters: a "see you tomorrow :)" sent straight after two flagged turns + * scores near zero on its own and 0.74 as a conversation. Branch on the + * higher of the two, not on `risk_score` alone. + * + * Anchored on the highest severity seen so far, decaying slowly across + * benign turns and never falling below the current turn. Derived from the + * signed `continuation_token`: no message content is stored to produce it. + * + * Absent on the first turn of a fresh conversation, where it would only + * restate `risk_score`. + */ + trajectory_risk?: number; + /** + * Direction of travel across the conversation so far. Absent on the first + * turn, alongside `trajectory_risk`. + */ + trajectory?: ConversationTrajectory; + /** + * Per-turn severity, oldest first — the evidence behind `trajectory_risk`. + * Render it rather than asserting the number: it is what shows a moderator + * why a benign-looking message arrived with elevated conversation risk. + */ + severity_series?: number[]; + /** + * Crisis support resources, present only when the result meets the + * request's `supportThreshold`. Localised to `context.country`. + */ + support?: SupportData; } // ============================================================================= @@ -246,8 +357,11 @@ export interface GroomingResult { confidence: number; /** Grooming indicators/flags detected */ flags: string[]; - /** Explanation of the analysis. Omitted when `verdictOnly` was set. */ - rationale?: string; + /** + * Explanation of the analysis — this is what a moderator reads to triage + * the incident, and always generated, including when `verdictOnly` is set. + */ + rationale: string; /** Risk score (0-1) */ risk_score: number; /** @@ -257,10 +371,12 @@ export interface GroomingResult { recommended_action: RecommendedAction; /** * Optional human-readable expansion of `recommended_action`, for display in - * a moderator UI. Free text: do not branch on it. + * a moderator UI. Free text: do not branch on it. Omitted when + * `verdictOnly` is set. */ action_detail?: string; - /** Per-message analysis (conversation-aware endpoints) */ + /** Per-message analysis (conversation-aware endpoints). Omitted when + * `verdictOnly` is set. */ message_analysis?: MessageAnalysis[]; /** Language code used for analysis */ language?: string; @@ -282,6 +398,43 @@ export interface GroomingResult { continuation_token?: string; /** ISO 8601 expiry timestamp of the continuation_token. */ continuation_expires_at?: string; + /** + * How prior state was sourced: "token" (decoded from a continuation_token), + * "fresh" (no prior state), "reset" (reset_conversation forced a restart). + */ + state_source?: 'token' | 'fresh' | 'reset'; + /** + * Conversation-level risk (0-1) across every window seen so far. Distinct + * from `risk_score`, which scores only the messages in this request. + * + * Grooming is a slow burn, so a chunked conversation whose current window + * reads benign can still be at high conversation risk. Branch on the higher + * of the two, not on `risk_score` alone. + * + * Anchored on the highest severity seen so far, decaying slowly across + * benign windows and never falling below the current one. Derived from the + * signed `continuation_token`: no message content is stored to produce it. + * + * Absent on the first call of a fresh conversation. + */ + trajectory_risk?: number; + /** + * Direction of travel across the conversation so far. Absent on the first + * call, alongside `trajectory_risk`. + */ + trajectory?: ConversationTrajectory; + /** + * Per-window severity, oldest first — the evidence behind + * `trajectory_risk`. Render it rather than asserting the number: it is what + * shows a moderator why a benign-looking window carries elevated + * conversation risk. + */ + severity_series?: number[]; + /** + * Crisis support resources, present only when the result meets the + * request's `supportThreshold`. Localised to `context.country`. + */ + support?: SupportData; } // ============================================================================= @@ -316,7 +469,10 @@ export interface UnsafeResult { risk_score: number; /** Risk level derived from risk_score */ risk_level?: 'none' | 'low' | 'medium' | 'high' | 'critical'; - /** Explanation of the analysis. Omitted when `verdictOnly` was set. */ + /** + * Explanation of the analysis — this is what a moderator reads to triage + * the incident, and always generated, including when `verdictOnly` is set. + */ rationale?: string; /** * Recommended action, as a stable enum. Branch on this (or `isActionable`) @@ -325,7 +481,8 @@ export interface UnsafeResult { recommended_action: RecommendedAction; /** * Optional human-readable expansion of `recommended_action`, for display in - * a moderator UI. Free text: do not branch on it. + * a moderator UI. Free text: do not branch on it. Omitted when + * `verdictOnly` is set — this is the field fast mode cuts, not `rationale`. */ action_detail?: string; /** Language code used for analysis */ @@ -340,6 +497,11 @@ export interface UnsafeResult { customer_id?: string; /** Echo of provided metadata (if any) */ metadata?: Record; + /** + * Crisis support resources, present only when the result meets the + * request's `supportThreshold`. Localised to `context.country`. + */ + support?: SupportData; } // ============================================================================= @@ -355,10 +517,12 @@ export interface AnalyzeInput extends TrackingFields { include?: Array<'bullying' | 'unsafe'>; /** * Fast mode, forwarded to each detector this call fans out to. The - * bullying and unsafe endpoints skip generating `rationale` — the only - * free-text field either produces — which cuts response size and latency. - * The verdict (severity, categories, recommended_action, risk_score) is - * unchanged, as is the combined `risk_level` this method derives. + * bullying and unsafe endpoints skip generating `action_detail` — the + * moderator-guidance expansion of `recommended_action` — which cuts + * response size and latency. `rationale`, the field a moderator actually + * reads to triage an incident, is always generated regardless of this + * flag. The verdict (severity, categories, recommended_action, risk_score) + * is unchanged, as is the combined `risk_level` this method derives. * * Because the sub-calls run in parallel, the saving here is the difference * on the slower detector rather than the full per-call saving. @@ -389,6 +553,12 @@ export interface AnalyzeResult { customer_id?: string; /** Echo of provided metadata (if any) */ metadata?: Record; + /** + * Echo of provided incident_moderation_enabled (if any). The method emitted + * this before it forwarded the flag to the detectors it fans out to; it is + * now genuinely applied to both sub-calls. + */ + incident_moderation_enabled?: boolean; } // Legacy type aliases for backwards compatibility diff --git a/src/types/verification.ts b/src/types/verification.ts index e845d6b..ad21725 100644 --- a/src/types/verification.ts +++ b/src/types/verification.ts @@ -42,10 +42,19 @@ export interface VerificationSession { session_id: string; /** URL to open in a new tab or web view for the user to complete verification */ url: string; - /** ISO timestamp when the session expires */ - expires_at: string; + /** Epoch milliseconds at which the session expires */ + expires_at: number; /** Verification mode */ mode: VerificationMode; + /** Which checks the session will run (e.g. "document_and_selfie") */ + verification_mode?: string; + /** + * Width, in pixels, the capture UI should request from the camera. + * Small print (document number, issuing authority, issue date) only + * survives at this resolution, so pass it through to the capture step + * rather than picking your own. + */ + recommended_image_width?: number; } export interface VerificationSessionResult { diff --git a/tests/batch.test.ts b/tests/batch.test.ts new file mode 100644 index 0000000..75869b9 --- /dev/null +++ b/tests/batch.test.ts @@ -0,0 +1,336 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Tuteliq } from '../src/client.js'; +import { ValidationError } from '../src/errors.js'; + +function mockFetchResponse(data: unknown, options: { ok?: boolean; status?: number } = {}) { + const { ok = true, status = 200 } = options; + return { + ok, + status, + json: async () => data, + headers: { get: () => null }, + } as unknown as Response; +} + +/** The shape POST /api/v1/batch/analyze actually returns. */ +function apiBatchResponse(items: Array<{ id: string; type: string; success?: boolean }>) { + return { + results: items.map(i => ({ + id: i.id, + type: i.type, + success: i.success ?? true, + ...(i.success === false + ? { error: 'analysis failed', error_code: 'SVC_4001' } + : { result: { severity: 'low' }, credits_used: 2 }), + })), + summary: { + total: items.length, + successful: items.filter(i => i.success !== false).length, + failed: items.filter(i => i.success === false).length, + processingTimeMs: 412, + total_credits_used: 2 * items.filter(i => i.success !== false).length, + }, + }; +} + +function bodyOf(fetchSpy: ReturnType): any { + return JSON.parse((fetchSpy.mock.calls[0][1] as RequestInit).body as string); +} + +describe('Tuteliq.batch', () => { + let client: Tuteliq; + + beforeEach(() => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ------------------------------------------------------------------- + // Request shape — the API requires { id, type, data } + // ------------------------------------------------------------------- + + it('sends every item as { id, type, data }', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse(apiBatchResponse([{ id: 'item-0', type: 'bullying' }])), + ); + + await client.batch({ items: [{ type: 'bullying', content: 'you are a loser' }] }); + + const body = bodyOf(fetchSpy); + expect(Object.keys(body.items[0]).sort()).toEqual(['data', 'id', 'type']); + expect(body.items[0].data.text).toBe('you are a loser'); + // The old shape put `text` on the item itself and sent no id at all, + // which the route rejected outright. + expect(body.items[0].content).toBeUndefined(); + expect(body.items[0].text).toBeUndefined(); + }); + + it('generates a positional id when the caller does not supply one', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse(apiBatchResponse([ + { id: 'item-0', type: 'bullying' }, + { id: 'item-1', type: 'unsafe' }, + ])), + ); + + await client.batch({ + items: [ + { type: 'bullying', content: 'a' }, + { type: 'unsafe', content: 'b' }, + ], + }); + + expect(bodyOf(fetchSpy).items.map((i: any) => i.id)).toEqual(['item-0', 'item-1']); + }); + + it('uses the caller id when supplied and keeps external_id separate', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse(apiBatchResponse([{ id: 'msg-42', type: 'bullying' }])), + ); + + const result = await client.batch({ + items: [{ id: 'msg-42', type: 'bullying', content: 'a', external_id: 'crm-999' }], + }); + + const body = bodyOf(fetchSpy); + expect(body.items[0].id).toBe('msg-42'); + // external_id is the caller's own record id; it is not the batch id and + // is not sent as one. + expect(body.items[0].external_id).toBeUndefined(); + expect(result.results[0].id).toBe('msg-42'); + expect(result.results[0].external_id).toBe('crm-999'); + }); + + it('sends parallel at the top level, not nested under options', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse(apiBatchResponse([{ id: 'item-0', type: 'bullying' }])), + ); + + await client.batch({ items: [{ type: 'bullying', content: 'a' }], parallel: false }); + + const body = bodyOf(fetchSpy); + expect(body.parallel).toBe(false); + expect(body.options).toBeUndefined(); + }); + + // ------------------------------------------------------------------- + // Per-type payloads + // ------------------------------------------------------------------- + + it('maps grooming messages to sender_role/text under data', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse(apiBatchResponse([{ id: 'item-0', type: 'grooming' }])), + ); + + await client.batch({ + items: [{ + type: 'grooming', + childAge: 12, + messages: [ + { role: 'adult', content: 'what school do you go to?' }, + { role: 'child', content: 'why?' }, + ], + }], + }); + + const data = bodyOf(fetchSpy).items[0].data; + expect(data.messages).toEqual([ + { sender_role: 'adult', text: 'what school do you go to?' }, + { sender_role: 'child', text: 'why?' }, + ]); + expect(data.context.child_age).toBe(12); + }); + + it('maps emotions messages to sender/text, not sender_role', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse(apiBatchResponse([{ id: 'item-0', type: 'emotions' }])), + ); + + await client.batch({ + items: [{ type: 'emotions', messages: [{ sender: 'alex', content: 'i feel awful' }] }], + }); + + expect(bodyOf(fetchSpy).items[0].data.messages).toEqual([ + { sender: 'alex', text: 'i feel awful' }, + ]); + }); + + it('wraps a bare emotions content into a one-message conversation', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse(apiBatchResponse([{ id: 'item-0', type: 'emotions' }])), + ); + + await client.batch({ items: [{ type: 'emotions', content: 'i feel awful' }] }); + + // The endpoint is message-based and would otherwise see no messages. + expect(bodyOf(fetchSpy).items[0].data.messages).toEqual([ + { sender: 'user', text: 'i feel awful' }, + ]); + }); + + it('accepts the fraud and extended detection types', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse(apiBatchResponse([ + { id: 'item-0', type: 'romance_scam' }, + { id: 'item-1', type: 'radicalisation' }, + ])), + ); + + await client.batch({ + items: [ + { type: 'romance_scam', content: 'send me an itunes card my love' }, + { type: 'radicalisation', content: 'they are not like us' }, + ], + }); + + expect(bodyOf(fetchSpy).items.map((i: any) => i.type)).toEqual([ + 'romance_scam', + 'radicalisation', + ]); + }); + + // ------------------------------------------------------------------- + // Response normalisation + // ------------------------------------------------------------------- + + it('restores index, processing_time_ms and credits from the API response', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse(apiBatchResponse([ + { id: 'item-0', type: 'bullying' }, + { id: 'item-1', type: 'unsafe', success: false }, + ])), + ); + + const result = await client.batch({ + items: [ + { type: 'bullying', content: 'a' }, + { type: 'unsafe', content: 'b' }, + ], + }); + + expect(result.results.map(r => r.index)).toEqual([0, 1]); + expect(result.results[0].credits_used).toBe(2); + expect(result.results[1].success).toBe(false); + expect(result.results[1].error).toBe('analysis failed'); + // summary.processingTimeMs on the wire, processing_time_ms in the SDK. + expect(result.processing_time_ms).toBe(412); + expect(result.summary.total_credits_used).toBe(2); + }); + + it('maps results back by id even when the API reorders them', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse(apiBatchResponse([ + { id: 'b', type: 'unsafe' }, + { id: 'a', type: 'bullying' }, + ])), + ); + + const result = await client.batch({ + items: [ + { id: 'a', type: 'bullying', content: 'a', external_id: 'ext-a' }, + { id: 'b', type: 'unsafe', content: 'b', external_id: 'ext-b' }, + ], + }); + + expect(result.results.map(r => [r.id, r.index, r.external_id])).toEqual([ + ['b', 1, 'ext-b'], + ['a', 0, 'ext-a'], + ]); + }); + + // ------------------------------------------------------------------- + // Validation + // ------------------------------------------------------------------- + + it('rejects an empty batch', async () => { + await expect(client.batch({ items: [] })).rejects.toThrow(ValidationError); + }); + + it('rejects more than 50 items', async () => { + const items = Array.from({ length: 51 }, (_, i) => ({ + type: 'bullying' as const, + content: `msg ${i}`, + })); + await expect(client.batch({ items })).rejects.toThrow(ValidationError); + }); +}); + +describe('Tuteliq detection endpoints — continuation token', () => { + let client: Tuteliq; + + beforeEach(() => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('forwards continuationToken on the unified detection endpoints', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ detected: false, severity: 0.1, risk_score: 0.1, categories: [] }), + ); + + await client.detectCoerciveControl({ content: 'hello', continuationToken: 'tok-abc' }); + + const body = JSON.parse((fetchSpy.mock.calls[0][1] as RequestInit).body as string); + expect(body.continuation_token).toBe('tok-abc'); + }); + + it('forwards resetConversation', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ detected: false, severity: 0.1, risk_score: 0.1, categories: [] }), + ); + + await client.detectDistressSignals({ content: 'hello', resetConversation: true }); + + const body = JSON.parse((fetchSpy.mock.calls[0][1] as RequestInit).body as string); + expect(body.reset_conversation).toBe(true); + }); + + it('omits both when not supplied', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ detected: false, severity: 0.1, risk_score: 0.1, categories: [] }), + ); + + await client.detectAppFraud({ content: 'hello' }); + + const body = JSON.parse((fetchSpy.mock.calls[0][1] as RequestInit).body as string); + expect('continuation_token' in body).toBe(false); + expect('reset_conversation' in body).toBe(false); + }); +}); + +describe('Tuteliq.createVerificationSession', () => { + let client: Tuteliq; + + beforeEach(() => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('passes through recommended_image_width and verification_mode', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ + session_id: 'sess-1', + mobile_url: 'https://verify.tuteliq.ai/age/?session=sess-1&token=t', + expires_at: 1787253193747, + mode: 'age', + verification_mode: 'document_and_selfie', + recommended_image_width: 3264, + }), + ); + + const session = await client.createVerificationSession({ mode: 'age' }); + + expect(session.url).toContain('/age/?session=sess-1'); + expect(session.recommended_image_width).toBe(3264); + expect(session.verification_mode).toBe('document_and_selfie'); + }); +}); diff --git a/tests/trajectory.test.ts b/tests/trajectory.test.ts new file mode 100644 index 0000000..20daf79 --- /dev/null +++ b/tests/trajectory.test.ts @@ -0,0 +1,306 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Tuteliq } from '../src/client.js'; +import type { BullyingResult, GroomingResult, DetectionResult } from '../src/types/index.js'; + +/** + * Conversation-level fields (`trajectory_risk`, `trajectory`, `severity_series`). + * + * These arrive alongside `continuation_token` on the endpoints that maintain + * continuation state. Nothing in the client projects those responses down, so + * the risk is not that a field is transformed — it is that a future refactor + * introduces a projection and silently drops them. These tests assert the whole + * response survives the client, using the exact shape the reviewer's escalation + * produced: a benign final message (`risk_score` 0.10) inside a conversation the + * API scores at 0.74. + */ + +function mockFetchResponse(data: unknown, options: { ok?: boolean; status?: number } = {}) { + const { ok = true, status = 200 } = options; + return { + ok, + status, + json: async () => data, + headers: { get: () => null }, + } as unknown as Response; +} + +/** Turn 6 of the reviewer's six-turn bullying escalation: "see you tomorrow :)". */ +const benignTurnAfterEscalation = { + is_bullying: false, + bullying_type: [], + confidence: 0.91, + severity: 'low', + rationale: 'A friendly sign-off with no hostile content.', + recommended_action: 'monitor', + risk_score: 0.1, + language: 'en', + language_status: 'stable', + credits_used: 1, + continuation_token: 'eyJhbGciOiJIUzI1NiJ9.payload.sig', + continuation_expires_at: '2026-08-21T19:13:13.747Z', + state_source: 'token', + trajectory_risk: 0.74, + trajectory: 'rising', + severity_series: [0.05, 0.1, 0.65, 0.05, 0.75, 0.1], +}; + +describe('conversation-level fields on detectBullying', () => { + let client: Tuteliq; + + beforeEach(() => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('surfaces trajectory_risk, trajectory and severity_series', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse(benignTurnAfterEscalation), + ); + + const result = await client.detectBullying({ + content: 'see you tomorrow :)', + continuationToken: 'prior-token', + }); + + expect(result.trajectory_risk).toBe(0.74); + expect(result.trajectory).toBe('rising'); + expect(result.severity_series).toEqual([0.05, 0.1, 0.65, 0.05, 0.75, 0.1]); + }); + + it('keeps trajectory_risk distinct from risk_score', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse(benignTurnAfterEscalation), + ); + + const result = await client.detectBullying({ content: 'see you tomorrow :)' }); + + // The reported defect in one line: the message is benign, the + // conversation is not, and both numbers have to reach the caller. + expect(result.risk_score).toBe(0.1); + expect(result.trajectory_risk).toBeGreaterThan(result.risk_score); + }); + + it('omits them on the first turn of a fresh conversation', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ + ...benignTurnAfterEscalation, + state_source: 'fresh', + trajectory_risk: undefined, + trajectory: undefined, + severity_series: undefined, + }), + ); + + const result = await client.detectBullying({ content: 'hi' }); + + expect(result.trajectory_risk).toBeUndefined(); + expect(result.trajectory).toBeUndefined(); + expect(result.severity_series).toBeUndefined(); + // The token still comes back — only the conversation-level view waits + // for a second turn. + expect(result.continuation_token).toBe('eyJhbGciOiJIUzI1NiJ9.payload.sig'); + }); +}); + +describe('conversation-level fields on detectGrooming', () => { + let client: Tuteliq; + + beforeEach(() => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('survives the messages/context reshaping detectGrooming does on the way out', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ + grooming_risk: 'low', + confidence: 0.8, + flags: [], + risk_score: 0.12, + recommended_action: 'monitor', + continuation_token: 'tok', + continuation_expires_at: '2026-08-21T19:13:13.747Z', + state_source: 'token', + trajectory_risk: 0.68, + trajectory: 'stable', + severity_series: [0.7, 0.6, 0.12], + }), + ); + + const result = await client.detectGrooming({ + messages: [{ role: 'adult', content: 'how was school' }], + childAge: 12, + continuationToken: 'prior-token', + }); + + expect(result.trajectory_risk).toBe(0.68); + expect(result.trajectory).toBe('stable'); + expect(result.severity_series).toEqual([0.7, 0.6, 0.12]); + }); +}); + +describe('conversation-level fields on the unified detection endpoints', () => { + let client: Tuteliq; + + beforeEach(() => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('reaches the caller from detectCoerciveControl', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ + endpoint: 'coercive-control', + detected: false, + level: 'low', + confidence: 0.7, + risk_score: 0.09, + categories: [], + recommended_action: 'monitor', + language: 'en', + language_status: 'stable', + continuation_token: 'tok', + state_source: 'token', + trajectory_risk: 0.61, + trajectory: 'declining', + severity_series: [0.8, 0.4, 0.09], + }), + ); + + const result = await client.detectCoerciveControl({ + content: 'ok love you', + continuationToken: 'prior-token', + }); + + expect(result.trajectory_risk).toBe(0.61); + expect(result.trajectory).toBe('declining'); + expect(result.severity_series).toEqual([0.8, 0.4, 0.09]); + }); +}); + +describe('response projection', () => { + let client: Tuteliq; + + beforeEach(() => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('detectBullying returns the API body untouched, so no field can be dropped', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ ...benignTurnAfterEscalation, a_field_the_sdk_has_never_heard_of: 1 }), + ); + + const result = await client.detectBullying({ content: 'see you tomorrow :)' }) as Record; + + expect(result.a_field_the_sdk_has_never_heard_of).toBe(1); + }); + + it('analyze() nests the full bullying sub-result, trajectory included', async () => { + vi.spyOn(global, 'fetch') + .mockResolvedValueOnce(mockFetchResponse(benignTurnAfterEscalation)) + .mockResolvedValueOnce(mockFetchResponse({ + unsafe: false, + categories: [], + confidence: 0.9, + severity: 'low', + risk_score: 0.02, + recommended_action: 'none', + })); + + const result = await client.analyze({ content: 'see you tomorrow :)' }); + + expect(result.bullying?.trajectory_risk).toBe(0.74); + // Documented limitation: the combined top-level risk_score is the max of + // the per-message scores and does not consider trajectory, because + // analyze() has no way to accept a continuation_token in the first + // place. Read result.bullying.trajectory_risk, or call detectBullying. + expect(result.risk_score).toBe(0.1); + }); +}); + +describe('analyze() incident_moderation_enabled', () => { + let client: Tuteliq; + + beforeEach(() => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('forwards the flag to both detectors instead of only echoing it', async () => { + const fetchSpy = vi.spyOn(global, 'fetch') + .mockResolvedValue(mockFetchResponse(benignTurnAfterEscalation)); + + const result = await client.analyze({ + content: 'see you tomorrow :)', + incident_moderation_enabled: false, + }); + + // Previously: the result said `false` while both sub-calls went out + // without the flag, so incidents were persisted anyway. + for (const call of fetchSpy.mock.calls) { + const body = JSON.parse((call[1] as RequestInit).body as string); + expect(body.incident_moderation_enabled).toBe(false); + } + expect(result.incident_moderation_enabled).toBe(false); + }); +}); + +// Type-level assertions: these only need to compile. +describe('types', () => { + it('declares the fields on every result type that carries continuation_token', () => { + const bullying: BullyingResult = { + is_bullying: false, + bullying_type: [], + confidence: 0.9, + severity: 'low', + recommended_action: 'monitor', + risk_score: 0.1, + trajectory_risk: 0.74, + trajectory: 'rising', + severity_series: [0.05, 0.75, 0.1], + }; + const grooming: GroomingResult = { + grooming_risk: 'low', + confidence: 0.8, + flags: [], + risk_score: 0.12, + recommended_action: 'monitor', + trajectory_risk: 0.68, + trajectory: 'declining', + severity_series: [0.7, 0.12], + }; + const detection: DetectionResult = { + endpoint: 'coercive-control', + detected: false, + level: 'low', + confidence: 0.7, + risk_score: 0.09, + categories: [], + recommended_action: 'monitor', + language: 'en', + language_status: 'stable', + trajectory_risk: 0.61, + trajectory: 'none', + severity_series: [0.8, 0.09], + }; + + expect([bullying.trajectory, grooming.trajectory, detection.trajectory]) + .toEqual(['rising', 'declining', 'none']); + }); +}); diff --git a/tests/verdictOnlyIncludeEvidence.test.ts b/tests/verdictOnlyIncludeEvidence.test.ts new file mode 100644 index 0000000..d5e7e7f --- /dev/null +++ b/tests/verdictOnlyIncludeEvidence.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { Tuteliq } from '../src/client.js'; + +// --------------------------------------------------------------------------- +// buildDetectionBody's handling of includeEvidence/verdictOnly, on the +// unified detection endpoints (fraud + safety-extended). Two bugs found +// while auditing this after the API-side verdict_only/rationale fix +// (Tuteliq/api PR #109): +// +// 1. `if (input.includeEvidence) options.include_evidence = true` only +// handled the truthy case, so an explicit `includeEvidence: false` was +// silently dropped and never reached the API at all — the caller's +// choice to exclude evidence was ignored. +// 2. The SDK never wired `verdictOnly` to `includeEvidence` at all. The API +// now infers `include_evidence: false` from `verdict_only: true` when +// `include_evidence` isn't explicitly set, so the fix here is simply to +// NOT send an explicit `include_evidence` when the caller didn't ask for +// one — letting the server's inference apply — rather than duplicating +// that inference client-side. +// --------------------------------------------------------------------------- + +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); +} + +describe('buildDetectionBody — includeEvidence / verdictOnly forwarding', () => { + let client: Tuteliq; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('sends nothing for options when neither is set (server applies its own default)', async () => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ endpoint: 'romance-scam', detected: false, severity: 0, level: 'none', categories: [], recommended_action: 'none', rationale: '' }), + ); + await client.detectRomanceScam({ content: 'hello' }); + const body = bodySentIn(fetchSpy); + expect(body.options).toBeUndefined(); + expect(body.include_evidence).toBeUndefined(); + }); + + it('forwards includeEvidence: true explicitly', async () => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ endpoint: 'romance-scam', detected: false, severity: 0, level: 'none', categories: [], recommended_action: 'none', rationale: '' }), + ); + await client.detectRomanceScam({ content: 'hello', includeEvidence: true }); + const body = bodySentIn(fetchSpy); + expect((body.options as Record).include_evidence).toBe(true); + expect(body.include_evidence).toBe(true); + }); + + it('forwards includeEvidence: false explicitly (previously silently dropped)', async () => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ endpoint: 'romance-scam', detected: false, severity: 0, level: 'none', categories: [], recommended_action: 'none', rationale: '' }), + ); + await client.detectRomanceScam({ content: 'hello', includeEvidence: false }); + const body = bodySentIn(fetchSpy); + expect((body.options as Record).include_evidence).toBe(false); + expect(body.include_evidence).toBe(false); + }); + + it('forwards verdictOnly without forcing an explicit include_evidence, so the server can infer false', async () => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ endpoint: 'romance-scam', detected: false, severity: 0, level: 'none', categories: [], recommended_action: 'none', rationale: '' }), + ); + await client.detectRomanceScam({ content: 'hello', verdictOnly: true }); + const body = bodySentIn(fetchSpy); + expect((body.options as Record).verdict_only).toBe(true); + expect((body.options as Record).include_evidence).toBeUndefined(); + }); + + it('verdictOnly + explicit includeEvidence:true sends both, letting the server honour the explicit value', async () => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ endpoint: 'romance-scam', detected: false, severity: 0, level: 'none', categories: [], recommended_action: 'none', rationale: '' }), + ); + await client.detectRomanceScam({ content: 'hello', verdictOnly: true, includeEvidence: true }); + const body = bodySentIn(fetchSpy); + expect((body.options as Record).verdict_only).toBe(true); + expect((body.options as Record).include_evidence).toBe(true); + }); +});