pipeline-bidding/pipeline_bidding/bid_material_capability.py

317 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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