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 服务状态

+
+
+
服务状态
+
加载中...
+
+
+
当前模型
+
-
+
+
+
+ + +
+
+ + +
+
+ +
+ + + + 点击这里获取 API Key + +
+ +
+ + +
+ +
+ + +
+ + + + + +
+ +
+

功能说明

+ +
+ +
+

模型对比

+ + + + + + + + + + + + + + + + + + + + + + + + + +
模型速度准确度成本
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 {