From c0fc48f7ac6e47680bc22ac550501eb60506c570 Mon Sep 17 00:00:00 2001 From: "helpcode.ai" <340500827+helpcodeai@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:05:11 +0200 Subject: [PATCH] Give support triage the catalog and the last AI call The triage only saw the workspace's own connectors, so it told two customers we had no Telegram connector when the catalog has one, and it handed out the OAuth redirect URI for bot-token connectors. It now gets the catalog connectors named in the messages, the installed connectors' names and the day of the last successful tool call ("never" = no AI client connected), with rules for each. --- .../src/ee/support/support-catalog.spec.ts | 26 +++++++++++ .../backend/src/ee/support/support-catalog.ts | 44 +++++++++++++++++++ .../support/support-identity.service.spec.ts | 18 ++++++++ .../ee/support/support-identity.service.ts | 22 ++++++++++ .../ee/support/support-triage.service.spec.ts | 3 ++ .../src/ee/support/support-triage.service.ts | 17 +++++-- 6 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 packages/backend/src/ee/support/support-catalog.spec.ts create mode 100644 packages/backend/src/ee/support/support-catalog.ts 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 = [