feat(service): 产线流程裁剪机制层——pipeline_flow_plans表+flow_plan_capability(propose六项硬校验/confirm-reject CAS/plan_stage_enabled/create_sub_tasks PM拆解)+shared_ability聚合会话三工具(show_flow_template/propose_flow_plan/get_flow_plan);agent_loop _get_next_role跳段泛化(_skip_trimmed_roles线性链自动跳过被裁角色)+_pm_create_tasks extra_params(继承skip_generic_qc);capability_tools注册create_sub_tasks schema;init导出env.flow_plan_*

This commit is contained in:
ymq 2026-09-11 12:58:26 +08:00
parent 4d2b3aa848
commit 2c35abfdab
6 changed files with 888 additions and 3 deletions

View File

@ -0,0 +1,121 @@
{
"summary": [
{
"name": "pipeline_flow_plans",
"title": "产线流程裁剪计划",
"primary": [
"id"
],
"catelog": "entity"
}
],
"fields": [
{
"name": "id",
"title": "主键ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "project_id",
"title": "项目ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "pipeline_id",
"title": "产线ID",
"type": "str",
"length": 64,
"nullable": "no"
},
{
"name": "version",
"title": "版本(驳回重提+1)",
"type": "int",
"nullable": "no",
"default": "1"
},
{
"name": "status",
"title": "状态 draft/pending_confirm/confirmed/rejected/superseded",
"type": "str",
"length": 20,
"nullable": "no",
"default": "draft"
},
{
"name": "stages",
"title": "阶段裁剪JSON(全量阶段快照,每项含key/enabled/reason)",
"type": "longtext",
"nullable": "no"
},
{
"name": "user_requirements",
"title": "用户裁剪要求原文",
"type": "text"
},
{
"name": "propose_note",
"title": "会话agent提案说明",
"type": "text"
},
{
"name": "confirm_task_id",
"title": "确认待办ID(pipeline_human_tasks)",
"type": "str",
"length": 32
},
{
"name": "confirmed_by",
"title": "确认人",
"type": "str",
"length": 64
},
{
"name": "confirmed_at",
"title": "确认时间",
"type": "datetime"
},
{
"name": "reject_comment",
"title": "驳回意见(会话agent按此修订重提)",
"type": "text"
},
{
"name": "created_by",
"title": "提案人(agent.main_agent或user_id)",
"type": "str",
"length": 64
},
{
"name": "created_at",
"title": "创建时间",
"type": "timestamp",
"nullable": "no"
},
{
"name": "updated_at",
"title": "更新时间",
"type": "timestamp"
}
],
"indexes": [
{
"name": "idx_pfp_project",
"idxtype": "index",
"idxfields": [
"project_id"
]
},
{
"name": "idx_pfp_status",
"idxtype": "index",
"idxfields": [
"status"
]
}
]
}

View File

@ -1432,19 +1432,60 @@ async def _get_repo_state(space_dir, project_name=''):
async def _get_next_role(current_role, project_id=""):
"""任务链下一角色:从能力包取。"""
"""任务链下一角色:从能力包取。
流程裁剪跳段通用2026-09-11项目有已确认confirmed的流程裁剪计划时
下一角色若属于被裁阶段FlowStage.roles 命中且 enabled=False沿 next_role
前跳到第一个保留角色线性任务链产线SDLC/商机裁剪后自动跳段无需各产线改码
无计划None= 标准全流程行为不变
"""
if project_id:
try:
pid = await _resolve_pipeline_id(project_id)
from pipeline_core import get_role_spec
spec = get_role_spec(pid, current_role)
if spec:
return spec.next_role # 可能是 ""(终结)
nxt = spec.next_role # 可能是 ""(终结)
if nxt:
nxt = await _skip_trimmed_roles(pid, project_id, nxt)
return nxt
except Exception:
pass
return ""
async def _skip_trimmed_roles(pipeline_id, project_id, next_role):
"""沿 next_role 链跳过被裁阶段的角色,返回第一个保留角色(或 "")。
角色 阶段归属能力包 flow_stages roles 含该角色即属该阶段
阶段不在任何 flow_stage 声明里的角色 = 不受裁剪影响照常保留
防环最多跳 20 角色链本身有限异常声明不至于死循环
"""
from pipeline_core import get_role_spec, get_flow_stages
try:
from .flow_plan_capability import plan_stage_enabled
enabled = await plan_stage_enabled(project_id)
except Exception:
return next_role
if enabled is None:
return next_role # 无确认计划 = 标准全流程
stages = get_flow_stages(pipeline_id) or []
role_stage = {}
for s in stages:
for r in (s.roles or []):
role_stage.setdefault(r, s.key)
role = next_role
for _ in range(20):
if not role:
return ""
stage_key = role_stage.get(role)
if not stage_key or enabled.get(stage_key, True):
return role # 无阶段归属(不受裁剪影响)或阶段保留
spec = get_role_spec(pipeline_id, role)
role = spec.next_role if spec else ""
return ""
async def _check_orchestration_gaps(sor, project_id, task, next_role='', next_task_id=''):
"""编排完备性校验:代码只**查漏**,不替 PM 决策。
@ -2593,7 +2634,7 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None):
# ── PM 审核 ──
async def _pm_create_tasks(sor, project_id, params, parent_task_id=None):
async def _pm_create_tasks(sor, project_id, params, parent_task_id=None, extra_params=None):
"""PM 派发后续任务(项目计划 / 任务分配):批量创建任务并指定角色。
params.tasks: JSON 数组 [{title, role, description, key, depends_on, parent_id}]
@ -2602,6 +2643,8 @@ async def _pm_create_tasks(sor, project_id, params, parent_task_id=None):
- keyLLM 给子任务起的短标识供同批任务间 depends_on 相互引用
- depends_on依赖的 key 或任务ID 数组 = 并行非空 = 串行等待依赖完成后才可认领
兼容单任务形态params 直接含 {title, role, description}
extra_params可选向后兼容dict合入每个子任务的 params
用于继承父任务的产线门禁风格如投标 skip_generic_qc默认 None 不影响既有调用方
"""
from appPublic.uniqueID import getID
tasks = params.get('tasks') or []
@ -2657,6 +2700,8 @@ async def _pm_create_tasks(sor, project_id, params, parent_task_id=None):
cancelled.append(did)
tparams = {"description": desc, "pm_assigned": True}
if extra_params:
tparams.update(extra_params)
# 任务来源标记PM 派发的任务按 title/description 判断——
# 含「修复 Bug」= bug_fixdevelop 必须走 fix_bug 状态机);否则 = new_dev模块开发等
_title_desc = f"{title} {desc}"

View File

@ -230,6 +230,15 @@ TOOL_SCHEMAS = {
"content": "SKILL.md 正文(头部 <!-- target: ... --> 定位,四段:触发条件/问题现象/根因/处理方法)"},
"required": ["name", "content"],
},
# ── flow_plan_capabilityPM 复杂度拆解(通用,各产线 PM 角色技能声明即用)──
"create_sub_tasks": {
"module": "flow_plan_capability",
"description": "把当前复杂任务拆解为子任务组派发编排子流程自动挂进项目主流程parent_id 挂当前任务任务树分层展示。先按技能里的复杂度标准判断确需拆解才调用。tasks 每项 {title, role, description, key?, depends_on?, dep_policy?}key 供同批互引depends_on 空=并行/非空=等前置完成dep_policy 可选 {mode:all|any|at_least,n}。角色必须是本产线角色list_roles 可查)",
"params": {"tasks": "子任务 JSON 数组 [{title, role, description, key?, depends_on?, dep_policy?}]",
"project_id": "项目ID(可选,默认当前)",
"parent_task_id": "父任务ID(可选,默认当前任务)"},
"required": ["tasks"],
},
}

View File

@ -0,0 +1,690 @@
# -*- coding: utf-8 -*-
"""产线流程裁剪能力(通用、产线无关)。
让任意产线的预制流程可被会话 agent 按用户要求裁剪裁剪后的流程经用户在平台待办
里确认才生效确认后由各产线的流转机制对账器 / next_role 按裁剪计划派发PM 推进
机制分工沿用产线既有约定
- 阶段定义哪些可裁依赖关系由各产线在能力包 flow_stages 字段声明pipeline-core FlowStage
- 裁剪合法性由本模块**代码硬校验**不靠 LLM 自觉不可裁阶段拒绝依赖断裂拒绝
已开跑项目拒绝已确认计划拒绝改
- 用户确认走平台待办flow_plan_confirm 人类任务+ 专用确认端点CAS 激活
**agent 无确认工具**物理上无法代替用户确认
- 计划状态存 pipeline_flow_plans流转机制读 get_active_plan / plan_stage_enabled 决定派发
状态机draft pending_confirm confirmconfirmed /rejectrejected 重提新版本 draft
superseded = 被更新版本取代的旧 pending/draft
"""
import json
import logging
from sqlor.dbpools import DBPools
from appPublic.uniqueID import getID
from .audit import record_audit
from .human_task_capability import (
create_human_task, _get_user_org, _get_user_roles,
)
DBNAME = "pipeline"
logger = logging.getLogger("pipeline.flow_plan")
# 计划状态
S_DRAFT = "draft"
S_PENDING = "pending_confirm"
S_CONFIRMED = "confirmed"
S_REJECTED = "rejected"
S_SUPERSEDED = "superseded"
# 确认待办类型todo_detail.dspy 按它渲染确认/驳回按钮)
T_FLOW_CONFIRM = "flow_plan_confirm"
# 裁剪时机门禁:项目已有角色任务**产出结果**approved/completed即视为流程已开跑
# 禁止再裁。submitted/running 不拦——启动竞态(导入招标文件后对账器 15s 内可能已派
# 编排任务)不应堵死裁剪窗口;确认后由流转机制按计划收敛(不再派发被裁阶段,
# 角色专属产线还会取消被裁阶段在办任务)。
_STARTED_STATES = ("approved", "completed")
def _get_db():
db = DBPools()
if not db.databases:
from appPublic.jsonConfig import getConfig
config = getConfig()
if config.databases:
db.databases = config.databases
return db, DBNAME
def _rec(rec):
if rec is None:
return {}
try:
return dict(rec)
except (TypeError, ValueError):
return {}
def _parse_trim_keys(raw):
"""trim_keys 容错解析JSON 数组 / 逗号分隔字符串 / 已是 list → [key,...]。"""
if not raw:
return []
if isinstance(raw, (list, tuple)):
return [str(x).strip() for x in raw if str(x).strip()]
s = str(raw).strip()
if not s:
return []
if s.startswith("["):
try:
v = json.loads(s)
if isinstance(v, list):
return [str(x).strip() for x in v if str(x).strip()]
except (json.JSONDecodeError, ValueError):
pass
return [x.strip() for x in s.replace("", ",").split(",") if x.strip()]
def _load_stages(pipeline_id):
"""从能力包取产线声明的阶段,返回 (stage_map, ordered_list)。"""
from pipeline_core import get_flow_stages
stages = get_flow_stages(pipeline_id) or []
return {s.key: s for s in stages}, list(stages)
async def _resolve_pipeline(sor, project_id, pipeline_id=""):
"""解析项目产线 id优先入参缺省从 sd_projects 取),返回 (pid, proj_dict)。"""
proj = {}
if project_id:
recs = await sor.sqlExe(
"SELECT id, name, pipeline_id, status, org_id, created_by FROM sd_projects "
"WHERE id=${p}$", {"p": project_id})
await sor.sqlExe("COMMIT", {})
proj = _rec(recs[0]) if recs else {}
pid = (pipeline_id or proj.get("pipeline_id") or "").strip()
return pid, proj
# ══════════════════════ 展示预制流程 ══════════════════════
async def get_flow_template(project_id="", pipeline_id=""):
"""返回 (pid, [stage_dict,...]):产线预制流程阶段(含可裁性),供 show_flow_template 渲染。"""
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
pid, _proj = await _resolve_pipeline(sor, project_id, pipeline_id)
from pipeline_core import flow_stage_to_dict
_map, stages = _load_stages(pid)
return pid, [flow_stage_to_dict(s) for s in stages]
def render_template_md(stages):
"""把阶段列表渲染成 markdown 表(展示给用户/待办)。"""
if not stages:
return "(该产线未声明可裁剪流程)"
trim_label = {"no": "不可裁", "warn": "可裁(需警告)", "yes": "可自由裁剪"}
lines = ["| # | 阶段 | 作用 | 可裁性 |", "|---|---|---|---|"]
for i, s in enumerate(stages, 1):
lines.append("| %d | %s | %s | %s |" % (
i, s.get("label") or s.get("key"),
(s.get("description") or "").replace("\n", " ")[:120],
trim_label.get(s.get("trim"), s.get("trim"))))
return "\n".join(lines)
# ══════════════════════ 提案(裁剪草案 + 确认待办) ══════════════════════
async def propose_flow_plan(project_id, trim_keys=None, user_requirements="",
propose_note="", pipeline_id="", who=None, agent_id=None):
"""按用户要求生成裁剪草案,落库 pending_confirm 并发确认待办。
代码硬校验任一不过即拒绝返回可行动原因绝不静默降级
1. 项目/产线存在且产线声明了 flow_stages
2. 裁剪时机项目流程已开跑有角色任务进入 started 拒绝
3. 已有 confirmed 计划 拒绝启动时裁剪一次确认后不可改
4. trim_keys 都是已知阶段
5. 不裁 trim=='no' 的阶段
6. 裁剪不导致依赖断裂保留阶段的 deps 不得落在被裁阶段
trim=='warn' 的阶段允许裁 warn_note 写进确认待办双确认
"""
if not project_id:
return False, "缺少 project_id请先切换到项目"
trim_keys = _parse_trim_keys(trim_keys)
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
pid, proj = await _resolve_pipeline(sor, project_id, pipeline_id)
if not proj:
return False, "项目不存在: %s" % project_id
if not pid:
return False, "项目未关联产线,无法裁剪流程"
stage_map, stages = _load_stages(pid)
if not stages:
return False, ("产线「%s」未声明可裁剪流程flow_stages 为空),"
"不支持流程裁剪" % pid)
pname = proj.get("name") or project_id
# 校验3已确认计划不可改
conf = await sor.sqlExe(
"SELECT id FROM pipeline_flow_plans WHERE project_id=${p}$ AND status=${s}$ LIMIT 1",
{"p": project_id, "s": S_CONFIRMED})
await sor.sqlExe("COMMIT", {})
if conf:
return False, ("项目流程计划已确认plan %s),确认后不可修改。"
"如需调整请新建项目。" % getattr(conf[0], "id", "")[:8])
# 校验2裁剪时机——流程已产出结果则拒绝启动竞态容忍见 _STARTED_STATES 注释)
started = "','".join(_STARTED_STATES)
n_started = await sor.sqlExe(
"SELECT COUNT(*) AS c FROM pipeline_tasks WHERE tenant_id=${p}$ "
"AND pipeline_id='role_task' AND state IN ('" + started + "')",
{"p": project_id})
await sor.sqlExe("COMMIT", {})
n_started = int(getattr(n_started[0], "c", 0) if n_started else 0)
if n_started > 0 and trim_keys:
return False, ("项目流程已开始执行(%d 个角色任务已产出结果),"
"不能再裁剪。裁剪仅在项目启动阶段有效。" % n_started)
# 校验4未知阶段
unknown = [k for k in trim_keys if k not in stage_map]
if unknown:
return False, ("未知阶段 key%s。本产线可裁阶段见 show_flow_template。"
% "".join(unknown))
# 校验5不可裁阶段
no_trim = [k for k in trim_keys if (stage_map[k].trim or "no") == "no"]
if no_trim:
detail = "".join("%s%s" % (stage_map[k].label or k,
stage_map[k].description or "流程必需")
for k in no_trim)
return False, "以下阶段不可裁剪,裁掉流程不成立:%s" % detail
# 校验6依赖断裂——保留阶段的 dep 落在被裁阶段
trim_set = set(trim_keys)
broken = []
for s in stages:
if s.key in trim_set:
continue
for d in (s.deps or []):
if d in trim_set:
_dl = stage_map[d].label if d in stage_map else d
broken.append("%s 依赖被裁的 %s" % (s.label or s.key, _dl or d))
if broken:
return False, "裁剪导致依赖断裂:%s。请保留上游阶段或一并裁剪下游。" % "".join(broken)
# warn 收集trim==warn 允许裁,但风险提示进确认待办)
warns = [stage_map[k].warn_note for k in trim_keys
if (stage_map[k].trim or "") == "warn" and stage_map[k].warn_note]
# 阶段快照 JSON全量每项 enabled 标记保留/裁剪)
from pipeline_core import flow_stage_to_dict
stages_snap = []
for s in stages:
d = flow_stage_to_dict(s)
d["enabled"] = s.key not in trim_set
stages_snap.append(d)
# version = 历史最大 + 1旧 pending/draft 置 superseded其确认待办同步关闭
# 防用户看到指向旧版本的死待办)
vmax = await sor.sqlExe(
"SELECT MAX(version) AS v FROM pipeline_flow_plans WHERE project_id=${p}$",
{"p": project_id})
await sor.sqlExe("COMMIT", {})
version = int(getattr(vmax[0], "v", 0) or 0) + 1
old_pend = await sor.sqlExe(
"SELECT id, confirm_task_id FROM pipeline_flow_plans WHERE project_id=${p}$ "
"AND status IN (${a}$, ${b}$)",
{"p": project_id, "a": S_PENDING, "b": S_DRAFT})
await sor.sqlExe("COMMIT", {})
for _op in (old_pend or []):
_op_ht = getattr(_op, "confirm_task_id", "") or ""
if _op_ht:
await sor.sqlExe(
"UPDATE pipeline_human_tasks SET status='done', qc_status='passed', "
"qc_comment='流程裁剪方案已更新版本,旧确认待办自动关闭', submitted_at=NOW() "
"WHERE id=${h}$ AND status='pending'", {"h": _op_ht})
await sor.sqlExe(
"UPDATE pipeline_flow_plans SET status=${ns}$, updated_at=NOW() "
"WHERE project_id=${p}$ AND status IN (${a}$, ${b}$)",
{"ns": S_SUPERSEDED, "p": project_id, "a": S_PENDING, "b": S_DRAFT})
await sor.sqlExe("COMMIT", {})
plan_id = getID()
await sor.C("pipeline_flow_plans", {
"id": plan_id, "project_id": project_id, "pipeline_id": pid,
"version": version, "status": S_PENDING,
"stages": json.dumps(stages_snap, ensure_ascii=False),
"user_requirements": (user_requirements or "")[:8000],
"propose_note": (propose_note or "")[:8000],
"created_by": who or "agent.main_agent",
})
await sor.sqlExe("COMMIT", {})
# 确认待办(平台待办中枢;专用确认端点 flow_plan_confirm.dspy 激活)
desc = _render_confirm_md(pname, stages_snap, trim_keys, warns,
user_requirements, propose_note, version)
ok_ht, ht = await create_human_task(
project_id,
"流程裁剪确认:%sv%d" % (pname, version),
desc, task_type=T_FLOW_CONFIRM,
assignee_role="owner.superuser", created_by=who or "agent.main_agent")
if ok_ht:
await sor.sqlExe(
"UPDATE pipeline_flow_plans SET confirm_task_id=${h}$, updated_at=NOW() "
"WHERE id=${i}$", {"h": ht, "i": plan_id})
await sor.sqlExe("COMMIT", {})
await record_audit(project_id, "pipeline_flow_plans", plan_id, "propose",
to_state=S_PENDING, who=who, agent_id=agent_id,
detail="version=%d trim=%s" % (version, ",".join(trim_keys) or "(无裁剪)"),
sor=sor)
await sor.sqlExe("COMMIT", {})
logger.info("flow_plan propose: project=%s plan=%s v=%d trim=%s",
project_id, plan_id, version, trim_keys)
kept = [s["label"] or s["key"] for s in stages_snap if s["enabled"]]
trimmed = [s["label"] or s["key"] for s in stages_snap if not s["enabled"]]
msg = ("已生成流程裁剪草案plan %sv%d)并发确认待办。\n保留阶段:%s\n裁剪阶段:%s"
% (plan_id[:8], version, "".join(kept) or "(无)",
"".join(trimmed) or "(无)"))
if warns:
msg += "\n⚠️ 风险提示(请在待办里向用户说明):\n" + "\n".join("- " + w for w in warns)
msg += "\n用户需在「流程裁剪确认」待办里点击确认后流程才生效agent 不能代替确认。"
return True, msg
def _render_confirm_md(pname, stages_snap, trim_keys, warns, user_req, note, version):
"""确认待办正文markdowntodo_detail 直接渲染)。"""
lines = ["## 项目「%s」流程裁剪方案v%d" % (pname, version), ""]
if user_req:
lines += ["**你的裁剪要求:**", "", user_req.strip(), ""]
if note:
lines += ["**助手说明:**", "", note.strip(), ""]
lines += ["### 裁剪后流程(保留 → 执行;裁剪 → 跳过)", "",
"| # | 阶段 | 处理 |", "|---|---|---|"]
for i, s in enumerate(stages_snap, 1):
mark = "✅ 保留" if s["enabled"] else "✂️ 裁剪"
lines.append("| %d | %s | %s |" % (i, s.get("label") or s.get("key"), mark))
if warns:
lines += ["", "### ⚠️ 裁剪风险(裁掉的是质量门禁,请确认知晓)", ""]
lines += ["- " + w for w in warns]
lines += ["", "---", "",
"点击「确认并启用」后流程按上表生效,**确认后不可再改**",
"点击「驳回」并填写意见,助手会按你的意见修订后重新提案。"]
return "\n".join(lines)
# ══════════════════════ 用户确认 / 驳回(专用端点调用) ══════════════════════
async def _check_confirm_operator(sor, plan, operator_id):
"""确认权限:同机构 + owner.superuser 角色 或 项目创建者本人)。"""
if not operator_id:
return False, "未登录"
project_id = plan.get("project_id", "")
prec = await sor.sqlExe("SELECT org_id, created_by FROM sd_projects WHERE id=${p}$",
{"p": project_id})
await sor.sqlExe("COMMIT", {})
proj = _rec(prec[0]) if prec else {}
proj_org = proj.get("org_id", "") or ""
user_org = await _get_user_org(sor, operator_id)
if proj_org and user_org != proj_org:
return False, "仅同机构用户可确认该项目流程"
roles = await _get_user_roles(sor, operator_id)
if "owner.superuser" in roles:
return True, ""
if operator_id == (proj.get("created_by") or ""):
return True, ""
return False, "仅项目 owner / 超级管理员可确认流程裁剪"
async def confirm_flow_plan(plan_id, operator_id):
"""用户确认pending_confirm → confirmedCAS+ 关待办 + 收敛清理被裁阶段在办任务。"""
if not plan_id:
return False, "缺少 plan_id"
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT * FROM pipeline_flow_plans WHERE id=${i}$", {"i": plan_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return False, "流程计划不存在: %s" % plan_id
plan = _rec(recs[0])
if plan.get("status") != S_PENDING:
return False, "计划不在待确认状态(当前 %s" % plan.get("status")
ok, err = await _check_confirm_operator(sor, plan, operator_id)
if not ok:
return False, err
project_id = plan.get("project_id", "")
# CAS仅 pending_confirm 可确认(防并发/重复)
await sor.sqlExe(
"UPDATE pipeline_flow_plans SET status=${ns}$, confirmed_by=${u}$, "
"confirmed_at=NOW(), updated_at=NOW() WHERE id=${i}$ AND status=${os}$",
{"ns": S_CONFIRMED, "u": operator_id, "i": plan_id, "os": S_PENDING})
await sor.sqlExe("COMMIT", {})
chk = await sor.sqlExe("SELECT status FROM pipeline_flow_plans WHERE id=${i}$",
{"i": plan_id})
await sor.sqlExe("COMMIT", {})
if not chk or getattr(chk[0], "status", "") != S_CONFIRMED:
return False, "确认竞争失败(计划已被处理)"
# 关确认待办
ht = plan.get("confirm_task_id", "") or ""
if ht:
await sor.sqlExe(
"UPDATE pipeline_human_tasks SET status='done', qc_status='passed', "
"qc_comment='用户已确认流程裁剪方案', submitted_by=${u}$, submitted_at=NOW() "
"WHERE id=${h}$ AND status='pending'", {"u": operator_id, "h": ht})
await sor.sqlExe("COMMIT", {})
# 收敛清理:被裁阶段角色的在办任务取消(防确认后旧任务残留)
n_cancel = await _cancel_trimmed_tasks(sor, project_id, plan.get("stages"))
await record_audit(project_id, "pipeline_flow_plans", plan_id, "confirm",
from_state=S_PENDING, to_state=S_CONFIRMED, who=operator_id,
detail="user confirmed; cancelled %d trimmed-stage tasks" % n_cancel,
sor=sor)
await sor.sqlExe("COMMIT", {})
logger.info("flow_plan confirmed: project=%s plan=%s by=%s cancelled=%d",
project_id, plan_id, operator_id, n_cancel)
return True, "流程裁剪已确认并启用,系统将按裁剪后的流程派发任务。"
async def reject_flow_plan(plan_id, comment, operator_id):
"""用户驳回pending_confirm → rejected + 记意见(会话 agent 据此修订重提)。"""
if not plan_id:
return False, "缺少 plan_id"
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT * FROM pipeline_flow_plans WHERE id=${i}$", {"i": plan_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return False, "流程计划不存在: %s" % plan_id
plan = _rec(recs[0])
if plan.get("status") != S_PENDING:
return False, "计划不在待确认状态(当前 %s" % plan.get("status")
ok, err = await _check_confirm_operator(sor, plan, operator_id)
if not ok:
return False, err
project_id = plan.get("project_id", "")
await sor.sqlExe(
"UPDATE pipeline_flow_plans SET status=${ns}$, reject_comment=${c}$, "
"updated_at=NOW() WHERE id=${i}$ AND status=${os}$",
{"ns": S_REJECTED, "c": (comment or "")[:4000], "i": plan_id, "os": S_PENDING})
await sor.sqlExe("COMMIT", {})
ht = plan.get("confirm_task_id", "") or ""
if ht:
await sor.sqlExe(
"UPDATE pipeline_human_tasks SET status='rejected', "
"qc_comment=${c}$, submitted_by=${u}$, submitted_at=NOW() "
"WHERE id=${h}$ AND status='pending'",
{"c": (comment or "")[:2000], "u": operator_id, "h": ht})
await sor.sqlExe("COMMIT", {})
await record_audit(project_id, "pipeline_flow_plans", plan_id, "reject",
from_state=S_PENDING, to_state=S_REJECTED, who=operator_id,
detail=(comment or "")[:500], sor=sor)
await sor.sqlExe("COMMIT", {})
logger.info("flow_plan rejected: project=%s plan=%s by=%s", project_id, plan_id, operator_id)
return True, "已驳回,助手将按你的意见修订流程后重新提案。"
async def _cancel_trimmed_tasks(sor, project_id, stages_json):
"""取消「仅属于被裁阶段」角色的在办任务(收敛清理,防确认后旧任务残留)。
角色可能跨阶段共用如投标 tender_analyst 属于 4 个解析维度阶段
只裁其中一个阶段时该角色仍被保留阶段需要**不得取消**
仅当角色不属于任何保留阶段时才取消其在办任务
"""
try:
snap = json.loads(stages_json) if isinstance(stages_json, str) else (stages_json or [])
except (json.JSONDecodeError, ValueError):
snap = []
trimmed_roles, kept_roles = set(), set()
for s in snap:
if not isinstance(s, dict):
continue
for r in (s.get("roles") or []):
if r:
(kept_roles if s.get("enabled", True) else trimmed_roles).add(r)
roles = trimmed_roles - kept_roles # 只裁独占角色
if not roles:
return 0
states = "','".join(("submitted", "running", "review", "qc_review", "waiting"))
role_in = "','".join(sorted(roles))
recs = await sor.sqlExe(
"SELECT id FROM pipeline_tasks WHERE tenant_id=${p}$ AND pipeline_id='role_task' "
"AND role IN ('" + role_in + "') AND state IN ('" + states + "')",
{"p": project_id})
await sor.sqlExe("COMMIT", {})
ids = [getattr(r, "id", "") for r in (recs or []) if getattr(r, "id", "")]
if not ids:
return 0
for tid in ids:
await sor.sqlExe(
"UPDATE pipeline_tasks SET state='cancelled', claimed_by=NULL, updated_at=NOW() "
"WHERE id=${t}$ AND state IN ('" + states + "')", {"t": tid})
await record_audit(project_id, "pipeline_tasks", tid, "cancel",
to_state="cancelled", who="system.flow_plan",
detail="流程裁剪确认:被裁阶段在办任务收敛取消", sor=sor)
await sor.sqlExe("COMMIT", {})
return len(ids)
# ══════════════════════ 流转机制读取(对账器 / next_role 跳段) ══════════════════════
async def get_active_plan(project_id):
"""取项目已确认confirmed的流程计划 dict无则返回 {}"""
if not project_id:
return {}
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT * FROM pipeline_flow_plans WHERE project_id=${p}$ AND status=${s}$ "
"ORDER BY version DESC LIMIT 1", {"p": project_id, "s": S_CONFIRMED})
await sor.sqlExe("COMMIT", {})
return _rec(recs[0]) if recs else {}
async def plan_stage_enabled(project_id):
"""返回 {stage_key: bool} —— confirmed 计划的阶段启用表;无计划返回 None用标准全流程
流转机制对账器/next_role据此判定某阶段是否执行None=不裁剪标准全流程
dict dict.get(key, True)=False 表示该阶段被裁
"""
plan = await get_active_plan(project_id)
if not plan:
return None
try:
snap = json.loads(plan.get("stages") or "[]")
except (json.JSONDecodeError, ValueError):
snap = []
return {s.get("key"): bool(s.get("enabled", True)) for s in snap if isinstance(s, dict)}
async def stage_enabled(project_id, stage_key, default=True):
"""单个阶段是否启用无计划→default通常 True=标准全流程都执行)。"""
tbl = await plan_stage_enabled(project_id)
if tbl is None:
return default
return tbl.get(stage_key, default)
async def get_latest_plan(project_id):
"""取项目最新一条计划(任意状态,供 get_flow_plan 工具展示进度)。"""
if not project_id:
return {}
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT * FROM pipeline_flow_plans WHERE project_id=${p}$ "
"ORDER BY version DESC, created_at DESC LIMIT 1", {"p": project_id})
await sor.sqlExe("COMMIT", {})
return _rec(recs[0]) if recs else {}
# ══════════════════════ PM 复杂度拆解create_sub_tasks通用角色工具 ══════════════════════
async def create_sub_tasks(project_id="", tasks=None, parent_task_id="", task_id="",
who=None, agent_id=None):
"""PM 把复杂任务拆解为子任务组并挂进项目主流程(通用,各产线 PM 角色可用)。
复杂度判断归 PM技能里的判断标准本工具是机制执行件复用引擎
_pm_create_tasks 的完整语义
- parent_id 挂父任务任务树分层展示子流程自动进主流程视图
- key/depends_on 同批互引 + dep_policy 启动策略all/any/at_least
- 依赖解析不完整 任务置 waiting + 冒泡绝不静默降级为并行
- 同标题活跃任务自动取消重做保护
- 派发后跑编排查漏G1/G2/G3缺口当场返回给 PM
角色守卫子任务角色必须属于当前产线角色集防跨产线角色名幻觉建死任务
"""
if not project_id:
return False, "缺少 project_id"
if isinstance(tasks, str):
try:
tasks = json.loads(tasks)
except (json.JSONDecodeError, ValueError):
return False, "tasks 必须是 JSON 数组字符串"
if not tasks or not isinstance(tasks, list):
return False, "tasks 必须是子任务 JSON 数组(每项 {title, role, description, key?, depends_on?}"
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
# 产线角色守卫(同 _h_create_task 语义):查项目产线,角色必须在其角色集
prec = await sor.sqlExe(
"SELECT pipeline_id FROM sd_projects WHERE id=${p}$", {"p": project_id})
await sor.sqlExe("COMMIT", {})
pid = getattr(prec[0], "pipeline_id", "") if prec else ""
if pid:
from pipeline_core import get_role_spec, list_roles as _list_roles
if _list_roles(pid):
bad = []
for t in tasks:
if not isinstance(t, dict):
continue
r = str(t.get("role") or "").strip()
if not r:
bad.append("(缺role)「%s" % str(t.get("title") or "")[:30])
continue
spec = get_role_spec(pid, r)
if not spec:
bad.append("%s" % r)
else:
t["role"] = spec.name # 归一到注册表规范名
if bad:
avail = "".join(s.name for s in _list_roles(pid))
return False, ("角色不属于产线「%s」:%s。可用角色:%s"
% (pid, "".join(bad), avail))
from .agent_loop import _pm_create_tasks
parent = (parent_task_id or task_id or "").strip()
# 继承父任务的产线门禁风格:投标等产线角色任务带 skip_generic_qc产线自带
# 质量门禁,豁免引擎通用 QC——子任务必须继承否则拆解出的任务误走通用门禁。
extra_params = None
if parent:
prec2 = await sor.sqlExe(
"SELECT params FROM pipeline_tasks WHERE id=${t}$ AND tenant_id=${p}$",
{"t": parent, "p": project_id})
await sor.sqlExe("COMMIT", {})
if prec2:
try:
pp = json.loads(getattr(prec2[0], "params", "") or "{}")
except (json.JSONDecodeError, TypeError):
pp = {}
if isinstance(pp, dict) and pp.get("skip_generic_qc"):
extra_params = {"skip_generic_qc": True}
result = await _pm_create_tasks(
sor, project_id, {"tasks": tasks, "parent_id": parent},
parent_task_id=parent or None, extra_params=extra_params)
await sor.sqlExe("COMMIT", {})
await record_audit(project_id, "pipeline_tasks", parent or project_id,
"create_sub_tasks", who=who, agent_id=agent_id,
detail="PM 拆解 %d 个子任务 parent=%s" % (len(tasks), parent or "(无)"),
sor=sor)
await sor.sqlExe("COMMIT", {})
if str(result).startswith("FAIL"):
return False, str(result)
return True, str(result)
# ══════════════════════ 会话工具FLOW_PLAN_TOOLS / HANDLERSshared_ability 聚合) ══════════════════════
try:
from pipeline_core import ToolDefinition
except Exception: # pragma: no cover - core 不可用时降级(工具不注册)
ToolDefinition = None
FLOW_PLAN_TOOLS = []
if ToolDefinition is not None:
FLOW_PLAN_TOOLS = [
ToolDefinition(
name="show_flow_template",
description="查看当前项目所属产线的预制流程(各阶段作用 + 可裁性:不可裁/可裁需警告/可自由裁剪)。用户提出裁剪流程前先用它了解可裁项",
parameters={}, required=[], category="project"),
ToolDefinition(
name="propose_flow_plan",
description=("按用户要求裁剪项目流程,生成裁剪草案并发「流程裁剪确认」待办给用户。"
"裁剪合法性由系统硬校验(不可裁阶段/依赖断裂/已开跑会被拒绝并说明原因)。"
"草案需用户在待办里确认才生效——你没有确认权,禁止声称已确认。"
"trim_keys 传要裁掉的阶段 key 数组(来自 show_flow_template"),
parameters={"trim_keys": "要裁剪的阶段key数组(如[\"analysis_cost_benefit\"]),空数组=不裁剪仅走确认",
"user_requirements": "用户裁剪要求原文(必填,写进确认待办)",
"propose_note": "你对裁剪方案的说明/风险提示(可选)"},
required=["user_requirements"], category="project"),
ToolDefinition(
name="get_flow_plan",
description="查看当前项目流程裁剪计划的状态(草案/待确认/已确认/已驳回+驳回意见)",
parameters={}, required=[], category="project"),
]
async def _h_show_flow_template(sor, params, ctx):
pid = (ctx.get("pipeline_id") or "").strip()
project_id = ctx.get("project_id") or ""
pid2, stages = await get_flow_template(project_id=project_id, pipeline_id=pid)
if not stages:
return ("当前产线(%s)未声明可裁剪流程,按标准全流程执行,无需裁剪。"
% (pid2 or "未知"))
return ("产线「%s」预制流程(共 %d 阶段):\n%s\n\n"
"用户要裁剪时,把要裁掉的阶段 key 传给 propose_flow_plan。"
% (pid2 or "?", len(stages), render_template_md(stages)))
async def _h_propose_flow_plan(sor, params, ctx):
project_id = ctx.get("project_id") or ""
if not project_id:
return "ERROR: 请先切换到项目(用 switch_project 或在项目列表选择)"
ok, msg = await propose_flow_plan(
project_id,
trim_keys=params.get("trim_keys"),
user_requirements=params.get("user_requirements", "") or "",
propose_note=params.get("propose_note", "") or "",
pipeline_id=ctx.get("pipeline_id", "") or "",
who="agent.main_agent",
agent_id=ctx.get("user_id", "") or "")
return ("OK: " if ok else "ERROR: ") + msg
async def _h_get_flow_plan(sor, params, ctx):
project_id = ctx.get("project_id") or ""
if not project_id:
return "ERROR: 请先切换到项目"
plan = await get_latest_plan(project_id)
if not plan:
return "当前项目尚无流程裁剪计划(按产线标准全流程执行)。用 show_flow_template 查看可裁项。"
st_label = {S_DRAFT: "草案", S_PENDING: "待用户确认", S_CONFIRMED: "已确认生效",
S_REJECTED: "已驳回", S_SUPERSEDED: "已被新版本取代"}
try:
snap = json.loads(plan.get("stages") or "[]")
except (json.JSONDecodeError, ValueError):
snap = []
kept = [str(s.get("label") or s.get("key") or "?") for s in snap if isinstance(s, dict) and s.get("enabled")]
trimmed = [str(s.get("label") or s.get("key") or "?") for s in snap if isinstance(s, dict) and not s.get("enabled")]
lines = ["流程裁剪计划 v%s%s" % (plan.get("version"), st_label.get(plan.get("status"), plan.get("status"))),
"保留:%s" % ("".join(kept) or "(无)"),
"裁剪:%s" % ("".join(trimmed) or "(无)")]
if plan.get("status") == S_REJECTED and plan.get("reject_comment"):
lines.append("驳回意见:%s" % plan.get("reject_comment"))
lines.append("→ 请按驳回意见修订后用 propose_flow_plan 重新提案version 自动 +1")
if plan.get("status") == S_PENDING:
lines.append("→ 已发确认待办,等用户在「流程裁剪确认」待办里确认;你不能代替确认。")
return "\n".join(lines)
FLOW_PLAN_HANDLERS = {
"show_flow_template": _h_show_flow_template,
"propose_flow_plan": _h_propose_flow_plan,
"get_flow_plan": _h_get_flow_plan,
}

View File

@ -531,6 +531,18 @@ def load_pipeline_service():
env.suggest_kb_ingest = suggest_kb_ingest
env.decide_kb_ingest = decide_kb_ingest
# 产线流程裁剪(提案/确认/驳回/查询,确认端点 flow_plan_confirm.dspy 调用)
from .flow_plan_capability import (
propose_flow_plan, confirm_flow_plan, reject_flow_plan,
get_active_plan, get_latest_plan, get_flow_template,
)
env.flow_plan_propose = propose_flow_plan
env.flow_plan_confirm = confirm_flow_plan
env.flow_plan_reject = reject_flow_plan
env.flow_plan_active = get_active_plan
env.flow_plan_latest = get_latest_plan
env.flow_template = get_flow_template
# Register default handler
register_default_handler()

View File

@ -55,6 +55,14 @@ def _build():
missing = names - set(SHARED_HANDLERS)
if missing:
logger.info("shared_ability: 以下通用工具未找到 handler跳过: %s", sorted(missing))
# 流程裁剪工具(通用机制,产线声明 flow_stages 即可用;未声明的产线工具诚实降级)
try:
from .flow_plan_capability import FLOW_PLAN_TOOLS, FLOW_PLAN_HANDLERS
_seen = {getattr(t, "name", "") for t in SHARED_TOOLS}
SHARED_TOOLS += [t for t in FLOW_PLAN_TOOLS if getattr(t, "name", "") not in _seen]
SHARED_HANDLERS.update(FLOW_PLAN_HANDLERS)
except Exception as e:
logger.warning("shared_ability: 流程裁剪工具不可用(跳过): %s", str(e)[:160])
logger.info("shared_ability: %d tools / %d handlers 可复用",
len(SHARED_TOOLS), len(SHARED_HANDLERS))