pipeline-bidding/pipeline_bidding/bid_review_capability.py

271 lines
16 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="",
material_missing="", who=None, agent_id=None, task_id=""):
"""章节评审落库 + 按门限判定通过/退回。
score / max_score:按该章节对应评分项的得分规则打分(max_score 省略时取章节 max_score)。
comments:不通过时必须给出可执行的改进意见(缺失则拒绝,避免"退回但不说怎么改")。
material_missing(2026-09-16 甘肃项目 2.1.5/2.1.7 实测 bug):评审判定失分全部因
「实体材料缺失」(证书扫描件/截图/业绩证明未回传)、写作层已达可写上限时传 1——
章节如实披露缺口后通过(不烧写作轮次、不冻结流转),同时发 material_supply
信息流待办通知人类补料,材料到位后可走增补轮重写该章。
与 2026-09-15 用户裁定一致:资质缺口=信息流,非门禁流。
"""
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()
# ── 配图形态门禁(2026-09-15):mermaid/ASCII 伪图必退(write_chapter 已拦,
# 此处兜底存量正文/绕行写入),意见自动附带 ──
try:
from pipeline_service.diagram_gate import find_fake_diagrams
_dg = find_fake_diagrams(c.get("content") or "")
except ImportError:
_dg = []
if _dg:
passed = False
dg_note = ("【配图不合规】正文含伪图(%s)。图必须用 invoke_model 调 t2i/i2i 模型"
"生成真实图片,产物 URL 以 ![图N 标题](URL) 独占一行嵌入;"
"删除 mermaid/ASCII 字符画后重写提交。" % ";".join(sorted(set(_dg))))
comments = (dg_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))
# ── 实体材料缺失通道(2026-09-16):如实标注后通过 + 信息流待办 ──
# 旧行为 bug:证书扫描件/截图未回传 → 评审每轮打低分退回 → 写者无法变出
# 材料仍重写 → 烧光 revise_count → 章节冻结阻塞合成。与 2026-09-15 用户裁定
# 「资质缺口=信息流待办,非门禁流」对齐:写作层已达上限、失分全因实体材料
# 未回传 → 章节通过(真实分数+材料缺失披露注记,整书评分如实反映),
# 同时发 material_supply 信息流待办通知人类补料,不冻结任何流转。
_mat_missing = str(material_missing).strip().lower() in ("1", "true", "yes", "y")
if _mat_missing:
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": ("【材料缺失如实披露后放行(2026-09-15 用户裁定:资质缺口=信息流"
"非门禁流)】实体材料未回传致失分 %.2f/%.2f,写作层已达可写上限,"
"不计写作重做轮次;已发人类供料待办,材料到位后可增补。\n"
% (sc, ms) + (comments or ""))[:60000],
"cid": chapter_id})
await sor.sqlExe("COMMIT", {})
await record(sor, pid, TABLE, chapter_id, "review_material_missing_waive",
from_state=c.get("status"), to_state=CH_APPROVED,
who=who, agent_id=agent_id,
detail="%.2f/%.2f 实体材料缺失如实披露放行(不计轮次)" % (sc, ms))
title = "章节实体材料缺口(信息流):%s %s" % (c.get("chapter_no"), c.get("title"))
await create_human_task(
sor, pid, "material_supply", title,
("章节「%s %s」评审判定:写作层已达可写上限,失分 %.2f/%.2f 全部因实体材料"
"未回传。章节已如实披露缺口后放行(不阻塞标书推进)。\n\n评审意见:\n%s\n\n"
"如需补材料(证书扫描件/截图/业绩证明),上传到项目工作空间/知识库后"
"可在增补轮重写该章;不补则完成本待办即可。"
% (c.get("chapter_no"), c.get("title"), sc, ms, (comments or "")[:1500])),
assignee_role="owner.superuser", dedup_by_title=True)
await sor.sqlExe("COMMIT", {})
return True, ("章节「%s」评审 %.2f/%.2f:失分全部因实体材料缺失(非写作缺陷),"
"已如实披露放行 + 发信息流供料待办「%s」,不阻塞标书推进。"
% (c.get("title"), sc, ms, title))
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))
# ── 标书状态联动作废(2026-09-16 甘肃项目实测 bug)──
# 章节退回重写时,若 bid_documents 已合成且停在 draft/reviewing/passed,
# 对账器 E 段只对「无文档 or 文档 rejected」派合成任务 → 重写后的新正文
# 永远进不了交付物(甘肃 bid_v1 即此状:章节被退回、文档仍 passed、
# 待办仍指向旧版)。退回即把当前标书置 rejected,全章 approved 后
# 自动重派合成(prev_version 递增出新版),形成完整闭环。
try:
await sor.sqlExe(
"UPDATE bid_documents SET status='rejected', updated_at=NOW() "
"WHERE project_id=${pid}$ AND status IN ('draft','reviewing','passed')",
{"pid": pid})
# 交付确认待办同步取消(指向旧版标书,留着=僵尸待办误导用户确认旧版)
await sor.sqlExe(
"UPDATE pipeline_human_tasks SET status='cancelled', updated_at=NOW() "
"WHERE project_id=${pid}$ AND task_type=${tt}$ AND status='pending'",
{"pid": pid, "tt": "bid_delivery_confirm"})
await sor.sqlExe("COMMIT", {})
except Exception as e: # noqa: BLE001
logger.warning("章节退回联动标书作废失败 pid=%s: %s", pid, e)
if revise > th["max_revise"]:
# 2026-09-16 标题级去重:多章各自达上限各发一条(旧代码类型级 dedup,
# 第 2~N 章升级信号被第一条 general pending 吞掉——甘肃项目 1/2.1.5/2.2
# 三章达上限只有 2.1.7 一条待办,其余静默丢失)。
title = "章节反复不达标需人工介入:%s %s" % (c.get("chapter_no"), c.get("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", dedup_by_title=True)
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)