"""整书评分能力(agent.bid_scorer):按评分项和得分规则对整个标书打分。 对应业务:对整个标书评审,并按照评分项和得分规则对标书进行评分;如果不合适,提出改进意见, 按照意见对问题所在章节修改、评审……直到得分通过。 机制:add_score_item_result 逐项落分(含 improvement + target_chapter_id)→ finalize_bid_score 汇总判定;不通过则把命中章节置 rejected(reconciler 自动派重写任务),通过则发人类交付确认任务。 """ import json import logging from .bid_common import ( get_db, new_id, rec_to_dict, rows_to_dicts, to_float, to_int, get_thresholds, record, create_human_task, find_human_task, resolve_project_id, CH_APPROVED, CH_REJECTED, DOC_DRAFT, DOC_REVIEWING, DOC_PASSED, DOC_REJECTED, HT_DELIVERY_CONFIRM, ) logger = logging.getLogger("pipeline.bidding.score") async def _latest_doc(sor, project_id, document_id=""): if document_id: recs = await sor.sqlExe("SELECT * FROM bid_documents WHERE id=${d}$", {"d": document_id}) else: recs = await sor.sqlExe( "SELECT * FROM bid_documents WHERE project_id=${p}$ ORDER BY version DESC LIMIT 1", {"p": project_id}) await sor.sqlExe("COMMIT", {}) return rec_to_dict(recs[0]) if recs else {} async def start_bid_score(project_id, document_id="", who=None, agent_id=None, task_id=""): """开启一轮整书评分:创建 bid_reviews(scope=whole) 并返回 review_id + 评分项清单。""" try: project_id = await resolve_project_id(project_id) except ValueError as e: return False, str(e) db, dbname = get_db() async with db.sqlorContext(dbname) as sor: doc = await _latest_doc(sor, project_id, document_id) if not doc: return False, "项目下无合成标书,请先 compose_bid" th = await get_thresholds(sor) rn = await sor.sqlExe( "SELECT COUNT(*) AS c FROM bid_reviews WHERE project_id=${p}$ AND scope='whole'", {"p": project_id}) await sor.sqlExe("COMMIT", {}) rnd = to_int(getattr(rn[0], "c", 0) if rn else 0) + 1 # 同一标书版本已有未完成评审 → 复用,避免重复开轮 open_r = await sor.sqlExe( "SELECT id FROM bid_reviews WHERE project_id=${p}$ AND scope='whole' " "AND document_id=${d}$ AND score IS NULL ORDER BY created_at DESC LIMIT 1", {"p": project_id, "d": doc.get("id")}) await sor.sqlExe("COMMIT", {}) if open_r: rid = getattr(open_r[0], "id", "") else: rid = new_id() await sor.C("bid_reviews", { "id": rid, "project_id": project_id, "scope": "whole", "document_id": doc.get("id"), "round": rnd, "reviewer": (who or "agent.bid_scorer")[:60], "review_type": "agent", "pass_ratio": th["bid_pass_ratio"], "passed": "0", "max_score": to_float(doc.get("max_score"), 0.0) or None, "task_id": task_id or "", }) await sor.sqlExe( "UPDATE bid_documents SET status=${st}$, review_id=${r}$, updated_at=NOW() " "WHERE id=${d}$", {"st": DOC_REVIEWING, "r": rid, "d": doc.get("id")}) await sor.sqlExe("COMMIT", {}) items = await sor.sqlExe( "SELECT s.id, s.item_no, s.item_name, s.max_score, s.is_veto, s.section, " "s.scoring_rule, s.target_chapter_no FROM bid_scoring_items s " "WHERE s.project_id=${p}$ ORDER BY s.section, s.order_no", {"p": project_id}) await sor.sqlExe("COMMIT", {}) rows = rows_to_dicts(items, limit=300) for r in rows: r["scoring_rule"] = (r.get("scoring_rule") or "")[:800] return True, json.dumps({ "review_id": rid, "round": rnd, "document_id": doc.get("id"), "document_version": doc.get("version"), "file_path": doc.get("file_path"), "pass_ratio": th["bid_pass_ratio"], "max_round": th["max_round"], "scoring_items": rows, "note": ("请逐个评分项调用 add_score_item_result 落分(不合格项必须给 improvement 与 " "target_chapter_no/target_chapter_id),全部落完后调用 finalize_bid_score 汇总。"), }, ensure_ascii=False, default=str) async def add_score_item_result(review_id, scoring_item_id="", score=0, max_score="", reason="", improvement="", target_chapter_no="", target_chapter_id="", item_name="", who=None, agent_id=None): """逐个评分项落分。不合格项(得分率 < 门限)必须给 improvement + 目标章节。""" if not review_id: return False, "缺少 review_id" db, dbname = get_db() async with db.sqlorContext(dbname) as sor: rv = await sor.sqlExe("SELECT * FROM bid_reviews WHERE id=${r}$", {"r": review_id}) await sor.sqlExe("COMMIT", {}) if not rv: return False, "评审记录不存在: %s" % review_id review = rec_to_dict(rv[0]) pid = review.get("project_id") th = await get_thresholds(sor) it = {} if scoring_item_id: irec = await sor.sqlExe( "SELECT * FROM bid_scoring_items WHERE id=${i}$", {"i": scoring_item_id}) await sor.sqlExe("COMMIT", {}) it = rec_to_dict(irec[0]) if irec else {} ms = to_float(max_score, 0.0) or to_float(it.get("max_score"), 0.0) if ms <= 0: return False, "缺少 max_score(评分项满分)" sc = to_float(score, -1.0) if sc < 0: return False, "score 必须为数字" ratio = sc / ms weak = ratio < th["bid_pass_ratio"] if weak and not (improvement or "").strip(): return False, ("该评分项得分率 %.0f%% 低于门限,必须给出 improvement(改进意见)" "与目标章节 target_chapter_no/target_chapter_id" % (ratio * 100)) cid = target_chapter_id or "" cno = (target_chapter_no or it.get("target_chapter_no") or "").strip() if not cid and cno: crec = await sor.sqlExe( "SELECT id FROM bid_chapters WHERE project_id=${p}$ AND chapter_no=${c}$ LIMIT 1", {"p": pid, "c": cno}) await sor.sqlExe("COMMIT", {}) cid = getattr(crec[0], "id", "") if crec else "" if not cid and scoring_item_id: crec = await sor.sqlExe( "SELECT id FROM bid_chapters WHERE project_id=${p}$ " "AND scoring_item_ids LIKE ${kw}$ LIMIT 1", {"p": pid, "kw": "%" + scoring_item_id + "%"}) await sor.sqlExe("COMMIT", {}) cid = getattr(crec[0], "id", "") if crec else "" exist = await sor.sqlExe( "SELECT id FROM bid_scores WHERE review_id=${r}$ AND scoring_item_id=${i}$ LIMIT 1", {"r": review_id, "i": scoring_item_id or ""}) await sor.sqlExe("COMMIT", {}) data = { "project_id": pid, "review_id": review_id, "scoring_item_id": scoring_item_id or "", "item_name": (item_name or it.get("item_name") or "")[:250], "score": sc, "max_score": ms, "reason": reason or "", "improvement": improvement or "", "target_chapter_id": cid, } if exist: data["id"] = getattr(exist[0], "id", "") await sor.U("bid_scores", data) else: data["id"] = new_id() await sor.C("bid_scores", data) await sor.sqlExe("COMMIT", {}) return True, ("评分项「%s」已落分 %.2f/%.2f%s" % (data["item_name"], sc, ms, "(需改进 → 章节 %s)" % (cid or cno or "未映射") if weak else "")) async def finalize_bid_score(review_id, comments="", who=None, agent_id=None): """汇总评分:判定通过/不通过。 不通过 → 命中章节置 rejected(带改进意见)→ reconciler 自动派重写任务; 通过 → 标书 passed + 发人类交付确认任务; 轮次超上限 → 抛人工介入。 """ if not review_id: return False, "缺少 review_id" db, dbname = get_db() async with db.sqlorContext(dbname) as sor: rv = await sor.sqlExe("SELECT * FROM bid_reviews WHERE id=${r}$", {"r": review_id}) await sor.sqlExe("COMMIT", {}) if not rv: return False, "评审记录不存在: %s" % review_id review = rec_to_dict(rv[0]) pid = review.get("project_id") th = await get_thresholds(sor) srecs = await sor.sqlExe( "SELECT * FROM bid_scores WHERE review_id=${r}$", {"r": review_id}) await sor.sqlExe("COMMIT", {}) scores = rows_to_dicts(srecs, limit=500) if not scores: return False, "本轮尚无评分明细,请先用 add_score_item_result 逐项落分" total = sum(to_float(s.get("score"), 0.0) for s in scores) maxsum = sum(to_float(s.get("max_score"), 0.0) for s in scores) # 否决项检查:满分项得 0 分且 is_veto=1 视为致命 veto_ids = await sor.sqlExe( "SELECT id, item_name FROM bid_scoring_items WHERE project_id=${p}$ AND is_veto='1'", {"p": pid}) await sor.sqlExe("COMMIT", {}) veto_map = {getattr(r, "id", ""): getattr(r, "item_name", "") for r in (veto_ids or [])} veto_fail = [veto_map[s["scoring_item_id"]] for s in scores if s.get("scoring_item_id") in veto_map and to_float(s.get("score"), 0.0) <= 0] ratio = (total / maxsum) if maxsum > 0 else 0.0 passed = (ratio >= th["bid_pass_ratio"]) and not veto_fail weak = [s for s in scores if to_float(s.get("max_score"), 0.0) > 0 and to_float(s.get("score"), 0.0) / to_float(s.get("max_score"), 1.0) < th["bid_pass_ratio"]] summary_lines = ["总分 %.2f/%.2f(%.1f%%,门限 %.0f%%)" % (total, maxsum, ratio * 100, th["bid_pass_ratio"] * 100)] if veto_fail: summary_lines.append("⛔ 否决项未响应:" + "、".join(veto_fail[:5])) for s in weak[:20]: summary_lines.append("- %s %.1f/%.1f → %s" % (s.get("item_name"), to_float(s.get("score")), to_float(s.get("max_score")), (s.get("improvement") or "")[:200])) summary = "\n".join(summary_lines) if comments: summary += "\n\n评审说明:" + comments await sor.sqlExe( "UPDATE bid_reviews SET score=${sc}$, max_score=${ms}$, passed=${ps}$, " "comments=${cm}$, updated_at=NOW() WHERE id=${r}$", {"sc": total, "ms": maxsum, "ps": "1" if passed else "0", "cm": summary[:60000], "r": review_id}) doc_status = DOC_PASSED if passed else DOC_REJECTED await sor.sqlExe( "UPDATE bid_documents SET status=${st}$, total_score=${sc}$, max_score=${ms}$, " "pass_ratio=${pr}$, updated_at=NOW() WHERE id=${d}$", {"st": doc_status, "sc": total, "ms": maxsum, "pr": th["bid_pass_ratio"], "d": review.get("document_id") or ""}) await sor.sqlExe("COMMIT", {}) await record(sor, pid, "bid_documents", review.get("document_id") or "", "score", to_state=doc_status, who=who, agent_id=agent_id, detail="%.2f/%.2f round=%s" % (total, maxsum, review.get("round"))) if passed: exist = await find_human_task(sor, pid, HT_DELIVERY_CONFIRM, status="pending") if not exist: await create_human_task( sor, pid, HT_DELIVERY_CONFIRM, "标书评分通过,请确认交付", ("标书评分已达通过门限。\n%s\n\n" "请人工复核并确认交付(用印/密封/份数/递交方式按招标文件要求执行)。" % summary[:3000]), assignee_role="owner.superuser") await sor.sqlExe("COMMIT", {}) return True, "整书评分通过:%s\n已发人工交付确认任务。" % summary # 不通过:轮次门禁 if to_int(review.get("round"), 1) >= th["max_round"]: await create_human_task( sor, pid, "general", "标书评分连续不达标需人工介入(第 %s 轮)" % review.get("round"), ("整书评分已进行 %s 轮仍未达门限:\n%s\n\n" "请人工决策:调整评分预期/补充关键素材/重构章节,处理完成后完成本任务。" % (review.get("round"), summary[:3000])), assignee_role="owner.superuser") await sor.sqlExe("COMMIT", {}) return True, ("整书评分不通过且已达轮次上限 %d → 已抛人工介入。\n%s" % (th["max_round"], summary)) # 把改进意见落到问题章节:approved → rejected(reconciler 会派重写任务) touched = 0 for s in weak: cid = s.get("target_chapter_id") or "" if not cid: continue crec = await sor.sqlExe( "SELECT id, status, revise_count, title FROM bid_chapters WHERE id=${c}$", {"c": cid}) await sor.sqlExe("COMMIT", {}) if not crec: continue c = rec_to_dict(crec[0]) imp = ("【整书评分改进意见 · 第%s轮】评分项「%s」得 %.1f/%.1f:\n%s" % (review.get("round"), s.get("item_name"), to_float(s.get("score")), to_float(s.get("max_score")), s.get("improvement") or "")) await sor.sqlExe( "UPDATE bid_chapters SET status=${st}$, review_comment=${cm}$, " "revise_count=${rc}$, updated_at=NOW() WHERE id=${c}$", {"st": CH_REJECTED, "cm": imp[:60000], "rc": to_int(c.get("revise_count"), 0) + 1, "c": cid}) await sor.sqlExe("COMMIT", {}) await record(sor, pid, "bid_chapters", cid, "score_reject", from_state=c.get("status"), to_state=CH_REJECTED, who=who, agent_id=agent_id, detail=(s.get("improvement") or "")[:400]) touched += 1 if touched == 0 and weak: await create_human_task( sor, pid, "general", "整书评分不通过但改进意见未映射到章节", ("以下评分项不达标,但 target_chapter_id 为空,系统无法自动定位待改章节:\n%s\n\n" "请人工指定应修改的章节(或补 target_chapter_no 后重新评分)。" % summary[:3000]), assignee_role="owner.superuser") await sor.sqlExe("COMMIT", {}) return True, ("整书评分不通过:%s\n已退回 %d 个问题章节重写,系统将自动派发重写任务。" % (summary, touched)) async def request_chapter_revise(chapter_id, improvement, who=None, agent_id=None): """按改进意见直接退回单个章节(评分员定点回退用)。""" if not (improvement or "").strip(): return False, "必须给出 improvement(改进意见)" from .bid_review_capability import reject_chapter return await reject_chapter(chapter_id, comment=improvement, who=who, agent_id=agent_id) async def bid_score_report(project_id, limit=50): """评分报告:最近一轮整书评分的总分 + 逐项明细。""" try: project_id = await resolve_project_id(project_id) except ValueError as e: return "缺少 project_id" db, dbname = get_db() async with db.sqlorContext(dbname) as sor: rv = await sor.sqlExe( "SELECT * FROM bid_reviews WHERE project_id=${p}$ AND scope='whole' " "ORDER BY created_at DESC LIMIT 1", {"p": project_id}) await sor.sqlExe("COMMIT", {}) if not rv: return "尚无整书评分记录" r = rec_to_dict(rv[0]) srecs = await sor.sqlExe( "SELECT item_name, score, max_score, LEFT(reason,300) AS reason, " "LEFT(improvement,300) AS improvement, target_chapter_id " "FROM bid_scores WHERE review_id=${r}$ ORDER BY created_at LIMIT " + str(to_int(limit, 50)), {"r": r.get("id")}) await sor.sqlExe("COMMIT", {}) return json.dumps({ "review_id": r.get("id"), "round": r.get("round"), "score": r.get("score"), "max_score": r.get("max_score"), "passed": r.get("passed"), "pass_ratio": r.get("pass_ratio"), "comments": (r.get("comments") or "")[:3000], "items": rows_to_dicts(srecs, limit=to_int(limit, 50)), }, ensure_ascii=False, default=str)