317 lines
15 KiB
Python
317 lines
15 KiB
Python
"""投标材料缺口能力(占位符规范,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 图片行 )。
|
||
- 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", "")
|