271 lines
16 KiB
Python
271 lines
16 KiB
Python
"""章节评审能力(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 以  独占一行嵌入;"
|
||
"删除 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)
|