Files

572 lines
23 KiB
Python
Raw Permalink 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.
"""External-task multi-agent scheduled analysis API (任务分析定时).
Two audiences share this router:
- **对方系统(external)**:创建/删除任务(推送式接入)、时间线详情(他们详情页
拉取渲染)、问答历史查询、**定时数据交付**(6 块 JSON + 时间轴期刻度)。鉴权统一
走现有 Bearer token(对方后端/前端用我方
登录接口换 token),不建独立 API-Key 机制。
- **我方管理页(internal)**:任务列表 / 绑定 CRUD / 运行记录与席位过程消息 /
手动触发 / 数据块标题与排序编辑。与任务分析前端页(official-task-analysis)一一对应。
数据交付(第三期):智能体在定时执行里经 ``POST /data/submit`` 入库(内部接口,同样
走 Bearer,技能脚本缺省经公共 token 端点现签);提交落在「提交时刻的当前最新一期」,
首次提交无期自动开期。合并规则:顶层 key 合并、同 key 后写覆盖。
"""
from __future__ import annotations
import logging
from typing import Any, Literal
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from deerflow.persistence.task_analysis import TaskAnalysisStore
from deerflow.runtime.task_analysis.data_delivery import (
block_defs_payload,
period_detail_payload,
periods_payload,
submit_block_data,
)
from deerflow.runtime.task_analysis.live_messages import (
live_rows_to_process_messages,
merge_process_messages,
)
from deerflow.runtime.task_analysis.service import TaskAnalysisService, next_run_at
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/task-analysis", tags=["task-analysis"])
def _store(request: Request) -> TaskAnalysisStore:
store = getattr(request.app.state, "task_analysis_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Task analysis store not available")
return store
def _service(request: Request) -> TaskAnalysisService:
service = getattr(request.app.state, "task_analysis_service", None)
if service is None:
raise HTTPException(status_code=503, detail="Task analysis service not running")
return service
def _user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is not None:
return str(user.id)
from deerflow.runtime.user_context import get_effective_user_id
return get_effective_user_id()
async def _task_or_404(store: TaskAnalysisStore, *, task_id: str | None = None, source: str | None = None) -> dict[str, Any]:
task = (
await store.get_task_by_source(source) if source else await store.get_task(task_id or "")
)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
return task
# ── 对外接口(对方系统调用;Bearer token 鉴权与内部接口一致) ────────────────
class ExternalTaskCreateRequest(BaseModel):
source_task_id: str = Field(min_length=1, max_length=128)
name: str = Field(min_length=1, max_length=512)
description: str | None = Field(default=None, max_length=8192)
@router.post("/external/tasks")
async def external_create_task(request: Request, body: ExternalTaskCreateRequest) -> dict:
"""对方创建完任务后调用;响应带 default_chain_id(我方记录的最近一次绑定链)。"""
store = _store(request)
try:
task = await store.create_task(
{"source_task_id": body.source_task_id, "name": body.name, "description": body.description}
)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return {"task_id": task["id"], "default_chain_id": await store.last_bound_chain_id()}
@router.delete("/external/tasks/{source_task_id}")
async def external_delete_task(request: Request, source_task_id: str) -> dict:
"""对方删除任务时同步删除:任务 + 绑定 + 执行记录 + 过程消息 + 时间线版本。"""
store = _store(request)
task = await _task_or_404(store, source=source_task_id)
await store.delete_task_cascade(task["id"])
return {"deleted": True, "task_id": task["id"]}
@router.get("/external/tasks/{source_task_id}/detail")
async def external_task_detail(
request: Request, source_task_id: str, limit: int = 20, offset: int = 0
) -> dict:
"""时间线详情:对方详情页按任务号拉取(版本倒序,含各块内容)。"""
store = _store(request)
task = await _task_or_404(store, source=source_task_id)
binding = await store.get_binding(task["id"])
versions = await store.list_versions(task["id"], limit=limit, offset=offset)
return {
"task": {"task_id": task["id"], "source_task_id": task["source_task_id"], "name": task["name"]},
"binding": {
"chain_id": binding.get("chain_id"),
"cron_expr": binding.get("cron_expr"),
"schedule_type": binding.get("schedule_type") or "cron",
"enabled": binding.get("enabled"),
}
if binding
else None,
"timeline": versions,
}
@router.get("/external/runs/{run_id}/messages")
async def external_run_messages(
request: Request, run_id: str, seat: str | None = None, limit: int = 500
) -> dict:
"""问答历史 / 执行过程查询(seat 过滤可只看某席位或 __coordinator__ 总控)。"""
store = _store(request)
run = await store.get_run(run_id)
if run is None:
raise HTTPException(status_code=404, detail="运行记录不存在")
messages = await _messages_for_run(request, run, seat=seat, limit=limit)
return {"run": {k: run[k] for k in ("id", "trigger", "status", "started_at", "finished_at")}, "messages": messages}
# ── 定时数据交付(6 块 JSON + 时间轴;第三期) ───────────────────────────────
class DataSubmitRequest(BaseModel):
# 外部任务号(对方系统的任务 id,非 DeerFlow 内部 task_id)。
task_id: str = Field(min_length=1, max_length=128)
block_key: str = Field(min_length=1, max_length=32)
# JSON 对象即可,不做 schema 校验(schema 会变);非对象由 pydantic 拒绝(422)。
data: dict[str, Any]
@router.post("/data/submit")
async def submit_data_block(request: Request, body: DataSubmitRequest) -> dict:
"""智能体数据入库:落「提交时刻的当前最新一期」并按流水合并(顶层 key 合并、同 key 后写覆盖)。
供 task-data-submit 技能(席位执行时 bash 调用)使用;首次提交无期自动开期
(trigger=external),保证写入永远成功。
"""
store = _store(request)
source = body.task_id.strip()
task = await store.get_task_by_source(source)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
try:
result = await submit_block_data(
store,
task_id=task["id"],
block_key=body.block_key,
data=body.data,
submitted_by=_user_id(request),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"source_task_id": task["source_task_id"], "task_id": task["id"], **result}
@router.get("/external/tasks/{source_task_id}/blocks")
async def external_task_blocks(request: Request, source_task_id: str) -> dict:
"""数据块定义列表(块 key 固定 block_1~block_6,标题/排序可经管理端修改)。"""
task = await _task_or_404(_store(request), source=source_task_id)
return await block_defs_payload(_store(request), task)
@router.get("/external/tasks/{source_task_id}/periods")
async def external_task_periods(request: Request, source_task_id: str, limit: int = 500) -> dict:
"""时间轴刻度列表(期号升序;含是否当前期与各块更新时间摘要,进行中的刻度实时可见)。"""
task = await _task_or_404(_store(request), source=source_task_id)
return await periods_payload(_store(request), task, limit=max(1, min(limit, 2000)))
@router.get("/external/tasks/{source_task_id}/periods/{period}")
async def external_task_period_detail(request: Request, source_task_id: str, period: str) -> dict:
"""某期 6 块完整 JSON + 各块 updated_at;``period`` 传期号数字或 ``latest``。"""
store = _store(request)
task = await _task_or_404(store, source=source_task_id)
if period != "latest" and not period.isdigit():
raise HTTPException(status_code=400, detail="period 必须是期号数字或 latest")
payload = await period_detail_payload(store, task, period)
if payload is None:
raise HTTPException(status_code=404, detail="期不存在")
return payload
# ── 内部管理接口(任务分析页) ───────────────────────────────────────────────
class BindingUpsertRequest(BaseModel):
# 业务链;缺省时使用「默认业务链」配置(default-chain 接口维护),都没有则 400。
chain_id: str | None = Field(default=None, max_length=64)
cron: str = Field(default="0 9 * * *", max_length=64)
timezone: str = Field(default="Asia/Shanghai", max_length=64)
# 执行方式:cron = 按频率定时;once = 只执行一次(创建即立刻执行,之后不再自动)。
# None = 更新时保持原值(新建缺省 cron),避免局部更新(如启停)把 once 改回 cron。
schedule_type: Literal["cron", "once"] | None = None
enabled: bool = True
model: str | None = Field(default=None, max_length=128)
coordinator_prompt: str | None = Field(default=None, max_length=8192)
class TaskCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=512)
source_task_id: str | None = Field(default=None, max_length=128)
description: str | None = Field(default=None, max_length=8192)
binding: BindingUpsertRequest | None = None
@router.get("/tasks")
async def list_tasks(request: Request) -> dict:
store = _store(request)
return {"tasks": await store.list_tasks()}
@router.post("/tasks")
async def create_task(request: Request, body: TaskCreateRequest) -> dict:
"""页面一步式创建:任务信息 + 可选绑定(业务链 + 频率 + 启用 + 总控提示)。"""
store = _store(request)
user_id = _user_id(request)
source = body.source_task_id or f"TS-{_next_source_suffix()}"
try:
task = await store.create_task(
{"source_task_id": source, "name": body.name, "description": body.description}
)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
binding = None
if body.binding is not None:
binding = await _upsert_binding(request, store, task["id"], body.binding, user_id)
# 只执行一次:创建即立刻执行(enabled 关闭则不执行,等手动「立即执行」)。
run = None
if binding is not None and binding.get("schedule_type") == "once" and binding.get("enabled"):
try:
run = await _service(request).launch_binding(binding["id"], manual=False)
except Exception:
logger.exception("task-analysis once binding %s immediate launch failed", binding.get("id"))
return {"task": task, "binding": binding, "run": run}
@router.delete("/tasks/{task_id}")
async def delete_task(request: Request, task_id: str) -> dict:
store = _store(request)
task = await _task_or_404(store, task_id=task_id)
await store.delete_task_cascade(task["id"])
return {"deleted": True}
@router.put("/tasks/{task_id}/binding")
async def upsert_binding(request: Request, task_id: str, body: BindingUpsertRequest) -> dict:
store = _store(request)
task = await _task_or_404(store, task_id=task_id)
return {"binding": await _upsert_binding(request, store, task["id"], body, _user_id(request))}
@router.delete("/tasks/{task_id}/binding")
async def delete_binding(request: Request, task_id: str) -> dict:
store = _store(request)
task = await _task_or_404(store, task_id=task_id)
return {"deleted": await store.delete_binding(task["id"])}
@router.post("/tasks/{task_id}/trigger")
async def trigger_run(request: Request, task_id: str) -> dict:
"""立即执行一次(手动触发的定时语义执行,非总控问答)。"""
task = await _task_or_404(_store(request), task_id=task_id)
binding = await _store(request).get_binding(task["id"])
if binding is None:
raise HTTPException(status_code=400, detail="任务未绑定分析方案")
run = await _service(request).launch_binding(binding["id"], manual=True)
if run is None:
raise HTTPException(status_code=409, detail="执行被跳过(业务链缺失或已停用)")
return {"run": run}
class BlockDefUpdate(BaseModel):
block_key: str = Field(min_length=1, max_length=32)
title: str | None = Field(default=None, max_length=128)
sort_order: int | None = Field(default=None, ge=0, le=9999)
class BlockDefsUpdateRequest(BaseModel):
blocks: list[BlockDefUpdate] = Field(min_length=1, max_length=32)
@router.put("/tasks/{task_id}/blocks")
async def update_task_blocks(request: Request, task_id: str, body: BlockDefsUpdateRequest) -> dict:
"""修改数据块标题/排序(管理端;块集合固定 6 条,不能增删)。"""
store = _store(request)
task = await _task_or_404(store, task_id=task_id)
try:
defs = await store.update_block_defs(
task["id"], [block.model_dump() for block in body.blocks]
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"blocks": defs}
@router.get("/tasks/{task_id}/runs")
async def list_runs(request: Request, task_id: str, limit: int = 50) -> list[dict]:
store = _store(request)
task = await _task_or_404(store, task_id=task_id)
return await store.list_runs(task["id"], limit=limit)
@router.get("/runs/{run_id}")
async def get_run(request: Request, run_id: str) -> dict:
run = await _store(request).get_run(run_id)
if run is None:
raise HTTPException(status_code=404, detail="运行记录不存在")
return run
@router.get("/runs/{run_id}/messages")
async def get_run_messages(
request: Request, run_id: str, seat: str | None = None, limit: int = 500
) -> dict:
store = _store(request)
run = await store.get_run(run_id)
if run is None:
raise HTTPException(status_code=404, detail="运行记录不存在")
return {"messages": await _messages_for_run(request, run, seat=seat, limit=limit)}
# ── helpers ──────────────────────────────────────────────────────────────
async def _messages_for_run(
request: Request,
run: dict[str, Any],
*,
seat: str | None,
limit: int,
) -> list[dict[str, Any]]:
"""Stored orchestration notes + live agent events (same source as scheduled tasks)."""
store = _store(request)
stored = await store.list_run_messages(run["id"], seat=seat, limit=limit)
seat_ids = [seat] if seat else list((run.get("seat_results") or {}).keys())
if not seat_ids:
return stored
merged: list[dict[str, Any]] = []
for seat_id in seat_ids:
seat_stored = [row for row in stored if row.get("seat_agent_id") == seat_id] if not seat else stored
if seat and seat_id != seat:
continue
live_rows = await _live_agent_event_rows(request, run, seat_id)
live = live_rows_to_process_messages(
live_rows,
seat=seat_id,
stage_index=int((run.get("seat_results") or {}).get(seat_id, {}).get("stage_index") or 0),
)
merged.extend(merge_process_messages(stored=seat_stored, live=live, limit=limit))
if seat:
return merged[:limit]
# Unfiltered: keep original stored order when there is no live overlay.
if not any((row.get("metadata") or {}).get("source") == "live" for row in merged):
return stored[:limit]
return merged[:limit]
async def _live_agent_event_rows(
request: Request, run: dict[str, Any], seat: str
) -> list[dict[str, Any]]:
info = ((run.get("seat_results") or {}).get(seat) or {})
thread_id = info.get("thread_id")
agent_run_id = info.get("agent_run_id")
if not thread_id:
return []
rows: list[dict[str, Any]] = []
try:
from app.gateway.deps import get_run_event_store
event_store = get_run_event_store(request)
if agent_run_id:
rows = await event_store.list_messages_by_run(
thread_id, agent_run_id, limit=500, user_id=None
)
if not rows:
rows = await event_store.list_messages(thread_id, limit=500)
except Exception:
logger.debug("task-analysis live event-store read failed for seat %s", seat, exc_info=True)
rows = []
if rows:
return rows
return await _checkpoint_event_rows(request, thread_id)
async def _checkpoint_event_rows(request: Request, thread_id: str) -> list[dict[str, Any]]:
"""Same fallback scheduled tasks use: project the live LangGraph checkpoint."""
try:
from app.gateway.deps import get_checkpointer
from deerflow.runtime.serialization import serialize_channel_values
checkpointer = get_checkpointer(request)
tup = await checkpointer.aget_tuple(
{"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
)
except Exception:
logger.debug("task-analysis checkpoint read failed for %s", thread_id, exc_info=True)
return []
if tup is None:
return []
checkpoint = getattr(tup, "checkpoint", {}) or {}
try:
values = serialize_channel_values(checkpoint.get("channel_values") or {})
except Exception:
return []
messages = values.get("messages") or []
rows: list[dict[str, Any]] = []
for index, message in enumerate(messages):
if not isinstance(message, dict):
continue
raw_type = message.get("type") or message.get("role") or ""
if raw_type in {"human", "user"}:
event_type = "llm.human.input"
elif raw_type in {"ai", "assistant"}:
event_type = "llm.ai.response"
elif raw_type == "tool":
event_type = "llm.tool.result"
else:
continue
rows.append(
{
"seq": index + 1,
"event_type": event_type,
"content": message,
"metadata": {"source": "checkpoint"},
}
)
return rows
async def _upsert_binding(
request: Request,
store: TaskAnalysisStore,
external_task_id: str,
body: BindingUpsertRequest,
user_id: str,
) -> dict[str, Any]:
# 业务链缺省:使用模块设置里的默认业务链。
chain_id = body.chain_id or await store.get_setting("default_chain_id")
if not chain_id:
raise HTTPException(
status_code=400,
detail="未指定业务链,且未配置默认业务链(可在任务分析页或 PUT /default-chain 配置)",
)
# 绑定时固化业务链快照(seats/stages/goals/总控提示),执行前由 service 再刷新。
chain_snapshot = await _load_chain_snapshot(request, chain_id, user_id)
# 执行方式:请求未带时沿用现值(新建缺省 cron);once 永不排期(next_run_at 恒空)。
existing = await store.get_binding(external_task_id)
schedule_type = body.schedule_type or (existing or {}).get("schedule_type") or "cron"
next_at = (
None if schedule_type == "once" else next_run_at(body.cron, body.timezone).isoformat()
)
return await store.upsert_binding(
external_task_id,
{
"chain_id": chain_id,
"chain_snapshot": chain_snapshot,
"cron_expr": body.cron,
"timezone": body.timezone,
"schedule_type": schedule_type,
"enabled": body.enabled,
"next_run_at": next_at,
"model": body.model,
"coordinator_prompt": body.coordinator_prompt,
"created_by": user_id,
},
)
async def _load_chain_snapshot(request: Request, chain_id: str, user_id: str) -> dict[str, Any]:
from deerflow.runtime.task_analysis.service import build_chain_snapshot
chain_store = getattr(request.app.state, "roundtable_chain_store", None)
if chain_store is None:
return {}
try:
chain = await chain_store.get_chain(chain_id, user_id)
except Exception:
logger.exception("task-analysis load chain %s for snapshot failed", chain_id)
return {}
if chain is None:
raise HTTPException(status_code=400, detail=f"业务链不存在或不可见:{chain_id}")
return build_chain_snapshot(chain)
def _next_source_suffix() -> str:
import time
return f"LOCAL-{int(time.time() * 1000) % 100000000:08d}"
# ── 默认业务链 / 任务完成状态 ──────────────────────────────────────────────
class DefaultChainUpdateRequest(BaseModel):
chain_id: str | None = Field(default=None, max_length=64)
def _is_admin(request: Request) -> bool:
"""宽松管理员判定:无鉴权部署视为单管理空间(对齐 business_mapping)。"""
user = getattr(request.state, "user", None)
return user is None or getattr(user, "system_role", None) == "admin"
@router.get("/default-chain")
async def get_default_chain(request: Request) -> dict:
"""当前默认业务链(绑定不传 chain_id 时使用)。"""
store = _store(request)
chain_id = await store.get_setting("default_chain_id")
title = None
if chain_id:
snapshot = await _load_chain_snapshot(request, chain_id, _user_id(request))
title = snapshot.get("title")
return {"chain_id": chain_id, "chain_title": title}
@router.put("/default-chain")
async def set_default_chain(request: Request, body: DefaultChainUpdateRequest) -> dict:
"""配置默认业务链(仅管理员;chain_id 传空清除)。"""
if not _is_admin(request):
raise HTTPException(status_code=403, detail="仅管理员可配置默认业务链")
store = _store(request)
if body.chain_id:
# 配置前校验链存在(快照能加载即存在;不存在会 400)。
await _load_chain_snapshot(request, body.chain_id, _user_id(request))
await store.set_setting("default_chain_id", body.chain_id)
else:
await store.set_setting("default_chain_id", "")
return await get_default_chain(request)
@router.get("/external/tasks/{source_task_id}/status")
async def get_external_task_status(request: Request, source_task_id: str) -> dict:
"""任务完成状态:一次都没执行成功过 = pending(未完成);成功过 = completed(已完成)。"""
store = _store(request)
task = await _task_or_404(store, source=source_task_id)
runs = await store.list_runs(task["id"], limit=200)
succeeded = [r for r in runs if r.get("status") == "succeeded"]
last_finished = max(
(r.get("finished_at") for r in succeeded if r.get("finished_at")), default=None
)
return {
"source_task_id": source_task_id,
"task_id": task["id"],
"status": "completed" if succeeded else "pending",
"succeeded_runs": len(succeeded),
"total_runs": len(runs),
"last_finished_at": last_finished,
}