feat(material): 材料缺失占位符规范(2026-09-16用户裁定)——编写中实体材料缺失先search_bid_kb,找到直接用,找不到插统一占位符【材料待补:描述】继续写,不作为质量缺陷被QC/评审退回,不中断编写。新增bid_material_capability(list/report/fill/waive+scan_and_register_gaps机制层)+表bid_material_gaps(m0035);write_chapter落正文自动扫描占位符登记缺口(指纹去重,写者忘调工具也不漏,登记失败不阻塞写作);compose_bid合成后自动出material_gaps_vN.md清单;fill_material_gap用真实材料替换正文占位符(approved章节保持通过不重审,支持fill_content或kb_doc_id两种供料);会话agent挂4工具(list_material_gaps/material_gap_report/fill_material_gap/waive_material_gap)
This commit is contained in:
parent
09af403809
commit
842023772d
@ -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` +
|
||||
|
||||
1
models/bid_material_gaps.json
Normal file
1
models/bid_material_gaps.json
Normal file
@ -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"]}]}
|
||||
@ -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,
|
||||
|
||||
@ -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):
|
||||
|
||||
316
pipeline_bidding/bid_material_capability.py
Normal file
316
pipeline_bidding/bid_material_capability.py
Normal file
@ -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 图片行 )。
|
||||
- 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", "")
|
||||
@ -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):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user