pipeline-service/pipeline_service/flow_plan_capability.py

836 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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, flow_key=""):
"""从能力包取产线声明的阶段,返回 (stage_map, ordered_list)。
flow_key 非空时取多流程模板FlowTemplate对应阶段空 = 单流程 flow_stages。
"""
from pipeline_core import get_flow_stages
stages = get_flow_stages(pipeline_id, flow_key=flow_key or "") 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="", flow_key=""):
"""返回 (pid, [stage_dict,...]):产线预制流程阶段(含可裁性),供 show_flow_template 渲染。
flow_key 空时:项目已有确认/待确认计划则取计划的 base_flow_key展示实际将走的流程
多流程产线无计划时展示全部模板概览由调用方处理。
"""
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
pid, _proj = await _resolve_pipeline(sor, project_id, pipeline_id)
fk = (flow_key or "").strip()
if not fk and project_id:
plan = await _latest_plan_rec(sor, project_id)
fk = (plan.get("base_flow_key") or "").strip()
from pipeline_core import flow_stage_to_dict
_map, stages = _load_stages(pid, fk)
return pid, [flow_stage_to_dict(s) for s in stages]
async def list_flow_templates(pipeline_id=""):
"""列出产线的多流程模板 [(key, name, description, n_stages)];单流程产线返回 []。"""
from pipeline_core import get_flow_templates
return [(t.key, t.name or t.key, t.description or "", len(t.stages or []))
for t in get_flow_templates(pipeline_id)]
# ══════════════════════ 文件自动判流多流程机制2026-09-14 批2 ══════════════════════
async def detect_flow_template(pipeline_id, file_name="", file_text="",
project_id="", org_id=""):
"""LLM 按各模板 detect_criteria 给导入文件判流(语义判断归 LLM2026-09-11 用户铁律)。
返回 (flow_key, detect_note)
- 单流程产线(模板 <2→ ("", ""),调用方走现状(不判流不确认);
- 判流成功 → (模板key, 识别结论文本:置信度+判据摘录,进确认待办给用户核对)
- LLM 失败/无法解析 → ("", 错误说明):调用方**不得**默认选流程硬跑,
应发人工介入待办(判流是流程选择的语义判断,禁词匹配兜底、禁静默降级)。
错判由 flow_plan_confirm 确认门兜住:用户在待办里看到识别结论,驳回附意见重提。
"""
from pipeline_core import get_flow_templates
tpls = get_flow_templates(pipeline_id)
if len(tpls) < 2:
return "", ""
snippet = (file_text or "").strip()[:6000]
if not snippet and not file_name:
return "", "判流失败:文件内容为空"
tpl_lines = []
for t in tpls:
tpl_lines.append("- key=%s 名称=%s\n 适用判据:%s" % (
t.key, t.name or t.key, (t.detect_criteria or t.description or "(未写判据)")))
prompt = (
"你是产线流程路由器。根据输入文件的特征,判断它应该走哪条预制流程。\n\n"
"【候选流程】\n%s\n\n"
"【文件名】%s\n\n【文件正文(截断)】\n%s\n\n"
"严格只输出一个 JSON 对象:\n"
"{\"flow_key\": \"候选之一的key\", \"confidence\": \"high|medium|low\",\n"
" \"evidence\": \"判据摘录文件里哪些特征原文引用≤3处支持这个判断\"}\n"
"无法归入任何候选流程时 flow_key 输出空字符串并在 evidence 说明原因。"
% ("\n".join(tpl_lines), file_name or "(无)", snippet or "(无正文)")
)
try:
from pipeline_service.llm_bridge import llm_call
raw = await llm_call(prompt, purpose="utility", org_id=org_id or "",
project_id=project_id or "", timeout=45)
except Exception as e:
return "", "判流 LLM 调用失败:%s" % str(e)[:200]
txt = (raw or "").strip()
if txt.startswith("```"):
parts = txt.split("\n", 1)
txt = parts[1] if len(parts) > 1 else txt
if "```" in txt:
txt = txt.rsplit("```", 1)[0]
txt = txt.strip()
try:
if not txt.startswith("{") and "{" in txt:
txt = txt[txt.find("{"):txt.rfind("}") + 1]
data = json.loads(txt)
except (json.JSONDecodeError, ValueError):
return "", "判流结果无法解析:%s" % (raw or "")[:200]
fk = str(data.get("flow_key") or "").strip()
conf = str(data.get("confidence") or "").strip()
ev = str(data.get("evidence") or "").strip()
valid = {t.key for t in tpls}
if fk and fk not in valid:
return "", "判流返回未知流程 key%s(候选:%s" % (fk, "".join(sorted(valid)))
if not fk:
return "", "判流无法归类:%s" % (ev or "LLM 未说明原因)")
note = "识别为「%s」流程(置信度 %s)。\n判据:%s" % (
fk, conf or "?", ev or "(未提供判据摘录)")
if conf == "low":
note += "\n⚠️ 识别置信度低,请重点核对——不对就驳回并说明应走哪条流程。"
return fk, note
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="", flow_key="",
detect_note="", who=None, agent_id=None):
"""按用户要求生成裁剪草案,落库 pending_confirm 并发确认待办。
flow_key多流程机制2026-09-14 批2指定基准流程模板 key。
- 空 = 单流程产线(用 flow_stages 声明),或多流程产线沿用已有计划的模板;
- 非空 = 按该模板的阶段链出草案base_flow_key 落库,流转机制按它走分支)。
detect_note判流依据说明LLM 自动判流时写识别结论+判据摘录,进确认待办给用户核对)。
代码硬校验(任一不过即拒绝,返回可行动原因,绝不静默降级):
1. 项目/产线存在,且产线声明了 flow_stages或 flow_key 对应模板);
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)
flow_key = (flow_key or "").strip()
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, "项目未关联产线,无法裁剪流程"
# flow_key 校验:多流程产线必须是已声明的模板 key
if flow_key:
from pipeline_core import get_flow_template as _get_ftpl
if not _get_ftpl(pid, flow_key):
tpls = await list_flow_templates(pid)
avail = "".join(k for k, _n, _d, _c in tpls) or "(该产线未声明多流程模板)"
return False, ("未知流程模板 key%s。可用流程:%s" % (flow_key, avail))
else:
# 未指定:已有计划则沿用其 base_flow_key驳回重提场景
old_plan = await _latest_plan_rec(sor, project_id)
flow_key = (old_plan.get("base_flow_key") or "").strip()
stage_map, stages = _load_stages(pid, flow_key)
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,
"base_flow_key": flow_key,
"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,
flow_key=flow_key, pipeline_id=pid,
detect_note=detect_note)
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 flow_key=%s trim=%s" % (
version, flow_key or "(单流程)",
",".join(trim_keys) or "(无裁剪)"),
sor=sor)
await sor.sqlExe("COMMIT", {})
logger.info("flow_plan propose: project=%s plan=%s v=%d flow_key=%s trim=%s",
project_id, plan_id, version, flow_key, 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 flow_key:
msg = "基准流程:%s\n%s" % (flow_key, msg)
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,
flow_key="", pipeline_id="", detect_note=""):
"""确认待办正文markdowntodo_detail 直接渲染)。"""
lines = ["## 项目「%s」流程裁剪方案v%d" % (pname, version), ""]
if flow_key:
# 多流程产线:展示判定的流程模板名 + 判流依据(用户核对识别结论)
try:
from pipeline_core import get_flow_template as _gft
tpl = _gft(pipeline_id, flow_key) if pipeline_id else None
tname = (tpl.name if tpl else "") or flow_key
except Exception:
tname = flow_key
lines += ["**将走的流程:** %s`%s`" % (tname, flow_key), ""]
if detect_note:
lines += ["### 文件识别结论(自动判流)", "", detect_note.strip(), "",
"> 识别不对?点「驳回」并说明应走哪条流程,助手会修订重提。", ""]
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:
return await _latest_plan_rec(sor, project_id)
async def _latest_plan_rec(sor, project_id):
"""取项目最新一条计划(已开 sor 上下文版,内部复用)。"""
if not project_id:
return {}
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 {}
async def get_plan_flow_key(project_id):
"""项目已确认计划的 base_flow_key'' = 无计划/单流程)。流转机制按它选流程分支。"""
plan = await get_active_plan(project_id)
return (plan.get("base_flow_key") or "").strip() if plan 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": "你对裁剪方案的说明/风险提示(可选)",
"flow_key": "多流程产线指定流程模板key(可选,来自show_flow_template;单流程产线留空)"},
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 ""
tpls = await list_flow_templates(pid)
head = ""
if len(tpls) >= 2:
head = ("本产线是多流程产线可选流程模板propose_flow_plan 用 flow_key 指定):\n"
+ "\n".join("- %s%s%s%d 阶段" % (k, n, d[:80], c)
for k, n, d, c in tpls) + "\n\n")
pid2, stages = await get_flow_template(project_id=project_id, pipeline_id=pid)
if not stages:
return head + ("当前产线(%s)未声明可裁剪流程,按标准全流程执行,无需裁剪。"
% (pid2 or "未知"))
return head + ("产线「%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 "",
flow_key=params.get("flow_key", "") 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,
}