pipeline-bidding/pipeline_bidding/bid_score_capability.py

335 lines
17 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_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)