feat(server): add workbench API, DSH planner worker and RAG pipeline

This commit is contained in:
yi
2026-09-16 19:06:20 +08:00
parent 72fc5b47c7
commit d4b5b3f43a
122 changed files with 31906 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
"""内容段执行智能体的人工覆盖,以及重新生成时的版本与连续性边界。"""
from __future__ import annotations
from fastapi import HTTPException
from sqlmodel import Session, select
from .access import can_access, can_use_canvas
from .models import Agent, AgentSession, AppUser, Campaign, CampaignRun, Canvas, KnowledgeUsage, Segment, now
def available_agents(session: Session, campaign: Campaign, user: AppUser | None) -> list[Agent]:
canvas_agent_ids = set()
if user:
for canvas in session.exec(select(Canvas).where(Canvas.organization_id.in_([0, user.organization_id]))).all():
if can_use_canvas(session, canvas, user, 'use'):
canvas_agent_ids.update(int(node['agent_id']) for node in canvas.nodes or []
if node.get('enabled', True) and str(node.get('agent_id', '')).isdigit())
return [agent for agent in session.exec(select(Agent).where(
Agent.status == 'active', Agent.role == 'production',
Agent.organization_id.in_([0, campaign.organization_id])).order_by(Agent.id)).all()
if can_access(agent, user, 'use') or agent.id in canvas_agent_ids]
def edit_blocker(session: Session, campaign: Campaign) -> str:
if campaign.status == 'done':
return '任务已交付,不能更换执行智能体,请创建新版本任务'
if campaign.status in {'running', 'parsing', 'composing', 'qc'}:
return '任务正在生成、拆解或组合,请等待结束后再更换智能体'
if session.exec(select(CampaignRun.id).where(
CampaignRun.campaign_id == campaign.id,
CampaignRun.status.in_(['queued', 'running', 'paused']))).first():
return '任务运行中或已暂停,请先停止本次运行,再更换智能体'
if session.exec(select(AgentSession.id).where(
AgentSession.campaign_id == campaign.id,
AgentSession.status.in_(['running', 'waiting_approval']))).first():
return '智能体或后台质检仍在执行,请等待结束或停止运行后再更换'
if session.exec(select(Segment.id).where(Segment.campaign_id == campaign.id,
Segment.status.in_(['running', 'qc']))).first():
return '内容段正在生成或质检,请等待结束后再更换智能体'
return ''
def has_generation(segment: Segment) -> bool:
return bool(segment.artifact_path or segment.anchor_card or segment.qc_reports
or segment.status in {'human_review', 'approved', 'rework'})
def affected_segments(session: Session, segment: Segment) -> list[Segment]:
# 当前逐段工作台采用内容段 ID 顺序;全部后续非跳过段都依赖前面已确认的正文。
return session.exec(select(Segment).where(
Segment.campaign_id == segment.campaign_id, Segment.id >= segment.id,
Segment.status != 'skipped').order_by(Segment.id)).all()
def change_agent(session: Session, campaign: Campaign, segment: Segment, agent: Agent,
*, reset_generated: bool = False) -> list[int]:
frozen_agent_id = (segment.runtime_snapshot or {}).get('primary', {}).get('id')
if segment.agent_id == agent.id and (not frozen_agent_id or int(frozen_agent_id) == agent.id):
segment.agent_assignment_mode = 'manual'
session.add(segment)
return []
blocker = edit_blocker(session, campaign)
if blocker:
raise HTTPException(409, blocker)
if segment.status == 'skipped':
raise HTTPException(409, '该内容段已跳过,请新增需要生成的内容段')
affected = affected_segments(session, segment)
generated = [item for item in affected if has_generation(item)]
if generated and not reset_generated:
names = '、'.join(item.title or f'内容段#{item.id}' for item in generated[:4])
raise HTTPException(409, f'更换后需重新生成并确认:{names}。请确认保留旧稿并重新生成')
old_agent = session.get(Agent, segment.agent_id)
change_note = f'执行智能体由「{old_agent.name if old_agent else segment.agent_id}」更换为「{agent.name}」'
for item in affected:
if has_generation(item) or item.runtime_snapshot:
previous_agent = session.get(Agent, item.agent_id)
item.generation_history = list(item.generation_history or []) + [{
'agent_id': item.agent_id, 'agent_name': previous_agent.name if previous_agent else '',
'round': item.round, 'status': item.status, 'artifact_path': item.artifact_path,
'anchor_card': item.anchor_card, 'references': item.references,
'qc_reports': item.qc_reports, 'runtime_snapshot': item.runtime_snapshot,
'archived_at': now(), 'reason': change_note if item.id == segment.id else f'前段「{segment.title}」更换了智能体,重新衔接',
}]
item.round += 1 # 新稿使用新的文件名,不覆盖历史正文。
item.runtime_snapshot = {}
item.artifact_path = ''
item.anchor_card = {}
item.qc_reports = []
item.references = []
item.failure = {'type': 'agent_reassigned' if item.id == segment.id else 'upstream_reassigned',
'detail': change_note, 'source_segment_id': segment.id}
item.status = 'pending'
item.updated_at = now()
session.add(item)
# 未写入稿件的旧预留素材不应被新智能体的生成占住;已消费历史保留。
for usage in session.exec(select(KnowledgeUsage).where(
KnowledgeUsage.campaign_id == campaign.id, KnowledgeUsage.segment_id == item.id,
KnowledgeUsage.status == 'reserved')).all():
usage.status = 'available'; usage.released_at = now(); session.add(usage)
segment.agent_id = agent.id
segment.agent_assignment_mode = 'manual'
segment.fusion_agent_ids = list(agent.fusion_agent_ids or [])
segment.kb_category_ids = []
segment.kb_category_recommendation = {}
campaign.status = 'parsed'
campaign.has_failure = False
for run in session.exec(select(CampaignRun).where(CampaignRun.campaign_id == campaign.id)).all():
# 历史快照与调用记录保留;不能再从旧会话续跑出被替换的内容。
run.checkpoint = {**(run.checkpoint or {}), 'segment_assignment_changed': True,
'assignment_changed_at': now()}
session.add(run)
session.add(segment); session.add(campaign)
return [int(item.id) for item in generated]