diff --git a/README.md b/README.md index 9fed849..e444cde 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ FlowTemplate 机制首个接入)——`bid_standard`(上述标准链)与 ` | `bid_cost_benefit` | 成本收益分析(分析维度4,单项+summary 结论行;m0006 建表未进 models,2026-09-10 归籍) | project_id, kind, category, item_name, amount, confidence | | `bid_tech_items` | 技术评估项(纯技术方案流程 tech_analysis 产出;kind=item/summary,category 六类;m0034 建表) | project_id, kind, category, item_name, requirement, mandatory, source_ref | | `bid_kb_docs` | 公司投标知识库 | org_id, doc_type(sample_bid 等), doc_name, doc_no, tags, file_path, content_text | +| `bid_material_gaps` | 投标材料缺口清单(占位符规范:正文 `【材料待补:…】` 自动登记,合成后出清单,人工/产线补齐;m0035 建表) | project_id, chapter_no, material_name, placeholder_text, fingerprint, status(pending/filled/waived) | | `bid_members` | 投标项目参与人员 | project_id, user_id, user_name, member_role, duty, status | 种子数据(`init/data.json`,build.sh 幂等写入):`pipelines` 产线主记录 `bidding_general` + diff --git a/models/bid_material_gaps.json b/models/bid_material_gaps.json new file mode 100644 index 0000000..20bc832 --- /dev/null +++ b/models/bid_material_gaps.json @@ -0,0 +1 @@ +{"summary": [{"name": "bid_material_gaps", "title": "投标材料缺口清单表(占位符规范:m0035建表,写入方bid_material_capability.py与write_chapter自动登记,合成后出清单,人工补齐走fill_material_gap)", "primary": ["id"], "catelog": "entity"}], "fields": [{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "project_id", "title": "项目ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "chapter_id", "title": "章节ID", "type": "str", "length": 32, "nullable": "yes"}, {"name": "chapter_no", "title": "章节号", "type": "str", "length": 50, "nullable": "yes"}, {"name": "material_name", "title": "材料名称描述", "type": "str", "length": 250, "nullable": "no"}, {"name": "material_type", "title": "材料类型(certificate证书/screenshot截图/contract合同/seal盖章件/other)", "type": "str", "length": 32, "nullable": "yes", "default": "other"}, {"name": "placeholder_text", "title": "正文占位符完整文本(补齐时精确替换用)", "type": "str", "length": 400, "nullable": "yes"}, {"name": "fingerprint", "title": "去重指纹(project+chapter+描述sha1)", "type": "str", "length": 64, "nullable": "yes"}, {"name": "requirement", "title": "招标文件原文要求或评分条款", "type": "text", "nullable": "yes"}, {"name": "source_ref", "title": "招标要求出处", "type": "str", "length": 480, "nullable": "yes"}, {"name": "status", "title": "状态(pending待补/filled已补齐/waived放弃)", "type": "str", "length": 16, "nullable": "no", "default": "pending"}, {"name": "filled_by", "title": "补齐人", "type": "str", "length": 64, "nullable": "yes"}, {"name": "fill_note", "title": "补齐说明(材料出处或放弃理由)", "type": "text", "nullable": "yes"}, {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, {"name": "updated_at", "title": "更新时间(写入方显式NOW())", "type": "timestamp", "nullable": "no"}], "indexes": [{"name": "idx_project", "idxtype": "index", "idxfields": ["project_id"]}, {"name": "idx_fingerprint", "idxtype": "index", "idxfields": ["fingerprint"]}]} \ No newline at end of file diff --git a/pipeline_bidding/bid_ability.py b/pipeline_bidding/bid_ability.py index 39ccfbf..cc0d274 100644 --- a/pipeline_bidding/bid_ability.py +++ b/pipeline_bidding/bid_ability.py @@ -167,6 +167,17 @@ BID_TOOLS = [ ToolDefinition(name="bid_score_report", description="查看最近一轮整书评分报告(总分+逐项明细+改进意见)", parameters={}, category="bid"), + # ── 材料缺口(占位符规范,2026-09-16)── + ToolDefinition(name="list_material_gaps", description="查看投标材料缺失清单(正文占位符自动登记的缺口:章节/材料/状态)", + parameters={"status": "状态(可选 pending/filled/waived)"}, category="bid"), + ToolDefinition(name="material_gap_report", description="生成材料缺失清单报告(markdown 全文,合成标书时也会自动落盘)", + parameters={}, category="bid"), + ToolDefinition(name="fill_material_gap", description="补齐材料缺口:用户提供了材料内容/知识库文档后调用,自动替换章节正文占位符(approved 章节保持通过不重审)", + parameters={"gap_id": "缺口ID", "fill_content": "材料正文(与kb_doc_id二选一)", + "kb_doc_id": "知识库文档ID(与fill_content二选一)"}, category="bid"), + ToolDefinition(name="waive_material_gap", description="放弃材料缺口(用户确认不提供该材料,正文占位符保留为如实披露)", + parameters={"gap_id": "缺口ID", "reason": "放弃理由(必填)"}, category="bid"), + # ── 诊断 / 推进 ── ToolDefinition(name="diagnose_bid", description="诊断投标项目:卡在哪个阶段、门禁、章节分布、在办任务、最近评分", parameters={}, category="agent"), @@ -459,6 +470,46 @@ async def _h_chapter_detail(sor, p, ctx): return await chapter_detail(p.get("chapter_id", "")) +async def _h_list_material_gaps(sor, p, ctx): + pid, err = _need_project(ctx) + if err: + return err + from .bid_material_capability import list_material_gaps + return await list_material_gaps(pid, status=p.get("status", "") or "") + + +async def _h_material_gap_report(sor, p, ctx): + pid, err = _need_project(ctx) + if err: + return err + from .bid_material_capability import material_gap_report + ok, rep = await material_gap_report(pid) + return rep if ok else "ERROR: %s" % rep + + +async def _h_fill_material_gap(sor, p, ctx): + pid, err = _need_project(ctx) + if err: + return err + from .bid_material_capability import fill_material_gap + ok, msg = await fill_material_gap( + p.get("gap_id", ""), fill_content=p.get("fill_content", "") or "", + kb_doc_id=p.get("kb_doc_id", "") or "", + who=ctx.get("user_id", "") or "user", agent_id=ctx.get("agent_id", "")) + return msg if ok else "ERROR: %s" % msg + + +async def _h_waive_material_gap(sor, p, ctx): + pid, err = _need_project(ctx) + if err: + return err + from .bid_material_capability import waive_material_gap + ok, msg = await waive_material_gap( + p.get("gap_id", ""), reason=p.get("reason", "") or "", + who=ctx.get("user_id", "") or "user", agent_id=ctx.get("agent_id", "")) + return msg if ok else "ERROR: %s" % msg + + async def _h_list_bid_documents(sor, p, ctx): pid, err = _need_project(ctx) if err: @@ -530,6 +581,10 @@ BID_HANDLERS = { "request_human_docs": _h_request_human_docs, "list_chapters": _h_list_chapters, "chapter_detail": _h_chapter_detail, + "list_material_gaps": _h_list_material_gaps, + "material_gap_report": _h_material_gap_report, + "fill_material_gap": _h_fill_material_gap, + "waive_material_gap": _h_waive_material_gap, "list_bid_documents": _h_list_bid_documents, "list_reviews": _h_list_reviews, "bid_score_report": _h_bid_score_report, diff --git a/pipeline_bidding/bid_compose_capability.py b/pipeline_bidding/bid_compose_capability.py index 34bcda6..e614579 100644 --- a/pipeline_bidding/bid_compose_capability.py +++ b/pipeline_bidding/bid_compose_capability.py @@ -238,9 +238,27 @@ async def compose_bid(project_id, doc_name="", who=None, agent_id=None, task_id= to_state=DOC_DRAFT, who=who, agent_id=agent_id, detail="v%d %d章 %d字" % (ver, len(chapters), total_chars)) note = "" if ok_docx else ("(docx 生成失败,已产出 markdown:%s)" % err) - return True, ("标书已合成 v%d:%s\n章节 %d 个,约 %d 字,估算 %d 页,对应满分 %.1f%s\n" + # ── 材料缺失清单(2026-09-16 占位符规范):合成后自动出清单文件 ── + # 正文占位符在写作期已自动登记 bid_material_gaps;合成后生成 md 清单落 + # 项目工作空间,供人工补齐(fill_material_gap 自动替换正文占位符)。 + gap_note = "" + try: + from .bid_material_capability import material_gap_report + _ok_r, _rep = await material_gap_report(project_id) + if _ok_r and _rep and not _rep.startswith("(无材料缺口)"): + gap_path = os.path.join(ws, "material_gaps_v%d.md" % ver) + with open(gap_path, "w", encoding="utf-8") as f: + f.write(_rep) + _n_pend = _rep.count("⏳待补") + gap_note = ("\n材料缺失清单已生成:%s(待补 %d 项)。" + "可人工直接编辑正文补齐,或提供材料后由产线自动补齐" + "(fill_material_gap 工具替换占位符)。" % (gap_path, _n_pend)) + except Exception as _e: + logger.warning("material gap report failed: %s", str(_e)[:120]) + return True, ("标书已合成 v%d:%s\n章节 %d 个,约 %d 字,估算 %d 页,对应满分 %.1f%s%s\n" "系统将自动派发整书评分任务。" - % (ver, file_path, len(chapters), total_chars, page_est, max_sum, note)) + % (ver, file_path, len(chapters), total_chars, page_est, max_sum, + note, gap_note)) async def list_bid_documents(project_id, limit=20): diff --git a/pipeline_bidding/bid_material_capability.py b/pipeline_bidding/bid_material_capability.py new file mode 100644 index 0000000..be3235a --- /dev/null +++ b/pipeline_bidding/bid_material_capability.py @@ -0,0 +1,316 @@ +"""投标材料缺口能力(占位符规范,2026-09-16 用户裁定)。 + +规范(用户原话级):标书编写过程中材料缺失的,一律用占位符代替,不作为质量缺陷 +被 QC/评审退回,不中断标书编写。RAG/知识库能找到的直接用;找不到就插占位符继续写。 +完成标书编写(合成)后自动出一份材料缺失清单,可人工补齐,或人工提供材料后产线 +自动补齐(fill_material_gap 替换正文占位符)。 + +占位符统一格式(确定性,机制层可扫描):【材料待补:描述】 +- 描述写清缺什么材料(如「ISO9001 质量管理体系认证证书原件彩色扫描件」)。 +- write_chapter 机制层自动扫描正文里的占位符登记到 bid_material_gaps(指纹去重), + 写者即使忘记显式调用登记工具也不会漏。 +- 评审/QC 见占位符不扣质量分、不退回(占位符=材料信息流缺口,非写作缺陷)。 +""" + +import hashlib +import json +import logging +import os +import re + +from .bid_common import ( + get_db, new_id, rec_to_dict, rows_to_dicts, to_int, record, + resolve_project_id, CH_APPROVED, CH_WRITTEN, CH_WRITING, CH_REJECTED, +) + +logger = logging.getLogger("pipeline.bidding.material") + +TABLE = "bid_material_gaps" + +# 占位符统一格式:【材料待补:描述】(全角冒号,描述不含右括号) +PLACEHOLDER_RE = re.compile(r"【材料待补:([^】\n]{1,200})】") +PLACEHOLDER_FMT = "【材料待补:%s】" + +# 材料类型推断(按描述关键词,确定性;仅用于清单分组,不影响主流程) +_TYPE_KEYWORDS = ( + ("certificate", ("证书", "认证", "资质", "执照", "许可证", "ISO", "体系")), + ("screenshot", ("截图", "查询", "信用记录", "信用中国", "平台")), + ("contract", ("合同", "业绩", "案例", "中标通知", "验收")), + ("seal", ("盖章", "签章", "密封", "原件", "扫描件", "彩色")), +) + + +def infer_material_type(material_name): + """按描述关键词推断材料类型(确定性,兜底 other)。""" + nm = material_name or "" + for t, kws in _TYPE_KEYWORDS: + if any(k in nm for k in kws): + return t + return "other" + + +def _fingerprint(project_id, chapter_no, material_name): + """去重指纹:同项目+同章节+同材料描述只登记一条。""" + raw = "%s|%s|%s" % (project_id or "", (chapter_no or "").strip(), + (material_name or "").strip()) + return hashlib.sha1(raw.encode("utf-8")).hexdigest() + + +def find_placeholders(content): + """扫描正文里的全部占位符,返回去重后的材料描述列表(保持出现顺序)。""" + seen, out = set(), [] + for m in PLACEHOLDER_RE.finditer(content or ""): + desc = m.group(1).strip() + if desc and desc not in seen: + seen.add(desc) + out.append(desc) + return out + + +async def scan_and_register_gaps(sor, project_id, chapter_id, chapter_no, + content, requirement="", source_ref="", + who=None, agent_id=None): + """机制层:扫描章节正文占位符 → 登记/刷新 bid_material_gaps(指纹去重)。 + + write_chapter 每次落正文后调用。已 filled/waived 的缺口若正文又出现同指纹 + 占位符(人工把材料删了),重新置 pending(缺口复活)。返回本轮登记的缺口数。 + """ + descs = find_placeholders(content) + if not descs: + return 0 + n = 0 + for desc in descs: + fp = _fingerprint(project_id, chapter_no, desc) + recs = await sor.sqlExe( + "SELECT id, status FROM " + TABLE + + " WHERE project_id=${p}$ AND fingerprint=${f}$ LIMIT 1", + {"p": project_id, "f": fp}) + await sor.sqlExe("COMMIT", {}) + if recs: + gid = getattr(recs[0], "id", "") + st = getattr(recs[0], "status", "") + if st in ("filled", "waived"): + # 缺口复活:正文又出现占位符(材料被删/补齐回退) + await sor.sqlExe( + "UPDATE " + TABLE + " SET status='pending', filled_by=NULL, " + "fill_note=NULL, updated_at=NOW() WHERE id=${i}$", {"i": gid}) + await sor.sqlExe("COMMIT", {}) + await record(sor, project_id, TABLE, gid, "reopen", + to_state="pending", who=who, agent_id=agent_id, + detail="正文重现占位符,缺口复活:%s" % desc[:80]) + continue + gid = new_id() + await sor.C(TABLE, { + "id": gid, "project_id": project_id, + "chapter_id": chapter_id or "", "chapter_no": str(chapter_no or "")[:50], + "material_name": desc[:250], + "material_type": infer_material_type(desc), + "placeholder_text": (PLACEHOLDER_FMT % desc)[:400], + "fingerprint": fp, + "requirement": requirement or "", + "source_ref": (source_ref or "")[:480], + "status": "pending", + }) + await sor.sqlExe("COMMIT", {}) + await record(sor, project_id, TABLE, gid, "register", + to_state="pending", who=who, agent_id=agent_id, + detail="正文占位符自动登记:%s" % desc[:80]) + n += 1 + return n + + +# ══════════════ 查询 ══════════════ + +async def list_material_gaps(project_id, status=""): + """列出材料缺口清单(按章节排序;status 可选 pending/filled/waived)。""" + 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, chapter_no, material_name, material_type, status, " + "requirement, source_ref, filled_by, fill_note, created_at, updated_at " + "FROM " + TABLE + " WHERE project_id=${pid}$") + p = {"pid": project_id} + if status: + sql += " AND status=${st}$" + p["st"] = status + sql += " ORDER BY chapter_no, created_at" + 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 "")[:300] + return json.dumps({"total": len(out), + "pending": sum(1 for r in out if r.get("status") == "pending"), + "gaps": out}, ensure_ascii=False, default=str) + + +async def material_gap_report(project_id): + """生成材料缺失清单 markdown(合成后自动调用,也可人工随时查)。""" + 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 chapter_no, material_name, material_type, status, requirement, " + "source_ref, filled_by, fill_note FROM " + TABLE + + " WHERE project_id=${pid}$ ORDER BY chapter_no, created_at", + {"pid": project_id}) + await sor.sqlExe("COMMIT", {}) + rows = rows_to_dicts(recs) + if not rows: + return True, "(无材料缺口)" + lines = ["# 投标材料缺失清单", "", + "> 标书编写中材料缺失处已用占位符「【材料待补:…】」代替,不中断编写、", + "> 不作为质量缺陷退回。本清单列出全部缺口,可人工补齐(直接编辑正文)", + "> 或提供材料后由产线自动补齐(fill_material_gap)。", ""] + _TYPE_LABEL = {"certificate": "证书/资质", "screenshot": "查询截图", + "contract": "合同/业绩", "seal": "盖章/原件", "other": "其他"} + pend = [r for r in rows if r.get("status") == "pending"] + lines.append("合计 %d 项,待补 %d 项,已补 %d 项,放弃 %d 项。" % ( + len(rows), len(pend), + sum(1 for r in rows if r.get("status") == "filled"), + sum(1 for r in rows if r.get("status") == "waived"))) + lines.append("") + lines.append("| 章节 | 材料 | 类型 | 状态 | 招标要求/出处 | 补齐说明 |") + lines.append("|------|------|------|------|----------------|----------|") + for r in rows: + st = {"pending": "⏳待补", "filled": "✅已补", "waived": "⛔放弃"}.get( + str(r.get("status") or ""), str(r.get("status") or "")) + req = str(r.get("requirement") or r.get("source_ref") or "").replace("|", "/")[:60] + _fb = str(r.get("filled_by") or "") + note = (str(r.get("fill_note") or "") + ((" by " + _fb) if _fb else "")).replace("|", "/")[:40] + lines.append("| %s | %s | %s | %s | %s | %s |" % ( + r.get("chapter_no") or "-", str(r.get("material_name") or "")[:40], + _TYPE_LABEL.get(str(r.get("material_type") or ""), str(r.get("material_type") or "")), + st, req, note)) + return True, "\n".join(lines) + + +# ══════════════ 补齐(人工提供材料后产线自动补齐) ══════════════ + +async def fill_material_gap(gap_id, fill_content="", kb_doc_id="", + who=None, agent_id=None): + """补齐一个材料缺口:用真实材料内容替换章节正文里的占位符。 + + 两种供料方式(二选一,fill_content 优先): + - fill_content:直接给材料正文(如证书编号/日期/查询结论的文字描述,或 + markdown 图片行 ![证书](URL))。 + - kb_doc_id:材料已录入知识库(add_kb_doc),给文档 id,自动取其正文/文件路径。 + + 替换规则:把该章节正文里同指纹的占位符整体替换为 fill_content;章节若已 + approved 保持 approved(材料是真实的,无需重审),仅刷新正文+版本+审计。 + 缺口标 filled。返回 (ok, msg)。 + """ + if not gap_id: + return False, "缺少 gap_id(先 list_material_gaps 查到目标缺口 id)" + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + recs = await sor.sqlExe( + "SELECT * FROM " + TABLE + " WHERE id=${i}$", {"i": gap_id}) + await sor.sqlExe("COMMIT", {}) + if not recs: + return False, "缺口不存在: %s" % gap_id + g = rec_to_dict(recs[0]) + pid = g.get("project_id") + if g.get("status") == "filled": + return False, "该缺口已补齐(filled),无需重复操作" + + # 取供料内容 + content = (fill_content or "").strip() + if not content and kb_doc_id: + krecs = await sor.sqlExe( + "SELECT doc_name, doc_no, issuer, customer, contract_amount, " + "valid_until, file_path, LEFT(content_text, 4000) AS body " + "FROM bid_kb_docs WHERE id=${k}$", {"k": kb_doc_id}) + await sor.sqlExe("COMMIT", {}) + if not krecs: + return False, "知识库文档不存在: %s" % kb_doc_id + k = rec_to_dict(krecs[0]) + parts = ["**%s**" % (k.get("doc_name") or "材料")] + if k.get("doc_no"): + parts.append("编号:%s" % k["doc_no"]) + if k.get("issuer"): + parts.append("发证机构:%s" % k["issuer"]) + if k.get("valid_until"): + parts.append("有效期至:%s" % k["valid_until"]) + if k.get("customer"): + parts.append("客户:%s" % k["customer"]) + if k.get("contract_amount"): + parts.append("合同金额:%s" % k["contract_amount"]) + if k.get("body"): + parts.append(k["body"]) + if k.get("file_path"): + parts.append("(原件文件:%s)" % k["file_path"]) + content = "\n".join(parts) + if not content: + return False, "必须提供 fill_content(材料正文)或 kb_doc_id(知识库文档)之一" + + # 替换章节正文里的占位符 + chap_id = g.get("chapter_id") or "" + replaced = 0 + if chap_id: + crecs = await sor.sqlExe( + "SELECT id, project_id, status, version, content FROM bid_chapters " + "WHERE id=${c}$", {"c": chap_id}) + await sor.sqlExe("COMMIT", {}) + if crecs: + c = rec_to_dict(crecs[0]) + ph = g.get("placeholder_text") or (PLACEHOLDER_FMT % g.get("material_name", "")) + old_txt = c.get("content") or "" + if ph in old_txt: + new_txt = old_txt.replace(ph, content) + replaced = old_txt.count(ph) + ver = to_int(c.get("version"), 1) + 1 + await sor.sqlExe( + "UPDATE bid_chapters SET content=${t}$, word_count=${w}$, " + "version=${v}$, updated_at=NOW() WHERE id=${c}$", + {"t": new_txt, "w": len(new_txt), "v": ver, "c": chap_id}) + await sor.sqlExe("COMMIT", {}) + await record(sor, pid, "bid_chapters", chap_id, "material_fill", + who=who, agent_id=agent_id, + detail="补齐材料「%s」替换 %d 处占位符(章节状态 %s 保持)" + % (g.get("material_name", "")[:40], replaced, c.get("status"))) + + # 标 filled + await sor.sqlExe( + "UPDATE " + TABLE + " SET status='filled', filled_by=${fb}$, " + "fill_note=${fn}$, updated_at=NOW() WHERE id=${i}$", + {"fb": (who or "")[:64], "fn": (content or "")[:2000], "i": gap_id}) + await sor.sqlExe("COMMIT", {}) + await record(sor, pid, TABLE, gap_id, "fill", + from_state=g.get("status"), to_state="filled", + who=who, agent_id=agent_id, + detail="替换 %d 处占位符;材料:%s" % (replaced, content[:120])) + return True, ("缺口「%s」已补齐:替换章节 %s 正文 %d 处占位符。" + % (g.get("material_name", ""), g.get("chapter_no") or "-", replaced)) + + +async def waive_material_gap(gap_id, reason="", who=None, agent_id=None): + """放弃一个材料缺口(确认不提供该材料,正文占位符保留为如实披露)。""" + if not gap_id: + return False, "缺少 gap_id" + if not (reason or "").strip(): + return False, "放弃必须给出 reason(为何不提供该材料)" + db, dbname = get_db() + async with db.sqlorContext(dbname) as sor: + recs = await sor.sqlExe( + "SELECT project_id, material_name, status FROM " + TABLE + " WHERE id=${i}$", + {"i": gap_id}) + await sor.sqlExe("COMMIT", {}) + if not recs: + return False, "缺口不存在: %s" % gap_id + g = rec_to_dict(recs[0]) + await sor.sqlExe( + "UPDATE " + TABLE + " SET status='waived', filled_by=${fb}$, " + "fill_note=${fn}$, updated_at=NOW() WHERE id=${i}$", + {"fb": (who or "")[:64], "fn": reason[:2000], "i": gap_id}) + await sor.sqlExe("COMMIT", {}) + await record(sor, g.get("project_id"), TABLE, gap_id, "waive", + from_state=g.get("status"), to_state="waived", + who=who, agent_id=agent_id, detail=reason[:200]) + return True, "缺口「%s」已标放弃(正文占位符保留为如实披露)。" % g.get("material_name", "") diff --git a/pipeline_bidding/bid_write_capability.py b/pipeline_bidding/bid_write_capability.py index 48fe2c8..08d484f 100644 --- a/pipeline_bidding/bid_write_capability.py +++ b/pipeline_bidding/bid_write_capability.py @@ -135,12 +135,25 @@ async def write_chapter(chapter_id, content, summary="", mode="replace", "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 "" if mode == "append": - return True, ("章节「%s」已追加 %d 字符(累计 %d 字符,v%d)。" + return True, ("章节「%s」已追加 %d 字符(累计 %d 字符,v%d)%s。" "全部批次写完后回读核对总行数/总字数,再调 submit_chapter 提交评审。" - % (d.get("title"), len(txt), len(new_txt), ver + 1)) - return True, ("章节「%s」正文已保存(%d 字符,v%d)。确认完成后调用 submit_chapter 提交评审。" - % (d.get("title"), len(new_txt), ver + 1)) + % (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):