1968 lines
80 KiB
JavaScript
1968 lines
80 KiB
JavaScript
'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,
|
||
},
|
||
};
|