- bid_flow A3段: 链激活五条件(有招标文件+两阶段启用+骨架QC已过+technical章全pending+无kind='summary'摘要行),任一不满足整链跳过=存量行为(甘肃等已开写项目零影响);完成判定用摘要行(换代闭环:新招标文件reset清bid_tech_items+作废pending确认待办) - C段门禁_tech_gate_on: 链激活只挡technical章,商务/报价章照常流转(HT_TEMPLATE_CONFIRM移出BLOCKING_HT_TYPES降级章节级) - confirm_tech_template双模式分流: 有非technical章=标准流程只删建technical章+全序列重排order_no(走查修复:原min+idx顺延在新骨架章数多于原技术章时与后置商务/报价章撞号,合成ORDER BY会把报价章插进技术章中间);无=纯技术流程整表清落;重确认守卫收窄到待替换范围 - record_tech_summary: 裁剪摘要落bid_tech_items kind='summary'(六类覆盖+模版来源+章数),幂等覆盖,失败不阻塞确认 - _reset_analysis_for_new_file: bid_tech_items入清空表清单+作废pending骨架确认待办 - chapter_detail: technical章注入tech_summary+list_tech_items提示(老库无表静默降级) - BID_FLOW_STAGES 14阶段: +tech_analysis(yes)/tech_qc(warn)/template_confirm(warn,dep=tech_analysis,裁头段必连带裁确认段) - bid_task_tree: analyst加tech维度组/QC加tech_items组/DIM_QCS补tech - README+DESIGN v1.6同步(3.7节A3设计+门禁表tech_template_confirm行)
351 lines
20 KiB
Python
351 lines
20 KiB
Python
"""章节编写能力(agent.bid_writer):查章节、看要求、写正文、提交评审。
|
||
|
||
对应业务:按照投标文件要求分章节编写投标文件。
|
||
一章一任务(任务 params.chapter_id),写完置 written 由评审角色接手(reconciler 自动派评审任务)。
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
|
||
from .bid_common import (
|
||
get_db, rec_to_dict, rows_to_dicts, json_loads, to_int, record,
|
||
resolve_project_id, CH_PENDING, CH_WRITING, CH_WRITTEN, CH_REJECTED, CH_APPROVED,
|
||
)
|
||
|
||
logger = logging.getLogger("pipeline.bidding.write")
|
||
|
||
TABLE = "bid_chapters"
|
||
MIN_CONTENT_LEN = 200 # 章节正文最小长度门槛(防空交付)
|
||
|
||
|
||
async def list_chapters(project_id, status=""):
|
||
"""列出章节(不含正文,避免上下文爆炸)。"""
|
||
try:
|
||
project_id = await resolve_project_id(project_id)
|
||
except ValueError as e:
|
||
return []
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
sql = ("SELECT id, chapter_no, title, section, status, version, revise_count, "
|
||
"word_count, review_score, max_score, assignee_role "
|
||
"FROM " + TABLE + " WHERE project_id=${pid}$")
|
||
p = {"pid": project_id}
|
||
if status:
|
||
sql += " AND status=${st}$"
|
||
p["st"] = status
|
||
sql += " ORDER BY order_no, chapter_no"
|
||
recs = await sor.sqlExe(sql, p)
|
||
await sor.sqlExe("COMMIT", {})
|
||
return rows_to_dicts(recs)
|
||
|
||
|
||
async def chapter_detail(chapter_id, with_content="1", length=12000):
|
||
"""章节详情:要点、对应评分项及得分规则、上一轮评审改进意见、当前正文。"""
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
recs = await sor.sqlExe("SELECT * FROM " + TABLE + " WHERE id=${cid}$", {"cid": chapter_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if not recs:
|
||
return "章节不存在: %s" % chapter_id
|
||
d = rec_to_dict(recs[0])
|
||
sids = json_loads(d.get("scoring_item_ids"), [])
|
||
items = []
|
||
if sids:
|
||
inlist = ",".join("'" + str(x).replace("'", "") + "'" for x in sids)
|
||
srecs = await sor.sqlExe(
|
||
"SELECT item_no, item_name, max_score, is_veto, scoring_rule, source_ref "
|
||
"FROM bid_scoring_items WHERE id IN (" + inlist + ")", {})
|
||
await sor.sqlExe("COMMIT", {})
|
||
items = rows_to_dicts(srecs)
|
||
# ── 编写要求:显式绑定优先,编号匹配回退+错位触发器(2026-09-16)──
|
||
# 根因实测:bid_doc_requirements 与 bid_chapters 两套 chapter_no 会漂移
|
||
# (抽取按招标书目录编号,骨架按投标文件装订编号),等值 JOIN 静默递错要求——
|
||
# 写者拿「营业执照」的要求写授权委托书。绑定(doc_requirement_ids)是正本;
|
||
# 未绑定时按编号回退,但用标题互含做词匹配触发器:不互含→输出⚠可疑警告
|
||
# (词匹配只报可疑,语义裁决权在 agent;判错用 list_doc_requirements +
|
||
# patch_chapters(doc_requirements_by_no) 落绑定后再写)。
|
||
rq_raw = d.get("doc_requirement_ids")
|
||
rq_ids = json_loads(rq_raw, [])
|
||
rq_bound = False
|
||
if rq_raw is not None:
|
||
# 显式绑定(含裁定为空绑定 "[]":语义裁决后确认本章无对应要求,
|
||
# 禁止编号回退再递错——2026-09-16 甘肃 1.9 实测:招标书明文
|
||
# "供应商无需提供信用记录查询结果",同号要求是别的章节的)
|
||
rq_bound = True
|
||
if rq_ids:
|
||
inlist = ",".join("'" + str(x).replace("'", "") + "'" for x in rq_ids)
|
||
rrecs = await sor.sqlExe(
|
||
"SELECT req_type, chapter_no, chapter_title, requirement, page_limit, "
|
||
"source_ref FROM bid_doc_requirements WHERE id IN (" + inlist + ") "
|
||
"OR (project_id=${pid}$ AND req_type IN ('format','seal','copies')) "
|
||
"ORDER BY order_no", {"pid": d.get("project_id")})
|
||
await sor.sqlExe("COMMIT", {})
|
||
reqs = rows_to_dicts(rrecs)
|
||
elif rq_bound:
|
||
# 显式空绑定:无章节级要求,但全局规则(format/seal/copies)仍适用
|
||
rrecs = await sor.sqlExe(
|
||
"SELECT req_type, chapter_no, chapter_title, requirement, page_limit, "
|
||
"source_ref FROM bid_doc_requirements WHERE project_id=${pid}$ "
|
||
"AND req_type IN ('format','seal','copies') ORDER BY order_no",
|
||
{"pid": d.get("project_id")})
|
||
await sor.sqlExe("COMMIT", {})
|
||
reqs = rows_to_dicts(rrecs)
|
||
else:
|
||
rrecs = await sor.sqlExe(
|
||
"SELECT req_type, chapter_no, chapter_title, requirement, page_limit, "
|
||
"source_ref FROM bid_doc_requirements "
|
||
"WHERE project_id=${pid}$ AND (chapter_no=${cno}$ OR req_type IN "
|
||
"('format','seal','copies')) ORDER BY order_no",
|
||
{"pid": d.get("project_id"), "cno": d.get("chapter_no")})
|
||
await sor.sqlExe("COMMIT", {})
|
||
reqs = rows_to_dicts(rrecs)
|
||
rq_warn = ""
|
||
rq_empty = rq_bound and not rq_ids # 显式空绑定(已裁定本章无章节级要求)
|
||
if not rq_bound:
|
||
ctitle = (d.get("title") or "").strip()
|
||
mismatched = []
|
||
for r in reqs:
|
||
if r.get("req_type") != "structure":
|
||
continue
|
||
rt = (r.get("chapter_title") or "").strip()
|
||
if rt and ctitle and (rt not in ctitle and ctitle not in rt):
|
||
mismatched.append("%s≠%s" % (rt[:20], ctitle[:20]))
|
||
if mismatched:
|
||
rq_warn = ("⚠ 编号匹配可疑(要求标题与章节标题不互含:%s)——按编号取到的编写要求"
|
||
"可能属于其他章节,动笔前先 list_doc_requirements 语义核对,"
|
||
"确属错位请 patch_chapters(doc_requirements_by_no=[…]) 落绑定。"
|
||
% ";".join(mismatched[:3]))
|
||
elif reqs:
|
||
rq_warn = "(本章要求为编号匹配、未落显式绑定;标题核对一致,可用)"
|
||
elif rq_empty:
|
||
rq_warn = ("(本章经裁决为空绑定:无章节级编写要求,仅列全局规则——按章节 outline "
|
||
"与招标原文相关条款编写,勿套用其他章节的要求)")
|
||
out = {
|
||
"id": d.get("id"), "chapter_no": d.get("chapter_no"), "title": d.get("title"),
|
||
"section": d.get("section"), "status": d.get("status"), "version": d.get("version"),
|
||
"revise_count": d.get("revise_count"), "outline": (d.get("outline") or "")[:6000],
|
||
"max_score": d.get("max_score"), "review_score": d.get("review_score"),
|
||
"review_comment": (d.get("review_comment") or "")[:6000],
|
||
"scoring_items": items,
|
||
"doc_requirements": reqs,
|
||
"doc_requirements_binding": "explicit" if rq_bound else "fallback_by_no",
|
||
}
|
||
# ── 技术方案部分:裁剪摘要注入(2026-09-17 裁剪链挂标准流程)──
|
||
# 技术章的骨架是按六类技术评估项裁剪出来的——写者必须知道裁剪依据才能
|
||
# 对标编写(评估项=招标技术需求原文摘录,是本章内容的第一基准)。
|
||
if (d.get("section") or "technical") == "technical":
|
||
try:
|
||
_sm = await sor.sqlExe(
|
||
"SELECT requirement FROM bid_tech_items WHERE project_id=${pid}$ "
|
||
"AND kind='summary' ORDER BY created_at DESC LIMIT 1",
|
||
{"pid": d.get("project_id")})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if _sm:
|
||
out["tech_summary"] = str(getattr(_sm[0], "requirement", "") or "")[:2000]
|
||
out["tech_items_hint"] = ("本章骨架由技术标模版按招标技术需求裁剪而来——"
|
||
"编写时用 list_tech_items 查六类技术评估项"
|
||
"(功能清单/功能点/硬件配置/技术架构/功能架构/"
|
||
"部署架构),逐项对标响应,漏项=方案书必漏需求")
|
||
except Exception:
|
||
pass # 表不存在(老库未迁移)等场景静默降级,不阻塞写者
|
||
if rq_warn:
|
||
out["doc_requirements_warning"] = rq_warn
|
||
if str(with_content) in ("1", "true", "True"):
|
||
out["content"] = (d.get("content") or "")[:to_int(length, 12000)]
|
||
return json.dumps(out, ensure_ascii=False, default=str)
|
||
|
||
|
||
async def write_chapter(chapter_id, content, summary="", mode="replace",
|
||
who=None, agent_id=None):
|
||
"""写入章节正文:mode=replace 覆盖 / append 追加,版本+1,字数统计。
|
||
|
||
append(2026-09-16 新增,甘肃项目 2.2 章实测):超长章节(如 403 行技术
|
||
参数响应表)一次 write 超出单条工具调用可承载长度写不进去,评审明确要求
|
||
「机制侧开放 write_chapter 分段追加,按标的分批每批 50~80 行」。
|
||
追加时自动在两段之间补换行;篇幅门禁按累计总字数判定。
|
||
"""
|
||
if not chapter_id:
|
||
return False, "缺少 chapter_id"
|
||
txt = content or ""
|
||
mode = (mode or "replace").strip().lower()
|
||
if mode not in ("replace", "append"):
|
||
return False, "mode 只支持 replace(覆盖)或 append(追加)"
|
||
if len(txt.strip()) < MIN_CONTENT_LEN and mode == "replace":
|
||
return False, ("章节正文过短(%d 字符 < %d),拒绝写入。请按招标文件要求与评分规则"
|
||
"写到操作层面(量化指标/流程步骤/责任人/表单),不要写空话。"
|
||
% (len(txt.strip()), MIN_CONTENT_LEN))
|
||
if mode == "append" and not txt.strip():
|
||
return False, "追加内容为空"
|
||
# ── 配图形态硬门禁(2026-09-15 用户要求:标书章节的图必须 t2i/i2i 真图)──
|
||
# 确定性判定(复用 pipeline_service.diagram_gate),mermaid/ASCII 伪图拒绝写入;
|
||
# 平台无图像模型时正文如实标注「配图缺失」即豁免。
|
||
try:
|
||
from pipeline_service.diagram_gate import find_fake_diagrams, gate_message
|
||
_dg = find_fake_diagrams(txt)
|
||
if _dg:
|
||
return False, gate_message(_dg, scene="标书章节")
|
||
except ImportError:
|
||
pass # 宿主未装 pipeline-service 时放行(逃逸阀,与产线零接触分层一致)
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
recs = await sor.sqlExe(
|
||
"SELECT id, project_id, status, version, title FROM " + TABLE + " WHERE id=${cid}$",
|
||
{"cid": chapter_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if not recs:
|
||
return False, "章节不存在: %s" % chapter_id
|
||
d = rec_to_dict(recs[0])
|
||
if d.get("status") == CH_APPROVED:
|
||
return False, "章节已评审通过(approved),不可覆盖;如需修改请先由评审/评分角色退回"
|
||
ver = to_int(d.get("version"), 1)
|
||
if mode == "append":
|
||
old_txt = d.get("content") or ""
|
||
new_txt = (old_txt.rstrip() + "\n\n" + txt.strip() + "\n") if old_txt.strip() \
|
||
else (txt.strip() + "\n")
|
||
else:
|
||
new_txt = txt
|
||
await sor.sqlExe(
|
||
"UPDATE " + TABLE + " SET content=${c}$, word_count=${w}$, status=${st}$, "
|
||
"version=${v}$, updated_at=NOW() WHERE id=${cid}$",
|
||
{"c": new_txt, "w": len(new_txt), "st": CH_WRITING, "v": ver + 1, "cid": chapter_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
await record(sor, d.get("project_id"), TABLE, chapter_id,
|
||
"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 ""
|
||
# ── 章节镜像文件(2026-09-16 用户裁定:章节写文件,用户看文件即时发现问题)──
|
||
# 库仍是正本;镜像 = {项目根}/chapters/{order:03d}_{章节号}_{标题}.md,
|
||
# 图片本地化到 chapters/media/ 同目录。失败不阻塞(try 在 mirror 模块内)。
|
||
_mir = ""
|
||
try:
|
||
from .bid_chapter_mirror import sync_chapter_mirror
|
||
_mir = await sync_chapter_mirror(sor, d.get("project_id"), chapter_id)
|
||
except Exception as _e: # noqa: BLE001
|
||
logger.warning("chapter mirror failed: %s", str(_e)[:120])
|
||
if mode == "append":
|
||
return True, ("章节「%s」已追加 %d 字符(累计 %d 字符,v%d)%s。"
|
||
"全部批次写完后回读核对总行数/总字数,再调 submit_chapter 提交评审。"
|
||
% (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 revise_chapter_text(chapter_id, edits="", who=None, agent_id=None):
|
||
"""定点替换已定稿章节正文的局部文本(2026-09-16 甘肃 1.9 删章后实测需要)。
|
||
|
||
write_chapter 对 approved 章节拒写(防误覆盖),但存在必须修正的合规性
|
||
局部改动(悬空交叉引用/违反招标书明文),整章重写既昂贵又有丢内容风险。
|
||
本工具做「断言式」定点替换:
|
||
- edits = [{"find": 原文片段, "replace": 新文本}],find 必须在正文中
|
||
**恰好出现一次**(0 次或多次都拒绝整个操作,防误伤/防歧义);
|
||
- 全部命中才落库(先校验后写,单条不合法整批拒绝);
|
||
- 版本 +1、审计记录、镜像文件同步;approved 状态保持不变(局部合规修正
|
||
不触发整章重审——评审语义与分数未变)。
|
||
|
||
用于:删章后的悬空引用清理、与招标书明文冲突的表述纠正、跨章编号引用
|
||
错位修正。不用于内容扩写(那走评审退回重写)。
|
||
"""
|
||
import json as _json
|
||
if not chapter_id:
|
||
return False, "缺少 chapter_id"
|
||
if isinstance(edits, str):
|
||
try:
|
||
edits = _json.loads(edits)
|
||
except Exception as e: # noqa: BLE001
|
||
return False, "edits 解析失败(须为 JSON 数组):%s" % str(e)[:120]
|
||
if not isinstance(edits, list) or not edits:
|
||
return False, "edits 必须是非空数组:[{find, replace}]"
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
recs = await sor.sqlExe(
|
||
"SELECT id, project_id, chapter_no, title, content, version, status "
|
||
"FROM " + TABLE + " WHERE id=${cid}$", {"cid": chapter_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if not recs:
|
||
return False, "章节不存在: %s" % chapter_id
|
||
d = rec_to_dict(recs[0])
|
||
txt = d.get("content") or ""
|
||
norm, applied = [], []
|
||
for i, e in enumerate(edits):
|
||
if not isinstance(e, dict):
|
||
return False, "edits[%d] 必须是对象 {find, replace}" % i
|
||
find = e.get("find")
|
||
rep = e.get("replace")
|
||
if not isinstance(find, str) or not find.strip():
|
||
return False, "edits[%d].find 必须是非空字符串" % i
|
||
if rep is None or not isinstance(rep, str):
|
||
return False, "edits[%d].replace 必须是字符串(可为空串=删除)" % i
|
||
n = txt.count(find)
|
||
if n != 1:
|
||
return False, ("edits[%d] 不满足唯一性断言:find 在章节「%s」中出现 %d 次"
|
||
"(要求恰好 1 次)。请扩大上下文片段使其唯一。find 前80字=%s"
|
||
% (i, d.get("title"), n, find[:80]))
|
||
norm.append((find, rep))
|
||
# 全部断言通过才落库
|
||
new_txt = txt
|
||
for find, rep in norm:
|
||
new_txt = new_txt.replace(find, rep, 1)
|
||
applied.append(find[:40])
|
||
ver = to_int(d.get("version"), 1)
|
||
await sor.sqlExe(
|
||
"UPDATE " + TABLE + " SET content=${c}$, word_count=${w}$, "
|
||
"version=${v}$, updated_at=NOW() WHERE id=${cid}$",
|
||
{"c": new_txt, "w": len(new_txt), "v": ver + 1, "cid": chapter_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
await record(sor, d.get("project_id"), TABLE, chapter_id, "revise_text",
|
||
from_state=d.get("status"), to_state=d.get("status"),
|
||
who=who, agent_id=agent_id,
|
||
detail="定点替换 %d 处:%s" % (len(norm), "|".join(applied)[:400]))
|
||
_mir = ""
|
||
try:
|
||
from .bid_chapter_mirror import sync_chapter_mirror
|
||
_mir = await sync_chapter_mirror(sor, d.get("project_id"), chapter_id)
|
||
except Exception as _e: # noqa: BLE001
|
||
logger.warning("chapter mirror failed: %s", str(_e)[:120])
|
||
return True, ("章节「%s」定点替换 %d 处(v%d,%d 字符,状态 %s 不变)%s"
|
||
% (d.get("title"), len(norm), ver + 1, len(new_txt),
|
||
d.get("status"), ";镜像 %s" % _mir if _mir else ""))
|
||
|
||
|
||
async def submit_chapter(chapter_id, note="", who=None, agent_id=None):
|
||
"""提交章节评审:writing → written(reconciler 会自动派发评审任务)。"""
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
recs = await sor.sqlExe(
|
||
"SELECT id, project_id, status, title, content FROM " + TABLE + " WHERE id=${cid}$",
|
||
{"cid": chapter_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if not recs:
|
||
return False, "章节不存在: %s" % chapter_id
|
||
d = rec_to_dict(recs[0])
|
||
if len((d.get("content") or "").strip()) < MIN_CONTENT_LEN:
|
||
return False, "章节正文为空或过短,请先 write_chapter 写入正文"
|
||
if d.get("status") not in (CH_WRITING, CH_PENDING, CH_REJECTED):
|
||
return False, "当前状态 %s 不可提交(需 writing/pending/rejected)" % d.get("status")
|
||
await sor.sqlExe(
|
||
"UPDATE " + TABLE + " SET status=${st}$, updated_at=NOW() WHERE id=${cid}$ "
|
||
"AND status=${old}$",
|
||
{"st": CH_WRITTEN, "old": d.get("status"), "cid": chapter_id})
|
||
chk = await sor.sqlExe("SELECT status FROM " + TABLE + " WHERE id=${cid}$", {"cid": chapter_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
cur = getattr(chk[0], "status", "") if chk else ""
|
||
if cur != CH_WRITTEN:
|
||
return False, "提交失败(CAS):当前状态 %s" % cur
|
||
await record(sor, d.get("project_id"), TABLE, chapter_id, "submit",
|
||
from_state=d.get("status"), to_state=CH_WRITTEN,
|
||
who=who, agent_id=agent_id, detail=(note or "")[:480])
|
||
return True, "章节「%s」已提交评审" % d.get("title")
|