pipeline-bidding/pipeline_bidding/bid_analysis_capability.py

537 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""招标文件解析能力:抽取评分项+得分规则、所需资质、投标文件要求,生成章节骨架。
对应业务:招标文件获得后,从招标文件中抽取评分项以及得分规则、所需资质、投标文件要求。
章节骨架规则(写进 bid-doc-spec 技能):章节子项直接来自评分标准原文枚举的子维度,不是通用目录。
"""
import json
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, TD_SUMMARIZED,
)
logger = logging.getLogger("pipeline.bidding.analysis")
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": "得分规则原文(含档位/扣分/加分细则)",
"is_veto": "0或1(是否否决项/废标项)", "source_ref": "出处,如 第三章 评标办法 表1"}
],
"qualifications": [
{"qual_name": "资质名称", "requirement": "要求原文", "is_mandatory": "0或1"}
],
"doc_requirements": [
{"req_type": "structure|format|seal|copies|deadline|other",
"chapter_no": "章节号(structure 类必填,如 1 / 1.1)",
"chapter_title": "章节标题(structure 类必填)",
"requirement": "要求内容", "page_limit": 数字或null, "source_ref": "出处"}
]
}
要求:
1. tender_facts 是招标文件的核心商务要素(项目名称/招标人/招标编号/投标截止时间/项目周期/预算金额/保证金/资质门槛),必须优先准确抽取。
2. scoring_items 必须逐条覆盖评标办法中的每个评分项,满分数字准确;得分规则要保留原文档位/扣分/加分细则;否决项/废标条款 is_veto=1。
3. doc_requirements 中 req_type=structure 的条目就是投标文件应有的章节结构,按招标文件要求的顺序给出。
4. 只抽取正文中真实存在的内容,缺失就不要输出该字段/该条,禁止编造。
金额只填数字(如 1200000),不带单位和千分位;日期只填 YYYY-MM-DD。"""
# ══════════════ 招标文件读取 ══════════════
async def list_tender_files(project_id, extract_status=""):
"""列出项目的招标文件。"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
sql = ("SELECT id, file_name, file_type, extract_status, file_size, file_path "
"FROM bid_tender_files WHERE project_id=${pid}$")
p = {"pid": project_id}
if extract_status:
sql += " AND extract_status=${st}$"
p["st"] = extract_status
sql += " ORDER BY created_at"
recs = await sor.sqlExe(sql, p)
await sor.sqlExe("COMMIT", {})
return rows_to_dicts(recs)
async def read_tender_file(file_id, offset=0, length=20000):
"""读招标文件正文(分段读,供 LLM 分析)。正文为空时尝试从磁盘提取。"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT id, project_id, file_name, file_path, content_text "
"FROM bid_tender_files WHERE id=${fid}$", {"fid": file_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return "招标文件不存在: %s" % file_id
d = rec_to_dict(recs[0])
txt = d.get("content_text") or ""
if not txt.strip() and d.get("file_path"):
txt = _extract_file_text(d["file_path"])
if txt:
await sor.sqlExe(
"UPDATE bid_tender_files SET content_text=${c}$, updated_at=NOW() "
"WHERE id=${fid}$", {"c": txt[:2000000], "fid": file_id})
await sor.sqlExe("COMMIT", {})
if not txt.strip():
return ("招标文件正文为空(file_path=%s)。请确认文件已上传且格式为 docx/pdf/txt。"
% d.get("file_path", ""))
o = to_int(offset, 0)
n = to_int(length, 20000)
seg = txt[o:o + n]
return ("【%s】字符 %d-%d / 共 %d\n%s"
% (d.get("file_name", ""), o, o + len(seg), len(txt), seg))
def _extract_file_text(path):
"""从 docx/pdf/txt 提取纯文本(无依赖时返回空串,不抛异常)。"""
import os
import re
import zipfile
if not path or not os.path.isfile(path):
return ""
ext = os.path.splitext(path)[1].lower()
try:
if ext in (".txt", ".md", ".csv", ".json"):
with open(path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()
if ext == ".docx":
with zipfile.ZipFile(path) as z:
out = []
for name in z.namelist():
if not (name.startswith("word/") and name.endswith(".xml")):
continue
if "document" not in name and "header" not in name and "footer" not in name:
continue
xml = z.read(name).decode("utf-8", errors="ignore")
xml = xml.replace("</w:p>", "\n")
out.append("".join(re.findall(r"<w:t[^>]*>(.*?)</w:t>", xml, flags=re.S)))
return "\n".join(out)
if ext == ".pdf":
try:
import fitz
doc = fitz.open(path)
return "\n".join(pg.get_text() for pg in doc)
except ImportError:
return ""
except Exception as e:
logger.warning("extract file text failed %s: %s", path, str(e)[:120])
return ""
async def mark_file_extracted(file_id, note="", who=None, agent_id=None):
"""标记招标文件已抽取完成。"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
await sor.sqlExe(
"UPDATE bid_tender_files SET extract_status='extracted', extract_note=${n}$, "
"updated_at=NOW() WHERE id=${fid}$", {"n": (note or "")[:480], "fid": file_id})
await sor.sqlExe("COMMIT", {})
return True, "已标记抽取完成: %s" % file_id
# ══════════════ 抽取落库(逐条) ══════════════
async def add_scoring_item(project_id, item_name, max_score, scoring_rule="",
section="technical", item_no="", is_veto="0",
source_ref="", target_chapter_no="", weight="",
who=None, agent_id=None):
"""新增一个评分项及得分规则。"""
try:
project_id = await resolve_project_id(project_id)
except ValueError as e:
return False, str(e)
if not item_name:
return False, "缺少 item_name"
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
exist = await sor.sqlExe(
"SELECT id FROM bid_scoring_items WHERE project_id=${pid}$ AND item_name=${nm}$ LIMIT 1",
{"pid": project_id, "nm": str(item_name)[:250]})
await sor.sqlExe("COMMIT", {})
if exist:
return True, "评分项已存在: %s" % item_name
n = await sor.sqlExe(
"SELECT COUNT(*) AS c FROM bid_scoring_items WHERE project_id=${pid}$",
{"pid": project_id})
order_no = to_int(getattr(n[0], "c", 0) if n else 0) + 1
sid = new_id()
await sor.C("bid_scoring_items", {
"id": sid, "project_id": project_id,
"section": (section or "technical")[:30],
"item_no": str(item_no or "")[:30],
"item_name": str(item_name)[:250],
"max_score": to_float(max_score, 0.0),
"scoring_rule": scoring_rule or "",
"is_veto": "1" if str(is_veto) in ("1", "true", "True", "是") else "0",
"weight": to_float(weight, 0.0) or None,
"source_ref": str(source_ref or "")[:480],
"target_chapter_no": str(target_chapter_no or "")[:30],
"order_no": order_no,
})
await sor.sqlExe("COMMIT", {})
await record(sor, project_id, "bid_scoring_items", sid, "add",
who=who, agent_id=agent_id, detail=str(item_name)[:200])
return True, "评分项已录入: %s (%s分)" % (item_name, max_score)
async def add_qualification(project_id, qual_name, requirement="", is_mandatory="1",
owner="kb", who=None, agent_id=None):
"""新增一项招标所需资质。"""
try:
project_id = await resolve_project_id(project_id)
except ValueError as e:
return False, str(e)
if not qual_name:
return False, "缺少 qual_name"
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
exist = await sor.sqlExe(
"SELECT id FROM bid_qualifications WHERE project_id=${pid}$ AND qual_name=${nm}$ LIMIT 1",
{"pid": project_id, "nm": str(qual_name)[:250]})
await sor.sqlExe("COMMIT", {})
if exist:
return True, "资质项已存在: %s" % qual_name
qid = new_id()
await sor.C("bid_qualifications", {
"id": qid, "project_id": project_id,
"qual_name": str(qual_name)[:250],
"requirement": requirement or "",
"is_mandatory": "1" if str(is_mandatory) in ("1", "true", "True", "是") else "0",
"owner": (owner or "kb")[:15],
"match_status": "pending",
})
await sor.sqlExe("COMMIT", {})
await record(sor, project_id, "bid_qualifications", qid, "add",
who=who, agent_id=agent_id, detail=str(qual_name)[:200])
return True, "资质要求已录入: %s" % qual_name
async def add_doc_requirement(project_id, requirement, req_type="structure",
chapter_no="", chapter_title="", page_limit="",
source_ref="", who=None, agent_id=None):
"""新增一条投标文件要求(structure 类同时是章节骨架来源)。"""
try:
project_id = await resolve_project_id(project_id)
except ValueError as e:
return False, str(e)
if not requirement:
return False, "缺少 requirement"
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
n = await sor.sqlExe(
"SELECT COUNT(*) AS c FROM bid_doc_requirements WHERE project_id=${pid}$",
{"pid": project_id})
await sor.sqlExe("COMMIT", {})
order_no = to_int(getattr(n[0], "c", 0) if n else 0) + 1
rid = new_id()
await sor.C("bid_doc_requirements", {
"id": rid, "project_id": project_id,
"req_type": (req_type or "structure")[:30],
"chapter_no": str(chapter_no or "")[:30],
"chapter_title": str(chapter_title or "")[:250],
"requirement": requirement or "",
"page_limit": to_int(page_limit, 0) or None,
"source_ref": str(source_ref or "")[:480],
"order_no": order_no,
})
await sor.sqlExe("COMMIT", {})
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 一次性抽取三类信息并批量落库(评分项/资质/投标文件要求)。
大文件分段调用(offset 递增),重复项按名称去重,可安全多次调用。
"""
try:
project_id = await resolve_project_id(project_id, file_id=file_id)
except ValueError as e:
return False, str(e)
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
if file_id:
recs = await sor.sqlExe(
"SELECT id, content_text, file_path, file_name FROM bid_tender_files "
"WHERE id=${fid}$", {"fid": file_id})
else:
recs = await sor.sqlExe(
"SELECT id, content_text, file_path, file_name FROM bid_tender_files "
"WHERE project_id=${pid}$ AND file_type='tender_doc' ORDER BY created_at LIMIT 1",
{"pid": project_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return False, "项目下没有招标文件(bid_tender_files),无法抽取"
d = rec_to_dict(recs[0])
txt = d.get("content_text") or ""
if not txt.strip() and d.get("file_path"):
txt = _extract_file_text(d["file_path"])
if txt:
await sor.sqlExe(
"UPDATE bid_tender_files SET content_text=${c}$ WHERE id=${fid}$",
{"c": txt[:2000000], "fid": d["id"]})
await sor.sqlExe("COMMIT", {})
if not txt.strip():
return False, "招标文件正文为空,无法抽取(file=%s)" % d.get("file_name", "")
o = to_int(offset, 0)
seg = txt[o:o + to_int(length, 30000)]
data, err = await llm_json(EXTRACT_PROMPT.replace("__CONTENT__", seg),
model_name=model_name, retries=1)
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
ok, _ = await add_scoring_item(
project_id, it.get("item_name"), it.get("max_score", 0),
scoring_rule=it.get("scoring_rule", ""), section=it.get("section", "technical"),
item_no=it.get("item_no", ""), is_veto=it.get("is_veto", "0"),
source_ref=it.get("source_ref", ""), who=who, agent_id=agent_id)
cnt["scoring_items"] += 1 if ok else 0
for it in (data.get("qualifications") or []):
if not isinstance(it, dict) or not it.get("qual_name"):
continue
ok, _ = await add_qualification(
project_id, it.get("qual_name"), requirement=it.get("requirement", ""),
is_mandatory=it.get("is_mandatory", "1"), who=who, agent_id=agent_id)
cnt["qualifications"] += 1 if ok else 0
for it in (data.get("doc_requirements") or []):
if not isinstance(it, dict) or not it.get("requirement"):
continue
ok, _ = await add_doc_requirement(
project_id, it.get("requirement"), req_type=it.get("req_type", "structure"),
chapter_no=it.get("chapter_no", ""), chapter_title=it.get("chapter_title", ""),
page_limit=it.get("page_limit", ""), source_ref=it.get("source_ref", ""),
who=who, agent_id=agent_id)
cnt["doc_requirements"] += 1 if ok else 0
return True, ("抽取完成(文件 %s,字符 %d-%d):评分项 %d,资质 %d,投标文件要求 %d"
% (d.get("file_name", ""), o, o + len(seg),
cnt["scoring_items"], cnt["qualifications"], cnt["doc_requirements"]))
# ══════════════ 查询 ══════════════
async def list_scoring_items(project_id, section=""):
try:
project_id = await resolve_project_id(project_id)
except ValueError as e:
return []
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
sql = ("SELECT id, section, item_no, item_name, max_score, is_veto, "
"target_chapter_no, scoring_rule FROM bid_scoring_items WHERE project_id=${pid}$")
p = {"pid": project_id}
if section:
sql += " AND section=${sec}$"
p["sec"] = section
sql += " ORDER BY section, order_no"
recs = await sor.sqlExe(sql, p)
await sor.sqlExe("COMMIT", {})
out = rows_to_dicts(recs)
for r in out:
r["scoring_rule"] = (r.get("scoring_rule") or "")[:600]
return out
async def list_doc_requirements(project_id, req_type=""):
try:
project_id = await resolve_project_id(project_id)
except ValueError as e:
return []
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
sql = ("SELECT id, req_type, chapter_no, chapter_title, requirement, page_limit "
"FROM bid_doc_requirements WHERE project_id=${pid}$")
p = {"pid": project_id}
if req_type:
sql += " AND req_type=${rt}$"
p["rt"] = req_type
sql += " ORDER BY req_type, order_no"
recs = await sor.sqlExe(sql, p)
await sor.sqlExe("COMMIT", {})
out = rows_to_dicts(recs)
for r in out:
r["requirement"] = (r.get("requirement") or "")[:600]
return out
# ══════════════ 章节骨架 ══════════════
def _infer_section(title):
"""按章节标题关键词推断商务/技术分类(商务标:报价/商务/资质证明/承诺函等;其余技术标)。"""
t = title or ""
biz_kw = ("报价", "投标函", "商务", "资格证明", "资质证明", "承诺书", "声明", "保证金",
"法定代表人", "授权委托", "财务状况", "业绩一览", "合同格式")
for kw in biz_kw:
if kw in t:
return "business"
return "technical"
async def create_chapter_outline(project_id, chapters="", who=None, agent_id=None):
"""生成章节骨架 → bid_chapters(status=pending)。
chapters 为空时:自动用 bid_doc_requirements(req_type=structure) 生成,
并按 target_chapter_no / 章节标题关键词把评分项挂到章节上(scoring_item_ids + max_score)。
"""
try:
project_id = await resolve_project_id(project_id)
except ValueError as e:
return False, str(e)
items = json_loads(chapters, []) if chapters else []
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
if not items:
recs = await sor.sqlExe(
"SELECT chapter_no, chapter_title, requirement, page_limit FROM bid_doc_requirements "
"WHERE project_id=${pid}$ AND req_type='structure' ORDER BY order_no",
{"pid": project_id})
await sor.sqlExe("COMMIT", {})
for r in (recs or []):
d = rec_to_dict(r)
if not (d.get("chapter_title") or d.get("chapter_no")):
continue
items.append({
"chapter_no": d.get("chapter_no") or "",
"title": d.get("chapter_title") or "",
"outline": (d.get("requirement") or "")[:4000],
"section": _infer_section(d.get("chapter_title") or ""),
})
if not items:
return False, ("无章节来源:请先抽取 req_type=structure 的投标文件要求,"
"或显式传 chapters=[{\"chapter_no\":\"1\",\"title\":\"...\"}]")
sitems = await sor.sqlExe(
"SELECT id, item_name, max_score, section, target_chapter_no "
"FROM bid_scoring_items WHERE project_id=${pid}$", {"pid": project_id})
await sor.sqlExe("COMMIT", {})
sc = rows_to_dicts(sitems)
created, skipped = 0, 0
for idx, it in enumerate(items):
if not isinstance(it, dict):
continue
cno = str(it.get("chapter_no") or (idx + 1))[:30]
title = str(it.get("title") or it.get("chapter_title") or "").strip()[:250]
if not title:
continue
exist = await sor.sqlExe(
"SELECT id FROM bid_chapters WHERE project_id=${pid}$ AND chapter_no=${cno}$ LIMIT 1",
{"pid": project_id, "cno": cno})
await sor.sqlExe("COMMIT", {})
if exist:
skipped += 1
continue
matched, msum = [], 0.0
for s in sc:
tgt = (s.get("target_chapter_no") or "").strip()
nm = s.get("item_name") or ""
if (tgt and tgt == cno) or (nm and (nm in title or title in nm)):
matched.append(s["id"])
msum += to_float(s.get("max_score"), 0.0)
sec = str(it.get("section") or _infer_section(title) or "technical")[:30]
await sor.C("bid_chapters", {
"id": new_id(), "project_id": project_id,
"chapter_no": cno, "parent_no": str(it.get("parent_no") or "")[:30],
"title": title, "section": sec,
"scoring_item_ids": json.dumps(matched, ensure_ascii=False),
"outline": str(it.get("outline") or "")[:20000],
"status": CH_PENDING, "version": 1, "revise_count": 0,
"max_score": msum or None,
"assignee_role": "agent.bid_biz_writer" if sec == "business" else "agent.bid_writer",
"order_no": idx + 1,
})
created += 1
await sor.sqlExe("COMMIT", {})
await record(sor, project_id, "bid_chapters", project_id, "create_outline",
who=who, agent_id=agent_id, detail="新增 %d 章" % created)
return True, ("章节骨架已生成:新增 %d 章,已存在跳过 %d 章。"
"系统将自动为每个章节派发编写任务。" % (created, skipped))