537 lines
25 KiB
Python
537 lines
25 KiB
Python
"""招标文件解析能力:抽取评分项+得分规则、所需资质、投标文件要求,生成章节骨架。
|
||
|
||
对应业务:招标文件获得后,从招标文件中抽取评分项以及得分规则、所需资质、投标文件要求。
|
||
章节骨架规则(写进 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))
|