pipeline-bidding/pipeline_bidding/bid_write_capability.py
yumoqing 2fe7c784be feat(a3): 标准流程技术标模版裁剪链——bid_standard内置tech_analysis→tech_qc→template_confirm三段(与纯技术方案流程共用对账段,逻辑只此一份)
- 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行)
2026-09-17 16:23:54 +08:00

351 lines
20 KiB
Python
Raw Permalink 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.

"""章节编写能力(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")