"""章节评审能力(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)