"""投标材料缺口能力(占位符规范,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", "")