From 4943a04aacabe65facbbeb1e28d3c788908bd025 Mon Sep 17 00:00:00 2001 From: ymq Date: Thu, 27 Aug 2026 13:28:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(analysis):=20=E6=8B=9B=E6=A0=87=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E8=A7=A3=E6=9E=90=E5=A2=9E=E5=8A=A0=E6=8A=95=E6=A0=87?= =?UTF-8?q?=E8=A6=81=E7=B4=A0=E6=8A=BD=E5=8F=96(=E8=90=BD=20bid=5Ftenders?= =?UTF-8?q?=20source=3Dtender=5Fdoc)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EXTRACT_PROMPT 增加 tender_facts(项目名/招标人/编号/投标时间/周期/金额/保证金/资质/关键节点/风险) - extract_from_file 抽取后 _upsert_tender_facts 幂等落库,复用 bid_tenders 承载商务要素 - 合成标书 get_tender_by_project 链路无需改动即可拿到 title/tender_no - 强化评分项+得分规则抽取要求:逐条覆盖、保留档位/扣分/加分细则、否决项 is_veto=1 --- pipeline_bidding/bid_analysis_capability.py | 89 +++++++++++++++++++-- 1 file changed, 84 insertions(+), 5 deletions(-) diff --git a/pipeline_bidding/bid_analysis_capability.py b/pipeline_bidding/bid_analysis_capability.py index 1af90a7..fb0c1b5 100644 --- a/pipeline_bidding/bid_analysis_capability.py +++ b/pipeline_bidding/bid_analysis_capability.py @@ -9,18 +9,33 @@ import logging from .bid_common import ( get_db, new_id, rec_to_dict, rows_to_dicts, to_float, to_int, json_loads, - llm_json, record, resolve_project_id, CH_PENDING, + llm_json, record, resolve_project_id, CH_PENDING, TD_SUMMARIZED, ) logger = logging.getLogger("pipeline.bidding.analysis") -EXTRACT_PROMPT = """你是招标文件分析师。下面是招标文件正文(可能被截断),请抽取三类结构化信息。 +EXTRACT_PROMPT = """你是招标文件分析师。下面是招标文件正文(可能被截断),请抽取结构化信息。 【招标文件正文】 __CONTENT__ 严格只输出一个 JSON 对象: { + "tender_facts": { + "title": "项目名称", + "purchaser": "招标人/采购人单位名称", + "tender_no": "招标编号/项目编号", + "industry": "所属行业(可选)", + "summary": "项目概况摘要,200字以内,说明采购标的、范围、主要要求", + "bid_start_at": "获取招标文件/投标开始时间 YYYY-MM-DD(可选)", + "bid_deadline": "投标截止时间 YYYY-MM-DD", + "project_period": "项目周期/工期,如 12个月 / 180日历天", + "budget_amount": "招标控制价/预算金额(仅数字,人民币元)", + "deposit_amount": "投标保证金(仅数字,人民币元)", + "qualification_req": "投标人资格/资质要求原文摘要", + "key_dates": [{"name": "节点名称", "date": "YYYY-MM-DD"}], + "risk_note": "风险提示:时间紧、资质缺口、否决项等;无则空" + }, "scoring_items": [ {"section": "business|technical|price", "item_no": "评分项编号", "item_name": "评分项名称", "max_score": 数字满分, "scoring_rule": "得分规则原文(含档位/扣分/加分细则)", @@ -37,9 +52,11 @@ __CONTENT__ ] } 要求: -1. scoring_items 必须逐条覆盖评标办法中的每个评分项,满分数字准确;否决项/废标条款 is_veto=1。 -2. doc_requirements 中 req_type=structure 的条目就是投标文件应有的章节结构,按招标文件要求的顺序给出。 -3. 只抽取正文中真实存在的内容,缺失就不要输出该条,禁止编造。""" +1. tender_facts 是招标文件的核心商务要素(项目名称/招标人/招标编号/投标截止时间/项目周期/预算金额/保证金/资质门槛),必须优先准确抽取。 +2. scoring_items 必须逐条覆盖评标办法中的每个评分项,满分数字准确;得分规则要保留原文档位/扣分/加分细则;否决项/废标条款 is_veto=1。 +3. doc_requirements 中 req_type=structure 的条目就是投标文件应有的章节结构,按招标文件要求的顺序给出。 +4. 只抽取正文中真实存在的内容,缺失就不要输出该字段/该条,禁止编造。 +金额只填数字(如 1200000),不带单位和千分位;日期只填 YYYY-MM-DD。""" # ══════════════ 招标文件读取 ══════════════ @@ -245,6 +262,66 @@ async def add_doc_requirement(project_id, requirement, req_type="structure", return True, "投标文件要求已录入: %s %s" % (req_type, chapter_title or "") +async def _upsert_tender_facts(project_id, facts, who=None, agent_id=None): + """把投标要素写入 bid_tenders(source=tender_doc,复用原招标信息表承载商务要素)。 + + 无项目关联记录则新建,有则更新要素字段(幂等,可随文件重复解析覆盖)。 + """ + if not facts or not isinstance(facts, dict): + return + str_fields = ("title", "purchaser", "tender_no", "industry", "summary", + "bid_start_at", "bid_deadline", "project_period", + "qualification_req", "risk_note") + num_fields = ("budget_amount", "deposit_amount") + data = {} + for k in str_fields: + v = (facts.get(k) or "").strip() + if v: + data[k] = v[:250] if k in ("title", "purchaser", "tender_no", "industry", + "bid_start_at", "bid_deadline", "project_period") else v[:4000] + for k in num_fields: + v = facts.get(k) + if v not in (None, "", "无"): + amt = to_float(str(v).replace(",", "").replace("元", "").strip(), 0.0) + if amt > 0: + data[k] = amt + kd = facts.get("key_dates") + if kd: + data["key_dates"] = json.dumps(kd, ensure_ascii=False)[:4000] + if not data: + return + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + recs = await sor.sqlExe( + "SELECT id FROM bid_tenders WHERE project_id=${pid}$ " + "ORDER BY created_at DESC LIMIT 1", {"pid": project_id}) + await sor.sqlExe("COMMIT", {}) + if recs: + tid = getattr(recs[0], "id", "") + sets = ", ".join("%s=${%s}$" % (k, k) for k in data) + p = dict(data) + p["tid"] = tid + await sor.sqlExe( + "UPDATE bid_tenders SET " + sets + ", updated_at=NOW() WHERE id=${tid}$", p) + await sor.sqlExe("COMMIT", {}) + await record(sor, project_id, "bid_tenders", tid, "extract_facts", + who=who, agent_id=agent_id, + detail="更新要素: %s" % ", ".join(sorted(data))) + else: + tid = new_id() + data["id"] = tid + data["project_id"] = project_id + data["org_id"] = "0" + data["source"] = "tender_doc" + data["status"] = TD_SUMMARIZED + data["created_by"] = who or "" + await sor.C("bid_tenders", data) + await sor.sqlExe("COMMIT", {}) + await record(sor, project_id, "bid_tenders", tid, "extract_facts", + who=who, agent_id=agent_id, + detail="从招标文件抽取要素: %s" % ", ".join(sorted(data))) + + async def extract_from_file(project_id, file_id="", model_name="", offset=0, length=30000, who=None, agent_id=None): """LLM 一次性抽取三类信息并批量落库(评分项/资质/投标文件要求)。 @@ -287,6 +364,8 @@ async def extract_from_file(project_id, file_id="", model_name="", offset=0, if data is None: return False, "LLM 抽取失败: %s" % err cnt = {"scoring_items": 0, "qualifications": 0, "doc_requirements": 0} + await _upsert_tender_facts(project_id, data.get("tender_facts"), + who=who, agent_id=agent_id) for it in (data.get("scoring_items") or []): if not isinstance(it, dict) or not it.get("item_name"): continue