pipeline-bidding/pipeline_bidding/bid_analysis_capability.py
yumoqing e387c7c5ce fix(bidding): 解析读不到完整招标书根治——最优文件选择+路径解析+升级自动恢复
- extract/read 选最优记录: 磁盘文件大小与正文长度取大, 替代固定取最老
- 相对/裸文件名按项目根目录解析, 解析后回写绝对路径
- bid_flow: 新招标文件到来自动重置解析计数+清旧产出残留+关旧人工任务(防永久卡死)
- 主 agent 补 reset_task_retry 工具(待办文案承诺但一直未实现)
2026-09-01 09:26:45 +08:00

638 lines
29 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
import os
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 分析)。正文为空时尝试从磁盘提取。
磁盘提取支持相对路径:裸文件名/相对路径按项目根目录解析
(上传时可能只存了文件名,2026-09-01 修复)。
"""
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"):
pdir = await _project_dir_of(sor, d.get("project_id", ""))
real = _resolve_file_path(pdir, d["file_path"])
txt = _extract_file_text(real) if real else ""
if txt:
await sor.sqlExe(
"UPDATE bid_tender_files SET content_text=${c}$, file_path=${p}$, "
"updated_at=NOW() WHERE id=${fid}$",
{"c": txt[:2000000], "p": real, "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 _project_dir_of(sor, project_id):
"""项目根目录(供相对路径解析)。解析口径与引擎上传落盘一致:
sd_projects.workspace_dir 优先;为空时按 {base}/{org}/{pipeline}/{项目名} 兜底。
"""
if not project_id:
return ""
try:
recs = await sor.sqlExe(
"SELECT name, org_id, pipeline_id, workspace_dir FROM sd_projects "
"WHERE id=${p}$ LIMIT 1", {"p": project_id})
if not recs:
return ""
r = recs[0]
ws = (getattr(r, 'workspace_dir', '') or '').strip()
if ws.startswith('/'):
return ws
from pipeline_service.workspace import get_workspace_base, build_workspace_path
base = await get_workspace_base(sor)
name = (getattr(r, 'name', '') or '').strip()
if not base or not name:
return ""
return build_workspace_path(base, getattr(r, 'org_id', '0') or '0',
getattr(r, 'pipeline_id', '') or 'general', name)
except Exception as e:
logger.warning("project dir resolve failed pid=%s: %s", project_id, str(e)[:120])
return ""
def _resolve_file_path(project_dir, file_path):
"""把存储的 file_path 解析成磁盘绝对路径。
- 绝对路径且存在:原样返回;
- 相对/裸文件名:依次尝试 项目根目录、项目根目录下的 docs/、进程 cwd,存在即返回;
- 都找不到返回 ''。
"""
fp = str(file_path or "").strip()
if not fp:
return ""
if os.path.isabs(fp):
return fp if os.path.isfile(fp) else ""
cands = []
if project_dir:
cands.append(os.path.join(project_dir, fp))
cands.append(os.path.join(project_dir, "docs", fp))
cands.append(fp)
for c in cands:
if os.path.isfile(c):
return os.path.abspath(c)
return ""
async def _best_tender_record(sor, project_id):
"""选最优招标文件记录(修复 2026-09-01「解析读旧截断稿」根因)。
一个项目可能有多条招标文件记录(重复上传/导入)。选择规则:
**可得正文最多者胜**——正文取 len(content_text),磁盘文件取文件大小,
两者取大;并列时取最新。返回 dict 附带 _resolved_path(磁盘绝对路径提示)。
旧实现 ORDER BY created_at LIMIT 1 固定取最老记录——最老的往往是早期
截断摘要,完整文件后来才传,导致解析永远读不到完整文件。
只按「有正文优先」也不够:截断稿有正文、完整稿只在磁盘上,会误选截断稿。
"""
recs = await sor.sqlExe(
"SELECT id, project_id, file_name, file_path, content_text, created_at "
"FROM bid_tender_files WHERE project_id=${pid}$ AND file_type='tender_doc' "
"ORDER BY created_at DESC", {"pid": project_id})
if not recs:
return {}
rows = rows_to_dicts(recs, limit=50)
pdir = await _project_dir_of(sor, project_id)
best, best_score, best_real = {}, -1, ""
for r in rows:
score = len((r.get("content_text") or "").strip())
real = ""
if (r.get("file_path") or "").strip():
real = _resolve_file_path(pdir, r["file_path"])
if real:
try:
score = max(score, os.path.getsize(real))
except OSError:
pass
if score > best_score:
best, best_score, best_real = r, score, real
if best and best_real:
best["_resolved_path"] = best_real
return best
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})
await sor.sqlExe("COMMIT", {})
if not recs:
return False, "项目下没有招标文件(bid_tender_files),无法抽取"
d = rec_to_dict(recs[0])
else:
# 选最优记录:有正文优先、其次磁盘有文件,同类取最新
# (2026-09-01 修复:旧实现固定取最老记录,完整文件后来上传时永远读不到)
d = await _best_tender_record(sor, project_id)
if not d:
return False, "项目下没有招标文件(bid_tender_files),无法抽取"
txt = d.get("content_text") or ""
if not txt.strip() and d.get("file_path"):
pdir = await _project_dir_of(sor, project_id)
real = d.get("_resolved_path") or _resolve_file_path(pdir, d["file_path"])
txt = _extract_file_text(real) if real else ""
if txt:
await sor.sqlExe(
"UPDATE bid_tender_files SET content_text=${c}$, file_path=${p}$ "
"WHERE id=${fid}$",
{"c": txt[:2000000], "p": real, "fid": d["id"]})
await sor.sqlExe("COMMIT", {})
if not txt.strip():
return False, ("招标文件正文为空,无法抽取(file=%s,file_path=%s);"
"请确认文件已上传到项目目录" %
(d.get("file_name", ""), d.get("file_path", "")))
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))