const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const { spawn } = require('child_process'); const RISK_PATTERN = /合同|签约|产权|学区资格|保证|承诺|最低价|贷款|利率|首付|投诉|退款|发票|身份证|银行卡|法律|违约/; const NO_REPLY_NEEDED_PATTERN = /^(?:收到|好|好的|好嘞|明白|明白了|知道了|了解|了解了|谢谢|谢谢你|谢谢您|感谢|ok|okay|嗯+|哦+)$/i; function isNoReplyNeededMessage(input = '') { const normalized = String(input || '').trim().replace(/[,。!?!?,.~~]+$/g, '').trim(); return Boolean(normalized && NO_REPLY_NEEDED_PATTERN.test(normalized)); } function claudeSessionResetReason(error) { const detail = `${error?.message || ''} ${error?.subtype || ''}`; if (/error_max_budget_usd|max[-_\s]?budget[-_\s]?usd/i.test(detail)) return 'budget_exceeded'; if (/session|conversation|resume/i.test(detail)) return 'session_invalid'; return ''; } function parseClaudeProcessResult(stdout = '', stderr = '', code = 0) { let payload = null; try { payload = JSON.parse(String(stdout || '').trim()); } catch {} if (code === 0) { return payload ? { payload } : { error: `Claude Code 未返回有效 JSON${stderr ? ',请检查 Fmode 配置' : ''}` }; } const structured = payload?.structured_output; const usableStructuredOutput = structured && typeof structured === 'object' && !Array.isArray(structured) && Object.prototype.hasOwnProperty.call(structured, 'reply') && Object.prototype.hasOwnProperty.call(structured, 'confidence') && Object.prototype.hasOwnProperty.call(structured, 'intent'); const safeError = String(stderr || '') .replace(/Bearer\s+\S+/gi, 'Bearer [REDACTED]') .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, 'sk-[REDACTED]') .trim() .slice(0, 300); const structuredError = String(payload?.error || payload?.message || payload?.result || payload?.subtype || '').trim().slice(0, 300); const detail = safeError || structuredError || `退出码 ${code}`; if (usableStructuredOutput) { return { payload: { ...payload, is_error: false, process_warning: { exitCode: code, detail }, }, }; } return { error: `Claude Code 调用失败(退出码 ${code})${detail ? `:${detail}` : ''}` }; } class AgentNotConfiguredError extends Error { constructor() { super('Agent 模型尚未配置,消息已保留但不会生成伪造回复'); this.name = 'AgentNotConfiguredError'; } } class OpenAICompatibleClient { constructor(config) { this.config = config; } async complete(messages, tools) { if (!this.config.apiKey) throw new AgentNotConfiguredError(); const body = { model: this.config.model, temperature: 0.2, messages, }; if (Array.isArray(tools) && tools.length) { body.tools = tools; body.tool_choice = 'auto'; } const response = await fetch(`${this.config.baseUrl}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.config.apiKey}` }, body: JSON.stringify(body), signal: AbortSignal.timeout(30000), }); const payload = await response.json().catch(() => ({})); if (!response.ok) throw new Error(`Agent 上游暂时不可用(HTTP ${response.status})`); const message = payload.choices?.[0]?.message; if (!message) throw new Error('Agent 上游没有返回有效消息'); return message; } } class AnthropicCompatibleClient { constructor(config) { this.config = config; } toAnthropicMessages(messages) { return messages.filter(message => message.role !== 'system').map(message => { if (message.role === 'tool') { return { role: 'user', content: [{ type: 'tool_result', tool_use_id: message.tool_call_id, content: message.content }] }; } if (message.role === 'assistant' && message.tool_calls?.length) { const content = []; if (message.content) content.push({ type: 'text', text: message.content }); for (const call of message.tool_calls) { let input = {}; try { input = JSON.parse(call.function.arguments || '{}'); } catch {} content.push({ type: 'tool_use', id: call.id, name: call.function.name, input }); } return { role: 'assistant', content }; } return { role: message.role, content: message.content }; }); } async complete(messages, tools) { if (!this.config.apiKey) throw new AgentNotConfiguredError(); const system = messages.find(message => message.role === 'system')?.content || ''; const body = { model: this.config.model, max_tokens: 1400, temperature: 0.2, system, messages: this.toAnthropicMessages(messages), }; if (Array.isArray(tools) && tools.length) { body.tools = tools.map(tool => ({ name: tool.function.name, description: tool.function.description, input_schema: tool.function.parameters, })); } const response = await fetch(`${this.config.baseUrl}/v1/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01', 'x-api-key': this.config.apiKey, Authorization: `Bearer ${this.config.apiKey}`, }, body: JSON.stringify(body), signal: AbortSignal.timeout(45000), }); const payload = await response.json().catch(() => ({})); if (!response.ok) throw new Error(`Agent 上游暂时不可用(HTTP ${response.status})`); const blocks = Array.isArray(payload.content) ? payload.content : []; const toolCalls = blocks.filter(block => block.type === 'tool_use').map(block => ({ id: block.id, function: { name: block.name, arguments: JSON.stringify(block.input || {}) }, })); return { content: blocks.filter(block => block.type === 'text').map(block => block.text).join('\n'), ...(toolCalls.length ? { tool_calls: toolCalls } : {}), }; } } function readJson(filePath, fallback = {}) { try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return fallback; } } function writeJsonAtomic(filePath, value) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); const tempPath = `${filePath}.${process.pid}.tmp`; fs.writeFileSync(tempPath, JSON.stringify(value, null, 2), 'utf8'); fs.renameSync(tempPath, filePath); } function resolveClaudeExecutable(config = {}) { const candidates = [ config.claudeExecutable, process.env.CLAUDE_CODE_EXECUTABLE, path.join(path.dirname(process.execPath), 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'), path.join(process.env.APPDATA || '', 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'), ].filter(Boolean); return candidates.find(candidate => fs.existsSync(candidate)) || ''; } function normalizeSessionLabel(value) { return String(value || '企微客户') .trim() .replace(/[\\/:*?"<>|\r\n]+/g, '-') .replace(/\s+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') .slice(0, 24) || '企微客户'; } function buildClaudeSessionName(context = {}, key = '') { const customerName = normalizeSessionLabel( context.conversation?.contact_name || context.conversation?.displayName || '企微客户' ); const reference = crypto.createHash('sha256').update(String(key || 'qiwei-default')).digest('hex').slice(0, 4); return `企微客户-${customerName}-${reference}`; } function selectAuthoritativeHistory(messages = [], limit = 10) { const history = messages.filter(message => message.role !== 'system'); const latestInbound = history.findLastIndex(message => message.role === 'user'); if (latestInbound < 0) return history.slice(-limit); const previousOutbound = history.slice(0, latestInbound).findLastIndex(message => message.role === 'assistant'); const start = previousOutbound >= 0 ? previousOutbound : Math.max(0, history.length - limit); return history.slice(start).slice(-limit); } function uniqueIntelligence(items = [], keyFn) { const seen = new Set(); return items.filter(item => { const key = keyFn(item); if (!key || seen.has(key)) return false; seen.add(key); return true; }); } function normalizedEvidence(value) { return String(value || '').toLowerCase().replace(/[\s,。!?;:、,.!?;:'"“”‘’()()【】\[\]-]+/g, ''); } function evidenceIsSupported(evidence, authoritativeText) { const source = normalizedEvidence(authoritativeText); const claim = normalizedEvidence(evidence); if (!source || !claim) return false; return source.includes(claim) || (source.length >= 4 && claim.includes(source)); } function supportedModelProfileUpdates(updates = {}, authoritativeText = '') { if (!updates || typeof updates !== 'object' || Array.isArray(updates)) return {}; const explicitNumbers = new Set(String(authoritativeText || '').match(/\d+(?:\.\d+)?/g) || []); return Object.fromEntries(Object.entries(updates).filter(([, value]) => { if (value === undefined || value === null || value === '') return false; const values = Array.isArray(value) ? value : [value]; return values.every(item => typeof item === 'number' ? explicitNumbers.has(String(item)) : evidenceIsSupported(String(item), authoritativeText)); })); } function unsupportedAttributedClaims(final = {}, authoritativeHistory = []) { const output = `${String(final.reply || '')}\n${String(final.reason || '')}`; const source = authoritativeHistory.map(item => item.content || '').join('\n'); const claims = []; const marker = /(?:您|客户)(?:之前|此前|刚才)?(?:曾经)?(?:提到|说过|说|表示)/g; for (const match of output.matchAll(marker)) { const tail = output.slice((match.index || 0) + match[0].length, (match.index || 0) + match[0].length + 100); const quoted = tail.match(/^\s*[::]?\s*["“‘']([^"”’'\r\n]{2,60})["”’']/); if (quoted && !evidenceIsSupported(quoted[1], source)) claims.push(quoted[1].trim()); } return [...new Set(claims)]; } function groundedConfirmationReply(profile = {}, inboundContent = '') { const region = profile.preferredRegion || profile.region || profile.district || ''; const layout = profile.layout || profile.roomType || ''; const budget = Number(profile.budgetWan || profile.budget || 0); const confirmed = [region, layout, budget ? `${budget}万预算` : ''].filter(Boolean).join('、'); const opening = confirmed ? `收到,我先按${confirmed}继续整理。` : '收到,您刚才的信息我已经记录。'; const questions = []; if (budget && (!profile.budgetType || profile.budgetType === '待确认')) questions.push(`这${budget}万是总价预算还是首付预算`); if (!profile.purpose) questions.push('主要用于自住还是投资'); if (!profile.timeline) questions.push('希望什么时候购置'); if (!questions.length) return `${opening}我会先核对可用方案,再给您准确回复。`; return `${opening}为了避免理解偏差,想再确认一下:${questions.join(';')}?`; } function enforceAuthoritativeGrounding(final = {}, authoritativeHistory = [], profile = {}, inboundContent = '') { const unsupported = unsupportedAttributedClaims(final, authoritativeHistory); if (!unsupported.length) return { ...final, groundingWarnings: [] }; return { ...final, reply: groundedConfirmationReply(profile, inboundContent), reason: `检测到模型引用了本轮有效会话中不存在的客户原话,已降级为确认式草稿。未支持内容:${unsupported.join('、')}`, confidence: Math.min(clamp(final.confidence), 0.68), requiresHuman: true, groundingWarnings: unsupported, }; } function extractExplicitCustomerIntelligence(content, currentProfile = {}, modelOutput = {}) { const text = String(content || '').trim(); const profileUpdates = supportedModelProfileUpdates(modelOutput.profileUpdates, text); const amount = text.match(/(\d+(?:\.\d+)?)\s*万/); if (amount && (/预算|总价|首付/.test(text) || /^\s*\d+(?:\.\d+)?\s*万(?:吧|左右|上下|以内|起)?\s*[。!!??]*$/.test(text))) { profileUpdates.budgetWan = Number(amount[1]); profileUpdates.budgetType = /首付/.test(text) ? '首付' : /总价/.test(text) ? '总价' : (currentProfile.budgetType || '待确认'); } const region = text.match(/([\p{Script=Han}]{2,8}(?:区|市|镇|板块))/u); if (region) { let regionValue = region[1].replace(/^.*(?:想咨询一下|咨询一下|了解一下|考虑在|想在|咨询|了解|看看|一下)/, ''); if (regionValue.length > 5 && !regionValue.endsWith('板块')) regionValue = regionValue.slice(-4); profileUpdates.preferredRegion = regionValue; } const layout = text.match(/([一二三四五六七八九十两\d]+)\s*室/); if (layout) profileUpdates.layout = `${layout[1]}室`; if (/自己住|自住|婚房|改善/.test(text)) profileUpdates.purpose = /自己住|自住/.test(text) ? '自住' : text.match(/婚房|改善/)?.[0]; if (/投资|出租|收租/.test(text)) profileUpdates.purpose = '投资'; if (/今天|明天|本周|这周|尽快|马上|急/.test(text)) profileUpdates.urgency = '高'; const mergedProfile = { ...currentProfile, ...profileUpdates }; const factEvidence = [ mergedProfile.budgetWan || mergedProfile.budget ? `预算${mergedProfile.budgetWan || mergedProfile.budget}万` : '', mergedProfile.preferredRegion || mergedProfile.region || mergedProfile.area ? `区域${mergedProfile.preferredRegion || mergedProfile.region || mergedProfile.area}` : '', mergedProfile.layout || mergedProfile.roomType ? `户型${mergedProfile.layout || mergedProfile.roomType}` : '', mergedProfile.purpose ? `用途${mergedProfile.purpose}` : '', mergedProfile.timeline ? `时间${mergedProfile.timeline}` : '', ].filter(Boolean).join(',').slice(0, 240); const evidence = text.slice(0, 240); const tasks = Array.isArray(modelOutput.tasks) ? modelOutput.tasks.filter(item => evidenceIsSupported(item?.evidence, text)).map(item => ({ ...item, businessKey: item.businessKey || item.key, managedBy: 'agent', })) : []; const alerts = Array.isArray(modelOutput.alerts) ? modelOutput.alerts.filter(item => evidenceIsSupported(item?.evidence, text)).map(item => ({ ...item, businessKey: item.businessKey || item.key, managedBy: 'agent', })) : []; const hasBudget = Number(mergedProfile.budgetWan || mergedProfile.budget || 0) > 0; const hasRegion = Boolean(mergedProfile.preferredRegion || mergedProfile.region || mergedProfile.area); const hasLayout = Boolean(mergedProfile.layout || mergedProfile.roomType); const hasPurpose = Boolean(mergedProfile.purpose); const hasTimeline = Boolean(mergedProfile.timeline || mergedProfile.purchaseTime || mergedProfile.purchase_time); if (hasBudget && (!hasPurpose || !hasTimeline)) { tasks.push({ businessKey: 'qualification:purpose_and_timeline', managedBy: 'rule', type: 'qualification', title: '确认客户用途与购置时间', owner: '待分配', dueAt: '', priority: hasRegion || hasLayout ? 'high' : 'medium', reason: `客户已经给出预算,但${[!hasPurpose ? '用途' : '', !hasTimeline ? '购置时间' : ''].filter(Boolean).join('和')}仍不明确。`, evidence: factEvidence || evidence, }); } if (hasBudget && hasRegion && hasLayout) { tasks.push({ businessKey: 'recommendation:shortlist', managedBy: 'rule', type: 'recommendation', title: '按已确认条件筛选并发送重点方案', owner: '待分配', dueAt: '', priority: 'high', reason: '预算、区域和户型三项核心条件已经明确。', evidence: factEvidence || evidence, }); alerts.push({ businessKey: 'high_intent:core_demand_ready', managedBy: 'rule', type: 'high_intent', severity: 'high', title: '客户核心需求已基本成形', detail: '预算、区域和户型信息已具备,可以从泛咨询进入重点方案或下一步转化。', evidence: factEvidence || evidence, recommendedAction: '优先核对用途和时间计划,再给出少量重点方案。', }); } if (/投诉|不满意|骗人|退款|举报|再也不|太差|生气/.test(text)) { alerts.push({ businessKey: 'complaint:manual_takeover', managedBy: 'event', type: 'complaint', severity: 'critical', title: '检测到投诉或强烈负面情绪', detail: '该消息不应由自动回复独立处理。', evidence, recommendedAction: '立即人工接管,先确认事实和客户诉求。', }); } else if (/今天|明天|本周|这周|尽快|马上|急/.test(text)) { alerts.push({ businessKey: 'time_sensitive:follow_up', managedBy: 'event', type: 'time_sensitive', severity: 'high', title: '客户表达了明确时效要求', detail: '消息中包含较紧迫的时间表达。', evidence, recommendedAction: '优先确认具体截止时间并安排跟进。', }); } return { profileUpdates, tasks: uniqueIntelligence(tasks, item => `${item?.businessKey || item?.key || ''}|${item?.type || ''}|${item?.title || ''}`.toLowerCase()).slice(0, 8), alerts: uniqueIntelligence(alerts, item => `${item?.businessKey || item?.key || ''}|${item?.type || ''}|${item?.title || ''}`.toLowerCase()).slice(0, 6), }; } class ClaudeCodeSessionStore { constructor(filePath, project = {}) { this.filePath = filePath; this.project = project; } loadState() { const state = readJson(this.filePath, { version: 1, project: {}, sessions: {} }); state.version = 1; state.sessions ||= {}; state.project ||= {}; state.project.projectId = this.project.projectId || state.project.projectId || 'qiwei-project'; state.project.projectRoot = this.project.projectRoot || state.project.projectRoot || ''; state.project.controllerSessionId = this.project.mainSessionId || state.project.controllerSessionId || crypto.randomUUID(); state.project.boundMainSessionId = this.project.mainSessionId || state.project.boundMainSessionId || null; return state; } get(key) { return this.loadState().sessions?.[key] || null; } ensure(key, metadata = {}) { const state = this.loadState(); if (!state.sessions[key]) { state.sessions[key] = { id: crypto.randomUUID(), initialized: false, role: 'customer-agent', projectId: state.project.projectId, parentControllerSessionId: state.project.controllerSessionId, createdAt: new Date().toISOString(), }; } const session = state.sessions[key]; session.role ||= 'customer-agent'; session.projectId ||= state.project.projectId; session.parentControllerSessionId ||= state.project.controllerSessionId; if (metadata.customerName) session.customerName = String(metadata.customerName).trim().slice(0, 80); if (metadata.displayName) session.displayName = String(metadata.displayName).trim().slice(0, 80); writeJsonAtomic(this.filePath, state); return session; } markInitialized(key) { const state = this.loadState(); if (!state.sessions?.[key]) return; state.sessions[key].initialized = true; state.sessions[key].updatedAt = new Date().toISOString(); writeJsonAtomic(this.filePath, state); } setRole(key, role) { const state = this.loadState(); if (!state.sessions?.[key]) return false; state.sessions[key].role = String(role || 'customer-agent'); state.sessions[key].updatedAt = new Date().toISOString(); writeJsonAtomic(this.filePath, state); return true; } reset(key, metadata = {}) { const state = this.loadState(); state.sessions[key] = { id: crypto.randomUUID(), initialized: false, role: 'customer-agent', projectId: state.project.projectId, parentControllerSessionId: state.project.controllerSessionId, customerName: metadata.customerName || undefined, displayName: metadata.displayName || undefined, createdAt: new Date().toISOString(), }; writeJsonAtomic(this.filePath, state); return state.sessions[key]; } } class ClaudeCodeClient { constructor(config) { this.config = config; this.executable = resolveClaudeExecutable(config); this.workdir = path.resolve(config.claudeWorkdir || process.cwd()); this.sessionStore = new ClaudeCodeSessionStore(config.claudeSessionFile, { projectId: config.claudeProjectId, projectRoot: this.workdir, mainSessionId: config.claudeMainSessionId, }); this.queues = new Map(); } isConfigured() { return Boolean(this.executable && fs.existsSync(this.workdir)); } outputSchema() { return this.config.outputSchema || { type: 'object', additionalProperties: false, properties: { reply: { type: 'string' }, confidence: { type: 'number', minimum: 0, maximum: 1 }, intent: { type: 'string' }, reason: { type: 'string' }, requiresHuman: { type: 'boolean' }, profileUpdates: { type: 'object' }, tasks: { type: 'array', maxItems: 6, items: { type: 'object', additionalProperties: false, properties: { key: { type: 'string' }, type: { type: 'string' }, title: { type: 'string' }, owner: { type: 'string' }, dueAt: { type: 'string' }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'urgent'] }, reason: { type: 'string' }, evidence: { type: 'string' }, }, required: ['type', 'title', 'owner', 'dueAt', 'priority', 'reason', 'evidence'], }, }, alerts: { type: 'array', maxItems: 4, items: { type: 'object', additionalProperties: false, properties: { key: { type: 'string' }, type: { type: 'string' }, severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] }, title: { type: 'string' }, detail: { type: 'string' }, evidence: { type: 'string' }, recommendedAction: { type: 'string' }, }, required: ['type', 'severity', 'title', 'detail', 'evidence', 'recommendedAction'], }, }, }, required: ['reply', 'confidence', 'intent', 'reason', 'requiresHuman', 'profileUpdates', 'tasks', 'alerts'], }; } sessionKey(context = {}) { return String(context.conversation?.id || context.conversation?.contact_id || 'qiwei-default'); } buildPrompt(messages, context = {}) { if (context.directPrompt) return String(context.directPrompt); const history = selectAuthoritativeHistory(messages, 10) .map(message => `${message.role === 'assistant' ? '客服' : message.role === 'tool' ? '工具' : '客户'}:${String(message.content || '').slice(0, 1200)}`) .join('\n\n'); const profile = context.profile?.profile || context.profile || {}; const customerIntelligence = context.customerIntelligence || {}; const activeTasks = (customerIntelligence.tasks || []).filter(item => ['open', 'in_progress'].includes(item.status)); const activeAlerts = (customerIntelligence.alerts || []).filter(item => ['open', 'acknowledged'].includes(item.status)); const recommendations = (customerIntelligence.recommendations || []).map(item => ({ propertyId: item.property_id || item.propertyId, community: item.property_snapshot?.community || item.property?.community, totalPrice: item.property_snapshot?.totalPrice || item.property?.totalPrice, status: item.status, feedbackReason: item.feedback_reason || item.feedbackReason || '', })); return [ '请处理下面的企业微信客户会话。你可以使用只读工具检索当前工作区中的知识库、规则和房源数据。', '不要修改文件,不要发送消息,不要编造业务事实。只生成供 Dashboard 审核的回复草稿。', '严格按照 JSON Schema 输出;reply 面向客户,reason 仅供内部审核。', '【上下文边界】下面的“本轮有效会话”是本轮唯一可信的客户对话。即使当前 Claude Code Session 曾经出现过其他客户原话,也不得引用未在本轮有效会话中重复出现的内容。', '把语音转写重复、语义残缺、与当前业务无关的测试/技术消息视为未确认噪声;不得据此推断购买数量、预算用途、投资意图等高影响需求,必须先向客户确认。', 'reply 使用适合企微的简洁纯文本,不输出 Markdown 星号、标题语法或内部推理。客户给出明确事实时,在 profileUpdates 中同步记录;不明确的字段标记待确认。', '如果最新消息只是“收到、好的、明白了、谢谢”等纯确认或致谢,且没有新的问题或待确认动作,reply 可以返回空字符串。', '同时输出内部 tasks 和 alerts:tasks 只记录明确可执行的跟进动作,alerts 只记录有证据的高意向、时效、矛盾、投诉或人工接管风险;每项 evidence 必须来自本轮有效会话。没有就输出空数组。', '', `当前客户画像:${JSON.stringify(profile)}`, `当前未完成待办:${JSON.stringify(activeTasks.map(item => ({ key: item.business_key || item.businessKey, title: item.title, status: item.status, reason: item.reason })))}`, `当前未解决预警:${JSON.stringify(activeAlerts.map(item => ({ key: item.business_key || item.businessKey, title: item.title, status: item.status, detail: item.detail })))}`, `房源推荐与反馈历史:${JSON.stringify(recommendations)}`, '不要重复推荐已明确标记 rejected 的房源;已推荐但未反馈的房源应优先询问感受,不能把“已推荐”说成“客户感兴趣”。', '不要重复创建语义相同的待办或预警;如需引用已有项,沿用其 key。画像事实变化时,优先更新已有业务项。', '', '本轮有效会话:', history || `客户:${String(context.inboundContent || '')}`, ].join('\n'); } runProcess(args, input = '') { if (!this.isConfigured()) throw new AgentNotConfiguredError(); return new Promise((resolve, reject) => { const childEnv = { ...process.env, NO_COLOR: '1' }; if (this.config.claudeBare !== false && childEnv.ANTHROPIC_AUTH_TOKEN) { childEnv.ANTHROPIC_API_KEY = childEnv.ANTHROPIC_AUTH_TOKEN; } const child = spawn(this.executable, args, { cwd: this.workdir, env: childEnv, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], }); let stdout = ''; let stderr = ''; let finished = false; const maxBuffer = 8 * 1024 * 1024; const timer = setTimeout(() => { if (finished) return; child.kill('SIGTERM'); reject(new Error('Claude Code 处理超时,已转人工审核')); }, Number(this.config.claudeTimeoutMs || 120000)); child.stdout.on('data', chunk => { stdout += chunk.toString('utf8'); if (stdout.length > maxBuffer) child.kill('SIGTERM'); }); child.stderr.on('data', chunk => { stderr += chunk.toString('utf8'); if (stderr.length > maxBuffer) child.kill('SIGTERM'); }); child.stdin.on('error', () => {}); child.stdin.end(String(input || ''), 'utf8'); child.once('error', error => { if (finished) return; finished = true; clearTimeout(timer); reject(error); }); child.once('close', code => { if (finished) return; finished = true; clearTimeout(timer); const parsed = parseClaudeProcessResult(stdout, stderr, code); if (parsed.error) reject(new Error(parsed.error)); else resolve(parsed.payload); }); }); } async invoke(messages, context, session, options = {}) { const system = messages.find(message => message.role === 'system')?.content || ''; const prompt = `${system}\n\n${this.buildPrompt(messages, context)}`; const budgetLimitUsd = Number(options.maxBudgetUsd || this.config.claudeMaxBudgetUsd || 0.25); const args = [ '--print', '--output-format', 'json', '--permission-mode', 'dontAsk', ...(this.config.claudeBare !== false ? ['--bare'] : []), ...(this.config.claudeEffort ? ['--effort', String(this.config.claudeEffort)] : []), '--tools', String(this.config.claudeTools || 'Read,Glob,Grep'), '--model', String(this.config.model || 'sonnet'), '--max-budget-usd', String(budgetLimitUsd), '--json-schema', JSON.stringify(this.outputSchema()), '--name', session.displayName || buildClaudeSessionName(context, this.sessionKey(context)), ]; for (const dir of this.config.claudeAddDirs || []) { if (dir && fs.existsSync(dir)) args.push('--add-dir', path.resolve(dir)); } if (session.initialized) args.push('--resume', session.id); else args.push('--session-id', session.id); const payload = await this.runProcess(args, prompt); if (payload.is_error) { const subtype = String(payload.subtype || 'unknown'); const error = new Error(`Claude Code/Fmode 暂时不可用(${subtype})`); error.subtype = subtype; throw error; } const structured = payload.structured_output ?? payload.result; if (structured === undefined || structured === null || structured === '') { throw new Error('Claude Code 没有返回客服草稿'); } return { content: typeof structured === 'string' ? structured : JSON.stringify(structured), claudeCode: { model: this.config.model, sessionName: session.displayName || buildClaudeSessionName(context, this.sessionKey(context)), durationMs: Number(payload.duration_ms || 0), costUsd: Number(payload.total_cost_usd || 0), budgetLimitUsd, resumed: Boolean(session.initialized), ...(payload.process_warning ? { processWarning: payload.process_warning } : {}), }, }; } async complete(messages, tools, context = {}) { const key = this.sessionKey(context); const metadata = { customerName: context.conversation?.contact_name || context.conversation?.displayName || '', displayName: buildClaudeSessionName(context, key), }; const previous = this.queues.get(key) || Promise.resolve(); const current = previous.catch(() => {}).then(async () => { let session = this.sessionStore.ensure(key, metadata); try { const result = await this.invoke(messages, context, session); this.sessionStore.markInitialized(key); return result; } catch (error) { const resetReason = claudeSessionResetReason(error); if (resetReason) { session = this.sessionStore.reset(key, metadata); const retryMaxBudgetUsd = resetReason === 'budget_exceeded' ? Number(this.config.claudeRetryMaxBudgetUsd || Math.max(Number(this.config.claudeMaxBudgetUsd || 0.35) * 2, 1)) : undefined; const result = await this.invoke(messages, context, session, { maxBudgetUsd: retryMaxBudgetUsd }); this.sessionStore.markInitialized(key); if (result.claudeCode) result.claudeCode.sessionResetReason = resetReason; return result; } throw error; } }); this.queues.set(key, current); try { return await current; } finally { if (this.queues.get(key) === current) this.queues.delete(key); } } } function parseFinal(content) { const text = String(content || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, ''); try { return JSON.parse(text); } catch { return { reply: text, confidence: 0.5, intent: 'unknown', reason: '模型未返回结构化结果,必须人工审核', requiresHuman: true, profileUpdates: {}, }; } } function clamp(value) { return Math.max(0, Math.min(1, Number(value) || 0)); } class QiweiAgentRuntime { constructor({ config, knowledge, modelClient = null }) { this.config = config; this.knowledge = knowledge; this.modelClient = modelClient || (config.provider === 'claude-code' ? new ClaudeCodeClient(config) : config.provider === 'anthropic' ? new AnthropicCompatibleClient(config) : new OpenAICompatibleClient(config)); } tools() { return [ { type: 'function', function: { name: 'search_knowledge', description: '检索企业规则、FAQ 和沟通 Playbook。', parameters: { type: 'object', properties: { query: { type: 'string' }, limit: { type: 'integer' } }, required: ['query'], }, }, }, { type: 'function', function: { name: 'search_properties', description: '按客户明确需求查询已接入的房源数据。数据源未配置或仅为演示数据时会明确返回警告。', parameters: { type: 'object', properties: { query: { type: 'string' }, district: { type: 'string' }, budgetMax: { type: 'number' }, rooms: { type: 'integer' }, decoration: { type: 'string' }, orientation: { type: 'string' }, schoolRequired: { type: 'boolean' }, }, }, }, }, { type: 'function', function: { name: 'get_customer_profile', description: '读取当前客户画像与标签。', parameters: { type: 'object', properties: {} }, }, }, ]; } async executeTool(name, args, context) { if (name === 'search_knowledge') return this.knowledge.search(args.query, args.limit || 5); if (name === 'search_properties') return this.knowledge.searchProperties(args); if (name === 'get_customer_profile') return context.profile || { profile: {}, tags: [] }; return { error: `未知工具 ${name}` }; } async run({ conversation, messages, profile, customerIntelligence = {}, inboundContent }) { const history = messages.slice(-16).map(message => ({ role: message.direction === 'inbound' ? 'user' : 'assistant', content: message.content, })); const recommendationContext = (customerIntelligence.recommendations || []).map(item => ({ propertyId: item.property_id || item.propertyId, community: item.property_snapshot?.community || item.property?.community, totalPrice: item.property_snapshot?.totalPrice || item.property?.totalPrice, status: item.status, feedbackReason: item.feedback_reason || item.feedbackReason || '' })); const system = [ '你是企业微信客户服务 Agent。你需要自主判断是否检索知识、客户画像或房源工具,再生成回复草稿。', '严禁编造业务事实。工具没有证据时明确说需要确认。敏感或低置信内容标记 requiresHuman=true。', '最终必须只输出 JSON,包含 reply、confidence、intent、reason、requiresHuman、profileUpdates、tasks 和 alerts。', 'reason 用于内部监管台,简要说明依据和不确定性,不要发送给客户。', `历史房源推荐与反馈:${JSON.stringify(recommendationContext)}`, '不得重复推荐已明确 rejected 的房源;recommended 只代表已发给客户,不能推断客户感兴趣。', '', '必须遵守的规则:', this.knowledge.rulesText(), ].join('\n'); const llmMessages = [{ role: 'system', content: system }, ...history]; const toolTrace = []; const citations = []; for (let round = 0; round < this.config.maxToolRounds; round += 1) { const assistant = await this.modelClient.complete(llmMessages, this.tools(), { conversation, profile, customerIntelligence, inboundContent, }); if (assistant.claudeCode) { toolTrace.push({ tool: 'claude_code_session', args: { provider: 'Fmode Studio', model: assistant.claudeCode.model }, result: { sessionName: assistant.claudeCode.sessionName, durationMs: assistant.claudeCode.durationMs, costUsd: assistant.claudeCode.costUsd, budgetLimitUsd: assistant.claudeCode.budgetLimitUsd, resumed: assistant.claudeCode.resumed, ...(assistant.claudeCode.processWarning ? { processWarning: assistant.claudeCode.processWarning } : {}), ...(assistant.claudeCode.sessionResetReason ? { sessionResetReason: assistant.claudeCode.sessionResetReason } : {}), }, }); } if (assistant.tool_calls?.length) { llmMessages.push({ role: 'assistant', content: assistant.content || '', tool_calls: assistant.tool_calls }); for (const call of assistant.tool_calls) { let args = {}; try { args = JSON.parse(call.function.arguments || '{}'); } catch {} const result = await this.executeTool(call.function.name, args, { conversation, profile, customerIntelligence }); toolTrace.push({ tool: call.function.name, args, result }); if (call.function.name === 'search_knowledge') { for (const item of result) citations.push({ id: item.id, source: item.source, heading: item.heading }); } if (call.function.name === 'search_properties' && result.dataSource) { citations.push({ id: `properties:${result.dataSource}`, source: result.dataSource, heading: `${result.dataLabel || '房源数据'}查询 ${result.total} 条`, }); } llmMessages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) }); } continue; } const parsedFinal = parseFinal(assistant.content); const authoritativeHistory = selectAuthoritativeHistory(history, 10); const currentProfile = profile?.profile || profile || {}; const final = enforceAuthoritativeGrounding(parsedFinal, authoritativeHistory, currentProfile, inboundContent); const intelligence = extractExplicitCustomerIntelligence(inboundContent, currentProfile, final); const risky = RISK_PATTERN.test(inboundContent) || RISK_PATTERN.test(final.reply || ''); const confidence = risky ? Math.min(clamp(final.confidence), 0.75) : clamp(final.confidence); return { content: String(final.reply || '').trim(), confidence, intent: String(final.intent || 'unknown'), reason: String(final.reason || 'Agent 未提供说明'), requiresHuman: Boolean(final.requiresHuman || risky || confidence < 0.7), profileUpdates: intelligence.profileUpdates, tasks: intelligence.tasks, alerts: intelligence.alerts, citations: [...new Map(citations.map(item => [item.id, item])).values()], toolTrace, }; } throw new Error('Agent 工具调用轮次超过上限,已转人工处理'); } } module.exports = { AgentNotConfiguredError, OpenAICompatibleClient, AnthropicCompatibleClient, ClaudeCodeClient, ClaudeCodeSessionStore, buildClaudeSessionName, claudeSessionResetReason, parseClaudeProcessResult, selectAuthoritativeHistory, enforceAuthoritativeGrounding, extractExplicitCustomerIntelligence, isNoReplyNeededMessage, resolveClaudeExecutable, QiweiAgentRuntime, };