pipeline-bidding/pipeline_bidding/bid_score_capability.py

455 lines
24 KiB
Python
Raw Permalink 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,
create_role_task,
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]),
)
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])),
)
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])
# 退回也同步镜像(文件头状态/评审分/改进意见要与库一致,2026-09-16)
try:
from .bid_chapter_mirror import sync_chapter_mirror
await sync_chapter_mirror(sor, pid, cid)
except Exception as e: # noqa: BLE001
logger.warning("章节镜像同步失败(不阻断): chapter=%s err=%s", cid, e)
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]),
)
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)
R_SCORER = "agent.bid_scorer"
async def reject_delivery(human_task_id, comment="", operator_id=None):
"""用户退回交付(专用端点调用,agent 无此工具——退回是用户裁决)。
2026-09-17 用户报障:交付确认待办只有「确认交付」单按钮,没有退回通道——
复核发现问题(内容/用印/密封/口径)时无法把标书打回,只能确认或干等。
对齐 tech_template_confirm 的 confirm/reject 双通道模式。
机制(复用既有两级回退循环,不另建链路):
① 待办置 rejected,退回意见存 result_data(tech_template 驳回同款约定);
② 最新 passed 标书置 rejected(否则 G 段按 passed 立刻重发确认待办);
③ 派 scorer「交付驳回处理」任务(task_kind=bid_delivery_reject,params 带
reject_comment)——scorer 按用户意见 request_chapter_revise 退回问题章节,
之后 C→D→E→F→G 既有链路自动:重写→重审→重合成→重评分→重发交付确认;
④ 全部 approved 章节 revise_count 复位(用户级退回=新一轮修订预算;不复位则
上轮烧满 max_revise 的章节被 C 段门禁拦死,退回即死锁——对齐 C1 段人工
完成待办后复位 revise_count 的既有语义);
⑤ E 段配套门禁(bid_flow):bid_delivery_reject 任务在办期间不派合成——
否则章节还没被退回就抢先重合成同内容新版,用户意见被静默绕过。
"""
if not human_task_id:
return False, "缺少 human_task_id"
if not operator_id:
return False, "未登录"
if not (comment or "").strip():
return False, "退回必须填写意见(说明标书哪里需要修改)"
db, dbname = get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT * FROM pipeline_human_tasks WHERE id=${h}$", {"h": human_task_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return False, "待办不存在: %s" % human_task_id
ht = rec_to_dict(recs[0])
if ht.get("task_type") != HT_DELIVERY_CONFIRM:
return False, "该待办不是交付确认类型(%s)" % ht.get("task_type")
if ht.get("status") != "pending":
return False, "待办已处理(当前 %s)" % ht.get("status")
project_id = ht.get("project_id") or ""
if not project_id:
return False, "待办未挂项目"
# 权限:复用引擎 _check_confirm_operator(同机构 + owner.superuser/项目创建者)
# ——与 flow_plan/tech_template 确认门语义一致,不自写校验(2026-09-15 实测
# 自写「仅同机构」会误拒超管)。
try:
from pipeline_service.flow_plan_capability import _check_confirm_operator
ok_op, err_op = await _check_confirm_operator(
sor, {"project_id": project_id}, operator_id)
if not ok_op:
return False, err_op or "无权退回该项目交付"
except ImportError:
prec = await sor.sqlExe("SELECT org_id FROM sd_projects WHERE id=${p}$",
{"p": project_id})
await sor.sqlExe("COMMIT", {})
proj_org = getattr(prec[0], "org_id", "") if prec else ""
from pipeline_service.human_task_capability import _get_user_org
user_org = await _get_user_org(sor, operator_id)
if proj_org and user_org != proj_org:
return False, "仅同机构用户可退回该项目交付"
# ① 待办置 rejected + 意见落 result_data(与 tech_template 驳回分支同款约定;
# 注:pipeline_human_tasks 无 updated_at 列)
await sor.sqlExe(
"UPDATE pipeline_human_tasks SET status='rejected', result_data=${rd}$, "
"submitted_by=${u}$, submitted_at=NOW() WHERE id=${h}$ AND status='pending'",
{"rd": json.dumps({"decision": "reject", "comment": comment[:2000]},
ensure_ascii=False),
"u": operator_id, "h": human_task_id})
await sor.sqlExe("COMMIT", {})
await record(sor, project_id, "pipeline_human_tasks", human_task_id,
"delivery_rejected", to_state="rejected", who=operator_id,
detail=comment[:500])
# ② 最新标书 passed → rejected(draft/reviewing 本就未过评分,不动)
doc = await _latest_doc(sor, project_id)
doc_id = doc.get("id") or ""
if doc and doc.get("status") == DOC_PASSED:
await sor.sqlExe(
"UPDATE bid_documents SET status=${st}$, updated_at=NOW() "
"WHERE id=${d}$ AND status=${os}$",
{"st": DOC_REJECTED, "d": doc_id, "os": DOC_PASSED})
await sor.sqlExe("COMMIT", {})
# ④ approved 章节重做预算复位(幂等:只动 approved,正在重写的 rejected 章不动)
await sor.sqlExe(
"UPDATE bid_chapters SET revise_count=0 WHERE project_id=${p}$ "
"AND status=${st}$ AND revise_count>0", {"p": project_id, "st": CH_APPROVED})
await sor.sqlExe("COMMIT", {})
# ③ 派 scorer 交付驳回处理任务(在办任务幂等:已有同类在办不重派)
from .bid_flow import _open_tech_tasks
if await _open_tech_tasks(sor, project_id, "bid_delivery_reject") > 0:
return True, "交付已退回(驳回处理任务已在办,勿重复退回)"
prec = await sor.sqlExe("SELECT name FROM sd_projects WHERE id=${p}$",
{"p": project_id})
await sor.sqlExe("COMMIT", {})
pname = getattr(prec[0], "name", "") if prec else ""
tid = await create_role_task(
sor, project_id, R_SCORER,
"%s 交付驳回处理(按用户退回意见打回章节)" % pname,
{"stage": "score", "task_kind": "bid_delivery_reject",
"document_id": doc_id, "reject_comment": comment[:60000]})
await sor.sqlExe("COMMIT", {})
await record(sor, project_id, "bid_documents", doc_id, "delivery_reject",
from_state=DOC_PASSED, to_state=DOC_REJECTED,
who=operator_id, detail=comment[:480])
return True, ("交付已退回:标书 v%s 置 rejected,评分员将按你的意见退回问题章节"
"重写(重写→重审→重合成→重评分后自动重发交付确认)。"
% (doc.get("version") or "?"))