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