feat(position-roundtable): add delivery rejection workflow

This commit is contained in:
fengxiangdebaozi committed 2026-07-21 20:16:52 +08:00
1 parent 2ca8a23102
commit 1cd99a5a90
9 files changed
+1315 -41

No files matched your search

@@ -30,6 +30,9 @@ export interface PositionRoundtableNode {
artifactManifest: Array<Record<string, unknown>>; artifactManifest: Array<Record<string, unknown>>;
revision: number; revision: number;
upstreamRevision: Record<string, unknown> | null; upstreamRevision: Record<string, unknown> | null;
rejectionCount: number;
lastRejection: Record<string, unknown> | null;
invalidatedBy: string | null;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
} }
@@ -42,6 +45,8 @@ export interface PositionRoundtableSession {
chainSnapshot: Record<string, unknown> | null; chainSnapshot: Record<string, unknown> | null;
intentThreadId: string | null; intentThreadId: string | null;
intentSnapshot: Record<string, unknown> | null; intentSnapshot: Record<string, unknown> | null;
summarySnapshot: Record<string, unknown> | null;
actionPlanSnapshot: Record<string, unknown> | null;
status: "intent_pending" | "active" | "completed" | "archived"; status: "intent_pending" | "active" | "completed" | "archived";
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
@@ -62,6 +67,9 @@ interface ServerNode {
artifact_manifest?: Array<Record<string, unknown>>; artifact_manifest?: Array<Record<string, unknown>>;
revision?: number; revision?: number;
upstream_revision?: Record<string, unknown> | null; upstream_revision?: Record<string, unknown> | null;
rejection_count?: number;
last_rejection?: Record<string, unknown> | null;
invalidated_by?: string | null;
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
} }
@@ -74,6 +82,8 @@ interface ServerSession {
chain_snapshot?: Record<string, unknown> | null; chain_snapshot?: Record<string, unknown> | null;
intent_thread_id: string | null; intent_thread_id: string | null;
intent_snapshot?: Record<string, unknown> | null; intent_snapshot?: Record<string, unknown> | null;
summary_snapshot?: Record<string, unknown> | null;
action_plan_snapshot?: Record<string, unknown> | null;
status: PositionRoundtableSession["status"]; status: PositionRoundtableSession["status"];
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
@@ -105,6 +115,9 @@ function toNode(node: ServerNode): PositionRoundtableNode {
artifactManifest: node.artifact_manifest ?? [], artifactManifest: node.artifact_manifest ?? [],
revision: node.revision ?? 0, revision: node.revision ?? 0,
upstreamRevision: node.upstream_revision ?? null, upstreamRevision: node.upstream_revision ?? null,
rejectionCount: node.rejection_count ?? 0,
lastRejection: node.last_rejection ?? null,
invalidatedBy: node.invalidated_by ?? null,
createdAt: node.created_at, createdAt: node.created_at,
updatedAt: node.updated_at, updatedAt: node.updated_at,
}; };
@@ -119,6 +132,8 @@ function toSession(session: ServerSession): PositionRoundtableSession {
chainSnapshot: session.chain_snapshot ?? null, chainSnapshot: session.chain_snapshot ?? null,
intentThreadId: session.intent_thread_id, intentThreadId: session.intent_thread_id,
intentSnapshot: session.intent_snapshot ?? null, intentSnapshot: session.intent_snapshot ?? null,
summarySnapshot: session.summary_snapshot ?? null,
actionPlanSnapshot: session.action_plan_snapshot ?? null,
status: session.status, status: session.status,
createdAt: session.created_at, createdAt: session.created_at,
updatedAt: session.updated_at, updatedAt: session.updated_at,
@@ -263,3 +278,45 @@ export async function completePositionNodeTurn(
); );
return toNode(result); return toNode(result);
} }
export async function rejectPositionNode(
sessionId: string,
nodeKey: string,
input: { title: string; reason: string },
): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>(
await apiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/reject`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: input.title,
reason: input.reason,
}),
},
),
"reject position node",
);
return toSession(result);
}
export async function generatePositionSummary(sessionId: string): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/summary/start`, {
method: "POST",
}),
"generate position summary",
);
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);
}
@@ -78,7 +78,8 @@ export function PositionNodeChat({
sessionStatus !== "archived" && sessionStatus !== "archived" &&
node.positionId === currentPositionId && node.positionId === currentPositionId &&
node.status !== "locked" && node.status !== "locked" &&
node.status !== "error"; node.status !== "error" &&
!node.invalidatedBy;
useEffect(() => { useEffect(() => {
setBoundThreadId(node.threadId); setBoundThreadId(node.threadId);
@@ -152,6 +153,8 @@ export function PositionNodeChat({
toast.info( toast.info(
sessionStatus === "archived" sessionStatus === "archived"
? "该会话已归档,仅可查看历史记录" ? "该会话已归档,仅可查看历史记录"
: node.invalidatedBy
? "上游产物已被驳回或重做,当前节点需等待前序节点重新完成"
: node.status === "locked" : node.status === "locked"
? "该节点尚未解锁" ? "该节点尚未解锁"
: "当前岗位只能查看该节点对话", : "当前岗位只能查看该节点对话",
@@ -181,6 +184,8 @@ export function PositionNodeChat({
const readOnlyHint = const readOnlyHint =
sessionStatus === "archived" sessionStatus === "archived"
? "该会话已归档,仅可查看历史对话和产物" ? "该会话已归档,仅可查看历史对话和产物"
: node.invalidatedBy
? "上游产物已变更,需等待前序节点重新完成后再重做"
: node.status === "locked" : node.status === "locked"
? "等待上游岗位完成后解锁" ? "等待上游岗位完成后解锁"
: node.positionId !== currentPositionId : node.positionId !== currentPositionId
@@ -12,13 +12,14 @@ import {
Circle, Circle,
CircleDashed, CircleDashed,
Clock3, Clock3,
Eye,
FileOutput, FileOutput,
FileText,
GitBranch, GitBranch,
Layers3, Layers3,
Loader2, Loader2,
MessageSquareText, MessageSquareText,
Plus, Plus,
RotateCcw,
Search, Search,
Settings, Settings,
Sparkles, Sparkles,
@@ -40,9 +41,11 @@ import { useAgentModuleSidebarData } from "@/components/workspace/agents/agent-m
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { useAgent } from "@/core/agents"; import { useAgent } from "@/core/agents";
import type { Agent } from "@/core/agents"; import type { Agent } from "@/core/agents";
@@ -67,12 +70,16 @@ import {
activatePositionSession, activatePositionSession,
archivePositionSession, archivePositionSession,
createPositionSession, createPositionSession,
generatePositionActionPlan,
generatePositionSummary,
getPositionSession, getPositionSession,
listPositionSessions, listPositionSessions,
rejectPositionNode,
savePositionIntent, savePositionIntent,
type PositionRoundtableNode, type PositionRoundtableNode,
type PositionRoundtableSession, type PositionRoundtableSession,
} from "@/position-roundtable/api/sessions"; } from "@/position-roundtable/api/sessions";
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
import { import {
DEFAULT_MOCK_POSITION_TASK_ID, DEFAULT_MOCK_POSITION_TASK_ID,
getMockPositionTask, getMockPositionTask,
@@ -174,6 +181,59 @@ function allExecutableNodesDone(nodes: PositionRoundtableNode[]): boolean {
return nodes.length > 0 && nodes.every((node) => node.status === "done"); return nodes.length > 0 && nodes.every((node) => node.status === "done");
} }
function nodeHasDelivery(node: PositionRoundtableNode): boolean {
return (
node.revision > 0 ||
Boolean(node.latestAnswer?.trim()) ||
node.artifactManifest.some((artifact) => typeof artifact.path === "string" && artifact.path.trim())
);
}
function canRejectNodeDelivery(node: PositionRoundtableNode): boolean {
return node.status === "done" && nodeHasDelivery(node);
}
function recordString(record: Record<string, unknown> | null | undefined, key: string): string {
const value = record?.[key];
return typeof value === "string" ? value : "";
}
function nodeRejectionTitle(node: PositionRoundtableNode): string {
return recordString(node.lastRejection, "title");
}
function nodeRejectionReason(node: PositionRoundtableNode): string {
return recordString(node.lastRejection, "reason");
}
function snapshotStatus(snapshot: Record<string, unknown> | null | undefined): string {
const value = snapshot?.status;
return typeof value === "string" ? value : "";
}
function snapshotText(snapshot: Record<string, unknown> | null | undefined, key: string): string {
const value = snapshot?.[key];
return typeof value === "string" ? value : "";
}
function builtinNodeStatus(
snapshot: Record<string, unknown> | null | undefined,
ready: boolean,
): NodeVisualStatus {
const status = snapshotStatus(snapshot);
if (status === "done") return "done";
if (status === "stale") return "stale";
if (status === "running") return "running";
if (status === "error") return "error";
return ready ? "ready" : "locked";
}
function resultPositionName(positionId: string | null): string {
if (positionId === "summary") return "内置总结节点";
if (positionId === "action_plan") return "内置规划节点";
return positionNameById(positionId);
}
function parseIntentSummary(snapshot: Record<string, unknown> | null | undefined): IntentSummary | null { function parseIntentSummary(snapshot: Record<string, unknown> | null | undefined): IntentSummary | null {
if (!snapshot || typeof snapshot.objective !== "string" || !Array.isArray(snapshot.constraints)) { if (!snapshot || typeof snapshot.objective !== "string" || !Array.isArray(snapshot.constraints)) {
return null; return null;
@@ -558,6 +618,8 @@ export default function PositionRoundtablePage() {
> >
{positionSession.status === "archived" {positionSession.status === "archived"
? "会话已归档" ? "会话已归档"
: positionSession.status === "completed"
? "行动规划已生成"
: positionSession.status === "active" : positionSession.status === "active"
? "业务链已启动" ? "业务链已启动"
: "意图识别中"} : "意图识别中"}
@@ -635,6 +697,7 @@ export default function PositionRoundtablePage() {
<BusinessChainFlow <BusinessChainFlow
chain={activeChain} chain={activeChain}
nodes={positionSession?.nodes ?? []} nodes={positionSession?.nodes ?? []}
session={positionSession}
activeNodeKey={activeNodeKey} activeNodeKey={activeNodeKey}
onSelectNode={(node) => { onSelectNode={(node) => {
setActiveNodeKey(node.nodeKey); setActiveNodeKey(node.nodeKey);
@@ -726,6 +789,7 @@ export default function PositionRoundtablePage() {
intentCompletedAt={intentCompletedAt} intentCompletedAt={intentCompletedAt}
assignedAgentIds={assignedAgentIds} assignedAgentIds={assignedAgentIds}
hasSelectedChain={Boolean(selectedChain)} hasSelectedChain={Boolean(selectedChain)}
session={positionSession}
onConfigureChain={ onConfigureChain={
selectedChain ? () => openPositionConfig(selectedChain.id) : undefined selectedChain ? () => openPositionConfig(selectedChain.id) : undefined
} }
@@ -735,6 +799,7 @@ export default function PositionRoundtablePage() {
setActiveNodeKey(node.nodeKey); setActiveNodeKey(node.nodeKey);
setSelectedAgentId(node.agentId); setSelectedAgentId(node.agentId);
}} }}
onSessionUpdate={setPositionSession}
/> />
</aside> </aside>
</div> </div>
@@ -1109,11 +1174,13 @@ const FLOW_NODE_GAP = 10;
function BusinessChainFlow({ function BusinessChainFlow({
chain, chain,
nodes, nodes,
session,
activeNodeKey, activeNodeKey,
onSelectNode, onSelectNode,
}: { }: {
chain: BusinessChain | null; chain: BusinessChain | null;
nodes: PositionRoundtableNode[]; nodes: PositionRoundtableNode[];
session: PositionRoundtableSession | null;
activeNodeKey: string | null; activeNodeKey: string | null;
onSelectNode: (node: PositionRoundtableNode) => void; onSelectNode: (node: PositionRoundtableNode) => void;
}) { }) {
@@ -1134,11 +1201,11 @@ function BusinessChainFlow({
<TButton <TButton
size="small" size="small"
variant="text" variant="text"
icon={<Eye className="size-3.5" />} shape="circle"
icon={<Search className="size-3.5" />}
onClick={() => setFlowDialogOpen(true)} onClick={() => setFlowDialogOpen(true)}
> title="查看完整流程图"
查看 />
</TButton>
)} )}
</div> </div>
{!chain ? ( {!chain ? (
@@ -1150,12 +1217,13 @@ function BusinessChainFlow({
<ChainFlowCanvas <ChainFlowCanvas
chain={chain} chain={chain}
nodes={nodes} nodes={nodes}
session={session}
activeNodeKey={activeNodeKey} activeNodeKey={activeNodeKey}
onSelectNode={onSelectNode} onSelectNode={onSelectNode}
className="h-[456px]" className="h-[456px]"
/> />
<Dialog open={flowDialogOpen} onOpenChange={setFlowDialogOpen}> <Dialog open={flowDialogOpen} onOpenChange={setFlowDialogOpen}>
<DialogContent className="flex h-[82vh] max-w-[1040px] flex-col gap-0 overflow-hidden p-0"> <DialogContent className="flex h-[88vh] w-[96vw] !max-w-[96vw] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="shrink-0 border-b px-5 py-4 dark:border-[#2b8ffe]/25"> <DialogHeader className="shrink-0 border-b px-5 py-4 dark:border-[#2b8ffe]/25">
<DialogTitle className="flex items-center gap-2 text-base"> <DialogTitle className="flex items-center gap-2 text-base">
<Layers3 className="size-4 text-primary" /> <Layers3 className="size-4 text-primary" />
@@ -1166,6 +1234,7 @@ function BusinessChainFlow({
<ChainFlowCanvas <ChainFlowCanvas
chain={chain} chain={chain}
nodes={nodes} nodes={nodes}
session={session}
activeNodeKey={activeNodeKey} activeNodeKey={activeNodeKey}
onSelectNode={(node) => { onSelectNode={(node) => {
onSelectNode(node); onSelectNode(node);
@@ -1186,6 +1255,7 @@ function BusinessChainFlow({
function ChainFlowCanvas({ function ChainFlowCanvas({
chain, chain,
nodes, nodes,
session,
activeNodeKey, activeNodeKey,
onSelectNode, onSelectNode,
className, className,
@@ -1193,6 +1263,7 @@ function ChainFlowCanvas({
}: { }: {
chain: BusinessChain; chain: BusinessChain;
nodes: PositionRoundtableNode[]; nodes: PositionRoundtableNode[];
session: PositionRoundtableSession | null;
activeNodeKey: string | null; activeNodeKey: string | null;
onSelectNode: (node: PositionRoundtableNode) => void; onSelectNode: (node: PositionRoundtableNode) => void;
className?: string; className?: string;
@@ -1210,6 +1281,11 @@ function ChainFlowCanvas({
const nodeWidth = expanded ? 190 : FLOW_NODE_WIDTH; const nodeWidth = expanded ? 190 : FLOW_NODE_WIDTH;
const nodeGap = expanded ? 16 : FLOW_NODE_GAP; const nodeGap = expanded ? 16 : FLOW_NODE_GAP;
const allDone = allExecutableNodesDone(nodes); const allDone = allExecutableNodesDone(nodes);
const summaryStatus = builtinNodeStatus(session?.summarySnapshot, allDone);
const actionPlanStatus = builtinNodeStatus(
session?.actionPlanSnapshot,
snapshotStatus(session?.summarySnapshot) === "done",
);
const onPanStart = (event: React.PointerEvent<HTMLDivElement>) => { const onPanStart = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return; if (event.button !== 0) return;
@@ -1322,18 +1398,18 @@ function ChainFlowCanvas({
title="方案总结智能体" title="方案总结智能体"
description="内置总结节点" description="内置总结节点"
accent="#6366f1" accent="#6366f1"
nodeStatus={allDone ? "ready" : "locked"} nodeStatus={summaryStatus}
width={nodeWidth} width={nodeWidth}
titleAttr="待接入:对所有有效交付产物进行方案总结" titleAttr="对所有有效交付产物进行方案总结"
/> />
<VerticalFlowConnector seatCount={1} width={nodeWidth} nodeWidth={nodeWidth} nodeGap={nodeGap} /> <VerticalFlowConnector seatCount={1} width={nodeWidth} nodeWidth={nodeWidth} nodeGap={nodeGap} />
<CompactChainNode <CompactChainNode
title="行动规划智能体" title="行动规划智能体"
description="内置规划节点" description="内置规划节点"
accent="#0ea5e9" accent="#0ea5e9"
nodeStatus="locked" nodeStatus={actionPlanStatus}
width={nodeWidth} width={nodeWidth}
titleAttr="待接入:基于方案总结和岗位产物拆解子任务" titleAttr="基于方案总结和岗位产物拆解子任务"
/> />
</div> </div>
</div> </div>
@@ -1353,7 +1429,11 @@ function VerticalFlowConnector({
}) { }) {
const nodeCenter = nodeWidth / 2; const nodeCenter = nodeWidth / 2;
if (seatCount <= 1) { if (seatCount <= 1) {
return <span className="h-7 w-px bg-slate-300 dark:bg-[#2b8ffe]/55" />; return (
<span className="relative h-7 w-px bg-slate-300 dark:bg-[#2b8ffe]/55">
<span className="absolute -bottom-0.5 left-1/2 size-1.5 -translate-x-1/2 rotate-45 border-b border-r border-slate-300 dark:border-[#2b8ffe]/55" />
</span>
);
} }
return ( return (
@@ -1370,7 +1450,9 @@ function VerticalFlowConnector({
style={{ style={{
left: `${((index * (nodeWidth + nodeGap) + nodeCenter) / width) * 100}%`, left: `${((index * (nodeWidth + nodeGap) + nodeCenter) / width) * 100}%`,
}} }}
/> >
<span className="absolute -bottom-0.5 left-1/2 size-1.5 -translate-x-1/2 rotate-45 border-b border-r border-slate-300 dark:border-[#2b8ffe]/55" />
</span>
))} ))}
</div> </div>
); );
@@ -1442,10 +1524,12 @@ function RightWorkspace({
intentCompletedAt, intentCompletedAt,
assignedAgentIds, assignedAgentIds,
hasSelectedChain, hasSelectedChain,
session,
onConfigureChain, onConfigureChain,
nodes, nodes,
chain, chain,
onSelectNode, onSelectNode,
onSessionUpdate,
}: { }: {
selectedAgent: Agent | null; selectedAgent: Agent | null;
selectedAgentId: string | null; selectedAgentId: string | null;
@@ -1455,10 +1539,12 @@ function RightWorkspace({
intentCompletedAt: string; intentCompletedAt: string;
assignedAgentIds: Set<string>; assignedAgentIds: Set<string>;
hasSelectedChain: boolean; hasSelectedChain: boolean;
session: PositionRoundtableSession | null;
onConfigureChain?: () => void; onConfigureChain?: () => void;
nodes: PositionRoundtableNode[]; nodes: PositionRoundtableNode[];
chain: BusinessChain | null; chain: BusinessChain | null;
onSelectNode: (node: PositionRoundtableNode) => void; onSelectNode: (node: PositionRoundtableNode) => void;
onSessionUpdate: (session: PositionRoundtableSession) => void;
}) { }) {
const [tab, setTab] = useState<WorkspaceTab>("agents"); const [tab, setTab] = useState<WorkspaceTab>("agents");
@@ -1540,7 +1626,9 @@ function RightWorkspace({
intentCompletedAt={intentCompletedAt} intentCompletedAt={intentCompletedAt}
nodes={nodes} nodes={nodes}
chain={chain} chain={chain}
session={session}
onSelectNode={onSelectNode} onSelectNode={onSelectNode}
onSessionUpdate={onSessionUpdate}
/> />
</div> </div>
)} )}
@@ -1706,20 +1794,8 @@ function PositionAgentList({
); );
} }
function AnalysisResults({ type ResultKind = "intent" | "delivery" | "artifact" | "summary" | "action_plan";
intentObjective,
intentCompletedAt,
nodes,
chain,
onSelectNode,
}: {
intentObjective?: string;
intentCompletedAt: string;
nodes: PositionRoundtableNode[];
chain: BusinessChain | null;
onSelectNode: (node: PositionRoundtableNode) => void;
}) {
type ResultKind = "intent" | "delivery" | "artifact";
interface ResultItem { interface ResultItem {
id: string; id: string;
kind: ResultKind; kind: ResultKind;
@@ -1730,15 +1806,52 @@ function AnalysisResults({
positionId: string | null; positionId: string | null;
updatedAt: string | undefined; updatedAt: string | undefined;
status?: NodeVisualStatus; status?: NodeVisualStatus;
artifactPath?: string;
artifactMimeType?: string | null;
} }
function AnalysisResults({
intentObjective,
intentCompletedAt,
nodes,
chain,
session,
onSelectNode,
onSessionUpdate,
}: {
intentObjective?: string;
intentCompletedAt: string;
nodes: PositionRoundtableNode[];
chain: BusinessChain | null;
session: PositionRoundtableSession | null;
onSelectNode: (node: PositionRoundtableNode) => void;
onSessionUpdate: (session: PositionRoundtableSession) => void;
}) {
const [positionFilter, setPositionFilter] = useState("all"); const [positionFilter, setPositionFilter] = useState("all");
const [nodeFilter, setNodeFilter] = useState("all"); const [nodeFilter, setNodeFilter] = useState("all");
const [kindFilter, setKindFilter] = useState<"all" | ResultKind>("all"); const [kindFilter, setKindFilter] = useState<"all" | ResultKind>("all");
const [detailItem, setDetailItem] = useState<ResultItem | null>(null);
const [rejectNode, setRejectNode] = useState<PositionRoundtableNode | null>(null);
const [rejectTitle, setRejectTitle] = useState("");
const [rejectReason, setRejectReason] = useState("");
const [rejectSubmitting, setRejectSubmitting] = useState(false);
const [summarySubmitting, setSummarySubmitting] = useState(false);
const [actionPlanSubmitting, setActionPlanSubmitting] = useState(false);
const [previewArtifact, setPreviewArtifact] = useState<{
threadId: string;
path: string;
name: string;
mimeType: string | null;
} | null>(null);
const seatsByAgentId = useMemo( const seatsByAgentId = useMemo(
() => new Map((chain?.seats ?? []).map((seat) => [seat.agent_id, seat])), () => new Map((chain?.seats ?? []).map((seat) => [seat.agent_id, seat])),
[chain], [chain],
); );
const allDelivered = allExecutableNodesDone(nodes);
const summaryDone = snapshotStatus(session?.summarySnapshot) === "done";
const summaryStale = snapshotStatus(session?.summarySnapshot) === "stale";
const actionPlanDone = snapshotStatus(session?.actionPlanSnapshot) === "done";
const actionPlanStale = snapshotStatus(session?.actionPlanSnapshot) === "stale";
const resultItems = useMemo<ResultItem[]>(() => { const resultItems = useMemo<ResultItem[]>(() => {
const items: ResultItem[] = []; const items: ResultItem[] = [];
if (intentObjective) { if (intentObjective) {
@@ -1773,7 +1886,9 @@ function AnalysisResults({
const summary = const summary =
node.latestAnswer || node.latestAnswer ||
(node.status === "locked" (node.status === "locked"
? "等待上游阶段完成后解锁。" ? node.invalidatedBy
? "上游产物已被驳回或重做,等待前序节点重新完成。"
: "等待上游阶段完成后解锁。"
: node.status === "running" : node.status === "running"
? "智能体正在生成回答与产物。" ? "智能体正在生成回答与产物。"
: node.status === "stale" : node.status === "stale"
@@ -1814,11 +1929,49 @@ function AnalysisResults({
positionId: node.positionId, positionId: node.positionId,
updatedAt: node.updatedAt, updatedAt: node.updatedAt,
status: node.status, status: node.status,
artifactPath: path,
artifactMimeType: mimeType,
}); });
}); });
} }
const summarySnapshot = session?.summarySnapshot;
if (summarySnapshot) {
items.push({
id: "builtin:summary",
kind: "summary",
node: null,
agentName: "方案总结智能体",
productName: snapshotText(summarySnapshot, "title") || "岗位会商方案总结报告",
summary:
snapshotText(summarySnapshot, "content") ||
(snapshotStatus(summarySnapshot) === "stale"
? "前序岗位产物已变更,需要重新生成方案总结。"
: "方案总结已生成。"),
positionId: "summary",
updatedAt: snapshotText(summarySnapshot, "generated_at") || undefined,
status: builtinNodeStatus(summarySnapshot, true),
});
}
const actionPlanSnapshot = session?.actionPlanSnapshot;
if (actionPlanSnapshot) {
items.push({
id: "builtin:action-plan",
kind: "action_plan",
node: null,
agentName: "行动规划智能体",
productName: snapshotText(actionPlanSnapshot, "title") || "岗位会商行动规划",
summary:
snapshotText(actionPlanSnapshot, "content") ||
(snapshotStatus(actionPlanSnapshot) === "stale"
? "前序岗位产物已变更,需要重新生成行动规划。"
: "行动规划已生成。"),
positionId: "action_plan",
updatedAt: snapshotText(actionPlanSnapshot, "generated_at") || undefined,
status: builtinNodeStatus(actionPlanSnapshot, true),
});
}
return items.sort((left, right) => (right.updatedAt ?? "").localeCompare(left.updatedAt ?? "")); return items.sort((left, right) => (right.updatedAt ?? "").localeCompare(left.updatedAt ?? ""));
}, [intentObjective, nodes, seatsByAgentId]); }, [intentObjective, nodes, seatsByAgentId, session?.actionPlanSnapshot, session?.summarySnapshot]);
const positionOptions = useMemo( const positionOptions = useMemo(
() => [ () => [
{ label: "全部岗位", value: "all" }, { label: "全部岗位", value: "all" },
@@ -1851,6 +2004,91 @@ function AnalysisResults({
[kindFilter, nodeFilter, positionFilter, resultItems], [kindFilter, nodeFilter, positionFilter, resultItems],
); );
const openDetail = useCallback(
(item: ResultItem) => {
setDetailItem(item);
if (item.node) {
onSelectNode(item.node);
}
},
[onSelectNode],
);
const openRejectDialog = useCallback((node: PositionRoundtableNode) => {
setRejectNode(node);
setRejectTitle(nodeRejectionTitle(node));
setRejectReason("");
}, []);
const handleRejectSubmit = useCallback(async () => {
if (!rejectNode) return;
const title = rejectTitle.trim();
const reason = rejectReason.trim();
if (!title || !reason) {
toast.warning("请填写驳回标题和理由");
return;
}
setRejectSubmitting(true);
try {
const session = await rejectPositionNode(rejectNode.sessionId, rejectNode.nodeKey, {
title,
reason,
});
onSessionUpdate(session);
setRejectNode(null);
setRejectTitle("");
setRejectReason("");
setDetailItem(null);
toast.success("已驳回该产物,后续阶段已标记为需重新完成");
} catch (error) {
toast.error(`驳回失败:${error instanceof Error ? error.message : String(error)}`);
} finally {
setRejectSubmitting(false);
}
}, [onSessionUpdate, rejectNode, rejectReason, rejectTitle]);
const handleGenerateSummary = useCallback(async () => {
if (!session) {
toast.warning("请先启动业务链");
return;
}
if (!allDelivered) {
toast.warning("所有岗位产物完成后才能生成方案总结");
return;
}
setSummarySubmitting(true);
try {
const next = await generatePositionSummary(session.id);
onSessionUpdate(next);
toast.success("方案总结已生成");
} catch (error) {
toast.error(`生成方案总结失败:${error instanceof Error ? error.message : String(error)}`);
} finally {
setSummarySubmitting(false);
}
}, [allDelivered, onSessionUpdate, session]);
const handleGenerateActionPlan = useCallback(async () => {
if (!session) {
toast.warning("请先启动业务链");
return;
}
if (!summaryDone) {
toast.warning("请先生成有效的方案总结");
return;
}
setActionPlanSubmitting(true);
try {
const next = await generatePositionActionPlan(session.id);
onSessionUpdate(next);
toast.success("行动规划已生成");
} catch (error) {
toast.error(`生成行动规划失败:${error instanceof Error ? error.message : String(error)}`);
} finally {
setActionPlanSubmitting(false);
}
}, [onSessionUpdate, session, summaryDone]);
if (resultItems.length === 0) { if (resultItems.length === 0) {
return ( return (
<div className="flex h-full min-h-[260px] flex-col items-center justify-center rounded-xl border border-dashed p-6 text-center"> <div className="flex h-full min-h-[260px] flex-col items-center justify-center rounded-xl border border-dashed p-6 text-center">
@@ -1864,6 +2102,7 @@ function AnalysisResults({
} }
return ( return (
<>
<div className="space-y-3 rounded-xl border border-slate-200 p-3.5 dark:border-border"> <div className="space-y-3 rounded-xl border border-slate-200 p-3.5 dark:border-border">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
@@ -1885,10 +2124,62 @@ function AnalysisResults({
{ label: "意图理解", value: "intent" }, { label: "意图理解", value: "intent" },
{ label: "节点交付", value: "delivery" }, { label: "节点交付", value: "delivery" },
{ label: "文件产物", value: "artifact" }, { label: "文件产物", value: "artifact" },
{ label: "方案总结", value: "summary" },
{ label: "行动规划", value: "action_plan" },
]} ]}
onChange={(value) => setKindFilter(String(value) as "all" | ResultKind)} onChange={(value) => setKindFilter(String(value) as "all" | ResultKind)}
/> />
</div> </div>
{nodes.length > 0 && (
<div className="rounded-xl border border-slate-200 bg-slate-50/70 p-3 dark:border-[#2b8ffe]/30 dark:bg-[#032650]/65">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-xs font-bold text-slate-800 dark:text-[#e8edf5]">
内置收口智能体
</p>
<p className="mt-0.5 truncate text-[11px] text-muted-foreground dark:text-[#8aa0c4]">
全部岗位交付后生成方案总结,再生成行动规划
</p>
</div>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold",
allDelivered
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: "bg-slate-500/10 text-slate-500 dark:text-[#8aa0c4]",
)}
>
{nodes.filter((node) => node.status === "done").length}/{nodes.length} 已交付
</span>
</div>
<div className="grid grid-cols-1 gap-2">
<TButton
block
size="small"
theme={summaryStale ? "warning" : "primary"}
variant={summaryDone ? "outline" : "base"}
loading={summarySubmitting}
disabled={!allDelivered || summarySubmitting}
icon={<Sparkles className="size-3.5" />}
onClick={() => void handleGenerateSummary()}
>
{summaryDone ? "重新生成方案总结" : summaryStale ? "重新生成失效总结" : "生成方案总结"}
</TButton>
<TButton
block
size="small"
theme={actionPlanStale ? "warning" : "primary"}
variant={actionPlanDone ? "outline" : "base"}
loading={actionPlanSubmitting}
disabled={!summaryDone || actionPlanSubmitting}
icon={<Target className="size-3.5" />}
onClick={() => void handleGenerateActionPlan()}
>
{actionPlanDone ? "重新生成行动规划" : actionPlanStale ? "重新生成失效规划" : "生成行动规划"}
</TButton>
</div>
</div>
)}
{visibleItems.length === 0 ? ( {visibleItems.length === 0 ? (
<div className="rounded-xl border border-dashed p-5 text-center text-xs text-muted-foreground"> <div className="rounded-xl border border-dashed p-5 text-center text-xs text-muted-foreground">
当前筛选条件下没有结果 当前筛选条件下没有结果
@@ -1905,6 +2196,7 @@ function AnalysisResults({
}).format(new Date(item.updatedAt)) }).format(new Date(item.updatedAt))
: intentCompletedAt || "刚刚"; : intentCompletedAt || "刚刚";
const stale = item.node?.status === "stale"; const stale = item.node?.status === "stale";
const invalidated = Boolean(item.node?.invalidatedBy);
const status = item.status ? nodeStatusMeta(item.status) : null; const status = item.status ? nodeStatusMeta(item.status) : null;
const StatusIcon = status?.Icon; const StatusIcon = status?.Icon;
const contents = ( const contents = (
@@ -1939,7 +2231,7 @@ function AnalysisResults({
{status.label} {status.label}
</span> </span>
)} )}
{stale && ( {(stale || invalidated) && (
<span className="shrink-0 rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[9px] font-semibold text-amber-700 dark:text-amber-300"> <span className="shrink-0 rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[9px] font-semibold text-amber-700 dark:text-amber-300">
上游已更新 上游已更新
</span> </span>
@@ -1956,7 +2248,7 @@ function AnalysisResults({
<footer className="mt-4 flex items-center justify-between border-t pt-3 text-[11px] text-muted-foreground"> <footer className="mt-4 flex items-center justify-between border-t pt-3 text-[11px] text-muted-foreground">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<BriefcaseBusiness className="size-3.5" /> <BriefcaseBusiness className="size-3.5" />
{positionNameById(item.positionId)} {resultPositionName(item.positionId)}
</span> </span>
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Clock3 className="size-3.5" /> <Clock3 className="size-3.5" />
@@ -1969,18 +2261,272 @@ function AnalysisResults({
<button <button
key={item.id} key={item.id}
type="button" type="button"
onClick={() => onSelectNode(item.node!)} onClick={() => openDetail(item)}
className="w-full rounded-xl border border-slate-200 bg-card p-3.5 text-left shadow-sm transition hover:border-primary/45 hover:shadow-md dark:border-border" className={cn(
"w-full rounded-xl border border-slate-200 bg-card p-3.5 text-left shadow-sm transition hover:border-primary/45 hover:shadow-md dark:border-border",
item.node.status === "rejected" && "border-rose-200 bg-rose-50/60 dark:border-rose-500/35 dark:bg-rose-950/20",
invalidated && "border-amber-200 bg-amber-50/60 dark:border-amber-500/35 dark:bg-amber-950/20",
)}
> >
{contents} {contents}
</button> </button>
) : ( ) : (
<article key={item.id} className="rounded-xl border border-slate-200 bg-card p-3.5 shadow-sm dark:border-border"> <button
key={item.id}
type="button"
onClick={() => openDetail(item)}
className="w-full rounded-xl border border-slate-200 bg-card p-3.5 text-left shadow-sm transition hover:border-primary/45 hover:shadow-md dark:border-border"
>
{contents} {contents}
</article> </button>
); );
}) })
)} )}
</div> </div>
<DeliverableDetailDialog
item={detailItem}
onOpenChange={(open) => {
if (!open) setDetailItem(null);
}}
onReject={(node) => openRejectDialog(node)}
onPreviewArtifact={(item) => {
if (!item.node?.threadId || !item.artifactPath) {
toast.info("该文件产物暂无可预览路径");
return;
}
setPreviewArtifact({
threadId: item.node.threadId,
path: item.artifactPath,
name: item.productName,
mimeType: item.artifactMimeType ?? null,
});
}}
/>
<RejectDeliveryDialog
node={rejectNode}
title={rejectTitle}
reason={rejectReason}
submitting={rejectSubmitting}
onOpenChange={(open) => {
if (!open && !rejectSubmitting) {
setRejectNode(null);
setRejectTitle("");
setRejectReason("");
}
}}
onTitleChange={setRejectTitle}
onReasonChange={setRejectReason}
onSubmit={() => void handleRejectSubmit()}
/>
{previewArtifact && (
<ArtifactPreviewModal
threadId={previewArtifact.threadId}
path={previewArtifact.path}
name={previewArtifact.name}
mimeType={previewArtifact.mimeType}
onClose={() => setPreviewArtifact(null)}
/>
)}
</>
);
}
function DeliverableDetailDialog({
item,
onOpenChange,
onReject,
onPreviewArtifact,
}: {
item: ResultItem | null;
onOpenChange: (open: boolean) => void;
onReject: (node: PositionRoundtableNode) => void;
onPreviewArtifact: (item: ResultItem) => void;
}) {
const status = item?.status ? nodeStatusMeta(item.status) : null;
const StatusIcon = status?.Icon;
const canReject = item?.node ? canRejectNodeDelivery(item.node) : false;
const rejectionTitle = item?.node ? nodeRejectionTitle(item.node) : "";
const rejectionReason = item?.node ? nodeRejectionReason(item.node) : "";
const displayedTime = item?.updatedAt
? new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(new Date(item.updatedAt))
: "";
return (
<Dialog open={Boolean(item)} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[82vh] w-[720px] !max-w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="shrink-0 border-b px-5 py-4 dark:border-[#2b8ffe]/25">
<DialogTitle className="flex items-center gap-2 text-base">
<FileOutput className="size-4 text-primary" />
交付产物详情
</DialogTitle>
</DialogHeader>
{!item ? null : (
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-extrabold text-slate-900 dark:text-[#e8edf5]">
{desensitize(item.productName)}
</p>
<p className="mt-1 truncate text-xs text-muted-foreground">
{desensitize(item.agentName)} · {resultPositionName(item.positionId)}
</p>
</div>
{status && StatusIcon && (
<span
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-full px-2 py-1 text-[11px] font-semibold",
status.badgeClassName,
)}
>
<StatusIcon className={cn("size-3.5", status.spin && "animate-spin")} />
{status.label}
</span>
)}
</div>
<div className="mt-4 grid grid-cols-2 gap-2 text-[11px] text-muted-foreground">
<div className="rounded-lg bg-slate-50 px-3 py-2 dark:bg-[#032650]">
岗位:{resultPositionName(item.positionId)}
</div>
<div className="rounded-lg bg-slate-50 px-3 py-2 dark:bg-[#032650]">
时间:{displayedTime || "暂无"}
</div>
{item.node && (
<>
<div className="rounded-lg bg-slate-50 px-3 py-2 dark:bg-[#032650]">
版本:第 {item.node.revision} 版
</div>
<div className="rounded-lg bg-slate-50 px-3 py-2 dark:bg-[#032650]">
驳回次数:{item.node.rejectionCount}
</div>
</>
)}
</div>
{item.node?.invalidatedBy && (
<div className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800 dark:border-amber-500/35 dark:bg-amber-950/25 dark:text-amber-200">
该产物受上游节点「{item.node.invalidatedBy}」影响,已不再作为后续总结或问答输入。
</div>
)}
{rejectionTitle && (
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-xs leading-5 text-rose-800 dark:border-rose-500/35 dark:bg-rose-950/25 dark:text-rose-200">
<p className="font-semibold">最近驳回:{desensitize(rejectionTitle)}</p>
{rejectionReason && <p className="mt-1">{desensitize(rejectionReason)}</p>}
</div>
)}
<div className="mt-4 rounded-xl border border-slate-200 bg-white p-3 dark:border-[#2b8ffe]/30 dark:bg-[#042b59]/65">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-xs font-bold text-slate-700 dark:text-[#e8edf5]">
{item.kind === "artifact" ? "文件产物" : "产物内容"}
</p>
{item.kind === "artifact" && (
<TButton
size="small"
variant="outline"
icon={<FileText className="size-3.5" />}
onClick={() => onPreviewArtifact(item)}
>
查看文件
</TButton>
)}
</div>
<p className="whitespace-pre-wrap text-xs leading-6 text-slate-600 dark:text-[#c5cfe0]">
{desensitize(item.summary || "暂无产物正文")}
</p>
</div>
</div>
)}
<DialogFooter className="shrink-0 border-t px-5 py-3 dark:border-[#2b8ffe]/25">
{item?.node && (
<TButton
theme="danger"
variant="outline"
icon={<RotateCcw className="size-3.5" />}
disabled={!canReject}
onClick={() => onReject(item.node!)}
>
驳回产物
</TButton>
)}
<TButton variant="outline" onClick={() => onOpenChange(false)}>
关闭
</TButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function RejectDeliveryDialog({
node,
title,
reason,
submitting,
onOpenChange,
onTitleChange,
onReasonChange,
onSubmit,
}: {
node: PositionRoundtableNode | null;
title: string;
reason: string;
submitting: boolean;
onOpenChange: (open: boolean) => void;
onTitleChange: (value: string) => void;
onReasonChange: (value: string) => void;
onSubmit: () => void;
}) {
return (
<Dialog open={Boolean(node)} onOpenChange={onOpenChange}>
<DialogContent className="w-[560px] !max-w-[calc(100vw-2rem)]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base">
<RotateCcw className="size-4 text-destructive" />
驳回交付产物
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800 dark:border-amber-500/35 dark:bg-amber-950/25 dark:text-amber-200">
驳回后,该节点会回到需重做状态;其后续阶段的产物会一起失效,但历史内容仍可查看。
</div>
<label className="block space-y-1.5">
<span className="text-xs font-semibold text-slate-700 dark:text-[#c5cfe0]">驳回标题</span>
<TInput
value={title}
placeholder="例如:依据不足,需要补充约束条件"
onChange={onTitleChange}
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold text-slate-700 dark:text-[#c5cfe0]">驳回理由</span>
<Textarea
rows={5}
value={reason}
placeholder="请说明需要重做的原因、补充材料或调整方向"
onChange={(event) => onReasonChange(event.target.value)}
/>
</label>
</div>
<DialogFooter>
<TButton variant="outline" disabled={submitting} onClick={() => onOpenChange(false)}>
取消
</TButton>
<TButton theme="danger" loading={submitting} onClick={onSubmit}>
确认驳回
</TButton>
</DialogFooter>
</DialogContent>
</Dialog>
); );
} }
@@ -9,6 +9,7 @@ messages and artifacts stay in the established LangGraph thread stores.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -52,6 +53,9 @@ class NodeResponse(BaseModel):
artifact_manifest: list[dict[str, Any]] = Field(default_factory=list) artifact_manifest: list[dict[str, Any]] = Field(default_factory=list)
revision: int = 0 revision: int = 0
upstream_revision: dict[str, Any] | None = None upstream_revision: dict[str, Any] | None = None
rejection_count: int = 0
last_rejection: dict[str, Any] | None = None
invalidated_by: str | None = None
created_at: str | None = None created_at: str | None = None
updated_at: str | None = None updated_at: str | None = None
@@ -64,6 +68,8 @@ class SessionResponse(BaseModel):
chain_snapshot: dict[str, Any] | None = None chain_snapshot: dict[str, Any] | None = None
intent_thread_id: str | None = None intent_thread_id: str | None = None
intent_snapshot: dict[str, Any] | None = None intent_snapshot: dict[str, Any] | None = None
summary_snapshot: dict[str, Any] | None = None
action_plan_snapshot: dict[str, Any] | None = None
status: str status: str
created_at: str | None = None created_at: str | None = None
updated_at: str | None = None updated_at: str | None = None
@@ -101,6 +107,11 @@ class CompleteTurnRequest(BaseModel):
upstream_revision: dict[str, Any] | None = None upstream_revision: dict[str, Any] | None = None
class RejectNodeRequest(BaseModel):
title: str = Field(min_length=1, max_length=120)
reason: str = Field(min_length=1, max_length=5_000)
def _current_user_id(request: Request) -> str: def _current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None) user = getattr(request.state, "user", None)
return str(user.id) if user is not None else get_effective_user_id() return str(user.id) if user is not None else get_effective_user_id()
@@ -229,6 +240,175 @@ def _truncate_text(value: Any, limit: int) -> str:
return f"{text[:limit]}\n…(已按交接上下文长度截断)" return f"{text[:limit]}\n…(已按交接上下文长度截断)"
def _text_from_keys(source: dict[str, Any], keys: list[str], fallback: str = "") -> str:
for key in keys:
value = source.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return fallback
def _seat_meta(session: dict[str, Any], node: dict[str, Any]) -> tuple[str, str]:
chain = session.get("chain_snapshot") if isinstance(session.get("chain_snapshot"), dict) else {}
seats = chain.get("seats") if isinstance(chain.get("seats"), list) else []
seat = next(
(
item
for item in seats
if isinstance(item, dict) and str(item.get("agent_id") or "") == str(node.get("agent_id") or "")
),
{},
)
agent_name = str(seat.get("name") or node.get("agent_id") or "未命名智能体")
position_id = str(node.get("position_id") or seat.get("position_id") or "未配置岗位")
return agent_name, position_id
def _delivered_nodes(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [node for node in nodes if node.get("status") == "done"]
def _all_nodes_delivered(nodes: list[dict[str, Any]]) -> bool:
return bool(nodes) and all(node.get("status") == "done" for node in nodes)
def _artifact_lines(node: dict[str, Any]) -> list[str]:
artifacts = node.get("artifact_manifest")
if not isinstance(artifacts, list) or not artifacts:
return []
lines: list[str] = []
for artifact in artifacts[:8]:
if not isinstance(artifact, dict):
continue
name = artifact.get("name") or artifact.get("filename") or artifact.get("path") or "未命名产物"
path = artifact.get("path") or artifact.get("artifact_url") or ""
lines.append(f" - {name}{f'({path})' if path else ''}")
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]]: def _artifact_refs(artifacts: Any) -> list[dict[str, Any]]:
if not isinstance(artifacts, list): if not isinstance(artifacts, list):
return [] return []
@@ -342,6 +522,8 @@ async def _handoff_context(request: Request, session_id: str, node_key: str) ->
raise HTTPException(status_code=404, detail="Position roundtable node not found") raise HTTPException(status_code=404, detail="Position roundtable node not found")
if current["status"] == "locked": if current["status"] == "locked":
raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁") raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁")
if current.get("invalidated_by"):
raise HTTPException(status_code=409, detail="上游产物已被驳回或重做,当前节点需等待前序节点重新完成")
# Same-stage nodes are intentionally excluded. The business chain only # Same-stage nodes are intentionally excluded. The business chain only
# hands off completed previous stages, in stage/seat order. # hands off completed previous stages, in stage/seat order.
@@ -350,7 +532,7 @@ async def _handoff_context(request: Request, session_id: str, node_key: str) ->
node node
for node in nodes for node in nodes
if node["stage_index"] < current["stage_index"] if node["stage_index"] < current["stage_index"]
and node["status"] in {"done", "stale"} and node["status"] == "done"
] ]
upstream_nodes: list[dict[str, Any]] = [] upstream_nodes: list[dict[str, Any]] = []
for index, node in enumerate(upstream_candidates): for index, node in enumerate(upstream_candidates):
@@ -509,6 +691,8 @@ async def bind_node_thread(
raise HTTPException(status_code=404, detail="Position roundtable node not found") raise HTTPException(status_code=404, detail="Position roundtable node not found")
if current["status"] == "locked": if current["status"] == "locked":
raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁") raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁")
if current.get("invalidated_by"):
raise HTTPException(status_code=409, detail="上游产物已被驳回或重做,当前节点需等待前序节点重新完成")
if current.get("thread_id") and current["thread_id"] != body.thread_id: if current.get("thread_id") and current["thread_id"] != body.thread_id:
raise HTTPException(status_code=409, detail="该节点已绑定另一条对话线程") raise HTTPException(status_code=409, detail="该节点已绑定另一条对话线程")
row = await store.update_node( row = await store.update_node(
@@ -537,6 +721,8 @@ async def complete_node_turn(
raise HTTPException(status_code=404, detail="Position roundtable node not found") raise HTTPException(status_code=404, detail="Position roundtable node not found")
if node["status"] == "locked": if node["status"] == "locked":
raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁") raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁")
if node.get("invalidated_by"):
raise HTTPException(status_code=409, detail="上游产物已被驳回或重做,当前节点需等待前序节点重新完成")
row = await store.update_node( row = await store.update_node(
session_id, session_id,
node_key, node_key,
@@ -545,6 +731,7 @@ async def complete_node_turn(
latest_answer=body.latest_answer, latest_answer=body.latest_answer,
artifact_manifest=body.artifact_manifest, artifact_manifest=body.artifact_manifest,
upstream_revision=body.upstream_revision, upstream_revision=body.upstream_revision,
invalidated_by=None,
increment_revision=True, increment_revision=True,
) )
if row is None: if row is None:
@@ -562,13 +749,19 @@ async def complete_node_turn(
for downstream in nodes: for downstream in nodes:
if ( if (
downstream["stage_index"] > row["stage_index"] downstream["stage_index"] > row["stage_index"]
and downstream["status"] in {"ready", "running", "done", "stale"} and downstream["status"] in {"ready", "running", "done", "stale", "rejected", "error"}
): ):
has_material = (
downstream.get("revision", 0) > 0
or bool(downstream.get("latest_answer"))
or bool(downstream.get("artifact_manifest"))
)
await store.update_node( await store.update_node(
session_id, session_id,
downstream["node_key"], downstream["node_key"],
user_id, user_id,
status="stale", status="stale" if has_material else "locked",
invalidated_by=row["node_key"],
) )
nodes = await store.list_nodes(session_id, user_id) or [] nodes = await store.list_nodes(session_id, user_id) or []
@@ -579,16 +772,93 @@ async def complete_node_turn(
if current_stage and all(item["status"] == "done" for item in current_stage) and later_stages: if current_stage and all(item["status"] == "done" for item in current_stage) and later_stages:
next_stage = later_stages[0] next_stage = later_stages[0]
for next_node in nodes: for next_node in nodes:
if next_node["stage_index"] == next_stage and next_node["status"] == "locked": if next_node["stage_index"] == next_stage and next_node["status"] in {"locked", "stale"}:
await store.update_node( await store.update_node(
session_id, session_id,
next_node["node_key"], next_node["node_key"],
user_id, user_id,
status="ready", status="ready" if next_node["status"] == "locked" else "stale",
invalidated_by=None,
) )
return _project_node(row) return _project_node(row)
@router.post("/sessions/{session_id}/nodes/{node_key}/reject", response_model=SessionDetailResponse)
async def reject_node(
request: Request, session_id: str, node_key: str, body: RejectNodeRequest
) -> SessionDetailResponse:
title = body.title.strip()
reason = body.reason.strip()
if not title or not reason:
raise HTTPException(status_code=400, detail="驳回标题和理由不能为空")
store, user_id, _ = await _require_writable_session(request, session_id)
current = await store.get_node(session_id, node_key, user_id)
if current is None:
raise HTTPException(status_code=404, detail="Position roundtable node not found")
if current["status"] == "locked":
raise HTTPException(status_code=409, detail="当前节点尚未解锁,暂无可驳回的交付产物")
if current.get("revision", 0) <= 0 and not current.get("latest_answer") and not current.get("artifact_manifest"):
raise HTTPException(status_code=409, detail="当前节点暂无可驳回的交付产物")
rejected = await store.reject_node(
session_id,
node_key,
user_id,
title=title,
reason=reason,
rejected_by=user_id,
)
if rejected is None:
raise HTTPException(status_code=404, detail="Position roundtable node not found")
row, node_rows = rejected
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:
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",
)
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.get("/sessions/{session_id}/results", response_model=list[NodeResponse]) @router.get("/sessions/{session_id}/results", response_model=list[NodeResponse])
async def list_results(request: Request, session_id: str) -> list[NodeResponse]: async def list_results(request: Request, session_id: str) -> list[NodeResponse]:
rows = await _get_store(request).list_results(session_id, _current_user_id(request)) rows = await _get_store(request).list_results(session_id, _current_user_id(request))
@@ -0,0 +1,54 @@
"""Add rejection metadata to position roundtable nodes.
Revision ID: 20260721_02
Revises: 20260721_01
Create Date: 2026-07-21
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects.mysql import LONGTEXT
revision = "20260721_02"
down_revision = "20260721_01"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
return table_name in inspect(op.get_bind()).get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
return column_name in {column["name"] for column in inspect(op.get_bind()).get_columns(table_name)}
def upgrade() -> None:
table = "position_roundtable_nodes"
if not _has_table(table):
return
long_text = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
if not _has_column(table, "rejection_count"):
op.add_column(
table,
sa.Column("rejection_count", sa.Integer(), nullable=False, server_default="0"),
)
if not _has_column(table, "last_rejection"):
op.add_column(table, sa.Column("last_rejection", long_text, nullable=True))
if not _has_column(table, "invalidated_by"):
op.add_column(table, sa.Column("invalidated_by", sa.String(256), nullable=True))
def downgrade() -> None:
table = "position_roundtable_nodes"
if not _has_table(table):
return
for column in ("invalidated_by", "last_rejection", "rejection_count"):
if _has_column(table, column):
op.drop_column(table, column)
@@ -0,0 +1,49 @@
"""Add builtin summary/action-plan snapshots to position roundtable sessions.
Revision ID: 20260721_03
Revises: 20260721_02
Create Date: 2026-07-21
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects.mysql import LONGTEXT
revision = "20260721_03"
down_revision = "20260721_02"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
return table_name in inspect(op.get_bind()).get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
return column_name in {column["name"] for column in inspect(op.get_bind()).get_columns(table_name)}
def upgrade() -> None:
table = "position_roundtable_sessions"
if not _has_table(table):
return
long_text = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
if not _has_column(table, "summary_snapshot"):
op.add_column(table, sa.Column("summary_snapshot", long_text, nullable=True))
if not _has_column(table, "action_plan_snapshot"):
op.add_column(table, sa.Column("action_plan_snapshot", long_text, nullable=True))
def downgrade() -> None:
table = "position_roundtable_sessions"
if not _has_table(table):
return
for column in ("action_plan_snapshot", "summary_snapshot"):
if _has_column(table, column):
op.drop_column(table, column)
@@ -31,6 +31,8 @@ class PositionRoundtableSessionRow(Base):
chain_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) chain_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
intent_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True) intent_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
intent_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) intent_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
summary_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
action_plan_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# intent_pending -> active -> completed / archived. The router owns legal # intent_pending -> active -> completed / archived. The router owns legal
# transitions; the DB keeps it as a concise, portable string. # transitions; the DB keeps it as a concise, portable string.
status: Mapped[str] = mapped_column(String(32), nullable=False, default="intent_pending", index=True) status: Mapped[str] = mapped_column(String(32), nullable=False, default="intent_pending", index=True)
@@ -66,12 +68,15 @@ class PositionRoundtableNodeRow(Base):
position_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) position_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
# The real messages remain in the standard LangGraph/checkpoint store. # The real messages remain in the standard LangGraph/checkpoint store.
thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True, unique=True) thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True, unique=True)
# locked / ready / running / done / stale / error # locked / ready / running / done / stale / rejected / error
status: Mapped[str] = mapped_column(String(32), nullable=False, default="locked", index=True) status: Mapped[str] = mapped_column(String(32), nullable=False, default="locked", index=True)
latest_answer: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) latest_answer: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
artifact_manifest: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) artifact_manifest: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
revision: Mapped[int] = mapped_column(Integer, nullable=False, default=0) revision: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
upstream_revision: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) upstream_revision: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
rejection_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
last_rejection: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
invalidated_by: Mapped[str | None] = mapped_column(String(256), nullable=True)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC) BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
) )
@@ -37,6 +37,10 @@ def _time(value: Any) -> str | Any:
return value.isoformat() if isinstance(value, datetime) else value return value.isoformat() if isinstance(value, datetime) else value
def _has_material(row: PositionRoundtableNodeRow) -> bool:
return row.revision > 0 or bool(row.latest_answer) or bool(_loads(row.artifact_manifest))
class PositionRoundtableRepository: class PositionRoundtableRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory self._sf = session_factory
@@ -51,6 +55,8 @@ class PositionRoundtableRepository:
"chain_snapshot": _loads(row.chain_snapshot), "chain_snapshot": _loads(row.chain_snapshot),
"intent_thread_id": row.intent_thread_id, "intent_thread_id": row.intent_thread_id,
"intent_snapshot": _loads(row.intent_snapshot), "intent_snapshot": _loads(row.intent_snapshot),
"summary_snapshot": _loads(row.summary_snapshot),
"action_plan_snapshot": _loads(row.action_plan_snapshot),
"status": row.status, "status": row.status,
"created_at": _time(row.created_at), "created_at": _time(row.created_at),
"updated_at": _time(row.updated_at), "updated_at": _time(row.updated_at),
@@ -72,6 +78,9 @@ class PositionRoundtableRepository:
"artifact_manifest": _loads(row.artifact_manifest) or [], "artifact_manifest": _loads(row.artifact_manifest) or [],
"revision": row.revision, "revision": row.revision,
"upstream_revision": _loads(row.upstream_revision), "upstream_revision": _loads(row.upstream_revision),
"rejection_count": row.rejection_count or 0,
"last_rejection": _loads(row.last_rejection),
"invalidated_by": row.invalidated_by,
"created_at": _time(row.created_at), "created_at": _time(row.created_at),
"updated_at": _time(row.updated_at), "updated_at": _time(row.updated_at),
} }
@@ -125,6 +134,8 @@ class PositionRoundtableRepository:
task_snapshot: dict[str, Any] | object = _UNSET, task_snapshot: dict[str, Any] | object = _UNSET,
intent_thread_id: str | None | object = _UNSET, intent_thread_id: str | None | object = _UNSET,
intent_snapshot: dict[str, Any] | None | object = _UNSET, intent_snapshot: dict[str, Any] | None | object = _UNSET,
summary_snapshot: dict[str, Any] | None | object = _UNSET,
action_plan_snapshot: dict[str, Any] | None | object = _UNSET,
status: str | object = _UNSET, status: str | object = _UNSET,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
async with self._sf() as session: async with self._sf() as session:
@@ -137,6 +148,10 @@ class PositionRoundtableRepository:
row.intent_thread_id = intent_thread_id or None row.intent_thread_id = intent_thread_id or None
if intent_snapshot is not _UNSET: if intent_snapshot is not _UNSET:
row.intent_snapshot = _dumps(intent_snapshot) row.intent_snapshot = _dumps(intent_snapshot)
if summary_snapshot is not _UNSET:
row.summary_snapshot = _dumps(summary_snapshot)
if action_plan_snapshot is not _UNSET:
row.action_plan_snapshot = _dumps(action_plan_snapshot)
if status is not _UNSET: if status is not _UNSET:
row.status = str(status) row.status = str(status)
row.updated_at = datetime.now(UTC) row.updated_at = datetime.now(UTC)
@@ -239,7 +254,10 @@ class PositionRoundtableRepository:
latest_answer: str | None | object = _UNSET, latest_answer: str | None | object = _UNSET,
artifact_manifest: list[dict[str, Any]] | object = _UNSET, artifact_manifest: list[dict[str, Any]] | object = _UNSET,
upstream_revision: dict[str, Any] | object = _UNSET, upstream_revision: dict[str, Any] | object = _UNSET,
last_rejection: dict[str, Any] | None | object = _UNSET,
invalidated_by: str | None | object = _UNSET,
increment_revision: bool = False, increment_revision: bool = False,
increment_rejection: bool = False,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
async with self._sf() as session: async with self._sf() as session:
stmt = ( stmt = (
@@ -265,13 +283,100 @@ class PositionRoundtableRepository:
row.artifact_manifest = _dumps(artifact_manifest) row.artifact_manifest = _dumps(artifact_manifest)
if upstream_revision is not _UNSET: if upstream_revision is not _UNSET:
row.upstream_revision = _dumps(upstream_revision) row.upstream_revision = _dumps(upstream_revision)
if last_rejection is not _UNSET:
row.last_rejection = _dumps(last_rejection)
if invalidated_by is not _UNSET:
row.invalidated_by = invalidated_by or None
if increment_revision: if increment_revision:
row.revision += 1 row.revision += 1
if increment_rejection:
row.rejection_count += 1
row.updated_at = datetime.now(UTC) row.updated_at = datetime.now(UTC)
await session.commit() await session.commit()
await session.refresh(row) await session.refresh(row)
return self._node_dict(row) return self._node_dict(row)
async def reject_node(
self,
session_id: str,
node_key: str,
user_id: str,
*,
title: str,
reason: str,
rejected_by: str,
) -> tuple[dict[str, Any], list[dict[str, Any]]] | None:
"""Reject one delivered node and invalidate every later stage.
Historical answers and artifact manifests are deliberately preserved.
``invalidated_by`` gates downstream rework until the rejected upstream
stage is completed again.
"""
now = datetime.now(UTC)
async with self._sf() as session:
parent = await session.get(PositionRoundtableSessionRow, session_id)
if parent is None or parent.user_id != user_id:
return None
rows = (
await session.execute(
select(PositionRoundtableNodeRow)
.where(PositionRoundtableNodeRow.session_id == session_id)
.order_by(PositionRoundtableNodeRow.stage_index, PositionRoundtableNodeRow.seat_index)
)
).scalars().all()
target = next((row for row in rows if row.node_key == node_key), None)
if target is None:
return None
rejection = {
"title": title,
"reason": reason,
"rejected_by": rejected_by,
"rejected_at": now.isoformat(),
}
summary_snapshot = _loads(parent.summary_snapshot)
if isinstance(summary_snapshot, dict) and summary_snapshot.get("status") == "done":
summary_snapshot = {
**summary_snapshot,
"status": "stale",
"invalidated_by": target.node_key,
"invalidated_at": now.isoformat(),
}
parent.summary_snapshot = _dumps(summary_snapshot)
action_plan_snapshot = _loads(parent.action_plan_snapshot)
if isinstance(action_plan_snapshot, dict) and action_plan_snapshot.get("status") == "done":
action_plan_snapshot = {
**action_plan_snapshot,
"status": "stale",
"invalidated_by": target.node_key,
"invalidated_at": now.isoformat(),
}
parent.action_plan_snapshot = _dumps(action_plan_snapshot)
if parent.status == "completed":
parent.status = "active"
target.status = "rejected"
target.last_rejection = _dumps(rejection)
target.rejection_count += 1
target.invalidated_by = None
target.updated_at = now
for row in rows:
if row.stage_index <= target.stage_index:
continue
if _has_material(row) or row.status in {"running", "done", "stale", "rejected", "error"}:
row.status = "stale"
else:
row.status = "locked"
row.invalidated_by = target.node_key
row.updated_at = now
parent.updated_at = now
await session.commit()
await session.refresh(parent)
for row in rows:
await session.refresh(row)
return self._session_dict(parent), [self._node_dict(row) for row in rows]
async def archive_session(self, session_id: str, user_id: str) -> dict[str, Any] | None: async def archive_session(self, session_id: str, user_id: str) -> dict[str, Any] | None:
return await self.update_session(session_id, user_id, status="archived") return await self.update_session(session_id, user_id, status="archived")
@@ -139,6 +139,176 @@ async def test_node_updates_are_user_scoped_and_keep_thread_out_of_messages(stor
assert await store.get_node(created["id"], nodes[0]["node_key"], "user-b") is None assert await store.get_node(created["id"], nodes[0]["node_key"], "user-b") is None
@pytest.mark.asyncio
async def test_reject_node_marks_current_and_invalidates_later_stages(store):
created = await store.create_session("user-a", external_task_id=None, task_snapshot={})
_, nodes = await store.activate_session(
created["id"],
"user-a",
chain_id="chain-a",
chain_snapshot={"id": "chain-a"},
nodes=[
{
"node_key": "stage-1-seat-1-agent-a",
"stage_index": 0,
"seat_index": 0,
"agent_id": "agent-a",
"position_id": "planning",
"status": "ready",
},
{
"node_key": "stage-2-seat-1-agent-b",
"stage_index": 1,
"seat_index": 0,
"agent_id": "agent-b",
"position_id": "solution",
"status": "ready",
},
{
"node_key": "stage-3-seat-1-agent-c",
"stage_index": 2,
"seat_index": 0,
"agent_id": "agent-c",
"position_id": "command",
"status": "ready",
},
],
)
await store.update_node(
created["id"],
nodes[0]["node_key"],
"user-a",
status="done",
latest_answer="第一阶段产物",
increment_revision=True,
)
await store.update_node(
created["id"],
nodes[1]["node_key"],
"user-a",
status="done",
latest_answer="第二阶段产物",
increment_revision=True,
)
rejected = await store.reject_node(
created["id"],
nodes[0]["node_key"],
"user-a",
title="依据不足",
reason="第一阶段判断依据需要补充。",
rejected_by="user-a",
)
assert rejected is not None
_, rejected_nodes = rejected
by_key = {node["node_key"]: node for node in rejected_nodes}
first = by_key[nodes[0]["node_key"]]
second = by_key[nodes[1]["node_key"]]
third = by_key[nodes[2]["node_key"]]
assert first["status"] == "rejected"
assert first["rejection_count"] == 1
assert first["last_rejection"]["title"] == "依据不足"
assert first["invalidated_by"] is None
assert second["status"] == "stale"
assert second["latest_answer"] == "第二阶段产物"
assert second["invalidated_by"] == nodes[0]["node_key"]
assert third["status"] == "locked"
assert third["invalidated_by"] == nodes[0]["node_key"]
@pytest.mark.asyncio
async def test_builtin_summary_and_action_plan_snapshots_are_invalidated_by_reject(store):
created = await store.create_session(
"user-a",
external_task_id=None,
task_snapshot={
"title": "测试任务",
"description": "形成协同方案",
"direction": "态势研判",
},
)
await store.update_session(
created["id"],
"user-a",
intent_snapshot={"objective": "明确处置意图", "constraints": ["时间紧"]},
)
chain = {
"id": "chain-a",
"seats": [
{"agent_id": "agent-a", "name": "研判智能体", "position_id": "planning"},
{"agent_id": "agent-b", "name": "处置智能体", "position_id": "solution"},
],
}
_, nodes = await store.activate_session(
created["id"],
"user-a",
chain_id="chain-a",
chain_snapshot=chain,
nodes=[
{
"node_key": "stage-1-seat-1-agent-a",
"stage_index": 0,
"seat_index": 0,
"agent_id": "agent-a",
"position_id": "planning",
"status": "ready",
},
{
"node_key": "stage-2-seat-1-agent-b",
"stage_index": 1,
"seat_index": 0,
"agent_id": "agent-b",
"position_id": "solution",
"status": "ready",
},
],
)
for node in nodes:
await store.update_node(
created["id"],
node["node_key"],
"user-a",
status="done",
latest_answer=f"{node['agent_id']} 产物",
increment_revision=True,
)
session = await store.get_session(created["id"], "user-a")
assert session is not None
nodes = await store.list_nodes(created["id"], "user-a") or []
summary = position_roundtable_router._build_summary_snapshot(session, nodes)
assert "岗位会商方案总结报告" in summary["content"]
assert "研判智能体" in summary["content"]
session = await store.update_session(created["id"], "user-a", summary_snapshot=summary)
assert session is not None
action_plan = position_roundtable_router._build_action_plan_snapshot(session, nodes)
session = await store.update_session(
created["id"],
"user-a",
action_plan_snapshot=action_plan,
status="completed",
)
assert session is not None
assert session["summary_snapshot"]["status"] == "done"
assert session["action_plan_snapshot"]["status"] == "done"
rejected = await store.reject_node(
created["id"],
nodes[0]["node_key"],
"user-a",
title="重做第一阶段",
reason="前序依据变化",
rejected_by="user-a",
)
assert rejected is not None
rejected_session, _ = rejected
assert rejected_session["status"] == "active"
assert rejected_session["summary_snapshot"]["status"] == "stale"
assert rejected_session["summary_snapshot"]["invalidated_by"] == nodes[0]["node_key"]
assert rejected_session["action_plan_snapshot"]["status"] == "stale"
assert rejected_session["action_plan_snapshot"]["invalidated_by"] == nodes[0]["node_key"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_handoff_context_inlines_only_upstream_thread_text_artifacts(tmp_path, monkeypatch): async def test_handoff_context_inlines_only_upstream_thread_text_artifacts(tmp_path, monkeypatch):
artifact = tmp_path / "handoff.md" artifact = tmp_path / "handoff.md"
@@ -188,6 +358,18 @@ async def test_handoff_context_inlines_only_upstream_thread_text_artifacts(tmp_p
"latest_answer": None, "latest_answer": None,
"artifact_manifest": [], "artifact_manifest": [],
}, },
{
"node_key": "stage-1-seat-2-agent-stale",
"stage_index": 0,
"seat_index": 1,
"agent_id": "agent-stale",
"position_id": "planning",
"thread_id": "stale-thread",
"status": "stale",
"revision": 1,
"latest_answer": "失效上游产物",
"artifact_manifest": [],
},
] ]
async def resolve(thread_id: str, path: str): async def resolve(thread_id: str, path: str):
@@ -207,6 +389,7 @@ async def test_handoff_context_inlines_only_upstream_thread_text_artifacts(tmp_p
) )
upstream = context["upstream_nodes"][0] upstream = context["upstream_nodes"][0]
assert len(context["upstream_nodes"]) == 1
assert upstream["latest_answer"] == "上游问答结果" assert upstream["latest_answer"] == "上游问答结果"
assert upstream["artifacts"][0]["content"] == "上游产物正文:可供下一岗位直接参考。" assert upstream["artifacts"][0]["content"] == "上游产物正文:可供下一岗位直接参考。"
assert "content" not in upstream["artifacts"][1] assert "content" not in upstream["artifacts"][1]