193 lines
8.7 KiB
Python
193 lines
8.7 KiB
Python
"""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
|