diff --git a/pipeline_bidding/bid_common.py b/pipeline_bidding/bid_common.py index 7284dbf..4dd2982 100644 --- a/pipeline_bidding/bid_common.py +++ b/pipeline_bidding/bid_common.py @@ -38,7 +38,10 @@ HT_HUMAN_DOCS = "human_docs_request" HT_QC_ESCALATION = "qc_escalation" HT_DELIVERY_CONFIRM = "bid_delivery_confirm" -BLOCKING_HT_TYPES = (HT_TENDER_FILE, HT_HUMAN_DOCS, HT_QC_ESCALATION) +BLOCKING_HT_TYPES = (HT_TENDER_FILE,) +# 2026-09-03 起只保留「等招标文件」为全局阻塞: +# - HT_HUMAN_DOCS(等人类资质文件)降级为章节级依赖——只阻塞商务章(等资料准备),技术/报价章不受影响; +# - HT_QC_ESCALATION(QC 轮次用尽)降级为章节级依赖——只阻塞依赖该产出类型的章节,其余章节照常流转。 # ── QC 契合度审核类型(解析产出逐类审核)── QC_TYPE_SCORING = "scoring_items" # 评分项 + 得分规则 @@ -249,13 +252,17 @@ async def create_role_task(sor, project_id, role, title, params=None, owner_id=" 与开发产线共用同一套任务表和 poller —— 投标产线不另建任务机制。 """ tid = new_id() + params = dict(params or {}) + # 豁免声明(2026-09-03):投标产线所有角色任务自带质量门禁 + # (解析产出契合度审核 / 章节评审打分),交付后不走引擎通用 QC/PM 门禁。 + params.setdefault("skip_generic_qc", True) await sor.C("pipeline_tasks", { "id": tid, "tenant_id": project_id, "pipeline_id": "role_task", "owner_id": owner_id, "title": (title or "")[:250], - "params": json.dumps(params or {}, ensure_ascii=False), + "params": json.dumps(params, ensure_ascii=False), "role": normalize_role(role), "state": "submitted", "claimed_by": None, diff --git a/pipeline_bidding/bid_flow.py b/pipeline_bidding/bid_flow.py index a9e9aaf..5e04140 100644 --- a/pipeline_bidding/bid_flow.py +++ b/pipeline_bidding/bid_flow.py @@ -88,6 +88,33 @@ ANALYSIS_DIMS = ( DIM_QC_TYPES = {d: ts for d, _, ts in ANALYSIS_DIMS} QC_TYPE_TO_DIM = {q: d for d, _, ts in ANALYSIS_DIMS for q in ts} +# 章节级上游依赖矩阵(2026-09-03):每种章节 section 只依赖自己必需的分析产出 +# 通过 QC——某一维度 QC 卡住只阻塞依赖它的章节,其余章节照常流转。 +# 公共事实:章节骨架(bid_chapters)本身就是章节,OUTLINE 过了章节才可信; +# REQS(投标文件格式要求)决定章节怎么写,所有章节都要。 +CH_SECTION_DEPS = { + "technical": (QC_TYPE_SCORING, QC_TYPE_REQS, QC_TYPE_OUTLINE), + "price": (QC_TYPE_SCORING, QC_TYPE_COST, QC_TYPE_OUTLINE), + "business": (QC_TYPE_QUALS, QC_TYPE_REQS, QC_TYPE_OUTLINE), +} + + +async def _qc_passed_types(sor, project_id): + """最新一轮审核已通过(或人工强制放行)的 QC 类型集合。章节级依赖判定用。 + + 逃逸阀闭环:QC 轮次用尽冒泡人工后,未通过类型不在集合里 → 依赖它的章节被挡; + 人工在记录页把 passed 改为 1 强制放行后,该类型进集合 → 依赖章节自动解锁,无需改码。 + """ + passed = set() + for t in QC_TYPES: + recs = await sor.sqlExe( + "SELECT passed 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", {}) + if recs and str(rec_to_dict(recs[0]).get("passed")) == "1": + passed.add(t) + return passed + async def _missing_dims(n_items, n_struct, n_quals, n_cost, chapters): """返回缺失的分析维度 [(dim, label)]。各维度独立判定,缺几个并行派几个。""" @@ -491,16 +518,21 @@ async def reconcile_project(sor, project_id, project_name=""): created_dims.append(dim) if created_dims: await sor.sqlExe("COMMIT", {}) - return acts + # 2026-09-03:不再提前 return——同轮继续章节级派发(依赖已满足的章节立即启动) # ── A2. 分析产出 QC 门禁(五个类型逐一审核,每类一个审核任务并行;阈值见 bid_qc_pass_score)── + # 2026-09-03 章节级门禁:本段只负责「推进 QC」(重做/审核/冒泡),不再提前 return + # 阻塞全局流转。哪些章节可以开写,由 C 段按章节依赖矩阵(CH_SECTION_DEPS)逐章判定—— + # 某一维度卡住只阻塞依赖它的章节,其余章节照常流转。 qc_pending = await _qc_pending_types(sor, project_id) + over_types = set() if qc_pending: # 轮次用尽守卫:最新审核已达上限仍未通过 → 不再空转派审核任务, # 确保有阻塞人工任务(逃逸阀:人工修产出物或在记录页强制放行后流程自恢复)。 over = [(t, s, imp, rnd) for t, s, imp, rnd in qc_pending if s == "rework" and rnd >= th["qc_max_round"]] if over: + over_types = set(t for t, _, _, _ in over) ht = await find_human_task(sor, project_id, HT_QC_ESCALATION, status="pending") if not ht: await create_human_task( @@ -514,8 +546,8 @@ async def reconcile_project(sor, project_id, project_name=""): for t, s, imp, rnd in over))), assignee_role="owner.superuser") await sor.sqlExe("COMMIT", {}) - return acts + ["blocked: QC 审核轮次用尽(%s),等人工介入" - % "/".join(t for t, _, _, _ in over)] + acts.append("escalated: QC 审核轮次用尽(%s),等人工介入;不依赖它的章节照常流转" + % "/".join(sorted(over_types))) # awaiting_redo:产出已被 QC 清空 → 重派对应维度的重做任务,不能对空产出审核 needs_redo = [(t, s, imp, rnd) for t, s, imp, rnd in qc_pending if s == "awaiting_redo"] if needs_redo: @@ -549,10 +581,11 @@ async def reconcile_project(sor, project_id, project_name=""): acts.append("created: 分析重做任务(%s,产出被 QC 清空)" % dim) if redo_dims: await sor.sqlExe("COMMIT", {}) - return acts - return acts + ["waiting: 分析重做任务在办(QC 退回产出已清空)"] - # 逐类型并行审核:每类一个审核任务(有在办的类型跳过) - needs_qc = [(t, s, imp, rnd) for t, s, imp, rnd in qc_pending] + else: + acts.append("waiting: 分析重做任务在办(QC 退回产出已清空)") + # 逐类型并行审核:每类一个审核任务(跳过:在办的类型 / 空产出待重做 / 轮次用尽等人工) + needs_qc = [(t, s, imp, rnd) for t, s, imp, rnd in qc_pending + if s == "unaudited" or (s == "rework" and t not in over_types)] created_qc = [] for t, _, _, _ in needs_qc: if await _open_qc_type_tasks(sor, project_id, t) > 0: @@ -572,20 +605,22 @@ async def reconcile_project(sor, project_id, project_name=""): created_qc.append(t) if created_qc: await sor.sqlExe("COMMIT", {}) - return acts - return acts + ["waiting: 解析产出 QC 审核任务在办"] + # 不提前 return:继续往下走章节级派发(依赖已满足的章节立即启动) # ── B. 资料准备(知识库资质匹配 + 人类文件清单)── + # 2026-09-03 章节级依赖:资料准备是商务章(business)的上游依赖, + # 不再阻塞全局流转——技术/报价章在上游 QC 通过后立即开写。 prep_done = await has_done_task(sor, project_id, R_PREP) if n_quals > 0 and not prep_done: if await count_open_tasks(sor, project_id, role=R_PREP) > 0: - return acts + ["waiting: 资料准备任务在办"] - tid = await create_role_task( - sor, project_id, R_PREP, - "%s 投标资料准备(资质匹配 + 人类文件清单)" % pname, - {"stage": "prep", "task_kind": "bid_prep", "qual_pending": n_quals_pending}) - await sor.sqlExe("COMMIT", {}) - return acts + ["created: 资料准备任务 %s" % tid] + acts.append("waiting: 资料准备任务在办(只阻塞商务章)") + else: + tid = await create_role_task( + sor, project_id, R_PREP, + "%s 投标资料准备(资质匹配 + 人类文件清单)" % pname, + {"stage": "prep", "task_kind": "bid_prep", "qual_pending": n_quals_pending}) + await sor.sqlExe("COMMIT", {}) + acts.append("created: 资料准备任务 %s" % tid) if not chapters: return acts + ["idle: 无章节,等待解析产出章节骨架"] @@ -593,7 +628,15 @@ async def reconcile_project(sor, project_id, project_name=""): # ── C. 章节编写(一章一任务、并发派发;pending/rejected 且无在办任务)── # 商务/技术分角色:section=business → 商务标写者;其余(technical/price)→ 技术标写者。 # 并发:单轮最多派 write_concurrency 个编写任务(引擎另有每项目并发上限兜底)。 + # 章节级上游依赖门禁(2026-09-03):每章只等自己 section 依赖的上游产出通过 QC + # (CH_SECTION_DEPS),依赖满足立即派发——某一维度卡住只阻塞依赖它的章节, + # 其余章节照常流转(根治:此前五类 QC 全过才放行,cost_benefit 一个维度慢 + # 导致全部章节冻结)。商务章额外等资料准备完成。 + qc_passed = await _qc_passed_types(sor, project_id) + hd_pending = await find_human_task(sor, project_id, HT_HUMAN_DOCS, status="pending") + biz_ok = (prep_done or n_quals == 0) and not hd_pending created_w = 0 + waiting_deps = {} for c in chapters: if created_w >= th["write_concurrency"]: break @@ -601,9 +644,19 @@ async def reconcile_project(sor, project_id, project_name=""): continue if to_int(c.get("revise_count"), 0) > th["max_revise"]: continue # 已超重做上限,等人工介入(review 阶段已抛任务) - role = R_BIZ_WRITER if (c.get("section") or "technical") == "business" else R_WRITER + section = c.get("section") or "technical" + role = R_BIZ_WRITER if section == "business" else R_WRITER if await count_open_tasks(sor, project_id, role=role, chapter_id=c["id"]) > 0: continue + # 上游依赖门禁:该 section 依赖的所有产出类型最新一轮必须已通过契合度审核 + deps = CH_SECTION_DEPS.get(section, CH_SECTION_DEPS["technical"]) + unmet = [t for t in deps if t not in qc_passed] + if unmet: + waiting_deps.setdefault("/".join(sorted(unmet)), []).append(str(c.get("chapter_no"))) + continue + if section == "business" and not biz_ok: + waiting_deps.setdefault("bid_prep(资料准备)", []).append(str(c.get("chapter_no"))) + continue stage = "revise" if c.get("status") == CH_REJECTED else "write" tid = await create_role_task( sor, project_id, role, @@ -611,10 +664,14 @@ async def reconcile_project(sor, project_id, project_name=""): "修改重写" if stage == "revise" else ("编写(商务标)" if role == R_BIZ_WRITER else "编写(技术标)")), {"stage": stage, "task_kind": "bid_write", "chapter_id": c["id"], - "chapter_no": c.get("chapter_no"), "section": c.get("section") or "technical", + "chapter_no": c.get("chapter_no"), "section": section, "revise_count": c.get("revise_count")}) created_w += 1 acts.append("created: 章节编写任务 %s (%s/%s)" % (tid, c.get("chapter_no"), role)) + if waiting_deps: + acts.append("waiting: 章节上游依赖未满足:" + "; ".join( + "章[%s]等(%s)" % ("/".join(nums), deps) + for deps, nums in sorted(waiting_deps.items()))) if created_w: await sor.sqlExe("COMMIT", {})