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_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_ESCALATIONQC 轮次用尽)降级为章节级依赖——只阻塞依赖该产出类型的章节,其余章节照常流转。
# ── 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,

View File

@ -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", {})