const express = require('express'); const http = require('http'); const https = require('https'); const { URL } = require('url'); const router = express.Router(); const db = require('../db'); const logger = require('../utils/logger'); const { verifyToken } = require('../middleware/auth'); const normalizePositiveInt = (value, fallback, min = 1, max = Number.MAX_SAFE_INTEGER) => { const n = Number(value); if (!Number.isFinite(n)) return fallback; const intValue = Math.trunc(n); if (intValue < min) return min; if (intValue > max) return max; return intValue; }; const AI_TIMEOUT_MS = Number(process.env.AI_TIMEOUT_MS || 45000); const AI_MAX_TOKENS = normalizePositiveInt(process.env.AI_MAX_TOKENS, 4096, 256, 8192); const AI_DEFAULT_PROVIDER = String(process.env.AI_DEFAULT_PROVIDER || 'deepseek').trim().toLowerCase(); const AI_PROVIDER_PRIORITY = String(process.env.AI_PROVIDER_PRIORITY || 'deepseek').trim(); const AI_NAME_MAX_ITEMS_DEFAULT = normalizePositiveInt(process.env.AI_NAME_MAX_ITEMS_DEFAULT, 100, 20, 500); const AI_NAME_MAX_ITEMS_HARD_LIMIT = normalizePositiveInt(process.env.AI_NAME_MAX_ITEMS_HARD_LIMIT, 300, 50, 1000); const ADMIN_ROLES = new Set(['admin', 'super_admin', 'senior_user', 'intermediate_user']); const SUPPORTED_PROVIDERS = new Set(['auto', 'deepseek']); const SUPPORTED_MODES = new Set(['standard', 'deep']); const SUPPORTED_NAME_STRATEGIES = new Set(['latest', 'balanced']); const TYPE_LABEL_MAP = { paper: 'Paper', project: 'Project', award: 'Award', standard: 'Standard', monograph: 'Monograph', report: 'Technical Report', plan: 'Plan', patent: 'Patent', transformation: 'Transformation', software: 'Software Copyright' }; const STATUS_LABEL_MAP = { pending: 'Pending', approved: 'Approved', rejected: 'Rejected' }; const clampText = (value, maxLen = 600) => { return String(value || '').trim().slice(0, maxLen); }; const normalizeFilter = (value) => { if (value === undefined || value === null) return null; const s = String(value).trim(); if (!s || s.toLowerCase() === 'all') return null; return s; }; const toSafeInt = (value, fallback = null) => { const n = Number(value); if (!Number.isFinite(n)) return fallback; return Math.trunc(n); }; const normalizeProvider = (value) => { const provider = String(value || '').trim().toLowerCase(); if (!provider) return 'auto'; if (!SUPPORTED_PROVIDERS.has(provider)) return 'auto'; return provider; }; const normalizeMode = (value) => { const mode = String(value || '').trim().toLowerCase(); if (!mode || !SUPPORTED_MODES.has(mode)) return 'standard'; return mode; }; const normalizeNameStrategy = (value) => { const strategy = String(value || '').trim().toLowerCase(); if (!strategy || !SUPPORTED_NAME_STRATEGIES.has(strategy)) return 'latest'; return strategy; }; const normalizeNameLimit = (value) => { return normalizePositiveInt(value, AI_NAME_MAX_ITEMS_DEFAULT, 20, AI_NAME_MAX_ITEMS_HARD_LIMIT); }; const toIsoDate = (value) => { if (!value) return null; const date = value instanceof Date ? value : new Date(value); if (!Number.isFinite(date.getTime())) return null; return date.toISOString().slice(0, 10); }; const getProviderConfigs = () => { return { deepseek: { baseUrl: String(process.env.DEEPSEEK_API_BASE_URL || '').trim(), apiKey: String(process.env.DEEPSEEK_API_KEY || '').trim(), model: String(process.env.DEEPSEEK_MODEL || 'deepseek-chat').trim() || 'deepseek-chat' } }; }; const getAvailableProviders = (configs) => { return Object.keys(configs).filter((provider) => { const cfg = configs[provider]; return !!(cfg && cfg.baseUrl && cfg.apiKey && cfg.model); }); }; const parsePriority = () => { const tokens = AI_PROVIDER_PRIORITY .split(',') .map((p) => normalizeProvider(p)) .filter((p) => p !== 'auto'); return Array.from(new Set(tokens)); }; const resolveProviderOrder = (requestedProvider, availableProviders) => { if (requestedProvider !== 'auto') { return availableProviders.includes(requestedProvider) ? [requestedProvider] : []; } const priority = parsePriority(); const ordered = [ ...priority.filter((p) => availableProviders.includes(p)), ...availableProviders.filter((p) => !priority.includes(p)) ]; const defaultProvider = normalizeProvider(AI_DEFAULT_PROVIDER); if (defaultProvider !== 'auto' && ordered.includes(defaultProvider)) { return [defaultProvider, ...ordered.filter((p) => p !== defaultProvider)]; } return ordered; }; const postJson = (urlString, payload, headers = {}, timeoutMs = 30000) => { return new Promise((resolve, reject) => { const url = new URL(urlString); const body = JSON.stringify(payload); const requestLib = url.protocol === 'http:' ? http : https; const defaultPort = url.protocol === 'http:' ? 80 : 443; const req = requestLib.request({ protocol: url.protocol, hostname: url.hostname, port: url.port || defaultPort, path: `${url.pathname}${url.search}`, method: 'POST', timeout: timeoutMs, headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), ...headers } }, (res) => { let raw = ''; res.setEncoding('utf8'); res.on('data', (chunk) => { raw += chunk; }); res.on('end', () => { const statusCode = res.statusCode || 500; let parsed = {}; try { parsed = raw ? JSON.parse(raw) : {}; } catch (e) { parsed = { raw }; } if (statusCode >= 200 && statusCode < 300) { resolve(parsed); } else { reject(new Error(parsed?.error?.message || parsed?.message || `AI provider error: ${statusCode}`)); } }); }); req.on('timeout', () => { req.destroy(new Error('AI request timeout')); }); req.on('error', reject); req.write(body); req.end(); }); }; const writeSse = (res, event, payload) => { if (res.writableEnded) return; res.write(`event: ${event}\n`); res.write(`data: ${JSON.stringify(payload)}\n\n`); }; const extractChoiceText = (parsed) => { const choice = parsed?.choices?.[0] || {}; if (typeof choice?.delta?.content === 'string') return choice.delta.content; if (typeof choice?.message?.content === 'string') return choice.message.content; return ''; }; const streamProviderToClient = ({ req, res, provider, config, systemPrompt, userPrompt, maxTokens = AI_MAX_TOKENS, timeoutMs = 30000 }) => { return new Promise((resolve, reject) => { const endpoint = `${config.baseUrl.replace(/\/+$/, '')}/chat/completions`; const url = new URL(endpoint); const requestLib = url.protocol === 'http:' ? http : https; const defaultPort = url.protocol === 'http:' ? 80 : 443; const payload = JSON.stringify({ model: config.model, temperature: 0.2, max_tokens: maxTokens, stream: true, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt } ] }); let settled = false; let tokenChars = 0; let lineBuffer = ''; let rawFallbackBuffer = ''; let metaSent = false; const onClientClose = () => { if (!upstreamReq.destroyed) { upstreamReq.destroy(new Error('client closed connection')); } }; const settle = (fn, value) => { if (settled) return; settled = true; req.off('close', onClientClose); fn(value); }; const upstreamReq = requestLib.request({ protocol: url.protocol, hostname: url.hostname, port: url.port || defaultPort, path: `${url.pathname}${url.search}`, method: 'POST', timeout: timeoutMs, headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload), Accept: 'text/event-stream', Authorization: `Bearer ${config.apiKey}` } }, (upstreamRes) => { const statusCode = upstreamRes.statusCode || 500; upstreamRes.setEncoding('utf8'); if (statusCode < 200 || statusCode >= 300) { let errRaw = ''; upstreamRes.on('data', (chunk) => { errRaw += chunk; }); upstreamRes.on('end', () => { try { const parsed = errRaw ? JSON.parse(errRaw) : {}; settle(reject, new Error(parsed?.error?.message || parsed?.message || `AI provider error: ${statusCode}`)); } catch (e) { settle(reject, new Error(`AI provider error: ${statusCode}`)); } }); return; } if (!metaSent) { writeSse(res, 'meta', { provider, model: config.model, generatedAt: new Date().toISOString() }); metaSent = true; } const flushLine = (line) => { const trimmed = String(line || '').trim(); if (!trimmed || trimmed.startsWith(':') || !trimmed.startsWith('data:')) return; const data = trimmed.slice(5).trim(); if (!data || data === '[DONE]') return; try { const parsed = JSON.parse(data); const deltaText = extractChoiceText(parsed); if (deltaText) { tokenChars += deltaText.length; writeSse(res, 'token', { content: deltaText }); } } catch (e) { // Ignore malformed stream chunks. } }; upstreamRes.on('data', (chunk) => { rawFallbackBuffer += chunk; lineBuffer += chunk.replace(/\r/g, ''); let idx = lineBuffer.indexOf('\n'); while (idx !== -1) { const line = lineBuffer.slice(0, idx); lineBuffer = lineBuffer.slice(idx + 1); flushLine(line); idx = lineBuffer.indexOf('\n'); } }); upstreamRes.on('end', () => { if (lineBuffer) flushLine(lineBuffer); if (tokenChars === 0 && rawFallbackBuffer.trim()) { try { const parsed = JSON.parse(rawFallbackBuffer); const wholeText = extractChoiceText(parsed); if (wholeText) { tokenChars += wholeText.length; writeSse(res, 'token', { content: wholeText }); } } catch (e) { // Ignore fallback parse errors. } } if (tokenChars === 0) { settle(reject, new Error('provider returned empty analysis')); return; } settle(resolve, { provider, model: config.model, tokenChars }); }); upstreamRes.on('error', (err) => { settle(reject, err); }); }); upstreamReq.on('timeout', () => { upstreamReq.destroy(new Error('AI request timeout')); }); upstreamReq.on('error', (err) => { settle(reject, err); }); req.once('close', onClientClose); upstreamReq.write(payload); upstreamReq.end(); }); }; const buildWhereClause = (filters = {}) => { const where = ['1=1']; const params = []; const year = toSafeInt(normalizeFilter(filters.year)); const type = normalizeFilter(filters.type); const status = normalizeFilter(filters.status); const department = normalizeFilter(filters.department); if (year && year >= 2000 && year <= 2100) { where.push(`EXTRACT(YEAR FROM achievement_date) = $${params.length + 1}`); params.push(year); } if (type) { where.push(`type = $${params.length + 1}`); params.push(type); } if (status) { where.push(`status = $${params.length + 1}`); params.push(status); } return { whereSql: where.join(' AND '), params, department }; }; const toNumberRows = (rows = []) => { return rows.map((row) => ({ ...row, count: Number(row.count || 0) })); }; const toPercent = (count, total) => { if (!total) return 0; return Number(((Number(count || 0) / Number(total || 1)) * 100).toFixed(1)); }; const formatFilterValue = (key, value) => { if (!value || value === 'all') return '鍏ㄩ儴'; if (key === 'type') return TYPE_LABEL_MAP[value] || value; if (key === 'status') return STATUS_LABEL_MAP[value] || value; return value; }; const buildPromptDataset = (dataset) => { const total = Number(dataset?.overview?.total || 0); const rawFilters = dataset?.filters || {}; const mode = dataset?.mode === 'deep' ? 'deep' : 'standard'; const rawNameSamples = dataset?.nameSamples || {}; return { mode, filters: { year: formatFilterValue('year', rawFilters.year), type: formatFilterValue('type', rawFilters.type), status: formatFilterValue('status', rawFilters.status), department: formatFilterValue('department', rawFilters.department) }, overview: { total }, byType: (dataset?.byType || []).slice(0, 8).map((item) => ({ type: TYPE_LABEL_MAP[item.type] || item.type, count: Number(item.count || 0), ratio: toPercent(item.count, total) })), byStatus: (dataset?.byStatus || []).map((item) => ({ status: STATUS_LABEL_MAP[item.status] || item.status, count: Number(item.count || 0), ratio: toPercent(item.count, total) })), byDepartmentTop: (dataset?.byDepartment || []).slice(0, 10).map((item) => ({ department: item.department, count: Number(item.count || 0), ratio: toPercent(item.count, total) })), monthlyRecent: (dataset?.byMonth || []).slice(-24).map((item) => ({ month: item.month, count: Number(item.count || 0) })), quality: dataset?.quality || {}, nameSamples: { mode, strategy: rawNameSamples?.strategy || 'latest', sent: Number(rawNameSamples?.sent || 0), total: Number(rawNameSamples?.total || 0), truncated: Boolean(rawNameSamples?.truncated), items: (rawNameSamples?.items || []).map((item) => ({ name: clampText(item?.name, 120), type: TYPE_LABEL_MAP[item?.type] || item?.type || 'unknown', department: item?.department || 'unknown', date: item?.date || null })) } }; }; const gatherAnalysisDataset = async (filters = {}, options = {}) => { const { whereSql, params, department } = buildWhereClause(filters); const scopedParams = department ? [...params, department] : params; const scopedWhere = department ? `${whereSql} AND owner_department = $${params.length + 1}` : whereSql; const mode = normalizeMode(options?.mode); const nameStrategy = normalizeNameStrategy(options?.nameStrategy); const nameLimit = normalizeNameLimit(options?.nameLimit); const ownerAchievementsCte = ` WITH leader_depts AS ( SELECT name FROM dict_leader_departments ), leader_dept_array AS ( SELECT COALESCE(array_agg(name::text), ARRAY[]::text[]) AS names FROM leader_depts ), owner_achievements AS ( SELECT a.id, a.name, a.type, a.status, a.achievement_date, a.contributors, a.assigned_departments, a.remarks, COALESCE(non_leader.dept, first_any.dept) AS owner_department FROM achievements a CROSS JOIN leader_dept_array lda LEFT JOIN LATERAL ( SELECT u.dept FROM unnest(COALESCE(a.assigned_departments::text[], ARRAY[]::text[])) WITH ORDINALITY AS u(dept, ord) WHERE u.dept IS NOT NULL AND BTRIM(u.dept) <> '' AND u.dept <> ALL(lda.names) ORDER BY u.ord LIMIT 1 ) non_leader ON TRUE LEFT JOIN LATERAL ( SELECT u.dept FROM unnest(COALESCE(a.assigned_departments::text[], ARRAY[]::text[])) WITH ORDINALITY AS u(dept, ord) WHERE u.dept IS NOT NULL AND BTRIM(u.dept) <> '' ORDER BY u.ord LIMIT 1 ) first_any ON TRUE ) `; const totalRes = await db.query( `${ownerAchievementsCte} SELECT COUNT(*)::int AS count FROM owner_achievements WHERE ${scopedWhere}`, scopedParams ); const typeRes = await db.query( `${ownerAchievementsCte} SELECT type, COUNT(*)::int AS count FROM owner_achievements WHERE ${scopedWhere} GROUP BY type ORDER BY count DESC`, scopedParams ); const statusRes = await db.query( `${ownerAchievementsCte} SELECT status, COUNT(*)::int AS count FROM owner_achievements WHERE ${scopedWhere} GROUP BY status ORDER BY count DESC`, scopedParams ); const deptRes = await db.query( `${ownerAchievementsCte}, department_scope AS ( SELECT d.name AS department FROM departments d, leader_dept_array lda WHERE d.name::text <> ALL(lda.names) ) SELECT ds.department, COUNT(oa.id)::int AS count FROM department_scope ds LEFT JOIN owner_achievements oa ON ds.department = oa.owner_department AND (${scopedWhere}) GROUP BY ds.department ORDER BY count DESC LIMIT 20`, scopedParams ); const monthlyRes = await db.query( `${ownerAchievementsCte} SELECT month, count FROM ( SELECT TO_CHAR(achievement_date, 'YYYY-MM') AS month, COUNT(*)::int AS count FROM owner_achievements WHERE ${scopedWhere} AND achievement_date IS NOT NULL GROUP BY month ORDER BY month DESC LIMIT 48 ) t ORDER BY month`, scopedParams ); const qualityRes = await db.query( `${ownerAchievementsCte} SELECT COUNT(*) FILTER (WHERE contributors IS NULL OR jsonb_array_length(contributors) = 0)::int AS empty_contributors, COUNT(*) FILTER (WHERE owner_department IS NULL)::int AS empty_departments, COUNT(*) FILTER (WHERE remarks IS NULL OR LENGTH(BTRIM(remarks)) = 0)::int AS empty_remarks FROM owner_achievements WHERE ${scopedWhere}`, scopedParams ); const total = Number(totalRes.rows[0]?.count || 0); const byType = toNumberRows(typeRes.rows); const byStatus = toNumberRows(statusRes.rows); const byDepartment = toNumberRows(deptRes.rows); const byMonth = toNumberRows(monthlyRes.rows); const quality = { emptyContributors: Number(qualityRes.rows[0]?.empty_contributors || 0), emptyDepartments: Number(qualityRes.rows[0]?.empty_departments || 0), emptyRemarks: Number(qualityRes.rows[0]?.empty_remarks || 0) }; let nameSamples = { mode, strategy: nameStrategy, sent: 0, total: 0, truncated: false, items: [] }; if (mode === 'deep') { const nameCountRes = await db.query( `${ownerAchievementsCte} SELECT COUNT(*)::int AS count FROM owner_achievements WHERE ${scopedWhere} AND name IS NOT NULL AND LENGTH(BTRIM(name)) > 0`, scopedParams ); const totalNameCount = Number(nameCountRes.rows[0]?.count || 0); let sampleRows = []; if (totalNameCount > 0) { if (nameStrategy === 'balanced') { const distinctTypeCount = Math.max(byType.filter((item) => !!item.type).length, 1); const perTypeCap = Math.max(2, Math.ceil(nameLimit / distinctTypeCount)); const balancedParams = [...scopedParams, perTypeCap, nameLimit]; const balancedRes = await db.query( `${ownerAchievementsCte} SELECT id, name, type, owner_department AS department, achievement_date FROM ( SELECT id, name, type, owner_department, achievement_date, ROW_NUMBER() OVER ( PARTITION BY COALESCE(type, 'unknown') ORDER BY achievement_date DESC NULLS LAST, id DESC ) AS rn FROM owner_achievements WHERE ${scopedWhere} AND name IS NOT NULL AND LENGTH(BTRIM(name)) > 0 ) t WHERE rn <= $${scopedParams.length + 1} ORDER BY achievement_date DESC NULLS LAST, id DESC LIMIT $${scopedParams.length + 2}`, balancedParams ); sampleRows = balancedRes.rows; } else { const latestParams = [...scopedParams, nameLimit]; const latestRes = await db.query( `${ownerAchievementsCte} SELECT id, name, type, owner_department AS department, achievement_date FROM owner_achievements WHERE ${scopedWhere} AND name IS NOT NULL AND LENGTH(BTRIM(name)) > 0 ORDER BY achievement_date DESC NULLS LAST, id DESC LIMIT $${scopedParams.length + 1}`, latestParams ); sampleRows = latestRes.rows; } } const items = sampleRows .map((row) => ({ name: clampText(row?.name, 120), type: row?.type || null, department: row?.department || null, date: toIsoDate(row?.achievement_date) })) .filter((item) => !!item.name); nameSamples = { mode, strategy: nameStrategy, sent: items.length, total: totalNameCount, truncated: items.length < totalNameCount, items }; } return { mode, filters: { year: normalizeFilter(filters.year) || 'all', type: normalizeFilter(filters.type) || 'all', status: normalizeFilter(filters.status) || 'all', department: normalizeFilter(filters.department) || 'all' }, overview: { total }, byType, byStatus, byDepartment, byMonth, quality, nameSamples, protection: { attachmentContentSent: false, attachmentPathSent: false, attachmentFilenameSent: false, contributorPhoneSent: false, contributorNameSent: false, achievementNameSent: mode === 'deep', note: mode === 'deep' ? 'Deep mode sends aggregated statistics and achievement names only. No personal phone, contributor name, or attachment data is sent.' : 'Standard mode sends aggregated statistics only. No names, phones, or attachment data is sent.' } }; }; const buildPrompt = (question, dataset) => { const promptDataset = buildPromptDataset(dataset); const mode = dataset?.mode === 'deep' ? 'deep' : 'standard'; const modeNote = mode === 'deep' ? '当前为深度分析模式:会额外提供成果名称样本(不含个人姓名与手机号)。' : '当前为标准分析模式:仅提供聚合统计数据,不提供成果名称。'; const systemPrompt = ` 你是科研成果统计分析助手。只能基于输入数据给出结论,不得编造事实。 要求: 1) 明确统计口径与边界; 2) 关键判断尽量给出数量/占比/趋势证据; 3) 证据不足时直接说明“数据不足以支持该结论”; 4) 建议必须可执行、可落地。 请使用中文 Markdown 输出,结构如下: ## 执行摘要 ## 关键发现 ## 风险与数据质量 ## 优先行动(表格) ## 一句话结论 `.trim(); const userPrompt = ` 用户问题:${question || '请给出整体分析、风险和改进建议。'} 模式说明:${modeNote} 隐私口径: - 不包含附件内容、文件路径、文件名; - 不包含个人手机号、贡献者姓名; - 部门统计已排除院领导部门,每条成果仅计入一个归属部门。 请先结论后证据再建议。以下是可用数据 JSON: ${JSON.stringify(promptDataset)} `.trim(); return { systemPrompt, userPrompt }; }; const callProvider = async (provider, config, systemPrompt, userPrompt, maxTokens = AI_MAX_TOKENS) => { const endpoint = `${config.baseUrl.replace(/\/+$/, '')}/chat/completions`; const aiResp = await postJson( endpoint, { model: config.model, temperature: 0.2, max_tokens: maxTokens, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt } ] }, { Authorization: `Bearer ${config.apiKey}` }, AI_TIMEOUT_MS ); const analysis = aiResp?.choices?.[0]?.message?.content || ''; if (!analysis) { throw new Error('provider returned empty analysis'); } return { provider, model: config.model, analysis }; }; router.post('/achievements', verifyToken, async (req, res) => { if (!ADMIN_ROLES.has(req.user.role)) { return res.status(403).json({ message: 'No permission to use AI analysis.' }); } const requestedProvider = normalizeProvider(req.body?.provider || 'auto'); const providerConfigs = getProviderConfigs(); const availableProviders = getAvailableProviders(providerConfigs); if (availableProviders.length === 0) { return res.status(503).json({ message: 'AI service is not configured. Please configure DeepSeek API settings.' }); } const providerOrder = resolveProviderOrder(requestedProvider, availableProviders); if (providerOrder.length === 0) { return res.status(503).json({ message: `Requested provider '${requestedProvider}' is unavailable. Available providers: ${availableProviders.join(', ')}` }); } const question = clampText(req.body?.question, 600); const filters = req.body?.filters || {}; const mode = normalizeMode(req.body?.mode); const nameLimit = normalizeNameLimit(req.body?.nameLimit); const nameStrategy = normalizeNameStrategy(req.body?.nameStrategy); const analysisOptions = { mode, nameLimit, nameStrategy }; const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress; try { const dataset = await gatherAnalysisDataset(filters, analysisOptions); const { systemPrompt, userPrompt } = buildPrompt(question, dataset); const triedProviders = []; const errors = []; let aiResult = null; for (const provider of providerOrder) { triedProviders.push(provider); try { aiResult = await callProvider(provider, providerConfigs[provider], systemPrompt, userPrompt, AI_MAX_TOKENS); break; } catch (providerErr) { errors.push(`${provider}: ${providerErr.message}`); } } if (!aiResult) { throw new Error(`All AI providers failed: ${errors.join(' | ')}`); } logger.info( `AI analysis generated by user ${req.user.username} (role=${req.user.role}, provider=${aiResult.provider}, ip=${clientIp})` ); res.json({ provider: aiResult.provider, model: aiResult.model, generatedAt: new Date().toISOString(), analysis: aiResult.analysis, triedProviders, mode: dataset.mode, nameSamples: dataset.nameSamples, datasetSummary: { filters: dataset.filters, total: dataset.overview.total, byTypeCount: dataset.byType.length, byDepartmentCount: dataset.byDepartment.length, byMonthCount: dataset.byMonth.length }, protection: dataset.protection }); } catch (err) { logger.error(`AI analysis failed for user ${req.user.username}: ${err.message}`); res.status(500).json({ message: `AI analysis failed: ${err.message}` }); } }); router.post('/achievements/stream', verifyToken, async (req, res) => { if (!ADMIN_ROLES.has(req.user.role)) { return res.status(403).json({ message: 'No permission to use AI analysis.' }); } const requestedProvider = normalizeProvider(req.body?.provider || 'auto'); const providerConfigs = getProviderConfigs(); const availableProviders = getAvailableProviders(providerConfigs); if (availableProviders.length === 0) { return res.status(503).json({ message: 'AI service is not configured. Please configure DeepSeek API settings.' }); } const providerOrder = resolveProviderOrder(requestedProvider, availableProviders); if (providerOrder.length === 0) { return res.status(503).json({ message: `Requested provider '${requestedProvider}' is unavailable. Available providers: ${availableProviders.join(', ')}` }); } const question = clampText(req.body?.question, 600); const filters = req.body?.filters || {}; const mode = normalizeMode(req.body?.mode); const nameLimit = normalizeNameLimit(req.body?.nameLimit); const nameStrategy = normalizeNameStrategy(req.body?.nameStrategy); const analysisOptions = { mode, nameLimit, nameStrategy }; const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress; try { const dataset = await gatherAnalysisDataset(filters, analysisOptions); const { systemPrompt, userPrompt } = buildPrompt(question, dataset); const triedProviders = []; const errors = []; res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); res.setHeader('Cache-Control', 'no-cache, no-transform'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); if (typeof res.flushHeaders === 'function') { res.flushHeaders(); } let streamResult = null; for (const provider of providerOrder) { triedProviders.push(provider); try { streamResult = await streamProviderToClient({ req, res, provider, config: providerConfigs[provider], systemPrompt, userPrompt, maxTokens: AI_MAX_TOKENS, timeoutMs: AI_TIMEOUT_MS }); break; } catch (providerErr) { errors.push(`${provider}: ${providerErr.message}`); writeSse(res, 'provider_error', { provider, message: providerErr.message }); } } if (!streamResult) { writeSse(res, 'error', { message: `All AI providers failed: ${errors.join(' | ')}` }); writeSse(res, 'done', { success: false, triedProviders }); res.end(); return; } logger.info( `AI stream analysis generated by user ${req.user.username} (role=${req.user.role}, provider=${streamResult.provider}, ip=${clientIp})` ); writeSse(res, 'done', { success: true, provider: streamResult.provider, model: streamResult.model, generatedAt: new Date().toISOString(), triedProviders, mode: dataset.mode, nameSamples: dataset.nameSamples, datasetSummary: { filters: dataset.filters, total: dataset.overview.total, byTypeCount: dataset.byType.length, byDepartmentCount: dataset.byDepartment.length, byMonthCount: dataset.byMonth.length }, protection: dataset.protection }); res.end(); } catch (err) { logger.error(`AI stream analysis failed for user ${req.user.username}: ${err.message}`); if (res.headersSent) { writeSse(res, 'error', { message: `AI analysis failed: ${err.message}` }); writeSse(res, 'done', { success: false }); res.end(); return; } res.status(500).json({ message: `AI analysis failed: ${err.message}` }); } }); module.exports = router;