pipeline-bidding/pipeline_bidding/bid_review_capability.py
yumoqing b40f54d63f feat(bidding v1.2): 编写分商务/技术角色+篇幅门禁+样板标书规范+并发编写
- 章节按section分流: business→agent.bid_biz_writer(新角色), technical/price→agent.bid_writer
- 骨架生成自动推断section(_infer_section标题关键词)
- 篇幅门禁机制层强制: review_chapter字数低于门限必退+自动附意见
  (参数bid_chapter_min_words=800/bid_chapter_min_words_biz=500)
- 并发编写: 对账器单轮最多派bid_write_concurrency(4)个编写任务
- 样板标书: bid_kb_docs doc_type=sample_bid码值; extract_writing_spec工具
  (PM抽章节结构/篇幅口径/表格图示/商务要素, 落bid_doc_requirements writing_spec)
- DESIGN.md v1.2
2026-08-31 15:40:03 +08:00

194 lines
10 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_reviewer):对照招标文件要求 + 投标文件要求 + 得分规则逐章打分。
对应业务:每章节完成后对照招标文件要求和投标文件要求评审,分数不够给出改进意见重做。
达标 → approved;不达标 → rejected + 改进意见 + revise_count+1(reconciler 自动派重写任务);
超过重做上限 → 抛人类介入任务,不无限重做。
"""
import json
import logging
from .bid_common import (
get_db, new_id, rec_to_dict, rows_to_dicts, json_loads, to_float, to_int,
get_thresholds, record, create_human_task, find_human_task, resolve_project_id,
CH_WRITTEN, CH_REVIEWING, CH_APPROVED, CH_REJECTED,
)
logger = logging.getLogger("pipeline.bidding.review")
TABLE = "bid_chapters"
async def _chapter(sor, chapter_id):
recs = await sor.sqlExe("SELECT * FROM " + TABLE + " WHERE id=${cid}$", {"cid": chapter_id})
await sor.sqlExe("COMMIT", {})
return rec_to_dict(recs[0]) if recs else {}
async def review_chapter(chapter_id, score, max_score="", comments="", detail="",
who=None, agent_id=None, task_id=""):
"""章节评审落库 + 按门限判定通过/退回。
score / max_score:按该章节对应评分项的得分规则打分(max_score 省略时取章节 max_score)。
comments:不通过时必须给出可执行的改进意见(缺失则拒绝,避免"退回但不说怎么改")。
"""
if not chapter_id:
return False, "缺少 chapter_id"
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
c = await _chapter(sor, chapter_id)
if not c:
return False, "章节不存在: %s" % chapter_id
pid = c.get("project_id")
th = await get_thresholds(sor)
sc = to_float(score, -1.0)
if sc < 0:
return False, "score 必须为数字(0~满分)"
ms = to_float(max_score, 0.0) or to_float(c.get("max_score"), 0.0)
if ms <= 0:
ms = 100.0 # 章节未挂评分项时按百分制评审
ratio = sc / ms if ms > 0 else 0.0
passed = ratio >= th["chapter_pass_ratio"]
# ── 篇幅门禁:字数低于门限必退(防内容过短/缺失),意见自动附带 ──
wc = to_int(c.get("word_count"), 0)
min_w = th["chapter_min_words_biz"] if (c.get("section") or "technical") == "business" \
else th["chapter_min_words"]
if wc < min_w:
passed = False
short_note = ("【篇幅不达标】正文 %d 字 < 门限 %d 字(%s标),内容疑似过短或缺失,"
"请按评分规则子维度补全到操作层。" % (
wc, min_w, "商务" if (c.get("section") or "") == "business" else "技术"))
comments = (short_note + "\n" + (comments or "")).strip()
if not passed and not (comments or "").strip():
return False, ("评审不通过必须给出改进意见(comments),"
"写清缺哪些评分维度、应补什么内容、对照哪条得分规则")
# 轮次 = 该章节已有评审数 + 1
rn = await sor.sqlExe(
"SELECT COUNT(*) AS c FROM bid_reviews WHERE chapter_id=${cid}$ AND scope='chapter'",
{"cid": chapter_id})
await sor.sqlExe("COMMIT", {})
rnd = to_int(getattr(rn[0], "c", 0) if rn else 0) + 1
rid = new_id()
await sor.C("bid_reviews", {
"id": rid, "project_id": pid, "scope": "chapter", "chapter_id": chapter_id,
"round": rnd, "reviewer": (who or "agent.bid_reviewer")[:60],
"review_type": "agent", "score": sc, "max_score": ms,
"pass_ratio": th["chapter_pass_ratio"], "passed": "1" if passed else "0",
"comments": comments or "", "detail": (detail or "")[:60000],
"task_id": task_id or "",
})
await sor.sqlExe("COMMIT", {})
if passed:
await sor.sqlExe(
"UPDATE " + TABLE + " SET status=${st}$, review_score=${sc}$, max_score=${ms}$, "
"review_comment=${cm}$, updated_at=NOW() WHERE id=${cid}$",
{"st": CH_APPROVED, "sc": sc, "ms": ms,
"cm": (comments or "评审通过")[:60000], "cid": chapter_id})
await sor.sqlExe("COMMIT", {})
await record(sor, pid, TABLE, chapter_id, "review_pass",
from_state=c.get("status"), to_state=CH_APPROVED,
who=who, agent_id=agent_id,
detail="%.2f/%.2f (%.0f%%)" % (sc, ms, ratio * 100))
return True, ("章节「%s」评审通过 %.2f/%.2f(%.0f%%,门限 %.0f%%)"
% (c.get("title"), sc, ms, ratio * 100, th["chapter_pass_ratio"] * 100))
revise = to_int(c.get("revise_count"), 0) + 1
await sor.sqlExe(
"UPDATE " + TABLE + " SET status=${st}$, review_score=${sc}$, max_score=${ms}$, "
"review_comment=${cm}$, revise_count=${rc}$, updated_at=NOW() WHERE id=${cid}$",
{"st": CH_REJECTED, "sc": sc, "ms": ms, "cm": comments or "",
"rc": revise, "cid": chapter_id})
await sor.sqlExe("COMMIT", {})
await record(sor, pid, TABLE, chapter_id, "review_reject",
from_state=c.get("status"), to_state=CH_REJECTED,
who=who, agent_id=agent_id,
detail="%.2f/%.2f revise=%d" % (sc, ms, revise))
if revise > th["max_revise"]:
exist = await find_human_task(sor, pid, "general", status="pending")
title = "章节反复不达标需人工介入:%s %s" % (c.get("chapter_no"), c.get("title"))
if not (exist and exist.get("title") == title):
await create_human_task(
sor, pid, "general", title,
("章节「%s %s」已重做 %d 次仍未达门限(最近得分 %.2f/%.2f,门限 %.0f%%)。\n"
"最新改进意见:\n%s\n\n"
"请人工判断:补充素材/调整章节范围/降低目标分,处理完成后完成本任务。"
% (c.get("chapter_no"), c.get("title"), revise, sc, ms,
th["chapter_pass_ratio"] * 100, (comments or "")[:1500])),
assignee_role="owner.superuser")
await sor.sqlExe("COMMIT", {})
return True, ("章节「%s」评审不通过 %.2f/%.2f,已达重做上限 %d 次 → 已抛人工介入任务"
% (c.get("title"), sc, ms, th["max_revise"]))
return True, ("章节「%s」评审不通过 %.2f/%.2f(%.0f%% < 门限 %.0f%%),已退回重写"
"(第 %d 次),系统将自动派发重写任务"
% (c.get("title"), sc, ms, ratio * 100,
th["chapter_pass_ratio"] * 100, revise))
async def approve_chapter(chapter_id, comment="", who=None, agent_id=None):
"""人工/评审直接通过章节(written/reviewing/rejected → approved)。"""
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
c = await _chapter(sor, chapter_id)
if not c:
return False, "章节不存在: %s" % chapter_id
await sor.sqlExe(
"UPDATE " + TABLE + " SET status=${st}$, review_comment=${cm}$, updated_at=NOW() "
"WHERE id=${cid}$ AND status IN ('written','reviewing','rejected','writing')",
{"st": CH_APPROVED, "cm": (comment or "人工通过")[:60000], "cid": chapter_id})
cur = await _chapter(sor, chapter_id)
if cur.get("status") != CH_APPROVED:
return False, "通过失败:当前状态 %s" % cur.get("status")
await record(sor, c.get("project_id"), TABLE, chapter_id, "approve",
from_state=c.get("status"), to_state=CH_APPROVED,
who=who, agent_id=agent_id, detail=comment)
return True, "章节「%s」已通过" % c.get("title")
async def reject_chapter(chapter_id, comment="", who=None, agent_id=None):
"""退回章节重写(需给改进意见)。"""
if not (comment or "").strip():
return False, "退回必须给出改进意见(comment)"
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
c = await _chapter(sor, chapter_id)
if not c:
return False, "章节不存在: %s" % chapter_id
revise = to_int(c.get("revise_count"), 0) + 1
await sor.sqlExe(
"UPDATE " + TABLE + " SET status=${st}$, review_comment=${cm}$, "
"revise_count=${rc}$, updated_at=NOW() WHERE id=${cid}$",
{"st": CH_REJECTED, "cm": comment[:60000], "rc": revise, "cid": chapter_id})
await sor.sqlExe("COMMIT", {})
await record(sor, c.get("project_id"), TABLE, chapter_id, "reject",
from_state=c.get("status"), to_state=CH_REJECTED,
who=who, agent_id=agent_id, detail=comment[:480])
return True, "章节「%s」已退回重写(第 %d 次)" % (c.get("title"), revise)
async def list_reviews(project_id, scope="", chapter_id="", limit=50):
"""查评审记录(章节级/整书级)。"""
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, scope, chapter_id, document_id, round, reviewer, score, max_score, "
"passed, pass_ratio, LEFT(comments, 500) AS comments, created_at "
"FROM bid_reviews WHERE project_id=${pid}$")
p = {"pid": project_id}
if scope:
sql += " AND scope=${sc}$"
p["sc"] = scope
if chapter_id:
sql += " AND chapter_id=${cid}$"
p["cid"] = chapter_id
sql += " ORDER BY created_at DESC LIMIT " + str(to_int(limit, 50))
recs = await sor.sqlExe(sql, p)
await sor.sqlExe("COMMIT", {})
return rows_to_dicts(recs)