pipeline-bidding/pipeline_bidding/bid_review_capability.py

272 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()
# ── 实质性响应门禁(2026-09-16 用户报障根治):指南体/表单大面积待填必退 ──
# submit_chapter 已拦一道;此处兜底存量正文/绕行写入,章节评审级即给反馈
# (不必等整书评分),意见自动附带。确定性检测,不采信评分器满分。
try:
from pipeline_service.substance_gate import find_substance_problems, gate_message
_sg = find_substance_problems(c.get("content") or "")
_sg_note = gate_message(_sg, scene="本章") if _sg else ""
except ImportError:
_sg_note = ""
if _sg_note:
passed = False
comments = (_sg_note + "\n" + (comments or "")).strip()
_substance_fail = bool(_sg_note) # 实质门禁不通过 → 禁止 material_missing 豁免绕过
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")
# 守卫(2026-09-16):实质门禁不通过的章节禁止用 material_missing 豁免绕过——
# 「指南体/表单大面积待填」不是实体材料缺失,是写作本身没写实质内容;
# 若放行会把我上一轮加的占位符豁免变成质量门禁的后门(实测满分放行的帮凶)。
if _mat_missing and _substance_fail:
_mat_missing = False
comments = ("【material_missing 豁免被机制拒绝】本章未通过实质性响应门禁"
"(是指南体/表单待填,非实体材料缺失),不适用材料缺失豁免通道,"
"必须按实质门禁意见重写正文。\n" + (comments or "")).strip()
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))
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)