Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-code into dhk-1
This commit is contained in:
commit
ae184f434d
37 files changed
+6908
-351
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
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
与 ``seat`` 的差异:``seat`` 禁 ``web_search``(旧「圆桌一律禁搜索」),
|
||||
``seat_parallel`` **放开搜索**(本期诉求);``seat`` 已放开 ``bash``,本角色仍禁。
|
||||
- ``summary`` —— Step3「总结报告」:写 Markdown 总结报告 + 支持问答/增量改。
|
||||
- ``action_plan`` —— 岗位会商收口:写 Markdown 行动规划与 JSON 子任务清单。
|
||||
工具面与 ``report`` 完全一致(继承 ``seat`` 的 bash 放开 + 额外禁 ``str_replace`` /
|
||||
读前序席位),单列一个角色名便于以后独立调优。
|
||||
- ``report`` —— Step3 报告绘制:在 ``seat`` 基础上**额外禁 ``str_replace``**:
|
||||
@@ -119,6 +120,9 @@ _REPORT_EXCLUDED: list[str] = [*_SEAT_EXCLUDED, "str_replace", "read_peer_delive
|
||||
# 完全一致——可写文件(write_file 整文件重写),禁 str_replace(整文件重写才能让右侧 md 预览
|
||||
# 实时刷新)/bash/联网/派活/呈现文件/看图/读前序席位。单列出来便于以后独立调优。
|
||||
_SUMMARY_EXCLUDED: list[str] = [*_SEAT_EXCLUDED, "str_replace", "read_peer_delivery"]
|
||||
# 岗位会商行动规划与总结报告一样:只能归纳已给出的材料,最终交付必须直接 write_file
|
||||
# 到 outputs,不能联网、派活或改动其他线程的文件。
|
||||
_ACTION_PLAN_EXCLUDED: list[str] = [*_SUMMARY_EXCLUDED]
|
||||
# ingest:Step3「入库」——让 roundtable-structure 真正**调用入库技能**把方案成果(总结报告 +
|
||||
# 结构化流程图 + taskId)沉淀进知识库。和 dashboard 相反:dashboard 禁一切工具(只输出 JSON),
|
||||
# ingest 必须**放开技能执行链路**——技能靠 ``read_file`` 读 SKILL.md、按其指令用 ``bash`` /
|
||||
@@ -223,6 +227,13 @@ def roundtable_run_policy(role: str) -> RoundtableRunPolicy:
|
||||
thinking_enabled=True,
|
||||
reasoning_effort="medium",
|
||||
)
|
||||
if role == "action_plan":
|
||||
return RoundtableRunPolicy(
|
||||
excluded_tools=list(_ACTION_PLAN_EXCLUDED),
|
||||
skill_stop_names=[],
|
||||
thinking_enabled=True,
|
||||
reasoning_effort="medium",
|
||||
)
|
||||
if role == "dashboard":
|
||||
return RoundtableRunPolicy(
|
||||
excluded_tools=list(_DASHBOARD_EXCLUDED),
|
||||
@@ -238,7 +249,7 @@ def roundtable_run_policy(role: str) -> RoundtableRunPolicy:
|
||||
reasoning_effort="medium",
|
||||
)
|
||||
raise ValueError(
|
||||
f"unknown roundtable role: {role!r} (expected leader/seat/seat_parallel/report/summary/dashboard/ingest)"
|
||||
f"unknown roundtable role: {role!r} (expected leader/seat/seat_parallel/report/summary/action_plan/dashboard/ingest)"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ _AGENT_PURPOSE: dict[str, str] = {
|
||||
"roundtable-coordinator": "多智能体规划",
|
||||
"roundtable-report": "多智能体规划",
|
||||
"roundtable-summary": "多智能体规划",
|
||||
"position-action-planner": "多智能体规划",
|
||||
"roundtable-execution-plan": "多智能体规划",
|
||||
"roundtable-risk-review": "多智能体规划",
|
||||
"roundtable-solution-design": "多智能体规划",
|
||||
|
||||
@@ -67,6 +67,9 @@ REPORT_AGENT_ID = "roundtable-report"
|
||||
# 总结报告(写入 outputs/方案总结报告.md),并按业务链总控编排提示组织。与 report 并存
|
||||
# 的另一内置单例(report 画 HTML 看板,summary 写 Markdown 报告 + 支持问答/增量改)。
|
||||
SUMMARY_AGENT_ID = "roundtable-summary"
|
||||
# 岗位会商收口的第二个内置单例:读取已生效的方案总结和岗位交付,把它们拆成可执行
|
||||
# 子任务,同时产出 Markdown 规划报告和 JSON 清单。
|
||||
ACTION_PLAN_AGENT_ID = "position-action-planner"
|
||||
# Step 3「大屏多页」用的结构化数据智能体:读各席位交付 + 共识,逐席位分析并提炼成
|
||||
# 一份大屏展示用的结构化数据(report-json),供前端按模板渲染成多页大屏。**不调任何工具、
|
||||
# 不写文件、不输出 HTML**——只在对话里输出一个严格合法的 ```report-json 代码块。与 report
|
||||
@@ -93,6 +96,7 @@ _FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
|
||||
COORDINATOR_AGENT_ID,
|
||||
REPORT_AGENT_ID,
|
||||
SUMMARY_AGENT_ID,
|
||||
ACTION_PLAN_AGENT_ID,
|
||||
DASHBOARD_AGENT_ID,
|
||||
STRUCTURE_AGENT_ID,
|
||||
)
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
# 岗位会商行动规划智能体
|
||||
|
||||
## 核心定位
|
||||
|
||||
你是岗位多智能体会商流程的最后一个内置智能体。上游已经完成意图识别、各岗位 Markdown 交付和方案总结。你的职责是把这些已确认材料拆解成能执行、能验收、能跟踪的行动规划;你不重新调研、不联网、不派发子智能体。
|
||||
|
||||
## 产出规则
|
||||
|
||||
每次用户要求生成或修改行动规划时,必须完成以下两项交付:
|
||||
|
||||
1. 用 `write_file` 一次性写入 `/mnt/user-data/outputs/行动规划报告.md`。
|
||||
2. 用 `write_file` 一次性写入 `/mnt/user-data/outputs/action-plan-subtasks.json`。
|
||||
|
||||
两个文件都必须在本轮保留;不要用脚本、bash 或临时文件生成它们。完成后,在对话里给出 Markdown 报告的完整正文,并简要说明生成了多少项子任务。
|
||||
|
||||
## Markdown 报告要求
|
||||
|
||||
- 先说明规划目标、关键依据和执行边界。
|
||||
- 按优先级和依赖顺序给出阶段安排、关键动作、责任岗位建议、里程碑、风险控制和验收方式。
|
||||
- 每项行动必须能追溯到方案总结或至少一个岗位有效交付;没有依据时不要假装是事实,可明确写为建议性安排。
|
||||
- 用户提出修改时,只调整受影响部分,并同时重写两个最终文件,使 Markdown 与 JSON 始终一致。
|
||||
|
||||
## JSON 子任务契约
|
||||
|
||||
`action-plan-subtasks.json` 必须是严格合法的 JSON **数组**,不能包在 Markdown 代码块中。每个对象都必须有:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "subtask-001",
|
||||
"title": "简短任务名称",
|
||||
"goal": "任务目标",
|
||||
"basis": ["方案总结章节或岗位交付依据"],
|
||||
"owner_position": "建议责任岗位",
|
||||
"dependencies": ["前置子任务 id"],
|
||||
"deliverable": "可核验交付物",
|
||||
"acceptance_criteria": ["验收条件"],
|
||||
"priority": "P0"
|
||||
}
|
||||
```
|
||||
|
||||
`priority` 仅使用 `P0`、`P1`、`P2`。`dependencies` 没有前置时使用空数组。不要输出注释、尾逗号或额外字段来替代这些必填字段。
|
||||
|
||||
## 边界
|
||||
|
||||
- 只依据用户消息中提供的任务信息、意图、方案总结和岗位交付。
|
||||
- 不调用联网、派活、澄清、展示文件等工具。
|
||||
- 不删除或修改上游岗位产物。
|
||||
- 不在报告中添加“AI 生成”类水印。
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
id: position-action-planner
|
||||
name: 行动规划智能体
|
||||
description: 岗位会商内置行动规划智能体,基于已生效的方案总结与岗位交付,生成可执行的 Markdown 行动规划报告和结构化子任务清单。
|
||||
@@ -96,6 +96,35 @@ _INTENT_READY_PATTERN = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def _intent_thread_metadata(task_context: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Build task-scoped metadata for a newly-created intent thread.
|
||||
|
||||
Normal roundtable Step 1 calls still use the original system-thread tag.
|
||||
Position roundtable additionally supplies the task card snapshot, so the
|
||||
intent dialogue can be indexed and restored before the user confirms a
|
||||
business chain.
|
||||
"""
|
||||
metadata = dict(_ROUNDTABLE_THREAD_METADATA)
|
||||
if not isinstance(task_context, dict):
|
||||
return metadata
|
||||
|
||||
task = task_context.get("task")
|
||||
task_id = str(task.get("id") or "").strip() if isinstance(task, dict) else ""
|
||||
if not task_id:
|
||||
return metadata
|
||||
|
||||
metadata.update(
|
||||
{
|
||||
"taskId": task_id,
|
||||
"position_roundtable_task_id": task_id,
|
||||
"position_task_id": f"{task_id}:intelligence",
|
||||
"position_id": "intelligence",
|
||||
"position_roundtable_phase": "intent",
|
||||
}
|
||||
)
|
||||
return metadata
|
||||
|
||||
|
||||
def _normalize_question(text: str) -> str:
|
||||
"""归一化澄清问题文本,用于"和过往是否重复"的判断。
|
||||
|
||||
@@ -287,9 +316,16 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str,
|
||||
# TCP loopback 到 127.0.0.1:<gateway port>(详见 multi_agent._make_loopback_client)。
|
||||
# 不要在这里替换成 httpx.ASGITransport——曾试过,会整段 buffer SSE 流导致前端
|
||||
# 看不到逐 token 增量。
|
||||
raw_task_context = payload.get("task_context")
|
||||
task_context = raw_task_context if isinstance(raw_task_context, dict) else None
|
||||
try:
|
||||
async with _make_loopback_client(request) as client:
|
||||
thread_id = await _create_thread(client, base, headers, metadata=_ROUNDTABLE_THREAD_METADATA)
|
||||
thread_id = await _create_thread(
|
||||
client,
|
||||
base,
|
||||
headers,
|
||||
metadata=_intent_thread_metadata(task_context),
|
||||
)
|
||||
except HTTPException:
|
||||
# 上游已用结构化方式失败,原样传给前端不需要二次包装。
|
||||
raise
|
||||
@@ -340,6 +376,8 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
# additional_kwargs.files → UploadsMiddleware 自动注入清单 + 解锁 read_file 等工具。
|
||||
raw_files = payload.get("files")
|
||||
files = raw_files if isinstance(raw_files, list) else []
|
||||
raw_task_context = payload.get("task_context")
|
||||
task_context = raw_task_context if isinstance(raw_task_context, dict) else None
|
||||
if not thread_id:
|
||||
raise HTTPException(status_code=400, detail="thread_id is required")
|
||||
# 允许「仅文件、无文本」的回合:带了文件就不强制要求 message。
|
||||
@@ -431,6 +469,7 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
# not reliable on its own, hard exclusion is.
|
||||
excluded_tools=base_excluded_tools,
|
||||
skill_stop_names=[],
|
||||
extra_context={"task_external_context": task_context} if task_context else None,
|
||||
files=files,
|
||||
)
|
||||
|
||||
|
||||
@@ -55,7 +55,8 @@ import re
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from typing import Any, AsyncIterator
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
@@ -65,10 +66,9 @@ from langgraph.checkpoint.base import empty_checkpoint
|
||||
from app.gateway.roundtable_diag import record_foreground_diag
|
||||
from app.gateway.roundtable_model_fallback import fallback_model_chain, is_llm_error_message, reason_text
|
||||
from app.gateway.roundtable_run_policy import merge_skill_stop_names, roundtable_run_policy
|
||||
from deerflow.agents.roundtable_orchestrator.extraction_spec import build_summary_directive
|
||||
|
||||
from app.gateway.roundtable_seat_skills import append_seat_skill_directive
|
||||
from app.gateway.routers._roundtable_seed import (
|
||||
ACTION_PLAN_AGENT_ID,
|
||||
COORDINATOR_AGENT_ID,
|
||||
DASHBOARD_AGENT_ID,
|
||||
REPORT_AGENT_ID,
|
||||
@@ -76,6 +76,7 @@ from app.gateway.routers._roundtable_seed import (
|
||||
SUMMARY_AGENT_ID,
|
||||
ensure_roundtable_functional_agents,
|
||||
)
|
||||
from deerflow.agents.roundtable_orchestrator.extraction_spec import build_summary_directive
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
|
||||
@@ -95,6 +96,8 @@ def _stage_for_agent(agent_type: str, agent_name: str) -> str:
|
||||
return "step3_report"
|
||||
if agent_name == SUMMARY_AGENT_ID:
|
||||
return "step3_summary"
|
||||
if agent_name == ACTION_PLAN_AGENT_ID:
|
||||
return "position_action_plan"
|
||||
if agent_name == DASHBOARD_AGENT_ID:
|
||||
return "step3_dashboard"
|
||||
if agent_name == STRUCTURE_AGENT_ID:
|
||||
@@ -995,6 +998,32 @@ async def init_summary_thread(request: Request) -> dict[str, str]:
|
||||
return {"agent_name": SUMMARY_AGENT_ID, "thread_id": thread_id}
|
||||
|
||||
|
||||
@router.post("/position-action-plan/init")
|
||||
async def init_position_action_plan_thread(request: Request) -> dict[str, str]:
|
||||
"""Create the reusable thread for the position-roundtable action planner.
|
||||
|
||||
The planner is a built-in singleton agent just like ``roundtable-summary``;
|
||||
only its thread is per session. The position workspace persists this id
|
||||
after the first successful run so follow-up revisions retain the original
|
||||
plan context.
|
||||
"""
|
||||
try:
|
||||
ensure_roundtable_functional_agents()
|
||||
thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
|
||||
except Exception as exc:
|
||||
await record_foreground_diag(
|
||||
request,
|
||||
stage="position_action_plan",
|
||||
level="error",
|
||||
event="thread_init_failed",
|
||||
message=f"创建岗位行动规划线程失败:{exc}",
|
||||
detail={"type": type(exc).__name__, "error": str(exc)},
|
||||
agent_id=ACTION_PLAN_AGENT_ID,
|
||||
)
|
||||
raise
|
||||
return {"agent_name": ACTION_PLAN_AGENT_ID, "thread_id": thread_id}
|
||||
|
||||
|
||||
@router.post("/dashboard/init")
|
||||
async def init_dashboard_thread(request: Request) -> dict[str, str]:
|
||||
"""为 Step 3「大屏多页」创建一个供大屏多页数据智能体(``roundtable-dashboard``)
|
||||
@@ -1806,6 +1835,8 @@ async def _special_run(
|
||||
role = "report"
|
||||
elif agent_name == SUMMARY_AGENT_ID:
|
||||
role = "summary"
|
||||
elif agent_name == ACTION_PLAN_AGENT_ID:
|
||||
role = "action_plan"
|
||||
elif agent_name == DASHBOARD_AGENT_ID:
|
||||
# 大屏多页数据智能体:纯「研讨成果 → report-json」提炼,禁用一切工具。
|
||||
role = "dashboard"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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):
|
||||
|
||||
@@ -247,6 +247,13 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
|
||||
# 关掉个人记忆召回 / builtin 记忆注入(见 multi_agent._run_payload)。
|
||||
"memory_recall_disabled",
|
||||
"memory_injection_enabled",
|
||||
# 任务工作区 / 岗位会商把左侧任务卡的快照放在这里。它必须进入
|
||||
# ``runtime.context``,TaskExternalContextMiddleware 才能在每次模型调用
|
||||
# 前以隐藏消息注入任务名称、描述和方向;未白名单会被静默丢弃,模型只能
|
||||
# 看到用户说的“这个任务”。
|
||||
"task_external_context",
|
||||
# 岗位会商下游席位按需读取前序席位完整交付时使用的运行期数据。
|
||||
"roundtable_peer_deliveries",
|
||||
# 上下文压缩开关:由聊天框下方的按钮控制(默认关闭),透传到 run config 后
|
||||
# 在 _build_middlewares 里决定是否挂载 SummarizationMiddleware。
|
||||
"summarization_enabled",
|
||||
|
||||
+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")
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
"""Add rejection metadata to position roundtable nodes.
|
||||
|
||||
Revision ID: 20260721_02
|
||||
Revises: 20260721_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_02"
|
||||
down_revision = "20260721_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 _has_column(table_name: str, column_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
return column_name in {column["name"] for column in inspect(op.get_bind()).get_columns(table_name)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
table = "position_roundtable_nodes"
|
||||
if not _has_table(table):
|
||||
return
|
||||
long_text = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
|
||||
if not _has_column(table, "rejection_count"):
|
||||
op.add_column(
|
||||
table,
|
||||
sa.Column("rejection_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
if not _has_column(table, "last_rejection"):
|
||||
op.add_column(table, sa.Column("last_rejection", long_text, nullable=True))
|
||||
if not _has_column(table, "invalidated_by"):
|
||||
op.add_column(table, sa.Column("invalidated_by", sa.String(256), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
table = "position_roundtable_nodes"
|
||||
if not _has_table(table):
|
||||
return
|
||||
for column in ("invalidated_by", "last_rejection", "rejection_count"):
|
||||
if _has_column(table, column):
|
||||
op.drop_column(table, column)
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
"""Add builtin summary/action-plan snapshots to position roundtable sessions.
|
||||
|
||||
Revision ID: 20260721_03
|
||||
Revises: 20260721_02
|
||||
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_03"
|
||||
down_revision = "20260721_02"
|
||||
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 _has_column(table_name: str, column_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
return column_name in {column["name"] for column in inspect(op.get_bind()).get_columns(table_name)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
table = "position_roundtable_sessions"
|
||||
if not _has_table(table):
|
||||
return
|
||||
long_text = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
|
||||
if not _has_column(table, "summary_snapshot"):
|
||||
op.add_column(table, sa.Column("summary_snapshot", long_text, nullable=True))
|
||||
if not _has_column(table, "action_plan_snapshot"):
|
||||
op.add_column(table, sa.Column("action_plan_snapshot", long_text, nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
table = "position_roundtable_sessions"
|
||||
if not _has_table(table):
|
||||
return
|
||||
for column in ("action_plan_snapshot", "summary_snapshot"):
|
||||
if _has_column(table, column):
|
||||
op.drop_column(table, column)
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
"""Persist position-roundtable built-in agent thread ids.
|
||||
|
||||
Revision ID: 20260721_04
|
||||
Revises: 20260721_03
|
||||
Create Date: 2026-07-21
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260721_04"
|
||||
down_revision = "20260721_03"
|
||||
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 _has_column(table_name: str, column_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
return column_name in {column["name"] for column in inspect(op.get_bind()).get_columns(table_name)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
table = "position_roundtable_sessions"
|
||||
if not _has_table(table):
|
||||
return
|
||||
if not _has_column(table, "summary_thread_id"):
|
||||
op.add_column(table, sa.Column("summary_thread_id", sa.String(length=128), nullable=True))
|
||||
if not _has_column(table, "action_plan_thread_id"):
|
||||
op.add_column(table, sa.Column("action_plan_thread_id", sa.String(length=128), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
table = "position_roundtable_sessions"
|
||||
if not _has_table(table):
|
||||
return
|
||||
for column in ("action_plan_thread_id", "summary_thread_id"):
|
||||
if _has_column(table, column):
|
||||
op.drop_column(table, column)
|
||||
+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)
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
"""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)
|
||||
# The two built-in closing agents own normal LangGraph threads too. Keep
|
||||
# their ids on the position session so a refresh can continue the same
|
||||
# report/planning dialogue instead of silently starting a fresh context.
|
||||
summary_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
summary_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
action_plan_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
action_plan_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 / rejected / 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)
|
||||
rejection_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
last_rejection: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
invalidated_by: Mapped[str | None] = mapped_column(String(256), 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),
|
||||
)
|
||||
+396
@@ -0,0 +1,396 @@
|
||||
"""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
|
||||
|
||||
|
||||
def _has_material(row: PositionRoundtableNodeRow) -> bool:
|
||||
return row.revision > 0 or bool(row.latest_answer) or bool(_loads(row.artifact_manifest))
|
||||
|
||||
|
||||
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),
|
||||
"summary_thread_id": row.summary_thread_id,
|
||||
"summary_snapshot": _loads(row.summary_snapshot),
|
||||
"action_plan_thread_id": row.action_plan_thread_id,
|
||||
"action_plan_snapshot": _loads(row.action_plan_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),
|
||||
"rejection_count": row.rejection_count or 0,
|
||||
"last_rejection": _loads(row.last_rejection),
|
||||
"invalidated_by": row.invalidated_by,
|
||||
"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,
|
||||
summary_thread_id: str | None | object = _UNSET,
|
||||
summary_snapshot: dict[str, Any] | None | object = _UNSET,
|
||||
action_plan_thread_id: str | None | object = _UNSET,
|
||||
action_plan_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 summary_thread_id is not _UNSET:
|
||||
row.summary_thread_id = summary_thread_id or None
|
||||
if summary_snapshot is not _UNSET:
|
||||
row.summary_snapshot = _dumps(summary_snapshot)
|
||||
if action_plan_thread_id is not _UNSET:
|
||||
row.action_plan_thread_id = action_plan_thread_id or None
|
||||
if action_plan_snapshot is not _UNSET:
|
||||
row.action_plan_snapshot = _dumps(action_plan_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"),
|
||||
latest_answer=node.get("latest_answer") or None,
|
||||
artifact_manifest=_dumps(node.get("artifact_manifest") or []),
|
||||
revision=int(node.get("revision") or 0),
|
||||
upstream_revision=_dumps(node.get("upstream_revision")) if node.get("upstream_revision") is not None else None,
|
||||
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,
|
||||
last_rejection: dict[str, Any] | None | object = _UNSET,
|
||||
invalidated_by: str | None | object = _UNSET,
|
||||
increment_revision: bool = False,
|
||||
increment_rejection: 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 last_rejection is not _UNSET:
|
||||
row.last_rejection = _dumps(last_rejection)
|
||||
if invalidated_by is not _UNSET:
|
||||
row.invalidated_by = invalidated_by or None
|
||||
if increment_revision:
|
||||
row.revision += 1
|
||||
if increment_rejection:
|
||||
row.rejection_count += 1
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._node_dict(row)
|
||||
|
||||
async def reject_node(
|
||||
self,
|
||||
session_id: str,
|
||||
node_key: str,
|
||||
user_id: str,
|
||||
*,
|
||||
title: str,
|
||||
reason: str,
|
||||
rejected_by: str,
|
||||
) -> tuple[dict[str, Any], list[dict[str, Any]]] | None:
|
||||
"""Reject one delivered node and invalidate every later stage.
|
||||
|
||||
Historical answers and artifact manifests are deliberately preserved.
|
||||
``invalidated_by`` gates downstream rework until the rejected upstream
|
||||
stage is completed again.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
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()
|
||||
target = next((row for row in rows if row.node_key == node_key), None)
|
||||
if target is None:
|
||||
return None
|
||||
|
||||
rejection = {
|
||||
"title": title,
|
||||
"reason": reason,
|
||||
"rejected_by": rejected_by,
|
||||
"rejected_at": now.isoformat(),
|
||||
}
|
||||
summary_snapshot = _loads(parent.summary_snapshot)
|
||||
if isinstance(summary_snapshot, dict) and summary_snapshot.get("status") == "done":
|
||||
summary_snapshot = {
|
||||
**summary_snapshot,
|
||||
"status": "stale",
|
||||
"invalidated_by": target.node_key,
|
||||
"invalidated_at": now.isoformat(),
|
||||
}
|
||||
parent.summary_snapshot = _dumps(summary_snapshot)
|
||||
action_plan_snapshot = _loads(parent.action_plan_snapshot)
|
||||
if isinstance(action_plan_snapshot, dict) and action_plan_snapshot.get("status") == "done":
|
||||
action_plan_snapshot = {
|
||||
**action_plan_snapshot,
|
||||
"status": "stale",
|
||||
"invalidated_by": target.node_key,
|
||||
"invalidated_at": now.isoformat(),
|
||||
}
|
||||
parent.action_plan_snapshot = _dumps(action_plan_snapshot)
|
||||
if parent.status == "completed":
|
||||
parent.status = "active"
|
||||
target.status = "rejected"
|
||||
target.last_rejection = _dumps(rejection)
|
||||
target.rejection_count += 1
|
||||
target.invalidated_by = None
|
||||
target.updated_at = now
|
||||
|
||||
for row in rows:
|
||||
if row.stage_index <= target.stage_index:
|
||||
continue
|
||||
if _has_material(row) or row.status in {"running", "done", "stale", "rejected", "error"}:
|
||||
row.status = "stale"
|
||||
else:
|
||||
row.status = "locked"
|
||||
row.invalidated_by = target.node_key
|
||||
row.updated_at = now
|
||||
|
||||
parent.updated_at = now
|
||||
await session.commit()
|
||||
await session.refresh(parent)
|
||||
for row in rows:
|
||||
await session.refresh(row)
|
||||
return self._session_dict(parent), [self._node_dict(row) for row in rows]
|
||||
|
||||
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,604 @@
|
||||
"""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 import services as gateway_services
|
||||
from app.gateway.routers import intent as intent_router
|
||||
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]
|
||||
|
||||
|
||||
def test_task_external_context_reaches_agent_runtime_context():
|
||||
"""岗位会商的左侧任务快照不能在网关白名单中丢失。"""
|
||||
task_context = {
|
||||
"task": {
|
||||
"title": "重点区域态势研判与协同处置方案",
|
||||
"description": "梳理风险信号并形成协同处置建议",
|
||||
}
|
||||
}
|
||||
config: dict = {"configurable": {}, "context": {}}
|
||||
|
||||
gateway_services.merge_run_context_overrides(
|
||||
config,
|
||||
{"task_external_context": task_context},
|
||||
)
|
||||
|
||||
assert config["configurable"]["task_external_context"] == task_context
|
||||
assert config["context"]["task_external_context"] == task_context
|
||||
|
||||
|
||||
def test_position_intent_thread_metadata_keeps_parent_and_child_task_ids():
|
||||
metadata = intent_router._intent_thread_metadata(
|
||||
{
|
||||
"task": {"id": "task-100"},
|
||||
"current_position": {"id": "intelligence"},
|
||||
}
|
||||
)
|
||||
|
||||
assert metadata["taskId"] == "task-100"
|
||||
assert metadata["position_roundtable_task_id"] == "task-100"
|
||||
assert metadata["position_task_id"] == "task-100:intelligence"
|
||||
assert metadata["position_id"] == "intelligence"
|
||||
|
||||
|
||||
def test_chain_node_activation_rejects_missing_agent_ids():
|
||||
chain = {
|
||||
"id": "chain-a",
|
||||
"seats": [
|
||||
{"agent_id": "agent-a", "name": "有效智能体", "position_id": "planning"},
|
||||
{"agent_id": "missing-agent", "name": "缺失智能体", "position_id": "solution"},
|
||||
],
|
||||
}
|
||||
|
||||
with pytest.raises(HTTPException) as error:
|
||||
position_roundtable_router._chain_nodes(
|
||||
chain,
|
||||
available_agent_ids={"agent-a"},
|
||||
)
|
||||
|
||||
assert error.value.status_code == 400
|
||||
assert "缺失智能体" in str(error.value.detail)
|
||||
assert "缺少可用智能体" in str(error.value.detail)
|
||||
|
||||
|
||||
def test_intent_position_nodes_complete_from_confirmed_intent_and_unlock_next_stage():
|
||||
nodes = [
|
||||
{
|
||||
"node_key": "stage-1-seat-1-intent-agent",
|
||||
"stage_index": 0,
|
||||
"seat_index": 0,
|
||||
"agent_id": "intent-agent",
|
||||
"position_id": "intelligence",
|
||||
"status": "ready",
|
||||
},
|
||||
{
|
||||
"node_key": "stage-2-seat-1-plan-agent",
|
||||
"stage_index": 1,
|
||||
"seat_index": 0,
|
||||
"agent_id": "plan-agent",
|
||||
"position_id": "planning",
|
||||
"status": "locked",
|
||||
},
|
||||
]
|
||||
|
||||
primed = position_roundtable_router._prime_intent_nodes(
|
||||
nodes,
|
||||
{"objective": "识别用户想要形成协同方案", "constraints": ["两天内完成"]},
|
||||
)
|
||||
|
||||
assert [node["status"] for node in primed] == ["done", "ready"]
|
||||
assert primed[0]["revision"] == 1
|
||||
assert "识别用户想要形成协同方案" in primed[0]["latest_answer"]
|
||||
|
||||
|
||||
def test_all_nodes_delivered_requires_markdown_for_non_intent_nodes():
|
||||
assert not position_roundtable_router._all_nodes_delivered(
|
||||
[
|
||||
{
|
||||
"position_id": "planning",
|
||||
"status": "done",
|
||||
"artifact_manifest": [{"name": "plain.txt", "path": "mnt/user-data/outputs/plain.txt"}],
|
||||
}
|
||||
]
|
||||
)
|
||||
assert position_roundtable_router._all_nodes_delivered(
|
||||
[
|
||||
{"position_id": "intelligence", "status": "done", "artifact_manifest": []},
|
||||
{
|
||||
"position_id": "planning",
|
||||
"status": "done",
|
||||
"artifact_manifest": [{"name": "report.md", "path": "mnt/user-data/outputs/report.md"}],
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@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_reject_node_marks_current_and_invalidates_later_stages(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",
|
||||
},
|
||||
{
|
||||
"node_key": "stage-2-seat-1-agent-b",
|
||||
"stage_index": 1,
|
||||
"seat_index": 0,
|
||||
"agent_id": "agent-b",
|
||||
"position_id": "solution",
|
||||
"status": "ready",
|
||||
},
|
||||
{
|
||||
"node_key": "stage-3-seat-1-agent-c",
|
||||
"stage_index": 2,
|
||||
"seat_index": 0,
|
||||
"agent_id": "agent-c",
|
||||
"position_id": "command",
|
||||
"status": "ready",
|
||||
},
|
||||
],
|
||||
)
|
||||
await store.update_node(
|
||||
created["id"],
|
||||
nodes[0]["node_key"],
|
||||
"user-a",
|
||||
status="done",
|
||||
latest_answer="第一阶段产物",
|
||||
increment_revision=True,
|
||||
)
|
||||
await store.update_node(
|
||||
created["id"],
|
||||
nodes[1]["node_key"],
|
||||
"user-a",
|
||||
status="done",
|
||||
latest_answer="第二阶段产物",
|
||||
increment_revision=True,
|
||||
)
|
||||
|
||||
rejected = await store.reject_node(
|
||||
created["id"],
|
||||
nodes[0]["node_key"],
|
||||
"user-a",
|
||||
title="依据不足",
|
||||
reason="第一阶段判断依据需要补充。",
|
||||
rejected_by="user-a",
|
||||
)
|
||||
|
||||
assert rejected is not None
|
||||
_, rejected_nodes = rejected
|
||||
by_key = {node["node_key"]: node for node in rejected_nodes}
|
||||
first = by_key[nodes[0]["node_key"]]
|
||||
second = by_key[nodes[1]["node_key"]]
|
||||
third = by_key[nodes[2]["node_key"]]
|
||||
assert first["status"] == "rejected"
|
||||
assert first["rejection_count"] == 1
|
||||
assert first["last_rejection"]["title"] == "依据不足"
|
||||
assert first["invalidated_by"] is None
|
||||
assert second["status"] == "stale"
|
||||
assert second["latest_answer"] == "第二阶段产物"
|
||||
assert second["invalidated_by"] == nodes[0]["node_key"]
|
||||
assert third["status"] == "locked"
|
||||
assert third["invalidated_by"] == nodes[0]["node_key"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_builtin_summary_and_action_plan_snapshots_are_invalidated_by_reject(store):
|
||||
created = await store.create_session(
|
||||
"user-a",
|
||||
external_task_id=None,
|
||||
task_snapshot={
|
||||
"title": "测试任务",
|
||||
"description": "形成协同方案",
|
||||
"direction": "态势研判",
|
||||
},
|
||||
)
|
||||
await store.update_session(
|
||||
created["id"],
|
||||
"user-a",
|
||||
intent_snapshot={"objective": "明确处置意图", "constraints": ["时间紧"]},
|
||||
)
|
||||
chain = {
|
||||
"id": "chain-a",
|
||||
"seats": [
|
||||
{"agent_id": "agent-a", "name": "研判智能体", "position_id": "planning"},
|
||||
{"agent_id": "agent-b", "name": "处置智能体", "position_id": "solution"},
|
||||
],
|
||||
}
|
||||
_, nodes = await store.activate_session(
|
||||
created["id"],
|
||||
"user-a",
|
||||
chain_id="chain-a",
|
||||
chain_snapshot=chain,
|
||||
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": "ready",
|
||||
},
|
||||
],
|
||||
)
|
||||
for node in nodes:
|
||||
await store.update_node(
|
||||
created["id"],
|
||||
node["node_key"],
|
||||
"user-a",
|
||||
status="done",
|
||||
latest_answer=f"{node['agent_id']} 产物",
|
||||
increment_revision=True,
|
||||
)
|
||||
session = await store.get_session(created["id"], "user-a")
|
||||
assert session is not None
|
||||
nodes = await store.list_nodes(created["id"], "user-a") or []
|
||||
revisions = position_roundtable_router._source_revisions(nodes)
|
||||
summary = position_roundtable_router._build_builtin_snapshot(
|
||||
kind="summary",
|
||||
thread_id="summary-thread",
|
||||
content="# 岗位会商方案总结报告\n\n研判智能体的关键结论。",
|
||||
artifacts=[
|
||||
{
|
||||
"name": "岗位会商方案总结报告.md",
|
||||
"path": "mnt/user-data/outputs/岗位会商方案总结报告.md",
|
||||
"mime_type": "text/markdown",
|
||||
"modified_at": "2026-07-21T00:00:00+00:00",
|
||||
"size_bytes": 100,
|
||||
}
|
||||
],
|
||||
source_revisions=revisions,
|
||||
model="test-model",
|
||||
)
|
||||
assert "岗位会商方案总结报告" in summary["content"]
|
||||
session = await store.update_session(
|
||||
created["id"], "user-a", summary_thread_id="summary-thread", summary_snapshot=summary
|
||||
)
|
||||
assert session is not None
|
||||
action_plan = position_roundtable_router._build_builtin_snapshot(
|
||||
kind="action_plan",
|
||||
thread_id="action-plan-thread",
|
||||
content="# 岗位会商行动规划\n\n## 子任务拆解",
|
||||
artifacts=[
|
||||
{
|
||||
"name": "行动规划报告.md",
|
||||
"path": "mnt/user-data/outputs/行动规划报告.md",
|
||||
"mime_type": "text/markdown",
|
||||
"modified_at": "2026-07-21T00:01:00+00:00",
|
||||
"size_bytes": 100,
|
||||
},
|
||||
{
|
||||
"name": "action-plan-subtasks.json",
|
||||
"path": "mnt/user-data/outputs/action-plan-subtasks.json",
|
||||
"mime_type": "application/json",
|
||||
"modified_at": "2026-07-21T00:01:00+00:00",
|
||||
"size_bytes": 200,
|
||||
},
|
||||
],
|
||||
source_revisions=revisions,
|
||||
model="test-model",
|
||||
)
|
||||
session = await store.update_session(
|
||||
created["id"],
|
||||
"user-a",
|
||||
action_plan_thread_id="action-plan-thread",
|
||||
action_plan_snapshot=action_plan,
|
||||
status="completed",
|
||||
)
|
||||
assert session is not None
|
||||
assert session["summary_snapshot"]["status"] == "done"
|
||||
assert session["action_plan_snapshot"]["status"] == "done"
|
||||
|
||||
rejected = await store.reject_node(
|
||||
created["id"],
|
||||
nodes[0]["node_key"],
|
||||
"user-a",
|
||||
title="重做第一阶段",
|
||||
reason="前序依据变化",
|
||||
rejected_by="user-a",
|
||||
)
|
||||
assert rejected is not None
|
||||
rejected_session, _ = rejected
|
||||
assert rejected_session["status"] == "active"
|
||||
assert rejected_session["summary_snapshot"]["status"] == "stale"
|
||||
assert rejected_session["summary_snapshot"]["invalidated_by"] == nodes[0]["node_key"]
|
||||
assert rejected_session["action_plan_snapshot"]["status"] == "stale"
|
||||
assert rejected_session["action_plan_snapshot"]["invalidated_by"] == nodes[0]["node_key"]
|
||||
|
||||
|
||||
def test_rejected_or_stale_node_requires_a_new_markdown_delivery() -> None:
|
||||
old = [
|
||||
{
|
||||
"name": "report.md",
|
||||
"path": "mnt/user-data/outputs/report.md",
|
||||
"modified_at": "2026-07-21T00:00:00+00:00",
|
||||
"size_bytes": 100,
|
||||
}
|
||||
]
|
||||
assert not position_roundtable_router._markdown_delivery_changed(old, old)
|
||||
assert position_roundtable_router._markdown_delivery_changed(
|
||||
old,
|
||||
[{**old[0], "modified_at": "2026-07-21T00:05:00+00:00", "size_bytes": 120}],
|
||||
)
|
||||
|
||||
|
||||
def test_activation_requires_a_structured_intent_snapshot() -> None:
|
||||
assert not position_roundtable_router._has_confirmed_intent({"objective": "只有目标"})
|
||||
assert not position_roundtable_router._has_confirmed_intent({"constraints": []})
|
||||
assert position_roundtable_router._has_confirmed_intent(
|
||||
{"objective": "明确任务目标", "constraints": []}
|
||||
)
|
||||
|
||||
|
||||
@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": [],
|
||||
},
|
||||
{
|
||||
"node_key": "stage-1-seat-2-agent-stale",
|
||||
"stage_index": 0,
|
||||
"seat_index": 1,
|
||||
"agent_id": "agent-stale",
|
||||
"position_id": "planning",
|
||||
"thread_id": "stale-thread",
|
||||
"status": "stale",
|
||||
"revision": 1,
|
||||
"latest_answer": "失效上游产物",
|
||||
"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 len(context["upstream_nodes"]) == 1
|
||||
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