pipeline-bidding/pipeline_bidding/bid_review_capability.py

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