208 lines
11 KiB
Python
208 lines
11 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="",
|
||
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()
|
||
|
||
# ── 配图形态门禁(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))
|
||
|
||
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)
|