pipeline-service/pipeline_service/flow_plan_capability.py

1354 lines
70 KiB
Python
Raw Permalink 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,
resolve_project_owner_assignee,
)
DBNAME = "pipeline"
async def _owner_assignee(sor, project_id):
"""确认/介入待办归属 (role, id):项目真人创建者优先(进其「我的待办」),
无主项目回退 owner.superuser。2026-09-18 银联事故:硬编码 owner.superuser
导致非超管机构创建者待办 0 条、确认无门。"""
return await resolve_project_owner_assignee(sor, project_id)
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)。运行中调整流程请改用 "
"revise_flow_plan 提出修订(可改裁剪、可从任一阶段按用户意见"
"定向重做),修订经用户确认后生效。"
% 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)
_ar, _ai = await _owner_assignee(sor, project_id)
ok_ht, ht = await create_human_task(
project_id,
"流程裁剪确认:%sv%d" % (pname, version),
desc, task_type=T_FLOW_CONFIRM,
assignee_role=_ar, assignee_id=_ai,
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 += ["", "---", "",
"点击「确认并启用」后流程按上表生效生效后可随时再提修订revise_flow_plan",
"含从任一阶段定向重做);点击「驳回」并填写意见,助手会按你的意见修订后重新提案。"]
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", {})
# 修订版换版2026-09-17 流程修订机制):旧 confirmed 版本置 superseded
# ——同一项目同时只允许一个 confirmed 计划plan_stage_enabled/get_active_plan
# 按 version DESC 取最新,但显式换版语义更清晰,审计可追溯)。
await sor.sqlExe(
"UPDATE pipeline_flow_plans SET status=${ns}$, updated_at=NOW() "
"WHERE project_id=${p}$ AND status=${cs}$ AND id<>${i}$",
{"ns": S_SUPERSEDED, "p": project_id, "cs": S_CONFIRMED, "i": plan_id})
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; old confirmed superseded; "
"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)
# 定向重做reentry_stage 非空):调产线钩子执行阶段重置。放在计划生效后——
# 重置后产线流转机制按新计划+被重置的状态自动重新推进。
reentry_msg = ""
if (plan.get("reentry_stage") or "").strip():
ok_r, msg_r = await _run_stage_reentry(sor, plan, operator_id)
if ok_r:
reentry_msg = "\n定向重做已执行:%s" % (msg_r or "阶段重置完成,流程将自动重新推进")
else:
reentry_msg = "\n⚠️ 定向重做执行失败:%s(已抛人工待办)" % (msg_r or "未知错误")
_is_rev = int(plan.get("version", 1) or 1) > 1 or (plan.get("reentry_stage") or "").strip()
_base = ("流程修订已确认并启用,系统将按新计划派发任务。" if _is_rev
else "流程裁剪已确认并启用,系统将按裁剪后的流程派发任务。")
return True, _base + reentry_msg
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)
async def _affected_stage_keys(stages_snap, stage_key):
"""注入阶段 + 其依赖闭包下游阶段 key 集合(按快照 deps 反向传播)。"""
keys = {s.get("key") for s in stages_snap if isinstance(s, dict)}
if stage_key not in keys:
return set()
affected = {stage_key}
changed = True
while changed:
changed = False
for s in stages_snap:
if not isinstance(s, dict):
continue
k = s.get("key")
if k in affected:
continue
if any(d in affected for d in (s.get("deps") or [])):
affected.add(k)
changed = True
return affected
async def _render_reentry_desc(pipeline_id, project_id, stage_key, affected, stages_snap, sor):
"""重做影响说明:产线钩子优先,无钩子给通用文案。"""
label_map = {(s.get("key") or ""): (s.get("label") or s.get("key") or "")
for s in stages_snap if isinstance(s, dict)}
try:
from pipeline_core import get_stage_reentry_describe
fn = get_stage_reentry_describe(pipeline_id)
if fn:
txt = await fn(sor, project_id, stage_key, sorted(affected))
if txt:
return txt
except Exception as e:
logger.warning("stage_reentry_describe hook failed: %s", str(e)[:160])
lines = ["从阶段「%s」起重做,将影响以下阶段(含其下游):" % label_map.get(stage_key, stage_key), ""]
for k in stages_snap:
if isinstance(k, dict) and k.get("key") in affected:
lines.append("- %s" % (k.get("label") or k.get("key")))
return "\n".join(lines)
async def revise_flow_plan(project_id, user_requirements="", reentry_stage="",
trim_keys=None, propose_note="", reentry_detect_note="",
pipeline_id="", who=None, agent_id=None):
"""流程修订提案:对已 confirmed 计划提出修订版(改裁剪 和/或 定向重做)。
与 propose_flow_plan 的分工:
- propose_flow_plan项目未开跑时的首次裁剪提案confirmed 计划存在时拒绝);
- revise_flow_plan已有 confirmed 计划后的修订提案(无 confirmed 计划时拒绝,
让调用方走 propose
reentry_stage 空 = 仅调整裁剪;非空 = 定向重做(确认后产线钩子重置该阶段及
依赖下游的产出/状态,流转机制自动从该阶段重新推进)。
校验(代码硬校验,不靠 LLM 自觉):
1. 存在 confirmed 计划(修订对象);
2. 无 pending_confirm 计划在途(一次只允许一个修订在途;旧 pending 自动 superseded
沿用 propose 的版本语义);
3. trim_keys 合法性同 propose未知阶段/不可裁/依赖断裂拒绝);
4. reentry_stage 必须是声明阶段且在新计划中保留enabled——被裁阶段无产出可重做
5. 产线未注册 stage_reentry 钩子且 reentry_stage 非空 → 拒绝(诚实降级:该产线
不支持定向重做,说明原因,不静默忽略用户要求)。
"""
if not project_id:
return False, "缺少 project_id请先切换到项目"
user_requirements = (user_requirements or "").strip()
if not user_requirements:
return False, "缺少 user_requirements用户的修订要求原文必填——进确认待办"
reentry_stage = (reentry_stage or "").strip()
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, "项目未关联产线,无法修订流程"
pname = proj.get("name") or project_id
old_plan = await _latest_plan_rec(sor, project_id)
if not old_plan:
return False, "项目尚无流程计划——首次裁剪请用 propose_flow_plan"
flow_key = (old_plan.get("base_flow_key") or "").strip()
if old_plan.get("status") == S_PENDING:
return False, ("已有修订版待用户确认plan %s v%s)——等用户处理完再提,"
"或让用户驳回后按意见重提" % (
(old_plan.get("id") or "")[:8], old_plan.get("version")))
if old_plan.get("status") != S_CONFIRMED:
# rejected/superseded/draft走 propose 重提(版本语义不变)
return False, ("最新计划状态为 %s(非已确认)——修订仅针对已生效计划,"
"请用 propose_flow_plan 重新提案" % old_plan.get("status"))
stage_map, stages = _load_stages(pid, flow_key)
if not stages:
return False, "产线「%s」未声明可裁剪流程,不支持修订" % pid
# 校验3trim 合法性(与 propose 同规则)
unknown = [k for k in trim_keys if k not in stage_map]
if unknown:
return False, "未知阶段 key%s。本产线阶段见 show_flow_template。" % "".join(unknown)
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
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:
broken.append("%s 依赖被裁的 %s" % (s.label or s.key,
stage_map[d].label or d))
if broken:
return False, "裁剪导致依赖断裂:%s。请保留上游阶段或一并裁剪下游。" % "".join(broken)
# 校验4/5reentry 合法性
reentry_note = (reentry_detect_note or "").strip()
if reentry_stage:
if reentry_stage not in stage_map:
return False, ("未知重做阶段 key%s。可注入阶段见 show_flow_template。"
% reentry_stage)
if reentry_stage in trim_set:
return False, ("重做阶段「%s」同时被裁剪,矛盾——被裁阶段不会执行,"
"无法从它重做。请保留该阶段。"
% (stage_map[reentry_stage].label or reentry_stage))
try:
from pipeline_core import get_stage_reentry_handler
has_hook = get_stage_reentry_handler(pid) is not None
except Exception:
has_hook = False
if not has_hook:
return False, ("产线「%s」未注册阶段重做能力stage_reentry 钩子),"
"暂不支持定向重做;可联系平台扩展该产线。" % pid)
warns = [stage_map[k].warn_note for k in trim_keys
if (stage_map[k].trim or "") == "warn" and stage_map[k].warn_note]
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)
# 版本号 +1旧 pending/draft 置 superseded同 propose 语义)
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[:8000],
"propose_note": (propose_note or "")[:8000],
"reentry_stage": reentry_stage,
"reentry_detect_note": reentry_note[:8000],
"created_by": who or "agent.main_agent",
})
await sor.sqlExe("COMMIT", {})
# 确认待办正文:裁剪对照表 + 重做影响预告(破坏性动作必须在确认前可见)
desc = _render_confirm_md(pname, stages_snap, trim_keys, warns,
user_requirements, propose_note, version,
flow_key=flow_key, pipeline_id=pid)
if reentry_stage:
affected = await _affected_stage_keys(stages_snap, reentry_stage)
rdesc = await _render_reentry_desc(pid, project_id, reentry_stage,
affected, stages_snap, sor)
label = stage_map[reentry_stage].label or reentry_stage
reentry_md = ["", "### 🔁 定向重做(从「%s」阶段起)" % label, ""]
if reentry_note:
reentry_md += ["**注入点判定依据:**", "", reentry_note.strip(), ""]
reentry_md += ["**确认后系统将执行:**", "", rdesc.strip(), "",
"> ⚠️ 重做会重置上述阶段的已有产出(见影响说明),流程从该阶段自动重新推进。"]
desc = desc + "\n".join(reentry_md)
_ar2, _ai2 = await _owner_assignee(sor, project_id)
ok_ht, ht = await create_human_task(
project_id,
("流程修订确认:%sv%d%s" % (
pname, version, ",含定向重做" if reentry_stage else "")),
desc, task_type=T_FLOW_CONFIRM,
assignee_role=_ar2, assignee_id=_ai2,
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, "revise_propose",
to_state=S_PENDING, who=who, agent_id=agent_id,
detail="version=%d reentry=%s trim=%s" % (
version, reentry_stage or "(无)",
",".join(trim_keys) or "(无裁剪)"),
sor=sor)
await sor.sqlExe("COMMIT", {})
logger.info("flow_plan revise: project=%s plan=%s v=%d reentry=%s trim=%s",
project_id, plan_id, version, reentry_stage, 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 reentry_stage:
msg += "\n定向重做注入点:%s%s" % (
stage_map[reentry_stage].label or reentry_stage, reentry_stage)
if warns:
msg += "\n⚠️ 风险提示(请在待办里向用户说明):\n" + "\n".join("- " + w for w in warns)
msg += ("\n修订确认前旧计划持续生效;用户需在「流程修订确认」待办里确认后修订才生效"
"重做重置在确认时执行agent 不能代替确认。")
return True, msg
async def detect_reentry_stage(project_id, user_comment="", pipeline_id=""):
"""LLM 判定定向重做注入点(用户只给意见时,语义判断归 LLM2026-09-17 用户裁定)。
返回 (stage_key, detect_note)
- 判定成功 → (阶段key, 判定依据文本:置信度+evidence进确认待办给用户核对)
- 产线未声明流程/LLM 失败/无法归类 → ("", 错误说明):调用方不得默认选阶段硬跑,
应把失败原因如实告知用户禁词匹配兜底、禁静默降级——detect_flow_template 同款纪律)。
错判由 flow_plan_confirm 确认门兜住:用户在修订确认待办里看到注入点+判据,
驳回附意见重提。
"""
if not project_id:
return "", "缺少 project_id"
user_comment = (user_comment or "").strip()
if not user_comment:
return "", "缺少用户意见原文user_comment"
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
pid, proj = await _resolve_pipeline(sor, project_id, pipeline_id)
if not pid:
return "", "项目未关联产线"
old_plan = await _latest_plan_rec(sor, project_id)
flow_key = (old_plan.get("base_flow_key") or "").strip() if old_plan else ""
stage_map, stages = _load_stages(pid, flow_key)
if not stages:
return "", "产线「%s」未声明流程阶段,无法判定注入点" % pid
# 进度摘要:产线钩子优先,无钩子给通用任务状态摘要
summary = ""
try:
from pipeline_core import get_progress_summary_handler
fn = get_progress_summary_handler(pid)
if fn:
summary = await fn(sor, project_id) or ""
except Exception as e:
logger.warning("progress_summary hook failed: %s", str(e)[:160])
if not summary:
summary = await _generic_progress_summary(sor, project_id)
stage_lines = []
for i, s in enumerate(stages, 1):
stage_lines.append("%d. key=%s 名称=%s\n 作用:%s" % (
i, s.key, s.label or s.key, (s.description or "(未写说明)")[:200]))
prompt = (
"你是产线流程调度器。用户要求对进行中的项目做修改/重做,请判定应从哪个流程阶段"
"注入重做(该阶段及其下游会按用户意见重新执行)。\n\n"
"【流程阶段(按执行顺序)】\n%s\n\n"
"【项目当前进度】\n%s\n\n"
"【用户意见原文】\n%s\n\n"
"判定原则:选择「能覆盖用户意见涉及的最小上游阶段」——从更早阶段重做代价更大,"
"从更晚阶段重做可能漏改;用户意见明确指向某阶段产物时直接选该阶段。\n"
"严格只输出一个 JSON 对象:\n"
"{\"stage_key\": \"候选之一的key\", \"confidence\": \"high|medium|low\",\n"
" \"evidence\": \"判定依据:用户意见哪句话+进度哪个事实支持从该阶段注入≤120字\"}\n"
"无法判定时 stage_key 输出空字符串并在 evidence 说明缺什么信息。"
% ("\n".join(stage_lines), (summary or "(无进度信息)")[:4000], user_comment[:3000])
)
try:
from pipeline_service.llm_bridge import llm_call
raw = await llm_call(prompt, purpose="utility",
org_id=(proj.get("org_id") or "") if proj else "",
project_id=project_id, 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]
sk = str(data.get("stage_key") or "").strip()
conf = str(data.get("confidence") or "").strip()
ev = str(data.get("evidence") or "").strip()
valid = {s.key for s in stages}
if sk and sk not in valid:
return "", "判定返回未知阶段 key%s(候选:%s" % (sk, "".join(sorted(valid)))
if not sk:
return "", "无法判定注入点:%s" % (ev or "LLM 未说明原因)")
label = stage_map[sk].label or sk
note = "判定注入点:「%s」(%s,置信度 %s)。\n依据:%s" % (
label, sk, conf or "?", ev or "(未提供依据)")
if conf == "low":
note += "\n⚠️ 判定置信度低,请在确认待办里重点核对——不对就驳回并说明应从哪个阶段重做。"
return sk, note
async def _generic_progress_summary(sor, project_id):
"""通用进度摘要(产线无 progress_summary 钩子时的兜底):任务状态 + 阶段产出计数。"""
try:
recs = await sor.sqlExe(
"SELECT role, state, COUNT(*) AS c FROM pipeline_tasks WHERE tenant_id=${p}$ "
"AND pipeline_id='role_task' GROUP BY role, state ORDER BY role",
{"p": project_id})
await sor.sqlExe("COMMIT", {})
lines = ["角色任务状态分布:"]
for r in (recs or []):
lines.append("- %s %s: %s" % (getattr(r, "role", "?"),
getattr(r, "state", "?"), getattr(r, "c", 0)))
ht = await sor.sqlExe(
"SELECT task_type, title, status FROM pipeline_human_tasks WHERE project_id=${p}$ "
"AND status='pending' ORDER BY created_at DESC LIMIT 10", {"p": project_id})
await sor.sqlExe("COMMIT", {})
if ht:
lines.append("在办人类待办:")
for r in ht:
lines.append("- [%s] %s" % (getattr(r, "task_type", "?"),
str(getattr(r, "title", ""))[:80]))
proj = await sor.sqlExe(
"SELECT status FROM sd_projects WHERE id=${p}$", {"p": project_id})
await sor.sqlExe("COMMIT", {})
if proj:
lines.append("项目状态:%s" % getattr(proj[0], "status", "?"))
return "\n".join(lines)
except Exception as e:
logger.warning("generic progress summary failed: %s", str(e)[:160])
return ""
async def _run_stage_reentry(sor, plan, operator_id):
"""确认修订版时执行定向重做(产线钩子)。返回 (ok, msg)。
钩子失败不回滚计划确认(计划换版是用户裁决,已生效)——诚实上报 + 抛人工待办,
人工可按影响说明手工重置或让 agent 修复(不静默吞错)。
"""
project_id = plan.get("project_id", "")
pid = plan.get("pipeline_id", "")
stage_key = (plan.get("reentry_stage") or "").strip()
if not stage_key:
return True, ""
try:
stages_snap = json.loads(plan.get("stages") or "[]")
except (json.JSONDecodeError, ValueError):
stages_snap = []
affected = await _affected_stage_keys(stages_snap, stage_key)
try:
from pipeline_core import get_stage_reentry_handler
fn = get_stage_reentry_handler(pid)
except Exception:
fn = None
if not fn:
# 提案时已校验过钩子存在;确认时消失 = 部署窗口异常,诚实上报
msg = "产线「%s」阶段重做钩子不可用,未执行重置——请人工处理" % pid
_ar3, _ai3 = await _owner_assignee(sor, project_id)
await create_human_task(
project_id, "流程修订已确认但阶段重做未执行:%s" % stage_key,
msg + "\n\n修订计划已生效v%s),但定向重做的产出重置未执行。\n"
"请人工按修订确认待办里的影响说明处置,或修复钩子后让平台重试。"
% plan.get("version"),
task_type="general", assignee_role=_ar3, assignee_id=_ai3)
await sor.sqlExe("COMMIT", {})
return False, msg
try:
ok, msg = await fn(sor, project_id, stage_key, sorted(affected),
plan.get("user_requirements") or "", operator_id)
await sor.sqlExe("COMMIT", {})
except Exception as e:
logger.exception("stage_reentry hook crashed: project=%s stage=%s",
project_id, stage_key)
msg = "阶段重做执行异常:%s" % str(e)[:300]
ok = False
if not ok:
_ar4, _ai4 = await _owner_assignee(sor, project_id)
await create_human_task(
project_id, "流程修订已确认但阶段重做执行失败:%s" % stage_key,
(msg or "钩子返回失败") + "\n\n修订计划已生效v%s),但阶段重置未完成,"
"流程可能停在旧产出上。请人工处置后项目自动恢复推进。"
% plan.get("version"),
task_type="general", assignee_role=_ar4, assignee_id=_ai4)
await sor.sqlExe("COMMIT", {})
await record_audit(project_id, "pipeline_flow_plans", plan.get("id", ""),
"stage_reentry", to_state=stage_key, who=operator_id,
detail=("ok" if ok else "failed") + ": " + (msg or "")[:400],
sor=sor)
await sor.sqlExe("COMMIT", {})
return ok, msg
# ══════════════════════ 会话工具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="revise_flow_plan",
description=("对已确认生效的项目流程提出修订(用户有权随时调整流程):可改裁剪(trim_keys)"
"和/或要求从任一阶段按用户意见定向重做(reentry_stage)。生成修订草案(v+1)并发"
"「流程修订确认」待办——确认前旧计划持续生效,用户确认时才换版并执行阶段重置"
"(重置影响会在待办里预告)。用户只给意见没指明阶段时:先调 detect_reentry_stage "
"判定注入点LLM 语义判定),把返回的 stage_key 和 detect_note 原样传入本工具"
"note 进待办给用户核对,判定失败如实转告用户,禁止自己猜阶段)。"
"修订合法性由系统硬校验;你没有确认权,禁止声称已确认。"),
parameters={"user_requirements": "用户修订要求原文(必填,写进确认待办)",
"reentry_stage": "定向重做注入阶段key(可选;空=仅调整裁剪不重做来自show_flow_template或detect_reentry_stage)",
"trim_keys": "要裁剪的阶段key数组(可选;修订后的完整裁剪集,沿用现状传空)",
"propose_note": "你对修订方案的说明/风险提示(可选)",
"reentry_detect_note": "注入点判定依据(detect_reentry_stage返回的note原样传入进待办给用户核对)"},
required=["user_requirements"], category="project"),
ToolDefinition(
name="detect_reentry_stage",
description=("用户只给修改意见、没指明从哪个阶段重做时调用LLM按意见+流程阶段声明+项目进度"
"判定定向重做注入点语义判断归LLM禁词匹配。返回stage_key+判定依据note——"
"原样传给revise_flow_plannote进确认待办由用户核对错判由确认门兜住"
"判定失败(空key)时如实把失败原因转告用户,禁止自己猜阶段硬跑。"),
parameters={"user_comment": "用户意见原文(必填)"},
required=["user_comment"], 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("reentry_stage") or "").strip():
lines.append("定向重做注入点:%s" % plan.get("reentry_stage"))
if plan.get("reentry_detect_note"):
lines.append("判定依据:%s" % str(plan.get("reentry_detect_note"))[:300])
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)
async def _h_revise_flow_plan(sor, params, ctx):
project_id = ctx.get("project_id") or ""
if not project_id:
return "ERROR: 请先切换到项目(用 switch_project 或在项目列表选择)"
ok, msg = await revise_flow_plan(
project_id,
user_requirements=params.get("user_requirements", "") or "",
reentry_stage=params.get("reentry_stage", "") or "",
trim_keys=params.get("trim_keys"),
propose_note=params.get("propose_note", "") or "",
reentry_detect_note=params.get("reentry_detect_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_detect_reentry_stage(sor, params, ctx):
project_id = ctx.get("project_id") or ""
if not project_id:
return "ERROR: 请先切换到项目"
comment = (params.get("user_comment") or "").strip()
if not comment:
return "ERROR: 缺少用户意见原文user_comment"
stage_key, note = await detect_reentry_stage(
project_id, comment, pipeline_id=ctx.get("pipeline_id", "") or "")
if not stage_key:
return "ERROR: 注入点判定失败——%s\n请把失败原因如实转告用户(禁止自己猜阶段)。" % note
return ("OK: stage_key=%s\n%s\n\n下一步:调 revise_flow_plan(reentry_stage=%s, "
"reentry_detect_note=上面的判定依据原文, user_requirements=用户意见原文)。"
% (stage_key, note, stage_key))
FLOW_PLAN_HANDLERS = {
"show_flow_template": _h_show_flow_template,
"propose_flow_plan": _h_propose_flow_plan,
"revise_flow_plan": _h_revise_flow_plan,
"detect_reentry_stage": _h_detect_reentry_stage,
"get_flow_plan": _h_get_flow_plan,
}