| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898 |
- 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,
- };
|