diff --git a/json/bid_chapters.json b/json/bid_chapters.json index a4ef90c..39ed57f 100644 --- a/json/bid_chapters.json +++ b/json/bid_chapters.json @@ -11,7 +11,8 @@ "exclouded": [ "content", "outline", - "scoring_item_ids" + "scoring_item_ids", + "doc_requirement_ids" ] }, "editexclouded": [ @@ -47,4 +48,4 @@ ] } } -} +} \ No newline at end of file diff --git a/models/bid_chapters.json b/models/bid_chapters.json index 4174ebd..1a4dd35 100644 --- a/models/bid_chapters.json +++ b/models/bid_chapters.json @@ -56,6 +56,11 @@ "title": "对应评分项(JSON)", "type": "text" }, + { + "name": "doc_requirement_ids", + "title": "绑定投标文件要求ID(JSON数组)", + "type": "text" + }, { "name": "outline", "title": "章节要点", diff --git a/pipeline_bidding/bid_analysis_capability.py b/pipeline_bidding/bid_analysis_capability.py index 8734e09..8edddf9 100644 --- a/pipeline_bidding/bid_analysis_capability.py +++ b/pipeline_bidding/bid_analysis_capability.py @@ -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()") diff --git a/pipeline_bidding/bid_qc_capability.py b/pipeline_bidding/bid_qc_capability.py index 45a8d27..031f86e 100644 --- a/pipeline_bidding/bid_qc_capability.py +++ b/pipeline_bidding/bid_qc_capability.py @@ -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" diff --git a/pipeline_bidding/bid_write_capability.py b/pipeline_bidding/bid_write_capability.py index 24086d1..b6bb89a 100644 --- a/pipeline_bidding/bid_write_capability.py +++ b/pipeline_bidding/bid_write_capability.py @@ -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) diff --git a/pipeline_bidding/role_tool_schemas.py b/pipeline_bidding/role_tool_schemas.py index bba1826..fbc3398 100644 --- a/pipeline_bidding/role_tool_schemas.py +++ b/pipeline_bidding/role_tool_schemas.py @@ -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": "标记招标文件抽取完成",