"""解析产出 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)