pipeline-bidding/pipeline_bidding/bid_analysis_capability.py

1235 lines
59 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。"""
# ── 四维度拆分抽取提示词2026-09-02 起:解析任务拆成四个并行维度)──
PROMPT_SCORING = """你是招标文件分析师。下面是一份招标文件正文(可能被截断),本任务只负责【评分项与得分规则】这一个维度。
【招标文件正文】
__CONTENT__
严格只输出一个 JSON 对象:
{
"tender_facts": {"title": "项目名称", "budget_amount": "预算金额数字", "risk_note": "评标相关风险提示,无则空"},
"scoring_items": [
{"section": "business|technical|price", "item_no": "评分项编号", "item_name": "评分项名称",
"max_score": 数字满分, "scoring_rule": "得分规则原文(含档位/扣分/加分细则)",
"is_veto": "0或1是否否决项/废标项)", "source_ref": "出处,如 第三章 评标办法 表1"}
]
}
要求:
1. 必须逐条覆盖评标办法中的每个评分项,不得漏项、不得编造;满分数字与原文一致。
2. 得分规则保留原文档位/扣分/加分细则,不要概括失真。
3. 否决项/废标条款 is_veto=1漏标视为致命缺陷。
4. 只输出评分维度相关内容,不要输出资质/文件要求/章节等其他维度。
5. 招标文件里没有评标办法就输出 {"tender_facts": {}, "scoring_items": []},禁止编造。"""
PROMPT_QUALS = """你是招标文件分析师。下面是一份招标文件正文(可能被截断),本任务只负责【资质清单与要求】这一个维度。
【招标文件正文】
__CONTENT__
严格只输出一个 JSON 对象:
{
"tender_facts": {"title": "项目名称", "qualification_req": "投标人资格/资质要求原文摘要"},
"qualifications": [
{"qual_name": "资质名称", "requirement": "要求原文", "is_mandatory": "0或1是否强制"}
]
}
要求:
1. 资格要求中的每项资质/证书/业绩门槛/体系认证全部录入,无漏项、无编造。
2. 要求描述与原文一致(等级/有效期/联合体口径),强制性条款 is_mandatory=1加分项不得误标必需。
3. 只输出资质维度相关内容,不要输出评分/文件要求/章节等其他维度。
4. 招标文件里没有资质要求就输出 {"tender_facts": {}, "qualifications": []},禁止编造。"""
PROMPT_REQS = """你是招标文件分析师。下面是一份招标文件正文(可能被截断),本任务只负责【投标文件要求与章节结构】这一个维度。
【招标文件正文】
__CONTENT__
严格只输出一个 JSON 对象:
{
"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. req_type=structure 的条目就是投标文件应有的章节结构,按招标文件要求的顺序给出,章节号/标题准确。
2. 格式/密封/份数/截止时间等要求format/seal/copies/deadline无遗漏。
3. 只输出投标文件要求维度相关内容,不要输出评分/资质等其他维度。
4. 只抽取正文中真实存在的内容,禁止编造;没有就输出 {"doc_requirements": []}。"""
PROMPT_COST_BENEFIT = """你是投标商务分析专家。下面是一份招标文件正文(可能被截断),请做【投标成本与收益分析】。
【招标文件正文】
__CONTENT__
严格只输出一个 JSON 对象:
{
"items": [
{"category": "cost|benefit", "item_name": "项目名(如 投标保证金/履约保证金/人员投入成本/设备采购成本/预期收益)",
"amount": 金额数字或null, "amount_note": "金额口径说明(估算/上限/区间),估算须写清依据",
"basis": "推理依据:招标文件原文引用 + 推理过程", "confidence": "high|medium|low",
"source_ref": "出处(如 第二章 投标须知)"}
],
"summary": {"total_cost_est": 成本合计估算数字或null, "total_revenue_est": 收益合计估算数字或null,
"margin_est": "毛利空间文字结论(区间/口径说明)", "note": "关键风险与口径声明"}
}
要求:
1. 成本项:投标保证金、履约保证金、实施/运维人力成本、设备/软件采购成本、差旅/投标直接费用等,按招标要求和行业常识估算,估算口径必须写在 amount_note 和 basis 里。
2. 收益项:预算金额/招标控制价(招标文件明确给出才算,不得编造数字)。
3. 每项必须给 basis原文引用+推理),招标文件没提的成本用行业常识估算并标 confidence=low严禁凭空编造确定性数字。
4. summary.margin_est 是成本合计与收益合计的差额分析,口径与单项一致;算不出就写"无法估算"并说明原因。"""
# ══════════════ 招标文件读取 ══════════════
async def list_tender_files(project_id, extract_status=""):
"""列出项目的招标文件。"""
try:
project_id = await resolve_project_id(project_id)
except ValueError as e:
# 2026-09-04与 list_scoring_items 同款——静默返空会让分析角色误判
# 「无招标文件」而阻塞实测中电新项目LLM 传项目名当 id查空 →
# 全维度首跑跑空)。明确报错指引补真实 project_id。
return "ERROR: 项目上下文缺失(%s)。请显式传 project_id 后重试。" % e
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"],
ws_base=await _workspace_base_of(sor))
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):
"""项目根目录(供相对路径解析)。统一走引擎 get_project_dir_by_id
读库创建时已写死不再自行推导——口径漂移是上传错位的根因2026-09-01 根治)。
"""
if not project_id:
return ""
try:
from pipeline_service.workspace import get_project_dir_by_id
pdir, _ = await get_project_dir_by_id(sor, project_id)
return pdir or ""
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, ws_base=""):
"""把存储的 file_path 解析成磁盘绝对路径。
- 绝对路径且存在:原样返回;
- 相对/裸文件名:依次尝试 项目根目录、项目根目录下的 docs/、
工作空间根ws_base2026-09-15 补——上传落点相对工作空间根存路径,
"0/第五章采购需求.docx",旧候选全解析不到 → 正文永远读空)、进程 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))
if ws_base:
cands.append(os.path.join(ws_base, fp))
cands.append(fp)
for c in cands:
if os.path.isfile(c):
return os.path.abspath(c)
return ""
async def _workspace_base_of(sor):
"""工作空间根目录params.workspace_base引擎统一口径。失败返回 ''"""
try:
from pipeline_service.workspace import get_workspace_base
return await get_workspace_base(sor) or ""
except Exception as e:
logger.warning("workspace base resolve failed: %s", str(e)[:120])
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)
ws_base = await _workspace_base_of(sor)
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"], ws_base=ws_base)
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:
exist = await sor.sqlExe(
"SELECT id FROM bid_doc_requirements WHERE project_id=${pid}$ "
"AND req_type=${rt}$ AND requirement=${rq}$ LIMIT 1",
{"pid": project_id, "rt": (req_type or "structure")[:30],
"rq": str(requirement or "")[:500]})
await sor.sqlExe("COMMIT", {})
if exist:
return True, "投标文件要求已存在: %s" % str(requirement)[:50]
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 update_doc_requirement(project_id, record_id="", chapter_no="", req_type="",
requirement="", source_ref="", page_limit="",
chapter_title="", delete=False, who=None, agent_id=None):
"""更新/删除既有投标文件要求条目2026-09-03根治 add 只增不改)。
定位record_id 优先;否则 chapter_no+req_type 唯一定位。
- delete=True → 删除该条(编造/无出处项的处置);
- requirement/source_ref/page_limit/chapter_title 非空 → 回写原条目
(纠正必须回写,不得只追加新条目形成矛盾对)。
"""
try:
project_id = await resolve_project_id(project_id)
except ValueError as e:
return False, str(e)
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
if record_id:
recs = await sor.sqlExe(
"SELECT id FROM bid_doc_requirements WHERE project_id=${pid}$ AND id=${r}$",
{"pid": project_id, "r": record_id})
elif chapter_no:
recs = await sor.sqlExe(
"SELECT id FROM bid_doc_requirements WHERE project_id=${pid}$ "
"AND chapter_no=${c}$ AND req_type=${rt}$",
{"pid": project_id, "c": str(chapter_no)[:30],
"rt": (req_type or "structure")[:30]})
else:
return False, "需要 record_id 或 chapter_no 定位条目"
await sor.sqlExe("COMMIT", {})
if not recs:
return False, "未找到条目record_id=%s chapter_no=%s req_type=%s" % (
record_id, chapter_no, req_type)
if len(recs) > 1 and not record_id:
return False, "chapter_no+req_type 命中 %d 条,请改用 record_id 精确定位" % len(recs)
rid = getattr(recs[0], "id", "")
if delete:
await sor.sqlExe("DELETE FROM bid_doc_requirements WHERE id=${r}$", {"r": rid})
await sor.sqlExe("COMMIT", {})
await record(sor, project_id, "bid_doc_requirements", rid, "delete",
who=who, agent_id=agent_id, detail="删除条目")
return True, "已删除条目 %s" % rid
sets, p = [], {"r": rid}
if requirement:
sets.append("requirement=${rq}$"); p["rq"] = requirement
if chapter_title:
sets.append("chapter_title=${ct}$"); p["ct"] = str(chapter_title)[:250]
if source_ref:
sets.append("source_ref=${sr}$"); p["sr"] = str(source_ref)[:480]
if page_limit:
sets.append("page_limit=${pl}$"); p["pl"] = to_int(page_limit, 0) or None
if not sets:
return False, "无可更新字段requirement/source_ref/page_limit 至少传一个)"
sets.append("updated_at=NOW()")
await sor.sqlExe("UPDATE bid_doc_requirements SET " + ", ".join(sets) +
" WHERE id=${r}$", p)
await sor.sqlExe("COMMIT", {})
await record(sor, project_id, "bid_doc_requirements", rid, "update",
who=who, agent_id=agent_id, detail="回写原条目")
return True, "已更新条目 %s" % rid
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 _load_tender_text(sor, project_id, file_id=""):
"""读招标文件正文(各维度抽取共用)。返回 (txt, file_name, err)。
file_id 指定 → 取该记录;否则选最优记录(有正文优先、其次磁盘有文件,同类取最新)。
磁盘文件首次读到时回写 content_text幂等
"""
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 "", "", "项目下没有该招标文件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 "", "", "项目下没有招标文件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"], ws_base=await _workspace_base_of(sor))
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 "", d.get("file_name", ""), (
"招标文件正文为空无法抽取file=%sfile_path=%s"
"请确认文件已上传到项目目录" % (d.get("file_name", ""), d.get("file_path", "")))
return txt, d.get("file_name", ""), ""
async def extract_from_file(project_id, file_id="", model_name="", offset=0,
length=30000, who=None, agent_id=None):
"""LLM 一次性抽取三类信息并批量落库(评分项/资质/投标文件要求)。
大文件分段调用offset 递增),重复项按名称去重,可安全多次调用。
【2026-09-02 起已由四维度拆分取代】对账器按维度各派一个任务并行抽取
extract_scoring / extract_quals / extract_reqs_outline / extract_cost_benefit
本函数保留仅为向后兼容(旧角色技能/旧项目重做仍可调),新流程不再用它。
"""
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:
txt, fname, err = await _load_tender_text(sor, project_id, file_id=file_id)
if err:
return False, err
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, project_id=project_id)
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"
% (fname, o, o + len(seg),
cnt["scoring_items"], cnt["qualifications"], cnt["doc_requirements"]))
# ── 四维度拆分抽取2026-09-02解析任务拆成四个并行维度各派一个任务──
async def _extract_seg(txt, prompt, model_name, offset, length, project_id=""):
"""取分段正文 → LLM 抽取。返回 (data, err)。"""
o = to_int(offset, 0)
seg = txt[o:o + to_int(length, 30000)]
data, err = await llm_json(prompt.replace("__CONTENT__", seg),
model_name=model_name, retries=1,
project_id=project_id or '')
if data is None:
return None, "LLM 抽取失败: %s" % err
return data, ""
async def extract_scoring(project_id, file_id="", model_name="", offset=0,
length=30000, who=None, agent_id=None):
"""维度1抽取评分项 + 得分规则(落 bid_scoring_items顺带更新商务要素"""
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:
txt, fname, err = await _load_tender_text(sor, project_id, file_id=file_id)
if err:
return False, err
data, err = await _extract_seg(txt, PROMPT_SCORING, model_name, offset, length,
project_id=project_id)
if data is None:
return False, err
cnt = 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 += 1 if ok else 0
return True, ("评分项维度抽取完成(文件 %s,字符 %d-%d):录入 %d 项。"
% (fname, to_int(offset, 0), to_int(offset, 0) + to_int(length, 30000), cnt))
async def extract_quals(project_id, file_id="", model_name="", offset=0,
length=30000, who=None, agent_id=None):
"""维度2抽取资质清单与要求落 bid_qualifications"""
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:
txt, fname, err = await _load_tender_text(sor, project_id, file_id=file_id)
if err:
return False, err
data, err = await _extract_seg(txt, PROMPT_QUALS, model_name, offset, length,
project_id=project_id)
if data is None:
return False, err
cnt = 0
await _upsert_tender_facts(project_id, data.get("tender_facts"),
who=who, agent_id=agent_id)
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 += 1 if ok else 0
return True, ("资质维度抽取完成(文件 %s,字符 %d-%d):录入 %d 项。"
% (fname, to_int(offset, 0), to_int(offset, 0) + to_int(length, 30000), cnt))
async def extract_reqs_outline(project_id, file_id="", model_name="", offset=0,
length=30000, who=None, agent_id=None):
"""维度3抽取投标文件要求落 bid_doc_requirements并自动生成章节骨架。"""
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:
txt, fname, err = await _load_tender_text(sor, project_id, file_id=file_id)
if err:
return False, err
data, err = await _extract_seg(txt, PROMPT_REQS, model_name, offset, length,
project_id=project_id)
if data is None:
return False, err
cnt = 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 += 1 if ok else 0
# 自动生成章节骨架structure 类 → 章节,评分项挂载能挂多少挂多少)
ok_o, msg_o = await create_chapter_outline(project_id, who=who, agent_id=agent_id)
return True, ("投标文件要求维度抽取完成(文件 %s,字符 %d-%d):录入 %d 条要求。%s"
% (fname, to_int(offset, 0), to_int(offset, 0) + to_int(length, 30000),
cnt, msg_o if not ok_o else "章节骨架已生成。"))
async def add_cost_benefit_item(project_id, category, item_name, amount="", amount_note="",
basis="", confidence="medium", source_ref="",
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_cost_benefit WHERE project_id=${pid}$ AND item_name=${nm}$ "
"AND category=${c}$ LIMIT 1",
{"pid": project_id, "nm": str(item_name)[:250], "c": (category or "cost")[:16]})
await sor.sqlExe("COMMIT", {})
if exist:
return True, "该成本/收益项已存在: %s" % item_name
n = await sor.sqlExe(
"SELECT COUNT(*) AS c FROM bid_cost_benefit WHERE project_id=${pid}$",
{"pid": project_id})
order_no = to_int(getattr(n[0], "c", 0) if n else 0) + 1
iid = new_id()
amt = to_float(amount, 0.0)
await sor.C("bid_cost_benefit", {
"id": iid, "project_id": project_id,
"kind": "item", "category": (category or "cost")[:16],
"item_name": str(item_name)[:250],
"amount": amt or None,
"amount_note": str(amount_note or "")[:200],
"basis": basis or "",
"confidence": (confidence or "medium")[:16],
"source_ref": str(source_ref or "")[:480],
"order_no": order_no,
})
await sor.sqlExe("COMMIT", {})
await record(sor, project_id, "bid_cost_benefit", iid, "add",
who=who, agent_id=agent_id, detail=str(item_name)[:200])
return True, "成本/收益项已录入: %s (%s)" % (item_name, category)
async def list_cost_benefit(project_id):
"""列出成本/收益分析全部条目QC 重做前必读现状,禁止盲重抽)。
2026-09-16 甘肃项目实测analyst 没有查看工具 → QC 退回后看不到旧行 →
只能整维度重抽 → 新行换名追加、旧行无人删 → 24 行互斥重复堆积,
QC 三轮全灭抛人工(与 2026-09-03 create_chapter_outline 只增不改同款缺陷)。
"""
try:
project_id = await resolve_project_id(project_id)
except ValueError as e:
return "ERROR: 项目上下文缺失(%s)。请显式传 project_id 后重试。" % e
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT id, kind, category, item_name, amount, amount_note, confidence, "
"source_ref, order_no, created_at FROM bid_cost_benefit "
"WHERE project_id=${pid}$ ORDER BY kind DESC, order_no, id",
{"pid": project_id})
await sor.sqlExe("COMMIT", {})
out = rows_to_dicts(recs)
for r in out:
r["source_ref"] = (r.get("source_ref") or "")[:200]
r["amount_note"] = (r.get("amount_note") or "")[:200]
return json.dumps({"total": len(out), "items": out}, ensure_ascii=False, default=str)
async def update_cost_benefit_item(project_id, record_id, item_name="", amount="",
amount_note="", basis="", confidence="",
source_ref="", who=None, agent_id=None):
"""修改一条成本/收益项QC 改进意见要求合并口径/修正引用/补算式时用)。
只改传入的字段amount 传空串不改、传 0 改为 0。改前必须先 list_cost_benefit。
"""
if not record_id:
return False, "缺少 record_id先 list_cost_benefit 查到目标行 id"
try:
project_id = await resolve_project_id(project_id)
except ValueError as e:
return False, str(e)
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT * FROM bid_cost_benefit WHERE id=${i}$ AND project_id=${p}$",
{"i": record_id, "p": project_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return False, "记录不存在或不属于本项目: %s" % record_id
old = rec_to_dict(recs[0])
upd = {}
if item_name:
upd["item_name"] = str(item_name)[:250]
if str(amount) not in ("", None):
upd["amount"] = to_float(amount, 0.0) or None
if amount_note:
upd["amount_note"] = str(amount_note)[:200]
if basis:
upd["basis"] = basis
if confidence:
upd["confidence"] = str(confidence)[:16]
if source_ref:
upd["source_ref"] = str(source_ref)[:480]
if not upd:
return False, "没有传入任何要修改的字段"
sets = ", ".join("%s=${%s}$" % (k, k) for k in upd)
p = dict(upd); p["i"] = record_id
await sor.sqlExe(
"UPDATE bid_cost_benefit SET " + sets + ", updated_at=NOW() WHERE id=${i}$", p)
await sor.sqlExe("COMMIT", {})
await record(sor, project_id, "bid_cost_benefit", record_id, "update",
who=who, agent_id=agent_id,
detail="; ".join("%s: %s%s" % (k, str(old.get(k))[:40], str(v)[:40])
for k, v in upd.items())[:480])
return True, "成本/收益项已更新: %s(字段:%s" % (record_id, ",".join(upd.keys()))
async def delete_cost_benefit_item(project_id, record_id, reason="",
who=None, agent_id=None):
"""删除一条成本/收益项QC 判定互斥重复/占位无效行时用reason 必填留审计)。"""
if not record_id:
return False, "缺少 record_id先 list_cost_benefit 查到目标行 id"
if not (reason or "").strip():
return False, "删除必须给出 reason对照 QC 改进意见说明为何删:重复/占位/被取代)"
try:
project_id = await resolve_project_id(project_id)
except ValueError as e:
return False, str(e)
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT item_name, category FROM bid_cost_benefit "
"WHERE id=${i}$ AND project_id=${p}$",
{"i": record_id, "p": project_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return False, "记录不存在或不属于本项目: %s" % record_id
nm = getattr(recs[0], "item_name", "")
await sor.sqlExe(
"DELETE FROM bid_cost_benefit WHERE id=${i}$ AND project_id=${p}$",
{"i": record_id, "p": project_id})
await sor.sqlExe("COMMIT", {})
await record(sor, project_id, "bid_cost_benefit", record_id, "delete",
who=who, agent_id=agent_id,
detail="%s | 理由: %s" % (str(nm)[:60], str(reason)[:200]))
return True, "已删除成本/收益项: %s(理由已留审计)" % nm
async def extract_cost_benefit(project_id, file_id="", model_name="", offset=0,
length=30000, who=None, agent_id=None):
"""维度4投标成本与收益分析落 bid_cost_benefit单项 + summary 结论行)。"""
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:
txt, fname, err = await _load_tender_text(sor, project_id, file_id=file_id)
if err:
return False, err
data, err = await _extract_seg(txt, PROMPT_COST_BENEFIT, model_name, offset, length,
project_id=project_id)
if data is None:
return False, err
cnt = 0
for it in (data.get("items") or []):
if not isinstance(it, dict) or not it.get("item_name"):
continue
ok, _ = await add_cost_benefit_item(
project_id, it.get("category", "cost"), it.get("item_name"),
amount=it.get("amount", ""), amount_note=it.get("amount_note", ""),
basis=it.get("basis", ""), confidence=it.get("confidence", "medium"),
source_ref=it.get("source_ref", ""), who=who, agent_id=agent_id)
cnt += 1 if ok else 0
# 关键结论行summarykind=summary供总览展示
sm = data.get("summary") or {}
if isinstance(sm, dict) and sm:
note = (sm.get("margin_est") or "")[:2000]
extra = (sm.get("note") or "")[:2000]
db2, dbname2 = get_db()
async with db2.sqlorContext(dbname2) as sor:
exist = await sor.sqlExe(
"SELECT id FROM bid_cost_benefit WHERE project_id=${pid}$ AND kind='summary' LIMIT 1",
{"pid": project_id})
await sor.sqlExe("COMMIT", {})
sdata = {
"category": "summary",
"item_name": "成本收益关键结论",
"amount": to_float(sm.get("total_cost_est"), 0.0) or None,
"amount_note": ("成本合计估算;收益合计估算=%s"
% str(sm.get("total_revenue_est") or "未估算"))[:200],
"basis": (note + ("\n\n风险与口径:" + extra if extra else ""))[:20000],
"confidence": "medium",
}
if exist:
sid = getattr(exist[0], "id", "")
sets = ", ".join("%s=${%s}$" % (k, k) for k in sdata if k != "id")
p = dict(sdata); p["sid"] = sid
await sor.sqlExe(
"UPDATE bid_cost_benefit SET " + sets + ", updated_at=NOW() WHERE id=${sid}$", p)
await sor.sqlExe("COMMIT", {})
else:
sdata.update({"id": new_id(), "project_id": project_id, "kind": "summary",
"source_ref": "", "order_no": 0})
await sor.C("bid_cost_benefit", sdata)
await sor.sqlExe("COMMIT", {})
return True, ("成本收益维度分析完成(文件 %s):录入 %d 项 + 关键结论。"
% (fname, cnt))
# ══════════════ 查询 ══════════════
async def list_scoring_items(project_id, section=""):
try:
project_id = await resolve_project_id(project_id)
except ValueError as e:
# 2026-09-03静默返回 [] 会让 LLM 误判「无数据」(实测 C-12 绑定阻塞根因),
# 改为明确报错,提示补 project_id。
return "ERROR: 项目上下文缺失(%s)。请显式传 project_id 后重试。" % e
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 "ERROR: 项目上下文缺失(%s)。请显式传 project_id 后重试。" % e
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))
async def patch_chapters(project_id, patches="", who=None, agent_id=None):
"""按 chapter_no 增量更新已有章节2026-09-03根治 create_chapter_outline 只增不改)。
patches: JSON 数组,每项 {chapter_no, title?, outline?, section?,
scoring_item_ids?, scoring_items_by_name?, max_score?}。
- scoring_item_ids 直接覆盖(传真实 id 数组);
- scoring_items_by_name: [{item_name, max_score?}] 按名称在 bid_scoring_items
反查 id 绑定item_name 为「商务N名称出处」格式时按包含匹配
- 白名单外的章节不动;不存在的 chapter_no 报错列出。
"""
try:
project_id = await resolve_project_id(project_id)
except ValueError as e:
return False, str(e)
items = json_loads(patches, []) if isinstance(patches, str) else (patches or [])
if not items:
return False, "patches 为空:传 JSON 数组 [{chapter_no, ...}]"
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
sitems = await sor.sqlExe(
"SELECT id, item_name, max_score FROM bid_scoring_items WHERE project_id=${pid}$",
{"pid": project_id})
await sor.sqlExe("COMMIT", {})
sc = rows_to_dicts(sitems)
def _bind_by_name(names):
ids, miss = [], []
for n in (names or []):
nm = (n.get("item_name") or "").strip() if isinstance(n, dict) else str(n or "").strip()
hit = None
for s in sc:
sn = s.get("item_name") or ""
if nm and (nm == sn or nm in sn or sn in nm):
hit = s
break
if hit:
ids.append(hit["id"])
else:
miss.append(nm)
return ids, miss
updated, missing, bound, unbound = [], [], 0, []
for it in items:
if not isinstance(it, dict):
continue
cno = str(it.get("chapter_no") or "").strip()
if not cno:
continue
recs = await sor.sqlExe(
"SELECT id, scoring_item_ids FROM bid_chapters "
"WHERE project_id=${pid}$ AND chapter_no=${cno}$ LIMIT 1",
{"pid": project_id, "cno": cno})
await sor.sqlExe("COMMIT", {})
if not recs:
missing.append(cno)
continue
sets, p = [], {"pid": project_id, "cno": cno}
if it.get("title"):
sets.append("title=${t}$"); p["t"] = str(it["title"])[:250]
if it.get("outline") is not None and it.get("outline") != "":
sets.append("outline=${o}$"); p["o"] = str(it["outline"])[:20000]
if it.get("section"):
sets.append("section=${s}$"); p["s"] = str(it["section"])[:30]
ids = list(it.get("scoring_item_ids") or [])
if it.get("scoring_items_by_name"):
b, m = _bind_by_name(it["scoring_items_by_name"])
ids = b
bound += len(b)
unbound += m
if it.get("scoring_item_ids") is not None or it.get("scoring_items_by_name"):
sets.append("scoring_item_ids=${si}$")
p["si"] = json.dumps(ids, ensure_ascii=False)
if it.get("max_score") is not None:
sets.append("max_score=${ms}$"); p["ms"] = to_float(it["max_score"], 0)
if not sets:
continue
sets.append("updated_at=NOW()")
sets.append("version=version+1")
await sor.sqlExe(
"UPDATE bid_chapters SET " + ", ".join(sets) +
" WHERE project_id=${pid}$ AND chapter_no=${cno}$", p)
updated.append(cno)
await sor.sqlExe("COMMIT", {})
await record(sor, project_id, "bid_chapters", project_id, "patch_outline",
who=who, agent_id=agent_id,
detail="更新 %d 章:%s" % (len(updated), ",".join(updated)[:200]))
msg = "章节增量更新:成功 %d 章(%s),评分项按名绑定 %d" % (
len(updated), ",".join(updated)[:200], bound)
if missing:
msg += ";不存在的章节:%s" % ",".join(missing)
if unbound:
msg += ";按名未绑定的评分项:%s" % ",".join(unbound)[:200]
return True, msg