'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, }, };