"""章节编写能力(agent.bid_writer):查章节、看要求、写正文、提交评审。 对应业务:按照投标文件要求分章节编写投标文件。 一章一任务(任务 params.chapter_id),写完置 written 由评审角色接手(reconciler 自动派评审任务)。 """ import json import logging from .bid_common import ( get_db, rec_to_dict, rows_to_dicts, json_loads, to_int, record, resolve_project_id, CH_PENDING, CH_WRITING, CH_WRITTEN, CH_REJECTED, CH_APPROVED, ) logger = logging.getLogger("pipeline.bidding.write") TABLE = "bid_chapters" MIN_CONTENT_LEN = 200 # 章节正文最小长度门槛(防空交付) async def list_chapters(project_id, status=""): """列出章节(不含正文,避免上下文爆炸)。""" try: project_id = await resolve_project_id(project_id) except ValueError as e: return [] db, dbname = get_db() async with db.sqlorContext(dbname) as sor: sql = ("SELECT id, chapter_no, title, section, status, version, revise_count, " "word_count, review_score, max_score, assignee_role " "FROM " + TABLE + " WHERE project_id=${pid}$") p = {"pid": project_id} if status: sql += " AND status=${st}$" p["st"] = status sql += " ORDER BY order_no, chapter_no" recs = await sor.sqlExe(sql, p) await sor.sqlExe("COMMIT", {}) return rows_to_dicts(recs) async def chapter_detail(chapter_id, with_content="1", length=12000): """章节详情:要点、对应评分项及得分规则、上一轮评审改进意见、当前正文。""" db, dbname = get_db() async with db.sqlorContext(dbname) as sor: recs = await sor.sqlExe("SELECT * FROM " + TABLE + " WHERE id=${cid}$", {"cid": chapter_id}) await sor.sqlExe("COMMIT", {}) if not recs: return "章节不存在: %s" % chapter_id d = rec_to_dict(recs[0]) sids = json_loads(d.get("scoring_item_ids"), []) items = [] if sids: inlist = ",".join("'" + str(x).replace("'", "") + "'" for x in sids) srecs = await sor.sqlExe( "SELECT item_no, item_name, max_score, is_veto, scoring_rule, source_ref " "FROM bid_scoring_items WHERE id IN (" + inlist + ")", {}) await sor.sqlExe("COMMIT", {}) items = rows_to_dicts(srecs) # ── 编写要求:显式绑定优先,编号匹配回退+错位触发器(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_raw = d.get("doc_requirement_ids") rq_ids = json_loads(rq_raw, []) rq_bound = False if rq_raw is not None: # 显式绑定(含裁定为空绑定 "[]":语义裁决后确认本章无对应要求, # 禁止编号回退再递错——2026-09-16 甘肃 1.9 实测:招标书明文 # "供应商无需提供信用记录查询结果",同号要求是别的章节的) rq_bound = True 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 + ") " "OR (project_id=${pid}$ AND req_type IN ('format','seal','copies')) " "ORDER BY order_no", {"pid": d.get("project_id")}) await sor.sqlExe("COMMIT", {}) reqs = rows_to_dicts(rrecs) elif rq_bound: # 显式空绑定:无章节级要求,但全局规则(format/seal/copies)仍适用 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 req_type IN ('format','seal','copies') ORDER BY order_no", {"pid": d.get("project_id")}) await sor.sqlExe("COMMIT", {}) reqs = rows_to_dicts(rrecs) 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 = "" rq_empty = rq_bound and not rq_ids # 显式空绑定(已裁定本章无章节级要求) 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 = "(本章要求为编号匹配、未落显式绑定;标题核对一致,可用)" elif rq_empty: rq_warn = ("(本章经裁决为空绑定:无章节级编写要求,仅列全局规则——按章节 outline " "与招标原文相关条款编写,勿套用其他章节的要求)") 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"), "revise_count": d.get("revise_count"), "outline": (d.get("outline") or "")[:6000], "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": 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) async def write_chapter(chapter_id, content, summary="", mode="replace", who=None, agent_id=None): """写入章节正文:mode=replace 覆盖 / append 追加,版本+1,字数统计。 append(2026-09-16 新增,甘肃项目 2.2 章实测):超长章节(如 403 行技术 参数响应表)一次 write 超出单条工具调用可承载长度写不进去,评审明确要求 「机制侧开放 write_chapter 分段追加,按标的分批每批 50~80 行」。 追加时自动在两段之间补换行;篇幅门禁按累计总字数判定。 """ if not chapter_id: return False, "缺少 chapter_id" txt = content or "" mode = (mode or "replace").strip().lower() if mode not in ("replace", "append"): return False, "mode 只支持 replace(覆盖)或 append(追加)" if len(txt.strip()) < MIN_CONTENT_LEN and mode == "replace": return False, ("章节正文过短(%d 字符 < %d),拒绝写入。请按招标文件要求与评分规则" "写到操作层面(量化指标/流程步骤/责任人/表单),不要写空话。" % (len(txt.strip()), MIN_CONTENT_LEN)) if mode == "append" and not txt.strip(): return False, "追加内容为空" # ── 配图形态硬门禁(2026-09-15 用户要求:标书章节的图必须 t2i/i2i 真图)── # 确定性判定(复用 pipeline_service.diagram_gate),mermaid/ASCII 伪图拒绝写入; # 平台无图像模型时正文如实标注「配图缺失」即豁免。 try: from pipeline_service.diagram_gate import find_fake_diagrams, gate_message _dg = find_fake_diagrams(txt) if _dg: return False, gate_message(_dg, scene="标书章节") except ImportError: pass # 宿主未装 pipeline-service 时放行(逃逸阀,与产线零接触分层一致) db, dbname = get_db() async with db.sqlorContext(dbname) as sor: recs = await sor.sqlExe( "SELECT id, project_id, status, version, title FROM " + TABLE + " WHERE id=${cid}$", {"cid": chapter_id}) await sor.sqlExe("COMMIT", {}) if not recs: return False, "章节不存在: %s" % chapter_id d = rec_to_dict(recs[0]) if d.get("status") == CH_APPROVED: return False, "章节已评审通过(approved),不可覆盖;如需修改请先由评审/评分角色退回" ver = to_int(d.get("version"), 1) if mode == "append": old_txt = d.get("content") or "" new_txt = (old_txt.rstrip() + "\n\n" + txt.strip() + "\n") if old_txt.strip() \ else (txt.strip() + "\n") else: new_txt = txt await sor.sqlExe( "UPDATE " + TABLE + " SET content=${c}$, word_count=${w}$, status=${st}$, " "version=${v}$, updated_at=NOW() WHERE id=${cid}$", {"c": new_txt, "w": len(new_txt), "st": CH_WRITING, "v": ver + 1, "cid": chapter_id}) await sor.sqlExe("COMMIT", {}) await record(sor, d.get("project_id"), TABLE, chapter_id, "append" if mode == "append" else "write", from_state=d.get("status"), to_state=CH_WRITING, who=who, agent_id=agent_id, detail=(summary or "")[:480]) # ── 材料缺口自动登记(2026-09-16 占位符规范,机制层兜底)── # 正文含「【材料待补:…】」占位符 → 扫描登记 bid_material_gaps(指纹去重)。 # 写者忘调工具也不漏;评审/QC 见占位符不退回(占位符=信息流缺口非写作缺陷)。 _n_gaps = 0 try: from .bid_material_capability import scan_and_register_gaps _n_gaps = await scan_and_register_gaps( sor, d.get("project_id"), chapter_id, d.get("chapter_no") or "", new_txt, who=who, agent_id=agent_id) except Exception as _e: # 登记失败不阻塞写作主流程 logger.warning("material gap scan failed: %s", str(_e)[:120]) _gap_note = (";本轮自动登记 %d 项材料缺口(合成后统一出缺失清单)" % _n_gaps) \ if _n_gaps else "" # ── 章节镜像文件(2026-09-16 用户裁定:章节写文件,用户看文件即时发现问题)── # 库仍是正本;镜像 = {项目根}/chapters/{order:03d}_{章节号}_{标题}.md, # 图片本地化到 chapters/media/ 同目录。失败不阻塞(try 在 mirror 模块内)。 _mir = "" try: from .bid_chapter_mirror import sync_chapter_mirror _mir = await sync_chapter_mirror(sor, d.get("project_id"), chapter_id) except Exception as _e: # noqa: BLE001 logger.warning("chapter mirror failed: %s", str(_e)[:120]) if mode == "append": return True, ("章节「%s」已追加 %d 字符(累计 %d 字符,v%d)%s。" "全部批次写完后回读核对总行数/总字数,再调 submit_chapter 提交评审。" % (d.get("title"), len(txt), len(new_txt), ver + 1, _gap_note)) return True, ("章节「%s」正文已保存(%d 字符,v%d)%s。确认完成后调用 submit_chapter 提交评审。" % (d.get("title"), len(new_txt), ver + 1, _gap_note)) async def submit_chapter(chapter_id, note="", who=None, agent_id=None): """提交章节评审:writing → written(reconciler 会自动派发评审任务)。""" db, dbname = get_db() async with db.sqlorContext(dbname) as sor: recs = await sor.sqlExe( "SELECT id, project_id, status, title, content FROM " + TABLE + " WHERE id=${cid}$", {"cid": chapter_id}) await sor.sqlExe("COMMIT", {}) if not recs: return False, "章节不存在: %s" % chapter_id d = rec_to_dict(recs[0]) if len((d.get("content") or "").strip()) < MIN_CONTENT_LEN: return False, "章节正文为空或过短,请先 write_chapter 写入正文" if d.get("status") not in (CH_WRITING, CH_PENDING, CH_REJECTED): return False, "当前状态 %s 不可提交(需 writing/pending/rejected)" % d.get("status") await sor.sqlExe( "UPDATE " + TABLE + " SET status=${st}$, updated_at=NOW() WHERE id=${cid}$ " "AND status=${old}$", {"st": CH_WRITTEN, "old": d.get("status"), "cid": chapter_id}) chk = await sor.sqlExe("SELECT status FROM " + TABLE + " WHERE id=${cid}$", {"cid": chapter_id}) await sor.sqlExe("COMMIT", {}) cur = getattr(chk[0], "status", "") if chk else "" if cur != CH_WRITTEN: return False, "提交失败(CAS):当前状态 %s" % cur await record(sor, d.get("project_id"), TABLE, chapter_id, "submit", from_state=d.get("status"), to_state=CH_WRITTEN, who=who, agent_id=agent_id, detail=(note or "")[:480]) return True, "章节「%s」已提交评审" % d.get("title")