"""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