cangku/backend/src/services/csQualityService.js

1968 lines
80 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 axios = require('axios');
const crypto = require('crypto');
const database = require('../config/database');
const cloudDatabase = require('../config/cloudDatabase');
const reportDatabase = require('../config/reportReplicaDatabase');
const { buildSystemPrompt, buildUserPrompt } = require('./csQualityAiPrompt');
const dataPageCache = require('./dataPageCache');
const CHAT_TABLE = '[rpt].[pdd_GoodsChatMessages]';
const GOODS_TABLE = '[rpt].[pdd_DataGoods]';
const HISTORY_TABLE = '[app].[pdd_chat_ai_analysis_history]';
const HISTORY_SCHEMA_VERSION = '1.0';
const MAX_ANALYSIS_MESSAGES = 10000;
const MAX_ANALYSIS_TEXT_LENGTH = 2000000;
const QWEN_PROVIDER = 'aliyun-model-studio';
const AGNES_PROVIDER = 'agnes-ai-cn';
const AI_CONFIG_KEYS = [
'cs_quality_ai_config',
'agnes_quality_ai_config',
'qwen_quality_ai_config',
'selection_fission_ai_analysis_config',
];
const DEFAULT_QWEN_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
const DEFAULT_QWEN_MODEL = 'qwen3.7-max-2026-06-08';
const DEFAULT_AGNES_BASE_URL = 'https://api.agnes-ai.cn/v1';
const DEFAULT_AGNES_MODEL = 'agnes-2.5-flash';
const DEFAULT_BATCH_MAX_CHARS = 45000;
const DEFAULT_BATCH_CONCURRENCY = 2;
const DEFAULT_BATCH_MAX_TOKENS = 8000;
const DEFAULT_MERGE_MAX_TOKENS = 12000;
const DEFAULT_MERGE_MAX_BATCHES = 8;
const DEFAULT_AI_REQUEST_RETRY_COUNT = 2;
const DEFAULT_AI_RETRY_BASE_DELAY_MS = 1500;
const MAX_AI_RETRY_DELAY_MS = 30000;
const CS_QUALITY_CACHE_VERSION = 'v2';
const CS_QUALITY_MEMORY_CACHE_TTL_SECONDS = Math.max(
Number.parseInt(process.env.CS_QUALITY_MEMORY_CACHE_TTL_SECONDS || '600', 10) || 600,
30
);
const CS_QUALITY_REDIS_CACHE_TTL_SECONDS = Math.max(
Number.parseInt(process.env.CS_QUALITY_REDIS_CACHE_TTL_SECONDS || '3600', 10) || 3600,
CS_QUALITY_MEMORY_CACHE_TTL_SECONDS
);
const CS_QUALITY_SNAPSHOT_MAX_DAYS = Math.min(Math.max(
Number.parseInt(process.env.CS_QUALITY_SNAPSHOT_MAX_DAYS || '3', 10) || 3,
1
), 7);
const CS_QUALITY_JOB_STALE_MS = Math.max(
Number.parseInt(process.env.CS_QUALITY_JOB_STALE_MS || String(45 * 60 * 1000), 10) || 45 * 60 * 1000,
15 * 60 * 1000
);
const IMPROVEMENT_AREAS = new Set(['产品本体', '规格配置', '宣传主图', '详情页', '客服话术', '价格策略', '库存备货', '履约物流', '安装服务', '售后服务', '无需改进']);
const IMAGE_URL_PATTERN = /\.(png|jpe?g|gif|webp|bmp|avif)(?:[?#].*)?$/i;
const PRE_SALE_MAP = new Map([
['是否有货', '库存备货'],
['发货地', '发货问题'], ['发货速度', '发货问题'], ['缺货问题', '发货问题'], ['催发货', '发货问题'], ['延期发货', '发货问题'],
['贴唛头', '批量外贸单'],
['是否包邮', '快递问题'], ['指定快递', '快递问题'], ['送货上门', '快递问题'], ['物流速度', '快递问题'], ['配送速度', '快递问题'], ['到货时间', '快递问题'],
['是否防潮/防火', '产品问题'], ['是否有背板/轨道', '产品问题'], ['材质的区别', '产品问题'], ['产地问题', '产品问题'], ['质保时效', '产品问题'], ['尺寸问题', '产品问题'], ['板材厚度', '产品问题'], ['是否支持定制', '产品问题'], ['承重性', '产品问题'],
['检测报告', '产品环保问题'], ['气味问题', '产品环保问题'],
['优惠券问题', '价格问题'], ['买后比价', '价格问题'], ['商品降价', '价格问题'],
['多拍错拍', '下单错误'], ['地址/电话信息错误', '下单错误'],
['尺码/尺寸/规格不合适', '决策信息不充分'],
['安装说明书/安装视频', '安装问题'], ['是否包安装', '安装问题'], ['是否整装发货', '安装问题'],
['是否有发票', '发票问题'], ['多开/少开发票', '发票问题'], ['催发票', '发票问题'],
]);
const AFTER_SALE_PRIMARY = {
发货异常: '发货履约问题', 虚假发货: '发货履约问题',
更新超时: '物流配送问题', 丢件: '物流配送问题', 派送超时: '物流配送问题', 送货异常: '物流配送问题', 退货到仓: '物流配送问题',
少发漏发: '商品交付问题', 错发: '商品交付问题', 运输产品破损: '商品交付问题',
产品质量: '商品质量问题', 描述不符: '商品质量问题',
安装咨询: '安装服务问题', 上门安装: '安装服务问题',
回复超时: '客服服务问题', 服务态度极差: '客服服务问题', 承诺未履行: '客服服务问题', 操作错误: '客服服务问题',
上门取件: '退换货问题', 铺货错误: '平台业务问题', 种菜异常: '平台业务问题', 其他: '其他售后',
};
const AFTER_SALE_LABELS = new Set(Object.keys(AFTER_SALE_PRIMARY));
let historyClientPromise = null;
let historySchemaPromise = null;
function normalizeText(value, maxLength = 2000) {
return String(value ?? '').trim().slice(0, maxLength);
}
function parseJson(value, fallback = null) {
if (value && typeof value === 'object') return value;
try { return JSON.parse(String(value || '')); } catch (_) { return fallback; }
}
function boundedInteger(value, fallback, min, max) {
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.min(Math.max(Math.trunc(number), min), max);
}
function parseDateOnly(value) {
const text = normalizeText(value, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) return null;
const timestamp = Date.parse(`${text}T00:00:00Z`);
return Number.isFinite(timestamp) ? text : null;
}
function addDate(dateText, amount) {
const date = new Date(`${dateText}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() + amount);
return date.toISOString().slice(0, 10);
}
function resolveDateRange(startDate, endDate) {
const today = new Date();
const fallback = new Date(today.getTime() - 86400000).toISOString().slice(0, 10);
const start = parseDateOnly(startDate) || fallback;
const end = parseDateOnly(endDate) || start;
const dayCount = Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86400000) + 1;
if (dayCount < 1) throw new Error('开始日期不能晚于结束日期');
if (dayCount > 30) throw new Error('聊天记录分析日期范围不能超过30天');
return { startDate: start, endDate: end, endExclusiveDate: addDate(end, 1), startAt: `${start} 00:00:00`, endExclusive: `${addDate(end, 1)} 00:00:00`, dayCount };
}
function normalizeGoodsId(value) {
const text = normalizeText(value, 80);
if (!text || !/^[0-9]+$/.test(text)) throw new Error('商品ID格式不正确');
return text;
}
function numberValue(value) {
const number = Number(String(value ?? '').replace(/,/g, '').replace('%', ''));
return Number.isFinite(number) ? number : 0;
}
function percentValue(value) {
const number = numberValue(value);
return number;
}
function formatMetric(value, digits = 2) {
const number = Number(value);
return Number.isFinite(number) ? Number(number.toFixed(digits)) : 0;
}
function buildDateWhere(alias, range) {
return `${alias}.[createTime] >= @startDate AND ${alias}.[createTime] < @endExclusiveDate`;
}
function extractMessageDate(value) {
const match = String(value || '').match(/(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})/);
if (!match) return null;
const dateText = `${match[1]}-${match[2].padStart(2, '0')}-${match[3].padStart(2, '0')}`;
return parseDateOnly(dateText);
}
function formatProgressDate(value) {
const date = parseDateOnly(value);
return date ? `${date.slice(5, 7)}月${date.slice(8, 10)}日` : '';
}
function formatBatchDateRange(batch = {}) {
const start = formatProgressDate(batch.startDate);
const end = formatProgressDate(batch.endDate);
if (!start && !end) return '当前批次';
if (!end || start === end) return start || end;
return `${start}至${end}`;
}
function isTruthy(value) {
return ['1', 'true', 'yes', 'on'].includes(normalizeText(value, 10).toLowerCase());
}
function selectMaxText(current, candidate, maxLength = 2000) {
const next = normalizeText(candidate, maxLength);
if (!next) return current || null;
if (!current || next > current) return next;
return current;
}
function selectTime(current, candidate, mode) {
if (!candidate) return current || null;
if (!current) return candidate;
const currentTime = new Date(current).getTime();
const candidateTime = new Date(candidate).getTime();
if (Number.isFinite(currentTime) && Number.isFinite(candidateTime)) {
return mode === 'min'
? (candidateTime < currentTime ? candidate : current)
: (candidateTime > currentTime ? candidate : current);
}
const currentText = String(current);
const candidateText = String(candidate);
return mode === 'min'
? (candidateText < currentText ? candidate : current)
: (candidateText > currentText ? candidate : current);
}
function aggregateChatDimensionRows(rows = []) {
const goodsMap = new Map();
const customerMaps = new Map();
for (const row of rows) {
const goodsId = normalizeText(row.goodsId, 80);
if (!goodsId) continue;
const uid = normalizeText(row.uid, 200);
const csname = normalizeText(row.csname, 200);
const messageCount = Math.max(0, Number(row.messageCount) || 0);
let goods = goodsMap.get(goodsId);
if (!goods) {
goods = {
goodsId,
goodsName: null,
hdThumbUrl: null,
shopName: null,
messageCount: 0,
userIds: new Set(),
csNames: new Set(),
reportedCsCount: 0,
chatStartAt: null,
chatEndAt: null,
};
goodsMap.set(goodsId, goods);
}
goods.goodsName = selectMaxText(goods.goodsName, row.goodsName);
goods.hdThumbUrl = selectMaxText(goods.hdThumbUrl, row.hdThumbUrl);
goods.shopName = selectMaxText(goods.shopName, row.shopName, 200);
goods.messageCount += messageCount;
if (uid) goods.userIds.add(uid);
if (csname) goods.csNames.add(csname);
goods.reportedCsCount = Math.max(goods.reportedCsCount, Number(row.csCount) || 0);
goods.chatStartAt = selectTime(goods.chatStartAt, row.firstMsgTime, 'min');
goods.chatEndAt = selectTime(goods.chatEndAt, row.lastMsgTime, 'max');
if (!uid) continue;
let customerMap = customerMaps.get(goodsId);
if (!customerMap) {
customerMap = new Map();
customerMaps.set(goodsId, customerMap);
}
let customer = customerMap.get(uid);
if (!customer) {
customer = {
uid,
nickname: null,
csname: null,
messageCount: 0,
firstMsgTime: null,
lastMsgTime: null,
};
customerMap.set(uid, customer);
}
customer.nickname = selectMaxText(customer.nickname, row.nickname, 200);
customer.csname = selectMaxText(customer.csname, csname, 200);
customer.messageCount += messageCount;
customer.firstMsgTime = selectTime(customer.firstMsgTime, row.firstMsgTime, 'min');
customer.lastMsgTime = selectTime(customer.lastMsgTime, row.lastMsgTime, 'max');
}
const goodsRows = Array.from(goodsMap.values())
.map((goods) => ({
goodsId: goods.goodsId,
goodsName: goods.goodsName,
hdThumbUrl: goods.hdThumbUrl,
shopName: goods.shopName,
messageCount: goods.messageCount,
userCount: goods.userIds.size,
csCount: Math.max(goods.reportedCsCount, goods.csNames.size),
chatStartAt: goods.chatStartAt,
chatEndAt: goods.chatEndAt,
}))
.sort((left, right) => (
right.userCount - left.userCount
|| right.messageCount - left.messageCount
|| left.goodsId.localeCompare(right.goodsId)
))
.slice(0, 5000);
const customersByGoods = {};
for (const [goodsId, customerMap] of customerMaps.entries()) {
customersByGoods[goodsId] = Array.from(customerMap.values())
.sort((left, right) => (
right.messageCount - left.messageCount
|| left.uid.localeCompare(right.uid)
))
.slice(0, 5000);
}
return { goodsRows, customersByGoods };
}
async function getCachedCsQualityPayload(namespace, payload, label, loader, ttl = {}) {
const key = dataPageCache.buildHashedCacheKey(
`cs-quality:${namespace}`,
payload,
CS_QUALITY_CACHE_VERSION
);
return dataPageCache.getOrSetCachedPayload({
key,
memoryTtlSeconds: ttl.memory || CS_QUALITY_MEMORY_CACHE_TTL_SECONDS,
redisTtlSeconds: ttl.redis || CS_QUALITY_REDIS_CACHE_TTL_SECONDS,
label,
loader,
shouldCache: ttl.shouldCache,
guardLoader: false,
});
}
async function getChatDimensionSnapshot(options, range, scopeGoodsId = '') {
const shopName = normalizeText(options.shopName, 200);
const params = { startDate: range.startDate, endExclusiveDate: range.endExclusiveDate };
const conditions = [buildDateWhere('g', range)];
if (shopName) {
params.shopName = `%${shopName}%`;
conditions.push('g.[shopName] LIKE @shopName');
}
if (scopeGoodsId) {
params.scopeGoodsId = scopeGoodsId;
conditions.push('g.[goodsId] = @scopeGoodsId');
}
const cached = await getCachedCsQualityPayload(
'dimension-snapshot',
{ startDate: range.startDate, endDate: range.endDate, shopName, scopeGoodsId },
'cs-quality-dimension-snapshot',
async () => {
const rows = await reportDatabase.query(
`WITH grouped AS (
SELECT
g.[goodsId] AS [goodsId], g.[uid] AS [uid], g.[csname] AS [csname],
MAX(NULLIF(g.[goodsName], N'')) AS [goodsName],
MAX(NULLIF(g.[hdThumbUrl], N'')) AS [hdThumbUrl],
MAX(NULLIF(g.[shopName], N'')) AS [shopName],
MAX(NULLIF(g.[nickname], N'')) AS [nickname],
COUNT_BIG(1) AS [messageCount],
MIN(TRY_CONVERT(DATETIME2, g.[msgTime])) AS [firstMsgTime],
MAX(TRY_CONVERT(DATETIME2, g.[msgTime])) AS [lastMsgTime]
FROM ${CHAT_TABLE} g WITH (NOLOCK)
WHERE ${conditions.join(' AND ')}
GROUP BY g.[goodsId], g.[uid], g.[csname]
), ranked AS (
SELECT grouped.*,
DENSE_RANK() OVER (
PARTITION BY grouped.[goodsId]
ORDER BY CASE WHEN NULLIF(LTRIM(RTRIM(grouped.[csname])), N'') IS NULL THEN 0 ELSE 1 END,
grouped.[csname]
) AS [csRank],
MAX(CASE WHEN NULLIF(LTRIM(RTRIM(grouped.[csname])), N'') IS NULL THEN 1 ELSE 0 END)
OVER (PARTITION BY grouped.[goodsId]) AS [hasBlankCs]
FROM grouped
)
SELECT
ranked.[goodsId] AS [goodsId], ranked.[uid] AS [uid],
MAX(NULLIF(ranked.[csname], N'')) AS [csname],
MAX(ranked.[goodsName]) AS [goodsName],
MAX(ranked.[hdThumbUrl]) AS [hdThumbUrl],
MAX(ranked.[shopName]) AS [shopName],
MAX(ranked.[nickname]) AS [nickname],
SUM(ranked.[messageCount]) AS [messageCount],
MIN(ranked.[firstMsgTime]) AS [firstMsgTime],
MAX(ranked.[lastMsgTime]) AS [lastMsgTime],
MAX(ranked.[csRank] - ranked.[hasBlankCs]) AS [csCount]
FROM ranked
GROUP BY ranked.[goodsId], ranked.[uid]`,
params,
{ requestTimeout: 120000 }
);
return aggregateChatDimensionRows(rows || []);
},
{ shouldCache: (value) => Array.isArray(value?.goodsRows) && value.goodsRows.length > 0 }
);
return { ...cached.value, cacheHit: cached.hit };
}
function mapChatDataMeta(row = {}) {
return {
tableName: 'rpt.pdd_GoodsChatMessages',
minDate: extractMessageDate(row.minDate),
latestDate: extractMessageDate(row.latestDate),
messageCount: Math.max(0, Number(row.messageCount) || 0),
};
}
async function getChatDataMeta() {
const cached = await getCachedCsQualityPayload(
'data-meta',
{},
'cs-quality-data-meta',
async () => {
const rows = await reportDatabase.query(
`SELECT
MIN(NULLIF(LTRIM(RTRIM(g.[createTime])), N'')) AS [minDate],
MAX(NULLIF(LTRIM(RTRIM(g.[createTime])), N'')) AS [latestDate],
COUNT_BIG(1) AS [messageCount]
FROM ${CHAT_TABLE} g WITH (NOLOCK)`,
{},
{ requestTimeout: 120000 }
);
return mapChatDataMeta(rows[0]);
},
{ memory: 300, redis: 300 }
);
return { ...cached.value, cache: { hit: cached.hit } };
}
async function getGoods(options = {}) {
const range = resolveDateRange(options.startDate, options.endDate);
const shopName = normalizeText(options.shopName, 200);
const scopeGoodsId = normalizeText(options.goodsId, 80) ? normalizeGoodsId(options.goodsId) : '';
const canUseSnapshot = range.dayCount <= CS_QUALITY_SNAPSHOT_MAX_DAYS || Boolean(scopeGoodsId);
if (canUseSnapshot) {
const snapshot = await getChatDimensionSnapshot(options, range, scopeGoodsId);
const result = {
tableName: 'rpt.pdd_GoodsChatMessages',
range,
rows: snapshot.goodsRows || [],
cache: { hit: snapshot.cacheHit },
};
if (isTruthy(options.includeInitial) && result.rows[0]) {
const firstGoods = result.rows[0];
const customers = snapshot.customersByGoods?.[String(firstGoods.goodsId)] || [];
const firstCustomer = customers[0] || null;
let messages = [];
let messagesPrefetched = !firstCustomer;
if (firstCustomer) {
try {
const messageResult = await getMessages({
startDate: range.startDate,
endDate: range.endDate,
shopName,
goodsId: firstGoods.goodsId,
uid: firstCustomer.uid,
});
messages = messageResult.rows || [];
messagesPrefetched = true;
} catch (error) {
console.warn('[CsQuality] 首屏聊天预取失败:', error.message);
}
}
result.initial = {
goodsId: firstGoods.goodsId,
customerUid: firstCustomer?.uid || null,
customers,
messages,
messagesPrefetched,
};
}
return result;
}
const params = { startDate: range.startDate, endExclusiveDate: range.endExclusiveDate };
const conditions = [buildDateWhere('g', range)];
if (shopName) {
params.shopName = `%${shopName}%`;
conditions.push('g.[shopName] LIKE @shopName');
}
if (scopeGoodsId) {
params.goodsId = scopeGoodsId;
conditions.push('g.[goodsId] = @goodsId');
}
const cached = await getCachedCsQualityPayload(
'goods',
{ startDate: range.startDate, endDate: range.endDate, shopName, scopeGoodsId },
'cs-quality-goods',
() => reportDatabase.query(
`SELECT TOP 5000
g.[goodsId] AS [goodsId],
MAX(NULLIF(g.[goodsName], N'')) AS [goodsName],
MAX(NULLIF(g.[hdThumbUrl], N'')) AS [hdThumbUrl],
MAX(NULLIF(g.[shopName], N'')) AS [shopName],
COUNT_BIG(1) AS [messageCount],
COUNT(DISTINCT g.[uid]) AS [userCount],
COUNT(DISTINCT g.[csname]) AS [csCount],
MIN(TRY_CONVERT(DATETIME2, g.[msgTime])) AS [chatStartAt],
MAX(TRY_CONVERT(DATETIME2, g.[msgTime])) AS [chatEndAt]
FROM ${CHAT_TABLE} g WITH (NOLOCK)
WHERE ${conditions.join(' AND ')}
GROUP BY g.[goodsId]
ORDER BY COUNT(DISTINCT g.[uid]) DESC, COUNT_BIG(1) DESC`,
params,
{ requestTimeout: 120000 }
),
{ shouldCache: (value) => Array.isArray(value) && value.length > 0 }
);
return {
tableName: 'rpt.pdd_GoodsChatMessages',
range,
rows: cached.value || [],
cache: { hit: cached.hit },
};
}
async function getCustomers(options = {}) {
const goodsId = normalizeGoodsId(options.goodsId);
const range = resolveDateRange(options.startDate, options.endDate);
const shopName = normalizeText(options.shopName, 200);
const requestedScopeGoodsId = normalizeText(options.snapshotScopeGoodsId, 80);
const scopeGoodsId = requestedScopeGoodsId === goodsId ? goodsId : '';
const canUseSnapshot = range.dayCount <= CS_QUALITY_SNAPSHOT_MAX_DAYS || Boolean(scopeGoodsId);
if (canUseSnapshot) {
const snapshot = await getChatDimensionSnapshot(options, range, scopeGoodsId);
return {
range,
rows: snapshot.customersByGoods?.[goodsId] || [],
cache: { hit: snapshot.cacheHit },
};
}
const params = { goodsId, startDate: range.startDate, endExclusiveDate: range.endExclusiveDate };
const conditions = [buildDateWhere('g', range), 'g.[goodsId] = @goodsId'];
if (shopName) {
params.shopName = `%${shopName}%`;
conditions.push('g.[shopName] LIKE @shopName');
}
const cached = await getCachedCsQualityPayload(
'customers',
{ startDate: range.startDate, endDate: range.endDate, shopName, goodsId },
'cs-quality-customers',
() => reportDatabase.query(
`SELECT TOP 5000
g.[uid] AS [uid],
MAX(NULLIF(g.[nickname], N'')) AS [nickname],
MAX(NULLIF(g.[csname], N'')) AS [csname],
COUNT_BIG(1) AS [messageCount],
MIN(TRY_CONVERT(DATETIME2, g.[msgTime])) AS [firstMsgTime],
MAX(TRY_CONVERT(DATETIME2, g.[msgTime])) AS [lastMsgTime]
FROM ${CHAT_TABLE} g WITH (NOLOCK)
WHERE ${conditions.join(' AND ')}
GROUP BY g.[uid]
ORDER BY COUNT_BIG(1) DESC`,
params,
{ requestTimeout: 120000 }
),
{ shouldCache: (value) => Array.isArray(value) && value.length > 0 }
);
return { range, rows: cached.value || [], cache: { hit: cached.hit } };
}
async function getMessages(options = {}) {
const goodsId = normalizeGoodsId(options.goodsId);
const range = resolveDateRange(options.startDate, options.endDate);
const uid = normalizeText(options.uid, 200);
const shopName = normalizeText(options.shopName, 200);
const params = { goodsId, startDate: range.startDate, endExclusiveDate: range.endExclusiveDate };
const conditions = [buildDateWhere('g', range), 'g.[goodsId] = @goodsId'];
if (uid) {
params.uid = uid;
conditions.push('g.[uid] = @uid');
}
if (shopName) {
params.shopName = `%${shopName}%`;
conditions.push('g.[shopName] LIKE @shopName');
}
const loader = () => reportDatabase.query(
`SELECT TOP 20000
g.[shopName] AS [shopName], g.[goodsId] AS [goodsId], g.[goodsName] AS [goodsName],
g.[uid] AS [uid], g.[nickname] AS [nickname], g.[msgId] AS [msgId],
g.[msgTime] AS [msgTime], g.[msgType] AS [msgType],
CASE WHEN ISNULL(g.[direction], 0) = 1 THEN N'客服' ELSE N'买家' END AS [directionLabel],
g.[csname] AS [csname], g.[content] AS [content]
FROM ${CHAT_TABLE} g WITH (NOLOCK)
WHERE ${conditions.join(' AND ')}
ORDER BY TRY_CONVERT(DATETIME2, g.[msgTime]) ASC, g.[id] ASC`,
params,
{ requestTimeout: 120000 }
);
if (!uid) return { range, rows: await loader() };
const cached = await getCachedCsQualityPayload(
'messages',
{ startDate: range.startDate, endDate: range.endDate, shopName, goodsId, uid },
'cs-quality-messages',
loader,
{
memory: 300,
redis: 900,
shouldCache: (value) => Array.isArray(value) && value.length > 0,
}
);
return { range, rows: cached.value || [], cache: { hit: cached.hit } };
}
async function getProductProfile(options = {}) {
const goodsId = normalizeGoodsId(options.goodsId);
const range = resolveDateRange(options.startDate, options.endDate);
const params = { goodsId, startDate: range.startDate, endDate: range.endDate };
const [latestRows, metricRows, chatRows] = await Promise.all([
reportDatabase.query(
`SELECT TOP 1
d.[goodsId] AS [goodsId], d.[goodsName] AS [goodsName], d.[shopName] AS [shopName],
d.[hdThumbUrl] AS [hdThumbUrl], d.[cate1Name] AS [cate1Name], d.[cate2Name] AS [cate2Name], d.[cate3Name] AS [cate3Name],
d.[createTime] AS [metricDate]
FROM ${GOODS_TABLE} d WITH (NOLOCK)
WHERE d.[goodsId] = TRY_CONVERT(BIGINT, @goodsId) AND d.[createTime] <= @endDate
ORDER BY TRY_CONVERT(DATE, d.[createTime]) DESC, d.[createdAt] DESC`,
params,
{ requestTimeout: 120000 }
),
reportDatabase.query(
`SELECT
COUNT_BIG(1) AS [metricDays],
SUM(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[goodsUv], N',', N''))) AS [goodsUv],
SUM(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[goodsPv], N',', N''))) AS [goodsPv],
SUM(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[payOrdrAmt], N',', N''))) AS [payOrdrAmt],
SUM(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[payOrdrGoodsQty], N',', N''))) AS [payOrdrGoodsQty],
SUM(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[payOrdrCnt], N',', N''))) AS [payOrdrCnt],
SUM(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[payOrdrUsrCnt], N',', N''))) AS [payOrdrUsrCnt],
SUM(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[goodsFavCnt], N',', N''))) AS [goodsFavCnt],
AVG(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[goodsVcr], N'%',''))) AS [goodsVcr],
AVG(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[ordrVstrRto], N'%',''))) AS [ordrVstrRto],
AVG(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[payOrdrRto], N'%',''))) AS [payOrdrRto],
AVG(TRY_CONVERT(DECIMAL(18, 4), REPLACE(d.[goodsPtHelpRate], N'%',''))) AS [goodsPtHelpRate]
FROM ${GOODS_TABLE} d WITH (NOLOCK)
WHERE d.[goodsId] = TRY_CONVERT(BIGINT, @goodsId)
AND d.[createTime] >= @startDate AND d.[createTime] <= @endDate`,
params,
{ requestTimeout: 120000 }
),
reportDatabase.query(
`SELECT COUNT_BIG(1) AS [messageCount], COUNT(DISTINCT g.[uid]) AS [userCount],
MIN(TRY_CONVERT(DATETIME2, g.[msgTime])) AS [chatStartAt],
MAX(TRY_CONVERT(DATETIME2, g.[msgTime])) AS [chatEndAt]
FROM ${CHAT_TABLE} g WITH (NOLOCK)
WHERE g.[goodsId] = @goodsId AND g.[createTime] >= @startDate AND g.[createTime] < @endExclusiveDate`,
{ goodsId, startDate: range.startDate, endExclusiveDate: range.endExclusiveDate },
{ requestTimeout: 120000 }
),
]);
const metrics = metricRows[0] || {};
return {
range,
product: latestRows[0] || { goodsId },
metrics: {
metricDays: Number(metrics.metricDays || 0),
goodsUv: formatMetric(metrics.goodsUv, 0), goodsPv: formatMetric(metrics.goodsPv, 0),
payOrdrAmt: formatMetric(metrics.payOrdrAmt), payOrdrGoodsQty: formatMetric(metrics.payOrdrGoodsQty, 0),
payOrdrCnt: formatMetric(metrics.payOrdrCnt, 0), payOrdrUsrCnt: formatMetric(metrics.payOrdrUsrCnt, 0),
goodsFavCnt: formatMetric(metrics.goodsFavCnt, 0), goodsVcr: formatMetric(metrics.goodsVcr),
ordrVstrRto: formatMetric(metrics.ordrVstrRto), payOrdrRto: formatMetric(metrics.payOrdrRto),
goodsPtHelpRate: formatMetric(metrics.goodsPtHelpRate),
},
chatSummary: chatRows[0] || { messageCount: 0, userCount: 0 },
};
}
async function getHistoryClient() {
if (!historyClientPromise) {
historyClientPromise = cloudDatabase.createPinnedQueryClient({
preferredProfile: 'public',
verifySql: `SELECT HAS_PERMS_BY_NAME(N'app', N'SCHEMA', N'INSERT') AS canInsert`,
verifyResult: (rows) => Number(rows?.[0]?.canInsert) === 1,
verifyFailureMessage: '公网业务数据库 app schema 没有写入权限',
}).catch((error) => {
historyClientPromise = null;
throw error;
});
}
return historyClientPromise;
}
async function ensureHistorySchema() {
if (!historySchemaPromise) {
historySchemaPromise = getHistoryClient().then((client) => client.query(
`IF OBJECT_ID(N'app.pdd_chat_ai_analysis_history', N'U') IS NULL
BEGIN
CREATE TABLE ${HISTORY_TABLE} (
id BIGINT IDENTITY(1,1) NOT NULL PRIMARY KEY,
analysis_id NVARCHAR(64) NOT NULL,
status NVARCHAR(20) NOT NULL,
goods_id NVARCHAR(80) NOT NULL,
goods_name NVARCHAR(500) NULL,
shop_name NVARCHAR(255) NULL,
product_image_url NVARCHAR(2000) NULL,
chat_start_date DATE NOT NULL,
chat_end_date DATE NOT NULL,
source_message_count INT NOT NULL DEFAULT 0,
source_customer_count INT NOT NULL DEFAULT 0,
product_metrics_json NVARCHAR(MAX) NULL,
request_prompt NVARCHAR(MAX) NULL,
ai_provider NVARCHAR(80) NULL,
ai_model NVARCHAR(120) NULL,
ai_response_json NVARCHAR(MAX) NULL,
ai_raw_response_json NVARCHAR(MAX) NULL,
conversation_type NVARCHAR(30) NULL,
analysis_json NVARCHAR(MAX) NULL,
error_message NVARCHAR(2000) NULL,
duration_ms INT NULL,
prompt_tokens INT NULL,
completion_tokens INT NULL,
total_tokens INT NULL,
progress_stage NVARCHAR(40) NULL,
progress_current INT NULL,
progress_total INT NULL,
progress_percent INT NULL,
progress_message NVARCHAR(500) NULL,
progress_updated_at DATETIME2(3) NULL,
created_by_user_id NVARCHAR(100) NULL,
created_by_name NVARCHAR(100) NULL,
created_at DATETIME2(3) NOT NULL DEFAULT SYSUTCDATETIME(),
updated_at DATETIME2(3) NOT NULL DEFAULT SYSUTCDATETIME()
);
END
IF COL_LENGTH(N'app.pdd_chat_ai_analysis_history', N'progress_stage') IS NULL
ALTER TABLE ${HISTORY_TABLE} ADD progress_stage NVARCHAR(40) NULL;
IF COL_LENGTH(N'app.pdd_chat_ai_analysis_history', N'progress_current') IS NULL
ALTER TABLE ${HISTORY_TABLE} ADD progress_current INT NULL;
IF COL_LENGTH(N'app.pdd_chat_ai_analysis_history', N'progress_total') IS NULL
ALTER TABLE ${HISTORY_TABLE} ADD progress_total INT NULL;
IF COL_LENGTH(N'app.pdd_chat_ai_analysis_history', N'progress_percent') IS NULL
ALTER TABLE ${HISTORY_TABLE} ADD progress_percent INT NULL;
IF COL_LENGTH(N'app.pdd_chat_ai_analysis_history', N'progress_message') IS NULL
ALTER TABLE ${HISTORY_TABLE} ADD progress_message NVARCHAR(500) NULL;
IF COL_LENGTH(N'app.pdd_chat_ai_analysis_history', N'progress_updated_at') IS NULL
ALTER TABLE ${HISTORY_TABLE} ADD progress_updated_at DATETIME2(3) NULL;
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_pdd_chat_ai_analysis_history_analysis_id' AND object_id = OBJECT_ID(N'app.pdd_chat_ai_analysis_history'))
CREATE UNIQUE INDEX UX_pdd_chat_ai_analysis_history_analysis_id ON ${HISTORY_TABLE}(analysis_id);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_pdd_chat_ai_analysis_history_goods_date' AND object_id = OBJECT_ID(N'app.pdd_chat_ai_analysis_history'))
CREATE INDEX IX_pdd_chat_ai_analysis_history_goods_date ON ${HISTORY_TABLE}(goods_id, chat_start_date, chat_end_date, created_at DESC);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_pdd_chat_ai_analysis_history_created_at' AND object_id = OBJECT_ID(N'app.pdd_chat_ai_analysis_history'))
CREATE INDEX IX_pdd_chat_ai_analysis_history_created_at ON ${HISTORY_TABLE}(created_at DESC);`,
{},
{ requestTimeout: 120000 }
)).catch((error) => {
historySchemaPromise = null;
throw error;
});
}
await historySchemaPromise;
}
function resolveAiProvider(config = {}) {
const hint = normalizeText(
config.provider || config.aiProvider || config.model || config.baseUrl || config.base_url,
500
).toLowerCase();
return hint.includes('agnes') ? AGNES_PROVIDER : QWEN_PROVIDER;
}
function resolveAiConfig(raw) {
const config = raw && typeof raw === 'object' ? raw : {};
const provider = resolveAiProvider(config);
const isAgnes = provider === AGNES_PROVIDER;
return {
provider,
baseUrl: normalizeText(
config.baseUrl || config.base_url
|| (isAgnes ? process.env.AGNES_AI_BASE_URL : process.env.QWEN_AI_BASE_URL)
|| (isAgnes ? DEFAULT_AGNES_BASE_URL : DEFAULT_QWEN_BASE_URL),
500
).replace(/\/+$/, ''),
apiKey: normalizeText(
config.apiKey || config.api_key
|| (isAgnes
? process.env.AGNES_API_KEY
: process.env.QWEN_API_KEY || process.env.DASHSCOPE_API_KEY),
4000
),
model: normalizeText(
config.model
|| (isAgnes ? process.env.AGNES_AI_MODEL : process.env.QWEN_AI_MODEL)
|| (isAgnes ? DEFAULT_AGNES_MODEL : DEFAULT_QWEN_MODEL),
120
),
timeoutMs: boundedInteger(
config.timeoutMs || config.timeout_ms
|| (isAgnes ? process.env.AGNES_AI_TIMEOUT_MS : process.env.QWEN_AI_TIMEOUT_MS),
isAgnes ? 600000 : 180000,
30000,
600000
),
batchMaxChars: boundedInteger(
config.batchMaxChars || config.batch_max_chars || process.env.CS_QUALITY_BATCH_MAX_CHARS,
DEFAULT_BATCH_MAX_CHARS,
10000,
200000
),
batchConcurrency: boundedInteger(
config.batchConcurrency || config.batch_concurrency || process.env.CS_QUALITY_BATCH_CONCURRENCY,
DEFAULT_BATCH_CONCURRENCY,
1,
5
),
batchMaxTokens: boundedInteger(
config.batchMaxTokens || config.batch_max_tokens || process.env.CS_QUALITY_BATCH_MAX_TOKENS,
DEFAULT_BATCH_MAX_TOKENS,
1000,
16000
),
mergeMaxTokens: boundedInteger(
config.mergeMaxTokens || config.merge_max_tokens || process.env.CS_QUALITY_MERGE_MAX_TOKENS,
DEFAULT_MERGE_MAX_TOKENS,
1000,
24000
),
mergeMaxBatches: boundedInteger(
config.mergeMaxBatches || config.merge_max_batches || process.env.CS_QUALITY_MERGE_MAX_BATCHES,
DEFAULT_MERGE_MAX_BATCHES,
0,
50
),
requestRetryCount: boundedInteger(
config.requestRetryCount || config.request_retry_count || process.env.CS_QUALITY_AI_REQUEST_RETRY_COUNT,
DEFAULT_AI_REQUEST_RETRY_COUNT,
0,
4
),
retryBaseDelayMs: boundedInteger(
config.retryBaseDelayMs || config.retry_base_delay_ms || process.env.CS_QUALITY_AI_RETRY_BASE_DELAY_MS,
DEFAULT_AI_RETRY_BASE_DELAY_MS,
250,
10000
),
};
}
async function getAiConfig() {
for (const key of AI_CONFIG_KEYS) {
const rows = await database.local.query('SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1', [key]);
if (rows[0]?.configValue) {
const stored = parseJson(rows[0].configValue, {});
if (stored?.enabled === false) continue;
const config = resolveAiConfig(stored);
if (config.apiKey) return config;
}
}
const config = resolveAiConfig({ provider: process.env.CS_QUALITY_AI_PROVIDER });
if (!config.apiKey) {
const error = new Error('AI质检服务尚未配置,请联系管理员');
error.statusCode = 503;
throw error;
}
return config;
}
function parseModelJson(content) {
const text = String(content || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim();
try { return JSON.parse(text); } catch (_) {
const start = text.indexOf('{');
const end = text.lastIndexOf('}');
if (start >= 0 && end > start) return JSON.parse(text.slice(start, end + 1));
throw new Error('AI服务返回内容格式异常');
}
}
function normalizeAnalysis(raw, sourceCounts, options = {}) {
const data = raw && typeof raw === 'object' ? raw : {};
const inputResults = Array.isArray(data.results) ? data.results : [];
const allowedConversationIds = new Set(
(Array.isArray(options.allowedConversationIds) ? options.allowedConversationIds : [])
.map((value) => normalizeText(value, 100))
.filter(Boolean)
);
const results = inputResults.map((item) => {
const consultationType = String(item?.consultation_type || item?.consultationType || '').includes('售后') ? '售后' : '售前';
let primaryReason = normalizeText(item?.primary_reason || item?.primaryReason, 100) || '未知';
let secondaryReason = normalizeText(item?.secondary_reason || item?.secondaryReason, 100);
if (consultationType === '售后') {
secondaryReason = AFTER_SALE_LABELS.has(secondaryReason) ? secondaryReason : '其他';
primaryReason = AFTER_SALE_PRIMARY[secondaryReason] || '其他售后';
} else {
primaryReason = PRE_SALE_MAP.get(secondaryReason) || '未知';
if (!PRE_SALE_MAP.has(secondaryReason)) secondaryReason = '其他问题';
}
const evidenceSource = Array.isArray(item?.evidence) ? item.evidence : (item?.evidence ? [{ text: item.evidence }] : []);
const evidence = evidenceSource.slice(0, 3).map((entry) => ({
conversation_id: normalizeText(entry?.conversation_id || entry?.conversationId || '', 100),
nickname: normalizeText(entry?.nickname, 100),
message_time: normalizeText(entry?.message_time || entry?.messageTime, 40),
text: normalizeText(entry?.text || entry?.content || entry, 1000),
})).filter((entry) => entry.text);
const affectedConversationIdSource = Array.isArray(item?.affected_conversation_ids)
? item.affected_conversation_ids
: Array.isArray(item?.affectedConversationIds)
? item.affectedConversationIds
: [];
const affectedConversationIds = Array.from(new Set([
...affectedConversationIdSource,
...evidence.map((entry) => entry.conversation_id),
]
.map((value) => normalizeText(value, 100))
.filter((value) => value && (allowedConversationIds.size === 0 || allowedConversationIds.has(value)))));
const areas = Array.isArray(item?.product_improvement_areas)
? item.product_improvement_areas.filter((area) => IMPROVEMENT_AREAS.has(area))
: [];
const imageUrls = Array.isArray(item?.image_urls)
? item.image_urls.filter((url) => /^https?:\/\//i.test(String(url)) && IMAGE_URL_PATTERN.test(String(url))).slice(0, 5)
: [];
return {
consultation_type: consultationType,
primary_reason: primaryReason,
secondary_reason: secondaryReason,
occurrence_count: Math.max(1, Number(item?.occurrence_count) || 1),
affected_customer_count: Math.max(affectedConversationIds.length, Number(item?.affected_customer_count) || 1),
affected_conversation_ids: affectedConversationIds,
buyer_intent: normalizeText(item?.buyer_intent, 500),
evidence,
image_urls: imageUrls,
product_improvement_areas: areas,
improvement_suggestion: normalizeText(item?.improvement_suggestion, 1000),
confidence: Math.min(1, Math.max(0, Number(item?.confidence) || 0)),
};
}).filter((item) => item.evidence.length || item.buyer_intent);
const hasPre = results.some((item) => item.consultation_type === '售前');
const hasAfter = results.some((item) => item.consultation_type === '售后');
return {
schema_version: HISTORY_SCHEMA_VERSION,
conversation_type: hasPre && hasAfter ? '混合' : hasAfter ? '售后' : hasPre ? '售前' : '无有效咨询',
summary: {
conversation_count: Number(data.summary?.conversation_count) || sourceCounts.customerCount,
pre_sale_conversation_count: Number(data.summary?.pre_sale_conversation_count) || 0,
after_sale_conversation_count: Number(data.summary?.after_sale_conversation_count) || 0,
mixed_conversation_count: Number(data.summary?.mixed_conversation_count) || 0,
main_findings: Array.isArray(data.summary?.main_findings) ? data.summary.main_findings.map((item) => normalizeText(item, 300)).filter(Boolean).slice(0, 10) : [],
},
results,
};
}
function formatConversation(uid, items) {
const header = `【会话ID:${uid};买家昵称:${normalizeText(items[0]?.nickname, 100)}】`;
const lines = items.map((row) => (
`[${normalizeText(row.msgTime, 40)}][${row.directionLabel}][客服:${normalizeText(row.csname, 100)}] ${normalizeText(row.content, 2000)}`
));
return `${header}\n${lines.join('\n')}`;
}
function buildConversationGroups(rows = []) {
const groups = new Map();
rows.forEach((row, index) => {
const key = normalizeText(row.uid, 100) || `unknown-${normalizeText(row.msgId, 100) || index}`;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(row);
});
return Array.from(groups.entries()).map(([conversationId, items]) => {
const text = formatConversation(conversationId, items);
const dates = items.map((item) => extractMessageDate(item.msgTime)).filter(Boolean).sort();
return {
conversationId,
messageCount: items.length,
text,
charCount: text.length,
startDate: dates[0] || null,
endDate: dates[dates.length - 1] || null,
};
});
}
function buildChatText(rows) {
return buildConversationGroups(rows).map((group) => group.text).join('\n\n');
}
function createChatBatches(rows, maxChars = DEFAULT_BATCH_MAX_CHARS) {
const limit = boundedInteger(maxChars, DEFAULT_BATCH_MAX_CHARS, 1000, 1000000);
const conversationGroups = buildConversationGroups(rows);
const batches = [];
let current = [];
let currentChars = 0;
const flush = () => {
if (!current.length) return;
const text = current.map((group) => group.text).join('\n\n');
batches.push({
index: batches.length + 1,
text,
charCount: text.length,
messageCount: current.reduce((sum, group) => sum + group.messageCount, 0),
customerCount: current.length,
conversationIds: current.map((group) => group.conversationId),
oversizedConversation: current.length === 1 && current[0].charCount > limit,
startDate: current.map((group) => group.startDate).filter(Boolean).sort()[0] || null,
endDate: current.map((group) => group.endDate).filter(Boolean).sort().at(-1) || null,
});
current = [];
currentChars = 0;
};
for (const group of conversationGroups) {
const separatorLength = current.length ? 2 : 0;
if (current.length && currentChars + separatorLength + group.charCount > limit) flush();
current.push(group);
currentChars += (current.length > 1 ? 2 : 0) + group.charCount;
}
flush();
return batches;
}
function analysisResultKey(item = {}) {
return [item.consultation_type, item.primary_reason, item.secondary_reason].join('|');
}
function mergeBatchAnalysesLocally(analyses, sourceCounts) {
const resultMap = new Map();
const findings = [];
const summary = {
conversation_count: sourceCounts.customerCount,
pre_sale_conversation_count: 0,
after_sale_conversation_count: 0,
mixed_conversation_count: 0,
main_findings: findings,
};
for (const analysis of analyses) {
const batchSummary = analysis?.summary || {};
summary.pre_sale_conversation_count += Number(batchSummary.pre_sale_conversation_count) || 0;
summary.after_sale_conversation_count += Number(batchSummary.after_sale_conversation_count) || 0;
summary.mixed_conversation_count += Number(batchSummary.mixed_conversation_count) || 0;
for (const finding of batchSummary.main_findings || []) {
const normalizedFinding = normalizeText(finding, 300);
if (normalizedFinding && !findings.includes(normalizedFinding) && findings.length < 10) findings.push(normalizedFinding);
}
for (const item of analysis?.results || []) {
const key = analysisResultKey(item);
const existing = resultMap.get(key);
if (!existing) {
resultMap.set(key, {
...item,
evidence: [...(item.evidence || [])],
affected_conversation_ids: [...(item.affected_conversation_ids || [])],
image_urls: [...(item.image_urls || [])],
product_improvement_areas: [...(item.product_improvement_areas || [])],
});
continue;
}
existing.occurrence_count += Number(item.occurrence_count) || 0;
existing.affected_customer_count += Number(item.affected_customer_count) || 0;
existing.affected_conversation_ids = Array.from(new Set([
...(existing.affected_conversation_ids || []),
...(item.affected_conversation_ids || []),
]));
existing.confidence = Math.max(Number(existing.confidence) || 0, Number(item.confidence) || 0);
for (const evidence of item.evidence || []) {
const evidenceKey = `${evidence.conversation_id}|${evidence.message_time}|${evidence.text}`;
const exists = existing.evidence.some((entry) => `${entry.conversation_id}|${entry.message_time}|${entry.text}` === evidenceKey);
if (!exists && existing.evidence.length < 3) existing.evidence.push(evidence);
}
existing.image_urls = Array.from(new Set([...existing.image_urls, ...(item.image_urls || [])])).slice(0, 5);
existing.product_improvement_areas = Array.from(new Set([
...existing.product_improvement_areas,
...(item.product_improvement_areas || []),
]));
}
}
const results = Array.from(resultMap.values());
const hasPre = results.some((item) => item.consultation_type === '售前');
const hasAfter = results.some((item) => item.consultation_type === '售后');
return {
schema_version: HISTORY_SCHEMA_VERSION,
conversation_type: hasPre && hasAfter ? '混合' : hasAfter ? '售后' : hasPre ? '售前' : '无有效咨询',
summary,
results,
};
}
function reconcileMergedAnalysis(modelAnalysis, localAnalysis, sourceCounts) {
const localByKey = new Map(localAnalysis.results.map((item) => [analysisResultKey(item), item]));
const reconciled = [];
const seen = new Set();
for (const item of modelAnalysis.results) {
const key = analysisResultKey(item);
const local = localByKey.get(key);
if (!local || seen.has(key)) continue;
seen.add(key);
reconciled.push({
...item,
occurrence_count: local.occurrence_count,
affected_customer_count: local.affected_customer_count,
affected_conversation_ids: [...(local.affected_conversation_ids || [])],
});
}
for (const local of localAnalysis.results) {
const key = analysisResultKey(local);
if (!seen.has(key)) reconciled.push(local);
}
const hasPre = reconciled.some((item) => item.consultation_type === '售前');
const hasAfter = reconciled.some((item) => item.consultation_type === '售后');
return {
...modelAnalysis,
conversation_type: hasPre && hasAfter ? '混合' : hasAfter ? '售后' : hasPre ? '售前' : '无有效咨询',
summary: {
...modelAnalysis.summary,
conversation_count: sourceCounts.customerCount,
pre_sale_conversation_count: localAnalysis.summary.pre_sale_conversation_count,
after_sale_conversation_count: localAnalysis.summary.after_sale_conversation_count,
mixed_conversation_count: localAnalysis.summary.mixed_conversation_count,
},
results: reconciled,
};
}
function normalizeUsage(usage = {}) {
const promptTokens = Number(usage.prompt_tokens ?? usage.input_tokens) || 0;
const completionTokens = Number(usage.completion_tokens ?? usage.output_tokens) || 0;
return {
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
total_tokens: Number(usage.total_tokens) || promptTokens + completionTokens,
};
}
function sumUsages(usages = []) {
return usages.reduce((total, usage) => {
const normalized = normalizeUsage(usage);
total.prompt_tokens += normalized.prompt_tokens;
total.completion_tokens += normalized.completion_tokens;
total.total_tokens += normalized.total_tokens;
return total;
}, { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
}
function buildMergePrompt({ product, sourceCounts, localAnalysis }) {
return [
`请汇总商品ID ${product.goodsId} 的分批聊天质检结果。`,
`完整数据共 ${sourceCounts.customerCount} 个客户、${sourceCounts.messageCount} 条消息。`,
'每个客户只存在于一个批次。请优化买家诉求概括、改进建议和主要发现,但必须遵守以下规则:',
'1. 不得新增或删除“咨询类型+一级原因+二级原因”的组合。',
'2. occurrence_count 和 affected_customer_count 必须原样保留。',
'3. 证据只能从输入中选择,不得改写、拼接或虚构。',
'4. 只能输出系统要求的合法 JSON。',
'',
`待汇总结果:${JSON.stringify(localAnalysis)}`,
].join('\n');
}
function getAiErrorDetail(error) {
const detail = error?.response?.data?.error?.message
|| error?.response?.data?.message
|| error?.response?.data?.error
|| error?.message;
const text = typeof detail === 'string' && detail.trim()
? detail
: detail && typeof detail === 'object'
? JSON.stringify(detail)
: error?.code || '未知错误';
return normalizeText(text, 1000)
.replace(/Bearer\s+\S+/gi, 'Bearer [redacted]')
.replace(/sk-[A-Za-z0-9_-]{12,}/g, '[redacted]');
}
function isRetryableAiError(error) {
const status = Number(error?.response?.status || error?.statusCode || 0);
if ([408, 425, 429].includes(status) || status >= 500) return true;
const code = normalizeText(error?.code, 100).toUpperCase();
return [
'ETIMEDOUT',
'ECONNABORTED',
'ECONNRESET',
'ECONNREFUSED',
'EAI_AGAIN',
'ENOTFOUND',
'EPIPE',
'UND_ERR_CONNECT_TIMEOUT',
'AI_RESPONSE_INVALID',
].includes(code);
}
function getAiRetryDelayMs(error, retryNumber, baseDelayMs = DEFAULT_AI_RETRY_BASE_DELAY_MS) {
const headers = error?.response?.headers;
const retryAfter = typeof headers?.get === 'function'
? headers.get('retry-after')
: headers?.['retry-after'];
if (retryAfter !== undefined && retryAfter !== null && String(retryAfter).trim()) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds) && seconds >= 0) {
return Math.min(Math.ceil(seconds * 1000), MAX_AI_RETRY_DELAY_MS);
}
const retryAt = Date.parse(String(retryAfter));
if (Number.isFinite(retryAt)) {
return Math.min(Math.max(retryAt - Date.now(), 0), MAX_AI_RETRY_DELAY_MS);
}
}
const base = boundedInteger(baseDelayMs, DEFAULT_AI_RETRY_BASE_DELAY_MS, 250, 10000);
return Math.min(base * (2 ** Math.max(0, Number(retryNumber) - 1)), MAX_AI_RETRY_DELAY_MS);
}
function sleep(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
function buildAiRequestPayload(config, messages, maxCompletionTokens) {
const payload = {
model: config.model,
messages,
temperature: 0,
stream: false,
};
if (config.provider === AGNES_PROVIDER) {
return { ...payload, max_tokens: maxCompletionTokens };
}
return {
...payload,
response_format: { type: 'json_object' },
max_completion_tokens: maxCompletionTokens,
enable_thinking: false,
};
}
async function requestAiJson(config, messages, maxCompletionTokens, context = {}) {
const maxAttempts = boundedInteger(config.requestRetryCount, DEFAULT_AI_REQUEST_RETRY_COUNT, 0, 4) + 1;
const requestStartedAt = Date.now();
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const attemptStartedAt = Date.now();
try {
const response = await axios.post(`${config.baseUrl}/chat/completions`, buildAiRequestPayload(
config,
messages,
maxCompletionTokens
), {
headers: { Authorization: `Bearer ${config.apiKey}`, 'Content-Type': 'application/json' },
timeout: config.timeoutMs,
maxBodyLength: 20 * 1024 * 1024,
});
const rawResponse = response.data || {};
const content = rawResponse?.choices?.[0]?.message?.content || '';
let parsed;
try {
parsed = parseModelJson(content);
} catch (parseError) {
parseError.code = 'AI_RESPONSE_INVALID';
throw parseError;
}
return {
rawResponse,
parsed,
usage: normalizeUsage(rawResponse.usage),
request: { attempts: attempt, durationMs: Date.now() - requestStartedAt },
};
} catch (error) {
const status = Number(error?.response?.status || 0) || null;
const code = normalizeText(error?.response?.data?.error?.code || error?.code || 'AI_REQUEST_FAILED', 100);
const detail = getAiErrorDetail(error);
const retryable = isRetryableAiError(error);
const diagnostic = {
operation: normalizeText(context.operation, 40) || 'analysis',
batchIndex: Number(context.batchIndex) || null,
batchTotal: Number(context.batchTotal) || null,
attempt,
maxAttempts,
status,
code,
durationMs: Date.now() - attemptStartedAt,
detail,
};
if (retryable && attempt < maxAttempts) {
const delayMs = getAiRetryDelayMs(error, attempt, config.retryBaseDelayMs);
console.warn('[CsQuality] AI 请求暂时失败,准备重试:', { ...diagnostic, delayMs });
if (typeof context.onRetry === 'function') {
try {
await context.onRetry({
attempt,
nextAttempt: attempt + 1,
maxAttempts,
delayMs,
code,
status,
});
} catch (progressError) {
console.warn('[CsQuality] 更新 AI 重试进度失败:', progressError.message);
}
}
await sleep(delayMs);
continue;
}
console.error('[CsQuality] AI 服务请求失败:', diagnostic);
const timedOut = ['ETIMEDOUT', 'ECONNABORTED', 'UND_ERR_CONNECT_TIMEOUT'].includes(code.toUpperCase());
const authorizationFailed = [401, 403].includes(status);
const rateLimited = status === 429;
const wrapped = new Error(
authorizationFailed
? 'AI质检服务配置校验失败,请联系管理员'
: rateLimited
? 'AI分析任务较多,请稍后重试'
: timedOut
? 'AI分析请求超时,请稍后重试'
: 'AI分析请求失败,请稍后重试'
);
wrapped.statusCode = status || (timedOut ? 504 : 502);
wrapped.code = code;
wrapped.internalDetail = detail;
wrapped.attempts = attempt;
wrapped.requestDurationMs = Date.now() - requestStartedAt;
wrapped.aiStatus = status;
wrapped.operation = diagnostic.operation;
throw wrapped;
}
}
throw new Error('AI分析请求失败,请稍后重试');
}
async function mapWithConcurrency(items, concurrency, mapper) {
const results = new Array(items.length);
let nextIndex = 0;
let firstError = null;
const worker = async () => {
while (nextIndex < items.length && !firstError) {
const index = nextIndex;
nextIndex += 1;
try {
results[index] = await mapper(items[index], index);
} catch (error) {
firstError ||= error;
}
}
};
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
if (firstError) throw firstError;
return results;
}
async function insertHistory(client, payload) {
const rows = await client.query(
`INSERT INTO ${HISTORY_TABLE} (
analysis_id, status, goods_id, goods_name, shop_name, product_image_url,
chat_start_date, chat_end_date, source_message_count, source_customer_count,
product_metrics_json, request_prompt, ai_provider, ai_model, conversation_type,
progress_stage, progress_current, progress_total, progress_percent, progress_message, progress_updated_at,
created_by_user_id, created_by_name
) OUTPUT INSERTED.id
VALUES (@analysisId, @status, @goodsId, @goodsName, @shopName, @productImageUrl,
@chatStartDate, @chatEndDate, @messageCount, @customerCount,
@metricsJson, @prompt, @provider, @model, @conversationType,
@progressStage, @progressCurrent, @progressTotal, @progressPercent, @progressMessage, SYSUTCDATETIME(),
@createdByUserId, @createdByName)`,
payload,
{ requestTimeout: 120000 }
);
return rows[0]?.id;
}
async function updateHistoryContext(client, id, payload) {
await client.query(
`UPDATE ${HISTORY_TABLE}
SET goods_name = @goodsName, shop_name = @shopName, product_image_url = @productImageUrl,
source_message_count = @messageCount, source_customer_count = @customerCount,
product_metrics_json = @metricsJson, request_prompt = @prompt,
ai_provider = @provider, ai_model = @model, updated_at = SYSUTCDATETIME()
WHERE id = @id`,
{ id, ...payload },
{ requestTimeout: 120000 }
);
}
async function updateHistoryProgress(client, id, payload) {
await client.query(
`UPDATE ${HISTORY_TABLE}
SET status = @status, progress_stage = @progressStage,
progress_current = @progressCurrent, progress_total = @progressTotal,
progress_percent = @progressPercent, progress_message = @progressMessage,
progress_updated_at = SYSUTCDATETIME(), updated_at = SYSUTCDATETIME()
WHERE id = @id`,
{ id, ...payload },
{ requestTimeout: 120000 }
);
}
async function updateHistory(client, id, payload) {
await client.query(
`UPDATE ${HISTORY_TABLE}
SET status = @status, ai_response_json = @aiResponseJson, ai_raw_response_json = @aiRawResponseJson,
conversation_type = @conversationType, analysis_json = @analysisJson, error_message = @errorMessage,
duration_ms = @durationMs, prompt_tokens = @promptTokens, completion_tokens = @completionTokens,
total_tokens = @totalTokens, progress_stage = @progressStage,
progress_current = @progressCurrent, progress_total = @progressTotal,
progress_percent = @progressPercent, progress_message = @progressMessage,
progress_updated_at = SYSUTCDATETIME(), updated_at = SYSUTCDATETIME()
WHERE id = @id`,
{ id, ...payload },
{ requestTimeout: 120000 }
);
}
function sanitizePublicError(value) {
return normalizeText(value, 2000)
.replace(/千问(?:模型)?/gi, 'AI服务')
.replace(/qwen[\w.:-]*/gi, 'AI服务')
.replace(/aliyun-model-studio/gi, 'AI服务')
.replace(/dashscope/gi, 'AI服务')
.replace(/(?:api|apihub)\.agnes-ai\.(?:cn|com)/gi, 'AI服务')
.replace(/agnes(?:-ai)?[\w.:-]*/gi, 'AI服务');
}
function createProgressReporter(client, historyId) {
let lastPercent = 0;
let writeQueue = Promise.resolve();
return (progress = {}) => {
lastPercent = Math.max(lastPercent, boundedInteger(progress.percent, lastPercent, 0, 100));
const payload = {
status: progress.status || 'running',
progressStage: normalizeText(progress.stage, 40) || 'running',
progressCurrent: Math.max(0, Number(progress.current) || 0),
progressTotal: Math.max(0, Number(progress.total) || 0),
progressPercent: lastPercent,
progressMessage: normalizeText(progress.message, 500) || '正在处理聊天记录',
};
writeQueue = writeQueue.then(() => updateHistoryProgress(client, historyId, payload));
return writeQueue;
};
}
async function createQueuedAnalysisHistory(input = {}, actor = {}) {
const goodsId = normalizeGoodsId(input.goodsId);
const range = resolveDateRange(input.startDate, input.endDate);
await ensureHistorySchema();
const client = await getHistoryClient();
const analysisId = crypto.randomUUID();
const historyId = await insertHistory(client, {
analysisId, status: 'queued', goodsId, goodsName: normalizeText(input.goodsName, 500) || null,
shopName: normalizeText(input.shopName, 255) || null,
productImageUrl: normalizeText(input.hdThumbUrl, 2000) || null,
chatStartDate: range.startDate, chatEndDate: range.endDate,
messageCount: 0, customerCount: 0, metricsJson: null,
prompt: null, provider: null, model: null, conversationType: null,
progressStage: 'queued', progressCurrent: 0, progressTotal: 0, progressPercent: 0,
progressMessage: '质检任务已创建,正在等待处理',
createdByUserId: normalizeText(actor.sub || actor.id, 100) || null, createdByName: normalizeText(actor.name || actor.username, 100) || null,
});
if (!historyId) throw new Error('创建质检任务失败,请稍后重试');
return { client, historyId, analysisId, goodsId, range, startedAt: Date.now() };
}
async function startAnalysisJob(input = {}, actor = {}) {
const queued = await createQueuedAnalysisHistory(input, actor);
const normalizedInput = {
...input,
goodsId: queued.goodsId,
startDate: queued.range.startDate,
endDate: queued.range.endDate,
};
setImmediate(() => {
runAnalysis(normalizedInput, actor, queued).catch((error) => {
console.error('[CsQuality] 后台质检任务失败:', {
analysisId: queued.analysisId,
message: error.message,
detail: error.internalDetail || null,
});
});
});
return {
historyId: queued.historyId,
analysisId: queued.analysisId,
status: 'queued',
range: queued.range,
createdAt: new Date(queued.startedAt).toISOString(),
progress: {
stage: 'queued', current: 0, total: 0, percent: 0,
message: '质检任务已创建,正在等待处理',
},
};
}
async function runAnalysis(input = {}, actor = {}, runtime = {}) {
const state = runtime.historyId
? {
client: runtime.client || await getHistoryClient(),
historyId: runtime.historyId,
analysisId: runtime.analysisId,
goodsId: normalizeGoodsId(input.goodsId),
range: resolveDateRange(input.startDate, input.endDate),
startedAt: runtime.startedAt || Date.now(),
}
: await createQueuedAnalysisHistory(input, actor);
const { client, historyId, analysisId, goodsId, range, startedAt } = state;
const reportProgress = createProgressReporter(client, historyId);
let progressTotal = 0;
let progressCurrent = 0;
try {
await reportProgress({ stage: 'preparing', percent: 3, message: '正在核对商品与时间范围' });
const [profile, config] = await Promise.all([
getProductProfile({ goodsId, startDate: range.startDate, endDate: range.endDate }),
getAiConfig(),
]);
const sourceCounts = {
messageCount: Number(profile.chatSummary?.messageCount || 0),
customerCount: Number(profile.chatSummary?.userCount || 0),
};
if (!sourceCounts.messageCount) throw new Error('所选日期内没有该商品的聊天记录');
if (sourceCounts.messageCount > MAX_ANALYSIS_MESSAGES) {
throw new Error(`聊天记录共${sourceCounts.messageCount}条,超过单次分析上限${MAX_ANALYSIS_MESSAGES}条,请缩短日期范围`);
}
await reportProgress({
stage: 'loading', percent: 10,
message: `已统计 ${sourceCounts.customerCount} 个客户、${sourceCounts.messageCount} 条消息,正在读取聊天内容`,
});
const messageResult = await getMessages({ goodsId, startDate: range.startDate, endDate: range.endDate });
const chatText = buildChatText(messageResult.rows);
if (chatText.length > MAX_ANALYSIS_TEXT_LENGTH) throw new Error('聊天内容过长,请缩短日期范围后再分析');
await reportProgress({ stage: 'grouping', percent: 16, message: '正在按客户整理完整会话,避免上下文被截断' });
const product = {
...profile.product,
goodsId,
goodsName: profile.product.goodsName || input.goodsName || '',
shopName: profile.product.shopName || input.shopName || '',
};
const batches = createChatBatches(messageResult.rows, config.batchMaxChars);
if (!batches.length) throw new Error('所选日期内没有可分析的客户会话');
progressTotal = batches.length;
const requestMetadata = JSON.stringify({
mode: 'segmented-conversations',
provider: config.provider,
model: config.model,
batchMaxChars: config.batchMaxChars,
batchConcurrency: config.batchConcurrency,
batches: batches.map((batch) => ({
index: batch.index,
messageCount: batch.messageCount,
customerCount: batch.customerCount,
charCount: batch.charCount,
conversationIds: batch.conversationIds,
oversizedConversation: batch.oversizedConversation,
startDate: batch.startDate,
endDate: batch.endDate,
})),
});
await updateHistoryContext(client, historyId, {
goodsName: product.goodsName,
shopName: product.shopName,
productImageUrl: product.hdThumbUrl || input.hdThumbUrl || null,
messageCount: sourceCounts.messageCount,
customerCount: sourceCounts.customerCount,
metricsJson: JSON.stringify(profile.metrics),
prompt: requestMetadata,
provider: config.provider,
model: config.model,
});
await reportProgress({
stage: 'batching', total: progressTotal, percent: 20,
message: `已拆分为 ${progressTotal} 批,准备开始分析`,
});
const batchResponses = await mapWithConcurrency(batches, config.batchConcurrency, async (batch) => {
await reportProgress({
stage: 'analyzing', current: progressCurrent, total: progressTotal,
percent: 20 + Math.floor((progressCurrent / progressTotal) * 64),
message: `正在分析 ${formatBatchDateRange(batch)} 的聊天记录(第 ${batch.index}/${progressTotal} 批)`,
});
const prompt = buildUserPrompt({
product,
metrics: profile.metrics,
chatText: batch.text,
startDate: range.startDate,
endDate: range.endDate,
});
let response;
try {
response = await requestAiJson(config, [
{ role: 'system', content: buildSystemPrompt() },
{
role: 'user',
content: `${prompt}\n\n这是分批质检中的第 ${batch.index}/${batches.length} 批,仅分析本批提供的完整客户会话。`,
},
], config.batchMaxTokens, {
operation: 'batch-analysis',
batchIndex: batch.index,
batchTotal: progressTotal,
onRetry: ({ nextAttempt, maxAttempts, delayMs }) => reportProgress({
stage: 'retrying', current: progressCurrent, total: progressTotal,
percent: 20 + Math.floor((progressCurrent / progressTotal) * 64),
message: `第 ${batch.index}/${progressTotal} 批连接波动,将在 ${Math.max(1, Math.ceil(delayMs / 1000))} 秒后进行第 ${nextAttempt}/${maxAttempts} 次尝试`,
}),
});
} catch (error) {
error.failedBatch = {
index: batch.index,
total: progressTotal,
messageCount: batch.messageCount,
customerCount: batch.customerCount,
charCount: batch.charCount,
startDate: batch.startDate,
endDate: batch.endDate,
};
throw error;
}
const batchResponse = {
batch: {
index: batch.index,
messageCount: batch.messageCount,
customerCount: batch.customerCount,
charCount: batch.charCount,
conversationIds: batch.conversationIds,
startDate: batch.startDate,
endDate: batch.endDate,
},
analysis: normalizeAnalysis(response.parsed, {
messageCount: batch.messageCount,
customerCount: batch.customerCount,
}, {
allowedConversationIds: batch.conversationIds,
}),
rawResponse: response.rawResponse,
usage: response.usage,
};
progressCurrent += 1;
await reportProgress({
stage: 'analyzing', current: progressCurrent, total: progressTotal,
percent: 20 + Math.floor((progressCurrent / progressTotal) * 64),
message: progressCurrent < progressTotal
? `已完成 ${progressCurrent}/${progressTotal} 批,正在继续处理其余聊天记录`
: `已完成全部 ${progressTotal} 批聊天记录,正在整理诊断结果`,
});
return batchResponse;
});
await reportProgress({
stage: 'merging', current: progressTotal, total: progressTotal, percent: 88,
message: '批次分析完成,正在合并诊断结果',
});
const localAnalysis = mergeBatchAnalysesLocally(
batchResponses.map((response) => response.analysis),
sourceCounts
);
let normalized = localAnalysis;
let mergeResponse = null;
let mergeFallback = false;
let mergeError = null;
const mergeSkipped = batchResponses.length > 1 && (
config.mergeMaxBatches === 0 || batchResponses.length > config.mergeMaxBatches
);
if (batchResponses.length > 1 && !mergeSkipped) {
try {
mergeResponse = await requestAiJson(config, [
{ role: 'system', content: buildSystemPrompt() },
{ role: 'user', content: buildMergePrompt({ product, sourceCounts, localAnalysis }) },
], config.mergeMaxTokens, {
operation: 'merge-analysis',
onRetry: ({ nextAttempt, maxAttempts, delayMs }) => reportProgress({
stage: 'retrying', current: progressTotal, total: progressTotal, percent: 88,
message: `汇总连接波动,将在 ${Math.max(1, Math.ceil(delayMs / 1000))} 秒后进行第 ${nextAttempt}/${maxAttempts} 次尝试`,
}),
});
const modelAnalysis = normalizeAnalysis(mergeResponse.parsed, sourceCounts, {
allowedConversationIds: batches.flatMap((batch) => batch.conversationIds),
});
normalized = reconcileMergedAnalysis(modelAnalysis, localAnalysis, sourceCounts);
} catch (error) {
mergeFallback = true;
mergeError = normalizeText(error.internalDetail || error.message, 1000);
console.warn('[CsQuality] 智能汇总请求失败,使用已完成批次生成结果:', mergeError);
}
}
await reportProgress({
stage: 'validating', current: progressTotal, total: progressTotal, percent: 94,
message: mergeFallback
? '智能汇总耗时较长,正在使用已完成批次校验结果'
: '正在校验问题分类和影响客户数',
});
const usage = sumUsages([
...batchResponses.map((response) => response.usage),
...(mergeResponse ? [mergeResponse.usage] : []),
]);
const rawResponse = {
provider: config.provider,
model: config.model,
batching: {
batchCount: batches.length,
batchMaxChars: config.batchMaxChars,
concurrency: config.batchConcurrency,
mergeFallback,
mergeSkipped,
mergeError,
},
batches: batchResponses.map((response) => ({
batch: response.batch,
response: response.rawResponse,
})),
mergeResponse: mergeResponse?.rawResponse || null,
};
await reportProgress({
stage: 'saving', current: progressTotal, total: progressTotal, percent: 98,
message: '正在保存质检结果',
});
await updateHistory(client, historyId, {
status: 'succeeded', aiResponseJson: JSON.stringify(normalized), aiRawResponseJson: JSON.stringify(rawResponse),
conversationType: normalized.conversation_type, analysisJson: JSON.stringify(normalized), errorMessage: null,
durationMs: Date.now() - startedAt, promptTokens: usage.prompt_tokens || null,
completionTokens: usage.completion_tokens || null, totalTokens: usage.total_tokens || null,
progressStage: 'completed', progressCurrent: progressTotal, progressTotal,
progressPercent: 100, progressMessage: '质检完成',
});
return {
historyId,
analysisId,
range,
product,
metrics: profile.metrics,
chatSummary: profile.chatSummary,
sourceCounts,
ai: {
usage,
durationMs: Date.now() - startedAt,
batching: {
batchCount: batches.length,
batchMaxChars: config.batchMaxChars,
concurrency: config.batchConcurrency,
mergeFallback,
mergeSkipped,
},
},
analysis: normalized,
};
} catch (error) {
const publicError = sanitizePublicError(error.message) || 'AI质检失败,请稍后重试';
const failureDiagnostic = {
type: 'cs-quality-failure',
analysisId,
operation: normalizeText(error.operation, 40) || null,
code: normalizeText(error.code, 100) || null,
status: Number(error.aiStatus || error.statusCode || 0) || null,
attempts: Number(error.attempts || 0) || null,
requestDurationMs: Number(error.requestDurationMs || 0) || null,
detail: getAiErrorDetail({ message: error.internalDetail || error.message, code: error.code }),
failedBatch: error.failedBatch || null,
};
console.error('[CsQuality] 质检任务执行失败:', failureDiagnostic);
await updateHistory(client, historyId, {
status: 'failed', aiResponseJson: null, aiRawResponseJson: JSON.stringify(failureDiagnostic), conversationType: null, analysisJson: null,
errorMessage: publicError,
durationMs: Date.now() - startedAt, promptTokens: null, completionTokens: null, totalTokens: null,
progressStage: 'failed', progressCurrent, progressTotal,
progressPercent: 100, progressMessage: '质检未完成,请查看失败原因后重试',
}).catch((updateError) => console.error('[CsQuality] 保存 AI 失败记录失败:', updateError.message));
error.message = publicError;
throw error;
}
}
function sqlDateToText(value) {
if (value instanceof Date && Number.isFinite(value.getTime())) return value.toISOString().slice(0, 10);
const match = String(value || '').match(/\d{4}-\d{2}-\d{2}/);
return match?.[0] || null;
}
function serializeAnalysisJob(row) {
const status = normalizeText(row.status, 20) || 'failed';
const analysis = parseJson(row.analysisJson, null) || parseJson(row.aiResponseJson, null);
const metrics = parseJson(row.metricsJson, {});
const requestMetadata = parseJson(row.requestPrompt, {});
const messageCount = Number(row.messageCount || 0);
const customerCount = Number(row.customerCount || 0);
const range = {
startDate: sqlDateToText(row.chatStartDate),
endDate: sqlDateToText(row.chatEndDate),
};
const product = {
goodsId: row.goodsId,
goodsName: row.goodsName,
shopName: row.shopName,
hdThumbUrl: row.productImageUrl,
};
const progress = {
stage: normalizeText(row.progressStage, 40) || status,
current: Math.max(0, Number(row.progressCurrent || 0)),
total: Math.max(0, Number(row.progressTotal || 0)),
percent: status === 'succeeded' ? 100 : Math.min(100, Math.max(0, Number(row.progressPercent || 0))),
message: sanitizePublicError(row.progressMessage)
|| (status === 'succeeded' ? '质检完成' : status === 'failed' ? '质检未完成' : '正在处理聊天记录'),
updatedAt: row.progressUpdatedAt || row.updatedAt || null,
};
const job = {
historyId: row.id,
analysisId: row.analysisId,
status,
range,
product,
messageCount,
customerCount,
durationMs: Number(row.durationMs || 0) || null,
errorMessage: sanitizePublicError(row.errorMessage),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
progress,
};
if (status === 'succeeded' && analysis) {
job.result = {
historyId: row.id,
analysisId: row.analysisId,
range,
product,
metrics,
chatSummary: { messageCount, userCount: customerCount },
sourceCounts: { messageCount, customerCount },
ai: {
durationMs: Number(row.durationMs || 0),
usage: {
prompt_tokens: Number(row.promptTokens || 0),
completion_tokens: Number(row.completionTokens || 0),
total_tokens: Number(row.totalTokens || 0),
},
batching: { batchCount: Array.isArray(requestMetadata.batches) ? requestMetadata.batches.length : progress.total },
},
analysis,
};
}
return job;
}
async function getAnalysisJob(analysisId) {
const normalizedId = normalizeText(analysisId, 64);
if (!normalizedId) {
const error = new Error('质检任务编号不能为空');
error.statusCode = 400;
throw error;
}
await ensureHistorySchema();
const client = await getHistoryClient();
const rows = await client.query(
`SELECT TOP 1
id, analysis_id AS [analysisId], status,
goods_id AS [goodsId], goods_name AS [goodsName], shop_name AS [shopName], product_image_url AS [productImageUrl],
chat_start_date AS [chatStartDate], chat_end_date AS [chatEndDate],
source_message_count AS [messageCount], source_customer_count AS [customerCount],
product_metrics_json AS [metricsJson], request_prompt AS [requestPrompt],
ai_response_json AS [aiResponseJson], analysis_json AS [analysisJson], error_message AS [errorMessage],
duration_ms AS [durationMs], prompt_tokens AS [promptTokens], completion_tokens AS [completionTokens], total_tokens AS [totalTokens],
progress_stage AS [progressStage], progress_current AS [progressCurrent], progress_total AS [progressTotal],
progress_percent AS [progressPercent], progress_message AS [progressMessage], progress_updated_at AS [progressUpdatedAt],
created_at AS [createdAt], updated_at AS [updatedAt]
FROM ${HISTORY_TABLE} WHERE analysis_id = @analysisId`,
{ analysisId: normalizedId },
{ requestTimeout: 120000 }
);
if (!rows[0]) {
const error = new Error('质检任务不存在');
error.statusCode = 404;
throw error;
}
const row = rows[0];
const lastProgressAt = Date.parse(row.progressUpdatedAt || row.updatedAt || row.createdAt || '');
if (['queued', 'running'].includes(row.status)
&& Number.isFinite(lastProgressAt)
&& Date.now() - lastProgressAt > CS_QUALITY_JOB_STALE_MS) {
const errorMessage = '质检任务长时间没有更新,可能因服务重启而中断,请重新发起';
await client.query(
`UPDATE ${HISTORY_TABLE}
SET status = N'failed', error_message = @errorMessage,
progress_stage = N'failed', progress_percent = 100, progress_message = @progressMessage,
duration_ms = COALESCE(duration_ms, DATEDIFF_BIG(MILLISECOND, created_at, SYSUTCDATETIME())),
progress_updated_at = SYSUTCDATETIME(), updated_at = SYSUTCDATETIME()
WHERE id = @id AND status IN (N'queued', N'running')`,
{ id: row.id, errorMessage, progressMessage: '质检任务已中断,请重新发起' },
{ requestTimeout: 120000 }
);
row.status = 'failed';
row.errorMessage = errorMessage;
row.progressStage = 'failed';
row.progressPercent = 100;
row.progressMessage = '质检任务已中断,请重新发起';
}
return serializeAnalysisJob(row);
}
async function listHistory(options = {}) {
await ensureHistorySchema();
const client = await getHistoryClient();
const page = Math.max(1, Number(options.page) || 1);
const pageSize = Math.min(100, Math.max(1, Number(options.pageSize) || 20));
const offset = (page - 1) * pageSize;
const params = { offset, pageSize };
const conditions = ['1=1'];
if (normalizeText(options.goodsId, 80)) { params.goodsId = `%${normalizeText(options.goodsId, 80)}%`; conditions.push('h.goods_id LIKE @goodsId'); }
if (normalizeText(options.shopName, 200)) { params.shopName = `%${normalizeText(options.shopName, 200)}%`; conditions.push('h.shop_name LIKE @shopName'); }
const [countRows, rows] = await Promise.all([
client.query(
`SELECT COUNT_BIG(1) AS [total] FROM ${HISTORY_TABLE} h WHERE ${conditions.join(' AND ')}`,
params,
{ requestTimeout: 120000 }
),
client.query(
`SELECT id, analysis_id AS [analysisId], status, goods_id AS [goodsId], goods_name AS [goodsName], shop_name AS [shopName], product_image_url AS [productImageUrl],
chat_start_date AS [chatStartDate], chat_end_date AS [chatEndDate], source_message_count AS [messageCount], source_customer_count AS [customerCount],
conversation_type AS [conversationType], error_message AS [errorMessage], duration_ms AS [durationMs],
progress_current AS [progressCurrent], progress_total AS [progressTotal], progress_percent AS [progressPercent], progress_message AS [progressMessage],
created_by_name AS [createdByName], created_at AS [createdAt]
FROM ${HISTORY_TABLE} h WHERE ${conditions.join(' AND ')} ORDER BY h.created_at DESC OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY`,
params,
{ requestTimeout: 120000 }
),
]);
return {
page,
pageSize,
total: Number(countRows?.[0]?.total || 0),
rows: (rows || []).map((row) => ({
...row,
errorMessage: sanitizePublicError(row.errorMessage),
progressMessage: sanitizePublicError(row.progressMessage),
})),
};
}
async function getHistoryDetail(id) {
await ensureHistorySchema();
const client = await getHistoryClient();
const rows = await client.query(
`SELECT TOP 1
id, analysis_id, status, goods_id, goods_name, shop_name, product_image_url,
chat_start_date, chat_end_date, source_message_count, source_customer_count,
product_metrics_json, ai_response_json, conversation_type, analysis_json, error_message,
duration_ms, prompt_tokens, completion_tokens, total_tokens,
progress_stage, progress_current, progress_total, progress_percent, progress_message, progress_updated_at,
created_by_user_id, created_by_name, created_at, updated_at
FROM ${HISTORY_TABLE} WHERE id = @id`,
{ id: Number(id) },
{ requestTimeout: 120000 }
);
if (!rows[0]) return null;
const row = rows[0];
return {
...row,
error_message: sanitizePublicError(row.error_message),
progress_message: sanitizePublicError(row.progress_message),
analysis: parseJson(row.analysis_json, null),
metrics: parseJson(row.product_metrics_json, {}),
aiResponse: parseJson(row.ai_response_json, null),
};
}
module.exports = {
QWEN_PROVIDER,
AGNES_PROVIDER,
DEFAULT_QWEN_BASE_URL,
DEFAULT_QWEN_MODEL,
DEFAULT_AGNES_BASE_URL,
DEFAULT_AGNES_MODEL,
getGoods,
getChatDataMeta,
getCustomers,
getMessages,
getProductProfile,
startAnalysisJob,
getAnalysisJob,
runAnalysis,
listHistory,
getHistoryDetail,
_test: {
resolveAiConfig,
buildAiRequestPayload,
parseModelJson,
mapChatDataMeta,
aggregateChatDimensionRows,
buildConversationGroups,
createChatBatches,
normalizeAnalysis,
mergeBatchAnalysesLocally,
reconcileMergedAnalysis,
normalizeUsage,
sumUsages,
getAiErrorDetail,
isRetryableAiError,
getAiRetryDelayMs,
sanitizePublicError,
},
};