cangku/backend/test/csQualityService.test.js

256 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
QWEN_PROVIDER,
AGNES_PROVIDER,
DEFAULT_QWEN_BASE_URL,
DEFAULT_QWEN_MODEL,
DEFAULT_AGNES_BASE_URL,
DEFAULT_AGNES_MODEL,
_test: {
resolveAiConfig,
buildAiRequestPayload,
parseModelJson,
mapChatDataMeta,
aggregateChatDimensionRows,
createChatBatches,
normalizeAnalysis,
mergeBatchAnalysesLocally,
reconcileMergedAnalysis,
sumUsages,
getAiErrorDetail,
isRetryableAiError,
getAiRetryDelayMs,
sanitizePublicError,
},
} = require('../src/services/csQualityService');
test('chat data metadata exposes the actual available date range', () => {
assert.deepEqual(mapChatDataMeta({
minDate: '2026-06-22',
latestDate: '2026-07-31 23:59:59',
messageCount: '792771',
}), {
tableName: 'rpt.pdd_GoodsChatMessages',
minDate: '2026-06-22',
latestDate: '2026-07-31',
messageCount: 792771,
});
});
function message(uid, content, index = 1) {
return {
uid,
nickname: `买家-${uid}`,
msgId: `${uid}-${index}`,
msgTime: `2026-07-30 10:00:${String(index).padStart(2, '0')}`,
directionLabel: '买家',
csname: '客服A',
content,
};
}
function result(overrides = {}) {
return {
consultation_type: '售前',
primary_reason: '产品问题',
secondary_reason: '尺寸问题',
occurrence_count: 1,
affected_customer_count: 1,
affected_conversation_ids: ['u1'],
buyer_intent: '咨询尺寸',
evidence: [{ conversation_id: 'u1', nickname: '买家-u1', message_time: '2026-07-30 10:00:01', text: '多高' }],
image_urls: [],
product_improvement_areas: ['详情页'],
improvement_suggestion: '补充尺寸图',
confidence: 0.9,
...overrides,
};
}
test('customer-service quality defaults to the verified Qwen model and endpoint', () => {
const config = resolveAiConfig({ apiKey: 'test-key' });
assert.equal(QWEN_PROVIDER, 'aliyun-model-studio');
assert.equal(config.provider, QWEN_PROVIDER);
assert.equal(config.baseUrl, DEFAULT_QWEN_BASE_URL);
assert.equal(config.model, DEFAULT_QWEN_MODEL);
assert.equal(config.batchConcurrency, 2);
assert.equal(config.batchMaxChars, 45000);
assert.equal(config.mergeMaxBatches, 8);
assert.equal(config.requestRetryCount, 2);
assert.equal(config.retryBaseDelayMs, 1500);
});
test('customer-service quality resolves the Agnes China endpoint and request format', () => {
const config = resolveAiConfig({
provider: AGNES_PROVIDER,
apiKey: 'test-key',
});
assert.equal(config.provider, AGNES_PROVIDER);
assert.equal(config.baseUrl, DEFAULT_AGNES_BASE_URL);
assert.equal(config.model, DEFAULT_AGNES_MODEL);
assert.equal(config.timeoutMs, 600000);
const payload = buildAiRequestPayload(config, [{ role: 'user', content: 'test' }], 2048);
assert.equal(payload.model, DEFAULT_AGNES_MODEL);
assert.equal(payload.max_tokens, 2048);
assert.equal(payload.temperature, 0);
assert.equal(payload.stream, false);
assert.equal('max_completion_tokens' in payload, false);
assert.equal('response_format' in payload, false);
});
test('Qwen requests retain the provider-specific JSON parameters', () => {
const config = resolveAiConfig({ apiKey: 'test-key' });
const payload = buildAiRequestPayload(config, [{ role: 'user', content: 'test' }], 2048);
assert.equal(payload.max_completion_tokens, 2048);
assert.deepEqual(payload.response_format, { type: 'json_object' });
assert.equal(payload.enable_thinking, false);
assert.equal('max_tokens' in payload, false);
});
test('chat batching keeps every customer conversation intact', () => {
const rows = [
message('u1', '甲'.repeat(700), 1),
message('u1', '乙'.repeat(700), 2),
message('u2', '丙'.repeat(700), 1),
message('u3', '丁'.repeat(700), 1),
];
const batches = createChatBatches(rows, 1300);
assert.equal(batches.length, 3);
assert.deepEqual(batches[0].conversationIds, ['u1']);
assert.equal(batches[0].messageCount, 2);
assert.ok(batches.every((batch) => !(
batch.conversationIds.includes('u1') && batch.conversationIds.length > 1
)));
assert.deepEqual(batches.flatMap((batch) => batch.conversationIds), ['u1', 'u2', 'u3']);
assert.equal(batches[0].startDate, '2026-07-30');
assert.equal(batches[0].endDate, '2026-07-30');
});
test('chat dimension snapshot derives goods and customers in one aggregation', () => {
const snapshot = aggregateChatDimensionRows([
{
goodsId: 'g1', uid: 'u1', csname: '客服A', goodsName: '商品1', shopName: '店铺1',
nickname: '客户1', messageCount: 2, firstMsgTime: '2026-07-29T08:00:00.000Z', lastMsgTime: '2026-07-29T08:01:00.000Z',
},
{
goodsId: 'g1', uid: 'u1', csname: '客服B', goodsName: '商品1', shopName: '店铺1',
nickname: '客户1', messageCount: 3, firstMsgTime: '2026-07-29T08:02:00.000Z', lastMsgTime: '2026-07-29T08:04:00.000Z',
},
{
goodsId: 'g1', uid: 'u2', csname: '客服A', goodsName: '商品1', shopName: '店铺1',
nickname: '客户2', messageCount: 1, firstMsgTime: '2026-07-29T09:00:00.000Z', lastMsgTime: '2026-07-29T09:00:00.000Z',
},
{
goodsId: 'g2', uid: 'u3', csname: '客服C', goodsName: '商品2', shopName: '店铺2',
nickname: '客户3', messageCount: 4, firstMsgTime: '2026-07-29T10:00:00.000Z', lastMsgTime: '2026-07-29T10:03:00.000Z',
},
]);
assert.equal(snapshot.goodsRows.length, 2);
assert.equal(snapshot.goodsRows[0].goodsId, 'g1');
assert.equal(snapshot.goodsRows[0].messageCount, 6);
assert.equal(snapshot.goodsRows[0].userCount, 2);
assert.equal(snapshot.goodsRows[0].csCount, 2);
assert.equal(snapshot.customersByGoods.g1.length, 2);
assert.equal(snapshot.customersByGoods.g1[0].uid, 'u1');
assert.equal(snapshot.customersByGoods.g1[0].messageCount, 5);
assert.equal(snapshot.customersByGoods.g1[0].csname, '客服B');
assert.equal(snapshot.customersByGoods.g1[0].firstMsgTime, '2026-07-29T08:00:00.000Z');
assert.equal(snapshot.customersByGoods.g1[0].lastMsgTime, '2026-07-29T08:04:00.000Z');
});
test('local merge sums disjoint batch counts and deduplicates evidence', () => {
const analyses = [
{
summary: { pre_sale_conversation_count: 1, after_sale_conversation_count: 0, mixed_conversation_count: 0, main_findings: ['尺寸咨询较多'] },
results: [result()],
},
{
summary: { pre_sale_conversation_count: 2, after_sale_conversation_count: 0, mixed_conversation_count: 0, main_findings: ['尺寸咨询较多'] },
results: [result({
occurrence_count: 3,
affected_customer_count: 2,
affected_conversation_ids: ['u1', 'u2'],
evidence: [
{ conversation_id: 'u1', nickname: '买家-u1', message_time: '2026-07-30 10:00:01', text: '多高' },
{ conversation_id: 'u2', nickname: '买家-u2', message_time: '2026-07-30 10:00:02', text: '多宽' },
],
})],
},
];
const merged = mergeBatchAnalysesLocally(analyses, { customerCount: 3, messageCount: 12 });
assert.equal(merged.results.length, 1);
assert.equal(merged.results[0].occurrence_count, 4);
assert.equal(merged.results[0].affected_customer_count, 3);
assert.deepEqual(merged.results[0].affected_conversation_ids, ['u1', 'u2']);
assert.equal(merged.results[0].evidence.length, 2);
assert.equal(merged.summary.conversation_count, 3);
assert.equal(merged.summary.pre_sale_conversation_count, 3);
assert.deepEqual(merged.summary.main_findings, ['尺寸咨询较多']);
});
test('model merge cannot change deterministic counts or drop a label group', () => {
const local = mergeBatchAnalysesLocally([{
summary: { pre_sale_conversation_count: 2, after_sale_conversation_count: 0, mixed_conversation_count: 0, main_findings: [] },
results: [result({ occurrence_count: 4, affected_customer_count: 2 })],
}], { customerCount: 2, messageCount: 8 });
const model = {
schema_version: '1.0',
conversation_type: '售前',
summary: { conversation_count: 99, pre_sale_conversation_count: 99, after_sale_conversation_count: 0, mixed_conversation_count: 0, main_findings: ['优化后的结论'] },
results: [result({ occurrence_count: 99, affected_customer_count: 99, buyer_intent: '优化后的诉求' })],
};
const reconciled = reconcileMergedAnalysis(model, local, { customerCount: 2, messageCount: 8 });
assert.equal(reconciled.results[0].occurrence_count, 4);
assert.equal(reconciled.results[0].affected_customer_count, 2);
assert.equal(reconciled.results[0].buyer_intent, '优化后的诉求');
assert.deepEqual(reconciled.results[0].affected_conversation_ids, ['u1']);
assert.equal(reconciled.summary.conversation_count, 2);
assert.equal(reconciled.summary.pre_sale_conversation_count, 2);
});
test('analysis normalization keeps every valid affected conversation id', () => {
const normalized = normalizeAnalysis({
summary: {},
results: [{
consultation_type: '售前',
primary_reason: '产品问题',
secondary_reason: '尺寸问题',
occurrence_count: 3,
affected_customer_count: 3,
affected_conversation_ids: ['u1', 'u2', 'not-in-batch'],
evidence: [{ conversation_id: 'u3', text: '尺寸是多少' }],
}],
}, { customerCount: 3, messageCount: 10 }, {
allowedConversationIds: ['u1', 'u2', 'u3'],
});
assert.deepEqual(normalized.results[0].affected_conversation_ids, ['u1', 'u2', 'u3']);
assert.equal(normalized.results[0].affected_customer_count, 3);
});
test('Qwen JSON parsing, usage aggregation and error extraction are provider-safe', () => {
assert.deepEqual(parseModelJson('```json\n{"ok":true}\n```'), { ok: true });
assert.deepEqual(sumUsages([
{ prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 },
{ input_tokens: 7, output_tokens: 2 },
]), { prompt_tokens: 17, completion_tokens: 5, total_tokens: 22 });
assert.equal(getAiErrorDetail({ response: { data: { error: { message: 'rate limited' } } } }), 'rate limited');
assert.equal(getAiErrorDetail({ message: 'Bearer sk-secret-example-1234567890 failed' }), 'Bearer [redacted] failed');
assert.equal(isRetryableAiError({ code: 'ETIMEDOUT' }), true);
assert.equal(isRetryableAiError({ response: { status: 429 } }), true);
assert.equal(isRetryableAiError({ response: { status: 401 } }), false);
assert.equal(getAiRetryDelayMs({}, 1, 1500), 1500);
assert.equal(getAiRetryDelayMs({}, 2, 1500), 3000);
assert.equal(getAiRetryDelayMs({ response: { headers: { 'retry-after': '2' } } }, 1, 1500), 2000);
assert.equal(sanitizePublicError('千问模型请求失败:qwen-timeout'), 'AI服务请求失败:AI服务');
assert.equal(sanitizePublicError('https://apihub.agnes-ai.com/v1 failed'), 'https://AI服务/v1 failed');
});