pipeline-bidding/pipeline_bidding/bid_write_capability.py

187 lines
9.9 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.

"""章节编写能力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)
reqs = await sor.sqlExe(
"SELECT req_type, requirement, page_limit 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", {})
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": rows_to_dicts(reqs),
}
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字数统计。
append2026-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_gatemermaid/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 ""
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 submit_chapter(chapter_id, note="", who=None, agent_id=None):
"""提交章节评审writing → writtenreconciler 会自动派发评审任务)。"""
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")