Files

752 lines
34 KiB
Python
Raw Permalink Normal View History

"""DSH Planner: generate an immutable, validated campaign plan version.
The workbench owns templates/resources/approvals. DSH Planner may propose a
segmented DAG, but workers can only execute a plan after it passes deterministic
validation and (for normal campaigns) human approval.
"""
from __future__ import annotations
import re
from typing import Any
from sqlmodel import Session, select
from . import progress
from .agents.coordinator import parse_mindmap
from .rag import backend as rag_backend
from .rag.pipeline import consumed_chunk_ids, simulate_lightweight_plan
from .models import (
Agent,
Campaign,
CampaignPlanVersion,
KnowledgeBase,
KnowledgeUsage,
Segment,
WorkflowTemplate,
now,
)
DEFAULT_TEMPLATE_KEY = "default-live-course"
TAG_ALIAS = {
"产品回指": ["产品销售", "产品体验"],
"转场": ["开场", "互动"],
"预告": ["钩子"],
"见证": ["案例"],
"金句": ["心法"],
"共鸣": ["心法"],
"安全提示": ["技法"],
"跟练": ["技法", "功法"],
"氛围": ["场景"],
"情绪": ["场景", "心法"],
}
PLAN_STATUSES = {"draft", "waiting_review", "approved", "rejected", "superseded", "failed"}
def default_template_config() -> dict[str, Any]:
return {
"version": 1,
"mode": "linear_dag",
"required_tags": [],
"optional_tags": [
"开场", "钩子", "心法", "技法", "功法", "产品销售", "产品体验",
"互动", "案例", "收尾", "预告",
],
"max_segments": 80,
"require_plan_approval": True,
"allow_auto_replan": False,
"default_kb_categories": {},
"risk_rules": {
"pending_confirm": "high",
"unassigned_agent": "high",
"knowledge_gap": "high",
"product_reference": "medium",
"duration_mismatch": "medium",
},
}
def ensure_default_workflow_template(session: Session) -> WorkflowTemplate:
"""Create the built-in template idempotently."""
tpl = session.exec(select(WorkflowTemplate).where(
WorkflowTemplate.key == DEFAULT_TEMPLATE_KEY)).first()
if tpl:
return tpl
tpl = WorkflowTemplate(
key=DEFAULT_TEMPLATE_KEY,
name="直播课标准工作流",
description="DSH Planner 在直播课模板内生成开场、主体、产品/转化与收束的线性 DAG;高风险计划必须人工批准。",
config=default_template_config(),
enabled=True,
builtin=True,
organization_id=1,
owner_user_id=0,
visibility="global",
)
session.add(tpl)
session.commit()
session.refresh(tpl)
return tpl
def get_template(session: Session, template_id: int = 0, template_key: str = "") -> WorkflowTemplate:
if template_id:
tpl = session.get(WorkflowTemplate, int(template_id))
if not tpl or not tpl.enabled:
raise ValueError("工作流模板不存在或已停用")
return tpl
if template_key:
tpl = session.exec(select(WorkflowTemplate).where(
WorkflowTemplate.key == template_key, WorkflowTemplate.enabled == True)).first() # noqa: E712
if tpl:
return tpl
return ensure_default_workflow_template(session)
def _issue(code: str, severity: str, message: str, node_id: str = "", **extra: Any) -> dict[str, Any]:
item = {"code": code, "severity": severity, "message": message, "node_id": node_id}
item.update(extra)
return item
def _candidate_agents(session: Session, tag: str) -> list[Agent]:
aliases = [tag, *TAG_ALIAS.get(tag, [])]
active = list(session.exec(select(Agent).where(
Agent.role == "production", Agent.status == "active")).all())
for alias in aliases:
hits = [a for a in active if alias in (a.content_tags or [])]
if hits:
return hits
return active
def _match_agent(session: Session, tag: str) -> tuple[int, str, list[int]]:
hits = _candidate_agents(session, tag)
if not hits:
return 0, "没有启用的生产 Agent", []
chosen = hits[0]
aliases = TAG_ALIAS.get(tag, [])
match_type = "tag" if (tag in (chosen.content_tags or []) or
any(a in (chosen.content_tags or []) for a in aliases)) else "fallback"
return int(chosen.id or 0), match_type, [int(a.id or 0) for a in hits]
def _match_plan_agent(session: Session, campaign: Campaign, tag: str,
used: dict[str, int]) -> tuple[int, str, list[int], str]:
"""Match a planner node to the selected workflow canvas first.
The canvas is a workbench-managed resource boundary. If its tagged nodes
cannot satisfy the proposal, fall back to active production agents and mark
the assignment for review instead of silently inventing permissions.
"""
aliases = [tag, *TAG_ALIAS.get(tag, [])]
snap = campaign.canvas_snapshot or {}
canvas_nodes = snap.get("nodes", []) if isinstance(snap, dict) else []
for alias in aliases:
hits = [n for n in canvas_nodes
if str(n.get("content_tag") or n.get("tag") or "") == alias
and n.get("enabled", True)]
if hits:
raw = hits[used.get(alias, 0) % len(hits)]
used[alias] = used.get(alias, 0) + 1
agent_id = int(raw.get("agent_id") or 0)
agent = session.get(Agent, agent_id) if agent_id else None
candidates = []
for n in hits:
aid = int(n.get("agent_id") or 0)
if aid and aid not in candidates:
candidates.append(aid)
source_id = str(raw.get("node_id") or raw.get("id") or "")
if agent and agent.status == "active" and agent.role == "production":
return int(agent.id or 0), "canvas_tag", candidates, source_id
return agent_id, "canvas_agent_invalid", candidates, source_id
agent_id, match_type, candidates = _match_agent(session, tag)
return agent_id, match_type, candidates, ""
def _time_range_minutes(value: str, campaign_duration_minutes: int = 0) -> int | None:
"""Parse planner ranges as mm:ss for short tasks and hh:mm for long tasks.
The LLM is asked for time_range values, but a 3 minute口播 naturally emits
``00:00-03:00`` while a 180 minute直播 emits ``00:00-180:00``. Interpreting
both as hh:mm makes short plans fail deterministic duration validation.
When the task duration is <= 60 minutes, two-component ranges are mm:ss;
otherwise they remain hh:mm. Parsed second values are rounded to whole
minutes, so a 30 second segment is represented as 1 minute in capacity checks.
"""
text = str(value or "").strip()
m = re.match(r"^(\d{1,3}):(\d{1,2})\s*(?:-|~|至|到)\s*(\d{1,3}):(\d{1,2})$", text)
if not m:
return None
first, second, third, fourth = map(int, m.groups())
if int(campaign_duration_minutes or 0) > 0 and int(campaign_duration_minutes) <= 60:
start_seconds = first * 60 + second
end_seconds = third * 60 + fourth
if end_seconds < start_seconds:
end_seconds += 60 * 60
seconds = end_seconds - start_seconds
return max(1, (seconds + 59) // 60) if seconds else 0
start, end = first * 60 + second, third * 60 + fourth
if end < start:
end += 24 * 60
return end - start
def topo_waves(node_ids: list[str], edges: list[dict[str, str]]) -> list[list[str]]:
"""Return Kahn waves for a DAG; raise ValueError when a cycle is present."""
indegree = {nid: 0 for nid in node_ids}
adjacency: dict[str, list[str]] = {nid: [] for nid in node_ids}
for edge in edges:
src = str(edge.get("from") or edge.get("from_node") or "").strip()
dst = str(edge.get("to") or edge.get("to_node") or "").strip()
if src not in indegree or dst not in indegree:
raise ValueError(f"DAG 边引用了不存在的节点:{src} -> {dst}")
adjacency[src].append(dst)
indegree[dst] += 1
wave = sorted(nid for nid, degree in indegree.items() if degree == 0)
waves: list[list[str]] = []
seen = 0
while wave:
waves.append(wave)
seen += len(wave)
next_wave: list[str] = []
for nid in wave:
for dst in adjacency[nid]:
indegree[dst] -= 1
if indegree[dst] == 0:
next_wave.append(dst)
wave = sorted(next_wave)
if seen != len(node_ids):
raise ValueError("工作流 DAG 存在环,不能执行")
return waves
def _knowledge_issues(session: Session, agent_id: int, node_id: str) -> list[dict]:
"""Validate Agent/KB bindings for every planned node.
Actual source capacity is checked once in ``_knowledge_capacity_issues``.
Checking chunk inventory per node would both duplicate errors and prevent a
plan-level reservation simulation across DAG order.
"""
issues: list[dict[str, Any]] = []
if not agent_id:
return [_issue("unassigned_agent", "error", "该节点还没有分配生产 Agent", node_id)]
agent = session.get(Agent, agent_id)
if not agent:
return [_issue("agent_missing", "error", "计划引用了不存在的 Agent", node_id)]
if agent.role != "production" or agent.status != "active":
return [_issue("agent_invalid", "error", "计划引用的 Agent 不是启用生产 Agent", node_id,
agent_id=agent_id)]
kb_ids = list(agent.kb_ids or [])
if not kb_ids:
return [_issue("knowledge_missing", "error", "生产 Agent 未绑定知识库", node_id,
agent_id=agent_id)]
for kb_id in kb_ids:
kb = session.get(KnowledgeBase, int(kb_id))
if not kb:
issues.append(_issue("knowledge_missing", "error", f"知识库 {kb_id} 不存在", node_id,
agent_id=agent_id, kb_id=kb_id))
continue
if int(kb.file_count or 0) <= 0:
issues.append(_issue("knowledge_gap", "error",
f"知识库「{kb.name}」没有素材文件,规划阶段不能确认事实来源",
node_id, agent_id=agent_id, kb_id=kb_id))
return issues
# Worker currently attempts an initial top-6 recall, then widens to top-24.
# Planner uses the widened bound for admission control; this is intentionally
# conservative because selected chunks are consumed by each production Agent.
_PLAN_SIMULATION_TOP_K = 24
_PLAN_SOURCE_CHAR_BUDGET = 12000
def _node_knowledge_query(node: dict[str, Any]) -> str:
return " ".join(str(node.get(key) or "") for key in
("title", "content_tag", "product_ref", "mindmap_node")).strip()
def _vector_knowledge_capacity(session: Session, agent: Agent,
assigned_nodes: list[dict[str, Any]]) -> tuple[list[dict], dict]:
issues: list[dict[str, Any]] = []
kb_ids = [int(kid) for kid in (agent.kb_ids or [])]
valid_kbs = [session.get(KnowledgeBase, kid) for kid in kb_ids]
valid_kbs = [kb for kb in valid_kbs if kb]
empty_kbs = [kb for kb in valid_kbs if int(kb.file_count or 0) <= 0
or int(kb.chunk_count or 0) <= 0]
locked_rows = session.exec(select(KnowledgeUsage.chunk_id, KnowledgeUsage.kb_id).where(
KnowledgeUsage.agent_id == int(agent.id or 0),
KnowledgeUsage.kb_id.in_(kb_ids),
(KnowledgeUsage.status.in_(["reserved", "consumed"]) | (KnowledgeUsage.banned == True)), # noqa: E712
)).all()
locked_count = len({(int(kb_id), str(chunk_id)) for chunk_id, kb_id in locked_rows})
total_chunks = sum(max(0, int(kb.chunk_count or 0)) for kb in valid_kbs)
available_chunks = max(0, total_chunks - locked_count)
demand = len(assigned_nodes)
report = {
"mode": "vector",
"kb_ids": kb_ids,
"total_chunks": total_chunks,
"locked_chunks": locked_count,
"available_chunks": available_chunks,
"required_nodes": demand,
"empty_kb_ids": [int(kb.id) for kb in empty_kbs],
}
first_node = assigned_nodes[0]
first_node_id = str(first_node.get("node_id") or "")
if not valid_kbs or all(int(kb.file_count or 0) <= 0 for kb in valid_kbs):
issues.append(_issue("knowledge_gap", "error",
f"Agent「{agent.name}」绑定的知识库没有素材文件,规划阶段不能确认事实来源",
first_node_id, agent_id=agent.id))
elif available_chunks <= 0:
issues.append(_issue("knowledge_exhausted", "error",
f"Agent「{agent.name}」已消费完绑定知识库的登记片段,需要补素材或重新开放",
first_node_id, agent_id=agent.id, kb_ids=kb_ids))
elif demand > available_chunks:
issues.append(_issue("knowledge_capacity", "error",
f"Agent「{agent.name}」本计划需要生成 {demand} 段,但当前只有 "
f"{available_chunks} 个未消费片段;请补素材、释放片段或减少节点",
first_node_id, agent_id=agent.id, kb_ids=kb_ids,
available_chunks=available_chunks, required_nodes=demand))
if empty_kbs and valid_kbs and available_chunks > 0:
issues.append(_issue("knowledge_unverified", "warning",
f"Agent「{agent.name}」绑定的知识库 "
f"{','.join(str(kb.id) for kb in empty_kbs)} 文件/片段计数不完整",
first_node_id, agent_id=agent.id))
return issues, report
def _agent_kb_categories(agent: Agent) -> dict[int, list[int]]:
raw = dict(getattr(agent, "kb_category_ids", None) or {})
normalized: dict[int, list[int]] = {}
for key, value in raw.items():
try:
normalized[int(key)] = [int(item) for item in (value or [])]
except (TypeError, ValueError):
continue
return normalized
def _lightweight_knowledge_capacity(session: Session, agent: Agent,
assigned_nodes: list[dict[str, Any]]) -> tuple[list[dict], dict]:
issues: list[dict[str, Any]] = []
kb_ids = [int(kid) for kid in (agent.kb_ids or [])]
queries = [_node_knowledge_query(node) for node in assigned_nodes]
report = simulate_lightweight_plan(
session, kb_ids, queries,
excluded_chunk_ids=consumed_chunk_ids(session, int(agent.id or 0), kb_ids),
category_ids_by_kb=_agent_kb_categories(agent),
initial_top_k=6,
widened_top_k=_PLAN_SIMULATION_TOP_K,
char_budget=_PLAN_SOURCE_CHAR_BUDGET,
)
report.update({"mode": "keyword-light", "required_nodes": len(assigned_nodes),
"kb_ids": kb_ids})
shortfalls = list(report.get("shortfalls", []))
affected_node_ids = [str(node.get("node_id") or "") for node in assigned_nodes]
if int(report.get("total_chunks") or 0) <= 0:
first_node = assigned_nodes[0] if assigned_nodes else {}
issues.append(_issue(
"knowledge_exhausted", "error",
f"Agent「{agent.name}」在轻量模式没有可用源文件片段:请补素材、重新开放片段,"
"或启用本地向量索引后重建片段",
str(first_node.get("node_id") or ""), agent_id=agent.id, kb_ids=kb_ids,
affected_node_ids=affected_node_ids, total_chunks=0,
locked_or_consumed_required=len(assigned_nodes)))
return issues, report
capacity_failed = False
mismatch_count = 0
for shortfall in shortfalls:
index = int(shortfall.get("order") or 1) - 1
node = assigned_nodes[max(0, min(index, len(assigned_nodes) - 1))]
node_id = str(node.get("node_id") or "")
title = str(node.get("title") or "")[:80]
if int(shortfall.get("candidate_count") or 0) <= 0:
# 主题不匹配是节点级问题;但仍限制数量,避免一个长计划刷出大量重复阻断项。
if mismatch_count >= 5:
continue
code, message = "knowledge_mismatch", (
f"节点「{title}」在 Agent「{agent.name}」绑定知识库中没有关键词匹配素材;"
"请补充同主题素材,或调整该节点/Agent 资源绑定")
mismatch_count += 1
else:
code, message = "knowledge_capacity", (
f"节点「{title}」起无法继续预留知识片段:Agent「{agent.name}」本计划还有 "
f"{len(assigned_nodes) - index} 段,但素材池已不足;请补充更多来源或减少节点")
capacity_failed = True
issues.append(_issue(code, "error", message, node_id, agent_id=agent.id,
kb_ids=kb_ids, affected_node_ids=affected_node_ids, **{
"total_chunks": report.get("total_chunks"),
"remaining_chunks": report.get("remaining_chunks"),
"candidate_count": shortfall.get("candidate_count"),
}))
if capacity_failed:
# 后续节点失败只是同一容量缺口的连锁结果,审计报告仍保留在 knowledge_capacity 中。
break
return issues, report
def _knowledge_capacity_issues(session: Session, nodes: list[dict[str, Any]]) -> tuple[list[dict], dict]:
"""Plan-level admission control for all DSH Worker knowledge reservations."""
issues: list[dict[str, Any]] = []
reports: dict[str, Any] = {}
grouped: dict[int, list[dict[str, Any]]] = {}
for node in nodes:
agent_id = int(node.get("agent_id") or 0)
if agent_id:
grouped.setdefault(agent_id, []).append(node)
for agent_id, assigned in grouped.items():
agent = session.get(Agent, agent_id)
if not agent or agent.role != "production" or agent.status != "active":
continue
if not list(agent.kb_ids or []):
continue
if rag_backend.is_disabled():
agent_issues, report = _lightweight_knowledge_capacity(session, agent, assigned)
else:
agent_issues, report = _vector_knowledge_capacity(session, agent, assigned)
issues.extend(agent_issues)
reports[str(agent_id)] = report
return issues, reports
def build_plan(session: Session, campaign: Campaign, parsed: dict, template: WorkflowTemplate) -> dict:
"""Convert coordinator segments to a planner DAG proposal."""
raw_segments = list(parsed.get("segments") or [])
config = dict(default_template_config())
config.update(template.config or {})
max_segments = int(config.get("max_segments") or 80)
nodes: list[dict[str, Any]] = []
segments: list[dict[str, Any]] = []
edges: list[dict[str, str]] = []
prev_id = ""
used: dict[str, int] = {}
for index, item in enumerate(raw_segments, start=1):
node_id = f"n{index}"
tag = str(item.get("content_tag") or "心法").strip() or "心法"
agent_id, match_type, candidates, source_canvas_node_id = _match_plan_agent(
session, campaign, tag, used)
minutes = _time_range_minutes(
str(item.get("time_range") or ""),
int(campaign.duration_minutes or 0))
node = {
"node_id": node_id,
"order": index,
"title": str(item.get("title") or f"内容段{index}")[:200],
"content_tag": tag,
"time_range": str(item.get("time_range") or "")[:60],
"duration_minutes": minutes,
"mindmap_node": str(item.get("mindmap_node") or item.get("title") or "")[:1000],
"product_ref": str(item.get("product_ref") or "")[:200],
"agent_id": agent_id,
"agent_candidate_ids": candidates,
"agent_match_type": match_type,
"source_canvas_node_id": source_canvas_node_id,
"kb_category_ids": [],
"knowledge_requirements": [],
"required": True,
"enabled": True,
"risk_level": "low",
}
segment = {
"node_id": node_id,
"title": node["title"],
"content_tag": tag,
"time_range": node["time_range"],
"mindmap_node": node["mindmap_node"],
"product_ref": node["product_ref"],
"agent_id": agent_id,
}
nodes.append(node)
segments.append(segment)
if prev_id:
edges.append({"from": prev_id, "to": node_id})
prev_id = node_id
return {
"schema_version": "campaign-plan/v1",
"template_key": template.key,
"template_id": int(template.id or 0),
"mode": config.get("mode", "linear_dag"),
"objective": campaign.objective or "",
"success_criteria": campaign.success_criteria or "",
"duration_minutes": int(campaign.duration_minutes or 0),
"nodes": nodes,
"edges": edges,
"segments": segments,
"pending_confirms": list(parsed.get("pending_confirms") or []),
"notes": list(parsed.get("notes") or []),
"source": {
"provider": parsed.get("provider", ""),
"model": parsed.get("model", ""),
"trace_id": parsed.get("trace_id", ""),
},
}
def validate_plan(session: Session, campaign: Campaign, plan: dict, template: WorkflowTemplate) -> dict[str, Any]:
"""Deterministic validation. The LLM proposal is never trusted directly."""
config = dict(default_template_config())
config.update(template.config or {})
issues: list[dict[str, Any]] = []
nodes = list(plan.get("nodes") or [])
edges = list(plan.get("edges") or [])
if not nodes:
issues.append(_issue("empty_plan", "error", "计划没有任何内容节点"))
if len(nodes) > int(config.get("max_segments") or 80):
issues.append(_issue("too_many_segments", "error",
f"计划节点数 {len(nodes)} 超过模板上限 {config.get('max_segments')}"))
ids = [str(n.get("node_id") or "") for n in nodes]
if len(set(ids)) != len(ids) or any(not x for x in ids):
issues.append(_issue("node_id_invalid", "error", "节点 ID 为空或重复"))
required_tags = set(config.get("required_tags") or [])
allowed_tags = set(config.get("optional_tags") or [])
actual_tags = {str(n.get("content_tag") or "") for n in nodes}
missing_required = sorted(required_tags - actual_tags)
if missing_required:
issues.append(_issue("required_tag_missing", "error",
"模板必备节点缺失:" + "、".join(missing_required)))
if allowed_tags and required_tags is not None:
unknown = sorted(tag for tag in actual_tags if tag and tag not in allowed_tags)
if unknown:
issues.append(_issue("tag_not_in_template", "warning",
"出现模板可选标签之外的标签:" + "、".join(unknown)))
try:
waves = topo_waves(ids, edges) if ids else []
except ValueError as exc:
waves = []
issues.append(_issue("dag_invalid", "error", str(exc)))
for node in nodes:
nid = str(node.get("node_id") or "")
title = str(node.get("title") or "").strip()
if not title:
issues.append(_issue("title_missing", "error", "节点标题为空", nid))
if not str(node.get("mindmap_node") or "").strip():
issues.append(_issue("source_missing", "warning", "节点缺少导图原文", nid))
issues.extend(_knowledge_issues(session, int(node.get("agent_id") or 0), nid))
if str(node.get("product_ref") or "").strip():
issues.append(_issue("product_reference", "medium",
"包含产品引用,批准前请核对素材证据和合规口径", nid))
capacity_issues, knowledge_capacity = _knowledge_capacity_issues(session, nodes)
issues.extend(capacity_issues)
duration = int(campaign.duration_minutes or plan.get("duration_minutes") or 0)
minute_values = [int(n.get("duration_minutes") or 0) for n in nodes if n.get("duration_minutes") is not None]
parsed_count = sum(1 for n in nodes if n.get("duration_minutes") is not None)
if duration and parsed_count == len(nodes) and minute_values and sum(minute_values) != duration:
issues.append(_issue("duration_mismatch", "medium",
f"节点时长合计 {sum(minute_values)} 分钟,任务要求 {duration} 分钟"))
elif duration and parsed_count != len(nodes):
issues.append(_issue("duration_unverified", "warning", "部分节点时间范围无法机器解析,未能核对总时长"))
for item in plan.get("pending_confirms") or []:
issues.append(_issue("pending_confirm", "high", f"待确认:{str(item)[:160]}"))
errors = [x for x in issues if x["severity"] == "error"]
warnings = [x for x in issues if x["severity"] != "error"]
return {
"ok": not errors,
"errors": errors,
"warnings": warnings,
"issues": issues,
"knowledge_capacity": knowledge_capacity,
"waves": waves,
"checked_at": now(),
}
def _next_version(session: Session, campaign_id: int) -> int:
row = session.exec(select(CampaignPlanVersion.version).where(
CampaignPlanVersion.campaign_id == campaign_id
).order_by(CampaignPlanVersion.version.desc())).first()
# SQLModel returns scalar when selecting a single column.
return int(row or 0) + 1
def save_planner_result(session: Session, campaign: Campaign, parsed: dict,
template: WorkflowTemplate, *, advice: str = "",
created_by: int = 0) -> CampaignPlanVersion:
plan = build_plan(session, campaign, parsed, template)
validation = validate_plan(session, campaign, plan, template)
risks = [x for x in validation.get("issues", []) if x.get("severity") in {"high", "medium"}]
for node in plan.get("nodes", []):
node_risks = [x for x in risks if x.get("node_id") == node["node_id"]]
node["risk_level"] = "high" if any(x["severity"] == "high" for x in node_risks) else (
"medium" if node_risks else "low")
version = CampaignPlanVersion(
campaign_id=campaign.id,
version=_next_version(session, campaign.id),
source="dsh",
template_id=int(template.id or 0),
template_key=template.key,
status="waiting_review" if validation.get("ok") else "failed",
plan=plan,
validation=validation,
summary=f"{len(plan.get('nodes') or [])} 个节点,{len(plan.get('edges') or [])} 条依赖;"
f"{len(validation.get('errors') or [])} 个错误,{len(validation.get('warnings') or [])} 个风险/提示",
risks=risks,
advice=advice[:1000],
created_by=created_by,
)
session.add(version)
session.commit()
session.refresh(version)
return version
def run_plan_job(session: Session, campaign_id: int, *, template_id: int = 0,
advice: str = "", created_by: int = 0) -> dict[str, Any]:
campaign = session.get(Campaign, campaign_id)
if not campaign:
raise ValueError(f"任务不存在:{campaign_id}")
if campaign.status == "done":
raise ValueError("任务已交付,不能重新规划")
template = get_template(session, template_id)
campaign.status = "planning"
campaign.notes = list(campaign.notes or [])
session.add(campaign)
session.commit()
progress.publish(int(campaign.owner_user_id or 0), "plan_start",
campaign_id=campaign_id, name=campaign.name, backend="dsh",
template=template.key)
parsed = parse_mindmap(session, campaign, user_advice=advice)
if parsed.get("status") != "ok" or not parsed.get("segments"):
campaign.status = "parse_failed"
campaign.notes = [f"DSH Planner 规划失败:{str(parsed.get('error') or '未产出节点')[:200]}"]
session.add(campaign)
session.commit()
progress.publish(int(campaign.owner_user_id or 0), "plan_failed",
campaign_id=campaign_id, error=str(parsed.get("error") or "未产出节点")[:200])
return {"status": "failed", "error": parsed.get("error") or "未产出节点"}
version = save_planner_result(session, campaign, parsed, template,
advice=advice, created_by=created_by)
notes = list(version.plan.get("notes") or [])
if version.status == "waiting_review":
campaign.status = "plan_review"
if version.risks:
notes.append(f"计划 v{version.version} 有 {len(version.risks)} 个中高风险项,等待人工确认")
progress_event = "plan_done"
else:
# LLM 已产出草案,但确定性校验未过。该版本只作为失败审计记录,
# 不物化为 Segment,也不能进入 Worker;工作台修正资源后必须重新规划生成新版本。
campaign.status = "plan_failed"
errors = list((version.validation or {}).get("errors") or [])
for item in errors[:5]:
notes.append(f"计划 v{version.version} 校验未过:{item.get('message', '')}")
progress_event = "plan_failed"
campaign.notes = notes[-30:]
campaign.has_failure = version.status == "failed"
session.add(campaign)
session.commit()
progress.publish(int(campaign.owner_user_id or 0), progress_event,
campaign_id=campaign_id, plan_version_id=version.id,
version=version.version, status=version.status,
risks=len(version.risks or []))
return {"status": version.status, "plan_version_id": version.id, "version": version.version}
def approve_plan(session: Session, plan_version_id: int, *, reviewer_user_id: int = 0,
review_note: str = "") -> CampaignPlanVersion:
version = session.get(CampaignPlanVersion, plan_version_id)
if not version:
raise ValueError("计划版本不存在")
campaign = session.get(Campaign, version.campaign_id)
if not campaign:
raise ValueError("内容任务不存在")
if version.status != "waiting_review":
raise ValueError(f"当前计划状态 {version.status} 不能批准;请修正后重新规划")
if campaign.status in {"done", "running"}:
raise ValueError("任务已交付或正在运行,不能更换计划")
template = get_template(session, int(version.template_id or 0), version.template_key)
validation = validate_plan(session, campaign, version.plan or {}, template)
if not validation.get("ok"):
version.status = "failed"
version.validation = validation
session.add(version)
session.commit()
raise ValueError("计划校验未通过:" + ";".join(x["message"] for x in validation.get("errors", [])[:5]))
nodes = list((version.plan or {}).get("nodes") or [])
unresolved = [n for n in nodes if not int(n.get("agent_id") or 0)]
if unresolved:
raise ValueError("仍有节点未分配 Agent,请先在计划中调整后再批准")
existing = list(session.exec(select(Segment).where(Segment.campaign_id == campaign.id)).all())
generated = [s for s in existing if (s.status not in {"pending", "skipped"} or s.artifact_path or s.generation_history)]
if generated:
raise ValueError("已有内容产出,不能直接覆盖计划;请创建新版本任务或走重规划审批")
for old in existing:
session.delete(old)
session.commit()
for index, node in enumerate(nodes, start=1):
session.add(Segment(
campaign_id=campaign.id,
node_id=str(node.get("node_id") or f"n{index}"),
agent_id=int(node.get("agent_id") or 0),
agent_assignment_mode="auto",
title=str(node.get("title") or f"内容段{index}"),
content_tag=str(node.get("content_tag") or ""),
time_range=str(node.get("time_range") or ""),
mindmap_node=str(node.get("mindmap_node") or node.get("title") or ""),
product_ref=str(node.get("product_ref") or ""),
kb_category_ids=list(node.get("kb_category_ids") or []),
status="pending",
))
campaign.canvas_snapshot = {
"nodes": [
{
"node_id": n["node_id"],
"id": n["node_id"],
"agent_id": int(n.get("agent_id") or 0),
"content_tag": n.get("content_tag") or "",
"enabled": bool(n.get("enabled", True)),
"title": n.get("title") or "",
}
for n in nodes
],
"edges": list((version.plan or {}).get("edges") or []),
"plan_version_id": version.id,
"template_key": version.template_key,
"waves": validation.get("waves") or [],
}
campaign.task_spec = list((version.plan or {}).get("segments") or [])
campaign.pending_confirms = list((version.plan or {}).get("pending_confirms") or [])
campaign.notes = list((version.plan or {}).get("notes") or [])
campaign.approved_plan_version_id = version.id
campaign.status = "parsed"
campaign.has_failure = False
for old in session.exec(select(CampaignPlanVersion).where(
CampaignPlanVersion.campaign_id == campaign.id,
CampaignPlanVersion.id != version.id,
CampaignPlanVersion.status.in_(["waiting_review", "rejected", "approved"]))).all():
old.status = "superseded"
session.add(old)
version.status = "approved"
version.reviewed_by = reviewer_user_id
version.review_note = review_note[:1000]
version.reviewed_at = now()
version.validation = validation
session.add(version)
session.add(campaign)
session.commit()
session.refresh(version)
progress.publish(int(campaign.owner_user_id or 0), "plan_approved",
campaign_id=campaign.id, plan_version_id=version.id,
version=version.version)
return version
def reject_plan(session: Session, plan_version_id: int, *, reviewer_user_id: int = 0,
review_note: str = "") -> CampaignPlanVersion:
version = session.get(CampaignPlanVersion, plan_version_id)
if not version:
raise ValueError("计划版本不存在")
if version.status not in {"waiting_review", "failed"}:
raise ValueError(f"当前计划状态 {version.status} 不能驳回")
version.status = "rejected"
version.reviewed_by = reviewer_user_id
version.review_note = review_note[:1000] or "人工驳回计划"
version.reviewed_at = now()
session.add(version)
campaign = session.get(Campaign, version.campaign_id)
if campaign and campaign.status in {"plan_review", "plan_failed"}:
campaign.status = "draft"
session.add(campaign)
session.commit()
session.refresh(version)
return version