"投标人员的时间守恒定律:格式调整耗时 = 插入图片数 × 标书页数/10" ⏰在企业的日常经营中,经过投标判断助手确定标底后,就来到了让无数投标人闻风丧胆的标书撰写阶段。以算力领域为例,一份标书通常包含:
📑 10+个章节
📝 5万字+文本
🖼️ 100+张资质图片
😫 1位濒临崩溃的投标人员
📖 前言:当算力遇上格式强迫症
格式的执念:虽然算力招标对技术参数允许合理波动,但对标书格式却有着"像素级"要求。不同招标单位的格式模板差异,足以让最淡定的投标人抓狂。
图片的迷宫:如何在保证技术准确性的同时,把企业资质图片精准插入到标书指定位置?这不仅是技术问题,更是艺术!
📥 输入输出样例
graph LR A[招标文件] –> B[智能解析] B –> C[自动提取附件] C –> D[精准插入图片] D –> E[完美格式标书]
* *
🎬 产品演示:眼见为实
温馨提示:观看时请勿羡慕到流泪 😭
* *
🤖 Agent搭建全解析
🎯知识库:AI的"记忆宫殿"
数据是LLM的血液,没有数据的Agent就像没有汽油的超跑 —— 只能看不能开
我们采用"分治策略"管理企业资质:
将营业执照、纳税证明等图片存入docx
按材料类型分段存储,确保检索时能返回完整图片集
在dify平台通过URL分发图片资源

知识库:这里存放着价值百万的"数字房产证"🏠
💡工作流:标书生成的"流水线"

温馨提示:本流程已通过ISO-9001"防加班"认证 🕒
* *
🛠️ 前处理三剑客
DOCX标题提取器 – 专业的"目录生成师"
import requestsimport tempfileimport osimport refrom collections.abc import Generatorfrom typing import Any, Dict, List, Optionalfrom io import BytesIOfrom docx import Documentfrom difyplugin import Toolfrom difyplugin.entities.tool import ToolInvokeMessageclass DocxTitleTool(Tool):def invoke(self, toolparameters: dict[str, Any]) -> Generator[ToolInvokeMessage]:"""提取DOCX文件中的标题Args:toolparameters: 包含docx文件和titlelevel参数"""# 获取参数uploadedfile = toolparameters.get('docx')titlelevel = toolparameters.get('titlelevel', 1)if not uploadedfile:yield self.createtextmessage("请上传DOCX文件")returntry:titlelevel = int(titlelevel)if titlelevel < 1 or titlelevel > 9:yield self.createtextmessage("标题级别必须在1-9之间")returnexcept (ValueError, TypeError):yield self.createtextmessage("标题级别必须是有效的数字")returntry:# 下载文件fileurl = "http://api:5001" + uploadedfile.urlresponse = requests.get(fileurl)if response.statuscode != 200:yield self.createtextmessage(f"文件下载失败,状态码: {response.statuscode}")return# 创建临时文件保存DOCX内容with tempfile.NamedTemporaryFile(suffix='.docx', delete=False) as tempfile:tempfile.write(response.content)tempfilepath = tempfile.nametry:# 提取标题headingsresult = self.extractheadingsbylevel(tempfilepath, titlelevel)if not headingsresult:yield self.createtextmessage("未在文档中找到指定级别的标题")return# 返回结果yield self.createjsonmessage({"result": headingsresult,"summary": f"成功提取了{titlelevel}级及以上的标题"})finally:# 清理临时文件if os.path.exists(tempfilepath):os.unlink(tempfilepath)except Exception as e:yield self.createtextmessage(f"处理文件时发生错误: {str(e)}")def getheadinglevel(self, paragraph) -> Optional[int]:"""获取段落的标题级别Args:paragraph: 文档段落对象Returns:int: 标题级别 (1-9),如果不是标题则返回None"""# 方法1: 通过样式名称判断stylename = paragraph.style.name.lower()# 检查标准的标题样式if 'heading' in stylename:# 提取数字,如 'Heading 1' -> 1match = re.search(r'headings*(d+)', stylename)if match:return int(match.group(1))# 检查中文标题样式if '标题' in stylename:match = re.search(r'标题s*(d+)', stylename)if match:return int(match.group(1))# 方法2: 通过outlinelevel属性判断if hasattr(paragraph, 'p') and hasattr(paragraph.p, 'pPr'):pPr = paragraph.p.pPrif pPr is not None and hasattr(pPr, 'outlineLvlval'):outlinelevel = pPr.outlineLvlvalif outlinelevel is not None:return outlinelevel + 1 # outlinelevel是0-based,转换为1-based# 方法3: 通过样式的outlinelevel属性try:if hasattr(paragraph.style, 'element') and hasattr(paragraph.style.element, 'pPr'):pPr = paragraph.style.element.pPrif pPr is not None:outlinelvl = pPr.find('.//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}outlineLvl')if outlinelvl is not None:val = outlinelvl.get('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val')if val is not None:return int(val) + 1except:passreturn Nonedef extractheadingsbylevel(self, filepath: str, maxlevel: int) -> List[Dict[str, List[str]]]:"""提取指定级别的标题Args:filepath: DOCX文件路径maxlevel: 最大标题级别Returns:List[Dict[str, List[str]]]: 按用户要求格式返回的标题列表"""try:# 加载文档doc = Document(filepath)# 按级别收集标题headingsbylevel = {}for level in range(1, maxlevel + 1):headingsbylevel[level] = []# 遍历所有段落for paragraph in doc.paragraphs:# 跳过空段落if not paragraph.text.strip():continue# 获取标题级别headinglevel = self.getheadinglevel(paragraph)if headinglevel is not None and headinglevel <= maxlevel:headingsbylevel[headinglevel].append(paragraph.text.strip())# 转换为用户要求的格式result = []levelnames = {1: "一级标题",2: "二级标题",3: "三级标题",4: "四级标题",5: "五级标题",6: "六级标题",7: "七级标题",8: "八级标题",9: "九级标题"}for level in range(1, maxlevel + 1):if headingsbylevel[level]: # 只添加有内容的级别levelname = levelnames.get(level, f"{level}级标题")result.append({levelname: headingsby_level[level]})return resultexcept Exception as e:raise Exception(f"提取标题时发生错误: {str(e)}")
招标文件分析 – LLM的"格式侦探"
你是一个招标文件分析助手,在招投标领域,乙方投标文件,必须按照甲方招标文件中规定的格式进行撰写#输入是招标文件的章节标题,请判断对于投标文件的格式要求,所在的章节,然后返回开始章节标题,和结束章节标题#符合条件的章节,一般包含有"附件"字样,即该章,附带了许多格式化的表格,投标人照此写标书#如果没有结束章节标题,则置为空参考输出格式:{"开始标题":”XXX“,”结束标题“:"XXX"}{{#投标文件的章节名称.result#}}/no_think经过LLM后,去掉输出结果的think标签import re,jsonfrom typing import Listimport jsondef main(arg1): tmp = re.sub(r"<think>[sS]*?</think>", "", arg1, flags=re.DOTALL) tmp = re.sub(r'^s*“(?:json)?s*n?', '', tmp,flags=re.IGNORECASE) tmp = re.sub(r'n?`s*$', '', tmp) tmp = tmp.replace(r'n', 'n').replace(r'"', '"').strip() result=tmp return { 'result':result }
DOCX章节提取器 – 精准的"文档外科医生",代码逻辑如下:
===== 数据结构定义 =====定义 ElementInfo: 元素类型: 'paragraph' 或 'table' XML内容: 字符串 文本内容: 字符串 (用于预览) 索引: 整数 分区格式: 字典 (可选)定义 SectionInfo: 标题: 字符串 元素列表: [ElementInfo]定义 PageFormatInfo: 页面宽度, 高度, 方向, 四周边距: 整数/字符串定义 DocumentExtractResult: 章节列表: [SectionInfo] 页面格式: PageFormatInfo 源文件路径: 字符串 元素总数: 整数 抽取信息: 字典 分区格式列表: [字典] (可选) 样式XML: 字符串 (可选) 编号XML: 字符串 (可选)# ===== 文档解析器类 =====类 DocxExtractor: 初始化(文档路径): 加载DOCX文档 获取页面格式(章节索引=0): 尝试从文档获取页面设置 失败则返回默认A4格式 查找单章节(开始标题, 标题级别=1, 结束标题=None): 预处理标题(移除空格) 收集所有文档元素(段落+表格) 遍历元素: – 发现匹配的开始标题 → 创建新章节 – 继续收集元素直到: a) 遇到结束标题 b) 遇到同级标题(无结束标题时) 返回找到的章节列表 抽取单章节(开始标题, 标题级别=1, 结束标题=None): 调用查找单章节 收集分区格式信息 确定最终页面格式: 优先使用提取的分区格式 否则调用获取页面格式 构建DocumentExtractResult对象 返回结果# ===== 插件工具类 =====类 DocxExtractorTool (继承Tool): 执行入口(参数): 验证必要参数(文档文件, 开始标题) 下载DOCX文件 → 保存为临时文件 创建DocxExtractor实例 调用抽取单章节方法 处理结果: 添加成功元数据 转换为JSON格式 生成输出文件 异常处理: 值错误/通用错误 → 生成错误JSON文件 返回处理结果(文件blob或错误信息)
🔄 迭代环节:图片与文字的"相亲大会"
遍历所有xml数据,查找对应的图片
// XML数据结构:标书的DNA{ "elementtype": "paragraph", "xmlcontent": "<w:p>7-2纳税证明</w:p>", "textcontent": "7-2纳税证明", "index": 642}智能匹配流程:a.文字:"7-2纳税证明"发出相亲请求b.知识库:匹配到纳税证明图片集c.LLM媒人:生成"结婚证"(XML数据)d.输出:文字+图片的完美组合 大模型将图片转换为xml的prompt:你是一个json数据处理专家,请将数据A,按照参考格式,进行构造,然后返回一个新的json数据#如果待处理数据只有文字,则生成elementtype为paragraph。如果待处理数据还有图像,则生成elementtype为picture。#index字段,与B保持一致,举例B为100,则输出的所有index都是100数据A:{{检索到的图片url.result#}}数据B:{{#当前数据索引#}}如果只有文字,输出格式参考 { "elementtype": "paragraph", "xmlcontent": "xxxx", "textcontent": "xxx", "index": xxx, "sectionformat": null }如果是图像,输出格式参考{ "elementtype": "picture", "image_path": "XXX, "index": XXX}
🎉 后处理:标书诞生的"高光时刻"
函数 assembledocument(输入: 抽取结果, 输出路径, 是否清理分区属性):`尝试:创建临时目录和DOCX基础结构初始化XML内容列表添加DOCX文档头部到XML列表收集所有元素并按原始索引排序遍历每个元素:如果元素类型是图片:从Docker获取图片数据生成图片XML并保存到媒体目录将图片XML添加到内容列表如果元素类型是段落或表格:获取元素XML内容根据标志决定是否清理分区属性将处理后的XML添加到内容列表添加DOCX文档尾部到XML列表将完整XML写入document.xml文件创建辅助文件:[ContentTypes].xml主关系文件(.rels)文档关系文件(document.xml.rels)样式文件(styles.xml)编号文件(numbering.xml)将临时目录打包为DOCX文件输出处理统计信息异常处理:抛出错误信息最终:清理临时目录
使用xml标签,确保了生成标书的格式的正确性,而且自动插入图片,节省了投标人员一部分时间。

