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:
parent
7ceedefeb7
commit
4943a04aac
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user