agent-runtime.js 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898
  1. const crypto = require('crypto');
  2. const fs = require('fs');
  3. const path = require('path');
  4. const { spawn } = require('child_process');
  5. const RISK_PATTERN = /合同|签约|产权|学区资格|保证|承诺|最低价|贷款|利率|首付|投诉|退款|发票|身份证|银行卡|法律|违约/;
  6. const NO_REPLY_NEEDED_PATTERN = /^(?:收到|好|好的|好嘞|明白|明白了|知道了|了解|了解了|谢谢|谢谢你|谢谢您|感谢|ok|okay|嗯+|哦+)$/i;
  7. function isNoReplyNeededMessage(input = '') {
  8. const normalized = String(input || '').trim().replace(/[,。!?!?,.~~]+$/g, '').trim();
  9. return Boolean(normalized && NO_REPLY_NEEDED_PATTERN.test(normalized));
  10. }
  11. function claudeSessionResetReason(error) {
  12. const detail = `${error?.message || ''} ${error?.subtype || ''}`;
  13. if (/error_max_budget_usd|max[-_\s]?budget[-_\s]?usd/i.test(detail)) return 'budget_exceeded';
  14. if (/session|conversation|resume/i.test(detail)) return 'session_invalid';
  15. return '';
  16. }
  17. function parseClaudeProcessResult(stdout = '', stderr = '', code = 0) {
  18. let payload = null;
  19. try { payload = JSON.parse(String(stdout || '').trim()); } catch {}
  20. if (code === 0) {
  21. return payload
  22. ? { payload }
  23. : { error: `Claude Code 未返回有效 JSON${stderr ? ',请检查 Fmode 配置' : ''}` };
  24. }
  25. const structured = payload?.structured_output;
  26. const usableStructuredOutput = structured
  27. && typeof structured === 'object'
  28. && !Array.isArray(structured)
  29. && Object.prototype.hasOwnProperty.call(structured, 'reply')
  30. && Object.prototype.hasOwnProperty.call(structured, 'confidence')
  31. && Object.prototype.hasOwnProperty.call(structured, 'intent');
  32. const safeError = String(stderr || '')
  33. .replace(/Bearer\s+\S+/gi, 'Bearer [REDACTED]')
  34. .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, 'sk-[REDACTED]')
  35. .trim()
  36. .slice(0, 300);
  37. const structuredError = String(payload?.error || payload?.message || payload?.result || payload?.subtype || '').trim().slice(0, 300);
  38. const detail = safeError || structuredError || `退出码 ${code}`;
  39. if (usableStructuredOutput) {
  40. return {
  41. payload: {
  42. ...payload,
  43. is_error: false,
  44. process_warning: { exitCode: code, detail },
  45. },
  46. };
  47. }
  48. return { error: `Claude Code 调用失败(退出码 ${code})${detail ? `:${detail}` : ''}` };
  49. }
  50. class AgentNotConfiguredError extends Error {
  51. constructor() {
  52. super('Agent 模型尚未配置,消息已保留但不会生成伪造回复');
  53. this.name = 'AgentNotConfiguredError';
  54. }
  55. }
  56. class OpenAICompatibleClient {
  57. constructor(config) { this.config = config; }
  58. async complete(messages, tools) {
  59. if (!this.config.apiKey) throw new AgentNotConfiguredError();
  60. const body = {
  61. model: this.config.model,
  62. temperature: 0.2,
  63. messages,
  64. };
  65. if (Array.isArray(tools) && tools.length) {
  66. body.tools = tools;
  67. body.tool_choice = 'auto';
  68. }
  69. const response = await fetch(`${this.config.baseUrl}/chat/completions`, {
  70. method: 'POST',
  71. headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.config.apiKey}` },
  72. body: JSON.stringify(body),
  73. signal: AbortSignal.timeout(30000),
  74. });
  75. const payload = await response.json().catch(() => ({}));
  76. if (!response.ok) throw new Error(`Agent 上游暂时不可用(HTTP ${response.status})`);
  77. const message = payload.choices?.[0]?.message;
  78. if (!message) throw new Error('Agent 上游没有返回有效消息');
  79. return message;
  80. }
  81. }
  82. class AnthropicCompatibleClient {
  83. constructor(config) { this.config = config; }
  84. toAnthropicMessages(messages) {
  85. return messages.filter(message => message.role !== 'system').map(message => {
  86. if (message.role === 'tool') {
  87. return { role: 'user', content: [{ type: 'tool_result', tool_use_id: message.tool_call_id, content: message.content }] };
  88. }
  89. if (message.role === 'assistant' && message.tool_calls?.length) {
  90. const content = [];
  91. if (message.content) content.push({ type: 'text', text: message.content });
  92. for (const call of message.tool_calls) {
  93. let input = {};
  94. try { input = JSON.parse(call.function.arguments || '{}'); } catch {}
  95. content.push({ type: 'tool_use', id: call.id, name: call.function.name, input });
  96. }
  97. return { role: 'assistant', content };
  98. }
  99. return { role: message.role, content: message.content };
  100. });
  101. }
  102. async complete(messages, tools) {
  103. if (!this.config.apiKey) throw new AgentNotConfiguredError();
  104. const system = messages.find(message => message.role === 'system')?.content || '';
  105. const body = {
  106. model: this.config.model,
  107. max_tokens: 1400,
  108. temperature: 0.2,
  109. system,
  110. messages: this.toAnthropicMessages(messages),
  111. };
  112. if (Array.isArray(tools) && tools.length) {
  113. body.tools = tools.map(tool => ({
  114. name: tool.function.name,
  115. description: tool.function.description,
  116. input_schema: tool.function.parameters,
  117. }));
  118. }
  119. const response = await fetch(`${this.config.baseUrl}/v1/messages`, {
  120. method: 'POST',
  121. headers: {
  122. 'Content-Type': 'application/json',
  123. 'anthropic-version': '2023-06-01',
  124. 'x-api-key': this.config.apiKey,
  125. Authorization: `Bearer ${this.config.apiKey}`,
  126. },
  127. body: JSON.stringify(body),
  128. signal: AbortSignal.timeout(45000),
  129. });
  130. const payload = await response.json().catch(() => ({}));
  131. if (!response.ok) throw new Error(`Agent 上游暂时不可用(HTTP ${response.status})`);
  132. const blocks = Array.isArray(payload.content) ? payload.content : [];
  133. const toolCalls = blocks.filter(block => block.type === 'tool_use').map(block => ({
  134. id: block.id,
  135. function: { name: block.name, arguments: JSON.stringify(block.input || {}) },
  136. }));
  137. return {
  138. content: blocks.filter(block => block.type === 'text').map(block => block.text).join('\n'),
  139. ...(toolCalls.length ? { tool_calls: toolCalls } : {}),
  140. };
  141. }
  142. }
  143. function readJson(filePath, fallback = {}) {
  144. try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); }
  145. catch { return fallback; }
  146. }
  147. function writeJsonAtomic(filePath, value) {
  148. fs.mkdirSync(path.dirname(filePath), { recursive: true });
  149. const tempPath = `${filePath}.${process.pid}.tmp`;
  150. fs.writeFileSync(tempPath, JSON.stringify(value, null, 2), 'utf8');
  151. fs.renameSync(tempPath, filePath);
  152. }
  153. function resolveClaudeExecutable(config = {}) {
  154. const candidates = [
  155. config.claudeExecutable,
  156. process.env.CLAUDE_CODE_EXECUTABLE,
  157. path.join(path.dirname(process.execPath), 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
  158. path.join(process.env.APPDATA || '', 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
  159. ].filter(Boolean);
  160. return candidates.find(candidate => fs.existsSync(candidate)) || '';
  161. }
  162. function normalizeSessionLabel(value) {
  163. return String(value || '企微客户')
  164. .trim()
  165. .replace(/[\\/:*?"<>|\r\n]+/g, '-')
  166. .replace(/\s+/g, '-')
  167. .replace(/-+/g, '-')
  168. .replace(/^-|-$/g, '')
  169. .slice(0, 24) || '企微客户';
  170. }
  171. function buildClaudeSessionName(context = {}, key = '') {
  172. const customerName = normalizeSessionLabel(
  173. context.conversation?.contact_name || context.conversation?.displayName || '企微客户'
  174. );
  175. const reference = crypto.createHash('sha256').update(String(key || 'qiwei-default')).digest('hex').slice(0, 4);
  176. return `企微客户-${customerName}-${reference}`;
  177. }
  178. function selectAuthoritativeHistory(messages = [], limit = 10) {
  179. const history = messages.filter(message => message.role !== 'system');
  180. const latestInbound = history.findLastIndex(message => message.role === 'user');
  181. if (latestInbound < 0) return history.slice(-limit);
  182. const previousOutbound = history.slice(0, latestInbound).findLastIndex(message => message.role === 'assistant');
  183. const start = previousOutbound >= 0 ? previousOutbound : Math.max(0, history.length - limit);
  184. return history.slice(start).slice(-limit);
  185. }
  186. function uniqueIntelligence(items = [], keyFn) {
  187. const seen = new Set();
  188. return items.filter(item => {
  189. const key = keyFn(item);
  190. if (!key || seen.has(key)) return false;
  191. seen.add(key);
  192. return true;
  193. });
  194. }
  195. function normalizedEvidence(value) {
  196. return String(value || '').toLowerCase().replace(/[\s,。!?;:、,.!?;:'"“”‘’()()【】\[\]-]+/g, '');
  197. }
  198. function evidenceIsSupported(evidence, authoritativeText) {
  199. const source = normalizedEvidence(authoritativeText);
  200. const claim = normalizedEvidence(evidence);
  201. if (!source || !claim) return false;
  202. return source.includes(claim) || (source.length >= 4 && claim.includes(source));
  203. }
  204. function supportedModelProfileUpdates(updates = {}, authoritativeText = '') {
  205. if (!updates || typeof updates !== 'object' || Array.isArray(updates)) return {};
  206. const explicitNumbers = new Set(String(authoritativeText || '').match(/\d+(?:\.\d+)?/g) || []);
  207. return Object.fromEntries(Object.entries(updates).filter(([, value]) => {
  208. if (value === undefined || value === null || value === '') return false;
  209. const values = Array.isArray(value) ? value : [value];
  210. return values.every(item => typeof item === 'number'
  211. ? explicitNumbers.has(String(item))
  212. : evidenceIsSupported(String(item), authoritativeText));
  213. }));
  214. }
  215. function unsupportedAttributedClaims(final = {}, authoritativeHistory = []) {
  216. const output = `${String(final.reply || '')}\n${String(final.reason || '')}`;
  217. const source = authoritativeHistory.map(item => item.content || '').join('\n');
  218. const claims = [];
  219. const marker = /(?:您|客户)(?:之前|此前|刚才)?(?:曾经)?(?:提到|说过|说|表示)/g;
  220. for (const match of output.matchAll(marker)) {
  221. const tail = output.slice((match.index || 0) + match[0].length, (match.index || 0) + match[0].length + 100);
  222. const quoted = tail.match(/^\s*[::]?\s*["“‘']([^"”’'\r\n]{2,60})["”’']/);
  223. if (quoted && !evidenceIsSupported(quoted[1], source)) claims.push(quoted[1].trim());
  224. }
  225. return [...new Set(claims)];
  226. }
  227. function groundedConfirmationReply(profile = {}, inboundContent = '') {
  228. const region = profile.preferredRegion || profile.region || profile.district || '';
  229. const layout = profile.layout || profile.roomType || '';
  230. const budget = Number(profile.budgetWan || profile.budget || 0);
  231. const confirmed = [region, layout, budget ? `${budget}万预算` : ''].filter(Boolean).join('、');
  232. const opening = confirmed ? `收到,我先按${confirmed}继续整理。` : '收到,您刚才的信息我已经记录。';
  233. const questions = [];
  234. if (budget && (!profile.budgetType || profile.budgetType === '待确认')) questions.push(`这${budget}万是总价预算还是首付预算`);
  235. if (!profile.purpose) questions.push('主要用于自住还是投资');
  236. if (!profile.timeline) questions.push('希望什么时候购置');
  237. if (!questions.length) return `${opening}我会先核对可用方案,再给您准确回复。`;
  238. return `${opening}为了避免理解偏差,想再确认一下:${questions.join(';')}?`;
  239. }
  240. function enforceAuthoritativeGrounding(final = {}, authoritativeHistory = [], profile = {}, inboundContent = '') {
  241. const unsupported = unsupportedAttributedClaims(final, authoritativeHistory);
  242. if (!unsupported.length) return { ...final, groundingWarnings: [] };
  243. return {
  244. ...final,
  245. reply: groundedConfirmationReply(profile, inboundContent),
  246. reason: `检测到模型引用了本轮有效会话中不存在的客户原话,已降级为确认式草稿。未支持内容:${unsupported.join('、')}`,
  247. confidence: Math.min(clamp(final.confidence), 0.68),
  248. requiresHuman: true,
  249. groundingWarnings: unsupported,
  250. };
  251. }
  252. function extractExplicitCustomerIntelligence(content, currentProfile = {}, modelOutput = {}) {
  253. const text = String(content || '').trim();
  254. const profileUpdates = supportedModelProfileUpdates(modelOutput.profileUpdates, text);
  255. const amount = text.match(/(\d+(?:\.\d+)?)\s*万/);
  256. if (amount && (/预算|总价|首付/.test(text) || /^\s*\d+(?:\.\d+)?\s*万(?:吧|左右|上下|以内|起)?\s*[。!!??]*$/.test(text))) {
  257. profileUpdates.budgetWan = Number(amount[1]);
  258. profileUpdates.budgetType = /首付/.test(text) ? '首付' : /总价/.test(text) ? '总价' : (currentProfile.budgetType || '待确认');
  259. }
  260. const region = text.match(/([\p{Script=Han}]{2,8}(?:区|市|镇|板块))/u);
  261. if (region) {
  262. let regionValue = region[1].replace(/^.*(?:想咨询一下|咨询一下|了解一下|考虑在|想在|咨询|了解|看看|一下)/, '');
  263. if (regionValue.length > 5 && !regionValue.endsWith('板块')) regionValue = regionValue.slice(-4);
  264. profileUpdates.preferredRegion = regionValue;
  265. }
  266. const layout = text.match(/([一二三四五六七八九十两\d]+)\s*室/);
  267. if (layout) profileUpdates.layout = `${layout[1]}室`;
  268. if (/自己住|自住|婚房|改善/.test(text)) profileUpdates.purpose = /自己住|自住/.test(text) ? '自住' : text.match(/婚房|改善/)?.[0];
  269. if (/投资|出租|收租/.test(text)) profileUpdates.purpose = '投资';
  270. if (/今天|明天|本周|这周|尽快|马上|急/.test(text)) profileUpdates.urgency = '高';
  271. const mergedProfile = { ...currentProfile, ...profileUpdates };
  272. const factEvidence = [
  273. mergedProfile.budgetWan || mergedProfile.budget ? `预算${mergedProfile.budgetWan || mergedProfile.budget}万` : '',
  274. mergedProfile.preferredRegion || mergedProfile.region || mergedProfile.area ? `区域${mergedProfile.preferredRegion || mergedProfile.region || mergedProfile.area}` : '',
  275. mergedProfile.layout || mergedProfile.roomType ? `户型${mergedProfile.layout || mergedProfile.roomType}` : '',
  276. mergedProfile.purpose ? `用途${mergedProfile.purpose}` : '',
  277. mergedProfile.timeline ? `时间${mergedProfile.timeline}` : '',
  278. ].filter(Boolean).join(',').slice(0, 240);
  279. const evidence = text.slice(0, 240);
  280. const tasks = Array.isArray(modelOutput.tasks)
  281. ? modelOutput.tasks.filter(item => evidenceIsSupported(item?.evidence, text)).map(item => ({
  282. ...item,
  283. businessKey: item.businessKey || item.key,
  284. managedBy: 'agent',
  285. }))
  286. : [];
  287. const alerts = Array.isArray(modelOutput.alerts)
  288. ? modelOutput.alerts.filter(item => evidenceIsSupported(item?.evidence, text)).map(item => ({
  289. ...item,
  290. businessKey: item.businessKey || item.key,
  291. managedBy: 'agent',
  292. }))
  293. : [];
  294. const hasBudget = Number(mergedProfile.budgetWan || mergedProfile.budget || 0) > 0;
  295. const hasRegion = Boolean(mergedProfile.preferredRegion || mergedProfile.region || mergedProfile.area);
  296. const hasLayout = Boolean(mergedProfile.layout || mergedProfile.roomType);
  297. const hasPurpose = Boolean(mergedProfile.purpose);
  298. const hasTimeline = Boolean(mergedProfile.timeline || mergedProfile.purchaseTime || mergedProfile.purchase_time);
  299. if (hasBudget && (!hasPurpose || !hasTimeline)) {
  300. tasks.push({
  301. businessKey: 'qualification:purpose_and_timeline',
  302. managedBy: 'rule',
  303. type: 'qualification',
  304. title: '确认客户用途与购置时间',
  305. owner: '待分配',
  306. dueAt: '',
  307. priority: hasRegion || hasLayout ? 'high' : 'medium',
  308. reason: `客户已经给出预算,但${[!hasPurpose ? '用途' : '', !hasTimeline ? '购置时间' : ''].filter(Boolean).join('和')}仍不明确。`,
  309. evidence: factEvidence || evidence,
  310. });
  311. }
  312. if (hasBudget && hasRegion && hasLayout) {
  313. tasks.push({
  314. businessKey: 'recommendation:shortlist',
  315. managedBy: 'rule',
  316. type: 'recommendation',
  317. title: '按已确认条件筛选并发送重点方案',
  318. owner: '待分配',
  319. dueAt: '',
  320. priority: 'high',
  321. reason: '预算、区域和户型三项核心条件已经明确。',
  322. evidence: factEvidence || evidence,
  323. });
  324. alerts.push({
  325. businessKey: 'high_intent:core_demand_ready',
  326. managedBy: 'rule',
  327. type: 'high_intent',
  328. severity: 'high',
  329. title: '客户核心需求已基本成形',
  330. detail: '预算、区域和户型信息已具备,可以从泛咨询进入重点方案或下一步转化。',
  331. evidence: factEvidence || evidence,
  332. recommendedAction: '优先核对用途和时间计划,再给出少量重点方案。',
  333. });
  334. }
  335. if (/投诉|不满意|骗人|退款|举报|再也不|太差|生气/.test(text)) {
  336. alerts.push({
  337. businessKey: 'complaint:manual_takeover',
  338. managedBy: 'event',
  339. type: 'complaint',
  340. severity: 'critical',
  341. title: '检测到投诉或强烈负面情绪',
  342. detail: '该消息不应由自动回复独立处理。',
  343. evidence,
  344. recommendedAction: '立即人工接管,先确认事实和客户诉求。',
  345. });
  346. } else if (/今天|明天|本周|这周|尽快|马上|急/.test(text)) {
  347. alerts.push({
  348. businessKey: 'time_sensitive:follow_up',
  349. managedBy: 'event',
  350. type: 'time_sensitive',
  351. severity: 'high',
  352. title: '客户表达了明确时效要求',
  353. detail: '消息中包含较紧迫的时间表达。',
  354. evidence,
  355. recommendedAction: '优先确认具体截止时间并安排跟进。',
  356. });
  357. }
  358. return {
  359. profileUpdates,
  360. tasks: uniqueIntelligence(tasks, item => `${item?.businessKey || item?.key || ''}|${item?.type || ''}|${item?.title || ''}`.toLowerCase()).slice(0, 8),
  361. alerts: uniqueIntelligence(alerts, item => `${item?.businessKey || item?.key || ''}|${item?.type || ''}|${item?.title || ''}`.toLowerCase()).slice(0, 6),
  362. };
  363. }
  364. class ClaudeCodeSessionStore {
  365. constructor(filePath, project = {}) {
  366. this.filePath = filePath;
  367. this.project = project;
  368. }
  369. loadState() {
  370. const state = readJson(this.filePath, { version: 1, project: {}, sessions: {} });
  371. state.version = 1;
  372. state.sessions ||= {};
  373. state.project ||= {};
  374. state.project.projectId = this.project.projectId || state.project.projectId || 'qiwei-project';
  375. state.project.projectRoot = this.project.projectRoot || state.project.projectRoot || '';
  376. state.project.controllerSessionId = this.project.mainSessionId
  377. || state.project.controllerSessionId
  378. || crypto.randomUUID();
  379. state.project.boundMainSessionId = this.project.mainSessionId || state.project.boundMainSessionId || null;
  380. return state;
  381. }
  382. get(key) {
  383. return this.loadState().sessions?.[key] || null;
  384. }
  385. ensure(key, metadata = {}) {
  386. const state = this.loadState();
  387. if (!state.sessions[key]) {
  388. state.sessions[key] = {
  389. id: crypto.randomUUID(),
  390. initialized: false,
  391. role: 'customer-agent',
  392. projectId: state.project.projectId,
  393. parentControllerSessionId: state.project.controllerSessionId,
  394. createdAt: new Date().toISOString(),
  395. };
  396. }
  397. const session = state.sessions[key];
  398. session.role ||= 'customer-agent';
  399. session.projectId ||= state.project.projectId;
  400. session.parentControllerSessionId ||= state.project.controllerSessionId;
  401. if (metadata.customerName) session.customerName = String(metadata.customerName).trim().slice(0, 80);
  402. if (metadata.displayName) session.displayName = String(metadata.displayName).trim().slice(0, 80);
  403. writeJsonAtomic(this.filePath, state);
  404. return session;
  405. }
  406. markInitialized(key) {
  407. const state = this.loadState();
  408. if (!state.sessions?.[key]) return;
  409. state.sessions[key].initialized = true;
  410. state.sessions[key].updatedAt = new Date().toISOString();
  411. writeJsonAtomic(this.filePath, state);
  412. }
  413. setRole(key, role) {
  414. const state = this.loadState();
  415. if (!state.sessions?.[key]) return false;
  416. state.sessions[key].role = String(role || 'customer-agent');
  417. state.sessions[key].updatedAt = new Date().toISOString();
  418. writeJsonAtomic(this.filePath, state);
  419. return true;
  420. }
  421. reset(key, metadata = {}) {
  422. const state = this.loadState();
  423. state.sessions[key] = {
  424. id: crypto.randomUUID(),
  425. initialized: false,
  426. role: 'customer-agent',
  427. projectId: state.project.projectId,
  428. parentControllerSessionId: state.project.controllerSessionId,
  429. customerName: metadata.customerName || undefined,
  430. displayName: metadata.displayName || undefined,
  431. createdAt: new Date().toISOString(),
  432. };
  433. writeJsonAtomic(this.filePath, state);
  434. return state.sessions[key];
  435. }
  436. }
  437. class ClaudeCodeClient {
  438. constructor(config) {
  439. this.config = config;
  440. this.executable = resolveClaudeExecutable(config);
  441. this.workdir = path.resolve(config.claudeWorkdir || process.cwd());
  442. this.sessionStore = new ClaudeCodeSessionStore(config.claudeSessionFile, {
  443. projectId: config.claudeProjectId,
  444. projectRoot: this.workdir,
  445. mainSessionId: config.claudeMainSessionId,
  446. });
  447. this.queues = new Map();
  448. }
  449. isConfigured() {
  450. return Boolean(this.executable && fs.existsSync(this.workdir));
  451. }
  452. outputSchema() {
  453. return this.config.outputSchema || {
  454. type: 'object',
  455. additionalProperties: false,
  456. properties: {
  457. reply: { type: 'string' },
  458. confidence: { type: 'number', minimum: 0, maximum: 1 },
  459. intent: { type: 'string' },
  460. reason: { type: 'string' },
  461. requiresHuman: { type: 'boolean' },
  462. profileUpdates: { type: 'object' },
  463. tasks: {
  464. type: 'array',
  465. maxItems: 6,
  466. items: {
  467. type: 'object',
  468. additionalProperties: false,
  469. properties: {
  470. key: { type: 'string' },
  471. type: { type: 'string' },
  472. title: { type: 'string' },
  473. owner: { type: 'string' },
  474. dueAt: { type: 'string' },
  475. priority: { type: 'string', enum: ['low', 'medium', 'high', 'urgent'] },
  476. reason: { type: 'string' },
  477. evidence: { type: 'string' },
  478. },
  479. required: ['type', 'title', 'owner', 'dueAt', 'priority', 'reason', 'evidence'],
  480. },
  481. },
  482. alerts: {
  483. type: 'array',
  484. maxItems: 4,
  485. items: {
  486. type: 'object',
  487. additionalProperties: false,
  488. properties: {
  489. key: { type: 'string' },
  490. type: { type: 'string' },
  491. severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },
  492. title: { type: 'string' },
  493. detail: { type: 'string' },
  494. evidence: { type: 'string' },
  495. recommendedAction: { type: 'string' },
  496. },
  497. required: ['type', 'severity', 'title', 'detail', 'evidence', 'recommendedAction'],
  498. },
  499. },
  500. },
  501. required: ['reply', 'confidence', 'intent', 'reason', 'requiresHuman', 'profileUpdates', 'tasks', 'alerts'],
  502. };
  503. }
  504. sessionKey(context = {}) {
  505. return String(context.conversation?.id || context.conversation?.contact_id || 'qiwei-default');
  506. }
  507. buildPrompt(messages, context = {}) {
  508. if (context.directPrompt) return String(context.directPrompt);
  509. const history = selectAuthoritativeHistory(messages, 10)
  510. .map(message => `${message.role === 'assistant' ? '客服' : message.role === 'tool' ? '工具' : '客户'}:${String(message.content || '').slice(0, 1200)}`)
  511. .join('\n\n');
  512. const profile = context.profile?.profile || context.profile || {};
  513. const customerIntelligence = context.customerIntelligence || {};
  514. const activeTasks = (customerIntelligence.tasks || []).filter(item => ['open', 'in_progress'].includes(item.status));
  515. const activeAlerts = (customerIntelligence.alerts || []).filter(item => ['open', 'acknowledged'].includes(item.status));
  516. const recommendations = (customerIntelligence.recommendations || []).map(item => ({
  517. propertyId: item.property_id || item.propertyId,
  518. community: item.property_snapshot?.community || item.property?.community,
  519. totalPrice: item.property_snapshot?.totalPrice || item.property?.totalPrice,
  520. status: item.status,
  521. feedbackReason: item.feedback_reason || item.feedbackReason || '',
  522. }));
  523. return [
  524. '请处理下面的企业微信客户会话。你可以使用只读工具检索当前工作区中的知识库、规则和房源数据。',
  525. '不要修改文件,不要发送消息,不要编造业务事实。只生成供 Dashboard 审核的回复草稿。',
  526. '严格按照 JSON Schema 输出;reply 面向客户,reason 仅供内部审核。',
  527. '【上下文边界】下面的“本轮有效会话”是本轮唯一可信的客户对话。即使当前 Claude Code Session 曾经出现过其他客户原话,也不得引用未在本轮有效会话中重复出现的内容。',
  528. '把语音转写重复、语义残缺、与当前业务无关的测试/技术消息视为未确认噪声;不得据此推断购买数量、预算用途、投资意图等高影响需求,必须先向客户确认。',
  529. 'reply 使用适合企微的简洁纯文本,不输出 Markdown 星号、标题语法或内部推理。客户给出明确事实时,在 profileUpdates 中同步记录;不明确的字段标记待确认。',
  530. '如果最新消息只是“收到、好的、明白了、谢谢”等纯确认或致谢,且没有新的问题或待确认动作,reply 可以返回空字符串。',
  531. '同时输出内部 tasks 和 alerts:tasks 只记录明确可执行的跟进动作,alerts 只记录有证据的高意向、时效、矛盾、投诉或人工接管风险;每项 evidence 必须来自本轮有效会话。没有就输出空数组。',
  532. '',
  533. `当前客户画像:${JSON.stringify(profile)}`,
  534. `当前未完成待办:${JSON.stringify(activeTasks.map(item => ({ key: item.business_key || item.businessKey, title: item.title, status: item.status, reason: item.reason })))}`,
  535. `当前未解决预警:${JSON.stringify(activeAlerts.map(item => ({ key: item.business_key || item.businessKey, title: item.title, status: item.status, detail: item.detail })))}`,
  536. `房源推荐与反馈历史:${JSON.stringify(recommendations)}`,
  537. '不要重复推荐已明确标记 rejected 的房源;已推荐但未反馈的房源应优先询问感受,不能把“已推荐”说成“客户感兴趣”。',
  538. '不要重复创建语义相同的待办或预警;如需引用已有项,沿用其 key。画像事实变化时,优先更新已有业务项。',
  539. '',
  540. '本轮有效会话:',
  541. history || `客户:${String(context.inboundContent || '')}`,
  542. ].join('\n');
  543. }
  544. runProcess(args, input = '') {
  545. if (!this.isConfigured()) throw new AgentNotConfiguredError();
  546. return new Promise((resolve, reject) => {
  547. const childEnv = { ...process.env, NO_COLOR: '1' };
  548. if (this.config.claudeBare !== false && childEnv.ANTHROPIC_AUTH_TOKEN) {
  549. childEnv.ANTHROPIC_API_KEY = childEnv.ANTHROPIC_AUTH_TOKEN;
  550. }
  551. const child = spawn(this.executable, args, {
  552. cwd: this.workdir,
  553. env: childEnv,
  554. windowsHide: true,
  555. stdio: ['pipe', 'pipe', 'pipe'],
  556. });
  557. let stdout = '';
  558. let stderr = '';
  559. let finished = false;
  560. const maxBuffer = 8 * 1024 * 1024;
  561. const timer = setTimeout(() => {
  562. if (finished) return;
  563. child.kill('SIGTERM');
  564. reject(new Error('Claude Code 处理超时,已转人工审核'));
  565. }, Number(this.config.claudeTimeoutMs || 120000));
  566. child.stdout.on('data', chunk => {
  567. stdout += chunk.toString('utf8');
  568. if (stdout.length > maxBuffer) child.kill('SIGTERM');
  569. });
  570. child.stderr.on('data', chunk => {
  571. stderr += chunk.toString('utf8');
  572. if (stderr.length > maxBuffer) child.kill('SIGTERM');
  573. });
  574. child.stdin.on('error', () => {});
  575. child.stdin.end(String(input || ''), 'utf8');
  576. child.once('error', error => {
  577. if (finished) return;
  578. finished = true;
  579. clearTimeout(timer);
  580. reject(error);
  581. });
  582. child.once('close', code => {
  583. if (finished) return;
  584. finished = true;
  585. clearTimeout(timer);
  586. const parsed = parseClaudeProcessResult(stdout, stderr, code);
  587. if (parsed.error) reject(new Error(parsed.error));
  588. else resolve(parsed.payload);
  589. });
  590. });
  591. }
  592. async invoke(messages, context, session, options = {}) {
  593. const system = messages.find(message => message.role === 'system')?.content || '';
  594. const prompt = `${system}\n\n${this.buildPrompt(messages, context)}`;
  595. const budgetLimitUsd = Number(options.maxBudgetUsd || this.config.claudeMaxBudgetUsd || 0.25);
  596. const args = [
  597. '--print',
  598. '--output-format', 'json',
  599. '--permission-mode', 'dontAsk',
  600. ...(this.config.claudeBare !== false ? ['--bare'] : []),
  601. ...(this.config.claudeEffort ? ['--effort', String(this.config.claudeEffort)] : []),
  602. '--tools', String(this.config.claudeTools || 'Read,Glob,Grep'),
  603. '--model', String(this.config.model || 'sonnet'),
  604. '--max-budget-usd', String(budgetLimitUsd),
  605. '--json-schema', JSON.stringify(this.outputSchema()),
  606. '--name', session.displayName || buildClaudeSessionName(context, this.sessionKey(context)),
  607. ];
  608. for (const dir of this.config.claudeAddDirs || []) {
  609. if (dir && fs.existsSync(dir)) args.push('--add-dir', path.resolve(dir));
  610. }
  611. if (session.initialized) args.push('--resume', session.id);
  612. else args.push('--session-id', session.id);
  613. const payload = await this.runProcess(args, prompt);
  614. if (payload.is_error) {
  615. const subtype = String(payload.subtype || 'unknown');
  616. const error = new Error(`Claude Code/Fmode 暂时不可用(${subtype})`);
  617. error.subtype = subtype;
  618. throw error;
  619. }
  620. const structured = payload.structured_output ?? payload.result;
  621. if (structured === undefined || structured === null || structured === '') {
  622. throw new Error('Claude Code 没有返回客服草稿');
  623. }
  624. return {
  625. content: typeof structured === 'string' ? structured : JSON.stringify(structured),
  626. claudeCode: {
  627. model: this.config.model,
  628. sessionName: session.displayName || buildClaudeSessionName(context, this.sessionKey(context)),
  629. durationMs: Number(payload.duration_ms || 0),
  630. costUsd: Number(payload.total_cost_usd || 0),
  631. budgetLimitUsd,
  632. resumed: Boolean(session.initialized),
  633. ...(payload.process_warning ? { processWarning: payload.process_warning } : {}),
  634. },
  635. };
  636. }
  637. async complete(messages, tools, context = {}) {
  638. const key = this.sessionKey(context);
  639. const metadata = {
  640. customerName: context.conversation?.contact_name || context.conversation?.displayName || '',
  641. displayName: buildClaudeSessionName(context, key),
  642. };
  643. const previous = this.queues.get(key) || Promise.resolve();
  644. const current = previous.catch(() => {}).then(async () => {
  645. let session = this.sessionStore.ensure(key, metadata);
  646. try {
  647. const result = await this.invoke(messages, context, session);
  648. this.sessionStore.markInitialized(key);
  649. return result;
  650. } catch (error) {
  651. const resetReason = claudeSessionResetReason(error);
  652. if (resetReason) {
  653. session = this.sessionStore.reset(key, metadata);
  654. const retryMaxBudgetUsd = resetReason === 'budget_exceeded'
  655. ? Number(this.config.claudeRetryMaxBudgetUsd || Math.max(Number(this.config.claudeMaxBudgetUsd || 0.35) * 2, 1))
  656. : undefined;
  657. const result = await this.invoke(messages, context, session, { maxBudgetUsd: retryMaxBudgetUsd });
  658. this.sessionStore.markInitialized(key);
  659. if (result.claudeCode) result.claudeCode.sessionResetReason = resetReason;
  660. return result;
  661. }
  662. throw error;
  663. }
  664. });
  665. this.queues.set(key, current);
  666. try { return await current; }
  667. finally { if (this.queues.get(key) === current) this.queues.delete(key); }
  668. }
  669. }
  670. function parseFinal(content) {
  671. const text = String(content || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
  672. try { return JSON.parse(text); } catch {
  673. return {
  674. reply: text,
  675. confidence: 0.5,
  676. intent: 'unknown',
  677. reason: '模型未返回结构化结果,必须人工审核',
  678. requiresHuman: true,
  679. profileUpdates: {},
  680. };
  681. }
  682. }
  683. function clamp(value) { return Math.max(0, Math.min(1, Number(value) || 0)); }
  684. class QiweiAgentRuntime {
  685. constructor({ config, knowledge, modelClient = null }) {
  686. this.config = config;
  687. this.knowledge = knowledge;
  688. this.modelClient = modelClient || (config.provider === 'claude-code'
  689. ? new ClaudeCodeClient(config)
  690. : config.provider === 'anthropic'
  691. ? new AnthropicCompatibleClient(config)
  692. : new OpenAICompatibleClient(config));
  693. }
  694. tools() {
  695. return [
  696. {
  697. type: 'function',
  698. function: {
  699. name: 'search_knowledge',
  700. description: '检索企业规则、FAQ 和沟通 Playbook。',
  701. parameters: {
  702. type: 'object',
  703. properties: { query: { type: 'string' }, limit: { type: 'integer' } },
  704. required: ['query'],
  705. },
  706. },
  707. },
  708. {
  709. type: 'function',
  710. function: {
  711. name: 'search_properties',
  712. description: '按客户明确需求查询已接入的房源数据。数据源未配置或仅为演示数据时会明确返回警告。',
  713. parameters: {
  714. type: 'object',
  715. properties: {
  716. query: { type: 'string' },
  717. district: { type: 'string' },
  718. budgetMax: { type: 'number' },
  719. rooms: { type: 'integer' },
  720. decoration: { type: 'string' },
  721. orientation: { type: 'string' },
  722. schoolRequired: { type: 'boolean' },
  723. },
  724. },
  725. },
  726. },
  727. {
  728. type: 'function',
  729. function: {
  730. name: 'get_customer_profile',
  731. description: '读取当前客户画像与标签。',
  732. parameters: { type: 'object', properties: {} },
  733. },
  734. },
  735. ];
  736. }
  737. async executeTool(name, args, context) {
  738. if (name === 'search_knowledge') return this.knowledge.search(args.query, args.limit || 5);
  739. if (name === 'search_properties') return this.knowledge.searchProperties(args);
  740. if (name === 'get_customer_profile') return context.profile || { profile: {}, tags: [] };
  741. return { error: `未知工具 ${name}` };
  742. }
  743. async run({ conversation, messages, profile, customerIntelligence = {}, inboundContent }) {
  744. const history = messages.slice(-16).map(message => ({
  745. role: message.direction === 'inbound' ? 'user' : 'assistant',
  746. content: message.content,
  747. }));
  748. 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 || '' }));
  749. const system = [
  750. '你是企业微信客户服务 Agent。你需要自主判断是否检索知识、客户画像或房源工具,再生成回复草稿。',
  751. '严禁编造业务事实。工具没有证据时明确说需要确认。敏感或低置信内容标记 requiresHuman=true。',
  752. '最终必须只输出 JSON,包含 reply、confidence、intent、reason、requiresHuman、profileUpdates、tasks 和 alerts。',
  753. 'reason 用于内部监管台,简要说明依据和不确定性,不要发送给客户。',
  754. `历史房源推荐与反馈:${JSON.stringify(recommendationContext)}`,
  755. '不得重复推荐已明确 rejected 的房源;recommended 只代表已发给客户,不能推断客户感兴趣。',
  756. '',
  757. '必须遵守的规则:',
  758. this.knowledge.rulesText(),
  759. ].join('\n');
  760. const llmMessages = [{ role: 'system', content: system }, ...history];
  761. const toolTrace = [];
  762. const citations = [];
  763. for (let round = 0; round < this.config.maxToolRounds; round += 1) {
  764. const assistant = await this.modelClient.complete(llmMessages, this.tools(), {
  765. conversation,
  766. profile,
  767. customerIntelligence,
  768. inboundContent,
  769. });
  770. if (assistant.claudeCode) {
  771. toolTrace.push({
  772. tool: 'claude_code_session',
  773. args: { provider: 'Fmode Studio', model: assistant.claudeCode.model },
  774. result: {
  775. sessionName: assistant.claudeCode.sessionName,
  776. durationMs: assistant.claudeCode.durationMs,
  777. costUsd: assistant.claudeCode.costUsd,
  778. budgetLimitUsd: assistant.claudeCode.budgetLimitUsd,
  779. resumed: assistant.claudeCode.resumed,
  780. ...(assistant.claudeCode.processWarning ? { processWarning: assistant.claudeCode.processWarning } : {}),
  781. ...(assistant.claudeCode.sessionResetReason ? { sessionResetReason: assistant.claudeCode.sessionResetReason } : {}),
  782. },
  783. });
  784. }
  785. if (assistant.tool_calls?.length) {
  786. llmMessages.push({ role: 'assistant', content: assistant.content || '', tool_calls: assistant.tool_calls });
  787. for (const call of assistant.tool_calls) {
  788. let args = {};
  789. try { args = JSON.parse(call.function.arguments || '{}'); } catch {}
  790. const result = await this.executeTool(call.function.name, args, { conversation, profile, customerIntelligence });
  791. toolTrace.push({ tool: call.function.name, args, result });
  792. if (call.function.name === 'search_knowledge') {
  793. for (const item of result) citations.push({ id: item.id, source: item.source, heading: item.heading });
  794. }
  795. if (call.function.name === 'search_properties' && result.dataSource) {
  796. citations.push({
  797. id: `properties:${result.dataSource}`,
  798. source: result.dataSource,
  799. heading: `${result.dataLabel || '房源数据'}查询 ${result.total} 条`,
  800. });
  801. }
  802. llmMessages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) });
  803. }
  804. continue;
  805. }
  806. const parsedFinal = parseFinal(assistant.content);
  807. const authoritativeHistory = selectAuthoritativeHistory(history, 10);
  808. const currentProfile = profile?.profile || profile || {};
  809. const final = enforceAuthoritativeGrounding(parsedFinal, authoritativeHistory, currentProfile, inboundContent);
  810. const intelligence = extractExplicitCustomerIntelligence(inboundContent, currentProfile, final);
  811. const risky = RISK_PATTERN.test(inboundContent) || RISK_PATTERN.test(final.reply || '');
  812. const confidence = risky ? Math.min(clamp(final.confidence), 0.75) : clamp(final.confidence);
  813. return {
  814. content: String(final.reply || '').trim(),
  815. confidence,
  816. intent: String(final.intent || 'unknown'),
  817. reason: String(final.reason || 'Agent 未提供说明'),
  818. requiresHuman: Boolean(final.requiresHuman || risky || confidence < 0.7),
  819. profileUpdates: intelligence.profileUpdates,
  820. tasks: intelligence.tasks,
  821. alerts: intelligence.alerts,
  822. citations: [...new Map(citations.map(item => [item.id, item])).values()],
  823. toolTrace,
  824. };
  825. }
  826. throw new Error('Agent 工具调用轮次超过上限,已转人工处理');
  827. }
  828. }
  829. module.exports = {
  830. AgentNotConfiguredError,
  831. OpenAICompatibleClient,
  832. AnthropicCompatibleClient,
  833. ClaudeCodeClient,
  834. ClaudeCodeSessionStore,
  835. buildClaudeSessionName,
  836. claudeSessionResetReason,
  837. parseClaudeProcessResult,
  838. selectAuthoritativeHistory,
  839. enforceAuthoritativeGrounding,
  840. extractExplicitCustomerIntelligence,
  841. isNoReplyNeededMessage,
  842. resolveClaudeExecutable,
  843. QiweiAgentRuntime,
  844. };