chore: CRM-NEW baseline

This commit is contained in:
wangliang
2026-10-23 10:35:28 +08:00
commit aa499f6dbf
111 changed files with 15126 additions and 0 deletions
+327
View File
@@ -0,0 +1,327 @@
'use strict';
const { createLLM } = require('./llm');
const { createLibreDeskClient } = require('./libredesk');
const { createTools, resolveSiteProfile } = require('./tools');
const { buildSystemPrompt } = require('./prompts');
const { createVerification } = require('./verification');
const { createRetriever } = require('./rag');
const { createMetrics } = require('./metrics');
const { auditAnswer } = require('./audit');
const { markdownToHtml } = require('./markdown');
const { guardReply } = require('./output-guard');
const { quickClassify, detectEmotion, INTENTS } = require('./intent');
const { createContextManager } = require('./context');
const { detectInjection, markToolResult, INJECTION_CAUTION_PROMPT } = require('./security');
const { hasBuyingIntent, missingLeadContacts, detectLanguage, appendLeadFollowup, alreadyAsksForLead } = require('./lead-intent');
const { createLlmCache } = require('./llm-cache');
const crypto = require('crypto');
const MAX_TOOL_STEPS = 4;
function stripHtml(html) {
return String(html || '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/\s+/g, ' ')
.trim();
}
function createAgent(cfg, deps = {}) {
const llm = deps.llm || createLLM(cfg);
const client = deps.client || createLibreDeskClient(cfg);
const verification = deps.verification || createVerification(cfg);
const retriever = deps.retriever || (cfg.ragPgUrl ? createRetriever(cfg) : null);
const metrics = deps.metrics || createMetrics(cfg);
const contextManager = deps.contextManager || createContextManager();
const llmCache = deps.llmCache || createLlmCache(cfg);
const tools = deps.tools || createTools(cfg, { verification, retriever, client });
const leadStore = deps.leadStore || (tools && tools.leadStore) || null;
const byName = new Map(tools.map((t) => [t.name, t]));
const toolDefs = tools.map((t) => ({
type: 'function',
function: { name: t.name, description: t.description, parameters: t.parameters },
}));
const locks = new Map();
async function identityFor(conv) {
const contact = conv.contact || {};
// 方案 A 保留 LibreDesk JWT+OTP:JWT 登录联系人 type=contact,视为已验证。
if (contact.type === 'contact' && contact.email) {
return { verified: true, email: String(contact.email).trim().toLowerCase() };
}
const email = await verification.getVerifiedEmail(conv.uuid);
return { verified: Boolean(email), email: email || '' };
}
async function buildMessages(conv, currentMessage) {
const history = await client.getMessages(conv.uuid, 60);
const sorted = history
.slice()
.sort((a, b) => Number(a.id) - Number(b.id))
.filter((m) => m && !m.private && m.type !== 'activity' && m.sender_type)
.map((m) => {
const text = (m.text_content || stripHtml(m.content)).trim();
if (!text) return null;
return { role: m.sender_type === 'contact' ? 'user' : 'assistant', content: text };
})
.filter(Boolean);
// 确保当前消息在最后(webhook 可能先于 history 可见)
const currentText = (currentMessage.text_content || stripHtml(currentMessage.content)).trim();
if (currentText && (!sorted.length || sorted[sorted.length - 1].content !== currentText || sorted[sorted.length - 1].role !== 'user')) {
sorted.push({ role: 'user', content: currentText });
} else if (currentText && sorted[sorted.length - 1].role !== 'user') {
sorted.push({ role: 'user', content: currentText });
}
return contextManager.compressHistory(conv.uuid, sorted, llm);
}
async function runOnce(conv, webhookMessage) {
if (!llm.enabled) throw new Error('LLM is not configured');
const startedAt = Date.now();
const stats = { llmCalls: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, toolCalls: [], toolResults: [] };
const profile = resolveSiteProfile(cfg, { inboxId: String(conv.inbox_id || '') });
const identity = await identityFor(conv);
const ctx = {
verified: identity.verified,
email: identity.email,
inboxId: String(conv.inbox_id || ''),
conversationUuid: conv.uuid,
siteProfile: profile,
};
const currentText = (webhookMessage.text_content || stripHtml(webhookMessage.content)).trim();
const messages = await buildMessages(conv, webhookMessage);
if (!messages.length) return null;
const injection = detectInjection(currentText);
let systemContent = buildSystemPrompt({
verified: ctx.verified,
email: ctx.email,
site: profile.site,
channel: conv.channel || 'livechat',
}) + (injection.level === 'high' ? INJECTION_CAUTION_PROMPT : '');
// 购买意向:LLM 之前先给硬指令,LLM 之后还有程序化兜底
let leadState = null;
if (leadStore) {
try { leadState = await leadStore.get(conv.uuid); } catch { leadState = null; }
}
const preSlots = (leadState && leadState.slots) || {};
const buyingIntent = hasBuyingIntent(currentText);
const preMissing = missingLeadContacts(preSlots, ctx.email);
if (buyingIntent && preMissing.length) {
systemContent += `\n\nLEAD COMPLETION (hard rule): the customer shows buying intent but we still miss ${preMissing.join(', ')}. At the END of your reply, ask naturally for the missing piece(s) in the customer's language. Do not end without asking.`;
}
messages.unshift({ role: 'system', content: systemContent });
if (injection.level === 'high') {
console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'injection_detected', conversation: conv.uuid, matches: injection.matches.length }));
}
// 预检索:政策问题先查 KB,商品问题先查商品,避免模型跳过工具直接编。
const intent = quickClassify(currentText);
const emotion = detectEmotion(currentText);
if (emotion !== 'neutral') {
const toneLine = emotion === 'angry'
? 'TONE: The customer is angry. Acknowledge their frustration, be empathetic, and escalate/hand off if needed.'
: emotion === 'frustrated'
? 'TONE: The customer is frustrated. Be patient and reassuring.'
: 'TONE: The customer is urgent. Be direct and concise.';
messages[0].content += `\n\n${toneLine}`;
}
let preResult = null;
try {
let preTool = null;
if (intent === INTENTS.POLICY_FAQ) preTool = byName.get('search_kb');
else if (intent === INTENTS.PRODUCT_SEARCH) preTool = byName.get('search_products');
if (preTool) {
const started = Date.now();
preResult = await preTool.handler({ query: currentText, limit: 5 }, ctx);
stats.toolCalls.push({ name: preTool.name, ms: Date.now() - started, ok: true, forced: true });
stats.toolResults.push(preResult);
console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'tool', tool: preTool.name, forced: true, ms: Date.now() - started, conversation: conv.uuid, ok: true }));
messages.splice(1, 0, {
role: 'system',
content: `Pre-retrieved tool result for this customer message (${preTool.name}). Use it as the primary source; if it is insufficient, call tools again.\n${markToolResult(preTool.name, JSON.stringify(preResult).slice(0, 6000))}`,
});
}
} catch (err) {
console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'pre_tool_error', conversation: conv.uuid, error: err.message }));
}
const lang = /[\u3400-\u9fff]/.test(currentText) ? 'zh' : 'en';
const systemHash = crypto.createHash('sha256').update(systemContent).digest('hex').slice(0, 24);
let kbFingerprint = '';
if (preResult && Array.isArray(preResult.results)) {
kbFingerprint = crypto.createHash('sha256').update(preResult.results.map((h) => `${h.question}|${(h.source_urls || []).join(',')}`).join('\n')).digest('hex').slice(0, 24);
}
let finalContent = '';
if (intent === INTENTS.POLICY_FAQ && llmCache.enabled) {
const cached = await llmCache.get({
query: currentText,
intent,
verifiedScope: ctx.verified ? 'verified' : 'anonymous',
lang,
kbFingerprint,
systemHash,
});
if (cached) {
const outbound = /<(p|br|a|ul|ol|strong|div)\b/i.test(cached) ? cached : markdownToHtml(cached);
await client.sendMessage(conv.uuid, outbound, { echoId: `bridge-ai-${webhookMessage.uuid}` });
await metrics.record({
conversationUuid: conv.uuid, inboxId: conv.inbox_id, channel: conv.channel || 'livechat',
verified: ctx.verified, model: llm.model, latencyMs: Date.now() - startedAt,
llmCalls: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0,
toolCalls: stats.toolCalls, audit: { ...auditAnswer({ finalContent: cached, toolResults: stats.toolResults, toolCalls: stats.toolCalls }), cache_hit: true },
replyLength: cached.length,
});
console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'cache_hit', conversation: conv.uuid, length: cached.length }));
return cached;
}
}
for (let step = 0; step < MAX_TOOL_STEPS; step += 1) {
const response = await llm.chat({ messages, tools: toolDefs, temperature: 0.3, maxTokens: 1200 });
stats.llmCalls += 1;
if (response.usage) {
stats.promptTokens += Number(response.usage.prompt_tokens) || 0;
stats.completionTokens += Number(response.usage.completion_tokens) || 0;
stats.totalTokens += Number(response.usage.total_tokens) || 0;
}
if (response.toolCalls && response.toolCalls.length) {
messages.push({ role: 'assistant', content: response.content || '', tool_calls: response.toolCalls });
for (const call of response.toolCalls) {
const name = call.function && call.function.name;
const tool = byName.get(name);
let result;
const started = Date.now();
try {
if (!tool) throw new Error(`unknown tool: ${name}`);
const args = call.function.arguments ? JSON.parse(call.function.arguments) : {};
// verify_email_code 成功后,后续工具要立刻看到 verified/email 变化
const latest = await identityFor(conv);
ctx.verified = latest.verified;
ctx.email = latest.email;
result = await tool.handler(args, ctx);
} catch (err) {
result = { error: err.code || err.message || 'tool_failed', message: err.message || String(err) };
}
stats.toolCalls.push({ name, ms: Date.now() - started, ok: !(result && result.error) });
stats.toolResults.push(result);
console.log(JSON.stringify({
ts: new Date().toISOString(),
agent: 'tool',
tool: name,
ms: Date.now() - started,
conversation: conv.uuid,
verified: ctx.verified,
ok: !(result && result.error),
}));
messages.push({ role: 'tool', tool_call_id: call.id, content: markToolResult(name, JSON.stringify(result).slice(0, 6000)) });
}
continue;
}
finalContent = String(response.content || '').trim();
break;
}
if (!finalContent) {
finalContent = '抱歉,我这边暂时无法处理,请您稍后再试。';
}
// post-LLM 兜底:LLM 没问公司/联系方式时,代码强制追加多语言追问
let leadFollowup = false;
try {
if (buyingIntent && leadStore) {
const latest = await leadStore.get(conv.uuid);
const latestSlots = (latest && latest.slots) || {};
const missing = missingLeadContacts(latestSlots, ctx.email);
if (missing.length && !alreadyAsksForLead(finalContent)) {
const lang = detectLanguage(currentText);
finalContent = appendLeadFollowup(finalContent, lang);
leadFollowup = true;
console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'lead_followup', conversation: conv.uuid, lang, missing }));
}
}
} catch (err) {
console.error(JSON.stringify({ ts: new Date().toISOString(), warn: `lead followup failed: ${err.message}` }));
}
// 发送前硬护栏:伪造下单/邮件/订单号、无工具证据的库存价格、非工具 URL
const guard = guardReply({
content: finalContent,
toolResults: stats.toolResults,
toolCalls: stats.toolCalls,
lang: detectLanguage(currentText),
});
if (guard.blocked || guard.removed_urls.length) {
console.log(JSON.stringify({
ts: new Date().toISOString(),
agent: 'output_guard',
conversation: conv.uuid,
blocked: guard.blocked,
rewritten: guard.rewritten,
reasons: guard.reasons,
removed_urls: guard.removed_urls,
evidence_missing: guard.evidence_missing,
}));
}
finalContent = guard.content;
const alreadyHtml = /<(p|br|a|ul|ol|strong|div)\b/i.test(finalContent);
const outboundContent = alreadyHtml ? finalContent : markdownToHtml(finalContent);
await client.sendMessage(conv.uuid, outboundContent, { echoId: `bridge-ai-${webhookMessage.uuid}` });
if (intent === INTENTS.POLICY_FAQ && llmCache.enabled) {
await llmCache.store({
query: currentText,
intent,
verifiedScope: ctx.verified ? 'verified' : 'anonymous',
lang,
kbFingerprint,
systemHash,
reply: finalContent,
});
}
const audit = auditAnswer({ finalContent, toolResults: stats.toolResults, toolCalls: stats.toolCalls });
audit.injection_level = injection.level;
audit.lead_followup = leadFollowup;
audit.output_guard = { blocked: guard.blocked, rewritten: guard.rewritten, reasons: guard.reasons, removed_urls: guard.removed_urls, evidence_missing: guard.evidence_missing };
await metrics.record({
conversationUuid: conv.uuid,
inboxId: conv.inbox_id,
channel: conv.channel || 'livechat',
verified: ctx.verified,
model: llm.model,
latencyMs: Date.now() - startedAt,
llmCalls: stats.llmCalls,
promptTokens: stats.promptTokens,
completionTokens: stats.completionTokens,
totalTokens: stats.totalTokens,
toolCalls: stats.toolCalls,
audit,
replyLength: finalContent.length,
});
console.log(JSON.stringify({ ts: new Date().toISOString(), agent: 'replied', conversation: conv.uuid, length: finalContent.length, audit }));
return finalContent;
}
async function run(conv, webhookMessage) {
const key = String(conv.uuid);
const previous = locks.get(key) || Promise.resolve();
const current = previous
.catch(() => {})
.then(() => runOnce(conv, webhookMessage));
locks.set(key, current);
try {
return await current;
} finally {
if (locks.get(key) === current) locks.delete(key);
}
}
return { run, tools: byName, toolDefs };
}
module.exports = { createAgent };