Files

193 lines
8.7 KiB
Python
Raw Permalink Normal View History

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