Files
tech-achievement-management…/server/routes/aiAnalysis.js
T
2026-04-19 14:05:40 +08:00

950 lines
34 KiB
JavaScript

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;