chore: CRM-NEW baseline
This commit is contained in:
@@ -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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/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 };
|
||||
Reference in New Issue
Block a user