Files
yawan-workbench/server/app/campaign_jobs.py
T

193 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Content-production jobs shared by inline threads and the DSH worker.
The FastAPI workbench validates and queues work. This module contains the
actual campaign parsing/composition orchestration that must behave identically
whether it runs in an API background thread or in an external DSH process.
"""
from __future__ import annotations
from sqlmodel import Session, select
from . import progress
from .agents.composer import compose as compose_artifact
from .agents.coordinator import parse_mindmap
from .models import Agent, Campaign, Notification, Segment
# Near-synonym content tags used when assigning parsed sections to canvas nodes.
TAG_ALIAS = {
"产品回指": ["产品销售", "产品体验"],
"转场": ["开场", "互动"],
"预告": ["钩子"],
"见证": ["案例"],
"金句": ["心法"],
"共鸣": ["心法"],
"安全提示": ["技法"],
"跟练": ["技法", "功法"],
"氛围": ["场景"],
"情绪": ["场景", "心法"],
}
def _match_agent(session: Session, tag: str,
candidates: list[Agent] | None = None,
*, allow_unassigned: bool = False) -> Agent | None:
aliases = TAG_ALIAS.get(tag, [])
if candidates is None:
candidates = list(session.exec(select(Agent).where(
Agent.role == "production", Agent.status == "active"
)).all())
for alias in [tag, *aliases]:
for agent in candidates:
if alias in (agent.content_tags or []):
return agent
if candidates:
return candidates[0]
if allow_unassigned:
return None
raise RuntimeError(f"没有能处理标签[{tag}]的启用生产 Agent——请先在 Agent 管理中配置")
def _match_node(session: Session, tag: str, canvas_nodes: list, used: dict,
*, allow_unassigned: bool = False) -> tuple[Agent | None, str]:
aliases = [tag, *TAG_ALIAS.get(tag, [])]
node = None
for alias in aliases:
hits = [n for n in canvas_nodes
if str(n.get("content_tag") or n.get("tag") or "") == alias]
if hits:
node = hits[used.get(alias, 0) % len(hits)]
used[alias] = used.get(alias, 0) + 1
break
if node is None:
return _match_agent(session, tag, allow_unassigned=allow_unassigned), f"seg-{len(used) + 1}"
agent = session.get(Agent, int(node.get("agent_id")))
if not agent:
if allow_unassigned:
return None, str(node.get("id") or node.get("node_id") or f"seg-{len(used) + 1}")
raise RuntimeError("画布节点引用的 Agent 不存在")
if allow_unassigned and (agent.status != "active" or agent.role != "production"):
return None, str(node.get("id") or node.get("node_id") or f"seg-{len(used) + 1}")
return agent, str(node.get("id") or node.get("node_id") or f"seg-{len(used) + 1}")
def run_parse_job(session: Session, campaign_id: int, user_advice: str = "") -> dict:
"""Parse a mind map and replace the campaign's draft segment list."""
campaign = session.get(Campaign, campaign_id)
if not campaign:
raise ValueError(f"任务不存在:{campaign_id}")
campaign.status = "parsing"
session.add(campaign)
session.commit()
progress.publish(int(campaign.owner_user_id or 0), "parse_start",
campaign_id=campaign_id, name=campaign.name, backend="dsh")
try:
result = parse_mindmap(session, campaign, user_advice=user_advice)
if result.get("status") != "ok" or not result.get("segments"):
if result.get("status") == "ok":
detail = ";".join(list(result.get("pending_confirms") or []) +
list(result.get("notes") or []))
campaign.notes = [f"拆解没有产出内容段:{str(detail)[:200]}"]
else:
campaign.notes = [f"拆解失败:{str(result.get('error'))[:200]}"]
campaign.status = "parse_failed"
session.add(campaign)
session.commit()
progress.publish(int(campaign.owner_user_id or 0), "parse_failed",
campaign_id=campaign_id,
error=str(campaign.notes[0] if campaign.notes else "拆解未产出内容段")[:120])
return {"status": "parse_failed", "error": campaign.notes[0] if campaign.notes else ""}
for old_seg in session.exec(select(Segment).where(Segment.campaign_id == campaign_id)).all():
session.delete(old_seg)
session.commit()
snap = campaign.canvas_snapshot or {}
canvas_nodes = snap.get("nodes", []) if isinstance(snap, dict) else []
used: dict = {}
unassigned = 0
for index, item in enumerate(result["segments"]):
tag = item.get("content_tag", "心法")
if canvas_nodes:
agent, node_id = _match_node(session, tag, canvas_nodes, used, allow_unassigned=True)
else:
agent = _match_agent(session, tag, allow_unassigned=True)
node_id = f"seg-{index + 1}"
if agent is None:
unassigned += 1
session.add(Segment(
campaign_id=campaign_id,
node_id=node_id,
agent_id=agent.id if agent else 0,
title=item.get("title", f"内容段{index + 1}"),
content_tag=tag,
time_range=item.get("time_range", ""),
mindmap_node=item.get("mindmap_node", item.get("title", "")),
product_ref=item.get("product_ref", ""),
))
campaign.task_spec = result["segments"]
campaign.pending_confirms = result.get("pending_confirms", [])
campaign.notes = list(result.get("notes", []))
if unassigned:
campaign.notes.append(
f"有 {unassigned} 个内容段暂未匹配启用的生产 Agent,请在内容段中选择 Agent 后再生成"
)
campaign.status = "parsed"
session.add(campaign)
session.commit()
progress.publish(int(campaign.owner_user_id or 0), "parse_done",
campaign_id=campaign_id, segments=len(result["segments"]))
return {"status": "parsed", "segments": len(result["segments"])}
except Exception as exc:
session.rollback()
campaign = session.get(Campaign, campaign_id)
if campaign:
campaign.status = "parse_failed"
campaign.notes = [f"拆解异常:{str(exc)[:200]}"]
session.add(campaign)
session.commit()
progress.publish(int(campaign.owner_user_id if campaign else 0), "parse_failed",
campaign_id=campaign_id, error=str(exc)[:120])
progress.publish(int(campaign.owner_user_id if campaign else 0), "parse_done",
campaign_id=campaign_id, segments=0)
return {"status": "parse_failed", "error": str(exc)}
def run_compose_job(session: Session, campaign_id: int) -> dict:
"""Run final composition and report progress through the shared bus."""
campaign = session.get(Campaign, campaign_id)
if not campaign:
raise ValueError(f"任务不存在:{campaign_id}")
progress.publish(int(campaign.owner_user_id or 0), "compose_start",
campaign_id=campaign_id, name=campaign.name, backend="dsh")
try:
result = compose_artifact(session, campaign_id)
except Exception:
# 基础设施异常(文件、模型、数据库等)交给任务队列退避重试/死信,
# 保持 composing,由 DSH 重试或死信补偿逻辑恢复到可操作状态。
session.rollback()
raise
if result.get("status") == "error":
# compose 主动返回的业务校验失败(如还有段未审查、没有已通过段)
# 不应触发队列重试;交还工作台,用户处理后可再次点击组合。
campaign = session.get(Campaign, campaign_id)
if campaign and campaign.status == "composing":
campaign.status = "composing_ready"
session.add(campaign)
session.commit()
owner_id = int(campaign.owner_user_id if campaign else 0)
error_text = str(result.get("error") or "成稿组合失败")[:200]
progress.publish(owner_id, "compose_failed",
campaign_id=campaign_id, error=error_text)
if campaign:
session.add(Notification(
type="failure", ref_id=campaign_id,
message=f"DSH 成稿组合失败:{error_text[:160]}",
))
session.commit()
return result
progress.publish(int(campaign.owner_user_id or 0), "compose_done",
campaign_id=campaign_id, artifact_id=result.get("artifact_id"),
status=result.get("status"))
return result