feat(analysis): 招标文件解析增加投标要素抽取(落 bid_tenders source=tender_doc)

- EXTRACT_PROMPT 增加 tender_facts(项目名/招标人/编号/投标时间/周期/金额/保证金/资质/关键节点/风险)
- extract_from_file 抽取后 _upsert_tender_facts 幂等落库,复用 bid_tenders 承载商务要素
- 合成标书 get_tender_by_project 链路无需改动即可拿到 title/tender_no
- 强化评分项+得分规则抽取要求:逐条覆盖、保留档位/扣分/加分细则、否决项 is_veto=1
This commit is contained in:
ymq 2026-08-27 13:28:15 +08:00
parent 7ceedefeb7
commit 4943a04aac

View File

@ -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_tenderssource=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