agent-response-quality-smoke-test.js 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453
  1. 'use strict';
  2. const assert = require('assert/strict');
  3. const fs = require('fs');
  4. const os = require('os');
  5. const path = require('path');
  6. const RUN_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-response-quality-'));
  7. process.env.QIWEI_OUTPUTS_DIR = path.join(RUN_ROOT, 'outputs');
  8. const { AgentContextBuilder } = require('../mcp/src/core/agent-context-builder');
  9. const { QiweiAgentRuntime, isNoReplyNeededMessage } = require('../mcp/src/core/agent-runtime');
  10. const { AgentWorkbenchDb } = require('../mcp/src/core/agent-workbench-db');
  11. const { AgentWorkbenchService } = require('../mcp/src/core/agent-workbench-service');
  12. const {
  13. QUALITY_FALLBACK_REASONS,
  14. normalizeQualityChecks,
  15. normalizeQualityScore,
  16. qualityFallbackReason,
  17. } = require('../mcp/src/core/response-quality-contract');
  18. const { verifyResponseQuality } = require('../mcp/src/core/response-quality-verifier');
  19. const { frozenA4Cases, hardFailCases, qualifiedCases } = require('./response-quality-regression-fixtures');
  20. const LOW_QUALITY_JSONL = `
  21. {"id":"first_consult","inbound":"想了解你们的企业服务怎么收费?","reply":"您好,感谢您的咨询,我们一直致力于提供优质服务。"}
  22. {"id":"price_promise","inbound":"最低能做到多少钱?","reply":"保证给您全网最低价,一定不会更贵。"}
  23. {"id":"missing_info","inbound":"我想做员工培训","reply":"您的预算是多少?计划什么时候开始?预计多少人参加?"}
  24. {"id":"objection","inbound":"这个方案太贵了","reply":"您的预算是多少?"}
  25. {"id":"urgent","inbound":"今天能安排吗?","reply":"您好,感谢关注,我们会认真研究并持续努力为您提供全面周到专业细致的服务体验,请耐心等待。"}
  26. {"id":"complaint","inbound":"你们一直不处理,我要投诉","reply":"我们一定马上全部解决,绝对不会让您失望。"}
  27. {"id":"thanks","inbound":"谢谢","reply":"不客气,后续有需要随时联系。"}
  28. {"id":"answered_context","inbound":"预算已经说过是20万","reply":"请问您的预算是多少?","messages":[{"role":"assistant","content":"请问您的预算是多少?"}]}
  29. {"id":"repeat_question","inbound":"我还在考虑","reply":"请问您的预算是多少?","messages":[{"role":"assistant","content":"请问您的预算是多少?"}]}
  30. {"id":"tool_no_result","inbound":"有现成案例吗?","reply":"有的,我们已经找到 3 个完全匹配案例。","toolNoResult":true,"toolTrace":[{"tool":"search_knowledge","result":{"items":[]}}]}
  31. {"id":"low_quality","inbound":"能不能先给个建议?","reply":"好的。"}
  32. `;
  33. const GOOD_QUALITY_JSONL = `
  34. {"id":"first_consult","inbound":"想了解你们的企业服务怎么收费?","reply":"费用要按服务范围确认;建议由人工专员核价,核验前不作为最终报价依据。","requiresHuman":true}
  35. {"id":"price_check","inbound":"最低能做到多少钱?","reply":"当前最低价需要核验;建议由人工专员复核,核验前不作为最终报价依据。","requiresHuman":true}
  36. {"id":"missing_info","inbound":"我想做员工培训","reply":"可以先按员工培训方向梳理;请问预计多少人参加?确认人数后我给您下一步方案。"}
  37. {"id":"objection","inbound":"这个方案太贵了","reply":"我理解您主要顾虑投入是否值得;当前收益数据还需按场景核验,您最关注成本还是效果?确认后我补对应依据。"}
  38. {"id":"urgent","inbound":"今天能安排吗?","reply":"今天能否安排暂时不确定;当前档期尚未核实,建议由人工顾问确认时间。"}
  39. {"id":"complaint","inbound":"你们一直不处理,我要投诉","reply":"您在意的是迟迟未处理;建议由主管接管复核,结论出来前按未处理完成看待。","requiresHuman":true}
  40. {"id":"thanks","inbound":"谢谢","reply":""}
  41. {"id":"answered_context","inbound":"预算已经说过是20万","reply":"已记录预算20万;我会按这个上限继续整理,确认后给您方案。","messages":[{"role":"assistant","content":"请问您的预算是多少?"}]}
  42. {"id":"no_repeat","inbound":"我还在考虑","reply":"可以按您的节奏来;我先保留当前条件,有明确时间后再继续。","messages":[{"role":"assistant","content":"请问您的预算是多少?"}]}
  43. {"id":"tool_no_result","inbound":"有现成案例吗?","reply":"当前数据源没有返回可验证案例;我可以调整一个筛选条件后再查。","toolNoResult":true,"toolTrace":[{"tool":"search_knowledge","result":{"items":[]}}]}
  44. {"id":"suggestion","inbound":"能不能先给个建议?","reply":"可以,建议先明确最重要的结果目标;您最希望先解决哪个问题?确认后我给您第一版方案。"}
  45. `;
  46. function parseJsonl(value) {
  47. return String(value || '').trim().split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line));
  48. }
  49. function assessFixture(item) {
  50. return verifyResponseQuality({
  51. inboundContent: item.inbound,
  52. reply: item.reply,
  53. messages: item.messages || [],
  54. toolNoResult: Boolean(item.toolNoResult),
  55. toolTrace: item.toolTrace || [],
  56. requiresHuman: Boolean(item.requiresHuman),
  57. });
  58. }
  59. function modelPayload(reply, overrides = {}) {
  60. return JSON.stringify({
  61. reply,
  62. confidence: 0.95,
  63. intent: 'quality_smoke',
  64. reason: '专项测试',
  65. requiresHuman: false,
  66. profileUpdates: {},
  67. tasks: [],
  68. alerts: [],
  69. ...overrides,
  70. });
  71. }
  72. function setupService(mode, output) {
  73. const dir = fs.mkdtempSync(path.join(RUN_ROOT, `${mode}-`));
  74. const db = new AgentWorkbenchDb(path.join(dir, 'workbench.db'), { defaultMode: mode, autoSendConfidence: 0.88 });
  75. const sent = [];
  76. const service = new AgentWorkbenchService({
  77. db,
  78. agent: {
  79. modelClient: { isConfigured: () => true },
  80. async run() { return { ...output }; },
  81. },
  82. qiwei: {
  83. isConfigured: () => true,
  84. async sendText(toId, content) { sent.push({ toId, content }); return { isSendSuccess: true }; },
  85. },
  86. config: {
  87. accountKey: 'quality-smoke',
  88. agent: { apiKey: 'smoke', model: 'stub', provider: 'stub', qualityPassScore: 82 },
  89. qiwei: { allowedSenders: ['contact-1'] },
  90. memory: { enabled: false },
  91. },
  92. });
  93. return {
  94. db,
  95. sent,
  96. service,
  97. close() { service.stopBackgroundWorkers(); db.close(); },
  98. };
  99. }
  100. async function main() {
  101. const lowCases = parseJsonl(LOW_QUALITY_JSONL);
  102. const goodCases = parseJsonl(GOOD_QUALITY_JSONL);
  103. const lowResults = lowCases.map(item => ({ id: item.id, assessment: assessFixture(item) }));
  104. const goodResults = goodCases.map(item => ({ id: item.id, assessment: assessFixture(item) }));
  105. const lowBlocked = lowResults.filter(item => !item.assessment.passed).length;
  106. const goodRejected = goodResults.filter(item => !item.assessment.passed);
  107. assert.equal(lowBlocked, lowCases.length, JSON.stringify(lowResults.filter(item => item.assessment.passed)));
  108. assert.deepEqual(goodRejected.map(item => ({ id: item.id, failed: item.assessment.failedCheckIds })), []);
  109. const hardRegressionResults = hardFailCases.map(item => ({ ...item, assessment: verifyResponseQuality(item.input) }));
  110. for (const item of hardRegressionResults) {
  111. assert.equal(item.assessment.passed, false, item.id);
  112. for (const expected of item.expectedFailed) {
  113. assert(item.assessment.failedCheckIds.includes(expected), `${item.id} missing ${expected}: ${item.assessment.failedCheckIds.join(',')}`);
  114. assert(item.assessment.hardFailureIds.includes(expected), `${item.id} expected hard failure ${expected}`);
  115. }
  116. }
  117. const qualifiedRegressionResults = qualifiedCases.map(item => ({ ...item, assessment: verifyResponseQuality(item.input) }));
  118. assert.deepEqual(qualifiedRegressionResults.filter(item => !item.assessment.passed).map(item => ({
  119. id: item.id,
  120. failed: item.assessment.failedCheckIds,
  121. })), []);
  122. assert.deepEqual(frozenA4Cases.map(item => item.id), [
  123. 'RQ-P01-R11', 'RQ-P02-R4', 'RQ-P02-R10', 'RQ-P03-R4', 'RQ-P04-R9',
  124. 'RQ-P04-R10', 'RQ-P05-R1', 'RQ-P05-R12', 'RQ-GEN-FIRST-1', 'RQ-GEN-COMPLAINT-1',
  125. ]);
  126. const frozenA4Results = frozenA4Cases.map(item => ({ ...item, assessment: verifyResponseQuality(item.input) }));
  127. for (const item of frozenA4Results) {
  128. assert.equal(item.assessment.passed, false, `${item.id} frozen bad reply passed`);
  129. for (const expected of item.expectedFailed) {
  130. assert(item.assessment.failedCheckIds.includes(expected), `${item.id} missing frozen check ${expected}`);
  131. assert(item.assessment.hardFailureIds.includes(expected), `${item.id} expected frozen hard failure ${expected}`);
  132. }
  133. }
  134. assert.equal(normalizeQualityScore(82.6), 83);
  135. assert.equal(normalizeQualityScore(undefined), null);
  136. assert.equal(normalizeQualityChecks(undefined), null);
  137. assert.deepEqual(normalizeQualityChecks([{ id: 'fact_evidence', passed: 0, weight: '14', hard: 1, detail: '待核验' }]), [{
  138. id: 'fact_evidence', passed: false, weight: 14, hard: true, detail: '待核验',
  139. }]);
  140. assert.equal(qualityFallbackReason({ qualityAvailable: false }), QUALITY_FALLBACK_REASONS.QUALITY_UNAVAILABLE);
  141. assert.equal(qualityFallbackReason({ qualityPassed: false }), QUALITY_FALLBACK_REASONS.QUALITY_GATE_FAILED);
  142. assert.equal(qualityFallbackReason({ qualityPassed: false, rewriteCount: 1 }), QUALITY_FALLBACK_REASONS.REWRITE_LIMIT_REACHED);
  143. assert.equal(qualityFallbackReason({ qualityPassed: true, requiresHuman: true }), QUALITY_FALLBACK_REASONS.REQUIRES_HUMAN);
  144. assert.equal(qualityFallbackReason({ qualityPassed: true }), '');
  145. const unrelatedEvidence = verifyResponseQuality({
  146. inboundContent: '最低多少钱?',
  147. reply: '最低只要 99 元,我已经确认;我可以直接给您下单。',
  148. toolTrace: [{ tool: 'search_knowledge', result: { title: '品牌介绍', content: '成立于某年' } }],
  149. citations: [{ id: 'brand.md#intro', source: 'brand.md', heading: '品牌介绍' }],
  150. });
  151. assert.equal(unrelatedEvidence.passed, false);
  152. assert(unrelatedEvidence.failedCheckIds.includes('fact_evidence'));
  153. const claimBoundaryCases = [
  154. verifyResponseQuality({
  155. inboundContent: '价格确认了吗?',
  156. reply: '价格已确认,其他信息待核验;我可以继续帮您核对。',
  157. citations: [{ content: '其他信息待核验' }],
  158. }),
  159. verifyResponseQuality({
  160. inboundContent: '还有库存吗?',
  161. reply: '库存已确认;我可以直接为您保留。',
  162. toolTrace: [{ tool: 'inventory_lookup', result: { content: '库存字段说明' } }],
  163. }),
  164. verifyResponseQuality({
  165. inboundContent: '价格是多少?',
  166. reply: '已确认价格是 99 元,其他待核验;我可以把依据发给您。',
  167. citations: [{ content: '当前价格是 88 元' }],
  168. }),
  169. ];
  170. assert(claimBoundaryCases.every(item => !item.passed && item.failedCheckIds.includes('fact_evidence')));
  171. const sourcedPrice = verifyResponseQuality({
  172. inboundContent: '价格是多少?',
  173. reply: '已确认价格是 99 元;建议由人工专员复核计价依据。',
  174. citations: [{ content: '当前价格是 99 元' }],
  175. requiresHuman: true,
  176. });
  177. assert.equal(sourcedPrice.passed, true, JSON.stringify(sourcedPrice.failedCheckIds));
  178. const splitPromise = verifyResponseQuality({
  179. inboundContent: '价格能确认吗?',
  180. reply: '保证是 99 元,其他待核验;我可以继续跟进。',
  181. citations: [{ content: '其他信息待核验' }],
  182. });
  183. assert.equal(splitPromise.passed, false);
  184. assert(splitPromise.failedCheckIds.includes('sensitive_commitment'));
  185. const bundledQuestions = verifyResponseQuality({
  186. inboundContent: '我想咨询服务',
  187. reply: '可以先了解需求;预算多少、什么时候开始、多少人参加?确认后我给您方案。',
  188. });
  189. assert.equal(bundledQuestions.passed, false);
  190. assert(bundledQuestions.failedCheckIds.includes('question_count'));
  191. const singleQuestion = verifyResponseQuality({
  192. inboundContent: '我想咨询服务',
  193. reply: '可以先明确投入范围;请问您的预算上限大概是多少?确认后我给您方案。',
  194. });
  195. assert.equal(singleQuestion.passed, true, JSON.stringify(singleQuestion.failedCheckIds));
  196. const semanticRepeat = verifyResponseQuality({
  197. inboundContent: '预算已经确认是20万',
  198. reply: '已记录当前需求;请问您的总投入上限多少?确认后我继续整理。',
  199. });
  200. assert.equal(semanticRepeat.passed, false);
  201. assert(semanticRepeat.failedCheckIds.includes('repeated_question'));
  202. const factThenNewPreference = verifyResponseQuality({
  203. inboundContent: '推荐新北区三室300万以内的房源',
  204. reply: '已记录总价300万以内;您更看重通勤还是楼层?',
  205. });
  206. assert.equal(factThenNewPreference.failedCheckIds.includes('repeated_question'), false);
  207. for (const emptyAnswer of ['这边给您处理一下。', '我帮您关注一下。', '具体情况我再回复。']) {
  208. const assessment = verifyResponseQuality({ inboundContent: '当前有库存吗?', reply: emptyAnswer });
  209. assert.equal(assessment.passed, false);
  210. assert(assessment.failedCheckIds.includes('direct_answer'));
  211. }
  212. assert.equal(verifyResponseQuality({
  213. inboundContent: '当前有库存吗?',
  214. reply: '当前资料不足,需要先核验在售状态;我确认后回复您。',
  215. }).passed, true);
  216. const builder = new AgentContextBuilder({
  217. config: { promptCharLimit: 2400, systemPromptCharLimit: 2200, businessGoal: '推进到清晰且可执行的下一步' },
  218. knowledge: { contextText: () => '通用规则:未知事实必须核验。'.repeat(200) },
  219. });
  220. const system = builder.buildSystemContext({ memoryContext: { promptText: '旧 Session 说客户接受高价。'.repeat(100) } });
  221. const prompt = builder.buildClaudePrompt([
  222. { role: 'assistant', content: '请问预算是多少?' },
  223. { role: 'user', content: '本轮我只确认预算是20万。' },
  224. { role: 'tool', content: JSON.stringify({ result: '当前没有已核验报价' }) },
  225. ], {
  226. inboundContent: '本轮我只确认预算是20万。',
  227. profile: { profile: { budgetWan: 20, __evidence: { budgetWan: { text: '预算是20万' } } } },
  228. customerIntelligence: { tasks: [{ status: 'open', title: '确认时间' }], alerts: [] },
  229. });
  230. const budget = builder.budgetReport();
  231. assert(prompt.length <= 2400 && system.length <= 2200);
  232. assert.match(prompt, /本轮我只确认预算是20万/);
  233. assert.match(system, /旧模型 Session/);
  234. assert(Object.values(budget.prompt).every(item => item.used <= item.budget));
  235. assert(Object.values(budget.system).every(item => item.used <= item.budget));
  236. const [requestA, requestB] = await Promise.all([
  237. Promise.resolve(builder.buildClaudePromptResult([{ role: 'user', content: '请求甲的唯一内容' }], { profile: { profile: { request: 'A' } } })),
  238. Promise.resolve(builder.buildClaudePromptResult([{ role: 'user', content: '请求乙的唯一内容' }], { profile: { profile: { request: 'B' } } })),
  239. ]);
  240. assert.match(requestA.text, /请求甲的唯一内容/);
  241. assert.doesNotMatch(requestA.text, /请求乙的唯一内容/);
  242. assert.match(requestB.text, /请求乙的唯一内容/);
  243. assert.notDeepEqual(requestA.budgetReport, {});
  244. assert.notStrictEqual(requestA.budgetReport, requestB.budgetReport);
  245. let noReplyModelCalls = 0;
  246. const noReplyRuntime = new QiweiAgentRuntime({
  247. config: { provider: 'stub', maxToolRounds: 2, qualityPassScore: 82 },
  248. knowledge: { search: () => [], contextText: () => '' },
  249. modelClient: { async complete() { noReplyModelCalls += 1; throw new Error('pure acknowledgement must not reach the model'); } },
  250. });
  251. const noReply = await noReplyRuntime.run({
  252. conversation: { id: 'runtime-no-reply', mode: 'autopilot' },
  253. messages: [{ direction: 'inbound', content: '行,知道了,谢谢' }],
  254. profile: { profile: {} },
  255. inboundContent: '行,知道了,谢谢',
  256. });
  257. assert.equal(isNoReplyNeededMessage('行,知道了,谢谢'), true);
  258. assert.equal(isNoReplyNeededMessage('行的、明白,谢谢'), true);
  259. assert.equal(isNoReplyNeededMessage('行,我还想问价格'), false);
  260. assert.equal(isNoReplyNeededMessage('好的,价格是多少?'), false);
  261. assert.equal(noReply.status, 'no_reply_needed');
  262. assert.equal(noReply.noReplyNeeded, true);
  263. assert.equal(noReply.content, '');
  264. assert.equal(noReply.qualityScore, null);
  265. assert.equal(noReplyModelCalls, 0);
  266. const sequence = [
  267. modelPayload('您好,感谢您的咨询,请耐心等待。'),
  268. modelPayload('今天能否安排暂时不确定;当前档期尚未核实,建议由人工顾问确认时间。'),
  269. ];
  270. const runtime = new QiweiAgentRuntime({
  271. config: { provider: 'stub', maxToolRounds: 2, qualityPassScore: 82 },
  272. knowledge: { search: () => [], contextText: () => '' },
  273. modelClient: { async complete() { return { content: sequence.shift() }; } },
  274. });
  275. const rewritten = await runtime.run({
  276. conversation: { id: 'runtime-rewrite', mode: 'autopilot' },
  277. messages: [{ direction: 'inbound', content: '今天能安排吗?' }],
  278. profile: { profile: {} },
  279. inboundContent: '今天能安排吗?',
  280. });
  281. assert.equal(rewritten.rewriteCount, 1);
  282. assert.equal(rewritten.qualityPassed, true);
  283. assert.equal(rewritten.requiresHuman, false);
  284. let failedCalls = 0;
  285. const failedRuntime = new QiweiAgentRuntime({
  286. config: { provider: 'stub', maxToolRounds: 2, qualityPassScore: 82 },
  287. knowledge: { search: () => [], contextText: () => '' },
  288. modelClient: { async complete() { failedCalls += 1; return { content: modelPayload('好的。') }; } },
  289. });
  290. const failedRewrite = await failedRuntime.run({
  291. conversation: { id: 'runtime-fallback', mode: 'autopilot' },
  292. messages: [{ direction: 'inbound', content: '今天能安排吗?' }],
  293. profile: { profile: {} },
  294. inboundContent: '今天能安排吗?',
  295. });
  296. assert.equal(failedCalls, 2);
  297. assert.equal(failedRewrite.rewriteCount, 1);
  298. assert.equal(failedRewrite.qualityPassed, false);
  299. assert.equal(failedRewrite.requiresHuman, true);
  300. assert.equal(failedRewrite.fallbackReason, QUALITY_FALLBACK_REASONS.REWRITE_LIMIT_REACHED);
  301. let plannedSystem = '';
  302. let plannedContext = null;
  303. const plannedRuntime = new QiweiAgentRuntime({
  304. config: { provider: 'stub', maxToolRounds: 1, qualityPassScore: 82 },
  305. knowledge: { search: () => [], contextText: () => '' },
  306. modelClient: {
  307. async complete(messages, tools, context) {
  308. plannedSystem = messages[0].content;
  309. plannedContext = context;
  310. return { content: modelPayload('这个方案偏贵的顾虑合理;当前收益没有可核验结论,建议先比较最关键的结果。') };
  311. },
  312. },
  313. });
  314. const plannedReply = await plannedRuntime.run({
  315. conversation: { id: 'runtime-sales-move', mode: 'review' },
  316. messages: [{ direction: 'inbound', content: '这个方案太贵了。' }],
  317. profile: { profile: {} },
  318. inboundContent: '这个方案太贵了。',
  319. });
  320. assert.equal(plannedReply.salesMovePlan.primaryMove, 'acknowledge_objection');
  321. assert.equal(plannedReply.salesMovePlan.candidateMoves.filter(item => item.selected).length, 1);
  322. assert.match(plannedSystem, /本轮唯一主要沟通动作:acknowledge_objection/);
  323. assert.match(plannedContext.salesMoveInstruction, /先接住并回应当前顾虑/);
  324. const qualified = {
  325. content: '今天能否安排暂时不确定;当前档期尚未核实,建议由人工顾问确认时间。',
  326. confidence: 0.95,
  327. intent: 'schedule',
  328. reason: '当前档期需要核验',
  329. requiresHuman: false,
  330. profileUpdates: {}, tasks: [], alerts: [], citations: [], toolTrace: [],
  331. };
  332. const low = {
  333. content: '好的。', confidence: 0.95, intent: 'schedule', reason: 'low', requiresHuman: false,
  334. profileUpdates: {}, tasks: [], alerts: [], citations: [], toolTrace: [], rewriteCount: 1,
  335. };
  336. for (const mode of ['review', 'auto', 'autopilot']) {
  337. const ctx = setupService(mode, low);
  338. try {
  339. const result = await ctx.service.ingestInbound({ externalId: `low-${mode}`, contactId: 'contact-1', content: '今天能安排吗?' });
  340. assert.equal(result.status, 'pending_review');
  341. assert.equal(result.draft.requires_human, true);
  342. assert.equal(result.draft.quality.qualityPassed, false);
  343. assert(result.draft.quality.qualityChecks.length >= 10);
  344. assert.equal(result.draft.quality.fallbackReason, QUALITY_FALLBACK_REASONS.REWRITE_LIMIT_REACHED);
  345. assert.equal(ctx.sent.length, 0);
  346. } finally { ctx.close(); }
  347. }
  348. for (const mode of ['auto', 'autopilot']) {
  349. const ctx = setupService(mode, qualified);
  350. try {
  351. const result = await ctx.service.ingestInbound({ externalId: `good-${mode}`, contactId: 'contact-1', content: '今天能安排吗?' });
  352. assert.equal(result.status, mode === 'auto' ? 'sent' : 'autopilot_sent');
  353. assert.equal(ctx.sent.length, 1);
  354. } finally { ctx.close(); }
  355. }
  356. const qualifiedRisk = {
  357. content: '最低报价需要核验;建议由人工专员复核计价依据,核验前不作为最终报价。',
  358. confidence: 0.95,
  359. intent: 'pricing',
  360. reason: '报价需要人工核验',
  361. requiresHuman: true,
  362. profileUpdates: {}, tasks: [], alerts: [], citations: [], toolTrace: [],
  363. };
  364. for (const mode of ['review', 'auto', 'autopilot']) {
  365. const ctx = setupService(mode, qualifiedRisk);
  366. try {
  367. const result = await ctx.service.ingestInbound({ externalId: `risk-${mode}`, contactId: 'contact-1', content: '最低报价能确认吗?' });
  368. assert.equal(result.status, 'pending_review');
  369. assert.equal(result.draft.requires_human, true);
  370. assert.equal(result.draft.quality.qualityPassed, true);
  371. assert.equal(result.draft.quality.fallbackReason, QUALITY_FALLBACK_REASONS.REQUIRES_HUMAN);
  372. assert.equal(ctx.sent.length, 0);
  373. assert.equal(ctx.db.listMessages(result.conversation.id).filter(message => message.direction === 'outbound').length, 0);
  374. } finally { ctx.close(); }
  375. }
  376. const human = setupService('review', low);
  377. try {
  378. const draftResult = await human.service.ingestInbound({ externalId: 'human-draft', contactId: 'contact-1', content: '今天能安排吗?' });
  379. await human.service.approveDraft(draftResult.draft.id, { content: '好的。', actor: 'human' });
  380. await human.service.manualSend(draftResult.conversation.id, '收到。', 'human');
  381. assert.equal(human.sent.length, 2);
  382. } finally { human.close(); }
  383. const directAutopilot = setupService('autopilot', qualified);
  384. try {
  385. const conversation = directAutopilot.db.ensureConversation('contact-1', '匿名客户');
  386. const inbound = directAutopilot.db.insertMessage({
  387. conversationId: conversation.id,
  388. direction: 'inbound',
  389. senderType: 'customer',
  390. content: '今天能安排吗?',
  391. }).message;
  392. await assert.rejects(() => directAutopilot.service.sendAutopilotReply(conversation, inbound, {
  393. ...low,
  394. qualityPassed: false,
  395. requiresHuman: true,
  396. }), /质量校验/);
  397. assert.equal(directAutopilot.sent.length, 0);
  398. } finally { directAutopilot.close(); }
  399. const thanks = setupService('autopilot', qualified);
  400. try {
  401. const result = await thanks.service.ingestInbound({ externalId: 'thanks', contactId: 'contact-1', content: '谢谢' });
  402. assert.equal(result.status, 'no_reply_needed');
  403. assert.equal(thanks.sent.length, 0);
  404. } finally { thanks.close(); }
  405. const summary = {
  406. baseline: { lowQualityAutopilotSent: 11, lowQualityBlocked: 0 },
  407. current: {
  408. lowQualityCases: lowCases.length,
  409. lowQualityBlocked: lowBlocked,
  410. lowQualityBlockRate: lowBlocked / lowCases.length,
  411. goodQualityCases: goodCases.length,
  412. goodQualityRejected: goodRejected.length,
  413. goodQualityFalsePositiveRate: goodRejected.length / goodCases.length,
  414. frozenA4Cases: frozenA4Cases.length,
  415. frozenA4ExactCoverage: frozenA4Results.filter(item => item.expectedFailed.every(id => item.assessment.failedCheckIds.includes(id))).length,
  416. },
  417. modes: ['review low->draft', 'auto low->draft', 'autopilot low->draft', 'auto good->sent', 'autopilot good->sent', 'risk all->human draft', 'human bypass', 'thanks no-reply'],
  418. };
  419. process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
  420. }
  421. main().catch(error => {
  422. console.error(error);
  423. process.exitCode = 1;
  424. }).finally(() => {
  425. fs.rmSync(RUN_ROOT, { recursive: true, force: true });
  426. });