From 6fc9748009a3316c550d28acf3286c8dd6754c71 Mon Sep 17 00:00:00 2001
From: zhaojunlong <5482498@qq.com>
Date: Mon, 15 Dec 2025 17:49:11 +0800
Subject: [PATCH] =?UTF-8?q?```=20feat(llm):=20=E6=B7=BB=E5=8A=A0AI?=
=?UTF-8?q?=E6=99=BA=E8=83=BD=E5=88=86=E6=9E=90=E9=85=8D=E7=BD=AE=E5=8A=9F?=
=?UTF-8?q?=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
新增LLM配置模块,支持通过阿里云DashScope API进行招标金额的智能提取。
配置包括API Key、Base URL、模型选择等,并提供启用开关。
前端界面增加“AI配置”标签页,包含状态展示、配置表单及测试连接功能。
后端增强parseDetailEnhanced方法,优先使用LLM提取金额,失败时降级至正则表达式。
同时实现LLM状态查询与连接测试接口,确保配置有效性。
配置文件中新增llm字段,默认关闭,支持安全存储API密钥。
```
---
config.example.json | 6 ++
config.json | 6 ++
public/app.js | 160 ++++++++++++++++++++++++++++++
public/index.html | 98 ++++++++++++++++++
src/llmService.js | 237 ++++++++++++++++++++++++++++++++++++++++++++
src/server.js | 62 ++++++++++--
6 files changed, 562 insertions(+), 7 deletions(-)
create mode 100644 src/llmService.js
diff --git a/config.example.json b/config.example.json
index bc04c4d..61ad234 100644
--- a/config.example.json
+++ b/config.example.json
@@ -17,5 +17,11 @@
"smtpUser": "your-email@example.com",
"smtpPass": "your-password",
"recipients": "recipient1@example.com,recipient2@example.com"
+ },
+ "llm": {
+ "enabled": false,
+ "apiKey": "",
+ "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1",
+ "model": "qwen-turbo"
}
}
diff --git a/config.json b/config.json
index 731d3c6..1aa8cb3 100644
--- a/config.json
+++ b/config.json
@@ -12,5 +12,11 @@
"smtpUser": "1076597680@qq.com",
"smtpPass": "nfrjdiraqddsjeeh",
"recipients": "5482498@qq.com"
+ },
+ "llm": {
+ "enabled": false,
+ "apiKey": "sk-c9b41f5fd02a495fb5c3f3497076aae8",
+ "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1",
+ "model": "qwen-plus"
}
}
\ No newline at end of file
diff --git a/public/app.js b/public/app.js
index 27f104a..4e8ea52 100644
--- a/public/app.js
+++ b/public/app.js
@@ -801,6 +801,7 @@ function updateCustomCron() {
document.addEventListener('DOMContentLoaded', function() {
loadEmailConfig();
loadSchedulerConfig();
+ loadLLMConfig();
// 添加自定义时间输入框的事件监听
const customHour = document.getElementById('customHour');
@@ -959,3 +960,162 @@ function showSchedulerStatus(message, type) {
}, 3000);
}
}
+
+// ========== LLM 配置功能 ==========
+
+// 加载 LLM 配置
+async function loadLLMConfig() {
+ try {
+ const response = await fetch(`${API_BASE}/config`);
+ const data = await response.json();
+
+ if (data.success && data.data && data.data.llm) {
+ const llmConfig = data.data.llm;
+
+ document.getElementById('llmEnabled').checked = llmConfig.enabled || false;
+ document.getElementById('llmApiKey').value = llmConfig.apiKey || '';
+ document.getElementById('llmBaseUrl').value = llmConfig.baseUrl || 'https://dashscope.aliyuncs.com/compatible-mode/v1';
+ document.getElementById('llmModel').value = llmConfig.model || 'qwen-turbo';
+ }
+
+ // 更新状态显示
+ await updateLLMStatus();
+ } catch (error) {
+ console.error('加载 LLM 配置失败:', error);
+ }
+}
+
+// 更新 LLM 状态显示
+async function updateLLMStatus() {
+ try {
+ const response = await fetch(`${API_BASE}/llm/status`);
+ const data = await response.json();
+
+ if (data.success && data.data) {
+ const status = data.data;
+
+ // 更新运行状态
+ let statusText, statusColor;
+ if (status.enabled) {
+ statusText = '✓ 已启用';
+ statusColor = '#28a745';
+ } else if (status.configured) {
+ statusText = '○ 已配置但未启用';
+ statusColor = '#ffc107';
+ } else {
+ statusText = '✗ 未配置';
+ statusColor = '#dc3545';
+ }
+ document.getElementById('llmRunningStatus').innerHTML = `${statusText}`;
+
+ // 更新模型名称
+ document.getElementById('llmModelName').textContent = status.model || '-';
+ }
+ } catch (error) {
+ console.error('获取 LLM 状态失败:', error);
+ document.getElementById('llmRunningStatus').innerHTML = '获取状态失败';
+ }
+}
+
+// 保存 LLM 配置
+async function saveLLMConfig() {
+ const llmConfig = {
+ enabled: document.getElementById('llmEnabled').checked,
+ apiKey: document.getElementById('llmApiKey').value,
+ baseUrl: document.getElementById('llmBaseUrl').value || 'https://dashscope.aliyuncs.com/compatible-mode/v1',
+ model: document.getElementById('llmModel').value || 'qwen-turbo'
+ };
+
+ // 验证必填项
+ if (llmConfig.enabled && !llmConfig.apiKey) {
+ showLLMStatus('启用 AI 功能需要填写 API Key', 'error');
+ return;
+ }
+
+ showLLMStatus('正在保存配置...', 'info');
+
+ try {
+ // 先获取现有配置
+ const configResponse = await fetch(`${API_BASE}/config`);
+ const configData = await configResponse.json();
+
+ if (!configData.success) {
+ showLLMStatus('获取配置失败', 'error');
+ return;
+ }
+
+ // 合并配置
+ const fullConfig = {
+ ...configData.data,
+ llm: llmConfig
+ };
+
+ // 保存配置
+ const response = await fetch(`${API_BASE}/config`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(fullConfig)
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ showLLMStatus('配置已保存!', 'success');
+ await updateLLMStatus();
+ } else {
+ showLLMStatus(`保存失败: ${data.error}`, 'error');
+ }
+ } catch (error) {
+ showLLMStatus(`请求失败: ${error.message}`, 'error');
+ }
+}
+
+// 测试 LLM 连接
+async function testLLMConnection() {
+ showLLMStatus('正在测试连接...', 'info');
+
+ try {
+ const response = await fetch(`${API_BASE}/llm/test`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' }
+ });
+
+ const data = await response.json();
+
+ if (data.success && data.data.success) {
+ showLLMStatus(`连接成功! 模型: ${data.data.model}, 响应: ${data.data.reply}`, 'success');
+ } else {
+ showLLMStatus(`连接失败: ${data.data?.error || data.error || '未知错误'}`, 'error');
+ }
+ } catch (error) {
+ showLLMStatus(`请求失败: ${error.message}`, 'error');
+ }
+}
+
+// 显示 LLM 配置状态
+function showLLMStatus(message, type) {
+ const statusDiv = document.getElementById('llmConfigStatus');
+ const bgColors = {
+ success: '#d4edda',
+ error: '#f8d7da',
+ info: '#d1ecf1'
+ };
+ const textColors = {
+ success: '#155724',
+ error: '#721c24',
+ info: '#0c5460'
+ };
+
+ statusDiv.innerHTML = `
+
+ ${message}
+
+ `;
+
+ // 5秒后自动隐藏成功消息
+ if (type === 'success') {
+ setTimeout(() => {
+ statusDiv.innerHTML = '';
+ }, 5000);
+ }
+}
diff --git a/public/index.html b/public/index.html
index 2be93f4..b61ee9f 100644
--- a/public/index.html
+++ b/public/index.html
@@ -344,6 +344,7 @@
+
@@ -632,6 +633,103 @@
+
+
+
+
AI 智能分析配置
+
使用大语言模型智能提取招标金额,提高金额识别的准确性
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
功能说明
+
+ - 智能提取: 使用大语言模型理解公告内容,准确提取预算金额
+ - 自动降级: 当 AI 服务不可用时,自动使用正则表达式提取
+ - 支持模型: 阿里云通义千问系列模型(qwen-turbo/plus/max)
+ - 计费说明: 按实际调用量计费,qwen-turbo 约 0.0008元/千tokens
+
+
+
+
+
模型对比
+
+
+ | 模型 |
+ 速度 |
+ 准确度 |
+ 成本 |
+
+
+ | qwen-turbo |
+ 最快 |
+ 良好 |
+ 最低 |
+
+
+ | qwen-plus |
+ 较快 |
+ 优秀 |
+ 中等 |
+
+
+ | qwen-max |
+ 较慢 |
+ 最佳 |
+ 较高 |
+
+
+
+
diff --git a/src/llmService.js b/src/llmService.js
new file mode 100644
index 0000000..50c1bd8
--- /dev/null
+++ b/src/llmService.js
@@ -0,0 +1,237 @@
+/**
+ * LLM 服务模块 - 使用阿里云通义千问 API 提取招标金额
+ */
+
+import { readFileSync } from 'fs';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+// 获取 LLM 配置
+function getLLMConfig() {
+ try {
+ const configPath = join(__dirname, '..', 'config.json');
+ const configContent = readFileSync(configPath, 'utf-8');
+ const config = JSON.parse(configContent);
+ return config.llm || null;
+ } catch (err) {
+ console.error('读取 LLM 配置失败:', err.message);
+ return null;
+ }
+}
+
+// 检查 LLM 是否已启用
+export function isLLMEnabled() {
+ const config = getLLMConfig();
+ return config && config.enabled && config.apiKey;
+}
+
+// 使用 LLM 提取招标金额
+export async function extractBudgetWithLLM(content) {
+ const config = getLLMConfig();
+
+ if (!config || !config.enabled || !config.apiKey) {
+ return null;
+ }
+
+ // 智能提取包含金额信息的段落,避免截断丢失关键信息
+ const maxContentLength = 4000;
+ let truncatedContent = content;
+
+ if (content.length > maxContentLength) {
+ // 查找金额关键词的位置,提取关键词周围的上下文
+ const budgetKeywords = ['预算金额', '项目预算', '采购预算', '控制价', '最高限价', '招标金额', '项目金额', '合同金额', '投标报价', '中标金额', '成交金额', '中标价', '成交价'];
+ const contextRadius = 200; // 关键词前后各取200字符
+ const extractedContexts = [];
+
+ for (const keyword of budgetKeywords) {
+ let pos = content.indexOf(keyword);
+ while (pos !== -1) {
+ const start = Math.max(0, pos - contextRadius);
+ const end = Math.min(content.length, pos + keyword.length + contextRadius);
+ extractedContexts.push(content.substring(start, end));
+ pos = content.indexOf(keyword, pos + 1);
+ }
+ }
+
+ if (extractedContexts.length > 0) {
+ // 有相关内容,拼接:开头部分 + 提取的上下文
+ const headerContent = content.substring(0, 1500);
+ const relevantContent = [...new Set(extractedContexts)].join('\n---\n'); // 去重
+ truncatedContent = headerContent + '\n\n【以下为金额相关内容】\n' + relevantContent;
+
+ if (truncatedContent.length > maxContentLength) {
+ truncatedContent = truncatedContent.substring(0, maxContentLength) + '...(内容已截断)';
+ }
+ } else {
+ // 没找到相关内容,使用原来的截断方式
+ truncatedContent = content.substring(0, maxContentLength) + '...(内容已截断)';
+ }
+ }
+
+ const prompt = `你是一个专业的招标文件分析助手。请从以下招标公告内容中提取预算金额信息。
+
+要求:
+1. 优先查找以下字段对应的金额:预算金额、项目预算、采购预算、预算、控制价、最高限价、招标金额、项目金额、合同金额、投标报价、中标金额、成交金额、中标价、成交价
+2. 如果有多个金额,优先选择"预算金额"或"项目预算"
+3. 金额统一转换为万元单位(如 70万元 = 70,700000元 = 70)
+4. 严格按照 JSON 格式返回,不要添加任何其他文字
+
+常见格式示例:
+- "预算金额:70万元" → amount: 70
+- "预算金额:700000元" → amount: 70
+- "项目预算:70.00万元" → amount: 70
+
+返回格式(必须是合法的 JSON):
+{"amount": 数值, "unit": "万元", "text": "原文中的金额描述"}
+
+如果没有找到金额,返回:
+{"amount": null, "unit": null, "text": null}
+
+公告内容:
+${truncatedContent}`;
+
+ try {
+ const response = await fetch(`${config.baseUrl}/chat/completions`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${config.apiKey}`,
+ },
+ body: JSON.stringify({
+ model: config.model || 'qwen-turbo',
+ messages: [
+ {
+ role: 'user',
+ content: prompt,
+ },
+ ],
+ temperature: 0.1, // 低温度,保证输出稳定
+ max_tokens: 200,
+ }),
+ signal: AbortSignal.timeout(15000), // 15秒超时
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error('LLM API 错误:', response.status, errorText);
+ return null;
+ }
+
+ const data = await response.json();
+ const assistantMessage = data.choices?.[0]?.message?.content;
+
+ if (!assistantMessage) {
+ console.error('LLM 返回内容为空');
+ return null;
+ }
+
+ // 解析 JSON 响应
+ const jsonMatch = assistantMessage.match(/\{[\s\S]*\}/);
+ if (!jsonMatch) {
+ console.error('LLM 返回格式异常:', assistantMessage);
+ return null;
+ }
+
+ const result = JSON.parse(jsonMatch[0]);
+
+ if (result.amount === null || result.amount === undefined) {
+ return null;
+ }
+
+ // 验证金额合理性
+ const amount = parseFloat(result.amount);
+ if (isNaN(amount) || amount < 0.01 || amount > 100000000) {
+ console.error('LLM 提取的金额不合理:', result.amount);
+ return null;
+ }
+
+ console.log(`LLM 提取金额成功: ${amount} 万元`);
+
+ return {
+ amount: amount,
+ unit: '万元',
+ text: result.text || `${amount}万元`,
+ source: 'llm', // 标记来源
+ };
+ } catch (err) {
+ if (err.name === 'TimeoutError') {
+ console.error('LLM API 超时');
+ } else {
+ console.error('LLM 提取金额失败:', err.message);
+ }
+ return null;
+ }
+}
+
+// 测试 LLM 连接
+export async function testLLMConnection() {
+ const config = getLLMConfig();
+
+ if (!config || !config.apiKey) {
+ return { success: false, error: '未配置 API Key' };
+ }
+
+ try {
+ const response = await fetch(`${config.baseUrl}/chat/completions`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${config.apiKey}`,
+ },
+ body: JSON.stringify({
+ model: config.model || 'qwen-turbo',
+ messages: [
+ {
+ role: 'user',
+ content: '请回复"连接成功"',
+ },
+ ],
+ max_tokens: 10,
+ }),
+ signal: AbortSignal.timeout(10000),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ return { success: false, error: `API 错误: ${response.status} - ${errorText}` };
+ }
+
+ const data = await response.json();
+ const reply = data.choices?.[0]?.message?.content;
+
+ return {
+ success: true,
+ message: '连接成功',
+ model: config.model || 'qwen-turbo',
+ reply: reply,
+ };
+ } catch (err) {
+ if (err.name === 'TimeoutError') {
+ return { success: false, error: '连接超时' };
+ }
+ return { success: false, error: err.message };
+ }
+}
+
+// 获取 LLM 状态
+export function getLLMStatus() {
+ const config = getLLMConfig();
+
+ if (!config) {
+ return {
+ configured: false,
+ enabled: false,
+ model: null,
+ };
+ }
+
+ return {
+ configured: !!config.apiKey,
+ enabled: config.enabled && !!config.apiKey,
+ model: config.model || 'qwen-turbo',
+ baseUrl: config.baseUrl,
+ };
+}
diff --git a/src/server.js b/src/server.js
index e315e2f..5073fde 100644
--- a/src/server.js
+++ b/src/server.js
@@ -6,6 +6,7 @@ import * as cheerio from 'cheerio';
import iconv from 'iconv-lite';
import { sendReportEmail } from './emailService.js';
import { initScheduler, runTaskNow, reloadScheduler, getSchedulerStatus } from './scheduler.js';
+import { extractBudgetWithLLM, testLLMConnection, getLLMStatus, isLLMEnabled } from './llmService.js';
const app = express();
const PORT = process.env.PORT || 5000;
@@ -321,7 +322,7 @@ function parseDetail(html) {
};
}
-// 增强版parseDetail,支持PDF解析
+// 增强版parseDetail,支持PDF解析和LLM金额提取
async function parseDetailEnhanced(html, pageUrl) {
const $ = cheerio.load(html);
@@ -359,8 +360,25 @@ async function parseDetailEnhanced(html, pageUrl) {
content = htmlDetail.content;
}
- // 使用现有的extractBudget函数提取金额
- const budget = extractBudget(content);
+ // 提取金额:优先使用 LLM,失败则降级到正则表达式
+ let budget = null;
+ if (isLLMEnabled()) {
+ console.log('使用 LLM 提取金额...');
+ budget = await extractBudgetWithLLM(content);
+ if (budget) {
+ console.log(`LLM 提取成功: ${budget.amount} ${budget.unit}`);
+ } else {
+ console.log('LLM 提取失败,降级到正则表达式');
+ }
+ }
+
+ // 如果 LLM 未启用或提取失败,使用正则表达式
+ if (!budget) {
+ budget = extractBudget(content);
+ if (budget) {
+ budget.source = 'regex'; // 标记来源
+ }
+ }
// 获取其他基本信息(标题、发布时间等)
const basicInfo = parseDetail(html);
@@ -749,10 +767,13 @@ app.get('/api/config', async (req, res) => {
const configContent = readFileSync(configPath, 'utf-8');
const config = JSON.parse(configContent);
- // 不返回敏感信息(密码)
+ // 不返回敏感信息(密码和API Key)
if (config.email && config.email.smtpPass) {
config.email.smtpPass = '***已配置***';
}
+ if (config.llm && config.llm.apiKey) {
+ config.llm.apiKey = '***已配置***';
+ }
res.json({ success: true, data: config });
} catch (error) {
@@ -774,11 +795,18 @@ app.post('/api/config', async (req, res) => {
const newConfig = req.body;
+ // 读取旧配置以保留敏感信息
+ const oldConfigContent = readFileSync(configPath, 'utf-8');
+ const oldConfig = JSON.parse(oldConfigContent);
+
// 如果密码字段是占位符,保留原密码
if (newConfig.email && newConfig.email.smtpPass === '***已配置***') {
- const oldConfigContent = readFileSync(configPath, 'utf-8');
- const oldConfig = JSON.parse(oldConfigContent);
- newConfig.email.smtpPass = oldConfig.email.smtpPass;
+ newConfig.email.smtpPass = oldConfig.email?.smtpPass || '';
+ }
+
+ // 如果 LLM API Key 是占位符,保留原 API Key
+ if (newConfig.llm && newConfig.llm.apiKey === '***已配置***') {
+ newConfig.llm.apiKey = oldConfig.llm?.apiKey || '';
}
// 保存配置
@@ -793,6 +821,26 @@ app.post('/api/config', async (req, res) => {
}
});
+// LLM 状态接口
+app.get('/api/llm/status', async (req, res) => {
+ try {
+ const status = getLLMStatus();
+ res.json({ success: true, data: status });
+ } catch (error) {
+ res.status(500).json({ success: false, error: error.message });
+ }
+});
+
+// LLM 连接测试接口
+app.post('/api/llm/test', async (req, res) => {
+ try {
+ const result = await testLLMConnection();
+ res.json({ success: result.success, data: result });
+ } catch (error) {
+ res.status(500).json({ success: false, error: error.message });
+ }
+});
+
// 获取定时任务状态
app.get('/api/scheduler/status', async (req, res) => {
try {