用 NLP 引擎自动检测招标文件缺失参数,5 分钟生成月报:投标业务流自动化的工程实现
做投标的人每月有两件烦心事:一是招标文件里关键参数经常写不全,技术部没法干活,销售和技术来回扯皮;二是月底要花时间统计工作量、写月报。
我们在 OpenCheck 里做了两个引擎来解决这两个问题——NLP 参数探测引擎和月报自动生成引擎。这篇文章讲讲技术实现。
NLP 参数探测引擎
核心问题
招标文件里的技术参数经常缺失。以防护服采购为例:面料纱支数、反光带型号、导电纤维占比、内胆材质——采购人经常不写或者写得很含糊。技术部拿到招标文件,参数不全没法算成本,只能打回来让销售去问。
我们需要一个引擎,扫完招标文件后自动列出"哪些关键参数缺失了"。
行业规则校验集
不同行业需要检查的参数不同。我们把规则按行业组织成规则集,每个规则定义了参数名称、提取正则、校验规则和缺失时的提示信息:
{"industry":"construction","rules":[{"param_name":"qualification_level","param_type":"required","extraction_pattern":"(?:资质等级|资质要求)[::]\\s*(.+?)(?:\\n|$)","validation_rule":"(?:一级|二级|三级|甲级|乙级|丙级)","missing_msg":"未找到资质等级要求,可能导致投标无效"},{"param_name":"bid_bond_amount","param_type":"required","extraction_pattern":"(?:投标保证金|投标担保)[::]?\\s*(\\d+(?:\\.\\d+)?)\\s*(?:万元|元)","validation_rule":"\\d+(?:\\.\\d+)?","missing_msg":"未找到投标保证金金额要求"},{"param_name":"bid_deadline","param_type":"required","extraction_pattern":"(?:投标截止|递交截止)[::]?\\s*(\\d{4}[-年]\\d{1,2}[-月]\\d{1,2}日?)","missing_msg":"未找到投标截止时间"}]}规则集存在数据库里,按行业分类,支持版本管理。加载器带缓存,同一个行业的规则集只从数据库读一次:
classRuleSetLoader{privatecache:Map<string,RuleSet>=newMap();asyncload(industry:string):Promise<RuleSet>{if(this.cache.has(industry)){returnthis.cache.get(industry)!;}construleSet=awaitprisma.industryRuleSet.findFirst({where:{industry,is_active:true},orderBy:{version:'desc'}});if(!ruleSet){returnthis.loadDefault();// 回退到默认规则集}constparsed:RuleSet={industry:ruleSet.industry,rules:JSON.parse(ruleSet.rules_config),version:ruleSet.version,updated_at:ruleSet.updated_at.toISOString()};this.cache.set(industry,parsed);returnparsed;}}检测流程
检测引擎加载规则集后,逐条规则在招标文件全文上做正则提取:
classParameterDetectionEngine{privateruleLoader:RuleSetLoader;asyncdetect(assessment:Assessment):Promise<DetectionResult>{construleSet=awaitthis.ruleLoader.load(assessment.industry);constpdfChunks=awaitthis.parsePDF(assessment.fileUrl);constfullText=pdfChunks.map(c=>c.text).join('\n');constresults:ParameterCheckResult[]=[];for(construleofruleSet.rules){constregex=newRegExp(rule.extraction_pattern,'g');constmatches=fullText.match(regex);// 参数不存在 → 标记缺失if(!matches||matches.length===0){results.push({param_name:rule.param_name,status:'missing',message:rule.missing_msg,severity:rule.param_type==='required'?'critical':'warning'});continue;}// 参数存在但值不合法 → 标记无效if(rule.validation_rule){constisValid=newRegExp(rule.validation_rule).test(matches[0]);if(!isValid){results.push({param_name:rule.param_name,status:'invalid',value:matches[0],message:`参数值不符合要求:${matches[0]}`,severity:'critical'});continue;}}// 通过检查results.push({param_name:rule.param_name,status:'valid',value:matches[0]});}returnthis.generateReport(results);}}检测结果分三种状态:valid(参数存在且合法)、missing(参数缺失)、invalid(参数存在但值不合法)。每种状态有不同的处理逻辑。
风险分数计算
检测报告里包含一个 0-100 的风险分数,计算逻辑很简单:
privatecalculateRiskScore(results:ParameterCheckResult[]):number{letscore=100;for(constrofresults){if(r.status==='missing'&&r.severity==='critical'){score-=20;// 必填参数缺失扣 20 分}elseif(r.status==='missing'&&r.severity==='warning'){score-=5;// 选填参数缺失扣 5 分}elseif(r.status==='invalid'){score-=15;// 参数无效扣 15 分}}returnMath.max(0,score);}分数低于 60 的项目会触发预警,提示投标人员重点关注。
数据聚合引擎
多源数据统一接口
投标数据散落在多个地方——数据库里的评估记录、API 返回的统计数据、手动录入的补充信息。聚合引擎的作用是把不同来源的数据统一成同一个格式:
classBidDataAggregatorimplementsDataAggregator{asyncaggregate(sources:DataSource[]):Promise<AggregatedData>{// 并行拉取所有数据源constresults=awaitPromise.allSettled(sources.map(source=>this.fetchSource(source)));constsuccessful=results.filter((r):risPromiseFulfilledResult<any>=>r.status==='fulfilled').map(r=>r.value);constmerged=this.mergeData(successful);conststatistics=this.calculateStatistics(merged);consttrends=this.generateTrends(merged);return{projects:merged,statistics,trends,metadata:{...}};}}数据合并时有一个需要注意的点:同一个项目可能在多个数据源中出现。合并逻辑是以项目 ID 为 key,重复的项目保留updated_at最新的那条:
privatemergeData(datasets:any[]):ProjectSummary[]{constprojectMap=newMap<string,ProjectSummary>();for(constdatasetofdatasets){for(constprojectofdataset){constexisting=projectMap.get(project.id);if(existing){projectMap.set(project.id,{...existing,...project,updated_at:Math.max(newDate(existing.updated_at).getTime(),newDate(project.updated_at).getTime())});}else{projectMap.set(project.id,project);}}}returnArray.from(projectMap.values());}实时数据推送
前端看板需要实时反映项目状态变化。我们用 WebSocket 做增量推送:
classRealTimeDataStream{privateclients:Set<WebSocket>=newSet();subscribe(client:WebSocket):void{this.clients.add(client);client.on('close',()=>this.clients.delete(client));}broadcast(data:DataUpdate):void{constmessage=JSON.stringify({type:'data_update',payload:data,timestamp:Date.now()});for(constclientofthis.clients){if(client.readyState===WebSocket.OPEN){client.send(message);}}}// 增量更新检测:对比新旧数据,只推送变化的部分detectChanges(oldData:any[],newData:any[]):DataUpdate[]{constchanges:DataUpdate[]=[];for(constnewItemofnewData){constoldItem=oldData.find(o=>o.id===newItem.id);if(!oldItem){changes.push({action:'create',data:newItem});}elseif(JSON.stringify(oldItem)!==JSON.stringify(newItem)){changes.push({action:'update',data:newItem,old:oldItem});}}for(constoldItemofoldData){if(!newData.find(n=>n.id===oldItem.id)){changes.push({action:'delete',data:oldItem});}}returnchanges;}}detectChanges做的是简单的新旧对比——新增、更新、删除三种变更类型。用JSON.stringify做深度比较在小数据量下够用,数据量大了可以换成结构化 diff。
月报自动生成
报告生成流程
月报生成器做的事情是:拉取聚合数据 → 按模板生成各章节 → 渲染图表 → 组装报告 → 存储。
classMonthlyReportGenerator{asyncgenerate(month:string):Promise<Report>{// 1. 拉取聚合数据constdata=awaitthis.dataAggregator.aggregate([{type:'database',endpoint:'assessments',schema:assessmentSchema},{type:'database',endpoint:'behavior_logs',schema:behaviorSchema},{type:'api',endpoint:'/api/stats/monthly',schema:statsSchema}]);// 2. 并行生成各章节constsections=awaitPromise.all(this.template.sections.map(section=>this.generateSection(section,data)));// 3. 并行生成图表constcharts=awaitPromise.all(this.template.charts.map(chart=>this.generateChart(chart,data)));// 4. 组装报告constreport:Report={id:`monthly-${month}`,title:`${month}月投标分析报告`,generated_at:newDate().toISOString(),sections,charts,summary:this.generateSummary(data)};awaitthis.storeReport(report);returnreport;}}摘要生成逻辑:
privategenerateSummary(data:AggregatedData):ReportSummary{const{statistics,trends}=data;return{total_projects:statistics.total_assessments,success_rate:statistics.win_rate,avg_score:statistics.average_score,key_findings:[`本月分析${statistics.total_assessments}个项目`,`中标率${statistics.win_rate}%`,`平均风险分数${statistics.average_score}`,trends[0]?.description||'暂无趋势数据'],recommendations:this.generateRecommendations(statistics)};}图表渲染
图表用 node-canvas 做服务端渲染,生成 PNG 图片嵌入报告:
classChartRenderer{asyncrenderPieChart(config:PieChartConfig):Promise<Buffer>{constcanvas=createCanvas(config.width,config.height);constctx=canvas.getContext('2d');consttotal=config.data.reduce((sum,item)=>sum+item.value,0);letstartAngle=-Math.PI/2;config.data.forEach((item,index)=>{constsliceAngle=(item.value/total)*2*Math.PI;ctx.beginPath();ctx.moveTo(config.centerX,config.centerY);ctx.arc(config.centerX,config.centerY,config.radius,startAngle,startAngle+sliceAngle);ctx.closePath();ctx.fillStyle=item.color||config.colors[index%config.colors.length];ctx.fill();startAngle+=sliceAngle;});returncanvas.toBuffer('image/png');}}柱状图的实现类似,就是算好每个柱子的 x 坐标和高度,用fillRect画出来,再标注标签。
问题清单自动生成
参数探测引擎检测出缺失参数后,系统会自动生成一份"电话采购人问题清单"。清单不只是列出问题,还会按严重程度排序,并给出建议的处理方式:
classProblemChecklistGenerator{asyncgenerate(assessment:Assessment):Promise<ProblemChecklist>{constproblems:Problem[]=[];// 四类检查并行const[disqRisks,missingParams,formatIssues,complianceIssues]=awaitPromise.all([this.checkDisqualificationRisks(assessment),this.checkMissingParameters(assessment),this.checkFormatIssues(assessment),this.checkCompliance(assessment)]);problems.push(...disqRisks,...missingParams,...formatIssues,...complianceIssues);// 按严重程度 + 影响范围排序constsortedProblems=this.prioritizeProblems(problems);return{assessment_id:assessment.id,total_problems:sortedProblems.length,critical_count:sortedProblems.filter(p=>p.severity==='critical').length,problems:sortedProblems,generated_at:newDate().toISOString()};}privateprioritizeProblems(problems:Problem[]):Problem[]{constseverityOrder={critical:0,warning:1,info:2};returnproblems.sort((a,b)=>{constseverityDiff=severityOrder[a.severity]-severityOrder[b.severity];if(severityDiff!==0)returnseverityDiff;returnb.impact_score-a.impact_score;});}}销售拿到这份清单,直接打电话给采购人,按清单上的问题逐个确认。不用再自己猜该问什么,也不用技术部反复打回。
效率对比
| 指标 | 传统人工 | OpenCheck |
|---|---|---|
| 参数检测时间 | 4 小时 | 3 分钟 |
| 月报生成时间 | 4 小时/月 | 5 分钟 |
| 问题清单生成 | 2 小时 | 10 秒 |
| 错误率 | 5-10% | <0.1% |
数据来源:防静电工作服项目实测。
踩坑记录
坑1:正则提取的误匹配。招标文件里经常出现"投标保证金不低于XX万元"这种表述,正则会匹配到"不低于XX万元"而不是具体金额。解法是在正则中加入负向前瞻,排除"不低于""不超过"等修饰词。
坑2:多数据源的项目 ID 冲突。同一个项目在不同系统中 ID 不同。解法是维护一个项目 ID 映射表,入库前先做 ID 归一化。
坑3:图表渲染的中文乱码。node-canvas 默认不支持中文字体,渲染出来全是方块。解法是在 Docker 镜像中安装中文字体(fonts-wqy-zenhei),并在 canvas 创建时指定字体。
坑4:WebSocket 连接泄漏。客户端异常断开时close事件不触发,连接一直留在 Set 里。解法是加心跳检测,超过 30 秒无响应主动断开。
小结
投标业务流自动化的核心是两件事:用 NLP 引擎把招标文件里的参数结构化,用数据聚合引擎把分散的项目数据统一起来。前者解决了"参数缺失导致内部扯皮"的问题,后者解决了"月报耗时"的问题。
技术上都是常规操作——正则提取、JSONB 规则配置、WebSocket 推送、canvas 图表渲染。难点不在技术本身,在于对招投标业务场景的理解:哪些参数是必须检查的,风险分数怎么算才合理,问题清单怎么排优先级。
#NLP #招投标 #数据聚合 #自动化 #TypeScript #Node.js #WebSocket #工程实践