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
9 changes: 8 additions & 1 deletion packages/backend/src/ee/support/libredesk.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ export interface LibredeskMessage {
created_at?: string;
}

export interface LibredeskMention {
type: 'agent' | 'team';
id: number;
}

/** Minimal REST client for the endpoints the triage uses (API-key auth). */
export class LibredeskClient {
constructor(
Expand Down Expand Up @@ -67,11 +72,13 @@ export class LibredeskClient {
});
}

addPrivateNote(uuid: string, html: string) {
/** Mentioned agents and team members get an in-app and email notification. */
addPrivateNote(uuid: string, html: string, mentions: LibredeskMention[] = []) {
return this.call('POST', `/api/v1/conversations/${encodeURIComponent(uuid)}/messages`, {
message: html,
private: true,
sender_type: 'agent',
...(mentions.length ? { mentions } : {}),
});
}

Expand Down
21 changes: 18 additions & 3 deletions packages/backend/src/ee/support/support-triage.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,20 @@ describe('SupportTriageService', () => {
expect(desk.addPrivateNote).toHaveBeenCalledTimes(1);
});

it('routes dedicated instances to the Enterprise team', async () => {
it('routes dedicated instances to the Enterprise team and mentions it in the note', async () => {
const desk = fakeDesk([{ private: false, sender_type: 'contact', text_content: 'help' }]);
jest.spyOn(llm, 'chatJson').mockResolvedValue({ json: { category: 'bug', priority: 'Low' } });
await new TestTriage(desk).triage({ uuid: 'c2', contact: { custom_attributes: { deployment: 'dedicated' } } });
expect(desk.assignTeam).toHaveBeenCalledWith('c2', 2);
expect(desk.setPriority).toHaveBeenCalledWith('c2', 'High');
expect(desk.addPrivateNote).toHaveBeenCalledWith('c2', expect.any(String), [{ type: 'team', id: 2 }]);
});

it('does not mention a team for non-enterprise conversations', async () => {
const desk = fakeDesk([{ private: false, sender_type: 'contact', text_content: 'help' }]);
jest.spyOn(llm, 'chatJson').mockResolvedValue({ json: { category: 'bug', priority: 'Low' } });
await new TestTriage(desk).triage({ uuid: 'c7', contact: { custom_attributes: { deployment: 'cloud', plan: 'starter' } } });
expect(desk.addPrivateNote).toHaveBeenCalledWith('c7', expect.any(String), []);
});

it('still tags and annotates when the LLM is unavailable', async () => {
Expand All @@ -151,7 +159,7 @@ describe('SupportTriageService', () => {
expect(desk.addTags).toHaveBeenCalledTimes(1);
});

it('hands safe live chats to the AI assistant after the team, never billing or enterprise', async () => {
it('hands safe live chats to the AI assistant after the team, enterprise included, never billing', async () => {
const msgs = [{ private: false, sender_type: 'contact', text_content: 'help' }];
const chat = jest.spyOn(llm, 'chatJson');

Expand All @@ -174,7 +182,14 @@ describe('SupportTriageService', () => {
chat.mockResolvedValueOnce({ json: { category: 'connector-setup', priority: 'Low' } });
const ent = fakeDesk(msgs);
await new TestTriage(ent, undefined, 5).triage({ uuid: 'a3', inbox_channel: 'livechat', contact: { custom_attributes: { deployment: 'dedicated' } } });
expect(ent.assignUser).not.toHaveBeenCalled();
expect(ent.assignTeam).toHaveBeenCalledWith('a3', 2);
expect(ent.assignUser).toHaveBeenCalledWith('a3', 5);
expect(ent.addPrivateNote).toHaveBeenCalledWith('a3', expect.any(String), [{ type: 'team', id: 2 }]);

chat.mockResolvedValueOnce({ json: { category: 'billing', priority: 'Low' } });
const entBilling = fakeDesk(msgs);
await new TestTriage(entBilling, undefined, 5).triage({ uuid: 'a6', inbox_channel: 'livechat', contact: { custom_attributes: { deployment: 'cloud', plan: 'enterprise' } } });
expect(entBilling.assignUser).not.toHaveBeenCalled();

chat.mockResolvedValueOnce({ json: { category: 'connector-setup', priority: 'Low' } });
const email = fakeDesk(msgs);
Expand Down
19 changes: 10 additions & 9 deletions packages/backend/src/ee/support/support-triage.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ export const TRIAGE_CATEGORIES = [
export type TriageCategory = (typeof TRIAGE_CATEGORIES)[number];

/**
* Categories the AI assistant may answer on its own. Billing, bugs, feature
* requests and sales always go to a person.
* Categories the AI assistant may answer on its own, enterprise customers
* included. Billing, bugs, feature requests and sales always go to a person.
*/
export const AI_HANDLED_CATEGORIES: readonly TriageCategory[] = [
'connector-setup',
Expand Down Expand Up @@ -162,18 +162,21 @@ export class SupportTriageService {
) {
const enterprise = isEnterprise(attrs);
const assistant = this.aiAssistantUserId();
const toAssistant = routeToAssistant(r, attrs, channel, assistant);
const toAssistant = routeToAssistant(r, channel, assistant);
const tags = [r.category, ...segmentTags(attrs), 'ai-triaged'];
if (toAssistant) tags.push('ai-assistant');
let teamId: number | undefined;
const steps: Array<[string, () => Promise<unknown>]> = [
['tags', () => desk.addTags(uuid, tags)],
['priority', () => desk.setPriority(uuid, r.priority)],
// Team first: Libredesk clears the user assignee when the team changes.
['team', async () => {
const id = await this.teamId(desk, enterprise ? 'Enterprise' : 'Support');
if (id) await desk.assignTeam(uuid, id);
teamId = await this.teamId(desk, enterprise ? 'Enterprise' : 'Support');
if (teamId) await desk.assignTeam(uuid, teamId);
}],
['note', () => desk.addPrivateNote(uuid, triageNote(r, attrs))],
// Mentioning the Enterprise team notifies its members at once, even
// when the assistant answers first.
['note', () => desk.addPrivateNote(uuid, triageNote(r, attrs), enterprise && teamId ? [{ type: 'team', id: teamId }] : [])],
];
if (toAssistant && assistant) {
// Assigning the assistant starts its reply; it hands back to the
Expand Down Expand Up @@ -235,17 +238,15 @@ export function normaliseAttrs(raw: unknown): Record<string, string> {
return out;
}

/** Live chat, a category the assistant handles, and never enterprise. */
/** Live chat in a category the assistant handles. */
export function routeToAssistant(
r: TriageResult,
attrs: Record<string, string>,
channel: string | undefined,
assistantUserId: number | null,
): boolean {
return (
!!assistantUserId &&
channel === 'livechat' &&
!isEnterprise(attrs) &&
AI_HANDLED_CATEGORIES.includes(r.category)
);
}
Expand Down
Loading