pipeline-bidding/pipeline_bidding/bid_analysis_capability.py

1368 lines
68 KiB
Python
Raw Permalink 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_base,2026-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, new_req_type="",
who=None, agent_id=None):
"""更新/删除既有投标文件要求条目(2026-09-03,根治 add 只增不改)。
定位:record_id 优先;否则 chapter_no+req_type 唯一定位。
- delete=True → 删除该条(编造/无出处项的处置);
- requirement/source_ref/page_limit/chapter_title 非空 → 回写原条目
(纠正必须回写,不得只追加新条目形成矛盾对);
- new_req_type(2026-09-16)→ 纠正条目类型:全局规则误标 structure 会占用
章节编号匹配位(chapter_detail 按编号回退时递错要求),应改 other/format。
与定位键 req_type 分开传,否则改类型时按新值定位不到原条目。
"""
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
# req_type 纠正走独立参数 new_req_type(req_type 是定位键:按 chapter_no+req_type
# 定位时传新值会定位不到——2026-09-16)。全局规则条目误标 structure 会占用章节
# 编号匹配位,导致写者按编号拿到错的要求(如"总体构成与编制顺序"应改 other)。
if new_req_type:
rt = str(new_req_type).strip()[:30]
if rt not in ("structure", "format", "seal", "copies", "deadline", "other"):
return False, ("new_req_type 非法: %s(structure/format/seal/copies/"
"deadline/other)" % rt)
sets.append("req_type=${nrt}$"); p["nrt"] = rt
if not sets:
return False, "无可更新字段(requirement/source_ref/page_limit/req_type 至少传一个)"
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 check_requirement_binding(project_id):
"""体检「章节↔投标文件要求」绑定对齐(2026-09-16:编号错位实测根因)。
输出报告:每章的绑定状态(explicit/fallback/none)、编号回退时标题不互含的可疑
错位、未被任何章节绑定的 structure 要求清单。词匹配只报可疑(触发器),
语义裁决与绑定修正走 patch_chapters(doc_requirement_ids/doc_requirements_by_no)。
"""
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:
chs = await sor.sqlExe(
"SELECT id, chapter_no, title, doc_requirement_ids FROM bid_chapters "
"WHERE project_id=${pid}$ ORDER BY order_no, chapter_no", {"pid": project_id})
await sor.sqlExe("COMMIT", {})
rqs = await sor.sqlExe(
"SELECT id, req_type, chapter_no, chapter_title FROM bid_doc_requirements "
"WHERE project_id=${pid}$ ORDER BY order_no", {"pid": project_id})
await sor.sqlExe("COMMIT", {})
chapters = rows_to_dicts(chs, limit=500)
reqs = rows_to_dicts(rqs, limit=500)
rq_by_id = {r["id"]: r for r in reqs if r.get("id")}
rq_by_no = {}
for r in reqs:
if r.get("req_type") == "structure" and r.get("chapter_no"):
rq_by_no.setdefault(r["chapter_no"], r)
bound_rq = set()
lines = ["## 章节↔编写要求绑定体检(%d 章 / %d 条要求)" % (len(chapters), len(reqs)), ""]
suspicious, unbound_ch, mismatched, empty_bound = [], [], [], []
for c in chapters:
raw = c.get("doc_requirement_ids")
ids = json_loads(raw, [])
cno = str(c.get("chapter_no") or "")
ctitle = str(c.get("title") or "")
if ids:
bound_rq.update(str(x) for x in ids)
bad = [x for x in ids if str(x) not in rq_by_id]
if bad:
suspicious.append("%s %s: 绑定含失效id %s" % (cno, ctitle[:18], bad[:2]))
continue
if raw is not None:
# 显式空绑定:已语义裁决"本章无章节级要求",是结论不是缺口
empty_bound.append("%s %s" % (cno, ctitle[:18]))
continue
fb = rq_by_no.get(cno)
if fb:
rt = str(fb.get("chapter_title") or "")
if rt and ctitle and (rt not in ctitle and ctitle not in rt):
mismatched.append("%s 章节《%s》↔同号要求《%s》不互含(疑似错位)"
% (cno, ctitle[:18], rt[:18]))
bound_rq.add(fb["id"])
else:
unbound_ch.append("%s %s" % (cno, ctitle[:18]))
orphan = ["%s %s" % (r.get("chapter_no"), (r.get("chapter_title") or "")[:22])
for r in reqs
if r.get("req_type") == "structure" and r["id"] not in bound_rq]
if mismatched:
lines += ["### ⚠ 编号错位可疑(%d)——LLM 语义裁决后 patch_chapters 落绑定" % len(mismatched)]
lines += ["- " + m for m in mismatched] + [""]
if suspicious:
lines += ["### ✗ 绑定失效(%d)" % len(suspicious), *["- " + m for m in suspicious], ""]
if unbound_ch:
lines += ["### 无同号要求的章节(%d)——核对是否漏抽要求或确属无单独要求"
% len(unbound_ch), *["- " + m for m in unbound_ch], ""]
if empty_bound:
lines += ["### 显式空绑定(%d)——已语义裁决本章无章节级要求(如招标书明文无需提供)"
% len(empty_bound), *[("- " + m) for m in empty_bound], ""]
if orphan:
lines += ["### 未被任何章节引用的 structure 要求(%d)——可能是全局规则误标 structure,"
"应 update_doc_requirement 改 req_type=other/format,或补绑定" % len(orphan)]
lines += ["- " + m for m in orphan] + [""]
if not (mismatched or suspicious or unbound_ch or orphan):
lines.append("✓ 全部章节要求绑定对齐,无错位、无孤儿要求。")
lines += ["---", "处置:错位/补绑 → patch_chapters [{chapter_no, doc_requirements_by_no:[{chapter_no:要求条目号}]}];"
"要求本身错 → update_doc_requirement 回写原条目。"]
return "\n".join(lines)
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 _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=%s,file_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
# 关键结论行(summary):kind=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 id, 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 ""),
# 显式绑定来源要求(2026-09-16:骨架从 structure 要求生成时
# 天然携带其 id——写者/评审按绑定取要求,不再靠编号巧合)
"doc_requirement_ids": [d.get("id")] if d.get("id") else [],
})
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),
"doc_requirement_ids": (json.dumps(
[str(x) for x in (it.get("doc_requirement_ids") or []) if x],
ensure_ascii=False)
if (it.get("doc_requirement_ids") or []) else None),
"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)
# ── 要求绑定修正(2026-09-16:编号错位实测——写者按 chapter_no 等值 JOIN
# 取到别的章节的编写要求。绑定是落库事实,LLM 语义裁决后经此回写)──
# doc_requirement_ids: 直接覆盖(真实 id 数组,逐个校验存在于本项目)
# doc_requirements_by_no: [{"chapter_no":"1.3"}] 按要求条目的 chapter_no 反查 id
if it.get("doc_requirement_ids") is not None or it.get("doc_requirements_by_no"):
rq_ids = [str(x) for x in (it.get("doc_requirement_ids") or []) if x]
if it.get("doc_requirements_by_no"):
rq_ids = []
for n in (it.get("doc_requirements_by_no") or []):
rno = str(n.get("chapter_no") if isinstance(n, dict) else (n or "")).strip()
if not rno:
continue
rr = await sor.sqlExe(
"SELECT id FROM bid_doc_requirements WHERE project_id=${pid}$ "
"AND chapter_no=${rn}$",
{"pid": project_id, "rn": rno[:30]})
await sor.sqlExe("COMMIT", {})
for x in (rr or []):
rq_ids.append(getattr(x, "id", ""))
rq_ids = [x for x in rq_ids if x]
if rq_ids:
inlist = ",".join("'" + x.replace("'", "") + "'" for x in rq_ids)
chk = await sor.sqlExe(
"SELECT COUNT(*) AS c FROM bid_doc_requirements WHERE id IN (" + inlist +
") AND project_id=${pid}$", {"pid": project_id})
await sor.sqlExe("COMMIT", {})
n_ok = to_int(getattr(chk[0], "c", 0) if chk else 0)
if n_ok != len(rq_ids):
return False, ("章节 %s 的要求绑定含不存在/非本项目的 id(%d/%d 有效),"
"先用 list_doc_requirements 取真实 id" % (cno, n_ok, len(rq_ids)))
sets.append("doc_requirement_ids=${dri}$")
p["dri"] = json.dumps(rq_ids, ensure_ascii=False) or None
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