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
26 changes: 26 additions & 0 deletions packages/backend/src/ee/support/support-catalog.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { catalogMentions, catalogMentionsLine } from './support-catalog';

describe('catalogMentions', () => {
it('finds a catalog connector named in a message, in any language', () => {
expect(catalogMentions('Please connect with telegram').map((m) => m.slug)).toContain('telegram-bot');
expect(catalogMentions('Как подключить Telegram бот?').map((m) => m.slug)).toContain('telegram-bot');
});

it('needs every word of a multi-word name', () => {
const slugs = catalogMentions('my google account').map((m) => m.slug);
expect(slugs).not.toContain('google-ads');
expect(catalogMentions('Google Ads report').map((m) => m.slug)).toContain('google-ads');
});

it('finds nothing in a message that names no app', () => {
expect(catalogMentions('Что мне делать?')).toEqual([]);
expect(catalogMentions('')).toEqual([]);
});

it('names the setup page in the prompt line', () => {
expect(catalogMentionsLine([{ slug: 'telegram-bot', name: 'Telegram Bot' }])).toContain(
'Telegram Bot (/connectors/setup/telegram-bot)',
);
expect(catalogMentionsLine([])).toMatch(/none found/);
});
});
44 changes: 44 additions & 0 deletions packages/backend/src/ee/support/support-catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { listAdapters } from '../../adapters/catalog';
import { searchTerms } from '../../adapters/catalog-search';

/** At most this many catalog connectors are named to the triage. */
const MAX_MENTIONS = 6;

export interface CatalogMention {
slug: string;
name: string;
}

/**
* Listed catalog connectors a customer names in their messages: every content
* word of the connector's name (or of one alias) appears in the text, so
* "Telegram" finds Telegram Bot and "Google Ads" finds Google Ads, but
* "Google" alone does not pull in every Google connector. The triage used to
* see only the workspace's own connectors and answered "we have no Telegram
* connector" when the catalog has one.
*/
export function catalogMentions(text: string): CatalogMention[] {
const words = new Set(searchTerms(text));
if (!words.size) return [];
const out: CatalogMention[] = [];
for (const a of listAdapters()) {
if (a.unlisted) continue;
const names = [a.name, ...(a.aliases ?? [])];
const named = names.some((n) => {
const terms = searchTerms(n).filter((w) => w.length > 2);
return terms.length > 0 && terms.every((w) => words.has(w));
});
if (named) out.push({ slug: a.slug, name: a.name });
if (out.length >= MAX_MENTIONS) break;
}
return out;
}

/** One line for the triage prompt. */
export function catalogMentionsLine(mentions: CatalogMention[]): string {
if (!mentions.length) return 'Catalog connectors named in the messages: none found.';
return (
'Catalog connectors named in the messages (we have them; setup page per connector): ' +
mentions.map((m) => `${m.name} (/connectors/setup/${m.slug})`).join(', ')
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,24 @@ describe('SupportIdentityService', () => {
expect(attrs.connector_issues).not.toContain('FAKE-not-a-real-credential-0000');
});

it('names the connectors and the day of the last successful tool call', async () => {
const { svc, prisma } = build();
prisma.toolInvocation.findFirst.mockImplementation(async (q: any) =>
q.where.status === 'SUCCESS' ? { createdAt: new Date('2026-10-20T08:00:00Z') } : null,
);
const attrs = await svc.contactAttributes('u1', 'org1');
expect(attrs.connector_names).toContain('Etsy');
expect(attrs.last_tool_call).toBe('2026-10-20');
const q = prisma.toolInvocation.findFirst.mock.calls.find((c: any[]) => c[0].where.status === 'SUCCESS')[0];
expect(q.where.organizationId).toBe('org1');
});

it('says "never" when no AI client has called a tool yet', async () => {
const { svc, prisma } = build();
prisma.toolInvocation.findFirst.mockResolvedValue(null);
expect((await svc.contactAttributes('u1', 'org1')).last_tool_call).toBe('never');
});

it('filters every query by the caller organization', async () => {
const { svc, prisma, license } = build();
await svc.contactAttributes('u1', 'org1');
Expand Down
22 changes: 22 additions & 0 deletions packages/backend/src/ee/support/support-identity.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const CONTEXT_CACHE_MAX = 5_000;
const JWT_TTL_SECONDS = 60 * 60;
const ERROR_WINDOW_MS = 24 * 60 * 60_000;
const MAX_ISSUES = 5;
const MAX_NAMES = 15;
const ISSUES_MAX_CHARS = 900;

/**
Expand Down Expand Up @@ -116,10 +117,24 @@ export class SupportIdentityService {

const inactive = connectors.filter((c) => !c.isActive).length;
attrs.connectors = inactive ? `${connectors.length} (${inactive} inactive)` : String(connectors.length);
if (connectors.length) attrs.connector_names = connectorNames(connectors);
attrs.connector_issues = await this.connectorIssues(organizationId, connectors);
// "never" with connectors installed is the most common blocker: no AI
// client is connected to the workspace's MCP server yet.
attrs.last_tool_call = await this.lastToolCall(organizationId);
return attrs;
}

/** Day of the workspace's latest successful tool call, or "never". */
private async lastToolCall(organizationId: string): Promise<string> {
const last = await this.prisma.toolInvocation.findFirst({
where: { organizationId, status: 'SUCCESS' },
orderBy: { createdAt: 'desc' },
select: { createdAt: true },
});
return last?.createdAt instanceof Date ? last.createdAt.toISOString().slice(0, 10) : 'never';
}

/**
* One line per connector that needs attention: an auth problem, or calls
* that failed in the last 24 hours with the latest error (redacted).
Expand Down Expand Up @@ -162,6 +177,13 @@ export class SupportIdentityService {
}
}

/** The installed connectors' names, bounded. */
export function connectorNames(connectors: Array<{ name: string }>): string {
const names = connectors.slice(0, MAX_NAMES).map((c) => c.name.slice(0, 60));
const more = connectors.length - names.length;
return names.join(', ') + (more > 0 ? ` and ${more} more` : '');
}

export function splitName(name: string | null | undefined, email: string): { first: string; last: string } {
const clean = (name || '').trim().replace(/\s+/g, ' ');
if (clean) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ describe('SupportTriageService', () => {
expect(userPrompt).toContain('My Etsy connector says unauthorized');
expect(userPrompt).not.toContain('internal');
expect(userPrompt).not.toContain('org-secret');
// The catalog connectors the customer names, so the reply never says we lack one.
expect(userPrompt).toContain('Etsy (/connectors/setup/etsy)');
expect(chat.mock.calls[0][1]).toMatch(/never used to connect an AI assistant/);
expect(desk.addTags).toHaveBeenCalledWith('c1', ['connector-error', 'cloud', 'trial', 'ai-triaged']);
expect(desk.setPriority).toHaveBeenCalledWith('c1', 'Medium');
expect(desk.assignTeam).toHaveBeenCalledWith('c1', 1);
Expand Down
17 changes: 14 additions & 3 deletions packages/backend/src/ee/support/support-triage.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { chatJson, resolveLlmConfig, LlmConfig } from '../../knowledge-graph/llm-client';
import { redactPii } from '../../knowledge-graph/redact';
import { catalogMentions, catalogMentionsLine } from './support-catalog';
import { LibredeskClient } from './libredesk.client';
import { supportTriageConfig } from './support-config';
import { redactSecrets } from './support-redact';
Expand Down Expand Up @@ -63,7 +64,7 @@ const SYSTEM_PROMPT = `You triage support conversations for AnythingMCP, a produ

Typical problems: OAuth apps whose redirect URI is not set to <instance URL>/api/mcp-oauth/callback; API keys pasted in the wrong field or with spaces; expired or revoked tokens ("needs_reauth"); the AI client not connected to the MCP URL; trial ended; plan or invoice questions; requests for a connector we do not have yet.

You receive the customer's messages and a snapshot of their workspace (plan, trial end, connectors, connector issues of the last 24 hours). Return ONLY a JSON object:
You receive the customer's messages, a snapshot of their workspace (plan, trial end, connectors and their names, connector issues of the last 24 hours, last_tool_call: the day of the latest successful tool call or "never") and the catalog connectors the messages name. Return ONLY a JSON object:
{
"category": one of ${TRIAGE_CATEGORIES.map((c) => `"${c}"`).join(', ')},
"priority": "Low" | "Medium" | "High",
Expand All @@ -74,7 +75,13 @@ You receive the customer's messages and a snapshot of their workspace (plan, tri

Priority: High when the customer cannot use the product at all, for billing problems, and for enterprise customers; Low for feature and connector requests; otherwise Medium.

Rules for suggested_reply: short, friendly, concrete next steps; use the workspace snapshot when it explains the problem; never promise refunds, cancellations, discounts, deadlines or features; never offer a call or meeting; for billing write that a person will check and reply; do not invent settings or URLs you are not sure of; no em dashes.`;
Rules for suggested_reply: short, friendly, concrete next steps; use the workspace snapshot when it explains the problem; never promise refunds, cancellations, discounts, deadlines or features; never offer a call or meeting; for billing write that a person will check and reply; do not invent settings or URLs you are not sure of; no em dashes.

Facts to get right:
- When the catalog line names a connector, we have it: never write that we do not; point to its setup page. Only when it names none and the workspace has none may you say a person will check whether we support the app.
- Connectors installed but last_tool_call "never" means no AI assistant is connected yet: the next step is adding the MCP server URL (MCP Servers page) to the assistant, e.g. in Claude Customize → Connectors → Add custom connector.
- The redirect URI <instance URL>/api/mcp-oauth/callback is only for connectors that sign in with OAuth, entered in the provider's developer console. It is never used to connect an AI assistant, for API-key or bot-token connectors (Telegram, for example), or for webhooks.
- If the customer pasted a password, token or key, say once that it is now visible in the chat and should be revoked or regenerated, and that new ones go only into the connector page.`;

/**
* Tags, prioritises and annotates every new support conversation. Runs only
Expand Down Expand Up @@ -140,6 +147,7 @@ export class SupportTriageService {
const user = [
`Channel: ${channel || 'unknown'}`,
`Workspace snapshot: ${JSON.stringify(snapshot)}`,
catalogMentionsLine(catalogMentions(customerText)),
'Customer messages:',
redactPii(redactSecrets(customerText)).slice(0, MAX_CUSTOMER_TEXT),
].join('\n');
Expand Down Expand Up @@ -220,7 +228,9 @@ const KNOWN_ATTRS = [
'trial_ends_at',
'role',
'connectors',
'connector_names',
'connector_issues',
'last_tool_call',
'signed_up_at',
'email_verified',
] as const;
Expand Down Expand Up @@ -305,8 +315,9 @@ export function triageNote(r: TriageResult, attrs: Record<string, string>): stri
['Organization', attrs.organization_name],
['Plan', [attrs.plan, attrs.license_status].filter(Boolean).join(', ')],
['Trial ends', attrs.trial_ends_at],
['Connectors', attrs.connectors],
['Connectors', [attrs.connectors, attrs.connector_names].filter(Boolean).join(': ')],
['Connector issues', attrs.connector_issues],
['Last AI tool call', attrs.last_tool_call],
['Email verified', attrs.email_verified],
].filter(([, v]) => v);
const parts = [
Expand Down
Loading