diff --git a/packages/backend/src/ee/support/support-catalog.spec.ts b/packages/backend/src/ee/support/support-catalog.spec.ts new file mode 100644 index 00000000..f46c4238 --- /dev/null +++ b/packages/backend/src/ee/support/support-catalog.spec.ts @@ -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/); + }); +}); diff --git a/packages/backend/src/ee/support/support-catalog.ts b/packages/backend/src/ee/support/support-catalog.ts new file mode 100644 index 00000000..c2f07159 --- /dev/null +++ b/packages/backend/src/ee/support/support-catalog.ts @@ -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(', ') + ); +} diff --git a/packages/backend/src/ee/support/support-identity.service.spec.ts b/packages/backend/src/ee/support/support-identity.service.spec.ts index 6b86291b..6aa74bc7 100644 --- a/packages/backend/src/ee/support/support-identity.service.spec.ts +++ b/packages/backend/src/ee/support/support-identity.service.spec.ts @@ -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'); diff --git a/packages/backend/src/ee/support/support-identity.service.ts b/packages/backend/src/ee/support/support-identity.service.ts index c6729bca..302de6a8 100644 --- a/packages/backend/src/ee/support/support-identity.service.ts +++ b/packages/backend/src/ee/support/support-identity.service.ts @@ -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; /** @@ -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 { + 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). @@ -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) { diff --git a/packages/backend/src/ee/support/support-triage.service.spec.ts b/packages/backend/src/ee/support/support-triage.service.spec.ts index 9bbf82e6..220cc6ee 100644 --- a/packages/backend/src/ee/support/support-triage.service.spec.ts +++ b/packages/backend/src/ee/support/support-triage.service.spec.ts @@ -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); diff --git a/packages/backend/src/ee/support/support-triage.service.ts b/packages/backend/src/ee/support/support-triage.service.ts index ec5a1e39..f4c40a72 100644 --- a/packages/backend/src/ee/support/support-triage.service.ts +++ b/packages/backend/src/ee/support/support-triage.service.ts @@ -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'; @@ -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 /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", @@ -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 /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 @@ -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'); @@ -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; @@ -305,8 +315,9 @@ export function triageNote(r: TriageResult, attrs: Record): 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 = [