From 047d8642fa547023f95fd899f591a482a0a2ab01 Mon Sep 17 00:00:00 2001 From: safenestdev Date: Sun, 8 Nov 2026 08:36:38 +0100 Subject: [PATCH 1/3] test(detection): reproduce the text detection request bugs Failing tests for the declared age on emotions, the action plan audience and reading level, the incident report meta, the multi-analysis flags, context translation and the batch error code. --- tests/textRequestBugs.test.ts | 156 ++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 tests/textRequestBugs.test.ts diff --git a/tests/textRequestBugs.test.ts b/tests/textRequestBugs.test.ts new file mode 100644 index 0000000..2935b5b --- /dev/null +++ b/tests/textRequestBugs.test.ts @@ -0,0 +1,156 @@ +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) { + return { + ok: true, + status: 200, + json: async () => data, + headers: { get: () => null }, + } as unknown as Response; +} + +function sentBody(spy: ReturnType, call = 0): Record { + return JSON.parse((spy.mock.calls[call][1] as RequestInit).body as string); +} + +/** + * Request bugs in the text detection methods: each of these sent a field the + * API does not read, or could not send one it does. + */ +describe('Text detection request bugs', () => { + let client: Tuteliq; + + beforeEach(() => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('analyzeEmotions', () => { + it('sends the declared age as context.age_group, the only key the route reads', async () => { + const spy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse({})); + await client.analyzeEmotions({ content: 'hello', context: { ageGroup: '11-13', platform: 'MyApp' } }); + expect(sentBody(spy).context).toEqual({ age_group: '11-13' }); + }); + + it('omits context when no age group is declared', async () => { + const spy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse({})); + await client.analyzeEmotions({ content: 'hello', context: 'MyApp' }); + expect(sentBody(spy).context).toBeUndefined(); + }); + + it('forwards incident_moderation_enabled', async () => { + const spy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse({})); + await client.analyzeEmotions({ content: 'hello', incident_moderation_enabled: false }); + expect(sentBody(spy).incident_moderation_enabled).toBe(false); + }); + + it('batch emotions items send context.age_group too', async () => { + const spy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse({ results: [] })); + await client.batch({ items: [{ type: 'emotions', content: 'hello', context: { ageGroup: '14-17' } }] }); + expect(sentBody(spy).items[0].data.context).toEqual({ age_group: '14-17' }); + }); + }); + + describe('getActionPlan', () => { + it('rejects an audience the API does not accept before sending', async () => { + const spy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse({})); + await expect( + client.getActionPlan({ situation: 'Someone is spreading rumours', audience: 'educator' as any }), + ).rejects.toThrow(ValidationError); + expect(spy).not.toHaveBeenCalled(); + }); + + it('returns approx_reading_level, and keeps reading_level populated from it', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ audience: 'parent', steps: ['a'], tone: 'calm', approx_reading_level: 'grade 6' }), + ); + const plan = await client.getActionPlan({ situation: 'Someone is spreading rumours' }); + expect(plan.approx_reading_level).toBe('grade 6'); + expect(plan.reading_level).toBe('grade 6'); + }); + }); + + describe('generateReport', () => { + it('sends conversation_id and timestamp_range in meta, and nothing the route drops', async () => { + const spy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse({})); + await client.generateReport({ + messages: [{ sender: 'a', content: 'hi' }], + childAge: 12, + conversationId: 'conv-1', + timestampRange: ['2026-11-01T10:00:00Z', '2026-11-01T11:00:00Z'], + incident: { type: 'bullying', notes: 'n' }, + }); + expect(sentBody(spy).meta).toEqual({ + child_age: 12, + conversation_id: 'conv-1', + timestamp_range: ['2026-11-01T10:00:00Z', '2026-11-01T11:00:00Z'], + }); + }); + }); + + describe('analyseMulti', () => { + it('forwards flagProfanity and flagRiskTerms as options', async () => { + const spy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse({ results: [], summary: {} })); + await client.analyseMulti({ + content: 'hello', + detections: ['bullying', 'unsafe'], + flagProfanity: true, + flagRiskTerms: false, + }); + expect(sentBody(spy).options).toEqual({ flag_profanity: true, flag_risk_terms: false }); + }); + + it('does not send verdict_only, which the multi route does not accept', async () => { + const spy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse({ results: [], summary: {} })); + await client.analyseMulti({ content: 'hello', detections: ['bullying'], verdictOnly: true }); + expect(sentBody(spy).options).toBeUndefined(); + }); + }); + + describe('context', () => { + it('translates the declared participants and setting to snake_case on the wire', async () => { + const spy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse({})); + await client.detectBullying({ + content: 'hello', + context: { + platform: 'MyApp', + sender: { role: 'adult', ageGroup: '25-34' }, + recipients: [{ role: 'child', ageGroup: '11-13', relationshipToSender: 'child_of_sender' }], + conversationType: 'family', + minorDisclosureCheck: true, + } as any, + }); + expect(sentBody(spy).context).toEqual({ + platform: 'MyApp - Node SDK', + sender: { role: 'adult', age_group: '25-34' }, + recipients: [{ role: 'child', age_group: '11-13', relationship_to_sender: 'child_of_sender' }], + conversation_type: 'family', + minor_disclosure_check: true, + }); + }); + + it('translates authorRole for distress signals', async () => { + const spy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse({})); + await client.detectDistressSignals({ content: 'hello', context: { authorRole: 'responder' } as any }); + expect(sentBody(spy).context).toEqual({ platform: 'Node SDK', author_role: 'responder' }); + }); + }); + + describe('batch', () => { + it('keeps the per-item error_code the API returns', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ + results: [{ id: 'item-0', type: 'bullying', success: false, error: 'Unsupported language', error_code: 'ANALYSIS_6009' }], + summary: { total: 1, successful: 0, failed: 1, processingTimeMs: 5 }, + }), + ); + const result = await client.batch({ items: [{ type: 'bullying', content: 'hello' }] }); + expect((result.results[0] as any).error_code).toBe('ANALYSIS_6009'); + }); + }); +}); From fc4c5d1997f54b8468e0088abd5b8d69f4af7605 Mon Sep 17 00:00:00 2001 From: safenestdev Date: Sun, 8 Nov 2026 08:36:38 +0100 Subject: [PATCH 2/3] feat(detection): bring text detection types up to date with the API - Context can declare the sender, the recipients and how they relate, the conversation type, the minor disclosure check and the distress author role; all are translated to the wire's snake_case. - attestation and messageId on every single-endpoint text detector, newConversation on detectUnsafe, includeRelationshipFrame on detectGrooming. - New detectNickname method. - Shared result fields (attestation, setting in effect, participants applied, minor disclosure) and typed result adjustment fields; per-endpoint result types for the unified detectors. - analyseMulti: summary status fields, support, flagProfanity and flagRiskTerms; verdictOnly is no longer sent. - Fixes for emotions age group, action plan audience and reading level, incident report meta and batch error codes. - Fields the API never returns are marked deprecated. Closes #57 --- README.md | 56 +++++- src/client.ts | 199 +++++++++++++++++---- src/constants.ts | 43 +++++ src/index.ts | 37 ++++ src/types/detection-fields.ts | 272 +++++++++++++++++++++++++++++ src/types/detection.ts | 295 ++++++++++++++++++++++++++++---- src/types/guidance.ts | 4 +- src/types/index.ts | 25 ++- src/types/reports.ts | 9 +- src/types/safety.ts | 146 ++++++++++++++-- src/types/settings.ts | 13 ++ tests/textDetectionSync.test.ts | 284 ++++++++++++++++++++++++++++++ 12 files changed, 1279 insertions(+), 104 deletions(-) create mode 100644 src/types/detection-fields.ts create mode 100644 tests/textDetectionSync.test.ts diff --git a/README.md b/README.md index 5eaa964..493a3c1 100644 --- a/README.md +++ b/README.md @@ -442,6 +442,44 @@ const result = await tuteliq.detectRadicalisation({ }) ``` +#### `detectNickname(input)` + +Reads a display name for concerns before it is shown to others. The result is a signal about the name, not a verdict on the person, and carries `recommendation` (`allow`, `allow_and_watch`, `ask_to_rename`, `hold_for_review`) rather than `recommended_action`. + +```typescript +const result = await tuteliq.detectNickname({ + name: 'xX_player_Xx', + strictness: 'balanced', + context: { ageGroup: '11-13', platformReserved: ['Support', 'Admin'] }, +}) + +if (!result.reading_complete) { + // Nothing was analysed and nothing charged: retry. +} else if (result.recommendation === 'hold_for_review') { + console.log(result.recommendation_detail) +} +``` + +#### Declaring the setting and participants + +Every text detector accepts the setting the text was written in and the people in the conversation, as your platform knows them. The SDK translates these to the API's field names. + +```typescript +const result = await tuteliq.detectBullying({ + content: 'message', + context: { + conversationType: 'gaming_chat', + sender: { role: 'teen', ageGroup: '14-17' }, + recipients: [{ role: 'teen', relationshipToSender: 'peer' }], + minorDisclosureCheck: true, + }, + attestation: true, + messageId: 'msg_123', +}) + +console.log(result.conversation_type_in_effect) // 'gaming_chat' +``` + --- ### Multi-Endpoint Analysis @@ -469,7 +507,8 @@ console.log(result.summary.detected_count) // 2 console.log(result.summary.highest_risk) // { endpoint: 'romance-scam', risk_score: 0.85 } console.log(result.summary.overall_risk_level) // 'high' console.log(result.cross_endpoint_modifier) // 1.3 (vulnerability modifier) -console.log(result.credits_used) // 3 +console.log(result.summary.total_credits_used) // 3 +console.log(result.summary.analysis_status) // 'complete' | 'partial' // Individual results for (const r of result.results) { @@ -492,6 +531,11 @@ for (const r of result.results) { | `coercive-control` | `detectCoerciveControl` | | `vulnerability-exploitation` | `detectVulnerabilityExploitation` | | `radicalisation` | `detectRadicalisation` | +| `distress-signals` | `detectDistressSignals` | +| `tfgbv` | `detectTFGBV` | +| `synthetic-content` | `detectSyntheticText` | + +When `summary.analysis_status` is `partial`, at least one detector did not answer: `summary.failed_endpoints` names them and `summary.recommended_action` asks for review. --- @@ -835,14 +879,14 @@ Generates age-appropriate action guidance. const plan = await tuteliq.getActionPlan({ situation: 'Someone is spreading rumors about me at school', childAge: 12, - audience: 'child', // 'child' | 'parent' | 'educator' | 'platform' + audience: 'child', // 'child' | 'parent' | 'platform' severity: 'medium' }) console.log(plan.audience) // 'child' console.log(plan.steps) // ['Talk to a trusted adult', ...] console.log(plan.tone) // 'supportive' -console.log(plan.reading_level) // 'grade_5' +console.log(plan.approx_reading_level) // 'grade_5' ``` #### `generateReport(input)` @@ -856,10 +900,8 @@ const report = await tuteliq.generateReport({ { sender: 'child', content: 'Please stop' } ], childAge: 14, - incident: { - type: 'harassment', - occurredAt: new Date() - } + conversationId: 'conv_123', + timestampRange: ['2026-11-01T10:00:00Z', '2026-11-01T10:45:00Z'] }) console.log(report.summary) // "Incident summary..." diff --git a/src/client.ts b/src/client.ts index e1451e1..a2967d7 100644 --- a/src/client.ts +++ b/src/client.ts @@ -75,9 +75,23 @@ import { UsageMonthlyResult, // Detection types DetectionInput, - DetectionResult, AnalyseMultiInput, AnalyseMultiResult, + SocialEngineeringResult, + AppFraudResult, + RomanceScamResult, + MuleRecruitmentResult, + GamblingHarmResult, + CoerciveControlResult, + VulnerabilityExploitationResult, + RadicalisationResult, + DistressSignalsResult, + TFGBVResult, + DetectNicknameInput, + NicknameResult, + DeclaredRecipient, + TextDetectionRequestFields, + Audience, // Video types AnalyzeVideoInput, VideoAnalysisResult, @@ -170,6 +184,8 @@ const DEFAULT_RETRY_DELAY = 1000; // Input limits to prevent abuse const MAX_CONTENT_LENGTH = 50000; // 50KB max content const MAX_MESSAGES_COUNT = 100; // Max messages per request +const MAX_NICKNAME_LENGTH = 64; // Max display name length for detectNickname +const ACTION_PLAN_AUDIENCES: readonly Audience[] = ['child', 'parent', 'platform']; /** * Tuteliq - AI-powered child safety analysis @@ -246,6 +262,7 @@ interface BatchApiResponse { success: boolean; result?: unknown; error?: string; + error_code?: string; credits_used?: number; }>; summary?: { @@ -398,11 +415,54 @@ export class Tuteliq { // ContextInput) is translated to the wire's prior_messages here -- // the API only recognizes the snake_case key. Destructured out so // the spread below never forwards the untranslated camelCase name. - const { priorMessages, ...rest } = context; + const { + priorMessages, + sender, + recipients, + conversationType, + minorDisclosureCheck, + authorRole, + ...rest + } = context; return { ...rest, platform: Tuteliq.resolvePlatform(context.platform), ...(priorMessages && { prior_messages: priorMessages }), + ...(sender && { sender: Tuteliq.toWireParticipant(sender) }), + ...(recipients && { recipients: recipients.map(r => Tuteliq.toWireParticipant(r)) }), + ...(conversationType && { conversation_type: conversationType }), + ...(minorDisclosureCheck !== undefined && { minor_disclosure_check: minorDisclosureCheck }), + ...(authorRole && { author_role: authorRole }), + }; + } + + /** + * Translate a declared participant to the wire's snake_case keys. + */ + private static toWireParticipant(participant: DeclaredRecipient): Record { + return { + ...(participant.role && { role: participant.role }), + ...(participant.ageGroup && { age_group: participant.ageGroup }), + ...(participant.relationshipToSender && { relationship_to_sender: participant.relationshipToSender }), + }; + } + + /** + * The emotions route reads only `context.age_group`, so it gets neither + * the platform nor any other context key. + */ + private static emotionsContext(context?: ContextInput): Record | undefined { + if (!context || typeof context === 'string' || !context.ageGroup) return undefined; + return { age_group: context.ageGroup }; + } + + /** + * Top-level request fields shared by the single-endpoint text detectors. + */ + private static textDetectionFields(input: TextDetectionRequestFields): Record { + return { + ...(input.attestation !== undefined && { attestation: input.attestation }), + ...(input.messageId && { message_id: input.messageId }), }; } @@ -433,6 +493,7 @@ export class Tuteliq { // distress-signals additionally issue a fresh token back. ...(input.continuationToken && { continuation_token: input.continuationToken }), ...(input.resetConversation && { reset_conversation: true }), + ...Tuteliq.textDetectionFields(input), ...(input.external_id && { external_id: input.external_id }), ...(input.customer_id && { customer_id: input.customer_id }), ...(input.metadata && { metadata: input.metadata }), @@ -702,8 +763,10 @@ export class Tuteliq { * Detect bullying in content * * Branching guidance: the top-level `is_bullying` boolean fires whenever - * the model observes ANY signal — including low-severity monitor-only - * cases. For production branching use `result.recommended_action` + * the model observes ANY signal, including low-severity monitor-only + * cases, and is false when the message reports bullying rather than + * doing it (`bullying_reported`), even though that report may still need + * a person. For production branching use `result.recommended_action` * (`flag_for_review` / `block` / `immediate_intervention`), or the * `isActionable` helper. * @@ -742,6 +805,7 @@ export class Tuteliq { ...(input.incident_moderation_enabled !== undefined && { incident_moderation_enabled: input.incident_moderation_enabled }), ...(input.continuationToken && { continuation_token: input.continuationToken }), ...(input.resetConversation && { reset_conversation: true }), + ...Tuteliq.textDetectionFields(input), ...(Object.keys(options).length > 0 && { options }), } ); @@ -752,8 +816,10 @@ export class Tuteliq { * * Branching guidance: `grooming_risk: "low"` is a monitor-only signal — do * not treat it as actionable. For production branching use - * `result.recommended_action` (`flag_for_review` / `block` / - * `immediate_intervention`), or the `isActionable` helper. + * `result.recommended_action` (`flag_for_review` / + * `immediate_intervention`; this endpoint does not return `block`), or the + * `isActionable` helper. Check `analysis_status` too: a `none` risk with + * `engine_error` is not a safe verdict. * * @example * ```typescript @@ -777,6 +843,7 @@ export class Tuteliq { const options: Record = {}; if (input.supportThreshold) options.support_threshold = input.supportThreshold; if (input.verdictOnly) options.verdict_only = true; + if (input.includeRelationshipFrame) options.include_relationship_frame = true; return this.requestWithRetry( 'POST', @@ -798,6 +865,7 @@ export class Tuteliq { ...(input.incident_moderation_enabled !== undefined && { incident_moderation_enabled: input.incident_moderation_enabled }), ...(input.continuationToken && { continuation_token: input.continuationToken }), ...(input.resetConversation && { reset_conversation: true }), + ...Tuteliq.textDetectionFields(input), ...(Object.keys(options).length > 0 && { options }), } ); @@ -839,12 +907,14 @@ export class Tuteliq { { text: input.content, context: this.normalizeContext(input.context), + ...(input.newConversation && { new_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 }), ...(input.continuationToken && { continuation_token: input.continuationToken }), ...(input.resetConversation && { reset_conversation: true }), + ...Tuteliq.textDetectionFields(input), ...(Object.keys(options).length > 0 && { options }), } ); @@ -1019,8 +1089,9 @@ export class Tuteliq { })); } - if (input.context) { - body.context = this.normalizeContext(input.context); + const context = Tuteliq.emotionsContext(input.context); + if (context) { + body.context = context; } if (input.external_id) { @@ -1032,6 +1103,9 @@ export class Tuteliq { if (input.metadata) { body.metadata = input.metadata; } + if (input.incident_moderation_enabled !== undefined) { + body.incident_moderation_enabled = input.incident_moderation_enabled; + } return this.requestWithRetry( 'POST', @@ -1063,8 +1137,11 @@ export class Tuteliq { throw new ValidationError('Situation description is required'); } this.validateContent(input.situation); + if (input.audience !== undefined && !ACTION_PLAN_AUDIENCES.includes(input.audience)) { + throw new ValidationError(`audience must be one of: ${ACTION_PLAN_AUDIENCES.join(', ')}`); + } - return this.requestWithRetry( + const plan = await this.requestWithRetry( 'POST', '/api/v1/guidance/action-plan', { @@ -1078,6 +1155,11 @@ export class Tuteliq { ...(input.incident_moderation_enabled !== undefined && { incident_moderation_enabled: input.incident_moderation_enabled }), } ); + // `reading_level` is the name this SDK used before it matched the + // API's `approx_reading_level`; keep it populated for existing callers. + return plan.approx_reading_level !== undefined && plan.reading_level === undefined + ? { ...plan, reading_level: plan.approx_reading_level } + : plan; } // ========================================================================= @@ -1115,7 +1197,8 @@ export class Tuteliq { })), meta: { child_age: input.childAge, - ...input.incident, + ...(input.conversationId && { conversation_id: input.conversationId }), + ...(input.timestampRange && { timestamp_range: input.timestampRange }), }, ...(input.external_id && { external_id: input.external_id }), ...(input.customer_id && { customer_id: input.customer_id }), @@ -1405,9 +1488,10 @@ export class Tuteliq { const messages = item.messages?.length ? item.messages.map(m => ({ sender: m.sender, text: m.content })) : [{ sender: 'user', text: item.content ?? '' }]; + const context = Tuteliq.emotionsContext(item.context); return { messages, - context: this.normalizeContext(item.context), + ...(context && { context }), }; } @@ -1484,6 +1568,7 @@ export class Tuteliq { success: r.success, ...(r.result !== undefined && { result: r.result }), ...(r.error !== undefined && { error: r.error }), + ...(r.error_code !== undefined && { error_code: r.error_code }), ...(r.credits_used !== undefined && { credits_used: r.credits_used }), ...(item?.external_id !== undefined && { external_id: item.external_id }), }; @@ -1899,9 +1984,9 @@ export class Tuteliq { /** * Detect social engineering tactics (pretexting, impersonation, urgency, authority exploitation) */ - async detectSocialEngineering(input: DetectionInput): Promise { + async detectSocialEngineering(input: DetectionInput): Promise { this.validateContent(input.content); - return this.requestWithRetry( + return this.requestWithRetry( 'POST', '/api/v1/fraud/social-engineering', this.buildDetectionBody(input) ); @@ -1910,9 +1995,9 @@ export class Tuteliq { /** * Detect app-based fraud (fake apps, malicious downloads, clone apps, fraudulent reviews) */ - async detectAppFraud(input: DetectionInput): Promise { + async detectAppFraud(input: DetectionInput): Promise { this.validateContent(input.content); - return this.requestWithRetry( + return this.requestWithRetry( 'POST', '/api/v1/fraud/app-fraud', this.buildDetectionBody(input) ); @@ -1921,9 +2006,9 @@ export class Tuteliq { /** * Detect romance scam patterns (love-bombing, financial requests, identity fabrication) */ - async detectRomanceScam(input: DetectionInput): Promise { + async detectRomanceScam(input: DetectionInput): Promise { this.validateContent(input.content); - return this.requestWithRetry( + return this.requestWithRetry( 'POST', '/api/v1/fraud/romance-scam', this.buildDetectionBody(input) ); @@ -1932,9 +2017,9 @@ export class Tuteliq { /** * Detect money mule recruitment (easy money offers, account sharing, laundering language) */ - async detectMuleRecruitment(input: DetectionInput): Promise { + async detectMuleRecruitment(input: DetectionInput): Promise { this.validateContent(input.content); - return this.requestWithRetry( + return this.requestWithRetry( 'POST', '/api/v1/fraud/mule-recruitment', this.buildDetectionBody(input) ); @@ -1947,9 +2032,9 @@ export class Tuteliq { /** * Detect gambling harm (underage gambling, addiction patterns, predatory odds) */ - async detectGamblingHarm(input: DetectionInput): Promise { + async detectGamblingHarm(input: DetectionInput): Promise { this.validateContent(input.content); - return this.requestWithRetry( + return this.requestWithRetry( 'POST', '/api/v1/safety/gambling-harm', this.buildDetectionBody(input) ); @@ -1958,9 +2043,9 @@ export class Tuteliq { /** * Detect coercive control patterns (isolation, financial control, surveillance, threats) */ - async detectCoerciveControl(input: DetectionInput): Promise { + async detectCoerciveControl(input: DetectionInput): Promise { this.validateContent(input.content); - return this.requestWithRetry( + return this.requestWithRetry( 'POST', '/api/v1/safety/coercive-control', this.buildDetectionBody(input) ); @@ -1969,9 +2054,9 @@ export class Tuteliq { /** * Detect vulnerability exploitation with cross-endpoint vulnerability modifier */ - async detectVulnerabilityExploitation(input: DetectionInput): Promise { + async detectVulnerabilityExploitation(input: DetectionInput): Promise { this.validateContent(input.content); - return this.requestWithRetry( + return this.requestWithRetry( 'POST', '/api/v1/safety/vulnerability-exploitation', this.buildDetectionBody(input) ); @@ -1980,9 +2065,9 @@ export class Tuteliq { /** * Detect radicalisation indicators (extremist rhetoric, recruitment patterns, dehumanisation) */ - async detectRadicalisation(input: DetectionInput): Promise { + async detectRadicalisation(input: DetectionInput): Promise { this.validateContent(input.content); - return this.requestWithRetry( + return this.requestWithRetry( 'POST', '/api/v1/safety/radicalisation', this.buildDetectionBody(input) ); @@ -1996,9 +2081,9 @@ export class Tuteliq { * than inner-state emotion inference — important under EU AI Act * Art 5(1)(f). Replaces the deprecated `detectEmotionalDistress`. */ - async detectDistressSignals(input: DetectionInput): Promise { + async detectDistressSignals(input: DetectionInput): Promise { this.validateContent(input.content); - return this.requestWithRetry( + return this.requestWithRetry( 'POST', '/api/v1/safety/distress-signals', this.buildDetectionBody(input) ); @@ -2009,14 +2094,63 @@ export class Tuteliq { * Covers image-based abuse, cyber stalking, online harassment, doxing, * outing, post-separation abuse, digital coercion, and sexualised deepfakes. */ - async detectTFGBV(input: DetectionInput): Promise { + async detectTFGBV(input: DetectionInput): Promise { this.validateContent(input.content); - return this.requestWithRetry( + return this.requestWithRetry( 'POST', '/api/v1/safety/tfgbv', this.buildDetectionBody(input) ); } + /** + * Read a display name (nickname, username) for concerns before it is + * shown to others. + * + * The result is a signal about the name, not a verdict on the person, and + * carries `recommendation` rather than `recommended_action`. When + * `reading_complete` is false nothing was analysed (and nothing charged): + * retry rather than treating `signal: 'none'` as clean. + * + * @example + * ```typescript + * const result = await tuteliq.detectNickname({ + * name: 'xX_player_Xx', + * context: { ageGroup: '11-13', platformReserved: ['Support', 'Admin'] }, + * }) + * + * if (result.reading_complete && result.recommendation === 'hold_for_review') { + * console.log(result.recommendation_detail) + * } + * ``` + */ + async detectNickname(input: DetectNicknameInput): Promise { + if (!input.name || typeof input.name !== 'string' || input.name.trim().length === 0) { + throw new ValidationError('Name is required'); + } + if (input.name.length > MAX_NICKNAME_LENGTH) { + throw new ValidationError(`Name exceeds maximum length of ${MAX_NICKNAME_LENGTH} characters`); + } + + // Built explicitly rather than through normalizeContext: this route + // takes no platform and only these four context keys. + const ctx = input.context; + const context = ctx && { + ...(ctx.language && { language: ctx.language }), + ...(ctx.ageGroup && { age_group: ctx.ageGroup }), + ...(ctx.conversationType && { conversation_type: ctx.conversationType }), + ...(ctx.platformReserved && { platform_reserved: ctx.platformReserved }), + }; + + return this.requestWithRetry( + 'POST', '/api/v1/safety/nickname', + { + name: input.name, + ...(input.strictness && { strictness: input.strictness }), + ...(context && Object.keys(context).length > 0 && { context }), + } + ); + } + // ========================================================================= // Multi-Endpoint Analysis // ========================================================================= @@ -2051,7 +2185,8 @@ export class Tuteliq { const options: Record = {}; if (input.includeEvidence) options.include_evidence = true; if (input.supportThreshold) options.support_threshold = input.supportThreshold; - if (input.verdictOnly) options.verdict_only = true; + if (input.flagProfanity !== undefined) options.flag_profanity = input.flagProfanity; + if (input.flagRiskTerms !== undefined) options.flag_risk_terms = input.flagRiskTerms; return this.requestWithRetry( 'POST', '/api/v1/analyse/multi', diff --git a/src/constants.ts b/src/constants.ts index 686019e..6f11266 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -253,8 +253,51 @@ export enum Detection { COERCIVE_CONTROL = 'coercive-control', VULNERABILITY_EXPLOITATION = 'vulnerability-exploitation', RADICALISATION = 'radicalisation', + DISTRESS_SIGNALS = 'distress-signals', + TFGBV = 'tfgbv', + SYNTHETIC_CONTENT = 'synthetic-content', } +/** + * Detection endpoint names accepted by `analyseMulti`, as plain strings. + * Every `Detection` enum value is assignable to this type. + */ +export type DetectionName = `${Detection}`; + +/** + * The declared settings a text can be written in, for + * `context.conversationType` and the account's `default_conversation_type`. + * Declared by your platform, never inferred from the text. + */ +export const CONVERSATION_TYPES = [ + 'fraud_report', + 'abuse_report', + 'support_disclosure', + 'helpline', + 'clinical', + 'investigative', + 'legal', + 'advisory', + 'training', + 'journalism', + 'research', + 'moderation_review', + 'healthcare', + 'law_enforcement', + 'education', + 'family', + 'workplace', + 'dating', + 'adult_community', + 'direct_message', + 'group_chat', + 'public_post', + 'customer_service', + 'gaming_chat', +] as const; + +export type ConversationType = (typeof CONVERSATION_TYPES)[number]; + /** * Verification mode for age or identity verification sessions */ diff --git a/src/index.ts b/src/index.ts index 9e8d392..977a03e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,7 +19,10 @@ export { DocumentType, VerificationStatus, VerificationSessionStatus, + Detection, + CONVERSATION_TYPES, } from './constants.js'; +export type { ConversationType, DetectionName } from './constants.js'; // Error exports export { @@ -56,6 +59,9 @@ export type { GroomingResult, DetectUnsafeInput, UnsafeResult, + TextDetectionRequestFields, + BullyingType, + GroomingFlag, AnalyzeInput, AnalyzeResult, @@ -97,6 +103,37 @@ export type { AnalyseMultiInput, AnalyseMultiResult, AnalyseMultiSummary, + MultiDetectionResult, + SocialEngineeringResult, + AppFraudResult, + RomanceScamResult, + MuleRecruitmentResult, + GamblingHarmResult, + CoerciveControlResult, + VulnerabilityExploitationResult, + RadicalisationResult, + DistressSignalsResult, + TFGBVResult, + DetectNicknameInput, + NicknameResult, + NicknameCategory, + NicknameStrictness, + + // Shared text detection fields + ParticipantRole, + ParticipantRelationship, + DeclaredSender, + DeclaredRecipient, + AttestationReason, + DetectionAttestation, + AttestationFields, + ParticipantsApplied, + MinorDisclosure, + DeclaredContextFields, + SpeakerRole, + SpeakerStance, + ResultAdjustments, + TextDetectionFields, // Media types AnalyzeVoiceInput, diff --git a/src/types/detection-fields.ts b/src/types/detection-fields.ts new file mode 100644 index 0000000..ead1d67 --- /dev/null +++ b/src/types/detection-fields.ts @@ -0,0 +1,272 @@ +import { ConversationType } from '../constants.js'; + +export type { ConversationType }; + +// ============================================================================= +// Declared participants (request side) +// ============================================================================= + +/** Who a participant is, as your platform knows them. */ +export type ParticipantRole = + | 'child' + | 'teen' + | 'adult' + | 'guardian' + | 'educator' + | 'staff' + | 'unknown'; + +/** How a recipient is related to the sender. */ +export type ParticipantRelationship = + | 'guardian_of_sender' + | 'child_of_sender' + | 'sibling' + | 'peer' + | 'educator_of_sender' + | 'student_of_sender' + | 'staff' + | 'none' + | 'unknown'; + +/** The sender of the text, declared by your platform (never inferred). */ +export interface DeclaredSender { + role?: ParticipantRole; + /** The sender's age group, e.g. "8-10". */ + ageGroup?: string; +} + +/** A recipient of the text, declared by your platform (never inferred). */ +export interface DeclaredRecipient extends DeclaredSender { + relationshipToSender?: ParticipantRelationship; +} + +// ============================================================================= +// Shared result fields +// ============================================================================= + +/** Why a requested `attestation` is null. */ +export type AttestationReason = + | 'endpoint_unsupported' + | 'nothing_detected' + | 'not_attributable' + | 'signing_unavailable' + | 'sandbox'; + +/** + * A signed, non-identifying statement of what was detected, returned when the + * request sets `attestation: true`. Verify `signature` against the published + * key named by `key_id`. + */ +export interface DetectionAttestation { + v: 'tlq-att-1'; + harm_descriptor: string; + /** Your `external_id`, echoed outside the signed payload. */ + external_ref?: string; + detector: { + model_id: string; + model_version: string; + prompt_template_version: string; + endpoint: string; + }; + operating_point: Record; + /** RFC 3339 timestamp */ + detected_at: string; + disclosure_url: string; + limits: string[]; + signature: { + algorithm: string; + key_id: string; + signature: string; + signed_at: string; + }; +} + +/** Signed attestation fields, on the single-endpoint text detectors. */ +export interface AttestationFields { + /** Present only when requested; null when none could be produced. */ + attestation?: DetectionAttestation | null; + /** Why `attestation` is null. */ + attestation_reason?: AttestationReason; +} + +/** What the declared `sender`/`recipients` supplied to the analysis. */ +export interface ParticipantsApplied { + conversation_type?: 'family' | 'education'; + age_group?: string; + child_age?: number; +} + +/** Opt-in reading returned when `context.minorDisclosureCheck` is set. */ +export interface MinorDisclosure { + detected: boolean; + evidence_type: 'stated_age' | 'school_year' | 'parental_control' | 'age_milestone' | 'life_stage' | 'other'; + /** Index of the message the disclosure is in */ + message_index: number; + suggested_age_group: string; + /** Human-readable; do not branch on it. */ + reason: string; +} + +/** The declared setting and participants that applied to a result. */ +export interface DeclaredContextFields { + /** The declared setting in effect, from the request or the account default. */ + conversation_type_in_effect?: ConversationType; + /** Where `conversation_type_in_effect` came from. */ + conversation_type_source?: 'request' | 'participants' | 'account_default'; + /** Present only when the declared setting changed the result. */ + conversation_type_applied?: string; + participants_applied?: ParticipantsApplied; + minor_disclosure?: MinorDisclosure; +} + +/** The speaker's relationship to the conduct described. */ +export type SpeakerRole = 'enacting' | 'experiencing' | 'reporting_other' | 'self_directed' | 'unclear'; + +/** Whether the speaker is using the tactic or describing someone else using it. */ +export type SpeakerStance = 'deploying' | 'describing_third_party' | 'unclear'; + +/** + * Adjustments the API applied to this result, reported for transparency. + * Descriptive only: route on `recommended_action`. Each is absent unless it + * applied, and each `*_reason` is human-readable text. + */ +export interface ResultAdjustments { + absence_reason_cleared?: boolean; + access_request_raised?: boolean; + access_request_raised_reason?: string; + action_capped?: boolean; + action_capped_reason?: string; + adult_exchange_capped?: boolean; + adult_exchange_capped_reason?: string; + affection_only_capped?: boolean; + affection_only_capped_reason?: string; + banter_capped?: boolean; + banter_capped_reason?: string; + bullying_report_surfaced?: boolean; + bullying_report_surfaced_reason?: string; + consent_floor_applied?: boolean; + continued_after_objection_raised?: boolean; + continued_after_objection_raised_reason?: string; + conversation_distress_surfaced?: boolean; + conversation_distress_surfaced_reason?: string; + declared_age_overridden?: boolean; + declared_age_overridden_reason?: string; + detection_evasion_raised?: boolean; + disclosure_cleared?: boolean; + disclosure_cleared_reason?: string; + document_safekeeping_capped?: boolean; + document_safekeeping_capped_reason?: string; + escalation_capped?: boolean; + escalation_capped_reason?: string; + exclusion_claim_watched?: boolean; + exclusion_claim_watched_reason?: string; + exclusion_report_recorded?: boolean; + exclusion_report_recorded_reason?: string; + figurative_low_mood_capped?: boolean; + figurative_low_mood_capped_reason?: string; + game_play_capped?: boolean; + game_play_capped_reason?: string; + high_risk_score_raised?: boolean; + high_risk_score_raised_reason?: string; + logistics_only_capped?: boolean; + logistics_only_capped_reason?: string; + lone_message_block_capped?: boolean; + lone_message_block_capped_reason?: string; + lone_rapport_capped?: boolean; + lone_rapport_capped_reason?: string; + long_conversation_raised?: boolean; + long_conversation_raised_reason?: string; + looks_attack_raised?: boolean; + looks_attack_raised_reason?: string; + love_bombing_ask_raised?: boolean; + love_bombing_ask_raised_reason?: string; + love_bombing_raised?: boolean; + love_bombing_raised_reason?: string; + meeting_logistics_cleared?: boolean; + meeting_logistics_cleared_reason?: string; + minor_on_adult_platform_raised?: boolean; + minor_on_adult_platform_raised_reason?: string; + no_child_shown_capped?: boolean; + no_child_shown_capped_reason?: string; + no_harmful_act_capped?: boolean; + no_harmful_act_capped_reason?: string; + no_harmful_act_urgency_capped?: boolean; + no_harmful_act_urgency_capped_reason?: string; + no_tactic_left_lowered?: boolean; + no_tactic_left_lowered_reason?: string; + number_coincidence_cleared?: boolean; + number_coincidence_cleared_reason?: string; + off_platform_raised?: boolean; + off_platform_raised_reason?: string; + ongoing_harm_floor_applied?: boolean; + ongoing_harm_floor_reason?: string; + only_confirmation_asked_capped?: boolean; + only_confirmation_asked_capped_reason?: string; + ordinary_day_capped?: boolean; + ordinary_day_capped_reason?: string; + ordinary_discipline_capped?: boolean; + ordinary_discipline_capped_reason?: string; + ordinary_hyperbole_capped?: boolean; + ordinary_hyperbole_capped_reason?: string; + ordinary_shared_finances_capped?: boolean; + ordinary_shared_finances_capped_reason?: string; + passing_remark_capped?: boolean; + passing_remark_capped_reason?: string; + past_account_cleared?: boolean; + peer_move_lowered?: boolean; + peer_move_lowered_reason?: string; + pressed_after_objection_watched?: boolean; + pressed_after_objection_watched_reason?: string; + private_move_raised?: boolean; + private_move_raised_reason?: string; + protective_speech_capped?: boolean; + protective_speech_capped_reason?: string; + reading_capped?: boolean; + reading_capped_reason?: string; + reciprocity_floor_applied?: boolean; + reporter_capped?: boolean; + resource_direction_cleared?: boolean; + risk_score_lowered?: boolean; + risk_score_lowered_reason?: string; + rumour_spread_raised?: boolean; + rumour_spread_raised_reason?: string; + secrecy_frame_cleared?: boolean; + severity_capped?: boolean; + severity_capped_reason?: string; + shared_surprise_capped?: boolean; + shared_surprise_capped_reason?: string; + single_insult_watched?: boolean; + single_insult_watched_reason?: string; + soft_evidence_capped?: boolean; + soft_evidence_capped_reason?: string; + talking_about_capped?: boolean; + talking_about_capped_reason?: string; + teasing_stopped_capped?: boolean; + teasing_stopped_capped_reason?: string; + third_party_report_cleared?: boolean; + third_party_report_surfaced?: boolean; + third_party_report_surfaced_reason?: string; + unseen_partner_raised?: boolean; + unseen_partner_raised_reason?: string; + urgency_kept?: boolean; + urgency_kept_reason?: string; + verdict_capped?: boolean; + verdict_capped_reason?: string; + verdict_withdrawn?: boolean; + verdict_withdrawn_reason?: string; + willing_exchange_capped?: boolean; + willing_exchange_capped_reason?: string; + ungrounded_flags?: string[]; + flag_attribution?: Record; + detection_evasion_messages?: number[]; + confidentiality_frame?: 'unconditional_concealment' | 'limited_confidentiality' | 'guardian_included' | 'none'; + consent_violation?: 'explicit_refusal_overridden' | 'threatened_exposure'; + off_platform_request?: boolean; + off_platform_request_reason?: string; + off_platform_markers?: Array<'scam_persona' | 'early_love_bombing' | 'avoids_video_or_meeting' | 'discredits_platform'>; + target_report_action_was?: 'block'; + sustained_hopelessness_conversations?: number; +} + +/** Fields shared by every single-endpoint text detection result. */ +export interface TextDetectionFields extends AttestationFields, DeclaredContextFields, ResultAdjustments {} diff --git a/src/types/detection.ts b/src/types/detection.ts index 893f8bd..7e2d61b 100644 --- a/src/types/detection.ts +++ b/src/types/detection.ts @@ -1,8 +1,10 @@ import { TrackingFields } from './index.js'; -import { ContextInput, ConversationTrajectory, RecommendedAction, SupportData } from './safety.js'; -import { LanguageStatus } from '../constants.js'; +import { ContextInput, ConversationTrajectory, RecommendedAction, SupportData, TextDetectionRequestFields } from './safety.js'; +import { ConversationType, DetectionName, LanguageStatus } from '../constants.js'; +import { DeclaredContextFields, ResultAdjustments, SpeakerRole, SpeakerStance, TextDetectionFields } from './detection-fields.js'; export { LanguageStatus }; +export type { DetectionName }; // ============================================================================= // Unified Detection (Fraud + Safety Extended) @@ -12,9 +14,10 @@ export { LanguageStatus }; * Input for all fraud detection and safety-extended endpoints. * Used by: detectSocialEngineering, detectAppFraud, detectRomanceScam, * detectMuleRecruitment, detectGamblingHarm, detectCoerciveControl, - * detectVulnerabilityExploitation, detectRadicalisation + * detectVulnerabilityExploitation, detectRadicalisation, + * detectDistressSignals, detectTFGBV */ -export interface DetectionInput extends TrackingFields { +export interface DetectionInput extends TrackingFields, TextDetectionRequestFields { /** The content to analyze */ content: string; /** Context for better analysis */ @@ -72,6 +75,8 @@ export interface MessageAnalysis { risk_score: number; /** Flags identified in this message */ flags: string[]; + /** Flags considered for this message and withdrawn (grooming). Not evidence. */ + unsupported_flags?: string[]; /** Brief summary of the message analysis */ summary: string; } @@ -103,7 +108,7 @@ export interface AgeCalibration { /** * Unified result from fraud detection and safety-extended endpoints. */ -export interface DetectionResult { +export interface DetectionResult extends TextDetectionFields { /** Name of the detection endpoint */ endpoint: string; /** Whether a threat was detected */ @@ -175,31 +180,11 @@ export interface DetectionResult { * "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. - */ + /** @deprecated Not returned by these endpoints. */ trajectory_risk?: number; - /** - * Direction of travel across the conversation so far. Absent on the first - * turn, alongside `trajectory_risk`. - */ + /** @deprecated Not returned by these endpoints. */ 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. - */ + /** @deprecated Not returned by these endpoints. */ severity_series?: number[]; /** * Crisis support resources, present only when the result meets the @@ -208,6 +193,96 @@ export interface DetectionResult { support?: SupportData; } + +// ============================================================================= +// Per-endpoint results +// ============================================================================= + +/** Result of `detectSocialEngineering`. */ +export type SocialEngineeringResult = DetectionResult; + +/** Result of `detectGamblingHarm`. */ +export type GamblingHarmResult = DetectionResult; + +/** Result of `detectAppFraud`. */ +export interface AppFraudResult extends DetectionResult { + /** True when the writer is the person being defrauded, reporting it. */ + target_report?: boolean; + /** Human-readable; do not branch on it. */ + target_report_reason?: string; +} + +/** Result of `detectRomanceScam`. */ +export interface RomanceScamResult extends DetectionResult { + /** Whether the speaker is using the tactics or describing someone else using them. */ + speaker_stance?: SpeakerStance; +} + +/** Result of `detectMuleRecruitment`. */ +export interface MuleRecruitmentResult extends DetectionResult { + /** Whether the speaker is using the tactics or describing someone else using them. */ + speaker_stance?: SpeakerStance; +} + +/** Result of `detectCoerciveControl`. */ +export interface CoerciveControlResult extends DetectionResult { + /** The speaker's relationship to the conduct. */ + speaker_role?: Exclude; + /** Whether the speaker is using the tactics or describing someone else using them. */ + speaker_stance?: SpeakerStance; + /** + * True when the writer is telling their own (or a known person's) story + * of controlling conduct: the result offers support rather than opening a + * moderation case. + */ + disclosure_capped?: boolean; + /** Human-readable; do not branch on it. */ + disclosure_capped_reason?: string; +} + +/** Result of `detectVulnerabilityExploitation`. */ +export interface VulnerabilityExploitationResult extends DetectionResult { + /** Modifier (1.0-2.0) applied to other endpoints' scores in `analyseMulti`. */ + cross_endpoint_modifier?: number; + /** Vulnerability dimensions read from the text (0-1 each). */ + vulnerability_profile?: { + emotional_vulnerability: number; + cognitive_vulnerability: number; + situational_vulnerability: number; + social_vulnerability: number; + }; + /** Whether the speaker is using the tactics or describing someone else using them. */ + speaker_stance?: SpeakerStance; +} + +/** Result of `detectRadicalisation`. */ +export interface RadicalisationResult extends DetectionResult { + /** Whether the speaker is using the rhetoric or describing someone else using it. */ + speaker_stance?: SpeakerStance; + /** True when the result should be reviewed ahead of others. */ + priority_review?: boolean; + /** Human-readable; do not branch on it. */ + priority_review_reason?: string; + /** Whether the content is aimed at the speaker or seen as a bystander. */ + exposure_target?: 'self_targeted' | 'bystander' | 'unclear'; +} + +/** Result of `detectDistressSignals`. */ +export interface DistressSignalsResult extends DetectionResult { + /** How the distress words are used. */ + distress_reading?: 'literal_present' | 'about_other' | 'past_resolved' | 'figurative' | 'quoted' | 'none'; + /** Who the distress is about; echoes `context.authorRole`. */ + distress_subject?: 'speaker' | 'other_party'; + /** True when a supportive check-in is suggested rather than a moderation case. */ + support_suggested?: boolean; +} + +/** Result of `detectTFGBV`. */ +export interface TFGBVResult extends DetectionResult { + /** The speaker's relationship to the conduct. */ + speaker_role?: SpeakerRole; +} + // ============================================================================= // Multi-Endpoint Analysis // ============================================================================= @@ -218,8 +293,10 @@ export interface DetectionResult { export interface AnalyseMultiInput extends TrackingFields { /** The content to analyze */ content: string; - /** Detection endpoints to run (max 10) */ - detections: string[]; + /** + * Detection endpoints to run (max 10). The `Detection` enum lists them. + */ + detections: DetectionName[]; /** Context for better analysis */ context?: ContextInput; /** Include evidence in individual results */ @@ -227,13 +304,60 @@ export interface AnalyseMultiInput extends TrackingFields { /** Minimum severity to show crisis support resources (default: 'high'). Critical always shows. */ supportThreshold?: 'low' | 'medium' | 'high' | 'critical'; /** - * Fast mode. When true, individual endpoint results omit their per-message - * `message_analysis` breakdown and return only the verdict. Lower latency - * and a smaller payload for real-time screening. + * Word-list profanity flag, applied to the bullying and unsafe results + * only. Same semantics as `flagProfanity` on `detectBullying`. + */ + flagProfanity?: boolean; + /** + * Word-list drug and violence term flag, applied to the bullying and + * unsafe results only. Same semantics as `flagRiskTerms` on `detectBullying`. + */ + flagRiskTerms?: boolean; + /** + * @deprecated The multi-endpoint route has no fast mode; this is not sent. */ verdictOnly?: boolean; } +/** + * One endpoint's result inside `analyseMulti`, normalised across detectors. + * Fields specific to one detector are present only on that detector's result. + */ +export interface MultiDetectionResult extends DeclaredContextFields, ResultAdjustments { + /** Name of the detection endpoint */ + endpoint: DetectionName; + detected: boolean; + severity: number; + confidence: number; + risk_score: number; + level?: string; + categories: DetectionCategory[]; + evidence?: DetectionEvidence[]; + recommended_action: RecommendedAction; + rationale?: string; + credits_used?: number; + /** Account policy rules evaluated for this detector. */ + policy_action?: Record; + /** Bullying and unsafe only, when `flagProfanity` is set. */ + profanity?: { detected: boolean; matches: string[] } | null; + /** Bullying and unsafe only, when `flagRiskTerms` is set. */ + risk_terms?: { detected: boolean; matches: Array<{ term: string; category: 'drug' | 'violence' }> } | null; + /** See `UnsafeResult.third_party_concern`. */ + third_party_concern?: boolean; + third_party_concern_reason?: string; + /** See `BullyingResult.bullying_reported`. */ + bullying_reported?: boolean; + bullying_reported_by?: 'target' | 'bystander'; + bullying_reported_reason?: string; + /** See `CoerciveControlResult.disclosure_capped`. */ + disclosure_capped?: boolean; + disclosure_capped_reason?: string; + /** See `DistressSignalsResult.support_suggested`. */ + support_suggested?: boolean; + speaker_role?: SpeakerRole; + speaker_stance?: SpeakerStance; +} + /** * Summary of multi-endpoint analysis results. */ @@ -242,10 +366,40 @@ export interface AnalyseMultiSummary { total_endpoints: number; /** Number of endpoints that detected a threat */ detected_count: number; - /** Endpoint with highest risk */ - highest_risk: { endpoint: string; risk_score: number }; + /** The primary classification and its own score */ + highest_risk: { + endpoint: string; + risk_score: number; + /** + * Other detected endpoints scoring about the same. When present, read + * them as part of the finding rather than treating `endpoint` alone + * as the classification. + */ + close_calls?: Array<{ endpoint: string; risk_score: number }>; + }; /** Overall risk level */ overall_risk_level: string; + processing_time_ms?: number; + /** Total credits consumed across every endpoint */ + total_credits_used?: number; + /** True when the vulnerability modifier was applied to the other results */ + vulnerability_modifier_applied?: boolean; + /** + * `complete` when every requested detector answered. `partial` when at + * least one did not (see `failed_endpoints`); `recommended_action` then + * asks for human review, because a detector that did not answer is not + * one that found nothing. + */ + analysis_status?: 'complete' | 'partial'; + /** Present only when `analysis_status` is `partial`. */ + recommended_action?: RecommendedAction; + /** Present only when `analysis_status` is `partial`. Free text. */ + action_detail?: string; + /** Detectors that could not produce an analysis */ + failed_endpoints?: Array<{ endpoint: string; code: string; error: string }>; + /** Endpoints skipped because they are disabled in your detection settings */ + skipped_endpoints?: string[]; + skipped_reason?: string; } /** @@ -253,13 +407,15 @@ export interface AnalyseMultiSummary { */ export interface AnalyseMultiResult { /** Individual results per endpoint */ - results: DetectionResult[]; + results: MultiDetectionResult[]; /** Summary across all endpoints */ summary: AnalyseMultiSummary; /** Cross-endpoint vulnerability modifier (1.0-2.0) */ cross_endpoint_modifier?: number; - /** Total credits consumed */ + /** @deprecated Not returned by the API; use `summary.total_credits_used`. */ credits_used?: number; + /** Crisis support resources relevant to the detected risk */ + support?: SupportData; /** Echo of provided external_id */ external_id?: string; /** Echo of provided customer_id */ @@ -267,3 +423,68 @@ export interface AnalyseMultiResult { /** Echo of provided metadata */ metadata?: Record; } + +// ============================================================================= +// Nickname +// ============================================================================= + +/** How readily a doubtful reading of a display name is acted on. */ +export type NicknameStrictness = 'cautious' | 'balanced' | 'strict'; + +/** Concern categories a display name can be read under. */ +export type NicknameCategory = + | 'self_harm_promotion' + | 'impersonation_of_staff_or_support' + | 'hate_or_extremist_reference' + | 'sexualised' + | 'seeks_older_contact' + | 'harassment_of_named_person' + | 'contact_detail_or_off_platform_lure' + | 'age_disclosure'; + +/** Input for `detectNickname` (POST /api/v1/safety/nickname). */ +export interface DetectNicknameInput { + /** The display name as typed (1-64 characters). */ + name: string; + /** Default: `balanced`. */ + strictness?: NicknameStrictness; + context?: { + /** Two-letter language code */ + language?: string; + /** Age group of the person choosing the name, e.g. "11-13" */ + ageGroup?: string; + /** The setting the name is shown in */ + conversationType?: ConversationType; + /** + * Your platform's own staff and support names (max 20, each up to 32 + * characters), so copies of them can be recognised. + */ + platformReserved?: string[]; + }; +} + +/** + * Result of `detectNickname`. A signal about the name, not a verdict on the + * person. It carries `recommendation`, not `recommended_action`, so do not + * pass it to `strongestAction` or `isActionable`. + */ +export interface NicknameResult { + signal: 'none' | 'low' | 'review'; + categories: NicknameCategory[]; + /** How each part of the name was read */ + parts: Array<{ text: string; reading: 'ordinary' | 'concerning' | 'unclear'; category: NicknameCategory | null }>; + rationale: string; + /** Advice for the name. Never an emergency action. */ + recommendation: 'allow' | 'allow_and_watch' | 'ask_to_rename' | 'hold_for_review'; + /** Free text: do not branch on it. */ + recommendation_detail: string; + confidence: number; + language: string | null; + strictness: NicknameStrictness; + /** + * False when nothing could be analysed: the call is not charged and + * should be retried, and `signal` (then `none`) must not be read as clean. + */ + reading_complete: boolean; + credits_used: number; +} diff --git a/src/types/guidance.ts b/src/types/guidance.ts index ee2205b..6e554a9 100644 --- a/src/types/guidance.ts +++ b/src/types/guidance.ts @@ -3,7 +3,7 @@ import { TrackingFields } from './index.js'; /** * Target audience for action plans */ -export type Audience = 'child' | 'parent' | 'educator' | 'platform'; +export type Audience = 'child' | 'parent' | 'platform'; // ============================================================================= // Action Plan @@ -28,6 +28,8 @@ export interface ActionPlanResult { /** Tone used in the guidance */ tone: string; /** Approximate reading level */ + approx_reading_level?: string; + /** @deprecated Use `approx_reading_level`; the SDK copies it here for compatibility. */ reading_level?: string; /** Number of credits consumed by this request */ credits_used?: number; diff --git a/src/types/index.ts b/src/types/index.ts index 8d5922b..f602569 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,5 @@ +import type { ContextInput } from './safety.js'; + // Re-export all types export * from './safety.js'; export * from './analysis.js'; @@ -10,6 +12,7 @@ export * from './webhooks.js'; export * from './pricing.js'; export * from './voice-stream.js'; export * from './detection.js'; +export * from './detection-fields.js'; export * from './verification.js'; export * from './synthetic.js'; export * from './encryption.js'; @@ -253,19 +256,11 @@ export interface BatchItemBase { * and is echoed back untouched. */ id?: string; - /** Optional context - string shorthand or detailed object */ - context?: string | { - language?: string; - ageGroup?: string; - relationship?: string; - /** - * Your platform/app name (e.g. "MyApp"), used for dashboard - * attribution. The SDK appends its own identifier before sending it — - * "MyApp" is sent to the API as "MyApp - Node SDK". - */ - platform?: string; - country?: string; - }; + /** + * Optional context - string shorthand or detailed object, as on the + * single-item methods. Emotions items use only `ageGroup`. + */ + context?: ContextInput; /** Optional external ID for correlation */ external_id?: string; } @@ -305,7 +300,7 @@ export interface BatchAnalyzeInput { items: BatchItem[]; /** Process items in parallel (default: true) */ parallel?: boolean; - /** Continue processing if an item fails (default: true) */ + /** @deprecated Not accepted by the API and not sent; items always fail independently. */ continueOnError?: boolean; } @@ -322,6 +317,8 @@ export interface BatchResultItem { result?: unknown; /** Error message (if failed) */ error?: string; + /** Error code (if failed), as on an error response */ + error_code?: string; /** Credits consumed by this item (if successful) */ credits_used?: number; /** External ID (if provided) */ diff --git a/src/types/reports.ts b/src/types/reports.ts index eae3250..6207a82 100644 --- a/src/types/reports.ts +++ b/src/types/reports.ts @@ -18,7 +18,14 @@ export interface GenerateReportInput extends TrackingFields { messages: ReportMessage[]; /** Age of the child */ childAge?: number; - /** Optional incident metadata */ + /** Your identifier for the conversation the messages come from */ + conversationId?: string; + /** Start and end of the incident, as ISO 8601 timestamps */ + timestampRange?: [string, string]; + /** + * @deprecated Not accepted by the API and not sent. Use `conversationId` + * and `timestampRange`. + */ incident?: { /** Incident type/category */ type?: string; diff --git a/src/types/safety.ts b/src/types/safety.ts index b151bf7..fbdaa4c 100644 --- a/src/types/safety.ts +++ b/src/types/safety.ts @@ -4,6 +4,14 @@ import { } from '../constants.js'; import { TrackingFields } from './index.js'; import { MessageAnalysis } from './detection.js'; +import { + ConversationType, + DeclaredRecipient, + DeclaredSender, + SpeakerRole, + SpeakerStance, + TextDetectionFields, +} from './detection-fields.js'; // Re-export enums for convenience export { Severity, GroomingRisk }; @@ -143,8 +151,39 @@ export type ContextInput = string | { * `detectGrooming` (which takes a required `messages` array directly). */ priorMessages?: PriorMessage[]; + /** The sender, as your platform knows them. Declared, never inferred from the text. */ + sender?: DeclaredSender; + /** The recipients (up to 20) and how each is related to the sender. */ + recipients?: DeclaredRecipient[]; + /** + * The setting the text was written in. When omitted, the account's + * `default_conversation_type` applies if one is set. The setting in effect + * is echoed as `conversation_type_in_effect`. + */ + conversationType?: ConversationType; + /** + * Opt in to a reading of whether the speaker says they are a minor in + * words other than an age. Returned as `minor_disclosure`. + */ + minorDisclosureCheck?: boolean; + /** + * `detectDistressSignals` only: whether the author is the person in + * distress (`subject`) or someone responding to them (`responder`). + * Echoed as `distress_subject`. + */ + authorRole?: 'subject' | 'responder'; }; +/** + * Request fields shared by the single-endpoint text detectors. + */ +export interface TextDetectionRequestFields { + /** Ask for a signed, non-identifying detection attestation (`attestation` on the result). */ + attestation?: boolean; + /** Your id for this message (max 128 characters). */ + messageId?: string; +} + // ============================================================================= // Crisis Support // ============================================================================= @@ -171,9 +210,12 @@ export interface SupportHelpline { export interface SupportResponseGuide { category?: string; immediateActions: string[]; + /** @deprecated Not returned by the API. */ childSpecificActions?: string[]; resources: Array<{ name: string; description?: string; url?: string }>; + /** @deprecated Not returned by the API. */ confidential?: boolean; + /** @deprecated Not returned by the API. */ language?: string; } @@ -193,9 +235,9 @@ export interface SupportData { helplines: SupportHelpline[]; /** Highest-priority guide */ response_guide?: SupportResponseGuide; - /** All matching guides, highest priority first */ + /** @deprecated Not returned by the API; use `response_guide`. */ response_guides?: SupportResponseGuide[]; - /** BCP-47 language of the guide content */ + /** @deprecated Not returned by the API. */ language?: string; } @@ -203,7 +245,7 @@ export interface SupportData { // Bullying Detection // ============================================================================= -export interface DetectBullyingInput extends TrackingFields { +export interface DetectBullyingInput extends TrackingFields, TextDetectionRequestFields { /** The content to analyze */ content: string; /** Context for better analysis - string shorthand or detailed object */ @@ -256,11 +298,45 @@ export interface DetectBullyingInput extends TrackingFields { flagRiskTerms?: boolean; } -export interface BullyingResult { - /** Whether bullying was detected */ +/** Bullying types the API returns in `bullying_type`. */ +export type BullyingType = + | 'exclusion' + | 'verbal_abuse' + | 'humiliation' + | 'threat' + | 'intimidation' + | 'sexual_harassment' + | 'body_shaming' + | 'identity_based' + | 'impersonation' + | 'outing' + | 'pile_on' + | 'cyberstalking' + | 'exclusion_threat' + | 'coded_harassment' + | 'cyberbullying' + | 'relational'; + +export interface BullyingResult extends TextDetectionFields { + /** + * Whether bullying was detected. False when the message reports bullying + * rather than doing it (see `bullying_reported`). + */ is_bullying: boolean; /** Types of bullying detected */ - bullying_type: string[]; + bullying_type: BullyingType[]; + /** + * True when the message reports bullying (by its target or a bystander) + * rather than doing it. `recommended_action` still says whether a person + * should look at it. + */ + bullying_reported?: boolean; + /** Who reported the bullying. Present with `bullying_reported`. */ + bullying_reported_by?: 'target' | 'bystander'; + /** Human-readable; do not branch on it. */ + bullying_reported_reason?: string; + /** The speaker's relationship to the conduct. */ + speaker_role?: SpeakerRole; /** Confidence score (0-1) */ confidence: number; /** Severity of the bullying */ @@ -386,7 +462,7 @@ export interface GroomingMessage { senderAge?: number; } -export interface DetectGroomingInput extends TrackingFields { +export interface DetectGroomingInput extends TrackingFields, TextDetectionRequestFields { /** Sequence of messages to analyze */ messages: GroomingMessage[]; /** Age of the child (optional) */ @@ -424,15 +500,47 @@ export interface DetectGroomingInput extends TrackingFields { * as a fresh conversation. Useful when the user starts a new chat. */ resetConversation?: boolean; + /** + * Opt in to a descriptive `relationship_frame` on the result. Costs one + * extra analysis step and is ignored in `verdictOnly` mode. + */ + includeRelationshipFrame?: boolean; } -export interface GroomingResult { +/** Grooming indicators the API returns in `flags`. */ +export type GroomingFlag = + | 'secrecy_request' + | 'boundary_pushing' + | 'photo_request' + | 'gift_giving' + | 'isolation' + | 'flattery' + | 'sexual_content' + | 'meeting_request' + | 'reconnaissance' + | 'sexualization' + | 'grooming' + | 'sextortion'; + +export interface GroomingResult extends TextDetectionFields { /** Level of grooming risk detected */ grooming_risk: GroomingRisk; /** Confidence score (0-1) */ confidence: number; /** Grooming indicators/flags detected */ - flags: string[]; + flags: GroomingFlag[]; + /** + * `ok` when a verdict was produced. `engine_error` means no verdict could + * be produced and the action asks for review; `out_of_scope_adults` means + * the declared participants put the conversation outside this detector's + * scope, so it was not analysed. Never read a `none` risk on either of + * those as safe. + */ + analysis_status?: 'ok' | 'engine_error' | 'out_of_scope_adults'; + /** Whether the speaker is using the tactics or describing someone else using them. */ + speaker_stance?: SpeakerStance; + /** Descriptive only; present when `includeRelationshipFrame` was requested. */ + relationship_frame?: 'established_offline' | 'online_only' | 'unestablished'; /** * Explanation of the analysis — this is what a moderator reads to triage * the incident, and always generated, including when `verdictOnly` is set. @@ -517,7 +625,7 @@ export interface GroomingResult { // Unsafe Content Detection // ============================================================================= -export interface DetectUnsafeInput extends TrackingFields { +export interface DetectUnsafeInput extends TrackingFields, TextDetectionRequestFields { /** The content to analyze */ content: string; /** Context for better analysis */ @@ -538,6 +646,11 @@ export interface DetectUnsafeInput extends TrackingFields { * conversation. Useful when starting a new chat in the same session. */ resetConversation?: boolean; + /** + * Start a new conversation while keeping the cross-conversation pattern + * carried by `continuationToken`. Requires `continuationToken`. + */ + newConversation?: boolean; /** * Fast mode. When true, the response omits any per-message * `message_analysis` breakdown and returns only the verdict. Lower latency @@ -569,18 +682,27 @@ export interface DetectUnsafeInput extends TrackingFields { flagRiskTerms?: boolean; } -export interface UnsafeResult { +export interface UnsafeResult extends TextDetectionFields { /** Whether unsafe content was detected */ unsafe: boolean; /** Categories of unsafe content detected */ categories: string[]; + /** Published influences on severity. */ + context_factors?: string[]; + /** + * True when the result rests on a child reporting a risk to somebody + * else. Route it to your safeguarding process. + */ + third_party_concern?: boolean; + /** Human-readable; do not branch on it. */ + third_party_concern_reason?: string; /** Severity of the unsafe content */ severity: Severity; /** Confidence score (0-1) */ confidence: number; /** Risk score (0-1) */ risk_score: number; - /** Risk level derived from risk_score */ + /** @deprecated Not returned by the API; derive a level from `risk_score` or `severity`. */ risk_level?: 'none' | 'low' | 'medium' | 'high' | 'critical'; /** * Explanation of the analysis — this is what a moderator reads to triage diff --git a/src/types/settings.ts b/src/types/settings.ts index 79c2293..75c8ab1 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -1,3 +1,5 @@ +import { ConversationType } from '../constants.js'; + // ============================================================================= // Detection Settings // ============================================================================= @@ -29,6 +31,12 @@ export interface DetectionSettings { * the call itself always overrides this. */ default_flag_risk_terms?: boolean; + /** + * Account-level default for `context.conversationType`. A request that + * sets `conversationType` always overrides it; where it applies, results + * report `conversation_type_source: 'account_default'`. + */ + default_conversation_type?: ConversationType; /** All endpoints the API supports */ available_endpoints: string[]; /** ISO 8601 timestamp of the last settings update */ @@ -48,6 +56,11 @@ export interface UpdateDetectionSettingsInput { default_flag_profanity?: boolean; /** Account-level default for `flagRiskTerms` on `detectBullying`/`detectUnsafe`. */ default_flag_risk_terms?: boolean; + /** + * Account-level default for `context.conversationType`. Set it from your + * own platform configuration, never from end-user input. + */ + default_conversation_type?: ConversationType; } /** Result of updating detection settings. */ diff --git a/tests/textDetectionSync.test.ts b/tests/textDetectionSync.test.ts new file mode 100644 index 0000000..b5d48d1 --- /dev/null +++ b/tests/textDetectionSync.test.ts @@ -0,0 +1,284 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Tuteliq } from '../src/client.js'; +import { ValidationError } from '../src/errors.js'; +import { CONVERSATION_TYPES, Detection } from '../src/constants.js'; +import type { + AnalyseMultiResult, + BullyingResult, + CoerciveControlResult, + DetectionName, + GroomingResult, + NicknameResult, +} from '../src/index.js'; + +function mockFetchResponse(data: unknown) { + return { + ok: true, + status: 200, + json: async () => data, + headers: { get: () => null }, + } as unknown as Response; +} + +function sentBody(spy: ReturnType, call = 0): Record { + return JSON.parse((spy.mock.calls[call][1] as RequestInit).body as string); +} + +describe('Text detection: request fields', () => { + let client: Tuteliq; + + beforeEach(() => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('detectBullying sends attestation and message_id at the top level', async () => { + const spy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse({})); + await client.detectBullying({ content: 'hello', attestation: true, messageId: 'm-1' }); + const body = sentBody(spy); + expect(body.attestation).toBe(true); + expect(body.message_id).toBe('m-1'); + }); + + it('forwards an explicit attestation: false', async () => { + const spy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse({})); + await client.detectUnsafe({ content: 'hello', attestation: false }); + expect(sentBody(spy).attestation).toBe(false); + }); + + it('omits attestation and message_id when not set', async () => { + const spy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse({})); + await client.detectBullying({ content: 'hello' }); + const body = sentBody(spy); + expect(body).not.toHaveProperty('attestation'); + expect(body).not.toHaveProperty('message_id'); + }); + + it('detectUnsafe sends new_conversation with the continuation token', async () => { + const spy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse({})); + await client.detectUnsafe({ content: 'hello', continuationToken: 'tok', newConversation: true, messageId: 'm-2' }); + const body = sentBody(spy); + expect(body.continuation_token).toBe('tok'); + expect(body.new_conversation).toBe(true); + expect(body.message_id).toBe('m-2'); + }); + + it('detectGrooming sends include_relationship_frame, attestation, message_id and translated participants', async () => { + const spy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse({})); + await client.detectGrooming({ + messages: [{ role: 'adult', content: 'hi' }], + childAge: 12, + includeRelationshipFrame: true, + attestation: true, + messageId: 'm-3', + context: { sender: { role: 'adult' }, conversationType: 'gaming_chat' }, + }); + const body = sentBody(spy); + expect(body.options).toEqual({ include_relationship_frame: true }); + expect(body.attestation).toBe(true); + expect(body.message_id).toBe('m-3'); + expect(body.context).toEqual({ + child_age: 12, + platform: 'Node SDK', + sender: { role: 'adult' }, + conversation_type: 'gaming_chat', + }); + }); + + it('unified detectors send attestation, message_id and the declared context', async () => { + const spy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse({})); + await client.detectCoerciveControl({ + content: 'hello', + attestation: true, + messageId: 'm-4', + context: { minorDisclosureCheck: false, recipients: [{ relationshipToSender: 'peer' }] }, + }); + expect(sentBody(spy)).toEqual({ + text: 'hello', + context: { + platform: 'Node SDK', + minor_disclosure_check: false, + recipients: [{ relationship_to_sender: 'peer' }], + }, + attestation: true, + message_id: 'm-4', + }); + }); + + it('batch items translate the declared context like the single-item methods', async () => { + const spy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse({ results: [] })); + await client.batch({ + items: [{ type: 'bullying', content: 'hello', context: { conversationType: 'education' } }], + }); + expect(sentBody(spy).items[0].data.context).toEqual({ + platform: 'Node SDK', + conversation_type: 'education', + }); + }); + + it('updateDetectionSettings forwards default_conversation_type', async () => { + const spy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse({ success: true, settings: {}, message: '' })); + await client.updateDetectionSettings({ default_conversation_type: 'gaming_chat' }); + expect(sentBody(spy)).toEqual({ default_conversation_type: 'gaming_chat' }); + }); +}); + +describe('detectNickname', () => { + let client: Tuteliq; + + beforeEach(() => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const reading: NicknameResult = { + signal: 'none', + categories: [], + parts: [{ text: 'player', reading: 'ordinary', category: null }], + rationale: 'An ordinary gaming name.', + recommendation: 'allow', + recommendation_detail: 'No change needed.', + confidence: 0.9, + language: 'en', + strictness: 'balanced', + reading_complete: true, + credits_used: 1, + }; + + it('POSTs the name, strictness and snake_case context, with no platform', async () => { + const spy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(reading)); + const result = await client.detectNickname({ + name: 'xX_player_Xx', + strictness: 'cautious', + context: { + language: 'en', + ageGroup: '11-13', + conversationType: 'gaming_chat', + platformReserved: ['Support', 'Admin'], + }, + }); + expect(spy.mock.calls[0][0]).toBe('https://api.tuteliq.ai/api/v1/safety/nickname'); + expect((spy.mock.calls[0][1] as RequestInit).method).toBe('POST'); + expect(sentBody(spy)).toEqual({ + name: 'xX_player_Xx', + strictness: 'cautious', + context: { + language: 'en', + age_group: '11-13', + conversation_type: 'gaming_chat', + platform_reserved: ['Support', 'Admin'], + }, + }); + expect(result.recommendation).toBe('allow'); + expect(result.reading_complete).toBe(true); + }); + + it('sends only the name when nothing else is given', async () => { + const spy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(reading)); + await client.detectNickname({ name: 'sam', context: {} }); + expect(sentBody(spy)).toEqual({ name: 'sam' }); + }); + + it('rejects an empty name', async () => { + await expect(client.detectNickname({ name: ' ' })).rejects.toThrow(ValidationError); + }); + + it('rejects a name longer than 64 characters', async () => { + await expect(client.detectNickname({ name: 'a'.repeat(65) })).rejects.toThrow(ValidationError); + }); +}); + +describe('Text detection: results and constants', () => { + let client: Tuteliq; + + beforeEach(() => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('passes the shared and endpoint-specific result fields through', async () => { + const wire = { + endpoint: 'coercive-control', + detected: false, + severity: 0.2, + confidence: 0.8, + risk_score: 0.2, + level: 'low', + categories: [], + recommended_action: 'monitor', + language: 'en', + language_status: 'stable', + conversation_type_in_effect: 'family', + conversation_type_source: 'account_default', + attestation: null, + attestation_reason: 'nothing_detected', + speaker_role: 'experiencing', + disclosure_capped: true, + disclosure_capped_reason: 'Describes their own experience.', + }; + vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(wire)); + const result: CoerciveControlResult = await client.detectCoerciveControl({ content: 'hello' }); + expect(result.disclosure_capped).toBe(true); + expect(result.speaker_role).toBe('experiencing'); + expect(result.conversation_type_source).toBe('account_default'); + expect(result.attestation_reason).toBe('nothing_detected'); + }); + + it('types the reported-bullying and grooming status fields', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ is_bullying: false, bullying_type: [], bullying_reported: true, bullying_reported_by: 'bystander', recommended_action: 'flag_for_review' }), + ); + const bullying: BullyingResult = await client.detectBullying({ content: 'hello' }); + expect(bullying.bullying_reported_by).toBe('bystander'); + + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ grooming_risk: 'none', flags: [], analysis_status: 'engine_error', recommended_action: 'flag_for_review' }), + ); + const grooming: GroomingResult = await client.detectGrooming({ messages: [{ role: 'adult', content: 'hi' }] }); + expect(grooming.analysis_status).toBe('engine_error'); + }); + + it('analyseMulti returns the summary status fields and support', async () => { + const wire: AnalyseMultiResult = { + results: [], + summary: { + total_endpoints: 2, + detected_count: 0, + highest_risk: { endpoint: 'bullying', risk_score: 0.4, close_calls: [{ endpoint: 'unsafe', risk_score: 0.38 }] }, + overall_risk_level: 'low', + total_credits_used: 2, + analysis_status: 'partial', + recommended_action: 'flag_for_review', + failed_endpoints: [{ endpoint: 'unsafe', code: 'ANALYSIS_6000', error: 'Analysis failed' }], + }, + support: { helplines: [] }, + }; + vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(wire)); + const result = await client.analyseMulti({ content: 'hello', detections: ['bullying', Detection.UNSAFE] }); + expect(result.summary.analysis_status).toBe('partial'); + expect(result.summary.highest_risk.close_calls).toHaveLength(1); + expect(result.summary.failed_endpoints?.[0].endpoint).toBe('unsafe'); + expect(result.support).toEqual({ helplines: [] }); + }); + + it('Detection lists every endpoint analyseMulti accepts', () => { + const names: DetectionName[] = Object.values(Detection); + expect(names).toEqual(expect.arrayContaining(['distress-signals', 'tfgbv', 'synthetic-content'])); + expect(names).toHaveLength(14); + }); + + it('CONVERSATION_TYPES lists the 24 declared settings', () => { + expect(CONVERSATION_TYPES).toHaveLength(24); + expect(CONVERSATION_TYPES).toContain('gaming_chat'); + expect(CONVERSATION_TYPES).toContain('support_disclosure'); + }); +}); From 27ae25ab0a8736fe9152fa2fdec4b4ffac844846 Mon Sep 17 00:00:00 2001 From: safenestdev Date: Sun, 8 Nov 2026 08:52:21 +0100 Subject: [PATCH 3/3] docs(detection): describe escalation_capped generically, like the other result adjustments Also documents prompt_template_version on the signed attestation. --- src/types/detection-fields.ts | 1 + src/types/safety.ts | 16 ++++++---------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/types/detection-fields.ts b/src/types/detection-fields.ts index ead1d67..83f7b4c 100644 --- a/src/types/detection-fields.ts +++ b/src/types/detection-fields.ts @@ -65,6 +65,7 @@ export interface DetectionAttestation { detector: { model_id: string; model_version: string; + /** Version identifier included in the signed attestation. */ prompt_template_version: string; endpoint: string; }; diff --git a/src/types/safety.ts b/src/types/safety.ts index fbdaa4c..f9a5851 100644 --- a/src/types/safety.ts +++ b/src/types/safety.ts @@ -414,13 +414,11 @@ export interface BullyingResult extends TextDetectionFields { */ 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. + * Adjustment the API applied to this result, reported for transparency. + * Descriptive only: route on `recommended_action`. */ escalation_capped?: boolean; - /** Human-readable explanation of the cap. Present only when `escalation_capped` is true. */ + /** Human-readable text; do not branch on it. */ escalation_capped_reason?: string; /** * Present only when `flagProfanity` on this request (or the account-level @@ -756,13 +754,11 @@ export interface UnsafeResult extends TextDetectionFields { /** Per-turn severity, oldest first — the evidence behind `trajectory_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. + * Adjustment the API applied to this result, reported for transparency. + * Descriptive only: route on `recommended_action`. */ escalation_capped?: boolean; - /** Human-readable explanation of the cap. Present only when `escalation_capped` is true. */ + /** Human-readable text; do not branch on it. */ escalation_capped_reason?: string; /** * Present only when `flagProfanity` on this request (or the account-level