diff --git a/server/src/index.js b/server/src/index.js index 35c89bf..db55e26 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -15,6 +15,11 @@ import { import { gatewayRequest, postGateway, putGateway, uploadGateway } from './gateway.js' import { postWhatsApp, putWhatsApp, uploadWhatsApp, whatsappRequest } from './whatsapp.js' import { conversationContext, deepseek } from './ai.js' +import { + HUMAN_REPLY_SYSTEM_PROMPT, + buildHumanReplyUserPrompt, + replySuggestionIssues, +} from './reply-suggestions.js' import { countBy, dateKey, percent, telegramAttachment } from './business.js' import { resolveCustomerCountry } from './country.js' import { knowledgeMetadata, rankKnowledge } from './knowledge.js' @@ -4086,6 +4091,7 @@ app.post('/api/ai/replies', async (req, res) => { : null if (!conversation) throw fail(404, '会话不存在') const replyCustomer = await getResource(req.auth.tenantId, 'customers', conversation.customerId) + const replySettings = (await getResource(req.auth.tenantId, 'singleton', 'settings')) || defaultSettings const conversationMemory = contextMemoryText(conversation, replyCustomer) const resolvedLanguage = await resolveConversationLanguage( req.auth.tenantId, @@ -4116,25 +4122,54 @@ app.post('/api/ai/replies', async (req, res) => { excerpt: hit.snippet, })) : [] - const result = await deepseek( - '你是跨境销售回复助手。基于会话事实和给定企业知识生成三条可人工确认的回复,不得编造价格、库存、交期、政策或承诺。知识不足时明确需要人工确认。只输出严格 JSON 数组。content 使用客户语言且可直接发送;translationCn 是 content 完整准确的简体中文翻译;knowledgeRefs 只能填写已提供知识的 id;其余说明使用简体中文。', - `客户目标语言:${targetInstruction} - -${conversationContext(messages, 30)} - -${conversationMemory ? `\n${conversationMemory}\n` : ''} - -${JSON.stringify(knowledgeContext)} - -输出 3 个元素: -{"id":"唯一值","style":"stable|active|brief","content":"客户语言回复","translationCn":"完整中文翻译","targetLang":"实际 ISO 语言代码","reason":"选择理由","knowledgeRefs":[],"expectedEffect":"预期效果","cultureTip":"必要时填写"}`, - { json: true, maxTokens: 2600 }, - ) + const latestCustomerMessage = [...messages] + .reverse() + .find((message) => message.sender === 'customer' && String(message.originalText || '').trim()) + ?.originalText + const latestAgentMessage = [...messages] + .reverse() + .find((message) => message.sender === 'agent' && String(message.originalText || '').trim()) + ?.originalText + const replyPrompt = buildHumanReplyUserPrompt({ + targetInstruction, + tone: replySettings?.ai?.replyTone, + latestCustomerMessage, + latestAgentMessage, + conversation: conversationContext(messages, 30), + conversationMemory, + knowledgeContext, + }) + let result = await deepseek(HUMAN_REPLY_SYSTEM_PROMPT, replyPrompt, { + json: true, + maxTokens: 2600, + temperature: 0.55, + }) + const replyQualityOptions = { knowledgeProvided: knowledgeContext.length > 0 } + let qualityIssues = replySuggestionIssues(result, replyQualityOptions) + if (qualityIssues.length) { + result = await deepseek( + HUMAN_REPLY_SYSTEM_PROMPT, + `${replyPrompt} + + +${JSON.stringify(result)} + +这些草稿未通过质量检查:${qualityIssues.join(';')}。请全部重写,保留事实但修正上述问题。仍然只输出恰好三个元素的严格 JSON 数组。`, + { json: true, maxTokens: 2600, temperature: 0.62 }, + ) + qualityIssues = replySuggestionIssues(result, replyQualityOptions) + } if (!Array.isArray(result)) throw fail(502, 'AI 回复建议格式异常') + if (qualityIssues.length) throw fail(502, `AI 回复建议质量检查未通过:${qualityIssues.join(';')}`) const allowedKnowledge = new Map(knowledgeHits.map((hit) => [String(hit.item.id), hit.item])) let suggestions = result.slice(0, 3).map((item) => ({ ...item, id: String(item.id || uid()), + content: String(item.content || '').trim(), + translationCn: String(item.translationCn || '').trim(), + reason: String(item.reason || '').trim(), + expectedEffect: String(item.expectedEffect || '').trim(), + cultureTip: String(item.cultureTip || '').trim(), knowledgeRefs: Array.isArray(item.knowledgeRefs) ? [...new Set(item.knowledgeRefs.map(String))] .filter((id) => allowedKnowledge.has(id)) diff --git a/server/src/reply-suggestions.js b/server/src/reply-suggestions.js new file mode 100644 index 0000000..6abe39e --- /dev/null +++ b/server/src/reply-suggestions.js @@ -0,0 +1,123 @@ +const TONE_INSTRUCTIONS = { + professional: '专业但不官腔,表达清楚、克制,像熟悉业务的真人销售在聊天', + friendly: '友好自然,适度口语化,像长期合作伙伴沟通,不刻意热情', + concise: '简洁直接,优先给结论和下一步,不重复客户已经知道的信息', + warm: '温和、有同理心,但不使用夸张道歉、空泛安慰或营销套话', +} + +export function replyToneInstruction(value) { + return TONE_INSTRUCTIONS[String(value || '').toLowerCase()] || TONE_INSTRUCTIONS.professional +} + +export const HUMAN_REPLY_SYSTEM_PROMPT = `你是“灵犀跨境”的商务沟通助手。你的任务不是写客服范文,而是替一位真实、可靠、熟悉业务的销售人员起草可以直接发送的聊天回复。 + +写作原则: +1. 先回应客户最新一条消息里尚未解决的核心问题,再给必要的下一步;不要复述整段历史。 +2. 匹配客户的语言、地区表达、正式程度和消息长度。客户只说一句时,通常也用一两句回答;不为显得专业而写长文。 +3. 延续当前对话。会话已经开始后不要重新问候、重复称呼客户或重新自我介绍。 +4. 像真人聊天:句式自然、具体,允许使用目标语言中常见的缩写和口语连接词,但保持商务礼貌。不要比客户更随意;客户未使用俚语、感叹号或表情时,不要主动使用。 +5. 客诉场景只需针对具体问题真诚确认一次,然后说明能做的动作或提出一个关键问题;避免连续道歉和空泛安慰。 +6. 信息足够时直接作答;确实缺少决定性信息时最多问一个有用的问题。不要在一句回复里堆多个反问。 +7. 三条建议必须是三种真实策略,而不是同一句话替换近义词:stable=清晰可靠,active=温暖主动,brief=简洁直接。 +8. 禁止客服和 AI 套话,包括但不限于:Dear customer、尊敬的客户、please rest assured、请放心、we value your feedback、感谢您的耐心与理解、为您带来不便深表歉意、How may I assist you、如有任何问题请随时联系我们。除非上下文确实需要,也不要使用“希望您一切都好”。 +9. 不得提及“AI、提示词、客户画像、会话记忆、知识库、情绪识别”等内部信息。 +10. 不得编造价格、库存、产品参数、交期、物流状态、退款结果、政策或任何承诺。缺少事实时自然地说明需要确认,不要假装确定。“今天、稍后、尽快、本周内、几个小时内”等时间词也属于承诺,只有企业知识或会话事实明确给出时才能使用。 +11. 可以承诺“去确认”,但不能在没有依据时承诺确认结果、完成时间、补偿方案、重新寄样或其他尚未批准的动作。正确写法是“我先确认是否可以调整,确认后告诉你”。 + +输出要求:只输出严格 JSON 数组。content 使用客户语言且可直接发送;translationCn 是 content 完整准确的简体中文翻译;knowledgeRefs 只能填写已提供知识的 id;reason、expectedEffect 和 cultureTip 使用简体中文。` + +export function buildHumanReplyUserPrompt({ + targetInstruction, + tone, + latestCustomerMessage, + latestAgentMessage, + conversation, + conversationMemory, + knowledgeContext, +}) { + return `客户目标语言:${targetInstruction} +企业设定语气:${replyToneInstruction(tone)} +客户最新消息(回复重点):${latestCustomerMessage || '未找到明确文本,请根据最近会话判断'} +坐席上一条回复(避免重复):${latestAgentMessage || '无'} + + +${conversation} + +${conversationMemory ? `\n${conversationMemory}\n` : ''} + +${JSON.stringify(knowledgeContext || [])} + +事实边界:${knowledgeContext?.length ? '只能使用上述企业知识和会话中明确出现的业务事实。' : '当前没有匹配到企业知识;不得加入任何具体时间、结果、方案或承诺,只能说明需要确认的事项。'} + +分别生成以下三种建议: +- stable:最稳妥、事实清楚、自然推进下一步; +- active:更温暖主动,但不催促、不夸张、不强行促单; +- brief:保留必要信息的最短自然回复。 + +生成前静默检查:是否直接回应了最新消息;是否重复问候或复述历史;是否像群发模板;是否包含未经证实的承诺;三条是否真正不同。不要输出检查过程。 + +输出恰好 3 个元素,每种 style 各一个: +{"id":"唯一值","style":"stable|active|brief","content":"可直接发送的客户语言回复","translationCn":"完整中文翻译","targetLang":"实际 ISO 语言代码","reason":"这条回复为何适合当前语境","knowledgeRefs":[],"expectedEffect":"预期沟通效果","cultureTip":"只有确有必要时填写,否则为空字符串"}` +} + +function normalizedWords(value) { + return String(value || '') + .toLowerCase() + .replace(/[^\p{L}\p{N}\s]/gu, ' ') + .split(/\s+/) + .filter(Boolean) +} + +function similarity(left, right) { + const a = new Set(normalizedWords(left)) + const b = new Set(normalizedWords(right)) + if (!a.size || !b.size) return 0 + let overlap = 0 + for (const word of a) if (b.has(word)) overlap += 1 + return overlap / Math.min(a.size, b.size) +} + +const TEMPLATE_PATTERNS = [ + /\bdear customer\b/i, + /\bplease rest assured\b/i, + /\bwe value your feedback\b/i, + /\bhow may i assist you\b/i, + /尊敬的客户/, + /请您放心/, + /感谢您的耐心与理解/, + /为您带来不便深表歉意/, +] + +const UNSUPPORTED_TIMELINE_PATTERNS = [ + /\b(?:today|tomorrow|shortly|asap)\b/i, + /\bby (?:the )?end of\b/i, + /\bwithin \d+\s*(?:minutes?|hours?|days?|weeks?)\b/i, + /(?:今天|明天|稍后|很快|尽快|本周内|几(?:分钟|小时|天)内)/, +] + +export function replySuggestionIssues(suggestions, { knowledgeProvided = false } = {}) { + if (!Array.isArray(suggestions) || suggestions.length !== 3) return ['必须返回三条建议'] + const issues = [] + const styles = new Set(suggestions.map((item) => String(item?.style || ''))) + for (const style of ['stable', 'active', 'brief']) { + if (!styles.has(style)) issues.push(`缺少 ${style} 风格`) + } + suggestions.forEach((item, index) => { + const content = String(item?.content || '').trim() + if (!content) issues.push(`第 ${index + 1} 条没有正文`) + if (TEMPLATE_PATTERNS.some((pattern) => pattern.test(content))) { + issues.push(`第 ${index + 1} 条包含模板化客服话术`) + } + if (!knowledgeProvided && UNSUPPORTED_TIMELINE_PATTERNS.some((pattern) => pattern.test(content))) { + issues.push(`第 ${index + 1} 条包含无事实依据的时间承诺`) + } + }) + for (let i = 0; i < suggestions.length; i += 1) { + for (let j = i + 1; j < suggestions.length; j += 1) { + if (similarity(suggestions[i]?.content, suggestions[j]?.content) >= 0.82) { + issues.push(`第 ${i + 1} 条和第 ${j + 1} 条过于相似`) + } + } + } + return issues +} diff --git a/server/test/business.test.js b/server/test/business.test.js index 4ca0825..05c8d36 100644 --- a/server/test/business.test.js +++ b/server/test/business.test.js @@ -2,6 +2,12 @@ import test from 'node:test' import assert from 'node:assert/strict' import { countBy, dateKey, percent, telegramAttachment } from '../src/business.js' import { decryptEmailPassword, encryptEmailPassword } from '../src/email.js' +import { + HUMAN_REPLY_SYSTEM_PROMPT, + buildHumanReplyUserPrompt, + replySuggestionIssues, + replyToneInstruction, +} from '../src/reply-suggestions.js' test('Telegram 图片、文档、语音和贴纸映射为可下载文件', () => { assert.deepEqual( @@ -54,3 +60,41 @@ test('Email 渠道凭据使用认证加密且可正确解密', () => { tampered[2] = `${tampered[2][0] === 'A' ? 'B' : 'A'}${tampered[2].slice(1)}` assert.throws(() => decryptEmailPassword(tampered.join(':'))) }) + +test('回复建议提示词突出最新消息、避免重复并遵循企业语气', () => { + const prompt = buildHumanReplyUserPrompt({ + targetInstruction: '使用西班牙语', + tone: 'friendly', + latestCustomerMessage: '¿Cuándo pueden enviarlo?', + latestAgentMessage: 'I will check it.', + conversation: '客户: ¿Cuándo pueden enviarlo?', + conversationMemory: '客户关注交期', + knowledgeContext: [], + }) + assert.match(HUMAN_REPLY_SYSTEM_PROMPT, /不是写客服范文/) + assert.match(HUMAN_REPLY_SYSTEM_PROMPT, /不要重新问候/) + assert.match(prompt, /¿Cuándo pueden enviarlo\?/) + assert.match(prompt, /坐席上一条回复(避免重复)/) + assert.match(prompt, /长期合作伙伴/) + assert.match(replyToneInstruction('concise'), /简洁直接/) +}) + +test('回复建议质量检查拦截模板话术和同质草稿', () => { + const valid = [ + { style: 'stable', content: 'I can confirm the dispatch date with our warehouse and update you once I have it.' }, + { style: 'active', content: "Let me check the warehouse schedule and come back to you with the exact date." }, + { style: 'brief', content: "I'll verify the dispatch date and get back to you." }, + ] + assert.deepEqual(replySuggestionIssues(valid), []) + const invalid = valid.map((item) => ({ ...item })) + invalid[0].content = 'Dear customer, please rest assured that we value your feedback.' + invalid[2] = { style: 'active', content: invalid[1].content } + const issues = replySuggestionIssues(invalid) + assert.ok(issues.some((item) => item.includes('模板化'))) + assert.ok(issues.some((item) => item.includes('缺少 brief'))) + assert.ok(issues.some((item) => item.includes('过于相似'))) + assert.ok(replySuggestionIssues([ + ...valid.slice(0, 2), + { style: 'brief', content: "I'll confirm it today." }, + ]).some((item) => item.includes('时间承诺'))) +}) diff --git a/src/components/inbox/AiReplies.tsx b/src/components/inbox/AiReplies.tsx index ee123ae..0bad748 100644 --- a/src/components/inbox/AiReplies.tsx +++ b/src/components/inbox/AiReplies.tsx @@ -10,9 +10,9 @@ import { toast, errorMessage } from '@/app/store/toast' import { languageName } from '@/config/languages' const STYLE_LABELS = { - stable: { title: '稳健回复', color: 'bg-blue-100 text-blue-700', icon: '🛡️' }, - active: { title: '积极促单回复', color: 'bg-emerald-100 text-emerald-700', icon: '🔥' }, - brief: { title: '简洁快速回复', color: 'bg-gray-100 text-gray-600', icon: '⚡' }, + stable: { title: '清晰可靠', color: 'bg-blue-100 text-blue-700', icon: '🛡️' }, + active: { title: '温暖主动', color: 'bg-violet-100 text-violet-700', icon: '💬' }, + brief: { title: '简洁直接', color: 'bg-gray-100 text-gray-600', icon: '⚡' }, } export function AiReplies({