feat: 章节级依赖门禁——每章只等自己section依赖的QC类型通过即派发(矩阵:技术=评分项+文件要求+骨架/报价=评分项+成本收益+骨架/商务=资质+文件要求+骨架+资料准备);A2全局门禁拆为推进QC不挡下游;create_role_task统一注入skip_generic_qc豁免通用门禁
This commit is contained in:
parent
6c4aaa79a2
commit
d66207032c
@ -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,
|
||||
|
||||
@ -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", {})
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user