feat: 章节级依赖门禁——每章只等自己section依赖的QC类型通过即派发(矩阵:技术=评分项+文件要求+骨架/报价=评分项+成本收益+骨架/商务=资质+文件要求+骨架+资料准备);A2全局门禁拆为推进QC不挡下游;create_role_task统一注入skip_generic_qc豁免通用门禁

This commit is contained in:
yumoqing 2026-09-03 12:28:21 +08:00
parent 6c4aaa79a2
commit d66207032c
2 changed files with 84 additions and 20 deletions

View File

@ -38,7 +38,10 @@ HT_HUMAN_DOCS = "human_docs_request"
HT_QC_ESCALATION = "qc_escalation" HT_QC_ESCALATION = "qc_escalation"
HT_DELIVERY_CONFIRM = "bid_delivery_confirm" 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 契合度审核类型(解析产出逐类审核)──
QC_TYPE_SCORING = "scoring_items" # 评分项 + 得分规则 QC_TYPE_SCORING = "scoring_items" # 评分项 + 得分规则
@ -249,13 +252,17 @@ async def create_role_task(sor, project_id, role, title, params=None, owner_id="
与开发产线共用同一套任务表和 poller —— 投标产线不另建任务机制。 与开发产线共用同一套任务表和 poller —— 投标产线不另建任务机制。
""" """
tid = new_id() tid = new_id()
params = dict(params or {})
# 豁免声明(2026-09-03):投标产线所有角色任务自带质量门禁
# (解析产出契合度审核 / 章节评审打分),交付后不走引擎通用 QC/PM 门禁。
params.setdefault("skip_generic_qc", True)
await sor.C("pipeline_tasks", { await sor.C("pipeline_tasks", {
"id": tid, "id": tid,
"tenant_id": project_id, "tenant_id": project_id,
"pipeline_id": "role_task", "pipeline_id": "role_task",
"owner_id": owner_id, "owner_id": owner_id,
"title": (title or "")[:250], "title": (title or "")[:250],
"params": json.dumps(params or {}, ensure_ascii=False), "params": json.dumps(params, ensure_ascii=False),
"role": normalize_role(role), "role": normalize_role(role),
"state": "submitted", "state": "submitted",
"claimed_by": None, "claimed_by": None,

View File

@ -88,6 +88,33 @@ ANALYSIS_DIMS = (
DIM_QC_TYPES = {d: ts for d, _, ts in 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} 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): async def _missing_dims(n_items, n_struct, n_quals, n_cost, chapters):
"""返回缺失的分析维度 [(dim, label)]。各维度独立判定,缺几个并行派几个。""" """返回缺失的分析维度 [(dim, label)]。各维度独立判定,缺几个并行派几个。"""
@ -491,16 +518,21 @@ async def reconcile_project(sor, project_id, project_name=""):
created_dims.append(dim) created_dims.append(dim)
if created_dims: if created_dims:
await sor.sqlExe("COMMIT", {}) await sor.sqlExe("COMMIT", {})
return acts # 2026-09-03:不再提前 return——同轮继续章节级派发(依赖已满足的章节立即启动)
# ── A2. 分析产出 QC 门禁(五个类型逐一审核,每类一个审核任务并行;阈值见 bid_qc_pass_score)── # ── A2. 分析产出 QC 门禁(五个类型逐一审核,每类一个审核任务并行;阈值见 bid_qc_pass_score)──
# 2026-09-03 章节级门禁:本段只负责「推进 QC」(重做/审核/冒泡),不再提前 return
# 阻塞全局流转。哪些章节可以开写,由 C 段按章节依赖矩阵(CH_SECTION_DEPS)逐章判定——
# 某一维度卡住只阻塞依赖它的章节,其余章节照常流转。
qc_pending = await _qc_pending_types(sor, project_id) qc_pending = await _qc_pending_types(sor, project_id)
over_types = set()
if qc_pending: if qc_pending:
# 轮次用尽守卫:最新审核已达上限仍未通过 → 不再空转派审核任务, # 轮次用尽守卫:最新审核已达上限仍未通过 → 不再空转派审核任务,
# 确保有阻塞人工任务(逃逸阀:人工修产出物或在记录页强制放行后流程自恢复)。 # 确保有阻塞人工任务(逃逸阀:人工修产出物或在记录页强制放行后流程自恢复)。
over = [(t, s, imp, rnd) for t, s, imp, rnd in 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 s == "rework" and rnd >= th["qc_max_round"]]
if over: if over:
over_types = set(t for t, _, _, _ in over)
ht = await find_human_task(sor, project_id, HT_QC_ESCALATION, status="pending") ht = await find_human_task(sor, project_id, HT_QC_ESCALATION, status="pending")
if not ht: if not ht:
await create_human_task( await create_human_task(
@ -514,8 +546,8 @@ async def reconcile_project(sor, project_id, project_name=""):
for t, s, imp, rnd in over))), for t, s, imp, rnd in over))),
assignee_role="owner.superuser") assignee_role="owner.superuser")
await sor.sqlExe("COMMIT", {}) await sor.sqlExe("COMMIT", {})
return acts + ["blocked: QC 审核轮次用尽(%s),等人工介入" acts.append("escalated: QC 审核轮次用尽(%s),等人工介入;不依赖它的章节照常流转"
% "/".join(t for t, _, _, _ in over)] % "/".join(sorted(over_types)))
# awaiting_redo:产出已被 QC 清空 → 重派对应维度的重做任务,不能对空产出审核 # awaiting_redo:产出已被 QC 清空 → 重派对应维度的重做任务,不能对空产出审核
needs_redo = [(t, s, imp, rnd) for t, s, imp, rnd in qc_pending if s == "awaiting_redo"] needs_redo = [(t, s, imp, rnd) for t, s, imp, rnd in qc_pending if s == "awaiting_redo"]
if needs_redo: if needs_redo:
@ -549,10 +581,11 @@ async def reconcile_project(sor, project_id, project_name=""):
acts.append("created: 分析重做任务(%s,产出被 QC 清空)" % dim) acts.append("created: 分析重做任务(%s,产出被 QC 清空)" % dim)
if redo_dims: if redo_dims:
await sor.sqlExe("COMMIT", {}) await sor.sqlExe("COMMIT", {})
return acts else:
return acts + ["waiting: 分析重做任务在办(QC 退回产出已清空)"] acts.append("waiting: 分析重做任务在办(QC 退回产出已清空)")
# 逐类型并行审核:每类一个审核任务(有在办的类型跳过) # 逐类型并行审核:每类一个审核任务(跳过:在办的类型 / 空产出待重做 / 轮次用尽等人工)
needs_qc = [(t, s, imp, rnd) for t, s, imp, rnd in qc_pending] 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 = [] created_qc = []
for t, _, _, _ in needs_qc: for t, _, _, _ in needs_qc:
if await _open_qc_type_tasks(sor, project_id, t) > 0: 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) created_qc.append(t)
if created_qc: if created_qc:
await sor.sqlExe("COMMIT", {}) await sor.sqlExe("COMMIT", {})
return acts # 不提前 return:继续往下走章节级派发(依赖已满足的章节立即启动)
return acts + ["waiting: 解析产出 QC 审核任务在办"]
# ── B. 资料准备(知识库资质匹配 + 人类文件清单)── # ── B. 资料准备(知识库资质匹配 + 人类文件清单)──
# 2026-09-03 章节级依赖:资料准备是商务章(business)的上游依赖,
# 不再阻塞全局流转——技术/报价章在上游 QC 通过后立即开写。
prep_done = await has_done_task(sor, project_id, R_PREP) prep_done = await has_done_task(sor, project_id, R_PREP)
if n_quals > 0 and not prep_done: if n_quals > 0 and not prep_done:
if await count_open_tasks(sor, project_id, role=R_PREP) > 0: if await count_open_tasks(sor, project_id, role=R_PREP) > 0:
return acts + ["waiting: 资料准备任务在办"] acts.append("waiting: 资料准备任务在办(只阻塞商务章)")
tid = await create_role_task( else:
sor, project_id, R_PREP, tid = await create_role_task(
"%s 投标资料准备(资质匹配 + 人类文件清单)" % pname, sor, project_id, R_PREP,
{"stage": "prep", "task_kind": "bid_prep", "qual_pending": n_quals_pending}) "%s 投标资料准备(资质匹配 + 人类文件清单)" % pname,
await sor.sqlExe("COMMIT", {}) {"stage": "prep", "task_kind": "bid_prep", "qual_pending": n_quals_pending})
return acts + ["created: 资料准备任务 %s" % tid] await sor.sqlExe("COMMIT", {})
acts.append("created: 资料准备任务 %s" % tid)
if not chapters: if not chapters:
return acts + ["idle: 无章节,等待解析产出章节骨架"] return acts + ["idle: 无章节,等待解析产出章节骨架"]
@ -593,7 +628,15 @@ async def reconcile_project(sor, project_id, project_name=""):
# ── C. 章节编写(一章一任务、并发派发;pending/rejected 且无在办任务)── # ── C. 章节编写(一章一任务、并发派发;pending/rejected 且无在办任务)──
# 商务/技术分角色:section=business → 商务标写者;其余(technical/price)→ 技术标写者。 # 商务/技术分角色:section=business → 商务标写者;其余(technical/price)→ 技术标写者。
# 并发:单轮最多派 write_concurrency 个编写任务(引擎另有每项目并发上限兜底)。 # 并发:单轮最多派 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 created_w = 0
waiting_deps = {}
for c in chapters: for c in chapters:
if created_w >= th["write_concurrency"]: if created_w >= th["write_concurrency"]:
break break
@ -601,9 +644,19 @@ async def reconcile_project(sor, project_id, project_name=""):
continue continue
if to_int(c.get("revise_count"), 0) > th["max_revise"]: if to_int(c.get("revise_count"), 0) > th["max_revise"]:
continue # 已超重做上限,等人工介入(review 阶段已抛任务) 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: if await count_open_tasks(sor, project_id, role=role, chapter_id=c["id"]) > 0:
continue 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" stage = "revise" if c.get("status") == CH_REJECTED else "write"
tid = await create_role_task( tid = await create_role_task(
sor, project_id, role, sor, project_id, role,
@ -611,10 +664,14 @@ async def reconcile_project(sor, project_id, project_name=""):
"修改重写" if stage == "revise" else "修改重写" if stage == "revise" else
("编写(商务标)" if role == R_BIZ_WRITER else "编写(技术标)")), ("编写(商务标)" if role == R_BIZ_WRITER else "编写(技术标)")),
{"stage": stage, "task_kind": "bid_write", "chapter_id": c["id"], {"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")}) "revise_count": c.get("revise_count")})
created_w += 1 created_w += 1
acts.append("created: 章节编写任务 %s (%s/%s)" % (tid, c.get("chapter_no"), role)) 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: if created_w:
await sor.sqlExe("COMMIT", {}) await sor.sqlExe("COMMIT", {})