forked from lunanting/cangku
256 lines
11 KiB
JavaScript
256 lines
11 KiB
JavaScript
'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');
|
||
});
|