feat(ai): make reply suggestions natural and context-aware

This commit is contained in:
wmq
2026-08-02 01:55:22 +08:00
parent ad126606f9
commit bb696c7de8
4 changed files with 219 additions and 17 deletions
+49 -14
View File
@@ -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}
<conversation>
${conversationContext(messages, 30)}
</conversation>
${conversationMemory ? `<conversation_memory>\n${conversationMemory}\n</conversation_memory>` : ''}
<enterprise_knowledge>
${JSON.stringify(knowledgeContext)}
</enterprise_knowledge>
输出 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}
<rejected_drafts>
${JSON.stringify(result)}
</rejected_drafts>
这些草稿未通过质量检查:${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))
+123
View File
@@ -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>
${conversation}
</conversation>
${conversationMemory ? `<conversation_memory>\n${conversationMemory}\n</conversation_memory>` : ''}
<enterprise_knowledge>
${JSON.stringify(knowledgeContext || [])}
</enterprise_knowledge>
事实边界:${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
}
+44
View File
@@ -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('时间承诺')))
})
+3 -3
View File
@@ -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({