335 lines
17 KiB
Python
335 lines
17 KiB
Python
"""整书评分能力(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)
|