qiwei-broker-playbook-run.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. const fs = require('fs');
  2. const path = require('path');
  3. const { okResult, errorResult } = require('../core/result-envelope');
  4. const { createRunDir, outputsRoot } = require('../core/output-paths');
  5. const { safeResult } = require('../core/shared-gateway');
  6. const PLAYBOOK_DIMENSIONS = [
  7. '客户分层', '开场白', '需求挖掘', '异议处理', '跟进节奏',
  8. '成交信号', '风险提示', '话术风格', '产品匹配', '转介绍',
  9. '复购维护', '竞争应对', '服务边界'
  10. ];
  11. const PLAYBOOK_KEYWORDS = {
  12. '客户分层': ['预算', '首付', '刚需', '改善', '投资'],
  13. '开场白': ['你好', '您好', '感谢', '介绍'],
  14. '需求挖掘': ['考虑', '想要', '需要', '关注', '看重'],
  15. '异议处理': ['但是', '不过', '担心', '顾虑', '再考虑'],
  16. '跟进节奏': ['明天', '下周', '到时候', '再联系', '约'],
  17. '成交信号': ['定', '签', '可以', '没问题', '确定'],
  18. '风险提示': ['注意', '提醒', '谨慎', '风险'],
  19. '话术风格': ['专业', '耐心', '直接', '细致'],
  20. '产品匹配': ['户型', '面积', '楼层', '朝向', '配套'],
  21. '转介绍': ['推荐', '介绍', '朋友', '亲戚'],
  22. '复购维护': ['后续', '服务', '维护', '长期'],
  23. '竞争应对': ['对比', '别家', '其他', '优势'],
  24. '服务边界': ['职责', '范围', '不包', '额外']
  25. };
  26. function brokerPlaybooksDir() {
  27. return path.join(outputsRoot(), 'broker-playbooks');
  28. }
  29. function ensureBrokerPlaybooksDir() {
  30. fs.mkdirSync(brokerPlaybooksDir(), { recursive: true });
  31. }
  32. function playbookFilePath(brokerUserId) {
  33. ensureBrokerPlaybooksDir();
  34. return path.join(brokerPlaybooksDir(), `${brokerUserId}.json`);
  35. }
  36. function readPlaybook(brokerUserId) {
  37. const filePath = playbookFilePath(brokerUserId);
  38. if (!fs.existsSync(filePath)) return null;
  39. try {
  40. return JSON.parse(fs.readFileSync(filePath, 'utf8'));
  41. } catch {
  42. return null;
  43. }
  44. }
  45. function writePlaybook(brokerUserId, playbook) {
  46. const filePath = playbookFilePath(brokerUserId);
  47. fs.writeFileSync(filePath, JSON.stringify(playbook, null, 2), 'utf8');
  48. return filePath;
  49. }
  50. function listMessageFiles() {
  51. const dir = path.join(outputsRoot(), 'messages');
  52. if (!fs.existsSync(dir)) return [];
  53. return fs.readdirSync(dir)
  54. .filter(f => f.startsWith('room-') && f.endsWith('.json'))
  55. .map(f => ({ name: f, path: path.join(dir, f), mtime: fs.statSync(path.join(dir, f)).mtime }))
  56. .sort((a, b) => b.mtime - a.mtime);
  57. }
  58. function extractRoomIdFromFileName(fileName) {
  59. const match = fileName.match(/^room-([^-]+)-/);
  60. return match ? match[1] : null;
  61. }
  62. function collectMessagesForBroker(brokerUserId, roomIds = null) {
  63. const files = listMessageFiles();
  64. const messages = [];
  65. for (const file of files) {
  66. const roomId = extractRoomIdFromFileName(file.name);
  67. if (roomIds && !roomIds.includes(roomId)) continue;
  68. try {
  69. const data = JSON.parse(fs.readFileSync(file.path, 'utf8'));
  70. if (!Array.isArray(data)) continue;
  71. for (const msg of data) {
  72. if (msg.senderId === brokerUserId) {
  73. messages.push({ ...msg, roomId });
  74. }
  75. }
  76. } catch {
  77. // ignore
  78. }
  79. }
  80. return messages;
  81. }
  82. function simpleKeywordPlaybook(messages) {
  83. const text = messages.map(m => m.content || '').join(' ');
  84. const playbook = {};
  85. for (const [dimension, keywords] of Object.entries(PLAYBOOK_KEYWORDS)) {
  86. const matched = keywords.filter(kw => text.includes(kw));
  87. if (matched.length) {
  88. playbook[dimension] = {
  89. inferred: true,
  90. keywords: [...new Set(matched)],
  91. note: `从 ${messages.length} 条消息中推断`
  92. };
  93. }
  94. }
  95. if (!Object.keys(playbook).length) {
  96. playbook.summary = { note: `收集了 ${messages.length} 条消息,未识别到明确 playbook 关键词` };
  97. }
  98. return playbook;
  99. }
  100. function buildPlaybookContext(brokerUserId, messages) {
  101. const text = messages.map(m => m.content || '').join('\n');
  102. return {
  103. brokerUserId,
  104. messageCount: messages.length,
  105. sampleMessages: messages.slice(0, 50),
  106. fullTextPreview: text.slice(0, 8000),
  107. suggestedDimensions: PLAYBOOK_DIMENSIONS,
  108. hint: '请基于 sampleMessages 和 fullTextPreview 分析顾问 playbook,返回 JSON 后调用 qiwei_save_broker_playbook 保存。'
  109. };
  110. }
  111. const qiweiPrepareBrokerPlaybook = safeResult(async function qiweiPrepareBrokerPlaybook(input = {}) {
  112. const brokerUserId = String(input.brokerUserId || input.brokerId || '').trim();
  113. if (!brokerUserId) return errorResult('缺少 brokerUserId');
  114. const roomIds = Array.isArray(input.roomIds) ? input.roomIds.map(String) : null;
  115. const messages = collectMessagesForBroker(brokerUserId, roomIds);
  116. const context = buildPlaybookContext(brokerUserId, messages);
  117. ensureBrokerPlaybooksDir();
  118. const runDir = createRunDir('broker-playbooks', `context-${brokerUserId}`);
  119. const filePath = path.join(runDir, `context-${brokerUserId}.json`);
  120. fs.writeFileSync(filePath, JSON.stringify(context, null, 2), 'utf8');
  121. return okResult({
  122. assistantMessage: `已为顾问 ${brokerUserId} 准备 playbook 分析上下文:共 ${messages.length} 条消息。`,
  123. summary: { brokerUserId, messageCount: messages.length },
  124. data: { context, contextFile: path.relative(outputsRoot(), filePath) },
  125. files: [filePath],
  126. nextActions: ['基于 context 分析后调用 qiwei_save_broker_playbook 保存']
  127. });
  128. });
  129. const qiweiDistillBroker = safeResult(async function qiweiDistillBroker(input = {}) {
  130. const brokerUserId = String(input.brokerUserId || input.brokerId || '').trim();
  131. if (!brokerUserId) return errorResult('缺少 brokerUserId');
  132. const aiMode = String(input.aiMode || '').trim();
  133. const roomIds = Array.isArray(input.roomIds) ? input.roomIds.map(String) : null;
  134. const messages = collectMessagesForBroker(brokerUserId, roomIds);
  135. if (aiMode === 'keyword') {
  136. const playbook = simpleKeywordPlaybook(messages);
  137. const saved = { brokerUserId, playbook, source: 'keyword', messageCount: messages.length, updatedAt: new Date().toISOString() };
  138. const filePath = writePlaybook(brokerUserId, saved);
  139. return okResult({
  140. assistantMessage: `关键词模式 playbook 蒸馏完成:${brokerUserId}。`,
  141. summary: { brokerUserId, messageCount: messages.length, dimensions: Object.keys(playbook) },
  142. data: saved,
  143. files: [filePath]
  144. });
  145. }
  146. const context = buildPlaybookContext(brokerUserId, messages);
  147. ensureBrokerPlaybooksDir();
  148. const runDir = createRunDir('broker-playbooks', `context-${brokerUserId}`);
  149. const filePath = path.join(runDir, `context-${brokerUserId}.json`);
  150. fs.writeFileSync(filePath, JSON.stringify(context, null, 2), 'utf8');
  151. return okResult({
  152. assistantMessage: `已为顾问 ${brokerUserId} 准备 playbook 分析上下文,请 Agent 分析后调用 qiwei_save_broker_playbook 保存。`,
  153. summary: { brokerUserId, messageCount: messages.length, requiresAgentAnalysis: true },
  154. data: { context, contextFile: path.relative(outputsRoot(), filePath), saveEndpoint: 'qiwei_save_broker_playbook' },
  155. files: [filePath],
  156. nextActions: ['分析 context 后调用 qiwei_save_broker_playbook']
  157. });
  158. });
  159. const qiweiSaveBrokerPlaybook = safeResult(async function qiweiSaveBrokerPlaybook(input = {}) {
  160. const brokerUserId = String(input.brokerUserId || input.brokerId || '').trim();
  161. const playbook = input.playbook || input.content;
  162. if (!brokerUserId) return errorResult('缺少 brokerUserId');
  163. if (!playbook || typeof playbook !== 'object') return errorResult('缺少 playbook(JSON)');
  164. const saved = {
  165. brokerUserId,
  166. playbook,
  167. source: input.source || 'agent',
  168. messageCount: input.messageCount || 0,
  169. updatedAt: new Date().toISOString()
  170. };
  171. const filePath = writePlaybook(brokerUserId, saved);
  172. return okResult({
  173. assistantMessage: `顾问 playbook 已保存:${brokerUserId}。`,
  174. summary: { brokerUserId, dimensions: Object.keys(playbook) },
  175. data: saved,
  176. files: [filePath]
  177. });
  178. });
  179. const qiweiBatchDistillBroker = safeResult(async function qiweiBatchDistillBroker(input = {}) {
  180. const brokerUserIds = Array.isArray(input.brokerUserIds) ? input.brokerUserIds.map(String).filter(Boolean) : [];
  181. if (!brokerUserIds.length) return errorResult('缺少 brokerUserIds');
  182. const results = [];
  183. for (const brokerUserId of brokerUserIds) {
  184. try {
  185. const result = await qiweiDistillBroker({ ...input, brokerUserId });
  186. results.push({ brokerUserId, status: result.status, summary: result.summary });
  187. } catch (error) {
  188. results.push({ brokerUserId, status: 'error', message: String(error && error.message ? error.message : error) });
  189. }
  190. }
  191. return okResult({
  192. assistantMessage: `批量 playbook 蒸馏完成:${results.filter(r => r.status === 'ok').length}/${results.length}。`,
  193. summary: { total: results.length, succeeded: results.filter(r => r.status === 'ok').length, failed: results.filter(r => r.status !== 'ok').length },
  194. data: { results }
  195. });
  196. });
  197. const qiweiBatchSaveBrokerPlaybook = safeResult(async function qiweiBatchSaveBrokerPlaybook(input = {}) {
  198. const items = Array.isArray(input.items) ? input.items : [];
  199. if (!items.length) return errorResult('缺少 items({ brokerUserId, playbook } 数组)');
  200. const results = [];
  201. for (const item of items) {
  202. try {
  203. const result = await qiweiSaveBrokerPlaybook({ brokerUserId: item.brokerUserId, playbook: item.playbook, source: item.source });
  204. results.push({ brokerUserId: item.brokerUserId, status: result.status, summary: result.summary });
  205. } catch (error) {
  206. results.push({ brokerUserId: item.brokerUserId, status: 'error', message: String(error && error.message ? error.message : error) });
  207. }
  208. }
  209. return okResult({
  210. assistantMessage: `批量 playbook 保存完成:${results.filter(r => r.status === 'ok').length}/${results.length}。`,
  211. summary: { total: results.length, succeeded: results.filter(r => r.status === 'ok').length, failed: results.filter(r => r.status !== 'ok').length },
  212. data: { results }
  213. });
  214. });
  215. const qiweiExportBrokerPlaybooks = safeResult(async function qiweiExportBrokerPlaybooks(input = {}) {
  216. let brokerUserIds = Array.isArray(input.brokerUserIds)
  217. ? input.brokerUserIds.map(String).filter(Boolean)
  218. : null;
  219. if (!brokerUserIds) {
  220. ensureBrokerPlaybooksDir();
  221. brokerUserIds = fs.readdirSync(brokerPlaybooksDir())
  222. .filter(f => f.endsWith('.json') && !f.startsWith('context-'))
  223. .map(f => f.replace(/\.json$/, ''));
  224. }
  225. if (!brokerUserIds.length) return errorResult('没有可导出的 playbook');
  226. let XLSX;
  227. try {
  228. XLSX = require('xlsx');
  229. } catch {
  230. return errorResult('当前包尚未安装 xlsx,无法导出 Excel;请运行 npm install xlsx');
  231. }
  232. const rows = [];
  233. for (const brokerUserId of brokerUserIds) {
  234. const data = readPlaybook(brokerUserId);
  235. if (!data || !data.playbook) continue;
  236. rows.push({ brokerUserId, ...flattenObject(data.playbook), updatedAt: data.updatedAt });
  237. }
  238. ensureBrokerPlaybooksDir();
  239. const fileName = `export-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.xlsx`;
  240. const filePath = path.join(brokerPlaybooksDir(), fileName);
  241. const worksheet = XLSX.utils.json_to_sheet(rows);
  242. const workbook = XLSX.utils.book_new();
  243. XLSX.utils.book_append_sheet(workbook, worksheet, 'playbooks');
  244. XLSX.writeFile(workbook, filePath);
  245. return okResult({
  246. assistantMessage: `已导出 ${rows.length} 条顾问 playbook:${filePath}。`,
  247. summary: { rowCount: rows.length },
  248. data: { filePath: path.relative(outputsRoot(), filePath) },
  249. files: [filePath]
  250. });
  251. });
  252. const qiweiGetBrokerPlaybook = safeResult(async function qiweiGetBrokerPlaybook(input = {}) {
  253. const brokerUserId = String(input.brokerUserId || input.brokerId || '').trim();
  254. if (!brokerUserId) return errorResult('缺少 brokerUserId');
  255. const playbook = readPlaybook(brokerUserId);
  256. if (!playbook) return errorResult(`顾问 ${brokerUserId} 暂无 playbook`);
  257. return okResult({
  258. assistantMessage: `已读取顾问 ${brokerUserId} 的 playbook。`,
  259. summary: { brokerUserId, dimensions: Object.keys(playbook.playbook || {}) },
  260. data: playbook
  261. });
  262. });
  263. function flattenObject(obj, prefix = '') {
  264. const result = {};
  265. for (const [key, value] of Object.entries(obj)) {
  266. const newKey = prefix ? `${prefix}.${key}` : key;
  267. if (value && typeof value === 'object' && !Array.isArray(value)) {
  268. Object.assign(result, flattenObject(value, newKey));
  269. } else {
  270. result[newKey] = Array.isArray(value) ? value.join(',') : value;
  271. }
  272. }
  273. return result;
  274. }
  275. module.exports = {
  276. qiweiDistillBroker,
  277. qiweiPrepareBrokerPlaybook,
  278. qiweiSaveBrokerPlaybook,
  279. qiweiBatchDistillBroker,
  280. qiweiBatchSaveBrokerPlaybook,
  281. qiweiExportBrokerPlaybooks,
  282. qiweiGetBrokerPlaybook
  283. };