清理(前三步归商机产线): - 删邮箱采集: bid_mail.py/bid_mail_accounts表与页面/首页按钮 - 删招标审批状态机: TD_*/HT_TENDER_APPROVAL/摘要审批工具族 - bid_tenders瘦身为解析抽取的商务要素(去邮件字段) QC契合度审核: - 新表bid_qc_reviews(qc_type/round/fit_score/pass_score/passed/improvement) - bid_qc_capability: start_qc/finish_qc/list_qc/qc_report, 四类产出逐类审核 - 判定: 10分制打分, >9.5放行(参数bid_qc_pass_score); 不通过给改进意见 +清空产出+对账器重派解析任务带意见重做; 超轮次抛qc_escalation人工 - bid_flow对账器A2门禁阶段: 幂等/在办去重/轮次守卫/重做不计首跑上限 - 驾驶舱: qc_report工具+解析QC审核菜单; 参数读appbase表+默认兜底
271 lines
14 KiB
Python
271 lines
14 KiB
Python
"""解析产出 QC 契合度审核能力(agent.qc)。
|
||
|
||
对应业务:招标文件解析的每类产出物(评分项+得分规则 / 资质 / 投标文件要求 / 章节骨架)
|
||
必须由 QC 对照招标文件原文逐项核对契合度,按 10 分制评分;**得分高于通过分(默认 9.5)才算完成**,
|
||
否则给出改进意见 → 清空该产出物 → 对账器重派解析任务带意见重做 → 重审。超轮次上限抛人工。
|
||
|
||
机制分工(与产线既有约定一致):
|
||
- 状态流转归机制:bid_flow 对账器派 QC 任务、判门禁、清空产出物触发重做;
|
||
- 本模块只固化操作原语:开轮(start_qc)/ 落分(finish_qc)/ 查询(qc_report、list_qc)。
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
|
||
from .bid_common import (
|
||
get_db, new_id, rec_to_dict, rows_to_dicts, to_float, to_int,
|
||
record, create_human_task, find_human_task, get_thresholds, resolve_project_id,
|
||
QC_TYPE_SCORING, QC_TYPE_QUALS, QC_TYPE_REQS, QC_TYPE_OUTLINE, QC_TYPES,
|
||
HT_QC_ESCALATION,
|
||
)
|
||
|
||
logger = logging.getLogger("pipeline.bidding.qc")
|
||
|
||
# 每类产出的审核清单(start_qc 时随评审记录返回,指导 LLM 对照原文核对)
|
||
QC_DIMENSIONS = {
|
||
QC_TYPE_SCORING: (
|
||
"评分项+得分规则契合度审核(对照招标文件评标办法原文):\n"
|
||
"1. 完整性:评标办法中的每个评分项是否全部录入,无漏项、无编造项;\n"
|
||
"2. 满分准确:每项满分与原文一致(商务/技术/价格分合计核对);\n"
|
||
"3. 得分规则:档位/扣分/加分细则与原文一致,无概括失真;\n"
|
||
"4. 否决项:废标/否决条款全部标 is_veto=1,无漏标;\n"
|
||
"5. 归属正确:业务/技术/价格归类与出处(source_ref)可回溯原文。"
|
||
),
|
||
QC_TYPE_QUALS: (
|
||
"所需资质契合度审核(对照招标文件投标人资格要求原文):\n"
|
||
"1. 完整性:资格要求中的每项资质/证书/业绩门槛全部录入,无漏项;\n"
|
||
"2. 原文一致:要求描述与原文一致(等级/有效期/联合体口径);\n"
|
||
"3. 必需性:强制性条款标 is_mandatory=1,加分性资质不得误标必需;\n"
|
||
"4. 无编造:招标文件未要求的资质不得出现。"
|
||
),
|
||
QC_TYPE_REQS: (
|
||
"投标文件要求契合度审核(对照招标文件投标文件格式/要求原文):\n"
|
||
"1. 章节结构(structure)完整且顺序与原文一致,章节号/标题准确;\n"
|
||
"2. 格式/密封/份数/截止时间等要求(format/seal/copies/deadline)无遗漏;\n"
|
||
"3. 页数限制与原文一致;\n"
|
||
"4. 出处(source_ref)可回溯。"
|
||
),
|
||
QC_TYPE_OUTLINE: (
|
||
"章节骨架契合度审核(对照投标文件要求与评分项):\n"
|
||
"1. 章节与 req_type=structure 的要求一一对应,无缺章、无多余章;\n"
|
||
"2. 章节号/标题/顺序与投标文件要求一致;\n"
|
||
"3. 评分项挂载正确:每个评分项能落到对应章节(target_chapter_no/名称匹配),无错挂漏挂;\n"
|
||
"4. 章节要点来自评分标准原文枚举的子维度,不是通用目录。"
|
||
),
|
||
}
|
||
|
||
# 每类产出对应的落库表(QC 不通过时清空重做的对象)
|
||
QC_OUTPUT_TABLE = {
|
||
QC_TYPE_SCORING: "bid_scoring_items",
|
||
QC_TYPE_QUALS: "bid_qualifications",
|
||
QC_TYPE_REQS: "bid_doc_requirements",
|
||
QC_TYPE_OUTLINE: "bid_chapters",
|
||
}
|
||
|
||
|
||
async def _qc_type_valid(sor, qc_type):
|
||
if qc_type in QC_TYPES:
|
||
return qc_type, ""
|
||
return "", "qc_type 非法(应为 %s)" % "/".join(QC_TYPES)
|
||
|
||
|
||
async def list_qc(project_id, qc_type="", limit=50):
|
||
"""列出项目 QC 审核记录。"""
|
||
try:
|
||
project_id = await resolve_project_id(project_id)
|
||
except ValueError:
|
||
return []
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
sql = ("SELECT id, qc_type, round, reviewer, fit_score, pass_score, passed, "
|
||
"LEFT(improvement, 500) AS improvement, task_id, created_at "
|
||
"FROM bid_qc_reviews WHERE project_id=${pid}$")
|
||
p = {"pid": project_id}
|
||
if qc_type:
|
||
sql += " AND qc_type=${qt}$"
|
||
p["qt"] = qc_type
|
||
sql += " ORDER BY qc_type, round DESC, created_at DESC LIMIT " + str(to_int(limit, 50))
|
||
recs = await sor.sqlExe(sql, p)
|
||
await sor.sqlExe("COMMIT", {})
|
||
return rows_to_dicts(recs)
|
||
|
||
|
||
async def start_qc(qc_type, project_id="", who=None, agent_id=None, task_id=""):
|
||
"""开启一轮 QC 审核:创建/复用当轮评审记录,返回审核清单 + 该产出物的待核内容。
|
||
|
||
LLM 随后读招标文件原文(list_tender_files/read_tender_file)+ 查产出物表,
|
||
对照审核清单逐项核对,得出 0~10 契合度分,调 finish_qc 落分。
|
||
"""
|
||
qc_type, err = await _qc_type_valid(None, qc_type)
|
||
if err:
|
||
return False, err
|
||
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:
|
||
th = await get_thresholds(sor)
|
||
# 轮次 = 该类型已有审核数 + 1;同轮未落分的记录复用(幂等)
|
||
rn = await sor.sqlExe(
|
||
"SELECT COUNT(*) AS c FROM bid_qc_reviews WHERE project_id=${p}$ AND qc_type=${t}$",
|
||
{"p": project_id, "t": qc_type})
|
||
await sor.sqlExe("COMMIT", {})
|
||
rnd = to_int(getattr(rn[0], "c", 0) if rn else 0) + 1
|
||
open_r = await sor.sqlExe(
|
||
"SELECT id, round FROM bid_qc_reviews WHERE project_id=${p}$ AND qc_type=${t}$ "
|
||
"AND fit_score IS NULL ORDER BY created_at DESC LIMIT 1",
|
||
{"p": project_id, "t": qc_type})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if open_r:
|
||
rid = getattr(open_r[0], "id", "")
|
||
rnd = to_int(getattr(open_r[0], "round", 1), 1)
|
||
else:
|
||
rid = new_id()
|
||
await sor.C("bid_qc_reviews", {
|
||
"id": rid, "project_id": project_id, "qc_type": qc_type, "round": rnd,
|
||
"reviewer": (who or "agent.qc")[:60], "review_type": "agent",
|
||
"pass_score": th["qc_pass_score"], "passed": "0",
|
||
"task_id": task_id or "",
|
||
})
|
||
await sor.sqlExe("COMMIT", {})
|
||
|
||
# 待核产出物(截断防上下文爆炸)
|
||
out_rows, note = [], ""
|
||
tbl = QC_OUTPUT_TABLE[qc_type]
|
||
if qc_type == QC_TYPE_OUTLINE:
|
||
recs = await sor.sqlExe(
|
||
"SELECT chapter_no, title, scoring_item_ids, max_score, LEFT(outline, 300) AS outline "
|
||
"FROM bid_chapters WHERE project_id=${p}$ ORDER BY order_no, chapter_no LIMIT 200",
|
||
{"p": project_id})
|
||
elif qc_type == QC_TYPE_SCORING:
|
||
recs = await sor.sqlExe(
|
||
"SELECT section, item_no, item_name, max_score, is_veto, target_chapter_no, "
|
||
"LEFT(scoring_rule, 400) AS scoring_rule, source_ref "
|
||
"FROM bid_scoring_items WHERE project_id=${p}$ ORDER BY section, order_no LIMIT 300",
|
||
{"p": project_id})
|
||
elif qc_type == QC_TYPE_QUALS:
|
||
recs = await sor.sqlExe(
|
||
"SELECT qual_name, is_mandatory, LEFT(requirement, 400) AS requirement "
|
||
"FROM bid_qualifications WHERE project_id=${p}$ ORDER BY is_mandatory DESC LIMIT 300",
|
||
{"p": project_id})
|
||
else:
|
||
recs = await sor.sqlExe(
|
||
"SELECT req_type, chapter_no, chapter_title, LEFT(requirement, 400) AS requirement, "
|
||
"page_limit, source_ref FROM bid_doc_requirements WHERE project_id=${p}$ "
|
||
"ORDER BY order_no LIMIT 300", {"p": project_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
out_rows = rows_to_dicts(recs, limit=300)
|
||
if not out_rows:
|
||
note = "该类产出物为空,无法审核(应等解析任务产出后再开审)。"
|
||
return True, json.dumps({
|
||
"review_id": rid, "qc_type": qc_type, "round": rnd,
|
||
"pass_score": th["qc_pass_score"], "max_round": th["qc_max_round"],
|
||
"checklist": QC_DIMENSIONS[qc_type],
|
||
"outputs_table": tbl, "outputs": out_rows, "note": note,
|
||
"instruction": ("请读招标文件原文(list_tender_files → read_tender_file 分段)并逐条对照上述清单核对,"
|
||
"得出 0~10 的契合度分(可带一位小数),然后调 finish_qc 落分。"
|
||
"不通过(得分未高于通过分)时 improvement 必须写清:漏了什么/错在哪/对照原文哪一处,"
|
||
"否则工具拒绝提交。"),
|
||
}, ensure_ascii=False, default=str)
|
||
|
||
|
||
async def finish_qc(review_id, fit_score, improvement="", comments="",
|
||
who=None, agent_id=None, task_id=""):
|
||
"""落分并判定:得分 > 通过分 → 该类产出放行;否则清空产出物 + 触发解析重做。
|
||
|
||
fit_score:0~10(10 分制契合度分)。
|
||
"""
|
||
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_qc_reviews WHERE id=${r}$", {"r": review_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if not rv:
|
||
return False, "QC 评审记录不存在: %s" % review_id
|
||
review = rec_to_dict(rv[0])
|
||
pid = review.get("project_id")
|
||
qc_type = review.get("qc_type")
|
||
th = await get_thresholds(sor)
|
||
sc = to_float(fit_score, -1.0)
|
||
if sc < 0 or sc > 10:
|
||
return False, "fit_score 必须为 0~10 的数字"
|
||
passed = sc > th["qc_pass_score"] # 严格高于通过分才算完成(9.5 → 需 >9.5)
|
||
if not passed and not (improvement or "").strip():
|
||
return False, ("QC 不通过(%.2f 未高于 %.2f)必须给出 improvement:\n"
|
||
"逐项写清缺了什么、错在哪、对照招标文件原文哪一处,解析角色据此重做。"
|
||
% (sc, th["qc_pass_score"]))
|
||
|
||
summary_lines = ["契合度 %.2f/10(通过线 >%.2f)→ %s"
|
||
% (sc, th["qc_pass_score"], "通过" if passed else "不通过")]
|
||
if comments:
|
||
summary_lines.append("审核说明:" + comments)
|
||
if improvement:
|
||
summary_lines.append("改进意见:" + improvement)
|
||
await sor.sqlExe(
|
||
"UPDATE bid_qc_reviews SET fit_score=${sc}$, passed=${ps}$, improvement=${imp}$, "
|
||
"comments=${cm}$, updated_at=NOW() WHERE id=${r}$",
|
||
{"sc": sc, "ps": "1" if passed else "0", "imp": improvement or "",
|
||
"cm": "\n".join(summary_lines)[:60000], "r": review_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
await record(sor, pid, "bid_qc_reviews", review_id, "qc_" + ("pass" if passed else "reject"),
|
||
to_state="passed" if passed else "rejected", who=who, agent_id=agent_id,
|
||
detail="%s round=%s %.2f" % (qc_type, review.get("round"), sc))
|
||
|
||
if passed:
|
||
return True, ("QC 通过:%s 契合度 %.2f > %.2f,该类产出放行。"
|
||
% (qc_type, sc, th["qc_pass_score"]))
|
||
|
||
# ── 不通过:轮次门禁 ──
|
||
if to_int(review.get("round"), 1) >= th["qc_max_round"]:
|
||
exist = await find_human_task(sor, pid, HT_QC_ESCALATION, status="pending")
|
||
if not exist:
|
||
await create_human_task(
|
||
sor, pid, HT_QC_ESCALATION,
|
||
"解析产出 QC 连续不达标需人工介入(%s)" % qc_type,
|
||
("「%s」已审核 %s 轮,最近契合度 %.2f/10(通过线 >%.2f),仍未达标:\n%s\n\n"
|
||
"请人工处理:核对招标文件修正产出物(可在对应管理页直接编辑),\n"
|
||
"或在 QC 审核记录页(bid_qc_reviews)把该记录 passed 改为 1 强制放行。\n"
|
||
"处理完成本任务后流程自动恢复。"
|
||
% (qc_type, review.get("round"), sc, th["qc_pass_score"],
|
||
(improvement or "")[:3000])),
|
||
assignee_role="owner.superuser")
|
||
await sor.sqlExe("COMMIT", {})
|
||
return True, ("QC 不通过且已达审核轮次上限 %d → 已抛人工介入(阻塞门禁)。"
|
||
% th["qc_max_round"])
|
||
|
||
# ── 清空该类产出物(章节骨架退回时章节表一并清空),触发解析重做 ──
|
||
tbl = QC_OUTPUT_TABLE.get(qc_type or "", "")
|
||
if not tbl:
|
||
return False, "QC 记录类型非法: %s" % qc_type
|
||
await sor.sqlExe("DELETE FROM " + tbl + " WHERE project_id=${p}$", {"p": pid})
|
||
await sor.sqlExe("COMMIT", {})
|
||
cleared = tbl if qc_type != QC_TYPE_OUTLINE else "bid_chapters(章节骨架)"
|
||
return True, ("QC 不通过:%s 契合度 %.2f 未高于 %.2f。已清空该类产出物(%s),"
|
||
"系统将在下一轮对账重派解析任务(携带改进意见)重做后重审。"
|
||
% (qc_type, sc, th["qc_pass_score"], cleared))
|
||
|
||
|
||
async def qc_report(project_id):
|
||
"""项目 QC 总览:四类产出的最新审核状态 + 未通过时的改进意见。"""
|
||
try:
|
||
project_id = await resolve_project_id(project_id)
|
||
except ValueError as e:
|
||
return str(e)
|
||
db, dbname = get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
th = await get_thresholds(sor)
|
||
out = {}
|
||
for t in QC_TYPES:
|
||
recs = await sor.sqlExe(
|
||
"SELECT round, fit_score, passed, LEFT(improvement, 600) AS improvement, created_at "
|
||
"FROM bid_qc_reviews WHERE project_id=${p}$ AND qc_type=${t}$ "
|
||
"ORDER BY round DESC LIMIT 1", {"p": project_id, "t": t})
|
||
await sor.sqlExe("COMMIT", {})
|
||
out[t] = rec_to_dict(recs[0]) if recs else {"round": 0, "note": "尚未审核"}
|
||
return json.dumps({
|
||
"project_id": project_id, "pass_score": th["qc_pass_score"],
|
||
"max_round": th["qc_max_round"], "latest": out,
|
||
}, ensure_ascii=False, default=str)
|