pipeline-bidding/pipeline_bidding/bid_qc_capability.py

391 lines
23 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.

"""解析产出 QC 契合度审核能力(agent.qc)。
对应业务:招标文件解析的每类产出物(评分项+得分规则 / 资质 / 投标文件要求 / 章节骨架)
必须由 QC 对照招标文件原文逐项核对契合度,按 10 分制评分;**得分高于通过分(当前 8.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_TYPE_COST,
QC_TYPE_TECH, QC_TYPES, TECH_QC_TYPES,
QC_OUTPUT_TABLE,
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_TYPE_COST: (
"投标成本收益分析契合度审核(对照招标文件商务条款与预算/保证金原文):\n"
"1. 完整性:招标文件明确的成本/收益项(保证金、预算金额、付款条件等)全部覆盖,无漏项;\n"
"2. 金额依据:招标原文明确给出的金额(预算/保证金)与原文一致,估算项标注口径与置信度;\n"
"3. 推理合理:每项 basis 含原文引用+推理过程,无凭空编造的确定性数字;\n"
"4. 结论一致:summary 的成本合计/收益合计与单项一致,毛利口径正确;算不出须说明原因,不得硬编。"
),
QC_TYPE_TECH: (
"技术评估项契合度审核(纯技术方案流程,对照【技术需求书】原文——需求书是唯一基准):\n"
"1. 完整性:六类评估项(功能清单/功能点/硬件配置/技术架构/功能架构/部署架构)需求书明确提出的条目全部录入,无漏项;\n"
"2. 原文一致:requirement 与需求书原文一致(参数/数量/指标不失真),source_ref 可回溯章节条款;\n"
"3. 硬性要求标注:需求书明确必须的条目 mandatory=1 无漏标;\n"
"4. 颗粒度:功能点细化到可评估颗粒度(不是把整个模块当一条);硬件参数逐项拆条;\n"
"5. 无编造:需求书未提出的通用最佳实践/臆测需求不得录入;qty_note 与原文数字一致。"
),
}
# 每类产出对应的落库表:统一从 bid_common.QC_OUTPUT_TABLE 导入(含 cost_benefit)
async def _qc_type_valid(sor, qc_type):
if qc_type in QC_TYPES or qc_type in TECH_QC_TYPES:
return qc_type, ""
return "", "qc_type 非法(应为 %s)" % "/".join(QC_TYPES + TECH_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", {})
# 已通过守卫(2026-09-02):最新一轮已放行 → 不再开新轮。
# 根治:任务被引擎通用门禁弹回重跑时再次 start_qc 会白白烧掉一轮
# (qualifications 实测:第2轮8.80已过,弹回重跑开第3轮对重写中的表打5.6)。
# 产出若被 QC 清空重做,对账器只在重做完成后才重新派审核,此处不会误挡。
last_r = await sor.sqlExe(
"SELECT round, fit_score, passed FROM bid_qc_reviews WHERE project_id=${p}$ AND qc_type=${t}$ "
"ORDER BY round DESC LIMIT 1", {"p": project_id, "t": qc_type})
await sor.sqlExe("COMMIT", {})
if last_r and not open_r and str(rec_to_dict(last_r[0]).get("passed")) == "1":
_lr = rec_to_dict(last_r[0])
return True, {"status": "already_passed", "qc_type": qc_type,
"round": _lr.get("round"), "fit_score": _lr.get("fit_score"),
"message": "该类型第 %s 轮已通过契合度审核(%.2f 分 > 通过分),无需重审。"
"直接完成任务即可。" % (_lr.get("round"), to_float(_lr.get("fit_score")))}
# 同任务重复开轮守卫(2026-09-03):本任务已有落分记录且产出未在其后更新 →
# 复用该轮,不开新轮。根治:QC agent 对同一产出自我重试连烧轮次
# (doc_requirements 实测:同一任务 18:08 开第4轮 5.5 分后 18:32 又开第5轮)。
if task_id and not open_r:
my_r = await sor.sqlExe(
"SELECT id, round, fit_score, passed, updated_at FROM bid_qc_reviews "
"WHERE project_id=${p}$ AND qc_type=${t}$ AND task_id=${tid}$ "
"AND fit_score IS NOT NULL ORDER BY round DESC LIMIT 1",
{"p": project_id, "t": qc_type, "tid": task_id})
await sor.sqlExe("COMMIT", {})
if my_r:
_mr = rec_to_dict(my_r[0])
rev_t = str(_mr.get("updated_at") or "")
out_rec = await sor.sqlExe(
"SELECT MAX(GREATEST(created_at, IFNULL(updated_at, created_at))) AS mt "
"FROM " + QC_OUTPUT_TABLE[qc_type] + " WHERE project_id=${p}$",
{"p": project_id})
await sor.sqlExe("COMMIT", {})
out_t = str(getattr(out_rec[0], 'mt', '') or '') if out_rec else ""
if not (out_t and rev_t and out_t > rev_t):
return True, {"status": "same_task_reviewed", "qc_type": qc_type,
"review_id": _mr.get("id"), "round": _mr.get("round"),
"fit_score": _mr.get("fit_score"),
"message": "本任务第 %s 轮已审核(%.2f 分)且产出未更新,"
"不重复开轮。请完成本任务。"
% (_mr.get("round"), to_float(_mr.get("fit_score")))}
# 空产出守卫(2026-09-02):产出被 QC 清空、重做未完成 → 不开新轮。
# 根治:QC 审核任务比分析重做任务先跑时对着空表开轮打 0 分,白白烧一轮
# (cost_benefit 实测:第2轮对着零记录表打 0.00)。
_cnt = await sor.sqlExe(
"SELECT COUNT(*) AS c FROM " + QC_OUTPUT_TABLE[qc_type] + " WHERE project_id=${p}$",
{"p": project_id})
await sor.sqlExe("COMMIT", {})
if not open_r and to_int(getattr(_cnt[0], "c", 0) if _cnt else 0) == 0:
return True, {"status": "no_output", "qc_type": qc_type,
"message": "%s 当前无产出记录(被 QC 清空待重做或重做未完成),"
"不开评审轮次。请等待分析重做任务落库后再审,"
"本轮任务直接交付说明等待原因即可。" % qc_type}
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})
elif qc_type == QC_TYPE_COST:
recs = await sor.sqlExe(
"SELECT kind, category, item_name, amount, amount_note, "
"LEFT(basis, 500) AS basis, confidence, source_ref "
"FROM bid_cost_benefit WHERE project_id=${p}$ ORDER BY kind DESC, order_no LIMIT 300",
{"p": project_id})
elif qc_type == QC_TYPE_TECH:
recs = await sor.sqlExe(
"SELECT category, item_name, LEFT(requirement, 400) AS requirement, "
"qty_note, mandatory, confidence, source_ref "
"FROM bid_tech_items WHERE project_id=${p}$ AND kind='item' "
"ORDER BY category, order_no LIMIT 400",
{"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 分段)并逐条对照上述清单核对,"
if qc_type == QC_TYPE_TECH else
"请读招标文件原文(list_tender_files → read_tender_file 分段)并逐条对照上述清单核对,") +
"得出 0~10 的契合度分(可带一位小数),然后调 finish_qc 落分。"
"不通过(得分未高于通过分)时 improvement 必须写清:漏了什么/错在哪/对照原文哪一处,"
"否则工具拒绝提交。"),
}, ensure_ascii=False, default=str)
async def _snapshot_outputs(sor, pid, qc_type, round_no, task_id, fit_score, improvement):
"""QC 不通过清空产出前,把本轮产出整表快照到 bid_analysis_history(幂等:同轮重复快照跳过)。
保留失败轮次的输出,供任务树/人工追溯;不清空历史。
"""
tbl = QC_OUTPUT_TABLE.get(qc_type or "", "")
if not tbl:
return 0
exist = await sor.sqlExe(
"SELECT COUNT(*) AS c FROM bid_analysis_history WHERE project_id=${p}$ "
"AND qc_type=${t}$ AND round=${r}$", {"p": pid, "t": qc_type, "r": round_no})
if exist and to_int(getattr(exist[0], 'c', 0), 0) > 0:
return 0
rows = await sor.sqlExe(
"SELECT * FROM " + tbl + " WHERE project_id=${p}$", {"p": pid}) or []
n = 0
for r in rows:
d = rec_to_dict(r)
d.pop('id', None)
await sor.sqlExe(
"INSERT INTO bid_analysis_history "
"(id, project_id, qc_type, round, task_id, fit_score, improvement, payload, created_at) "
"VALUES (${id}$, ${p}$, ${t}$, ${r}$, ${tid}$, ${sc}$, ${imp}$, ${pl}$, NOW())",
{"id": new_id(), "p": pid, "t": qc_type, "r": round_no, "tid": task_id or "",
"sc": fit_score, "imp": (improvement or "")[:20000],
"pl": json.dumps(d, ensure_ascii=False, default=str)[:200000]})
n += 1
await sor.sqlExe("COMMIT", {})
logger.info("bid_qc: snapshot %s round=%d rows=%d", qc_type, round_no, n)
return n
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"] # 严格高于通过分才算完成(8.5 → 需 >8.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"])
# ── 不通过且未达轮次上限:先快照本轮产出(保留失败轮次的输出),
# 再把该轮分析任务标 qc_rejected(任务状态与QC判定一致,不再显示误导的"已批准"),
# 最后清空产出触发重做。失败轮次的输入(qc_improvements 在重做任务 params)
# 与输出(快照表)全程可查。──
tbl = QC_OUTPUT_TABLE.get(qc_type or "", "")
if not tbl:
return False, "QC 记录类型非法: %s"
await _snapshot_outputs(sor, pid, qc_type, to_int(review.get("round"), 1),
review.get("task_id") or "", sc, improvement or "")
if review.get("task_id"):
await sor.sqlExe(
"UPDATE pipeline_tasks SET state='qc_rejected', updated_at=NOW() "
"WHERE id=${t}$", {"t": review.get("task_id")})
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。本轮产出已快照保留(bid_analysis_history),"
"任务标记 qc_rejected,产出物(%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)