feat(position-roundtable): add standalone position collaboration
This commit is contained in:
1 parent
b93b82fcaf
commit
85e119db08
22 files changed
+3494
-201
No files matched your search
@@ -375,6 +375,26 @@ Each agent/skill carries a single `square_id` column (`AgentRow` / `SkillRow`, `
|
||||
|
||||
Backs the zzJC / zzXD agent-chat left sidebar (「按类型聚合智能体 + 聊天记录」). An admin picks which agent **tags** belong to a module; the module's left sidebar then shows only agents under those tags (empty/unconfigured = show all visible agents). Stored in `system_settings.json` → `agent_modules` (`AgentModuleSettings`: `modules: {module_key → [tag_id, ...]}`); pure helpers `get_module_tag_ids` / `set_module_tag_ids` (de-dupe + strip). Endpoints (in `app/gateway/routers/system_settings.py`): `GET /api/system-settings/agent-modules/{key}` (readable by any user — the sidebar needs it to filter), `PUT /api/system-settings/agent-modules/{key}` (admin only, full replacement of the tag-id list). Module keys are frontend-defined (`AGENT_MODULES` in `components/workspace/agents/agent-module-sidebar.tsx`, keyed by the module's landing agent id: `6bf`→`zzjc`, `10d69…`→`zzxd`). Tests: `tests/test_agent_modules.py`. Frontend: `core/system-settings` (`useAgentModuleConfig`/`useUpdateAgentModuleConfig`), admin `AgentModuleSettingsDialog` (gear button in the agent-chat header).
|
||||
|
||||
### Position-collaboration foundation
|
||||
|
||||
The position-collaboration workspace is a separate frontend route and must not
|
||||
use the original roundtable coordinator or jobs. Its shared configuration
|
||||
integration point is the existing `/api/roundtable-chains` store: each `seats` JSON item
|
||||
may carry an optional `position_id` (validated by `ChainSeatSchema`, no schema
|
||||
migration). The original roundtable only reads its established seat fields and
|
||||
therefore remains unchanged. The separate `/api/position-roundtable` router
|
||||
persists task/intent/frozen-chain snapshots in `position_roundtable_sessions`
|
||||
and direct-agent node indexes in `position_roundtable_nodes` (migration
|
||||
`20260721_01`). Message history remains in the established LangGraph thread
|
||||
store; a node completion unlocks its next chain stage and a later upstream
|
||||
revision marks downstream records `stale` for refresh.
|
||||
For direct-agent handoff, the router resolves artifact paths only inside the
|
||||
completed upstream node's own thread, then includes bounded UTF-8 excerpts for
|
||||
text outputs; binary artifacts stay as metadata-only references.
|
||||
`POST /sessions/{id}/archive` turns a session into a read-only historical
|
||||
record: task/intent updates, node binding, hidden-context construction, and
|
||||
turn completion are rejected, while read endpoints remain available.
|
||||
|
||||
### 岗位 (Position) Subscription System
|
||||
|
||||
An admin-managed aggregation layer that decides, per user, which agents / skills
|
||||
|
||||
@@ -122,6 +122,8 @@ FastAPI application providing REST endpoints for frontend integration:
|
||||
| `POST /api/skills/install` | Install skill from `.skill` archive |
|
||||
| `GET/POST /api/agents` | List visible agents or create a user-owned agent |
|
||||
| `GET/PUT/DELETE /api/agents/{id}` | Read, update, or delete an id-addressed agent |
|
||||
| `GET/POST/PUT/DELETE /api/roundtable-chains` | Manage user-owned roundtable business chains; each seat may optionally carry `position_id` for the separate position-collaboration workspace (the original roundtable ignores it) |
|
||||
| `GET/POST/PUT /api/position-roundtable/sessions` | Standalone position-collaboration sessions: freeze task/intent/chain snapshots, index one direct-agent node per seat, build upstream handoff context (including bounded UTF-8 excerpts of text artifacts), and make archived sessions read-only without invoking the original roundtable coordinator or jobs |
|
||||
| `GET /api/memory` | Retrieve memory data |
|
||||
| `POST /api/memory/reload` | Force memory reload |
|
||||
| `GET /api/memory/config` | Memory configuration |
|
||||
|
||||
@@ -53,9 +53,10 @@ from app.gateway.routers import (
|
||||
notifications,
|
||||
open_3qfx,
|
||||
open_chat,
|
||||
parallel_agent_tasks,
|
||||
parallel_agents,
|
||||
positions,
|
||||
parallel_agent_tasks,
|
||||
parallel_agents,
|
||||
position_roundtable,
|
||||
positions,
|
||||
public_config,
|
||||
public_embed,
|
||||
public_leaderboard,
|
||||
@@ -1443,8 +1444,11 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# Step3 流程图结构化抽取 + 校验 (SSE) at /api/flow-extract
|
||||
app.include_router(flow_extract.router)
|
||||
|
||||
# Roundtable-planning business chains at /api/roundtable-chains
|
||||
app.include_router(roundtable_chains.router)
|
||||
# Roundtable-planning business chains at /api/roundtable-chains
|
||||
app.include_router(roundtable_chains.router)
|
||||
|
||||
# 岗位协同独立会话 / 节点状态(不使用原圆桌总控或后台作业)
|
||||
app.include_router(position_roundtable.router)
|
||||
|
||||
# Global business ? agent / chain mapping at /api/business-mapping
|
||||
app.include_router(business_mapping.router)
|
||||
|
||||
@@ -255,9 +255,15 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.roundtable_draft_share_store = make_roundtable_draft_share_store(sf)
|
||||
|
||||
from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
|
||||
|
||||
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
|
||||
from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
|
||||
|
||||
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
|
||||
|
||||
# 独立的岗位协同 Session / Node 存储。它只索引业务流程状态,真实消息和
|
||||
# 文件仍复用 LangGraph thread/checkpoint 与现有 artifact API。
|
||||
from deerflow.persistence.position_roundtable import make_position_roundtable_store
|
||||
|
||||
app.state.position_roundtable_store = make_position_roundtable_store(sf)
|
||||
|
||||
from deerflow.persistence.business_mapping import make_business_mapping_store
|
||||
|
||||
|
||||
@@ -0,0 +1,563 @@
|
||||
"""API for the standalone position-collaboration workspace.
|
||||
|
||||
Unlike the existing multi-agent roundtable, this feature has no coordinator and
|
||||
no background roundtable job. The API only persists the task/intent/chain
|
||||
snapshot and indexes one direct-agent node per business-chain seat. Actual chat
|
||||
messages and artifacts stay in the established LangGraph thread stores.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.path_utils import aresolve_thread_virtual_path
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
router = APIRouter(prefix="/api/position-roundtable", tags=["position-roundtable"])
|
||||
|
||||
NODE_STATUSES = {"locked", "ready", "running", "done", "stale", "error"}
|
||||
_HANDOFF_TEXT_BUDGET = 12_000
|
||||
_MAX_INLINE_ARTIFACTS_PER_NODE = 4
|
||||
_MAX_INLINE_ARTIFACT_CHARS = 1_800
|
||||
_TEXT_ARTIFACT_SUFFIXES = {
|
||||
".csv",
|
||||
".json",
|
||||
".log",
|
||||
".md",
|
||||
".markdown",
|
||||
".rst",
|
||||
".txt",
|
||||
".xml",
|
||||
".yaml",
|
||||
".yml",
|
||||
}
|
||||
|
||||
|
||||
class NodeResponse(BaseModel):
|
||||
id: str
|
||||
session_id: str
|
||||
node_key: str
|
||||
stage_index: int
|
||||
seat_index: int
|
||||
agent_id: str
|
||||
position_id: str | None = None
|
||||
thread_id: str | None = None
|
||||
status: str
|
||||
latest_answer: str | None = None
|
||||
artifact_manifest: list[dict[str, Any]] = Field(default_factory=list)
|
||||
revision: int = 0
|
||||
upstream_revision: dict[str, Any] | None = None
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class SessionResponse(BaseModel):
|
||||
id: str
|
||||
external_task_id: str | None = None
|
||||
task_snapshot: dict[str, Any] = Field(default_factory=dict)
|
||||
chain_id: str | None = None
|
||||
chain_snapshot: dict[str, Any] | None = None
|
||||
intent_thread_id: str | None = None
|
||||
intent_snapshot: dict[str, Any] | None = None
|
||||
status: str
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class SessionDetailResponse(SessionResponse):
|
||||
nodes: list[NodeResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SessionCreateRequest(BaseModel):
|
||||
external_task_id: str | None = Field(default=None, max_length=128)
|
||||
task_snapshot: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class TaskSnapshotUpdateRequest(BaseModel):
|
||||
task_snapshot: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class IntentUpdateRequest(BaseModel):
|
||||
intent_thread_id: str | None = Field(default=None, max_length=128)
|
||||
intent_snapshot: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ActivateRequest(BaseModel):
|
||||
chain_id: str = Field(min_length=1, max_length=64)
|
||||
|
||||
|
||||
class NodeThreadRequest(BaseModel):
|
||||
thread_id: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class CompleteTurnRequest(BaseModel):
|
||||
latest_answer: str = Field(default="", max_length=500_000)
|
||||
artifact_manifest: list[dict[str, Any]] = Field(default_factory=list)
|
||||
upstream_revision: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _current_user_id(request: Request) -> str:
|
||||
user = getattr(request.state, "user", None)
|
||||
return str(user.id) if user is not None else get_effective_user_id()
|
||||
|
||||
|
||||
def _get_store(request: Request):
|
||||
store = getattr(request.app.state, "position_roundtable_store", None)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="Position roundtable store not available")
|
||||
return store
|
||||
|
||||
|
||||
def _get_chain_store(request: Request):
|
||||
store = getattr(request.app.state, "roundtable_chain_store", None)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="Business chain store not available")
|
||||
return store
|
||||
|
||||
|
||||
async def _require_writable_session(
|
||||
request: Request, session_id: str
|
||||
) -> tuple[Any, str, dict[str, Any]]:
|
||||
"""Load a Session that can still accept task, intent, or node updates."""
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
session = await store.get_session(session_id, user_id)
|
||||
if session is None:
|
||||
raise HTTPException(status_code=404, detail="Position roundtable session not found")
|
||||
if session.get("status") == "archived":
|
||||
raise HTTPException(status_code=409, detail="该岗位协同会话已归档,仅可查看历史记录")
|
||||
return store, user_id, session
|
||||
|
||||
|
||||
def _project_session(row: dict[str, Any]) -> SessionResponse:
|
||||
return SessionResponse(**row)
|
||||
|
||||
|
||||
def _project_node(row: dict[str, Any]) -> NodeResponse:
|
||||
return NodeResponse(**row)
|
||||
|
||||
|
||||
async def _load_detail(request: Request, session_id: str) -> SessionDetailResponse:
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
row = await store.get_session(session_id, user_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Position roundtable session not found")
|
||||
nodes = await store.list_nodes(session_id, user_id)
|
||||
return SessionDetailResponse(**row, nodes=[_project_node(node) for node in nodes or []])
|
||||
|
||||
|
||||
def _chain_nodes(chain: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
seats = chain.get("seats") if isinstance(chain.get("seats"), list) else []
|
||||
if not seats:
|
||||
raise HTTPException(status_code=400, detail="业务链条没有可执行的智能体席位")
|
||||
seat_by_agent_id = {str(seat.get("agent_id")): seat for seat in seats if seat.get("agent_id")}
|
||||
if len(seat_by_agent_id) != len(seats):
|
||||
raise HTTPException(status_code=400, detail="业务链条包含无效或重复的智能体席位")
|
||||
|
||||
raw_stages = chain.get("stages")
|
||||
stages: list[list[str]]
|
||||
if isinstance(raw_stages, list) and raw_stages:
|
||||
stages = [[str(agent_id) for agent_id in stage] for stage in raw_stages if isinstance(stage, list)]
|
||||
else:
|
||||
stages = [[str(seat["agent_id"])] for seat in seats]
|
||||
if not stages or any(not stage for stage in stages):
|
||||
raise HTTPException(status_code=400, detail="业务链条阶段配置无效")
|
||||
|
||||
result: list[dict[str, Any]] = []
|
||||
for stage_index, stage in enumerate(stages):
|
||||
for seat_index, agent_id in enumerate(stage):
|
||||
seat = seat_by_agent_id.get(agent_id)
|
||||
if seat is None:
|
||||
raise HTTPException(status_code=400, detail=f"业务链条阶段包含不存在的席位: {agent_id}")
|
||||
position_id = seat.get("position_id")
|
||||
if not isinstance(position_id, str) or not position_id.strip():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"智能体「{seat.get('name') or agent_id}」尚未配置岗位,请先从岗位协同入口配置",
|
||||
)
|
||||
result.append(
|
||||
{
|
||||
"node_key": f"stage-{stage_index + 1}-seat-{seat_index + 1}-{agent_id}",
|
||||
"stage_index": stage_index,
|
||||
"seat_index": seat_index,
|
||||
"agent_id": agent_id,
|
||||
"position_id": position_id.strip(),
|
||||
"status": "ready" if stage_index == 0 else "locked",
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _truncate_text(value: Any, limit: int) -> str:
|
||||
if limit <= 0:
|
||||
return ""
|
||||
text = value if isinstance(value, str) else str(value or "")
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return f"{text[:limit]}\n…(已按交接上下文长度截断)"
|
||||
|
||||
|
||||
def _artifact_refs(artifacts: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(artifacts, list):
|
||||
return []
|
||||
refs: list[dict[str, Any]] = []
|
||||
for artifact in artifacts[:20]:
|
||||
if not isinstance(artifact, dict):
|
||||
continue
|
||||
refs.append(
|
||||
{
|
||||
"name": artifact.get("name") or artifact.get("filename") or "未命名产物",
|
||||
"path": artifact.get("path") or artifact.get("artifact_url") or "",
|
||||
"mime_type": artifact.get("mimeType") or artifact.get("mime_type") or None,
|
||||
}
|
||||
)
|
||||
return refs
|
||||
|
||||
|
||||
def _is_inline_text_artifact(artifact: dict[str, Any]) -> bool:
|
||||
"""Return whether a listed output is safe and useful to pass as text."""
|
||||
mime_type = artifact.get("mime_type")
|
||||
if isinstance(mime_type, str) and mime_type.lower().startswith("text/"):
|
||||
return True
|
||||
path = artifact.get("path")
|
||||
return isinstance(path, str) and Path(path).suffix.lower() in _TEXT_ARTIFACT_SUFFIXES
|
||||
|
||||
|
||||
def _read_text_excerpt(path: Path, max_chars: int) -> tuple[str, bool] | None:
|
||||
"""Read one bounded UTF-8 text artifact without loading the whole file."""
|
||||
if max_chars <= 0:
|
||||
return None
|
||||
# Four bytes per UTF-8 code point is enough to obtain the requested
|
||||
# character-sized excerpt without a potentially unbounded file read.
|
||||
with path.open("rb") as file:
|
||||
raw = file.read(max_chars * 4 + 1)
|
||||
if b"\x00" in raw:
|
||||
return None
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError as error:
|
||||
if len(raw) == max_chars * 4 + 1 and error.start >= len(raw) - 4:
|
||||
text = raw[: error.start].decode("utf-8")
|
||||
else:
|
||||
# Generated artifacts are expected to be UTF-8. Keep a non-UTF-8
|
||||
# output as a reference only instead of corrupting agent context.
|
||||
return None
|
||||
truncated = len(raw) > max_chars * 4 or len(text) > max_chars
|
||||
return _truncate_text(text, max_chars), truncated
|
||||
|
||||
|
||||
async def _artifact_refs_with_content(
|
||||
thread_id: str | None, artifacts: Any, text_budget: int
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""Attach bounded excerpts of text outputs, retaining binary files as refs.
|
||||
|
||||
Paths are always resolved against the upstream node's own thread. This is
|
||||
deliberate: an artifact manifest is client-supplied metadata, so it must
|
||||
never be allowed to select another thread's user-data directory.
|
||||
"""
|
||||
refs = _artifact_refs(artifacts)
|
||||
if not thread_id or text_budget <= 0:
|
||||
return refs, 0
|
||||
|
||||
consumed = 0
|
||||
inline_count = 0
|
||||
for ref in refs:
|
||||
if inline_count >= _MAX_INLINE_ARTIFACTS_PER_NODE:
|
||||
break
|
||||
if not _is_inline_text_artifact(ref):
|
||||
continue
|
||||
virtual_path = ref.get("path")
|
||||
if not isinstance(virtual_path, str):
|
||||
continue
|
||||
normalized_path = virtual_path.replace("\\", "/").lstrip("/")
|
||||
if not normalized_path.startswith("mnt/user-data/outputs/"):
|
||||
continue
|
||||
available = text_budget - consumed
|
||||
if available <= 0:
|
||||
break
|
||||
excerpt_limit = min(_MAX_INLINE_ARTIFACT_CHARS, available)
|
||||
try:
|
||||
actual_path = await aresolve_thread_virtual_path(thread_id, normalized_path)
|
||||
if not await asyncio.to_thread(actual_path.is_file):
|
||||
continue
|
||||
excerpt = await asyncio.to_thread(_read_text_excerpt, actual_path, excerpt_limit)
|
||||
except (HTTPException, OSError):
|
||||
# The file may have been removed after the manifest was stored.
|
||||
# Keep its metadata so the UI still shows the original output.
|
||||
continue
|
||||
if excerpt is None:
|
||||
continue
|
||||
content, truncated = excerpt
|
||||
ref["content"] = content
|
||||
ref["content_truncated"] = truncated
|
||||
consumed += len(content)
|
||||
inline_count += 1
|
||||
return refs, consumed
|
||||
|
||||
|
||||
async def _handoff_context(request: Request, session_id: str, node_key: str) -> dict[str, Any]:
|
||||
"""Construct bounded, deterministic hidden context for one direct-agent turn."""
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
session = await store.get_session(session_id, user_id)
|
||||
nodes = await store.list_nodes(session_id, user_id)
|
||||
if session is None or nodes is None:
|
||||
raise HTTPException(status_code=404, detail="Position roundtable session not found")
|
||||
if session.get("status") == "archived":
|
||||
raise HTTPException(status_code=409, detail="该岗位协同会话已归档,仅可查看历史记录")
|
||||
current = next((node for node in nodes if node["node_key"] == node_key), None)
|
||||
if current is None:
|
||||
raise HTTPException(status_code=404, detail="Position roundtable node not found")
|
||||
if current["status"] == "locked":
|
||||
raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁")
|
||||
|
||||
# Same-stage nodes are intentionally excluded. The business chain only
|
||||
# hands off completed previous stages, in stage/seat order.
|
||||
remaining = _HANDOFF_TEXT_BUDGET
|
||||
upstream_candidates = [
|
||||
node
|
||||
for node in nodes
|
||||
if node["stage_index"] < current["stage_index"]
|
||||
and node["status"] in {"done", "stale"}
|
||||
]
|
||||
upstream_nodes: list[dict[str, Any]] = []
|
||||
for index, node in enumerate(upstream_candidates):
|
||||
candidates_left = len(upstream_candidates) - index
|
||||
per_node_limit = min(2_800, max(0, (remaining // candidates_left) * 2 // 3))
|
||||
answer = _truncate_text(node.get("latest_answer"), per_node_limit)
|
||||
remaining -= len(answer)
|
||||
artifact_budget = min(
|
||||
_MAX_INLINE_ARTIFACT_CHARS,
|
||||
max(0, remaining // candidates_left),
|
||||
)
|
||||
artifacts, consumed = await _artifact_refs_with_content(
|
||||
node.get("thread_id"), node.get("artifact_manifest"), artifact_budget
|
||||
)
|
||||
remaining -= consumed
|
||||
upstream_nodes.append(
|
||||
{
|
||||
"node_key": node["node_key"],
|
||||
"agent_id": node["agent_id"],
|
||||
"position_id": node.get("position_id"),
|
||||
"revision": node.get("revision", 0),
|
||||
"latest_answer": answer,
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
)
|
||||
if remaining <= 0:
|
||||
break
|
||||
return {
|
||||
"kind": "position_roundtable_handoff",
|
||||
"task": session.get("task_snapshot") or {},
|
||||
"intent": session.get("intent_snapshot") or {},
|
||||
"current_node": {
|
||||
"node_key": current["node_key"],
|
||||
"agent_id": current["agent_id"],
|
||||
"position_id": current.get("position_id"),
|
||||
"stage_index": current["stage_index"],
|
||||
"seat_index": current["seat_index"],
|
||||
},
|
||||
"upstream_nodes": upstream_nodes,
|
||||
"upstream_revision": {
|
||||
node["node_key"]: node.get("revision", 0) for node in upstream_nodes
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=list[SessionResponse])
|
||||
async def list_sessions(request: Request, external_task_id: str | None = None) -> list[SessionResponse]:
|
||||
rows = await _get_store(request).list_sessions(
|
||||
_current_user_id(request), external_task_id=external_task_id
|
||||
)
|
||||
return [_project_session(row) for row in rows]
|
||||
|
||||
|
||||
@router.post("/sessions", response_model=SessionResponse, status_code=201)
|
||||
async def create_session(request: Request, body: SessionCreateRequest) -> SessionResponse:
|
||||
row = await _get_store(request).create_session(
|
||||
_current_user_id(request),
|
||||
external_task_id=body.external_task_id,
|
||||
task_snapshot=body.task_snapshot,
|
||||
)
|
||||
return _project_session(row)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}", response_model=SessionDetailResponse)
|
||||
async def get_session(request: Request, session_id: str) -> SessionDetailResponse:
|
||||
return await _load_detail(request, session_id)
|
||||
|
||||
|
||||
@router.put("/sessions/{session_id}/task", response_model=SessionResponse)
|
||||
async def update_task_snapshot(
|
||||
request: Request, session_id: str, body: TaskSnapshotUpdateRequest
|
||||
) -> SessionResponse:
|
||||
store, user_id, _ = await _require_writable_session(request, session_id)
|
||||
row = await store.update_session(session_id, user_id, task_snapshot=body.task_snapshot)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Position roundtable session not found")
|
||||
return _project_session(row)
|
||||
|
||||
|
||||
@router.put("/sessions/{session_id}/intent", response_model=SessionResponse)
|
||||
async def update_intent_snapshot(
|
||||
request: Request, session_id: str, body: IntentUpdateRequest
|
||||
) -> SessionResponse:
|
||||
store, user_id, _ = await _require_writable_session(request, session_id)
|
||||
row = await store.update_session(
|
||||
session_id,
|
||||
user_id,
|
||||
intent_thread_id=body.intent_thread_id,
|
||||
intent_snapshot=body.intent_snapshot,
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Position roundtable session not found")
|
||||
return _project_session(row)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/activate", response_model=SessionDetailResponse)
|
||||
async def activate_session(
|
||||
request: Request, session_id: str, body: ActivateRequest
|
||||
) -> SessionDetailResponse:
|
||||
store, user_id, current = await _require_writable_session(request, session_id)
|
||||
if not current.get("intent_snapshot"):
|
||||
raise HTTPException(status_code=400, detail="请先完成并保存任务意图")
|
||||
|
||||
chain = await _get_chain_store(request).get_chain(
|
||||
body.chain_id,
|
||||
user_id,
|
||||
is_admin=getattr(getattr(request.state, "user", None), "system_role", None) == "admin",
|
||||
)
|
||||
if chain is None:
|
||||
raise HTTPException(status_code=404, detail="业务链条不存在或无权访问")
|
||||
nodes = _chain_nodes(chain)
|
||||
activated = await store.activate_session(
|
||||
session_id,
|
||||
user_id,
|
||||
chain_id=body.chain_id,
|
||||
chain_snapshot=chain,
|
||||
nodes=nodes,
|
||||
)
|
||||
if activated is None:
|
||||
raise HTTPException(status_code=404, detail="Position roundtable session not found")
|
||||
row, node_rows = activated
|
||||
return SessionDetailResponse(**row, nodes=[_project_node(node) for node in node_rows])
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/archive", response_model=SessionResponse)
|
||||
async def archive_session(request: Request, session_id: str) -> SessionResponse:
|
||||
row = await _get_store(request).archive_session(session_id, _current_user_id(request))
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Position roundtable session not found")
|
||||
return _project_session(row)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/nodes/{node_key}", response_model=NodeResponse)
|
||||
async def get_node(request: Request, session_id: str, node_key: str) -> NodeResponse:
|
||||
row = await _get_store(request).get_node(session_id, node_key, _current_user_id(request))
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Position roundtable node not found")
|
||||
return _project_node(row)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/nodes/{node_key}/thread", response_model=NodeResponse)
|
||||
async def bind_node_thread(
|
||||
request: Request, session_id: str, node_key: str, body: NodeThreadRequest
|
||||
) -> NodeResponse:
|
||||
store, user_id, _ = await _require_writable_session(request, session_id)
|
||||
current = await store.get_node(session_id, node_key, user_id)
|
||||
if current is None:
|
||||
raise HTTPException(status_code=404, detail="Position roundtable node not found")
|
||||
if current["status"] == "locked":
|
||||
raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁")
|
||||
if current.get("thread_id") and current["thread_id"] != body.thread_id:
|
||||
raise HTTPException(status_code=409, detail="该节点已绑定另一条对话线程")
|
||||
row = await store.update_node(
|
||||
session_id,
|
||||
node_key,
|
||||
user_id,
|
||||
thread_id=body.thread_id,
|
||||
status="running" if current.get("status") == "ready" else current.get("status", "locked"),
|
||||
)
|
||||
return _project_node(row)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/nodes/{node_key}/context")
|
||||
async def build_node_context(request: Request, session_id: str, node_key: str) -> dict[str, Any]:
|
||||
"""Return the bounded hidden task/intent/upstream context for a node turn."""
|
||||
return await _handoff_context(request, session_id, node_key)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/nodes/{node_key}/complete-turn", response_model=NodeResponse)
|
||||
async def complete_node_turn(
|
||||
request: Request, session_id: str, node_key: str, body: CompleteTurnRequest
|
||||
) -> NodeResponse:
|
||||
store, user_id, _ = await _require_writable_session(request, session_id)
|
||||
node = await store.get_node(session_id, node_key, user_id)
|
||||
if node is None:
|
||||
raise HTTPException(status_code=404, detail="Position roundtable node not found")
|
||||
if node["status"] == "locked":
|
||||
raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁")
|
||||
row = await store.update_node(
|
||||
session_id,
|
||||
node_key,
|
||||
user_id,
|
||||
status="done",
|
||||
latest_answer=body.latest_answer,
|
||||
artifact_manifest=body.artifact_manifest,
|
||||
upstream_revision=body.upstream_revision,
|
||||
increment_revision=True,
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Position roundtable node not found")
|
||||
|
||||
# A node has no coordinator. Its completion alone drives the frozen chain:
|
||||
# all parallel seats in the current stage must finish before the next stage
|
||||
# is made writable. Re-answering an already completed upstream seat keeps
|
||||
# downstream records for audit but marks them stale so they must be
|
||||
# refreshed against the new handoff context.
|
||||
nodes = await store.list_nodes(session_id, user_id)
|
||||
if nodes is None:
|
||||
raise HTTPException(status_code=404, detail="Position roundtable session not found")
|
||||
if node.get("revision", 0) > 0:
|
||||
for downstream in nodes:
|
||||
if (
|
||||
downstream["stage_index"] > row["stage_index"]
|
||||
and downstream["status"] in {"ready", "running", "done", "stale"}
|
||||
):
|
||||
await store.update_node(
|
||||
session_id,
|
||||
downstream["node_key"],
|
||||
user_id,
|
||||
status="stale",
|
||||
)
|
||||
nodes = await store.list_nodes(session_id, user_id) or []
|
||||
|
||||
current_stage = [item for item in nodes if item["stage_index"] == row["stage_index"]]
|
||||
later_stages = sorted(
|
||||
{item["stage_index"] for item in nodes if item["stage_index"] > row["stage_index"]}
|
||||
)
|
||||
if current_stage and all(item["status"] == "done" for item in current_stage) and later_stages:
|
||||
next_stage = later_stages[0]
|
||||
for next_node in nodes:
|
||||
if next_node["stage_index"] == next_stage and next_node["status"] == "locked":
|
||||
await store.update_node(
|
||||
session_id,
|
||||
next_node["node_key"],
|
||||
user_id,
|
||||
status="ready",
|
||||
)
|
||||
return _project_node(row)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/results", response_model=list[NodeResponse])
|
||||
async def list_results(request: Request, session_id: str) -> list[NodeResponse]:
|
||||
rows = await _get_store(request).list_results(session_id, _current_user_id(request))
|
||||
if rows is None:
|
||||
raise HTTPException(status_code=404, detail="Position roundtable session not found")
|
||||
return [_project_node(row) for row in rows]
|
||||
@@ -62,6 +62,9 @@ class ChainSeatSchema(BaseModel):
|
||||
# 持久化,故无需迁移;仅需在此 schema 显式声明,否则 model_dump() 会剥掉它。非法值在运行期
|
||||
# 被 _SEAT_MODE_PARAMS.get() 安全忽略(回退作业级),故此处只约束长度、不强校验取值。
|
||||
seat_mode: str | None = Field(default=None, max_length=16)
|
||||
# 岗位协同专用的席位岗位归属。字段保存在既有 seats JSON 中,普通圆桌编排
|
||||
# 忽略它;显式声明可确保 Pydantic 在 create/update 的 model_dump() 时不剥掉。
|
||||
position_id: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
class ChainResponse(BaseModel):
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
"""Create standalone position-collaboration sessions and nodes.
|
||||
|
||||
The tables only index the task / intent / frozen business-chain workflow.
|
||||
Conversation messages remain in LangGraph checkpoints and artifacts stay in the
|
||||
existing thread artifact store, so this migration intentionally has no message
|
||||
or file columns beyond lightweight snapshots and manifests.
|
||||
|
||||
Revision ID: 20260721_01
|
||||
Revises: 20260701_01
|
||||
Create Date: 2026-07-21
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||
|
||||
|
||||
revision = "20260721_01"
|
||||
down_revision = "20260701_01"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
return table_name in inspect(op.get_bind()).get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
long_text = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
|
||||
if not _has_table("position_roundtable_sessions"):
|
||||
op.create_table(
|
||||
"position_roundtable_sessions",
|
||||
sa.Column("id", sa.String(64), primary_key=True),
|
||||
sa.Column("user_id", sa.String(64), nullable=False),
|
||||
sa.Column("external_task_id", sa.String(128), nullable=True),
|
||||
sa.Column("task_snapshot", long_text, nullable=True),
|
||||
sa.Column("chain_id", sa.String(64), nullable=True),
|
||||
sa.Column("chain_snapshot", long_text, nullable=True),
|
||||
sa.Column("intent_thread_id", sa.String(128), nullable=True),
|
||||
sa.Column("intent_snapshot", long_text, nullable=True),
|
||||
sa.Column("status", sa.String(32), nullable=False, server_default="intent_pending"),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_position_roundtable_sessions_user_id", "position_roundtable_sessions", ["user_id"])
|
||||
op.create_index(
|
||||
"ix_position_roundtable_sessions_external_task_id",
|
||||
"position_roundtable_sessions",
|
||||
["external_task_id"],
|
||||
)
|
||||
op.create_index("ix_position_roundtable_sessions_chain_id", "position_roundtable_sessions", ["chain_id"])
|
||||
op.create_index("ix_position_roundtable_sessions_status", "position_roundtable_sessions", ["status"])
|
||||
|
||||
if not _has_table("position_roundtable_nodes"):
|
||||
op.create_table(
|
||||
"position_roundtable_nodes",
|
||||
sa.Column("id", sa.String(64), primary_key=True),
|
||||
sa.Column("session_id", sa.String(64), sa.ForeignKey("position_roundtable_sessions.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("node_key", sa.String(256), nullable=False),
|
||||
sa.Column("stage_index", sa.Integer(), nullable=False),
|
||||
sa.Column("seat_index", sa.Integer(), nullable=False),
|
||||
sa.Column("agent_id", sa.String(128), nullable=False),
|
||||
sa.Column("position_id", sa.String(64), nullable=True),
|
||||
sa.Column("thread_id", sa.String(128), nullable=True, unique=True),
|
||||
sa.Column("status", sa.String(32), nullable=False, server_default="locked"),
|
||||
sa.Column("latest_answer", long_text, nullable=True),
|
||||
sa.Column("artifact_manifest", long_text, nullable=True),
|
||||
sa.Column("revision", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("upstream_revision", long_text, nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.UniqueConstraint("session_id", "node_key", name="uq_position_roundtable_nodes_session_key"),
|
||||
)
|
||||
op.create_index("ix_position_roundtable_nodes_session_id", "position_roundtable_nodes", ["session_id"])
|
||||
op.create_index("ix_position_roundtable_nodes_agent_id", "position_roundtable_nodes", ["agent_id"])
|
||||
op.create_index("ix_position_roundtable_nodes_position_id", "position_roundtable_nodes", ["position_id"])
|
||||
op.create_index("ix_position_roundtable_nodes_status", "position_roundtable_nodes", ["status"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_table("position_roundtable_nodes"):
|
||||
op.drop_table("position_roundtable_nodes")
|
||||
if _has_table("position_roundtable_sessions"):
|
||||
op.drop_table("position_roundtable_sessions")
|
||||
+6
@@ -46,6 +46,10 @@ from deerflow.persistence.menu_overrides.model import MenuOverrideRow
|
||||
from deerflow.persistence.models.run_event import RunEventRow
|
||||
from deerflow.persistence.notifications.model import NotificationRow
|
||||
from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow
|
||||
from deerflow.persistence.position_roundtable.model import (
|
||||
PositionRoundtableNodeRow,
|
||||
PositionRoundtableSessionRow,
|
||||
)
|
||||
from deerflow.persistence.positions.model import PositionRow, PositionTagBindingRow
|
||||
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
|
||||
from deerflow.persistence.research_templates.model import ResearchTemplateRow
|
||||
@@ -104,6 +108,8 @@ __all__ = [
|
||||
"MenuOverrideRow",
|
||||
"NotificationRow",
|
||||
"ParallelAgentTaskRow",
|
||||
"PositionRoundtableNodeRow",
|
||||
"PositionRoundtableSessionRow",
|
||||
"PositionRow",
|
||||
"PositionTagBindingRow",
|
||||
"RecommendedQuestionRow",
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
"""Position-collaboration session and node persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.position_roundtable.model import (
|
||||
PositionRoundtableNodeRow,
|
||||
PositionRoundtableSessionRow,
|
||||
)
|
||||
from deerflow.persistence.position_roundtable.sql import PositionRoundtableRepository
|
||||
|
||||
__all__ = [
|
||||
"PositionRoundtableNodeRow",
|
||||
"PositionRoundtableRepository",
|
||||
"PositionRoundtableSessionRow",
|
||||
"make_position_roundtable_store",
|
||||
]
|
||||
|
||||
|
||||
def make_position_roundtable_store(session_factory):
|
||||
if session_factory is None:
|
||||
return None
|
||||
return PositionRoundtableRepository(session_factory)
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
"""Persistence models for the standalone position-collaboration workspace.
|
||||
|
||||
This deliberately does **not** reuse ``roundtable_drafts`` or
|
||||
``roundtable_jobs``. A session captures the selected business chain and the
|
||||
confirmed task intent; every chain seat then has one durable node record. The
|
||||
node points at an existing LangGraph thread, which continues to own the actual
|
||||
message history and generated artifacts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.types import BeijingDateTime, PortableLongText
|
||||
|
||||
|
||||
class PositionRoundtableSessionRow(Base):
|
||||
__tablename__ = "position_roundtable_sessions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
# Phase one uses normal account isolation. A future membership / role ACL
|
||||
# can be added without changing the session or thread identifiers.
|
||||
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
external_task_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
task_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
chain_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
chain_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
intent_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
intent_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
# intent_pending -> active -> completed / archived. The router owns legal
|
||||
# transitions; the DB keeps it as a concise, portable string.
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="intent_pending", index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
BeijingDateTime(),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
class PositionRoundtableNodeRow(Base):
|
||||
__tablename__ = "position_roundtable_nodes"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("session_id", "node_key", name="uq_position_roundtable_nodes_session_key"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
session_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("position_roundtable_sessions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
# Stable inside one frozen chain snapshot; e.g. ``stage-1-seat-2-agent``.
|
||||
node_key: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
stage_index: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
seat_index: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
agent_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
position_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
# The real messages remain in the standard LangGraph/checkpoint store.
|
||||
thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True, unique=True)
|
||||
# locked / ready / running / done / stale / error
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="locked", index=True)
|
||||
latest_answer: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
artifact_manifest: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
revision: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
upstream_revision: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
BeijingDateTime(),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
"""SQL repository for position-collaboration sessions and chain nodes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.position_roundtable.model import (
|
||||
PositionRoundtableNodeRow,
|
||||
PositionRoundtableSessionRow,
|
||||
)
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def _dumps(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def _loads(value: Any) -> Any:
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
try:
|
||||
return json.loads(value)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _time(value: Any) -> str | Any:
|
||||
return value.isoformat() if isinstance(value, datetime) else value
|
||||
|
||||
|
||||
class PositionRoundtableRepository:
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
@staticmethod
|
||||
def _session_dict(row: PositionRoundtableSessionRow) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"external_task_id": row.external_task_id,
|
||||
"task_snapshot": _loads(row.task_snapshot) or {},
|
||||
"chain_id": row.chain_id,
|
||||
"chain_snapshot": _loads(row.chain_snapshot),
|
||||
"intent_thread_id": row.intent_thread_id,
|
||||
"intent_snapshot": _loads(row.intent_snapshot),
|
||||
"status": row.status,
|
||||
"created_at": _time(row.created_at),
|
||||
"updated_at": _time(row.updated_at),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _node_dict(row: PositionRoundtableNodeRow) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"session_id": row.session_id,
|
||||
"node_key": row.node_key,
|
||||
"stage_index": row.stage_index,
|
||||
"seat_index": row.seat_index,
|
||||
"agent_id": row.agent_id,
|
||||
"position_id": row.position_id,
|
||||
"thread_id": row.thread_id,
|
||||
"status": row.status,
|
||||
"latest_answer": row.latest_answer,
|
||||
"artifact_manifest": _loads(row.artifact_manifest) or [],
|
||||
"revision": row.revision,
|
||||
"upstream_revision": _loads(row.upstream_revision),
|
||||
"created_at": _time(row.created_at),
|
||||
"updated_at": _time(row.updated_at),
|
||||
}
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
external_task_id: str | None,
|
||||
task_snapshot: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
now = datetime.now(UTC)
|
||||
row = PositionRoundtableSessionRow(
|
||||
id=uuid4().hex,
|
||||
user_id=user_id,
|
||||
external_task_id=external_task_id or None,
|
||||
task_snapshot=_dumps(task_snapshot or {}),
|
||||
status="intent_pending",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._session_dict(row)
|
||||
|
||||
async def list_sessions(
|
||||
self, user_id: str, *, external_task_id: str | None = None, limit: int = 50
|
||||
) -> list[dict[str, Any]]:
|
||||
stmt = select(PositionRoundtableSessionRow).where(PositionRoundtableSessionRow.user_id == user_id)
|
||||
if external_task_id:
|
||||
stmt = stmt.where(PositionRoundtableSessionRow.external_task_id == external_task_id)
|
||||
stmt = stmt.order_by(PositionRoundtableSessionRow.updated_at.desc()).limit(limit)
|
||||
async with self._sf() as session:
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
return [self._session_dict(row) for row in rows]
|
||||
|
||||
async def get_session(self, session_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(PositionRoundtableSessionRow, session_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return None
|
||||
return self._session_dict(row)
|
||||
|
||||
async def update_session(
|
||||
self,
|
||||
session_id: str,
|
||||
user_id: str,
|
||||
*,
|
||||
task_snapshot: dict[str, Any] | object = _UNSET,
|
||||
intent_thread_id: str | None | object = _UNSET,
|
||||
intent_snapshot: dict[str, Any] | None | object = _UNSET,
|
||||
status: str | object = _UNSET,
|
||||
) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(PositionRoundtableSessionRow, session_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return None
|
||||
if task_snapshot is not _UNSET:
|
||||
row.task_snapshot = _dumps(task_snapshot)
|
||||
if intent_thread_id is not _UNSET:
|
||||
row.intent_thread_id = intent_thread_id or None
|
||||
if intent_snapshot is not _UNSET:
|
||||
row.intent_snapshot = _dumps(intent_snapshot)
|
||||
if status is not _UNSET:
|
||||
row.status = str(status)
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._session_dict(row)
|
||||
|
||||
async def activate_session(
|
||||
self,
|
||||
session_id: str,
|
||||
user_id: str,
|
||||
*,
|
||||
chain_id: str,
|
||||
chain_snapshot: dict[str, Any],
|
||||
nodes: list[dict[str, Any]],
|
||||
) -> tuple[dict[str, Any], list[dict[str, Any]]] | None:
|
||||
"""Freeze a chain snapshot and create its nodes once.
|
||||
|
||||
Repeated requests are intentionally idempotent: once nodes exist the
|
||||
existing snapshot and nodes are returned unchanged.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
async with self._sf() as session:
|
||||
row = await session.get(PositionRoundtableSessionRow, session_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return None
|
||||
node_rows = (
|
||||
await session.execute(
|
||||
select(PositionRoundtableNodeRow)
|
||||
.where(PositionRoundtableNodeRow.session_id == session_id)
|
||||
.order_by(PositionRoundtableNodeRow.stage_index, PositionRoundtableNodeRow.seat_index)
|
||||
)
|
||||
).scalars().all()
|
||||
if node_rows:
|
||||
return self._session_dict(row), [self._node_dict(node) for node in node_rows]
|
||||
|
||||
row.chain_id = chain_id
|
||||
row.chain_snapshot = _dumps(chain_snapshot)
|
||||
row.status = "active"
|
||||
row.updated_at = now
|
||||
node_rows = [
|
||||
PositionRoundtableNodeRow(
|
||||
id=uuid4().hex,
|
||||
session_id=session_id,
|
||||
node_key=str(node["node_key"]),
|
||||
stage_index=int(node["stage_index"]),
|
||||
seat_index=int(node["seat_index"]),
|
||||
agent_id=str(node["agent_id"]),
|
||||
position_id=node.get("position_id") or None,
|
||||
status=str(node.get("status") or "locked"),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
for node in nodes
|
||||
]
|
||||
session.add_all(node_rows)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
for node in node_rows:
|
||||
await session.refresh(node)
|
||||
return self._session_dict(row), [self._node_dict(node) for node in node_rows]
|
||||
|
||||
async def list_nodes(self, session_id: str, user_id: str) -> list[dict[str, Any]] | None:
|
||||
async with self._sf() as session:
|
||||
parent = await session.get(PositionRoundtableSessionRow, session_id)
|
||||
if parent is None or parent.user_id != user_id:
|
||||
return None
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(PositionRoundtableNodeRow)
|
||||
.where(PositionRoundtableNodeRow.session_id == session_id)
|
||||
.order_by(PositionRoundtableNodeRow.stage_index, PositionRoundtableNodeRow.seat_index)
|
||||
)
|
||||
).scalars().all()
|
||||
return [self._node_dict(row) for row in rows]
|
||||
|
||||
async def get_node(self, session_id: str, node_key: str, user_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
stmt = (
|
||||
select(PositionRoundtableNodeRow)
|
||||
.join(PositionRoundtableSessionRow, PositionRoundtableSessionRow.id == PositionRoundtableNodeRow.session_id)
|
||||
.where(
|
||||
PositionRoundtableNodeRow.session_id == session_id,
|
||||
PositionRoundtableNodeRow.node_key == node_key,
|
||||
PositionRoundtableSessionRow.user_id == user_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
row = (await session.execute(stmt)).scalars().first()
|
||||
return self._node_dict(row) if row is not None else None
|
||||
|
||||
async def update_node(
|
||||
self,
|
||||
session_id: str,
|
||||
node_key: str,
|
||||
user_id: str,
|
||||
*,
|
||||
thread_id: str | None | object = _UNSET,
|
||||
status: str | object = _UNSET,
|
||||
latest_answer: str | None | object = _UNSET,
|
||||
artifact_manifest: list[dict[str, Any]] | object = _UNSET,
|
||||
upstream_revision: dict[str, Any] | object = _UNSET,
|
||||
increment_revision: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
stmt = (
|
||||
select(PositionRoundtableNodeRow)
|
||||
.join(PositionRoundtableSessionRow, PositionRoundtableSessionRow.id == PositionRoundtableNodeRow.session_id)
|
||||
.where(
|
||||
PositionRoundtableNodeRow.session_id == session_id,
|
||||
PositionRoundtableNodeRow.node_key == node_key,
|
||||
PositionRoundtableSessionRow.user_id == user_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
row = (await session.execute(stmt)).scalars().first()
|
||||
if row is None:
|
||||
return None
|
||||
if thread_id is not _UNSET:
|
||||
row.thread_id = thread_id or None
|
||||
if status is not _UNSET:
|
||||
row.status = str(status)
|
||||
if latest_answer is not _UNSET:
|
||||
row.latest_answer = latest_answer
|
||||
if artifact_manifest is not _UNSET:
|
||||
row.artifact_manifest = _dumps(artifact_manifest)
|
||||
if upstream_revision is not _UNSET:
|
||||
row.upstream_revision = _dumps(upstream_revision)
|
||||
if increment_revision:
|
||||
row.revision += 1
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._node_dict(row)
|
||||
|
||||
async def archive_session(self, session_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
return await self.update_session(session_id, user_id, status="archived")
|
||||
|
||||
async def list_results(self, session_id: str, user_id: str) -> list[dict[str, Any]] | None:
|
||||
return await self.list_nodes(session_id, user_id)
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Persistence coverage for the standalone position-collaboration workflow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.gateway.routers import position_roundtable as position_roundtable_router
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.position_roundtable import PositionRoundtableRepository
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def store(tmp_path):
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'position-roundtable.db'}")
|
||||
async with engine.begin() as conn:
|
||||
# Register both tables with Base.metadata before creating the temporary DB.
|
||||
import deerflow.persistence.position_roundtable.model # noqa: F401
|
||||
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
return PositionRoundtableRepository(async_sessionmaker(engine, expire_on_commit=False))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_activation_freezes_chain_and_creates_nodes_once(store):
|
||||
created = await store.create_session(
|
||||
"user-a",
|
||||
external_task_id="task-1",
|
||||
task_snapshot={"title": "测试任务"},
|
||||
)
|
||||
await store.update_session(
|
||||
created["id"],
|
||||
"user-a",
|
||||
intent_thread_id="intent-thread",
|
||||
intent_snapshot={"objective": "识别意图"},
|
||||
)
|
||||
chain = {
|
||||
"id": "chain-a",
|
||||
"seats": [
|
||||
{"agent_id": "agent-a", "position_id": "planning"},
|
||||
{"agent_id": "agent-b", "position_id": "solution"},
|
||||
],
|
||||
}
|
||||
nodes = [
|
||||
{
|
||||
"node_key": "stage-1-seat-1-agent-a",
|
||||
"stage_index": 0,
|
||||
"seat_index": 0,
|
||||
"agent_id": "agent-a",
|
||||
"position_id": "planning",
|
||||
"status": "ready",
|
||||
},
|
||||
{
|
||||
"node_key": "stage-2-seat-1-agent-b",
|
||||
"stage_index": 1,
|
||||
"seat_index": 0,
|
||||
"agent_id": "agent-b",
|
||||
"position_id": "solution",
|
||||
"status": "locked",
|
||||
},
|
||||
]
|
||||
|
||||
activated, created_nodes = await store.activate_session(
|
||||
created["id"], "user-a", chain_id="chain-a", chain_snapshot=chain, nodes=nodes
|
||||
)
|
||||
assert activated["status"] == "active"
|
||||
assert activated["chain_snapshot"] == chain
|
||||
assert [node["status"] for node in created_nodes] == ["ready", "locked"]
|
||||
|
||||
# Repeated activate calls must return the first frozen snapshot/nodes unchanged.
|
||||
repeated, repeated_nodes = await store.activate_session(
|
||||
created["id"], "user-a", chain_id="other", chain_snapshot={"id": "other"}, nodes=[]
|
||||
)
|
||||
assert repeated["chain_id"] == "chain-a"
|
||||
assert [node["node_key"] for node in repeated_nodes] == [node["node_key"] for node in created_nodes]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_node_updates_are_user_scoped_and_keep_thread_out_of_messages(store):
|
||||
created = await store.create_session("user-a", external_task_id=None, task_snapshot={})
|
||||
_, nodes = await store.activate_session(
|
||||
created["id"],
|
||||
"user-a",
|
||||
chain_id="chain-a",
|
||||
chain_snapshot={"id": "chain-a"},
|
||||
nodes=[
|
||||
{
|
||||
"node_key": "stage-1-seat-1-agent-a",
|
||||
"stage_index": 0,
|
||||
"seat_index": 0,
|
||||
"agent_id": "agent-a",
|
||||
"position_id": "planning",
|
||||
"status": "ready",
|
||||
}
|
||||
],
|
||||
)
|
||||
assert len(nodes) == 1
|
||||
|
||||
updated = await store.update_node(
|
||||
created["id"],
|
||||
nodes[0]["node_key"],
|
||||
"user-a",
|
||||
thread_id="langgraph-thread-a",
|
||||
status="done",
|
||||
latest_answer="节点回答",
|
||||
artifact_manifest=[{"name": "产物.md"}],
|
||||
upstream_revision={"intent": 1},
|
||||
increment_revision=True,
|
||||
)
|
||||
assert updated is not None
|
||||
assert updated["thread_id"] == "langgraph-thread-a"
|
||||
assert updated["latest_answer"] == "节点回答"
|
||||
assert updated["artifact_manifest"] == [{"name": "产物.md"}]
|
||||
assert updated["revision"] == 1
|
||||
assert await store.get_node(created["id"], nodes[0]["node_key"], "user-b") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handoff_context_inlines_only_upstream_thread_text_artifacts(tmp_path, monkeypatch):
|
||||
artifact = tmp_path / "handoff.md"
|
||||
artifact.write_text("上游产物正文:可供下一岗位直接参考。", encoding="utf-8")
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
class StubStore:
|
||||
async def get_session(self, session_id, user_id):
|
||||
assert (session_id, user_id) == ("session-a", "user-a")
|
||||
return {"task_snapshot": {"title": "测试任务"}, "intent_snapshot": {"goal": "方案分析"}}
|
||||
|
||||
async def list_nodes(self, session_id, user_id):
|
||||
assert (session_id, user_id) == ("session-a", "user-a")
|
||||
return [
|
||||
{
|
||||
"node_key": "stage-1-seat-1-agent-a",
|
||||
"stage_index": 0,
|
||||
"seat_index": 0,
|
||||
"agent_id": "agent-a",
|
||||
"position_id": "planning",
|
||||
"thread_id": "upstream-thread",
|
||||
"status": "done",
|
||||
"revision": 1,
|
||||
"latest_answer": "上游问答结果",
|
||||
"artifact_manifest": [
|
||||
{
|
||||
"name": "handoff.md",
|
||||
"path": "mnt/user-data/outputs/handoff.md",
|
||||
"mime_type": "text/markdown",
|
||||
},
|
||||
{
|
||||
"name": "outside.md",
|
||||
"path": "mnt/user-data/uploads/outside.md",
|
||||
"mime_type": "text/markdown",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"node_key": "stage-2-seat-1-agent-b",
|
||||
"stage_index": 1,
|
||||
"seat_index": 0,
|
||||
"agent_id": "agent-b",
|
||||
"position_id": "solution",
|
||||
"thread_id": "current-thread",
|
||||
"status": "ready",
|
||||
"revision": 0,
|
||||
"latest_answer": None,
|
||||
"artifact_manifest": [],
|
||||
},
|
||||
]
|
||||
|
||||
async def resolve(thread_id: str, path: str):
|
||||
calls.append((thread_id, path))
|
||||
assert thread_id == "upstream-thread"
|
||||
assert path == "mnt/user-data/outputs/handoff.md"
|
||||
return artifact
|
||||
|
||||
monkeypatch.setattr(position_roundtable_router, "aresolve_thread_virtual_path", resolve)
|
||||
request = SimpleNamespace(
|
||||
app=SimpleNamespace(state=SimpleNamespace(position_roundtable_store=StubStore())),
|
||||
state=SimpleNamespace(user=SimpleNamespace(id="user-a")),
|
||||
)
|
||||
|
||||
context = await position_roundtable_router._handoff_context(
|
||||
request, "session-a", "stage-2-seat-1-agent-b"
|
||||
)
|
||||
|
||||
upstream = context["upstream_nodes"][0]
|
||||
assert upstream["latest_answer"] == "上游问答结果"
|
||||
assert upstream["artifacts"][0]["content"] == "上游产物正文:可供下一岗位直接参考。"
|
||||
assert "content" not in upstream["artifacts"][1]
|
||||
assert calls == [("upstream-thread", "mnt/user-data/outputs/handoff.md")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archived_session_rejects_node_writes_and_keeps_history_readable():
|
||||
class ArchivedStore:
|
||||
async def get_session(self, session_id, user_id):
|
||||
assert (session_id, user_id) == ("session-a", "user-a")
|
||||
return {"id": session_id, "status": "archived"}
|
||||
|
||||
async def get_node(self, session_id, node_key, user_id):
|
||||
return {
|
||||
"node_key": node_key,
|
||||
"status": "ready",
|
||||
"thread_id": None,
|
||||
}
|
||||
|
||||
request = SimpleNamespace(
|
||||
app=SimpleNamespace(state=SimpleNamespace(position_roundtable_store=ArchivedStore())),
|
||||
state=SimpleNamespace(user=SimpleNamespace(id="user-a")),
|
||||
)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await position_roundtable_router.bind_node_thread(
|
||||
request,
|
||||
"session-a",
|
||||
"stage-1-seat-1-agent-a",
|
||||
position_roundtable_router.NodeThreadRequest(thread_id="thread-a"),
|
||||
)
|
||||
assert error.value.status_code == 409
|
||||
assert "已归档" in str(error.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_locked_node_cannot_bind_a_thread_before_its_stage_is_ready():
|
||||
class LockedStore:
|
||||
async def get_session(self, session_id, user_id):
|
||||
return {"id": session_id, "status": "active"}
|
||||
|
||||
async def get_node(self, session_id, node_key, user_id):
|
||||
return {
|
||||
"node_key": node_key,
|
||||
"status": "locked",
|
||||
"thread_id": None,
|
||||
}
|
||||
|
||||
request = SimpleNamespace(
|
||||
app=SimpleNamespace(state=SimpleNamespace(position_roundtable_store=LockedStore())),
|
||||
state=SimpleNamespace(user=SimpleNamespace(id="user-a")),
|
||||
)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await position_roundtable_router.bind_node_thread(
|
||||
request,
|
||||
"session-a",
|
||||
"stage-2-seat-1-agent-b",
|
||||
position_roundtable_router.NodeThreadRequest(thread_id="thread-b"),
|
||||
)
|
||||
assert error.value.status_code == 409
|
||||
assert "未解锁" in str(error.value.detail)
|
||||
Reference in new issue
Block a user