chore: initial import
This commit is contained in:
@@ -0,0 +1,949 @@
|
||||
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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user