feat(server): add workbench API, DSH planner worker and RAG pipeline
This commit is contained in:
@@ -0,0 +1,751 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user