Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,16 @@ export class Tuteliq {
if (typeof context === 'string') {
return { platform: Tuteliq.resolvePlatform(context) };
}
return { ...context, platform: Tuteliq.resolvePlatform(context.platform) };
// priorMessages (idiomatic camelCase, matching every other field on
// 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;
return {
...rest,
platform: Tuteliq.resolvePlatform(context.platform),
...(priorMessages && { prior_messages: priorMessages }),
};
}

/**
Expand Down Expand Up @@ -834,6 +843,8 @@ export class Tuteliq {
...(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 }),
}
);
Expand Down
61 changes: 61 additions & 0 deletions src/types/safety.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,18 @@ export function strongestAction(
return best;
}

/**
* One prior turn of a conversation, for `ContextInput.priorMessages`.
*/
export interface PriorMessage {
/** Sender's role in this conversation (e.g. "user", "contact", "adult", "child") */
role: string;
/** Message text */
text: string;
/** ISO 8601 timestamp of the message (optional) */
timestamp?: string;
}

/**
* Context type - can be a string shorthand or detailed object
*/
Expand All @@ -119,6 +131,18 @@ export type ContextInput = string | {
platform?: string;
/** ISO 3166-1 alpha-2 country code (e.g., "GB", "US") for geo-localised helpline data */
country?: string;
/**
* Prior turns of this conversation, oldest first, submitted for THIS
* request only — never stored server-side, used once to build the
* analysis prompt for this call and then discarded. Lets a single call
* see the trajectory a gradually-building risk needs, without calling
* once per message. Supported by `detectUnsafe` and the fraud/coercive
* control/distress-signals endpoint family; ignored (harmlessly) by
* endpoints with no multi-turn content field of their own, e.g.
* `detectBullying` (use its `continuationToken` instead) and
* `detectGrooming` (which takes a required `messages` array directly).
*/
priorMessages?: PriorMessage[];
};

// =============================================================================
Expand Down Expand Up @@ -500,6 +524,20 @@ export interface DetectUnsafeInput extends TrackingFields {
context?: ContextInput;
/** Minimum severity to show crisis support resources (default: 'high'). Critical always shows. */
supportThreshold?: 'low' | 'medium' | 'high' | 'critical';
/**
* Opaque signed token returned by a prior /unsafe call. Carries derived
* conversation-trajectory state (category counts, severity history) into
* the next call without storing any user content server-side. Pass back
* verbatim to maintain multi-turn awareness across calls -- the
* alternative to submitting the whole conversation via
* `context.priorMessages` on every call.
*/
continuationToken?: string;
/**
* If true, discard any provided continuationToken and start a fresh
* conversation. Useful when starting a new chat in the same session.
*/
resetConversation?: boolean;
/**
* Fast mode. When true, the response omits any per-message
* `message_analysis` breakdown and returns only the verdict. Lower latency
Expand Down Expand Up @@ -572,6 +610,29 @@ export interface UnsafeResult {
customer_id?: string;
/** Echo of provided metadata (if any) */
metadata?: Record<string, unknown>;
/**
* Opaque signed token carrying derived analysis state to the next call.
* Pass back as `continuationToken` on the next /unsafe request to
* preserve multi-turn awareness without server-side content storage.
*/
continuation_token?: string;
/** ISO 8601 expiry timestamp of the continuation_token. */
continuation_expires_at?: string;
/**
* How prior state was sourced: "token" (decoded from a continuationToken),
* "fresh" (no prior state), "reset" (resetConversation forced a restart).
*/
state_source?: 'token' | 'fresh' | 'reset';
/**
* Conversation-level risk (0-1), derived from the signed continuation
* token. Distinct from `risk_score`, which scores only this request's
* content. Absent on the first turn of a fresh conversation.
*/
trajectory_risk?: number;
/** Direction of travel across the conversation so far. Absent alongside `trajectory_risk`. */
trajectory?: ConversationTrajectory;
/** 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
Expand Down
65 changes: 65 additions & 0 deletions tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,71 @@ describe('Tuteliq', () => {
expect(result.unsafe).toBe(true);
expect(result.categories).toContain('self_harm');
});

it('should translate context.priorMessages to prior_messages on the wire', async () => {
const mockResponse = { unsafe: false, categories: [] };

vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(mockResponse));

await tuteliq.detectUnsafe({
content: "anyway it's whatever, not a big deal",
context: {
ageGroup: '14-17',
priorMessages: [
{ role: 'user', text: 'nothing I do matters anymore' },
{ role: 'user', text: 'been avoiding everyone' },
],
},
});

const call = vi.mocked(fetch).mock.calls[0];
const body = JSON.parse(call[1]?.body as string);
expect(body.context.prior_messages).toEqual([
{ role: 'user', text: 'nothing I do matters anymore' },
{ role: 'user', text: 'been avoiding everyone' },
]);
// The idiomatic camelCase name must never leak onto the wire —
// the API only recognizes the snake_case key.
expect(body.context.priorMessages).toBeUndefined();
expect(body.context.ageGroup).toBe('14-17');
});

it('omits prior_messages entirely when priorMessages is not provided', async () => {
const mockResponse = { unsafe: false, categories: [] };

vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(mockResponse));

await tuteliq.detectUnsafe({ content: 'a plain message' });

const call = vi.mocked(fetch).mock.calls[0];
const body = JSON.parse(call[1]?.body as string);
expect(body.context).not.toHaveProperty('prior_messages');
});

it('passes continuationToken and resetConversation through to the request body', async () => {
const mockResponse = {
unsafe: false,
categories: [],
continuation_token: 'signed-token-abc',
trajectory_risk: 0.4,
trajectory: 'stable',
};

vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(mockResponse));

const result = await tuteliq.detectUnsafe({
content: 'a follow-up message',
continuationToken: 'prior-token-xyz',
resetConversation: true,
});

const call = vi.mocked(fetch).mock.calls[0];
const body = JSON.parse(call[1]?.body as string);
expect(body.continuation_token).toBe('prior-token-xyz');
expect(body.reset_conversation).toBe(true);
expect(result.continuation_token).toBe('signed-token-abc');
expect(result.trajectory_risk).toBe(0.4);
});
});

describe('analyze', () => {
Expand Down