岗位会商
This commit is contained in:
1 parent
1cd99a5a90
commit
1bc203b5cd
23 files changed
+2230
-401
No files matched your search
@@ -4,6 +4,29 @@
|
||||
>
|
||||
> 目标:在现有岗位会商基础上补齐“产物交付、查看、驳回、方案总结、行动规划”的完整闭环,同时继续保持原多智能体会商页面不受影响。
|
||||
|
||||
## 实施核对(2026-07-21)
|
||||
|
||||
本计划已按“岗位会商独立路由、无总控、各席位直接问答”的边界完成第一轮闭环。实际流程调整为:
|
||||
|
||||
```text
|
||||
任务信息 → 情报分析岗识别并确认意图 → 启动校验并冻结业务链
|
||||
→ 分阶段席位直接问答/交付 Markdown → 全部有效交付
|
||||
→ 方案总结智能体(roundtable-summary)→ 行动规划智能体(position-action-planner)
|
||||
→ 可随时返回交付产物驳回 → 后续阶段及内置产物级联失效 → 重做后重新收口
|
||||
```
|
||||
|
||||
- [x] 情报意图、业务链选择、席位岗位归属和席位智能体配置均在启动前校验;启动后保存业务链快照。
|
||||
- [x] 普通席位直接调用各自分配的智能体,并注入任务、意图及已完成上游席位的问答/产物;不经过总控智能体。
|
||||
- [x] 普通席位必须在 `outputs` 中交付 Markdown 才会完成;情报分析岗以确认的结构化意图完成为准。
|
||||
- [x] 节点、交付卡片、流程图和大图弹窗均展示 `locked / ready / running / done / rejected / stale / error` 状态,并可回看节点会话和产物。
|
||||
- [x] 驳回要求标题和理由;当前节点转为 `rejected`,后续阶段按依赖转为 `stale/locked`,总结和行动规划同时失效,历史内容保留可查看。
|
||||
- [x] 被驳回或失效的普通席位必须写入一份**新的或已改写** Markdown 才能再次完成;单纯继续问答且没有新交付不会误使下游产物失效。
|
||||
- [x] 方案总结复用 `roundtable-summary` 的真实流式线程;行动规划新增 `position-action-planner`,强制输出 Markdown 报告和 `action-plan-subtasks.json`。
|
||||
- [x] 两个内置智能体的线程、产物快照和来源节点版本栅栏均持久化;流式期间若上游版本变化,旧运行不能覆盖最新状态。
|
||||
- [x] 右侧“交付产物”可查看内置节点结果、继续同一线程问答/修改交付,并在打开 Markdown 沙箱时自动收起右栏。
|
||||
|
||||
设计收敛:当前版本以节点的 `artifact_manifest`、`latest_answer`、版本、驳回记录及 Session 内置快照作为唯一事实来源,已能覆盖统一产物卡片和级联逻辑,因此不新增重复的 `position_roundtable_deliverables` 表。若后续需要“单个文件独立驳回、跨会话检索或完整版本对比”,再将清单归一化为独立产物表,避免现在的双写一致性风险。
|
||||
|
||||
## 0. 核心结论
|
||||
|
||||
- 方案总结智能体建议复用现有会商里的 `roundtable-summary` 能力,但不要直接搬整套 Step 3 页面流程。
|
||||
@@ -75,32 +98,32 @@
|
||||
|
||||
### 3.1 Session 扩展
|
||||
|
||||
`position_roundtable_sessions` 建议增加:
|
||||
`position_roundtable_sessions` 已使用下列持久化字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| `workflow_status` | `intent_pending / active / summarizing / planning / completed / archived` |
|
||||
| `status` | `intent_pending / active / completed / archived`;总结/规划运行态由前端瞬时状态与对应快照的 `status` 表示,避免一次运行中断后留下伪运行态 |
|
||||
| `summary_thread_id` | 方案总结智能体线程 |
|
||||
| `summary_snapshot` | 最新有效总结报告快照 |
|
||||
| `action_plan_thread_id` | 行动规划智能体线程 |
|
||||
| `action_plan_snapshot` | 最新有效行动规划快照 |
|
||||
| `invalidated_at` | 最近一次级联失效时间,可选 |
|
||||
| `invalidated_at` | 记录在 `summary_snapshot` / `action_plan_snapshot` 内,包含 `invalidated_by`,无需重复写 Session 字段 |
|
||||
|
||||
### 3.2 Node 扩展
|
||||
|
||||
`position_roundtable_nodes` 建议增加:
|
||||
`position_roundtable_nodes` 已使用下列字段(展示状态由前端按状态映射):
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| `display_status` | 面向前端的聚合状态,可后端计算也可前端映射 |
|
||||
| `status` | `locked / ready / running / done / stale / rejected / error`,前端直接映射为展示状态 |
|
||||
| `rejection_count` | 当前节点累计驳回次数 |
|
||||
| `last_rejection` | 最新驳回标题、理由、操作者、时间 |
|
||||
| `valid_revision` | 当前有效交付版本 |
|
||||
| `revision` | 当前有效交付版本 |
|
||||
| `invalidated_by` | 导致当前节点失效的上游节点 key |
|
||||
|
||||
### 3.3 Deliverable 新表
|
||||
### 3.3 Deliverable 新表(后续可选)
|
||||
|
||||
建议新增 `position_roundtable_deliverables`,不要只依赖 `artifact_manifest`。
|
||||
当需要“单个文件独立驳回、跨会话检索或完整版本对比”时,再新增 `position_roundtable_deliverables`,不要只依赖 `artifact_manifest`。
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
@@ -121,7 +144,7 @@
|
||||
| `created_at` | 创建时间 |
|
||||
| `updated_at` | 更新时间 |
|
||||
|
||||
这样右侧产物卡片、驳回记录、总结输入材料都能从同一张表聚合。
|
||||
当前第一轮不新增这张表:右侧产物卡片、驳回记录、总结输入材料从节点 `artifact_manifest`、节点版本/驳回记录和 Session 快照聚合,避免节点表与产物表双写。后续引入该表时,应把它提升为唯一事实来源,而不是并行维护两套状态。
|
||||
|
||||
## 4. 驳回与级联失效规则
|
||||
|
||||
@@ -173,25 +196,27 @@
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/position-roundtable/sessions/{id}/deliverables` | 获取右侧产物列表 |
|
||||
| `GET` | `/api/position-roundtable/deliverables/{deliverable_id}` | 获取产物详情 |
|
||||
| `POST` | `/api/position-roundtable/deliverables/{deliverable_id}/reject` | 驳回产物并触发级联失效 |
|
||||
| `POST` | `/api/position-roundtable/nodes/{node_id}/complete-turn` | 节点回答结束后同步回答与产物 |
|
||||
| `GET` | `/api/position-roundtable/sessions/{id}/results` | 获取右侧统一产物来源节点(前端聚合文件与内置快照) |
|
||||
| `GET` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}` | 获取节点详情;文件正文由既有线程产物预览接口读取 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}/reject` | 驳回当前节点交付并触发级联失效 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}/complete-turn` | 节点回答结束后同步回答与产物 |
|
||||
|
||||
### 5.3 方案总结与行动规划
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/summary/start` | 启动或重启方案总结 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/summary/ask` | 对方案总结继续提问或修改 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/start` | 基于总结和产物生成行动规划 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/ask` | 对行动规划继续提问或修改 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/summary/prepare` | 服务端准备版本锁定的总结材料和线程信息 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/summary/ask` | 服务端准备总结的同线程续问材料 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/summary/complete` | 校验 Markdown 交付与来源版本后持久化总结快照 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/prepare` | 服务端准备行动规划材料和线程信息 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/ask` | 服务端准备行动规划同线程续问材料 |
|
||||
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/complete` | 校验 Markdown + JSON 交付与来源版本后持久化行动规划快照 |
|
||||
|
||||
可以复用现有 `streamMultiAgent` 流式协议,但建议在岗位会商下包一层 API,由后端负责拼装岗位会商材料,前端不直接拼长 prompt。
|
||||
|
||||
## 6. 前端实施步骤
|
||||
|
||||
### 阶段 1:状态与启动校验
|
||||
### 阶段 1:状态与启动校验(已完成)
|
||||
|
||||
- 扩展 `PositionNodeStatus` 和状态显示映射。
|
||||
- “确认意图并启动业务链”前补齐校验提示。
|
||||
@@ -201,7 +226,7 @@
|
||||
|
||||
验收标准:未选择业务链、岗位未配齐、意图未完成时都能准确阻止启动;启动后流程图能显示各节点状态。
|
||||
|
||||
### 阶段 2:流程图弹窗
|
||||
### 阶段 2:流程图弹窗(已完成)
|
||||
|
||||
- 左侧保留小型从上到下流程图。
|
||||
- 流程图右上角增加“查看”按钮。
|
||||
@@ -210,7 +235,7 @@
|
||||
|
||||
验收标准:小图不挤占左侧空间,大图能完整展示业务链和两个内置收口节点。
|
||||
|
||||
### 阶段 3:产物卡片与预览
|
||||
### 阶段 3:产物卡片与预览(已完成)
|
||||
|
||||
- 右侧新增或改造“分析结果”Tab 为“交付产物”列表。
|
||||
- 卡片展示智能体名称、产物名称、岗位名称、时间、状态。
|
||||
@@ -220,7 +245,7 @@
|
||||
|
||||
验收标准:节点回答和文件产物能进入统一产物列表;点击可查看详情。
|
||||
|
||||
### 阶段 4:驳回弹窗与级联失效
|
||||
### 阶段 4:驳回弹窗与级联失效(已完成)
|
||||
|
||||
- 新增 `RejectDeliverableDialog`。
|
||||
- 驳回表单包含标题和理由,均必填。
|
||||
@@ -230,7 +255,7 @@
|
||||
|
||||
验收标准:驳回第一阶段任意节点后,第二阶段及之后产物都显示未完成或已失效;旧内容可查看但不能作为总结输入。
|
||||
|
||||
### 阶段 5:方案总结智能体
|
||||
### 阶段 5:方案总结智能体(已完成)
|
||||
|
||||
- 封装 `usePositionSummary`,内部复用 `roundtable-summary` 的线程初始化、流式输出和 Markdown 产物预览能力。
|
||||
- 输入材料改为岗位会商材料:
|
||||
@@ -245,7 +270,7 @@
|
||||
|
||||
验收标准:总结报告使用现有方案总结能力生成 Markdown;刷新页面后可恢复;上游驳回后总结标记失效。
|
||||
|
||||
### 阶段 6:行动规划智能体
|
||||
### 阶段 6:行动规划智能体(已完成)
|
||||
|
||||
- 新增内置智能体 ID,建议先命名为 `position-action-planner`。
|
||||
- 新增行动规划 prompt:
|
||||
@@ -261,7 +286,7 @@
|
||||
|
||||
验收标准:行动规划能生成可读报告和结构化子任务;刷新可恢复;上游驳回后自动失效。
|
||||
|
||||
### 阶段 7:返回修改与重新生成
|
||||
### 阶段 7:返回修改与重新生成(已完成)
|
||||
|
||||
- 用户在方案总结或行动规划视图中仍可切回“交付产物”。
|
||||
- 驳回后自动显示需要重做的节点。
|
||||
@@ -270,7 +295,7 @@
|
||||
|
||||
验收标准:不会因为进入总结/规划阶段而锁死前序产物;前序重做后能形成新版本闭环。
|
||||
|
||||
### 阶段 8:测试与回归
|
||||
### 阶段 8:测试与回归(已完成)
|
||||
|
||||
- 后端测试:
|
||||
- activate 校验。
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
|
||||
|
||||
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
@@ -184,6 +184,18 @@ export function WorkspaceNavChatList({
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/roundtable/position")}
|
||||
asChild
|
||||
className={NAV_ITEM_CLASS}
|
||||
>
|
||||
<Link href="/page/workspace/roundtable/position">
|
||||
<BriefcaseBusiness />
|
||||
<span>岗位会商</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
||||
|
||||
@@ -54,6 +54,7 @@ import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
|
||||
import StudioNotebooksPage from './StudioNotebooksPage';
|
||||
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
|
||||
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
|
||||
import PositionRoundtablePage from '@/position-roundtable/pages/PositionRoundtablePage';
|
||||
import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEditorPage';
|
||||
import BusinessChainListPage from '@/roundtable-planning/pages/BusinessChainListPage';
|
||||
import DispatchChainPreviewPage from '@/roundtable-planning/pages/DispatchChainPreviewPage';
|
||||
@@ -433,6 +434,16 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="roundtable/position"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<PositionRoundtablePage compactChrome={headerHidden} />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="roundtable/chains"
|
||||
element={
|
||||
|
||||
@@ -45,7 +45,9 @@ export interface PositionRoundtableSession {
|
||||
chainSnapshot: Record<string, unknown> | null;
|
||||
intentThreadId: string | null;
|
||||
intentSnapshot: Record<string, unknown> | null;
|
||||
summaryThreadId: string | null;
|
||||
summarySnapshot: Record<string, unknown> | null;
|
||||
actionPlanThreadId: string | null;
|
||||
actionPlanSnapshot: Record<string, unknown> | null;
|
||||
status: "intent_pending" | "active" | "completed" | "archived";
|
||||
createdAt?: string;
|
||||
@@ -82,7 +84,9 @@ interface ServerSession {
|
||||
chain_snapshot?: Record<string, unknown> | null;
|
||||
intent_thread_id: string | null;
|
||||
intent_snapshot?: Record<string, unknown> | null;
|
||||
summary_thread_id?: string | null;
|
||||
summary_snapshot?: Record<string, unknown> | null;
|
||||
action_plan_thread_id?: string | null;
|
||||
action_plan_snapshot?: Record<string, unknown> | null;
|
||||
status: PositionRoundtableSession["status"];
|
||||
created_at?: string;
|
||||
@@ -132,7 +136,9 @@ function toSession(session: ServerSession): PositionRoundtableSession {
|
||||
chainSnapshot: session.chain_snapshot ?? null,
|
||||
intentThreadId: session.intent_thread_id,
|
||||
intentSnapshot: session.intent_snapshot ?? null,
|
||||
summaryThreadId: session.summary_thread_id ?? null,
|
||||
summarySnapshot: session.summary_snapshot ?? null,
|
||||
actionPlanThreadId: session.action_plan_thread_id ?? null,
|
||||
actionPlanSnapshot: session.action_plan_snapshot ?? null,
|
||||
status: session.status,
|
||||
createdAt: session.created_at,
|
||||
@@ -301,22 +307,104 @@ export async function rejectPositionNode(
|
||||
return toSession(result);
|
||||
}
|
||||
|
||||
export async function generatePositionSummary(sessionId: string): Promise<PositionRoundtableSession> {
|
||||
const result = await readJson<ServerSession>(
|
||||
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/summary/start`, {
|
||||
export interface PositionBuiltinRunPreparation {
|
||||
agentId: string;
|
||||
displayName: string;
|
||||
threadId: string | null;
|
||||
prompt: string;
|
||||
sourceRevisions: Record<string, number>;
|
||||
}
|
||||
|
||||
interface ServerBuiltinRunPreparation {
|
||||
agent_id: string;
|
||||
display_name: string;
|
||||
thread_id?: string | null;
|
||||
prompt: string;
|
||||
source_revisions?: Record<string, number>;
|
||||
}
|
||||
|
||||
function toBuiltinRunPreparation(value: ServerBuiltinRunPreparation): PositionBuiltinRunPreparation {
|
||||
return {
|
||||
agentId: value.agent_id,
|
||||
displayName: value.display_name,
|
||||
threadId: value.thread_id ?? null,
|
||||
prompt: value.prompt,
|
||||
sourceRevisions: value.source_revisions ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
type BuiltinKind = "summary" | "action_plan";
|
||||
|
||||
async function prepareBuiltinRun(
|
||||
sessionId: string,
|
||||
kind: BuiltinKind,
|
||||
message = "",
|
||||
): Promise<PositionBuiltinRunPreparation> {
|
||||
const path = kind === "summary" ? "summary/prepare" : "action-plan/prepare";
|
||||
const result = await readJson<ServerBuiltinRunPreparation>(
|
||||
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message }),
|
||||
}),
|
||||
"generate position summary",
|
||||
`prepare position ${kind}`,
|
||||
);
|
||||
return toBuiltinRunPreparation(result);
|
||||
}
|
||||
|
||||
async function completeBuiltinRun(
|
||||
sessionId: string,
|
||||
kind: BuiltinKind,
|
||||
input: {
|
||||
threadId: string;
|
||||
content: string;
|
||||
artifactManifest: Array<Record<string, unknown>>;
|
||||
sourceRevisions: Record<string, number>;
|
||||
model?: string;
|
||||
},
|
||||
): Promise<PositionRoundtableSession> {
|
||||
const path = kind === "summary" ? "summary/complete" : "action-plan/complete";
|
||||
const result = await readJson<ServerSession>(
|
||||
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
thread_id: input.threadId,
|
||||
content: input.content,
|
||||
artifact_manifest: input.artifactManifest,
|
||||
source_revisions: input.sourceRevisions,
|
||||
model: input.model || null,
|
||||
}),
|
||||
}),
|
||||
`complete position ${kind}`,
|
||||
);
|
||||
return toSession(result);
|
||||
}
|
||||
|
||||
export async function generatePositionActionPlan(sessionId: string): Promise<PositionRoundtableSession> {
|
||||
const result = await readJson<ServerSession>(
|
||||
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/action-plan/start`, {
|
||||
method: "POST",
|
||||
}),
|
||||
"generate position action plan",
|
||||
);
|
||||
return toSession(result);
|
||||
export function preparePositionSummary(
|
||||
sessionId: string,
|
||||
message = "",
|
||||
): Promise<PositionBuiltinRunPreparation> {
|
||||
return prepareBuiltinRun(sessionId, "summary", message);
|
||||
}
|
||||
|
||||
export function completePositionSummary(
|
||||
sessionId: string,
|
||||
input: Parameters<typeof completeBuiltinRun>[2],
|
||||
): Promise<PositionRoundtableSession> {
|
||||
return completeBuiltinRun(sessionId, "summary", input);
|
||||
}
|
||||
|
||||
export function preparePositionActionPlan(
|
||||
sessionId: string,
|
||||
message = "",
|
||||
): Promise<PositionBuiltinRunPreparation> {
|
||||
return prepareBuiltinRun(sessionId, "action_plan", message);
|
||||
}
|
||||
|
||||
export function completePositionActionPlan(
|
||||
sessionId: string,
|
||||
input: Parameters<typeof completeBuiltinRun>[2],
|
||||
): Promise<PositionRoundtableSession> {
|
||||
return completeBuiltinRun(sessionId, "action_plan", input);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Bot, FileText, LockKeyhole, Loader2 } from "lucide-react";
|
||||
import { Bot, FileText, LockKeyhole } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { useArtifactsOptional } from "@/components/workspace/artifacts";
|
||||
import { useAgent } from "@/core/agents";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
@@ -17,15 +18,26 @@ import {
|
||||
type PositionRoundtableNode,
|
||||
} from "@/position-roundtable/api/sessions";
|
||||
import { listSessionArtifacts } from "@/roundtable-planning/api/multi-agent";
|
||||
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
interface PositionNodeChatProps {
|
||||
sessionId: string;
|
||||
/** Parent task id: all node threads are indexed under this task. */
|
||||
taskId: string;
|
||||
chainId: string | null;
|
||||
node: PositionRoundtableNode;
|
||||
currentPositionId: string;
|
||||
sessionStatus: "intent_pending" | "active" | "completed" | "archived";
|
||||
agentName?: string | null;
|
||||
onNodeUpdate: (node: PositionRoundtableNode) => void;
|
||||
/** The workspace collapses the configuration panel on the first real turn. */
|
||||
onConversationStart?: () => void;
|
||||
onPreviewArtifact?: (artifact: {
|
||||
threadId: string;
|
||||
path: string;
|
||||
name: string;
|
||||
mimeType: string | null;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
interface NodeArtifact {
|
||||
@@ -52,6 +64,26 @@ function readNodeArtifacts(manifest: Array<Record<string, unknown>>): NodeArtifa
|
||||
});
|
||||
}
|
||||
|
||||
function hasMarkdownArtifact(manifest: Array<Record<string, unknown>>): boolean {
|
||||
return manifest.some((artifact) => {
|
||||
const path = typeof artifact.path === "string" ? artifact.path.toLowerCase() : "";
|
||||
const name = typeof artifact.name === "string" ? artifact.name.toLowerCase() : "";
|
||||
const mime =
|
||||
typeof artifact.mime_type === "string"
|
||||
? artifact.mime_type.toLowerCase()
|
||||
: typeof artifact.mimeType === "string"
|
||||
? artifact.mimeType.toLowerCase()
|
||||
: "";
|
||||
return (
|
||||
mime.includes("markdown") ||
|
||||
path.endsWith(".md") ||
|
||||
path.endsWith(".markdown") ||
|
||||
name.endsWith(".md") ||
|
||||
name.endsWith(".markdown")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct single-agent chat for one frozen business-chain node.
|
||||
*
|
||||
@@ -61,18 +93,24 @@ function readNodeArtifacts(manifest: Array<Record<string, unknown>>): NodeArtifa
|
||||
*/
|
||||
export function PositionNodeChat({
|
||||
sessionId,
|
||||
taskId,
|
||||
chainId,
|
||||
node,
|
||||
currentPositionId,
|
||||
sessionStatus,
|
||||
agentName,
|
||||
onNodeUpdate,
|
||||
onConversationStart,
|
||||
onPreviewArtifact,
|
||||
}: PositionNodeChatProps) {
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
const { agent } = useAgent(node.agentId);
|
||||
const agentDisplayName = desensitize(agentName || agent?.name || node.agentId);
|
||||
const artifactsCtx = useArtifactsOptional();
|
||||
const [boundThreadId, setBoundThreadId] = useState<string | null>(node.threadId);
|
||||
const boundThreadIdRef = useRef<string | null>(node.threadId);
|
||||
const draftThreadIdRef = useRef(node.threadId ?? uuid());
|
||||
const upstreamRevisionRef = useRef<Record<string, unknown> | null>(null);
|
||||
const [previewArtifact, setPreviewArtifact] = useState<NodeArtifact | null>(null);
|
||||
const nodeArtifacts = useMemo(() => readNodeArtifacts(node.artifactManifest), [node.artifactManifest]);
|
||||
const canWrite =
|
||||
sessionStatus !== "archived" &&
|
||||
@@ -81,6 +119,19 @@ export function PositionNodeChat({
|
||||
node.status !== "error" &&
|
||||
!node.invalidatedBy;
|
||||
|
||||
useEffect(() => {
|
||||
if (artifactsCtx?.open && artifactsCtx.selectedArtifact) {
|
||||
onPreviewArtifact?.({
|
||||
threadId: boundThreadIdRef.current ?? boundThreadId ?? draftThreadIdRef.current,
|
||||
path: artifactsCtx.selectedArtifact,
|
||||
name: artifactsCtx.selectedArtifact.split("/").filter(Boolean).at(-1) || "Markdown 报告",
|
||||
mimeType: artifactsCtx.selectedArtifact.toLowerCase().endsWith(".md")
|
||||
? "text/markdown"
|
||||
: null,
|
||||
});
|
||||
}
|
||||
}, [artifactsCtx?.open, artifactsCtx?.selectedArtifact, boundThreadId, onPreviewArtifact]);
|
||||
|
||||
useEffect(() => {
|
||||
setBoundThreadId(node.threadId);
|
||||
boundThreadIdRef.current = node.threadId;
|
||||
@@ -99,14 +150,21 @@ export function PositionNodeChat({
|
||||
[node.nodeKey, onNodeUpdate, sessionId],
|
||||
);
|
||||
|
||||
const [thread, sendMessage] = useThreadStream({
|
||||
const [thread, sendMessage, , actualThreadId] = useThreadStream({
|
||||
threadId: boundThreadId,
|
||||
assistantId: node.agentId,
|
||||
context: { ...settings.context, agent_id: node.agentId },
|
||||
threadMetadata: {
|
||||
thread_type: "position_roundtable",
|
||||
// ``taskId`` is the shared task-workspace convention. The two
|
||||
// position-specific identifiers retain the exact child node for audits
|
||||
// and restore, even when one position owns more than one chain seat.
|
||||
taskId,
|
||||
position_roundtable_task_id: taskId,
|
||||
position_task_id: `${taskId}:${node.positionId ?? "unassigned"}:${node.nodeKey}`,
|
||||
position_session_id: sessionId,
|
||||
position_node_id: node.id,
|
||||
position_node_key: node.nodeKey,
|
||||
position_id: node.positionId,
|
||||
chain_id: chainId,
|
||||
agent_id: node.agentId,
|
||||
@@ -128,6 +186,7 @@ export function PositionNodeChat({
|
||||
path: file.path,
|
||||
mime_type: file.mimeType,
|
||||
modified_at: file.modifiedAt,
|
||||
size_bytes: file.sizeBytes,
|
||||
}));
|
||||
} catch {
|
||||
// Artifact listing is best-effort: a valid text answer must still be
|
||||
@@ -140,6 +199,9 @@ export function PositionNodeChat({
|
||||
upstreamRevision: upstreamRevisionRef.current,
|
||||
});
|
||||
onNodeUpdate(updated);
|
||||
if (updated.status !== "done" && !hasMarkdownArtifact(artifactManifest)) {
|
||||
toast.info("已保存本轮回答;该席位需生成并交付一份 Markdown 报告文档后才会标记完成。");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`节点结果保存失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
@@ -147,6 +209,14 @@ export function PositionNodeChat({
|
||||
},
|
||||
});
|
||||
|
||||
// SDK thread creation is asynchronous. ``onStart`` normally binds it, but
|
||||
// watching the actual stream id as well closes the last race where a page
|
||||
// refresh could happen between creation and the async node-index write.
|
||||
useEffect(() => {
|
||||
if (!actualThreadId || actualThreadId === boundThreadIdRef.current) return;
|
||||
bindCreatedThread(actualThreadId);
|
||||
}, [actualThreadId, bindCreatedThread]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
if (!canWrite) {
|
||||
@@ -162,6 +232,7 @@ export function PositionNodeChat({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
onConversationStart?.();
|
||||
const taskExternalContext = await getPositionNodeContext(sessionId, node.nodeKey);
|
||||
const upstreamRevision = taskExternalContext.upstream_revision;
|
||||
upstreamRevisionRef.current =
|
||||
@@ -176,7 +247,16 @@ export function PositionNodeChat({
|
||||
toast.error(`发送失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
},
|
||||
[canWrite, node.agentId, node.nodeKey, node.status, sendMessage, sessionId, sessionStatus],
|
||||
[
|
||||
canWrite,
|
||||
node.agentId,
|
||||
node.nodeKey,
|
||||
node.status,
|
||||
onConversationStart,
|
||||
sendMessage,
|
||||
sessionId,
|
||||
sessionStatus,
|
||||
],
|
||||
);
|
||||
|
||||
const status = thread.error ? "error" : thread.isLoading ? "streaming" : "ready";
|
||||
@@ -201,13 +281,12 @@ export function PositionNodeChat({
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate text-sm font-extrabold text-slate-900 dark:text-[#e8edf5]">
|
||||
{node.agentId}
|
||||
{agentDisplayName}
|
||||
</h2>
|
||||
<p className="truncate text-[10px] text-muted-foreground dark:text-[#8aa0c4]">
|
||||
{readOnlyHint || `节点状态:${node.status}`}
|
||||
</p>
|
||||
</div>
|
||||
{thread.isLoading && <Loader2 className="size-4 animate-spin text-primary" />}
|
||||
</header>
|
||||
|
||||
{nodeArtifacts.length > 0 && (
|
||||
@@ -225,7 +304,16 @@ export function PositionNodeChat({
|
||||
key={artifact.path}
|
||||
type="button"
|
||||
title={`预览 ${artifact.name}`}
|
||||
onClick={() => setPreviewArtifact(artifact)}
|
||||
onClick={() => {
|
||||
if (boundThreadId && onPreviewArtifact) {
|
||||
onPreviewArtifact({
|
||||
threadId: boundThreadId,
|
||||
path: artifact.path,
|
||||
name: artifact.name,
|
||||
mimeType: artifact.mimeType,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-left text-[11px] text-slate-600 transition hover:border-primary/45 hover:text-primary dark:border-[#2b8ffe]/35 dark:bg-[#0a315d]/80 dark:text-[#c5cfe0]"
|
||||
>
|
||||
<FileText className="size-3 shrink-0" />
|
||||
@@ -242,6 +330,7 @@ export function PositionNodeChat({
|
||||
threadId={boundThreadId}
|
||||
thread={thread}
|
||||
paddingBottom={24}
|
||||
scrollButtonClassName="bottom-6"
|
||||
onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
|
||||
/>
|
||||
) : (
|
||||
@@ -275,15 +364,6 @@ export function PositionNodeChat({
|
||||
onStop={() => void thread.stop()}
|
||||
/>
|
||||
</div>
|
||||
{previewArtifact && boundThreadId && (
|
||||
<ArtifactPreviewModal
|
||||
threadId={boundThreadId}
|
||||
path={previewArtifact.path}
|
||||
name={previewArtifact.name}
|
||||
mimeType={previewArtifact.mimeType}
|
||||
onClose={() => setPreviewArtifact(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -97,11 +97,16 @@ function parseIntentErrorDetail(value: unknown): IntentErrorDetail | null {
|
||||
* `detail.trace_tail` to help diagnose intranet deployment issues without
|
||||
* shell access to the server.
|
||||
*/
|
||||
export async function initIntent(model?: string): Promise<IntentInitResponse> {
|
||||
export async function initIntent(
|
||||
model?: string,
|
||||
taskContext?: Record<string, unknown> | null,
|
||||
): Promise<IntentInitResponse> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model }),
|
||||
// 岗位会商在创建意图线程时就写入父任务标识。这样即使用户尚未确认
|
||||
// 业务链,第一阶段的对话也已经可以按 taskId 恢复。
|
||||
body: JSON.stringify({ model, task_context: taskContext ?? undefined }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text().catch(() => "");
|
||||
@@ -159,6 +164,8 @@ export interface StreamIntentRequest {
|
||||
threadId: string;
|
||||
message: string;
|
||||
model?: string;
|
||||
/** Optional hidden business/task context injected into the model run. */
|
||||
taskContext?: Record<string, unknown> | null;
|
||||
/** Step 1 用户上传的参考文件(可选)。 */
|
||||
files?: IntentFileRef[];
|
||||
signal?: AbortSignal;
|
||||
@@ -333,6 +340,7 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
||||
thread_id: req.threadId,
|
||||
message: req.message,
|
||||
model: req.model,
|
||||
task_context: req.taskContext ?? undefined,
|
||||
// 仅在有文件时带上,空数组后端也安全忽略。
|
||||
files: req.files && req.files.length > 0 ? req.files : undefined,
|
||||
}),
|
||||
|
||||
@@ -1054,6 +1054,36 @@ export async function initSummaryThread(
|
||||
return { agentName: json.agent_name, threadId: json.thread_id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a per-session thread for the position-roundtable action-planning
|
||||
* built-in agent. The agent id is fixed; callers persist/reuse the returned
|
||||
* thread id for subsequent plan refinements.
|
||||
*/
|
||||
export async function initPositionActionPlanThread(
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<ReportThreadResponse> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/position-action-plan/init`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
signal: options.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
reportRoundtableError({
|
||||
stage: "position_action_plan",
|
||||
event: "init_thread_http_error",
|
||||
message: `行动规划线程创建失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
|
||||
httpStatus: res.status,
|
||||
agentId: "position-action-planner",
|
||||
agentName: "行动规划智能体",
|
||||
});
|
||||
throw new Error(`init position action-plan thread failed (${res.status}): ${text}`);
|
||||
}
|
||||
const json = (await res.json()) as { agent_name: string; thread_id: string };
|
||||
return { agentName: json.agent_name, threadId: json.thread_id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a thread for the Step 3「入库」structure agent (`roundtable-structure`,
|
||||
* a built-in singleton — the same agent that extracts the flowchart, but this run
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface Step1PanelProps {
|
||||
* 不传 → 显示默认「任务意图理解 (人机问答)」标题。右侧「问答轮次」始终保留。
|
||||
*/
|
||||
headerLeftSlot?: React.ReactNode;
|
||||
headerRightSlot?: React.ReactNode;
|
||||
interactiveMessages: InteractiveMessage[];
|
||||
intentThreadId: string | null;
|
||||
intentReady: object | null;
|
||||
@@ -101,6 +102,7 @@ export interface Step1PanelProps {
|
||||
export function Step1Panel(props: Step1PanelProps) {
|
||||
const {
|
||||
headerLeftSlot,
|
||||
headerRightSlot,
|
||||
interactiveMessages,
|
||||
intentThreadId,
|
||||
intentReady,
|
||||
@@ -175,9 +177,12 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="shrink-0 text-[11px] font-bold text-slate-400 bg-white border px-2 py-0.5 rounded-md">
|
||||
问答轮次:{interactiveMessages.filter((m) => m.sender === "user").length} 轮
|
||||
</span>
|
||||
{headerRightSlot}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat list */}
|
||||
|
||||
@@ -90,6 +90,10 @@ export interface UseStep1IntentOptions {
|
||||
currentStep: number;
|
||||
/** 全局选中模型;调用 `/api/intent/init` / `/api/intent/stream` 时透传。 */
|
||||
selectedModel: string;
|
||||
/** Optional hidden task/business context for each intent-agent turn. */
|
||||
taskContext?: Record<string, unknown> | null;
|
||||
/** Optional task-aware welcome bubble text before the first user turn. */
|
||||
initialWelcomeText?: string;
|
||||
/** 主页面统一的 toast 入口。 */
|
||||
triggerToast: (msg: string) => void;
|
||||
}
|
||||
@@ -167,7 +171,7 @@ function intentPhaseLabel(phase: IntentStreamPhase, toolName?: string): string {
|
||||
}
|
||||
|
||||
export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentReturn {
|
||||
const { currentStep, selectedModel, triggerToast } = options;
|
||||
const { currentStep, selectedModel, taskContext, initialWelcomeText, triggerToast } = options;
|
||||
|
||||
const [intentThreadId, setIntentThreadId] = useState<string | null>(null);
|
||||
const [intentReady, setIntentReady] = useState<IntentSummary | null>(null);
|
||||
@@ -198,6 +202,14 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
useEffect(() => {
|
||||
selectedModelRef.current = selectedModel;
|
||||
}, [selectedModel]);
|
||||
const taskContextRef = useRef<Record<string, unknown> | null>(taskContext ?? null);
|
||||
useEffect(() => {
|
||||
taskContextRef.current = taskContext ?? null;
|
||||
}, [taskContext]);
|
||||
const initialWelcomeTextRef = useRef(initialWelcomeText);
|
||||
useEffect(() => {
|
||||
initialWelcomeTextRef.current = initialWelcomeText;
|
||||
}, [initialWelcomeText]);
|
||||
|
||||
const stopIntentStream = useCallback(() => {
|
||||
if (intentAborterRef.current) {
|
||||
@@ -225,7 +237,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
id: 1,
|
||||
sender: "agent",
|
||||
senderName: "任务理解智能体",
|
||||
content: INTENT_WELCOME_TEXT,
|
||||
content: initialWelcomeTextRef.current || INTENT_WELCOME_TEXT,
|
||||
time: nowTime(),
|
||||
},
|
||||
]);
|
||||
@@ -233,7 +245,10 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
try {
|
||||
// initIntent 当前不在 thread 上落盘 model,只为占位与兼容;空时由后端
|
||||
// 走 lead_agent._resolve_model_name 回退到 config.yaml models[0]
|
||||
const res = await initIntent(selectedModelRef.current || undefined);
|
||||
const res = await initIntent(
|
||||
selectedModelRef.current || undefined,
|
||||
taskContextRef.current,
|
||||
);
|
||||
if (step1InitGenRef.current !== myGen) return;
|
||||
setIntentThreadId(res.thread_id);
|
||||
setTimelineEntries([
|
||||
@@ -408,6 +423,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
threadId: intentThreadId,
|
||||
message: trimmed,
|
||||
model: selectedModelRef.current || undefined,
|
||||
taskContext: taskContextRef.current,
|
||||
files: fileRefs,
|
||||
signal: aborter.signal,
|
||||
onTextDelta: (delta) => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -25,6 +25,9 @@ NODE_STATUSES = {"locked", "ready", "running", "done", "stale", "rejected", "err
|
||||
_HANDOFF_TEXT_BUDGET = 12_000
|
||||
_MAX_INLINE_ARTIFACTS_PER_NODE = 4
|
||||
_MAX_INLINE_ARTIFACT_CHARS = 1_800
|
||||
_BUILTIN_MATERIAL_BUDGET = 24_000
|
||||
SUMMARY_AGENT_ID = "roundtable-summary"
|
||||
ACTION_PLAN_AGENT_ID = "position-action-planner"
|
||||
_TEXT_ARTIFACT_SUFFIXES = {
|
||||
".csv",
|
||||
".json",
|
||||
@@ -68,7 +71,9 @@ class SessionResponse(BaseModel):
|
||||
chain_snapshot: dict[str, Any] | None = None
|
||||
intent_thread_id: str | None = None
|
||||
intent_snapshot: dict[str, Any] | None = None
|
||||
summary_thread_id: str | None = None
|
||||
summary_snapshot: dict[str, Any] | None = None
|
||||
action_plan_thread_id: str | None = None
|
||||
action_plan_snapshot: dict[str, Any] | None = None
|
||||
status: str
|
||||
created_at: str | None = None
|
||||
@@ -112,6 +117,28 @@ class RejectNodeRequest(BaseModel):
|
||||
reason: str = Field(min_length=1, max_length=5_000)
|
||||
|
||||
|
||||
class PrepareBuiltinRunRequest(BaseModel):
|
||||
"""Optional follow-up instruction for one persisted built-in agent."""
|
||||
|
||||
message: str = Field(default="", max_length=20_000)
|
||||
|
||||
|
||||
class BuiltinRunPreparationResponse(BaseModel):
|
||||
agent_id: str
|
||||
display_name: str
|
||||
thread_id: str | None = None
|
||||
prompt: str
|
||||
source_revisions: dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CompleteBuiltinRunRequest(BaseModel):
|
||||
thread_id: str = Field(min_length=1, max_length=128)
|
||||
content: str = Field(default="", max_length=500_000)
|
||||
artifact_manifest: list[dict[str, Any]] = Field(default_factory=list)
|
||||
source_revisions: dict[str, int] = Field(default_factory=dict)
|
||||
model: str | None = Field(default=None, max_length=256)
|
||||
|
||||
|
||||
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()
|
||||
@@ -269,7 +296,195 @@ def _delivered_nodes(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
|
||||
|
||||
def _all_nodes_delivered(nodes: list[dict[str, Any]]) -> bool:
|
||||
return bool(nodes) and all(node.get("status") == "done" for node in nodes)
|
||||
return bool(nodes) and all(
|
||||
node.get("status") == "done" and (_is_intent_position_node(node) or _node_has_markdown_delivery(node))
|
||||
for node in nodes
|
||||
)
|
||||
|
||||
|
||||
def _is_intent_position_node(node: dict[str, Any]) -> bool:
|
||||
return str(node.get("position_id") or "").strip().lower() in {"intelligence", "intel", "intent"}
|
||||
|
||||
|
||||
def _is_markdown_artifact(artifact: Any) -> bool:
|
||||
if not isinstance(artifact, dict):
|
||||
return False
|
||||
path = str(artifact.get("path") or artifact.get("artifact_url") or "").strip().lower()
|
||||
name = str(artifact.get("name") or artifact.get("filename") or "").strip().lower()
|
||||
mime = str(artifact.get("mime_type") or artifact.get("mimeType") or "").strip().lower()
|
||||
if path and not path.replace("\\", "/").lstrip("/").startswith("mnt/user-data/outputs/"):
|
||||
return False
|
||||
return (
|
||||
"markdown" in mime
|
||||
or path.endswith(".md")
|
||||
or path.endswith(".markdown")
|
||||
or name.endswith(".md")
|
||||
or name.endswith(".markdown")
|
||||
)
|
||||
|
||||
|
||||
def _node_has_markdown_delivery(node: dict[str, Any]) -> bool:
|
||||
artifacts = node.get("artifact_manifest")
|
||||
return isinstance(artifacts, list) and any(_is_markdown_artifact(item) for item in artifacts)
|
||||
|
||||
|
||||
def _markdown_delivery_changed(
|
||||
previous_artifacts: Any,
|
||||
incoming_artifacts: list[dict[str, Any]],
|
||||
) -> bool:
|
||||
"""Whether this turn rewrote a Markdown deliverable, not just chatted.
|
||||
|
||||
A completed node can receive normal follow-up Q&A. That must not bump its
|
||||
effective delivery revision or invalidate every downstream stage unless a
|
||||
Markdown report really changed. The standard artifact listing includes
|
||||
`modified_at` and `size_bytes`, which together make a stable lightweight
|
||||
version marker without reading a potentially large file again.
|
||||
"""
|
||||
old = previous_artifacts if isinstance(previous_artifacts, list) else []
|
||||
old_signatures = {
|
||||
(
|
||||
str(item.get("path") or item.get("artifact_url") or ""),
|
||||
str(item.get("modified_at") or item.get("modifiedAt") or ""),
|
||||
str(item.get("size_bytes") or item.get("sizeBytes") or ""),
|
||||
)
|
||||
for item in old
|
||||
if isinstance(item, dict) and _is_markdown_artifact(item)
|
||||
}
|
||||
new_signatures = {
|
||||
(
|
||||
str(item.get("path") or item.get("artifact_url") or ""),
|
||||
str(item.get("modified_at") or item.get("modifiedAt") or ""),
|
||||
str(item.get("size_bytes") or item.get("sizeBytes") or ""),
|
||||
)
|
||||
for item in incoming_artifacts
|
||||
if isinstance(item, dict) and _is_markdown_artifact(item)
|
||||
}
|
||||
return bool(new_signatures - old_signatures)
|
||||
|
||||
|
||||
def _intent_node_answer(intent: Any) -> str:
|
||||
if not isinstance(intent, dict):
|
||||
return "情报分析岗已完成任务意图识别。"
|
||||
objective = _text_from_keys(intent, ["objective", "goal"], "已完成任务意图识别")
|
||||
constraints = intent.get("constraints") if isinstance(intent.get("constraints"), list) else []
|
||||
lines = ["情报分析岗已完成任务意图识别。", f"任务目标:{objective}"]
|
||||
if constraints:
|
||||
lines.append("关键约束:")
|
||||
lines.extend(f"- {item}" for item in constraints if isinstance(item, str) and item.strip())
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _has_confirmed_intent(intent: Any) -> bool:
|
||||
"""Only structured Step-1 output may start a frozen business chain."""
|
||||
if not isinstance(intent, dict):
|
||||
return False
|
||||
objective = _text_from_keys(intent, ["objective", "goal"])
|
||||
constraints = intent.get("constraints")
|
||||
return bool(objective and isinstance(constraints, list))
|
||||
|
||||
|
||||
def _prime_intent_nodes(nodes: list[dict[str, Any]], intent: Any) -> list[dict[str, Any]]:
|
||||
now_answer = _intent_node_answer(intent)
|
||||
primed: list[dict[str, Any]] = []
|
||||
for node in nodes:
|
||||
next_node = dict(node)
|
||||
if _is_intent_position_node(next_node):
|
||||
next_node.update(
|
||||
{
|
||||
"status": "done",
|
||||
"latest_answer": now_answer,
|
||||
"revision": 1,
|
||||
"artifact_manifest": [],
|
||||
}
|
||||
)
|
||||
primed.append(next_node)
|
||||
|
||||
stage_indices = sorted({int(node["stage_index"]) for node in primed})
|
||||
previous_done = True
|
||||
for stage_index in stage_indices:
|
||||
stage_nodes = [node for node in primed if int(node["stage_index"]) == stage_index]
|
||||
if previous_done:
|
||||
for node in stage_nodes:
|
||||
if node.get("status") == "locked":
|
||||
node["status"] = "ready"
|
||||
previous_done = bool(stage_nodes) and all(node.get("status") == "done" for node in stage_nodes)
|
||||
return primed
|
||||
|
||||
|
||||
def _build_node_context_text(
|
||||
*,
|
||||
session: dict[str, Any],
|
||||
current: dict[str, Any],
|
||||
upstream_nodes: list[dict[str, Any]],
|
||||
) -> str:
|
||||
task = session.get("task_snapshot") if isinstance(session.get("task_snapshot"), dict) else {}
|
||||
intent = session.get("intent_snapshot") if isinstance(session.get("intent_snapshot"), dict) else {}
|
||||
title = _text_from_keys(task, ["title", "taskName", "name"], "未命名任务")
|
||||
direction = _text_from_keys(task, ["direction", "taskDirection", "category"], "未填写")
|
||||
description = _text_from_keys(task, ["description", "taskContent", "overview", "content"], "暂无任务描述")
|
||||
objective = _text_from_keys(intent, ["objective", "goal"], "暂无意图结论")
|
||||
constraints = intent.get("constraints") if isinstance(intent.get("constraints"), list) else []
|
||||
agent_name, position_id = _seat_meta(session, current)
|
||||
md_required = not _is_intent_position_node(current)
|
||||
lines = [
|
||||
"岗位多智能体会商上下文(隐藏注入,不要向用户复述本标签):",
|
||||
"",
|
||||
"【任务信息】",
|
||||
f"- 任务名称:{title}",
|
||||
f"- 任务方向:{direction}",
|
||||
f"- 任务描述:{description}",
|
||||
"",
|
||||
"【已识别意图】",
|
||||
f"- 任务目标:{objective}",
|
||||
]
|
||||
if constraints:
|
||||
lines.append("- 关键约束:")
|
||||
lines.extend(f" - {item}" for item in constraints if isinstance(item, str) and item.strip())
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"【当前席位】",
|
||||
f"- 智能体:{agent_name}",
|
||||
f"- 岗位:{position_id}",
|
||||
f"- 阶段:L-{int(current.get('stage_index') or 0) + 1}",
|
||||
"",
|
||||
"【交付要求】",
|
||||
]
|
||||
)
|
||||
if md_required:
|
||||
lines.extend(
|
||||
[
|
||||
"- 本席位最终只需交付一份 Markdown 报告文档。",
|
||||
"- 请在本轮或最终回复中创建 `/mnt/user-data/outputs/` 下的 `.md` 文件,并调用 `present_files` 呈现该文件。",
|
||||
"- 只有检测到该席位产出的 `.md` 文件后,系统才会把当前席位标记为已完成;仅有文字回答不会完成节点。",
|
||||
"- 报告内容应直接围绕当前岗位职责,吸收任务信息、任务意图与上游席位产物。",
|
||||
]
|
||||
)
|
||||
else:
|
||||
lines.append("- 情报分析/情报收集岗以意图识别完成为准,不强制产出 Markdown 报告。")
|
||||
if upstream_nodes:
|
||||
lines.extend(["", "【可参考的上游席位结果】"])
|
||||
for index, node in enumerate(upstream_nodes, start=1):
|
||||
lines.append(
|
||||
f"{index}. {node.get('agent_id')} / {node.get('position_id')} / revision={node.get('revision', 0)}"
|
||||
)
|
||||
answer = str(node.get("latest_answer") or "").strip()
|
||||
if answer:
|
||||
lines.append(f" 问答结果:{answer}")
|
||||
artifacts = node.get("artifacts")
|
||||
if isinstance(artifacts, list) and artifacts:
|
||||
lines.append(" 产物:")
|
||||
for artifact in artifacts[:5]:
|
||||
if not isinstance(artifact, dict):
|
||||
continue
|
||||
artifact_name = artifact.get("name") or artifact.get("path") or "未命名产物"
|
||||
lines.append(f" - {artifact_name}")
|
||||
content = str(artifact.get("content") or "").strip()
|
||||
if content:
|
||||
lines.append(f" 摘要/正文片段:{content}")
|
||||
else:
|
||||
lines.extend(["", "【可参考的上游席位结果】", "- 当前节点暂无已完成上游席位。"])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _artifact_lines(node: dict[str, Any]) -> list[str]:
|
||||
@@ -286,129 +501,6 @@ def _artifact_lines(node: dict[str, Any]) -> list[str]:
|
||||
return lines
|
||||
|
||||
|
||||
def _build_summary_snapshot(session: dict[str, Any], nodes: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
now = datetime.now(UTC).isoformat()
|
||||
task = session.get("task_snapshot") if isinstance(session.get("task_snapshot"), dict) else {}
|
||||
intent = session.get("intent_snapshot") if isinstance(session.get("intent_snapshot"), dict) else {}
|
||||
title = _text_from_keys(task, ["title", "taskName", "name"], "未命名任务")
|
||||
direction = _text_from_keys(task, ["direction", "taskDirection", "category"], "未填写")
|
||||
description = _text_from_keys(task, ["description", "taskContent", "overview", "content"], "暂无任务描述")
|
||||
objective = _text_from_keys(intent, ["objective", "goal"], "暂无意图结论")
|
||||
constraints = intent.get("constraints") if isinstance(intent.get("constraints"), list) else []
|
||||
|
||||
lines = [
|
||||
"# 岗位会商方案总结报告",
|
||||
"",
|
||||
f"生成时间:{now}",
|
||||
"",
|
||||
"## 一、任务概况",
|
||||
"",
|
||||
f"- 任务名称:{title}",
|
||||
f"- 任务方向:{direction}",
|
||||
f"- 任务描述:{description}",
|
||||
"",
|
||||
"## 二、意图理解",
|
||||
"",
|
||||
f"- 核心目标:{objective}",
|
||||
]
|
||||
if constraints:
|
||||
lines.append("- 关键约束:")
|
||||
lines.extend(f" - {item}" for item in constraints if isinstance(item, str) and item.strip())
|
||||
lines.extend(["", "## 三、岗位交付汇总", ""])
|
||||
for index, node in enumerate(_delivered_nodes(nodes), start=1):
|
||||
agent_name, position_id = _seat_meta(session, node)
|
||||
answer = _truncate_text(node.get("latest_answer"), 1_200) or "暂无文本回答"
|
||||
lines.extend(
|
||||
[
|
||||
f"### {index}. {agent_name}",
|
||||
"",
|
||||
f"- 岗位:{position_id}",
|
||||
f"- 节点版本:第 {node.get('revision', 0)} 版",
|
||||
"",
|
||||
answer,
|
||||
"",
|
||||
]
|
||||
)
|
||||
artifacts = _artifact_lines(node)
|
||||
if artifacts:
|
||||
lines.extend(["产物文件:", *artifacts, ""])
|
||||
lines.extend(
|
||||
[
|
||||
"## 四、综合判断",
|
||||
"",
|
||||
"本报告基于当前所有有效岗位交付产物自动汇总生成,可作为后续行动规划的输入材料。若任一前序岗位产物被驳回,本报告将自动标记为失效并需要重新生成。",
|
||||
]
|
||||
)
|
||||
return {
|
||||
"kind": "summary",
|
||||
"status": "done",
|
||||
"title": "岗位会商方案总结报告",
|
||||
"content": "\n".join(lines),
|
||||
"generated_at": now,
|
||||
"source_revisions": {node["node_key"]: node.get("revision", 0) for node in _delivered_nodes(nodes)},
|
||||
}
|
||||
|
||||
|
||||
def _build_action_plan_snapshot(session: dict[str, Any], nodes: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
now = datetime.now(UTC).isoformat()
|
||||
summary = session.get("summary_snapshot") if isinstance(session.get("summary_snapshot"), dict) else {}
|
||||
delivered = _delivered_nodes(nodes)
|
||||
tasks: list[dict[str, Any]] = []
|
||||
for index, node in enumerate(delivered, start=1):
|
||||
agent_name, position_id = _seat_meta(session, node)
|
||||
tasks.append(
|
||||
{
|
||||
"id": f"subtask-{index:02d}",
|
||||
"title": f"落实{agent_name}交付建议",
|
||||
"owner_position": position_id,
|
||||
"basis_node": node.get("node_key"),
|
||||
"deliverable": f"{agent_name}整改/执行清单",
|
||||
"acceptance": "补齐输入依据,形成可验证的阶段成果并回填岗位会商产物。",
|
||||
}
|
||||
)
|
||||
lines = [
|
||||
"# 岗位会商行动规划",
|
||||
"",
|
||||
f"生成时间:{now}",
|
||||
"",
|
||||
"## 一、规划依据",
|
||||
"",
|
||||
f"- 方案总结:{summary.get('title') or '岗位会商方案总结报告'}",
|
||||
f"- 有效岗位产物数量:{len(delivered)}",
|
||||
"",
|
||||
"## 二、子任务拆解",
|
||||
"",
|
||||
]
|
||||
for task in tasks:
|
||||
lines.extend(
|
||||
[
|
||||
f"### {task['id']} {task['title']}",
|
||||
"",
|
||||
f"- 建议责任岗位:{task['owner_position']}",
|
||||
f"- 输入依据节点:{task['basis_node']}",
|
||||
f"- 交付物:{task['deliverable']}",
|
||||
f"- 验收标准:{task['acceptance']}",
|
||||
"",
|
||||
]
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
"## 三、执行提示",
|
||||
"",
|
||||
"若前序岗位产物被驳回,本行动规划会自动失效;请先完成被驳回节点及后续节点的重新交付,再重新生成行动规划。",
|
||||
]
|
||||
)
|
||||
return {
|
||||
"kind": "action_plan",
|
||||
"status": "done",
|
||||
"title": "岗位会商行动规划",
|
||||
"content": "\n".join(lines),
|
||||
"generated_at": now,
|
||||
"source_revisions": {node["node_key"]: node.get("revision", 0) for node in delivered},
|
||||
"subtasks": tasks,
|
||||
}
|
||||
|
||||
|
||||
def _artifact_refs(artifacts: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(artifacts, list):
|
||||
return []
|
||||
@@ -562,6 +654,16 @@ async def _handoff_context(request: Request, session_id: str, node_key: str) ->
|
||||
break
|
||||
return {
|
||||
"kind": "position_roundtable_handoff",
|
||||
"instructions": _build_node_context_text(
|
||||
session=session,
|
||||
current=current,
|
||||
upstream_nodes=upstream_nodes,
|
||||
),
|
||||
"delivery_requirement": {
|
||||
"type": "markdown_report",
|
||||
"required": not _is_intent_position_node(current),
|
||||
"completion_rule": "普通业务席位必须在 /mnt/user-data/outputs/ 下交付一份 .md 报告文档才会标记完成;情报分析/情报收集岗以意图识别完成为准。",
|
||||
},
|
||||
"task": session.get("task_snapshot") or {},
|
||||
"intent": session.get("intent_snapshot") or {},
|
||||
"current_node": {
|
||||
@@ -578,6 +680,200 @@ async def _handoff_context(request: Request, session_id: str, node_key: str) ->
|
||||
}
|
||||
|
||||
|
||||
def _source_revisions(nodes: list[dict[str, Any]]) -> dict[str, int]:
|
||||
"""Stable revision fence for a built-in report/planning run.
|
||||
|
||||
The report agents run asynchronously in the browser. If a user rejects or
|
||||
re-delivers a node while a run is still streaming, this fence prevents that
|
||||
old run from overwriting the newer business-chain state when it completes.
|
||||
"""
|
||||
return {str(node["node_key"]): int(node.get("revision") or 0) for node in _delivered_nodes(nodes)}
|
||||
|
||||
|
||||
def _is_json_artifact(artifact: Any) -> bool:
|
||||
if not isinstance(artifact, dict):
|
||||
return False
|
||||
path = str(artifact.get("path") or artifact.get("artifact_url") or "").strip().lower()
|
||||
name = str(artifact.get("name") or artifact.get("filename") or "").strip().lower()
|
||||
mime = str(artifact.get("mime_type") or artifact.get("mimeType") or "").strip().lower()
|
||||
if path and not path.replace("\\", "/").lstrip("/").startswith("mnt/user-data/outputs/"):
|
||||
return False
|
||||
return "json" in mime or path.endswith(".json") or name.endswith(".json")
|
||||
|
||||
|
||||
def _artifact_signature(artifact: dict[str, Any]) -> tuple[str, str, str]:
|
||||
"""Use server-returned path/mtime/size to detect an actually rewritten file."""
|
||||
return (
|
||||
str(artifact.get("path") or artifact.get("artifact_url") or ""),
|
||||
str(artifact.get("modified_at") or artifact.get("modifiedAt") or ""),
|
||||
str(artifact.get("size_bytes") or artifact.get("sizeBytes") or ""),
|
||||
)
|
||||
|
||||
|
||||
def _artifact_manifest_refs(artifacts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Persist only the display metadata received from the thread artifact API."""
|
||||
refs: list[dict[str, Any]] = []
|
||||
for artifact in artifacts:
|
||||
if not isinstance(artifact, dict):
|
||||
continue
|
||||
path = str(artifact.get("path") or artifact.get("artifact_url") or "").strip()
|
||||
if not path.replace("\\", "/").lstrip("/").startswith("mnt/user-data/outputs/"):
|
||||
continue
|
||||
refs.append(
|
||||
{
|
||||
"name": str(artifact.get("name") or artifact.get("filename") or path.rsplit("/", 1)[-1]),
|
||||
"path": path,
|
||||
"mime_type": artifact.get("mime_type") or artifact.get("mimeType") or None,
|
||||
"modified_at": artifact.get("modified_at") or artifact.get("modifiedAt") or None,
|
||||
"size_bytes": artifact.get("size_bytes") or artifact.get("sizeBytes") or None,
|
||||
}
|
||||
)
|
||||
return refs
|
||||
|
||||
|
||||
async def _effective_delivery_material(
|
||||
session: dict[str, Any], nodes: list[dict[str, Any]]
|
||||
) -> str:
|
||||
"""Build bounded source material from valid current deliveries only.
|
||||
|
||||
Stale/rejected rows are deliberately excluded. The persisted node answer
|
||||
provides a quick synopsis; the Markdown files provide the actual
|
||||
deliverable body, read through the owning thread's virtual-path resolver.
|
||||
"""
|
||||
remaining = _BUILTIN_MATERIAL_BUDGET
|
||||
lines: list[str] = ["【岗位有效交付】"]
|
||||
for index, node in enumerate(_delivered_nodes(nodes), start=1):
|
||||
agent_name, position_id = _seat_meta(session, node)
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
f"### {index}. {agent_name}",
|
||||
f"- 岗位:{position_id}",
|
||||
f"- 节点:{node.get('node_key')}",
|
||||
f"- 有效版本:v{node.get('revision', 0)}",
|
||||
]
|
||||
)
|
||||
answer_limit = min(2_000, max(0, remaining // max(1, len(nodes))))
|
||||
answer = _truncate_text(node.get("latest_answer"), answer_limit)
|
||||
if answer:
|
||||
remaining -= len(answer)
|
||||
lines.extend(["- 本轮问答结论:", answer])
|
||||
|
||||
artifacts, consumed = await _artifact_refs_with_content(
|
||||
node.get("thread_id"), node.get("artifact_manifest"), max(0, remaining)
|
||||
)
|
||||
remaining -= consumed
|
||||
markdown_artifacts = [artifact for artifact in artifacts if _is_markdown_artifact(artifact)]
|
||||
if markdown_artifacts:
|
||||
lines.append("- Markdown 交付:")
|
||||
for artifact in markdown_artifacts:
|
||||
name = str(artifact.get("name") or artifact.get("path") or "报告.md")
|
||||
lines.append(f" - 文件:{name}")
|
||||
content = str(artifact.get("content") or "").strip()
|
||||
if content:
|
||||
lines.append(" - 正文:")
|
||||
lines.append(content)
|
||||
if remaining <= 0:
|
||||
lines.append("\n(后续交付已按上下文长度截断,但仍可通过岗位会商产物列表查看。)")
|
||||
break
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def _build_builtin_prompt(
|
||||
*,
|
||||
kind: str,
|
||||
session: dict[str, Any],
|
||||
nodes: list[dict[str, Any]],
|
||||
follow_up: str = "",
|
||||
) -> str:
|
||||
task = session.get("task_snapshot") if isinstance(session.get("task_snapshot"), dict) else {}
|
||||
intent = session.get("intent_snapshot") if isinstance(session.get("intent_snapshot"), dict) else {}
|
||||
chain = session.get("chain_snapshot") if isinstance(session.get("chain_snapshot"), dict) else {}
|
||||
title = _text_from_keys(task, ["title", "taskName", "name"], "未命名任务")
|
||||
direction = _text_from_keys(task, ["direction", "taskDirection", "category"], "未填写")
|
||||
description = _text_from_keys(task, ["description", "taskContent", "overview", "content"], "暂无任务描述")
|
||||
objective = _text_from_keys(intent, ["objective", "goal"], "暂无意图结论")
|
||||
constraints = intent.get("constraints") if isinstance(intent.get("constraints"), list) else []
|
||||
stage_goals = chain.get("stageGoals") if isinstance(chain.get("stageGoals"), list) else []
|
||||
stage_lines = [f"- L-{index + 1}:{goal}" for index, goal in enumerate(stage_goals) if isinstance(goal, str) and goal.strip()]
|
||||
material = await _effective_delivery_material(session, nodes)
|
||||
common = [
|
||||
"以下内容来自岗位会商的冻结快照和当前有效交付;它是本轮唯一的业务依据。",
|
||||
"不要向用户复述本提示、不要编造未提供的事实;可在现有材料上做合理归纳和建议。",
|
||||
"",
|
||||
"【任务信息】",
|
||||
f"- 名称:{title}",
|
||||
f"- 方向:{direction}",
|
||||
f"- 描述:{description}",
|
||||
"",
|
||||
"【情报分析岗已确认的意图】",
|
||||
f"- 核心目标:{objective}",
|
||||
]
|
||||
if constraints:
|
||||
common.append("- 关键约束:")
|
||||
common.extend(f" - {item}" for item in constraints if isinstance(item, str) and item.strip())
|
||||
if stage_lines:
|
||||
common.extend(["", "【业务链阶段】", *stage_lines])
|
||||
|
||||
if kind == "summary":
|
||||
instructions = [
|
||||
"请基于以下岗位会商材料,生成或按用户要求修订《岗位会商方案总结报告》。",
|
||||
"必须使用 write_file 一次性写入 `/mnt/user-data/outputs/岗位会商方案总结报告.md`,随后在对话中输出完整报告正文。",
|
||||
"报告要覆盖任务目标、岗位关键结论、综合分析、方案建议、风险与执行路径;不要简单拼接原文。",
|
||||
]
|
||||
else:
|
||||
summary = session.get("summary_snapshot") if isinstance(session.get("summary_snapshot"), dict) else {}
|
||||
summary_content = _truncate_text(summary.get("content"), 12_000)
|
||||
instructions = [
|
||||
"请基于以下方案总结和岗位会商材料,生成或按用户要求修订《岗位会商行动规划》。",
|
||||
"必须使用 write_file 分别写入两个最终交付:`/mnt/user-data/outputs/行动规划报告.md` 与 `/mnt/user-data/outputs/action-plan-subtasks.json`。",
|
||||
"Markdown 报告需给出分阶段推进建议、优先级、风险控制与验收安排;JSON 必须是严格合法数组,每个子任务都包含 id、title、goal、basis、owner_position、dependencies、deliverable、acceptance_criteria、priority。",
|
||||
"",
|
||||
"【已生效方案总结】",
|
||||
summary_content or "(方案总结正文未写入快照,请依据下方有效岗位交付重新归纳。)",
|
||||
]
|
||||
if follow_up.strip():
|
||||
instructions.extend(["", "【用户本次补充/修改要求】", follow_up.strip()])
|
||||
return "\n".join([*instructions, "", *common, "", material])
|
||||
|
||||
|
||||
def _build_builtin_snapshot(
|
||||
*,
|
||||
kind: str,
|
||||
thread_id: str,
|
||||
content: str,
|
||||
artifacts: list[dict[str, Any]],
|
||||
source_revisions: dict[str, int],
|
||||
model: str | None,
|
||||
previous: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
markdown = next((artifact for artifact in artifacts if _is_markdown_artifact(artifact)), None)
|
||||
json_artifact = next((artifact for artifact in artifacts if _is_json_artifact(artifact)), None)
|
||||
primary = markdown if kind == "summary" else markdown
|
||||
previous_primary = previous.get("primary_artifact") if isinstance(previous, dict) else None
|
||||
primary_changed = not isinstance(previous_primary, dict) or not isinstance(primary, dict) or (
|
||||
_artifact_signature(previous_primary) != _artifact_signature(primary)
|
||||
)
|
||||
previous_content = str(previous.get("content") or "") if isinstance(previous, dict) else ""
|
||||
return {
|
||||
"kind": kind,
|
||||
"status": "done",
|
||||
"title": "岗位会商方案总结报告" if kind == "summary" else "岗位会商行动规划",
|
||||
# A pure follow-up may only answer a question and leave the report file
|
||||
# unchanged. Keep the last report body in that case while retaining
|
||||
# the answer separately for audit/display.
|
||||
"content": content.strip() if primary_changed or not previous_content else previous_content,
|
||||
"last_response": content.strip(),
|
||||
"thread_id": thread_id,
|
||||
"artifact_manifest": _artifact_manifest_refs(artifacts),
|
||||
"primary_artifact": primary,
|
||||
"subtasks_artifact": json_artifact if kind == "action_plan" else None,
|
||||
"source_revisions": source_revisions,
|
||||
"model": model or None,
|
||||
"generated_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@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(
|
||||
@@ -633,7 +929,7 @@ 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"):
|
||||
if not _has_confirmed_intent(current.get("intent_snapshot")):
|
||||
raise HTTPException(status_code=400, detail="请先完成并保存任务意图")
|
||||
|
||||
chain = await _get_chain_store(request).get_chain(
|
||||
@@ -652,6 +948,7 @@ async def activate_session(
|
||||
chain,
|
||||
available_agent_ids=await _available_agent_ids(request, seat_ids),
|
||||
)
|
||||
nodes = _prime_intent_nodes(nodes, current.get("intent_snapshot"))
|
||||
activated = await store.activate_session(
|
||||
session_id,
|
||||
user_id,
|
||||
@@ -723,29 +1020,54 @@ async def complete_node_turn(
|
||||
raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁")
|
||||
if node.get("invalidated_by"):
|
||||
raise HTTPException(status_code=409, detail="上游产物已被驳回或重做,当前节点需等待前序节点重新完成")
|
||||
# Artifact listing is best-effort on the client. Never erase a previously
|
||||
# delivered report merely because one listing request transiently failed.
|
||||
effective_artifacts = body.artifact_manifest or list(node.get("artifact_manifest") or [])
|
||||
has_markdown = any(_is_markdown_artifact(item) for item in effective_artifacts)
|
||||
markdown_changed = _markdown_delivery_changed(node.get("artifact_manifest"), effective_artifacts)
|
||||
requires_fresh_delivery = node.get("status") in {"rejected", "stale"}
|
||||
next_status = (
|
||||
"done"
|
||||
if _is_intent_position_node(node)
|
||||
else "done"
|
||||
if has_markdown and (not requires_fresh_delivery or markdown_changed)
|
||||
else "running"
|
||||
)
|
||||
# Effective revisions are delivery revisions, not every conversational
|
||||
# answer. This prevents a harmless follow-up on a completed seat from
|
||||
# invalidating all later stages just because its old Markdown file still
|
||||
# appears in the artifact list.
|
||||
new_effective_delivery = (
|
||||
next_status == "done"
|
||||
and (
|
||||
_is_intent_position_node(node)
|
||||
or node.get("status") != "done"
|
||||
or markdown_changed
|
||||
)
|
||||
)
|
||||
row = await store.update_node(
|
||||
session_id,
|
||||
node_key,
|
||||
user_id,
|
||||
status="done",
|
||||
status=next_status,
|
||||
latest_answer=body.latest_answer,
|
||||
artifact_manifest=body.artifact_manifest,
|
||||
artifact_manifest=effective_artifacts,
|
||||
upstream_revision=body.upstream_revision,
|
||||
invalidated_by=None,
|
||||
increment_revision=True,
|
||||
increment_revision=new_effective_delivery,
|
||||
)
|
||||
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.
|
||||
# A node has no coordinator. Its effective delivery alone drives the frozen
|
||||
# chain: all parallel seats in the current stage must finish before the
|
||||
# next stage is made writable. A plain conversational follow-up keeps the
|
||||
# existing downstream deliveries valid; only a newly written Markdown
|
||||
# report advances the revision and makes later stages stale.
|
||||
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:
|
||||
if new_effective_delivery and node.get("revision", 0) > 0:
|
||||
for downstream in nodes:
|
||||
if (
|
||||
downstream["stage_index"] > row["stage_index"]
|
||||
@@ -769,7 +1091,7 @@ async def complete_node_turn(
|
||||
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:
|
||||
if row["status"] == "done" and 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"] in {"locked", "stale"}:
|
||||
@@ -813,50 +1135,156 @@ async def reject_node(
|
||||
return SessionDetailResponse(**row, nodes=[_project_node(node) for node in node_rows])
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/summary/start", response_model=SessionDetailResponse)
|
||||
async def start_summary(request: Request, session_id: str) -> SessionDetailResponse:
|
||||
async def _prepare_builtin_run(
|
||||
request: Request,
|
||||
session_id: str,
|
||||
*,
|
||||
kind: str,
|
||||
follow_up: str = "",
|
||||
) -> BuiltinRunPreparationResponse:
|
||||
store, user_id, session = await _require_writable_session(request, session_id)
|
||||
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 not _all_nodes_delivered(nodes):
|
||||
raise HTTPException(status_code=409, detail="所有岗位产物完成后才能生成方案总结")
|
||||
snapshot = _build_summary_snapshot(session, nodes)
|
||||
row = await store.update_session(
|
||||
session_id,
|
||||
user_id,
|
||||
summary_snapshot=snapshot,
|
||||
action_plan_snapshot=None,
|
||||
status="active",
|
||||
raise HTTPException(status_code=409, detail="所有岗位产物完成后才能生成或更新内置交付")
|
||||
|
||||
if kind == "action_plan":
|
||||
summary = session.get("summary_snapshot")
|
||||
if not isinstance(summary, dict) or summary.get("status") != "done":
|
||||
raise HTTPException(status_code=409, detail="请先生成有效的方案总结")
|
||||
thread_id = session.get("action_plan_thread_id")
|
||||
agent_id = ACTION_PLAN_AGENT_ID
|
||||
display_name = "行动规划智能体"
|
||||
else:
|
||||
if follow_up.strip():
|
||||
summary = session.get("summary_snapshot")
|
||||
if not isinstance(summary, dict) or summary.get("status") != "done":
|
||||
raise HTTPException(status_code=409, detail="请先生成有效的方案总结后再继续问答")
|
||||
thread_id = session.get("summary_thread_id")
|
||||
agent_id = SUMMARY_AGENT_ID
|
||||
display_name = "方案总结智能体"
|
||||
|
||||
return BuiltinRunPreparationResponse(
|
||||
agent_id=agent_id,
|
||||
display_name=display_name,
|
||||
thread_id=str(thread_id) if isinstance(thread_id, str) and thread_id else None,
|
||||
prompt=await _build_builtin_prompt(
|
||||
kind=kind,
|
||||
session=session,
|
||||
nodes=nodes,
|
||||
follow_up=follow_up,
|
||||
),
|
||||
source_revisions=_source_revisions(nodes),
|
||||
)
|
||||
|
||||
|
||||
async def _complete_builtin_run(
|
||||
request: Request,
|
||||
session_id: str,
|
||||
*,
|
||||
kind: str,
|
||||
body: CompleteBuiltinRunRequest,
|
||||
) -> SessionDetailResponse:
|
||||
store, user_id, session = await _require_writable_session(request, session_id)
|
||||
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 not _all_nodes_delivered(nodes):
|
||||
raise HTTPException(status_code=409, detail="上游岗位产物已变更或未完成,不能写入旧的内置交付")
|
||||
current_revisions = _source_revisions(nodes)
|
||||
if body.source_revisions != current_revisions:
|
||||
raise HTTPException(status_code=409, detail="岗位产物版本已变化,请基于最新交付重新生成")
|
||||
|
||||
artifacts = _artifact_manifest_refs(body.artifact_manifest)
|
||||
markdown = [artifact for artifact in artifacts if _is_markdown_artifact(artifact)]
|
||||
if not markdown:
|
||||
raise HTTPException(status_code=422, detail="内置智能体必须交付 outputs 目录下的一份 Markdown 报告文档")
|
||||
if kind == "action_plan" and not any(_is_json_artifact(artifact) for artifact in artifacts):
|
||||
raise HTTPException(status_code=422, detail="行动规划智能体还需交付 action-plan-subtasks.json 子任务清单")
|
||||
|
||||
snapshot_field = "summary_snapshot" if kind == "summary" else "action_plan_snapshot"
|
||||
previous = session.get(snapshot_field)
|
||||
previous_snapshot = previous if isinstance(previous, dict) else None
|
||||
snapshot = _build_builtin_snapshot(
|
||||
kind=kind,
|
||||
thread_id=body.thread_id,
|
||||
content=body.content,
|
||||
artifacts=artifacts,
|
||||
source_revisions=current_revisions,
|
||||
model=body.model,
|
||||
previous=previous_snapshot,
|
||||
)
|
||||
previous_primary = previous_snapshot.get("primary_artifact") if previous_snapshot else None
|
||||
new_primary = snapshot.get("primary_artifact")
|
||||
report_changed = not isinstance(previous_primary, dict) or not isinstance(new_primary, dict) or (
|
||||
_artifact_signature(previous_primary) != _artifact_signature(new_primary)
|
||||
)
|
||||
if kind == "summary":
|
||||
row = await store.update_session(
|
||||
session_id,
|
||||
user_id,
|
||||
summary_thread_id=body.thread_id,
|
||||
summary_snapshot=snapshot,
|
||||
# A rewritten summary invalidates the derived action plan. A
|
||||
# pure Q&A follow-up keeps the existing plan valid.
|
||||
action_plan_snapshot=None if report_changed else session.get("action_plan_snapshot"),
|
||||
status="active",
|
||||
)
|
||||
else:
|
||||
row = await store.update_session(
|
||||
session_id,
|
||||
user_id,
|
||||
action_plan_thread_id=body.thread_id,
|
||||
action_plan_snapshot=snapshot,
|
||||
status="completed",
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Position roundtable session not found")
|
||||
node_rows = await store.list_nodes(session_id, user_id) or []
|
||||
return SessionDetailResponse(**row, nodes=[_project_node(node) for node in node_rows])
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/action-plan/start", response_model=SessionDetailResponse)
|
||||
async def start_action_plan(request: Request, session_id: str) -> SessionDetailResponse:
|
||||
store, user_id, session = await _require_writable_session(request, session_id)
|
||||
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 not _all_nodes_delivered(nodes):
|
||||
raise HTTPException(status_code=409, detail="所有岗位产物完成后才能生成行动规划")
|
||||
summary = session.get("summary_snapshot")
|
||||
if not isinstance(summary, dict) or summary.get("status") != "done":
|
||||
raise HTTPException(status_code=409, detail="请先生成有效的方案总结")
|
||||
snapshot = _build_action_plan_snapshot(session, nodes)
|
||||
row = await store.update_session(
|
||||
session_id,
|
||||
user_id,
|
||||
action_plan_snapshot=snapshot,
|
||||
status="completed",
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Position roundtable session not found")
|
||||
node_rows = await store.list_nodes(session_id, user_id) or []
|
||||
return SessionDetailResponse(**row, nodes=[_project_node(node) for node in node_rows])
|
||||
@router.post("/sessions/{session_id}/summary/prepare", response_model=BuiltinRunPreparationResponse)
|
||||
async def prepare_summary(
|
||||
request: Request, session_id: str, body: PrepareBuiltinRunRequest
|
||||
) -> BuiltinRunPreparationResponse:
|
||||
return await _prepare_builtin_run(request, session_id, kind="summary", follow_up=body.message)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/summary/ask", response_model=BuiltinRunPreparationResponse)
|
||||
async def ask_summary(
|
||||
request: Request, session_id: str, body: PrepareBuiltinRunRequest
|
||||
) -> BuiltinRunPreparationResponse:
|
||||
return await _prepare_builtin_run(request, session_id, kind="summary", follow_up=body.message)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/summary/complete", response_model=SessionDetailResponse)
|
||||
async def complete_summary(
|
||||
request: Request, session_id: str, body: CompleteBuiltinRunRequest
|
||||
) -> SessionDetailResponse:
|
||||
return await _complete_builtin_run(request, session_id, kind="summary", body=body)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/action-plan/prepare", response_model=BuiltinRunPreparationResponse)
|
||||
async def prepare_action_plan(
|
||||
request: Request, session_id: str, body: PrepareBuiltinRunRequest
|
||||
) -> BuiltinRunPreparationResponse:
|
||||
return await _prepare_builtin_run(request, session_id, kind="action_plan", follow_up=body.message)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/action-plan/ask", response_model=BuiltinRunPreparationResponse)
|
||||
async def ask_action_plan(
|
||||
request: Request, session_id: str, body: PrepareBuiltinRunRequest
|
||||
) -> BuiltinRunPreparationResponse:
|
||||
return await _prepare_builtin_run(request, session_id, kind="action_plan", follow_up=body.message)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/action-plan/complete", response_model=SessionDetailResponse)
|
||||
async def complete_action_plan(
|
||||
request: Request, session_id: str, body: CompleteBuiltinRunRequest
|
||||
) -> SessionDetailResponse:
|
||||
return await _complete_builtin_run(request, session_id, kind="action_plan", body=body)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/results", response_model=list[NodeResponse])
|
||||
|
||||
@@ -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",
|
||||
|
||||
+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)
|
||||
+5
@@ -31,7 +31,12 @@ class PositionRoundtableSessionRow(Base):
|
||||
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.
|
||||
|
||||
+12
@@ -55,7 +55,9 @@ class PositionRoundtableRepository:
|
||||
"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),
|
||||
@@ -134,7 +136,9 @@ class PositionRoundtableRepository:
|
||||
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:
|
||||
@@ -148,8 +152,12 @@ class PositionRoundtableRepository:
|
||||
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:
|
||||
@@ -202,6 +210,10 @@ class PositionRoundtableRepository:
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -9,6 +9,8 @@ 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
|
||||
@@ -79,6 +81,39 @@ async def test_session_activation_freezes_chain_and_creates_nodes_once(store):
|
||||
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",
|
||||
@@ -99,6 +134,58 @@ def test_chain_node_activation_rejects_missing_agent_ids():
|
||||
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={})
|
||||
@@ -276,15 +363,55 @@ async def test_builtin_summary_and_action_plan_snapshots_are_invalidated_by_reje
|
||||
session = await store.get_session(created["id"], "user-a")
|
||||
assert session is not None
|
||||
nodes = await store.list_nodes(created["id"], "user-a") or []
|
||||
summary = position_roundtable_router._build_summary_snapshot(session, nodes)
|
||||
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"]
|
||||
assert "研判智能体" in summary["content"]
|
||||
session = await store.update_session(created["id"], "user-a", summary_snapshot=summary)
|
||||
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_action_plan_snapshot(session, nodes)
|
||||
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",
|
||||
)
|
||||
@@ -309,6 +436,30 @@ async def test_builtin_summary_and_action_plan_snapshots_are_invalidated_by_reje
|
||||
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"
|
||||
|
||||
Reference in new issue
Block a user