diff --git a/apps/api/src/lead/lead.module.ts b/apps/api/src/lead/lead.module.ts index ba4101f..1a65a2b 100644 --- a/apps/api/src/lead/lead.module.ts +++ b/apps/api/src/lead/lead.module.ts @@ -4,13 +4,14 @@ import { IntegrationModule } from '../integration/integration.module' import { CrawlerModule } from '../crawler/crawler.module' import { ProductCategoryModule } from '../product-category/product-category.module' import { LeadService } from './lead.service' +import { MaterialExtractorService } from './material-extractor.service' import { LeadController } from './lead.controller' import { LeadNotesController } from './lead-notes.controller' @Module({ imports: [PrismaModule, IntegrationModule, CrawlerModule, ProductCategoryModule], controllers: [LeadController, LeadNotesController], - providers: [LeadService], + providers: [LeadService, MaterialExtractorService], exports: [LeadService], }) export class LeadModule {} diff --git a/apps/api/src/lead/lead.service.ts b/apps/api/src/lead/lead.service.ts index b599326..186ac57 100644 --- a/apps/api/src/lead/lead.service.ts +++ b/apps/api/src/lead/lead.service.ts @@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service' import { IntegrationService } from '../integration/integration.service' import { ProductCategoryService } from '../product-category/product-category.service' import { CategoryInferenceService } from '../product-category/category-inference.service' +import { MaterialExtractorService } from './material-extractor.service' @Injectable() export class LeadService { @@ -13,6 +14,7 @@ export class LeadService { private integrationService: IntegrationService, private categoryService: ProductCategoryService, private categoryInference: CategoryInferenceService, + private materialExtractor: MaterialExtractorService, ) {} async findAll(params?: { status?: string; level?: string; search?: string; star?: string; source?: string; category?: string; assignedTo?: string; timeRange?: string }) { @@ -308,6 +310,32 @@ export class LeadService { publishArgs.enterpriseName = `${b2bIntegration.name}公司` } + // 预校验: B2B 平台字段长度限制 (tb_inquiry.title varchar(50)) + // 在调 MCP 之前先报错,避免拿到通用的"服务器内部异常" + const TITLE_MAX_LEN = 50 + if (publishArgs.title && String(publishArgs.title).length > TITLE_MAX_LEN) { + const titleLen = String(publishArgs.title).length + throw new ServiceUnavailableException( + [ + `发布失败: 询价标题超过 B2B 平台限制(最多 ${TITLE_MAX_LEN} 字)。`, + `当前标题长度: ${titleLen} 字`, + `建议: ① 把标题精简到 ${TITLE_MAX_LEN} 字以内后再发布; ② 或联系 B2B 平台把 tb_inquiry.title 字段改为 varchar(200) 永久解决。`, + `线索标题: ${String(publishArgs.title).slice(0, 40)}${titleLen > 40 ? '...' : ''}`, + ].join('\n'), + ) + } + + // 实时提取物料清单(detailUrl 正文 → 标题 → 兜底),传给产业平台 items 字段 + try { + const items = await this.materialExtractor.extractItems(lead) + if (items.length > 0) { + publishArgs.items = items + this.logger.log(`[发布] 物料清单提取完成 leadId=${id} ${items.length} 行`) + } + } catch (e: any) { + this.logger.warn(`[发布] 物料清单提取失败(不阻断发布) leadId=${id}: ${e.message}`) + } + const result = await this.integrationService.executeTool( b2bIntegration.id, 'publish_rfq', @@ -331,9 +359,9 @@ export class LeadService { const payload = data.data || data const rfqId = payload.rfqId || payload.id || null if (!rfqId) { - throw new ServiceUnavailableException( - `发布失败: B2B 平台返回了响应但未包含询价 ID。响应: ${JSON.stringify(payload).slice(0, 200)}`, - ) + // 识别常见错误模式,给出明确提示 + const diagnosis = this.diagnosePublishError(lead, payload) + throw new ServiceUnavailableException(diagnosis) } const inquiryNo = payload.orderId || rfqId const direvUrl = `${b2bIntegration.endpoint}/inquiry/${rfqId}` @@ -443,4 +471,54 @@ export class LeadService { return [] } } + + /** + * 发布失败的诊断提示 — 把产业平台原始错误翻译成明确指引 + * 输入: lead(知道当前 title/quantity 等) + payload(产业平台响应) + * 输出: 给前端展示的友好错误信息 + */ + private diagnosePublishError( + lead: { + title?: string | null + quantity?: number | null + region?: string | null + deadline?: string | null + }, + payload: any, + ): string { + // 把 payload 序列化用于关键词匹配 + const raw = JSON.stringify(payload || {}).slice(0, 500) + const msg = String(payload?.msg || payload?.message || '') + + // 1. 标题超长(产业平台 tb_inquiry.title 是 varchar(50)) + if ( + /Data truncation|Data too long.*title|column 'title'.*too long/i.test(raw + ' ' + msg) + ) { + const titleLen = (lead.title || '').length + return [ + '发布失败: 询价标题超过 B2B 平台限制(最多 50 字)。', + `当前线索标题长度: ${titleLen} 字`, + `建议: ① 把标题精简到 50 字以内; ② 或联系 B2B 平台把 tb_inquiry.title 字段改为 varchar(200)。`, + `线索标题: ${(lead.title || '').slice(0, 30)}${titleLen > 30 ? '...' : ''}`, + ].join('\n') + } + + // 2. 数量字段异常 + if (/Data too long.*qty|column 'qty'|column 'quantity'/i.test(raw + ' ' + msg)) { + return `发布失败: 询价数量字段超过 B2B 平台限制。当前 quantity=${lead.quantity}。建议改成合理数字(≤1000)。` + } + + // 3. 企业/用户身份未通过 + if (/enterprise|user.*id|companyUserId|身份|权限/i.test(msg)) { + return `发布失败: B2B 平台校验企业身份未通过(${msg})。请检查运营管理 → 系统配置 → MCP 对接配置的连接状态(状态灯应为绿色"已连接")。` + } + + // 4. 必填字段缺失 + if (/required|不能为空|not.*null|缺失/i.test(msg)) { + return `发布失败: B2B 平台提示必填字段缺失(${msg})。线索当前数据:quantity=${lead.quantity ?? '空'} region=${lead.region || '空'} deadline=${lead.deadline || '空'}。请补充完整后再发布。` + } + + // 5. 默认:展示原始错误 + return `发布失败: B2B 平台返回了响应但未包含询价 ID。原因: ${msg || '未知错误'}。响应: ${raw}` + } } diff --git a/apps/api/src/lead/material-extractor.service.ts b/apps/api/src/lead/material-extractor.service.ts new file mode 100644 index 0000000..09e89c0 --- /dev/null +++ b/apps/api/src/lead/material-extractor.service.ts @@ -0,0 +1,280 @@ +import { Injectable, Logger } from '@nestjs/common' + +const MINIMAX_ENDPOINT = 'https://api.minimaxi.com/v1/chat/completions' +const MINIMAX_MODEL = process.env.MINIMAX_MODEL || 'MiniMax-M2.7' + +const DETAIL_FETCH_TIMEOUT_MS = 10_000 // detailUrl 正文抓取超时 +const TEXT_EXTRACT_TIMEOUT_MS = 30_000 // 正文物料提取超时 +const TITLE_EXTRACT_TIMEOUT_MS = 15_000 // 标题物资名提取超时 +const MAX_INPUT_CHARS = 4000 + +export interface MaterialItem { + productName: string + quantity: number + unit: string + specification?: string + brand?: string + note?: string +} + +const TEXT_SYSTEM_PROMPT = `你是招标公告物资清单提取助手。从以下公告内容中提取物资/物料明细。 + +输出格式:纯 JSON 数组,不要解释,不要 markdown 代码块,不要 标签。 +每项格式:{"productName":"物资名称","quantity":数字,"unit":"单位","specification":"规格型号"} + +规则: +- productName 必填,物资的具体名称(如:电力电缆、水泵、变压器) +- quantity 提取到的数量,没有就填 1 +- unit 计量单位(件/米/吨/台/批等),没有就填"批" +- specification 规格型号,没有就填空字符串 +- 最多输出 20 行 +- 如果内容里没有具体物资清单,返回 []` + +const TITLE_SYSTEM_PROMPT = `你是物资名称提取助手。从招标公告标题中提取涉及的物资名称。 + +输出格式:纯 JSON 数组,不要解释,不要 markdown 代码块,不要 标签。 +每项格式:{"productName":"物资名称"} + +规则: +- 只提取具体的物资/设备名称(如:油浸式电力变压器、电压互感器、水泵、逆变器) +- 去掉"采购""招标""项目""变更公告"等噪声词 +- 如果标题里有多个物资(用顿号/逗号分隔),每个都提取 +- 如果标题只提到一类物资,提取 1 条 +- 如果完全无法判断物资名称,返回 []` + +/** + * 物料清单提取服务 + * + * 发布到产业平台时实时提取,降级链: + * 1. 抓 detailUrl 正文 → AI 提取多行物料 + * 2. 抓不到 → AI 从标题提取物资名 (quantity=1, unit="批") + * 3. AI 也失败 → 用 lead.title 兜底 + */ +@Injectable() +export class MaterialExtractorService { + private readonly logger = new Logger(MaterialExtractorService.name) + + /** + * 主入口 - 从 Lead 提取物料清单 + * 全程降级,任何异常都返回至少 1 条兜底数据,不阻断发布 + */ + async extractItems(lead: { id: string; title: string; detailUrl?: string | null }): Promise { + const apiKey = process.env.MINIMAX_API_KEY + if (!apiKey) { + this.logger.warn(`[物料提取] MINIMAX_API_KEY 未配置,走标题兜底 leadId=${lead.id}`) + return this.fallbackWithTitle(lead.title) + } + + // 第 1 步:尝试抓 detailUrl 正文 + if (lead.detailUrl) { + try { + const text = await this.fetchDetailText(lead.detailUrl) + if (text.length > 100) { + this.logger.log(`[物料提取] 抓到正文 leadId=${lead.id} textLen=${text.length}`) + const items = await this.extractFromText(text, apiKey) + if (items.length > 0) { + this.logger.log(`[物料提取] 正文提取成功 leadId=${lead.id} ${items.length} 行`) + return items + } + this.logger.log(`[物料提取] 正文提取为空,降级标题 leadId=${lead.id}`) + } else { + this.logger.log(`[物料提取] 正文过短(${text.length}字),降级标题 leadId=${lead.id}`) + } + } catch (e: any) { + this.logger.warn(`[物料提取] 抓取正文失败 leadId=${lead.id}: ${e.message}`) + } + } + + // 第 2 步:从标题提取 + try { + const items = await this.extractFromTitle(lead.title, apiKey) + if (items.length > 0) { + this.logger.log(`[物料提取] 标题提取成功 leadId=${lead.id} ${items.length} 行`) + return items + } + } catch (e: any) { + this.logger.warn(`[物料提取] 标题提取失败 leadId=${lead.id}: ${e.message}`) + } + + // 第 3 步:兜底 + this.logger.log(`[物料提取] 走标题兜底 leadId=${lead.id}`) + return this.fallbackWithTitle(lead.title) + } + + /** 抓 detailUrl HTML 并转纯文本,失败返回空串 */ + private async fetchDetailText(detailUrl: string): Promise { + const r = await fetch(detailUrl, { + signal: AbortSignal.timeout(DETAIL_FETCH_TIMEOUT_MS), + headers: { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)' }, + }) + if (!r.ok) throw new Error(`HTTP ${r.status}`) + const html = await r.text() + return this.htmlToText(html) + } + + /** 从正文提取物料行 */ + private async extractFromText(text: string, apiKey: string): Promise { + const truncated = text.length > MAX_INPUT_CHARS + ? text.slice(0, MAX_INPUT_CHARS) + '\n[内容过长已截断]' + : text + + const content = await this.callMinimax( + TEXT_SYSTEM_PROMPT, + truncated, + apiKey, + TEXT_EXTRACT_TIMEOUT_MS, + 2000, + ) + return this.parseItems(content) + } + + /** 从标题提取物资名,自动补 quantity=1 unit=批 */ + private async extractFromTitle(title: string, apiKey: string): Promise { + const content = await this.callMinimax( + TITLE_SYSTEM_PROMPT, + title, + apiKey, + TITLE_EXTRACT_TIMEOUT_MS, + 800, + ) + + // 解析 [{productName: "xxx"}, ...] + const raw = this.extractJsonArray(content) + if (!raw) return [] + let parsed: any[] + try { + parsed = JSON.parse(raw) + } catch { + return [] + } + + return parsed + .filter((item: any) => item && typeof item.productName === 'string' && item.productName.trim()) + .map((item: any) => ({ + productName: String(item.productName).trim(), + quantity: 1, + unit: '批', + specification: '', + })) + } + + /** 调 MiniMax chat completion,返回 choices[0].message.content */ + private async callMinimax( + systemPrompt: string, + userContent: string, + apiKey: string, + timeoutMs: number, + maxTokens: number, + ): Promise { + const r = await fetch(MINIMAX_ENDPOINT, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: MINIMAX_MODEL, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userContent }, + ], + temperature: 0, + max_tokens: maxTokens, + }), + signal: AbortSignal.timeout(timeoutMs), + }) + + if (!r.ok) { + const errText = await r.text().catch(() => '') + throw new Error(`MiniMax API ${r.status}: ${errText.slice(0, 200)}`) + } + + const data = (await r.json()) as { choices?: Array<{ message?: { content?: string } }> } + const content = data.choices?.[0]?.message?.content || '' + if (!content) throw new Error(`MiniMax 返回空: ${JSON.stringify(data).slice(0, 200)}`) + return content + } + + /** 从 MiniMax 响应解析物料数组,带脏数据清洗 + 字段校验 */ + private parseItems(content: string): MaterialItem[] { + const jsonStr = this.extractJsonArray(content) + if (!jsonStr) return [] + + let parsed: any[] + try { + parsed = JSON.parse(jsonStr) + } catch { + return [] + } + if (!Array.isArray(parsed)) return [] + + const items: MaterialItem[] = [] + for (const raw of parsed) { + if (!raw || typeof raw !== 'object') continue + const productName = typeof raw.productName === 'string' ? raw.productName.trim() : '' + if (!productName) continue + + // quantity 必须是正数 + let quantity = 1 + if (typeof raw.quantity === 'number' && raw.quantity > 0) { + quantity = raw.quantity + } else if (typeof raw.quantity === 'string') { + const n = parseFloat(raw.quantity) + if (!isNaN(n) && n > 0) quantity = n + } + + const unit = typeof raw.unit === 'string' && raw.unit.trim() ? raw.unit.trim() : '批' + const specification = typeof raw.specification === 'string' ? raw.specification.trim() : '' + + items.push({ productName, quantity, unit, specification }) + if (items.length >= 20) break + } + return items + } + + /** + * 从模型输出中提取 JSON 数组字符串 + * 处理 标签、markdown 代码块、前后缀解释文字 + */ + private extractJsonArray(content: string): string | null { + // 1. 剥 ... 和 开放标签 + let s = content + .replace(/<\/?think>/g, '') + .replace(/\*\*/g, '') + .trim() + + // 2. 尝试从 markdown 代码块提取 ```json ... ``` 或 ``` ... ``` + const codeBlock = s.match(/```(?:json)?\s*([\s\S]*?)```/i) + if (codeBlock) s = codeBlock[1].trim() + + // 3. 提取第一个 [...] JSON 数组 + const match = s.match(/\[[\s\S]*\]/) + return match ? match[0] : null + } + + /** 兜底:用标题当物资名 */ + private fallbackWithTitle(title: string): MaterialItem[] { + return [{ productName: title || '未命名物资', quantity: 1, unit: '批', specification: '' }] + } + + /** HTML 转纯文本(复用 notes-extractor 的清洗逻辑) */ + private htmlToText(html: string): string { + return html + .replace(/)<[^<]*)*<\/script>/gi, ' ') + .replace(/)<[^<]*)*<\/style>/gi, ' ') + .replace(/)<[^<]*)*<\/noscript>/gi, ' ') + .replace(/)<[^<]*)*<\/head>/gi, ' ') + .replace(//g, ' ') + .replace(/<[^>]+>/g, '\n') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(Number(dec))) + .split('\n') + .map(l => l.trim()) + .filter(l => l.length > 0) + .join('\n') + } +}