feat(binding): 章节↔编写要求显式绑定doc_requirement_ids(2026-09-16编号错位实测根因)——requirements按招标书目录编号/chapters按装订编号两套编号漂移,chapter_detail等值JOIN静默递错要求(写者拿'营业执照'要求写授权委托书,4章错位+5章无要求)。机制:create_chapter_outline自动生成落绑定;patch_chapters支持doc_requirement_ids/doc_requirements_by_no(id校验);chapter_detail绑定优先+编号回退带标题互含触发器(词匹配只报可疑,语义裁决在agent);新工具check_requirement_binding体检报告(错位/失效/孤儿);QC_TYPE_OUTLINE加第5维绑定对齐;models+json2ddl实跑验证

This commit is contained in:
yumoqing 2026-09-16 18:23:52 +08:00
parent 598578d288
commit f9cbfdc625
6 changed files with 180 additions and 13 deletions

View File

@ -11,7 +11,8 @@
"exclouded": [
"content",
"outline",
"scoring_item_ids"
"scoring_item_ids",
"doc_requirement_ids"
]
},
"editexclouded": [
@ -47,4 +48,4 @@
]
}
}
}
}

View File

@ -56,6 +56,11 @@
"title": "对应评分项(JSON)",
"type": "text"
},
{
"name": "doc_requirement_ids",
"title": "绑定投标文件要求ID(JSON数组)",
"type": "text"
},
{
"name": "outline",
"title": "章节要点",

View File

@ -521,6 +521,78 @@ async def update_doc_requirement(project_id, record_id="", chapter_no="", req_ty
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 = [], [], []
for c in chapters:
ids = json_loads(c.get("doc_requirement_ids"), [])
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
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 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,复用原招标信息表承载商务要素)。
@ -1073,7 +1145,8 @@ async def create_chapter_outline(project_id, chapters="", who=None, agent_id=Non
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 "
"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", {})
@ -1086,6 +1159,9 @@ async def create_chapter_outline(project_id, chapters="", who=None, agent_id=Non
"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 的投标文件要求,"
@ -1125,6 +1201,10 @@ async def create_chapter_outline(project_id, chapters="", who=None, agent_id=Non
"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,
@ -1213,6 +1293,38 @@ async def patch_chapters(project_id, patches="", who=None, agent_id=None):
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()")

View File

@ -52,7 +52,10 @@ QC_DIMENSIONS = {
"1. 章节与 req_type=structure 的要求一一对应,无缺章、无多余章;\n"
"2. 章节号/标题/顺序与投标文件要求一致;\n"
"3. 评分项挂载正确:每个评分项能落到对应章节(target_chapter_no/名称匹配),无错挂漏挂;\n"
"4. 章节要点来自评分标准原文枚举的子维度,不是通用目录。"
"4. 章节要点来自评分标准原文枚举的子维度,不是通用目录;\n"
"5. 编写要求绑定对齐:先调 check_requirement_binding 体检,逐条裁决报告中的错位可疑项"
"(章节号与要求号是两套编号会漂移,同号≠同章),确认后督促/自行用 patch_chapters"
"(doc_requirements_by_no) 落显式绑定;绑定错位=写者拿错编写要求=整章按错标准写。"
),
QC_TYPE_COST: (
"投标成本收益分析契合度审核(对照招标文件商务条款与预算/保证金原文):\n"

View File

@ -57,12 +57,49 @@ async def chapter_detail(chapter_id, with_content="1", length=12000):
"FROM bid_scoring_items WHERE id IN (" + inlist + ")", {})
await sor.sqlExe("COMMIT", {})
items = rows_to_dicts(srecs)
reqs = await sor.sqlExe(
"SELECT req_type, requirement, page_limit FROM bid_doc_requirements "
"WHERE project_id=${pid}$ AND (chapter_no=${cno}$ OR req_type IN ('format','seal','copies')) "
"ORDER BY order_no",
{"pid": d.get("project_id"), "cno": d.get("chapter_no")})
await sor.sqlExe("COMMIT", {})
# ── 编写要求:显式绑定优先,编号匹配回退+错位触发器(2026-09-16)──
# 根因实测:bid_doc_requirements 与 bid_chapters 两套 chapter_no 会漂移
# (抽取按招标书目录编号,骨架按投标文件装订编号),等值 JOIN 静默递错要求——
# 写者拿「营业执照」的要求写授权委托书。绑定(doc_requirement_ids)是正本;
# 未绑定时按编号回退,但用标题互含做词匹配触发器:不互含→输出⚠可疑警告
# (词匹配只报可疑,语义裁决权在 agent;判错用 list_doc_requirements +
# patch_chapters(doc_requirements_by_no) 落绑定后再写)。
rq_ids = json_loads(d.get("doc_requirement_ids"), [])
rq_bound = False
if rq_ids:
inlist = ",".join("'" + str(x).replace("'", "") + "'" for x in rq_ids)
rrecs = await sor.sqlExe(
"SELECT req_type, chapter_no, chapter_title, requirement, page_limit, "
"source_ref FROM bid_doc_requirements WHERE id IN (" + inlist + ")", {})
await sor.sqlExe("COMMIT", {})
reqs = rows_to_dicts(rrecs)
rq_bound = True
else:
rrecs = await sor.sqlExe(
"SELECT req_type, chapter_no, chapter_title, requirement, page_limit, "
"source_ref FROM bid_doc_requirements "
"WHERE project_id=${pid}$ AND (chapter_no=${cno}$ OR req_type IN "
"('format','seal','copies')) ORDER BY order_no",
{"pid": d.get("project_id"), "cno": d.get("chapter_no")})
await sor.sqlExe("COMMIT", {})
reqs = rows_to_dicts(rrecs)
rq_warn = ""
if not rq_bound:
ctitle = (d.get("title") or "").strip()
mismatched = []
for r in reqs:
if r.get("req_type") != "structure":
continue
rt = (r.get("chapter_title") or "").strip()
if rt and ctitle and (rt not in ctitle and ctitle not in rt):
mismatched.append("%s≠%s" % (rt[:20], ctitle[:20]))
if mismatched:
rq_warn = ("⚠ 编号匹配可疑(要求标题与章节标题不互含:%s)——按编号取到的编写要求"
"可能属于其他章节,动笔前先 list_doc_requirements 语义核对,"
"确属错位请 patch_chapters(doc_requirements_by_no=[…]) 落绑定。"
% ";".join(mismatched[:3]))
elif reqs:
rq_warn = "(本章要求为编号匹配、未落显式绑定;标题核对一致,可用)"
out = {
"id": d.get("id"), "chapter_no": d.get("chapter_no"), "title": d.get("title"),
"section": d.get("section"), "status": d.get("status"), "version": d.get("version"),
@ -70,8 +107,11 @@ async def chapter_detail(chapter_id, with_content="1", length=12000):
"max_score": d.get("max_score"), "review_score": d.get("review_score"),
"review_comment": (d.get("review_comment") or "")[:6000],
"scoring_items": items,
"doc_requirements": rows_to_dicts(reqs),
"doc_requirements": reqs,
"doc_requirements_binding": "explicit" if rq_bound else "fallback_by_no",
}
if rq_warn:
out["doc_requirements_warning"] = rq_warn
if str(with_content) in ("1", "true", "True"):
out["content"] = (d.get("content") or "")[:to_int(length, 12000)]
return json.dumps(out, ensure_ascii=False, default=str)

View File

@ -127,9 +127,9 @@ BID_ROLE_TOOL_SCHEMAS = {
},
"patch_chapters": {
"module": f"{_M}.bid_analysis_capability",
"description": "按 chapter_no 增量更新已有章节(改标题/outline/挂载评分项/满分)。create_chapter_outline 只增不改,修正既有骨架必须用它。scoring_items_by_name 按名称反查绑定真实 id",
"description": "按 chapter_no 增量更新已有章节(改标题/outline/挂载评分项/满分/绑定编写要求)。create_chapter_outline 只增不改,修正既有骨架必须用它。scoring_items_by_name 按名称反查绑定真实 id;doc_requirements_by_no=[{chapter_no:要求条目号}] 按 bid_doc_requirements 的 chapter_no 反查 id 落显式绑定(章节号与要求号错位时必用)",
"params": {"project_id": "项目ID(可选,默认当前)",
"patches": "JSON数组[{chapter_no,title?,outline?,section?,scoring_item_ids?,scoring_items_by_name?,max_score?}]"},
"patches": "JSON数组[{chapter_no,title?,outline?,section?,scoring_item_ids?,scoring_items_by_name?,max_score?,doc_requirement_ids?,doc_requirements_by_no?}]"},
"required": ["patches"],
},
"update_doc_requirement": {
@ -153,6 +153,12 @@ BID_ROLE_TOOL_SCHEMAS = {
"params": {"project_id": "项目ID(可选,默认当前)", "req_type": "类型(可选)"},
"required": [],
},
"check_requirement_binding": {
"module": f"{_M}.bid_analysis_capability",
"description": "体检「章节↔编写要求」绑定对齐:报告编号错位可疑章节/绑定失效/无要求章节/未被引用的structure要求。词匹配只报可疑,语义裁决后用patch_chapters(doc_requirements_by_no)落绑定或update_doc_requirement改正",
"params": {"project_id": "项目ID(可选,默认当前)"},
"required": [],
},
"mark_file_extracted": {
"module": f"{_M}.bid_analysis_capability",
"description": "标记招标文件抽取完成",