Compare commits
8
Commits
master-dev
..
ceshi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c555b92089 | ||
|
|
aa3742b103 | ||
|
|
1aeee86984 | ||
|
|
a786cdcad0 | ||
|
|
f9a5b5bc1e | ||
|
|
49af497085 | ||
|
|
8b732b43ee | ||
|
|
69e455274a |
No files matched your search
@@ -643,3 +643,38 @@ maf-roundtable/conversation/
|
|||||||
- The final plan is Pydantic-validated and allowlisted before it is checkpointed. Invalid JSON, illegal intent or seat, missing convergence, manager failure, and timeout all finish on the clarification branch; they do not create expert threads or start expert/tool work.
|
- The final plan is Pydantic-validated and allowlisted before it is checkpointed. Invalid JSON, illegal intent or seat, missing convergence, manager failure, and timeout all finish on the clarification branch; they do not create expert threads or start expert/tool work.
|
||||||
- A conversation initially persists and binds the manager only. After a valid decision, only `selected_seat_agent_ids` are added to the participant store and receive internal threads.
|
- A conversation initially persists and binds the manager only. After a valid decision, only `selected_seat_agent_ids` are added to the participant store and receive internal threads.
|
||||||
- Every non-clarification route, including `group_chat`, starts at the same manager dispatch node. Group speakers are limited to the verified selected seats; static candidate configuration cannot bypass the plan.
|
- Every non-clarification route, including `group_chat`, starts at the same manager dispatch node. Group speakers are limited to the verified selected seats; static candidate configuration cannot bypass the plan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段 4 实施记录(当前代码基线)
|
||||||
|
|
||||||
|
本轮完成阶段 4(R3-Q:席位任务合同、总控验收、打回/改派/受控缺口、阶段质量闸门)的服务端实现。范围仅限 V2 structured 派活(`dispatch_contract_enabled` 且 `quality_gate_enabled`);旧 DeerFlow、旧 roundtable、AI 写作、group_chat 与 quality 关闭的 V2 路径行为不变。
|
||||||
|
|
||||||
|
### 纯策略层(`app/maf_roundtable/quality_contracts.py`,无 I/O、无 SDK)
|
||||||
|
|
||||||
|
- `MafTaskContract`:objective / expected_deliverable / acceptance_criteria / required / max_rework_attempts(默认 2)/ fallback_policy(`reassign_then_gap` / `controlled_gap` / `ask_user`),由校验后的派发 assignment 服务端冻结。
|
||||||
|
- 确定性检查 `check_delivery_deterministic`:empty_content、schema_invalid、missing_conclusion、below_min_length、missing_evidence、duplicate_content;任一失败即不可 accept(总控模型自评不是验收证据)。
|
||||||
|
- 总控验收 schema `MafManagerReviewOutput`(decision ∈ accept / return_for_rework / reassign / ask_user / controlled_gap + reason + revision_requirements),`finalize_review_decision` 服务端终审:确定性失败永不 accept;返工受合同上限约束;改派受 `max_reassignments` 约束;耗尽后只能 `controlled_gap`(带风险标记)或 `ask_user` 暂停,绝不伪装成功;总控输出缺失/非法时确定性降级。
|
||||||
|
|
||||||
|
### 持久化
|
||||||
|
|
||||||
|
- 新表 `maf_roundtable_seat_tasks`(`model.py` + `seat_tasks.py` + store facade 注册):task_key 稳定(`NN:agent_id`),改派只变 `agent_id` 不新建任务;`(run_id, task_key)` 唯一;状态机 CHECK(assigned/delivering/submitted/manager_review/accepted/rework/reassigned/exhausted/controlled_gap)。
|
||||||
|
- `maf_roundtable_seat_runs.execution_stage` 闭合枚举新增 `rework`、`manager_review`(seat_runs 常量 + ORM CHECK + 迁移脚本同步)。
|
||||||
|
- 迁移脚本 `scripts/migrate_maf_roundtable_stage4.py`(建表 + SQLite 重建 seat_runs 以放宽 CHECK,PG/MySQL drop+add,幂等,`validate_schema`)。
|
||||||
|
|
||||||
|
### 执行器与接线
|
||||||
|
|
||||||
|
- `QualitySeatTasksExecutor`(id=`quality_seat_tasks`)替换 V2 structured 的席位 fan-out:有界质量轮(初稿 + 最多 2 次返工 + 每次改派 1 轮),每轮「交付(复用 SeatExecutor,静默 ctx 防消息重入图)→ 确定性检查 → 批处理总控验收 → 服务端终审落库」,`ask_user` 置 `waiting_input`;改派席位须为服务端候选且未占用其他任务(一个席位同时只承担一个任务,避免四元组冲突)。
|
||||||
|
- `QualityGateExecutor`(id=`quality_gate`):所有 required 任务达到 accepted / controlled_gap 终态才放行(`passed` / `passed_with_gaps`);非终态 required 任务确定性阻塞(硬失败安全网);`waiting_input` 路由到 `WaitingInputTerminalExecutor`(不跑 cross_review/finalizer)。
|
||||||
|
- 最终报告:受控缺口注入综合 prompt 的「风险/待补信息」章节并逐条要求不得写成已验证事实;`final_result.quality_decision` 有缺口即为 `controlled_gap`,`final_result.controlled_gaps` 可追溯。
|
||||||
|
- 配置:`quality_gate_enabled`(默认 True,V2 会话可用 `maf_v2.quality_gate_enabled` 显式关闭)、`max_rework_attempts`、`max_reassignments`、`min_delivery_chars`、`min_delivery_evidence`、`default_fallback_policy`。
|
||||||
|
- 事件:`seat.delivery.submitted`、`manager.review.decision`(带 task_key/attempt/source)、`seat.rework.assigned`、`seat.reassigned`、`stage.quality_gate`;conversation 投影白名单与安全键已扩展。
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
|
||||||
|
- `tests/test_maf_quality_contracts.py`(34 例纯策略:合同冻结、确定性检查、验收解析、终审规则矩阵)。
|
||||||
|
- `tests/test_maf_phase4_quality_gate.py`(6 例 Runner 级端到端:好交付接受、短答→2 返工→1 改派→受控缺口(含 DB/事件/最终报告断言)、ask_user→waiting_input、质量闸门阻塞、缺失验收的确定性降级、事件落库)。
|
||||||
|
- `tests/test_maf_migration_stage4.py`(3 例迁移:建表+CHECK 放宽、幂等、旧数据保留、新阶段可写入)。
|
||||||
|
- 回归:全量 MAF 测试 459 通过;`ruff check` 全绿。
|
||||||
|
|
||||||
|
仍按阶段 5/8 前置条件保持阻塞:真实 UAT 写入系统、最小权限凭据、外部核验 API、补偿协议和生产 `MAF_ACTION_SECRET_KEY`。前端对话页的质量反馈(派活卡显示尝试次数、「总控审核中」、打回/改派/缺口卡片、席位条状态区分)属阶段 7 的前端工作,尚未实施。
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
|
FileText,
|
||||||
Folder,
|
Folder,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Gauge,
|
Gauge,
|
||||||
@@ -236,6 +237,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
|||||||
按钮管理
|
按钮管理
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => navigate("/page/strategy/admin/notification-templates")}
|
||||||
|
>
|
||||||
|
<FileText className="size-4" />
|
||||||
|
通知模板管理
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => navigate("/page/strategy/admin/position-roles")}
|
onClick={() => navigate("/page/strategy/admin/position-roles")}
|
||||||
@@ -549,6 +558,12 @@ function LegacyAccordionSidebar({
|
|||||||
按钮管理
|
按钮管理
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/notification-templates")}>
|
||||||
|
<FileText className="size-4" />
|
||||||
|
通知模板管理
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/position-roles")}>
|
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/position-roles")}>
|
||||||
<BriefcaseBusiness className="size-4" />
|
<BriefcaseBusiness className="size-4" />
|
||||||
|
|||||||
@@ -121,8 +121,9 @@ function findScrollableArtifactElement(root: HTMLElement | null) {
|
|||||||
|
|
||||||
export function ArtifactFileDetail({
|
export function ArtifactFileDetail({
|
||||||
className,
|
className,
|
||||||
filepath: filepathFromProps,
|
filepath: filepathFromProps,
|
||||||
threadId,
|
threadId,
|
||||||
|
mimeType = null,
|
||||||
onClose,
|
onClose,
|
||||||
allowVirtualMarkdownEdit = false,
|
allowVirtualMarkdownEdit = false,
|
||||||
disableDesensitize = false,
|
disableDesensitize = false,
|
||||||
@@ -131,6 +132,8 @@ export function ArtifactFileDetail({
|
|||||||
className?: string;
|
className?: string;
|
||||||
filepath: string;
|
filepath: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
|
/** Server/tool supplied type wins over path guessing when available. */
|
||||||
|
mimeType?: string | null;
|
||||||
/** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */
|
/** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
/**
|
/**
|
||||||
@@ -150,16 +153,38 @@ export function ArtifactFileDetail({
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { artifacts, setOpen, select } = useArtifacts();
|
const { artifacts, setOpen, select } = useArtifacts();
|
||||||
const { isMock, thread, publicShareCode } = useThread();
|
const { isMock, thread, publicShareCode } = useThread();
|
||||||
const isWriteFileVirtual = useMemo(() => {
|
const isWriteFileVirtual = useMemo(() => {
|
||||||
return filepathFromProps.startsWith("write-file:");
|
return filepathFromProps.startsWith("write-file:");
|
||||||
}, [filepathFromProps]);
|
}, [filepathFromProps]);
|
||||||
const filepath = useMemo(() => {
|
// A streamed tool call can be opened before the path has been normalized by
|
||||||
if (isWriteFileVirtual) {
|
// the artifacts API. Keep the format supplied by the writer so Markdown is
|
||||||
const url = new URL(filepathFromProps);
|
// rendered as Markdown instead of falling back to a TEXT source preview.
|
||||||
return decodeURIComponent(url.pathname);
|
const writeFileLanguageHint = useMemo(() => {
|
||||||
}
|
if (!isWriteFileVirtual) return null;
|
||||||
return filepathFromProps;
|
try {
|
||||||
}, [filepathFromProps, isWriteFileVirtual]);
|
const language = new URL(filepathFromProps).searchParams.get("language");
|
||||||
|
return language === "markdown" ? language : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [filepathFromProps, isWriteFileVirtual]);
|
||||||
|
const mimeLanguageHint = useMemo(() => {
|
||||||
|
const normalized = mimeType?.toLowerCase().trim() ?? "";
|
||||||
|
if (normalized.includes("markdown")) return "markdown";
|
||||||
|
if (normalized.includes("html")) return "html";
|
||||||
|
if (normalized.includes("mermaid")) return "mermaid";
|
||||||
|
if (normalized.startsWith("text/")) return "text";
|
||||||
|
return null;
|
||||||
|
}, [mimeType]);
|
||||||
|
const filepath = useMemo(() => {
|
||||||
|
if (isWriteFileVirtual) {
|
||||||
|
const url = new URL(filepathFromProps);
|
||||||
|
return decodeURIComponent(url.pathname).trim();
|
||||||
|
}
|
||||||
|
// Tool arguments may contain harmless surrounding whitespace. Passing it
|
||||||
|
// into extension detection turns `report.md ` into a generic TEXT file.
|
||||||
|
return filepathFromProps.trim();
|
||||||
|
}, [filepathFromProps, isWriteFileVirtual]);
|
||||||
const isSkillFile = useMemo(() => {
|
const isSkillFile = useMemo(() => {
|
||||||
return filepath.endsWith(".skill");
|
return filepath.endsWith(".skill");
|
||||||
}, [filepath]);
|
}, [filepath]);
|
||||||
@@ -186,17 +211,22 @@ export function ArtifactFileDetail({
|
|||||||
const useWriteFileStreaming = useMemo(() => {
|
const useWriteFileStreaming = useMemo(() => {
|
||||||
return isWriteFileVirtual && !isImageFile(filepath) && !editVirtualMarkdown;
|
return isWriteFileVirtual && !isImageFile(filepath) && !editVirtualMarkdown;
|
||||||
}, [filepath, isWriteFileVirtual, editVirtualMarkdown]);
|
}, [filepath, isWriteFileVirtual, editVirtualMarkdown]);
|
||||||
const { isCodeFile, language } = useMemo(() => {
|
const { isCodeFile, language } = useMemo(() => {
|
||||||
if (useWriteFileStreaming) {
|
if (useWriteFileStreaming) {
|
||||||
let nextLanguage = checkCodeFile(filepath).language;
|
let nextLanguage =
|
||||||
nextLanguage ??= "text";
|
mimeLanguageHint ?? writeFileLanguageHint ?? checkCodeFile(filepath).language;
|
||||||
return { isCodeFile: true, language: nextLanguage };
|
nextLanguage ??= "text";
|
||||||
|
return { isCodeFile: true, language: nextLanguage };
|
||||||
}
|
}
|
||||||
if (isSkillFile) {
|
if (isSkillFile) {
|
||||||
return { isCodeFile: true, language: "markdown" };
|
return { isCodeFile: true, language: "markdown" };
|
||||||
}
|
}
|
||||||
return checkCodeFile(filepath);
|
const detected = checkCodeFile(filepath);
|
||||||
}, [filepath, useWriteFileStreaming, isSkillFile]);
|
if (mimeLanguageHint) {
|
||||||
|
return { isCodeFile: true as const, language: mimeLanguageHint };
|
||||||
|
}
|
||||||
|
return detected;
|
||||||
|
}, [filepath, useWriteFileStreaming, isSkillFile, mimeLanguageHint, writeFileLanguageHint]);
|
||||||
const isSupportPreview = useMemo(() => {
|
const isSupportPreview = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
language === "html" ||
|
language === "html" ||
|
||||||
@@ -209,7 +239,8 @@ export function ArtifactFileDetail({
|
|||||||
// editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟
|
// editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟
|
||||||
// URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容
|
// URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容
|
||||||
// 不会被旧的内存 stub 值覆盖回退。
|
// 不会被旧的内存 stub 值覆盖回退。
|
||||||
filepath: editVirtualMarkdown ? filepath : filepathFromProps,
|
filepath:
|
||||||
|
editVirtualMarkdown || !isWriteFileVirtual ? filepath : filepathFromProps,
|
||||||
enabled: isCodeFile && !useWriteFileStreaming,
|
enabled: isCodeFile && !useWriteFileStreaming,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -25,10 +25,13 @@ import { promptInputFilePartToFile, uploadFiles } from "../uploads";
|
|||||||
|
|
||||||
import type { AgentThread, AgentThreadState } from "./types";
|
import type { AgentThread, AgentThreadState } from "./types";
|
||||||
|
|
||||||
export type ToolEndEvent = {
|
export type ToolEndEvent = {
|
||||||
name: string;
|
name: string;
|
||||||
data: unknown;
|
data: unknown;
|
||||||
};
|
toolCallId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolStartEvent = ToolEndEvent;
|
||||||
|
|
||||||
export type ThreadStreamOptions = {
|
export type ThreadStreamOptions = {
|
||||||
threadId?: string | null | undefined;
|
threadId?: string | null | undefined;
|
||||||
@@ -47,10 +50,11 @@ export type ThreadStreamOptions = {
|
|||||||
/** 额外写到新建 thread 的 metadata(与 agent_id 合并)。
|
/** 额外写到新建 thread 的 metadata(与 agent_id 合并)。
|
||||||
* 任务深链用 `{ taskId }` 把对话挂到任务下(1:N),列表按 metadata.taskId 过滤。 */
|
* 任务深链用 `{ taskId }` 把对话挂到任务下(1:N),列表按 metadata.taskId 过滤。 */
|
||||||
threadMetadata?: Record<string, unknown>;
|
threadMetadata?: Record<string, unknown>;
|
||||||
onStart?: (threadId: string) => void;
|
onStart?: (threadId: string) => void;
|
||||||
onFinish?: (state: AgentThreadState) => void;
|
onFinish?: (state: AgentThreadState) => void;
|
||||||
onToolEnd?: (event: ToolEndEvent) => void;
|
onToolStart?: (event: ToolStartEvent) => void;
|
||||||
};
|
onToolEnd?: (event: ToolEndEvent) => void;
|
||||||
|
};
|
||||||
|
|
||||||
type SendMessageOptions = {
|
type SendMessageOptions = {
|
||||||
additionalKwargs?: Record<string, unknown>;
|
additionalKwargs?: Record<string, unknown>;
|
||||||
@@ -447,10 +451,11 @@ export function useThreadStream({
|
|||||||
assistantId,
|
assistantId,
|
||||||
runOptions,
|
runOptions,
|
||||||
threadMetadata,
|
threadMetadata,
|
||||||
onStart,
|
onStart,
|
||||||
onFinish,
|
onFinish,
|
||||||
onToolEnd,
|
onToolStart,
|
||||||
}: ThreadStreamOptions) {
|
onToolEnd,
|
||||||
|
}: ThreadStreamOptions) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [localSettings] = useLocalSettings();
|
const [localSettings] = useLocalSettings();
|
||||||
// Track the thread ID that is currently streaming to handle thread changes during streaming
|
// Track the thread ID that is currently streaming to handle thread changes during streaming
|
||||||
@@ -466,16 +471,17 @@ export function useThreadStream({
|
|||||||
lastStreamActivityAtRef.current = Date.now();
|
lastStreamActivityAtRef.current = Date.now();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const listeners = useRef({
|
const listeners = useRef({
|
||||||
onStart,
|
onStart,
|
||||||
onFinish,
|
onFinish,
|
||||||
onToolEnd,
|
onToolStart,
|
||||||
});
|
onToolEnd,
|
||||||
|
});
|
||||||
|
|
||||||
// Keep listeners ref updated with latest callbacks
|
// Keep listeners ref updated with latest callbacks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listeners.current = { onStart, onFinish, onToolEnd };
|
listeners.current = { onStart, onFinish, onToolStart, onToolEnd };
|
||||||
}, [onStart, onFinish, onToolEnd]);
|
}, [onStart, onFinish, onToolStart, onToolEnd]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const normalizedThreadId = threadId ?? null;
|
const normalizedThreadId = threadId ?? null;
|
||||||
@@ -545,12 +551,6 @@ export function useThreadStream({
|
|||||||
},
|
},
|
||||||
onLangChainEvent(event) {
|
onLangChainEvent(event) {
|
||||||
markStreamActivity();
|
markStreamActivity();
|
||||||
if (event.event === "on_tool_end") {
|
|
||||||
listeners.current.onToolEnd?.({
|
|
||||||
name: event.name,
|
|
||||||
data: event.data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 操作日志(Y-Log):每一次大模型调用结束都各上报一条(一次 LLM 调用 = 一条),
|
// 操作日志(Y-Log):每一次大模型调用结束都各上报一条(一次 LLM 调用 = 一条),
|
||||||
// 让问答日志尽量「多、全」——一个问答轮次里 lead agent / 子代理的每次模型调用
|
// 让问答日志尽量「多、全」——一个问答轮次里 lead agent / 子代理的每次模型调用
|
||||||
// 都会落一条。门控与失败静默都在 writeYLog 内部处理,绝不影响问答。
|
// 都会落一条。门控与失败静默都在 writeYLog 内部处理,绝不影响问答。
|
||||||
@@ -572,8 +572,32 @@ export function useThreadStream({
|
|||||||
} catch {
|
} catch {
|
||||||
/* 上报绝不影响问答 */
|
/* 上报绝不影响问答 */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// The SDK emits UI tool lifecycle events on the dedicated `tools` stream
|
||||||
|
// mode. `events` is a separate LangChain trace channel; using it for UI
|
||||||
|
// reactions can miss a tool that the conversation has already rendered.
|
||||||
|
onToolEvent:
|
||||||
|
onToolStart || onToolEnd
|
||||||
|
? (event) => {
|
||||||
|
markStreamActivity();
|
||||||
|
if (event.event === "on_tool_start") {
|
||||||
|
listeners.current.onToolStart?.({
|
||||||
|
name: event.name,
|
||||||
|
data: event.input,
|
||||||
|
toolCallId: event.toolCallId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.event === "on_tool_end") {
|
||||||
|
listeners.current.onToolEnd?.({
|
||||||
|
name: event.name,
|
||||||
|
data: event.output,
|
||||||
|
toolCallId: event.toolCallId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
onUpdateEvent(data) {
|
onUpdateEvent(data) {
|
||||||
markStreamActivity();
|
markStreamActivity();
|
||||||
const updates: Array<Partial<AgentThreadState> | null> = Object.values(
|
const updates: Array<Partial<AgentThreadState> | null> = Object.values(
|
||||||
|
|||||||
@@ -0,0 +1,695 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Copy,
|
||||||
|
FileText,
|
||||||
|
Loader2,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Save,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { getStoredAuth } from "@/core/auth";
|
||||||
|
import {
|
||||||
|
ALLOWED_VARIABLES,
|
||||||
|
previewTemplate,
|
||||||
|
useNotificationTemplateActions,
|
||||||
|
useNotificationTemplates,
|
||||||
|
type NotificationTemplate,
|
||||||
|
type TemplateFormData,
|
||||||
|
} from "@/strategy-components/api/notification-templates";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 15;
|
||||||
|
|
||||||
|
const VARIABLE_LABELS: Record<string, string> = {
|
||||||
|
userName: "用户名",
|
||||||
|
taskName: "任务名",
|
||||||
|
taskId: "任务ID",
|
||||||
|
dueDate: "截止日期",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ConflictInfo {
|
||||||
|
server: NotificationTemplate;
|
||||||
|
local: TemplateFormData & { id: string; expectedVersion: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotificationTemplatesPage() {
|
||||||
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
|
if (!isAdmin) return <Navigate to="/page/workspace/chats/new" replace />;
|
||||||
|
|
||||||
|
return <PageContent />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageContent() {
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [enabledFilter, setEnabledFilter] = useState<string>("all");
|
||||||
|
const [tagFilter, setTagFilter] = useState<string>("all");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [debouncedKeyword, setDebouncedKeyword] = useState("");
|
||||||
|
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
setDebouncedKeyword(keyword);
|
||||||
|
setPage(1);
|
||||||
|
}, 300);
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
};
|
||||||
|
}, [keyword]);
|
||||||
|
|
||||||
|
const queryParams = useMemo(
|
||||||
|
() => ({
|
||||||
|
keyword: debouncedKeyword || undefined,
|
||||||
|
enabled: enabledFilter === "all" ? undefined : enabledFilter === "true",
|
||||||
|
tag: tagFilter === "all" ? undefined : tagFilter,
|
||||||
|
page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
}),
|
||||||
|
[debouncedKeyword, enabledFilter, tagFilter, page],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isFetching } = useNotificationTemplates(queryParams);
|
||||||
|
const actions = useNotificationTemplateActions();
|
||||||
|
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
|
||||||
|
const [conflict, setConflict] = useState<ConflictInfo | null>(null);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const allTags = useMemo(() => {
|
||||||
|
const tags = new Set<string>();
|
||||||
|
(data?.items ?? []).forEach((t) => t.tags.forEach((tag) => tags.add(tag)));
|
||||||
|
return Array.from(tags);
|
||||||
|
}, [data?.items]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil((data?.total ?? 0) / PAGE_SIZE);
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setEditingTemplate(null);
|
||||||
|
setConflict(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (t: NotificationTemplate) => {
|
||||||
|
setEditingTemplate(t);
|
||||||
|
setConflict(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = async (t: NotificationTemplate) => {
|
||||||
|
try {
|
||||||
|
await actions.update.mutateAsync({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
titleTemplate: t.titleTemplate,
|
||||||
|
contentTemplate: t.contentTemplate,
|
||||||
|
tags: t.tags,
|
||||||
|
enabled: !t.enabled,
|
||||||
|
expectedVersion: t.version,
|
||||||
|
});
|
||||||
|
toast.success(t.enabled ? "已停用" : "已启用");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as { status?: number; detail?: unknown };
|
||||||
|
if (e?.status === 409) {
|
||||||
|
toast.error("版本冲突,请刷新后重试");
|
||||||
|
} else {
|
||||||
|
toast.error("操作失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await actions.remove.mutateAsync(id);
|
||||||
|
toast.success("已删除");
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
} catch {
|
||||||
|
toast.error("删除失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDuplicate = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await actions.duplicate.mutateAsync(id);
|
||||||
|
toast.success("已复制");
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex size-full min-h-0 flex-col">
|
||||||
|
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="size-5" />
|
||||||
|
<h1 className="text-lg font-semibold">通知模板管理</h1>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={handleCreate}>
|
||||||
|
<Plus className="mr-1 size-4" />
|
||||||
|
新建模板
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 border-b px-6 py-3">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜索名称/标题/正文..."
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
className="w-64 pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={enabledFilter} onValueChange={(v) => { setEnabledFilter(v); setPage(1); }}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue placeholder="启用状态" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部状态</SelectItem>
|
||||||
|
<SelectItem value="true">已启用</SelectItem>
|
||||||
|
<SelectItem value="false">已停用</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={tagFilter} onValueChange={(v) => { setTagFilter(v); setPage(1); }}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue placeholder="标签筛选" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部标签</SelectItem>
|
||||||
|
{allTags.map((tag) => (
|
||||||
|
<SelectItem key={tag} value={tag}>
|
||||||
|
{tag}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{isFetching && <Loader2 className="size-4 animate-spin text-muted-foreground" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto px-6 py-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex h-40 items-center justify-center">
|
||||||
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : !data?.items.length ? (
|
||||||
|
<div className="flex h-40 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||||
|
<FileText className="size-8" />
|
||||||
|
<p>暂无模板</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.items.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className="flex items-center justify-between rounded-lg border p-4 transition-colors hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{t.name}</span>
|
||||||
|
<Badge variant={t.enabled ? "default" : "secondary"}>
|
||||||
|
{t.enabled ? "启用" : "停用"}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">v{t.version}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 truncate text-sm text-muted-foreground">
|
||||||
|
{t.titleTemplate}
|
||||||
|
</p>
|
||||||
|
{t.tags.length > 0 && (
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
|
{t.tags.map((tag) => (
|
||||||
|
<Badge key={tag} variant="outline" className="text-xs">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="ml-4 flex items-center gap-1">
|
||||||
|
<Switch checked={t.enabled} onCheckedChange={() => handleToggle(t)} />
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleEdit(t)}>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleDuplicate(t.id)}>
|
||||||
|
<Copy className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setDeleteConfirm(t.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between border-t px-6 py-3">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
共 {data?.total ?? 0} 条,第 {page}/{totalPages} 页
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dialogOpen && (
|
||||||
|
<EditDialog
|
||||||
|
template={editingTemplate}
|
||||||
|
onClose={() => { setDialogOpen(false); setConflict(null); }}
|
||||||
|
conflict={conflict}
|
||||||
|
onConflictReload={() => {
|
||||||
|
setConflict(null);
|
||||||
|
setEditingTemplate(conflict!.server);
|
||||||
|
}}
|
||||||
|
onConflictOverride={async () => {
|
||||||
|
try {
|
||||||
|
await actions.update.mutateAsync({
|
||||||
|
id: conflict!.local.id,
|
||||||
|
name: conflict!.local.name,
|
||||||
|
titleTemplate: conflict!.local.titleTemplate,
|
||||||
|
contentTemplate: conflict!.local.contentTemplate,
|
||||||
|
tags: conflict!.local.tags,
|
||||||
|
enabled: conflict!.local.enabled,
|
||||||
|
expectedVersion: conflict!.server.version,
|
||||||
|
});
|
||||||
|
toast.success("已覆盖保存");
|
||||||
|
setDialogOpen(false);
|
||||||
|
setConflict(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as { status?: number; detail?: unknown };
|
||||||
|
if (e?.status === 409) {
|
||||||
|
setConflict((prev) =>
|
||||||
|
prev ? { ...prev, server: (e.detail as { server: NotificationTemplate }).server } : null,
|
||||||
|
);
|
||||||
|
toast.error("再次发生版本冲突,请重新加载");
|
||||||
|
} else {
|
||||||
|
toast.error("保存失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={!!deleteConfirm} onOpenChange={(open) => !open && setDeleteConfirm(null)}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>确认删除</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
删除后无法恢复,确定要删除此模板吗?
|
||||||
|
</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={() => deleteConfirm && handleDelete(deleteConfirm)}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditDialog({
|
||||||
|
template,
|
||||||
|
onClose,
|
||||||
|
conflict,
|
||||||
|
onConflictReload,
|
||||||
|
onConflictOverride,
|
||||||
|
}: {
|
||||||
|
template: NotificationTemplate | null;
|
||||||
|
onClose: () => void;
|
||||||
|
conflict: ConflictInfo | null;
|
||||||
|
onConflictReload: () => void;
|
||||||
|
onConflictOverride: () => void;
|
||||||
|
}) {
|
||||||
|
const actions = useNotificationTemplateActions();
|
||||||
|
const isEdit = !!template;
|
||||||
|
|
||||||
|
const [name, setName] = useState(template?.name ?? "");
|
||||||
|
const [titleTemplate, setTitleTemplate] = useState(template?.titleTemplate ?? "");
|
||||||
|
const [contentTemplate, setContentTemplate] = useState(template?.contentTemplate ?? "");
|
||||||
|
const [tags, setTags] = useState<string[]>(template?.tags ?? []);
|
||||||
|
const [tagInput, setTagInput] = useState("");
|
||||||
|
const [enabled, setEnabled] = useState(template?.enabled ?? true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const [previewVars, setPreviewVars] = useState<Record<string, string>>({
|
||||||
|
userName: "张三",
|
||||||
|
taskName: "示例任务",
|
||||||
|
taskId: "T-001",
|
||||||
|
dueDate: "2026-08-15",
|
||||||
|
});
|
||||||
|
const [previewResult, setPreviewResult] = useState<{
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
missingVariables: string[];
|
||||||
|
} | null>(null);
|
||||||
|
const [previewing, setPreviewing] = useState(false);
|
||||||
|
|
||||||
|
const handleAddTag = () => {
|
||||||
|
const tag = tagInput.trim();
|
||||||
|
if (tag && tags.length < 5 && !tags.includes(tag) && tag.length <= 20) {
|
||||||
|
setTags([...tags, tag]);
|
||||||
|
setTagInput("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveTag = (tag: string) => {
|
||||||
|
setTags(tags.filter((t) => t !== tag));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePreview = async () => {
|
||||||
|
setPreviewing(true);
|
||||||
|
try {
|
||||||
|
const result = await previewTemplate(titleTemplate, contentTemplate, previewVars);
|
||||||
|
setPreviewResult(result);
|
||||||
|
} catch {
|
||||||
|
toast.error("预览失败");
|
||||||
|
} finally {
|
||||||
|
setPreviewing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!name.trim() || name.trim().length < 2) {
|
||||||
|
toast.error("名称长度至少 2 个字符");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!titleTemplate || titleTemplate.length > 120) {
|
||||||
|
toast.error("标题长度 1~120");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!contentTemplate || contentTemplate.length > 5000) {
|
||||||
|
toast.error("正文长度 1~5000");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (isEdit) {
|
||||||
|
await actions.update.mutateAsync({
|
||||||
|
id: template!.id,
|
||||||
|
name: name.trim(),
|
||||||
|
titleTemplate,
|
||||||
|
contentTemplate,
|
||||||
|
tags,
|
||||||
|
enabled,
|
||||||
|
expectedVersion: template!.version,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await actions.create.mutateAsync({
|
||||||
|
name: name.trim(),
|
||||||
|
titleTemplate,
|
||||||
|
contentTemplate,
|
||||||
|
tags,
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toast.success(isEdit ? "已更新" : "已创建");
|
||||||
|
onClose();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as { status?: number; detail?: unknown };
|
||||||
|
if (e?.status === 409) {
|
||||||
|
const detail = e.detail as { message?: string; server?: NotificationTemplate } | string;
|
||||||
|
if (typeof detail === "object" && detail?.server) {
|
||||||
|
toast.error("版本冲突");
|
||||||
|
} else {
|
||||||
|
toast.error(typeof detail === "string" ? detail : "名称已存在");
|
||||||
|
}
|
||||||
|
} else if (e?.status === 422) {
|
||||||
|
const detail = e.detail as string | { detail?: string };
|
||||||
|
toast.error(typeof detail === "string" ? detail : "参数校验失败");
|
||||||
|
} else {
|
||||||
|
toast.error("保存失败");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertVariable = (varName: string) => {
|
||||||
|
const textarea = document.querySelector<HTMLTextAreaElement>(
|
||||||
|
'[data-content-template="true"]',
|
||||||
|
);
|
||||||
|
if (textarea) {
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const before = contentTemplate.slice(0, start);
|
||||||
|
const after = contentTemplate.slice(end);
|
||||||
|
const insertion = `{{${varName}}}`;
|
||||||
|
setContentTemplate(before + insertion + after);
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(start + insertion.length, start + insertion.length);
|
||||||
|
}, 0);
|
||||||
|
} else {
|
||||||
|
setContentTemplate((prev) => prev + `{{${varName}}}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? "编辑模板" : "新建模板"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{conflict && (
|
||||||
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
|
||||||
|
<p className="mb-2 text-sm font-medium text-destructive">
|
||||||
|
版本冲突:服务器版本已更新 (v{conflict.server.version})
|
||||||
|
</p>
|
||||||
|
<div className="mb-3 grid grid-cols-2 gap-4 text-xs">
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 font-medium">服务器最新版本:</p>
|
||||||
|
<p className="rounded bg-background p-2">
|
||||||
|
名称: {conflict.server.name}
|
||||||
|
<br />
|
||||||
|
标题: {conflict.server.titleTemplate}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 font-medium">我的编辑内容:</p>
|
||||||
|
<p className="rounded bg-background p-2">
|
||||||
|
名称: {conflict.local.name}
|
||||||
|
<br />
|
||||||
|
标题: {conflict.local.titleTemplate}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={onConflictReload}>
|
||||||
|
<RefreshCw className="mr-1 size-3" />
|
||||||
|
重新加载
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="destructive" onClick={onConflictOverride}>
|
||||||
|
覆盖保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium">名称</label>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="2~50 字符"
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||||
|
<span className="text-sm">{enabled ? "启用" : "停用"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium">标题模板</label>
|
||||||
|
<Input
|
||||||
|
value={titleTemplate}
|
||||||
|
onChange={(e) => setTitleTemplate(e.target.value)}
|
||||||
|
placeholder="支持变量: {{userName}} {{taskName}} {{taskId}} {{dueDate}}"
|
||||||
|
maxLength={120}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{titleTemplate.length}/120</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium">正文模板</label>
|
||||||
|
<Textarea
|
||||||
|
data-content-template="true"
|
||||||
|
value={contentTemplate}
|
||||||
|
onChange={(e) => setContentTemplate(e.target.value)}
|
||||||
|
placeholder="支持变量: {{userName}} {{taskName}} {{taskId}} {{dueDate}}"
|
||||||
|
rows={6}
|
||||||
|
maxLength={5000}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{contentTemplate.length}/5000</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
<span className="text-xs text-muted-foreground">插入变量:</span>
|
||||||
|
{ALLOWED_VARIABLES.map((v) => (
|
||||||
|
<Button
|
||||||
|
key={v}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-xs"
|
||||||
|
onClick={() => insertVariable(v)}
|
||||||
|
>
|
||||||
|
{`{{${v}}}`}
|
||||||
|
<span className="ml-1 text-muted-foreground">
|
||||||
|
{VARIABLE_LABELS[v]}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium">
|
||||||
|
标签 ({tags.length}/5)
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<Badge key={tag} variant="secondary" className="gap-1">
|
||||||
|
{tag}
|
||||||
|
<button type="button" onClick={() => handleRemoveTag(tag)}>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{tags.length < 5 && (
|
||||||
|
<Input
|
||||||
|
value={tagInput}
|
||||||
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleAddTag())}
|
||||||
|
placeholder="输入标签后回车"
|
||||||
|
className="h-7 w-32"
|
||||||
|
maxLength={20}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">实时预览</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handlePreview}
|
||||||
|
disabled={previewing}
|
||||||
|
>
|
||||||
|
{previewing ? (
|
||||||
|
<Loader2 className="mr-1 size-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="mr-1 size-3" />
|
||||||
|
)}
|
||||||
|
预览
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3 grid grid-cols-2 gap-2">
|
||||||
|
{ALLOWED_VARIABLES.map((v) => (
|
||||||
|
<div key={v}>
|
||||||
|
<label className="text-xs text-muted-foreground">
|
||||||
|
{VARIABLE_LABELS[v]} ({v})
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={previewVars[v] ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPreviewVars((prev) => ({ ...prev, [v]: e.target.value }))
|
||||||
|
}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{previewResult && (
|
||||||
|
<div className="space-y-2 rounded bg-muted p-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-muted-foreground">标题:</span>
|
||||||
|
<p className="font-medium">{previewResult.title}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-muted-foreground">正文:</span>
|
||||||
|
<p className="whitespace-pre-wrap">{previewResult.content}</p>
|
||||||
|
</div>
|
||||||
|
{previewResult.missingVariables.length > 0 && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
缺少变量: {previewResult.missingVariables.join(", ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving && <Loader2 className="mr-1 size-4 animate-spin" />}
|
||||||
|
<Save className="mr-1 size-4" />
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ import LightAppListPage from "./LightAppListPage";
|
|||||||
import LightAppManagementPage from "./LightAppManagementPage";
|
import LightAppManagementPage from "./LightAppManagementPage";
|
||||||
import MenuManagementPage from "./MenuManagementPage";
|
import MenuManagementPage from "./MenuManagementPage";
|
||||||
import LoginWhitelistPage from "./LoginWhitelistPage";
|
import LoginWhitelistPage from "./LoginWhitelistPage";
|
||||||
|
import NotificationTemplatesPage from "./NotificationTemplatesPage";
|
||||||
import SensitiveWordsPage from "./SensitiveWordsPage";
|
import SensitiveWordsPage from "./SensitiveWordsPage";
|
||||||
import SkillAddressesPage from "./SkillAddressesPage";
|
import SkillAddressesPage from "./SkillAddressesPage";
|
||||||
import TaskButtonsPage from "./TaskButtonsPage";
|
import TaskButtonsPage from "./TaskButtonsPage";
|
||||||
@@ -438,6 +439,16 @@ export default function StrategyRoutes() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="admin/notification-templates"
|
||||||
|
element={
|
||||||
|
getStoredAuth()?.system_role === "admin" ? (
|
||||||
|
<NotificationTemplatesPage />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/page/workspace/chats/new" replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="admin/position-roles"
|
path="admin/position-roles"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -132,10 +132,15 @@ PositionRoundtablePage
|
|||||||
|
|
||||||
页面只维护一个 `sandboxArtifact`。`PositionNodeChat` 和 `PositionBuiltinAgentChat` 通过 `onPreviewArtifact` 上报预览目标;父页面统一渲染 `ArtifactFileDetail`。
|
页面只维护一个 `sandboxArtifact`。`PositionNodeChat` 和 `PositionBuiltinAgentChat` 通过 `onPreviewArtifact` 上报预览目标;父页面统一渲染 `ArtifactFileDetail`。
|
||||||
|
|
||||||
|
**对接 / 重做页面 / 排障指南(接口约定、必守规则、自测与症状表):**
|
||||||
|
[`docs/沙箱打开关闭与步骤条.md`](./docs/沙箱打开关闭与步骤条.md)
|
||||||
|
|
||||||
设计要点:
|
设计要点:
|
||||||
|
|
||||||
- 文件正在由 `write_file` 流式生成时,优先显示源码;完成后交由预览组件切换 Markdown 预览;
|
- 文件正在由 `write_file` 流式生成时,优先显示源码;完成后交由预览组件切换 Markdown 预览;
|
||||||
- `sandboxThreadSnapshot` 有 160ms 合并节流,只保留当前打开文件所在线程的最新快照,避免流式时频繁 setState;
|
- `write_file` 的参数是一整个 JSON 流,键顺序由模型决定。若 `content` 先于 `path` 到达,会先用临时文件名(`未命名文件.md`/`.html`,按正文首部嗅探)开沙箱——流式内容只按 `message_id` + `tool_call_id` 定位,与路径无关;真实 `path` 到达后沿用同一个 `previewKey` 改名,属于「更新」而非第二次交付;
|
||||||
|
- 流式快照**不能进页面 state**。本页很大(任务面板、意图结果、流程图、节点对话),按流式频率重渲染整页会把主线程占满,连消息列表都会停止绘制直到本轮结束。快照走 `createSandboxThreadStore()` + `SandboxThreadProvider`(`useSyncExternalStore`),只有沙箱子树重渲染;
|
||||||
|
- 快照的合并节流是**前沿**节流:距上次推送超过 160ms 就在流回调里同步推送,未满则挂一个补尾定时器。不能只靠尾随定时器——主线程繁忙时回调会被饿死,面板会一直冻在打开时的内容;
|
||||||
- 用户手动关闭后,`closedSandboxKeyRef` 与 `suppressedAutoPreviewThreadIdsRef` 阻止同一线程立即把沙箱重新打开;
|
- 用户手动关闭后,`closedSandboxKeyRef` 与 `suppressedAutoPreviewThreadIdsRef` 阻止同一线程立即把沙箱重新打开;
|
||||||
- 切岗位、切节点、切收口阶段、新建会话和切换历史会话都会先调用 `closeSandbox()`。
|
- 切岗位、切节点、切收口阶段、新建会话和切换历史会话都会先调用 `closeSandbox()`。
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ import { ThreadContext } from "@/components/workspace/messages/context";
|
|||||||
import { MessageList } from "@/components/workspace/messages";
|
import { MessageList } from "@/components/workspace/messages";
|
||||||
import { useAgent } from "@/core/agents";
|
import { useAgent } from "@/core/agents";
|
||||||
import { useLocalSettings } from "@/core/settings";
|
import { useLocalSettings } from "@/core/settings";
|
||||||
import { useEditMessage, useThreadStream } from "@/core/threads/hooks";
|
import {
|
||||||
|
useEditMessage,
|
||||||
|
useThreadStream,
|
||||||
|
type ToolStartEvent,
|
||||||
|
} from "@/core/threads/hooks";
|
||||||
import type { AgentThreadState } from "@/core/threads/types";
|
import type { AgentThreadState } from "@/core/threads/types";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
import { uuid } from "@/core/utils/uuid";
|
import { uuid } from "@/core/utils/uuid";
|
||||||
@@ -40,7 +44,6 @@ import {
|
|||||||
readConversationDraft,
|
readConversationDraft,
|
||||||
readConversationMessages,
|
readConversationMessages,
|
||||||
} from "@/position-roundtable/lib/conversation-snapshot";
|
} from "@/position-roundtable/lib/conversation-snapshot";
|
||||||
|
|
||||||
interface PositionNodeSwitchOption {
|
interface PositionNodeSwitchOption {
|
||||||
node: PositionRoundtableNode;
|
node: PositionRoundtableNode;
|
||||||
agentName?: string | null;
|
agentName?: string | null;
|
||||||
@@ -164,17 +167,100 @@ function artifactDisplayName(pathOrUrl: string): string {
|
|||||||
return path.split("/").filter(Boolean).at(-1) || "Markdown 报告";
|
return path.split("/").filter(Boolean).at(-1) || "Markdown 报告";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeWriteFilePath(path: string): string {
|
||||||
|
let normalized = path.trim();
|
||||||
|
const first = normalized[0];
|
||||||
|
const last = normalized.at(-1);
|
||||||
|
if (
|
||||||
|
normalized.length >= 2 &&
|
||||||
|
((first === '"' && last === '"') ||
|
||||||
|
(first === "'" && last === "'") ||
|
||||||
|
(first === "`" && last === "`"))
|
||||||
|
) {
|
||||||
|
normalized = normalized.slice(1, -1).trim();
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMarkdownPath(path: string): boolean {
|
||||||
|
const pathname = normalizeWriteFilePath(path).split(/[?#]/, 1)[0]?.toLowerCase() ?? "";
|
||||||
|
return pathname.endsWith(".md") || pathname.endsWith(".markdown");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCompleteArtifactExtension(path: string): boolean {
|
||||||
|
const pathname = normalizeWriteFilePath(path).split(/[?#]/, 1)[0] ?? "";
|
||||||
|
const filename = pathname.split("/").filter(Boolean).at(-1) ?? "";
|
||||||
|
// During JSON argument streaming a Markdown path arrives as `.m` and then
|
||||||
|
// `.md`. Opening on the partial suffix permanently labels the preview TEXT.
|
||||||
|
// Extensionless files still open from on_tool_start once the full path is known.
|
||||||
|
return /\.[a-z0-9]{2,12}$/i.test(filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `write_file` arguments stream as one JSON object and the key order is up to
|
||||||
|
* the model. Agents that emit `content` before `path` leave the file nameless
|
||||||
|
* for as long as the body takes to write — a minute for a full report. The
|
||||||
|
* sandbox follows a streaming file by message/tool-call id, not by path, so it
|
||||||
|
* can open under a provisional name and adopt the real one later. Sniff the
|
||||||
|
* body so the provisional name at least carries the right extension.
|
||||||
|
*/
|
||||||
|
function provisionalWriteFilePath(content: string): string {
|
||||||
|
const head = content.slice(0, 256).trimStart().toLowerCase();
|
||||||
|
if (head.startsWith("<!doctype html") || head.startsWith("<html")) {
|
||||||
|
return "未命名文件.html";
|
||||||
|
}
|
||||||
|
return "未命名文件.md";
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeFilePathFromToolEvent(data: unknown, depth = 0): string | null {
|
||||||
|
if (depth > 3 || data == null) return null;
|
||||||
|
if (typeof data === "string") {
|
||||||
|
const value = data.trim();
|
||||||
|
if (!value) return null;
|
||||||
|
if (value.startsWith("{") || value.startsWith("[")) {
|
||||||
|
try {
|
||||||
|
return writeFilePathFromToolEvent(JSON.parse(value), depth + 1);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof data !== "object" || Array.isArray(data)) return null;
|
||||||
|
const record = data as Record<string, unknown>;
|
||||||
|
if (typeof record.path === "string") {
|
||||||
|
const path = normalizeWriteFilePath(record.path);
|
||||||
|
return path || null;
|
||||||
|
}
|
||||||
|
for (const key of ["input", "inputs", "args", "kwargs"]) {
|
||||||
|
const path = writeFilePathFromToolEvent(record[key], depth + 1);
|
||||||
|
if (path) return path;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function buildWriteFileVirtualUrl(path: string, messageId: string, toolCallId: string): string {
|
function buildWriteFileVirtualUrl(path: string, messageId: string, toolCallId: string): string {
|
||||||
|
const language = isMarkdownPath(path) ? "&language=markdown" : "";
|
||||||
return new URL(
|
return new URL(
|
||||||
`write-file:${path}?message_id=${encodeURIComponent(
|
`write-file:${path}?message_id=${encodeURIComponent(
|
||||||
messageId,
|
messageId,
|
||||||
)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
|
)}&tool_call_id=${encodeURIComponent(toolCallId)}${language}`,
|
||||||
).toString();
|
).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isArtifactWriteTool(name: string | undefined): name is "write_file" | "str_replace" {
|
||||||
|
return name === "write_file" || name === "str_replace";
|
||||||
|
}
|
||||||
|
|
||||||
function latestStreamingWriteFile(
|
function latestStreamingWriteFile(
|
||||||
messages: AgentThreadState["messages"],
|
messages: AgentThreadState["messages"],
|
||||||
): { key: string; path: string; name: string; mimeType: string | null } | null {
|
): {
|
||||||
|
key: string;
|
||||||
|
path: string;
|
||||||
|
sourcePath: string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string | null;
|
||||||
|
} | null {
|
||||||
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
||||||
const message = messages[messageIndex] as (typeof messages)[number] & {
|
const message = messages[messageIndex] as (typeof messages)[number] & {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -183,17 +269,33 @@ function latestStreamingWriteFile(
|
|||||||
if (message.type !== "ai" || !message.id || !Array.isArray(message.tool_calls)) continue;
|
if (message.type !== "ai" || !message.id || !Array.isArray(message.tool_calls)) continue;
|
||||||
for (let toolIndex = message.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) {
|
for (let toolIndex = message.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) {
|
||||||
const toolCall = message.tool_calls[toolIndex];
|
const toolCall = message.tool_calls[toolIndex];
|
||||||
if (toolCall?.name !== "write_file" || !toolCall.id) continue;
|
if (!isArtifactWriteTool(toolCall?.name) || !toolCall.id) continue;
|
||||||
const rawPath = toolCall.args?.path;
|
const rawPath = typeof toolCall.args?.path === "string" ? toolCall.args.path : "";
|
||||||
if (typeof rawPath !== "string" || !rawPath.trim()) continue;
|
const rawContent = typeof toolCall.args?.content === "string" ? toolCall.args.content : "";
|
||||||
const content = toolCall.args?.content;
|
const normalizedPath = normalizeWriteFilePath(rawPath);
|
||||||
if (typeof content !== "string" || content.length === 0) continue;
|
const hasUsablePath =
|
||||||
const virtualPath = buildWriteFileVirtualUrl(rawPath, message.id, toolCall.id);
|
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
|
||||||
|
if (!hasUsablePath) {
|
||||||
|
// `str_replace` edits an existing file, so it can only be previewed once
|
||||||
|
// its real path is known. A new file is identified by its tool call, so
|
||||||
|
// its body can be previewed as soon as the first characters arrive.
|
||||||
|
if (toolCall.name !== "write_file" || !rawContent.trim()) continue;
|
||||||
|
}
|
||||||
|
const sourcePath = hasUsablePath ? normalizedPath : provisionalWriteFilePath(rawContent);
|
||||||
|
// Do not wait for the complete `content` argument. LangGraph streams
|
||||||
|
// write_file arguments incrementally, and the virtual artifact loader
|
||||||
|
// will redraw as `content` arrives. Waiting for it turns this into a
|
||||||
|
// post-generation preview instead of a live sandbox.
|
||||||
|
const previewPath =
|
||||||
|
toolCall.name === "write_file"
|
||||||
|
? buildWriteFileVirtualUrl(sourcePath, message.id, toolCall.id)
|
||||||
|
: sourcePath;
|
||||||
return {
|
return {
|
||||||
key: `${message.id}:${toolCall.id}`,
|
key: `${message.id}:${toolCall.id}`,
|
||||||
path: virtualPath,
|
path: previewPath,
|
||||||
name: artifactDisplayName(rawPath),
|
sourcePath,
|
||||||
mimeType: rawPath.toLowerCase().endsWith(".md") ? "text/markdown" : null,
|
name: artifactDisplayName(sourcePath),
|
||||||
|
mimeType: isMarkdownPath(sourcePath) ? "text/markdown" : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,7 +316,7 @@ function writeFileCallKeys(messages: AgentThreadState["messages"]): Set<string>
|
|||||||
};
|
};
|
||||||
if (message.type !== "ai" || !message.id || !Array.isArray(message.tool_calls)) continue;
|
if (message.type !== "ai" || !message.id || !Array.isArray(message.tool_calls)) continue;
|
||||||
for (const toolCall of message.tool_calls) {
|
for (const toolCall of message.tool_calls) {
|
||||||
if (toolCall?.name === "write_file" && toolCall.id) {
|
if (isArtifactWriteTool(toolCall?.name) && toolCall.id) {
|
||||||
keys.add(`${message.id}:${toolCall.id}`);
|
keys.add(`${message.id}:${toolCall.id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,11 +367,21 @@ export function PositionNodeChat({
|
|||||||
);
|
);
|
||||||
const [boundThreadId, setBoundThreadId] = useState<string | null>(node.threadId);
|
const [boundThreadId, setBoundThreadId] = useState<string | null>(node.threadId);
|
||||||
const boundThreadIdRef = useRef<string | null>(node.threadId);
|
const boundThreadIdRef = useRef<string | null>(node.threadId);
|
||||||
|
const previousNodeBindingRef = useRef({
|
||||||
|
nodeKey: node.nodeKey,
|
||||||
|
threadId: node.threadId,
|
||||||
|
});
|
||||||
const draftThreadIdRef = useRef(node.threadId ?? uuid());
|
const draftThreadIdRef = useRef(node.threadId ?? uuid());
|
||||||
const upstreamRevisionRef = useRef<Record<string, unknown> | null>(null);
|
const upstreamRevisionRef = useRef<Record<string, unknown> | null>(null);
|
||||||
const autoPreviewedArtifactKeyRef = useRef<string | null>(null);
|
const autoPreviewedArtifactKeyRef = useRef<string | null>(null);
|
||||||
const autoPreviewBaselineCallKeysRef = useRef<Set<string>>(new Set());
|
const autoPreviewBaselineCallKeysRef = useRef<Set<string>>(new Set());
|
||||||
const autoPreviewRunArmedRef = useRef(false);
|
const autoPreviewRunArmedRef = useRef(false);
|
||||||
|
const writeFilePreviewSequenceRef = useRef(0);
|
||||||
|
const activeWriteFilePreviewRef = useRef<{
|
||||||
|
path: string;
|
||||||
|
previewKey: string;
|
||||||
|
toolCallId?: string;
|
||||||
|
} | null>(null);
|
||||||
const editMessage = useEditMessage();
|
const editMessage = useEditMessage();
|
||||||
const [messageContentOverrides, setMessageContentOverrides] = useState<Record<string, string>>({});
|
const [messageContentOverrides, setMessageContentOverrides] = useState<Record<string, string>>({});
|
||||||
const persistedMessages = useMemo(
|
const persistedMessages = useMemo(
|
||||||
@@ -288,12 +400,28 @@ export function PositionNodeChat({
|
|||||||
!node.invalidatedBy;
|
!node.invalidatedBy;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const previousBinding = previousNodeBindingRef.current;
|
||||||
|
previousNodeBindingRef.current = { nodeKey: node.nodeKey, threadId: node.threadId };
|
||||||
|
// `onStart` binds a newly created LangGraph thread before the session API
|
||||||
|
// persists that id back into `node.threadId`. That prop update happens
|
||||||
|
// while the first turn is already streaming; it is not a node switch and
|
||||||
|
// must not clear the preview run that has just been armed.
|
||||||
|
const isOwnThreadBinding =
|
||||||
|
previousBinding.nodeKey === node.nodeKey &&
|
||||||
|
previousBinding.threadId == null &&
|
||||||
|
node.threadId != null &&
|
||||||
|
node.threadId === boundThreadIdRef.current;
|
||||||
|
if (isOwnThreadBinding) return;
|
||||||
|
|
||||||
setBoundThreadId(node.threadId);
|
setBoundThreadId(node.threadId);
|
||||||
boundThreadIdRef.current = node.threadId;
|
boundThreadIdRef.current = node.threadId;
|
||||||
setInputDraft(readConversationDraft(node.conversationSnapshot));
|
setInputDraft(readConversationDraft(node.conversationSnapshot));
|
||||||
setMessageContentOverrides({});
|
setMessageContentOverrides({});
|
||||||
|
autoPreviewedArtifactKeyRef.current = null;
|
||||||
autoPreviewBaselineCallKeysRef.current = new Set();
|
autoPreviewBaselineCallKeysRef.current = new Set();
|
||||||
autoPreviewRunArmedRef.current = false;
|
autoPreviewRunArmedRef.current = false;
|
||||||
|
writeFilePreviewSequenceRef.current = 0;
|
||||||
|
activeWriteFilePreviewRef.current = null;
|
||||||
// The component is keyed by nodeKey in the page. This reset is therefore
|
// The component is keyed by nodeKey in the page. This reset is therefore
|
||||||
// only a defensive guard for callers that reuse the component instance.
|
// only a defensive guard for callers that reuse the component instance.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -314,6 +442,43 @@ export function PositionNodeChat({
|
|||||||
[node.nodeKey, onNodeUpdate, sessionId],
|
[node.nodeKey, onNodeUpdate, sessionId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleToolStart = useCallback(
|
||||||
|
(event: ToolStartEvent) => {
|
||||||
|
if (!isArtifactWriteTool(event.name) || !autoPreviewRunArmedRef.current) return;
|
||||||
|
const path = writeFilePathFromToolEvent(event.data);
|
||||||
|
const threadId = boundThreadIdRef.current;
|
||||||
|
if (!path || !threadId) return;
|
||||||
|
|
||||||
|
// Message chunks and on_tool_start can describe the same invocation in
|
||||||
|
// either order. Reuse the first preview key so a manual close suppresses
|
||||||
|
// only this write, while a later rewrite receives a fresh key and opens.
|
||||||
|
const activePreview = activeWriteFilePreviewRef.current;
|
||||||
|
if (
|
||||||
|
activePreview?.path === path &&
|
||||||
|
(activePreview.toolCallId === event.toolCallId ||
|
||||||
|
(event.toolCallId != null && activePreview.previewKey.endsWith(`:${event.toolCallId}`)))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previewKey = event.toolCallId
|
||||||
|
? `tool-start:${event.toolCallId}`
|
||||||
|
: `tool-start:${node.nodeKey}:${++writeFilePreviewSequenceRef.current}`;
|
||||||
|
activeWriteFilePreviewRef.current = { path, previewKey, toolCallId: event.toolCallId };
|
||||||
|
const artifactKey = `${threadId}:${previewKey}`;
|
||||||
|
if (autoPreviewedArtifactKeyRef.current === artifactKey) return;
|
||||||
|
autoPreviewedArtifactKeyRef.current = artifactKey;
|
||||||
|
onPreviewArtifact?.({
|
||||||
|
threadId,
|
||||||
|
path,
|
||||||
|
name: artifactDisplayName(path),
|
||||||
|
mimeType: isMarkdownPath(path) ? "text/markdown" : null,
|
||||||
|
previewKey,
|
||||||
|
auto: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[node.nodeKey, onPreviewArtifact],
|
||||||
|
);
|
||||||
|
|
||||||
const [thread, sendMessage, , actualThreadId] = useThreadStream({
|
const [thread, sendMessage, , actualThreadId] = useThreadStream({
|
||||||
threadId: boundThreadId,
|
threadId: boundThreadId,
|
||||||
assistantId: node.agentId,
|
assistantId: node.agentId,
|
||||||
@@ -334,15 +499,21 @@ export function PositionNodeChat({
|
|||||||
agent_id: node.agentId,
|
agent_id: node.agentId,
|
||||||
},
|
},
|
||||||
onStart: bindCreatedThread,
|
onStart: bindCreatedThread,
|
||||||
|
onToolStart: handleToolStart,
|
||||||
onFinish: (state) => {
|
onFinish: (state) => {
|
||||||
// A turn is not considered delivered by the UI alone. The persisted
|
// A turn is not considered delivered by the UI alone. The persisted
|
||||||
// completion call records the answer, file manifest and upstream version
|
// completion call records the answer, file manifest and upstream version
|
||||||
// so the service can decide whether this seat unlocks its downstream.
|
// so the service can decide whether this seat unlocks its downstream.
|
||||||
const threadId = boundThreadIdRef.current;
|
const threadId = boundThreadIdRef.current;
|
||||||
const latestAi = [...(state.messages ?? [])]
|
// `present_files` is often the last AI tool call and has no prose of
|
||||||
|
// its own. Keep walking backwards to the last actual answer; otherwise
|
||||||
|
// a successful file delivery would return early here and never refresh
|
||||||
|
// the node's artifact cards.
|
||||||
|
const latestAnswer = [...(state.messages ?? [])]
|
||||||
.reverse()
|
.reverse()
|
||||||
.find((message) => message.type === "ai");
|
.filter((message) => message.type === "ai")
|
||||||
const latestAnswer = latestAi ? textOfMessage(latestAi) ?? "" : "";
|
.map((message) => textOfMessage(message) ?? "")
|
||||||
|
.find((content) => content.trim()) ?? "";
|
||||||
if (!threadId || !latestAnswer.trim()) return;
|
if (!threadId || !latestAnswer.trim()) return;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
let artifactManifest: Array<Record<string, unknown>> = [];
|
let artifactManifest: Array<Record<string, unknown>> = [];
|
||||||
@@ -378,6 +549,10 @@ export function PositionNodeChat({
|
|||||||
|
|
||||||
const activeThreadId =
|
const activeThreadId =
|
||||||
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? draftThreadIdRef.current;
|
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? draftThreadIdRef.current;
|
||||||
|
// A sandbox must always receive the server thread id. In particular, do not
|
||||||
|
// attach a streaming preview to the provisional UUID used for a new node
|
||||||
|
// before `useThreadStream` has created and bound its thread.
|
||||||
|
const previewThreadId = boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? null;
|
||||||
const persistedThreadId =
|
const persistedThreadId =
|
||||||
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? null;
|
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? null;
|
||||||
const recoveredMessages = useMemo(
|
const recoveredMessages = useMemo(
|
||||||
@@ -566,32 +741,60 @@ export function PositionNodeChat({
|
|||||||
() => (thread.isLoading ? latestStreamingWriteFile(thread.messages) : null),
|
() => (thread.isLoading ? latestStreamingWriteFile(thread.messages) : null),
|
||||||
[thread.isLoading, thread.messages],
|
[thread.isLoading, thread.messages],
|
||||||
);
|
);
|
||||||
|
const newStreamingArtifact =
|
||||||
|
streamingArtifact && !autoPreviewBaselineCallKeysRef.current.has(streamingArtifact.key)
|
||||||
|
? streamingArtifact
|
||||||
|
: null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!thread.isLoading ||
|
!thread.isLoading ||
|
||||||
!streamingArtifact ||
|
!newStreamingArtifact ||
|
||||||
!autoPreviewRunArmedRef.current ||
|
!previewThreadId ||
|
||||||
autoPreviewBaselineCallKeysRef.current.has(streamingArtifact.key)
|
!autoPreviewRunArmedRef.current
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// One write_file call should open at most one preview. The parent further
|
// One write_file call should open at most one preview. The parent further
|
||||||
// guards manual close state, because it owns the page-wide singleton sandbox.
|
// guards manual close state, because it owns the page-wide singleton sandbox.
|
||||||
// A report is often rewritten to the same output path. The tool-call key
|
// A report is often rewritten to the same output path — identity must be the
|
||||||
// (not just the path) makes that a new previewable delivery.
|
// tool-call key, not the path, otherwise a second write keeps the old panel
|
||||||
const artifactKey = `${activeThreadId}:${streamingArtifact.key}`;
|
// and the sandbox never clears / remounts for the rewrite.
|
||||||
|
const toolCallKey = newStreamingArtifact.key;
|
||||||
|
const toolCallId = toolCallKey.includes(":")
|
||||||
|
? toolCallKey.slice(toolCallKey.lastIndexOf(":") + 1)
|
||||||
|
: undefined;
|
||||||
|
const startedPreview = activeWriteFilePreviewRef.current;
|
||||||
|
const isSameToolCall =
|
||||||
|
startedPreview != null &&
|
||||||
|
(startedPreview.previewKey === toolCallKey ||
|
||||||
|
(toolCallId != null &&
|
||||||
|
(startedPreview.toolCallId === toolCallId ||
|
||||||
|
startedPreview.previewKey.endsWith(`:${toolCallId}`))));
|
||||||
|
// Same tool call may upgrade a provisional filename → real path; keep the
|
||||||
|
// open panel. A different tool call is a new delivery even on the same path.
|
||||||
|
const previewKey = isSameToolCall ? startedPreview.previewKey : toolCallKey;
|
||||||
|
activeWriteFilePreviewRef.current = {
|
||||||
|
path: newStreamingArtifact.sourcePath,
|
||||||
|
previewKey,
|
||||||
|
toolCallId: toolCallId ?? startedPreview?.toolCallId,
|
||||||
|
};
|
||||||
|
// The path takes part in the identity so that a preview opened under a
|
||||||
|
// provisional name is re-published once the real path streams in. The
|
||||||
|
// preview key stays the same, so the parent treats it as an update of the
|
||||||
|
// open panel rather than a second delivery.
|
||||||
|
const artifactKey = `${previewThreadId}:${previewKey}:${newStreamingArtifact.sourcePath}`;
|
||||||
if (autoPreviewedArtifactKeyRef.current === artifactKey) return;
|
if (autoPreviewedArtifactKeyRef.current === artifactKey) return;
|
||||||
autoPreviewedArtifactKeyRef.current = artifactKey;
|
autoPreviewedArtifactKeyRef.current = artifactKey;
|
||||||
onPreviewArtifact?.({
|
onPreviewArtifact?.({
|
||||||
threadId: activeThreadId,
|
threadId: previewThreadId,
|
||||||
path: streamingArtifact.path,
|
path: newStreamingArtifact.path,
|
||||||
name: streamingArtifact.name,
|
name: newStreamingArtifact.name,
|
||||||
mimeType: streamingArtifact.mimeType,
|
mimeType: newStreamingArtifact.mimeType,
|
||||||
previewKey: streamingArtifact.key,
|
previewKey,
|
||||||
auto: true,
|
auto: true,
|
||||||
});
|
});
|
||||||
}, [activeThreadId, onPreviewArtifact, streamingArtifact, thread.isLoading]);
|
}, [newStreamingArtifact, onPreviewArtifact, previewThreadId, thread.isLoading]);
|
||||||
|
|
||||||
// SDK thread creation is asynchronous. ``onStart`` normally binds it, but
|
// SDK thread creation is asynchronous. ``onStart`` normally binds it, but
|
||||||
// watching the actual stream id as well closes the last race where a page
|
// watching the actual stream id as well closes the last race where a page
|
||||||
@@ -628,6 +831,7 @@ export function PositionNodeChat({
|
|||||||
: null;
|
: null;
|
||||||
// A thread retains historical write_file tool calls. Only files whose
|
// A thread retains historical write_file tool calls. Only files whose
|
||||||
// call id appears after this exact submission may open the sandbox.
|
// call id appears after this exact submission may open the sandbox.
|
||||||
|
activeWriteFilePreviewRef.current = null;
|
||||||
autoPreviewBaselineCallKeysRef.current = writeFileCallKeys(thread.messages);
|
autoPreviewBaselineCallKeysRef.current = writeFileCallKeys(thread.messages);
|
||||||
autoPreviewRunArmedRef.current = true;
|
autoPreviewRunArmedRef.current = true;
|
||||||
await sendMessage(boundThreadIdRef.current ?? draftThreadIdRef.current, message, {
|
await sendMessage(boundThreadIdRef.current ?? draftThreadIdRef.current, message, {
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,13 @@
|
|||||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||||
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import {
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useSyncExternalStore,
|
||||||
|
} from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
@@ -336,6 +344,72 @@ function sandboxCloseKey(
|
|||||||
return `${artifact.threadId}:${normalizePreviewArtifactPath(artifact.path)}`;
|
return `${artifact.threadId}:${normalizePreviewArtifactPath(artifact.path)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SandboxThreadStore = {
|
||||||
|
subscribe: (listener: () => void) => () => void;
|
||||||
|
getSnapshot: () => SandboxThreadSnapshot | null;
|
||||||
|
publish: (next: SandboxThreadSnapshot | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The streaming thread reaches the sandbox through a subscription rather than
|
||||||
|
* page state. This page is large — task panel, intent results, flowchart, node
|
||||||
|
* chat — and a streamed file publishes several times per second. Driving that
|
||||||
|
* through `useState` here re-renders the whole page at that rate and starves the
|
||||||
|
* browser so badly that even the message list stops painting until the run ends.
|
||||||
|
* With a store, only `SandboxThreadProvider` and the preview below it re-render.
|
||||||
|
*/
|
||||||
|
function createSandboxThreadStore(): SandboxThreadStore {
|
||||||
|
let snapshot: SandboxThreadSnapshot | null = null;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
return {
|
||||||
|
subscribe(listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getSnapshot: () => snapshot,
|
||||||
|
publish(next) {
|
||||||
|
const isUnchanged =
|
||||||
|
snapshot === next ||
|
||||||
|
(snapshot != null &&
|
||||||
|
next != null &&
|
||||||
|
snapshot.threadId === next.threadId &&
|
||||||
|
snapshot.signature === next.signature);
|
||||||
|
if (isUnchanged) return;
|
||||||
|
snapshot = next;
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function SandboxThreadProvider({
|
||||||
|
store,
|
||||||
|
threadId,
|
||||||
|
fallbackThread,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
store: SandboxThreadStore;
|
||||||
|
threadId: string;
|
||||||
|
fallbackThread: BaseStream<AgentThreadState>;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
||||||
|
const thread = snapshot?.threadId === threadId ? snapshot.thread : fallbackThread;
|
||||||
|
const value = useMemo(() => ({ thread, isMock: false }), [thread]);
|
||||||
|
return <ThreadContext.Provider value={value}>{children}</ThreadContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper bound on how often the streaming thread is re-published to the sandbox.
|
||||||
|
* Message chunks arrive roughly every 30ms and re-rendering the artifact panel
|
||||||
|
* is expensive, so updates are coalesced — but never postponed past this gap,
|
||||||
|
* otherwise the panel visibly freezes while the file is being written.
|
||||||
|
*/
|
||||||
|
const SANDBOX_SNAPSHOT_MIN_INTERVAL_MS = 160;
|
||||||
|
|
||||||
function sandboxThreadSignature(thread: BaseStream<AgentThreadState>): string {
|
function sandboxThreadSignature(thread: BaseStream<AgentThreadState>): string {
|
||||||
const messages = thread.messages ?? [];
|
const messages = thread.messages ?? [];
|
||||||
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
||||||
@@ -872,10 +946,11 @@ export default function PositionRoundtablePage({
|
|||||||
const [sandboxArtifact, setSandboxArtifact] = useState<PositionArtifactPreview | null>(null);
|
const [sandboxArtifact, setSandboxArtifact] = useState<PositionArtifactPreview | null>(null);
|
||||||
const sandboxArtifactRef = useRef<PositionArtifactPreview | null>(null);
|
const sandboxArtifactRef = useRef<PositionArtifactPreview | null>(null);
|
||||||
const closedSandboxKeyRef = useRef<string | null>(null);
|
const closedSandboxKeyRef = useRef<string | null>(null);
|
||||||
const [sandboxThreadSnapshot, setSandboxThreadSnapshot] = useState<SandboxThreadSnapshot | null>(null);
|
const sandboxThreadStore = useMemo(createSandboxThreadStore, []);
|
||||||
const latestNodeThreadSnapshotRef = useRef<SandboxThreadSnapshot | null>(null);
|
const latestNodeThreadSnapshotRef = useRef<SandboxThreadSnapshot | null>(null);
|
||||||
const pendingSandboxThreadSnapshotRef = useRef<SandboxThreadSnapshot | null>(null);
|
const pendingSandboxThreadSnapshotRef = useRef<SandboxThreadSnapshot | null>(null);
|
||||||
const sandboxSnapshotTimerRef = useRef<number | null>(null);
|
const sandboxSnapshotTimerRef = useRef<number | null>(null);
|
||||||
|
const sandboxSnapshotAppliedAtRef = useRef(0);
|
||||||
const [flowDetailItem, setFlowDetailItem] = useState<ResultItem | null>(null);
|
const [flowDetailItem, setFlowDetailItem] = useState<ResultItem | null>(null);
|
||||||
const [taskEditorOpen, setTaskEditorOpen] = useState(false);
|
const [taskEditorOpen, setTaskEditorOpen] = useState(false);
|
||||||
const [taskDraft, setTaskDraft] = useState<PositionTask>(fallbackTask);
|
const [taskDraft, setTaskDraft] = useState<PositionTask>(fallbackTask);
|
||||||
@@ -908,7 +983,10 @@ export default function PositionRoundtablePage({
|
|||||||
sandboxArtifactRef.current = sandboxArtifact;
|
sandboxArtifactRef.current = sandboxArtifact;
|
||||||
}, [sandboxArtifact]);
|
}, [sandboxArtifact]);
|
||||||
const flushSandboxThreadSnapshot = useCallback(() => {
|
const flushSandboxThreadSnapshot = useCallback(() => {
|
||||||
sandboxSnapshotTimerRef.current = null;
|
if (sandboxSnapshotTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(sandboxSnapshotTimerRef.current);
|
||||||
|
sandboxSnapshotTimerRef.current = null;
|
||||||
|
}
|
||||||
const pending = pendingSandboxThreadSnapshotRef.current;
|
const pending = pendingSandboxThreadSnapshotRef.current;
|
||||||
pendingSandboxThreadSnapshotRef.current = null;
|
pendingSandboxThreadSnapshotRef.current = null;
|
||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
@@ -916,21 +994,28 @@ export default function PositionRoundtablePage({
|
|||||||
if (!currentArtifact || currentArtifact.threadId !== pending.threadId) {
|
if (!currentArtifact || currentArtifact.threadId !== pending.threadId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSandboxThreadSnapshot((current) =>
|
sandboxSnapshotAppliedAtRef.current = Date.now();
|
||||||
current?.threadId === pending.threadId && current.signature === pending.signature
|
sandboxThreadStore.publish(pending);
|
||||||
? current
|
}, [sandboxThreadStore]);
|
||||||
: pending,
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
const queueSandboxThreadSnapshot = useCallback(
|
const queueSandboxThreadSnapshot = useCallback(
|
||||||
(snapshot: SandboxThreadSnapshot) => {
|
(snapshot: SandboxThreadSnapshot) => {
|
||||||
pendingSandboxThreadSnapshotRef.current = snapshot;
|
pendingSandboxThreadSnapshotRef.current = snapshot;
|
||||||
if (sandboxSnapshotTimerRef.current !== null) return;
|
// Leading-edge throttle: publish straight away whenever the previous
|
||||||
if (typeof window === "undefined") {
|
// publish is old enough. Trailing timers alone are not dependable here —
|
||||||
|
// a continuous stream keeps the main thread busy and the panel then stays
|
||||||
|
// frozen on the content it had when it opened.
|
||||||
|
const sinceApplied = Date.now() - sandboxSnapshotAppliedAtRef.current;
|
||||||
|
if (typeof window === "undefined" || sinceApplied >= SANDBOX_SNAPSHOT_MIN_INTERVAL_MS) {
|
||||||
flushSandboxThreadSnapshot();
|
flushSandboxThreadSnapshot();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sandboxSnapshotTimerRef.current = window.setTimeout(flushSandboxThreadSnapshot, 160);
|
// Still inside the quiet window: make sure the last chunk of a stream that
|
||||||
|
// stops right now is not left unpublished.
|
||||||
|
if (sandboxSnapshotTimerRef.current !== null) return;
|
||||||
|
sandboxSnapshotTimerRef.current = window.setTimeout(
|
||||||
|
flushSandboxThreadSnapshot,
|
||||||
|
SANDBOX_SNAPSHOT_MIN_INTERVAL_MS - sinceApplied,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[flushSandboxThreadSnapshot],
|
[flushSandboxThreadSnapshot],
|
||||||
);
|
);
|
||||||
@@ -938,6 +1023,7 @@ export default function PositionRoundtablePage({
|
|||||||
() => () => {
|
() => () => {
|
||||||
if (sandboxSnapshotTimerRef.current !== null) {
|
if (sandboxSnapshotTimerRef.current !== null) {
|
||||||
window.clearTimeout(sandboxSnapshotTimerRef.current);
|
window.clearTimeout(sandboxSnapshotTimerRef.current);
|
||||||
|
sandboxSnapshotTimerRef.current = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
@@ -2014,7 +2100,7 @@ export default function PositionRoundtablePage({
|
|||||||
}
|
}
|
||||||
closedSandboxKeyRef.current = null;
|
closedSandboxKeyRef.current = null;
|
||||||
setSandboxArtifact(null);
|
setSandboxArtifact(null);
|
||||||
setSandboxThreadSnapshot(null);
|
sandboxThreadStore.publish(null);
|
||||||
intent.resetIntent();
|
intent.resetIntent();
|
||||||
hydratedIntentSessionRef.current = null;
|
hydratedIntentSessionRef.current = null;
|
||||||
restoringIntentSessionRef.current = null;
|
restoringIntentSessionRef.current = null;
|
||||||
@@ -2102,7 +2188,7 @@ export default function PositionRoundtablePage({
|
|||||||
// 会话的窗口期仍接收旧 thread 增量;hydrate 入口还会再做一次兜底清理。
|
// 会话的窗口期仍接收旧 thread 增量;hydrate 入口还会再做一次兜底清理。
|
||||||
intent.stopIntentStream();
|
intent.stopIntentStream();
|
||||||
setSandboxArtifact(null);
|
setSandboxArtifact(null);
|
||||||
setSandboxThreadSnapshot(null);
|
sandboxThreadStore.publish(null);
|
||||||
setActiveNodeKey(null);
|
setActiveNodeKey(null);
|
||||||
setActiveBuiltinKind(null);
|
setActiveBuiltinKind(null);
|
||||||
setSelectedAgentId(null);
|
setSelectedAgentId(null);
|
||||||
@@ -2225,7 +2311,7 @@ export default function PositionRoundtablePage({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const artifactKey = sandboxCloseKey(artifact);
|
const artifactKey = sandboxCloseKey(artifact);
|
||||||
if (artifact.auto) {
|
if (artifact.auto) {
|
||||||
// Closing an old file should only suppress that exact preview. A later
|
// Closing an old file should only suppress that exact preview. A later
|
||||||
// write_file in the same thread is a new deliverable and must still be
|
// write_file in the same thread is a new deliverable and must still be
|
||||||
@@ -2236,22 +2322,47 @@ export default function PositionRoundtablePage({
|
|||||||
}
|
}
|
||||||
closedSandboxKeyRef.current = null;
|
closedSandboxKeyRef.current = null;
|
||||||
|
|
||||||
setSandboxArtifact((current) => {
|
// Follow the same lifecycle as the regular conversation view. The
|
||||||
const isSamePreview =
|
// position workspace owns a custom visual container, but the artifact
|
||||||
current?.threadId === artifact.threadId &&
|
// selection/open state must still advance at tool-start so its preview is
|
||||||
current.path === artifact.path &&
|
// not deferred until the final artifact list is written.
|
||||||
current.previewKey === artifact.previewKey;
|
const ctx = artifactsCtxRef.current;
|
||||||
return isSamePreview ? current : artifact;
|
ctx?.select(artifact.path, true);
|
||||||
});
|
ctx?.setOpen(true);
|
||||||
setSandboxThreadSnapshot((current) =>
|
|
||||||
current?.threadId === artifact.threadId ? current : null,
|
const openedArtifact = sandboxArtifactRef.current;
|
||||||
);
|
const isSamePreview =
|
||||||
|
openedArtifact?.threadId === artifact.threadId &&
|
||||||
|
openedArtifact.path === artifact.path &&
|
||||||
|
openedArtifact.previewKey === artifact.previewKey;
|
||||||
|
// A streaming file is previewed under a provisional name until its real path
|
||||||
|
// arrives. Adopting that path renames the open panel; it does not deliver a
|
||||||
|
// second file, so the surrounding layout must not be reshuffled again.
|
||||||
|
const isPathUpgrade =
|
||||||
|
!isSamePreview &&
|
||||||
|
Boolean(artifact.previewKey) &&
|
||||||
|
openedArtifact?.threadId === artifact.threadId &&
|
||||||
|
openedArtifact.previewKey === artifact.previewKey;
|
||||||
|
if (!isSamePreview) {
|
||||||
|
setSandboxArtifact(artifact);
|
||||||
|
// The snapshot queued below is published synchronously, before the state
|
||||||
|
// update above commits, so the ref that gates it has to advance here.
|
||||||
|
sandboxArtifactRef.current = artifact;
|
||||||
|
// A newly opened panel must show the file's current content immediately
|
||||||
|
// instead of waiting out the throttle window of the previous preview.
|
||||||
|
sandboxSnapshotAppliedAtRef.current = 0;
|
||||||
|
}
|
||||||
|
if (sandboxThreadStore.getSnapshot()?.threadId !== artifact.threadId) {
|
||||||
|
sandboxThreadStore.publish(null);
|
||||||
|
}
|
||||||
|
|
||||||
const latestSnapshot = latestNodeThreadSnapshotRef.current;
|
const latestSnapshot = latestNodeThreadSnapshotRef.current;
|
||||||
if (latestSnapshot?.threadId === artifact.threadId) {
|
if (latestSnapshot?.threadId === artifact.threadId) {
|
||||||
queueSandboxThreadSnapshot(latestSnapshot);
|
queueSandboxThreadSnapshot(latestSnapshot);
|
||||||
}
|
}
|
||||||
setRightPanelOpen(false);
|
if (!isPathUpgrade) {
|
||||||
|
setRightPanelOpen(false);
|
||||||
|
}
|
||||||
}, [queueSandboxThreadSnapshot]);
|
}, [queueSandboxThreadSnapshot]);
|
||||||
const handlePreviewFlowArtifact = useCallback(
|
const handlePreviewFlowArtifact = useCallback(
|
||||||
(item: ResultItem) => {
|
(item: ResultItem) => {
|
||||||
@@ -2322,18 +2433,6 @@ export default function PositionRoundtablePage({
|
|||||||
}) as unknown as BaseStream<AgentThreadState>,
|
}) as unknown as BaseStream<AgentThreadState>,
|
||||||
[artifactsCtx?.artifacts],
|
[artifactsCtx?.artifacts],
|
||||||
);
|
);
|
||||||
const sandboxThread = useMemo(
|
|
||||||
() =>
|
|
||||||
sandboxArtifact &&
|
|
||||||
sandboxThreadSnapshot?.threadId === sandboxArtifact.threadId
|
|
||||||
? sandboxThreadSnapshot.thread
|
|
||||||
: sandboxThreadStub,
|
|
||||||
[sandboxArtifact, sandboxThreadSnapshot, sandboxThreadStub],
|
|
||||||
);
|
|
||||||
const sandboxThreadContextValue = useMemo(
|
|
||||||
() => ({ thread: sandboxThread, isMock: false }),
|
|
||||||
[sandboxThread],
|
|
||||||
);
|
|
||||||
const handleNodeThreadSnapshot = useCallback(
|
const handleNodeThreadSnapshot = useCallback(
|
||||||
(snapshot: { threadId: string; thread: BaseStream<AgentThreadState> }) => {
|
(snapshot: { threadId: string; thread: BaseStream<AgentThreadState> }) => {
|
||||||
const signature = sandboxThreadSignature(snapshot.thread);
|
const signature = sandboxThreadSignature(snapshot.thread);
|
||||||
@@ -2356,10 +2455,16 @@ export default function PositionRoundtablePage({
|
|||||||
if (ctx?.open) {
|
if (ctx?.open) {
|
||||||
ctx?.setOpen(false);
|
ctx?.setOpen(false);
|
||||||
}
|
}
|
||||||
|
if (sandboxSnapshotTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(sandboxSnapshotTimerRef.current);
|
||||||
|
sandboxSnapshotTimerRef.current = null;
|
||||||
|
}
|
||||||
pendingSandboxThreadSnapshotRef.current = null;
|
pendingSandboxThreadSnapshotRef.current = null;
|
||||||
|
sandboxSnapshotAppliedAtRef.current = 0;
|
||||||
|
sandboxArtifactRef.current = null;
|
||||||
setSandboxArtifact(null);
|
setSandboxArtifact(null);
|
||||||
setSandboxThreadSnapshot(null);
|
sandboxThreadStore.publish(null);
|
||||||
}, []);
|
}, [sandboxThreadStore]);
|
||||||
const handleSelectBuiltinStage = useCallback(
|
const handleSelectBuiltinStage = useCallback(
|
||||||
(kind: ClosureStageKind) => {
|
(kind: ClosureStageKind) => {
|
||||||
// The closing agents are part of the testing-analysis workspace only;
|
// The closing agents are part of the testing-analysis workspace only;
|
||||||
@@ -2993,18 +3098,23 @@ export default function PositionRoundtablePage({
|
|||||||
)}
|
)}
|
||||||
{sandboxOpen && sandboxArtifact && artifactsCtx && (
|
{sandboxOpen && sandboxArtifact && artifactsCtx && (
|
||||||
<aside className="flex w-[min(48vw,720px)] min-w-[440px] shrink-0 flex-col overflow-hidden rounded-[4px] border border-slate-200 bg-white shadow-sm dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80 dark:shadow-none">
|
<aside className="flex w-[min(48vw,720px)] min-w-[440px] shrink-0 flex-col overflow-hidden rounded-[4px] border border-slate-200 bg-white shadow-sm dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80 dark:shadow-none">
|
||||||
<ThreadContext.Provider value={sandboxThreadContextValue}>
|
<SandboxThreadProvider
|
||||||
|
store={sandboxThreadStore}
|
||||||
|
threadId={sandboxArtifact.threadId}
|
||||||
|
fallbackThread={sandboxThreadStub}
|
||||||
|
>
|
||||||
<ArtifactFileDetail
|
<ArtifactFileDetail
|
||||||
key={sandboxCloseKey(sandboxArtifact)}
|
key={sandboxCloseKey(sandboxArtifact)}
|
||||||
className="min-h-0 flex-1"
|
className="min-h-0 flex-1"
|
||||||
filepath={sandboxArtifact.path}
|
filepath={sandboxArtifact.path}
|
||||||
threadId={sandboxArtifact.threadId}
|
threadId={sandboxArtifact.threadId}
|
||||||
|
mimeType={sandboxArtifact.mimeType}
|
||||||
onClose={closeSandbox}
|
onClose={closeSandbox}
|
||||||
allowVirtualMarkdownEdit
|
allowVirtualMarkdownEdit
|
||||||
disableDesensitize
|
disableDesensitize
|
||||||
hideFileName
|
hideFileName
|
||||||
/>
|
/>
|
||||||
</ThreadContext.Provider>
|
</SandboxThreadProvider>
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
<DeliverableDetailDialog
|
<DeliverableDetailDialog
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||||
|
|
||||||
|
const BASE = () => `${getBackendBaseURL()}/api/notification-templates`;
|
||||||
|
|
||||||
|
export const ALLOWED_VARIABLES = ["userName", "taskName", "taskId", "dueDate"] as const;
|
||||||
|
|
||||||
|
export interface NotificationTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
titleTemplate: string;
|
||||||
|
contentTemplate: string;
|
||||||
|
tags: string[];
|
||||||
|
enabled: boolean;
|
||||||
|
version: number;
|
||||||
|
createdBy: string | null;
|
||||||
|
updatedBy: string | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateListResult {
|
||||||
|
total: number;
|
||||||
|
items: NotificationTemplate[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateListParams {
|
||||||
|
keyword?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
tag?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateFormData {
|
||||||
|
name: string;
|
||||||
|
titleTemplate: string;
|
||||||
|
contentTemplate: string;
|
||||||
|
tags: string[];
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplatePreviewResult {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
missingVariables: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
title_template: string;
|
||||||
|
content_template: string;
|
||||||
|
tags: string[];
|
||||||
|
enabled: boolean;
|
||||||
|
version: number;
|
||||||
|
created_by: string | null;
|
||||||
|
updated_by: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTemplate(s: ServerTemplate): NotificationTemplate {
|
||||||
|
return {
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
titleTemplate: s.title_template,
|
||||||
|
contentTemplate: s.content_template,
|
||||||
|
tags: s.tags ?? [],
|
||||||
|
enabled: s.enabled,
|
||||||
|
version: s.version,
|
||||||
|
createdBy: s.created_by,
|
||||||
|
updatedBy: s.updated_by,
|
||||||
|
createdAt: s.created_at,
|
||||||
|
updatedAt: s.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null as unknown);
|
||||||
|
if (data && typeof data === "object" && "detail" in data) {
|
||||||
|
throw { status: res.status, detail: (data as { detail: unknown }).detail };
|
||||||
|
}
|
||||||
|
throw { status: res.status, detail: `${ctx} failed (${res.status})` };
|
||||||
|
}
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTemplates(params: TemplateListParams = {}): Promise<TemplateListResult> {
|
||||||
|
const url = new URL(BASE());
|
||||||
|
if (params.keyword) url.searchParams.set("keyword", params.keyword);
|
||||||
|
if (params.enabled !== undefined) url.searchParams.set("enabled", String(params.enabled));
|
||||||
|
if (params.tag) url.searchParams.set("tag", params.tag);
|
||||||
|
if (params.page) url.searchParams.set("page", String(params.page));
|
||||||
|
if (params.pageSize) url.searchParams.set("page_size", String(params.pageSize));
|
||||||
|
|
||||||
|
const res = await apiFetch(url.toString(), { method: "GET" });
|
||||||
|
const data = await readJson<{
|
||||||
|
total: number;
|
||||||
|
items: ServerTemplate[];
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}>(res, "list templates");
|
||||||
|
return {
|
||||||
|
total: data.total,
|
||||||
|
items: (data.items ?? []).map(toTemplate),
|
||||||
|
page: data.page,
|
||||||
|
pageSize: data.page_size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTemplate(id: string): Promise<NotificationTemplate> {
|
||||||
|
const res = await apiFetch(`${BASE()}/${id}`, { method: "GET" });
|
||||||
|
return toTemplate(await readJson<ServerTemplate>(res, "get template"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTemplate(data: TemplateFormData): Promise<NotificationTemplate> {
|
||||||
|
const res = await apiFetch(BASE(), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: data.name,
|
||||||
|
title_template: data.titleTemplate,
|
||||||
|
content_template: data.contentTemplate,
|
||||||
|
tags: data.tags,
|
||||||
|
enabled: data.enabled,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const saved = toTemplate(await readJson<ServerTemplate>(res, "create template"));
|
||||||
|
writeYLog({
|
||||||
|
optType: OptType.create,
|
||||||
|
optDetail: "CM助手-通知模板-新建",
|
||||||
|
url: "/api/notification-templates",
|
||||||
|
reqParam: data,
|
||||||
|
returnParam: saved,
|
||||||
|
});
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTemplate(
|
||||||
|
id: string,
|
||||||
|
data: TemplateFormData & { expectedVersion: number },
|
||||||
|
): Promise<NotificationTemplate> {
|
||||||
|
const res = await apiFetch(`${BASE()}/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: data.name,
|
||||||
|
title_template: data.titleTemplate,
|
||||||
|
content_template: data.contentTemplate,
|
||||||
|
tags: data.tags,
|
||||||
|
enabled: data.enabled,
|
||||||
|
expected_version: data.expectedVersion,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const saved = toTemplate(await readJson<ServerTemplate>(res, "update template"));
|
||||||
|
writeYLog({
|
||||||
|
optType: OptType.edit,
|
||||||
|
optDetail: "CM助手-通知模板-编辑",
|
||||||
|
url: `/api/notification-templates/${id}`,
|
||||||
|
reqParam: data,
|
||||||
|
returnParam: saved,
|
||||||
|
});
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTemplate(id: string): Promise<void> {
|
||||||
|
const res = await apiFetch(`${BASE()}/${id}`, { method: "DELETE" });
|
||||||
|
if (!res.ok && res.status !== 204) {
|
||||||
|
const data = await res.json().catch(() => null as unknown);
|
||||||
|
throw { status: res.status, detail: data?.detail ?? "delete failed" };
|
||||||
|
}
|
||||||
|
writeYLog({
|
||||||
|
optType: OptType.delete,
|
||||||
|
optDetail: "CM助手-通知模板-删除",
|
||||||
|
url: `/api/notification-templates/${id}`,
|
||||||
|
reqParam: { id },
|
||||||
|
returnParam: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function duplicateTemplate(id: string): Promise<NotificationTemplate> {
|
||||||
|
const res = await apiFetch(`${BASE()}/${id}/duplicate`, { method: "POST" });
|
||||||
|
return toTemplate(await readJson<ServerTemplate>(res, "duplicate template"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewTemplate(
|
||||||
|
titleTemplate: string,
|
||||||
|
contentTemplate: string,
|
||||||
|
variables: Record<string, string>,
|
||||||
|
): Promise<TemplatePreviewResult> {
|
||||||
|
const res = await apiFetch(`${BASE()}/preview`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title_template: titleTemplate,
|
||||||
|
content_template: contentTemplate,
|
||||||
|
variables,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await readJson<{
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
missing_variables: string[];
|
||||||
|
}>(res, "preview template");
|
||||||
|
return {
|
||||||
|
title: data.title,
|
||||||
|
content: data.content,
|
||||||
|
missingVariables: data.missing_variables,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUERY_KEY = "notification-templates";
|
||||||
|
|
||||||
|
export function useNotificationTemplates(params: TemplateListParams = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEY, params],
|
||||||
|
queryFn: () => listTemplates(params),
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotificationTemplateActions() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const invalidate = () =>
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||||
|
|
||||||
|
return {
|
||||||
|
create: useMutation({ mutationFn: createTemplate, onSuccess: invalidate }),
|
||||||
|
update: useMutation({
|
||||||
|
mutationFn: ({ id, ...data }: TemplateFormData & { id: string; expectedVersion: number }) =>
|
||||||
|
updateTemplate(id, data),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
}),
|
||||||
|
remove: useMutation({ mutationFn: deleteTemplate, onSuccess: invalidate }),
|
||||||
|
duplicate: useMutation({ mutationFn: duplicateTemplate, onSuccess: invalidate }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -57,7 +57,8 @@ from app.gateway.routers import (
|
|||||||
menu_overrides,
|
menu_overrides,
|
||||||
models,
|
models,
|
||||||
multi_agent,
|
multi_agent,
|
||||||
notifications,
|
notification_templates,
|
||||||
|
notifications,
|
||||||
open_3qfx,
|
open_3qfx,
|
||||||
open_chat,
|
open_chat,
|
||||||
parallel_agent_tasks,
|
parallel_agent_tasks,
|
||||||
@@ -1609,6 +1610,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
|||||||
# Task-workspace configurable jump buttons (按钮管理) at /api/task-buttons
|
# Task-workspace configurable jump buttons (按钮管理) at /api/task-buttons
|
||||||
app.include_router(task_buttons.router)
|
app.include_router(task_buttons.router)
|
||||||
|
|
||||||
|
# Notification template management (通知模板管理) at /api/notification-templates
|
||||||
|
app.include_router(notification_templates.router)
|
||||||
|
|
||||||
# Microsoft Agent Framework 多智能体会商实验 (independent of the legacy
|
# Microsoft Agent Framework 多智能体会商实验 (independent of the legacy
|
||||||
# roundtable; feature-flag-gated, six isolated tables) at /api/maf-roundtable
|
# roundtable; feature-flag-gated, six isolated tables) at /api/maf-roundtable
|
||||||
app.include_router(maf_roundtable.router)
|
app.include_router(maf_roundtable.router)
|
||||||
|
|||||||
@@ -358,6 +358,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
app.state.task_button_store = make_task_button_store(sf)
|
app.state.task_button_store = make_task_button_store(sf)
|
||||||
|
|
||||||
|
from deerflow.persistence.notification_templates import make_notification_template_store
|
||||||
|
|
||||||
|
app.state.notification_template_store = make_notification_template_store(sf)
|
||||||
|
|
||||||
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
|
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
|
||||||
|
|
||||||
app.state.taskcop_task_store = make_taskcop_task_store(sf)
|
app.state.taskcop_task_store = make_taskcop_task_store(sf)
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
"""Notification template management API (通知模板管理).
|
||||||
|
|
||||||
|
Admin-only CRUD for message notification templates with variable validation,
|
||||||
|
optimistic locking, and live preview.
|
||||||
|
|
||||||
|
Routes (prefix ``/api/notification-templates``):
|
||||||
|
GET "" list templates (admin, paginated)
|
||||||
|
GET "/{id}" get single template (admin)
|
||||||
|
POST "" create template (admin)
|
||||||
|
PUT "/{id}" update template (admin, optimistic lock)
|
||||||
|
DELETE "/{id}" delete template (admin)
|
||||||
|
POST "/{id}/duplicate" duplicate template (admin)
|
||||||
|
POST "/preview" preview template with variables (admin)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.gateway.deps import get_current_user, get_optional_user_from_request
|
||||||
|
from deerflow.persistence.notification_templates import NotificationTemplateStore
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/notification-templates", tags=["notification-templates"])
|
||||||
|
|
||||||
|
ALLOWED_VARIABLES = {"userName", "taskName", "taskId", "dueDate"}
|
||||||
|
|
||||||
|
_VAR_PATTERN = re.compile(r"\{\{([^}]*)\}\}")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_template_variables(text: str, field_name: str) -> None:
|
||||||
|
for m in _VAR_PATTERN.finditer(text):
|
||||||
|
inner = m.group(1)
|
||||||
|
if not inner or inner != inner.strip():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"{field_name}: 变量格式错误 '{m.group(0)}',变量名不能为空或包含空格",
|
||||||
|
)
|
||||||
|
var_name = inner.strip()
|
||||||
|
if var_name not in ALLOWED_VARIABLES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"{field_name}: 不支持的变量 '{{{{{var_name}}}}}',"
|
||||||
|
f"仅支持: {', '.join(sorted(ALLOWED_VARIABLES))}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_tags(tags: list[str]) -> list[str]:
|
||||||
|
cleaned: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for t in tags:
|
||||||
|
t = t.strip()
|
||||||
|
if not t:
|
||||||
|
continue
|
||||||
|
if len(t) > 20:
|
||||||
|
raise HTTPException(status_code=422, detail=f"标签长度超过 20: '{t}'")
|
||||||
|
if t not in seen:
|
||||||
|
seen.add(t)
|
||||||
|
cleaned.append(t)
|
||||||
|
if len(cleaned) > 5:
|
||||||
|
raise HTTPException(status_code=422, detail="标签数量不能超过 5 个")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
# ── schemas ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateCreateRequest(BaseModel):
|
||||||
|
name: str = Field(..., min_length=2, max_length=50)
|
||||||
|
title_template: str = Field(..., min_length=1, max_length=120)
|
||||||
|
content_template: str = Field(..., min_length=1, max_length=5000)
|
||||||
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateUpdateRequest(BaseModel):
|
||||||
|
name: str = Field(..., min_length=2, max_length=50)
|
||||||
|
title_template: str = Field(..., min_length=1, max_length=120)
|
||||||
|
content_template: str = Field(..., min_length=1, max_length=5000)
|
||||||
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
enabled: bool = True
|
||||||
|
expected_version: int = Field(..., ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
title_template: str
|
||||||
|
content_template: str
|
||||||
|
tags: list[str]
|
||||||
|
enabled: bool
|
||||||
|
version: int
|
||||||
|
created_by: str | None = None
|
||||||
|
updated_by: str | None = None
|
||||||
|
created_at: datetime | str | None = None
|
||||||
|
updated_at: datetime | str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateListResponse(BaseModel):
|
||||||
|
total: int
|
||||||
|
items: list[TemplateResponse]
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewRequest(BaseModel):
|
||||||
|
title_template: str = Field(default="", max_length=120)
|
||||||
|
content_template: str = Field(default="", max_length=5000)
|
||||||
|
variables: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewResponse(BaseModel):
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
missing_variables: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _get_store(request: Request) -> NotificationTemplateStore:
|
||||||
|
store = getattr(request.app.state, "notification_template_store", None)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Notification template store not available")
|
||||||
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_admin(request: Request) -> str | None:
|
||||||
|
user = await get_optional_user_from_request(request)
|
||||||
|
if user is not None and getattr(user, "system_role", None) != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="通知模板管理仅限管理员")
|
||||||
|
return await get_current_user(request)
|
||||||
|
|
||||||
|
|
||||||
|
# ── routes ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=TemplateListResponse)
|
||||||
|
async def list_templates(
|
||||||
|
request: Request,
|
||||||
|
keyword: str | None = Query(default=None),
|
||||||
|
enabled: bool | None = Query(default=None),
|
||||||
|
tag: str | None = Query(default=None),
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=20, ge=1, le=100),
|
||||||
|
) -> TemplateListResponse:
|
||||||
|
await _require_admin(request)
|
||||||
|
store = _get_store(request)
|
||||||
|
result = await store.list_templates(
|
||||||
|
keyword=keyword, enabled=enabled, tag=tag, page=page, page_size=page_size
|
||||||
|
)
|
||||||
|
return TemplateListResponse(
|
||||||
|
total=result["total"],
|
||||||
|
items=[TemplateResponse(**r) for r in result["items"]],
|
||||||
|
page=result["page"],
|
||||||
|
page_size=result["page_size"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{template_id}", response_model=TemplateResponse)
|
||||||
|
async def get_template(request: Request, template_id: str) -> TemplateResponse:
|
||||||
|
await _require_admin(request)
|
||||||
|
store = _get_store(request)
|
||||||
|
row = await store.get_template(template_id)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="模板不存在")
|
||||||
|
return TemplateResponse(**row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=TemplateResponse, status_code=201)
|
||||||
|
async def create_template(request: Request, body: TemplateCreateRequest) -> TemplateResponse:
|
||||||
|
user_id = await _require_admin(request)
|
||||||
|
store = _get_store(request)
|
||||||
|
|
||||||
|
name = body.name.strip()
|
||||||
|
if len(name) < 2:
|
||||||
|
raise HTTPException(status_code=422, detail="name 去除首尾空格后长度不得少于 2")
|
||||||
|
|
||||||
|
if await store.name_exists(name):
|
||||||
|
raise HTTPException(status_code=409, detail=f"模板名称已存在: {name}")
|
||||||
|
|
||||||
|
_validate_template_variables(body.title_template, "title_template")
|
||||||
|
_validate_template_variables(body.content_template, "content_template")
|
||||||
|
tags = _validate_tags(body.tags)
|
||||||
|
|
||||||
|
row = await store.create_template(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"title_template": body.title_template,
|
||||||
|
"content_template": body.content_template,
|
||||||
|
"tags": tags,
|
||||||
|
"enabled": body.enabled,
|
||||||
|
},
|
||||||
|
created_by=user_id,
|
||||||
|
)
|
||||||
|
return TemplateResponse(**row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{template_id}", response_model=TemplateResponse)
|
||||||
|
async def update_template(request: Request, template_id: str, body: TemplateUpdateRequest) -> TemplateResponse:
|
||||||
|
user_id = await _require_admin(request)
|
||||||
|
store = _get_store(request)
|
||||||
|
|
||||||
|
name = body.name.strip()
|
||||||
|
if len(name) < 2:
|
||||||
|
raise HTTPException(status_code=422, detail="name 去除首尾空格后长度不得少于 2")
|
||||||
|
|
||||||
|
if await store.name_exists(name, exclude_id=template_id):
|
||||||
|
raise HTTPException(status_code=409, detail=f"模板名称已存在: {name}")
|
||||||
|
|
||||||
|
_validate_template_variables(body.title_template, "title_template")
|
||||||
|
_validate_template_variables(body.content_template, "content_template")
|
||||||
|
tags = _validate_tags(body.tags)
|
||||||
|
|
||||||
|
try:
|
||||||
|
row = await store.update_template(
|
||||||
|
template_id,
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"title_template": body.title_template,
|
||||||
|
"content_template": body.content_template,
|
||||||
|
"tags": tags,
|
||||||
|
"enabled": body.enabled,
|
||||||
|
},
|
||||||
|
expected_version=body.expected_version,
|
||||||
|
updated_by=user_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
msg = str(exc)
|
||||||
|
if msg == "NOT_FOUND":
|
||||||
|
raise HTTPException(status_code=404, detail="模板不存在")
|
||||||
|
if msg == "VERSION_CONFLICT":
|
||||||
|
latest = await store.get_template(template_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={
|
||||||
|
"message": "版本冲突,服务器版本已更新",
|
||||||
|
"server": TemplateResponse(**latest).model_dump() if latest else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
return TemplateResponse(**row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{template_id}", status_code=204)
|
||||||
|
async def delete_template(request: Request, template_id: str) -> None:
|
||||||
|
await _require_admin(request)
|
||||||
|
store = _get_store(request)
|
||||||
|
deleted = await store.delete_template(template_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="模板不存在")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{template_id}/duplicate", response_model=TemplateResponse, status_code=201)
|
||||||
|
async def duplicate_template(request: Request, template_id: str) -> TemplateResponse:
|
||||||
|
user_id = await _require_admin(request)
|
||||||
|
store = _get_store(request)
|
||||||
|
try:
|
||||||
|
row = await store.duplicate_template(template_id, created_by=user_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
if str(exc) == "NOT_FOUND":
|
||||||
|
raise HTTPException(status_code=404, detail="模板不存在")
|
||||||
|
raise
|
||||||
|
return TemplateResponse(**row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/preview", response_model=PreviewResponse)
|
||||||
|
async def preview_template(request: Request, body: PreviewRequest) -> PreviewResponse:
|
||||||
|
await _require_admin(request)
|
||||||
|
|
||||||
|
_validate_template_variables(body.title_template, "title_template")
|
||||||
|
_validate_template_variables(body.content_template, "content_template")
|
||||||
|
|
||||||
|
all_vars = set()
|
||||||
|
for text in (body.title_template, body.content_template):
|
||||||
|
for m in _VAR_PATTERN.finditer(text):
|
||||||
|
all_vars.add(m.group(1).strip())
|
||||||
|
|
||||||
|
missing = sorted(all_vars - set(body.variables.keys()))
|
||||||
|
|
||||||
|
def _replace(text: str) -> str:
|
||||||
|
def _sub(m: re.Match) -> str:
|
||||||
|
var_name = m.group(1).strip()
|
||||||
|
return body.variables.get(var_name, m.group(0))
|
||||||
|
return _VAR_PATTERN.sub(_sub, text)
|
||||||
|
|
||||||
|
return PreviewResponse(
|
||||||
|
title=_replace(body.title_template),
|
||||||
|
content=_replace(body.content_template),
|
||||||
|
missing_variables=missing,
|
||||||
|
)
|
||||||
@@ -430,6 +430,7 @@ class MafConversationService:
|
|||||||
_ALLOWED_TYPES = frozenset({
|
_ALLOWED_TYPES = frozenset({
|
||||||
"agent.message.start", "agent.message.delta", "agent.message.complete",
|
"agent.message.start", "agent.message.delta", "agent.message.complete",
|
||||||
"seat.delivery.submitted", "manager.dispatch", "manager.review.decision",
|
"seat.delivery.submitted", "manager.dispatch", "manager.review.decision",
|
||||||
|
"seat.rework.assigned", "seat.reassigned", "stage.quality_gate",
|
||||||
"tool.requested", "tool.started", "tool.progress", "tool.completed", "tool.failed",
|
"tool.requested", "tool.started", "tool.progress", "tool.completed", "tool.failed",
|
||||||
"skill.selected", "skill.started", "skill.completed", "skill.failed",
|
"skill.selected", "skill.started", "skill.completed", "skill.failed",
|
||||||
"artifact.created", "approval.required", "approval.decided", "clarification.required",
|
"artifact.created", "approval.required", "approval.decided", "clarification.required",
|
||||||
@@ -437,6 +438,11 @@ class MafConversationService:
|
|||||||
"run.cancelled", "run.completed",
|
"run.cancelled", "run.completed",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Execution stages whose ``agent.message.completed`` is a genuine seat
|
||||||
|
# delivery. Manager / dispatch / review / cross-review / finalizer /
|
||||||
|
# format-repair messages must NEVER be projected as seat.delivery.submitted.
|
||||||
|
_SEAT_DELIVERY_STAGES = frozenset({"expert", "rework", "legacy", "group_chat"})
|
||||||
|
|
||||||
_SAFE_KEYS: dict[str, dict[str, bool]] = {
|
_SAFE_KEYS: dict[str, dict[str, bool]] = {
|
||||||
"agent.message.start": {"thread_id": False},
|
"agent.message.start": {"thread_id": False},
|
||||||
"agent.message.delta": {"text": True},
|
"agent.message.delta": {"text": True},
|
||||||
@@ -463,10 +469,27 @@ class MafConversationService:
|
|||||||
"reason": True, "source": True, "clarification_prompt": True,
|
"reason": True, "source": True, "clarification_prompt": True,
|
||||||
},
|
},
|
||||||
"clarification.required": {"prompt": True, "reason": True},
|
"clarification.required": {"prompt": True, "reason": True},
|
||||||
"manager.review.decision": {"decision": True, "reason": True, "quality": True},
|
"manager.review.decision": {
|
||||||
|
"decision": True, "reason": True, "quality": True,
|
||||||
|
"task_key": True, "attempt": True, "source": True,
|
||||||
|
},
|
||||||
"fact.confirmed": {"fact_id": True, "summary": True},
|
"fact.confirmed": {"fact_id": True, "summary": True},
|
||||||
"review.completed": {"summary": True, "decision": True},
|
"review.completed": {"summary": True, "decision": True},
|
||||||
"seat.delivery.submitted": {"content": True, "schema_status": True},
|
"seat.delivery.submitted": {
|
||||||
|
"content": True, "schema_status": True,
|
||||||
|
"task_key": True, "attempt": True,
|
||||||
|
"deterministic_passed": True, "failure_codes": True,
|
||||||
|
},
|
||||||
|
"seat.rework.assigned": {
|
||||||
|
"task_key": True, "rework_count": True, "revision_requirements": True,
|
||||||
|
},
|
||||||
|
"seat.reassigned": {
|
||||||
|
"task_key": True, "from_agent_id": True, "reassign_count": True,
|
||||||
|
},
|
||||||
|
"stage.quality_gate": {
|
||||||
|
"decision": True, "total": True, "accepted": True,
|
||||||
|
"controlled_gap": True, "blocked_required": True,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
def _build_projection_items(
|
def _build_projection_items(
|
||||||
@@ -501,19 +524,26 @@ class MafConversationService:
|
|||||||
"source_event_id": source_evt_id,
|
"source_event_id": source_evt_id,
|
||||||
}]
|
}]
|
||||||
|
|
||||||
|
# Only a genuine seat delivery turn may be projected as
|
||||||
|
# seat.delivery.submitted. Manager / dispatch / manager_review /
|
||||||
|
# cross-review / finalizer / format-repair messages are ordinary
|
||||||
|
# agent messages and must never be mislabelled as seat deliveries
|
||||||
|
# (Stage-4 runs emit their own explicit seat.delivery.submitted event).
|
||||||
if source_type == "agent.message.completed":
|
if source_type == "agent.message.completed":
|
||||||
delivery_data: dict[str, Any] = {
|
stage = str(payload.get("execution_stage") or "")
|
||||||
key: payload[key]
|
if stage in self._SEAT_DELIVERY_STAGES:
|
||||||
for key in ("content", "schema_status")
|
delivery_data: dict[str, Any] = {
|
||||||
if key in payload
|
key: payload[key]
|
||||||
}
|
for key in ("content", "schema_status")
|
||||||
if event.get("agent_id"):
|
if key in payload
|
||||||
delivery_data["agent_id"] = event["agent_id"]
|
}
|
||||||
items.append({
|
if event.get("agent_id"):
|
||||||
"event_type": "seat.delivery.submitted",
|
delivery_data["agent_id"] = event["agent_id"]
|
||||||
"event_data": delivery_data,
|
items.append({
|
||||||
"source_event_id": f"{source_evt_id}:delivery" if source_evt_id else None,
|
"event_type": "seat.delivery.submitted",
|
||||||
})
|
"event_data": delivery_data,
|
||||||
|
"source_event_id": f"{source_evt_id}:delivery" if source_evt_id else None,
|
||||||
|
})
|
||||||
|
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|||||||
@@ -275,6 +275,7 @@ class DeerFlowAgentAdapter:
|
|||||||
content=response.content,
|
content=response.content,
|
||||||
usage=response.usage,
|
usage=response.usage,
|
||||||
metadata=response.metadata,
|
metadata=response.metadata,
|
||||||
|
execution_stage=execution_stage,
|
||||||
))
|
))
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|||||||
@@ -75,12 +75,17 @@ def make_turn_completed(
|
|||||||
content: str,
|
content: str,
|
||||||
usage: dict[str, Any] | None = None,
|
usage: dict[str, Any] | None = None,
|
||||||
metadata: dict[str, Any] | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
|
execution_stage: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
payload: dict[str, Any] = {"content": content}
|
payload: dict[str, Any] = {"content": content}
|
||||||
if usage:
|
if usage:
|
||||||
payload["usage"] = usage
|
payload["usage"] = usage
|
||||||
if metadata:
|
if metadata:
|
||||||
payload["metadata"] = metadata
|
payload["metadata"] = metadata
|
||||||
|
# The conversation projection must be able to tell a real seat delivery
|
||||||
|
# apart from manager / review / finalizer messages — carry the stage.
|
||||||
|
if execution_stage:
|
||||||
|
payload["execution_stage"] = execution_stage
|
||||||
return _make("agent.message.completed", agent_id, turn_index, payload)
|
return _make("agent.message.completed", agent_id, turn_index, payload)
|
||||||
|
|
||||||
|
|
||||||
@@ -154,6 +159,99 @@ def make_workflow_checkpointed(agent_id: str | None, turn_index: int, *, checkpo
|
|||||||
return _make("workflow.checkpointed", agent_id or "", turn_index, {"checkpoint_id": checkpoint_id, "reason": reason})
|
return _make("workflow.checkpointed", agent_id or "", turn_index, {"checkpoint_id": checkpoint_id, "reason": reason})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Stage 4 (R3-Q) quality gate events ──────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def make_seat_delivery_submitted(
|
||||||
|
agent_id: str,
|
||||||
|
turn_index: int,
|
||||||
|
*,
|
||||||
|
task_key: str,
|
||||||
|
attempt: int,
|
||||||
|
deterministic_passed: bool,
|
||||||
|
failure_codes: list[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""A seat task submitted its delivery and passed/failed deterministic checks."""
|
||||||
|
return _make("seat.delivery.submitted", agent_id, turn_index, {
|
||||||
|
"task_key": task_key,
|
||||||
|
"attempt": attempt,
|
||||||
|
"deterministic_passed": deterministic_passed,
|
||||||
|
"failure_codes": list(failure_codes or []),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def make_manager_review_decision(
|
||||||
|
agent_id: str,
|
||||||
|
turn_index: int,
|
||||||
|
*,
|
||||||
|
task_key: str,
|
||||||
|
decision: str,
|
||||||
|
reason: str,
|
||||||
|
source: str,
|
||||||
|
attempt: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Server-finalized manager review decision for one seat task."""
|
||||||
|
return _make("manager.review.decision", agent_id, turn_index, {
|
||||||
|
"task_key": task_key,
|
||||||
|
"decision": decision,
|
||||||
|
"reason": _truncate(reason, 500),
|
||||||
|
"source": source,
|
||||||
|
"attempt": attempt,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def make_seat_rework_assigned(
|
||||||
|
agent_id: str,
|
||||||
|
turn_index: int,
|
||||||
|
*,
|
||||||
|
task_key: str,
|
||||||
|
rework_count: int,
|
||||||
|
requirements: list[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""A seat task is sent back for rework with explicit revision requirements."""
|
||||||
|
return _make("seat.rework.assigned", agent_id, turn_index, {
|
||||||
|
"task_key": task_key,
|
||||||
|
"rework_count": rework_count,
|
||||||
|
"revision_requirements": [str(r) for r in (requirements or [])][:16],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def make_seat_reassigned(
|
||||||
|
agent_id: str,
|
||||||
|
turn_index: int,
|
||||||
|
*,
|
||||||
|
task_key: str,
|
||||||
|
from_agent_id: str,
|
||||||
|
reassign_count: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""A seat task is reassigned to a backup seat (agent_id = new owner)."""
|
||||||
|
return _make("seat.reassigned", agent_id, turn_index, {
|
||||||
|
"task_key": task_key,
|
||||||
|
"from_agent_id": from_agent_id,
|
||||||
|
"reassign_count": reassign_count,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def make_stage_quality_gate(
|
||||||
|
agent_id: str | None,
|
||||||
|
turn_index: int,
|
||||||
|
*,
|
||||||
|
decision: str,
|
||||||
|
total: int,
|
||||||
|
accepted: int,
|
||||||
|
controlled_gap: int,
|
||||||
|
blocked_required: list[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Stage quality gate outcome: passed / blocked / passed_with_gaps."""
|
||||||
|
return _make("stage.quality_gate", agent_id or "", turn_index, {
|
||||||
|
"decision": decision,
|
||||||
|
"total": total,
|
||||||
|
"accepted": accepted,
|
||||||
|
"controlled_gap": controlled_gap,
|
||||||
|
"blocked_required": list(blocked_required or []),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
# ── internal helpers ──────────────────────────────────────────────────────────
|
# ── internal helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,568 @@
|
|||||||
|
"""Stage 4 (R3-Q): seat task contracts, deterministic quality checks, manager
|
||||||
|
review schema, and server-owned decision finalization.
|
||||||
|
|
||||||
|
Pure policy module: no I/O and no MAF SDK imports, so the whole acceptance
|
||||||
|
policy is unit-testable with a weak/fake model before any workflow runs.
|
||||||
|
|
||||||
|
The manager model only ever *proposes* review decisions. This module is the
|
||||||
|
final validator and never allows:
|
||||||
|
|
||||||
|
- accepting a delivery that failed the deterministic quality checks (the
|
||||||
|
manager's self-assessment is NOT acceptance evidence);
|
||||||
|
- reworking a seat beyond its frozen ``max_rework_attempts``;
|
||||||
|
- more reassignments than the server-configured cap;
|
||||||
|
- an exhausted task being disguised as accepted — it becomes a
|
||||||
|
``controlled_gap`` carrying its risk, or an explicit ``ask_user`` pause.
|
||||||
|
|
||||||
|
Task status state machine (persisted per seat task)::
|
||||||
|
|
||||||
|
assigned → delivering → submitted → manager_review →
|
||||||
|
accepted
|
||||||
|
| rework (→ delivering, with explicit revision requirements)
|
||||||
|
| reassigned (→ delivering, backup seat takes over)
|
||||||
|
| exhausted (rework/reassign caps reached)
|
||||||
|
→ controlled_gap (risk recorded, carried into the final report)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
# ── task status state machine ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
TASK_ASSIGNED = "assigned"
|
||||||
|
TASK_DELIVERING = "delivering"
|
||||||
|
TASK_SUBMITTED = "submitted"
|
||||||
|
TASK_MANAGER_REVIEW = "manager_review"
|
||||||
|
TASK_ACCEPTED = "accepted"
|
||||||
|
TASK_REWORK = "rework"
|
||||||
|
TASK_REASSIGNED = "reassigned"
|
||||||
|
TASK_EXHAUSTED = "exhausted"
|
||||||
|
TASK_CONTROLLED_GAP = "controlled_gap"
|
||||||
|
|
||||||
|
TASK_TERMINAL_STATUSES = frozenset({TASK_ACCEPTED, TASK_CONTROLLED_GAP})
|
||||||
|
TASK_ALL_STATUSES = frozenset({
|
||||||
|
TASK_ASSIGNED,
|
||||||
|
TASK_DELIVERING,
|
||||||
|
TASK_SUBMITTED,
|
||||||
|
TASK_MANAGER_REVIEW,
|
||||||
|
TASK_ACCEPTED,
|
||||||
|
TASK_REWORK,
|
||||||
|
TASK_REASSIGNED,
|
||||||
|
TASK_EXHAUSTED,
|
||||||
|
TASK_CONTROLLED_GAP,
|
||||||
|
})
|
||||||
|
|
||||||
|
MafReviewDecision = Literal[
|
||||||
|
"accept", "return_for_rework", "reassign", "ask_user", "controlled_gap"
|
||||||
|
]
|
||||||
|
MafFallbackPolicy = Literal["reassign_then_gap", "controlled_gap", "ask_user"]
|
||||||
|
|
||||||
|
# Final (server-owned) outcomes of the decision finalizer.
|
||||||
|
OUTCOME_ACCEPTED = "accepted"
|
||||||
|
OUTCOME_REWORK = "rework"
|
||||||
|
OUTCOME_REASSIGNED = "reassigned"
|
||||||
|
OUTCOME_CONTROLLED_GAP = "controlled_gap"
|
||||||
|
OUTCOME_ASK_USER = "ask_user"
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidTaskContractError(ValueError):
|
||||||
|
"""Raised when a task contract cannot be frozen from a dispatch plan."""
|
||||||
|
|
||||||
|
|
||||||
|
# ── task contract ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class MafTaskContract(BaseModel):
|
||||||
|
"""Frozen acceptance contract for one seat task.
|
||||||
|
|
||||||
|
Created server-side from the validated dispatch assignment before the seat
|
||||||
|
starts working. The manager model can never widen these terms afterwards.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
objective: str = Field(min_length=1, max_length=2000)
|
||||||
|
expected_deliverable: str = Field(min_length=1, max_length=2000)
|
||||||
|
acceptance_criteria: list[str] = Field(default_factory=list, max_length=32)
|
||||||
|
required: bool = True
|
||||||
|
max_rework_attempts: int = Field(default=2, ge=0, le=8)
|
||||||
|
fallback_policy: MafFallbackPolicy = "reassign_then_gap"
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_EXPECTED_DELIVERABLE = (
|
||||||
|
"结构化 JSON 交付:结论(summary)、依据/证据(findings/evidence_refs)、"
|
||||||
|
"未解决项(open_questions)、自检(self_check)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_task_contract(
|
||||||
|
*,
|
||||||
|
task_goal: str,
|
||||||
|
delivery_requirements: list[str] | None = None,
|
||||||
|
required: bool = True,
|
||||||
|
max_rework_attempts: int = 2,
|
||||||
|
fallback_policy: MafFallbackPolicy = "reassign_then_gap",
|
||||||
|
) -> MafTaskContract:
|
||||||
|
"""Freeze a task contract from one validated dispatch assignment."""
|
||||||
|
objective = str(task_goal or "").strip()
|
||||||
|
if not objective:
|
||||||
|
raise InvalidTaskContractError("task contract objective must be non-empty")
|
||||||
|
criteria = [
|
||||||
|
str(item).strip()
|
||||||
|
for item in (delivery_requirements or [])
|
||||||
|
if str(item or "").strip()
|
||||||
|
]
|
||||||
|
return MafTaskContract(
|
||||||
|
objective=objective,
|
||||||
|
expected_deliverable=DEFAULT_EXPECTED_DELIVERABLE,
|
||||||
|
acceptance_criteria=criteria,
|
||||||
|
required=required,
|
||||||
|
max_rework_attempts=max_rework_attempts,
|
||||||
|
fallback_policy=fallback_policy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── seat delivery ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class MafSeatDelivery(BaseModel):
|
||||||
|
"""Structured delivery extracted from one seat turn output."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
conclusion: str = ""
|
||||||
|
evidence: list[str] = Field(default_factory=list)
|
||||||
|
# P1: only ``evidence_refs`` can satisfy the evidence requirement. Seat
|
||||||
|
# ``findings`` are candidate claims — they are kept in ``evidence`` for
|
||||||
|
# review display but never count as verified evidence.
|
||||||
|
evidence_refs: list[str] = Field(default_factory=list)
|
||||||
|
unresolved_items: list[str] = Field(default_factory=list)
|
||||||
|
self_check: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _flat_text(item: Any, *keys: str) -> str:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
for key in keys:
|
||||||
|
value = item.get(key)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
return ""
|
||||||
|
return str(item or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def delivery_from_seat_output(
|
||||||
|
*, structured_output: dict[str, Any] | None, raw_content: str
|
||||||
|
) -> MafSeatDelivery:
|
||||||
|
"""Map a validated SeatTurnOutput dict onto the delivery contract.
|
||||||
|
|
||||||
|
``structured_output`` is ``None`` when the seat output failed the schema —
|
||||||
|
the resulting delivery is (almost) empty so the deterministic checks flag
|
||||||
|
it instead of trusting the raw text.
|
||||||
|
"""
|
||||||
|
out = structured_output if isinstance(structured_output, dict) else {}
|
||||||
|
conclusion = str(out.get("summary") or "").strip()
|
||||||
|
|
||||||
|
evidence: list[str] = []
|
||||||
|
for item in out.get("findings") or []:
|
||||||
|
text = _flat_text(item, "text")
|
||||||
|
if text:
|
||||||
|
evidence.append(text)
|
||||||
|
evidence_refs: list[str] = []
|
||||||
|
for ref in out.get("evidence_refs") or []:
|
||||||
|
text = _flat_text(ref, "ref", "text")
|
||||||
|
if text:
|
||||||
|
evidence_refs.append(text)
|
||||||
|
evidence.append(text)
|
||||||
|
|
||||||
|
unresolved = [
|
||||||
|
str(q).strip() for q in (out.get("open_questions") or []) if str(q or "").strip()
|
||||||
|
]
|
||||||
|
self_check = str(out.get("self_check") or "").strip()
|
||||||
|
return MafSeatDelivery(
|
||||||
|
conclusion=conclusion,
|
||||||
|
evidence=evidence,
|
||||||
|
evidence_refs=evidence_refs,
|
||||||
|
unresolved_items=unresolved,
|
||||||
|
self_check=self_check,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── deterministic quality checks ────────────────────────────────────────────
|
||||||
|
|
||||||
|
FAIL_EMPTY_CONTENT = "empty_content"
|
||||||
|
FAIL_SCHEMA_INVALID = "schema_invalid"
|
||||||
|
FAIL_MISSING_CONCLUSION = "missing_conclusion"
|
||||||
|
FAIL_BELOW_MIN_LENGTH = "below_min_length"
|
||||||
|
FAIL_MISSING_EVIDENCE = "missing_evidence"
|
||||||
|
FAIL_DUPLICATE_CONTENT = "duplicate_content"
|
||||||
|
FAIL_UNVERIFIED_EVIDENCE_REF = "unverified_evidence_ref"
|
||||||
|
|
||||||
|
_REPAIR_HINTS: dict[str, str] = {
|
||||||
|
FAIL_EMPTY_CONTENT: "交付内容为空,请给出完整的分析与结论",
|
||||||
|
FAIL_SCHEMA_INVALID: "输出不是合法的结构化 JSON,请按 SeatTurnOutput 字段重新输出",
|
||||||
|
FAIL_MISSING_CONCLUSION: "缺少明确结论(summary),请补充",
|
||||||
|
FAIL_BELOW_MIN_LENGTH: "结论过短,请展开具体分析、依据与建议",
|
||||||
|
FAIL_MISSING_EVIDENCE: (
|
||||||
|
"缺少可信证据(evidence_refs)。findings 只是候选主张,不能充当证据;"
|
||||||
|
"请引用事实账本/成功工具结果中真实存在的证据引用"
|
||||||
|
),
|
||||||
|
FAIL_DUPLICATE_CONTENT: "内容与上一次交付重复,请针对退回意见提供新的分析",
|
||||||
|
FAIL_UNVERIFIED_EVIDENCE_REF: (
|
||||||
|
"引用了事实账本中不存在的证据(自造引用不可接受);"
|
||||||
|
"请只引用已记录的可信证据或成功工具结果"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def conclusion_digest(conclusion: str) -> str:
|
||||||
|
"""SHA-256 digest of the normalized conclusion.
|
||||||
|
|
||||||
|
Duplicate detection persists these digests (never the conclusion bodies)
|
||||||
|
in the task history, so the check survives restarts without storing the
|
||||||
|
duplicated text again.
|
||||||
|
"""
|
||||||
|
normalized = " ".join(str(conclusion or "").split())
|
||||||
|
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def check_delivery_deterministic(
|
||||||
|
*,
|
||||||
|
delivery: MafSeatDelivery,
|
||||||
|
raw_content: str,
|
||||||
|
schema_status: str | None,
|
||||||
|
min_length: int = 40,
|
||||||
|
min_evidence: int = 1,
|
||||||
|
prior_conclusion_digests: set[str] | list[str] | None = None,
|
||||||
|
verified_evidence_refs: set[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Deterministic acceptance gate run BEFORE the manager review.
|
||||||
|
|
||||||
|
A delivery that fails any check can never be accepted, regardless of what
|
||||||
|
the manager model claims. Returns ``{"passed": bool, "failure_codes": [...]}``.
|
||||||
|
|
||||||
|
Evidence rules (P1):
|
||||||
|
- seat ``findings`` are candidate claims only — they can NEVER satisfy the
|
||||||
|
evidence requirement; only ``evidence_refs`` count;
|
||||||
|
- when ``verified_evidence_refs`` is supplied (the trusted refs the fact
|
||||||
|
ledger actually backs: recorded evidence + successful tool results),
|
||||||
|
every cited ref must resolve — an invented ref fails with
|
||||||
|
``unverified_evidence_ref``, and only verified refs count toward
|
||||||
|
``min_evidence``;
|
||||||
|
- duplicate detection compares SHA-256 digests of normalized conclusions,
|
||||||
|
so it survives restarts via the persisted task history.
|
||||||
|
"""
|
||||||
|
codes: list[str] = []
|
||||||
|
text = str(raw_content or "").strip()
|
||||||
|
if not text:
|
||||||
|
codes.append(FAIL_EMPTY_CONTENT)
|
||||||
|
if schema_status == "schema_invalid":
|
||||||
|
codes.append(FAIL_SCHEMA_INVALID)
|
||||||
|
if not delivery.conclusion:
|
||||||
|
codes.append(FAIL_MISSING_CONCLUSION)
|
||||||
|
elif len(delivery.conclusion) < max(0, int(min_length)):
|
||||||
|
codes.append(FAIL_BELOW_MIN_LENGTH)
|
||||||
|
|
||||||
|
cited_refs = [str(r) for r in (delivery.evidence_refs or []) if str(r or "").strip()]
|
||||||
|
if verified_evidence_refs is not None:
|
||||||
|
trusted = {str(r) for r in verified_evidence_refs}
|
||||||
|
verified = [r for r in cited_refs if r in trusted]
|
||||||
|
if any(r not in trusted for r in cited_refs):
|
||||||
|
codes.append(FAIL_UNVERIFIED_EVIDENCE_REF)
|
||||||
|
else:
|
||||||
|
verified = cited_refs
|
||||||
|
if len(verified) < max(0, int(min_evidence)):
|
||||||
|
codes.append(FAIL_MISSING_EVIDENCE)
|
||||||
|
|
||||||
|
if (
|
||||||
|
delivery.conclusion
|
||||||
|
and prior_conclusion_digests
|
||||||
|
and conclusion_digest(delivery.conclusion) in set(prior_conclusion_digests)
|
||||||
|
):
|
||||||
|
codes.append(FAIL_DUPLICATE_CONTENT)
|
||||||
|
return {"passed": not codes, "failure_codes": codes}
|
||||||
|
|
||||||
|
|
||||||
|
def repair_requirements_for_codes(failure_codes: list[str]) -> list[str]:
|
||||||
|
"""Deterministic, targeted revision requirements for failed checks."""
|
||||||
|
hints = [_REPAIR_HINTS[code] for code in failure_codes if code in _REPAIR_HINTS]
|
||||||
|
return hints or ["请按照任务合同补充缺失内容后重新交付"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── manager review schema ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class MafManagerReviewItem(BaseModel):
|
||||||
|
"""One per-task review decision proposed by the manager model."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
agent_id: str = Field(min_length=1, max_length=64)
|
||||||
|
decision: MafReviewDecision
|
||||||
|
reason: str = Field(min_length=1, max_length=1000)
|
||||||
|
revision_requirements: list[str] = Field(default_factory=list, max_length=16)
|
||||||
|
|
||||||
|
|
||||||
|
class MafManagerReviewOutput(BaseModel):
|
||||||
|
"""Strict model-facing manager review schema (one entry per reviewed seat)."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
decisions: list[MafManagerReviewItem] = Field(default_factory=list, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
|
_JSON_FENCE_RE = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json_object(text: str) -> str | None:
|
||||||
|
raw = (text or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
m = _JSON_FENCE_RE.search(raw)
|
||||||
|
if m:
|
||||||
|
raw = m.group(1).strip()
|
||||||
|
if raw.startswith("{") and raw.endswith("}"):
|
||||||
|
return raw
|
||||||
|
start = raw.find("{")
|
||||||
|
end = raw.rfind("}")
|
||||||
|
if start >= 0 and end > start:
|
||||||
|
return raw[start : end + 1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_manager_review(text: str) -> tuple[MafManagerReviewOutput | None, list[str]]:
|
||||||
|
"""Parse the manager's review JSON. Returns ``(model, readable_errors)``."""
|
||||||
|
blob = _extract_json_object(text)
|
||||||
|
if blob is None:
|
||||||
|
return None, ["review output is not a JSON object; emit MafManagerReviewOutput JSON"]
|
||||||
|
try:
|
||||||
|
data = json.loads(blob)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
return None, [f"invalid JSON: {exc.msg}"]
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None, ["JSON root must be an object"]
|
||||||
|
try:
|
||||||
|
return MafManagerReviewOutput.model_validate(data), []
|
||||||
|
except Exception as exc: # pydantic ValidationError
|
||||||
|
errors = []
|
||||||
|
detail = getattr(exc, "errors", None)
|
||||||
|
if callable(detail):
|
||||||
|
for err in detail():
|
||||||
|
loc = ".".join(str(p) for p in err.get("loc", ()))
|
||||||
|
errors.append(f"{loc}: {err.get('msg', 'invalid')}")
|
||||||
|
return None, errors or ["manager review schema validation failed"]
|
||||||
|
|
||||||
|
|
||||||
|
def build_manager_review_prompt(
|
||||||
|
*,
|
||||||
|
task_text: str,
|
||||||
|
review_items: list[dict[str, Any]],
|
||||||
|
) -> str:
|
||||||
|
"""Build the strict manager review prompt.
|
||||||
|
|
||||||
|
Each review item: ``{agent_id, attempt, contract, delivery, check}``. The
|
||||||
|
manager may only propose decisions; the server finalizes them.
|
||||||
|
"""
|
||||||
|
contract_shape = {
|
||||||
|
"decisions": [
|
||||||
|
{
|
||||||
|
"agent_id": "被审查席位 id",
|
||||||
|
"decision": "accept|return_for_rework|reassign|ask_user|controlled_gap",
|
||||||
|
"reason": "必填:针对性理由",
|
||||||
|
"revision_requirements": ["打回时必填:缺什么、怎样补、哪些原结论不可用"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
lines = [
|
||||||
|
"你是 MAF 总控,负责验收下列席位交付。只输出一个 JSON 对象(不要 Markdown),"
|
||||||
|
"对每个席位给出一条 decision。验收依据是任务合同与确定性检查结果;"
|
||||||
|
"确定性检查未通过的交付不得 accept。打回时必须写明缺什么、怎样补。"
|
||||||
|
"不要输出任何席位/工具/线程 id 之外的字段。",
|
||||||
|
f"原始任务:\n{task_text[:2000]}",
|
||||||
|
"待审查交付:",
|
||||||
|
json.dumps(review_items, ensure_ascii=False)[:12000],
|
||||||
|
f"必须返回的 JSON 结构:{json.dumps(contract_shape, ensure_ascii=False)}",
|
||||||
|
]
|
||||||
|
return "\n\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ── server-owned decision finalization ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def finalize_review_decision(
|
||||||
|
*,
|
||||||
|
contract: MafTaskContract,
|
||||||
|
check_result: dict[str, Any],
|
||||||
|
review_item: MafManagerReviewItem | None,
|
||||||
|
rework_count: int,
|
||||||
|
reassign_count: int,
|
||||||
|
has_backup_seat: bool,
|
||||||
|
max_reassignments: int = 1,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Finalize one task's review decision server-side.
|
||||||
|
|
||||||
|
Returns ``{"decision", "reason", "revision_requirements", "source"}`` where
|
||||||
|
``decision`` is one of the final outcomes: ``accepted`` / ``rework`` /
|
||||||
|
``reassigned`` / ``controlled_gap`` / ``ask_user``.
|
||||||
|
|
||||||
|
Rules enforced here (never by the model alone):
|
||||||
|
|
||||||
|
1. A deterministic check failure can NEVER end in ``accepted``.
|
||||||
|
2. Rework is only granted while ``rework_count < contract.max_rework_attempts``.
|
||||||
|
3. Reassignment is only granted while a backup seat exists and
|
||||||
|
``reassign_count < max_reassignments``.
|
||||||
|
4. Once every cap is exhausted the task ends as ``controlled_gap`` (or an
|
||||||
|
explicit ``ask_user`` pause when the contract's fallback policy says
|
||||||
|
so) — never as a disguised success.
|
||||||
|
5. A missing/invalid manager review (timeout, exception, empty output,
|
||||||
|
invalid JSON, or a missing per-seat decision) is NEVER an acceptance,
|
||||||
|
even when the delivery passed every deterministic check: while the
|
||||||
|
rework cap allows it, the server forces a system rework; once the caps
|
||||||
|
are exhausted the contract's fallback policy decides (reassign /
|
||||||
|
``controlled_gap`` / ``ask_user``).
|
||||||
|
"""
|
||||||
|
codes = list(check_result.get("failure_codes") or [])
|
||||||
|
deterministic_passed = bool(check_result.get("passed"))
|
||||||
|
can_rework = rework_count < contract.max_rework_attempts
|
||||||
|
can_reassign = has_backup_seat and reassign_count < max_reassignments
|
||||||
|
|
||||||
|
def _outcome(decision: str, reason: str, source: str, requirements: list[str] | None = None) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"decision": decision,
|
||||||
|
"reason": str(reason or "")[:1000],
|
||||||
|
"revision_requirements": [str(r) for r in (requirements or [])][:16],
|
||||||
|
"source": source,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _exhaustion_outcome(reason: str, source: str) -> dict[str, Any]:
|
||||||
|
if contract.fallback_policy == "ask_user":
|
||||||
|
return _outcome(OUTCOME_ASK_USER, reason, source)
|
||||||
|
if contract.fallback_policy == "reassign_then_gap" and can_reassign:
|
||||||
|
return _outcome(OUTCOME_REASSIGNED, reason, source)
|
||||||
|
return _outcome(OUTCOME_CONTROLLED_GAP, reason, source)
|
||||||
|
|
||||||
|
# 1) Deterministic failure — never accepted.
|
||||||
|
if not deterministic_passed:
|
||||||
|
base_reason = f"deterministic_quality_check_failed: {', '.join(codes)}"
|
||||||
|
if can_rework:
|
||||||
|
requirements = (
|
||||||
|
list(review_item.revision_requirements)
|
||||||
|
if review_item is not None
|
||||||
|
and review_item.decision == "return_for_rework"
|
||||||
|
and review_item.revision_requirements
|
||||||
|
else repair_requirements_for_codes(codes)
|
||||||
|
)
|
||||||
|
return _outcome(OUTCOME_REWORK, base_reason, "deterministic", requirements)
|
||||||
|
return _exhaustion_outcome(base_reason, "deterministic")
|
||||||
|
|
||||||
|
# 2) Deterministic checks passed — but acceptance still requires a valid
|
||||||
|
# manager review. A missing/invalid review (timeout, exception, empty
|
||||||
|
# output, invalid JSON, or a missing per-seat decision) is NEVER an
|
||||||
|
# acceptance: passing length/format checks alone does not make a delivery
|
||||||
|
# verified. The server forces a system rework while the cap allows it, and
|
||||||
|
# falls back to the contract's exhaustion policy afterwards.
|
||||||
|
if review_item is None:
|
||||||
|
reason = "manager_review_missing_or_invalid"
|
||||||
|
if can_rework:
|
||||||
|
return _outcome(
|
||||||
|
OUTCOME_REWORK,
|
||||||
|
reason,
|
||||||
|
"system",
|
||||||
|
["总控未获得有效验收结论;请重新提交完整、可核验的交付。"],
|
||||||
|
)
|
||||||
|
return _exhaustion_outcome(reason, "system")
|
||||||
|
|
||||||
|
decision = review_item.decision
|
||||||
|
if decision == "accept":
|
||||||
|
return _outcome(OUTCOME_ACCEPTED, review_item.reason, "manager")
|
||||||
|
if decision == "return_for_rework":
|
||||||
|
if can_rework:
|
||||||
|
requirements = list(review_item.revision_requirements) or repair_requirements_for_codes(codes)
|
||||||
|
return _outcome(OUTCOME_REWORK, review_item.reason, "manager", requirements)
|
||||||
|
return _exhaustion_outcome(
|
||||||
|
f"rework_cap_reached: {review_item.reason}", "manager"
|
||||||
|
)
|
||||||
|
if decision == "reassign":
|
||||||
|
if can_reassign:
|
||||||
|
return _outcome(OUTCOME_REASSIGNED, review_item.reason, "manager")
|
||||||
|
return _exhaustion_outcome(
|
||||||
|
f"reassign_unavailable: {review_item.reason}", "manager"
|
||||||
|
)
|
||||||
|
if decision == "ask_user":
|
||||||
|
return _outcome(OUTCOME_ASK_USER, review_item.reason, "manager")
|
||||||
|
# decision == "controlled_gap"
|
||||||
|
return _outcome(OUTCOME_CONTROLLED_GAP, review_item.reason, "manager")
|
||||||
|
|
||||||
|
|
||||||
|
def make_controlled_gap(
|
||||||
|
*,
|
||||||
|
task_key: str,
|
||||||
|
agent_id: str,
|
||||||
|
objective: str,
|
||||||
|
reason: str,
|
||||||
|
failure_codes: list[str] | None = None,
|
||||||
|
attempts: int = 0,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build the persisted, traceable controlled-gap record.
|
||||||
|
|
||||||
|
A controlled gap is NEVER a verified fact: the final report must carry it
|
||||||
|
in its risk / pending-information section.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"task_key": task_key,
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"objective": str(objective or "")[:2000],
|
||||||
|
"reason": str(reason or "")[:1000],
|
||||||
|
"failure_codes": list(failure_codes or []),
|
||||||
|
"attempts": int(attempts),
|
||||||
|
"risk": "该问题未获得合格席位交付,不得作为已验证事实写入最终结论",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DEFAULT_EXPECTED_DELIVERABLE",
|
||||||
|
"FAIL_BELOW_MIN_LENGTH",
|
||||||
|
"FAIL_DUPLICATE_CONTENT",
|
||||||
|
"FAIL_EMPTY_CONTENT",
|
||||||
|
"FAIL_MISSING_CONCLUSION",
|
||||||
|
"FAIL_MISSING_EVIDENCE",
|
||||||
|
"FAIL_SCHEMA_INVALID",
|
||||||
|
"FAIL_UNVERIFIED_EVIDENCE_REF",
|
||||||
|
"InvalidTaskContractError",
|
||||||
|
"MafFallbackPolicy",
|
||||||
|
"MafManagerReviewItem",
|
||||||
|
"MafManagerReviewOutput",
|
||||||
|
"MafReviewDecision",
|
||||||
|
"MafSeatDelivery",
|
||||||
|
"MafTaskContract",
|
||||||
|
"OUTCOME_ACCEPTED",
|
||||||
|
"OUTCOME_ASK_USER",
|
||||||
|
"OUTCOME_CONTROLLED_GAP",
|
||||||
|
"OUTCOME_REASSIGNED",
|
||||||
|
"OUTCOME_REWORK",
|
||||||
|
"TASK_ACCEPTED",
|
||||||
|
"TASK_ALL_STATUSES",
|
||||||
|
"TASK_ASSIGNED",
|
||||||
|
"TASK_CONTROLLED_GAP",
|
||||||
|
"TASK_DELIVERING",
|
||||||
|
"TASK_EXHAUSTED",
|
||||||
|
"TASK_MANAGER_REVIEW",
|
||||||
|
"TASK_REASSIGNED",
|
||||||
|
"TASK_REWORK",
|
||||||
|
"TASK_SUBMITTED",
|
||||||
|
"TASK_TERMINAL_STATUSES",
|
||||||
|
"build_manager_review_prompt",
|
||||||
|
"build_task_contract",
|
||||||
|
"check_delivery_deterministic",
|
||||||
|
"conclusion_digest",
|
||||||
|
"delivery_from_seat_output",
|
||||||
|
"finalize_review_decision",
|
||||||
|
"make_controlled_gap",
|
||||||
|
"parse_manager_review",
|
||||||
|
"repair_requirements_for_codes",
|
||||||
|
]
|
||||||
@@ -24,6 +24,9 @@ class SeatTurnOutput(BaseModel):
|
|||||||
executed_actions: list[str | dict[str, Any]] = Field(default_factory=list)
|
executed_actions: list[str | dict[str, Any]] = Field(default_factory=list)
|
||||||
open_questions: list[str] = Field(default_factory=list)
|
open_questions: list[str] = Field(default_factory=list)
|
||||||
conflicts: list[str] = Field(default_factory=list)
|
conflicts: list[str] = Field(default_factory=list)
|
||||||
|
# Stage 4 (R3-Q): optional self-check against the task contract. The
|
||||||
|
# manager review treats this as advisory — it is never acceptance evidence.
|
||||||
|
self_check: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
class ConfirmedCandidate(BaseModel):
|
class ConfirmedCandidate(BaseModel):
|
||||||
@@ -134,7 +137,8 @@ def seat_output_instruction() -> str:
|
|||||||
"""Stage instruction fragment telling the model to emit SeatTurnOutput JSON."""
|
"""Stage instruction fragment telling the model to emit SeatTurnOutput JSON."""
|
||||||
return (
|
return (
|
||||||
"请以 JSON 对象回复,字段:summary(string,必填)、findings(string[])、"
|
"请以 JSON 对象回复,字段:summary(string,必填)、findings(string[])、"
|
||||||
"evidence_refs、proposed_actions、executed_actions、open_questions、conflicts。"
|
"evidence_refs、proposed_actions、executed_actions、open_questions、conflicts、"
|
||||||
|
"self_check(string,可选:对照任务合同自检:已覆盖哪些验收标准、哪些仍缺失)。"
|
||||||
"proposed_actions 只表示建议;不要把未真正由系统执行的外部写入标为已执行。"
|
"proposed_actions 只表示建议;不要把未真正由系统执行的外部写入标为已执行。"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -32,14 +32,45 @@ from app.maf_roundtable.dispatch_contracts import (
|
|||||||
MafDispatchCatalog,
|
MafDispatchCatalog,
|
||||||
MafDispatchPlan,
|
MafDispatchPlan,
|
||||||
)
|
)
|
||||||
|
from app.maf_roundtable.event_normalizer import (
|
||||||
|
make_manager_review_decision,
|
||||||
|
make_seat_delivery_submitted,
|
||||||
|
make_seat_reassigned,
|
||||||
|
make_seat_rework_assigned,
|
||||||
|
make_stage_quality_gate,
|
||||||
|
)
|
||||||
from app.maf_roundtable.fact_ledger import (
|
from app.maf_roundtable.fact_ledger import (
|
||||||
confirm_candidates,
|
confirm_candidates,
|
||||||
empty_ledger,
|
empty_ledger,
|
||||||
|
known_evidence_refs,
|
||||||
merge_seat_output,
|
merge_seat_output,
|
||||||
normalize_ledger,
|
normalize_ledger,
|
||||||
record_action_transition,
|
record_action_transition,
|
||||||
record_tool_result,
|
record_tool_result,
|
||||||
)
|
)
|
||||||
|
from app.maf_roundtable.quality_contracts import (
|
||||||
|
OUTCOME_ACCEPTED,
|
||||||
|
OUTCOME_ASK_USER,
|
||||||
|
OUTCOME_CONTROLLED_GAP,
|
||||||
|
OUTCOME_REASSIGNED,
|
||||||
|
OUTCOME_REWORK,
|
||||||
|
TASK_ACCEPTED,
|
||||||
|
TASK_CONTROLLED_GAP,
|
||||||
|
TASK_EXHAUSTED,
|
||||||
|
TASK_REASSIGNED,
|
||||||
|
TASK_REWORK,
|
||||||
|
TASK_SUBMITTED,
|
||||||
|
TASK_TERMINAL_STATUSES,
|
||||||
|
MafTaskContract,
|
||||||
|
build_manager_review_prompt,
|
||||||
|
build_task_contract,
|
||||||
|
check_delivery_deterministic,
|
||||||
|
conclusion_digest,
|
||||||
|
delivery_from_seat_output,
|
||||||
|
finalize_review_decision,
|
||||||
|
make_controlled_gap,
|
||||||
|
parse_manager_review,
|
||||||
|
)
|
||||||
from app.maf_roundtable.seat_output import (
|
from app.maf_roundtable.seat_output import (
|
||||||
build_repair_prompt,
|
build_repair_prompt,
|
||||||
parse_cross_review_output,
|
parse_cross_review_output,
|
||||||
@@ -85,6 +116,7 @@ class RoundtableState:
|
|||||||
dispatch_plan: dict[str, Any] | None = None,
|
dispatch_plan: dict[str, Any] | None = None,
|
||||||
candidate_seat_agent_ids: list[str] | None = None,
|
candidate_seat_agent_ids: list[str] | None = None,
|
||||||
dispatch_contract_enabled: bool | None = None,
|
dispatch_contract_enabled: bool | None = None,
|
||||||
|
quality_gate_enabled: bool | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.session_id = session_id
|
self.session_id = session_id
|
||||||
self.run_id = run_id
|
self.run_id = run_id
|
||||||
@@ -110,6 +142,14 @@ class RoundtableState:
|
|||||||
if dispatch_contract_enabled is None
|
if dispatch_contract_enabled is None
|
||||||
else bool(dispatch_contract_enabled)
|
else bool(dispatch_contract_enabled)
|
||||||
)
|
)
|
||||||
|
# Stage 4 (R3-Q): quality gate for V2 structured dispatch. Defaults to
|
||||||
|
# the dispatch-contract value when not explicitly resolved, so
|
||||||
|
# checkpoints written before Stage 4 keep the new behavior.
|
||||||
|
self.quality_gate_enabled = (
|
||||||
|
bool(self.dispatch_contract_enabled)
|
||||||
|
if quality_gate_enabled is None
|
||||||
|
else bool(quality_gate_enabled)
|
||||||
|
)
|
||||||
plan_seats = list((dispatch_plan or {}).get("seat_agent_ids") or [])
|
plan_seats = list((dispatch_plan or {}).get("seat_agent_ids") or [])
|
||||||
self.candidate_seat_agent_ids = list(
|
self.candidate_seat_agent_ids = list(
|
||||||
(dispatch_plan or {}).get("candidate_seat_agent_ids")
|
(dispatch_plan or {}).get("candidate_seat_agent_ids")
|
||||||
@@ -132,6 +172,19 @@ class RoundtableState:
|
|||||||
self.stage: str = "planning"
|
self.stage: str = "planning"
|
||||||
self.partial_failure: bool = False
|
self.partial_failure: bool = False
|
||||||
self.cross_review_result: dict[str, Any] | None = None
|
self.cross_review_result: dict[str, Any] | None = None
|
||||||
|
# Stage 4 (R3-Q): per-task quality state machine summary.
|
||||||
|
# task_key → {status, agent_id, original_agent_id, attempt_count,
|
||||||
|
# rework_count, reassign_count, required}
|
||||||
|
self.seat_task_statuses: dict[str, Any] = {}
|
||||||
|
# Controlled-gap records to be carried into the final report's risk
|
||||||
|
# section. Each entry is a make_controlled_gap(...) dict.
|
||||||
|
self.controlled_gaps: list[dict[str, Any]] = []
|
||||||
|
# Stage 4 (R3-Q): the ONLY seat outputs that cross-review and the
|
||||||
|
# final synthesis may consume when the quality gate is enabled. An
|
||||||
|
# entry is appended exactly when a task's final decision is
|
||||||
|
# ``accepted`` — reworked / reassigned / gap deliveries never land
|
||||||
|
# here, so unaccepted content cannot leak into the final report.
|
||||||
|
self.accepted_task_results: list[dict[str, Any]] = []
|
||||||
|
|
||||||
def mark_completed(self, node_id: str) -> None:
|
def mark_completed(self, node_id: str) -> None:
|
||||||
if node_id not in self.completed_nodes:
|
if node_id not in self.completed_nodes:
|
||||||
@@ -159,6 +212,7 @@ class RoundtableState:
|
|||||||
"intent_type": self.intent_type,
|
"intent_type": self.intent_type,
|
||||||
"dispatch_plan": self.dispatch_plan,
|
"dispatch_plan": self.dispatch_plan,
|
||||||
"dispatch_contract_enabled": self.dispatch_contract_enabled,
|
"dispatch_contract_enabled": self.dispatch_contract_enabled,
|
||||||
|
"quality_gate_enabled": self.quality_gate_enabled,
|
||||||
"candidate_seat_agent_ids": list(self.candidate_seat_agent_ids),
|
"candidate_seat_agent_ids": list(self.candidate_seat_agent_ids),
|
||||||
"selected_seat_agent_ids": self.selected_seat_agent_ids,
|
"selected_seat_agent_ids": self.selected_seat_agent_ids,
|
||||||
"current_round": self.current_round,
|
"current_round": self.current_round,
|
||||||
@@ -173,6 +227,9 @@ class RoundtableState:
|
|||||||
"stage": self.stage,
|
"stage": self.stage,
|
||||||
"partial_failure": self.partial_failure,
|
"partial_failure": self.partial_failure,
|
||||||
"cross_review_result": self.cross_review_result,
|
"cross_review_result": self.cross_review_result,
|
||||||
|
"seat_task_statuses": dict(self.seat_task_statuses),
|
||||||
|
"controlled_gaps": list(self.controlled_gaps),
|
||||||
|
"accepted_task_results": list(self.accepted_task_results),
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -196,6 +253,7 @@ class RoundtableState:
|
|||||||
dispatch_plan=d.get("dispatch_plan"),
|
dispatch_plan=d.get("dispatch_plan"),
|
||||||
candidate_seat_agent_ids=d.get("candidate_seat_agent_ids"),
|
candidate_seat_agent_ids=d.get("candidate_seat_agent_ids"),
|
||||||
dispatch_contract_enabled=d.get("dispatch_contract_enabled"),
|
dispatch_contract_enabled=d.get("dispatch_contract_enabled"),
|
||||||
|
quality_gate_enabled=d.get("quality_gate_enabled"),
|
||||||
)
|
)
|
||||||
state.current_round = d.get("current_round", 0)
|
state.current_round = d.get("current_round", 0)
|
||||||
state.turn_results = d.get("turn_results", [])
|
state.turn_results = d.get("turn_results", [])
|
||||||
@@ -212,6 +270,9 @@ class RoundtableState:
|
|||||||
state.stage = d.get("stage") or "planning"
|
state.stage = d.get("stage") or "planning"
|
||||||
state.partial_failure = bool(d.get("partial_failure", False))
|
state.partial_failure = bool(d.get("partial_failure", False))
|
||||||
state.cross_review_result = d.get("cross_review_result")
|
state.cross_review_result = d.get("cross_review_result")
|
||||||
|
state.seat_task_statuses = dict(d.get("seat_task_statuses") or {})
|
||||||
|
state.controlled_gaps = list(d.get("controlled_gaps") or [])
|
||||||
|
state.accepted_task_results = list(d.get("accepted_task_results") or [])
|
||||||
state.candidate_seat_agent_ids = list(
|
state.candidate_seat_agent_ids = list(
|
||||||
d.get("candidate_seat_agent_ids") or state.candidate_seat_agent_ids
|
d.get("candidate_seat_agent_ids") or state.candidate_seat_agent_ids
|
||||||
)
|
)
|
||||||
@@ -358,6 +419,15 @@ class ManagerDispatchExecutor(Executor):
|
|||||||
|
|
||||||
@handler(input=Any, output=dict)
|
@handler(input=Any, output=dict)
|
||||||
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||||||
|
# V2 clarification checkpoints must always re-enter the clarification
|
||||||
|
# recovery branch, even when manager_dispatch is already marked
|
||||||
|
# completed — otherwise a recovered clarification run would stay
|
||||||
|
# "running" instead of landing in waiting_input.
|
||||||
|
if self._state.dispatch_contract_enabled and self._state.intent_type == "clarification":
|
||||||
|
self._state.stage = "planning"
|
||||||
|
await _resolve_manager_dispatch(self, ctx)
|
||||||
|
return
|
||||||
|
|
||||||
if self._state.is_completed(self._node_id):
|
if self._state.is_completed(self._node_id):
|
||||||
await ctx.send_message({
|
await ctx.send_message({
|
||||||
"decision": self._dispatch_decision(),
|
"decision": self._dispatch_decision(),
|
||||||
@@ -854,12 +924,24 @@ class SeatExecutor(Executor):
|
|||||||
agent_id: str,
|
agent_id: str,
|
||||||
*,
|
*,
|
||||||
action_coordinator: ActionCoordinator | None = None,
|
action_coordinator: ActionCoordinator | None = None,
|
||||||
|
execution_stage: str | None = None,
|
||||||
|
defer_fact_ledger_merge: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(id=f"seat_{agent_id}")
|
super().__init__(id=f"seat_{agent_id}")
|
||||||
self._adapter = adapter
|
self._adapter = adapter
|
||||||
self._state = state
|
self._state = state
|
||||||
self._agent_id = agent_id
|
self._agent_id = agent_id
|
||||||
self._action_coordinator = action_coordinator
|
self._action_coordinator = action_coordinator
|
||||||
|
# Stage 4 (R3-Q): optional override for the execution_stage passed to
|
||||||
|
# run_seat_turn. When None, falls back to the mode-based default
|
||||||
|
# ("expert" for structured, "group_chat" for group_chat).
|
||||||
|
self._execution_stage = execution_stage
|
||||||
|
# Stage 4 (R3-Q): when True the structured output is validated, tool
|
||||||
|
# results are audited and events are recorded as usual, but the output
|
||||||
|
# is NOT merged into the fact ledger. The quality loop merges it only
|
||||||
|
# if the manager's final decision accepts the delivery — reworked,
|
||||||
|
# reassigned or gapped content must never become candidate facts.
|
||||||
|
self._defer_fact_ledger_merge = defer_fact_ledger_merge
|
||||||
|
|
||||||
@handler(input=Any, output=dict)
|
@handler(input=Any, output=dict)
|
||||||
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||||||
@@ -909,7 +991,7 @@ class SeatExecutor(Executor):
|
|||||||
run_id=self._state.run_id,
|
run_id=self._state.run_id,
|
||||||
agent_id=self._agent_id,
|
agent_id=self._agent_id,
|
||||||
turn_index=self._state.current_round,
|
turn_index=self._state.current_round,
|
||||||
execution_stage="group_chat" if self._state.mode == "group_chat" else "expert",
|
execution_stage=self._execution_stage or ("group_chat" if self._state.mode == "group_chat" else "expert"),
|
||||||
message=self._state.task_text,
|
message=self._state.task_text,
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
model_name=caps.get("model_name") or self._state.model_name,
|
model_name=caps.get("model_name") or self._state.model_name,
|
||||||
@@ -947,7 +1029,7 @@ class SeatExecutor(Executor):
|
|||||||
schema_result = await self._validate_output(raw_content)
|
schema_result = await self._validate_output(raw_content)
|
||||||
content = schema_result.get("raw_content") or raw_content
|
content = schema_result.get("raw_content") or raw_content
|
||||||
|
|
||||||
if schema_result.get("output"):
|
if schema_result.get("output") and not self._defer_fact_ledger_merge:
|
||||||
merge_seat_output(self._state.fact_ledger, schema_result["output"], agent_id=self._agent_id)
|
merge_seat_output(self._state.fact_ledger, schema_result["output"], agent_id=self._agent_id)
|
||||||
|
|
||||||
await self._capture_pending_approvals(response.tool_calls or [])
|
await self._capture_pending_approvals(response.tool_calls or [])
|
||||||
@@ -972,7 +1054,7 @@ class SeatExecutor(Executor):
|
|||||||
# Phase-3 P0-1: record which execution stage produced this turn so
|
# Phase-3 P0-1: record which execution stage produced this turn so
|
||||||
# recovery can distinguish an expert's original output from its
|
# recovery can distinguish an expert's original output from its
|
||||||
# format_repair / cross_review / finalizer re-runs.
|
# format_repair / cross_review / finalizer re-runs.
|
||||||
"execution_stage": "group_chat" if self._state.mode == "group_chat" else "expert",
|
"execution_stage": self._execution_stage or ("group_chat" if self._state.mode == "group_chat" else "expert"),
|
||||||
"content": content,
|
"content": content,
|
||||||
"tool_calls": response.tool_calls,
|
"tool_calls": response.tool_calls,
|
||||||
"error": response.error,
|
"error": response.error,
|
||||||
@@ -1151,8 +1233,21 @@ class CrossReviewExecutor(Executor):
|
|||||||
|
|
||||||
self._state.stage = "review"
|
self._state.stage = "review"
|
||||||
|
|
||||||
# Barrier: ensure every seat has an outcome (fan-in should guarantee this).
|
# P0-3: with the Stage-4 quality gate the cross-review may only see
|
||||||
review_seats = self._state.selected_seat_agent_ids or self._state.seat_agent_ids
|
# ACCEPTED seat results — reworked/reassigned/gapped deliveries never
|
||||||
|
# enter the review prompt (their content is already absent from the
|
||||||
|
# fact ledger because the merge was deferred until acceptance).
|
||||||
|
if self._state.quality_gate_enabled:
|
||||||
|
accepted_results = list(self._state.accepted_task_results)
|
||||||
|
review_seats = sorted({
|
||||||
|
str(r.get("agent_id") or "")
|
||||||
|
for r in accepted_results
|
||||||
|
if r.get("agent_id")
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
accepted_results = None
|
||||||
|
# Barrier: ensure every seat has an outcome (fan-in should guarantee this).
|
||||||
|
review_seats = self._state.selected_seat_agent_ids or self._state.seat_agent_ids
|
||||||
missing = [a for a in review_seats if a not in self._state.seat_outcomes]
|
missing = [a for a in review_seats if a not in self._state.seat_outcomes]
|
||||||
for aid in missing:
|
for aid in missing:
|
||||||
self._state.seat_outcomes[aid] = {"status": "missing", "error": "no_outcome"}
|
self._state.seat_outcomes[aid] = {"status": "missing", "error": "no_outcome"}
|
||||||
@@ -1184,9 +1279,16 @@ class CrossReviewExecutor(Executor):
|
|||||||
thread_id = self._state.thread_map.get(manager_id or "", "")
|
thread_id = self._state.thread_map.get(manager_id or "", "")
|
||||||
if manager_id and thread_id:
|
if manager_id and thread_id:
|
||||||
try:
|
try:
|
||||||
|
if accepted_results is not None:
|
||||||
|
seat_results_for_review: list[dict[str, Any]] = accepted_results
|
||||||
|
else:
|
||||||
|
seat_results_for_review = [
|
||||||
|
r for r in self._state.turn_results
|
||||||
|
if r.get("agent_id") in review_seats
|
||||||
|
]
|
||||||
prompt = build_manager_synthesis_input(
|
prompt = build_manager_synthesis_input(
|
||||||
task_text=self._state.task_text,
|
task_text=self._state.task_text,
|
||||||
seat_results=[r for r in self._state.turn_results if r.get("agent_id") in review_seats],
|
seat_results=seat_results_for_review,
|
||||||
fact_ledger=self._state.fact_ledger,
|
fact_ledger=self._state.fact_ledger,
|
||||||
)
|
)
|
||||||
# Surface the candidate ids + the evidence refs the ledger has
|
# Surface the candidate ids + the evidence refs the ledger has
|
||||||
@@ -1248,6 +1350,9 @@ class CrossReviewExecutor(Executor):
|
|||||||
or review.get("conflicts")
|
or review.get("conflicts")
|
||||||
or review.get("tool_failures")
|
or review.get("tool_failures")
|
||||||
or review.get("schema_status") == "schema_invalid"
|
or review.get("schema_status") == "schema_invalid"
|
||||||
|
# Stage 4: a controlled gap is authoritative — the cross-review
|
||||||
|
# decision must reflect it even when everything else looks clean.
|
||||||
|
or (self._state.quality_gate_enabled and self._state.controlled_gaps)
|
||||||
)
|
)
|
||||||
review["decision"] = "controlled_gap" if has_quality_gap else "accept"
|
review["decision"] = "controlled_gap" if has_quality_gap else "accept"
|
||||||
review["reason"] = (
|
review["reason"] = (
|
||||||
@@ -1540,16 +1645,24 @@ async def generate_final_report(adapter: Any, state: RoundtableState) -> dict[st
|
|||||||
manager_id = state.manager_agent_id or "coordinator"
|
manager_id = state.manager_agent_id or "coordinator"
|
||||||
thread_id = state.thread_map.get(manager_id, "")
|
thread_id = state.thread_map.get(manager_id, "")
|
||||||
|
|
||||||
usable = [
|
if state.quality_gate_enabled:
|
||||||
r for r in state.turn_results
|
# P0-3: Stage 4 synthesizes ONLY accepted task results. There is
|
||||||
if not r.get("error") or r.get("content")
|
# deliberately NO fallback to ``turn_results`` — when no delivery was
|
||||||
]
|
# accepted, the report must be built from the controlled-gap risk
|
||||||
if state.partial_failure:
|
# section alone instead of smuggling rejected content back in.
|
||||||
usable = [r for r in usable if (state.seat_outcomes.get(r.get("agent_id")) or {}).get("status") == "completed"]
|
seat_results_for_synthesis: list[dict[str, Any]] = list(state.accepted_task_results)
|
||||||
|
else:
|
||||||
|
usable = [
|
||||||
|
r for r in state.turn_results
|
||||||
|
if not r.get("error") or r.get("content")
|
||||||
|
]
|
||||||
|
if state.partial_failure:
|
||||||
|
usable = [r for r in usable if (state.seat_outcomes.get(r.get("agent_id")) or {}).get("status") == "completed"]
|
||||||
|
seat_results_for_synthesis = usable or state.turn_results
|
||||||
|
|
||||||
synthesis_input = build_manager_synthesis_input(
|
synthesis_input = build_manager_synthesis_input(
|
||||||
task_text=state.task_text,
|
task_text=state.task_text,
|
||||||
seat_results=usable or state.turn_results,
|
seat_results=seat_results_for_synthesis,
|
||||||
fact_ledger=state.fact_ledger,
|
fact_ledger=state.fact_ledger,
|
||||||
)
|
)
|
||||||
if state.partial_failure:
|
if state.partial_failure:
|
||||||
@@ -1564,6 +1677,32 @@ async def generate_final_report(adapter: Any, state: RoundtableState) -> dict[st
|
|||||||
if state.cross_review_result:
|
if state.cross_review_result:
|
||||||
synthesis_input += f"\n\n【交叉审查摘要】\n{state.cross_review_result.get('summary', '')[:2000]}"
|
synthesis_input += f"\n\n【交叉审查摘要】\n{state.cross_review_result.get('summary', '')[:2000]}"
|
||||||
|
|
||||||
|
# Stage 4 (R3-Q): controlled gaps must reach the final report traceably and
|
||||||
|
# must NEVER be presented as verified facts. They are injected into the
|
||||||
|
# synthesis prompt as an explicit risk/pending-info section.
|
||||||
|
if state.controlled_gaps:
|
||||||
|
gap_lines = [
|
||||||
|
f"- 任务 {gap.get('task_key')}(席位 {gap.get('agent_id')},"
|
||||||
|
f"尝试 {gap.get('attempts', 0)} 次):{gap.get('reason')}"
|
||||||
|
for gap in state.controlled_gaps
|
||||||
|
]
|
||||||
|
synthesis_input += (
|
||||||
|
"\n\n【受控缺口(尚未获得合格席位交付,严禁写成已验证事实)】\n"
|
||||||
|
+ "\n".join(gap_lines)
|
||||||
|
+ "\n要求:把上述每一条缺口完整列入最终结论的『风险/待补信息』章节,"
|
||||||
|
"逐条说明其对结论的影响;不得将缺口内容陈述为已验证结论。"
|
||||||
|
)
|
||||||
|
|
||||||
|
has_gaps = bool(state.controlled_gaps)
|
||||||
|
quality_decision = (
|
||||||
|
"controlled_gap"
|
||||||
|
if has_gaps
|
||||||
|
else (
|
||||||
|
(state.cross_review_result or {}).get("decision")
|
||||||
|
or ("controlled_gap" if state.partial_failure else "accept")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if thread_id:
|
if thread_id:
|
||||||
response = await adapter.run_seat_turn(
|
response = await adapter.run_seat_turn(
|
||||||
session_id=state.session_id,
|
session_id=state.session_id,
|
||||||
@@ -1586,11 +1725,9 @@ async def generate_final_report(adapter: Any, state: RoundtableState) -> dict[st
|
|||||||
"content": response.content,
|
"content": response.content,
|
||||||
"agent_id": manager_id,
|
"agent_id": manager_id,
|
||||||
"partial_failure": state.partial_failure,
|
"partial_failure": state.partial_failure,
|
||||||
"degraded": state.partial_failure,
|
"degraded": state.partial_failure or has_gaps,
|
||||||
"quality_decision": (
|
"quality_decision": quality_decision,
|
||||||
(state.cross_review_result or {}).get("decision")
|
"controlled_gaps": list(state.controlled_gaps),
|
||||||
or ("controlled_gap" if state.partial_failure else "accept")
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
state.final_result = {
|
state.final_result = {
|
||||||
@@ -1598,11 +1735,9 @@ async def generate_final_report(adapter: Any, state: RoundtableState) -> dict[st
|
|||||||
"agent_id": manager_id,
|
"agent_id": manager_id,
|
||||||
"note": "no_manager_thread",
|
"note": "no_manager_thread",
|
||||||
"partial_failure": state.partial_failure,
|
"partial_failure": state.partial_failure,
|
||||||
"degraded": state.partial_failure,
|
"degraded": state.partial_failure or has_gaps,
|
||||||
"quality_decision": (
|
"quality_decision": quality_decision,
|
||||||
(state.cross_review_result or {}).get("decision")
|
"controlled_gaps": list(state.controlled_gaps),
|
||||||
or ("controlled_gap" if state.partial_failure else "accept")
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await adapter._emit_critical(state.run_id, make_run_completed(manager_id, state.current_round + 1, {
|
await adapter._emit_critical(state.run_id, make_run_completed(manager_id, state.current_round + 1, {
|
||||||
@@ -1650,6 +1785,617 @@ class WaitingTerminalExecutor(Executor):
|
|||||||
await ctx.yield_output({"status": "waiting_approval"})
|
await ctx.yield_output({"status": "waiting_approval"})
|
||||||
|
|
||||||
|
|
||||||
|
class _SilentWorkflowCtx:
|
||||||
|
"""WorkflowContext stand-in for SeatExecutor invoked INSIDE the quality
|
||||||
|
loop. The embedded SeatExecutor reuses the outer executor's ctx when
|
||||||
|
called directly, which would otherwise publish its messages with the
|
||||||
|
outer node's source id and re-enter the graph. A silent ctx discards
|
||||||
|
those intermediate messages — the quality loop reads the delivery state
|
||||||
|
from ``turn_results`` / ``seat_outcomes`` instead."""
|
||||||
|
|
||||||
|
async def send_message(self, msg: Any) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def yield_output(self, msg: Any) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class WaitingInputTerminalExecutor(Executor):
|
||||||
|
"""Terminal executor for the Stage-4 ask_user / waiting_input path.
|
||||||
|
|
||||||
|
Reached when a seat task's fallback policy requests user clarification
|
||||||
|
(``ask_user``) and the manager cannot resolve the gap. The run ends in
|
||||||
|
``waiting_input`` — never as a disguised success.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(id="waiting_input_terminal")
|
||||||
|
|
||||||
|
@handler(input=Any, workflow_output=dict)
|
||||||
|
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||||||
|
await ctx.yield_output({"status": "waiting_input"})
|
||||||
|
|
||||||
|
|
||||||
|
class QualitySeatTasksExecutor(Executor):
|
||||||
|
"""Stage 4 (R3-Q): run every seat task through the acceptance state machine.
|
||||||
|
|
||||||
|
Replaces the plain SeatExecutor fan-out for V2 structured dispatch. One
|
||||||
|
executor drives all tasks in bounded quality rounds::
|
||||||
|
|
||||||
|
delivering → submitted → manager_review →
|
||||||
|
accepted | rework(→delivering) | reassigned(→delivering) |
|
||||||
|
controlled_gap | ask_user(→ waiting_input)
|
||||||
|
|
||||||
|
Every delivery runs a deterministic quality check first; the manager model
|
||||||
|
then proposes one structured decision per task, and the server finalizes it
|
||||||
|
via :func:`quality_contracts.finalize_review_decision` — a delivery that
|
||||||
|
failed the deterministic checks can NEVER be accepted, reworks are capped
|
||||||
|
by the frozen contract, and an exhausted task ends as ``controlled_gap``
|
||||||
|
(or an explicit ``ask_user`` pause) instead of being disguised as success.
|
||||||
|
|
||||||
|
Task rows persist in ``maf_roundtable_seat_tasks``; reassignment only
|
||||||
|
changes the task's ``agent_id`` (a server-validated backup candidate) and
|
||||||
|
never creates a new task.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
adapter: Any,
|
||||||
|
state: RoundtableState,
|
||||||
|
*,
|
||||||
|
action_coordinator: ActionCoordinator | None = None,
|
||||||
|
max_rework_attempts: int = 2,
|
||||||
|
max_reassignments: int = 1,
|
||||||
|
min_delivery_chars: int = 40,
|
||||||
|
min_delivery_evidence: int = 1,
|
||||||
|
fallback_policy: str = "reassign_then_gap",
|
||||||
|
) -> None:
|
||||||
|
super().__init__(id="quality_seat_tasks")
|
||||||
|
self._adapter = adapter
|
||||||
|
self._state = state
|
||||||
|
self._action_coordinator = action_coordinator
|
||||||
|
self._max_rework_attempts = max_rework_attempts
|
||||||
|
self._max_reassignments = max_reassignments
|
||||||
|
self._min_delivery_chars = min_delivery_chars
|
||||||
|
self._min_delivery_evidence = min_delivery_evidence
|
||||||
|
self._fallback_policy = fallback_policy
|
||||||
|
self._tasks: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _store(self) -> Any:
|
||||||
|
return self._adapter._service.store
|
||||||
|
|
||||||
|
@handler(input=Any, output=dict)
|
||||||
|
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||||||
|
if self._state.is_completed("quality_seat_tasks"):
|
||||||
|
await ctx.send_message({
|
||||||
|
"tasks": dict(self._state.seat_task_statuses),
|
||||||
|
"replayed": True,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
self._state.stage = "experts"
|
||||||
|
tasks = await self._load_or_create_tasks()
|
||||||
|
self._tasks = tasks
|
||||||
|
base_round = max(self._state.current_round, 1)
|
||||||
|
# P1: duplicate detection is seeded from the persisted task history
|
||||||
|
# (SHA-256 digests of prior conclusions), so a restart cannot re-submit
|
||||||
|
# an already-rejected conclusion and pass it off as new content.
|
||||||
|
prior_conclusions: dict[str, set[str]] = {}
|
||||||
|
for task in tasks:
|
||||||
|
digests = {
|
||||||
|
str(h.get("conclusion_sha256"))
|
||||||
|
for h in (task.get("history") or [])
|
||||||
|
if isinstance(h, dict) and h.get("conclusion_sha256")
|
||||||
|
}
|
||||||
|
prior_conclusions[str(task.get("task_key") or "")] = digests
|
||||||
|
|
||||||
|
# Bounded quality loop: initial delivery + max_rework_attempts reworks
|
||||||
|
# per task budget, plus one delivery round per possible reassignment.
|
||||||
|
max_rounds = (self._max_rework_attempts + 1) * (self._max_reassignments + 1) + 1
|
||||||
|
for round_offset in range(max_rounds):
|
||||||
|
self._state.current_round = base_round + round_offset
|
||||||
|
active = [t for t in tasks if t["status"] not in TASK_TERMINAL_STATUSES]
|
||||||
|
if not active or self._state.status == "waiting_input":
|
||||||
|
break
|
||||||
|
|
||||||
|
submitted: list[dict[str, Any]] = []
|
||||||
|
for task in active:
|
||||||
|
outcome = await self._deliver(task, prior_conclusions)
|
||||||
|
if outcome is not None:
|
||||||
|
submitted.append(outcome)
|
||||||
|
|
||||||
|
if not submitted:
|
||||||
|
continue
|
||||||
|
|
||||||
|
reviews = await self._manager_review(submitted)
|
||||||
|
for task in submitted:
|
||||||
|
final_outcome = await self._apply_decision(task, reviews.get(task["agent_id"]))
|
||||||
|
if final_outcome == OUTCOME_ASK_USER:
|
||||||
|
self._state.status = "waiting_input"
|
||||||
|
self._state.stage = "waiting_input"
|
||||||
|
|
||||||
|
self._state.seat_task_statuses = {
|
||||||
|
t["task_key"]: {
|
||||||
|
"task_key": t["task_key"],
|
||||||
|
"status": t["status"],
|
||||||
|
"agent_id": t["agent_id"],
|
||||||
|
"original_agent_id": t["original_agent_id"],
|
||||||
|
"attempt_count": t["attempt_count"],
|
||||||
|
"rework_count": t["rework_count"],
|
||||||
|
"reassign_count": t["reassign_count"],
|
||||||
|
"required": bool(t["required"]),
|
||||||
|
}
|
||||||
|
for t in tasks
|
||||||
|
}
|
||||||
|
self._state.mark_completed("quality_seat_tasks")
|
||||||
|
await ctx.send_message({"tasks": dict(self._state.seat_task_statuses)})
|
||||||
|
|
||||||
|
# ── task init (atomic, recovery-safe) ───────────────────────────────────
|
||||||
|
|
||||||
|
def _expected_task_specs(self) -> list[dict[str, Any]]:
|
||||||
|
"""The server-computed expected task set from the dispatch assignments.
|
||||||
|
|
||||||
|
``task_key`` is stable per original assignment, so recovery always
|
||||||
|
derives the identical key set — existing rows are kept, rows that a
|
||||||
|
crashed initialization never created are filled back in.
|
||||||
|
"""
|
||||||
|
plan = self._state.dispatch_plan or {}
|
||||||
|
assignments = plan.get("assignments") or []
|
||||||
|
specs: list[dict[str, Any]] = []
|
||||||
|
for index, assignment in enumerate(assignments, start=1):
|
||||||
|
agent_id = str(assignment.get("agent_id") or "").strip()
|
||||||
|
if not agent_id:
|
||||||
|
continue
|
||||||
|
contract = build_task_contract(
|
||||||
|
task_goal=str(assignment.get("task_goal") or ""),
|
||||||
|
delivery_requirements=list(assignment.get("delivery_requirements") or []),
|
||||||
|
required=True,
|
||||||
|
max_rework_attempts=self._max_rework_attempts,
|
||||||
|
fallback_policy=self._fallback_policy, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
specs.append({
|
||||||
|
"task_key": f"{index:02d}:{agent_id}",
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"contract": contract.model_dump(),
|
||||||
|
"required": True,
|
||||||
|
})
|
||||||
|
return specs
|
||||||
|
|
||||||
|
async def _load_or_create_tasks(self) -> list[dict[str, Any]]:
|
||||||
|
"""Atomic get-or-create over the FULL expected task set (P0-4).
|
||||||
|
|
||||||
|
Never short-circuits on "some tasks already exist": a crash mid-init
|
||||||
|
must not permanently drop the remaining required seats. Existing rows
|
||||||
|
keep their frozen contracts; concurrent recoveries converge without
|
||||||
|
unique-key errors.
|
||||||
|
"""
|
||||||
|
return await self._store.seat_tasks.get_or_create_tasks(
|
||||||
|
session_id=self._state.session_id,
|
||||||
|
run_id=self._state.run_id,
|
||||||
|
expected=self._expected_task_specs(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── delivery step ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _deliver(
|
||||||
|
self,
|
||||||
|
task: dict[str, Any],
|
||||||
|
prior_conclusions: dict[str, set[str]],
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Run one delivery via SeatExecutor (approvals / ledger / outcomes
|
||||||
|
reuse the existing seat path), then submit it with its deterministic
|
||||||
|
check result. Returns the submitted task dict or ``None`` if the seat
|
||||||
|
run hard-failed without any output."""
|
||||||
|
agent_id = task["agent_id"]
|
||||||
|
|
||||||
|
# Re-delivery must not replay the completed node.
|
||||||
|
node_id = f"seat_{agent_id}"
|
||||||
|
if node_id in self._state.completed_nodes:
|
||||||
|
self._state.completed_nodes.remove(node_id)
|
||||||
|
|
||||||
|
# Reassigned tasks: add the validated backup seat to the selected set
|
||||||
|
# and ensure its participant row + thread before it delivers.
|
||||||
|
if task["status"] == TASK_REASSIGNED:
|
||||||
|
if self._state.selected_seat_agent_ids is not None and agent_id not in self._state.selected_seat_agent_ids:
|
||||||
|
self._state.selected_seat_agent_ids.append(agent_id)
|
||||||
|
await _ensure_selected_seat_threads(self._adapter, self._state)
|
||||||
|
|
||||||
|
is_rework = task["attempt_count"] > 0
|
||||||
|
seat_exec = SeatExecutor(
|
||||||
|
self._adapter,
|
||||||
|
self._state,
|
||||||
|
agent_id,
|
||||||
|
action_coordinator=self._action_coordinator,
|
||||||
|
execution_stage="rework" if is_rework else None,
|
||||||
|
# P0-3: the delivery must not touch the fact ledger until the
|
||||||
|
# manager's final decision accepts it.
|
||||||
|
defer_fact_ledger_merge=True,
|
||||||
|
)
|
||||||
|
await seat_exec.handle({}, _SilentWorkflowCtx())
|
||||||
|
|
||||||
|
turn_row = next(
|
||||||
|
(
|
||||||
|
r for r in reversed(self._state.turn_results)
|
||||||
|
if r.get("agent_id") == agent_id
|
||||||
|
and r.get("turn_index") == self._state.current_round
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
raw_content = str((turn_row or {}).get("content") or "")
|
||||||
|
structured_output = (turn_row or {}).get("structured_output")
|
||||||
|
schema_status = str((turn_row or {}).get("schema_status") or "ok")
|
||||||
|
delivery = delivery_from_seat_output(
|
||||||
|
structured_output=structured_output, raw_content=raw_content
|
||||||
|
)
|
||||||
|
prior = prior_conclusions.get(task["task_key"])
|
||||||
|
if prior is None:
|
||||||
|
prior = set()
|
||||||
|
prior_conclusions[task["task_key"]] = prior
|
||||||
|
# P1: evidence must be traceable to the fact ledger (recorded evidence
|
||||||
|
# or successful tool results). Self-made findings / invented refs fail
|
||||||
|
# the deterministic gate.
|
||||||
|
verified_refs = known_evidence_refs(self._state.fact_ledger)
|
||||||
|
check = check_delivery_deterministic(
|
||||||
|
delivery=delivery,
|
||||||
|
raw_content=raw_content,
|
||||||
|
schema_status=schema_status,
|
||||||
|
min_length=self._min_delivery_chars,
|
||||||
|
min_evidence=self._min_delivery_evidence,
|
||||||
|
prior_conclusion_digests=prior,
|
||||||
|
verified_evidence_refs=verified_refs,
|
||||||
|
)
|
||||||
|
digest = conclusion_digest(delivery.conclusion) if delivery.conclusion else None
|
||||||
|
if digest:
|
||||||
|
prior.add(digest)
|
||||||
|
|
||||||
|
task["attempt_count"] = int(task.get("attempt_count") or 0) + 1
|
||||||
|
history = list(task.get("history") or [])
|
||||||
|
history.append({
|
||||||
|
"attempt": task["attempt_count"],
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"round": self._state.current_round,
|
||||||
|
"schema_status": schema_status,
|
||||||
|
"failure_codes": list(check["failure_codes"]),
|
||||||
|
# P1: persist the digest only — never the conclusion body — so
|
||||||
|
# duplicate detection survives restarts.
|
||||||
|
"conclusion_sha256": digest,
|
||||||
|
})
|
||||||
|
# P0-3: persisted delivery envelope. ``contract_delivery`` is the
|
||||||
|
# acceptance-facing view; ``validated_seat_output`` is the schema-
|
||||||
|
# validated raw SeatTurnOutput kept back until acceptance merges it
|
||||||
|
# into the fact ledger. Runtime objects / tool-call arguments are
|
||||||
|
# never written here.
|
||||||
|
task["delivery"] = {
|
||||||
|
"contract_delivery": delivery.model_dump(),
|
||||||
|
"validated_seat_output": structured_output
|
||||||
|
if isinstance(structured_output, dict)
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
task["deterministic_check"] = check
|
||||||
|
task["history"] = history
|
||||||
|
# In-memory only (never persisted): the turn row of this attempt, so
|
||||||
|
# an acceptance can merge exactly what was submitted.
|
||||||
|
task["_turn_row"] = turn_row
|
||||||
|
await self._store.seat_tasks.update_task(task["id"], {
|
||||||
|
"status": TASK_SUBMITTED,
|
||||||
|
"attempt_count": task["attempt_count"],
|
||||||
|
"delivery": task["delivery"],
|
||||||
|
"deterministic_check": check,
|
||||||
|
"history": history,
|
||||||
|
})
|
||||||
|
task["status"] = TASK_SUBMITTED
|
||||||
|
|
||||||
|
await self._adapter._emit_critical(self._state.run_id, make_seat_delivery_submitted(
|
||||||
|
agent_id,
|
||||||
|
self._state.current_round,
|
||||||
|
task_key=task["task_key"],
|
||||||
|
attempt=task["attempt_count"],
|
||||||
|
deterministic_passed=bool(check["passed"]),
|
||||||
|
failure_codes=list(check["failure_codes"]),
|
||||||
|
))
|
||||||
|
return task
|
||||||
|
|
||||||
|
# ── manager review step ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _manager_review(self, tasks: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
|
"""One batched manager review call for the round's submissions.
|
||||||
|
|
||||||
|
Returns ``{agent_id: MafManagerReviewItem}``. Any failure (timeout,
|
||||||
|
runtime error, empty output, invalid JSON, schema failure) degrades to
|
||||||
|
an empty dict — every task then finalizes as an INVALID review
|
||||||
|
(``source=system``): a system rework while the cap allows it, or the
|
||||||
|
contract's exhaustion outcome afterwards. A delivery is never accepted
|
||||||
|
without a valid manager review, and a seat missing from a partially
|
||||||
|
valid review is treated the same way.
|
||||||
|
"""
|
||||||
|
manager_id = self._state.manager_agent_id
|
||||||
|
thread_id = self._state.thread_map.get(manager_id or "", "")
|
||||||
|
if not (manager_id and thread_id):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
review_items: list[dict[str, Any]] = []
|
||||||
|
for task in tasks:
|
||||||
|
contract = task.get("contract") or {}
|
||||||
|
envelope = task.get("delivery") or {}
|
||||||
|
# The manager reviews the contract-facing delivery view; the raw
|
||||||
|
# validated seat output stays server-side until acceptance.
|
||||||
|
contract_delivery = envelope.get("contract_delivery") or envelope
|
||||||
|
review_items.append({
|
||||||
|
"agent_id": task["agent_id"],
|
||||||
|
"task_key": task["task_key"],
|
||||||
|
"attempt": task["attempt_count"],
|
||||||
|
"contract": {
|
||||||
|
"objective": contract.get("objective"),
|
||||||
|
"expected_deliverable": contract.get("expected_deliverable"),
|
||||||
|
"acceptance_criteria": list(contract.get("acceptance_criteria") or []),
|
||||||
|
"required": bool(contract.get("required", True)),
|
||||||
|
},
|
||||||
|
"delivery": contract_delivery,
|
||||||
|
"check": task.get("deterministic_check") or {},
|
||||||
|
})
|
||||||
|
prompt = build_manager_review_prompt(
|
||||||
|
task_text=self._state.task_text, review_items=review_items
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = await asyncio.wait_for(
|
||||||
|
self._adapter.run_seat_turn(
|
||||||
|
session_id=self._state.session_id,
|
||||||
|
run_id=self._state.run_id,
|
||||||
|
agent_id=manager_id,
|
||||||
|
turn_index=self._state.current_round,
|
||||||
|
execution_stage="manager_review",
|
||||||
|
message=prompt,
|
||||||
|
thread_id=thread_id,
|
||||||
|
model_name=self._state.model_name,
|
||||||
|
reasoning_effort=self._state.reasoning_effort,
|
||||||
|
task_text=prompt,
|
||||||
|
stage_instruction="返回严格的总控验收 JSON(每席位一条 decision)",
|
||||||
|
fact_ledger=self._state.fact_ledger,
|
||||||
|
capability_snapshot=self._state.capability_snapshot,
|
||||||
|
write_policy=self._state.write_policy,
|
||||||
|
wait_timeout_seconds=self._state.seat_timeout_seconds,
|
||||||
|
),
|
||||||
|
timeout=self._state.seat_timeout_seconds,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 - degraded to deterministic finalization
|
||||||
|
return {}
|
||||||
|
if response.error:
|
||||||
|
return {}
|
||||||
|
model, _errors = parse_manager_review(response.content or "")
|
||||||
|
if model is None:
|
||||||
|
return {}
|
||||||
|
return {item.agent_id: item for item in model.decisions}
|
||||||
|
|
||||||
|
# ── decision application ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _apply_decision(
|
||||||
|
self,
|
||||||
|
task: dict[str, Any],
|
||||||
|
review_item: Any | None,
|
||||||
|
) -> str:
|
||||||
|
"""Finalize and persist one task's review decision; returns the outcome."""
|
||||||
|
contract = MafTaskContract.model_validate(task.get("contract") or {})
|
||||||
|
check = task.get("deterministic_check") or {"passed": False, "failure_codes": []}
|
||||||
|
final = finalize_review_decision(
|
||||||
|
contract=contract,
|
||||||
|
check_result=check,
|
||||||
|
review_item=review_item,
|
||||||
|
rework_count=int(task.get("rework_count") or 0),
|
||||||
|
reassign_count=int(task.get("reassign_count") or 0),
|
||||||
|
has_backup_seat=bool(self._backup_agents(task)),
|
||||||
|
max_reassignments=self._max_reassignments,
|
||||||
|
)
|
||||||
|
outcome = final["decision"]
|
||||||
|
|
||||||
|
await self._adapter._emit_critical(self._state.run_id, make_manager_review_decision(
|
||||||
|
task["agent_id"],
|
||||||
|
self._state.current_round,
|
||||||
|
task_key=task["task_key"],
|
||||||
|
decision=outcome,
|
||||||
|
reason=final["reason"],
|
||||||
|
source=final["source"],
|
||||||
|
attempt=task["attempt_count"],
|
||||||
|
))
|
||||||
|
|
||||||
|
if outcome == OUTCOME_ACCEPTED:
|
||||||
|
task["status"] = TASK_ACCEPTED
|
||||||
|
# P0-3: only an accepted delivery may enter the fact ledger and
|
||||||
|
# the accepted result set consumed by cross-review / the final
|
||||||
|
# synthesis. Reworked / reassigned / gapped attempts never do.
|
||||||
|
envelope = task.get("delivery") or {}
|
||||||
|
validated = envelope.get("validated_seat_output")
|
||||||
|
if isinstance(validated, dict):
|
||||||
|
merge_seat_output(
|
||||||
|
self._state.fact_ledger, validated, agent_id=task["agent_id"]
|
||||||
|
)
|
||||||
|
turn_row = task.get("_turn_row") or {}
|
||||||
|
self._state.accepted_task_results.append({
|
||||||
|
"task_key": task["task_key"],
|
||||||
|
"agent_id": task["agent_id"],
|
||||||
|
"attempt": task["attempt_count"],
|
||||||
|
"accepted": True,
|
||||||
|
"source": final["source"],
|
||||||
|
"turn_index": turn_row.get("turn_index", self._state.current_round),
|
||||||
|
"content": str(turn_row.get("content") or ""),
|
||||||
|
"execution_stage": turn_row.get("execution_stage"),
|
||||||
|
"schema_status": turn_row.get("schema_status"),
|
||||||
|
"structured_output": validated if isinstance(validated, dict) else None,
|
||||||
|
})
|
||||||
|
await self._store.seat_tasks.update_task(task["id"], {
|
||||||
|
"status": TASK_ACCEPTED,
|
||||||
|
"review_decision": final,
|
||||||
|
})
|
||||||
|
|
||||||
|
elif outcome == OUTCOME_REWORK:
|
||||||
|
task["rework_count"] = int(task.get("rework_count") or 0) + 1
|
||||||
|
task["status"] = TASK_REWORK
|
||||||
|
await self._store.seat_tasks.update_task(task["id"], {
|
||||||
|
"status": TASK_REWORK,
|
||||||
|
"rework_count": task["rework_count"],
|
||||||
|
"review_decision": final,
|
||||||
|
})
|
||||||
|
await self._adapter._emit_critical(self._state.run_id, make_seat_rework_assigned(
|
||||||
|
task["agent_id"],
|
||||||
|
self._state.current_round,
|
||||||
|
task_key=task["task_key"],
|
||||||
|
rework_count=task["rework_count"],
|
||||||
|
requirements=list(final.get("revision_requirements") or []),
|
||||||
|
))
|
||||||
|
|
||||||
|
elif outcome == OUTCOME_REASSIGNED:
|
||||||
|
from_agent_id = task["agent_id"]
|
||||||
|
backup = self._pick_backup(task)
|
||||||
|
task["reassign_count"] = int(task.get("reassign_count") or 0) + 1
|
||||||
|
task["agent_id"] = backup
|
||||||
|
task["status"] = TASK_REASSIGNED
|
||||||
|
await self._store.seat_tasks.update_task(task["id"], {
|
||||||
|
"agent_id": backup,
|
||||||
|
"status": TASK_REASSIGNED,
|
||||||
|
"reassign_count": task["reassign_count"],
|
||||||
|
"review_decision": final,
|
||||||
|
})
|
||||||
|
await self._adapter._emit_critical(self._state.run_id, make_seat_reassigned(
|
||||||
|
backup,
|
||||||
|
self._state.current_round,
|
||||||
|
task_key=task["task_key"],
|
||||||
|
from_agent_id=from_agent_id,
|
||||||
|
reassign_count=task["reassign_count"],
|
||||||
|
))
|
||||||
|
|
||||||
|
elif outcome == OUTCOME_CONTROLLED_GAP:
|
||||||
|
gap = make_controlled_gap(
|
||||||
|
task_key=task["task_key"],
|
||||||
|
agent_id=task["agent_id"],
|
||||||
|
objective=contract.objective,
|
||||||
|
reason=final["reason"],
|
||||||
|
failure_codes=list(check.get("failure_codes") or []),
|
||||||
|
attempts=task["attempt_count"],
|
||||||
|
)
|
||||||
|
task["gap"] = gap
|
||||||
|
task["status"] = TASK_CONTROLLED_GAP
|
||||||
|
self._state.controlled_gaps.append(gap)
|
||||||
|
await self._store.seat_tasks.update_task(task["id"], {
|
||||||
|
"status": TASK_CONTROLLED_GAP,
|
||||||
|
"gap": gap,
|
||||||
|
"review_decision": final,
|
||||||
|
})
|
||||||
|
|
||||||
|
else: # OUTCOME_ASK_USER
|
||||||
|
task["status"] = TASK_EXHAUSTED
|
||||||
|
await self._store.seat_tasks.update_task(task["id"], {
|
||||||
|
"status": TASK_EXHAUSTED,
|
||||||
|
"review_decision": final,
|
||||||
|
})
|
||||||
|
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
# ── backup selection (server-validated) ─────────────────────────────────
|
||||||
|
|
||||||
|
def _backup_agents(self, task: dict[str, Any]) -> list[str]:
|
||||||
|
"""Ordered backup candidates for a task.
|
||||||
|
|
||||||
|
Candidates = server candidate seats minus every agent this task has
|
||||||
|
already used (original + rework owners) minus every agent that is the
|
||||||
|
current owner of ANY other task. A seat owns at most one task at a
|
||||||
|
time — a seat whose own task was accepted must not be double-booked as
|
||||||
|
a backup, because it would then hold two tasks and its deliveries
|
||||||
|
would collide on the (run, agent, turn, stage) idempotency key.
|
||||||
|
"""
|
||||||
|
candidates = list(self._state.candidate_seat_agent_ids or [])
|
||||||
|
used: set[str] = {
|
||||||
|
str(item.get("agent_id") or "")
|
||||||
|
for item in (task.get("history") or [])
|
||||||
|
if str(item.get("agent_id") or "")
|
||||||
|
}
|
||||||
|
used.add(str(task.get("agent_id") or ""))
|
||||||
|
for other in self._tasks:
|
||||||
|
if other.get("task_key") == task.get("task_key"):
|
||||||
|
continue
|
||||||
|
used.add(str(other.get("agent_id") or ""))
|
||||||
|
return [agent_id for agent_id in candidates if agent_id not in used]
|
||||||
|
|
||||||
|
def _pick_backup(self, task: dict[str, Any]) -> str:
|
||||||
|
backups = self._backup_agents(task)
|
||||||
|
if not backups:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"MAF quality loop requested reassignment for task "
|
||||||
|
f"{task.get('task_key')} but no validated backup seat exists"
|
||||||
|
)
|
||||||
|
return backups[0]
|
||||||
|
|
||||||
|
|
||||||
|
class QualityGateExecutor(Executor):
|
||||||
|
"""Stage 4 (R3-Q): phase quality gate between seat tasks and cross-review.
|
||||||
|
|
||||||
|
All required seat tasks must have reached a terminal state (``accepted``
|
||||||
|
or ``controlled_gap``) before the phase may proceed. A required task left
|
||||||
|
non-terminal blocks the phase deterministically (hard run failure — the
|
||||||
|
quality loop guarantees terminal states, so a blocked gate is a safety
|
||||||
|
net, never a normal outcome). Controlled gaps do NOT block the phase:
|
||||||
|
they proceed as ``passed_with_gaps`` and are carried into the final
|
||||||
|
report's risk section.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, adapter: Any, state: RoundtableState) -> None:
|
||||||
|
super().__init__(id="quality_gate")
|
||||||
|
self._adapter = adapter
|
||||||
|
self._state = state
|
||||||
|
|
||||||
|
@handler(input=Any, output=dict)
|
||||||
|
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||||||
|
if self._state.is_completed("quality_gate"):
|
||||||
|
await ctx.send_message({"decision": "passed", "replayed": True})
|
||||||
|
return
|
||||||
|
|
||||||
|
# ask_user path: route to the waiting_input terminal, never to
|
||||||
|
# cross-review / finalizer.
|
||||||
|
if self._state.status == "waiting_input":
|
||||||
|
self._state.mark_completed("quality_gate")
|
||||||
|
await ctx.send_message({"decision": "waiting_input"})
|
||||||
|
return
|
||||||
|
|
||||||
|
statuses = self._state.seat_task_statuses
|
||||||
|
required = {k: v for k, v in statuses.items() if v.get("required")}
|
||||||
|
non_terminal = [
|
||||||
|
k for k, v in required.items() if v.get("status") not in TASK_TERMINAL_STATUSES
|
||||||
|
]
|
||||||
|
# P0-4 safety net: every dispatch assignment must be represented by a
|
||||||
|
# task row. A missing required task blocks the gate exactly like a
|
||||||
|
# non-terminal one — the gate must never pass on a dropped seat.
|
||||||
|
expected_keys: list[str] = []
|
||||||
|
plan = self._state.dispatch_plan or {}
|
||||||
|
for index, assignment in enumerate(plan.get("assignments") or [], start=1):
|
||||||
|
agent_id = str(assignment.get("agent_id") or "").strip()
|
||||||
|
if agent_id:
|
||||||
|
expected_keys.append(f"{index:02d}:{agent_id}")
|
||||||
|
missing = [k for k in expected_keys if k not in statuses]
|
||||||
|
blocked = sorted({*non_terminal, *missing})
|
||||||
|
accepted = sum(1 for v in statuses.values() if v.get("status") == TASK_ACCEPTED)
|
||||||
|
gap_count = sum(1 for v in statuses.values() if v.get("status") == TASK_CONTROLLED_GAP)
|
||||||
|
decision = "blocked" if blocked else ("passed_with_gaps" if gap_count else "passed")
|
||||||
|
|
||||||
|
await self._adapter._emit_critical(self._state.run_id, make_stage_quality_gate(
|
||||||
|
self._state.manager_agent_id,
|
||||||
|
self._state.current_round,
|
||||||
|
decision=decision,
|
||||||
|
total=len(statuses),
|
||||||
|
accepted=accepted,
|
||||||
|
controlled_gap=gap_count,
|
||||||
|
blocked_required=blocked,
|
||||||
|
))
|
||||||
|
|
||||||
|
if blocked:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"MAF quality gate blocked: required seat tasks not terminal or missing: "
|
||||||
|
f"{', '.join(blocked)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._state.mark_completed("quality_gate")
|
||||||
|
self._state.stage = "review"
|
||||||
|
await ctx.send_message({"decision": decision, "tasks": dict(statuses)})
|
||||||
|
|
||||||
|
|
||||||
# ── helpers ─────────────────────────────────────────────────────────────────
|
# ── helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -55,11 +55,19 @@ def build_structured_workflow(
|
|||||||
dispatch_seat_entry: Any | None = None,
|
dispatch_seat_entry: Any | None = None,
|
||||||
manager_answer: Any | None = None,
|
manager_answer: Any | None = None,
|
||||||
clarification_terminal: Any | None = None,
|
clarification_terminal: Any | None = None,
|
||||||
|
quality_gate: Any | None = None,
|
||||||
|
waiting_input_terminal: Any | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Build a structured (fixed-topology) MAF Workflow.
|
"""Build a structured (fixed-topology) MAF Workflow.
|
||||||
|
|
||||||
Topology: planner → parallel seat executors → cross_review → approval gate
|
Topology (Stage 3): planner → parallel seat executors → cross_review →
|
||||||
→ finalizer | waiting_terminal.
|
approval gate → finalizer | waiting_terminal.
|
||||||
|
|
||||||
|
Topology (Stage 4 quality gate): quality seat-tasks loop → quality gate →
|
||||||
|
(waiting_input | cross_review) → approval gate → finalizer | waiting_terminal.
|
||||||
|
When ``quality_gate`` is provided, the seat fan-out is replaced by the
|
||||||
|
single quality loop executor and ``waiting_input_terminal`` becomes an
|
||||||
|
additional workflow output for the ``ask_user`` pause path.
|
||||||
"""
|
"""
|
||||||
from agent_framework import Case, Default, WorkflowBuilder
|
from agent_framework import Case, Default, WorkflowBuilder
|
||||||
|
|
||||||
@@ -71,6 +79,8 @@ def build_structured_workflow(
|
|||||||
outputs.append(manager_answer)
|
outputs.append(manager_answer)
|
||||||
if clarification_terminal is not None:
|
if clarification_terminal is not None:
|
||||||
outputs.append(clarification_terminal)
|
outputs.append(clarification_terminal)
|
||||||
|
if waiting_input_terminal is not None:
|
||||||
|
outputs.append(waiting_input_terminal)
|
||||||
builder = WorkflowBuilder(
|
builder = WorkflowBuilder(
|
||||||
start_executor=start,
|
start_executor=start,
|
||||||
name="maf-structured-roundtable",
|
name="maf-structured-roundtable",
|
||||||
@@ -97,7 +107,20 @@ def build_structured_workflow(
|
|||||||
builder.add_switch_case_edge_group(dispatch_router, cases)
|
builder.add_switch_case_edge_group(dispatch_router, cases)
|
||||||
seat_start = dispatch_seat_entry
|
seat_start = dispatch_seat_entry
|
||||||
|
|
||||||
if execution_mode == "chain" and seat_executors:
|
if quality_gate is not None:
|
||||||
|
# Stage 4: single quality-loop node replaces the seat fan-out; the
|
||||||
|
# quality gate routes ask_user pauses to waiting_input_terminal and
|
||||||
|
# everything else to cross-review.
|
||||||
|
builder.add_edge(seat_start, quality_gate)
|
||||||
|
gate_cases = []
|
||||||
|
if waiting_input_terminal is not None:
|
||||||
|
gate_cases.append(Case(
|
||||||
|
condition=lambda out: isinstance(out, dict) and out.get("decision") == "waiting_input",
|
||||||
|
target=waiting_input_terminal,
|
||||||
|
))
|
||||||
|
gate_cases.append(Default(target=cross_review))
|
||||||
|
builder.add_switch_case_edge_group(quality_gate, gate_cases)
|
||||||
|
elif execution_mode == "chain" and seat_executors:
|
||||||
builder.add_edge(seat_start, seat_executors[0])
|
builder.add_edge(seat_start, seat_executors[0])
|
||||||
for previous, current in zip(seat_executors, seat_executors[1:]):
|
for previous, current in zip(seat_executors, seat_executors[1:]):
|
||||||
builder.add_edge(previous, current)
|
builder.add_edge(previous, current)
|
||||||
@@ -249,6 +272,8 @@ def create_workflow(
|
|||||||
dispatch_router: Any | None = None,
|
dispatch_router: Any | None = None,
|
||||||
dispatch_seat_entry: Any | None = None,
|
dispatch_seat_entry: Any | None = None,
|
||||||
clarification_terminal: Any | None = None,
|
clarification_terminal: Any | None = None,
|
||||||
|
quality_gate: Any | None = None,
|
||||||
|
waiting_input_terminal: Any | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Factory: create the appropriate workflow for the given mode."""
|
"""Factory: create the appropriate workflow for the given mode."""
|
||||||
if mode == "manager_only":
|
if mode == "manager_only":
|
||||||
@@ -290,4 +315,6 @@ def create_workflow(
|
|||||||
dispatch_seat_entry=dispatch_seat_entry,
|
dispatch_seat_entry=dispatch_seat_entry,
|
||||||
manager_answer=manager_answer,
|
manager_answer=manager_answer,
|
||||||
clarification_terminal=clarification_terminal,
|
clarification_terminal=clarification_terminal,
|
||||||
|
quality_gate=quality_gate,
|
||||||
|
waiting_input_terminal=waiting_input_terminal,
|
||||||
)
|
)
|
||||||
@@ -36,8 +36,11 @@ from app.maf_roundtable.workflow_executors import (
|
|||||||
ManagerAnswerExecutor,
|
ManagerAnswerExecutor,
|
||||||
ManagerDispatchExecutor,
|
ManagerDispatchExecutor,
|
||||||
PlannerExecutor,
|
PlannerExecutor,
|
||||||
|
QualityGateExecutor,
|
||||||
|
QualitySeatTasksExecutor,
|
||||||
RoundtableState,
|
RoundtableState,
|
||||||
SeatExecutor,
|
SeatExecutor,
|
||||||
|
WaitingInputTerminalExecutor,
|
||||||
WaitingTerminalExecutor,
|
WaitingTerminalExecutor,
|
||||||
_agent_capabilities_from_snapshot,
|
_agent_capabilities_from_snapshot,
|
||||||
generate_final_report,
|
generate_final_report,
|
||||||
@@ -165,6 +168,7 @@ class WorkflowRunner:
|
|||||||
or candidate_set
|
or candidate_set
|
||||||
or maf_v2_payload.get("dispatch_plan") is not None
|
or maf_v2_payload.get("dispatch_plan") is not None
|
||||||
),
|
),
|
||||||
|
quality_gate_enabled=self._resolve_quality_gate_enabled(maf_v2_payload),
|
||||||
)
|
)
|
||||||
|
|
||||||
if state.dispatch_contract_enabled:
|
if state.dispatch_contract_enabled:
|
||||||
@@ -220,6 +224,54 @@ class WorkflowRunner:
|
|||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
return 600.0, True
|
return 600.0, True
|
||||||
|
|
||||||
|
def _resolve_quality_gate_enabled(self, maf_v2_payload: dict[str, Any]) -> bool:
|
||||||
|
"""Resolve the per-run quality gate flag.
|
||||||
|
|
||||||
|
A boolean frozen in the session's ``maf_v2`` payload wins (sessions
|
||||||
|
created before Stage 4 can opt out explicitly); otherwise the service
|
||||||
|
config default applies.
|
||||||
|
"""
|
||||||
|
payload_flag = maf_v2_payload.get("quality_gate_enabled")
|
||||||
|
if isinstance(payload_flag, bool):
|
||||||
|
return payload_flag
|
||||||
|
return bool(self._quality_settings()["enabled"])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cfg_int(cfg: Any, name: str, default: int) -> int:
|
||||||
|
"""Config int where an explicit 0 is honoured (only None falls back).
|
||||||
|
|
||||||
|
``int(getattr(...) or default)`` would silently turn a deliberate
|
||||||
|
``max_rework_attempts: 0`` back into the default — zero must mean zero.
|
||||||
|
"""
|
||||||
|
value = getattr(cfg, name, None)
|
||||||
|
return default if value is None else int(value)
|
||||||
|
|
||||||
|
def _quality_settings(self) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
from deerflow.config import get_app_config
|
||||||
|
|
||||||
|
cfg = get_app_config().maf_roundtable
|
||||||
|
return {
|
||||||
|
"enabled": bool(getattr(cfg, "quality_gate_enabled", True)),
|
||||||
|
"max_rework_attempts": self._cfg_int(cfg, "max_rework_attempts", 2),
|
||||||
|
"max_reassignments": self._cfg_int(cfg, "max_reassignments", 1),
|
||||||
|
"min_delivery_chars": self._cfg_int(cfg, "min_delivery_chars", 40),
|
||||||
|
"min_delivery_evidence": self._cfg_int(cfg, "min_delivery_evidence", 1),
|
||||||
|
"fallback_policy": str(
|
||||||
|
getattr(cfg, "default_fallback_policy", None)
|
||||||
|
or "reassign_then_gap"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return {
|
||||||
|
"enabled": True,
|
||||||
|
"max_rework_attempts": 2,
|
||||||
|
"max_reassignments": 1,
|
||||||
|
"min_delivery_chars": 40,
|
||||||
|
"min_delivery_evidence": 1,
|
||||||
|
"fallback_policy": "reassign_then_gap",
|
||||||
|
}
|
||||||
|
|
||||||
def _participants_requiring_threads(
|
def _participants_requiring_threads(
|
||||||
self,
|
self,
|
||||||
participants: list[dict[str, Any]],
|
participants: list[dict[str, Any]],
|
||||||
@@ -278,7 +330,14 @@ class WorkflowRunner:
|
|||||||
|
|
||||||
async def _run_manager_dispatch(self, state: RoundtableState) -> None:
|
async def _run_manager_dispatch(self, state: RoundtableState) -> None:
|
||||||
"""Freeze the V2 manager decision before constructing seat topology."""
|
"""Freeze the V2 manager decision before constructing seat topology."""
|
||||||
if state.is_completed("manager_dispatch"):
|
# A completed dispatch is replayed without another model call — except
|
||||||
|
# for clarification checkpoints, which must always re-enter the
|
||||||
|
# clarification recovery branch so a recovered run lands in
|
||||||
|
# waiting_input instead of staying "running" forever.
|
||||||
|
if (
|
||||||
|
state.is_completed("manager_dispatch")
|
||||||
|
and state.intent_type != "clarification"
|
||||||
|
):
|
||||||
return
|
return
|
||||||
workflow = build_manager_dispatch_workflow(
|
workflow = build_manager_dispatch_workflow(
|
||||||
manager_dispatch=ManagerDispatchExecutor(self._adapter, state),
|
manager_dispatch=ManagerDispatchExecutor(self._adapter, state),
|
||||||
@@ -337,6 +396,37 @@ class WorkflowRunner:
|
|||||||
|
|
||||||
state.current_round = max(state.current_round, 1)
|
state.current_round = max(state.current_round, 1)
|
||||||
cross_review = CrossReviewExecutor(self._adapter, state)
|
cross_review = CrossReviewExecutor(self._adapter, state)
|
||||||
|
|
||||||
|
# Stage 4 (R3-Q): the quality seat-task loop + phase gate replace
|
||||||
|
# the plain seat fan-out for V2 structured dispatch.
|
||||||
|
if state.quality_gate_enabled:
|
||||||
|
q = self._quality_settings()
|
||||||
|
quality_seats = QualitySeatTasksExecutor(
|
||||||
|
self._adapter,
|
||||||
|
state,
|
||||||
|
action_coordinator=self._action_coordinator,
|
||||||
|
max_rework_attempts=q["max_rework_attempts"],
|
||||||
|
max_reassignments=q["max_reassignments"],
|
||||||
|
min_delivery_chars=q["min_delivery_chars"],
|
||||||
|
min_delivery_evidence=q["min_delivery_evidence"],
|
||||||
|
fallback_policy=q["fallback_policy"],
|
||||||
|
)
|
||||||
|
quality_gate = QualityGateExecutor(self._adapter, state)
|
||||||
|
waiting_input_terminal = WaitingInputTerminalExecutor()
|
||||||
|
return create_workflow(
|
||||||
|
mode="structured",
|
||||||
|
planner=quality_seats,
|
||||||
|
cross_review=cross_review,
|
||||||
|
seat_executors=[],
|
||||||
|
approval_gate=approval_gate,
|
||||||
|
finalizer=finalizer,
|
||||||
|
waiting_terminal=waiting_terminal,
|
||||||
|
seat_agent_ids=[],
|
||||||
|
execution_mode=execution_mode,
|
||||||
|
quality_gate=quality_gate,
|
||||||
|
waiting_input_terminal=waiting_input_terminal,
|
||||||
|
)
|
||||||
|
|
||||||
return create_workflow(
|
return create_workflow(
|
||||||
mode="structured",
|
mode="structured",
|
||||||
planner=DispatchSeatEntryExecutor(state),
|
planner=DispatchSeatEntryExecutor(state),
|
||||||
|
|||||||
+33
@@ -220,6 +220,39 @@ class MafRoundtableConfig(BaseModel):
|
|||||||
le=60,
|
le=60,
|
||||||
description="后台自动 reconcile 扫描间隔(秒);补偿投影失败的审计事件。",
|
description="后台自动 reconcile 扫描间隔(秒);补偿投影失败的审计事件。",
|
||||||
)
|
)
|
||||||
|
# Stage 4 (R3-Q): seat task quality contracts + manager acceptance gate.
|
||||||
|
quality_gate_enabled: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="V2 结构化派活是否启用席位任务合同与总控验收闸门;关闭时退化为 Stage 3 行为。",
|
||||||
|
)
|
||||||
|
max_rework_attempts: int = Field(
|
||||||
|
default=2,
|
||||||
|
ge=0,
|
||||||
|
le=8,
|
||||||
|
description="同一席位任务最多返工次数(初稿之外);到达上限后按 fallback_policy 升级。",
|
||||||
|
)
|
||||||
|
max_reassignments: int = Field(
|
||||||
|
default=1,
|
||||||
|
ge=0,
|
||||||
|
le=4,
|
||||||
|
description="同一席位任务最多改派次数(备用席位接管);到达上限后受控降级为 controlled_gap。",
|
||||||
|
)
|
||||||
|
min_delivery_chars: int = Field(
|
||||||
|
default=40,
|
||||||
|
ge=0,
|
||||||
|
le=10000,
|
||||||
|
description="席位交付结论(summary)的最小有效长度;低于此值触发 below_min_length 确定性失败。",
|
||||||
|
)
|
||||||
|
min_delivery_evidence: int = Field(
|
||||||
|
default=1,
|
||||||
|
ge=0,
|
||||||
|
le=16,
|
||||||
|
description="席位交付必须提供的最少证据/依据条目;低于此值触发 missing_evidence 确定性失败。",
|
||||||
|
)
|
||||||
|
default_fallback_policy: Literal["reassign_then_gap", "controlled_gap", "ask_user"] = Field(
|
||||||
|
default="reassign_then_gap",
|
||||||
|
description="到达返工/改派上限后的默认降级策略:改派后受控缺口 / 直接受控缺口 / 请求用户澄清。",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@field_validator("default_manager_agent_id", mode="before")
|
@field_validator("default_manager_agent_id", mode="before")
|
||||||
|
|||||||
+62
-1
@@ -31,6 +31,7 @@ from __future__ import annotations
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
CheckConstraint,
|
CheckConstraint,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Integer,
|
Integer,
|
||||||
@@ -368,7 +369,8 @@ class MafRoundtableSeatRunRow(Base):
|
|||||||
# migration / manual SQL honest. Keep in sync with VALID_EXECUTION_STAGES.
|
# migration / manual SQL honest. Keep in sync with VALID_EXECUTION_STAGES.
|
||||||
CheckConstraint(
|
CheckConstraint(
|
||||||
"execution_stage IN ('legacy','planner','expert','format_repair',"
|
"execution_stage IN ('legacy','planner','expert','format_repair',"
|
||||||
"'cross_review','cross_review_repair','finalizer','group_chat')",
|
"'cross_review','cross_review_repair','finalizer','group_chat',"
|
||||||
|
"'rework','manager_review')",
|
||||||
name="ck_maf_seat_runs_execution_stage",
|
name="ck_maf_seat_runs_execution_stage",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -406,6 +408,64 @@ class MafRoundtableSeatRunRow(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 8b. seat tasks (Stage 4 / R3-Q quality state machine) ─────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class MafRoundtableSeatTaskRow(Base):
|
||||||
|
"""One seat task with its frozen contract and quality lifecycle.
|
||||||
|
|
||||||
|
Stage 4 (R3-Q): each manager dispatch assignment becomes one seat task.
|
||||||
|
The row persists the whole acceptance state machine
|
||||||
|
(``assigned → delivering → submitted → manager_review →
|
||||||
|
accepted / rework / reassigned / exhausted / controlled_gap``) together
|
||||||
|
with the frozen ``contract_json``, the latest ``delivery_json``,
|
||||||
|
``deterministic_check_json``, ``review_decision_json`` and a per-attempt
|
||||||
|
``history_json``. When every rework/reassign cap is exhausted the task
|
||||||
|
ends as ``controlled_gap`` with a ``gap_json`` risk record that the final
|
||||||
|
report must carry — it is never disguised as an accepted delivery.
|
||||||
|
|
||||||
|
``task_key`` is stable per (run, original assignment); reassignment only
|
||||||
|
changes ``agent_id`` and never creates a new task.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "maf_roundtable_seat_tasks"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("run_id", "task_key", name="uq_maf_seat_tasks_run_task"),
|
||||||
|
CheckConstraint(
|
||||||
|
"status IN ('assigned','delivering','submitted','manager_review',"
|
||||||
|
"'accepted','rework','reassigned','exhausted','controlled_gap')",
|
||||||
|
name="ck_maf_seat_tasks_status",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
session_id: Mapped[str] = mapped_column(
|
||||||
|
String(64), ForeignKey("maf_roundtable_sessions.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
run_id: Mapped[str] = mapped_column(
|
||||||
|
String(64), ForeignKey("maf_roundtable_runs.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
task_key: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
# Current owning seat (changes on reassignment).
|
||||||
|
agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
original_agent_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="assigned", index=True)
|
||||||
|
required: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
rework_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
reassign_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
contract_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
|
||||||
|
delivery_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
|
||||||
|
deterministic_check_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
|
||||||
|
review_decision_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
|
||||||
|
history_json: Mapped[list | None] = mapped_column(PortableJSON(), nullable=True)
|
||||||
|
gap_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
BeijingDateTime(), nullable=False, default=_now, onupdate=_now
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── 9. action secrets (encrypted actual execution params, server-only) ────
|
# ── 9. action secrets (encrypted actual execution params, server-only) ────
|
||||||
|
|
||||||
|
|
||||||
@@ -576,5 +636,6 @@ __all__ = [
|
|||||||
"MafRoundtableParticipantRow",
|
"MafRoundtableParticipantRow",
|
||||||
"MafRoundtableRunRow",
|
"MafRoundtableRunRow",
|
||||||
"MafRoundtableSeatRunRow",
|
"MafRoundtableSeatRunRow",
|
||||||
|
"MafRoundtableSeatTaskRow",
|
||||||
"MafRoundtableSessionRow",
|
"MafRoundtableSessionRow",
|
||||||
]
|
]
|
||||||
-2
@@ -144,8 +144,6 @@ class MafParticipantRepository:
|
|||||||
candidates: list[dict[str, Any]],
|
candidates: list[dict[str, Any]],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Execute a conflict-safe INSERT for the given candidate rows."""
|
"""Execute a conflict-safe INSERT for the given candidate rows."""
|
||||||
from sqlalchemy import insert as sa_insert
|
|
||||||
|
|
||||||
now = _now()
|
now = _now()
|
||||||
rows = [
|
rows = [
|
||||||
{
|
{
|
||||||
|
|||||||
+7
@@ -36,6 +36,11 @@ CROSS_REVIEW = "cross_review"
|
|||||||
CROSS_REVIEW_REPAIR = "cross_review_repair"
|
CROSS_REVIEW_REPAIR = "cross_review_repair"
|
||||||
FINALIZER = "finalizer"
|
FINALIZER = "finalizer"
|
||||||
GROUP_CHAT = "group_chat"
|
GROUP_CHAT = "group_chat"
|
||||||
|
# Stage 4 (R3-Q) quality-loop stages. Rework deliveries run at an advanced
|
||||||
|
# business round (one round per quality iteration), so the four-tuple
|
||||||
|
# (run, agent, turn, stage) stays unique across attempts.
|
||||||
|
REWORK = "rework"
|
||||||
|
MANAGER_REVIEW = "manager_review"
|
||||||
|
|
||||||
VALID_EXECUTION_STAGES: frozenset[str] = frozenset({
|
VALID_EXECUTION_STAGES: frozenset[str] = frozenset({
|
||||||
LEGACY,
|
LEGACY,
|
||||||
@@ -46,6 +51,8 @@ VALID_EXECUTION_STAGES: frozenset[str] = frozenset({
|
|||||||
CROSS_REVIEW_REPAIR,
|
CROSS_REVIEW_REPAIR,
|
||||||
FINALIZER,
|
FINALIZER,
|
||||||
GROUP_CHAT,
|
GROUP_CHAT,
|
||||||
|
REWORK,
|
||||||
|
MANAGER_REVIEW,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+232
@@ -0,0 +1,232 @@
|
|||||||
|
"""Seat task repository — Stage 4 (R3-Q) quality state machine persistence.
|
||||||
|
|
||||||
|
Each manager dispatch assignment becomes one seat task row. The row carries the
|
||||||
|
frozen ``contract_json`` and the full acceptance lifecycle (delivery, deterministic
|
||||||
|
check, manager review decision, rework / reassign counts, controlled-gap record).
|
||||||
|
|
||||||
|
``task_key`` is stable per (run, original assignment); reassignment only mutates
|
||||||
|
``agent_id`` and never creates a new row, so the (run_id, task_key) unique
|
||||||
|
constraint keeps a task single even across backup-seat handovers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
|
from deerflow.persistence.maf_roundtable.model import MafRoundtableSeatTaskRow
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_dict(row: MafRoundtableSeatTaskRow) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"session_id": row.session_id,
|
||||||
|
"run_id": row.run_id,
|
||||||
|
"task_key": row.task_key,
|
||||||
|
"agent_id": row.agent_id,
|
||||||
|
"original_agent_id": row.original_agent_id,
|
||||||
|
"status": row.status,
|
||||||
|
"required": bool(row.required),
|
||||||
|
"attempt_count": row.attempt_count,
|
||||||
|
"rework_count": row.rework_count,
|
||||||
|
"reassign_count": row.reassign_count,
|
||||||
|
"contract": row.contract_json,
|
||||||
|
"delivery": row.delivery_json,
|
||||||
|
"deterministic_check": row.deterministic_check_json,
|
||||||
|
"review_decision": row.review_decision_json,
|
||||||
|
"history": row.history_json or [],
|
||||||
|
"gap": row.gap_json,
|
||||||
|
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
|
||||||
|
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Column map for partial updates (public field name → ORM column).
|
||||||
|
_FIELD_MAP = {
|
||||||
|
"agent_id": "agent_id",
|
||||||
|
"status": "status",
|
||||||
|
"required": "required",
|
||||||
|
"attempt_count": "attempt_count",
|
||||||
|
"rework_count": "rework_count",
|
||||||
|
"reassign_count": "reassign_count",
|
||||||
|
"contract": "contract_json",
|
||||||
|
"delivery": "delivery_json",
|
||||||
|
"deterministic_check": "deterministic_check_json",
|
||||||
|
"review_decision": "review_decision_json",
|
||||||
|
"history": "history_json",
|
||||||
|
"gap": "gap_json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MafSeatTaskRepository:
|
||||||
|
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||||
|
self._sf = session_factory
|
||||||
|
|
||||||
|
async def create_task(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session_id: str,
|
||||||
|
run_id: str,
|
||||||
|
task_key: str,
|
||||||
|
agent_id: str,
|
||||||
|
contract: dict[str, Any],
|
||||||
|
required: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a single seat task in ``assigned`` status with its frozen
|
||||||
|
contract. Prefer :meth:`get_or_create_tasks` for run initialization —
|
||||||
|
it is atomic across the whole dispatch assignment set."""
|
||||||
|
row = self._new_row(
|
||||||
|
session_id=session_id,
|
||||||
|
run_id=run_id,
|
||||||
|
task_key=task_key,
|
||||||
|
agent_id=agent_id,
|
||||||
|
contract=contract,
|
||||||
|
required=required,
|
||||||
|
)
|
||||||
|
async with self._sf() as session:
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
return _to_dict(row)
|
||||||
|
|
||||||
|
def _new_row(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session_id: str,
|
||||||
|
run_id: str,
|
||||||
|
task_key: str,
|
||||||
|
agent_id: str,
|
||||||
|
contract: dict[str, Any],
|
||||||
|
required: bool,
|
||||||
|
) -> MafRoundtableSeatTaskRow:
|
||||||
|
now = _now()
|
||||||
|
return MafRoundtableSeatTaskRow(
|
||||||
|
id=uuid4().hex,
|
||||||
|
session_id=session_id,
|
||||||
|
run_id=run_id,
|
||||||
|
task_key=task_key,
|
||||||
|
agent_id=agent_id,
|
||||||
|
original_agent_id=agent_id,
|
||||||
|
status="assigned",
|
||||||
|
required=required,
|
||||||
|
attempt_count=0,
|
||||||
|
rework_count=0,
|
||||||
|
reassign_count=0,
|
||||||
|
contract_json=contract,
|
||||||
|
history_json=[],
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_or_create_tasks(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session_id: str,
|
||||||
|
run_id: str,
|
||||||
|
expected: list[dict[str, Any]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Return the COMPLETE task set for a run (P0-4 atomic init/recovery).
|
||||||
|
|
||||||
|
``expected`` items: ``{task_key, agent_id, contract, required}`` — the
|
||||||
|
server-computed task set derived from the dispatch assignments.
|
||||||
|
|
||||||
|
Semantics:
|
||||||
|
- existing rows are preserved untouched (their frozen contract, counts
|
||||||
|
and status are NEVER overwritten);
|
||||||
|
- missing ``task_key`` rows are created;
|
||||||
|
- the full set is returned, ordered by ``task_key``;
|
||||||
|
- concurrent recovery calls never surface a unique-key violation: on a
|
||||||
|
(run_id, task_key) race the loser retries and reads the winner's rows.
|
||||||
|
"""
|
||||||
|
expected_items = [
|
||||||
|
item for item in (expected or [])
|
||||||
|
if str(item.get("task_key") or "") and str(item.get("agent_id") or "")
|
||||||
|
]
|
||||||
|
for _attempt in range(3):
|
||||||
|
try:
|
||||||
|
async with self._sf() as session:
|
||||||
|
async with session.begin():
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(MafRoundtableSeatTaskRow).where(
|
||||||
|
MafRoundtableSeatTaskRow.run_id == run_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
existing_keys = {r.task_key for r in rows}
|
||||||
|
for item in expected_items:
|
||||||
|
if item["task_key"] in existing_keys:
|
||||||
|
continue
|
||||||
|
session.add(self._new_row(
|
||||||
|
session_id=session_id,
|
||||||
|
run_id=run_id,
|
||||||
|
task_key=str(item["task_key"]),
|
||||||
|
agent_id=str(item["agent_id"]),
|
||||||
|
contract=dict(item.get("contract") or {}),
|
||||||
|
required=bool(item.get("required", True)),
|
||||||
|
))
|
||||||
|
break # commit succeeded
|
||||||
|
except IntegrityError:
|
||||||
|
# A concurrent recovery created the same task(s) first. Retry:
|
||||||
|
# the re-read finds them and skips creation.
|
||||||
|
logger.info(
|
||||||
|
"seat task init race for run %s — retrying (attempt %d)",
|
||||||
|
run_id, _attempt + 1,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
return await self.list_by_run(run_id)
|
||||||
|
|
||||||
|
async def get_task(self, task_id: str) -> dict[str, Any] | None:
|
||||||
|
async with self._sf() as session:
|
||||||
|
stmt = select(MafRoundtableSeatTaskRow).where(
|
||||||
|
MafRoundtableSeatTaskRow.id == task_id
|
||||||
|
).limit(1)
|
||||||
|
row = (await session.execute(stmt)).scalars().first()
|
||||||
|
return _to_dict(row) if row is not None else None
|
||||||
|
|
||||||
|
async def get_by_run_task(self, run_id: str, task_key: str) -> dict[str, Any] | None:
|
||||||
|
async with self._sf() as session:
|
||||||
|
stmt = select(MafRoundtableSeatTaskRow).where(
|
||||||
|
MafRoundtableSeatTaskRow.run_id == run_id,
|
||||||
|
MafRoundtableSeatTaskRow.task_key == task_key,
|
||||||
|
).limit(1)
|
||||||
|
row = (await session.execute(stmt)).scalars().first()
|
||||||
|
return _to_dict(row) if row is not None else None
|
||||||
|
|
||||||
|
async def list_by_run(self, run_id: str) -> list[dict[str, Any]]:
|
||||||
|
async with self._sf() as session:
|
||||||
|
stmt = (
|
||||||
|
select(MafRoundtableSeatTaskRow)
|
||||||
|
.where(MafRoundtableSeatTaskRow.run_id == run_id)
|
||||||
|
.order_by(MafRoundtableSeatTaskRow.task_key.asc())
|
||||||
|
)
|
||||||
|
rows = (await session.execute(stmt)).scalars().all()
|
||||||
|
return [_to_dict(r) for r in rows]
|
||||||
|
|
||||||
|
async def update_task(self, task_id: str, fields: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
"""Partial update using the public field names (see ``_FIELD_MAP``)."""
|
||||||
|
async with self._sf() as session:
|
||||||
|
stmt = select(MafRoundtableSeatTaskRow).where(
|
||||||
|
MafRoundtableSeatTaskRow.id == task_id
|
||||||
|
).limit(1)
|
||||||
|
row = (await session.execute(stmt)).scalars().first()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
for key, value in fields.items():
|
||||||
|
col = _FIELD_MAP.get(key)
|
||||||
|
if col is not None:
|
||||||
|
setattr(row, col, value)
|
||||||
|
row.updated_at = _now()
|
||||||
|
await session.commit()
|
||||||
|
return _to_dict(row)
|
||||||
+2
@@ -49,6 +49,7 @@ from deerflow.persistence.maf_roundtable.model import (
|
|||||||
from deerflow.persistence.maf_roundtable.participants import MafParticipantRepository
|
from deerflow.persistence.maf_roundtable.participants import MafParticipantRepository
|
||||||
from deerflow.persistence.maf_roundtable.runs import MafRunRepository, run_to_dict
|
from deerflow.persistence.maf_roundtable.runs import MafRunRepository, run_to_dict
|
||||||
from deerflow.persistence.maf_roundtable.seat_runs import MafSeatRunRepository
|
from deerflow.persistence.maf_roundtable.seat_runs import MafSeatRunRepository
|
||||||
|
from deerflow.persistence.maf_roundtable.seat_tasks import MafSeatTaskRepository
|
||||||
from deerflow.persistence.maf_roundtable.sessions import MafSessionRepository
|
from deerflow.persistence.maf_roundtable.sessions import MafSessionRepository
|
||||||
|
|
||||||
# Session statuses that mean a run is in flight — the CAS claim refuses these.
|
# Session statuses that mean a run is in flight — the CAS claim refuses these.
|
||||||
@@ -80,6 +81,7 @@ class MafRoundtableStore:
|
|||||||
self.actions = MafActionRepository(session_factory)
|
self.actions = MafActionRepository(session_factory)
|
||||||
self.approvals = MafApprovalRepository(session_factory)
|
self.approvals = MafApprovalRepository(session_factory)
|
||||||
self.seat_runs = MafSeatRunRepository(session_factory)
|
self.seat_runs = MafSeatRunRepository(session_factory)
|
||||||
|
self.seat_tasks = MafSeatTaskRepository(session_factory)
|
||||||
self.action_secrets = MafActionSecretRepository(session_factory)
|
self.action_secrets = MafActionSecretRepository(session_factory)
|
||||||
# V2 conversation aggregation (user-visible layer).
|
# V2 conversation aggregation (user-visible layer).
|
||||||
self.conversations = MafConversationRepository(session_factory)
|
self.conversations = MafConversationRepository(session_factory)
|
||||||
|
|||||||
+2
@@ -53,6 +53,7 @@ from deerflow.persistence.maf_roundtable.model import (
|
|||||||
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
|
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
|
||||||
from deerflow.persistence.menu_overrides.model import MenuOverrideRow
|
from deerflow.persistence.menu_overrides.model import MenuOverrideRow
|
||||||
from deerflow.persistence.models.run_event import RunEventRow
|
from deerflow.persistence.models.run_event import RunEventRow
|
||||||
|
from deerflow.persistence.notification_templates.model import NotificationTemplateRow
|
||||||
from deerflow.persistence.notifications.model import NotificationRow
|
from deerflow.persistence.notifications.model import NotificationRow
|
||||||
from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow
|
from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow
|
||||||
from deerflow.persistence.position_roles.model import PositionRoleRow
|
from deerflow.persistence.position_roles.model import PositionRoleRow
|
||||||
@@ -124,6 +125,7 @@ __all__ = [
|
|||||||
"MafRoundtableSessionRow",
|
"MafRoundtableSessionRow",
|
||||||
"MenuOverrideRow",
|
"MenuOverrideRow",
|
||||||
"NotificationRow",
|
"NotificationRow",
|
||||||
|
"NotificationTemplateRow",
|
||||||
"ParallelAgentTaskRow",
|
"ParallelAgentTaskRow",
|
||||||
"PositionRoundtableNodeRow",
|
"PositionRoundtableNodeRow",
|
||||||
"PositionRoundtableSessionRow",
|
"PositionRoundtableSessionRow",
|
||||||
|
|||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
"""Notification template persistence (通知模板管理)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from deerflow.persistence.notification_templates.base import NotificationTemplateStore
|
||||||
|
from deerflow.persistence.notification_templates.memory import MemoryNotificationTemplateStore
|
||||||
|
from deerflow.persistence.notification_templates.model import NotificationTemplateRow
|
||||||
|
from deerflow.persistence.notification_templates.sql import NotificationTemplateRepository
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MemoryNotificationTemplateStore",
|
||||||
|
"NotificationTemplateRepository",
|
||||||
|
"NotificationTemplateRow",
|
||||||
|
"NotificationTemplateStore",
|
||||||
|
"make_notification_template_store",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def make_notification_template_store(session_factory):
|
||||||
|
if session_factory is None:
|
||||||
|
return MemoryNotificationTemplateStore()
|
||||||
|
return NotificationTemplateRepository(session_factory)
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
"""Abstract notification template persistence API."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTemplateStore(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def list_templates(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
keyword: str | None = None,
|
||||||
|
enabled: bool | None = None,
|
||||||
|
tag: str | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_template(self, template_id: str) -> dict[str, Any] | None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def create_template(self, data: dict[str, Any], *, created_by: str | None = None) -> dict[str, Any]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def update_template(
|
||||||
|
self, template_id: str, data: dict[str, Any], *, expected_version: int, updated_by: str | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def delete_template(self, template_id: str) -> bool:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def duplicate_template(self, template_id: str, *, created_by: str | None = None) -> dict[str, Any]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def name_exists(self, name: str, exclude_id: str | None = None) -> bool:
|
||||||
|
raise NotImplementedError
|
||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
"""In-memory notification template store (used when no DB is configured / tests)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from deerflow.persistence.notification_templates.base import NotificationTemplateStore
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(data: dict[str, Any], *, created_by: str | None = None, updated_by: str | None = None) -> dict[str, Any]:
|
||||||
|
now = datetime.now(UTC).isoformat()
|
||||||
|
return {
|
||||||
|
"id": str(data.get("id") or uuid.uuid4().hex),
|
||||||
|
"name": str(data["name"]),
|
||||||
|
"title_template": str(data.get("title_template") or ""),
|
||||||
|
"content_template": str(data.get("content_template") or ""),
|
||||||
|
"tags": list(data.get("tags") or []),
|
||||||
|
"enabled": bool(data.get("enabled", True)),
|
||||||
|
"version": int(data.get("version", 1)),
|
||||||
|
"created_by": data.get("created_by") or created_by,
|
||||||
|
"updated_by": updated_by or data.get("updated_by") or created_by,
|
||||||
|
"created_at": data.get("created_at") or now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryNotificationTemplateStore(NotificationTemplateStore):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._rows: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
async def list_templates(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
keyword: str | None = None,
|
||||||
|
enabled: bool | None = None,
|
||||||
|
tag: str | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
items = list(self._rows.values())
|
||||||
|
|
||||||
|
if keyword:
|
||||||
|
kw = keyword.lower()
|
||||||
|
items = [
|
||||||
|
r for r in items
|
||||||
|
if kw in r["name"].lower() or kw in r["title_template"].lower() or kw in r["content_template"].lower()
|
||||||
|
]
|
||||||
|
|
||||||
|
if enabled is not None:
|
||||||
|
items = [r for r in items if r["enabled"] == enabled]
|
||||||
|
|
||||||
|
if tag:
|
||||||
|
items = [r for r in items if tag in r.get("tags", [])]
|
||||||
|
|
||||||
|
items.sort(key=lambda r: r.get("created_at", ""), reverse=True)
|
||||||
|
total = len(items)
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
page_items = items[offset : offset + page_size]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"items": [dict(r) for r in page_items],
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_template(self, template_id: str) -> dict[str, Any] | None:
|
||||||
|
row = self._rows.get(template_id)
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
async def create_template(self, data: dict[str, Any], *, created_by: str | None = None) -> dict[str, Any]:
|
||||||
|
row = _normalize(data, created_by=created_by)
|
||||||
|
self._rows[row["id"]] = row
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
async def update_template(
|
||||||
|
self, template_id: str, data: dict[str, Any], *, expected_version: int, updated_by: str | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
row = self._rows.get(template_id)
|
||||||
|
if row is None:
|
||||||
|
raise ValueError("NOT_FOUND")
|
||||||
|
if row["version"] != expected_version:
|
||||||
|
raise ValueError("VERSION_CONFLICT")
|
||||||
|
for key in ("name", "title_template", "content_template", "tags", "enabled"):
|
||||||
|
if key in data:
|
||||||
|
row[key] = data[key]
|
||||||
|
row["version"] = row["version"] + 1
|
||||||
|
row["updated_by"] = updated_by
|
||||||
|
row["updated_at"] = datetime.now(UTC).isoformat()
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
async def delete_template(self, template_id: str) -> bool:
|
||||||
|
if template_id in self._rows:
|
||||||
|
del self._rows[template_id]
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def duplicate_template(self, template_id: str, *, created_by: str | None = None) -> dict[str, Any]:
|
||||||
|
row = self._rows.get(template_id)
|
||||||
|
if row is None:
|
||||||
|
raise ValueError("NOT_FOUND")
|
||||||
|
base_name = f"{row['name']} - 副本"
|
||||||
|
new_name = base_name
|
||||||
|
counter = 1
|
||||||
|
existing_names = {r["name"] for r in self._rows.values()}
|
||||||
|
while new_name in existing_names:
|
||||||
|
counter += 1
|
||||||
|
new_name = f"{base_name} {counter}"
|
||||||
|
|
||||||
|
new_row = _normalize(
|
||||||
|
{
|
||||||
|
"name": new_name,
|
||||||
|
"title_template": row["title_template"],
|
||||||
|
"content_template": row["content_template"],
|
||||||
|
"tags": list(row.get("tags", [])),
|
||||||
|
"enabled": row["enabled"],
|
||||||
|
},
|
||||||
|
created_by=created_by,
|
||||||
|
)
|
||||||
|
self._rows[new_row["id"]] = new_row
|
||||||
|
return dict(new_row)
|
||||||
|
|
||||||
|
async def name_exists(self, name: str, exclude_id: str | None = None) -> bool:
|
||||||
|
for rid, r in self._rows.items():
|
||||||
|
if r["name"] == name and (exclude_id is None or rid != exclude_id):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
"""ORM model for notification templates."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Integer, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from deerflow.persistence.base import Base
|
||||||
|
from deerflow.persistence.types import BeijingDateTime, PortableJSON
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTemplateRow(Base):
|
||||||
|
__tablename__ = "notification_templates"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
||||||
|
title_template: Mapped[str] = mapped_column(String(240), default="")
|
||||||
|
content_template: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
tags: Mapped[list] = mapped_column(PortableJSON(), default=list)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
version: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
|
created_by: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
updated_by: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
BeijingDateTime(), default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
+203
@@ -0,0 +1,203 @@
|
|||||||
|
"""SQLAlchemy-backed notification template store."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import delete, func, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
|
from deerflow.persistence.notification_templates.base import NotificationTemplateStore
|
||||||
|
from deerflow.persistence.notification_templates.model import NotificationTemplateRow
|
||||||
|
|
||||||
|
|
||||||
|
def _to_dict(row: NotificationTemplateRow) -> dict[str, Any]:
|
||||||
|
data = row.to_dict()
|
||||||
|
for key in ("created_at", "updated_at"):
|
||||||
|
value = data.get(key)
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
data[key] = value.isoformat()
|
||||||
|
data["tags"] = data.get("tags") or []
|
||||||
|
data["enabled"] = bool(data.get("enabled", True))
|
||||||
|
data["version"] = int(data.get("version", 1))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTemplateRepository(NotificationTemplateStore):
|
||||||
|
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||||
|
self._sf = session_factory
|
||||||
|
|
||||||
|
async def list_templates(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
keyword: str | None = None,
|
||||||
|
enabled: bool | None = None,
|
||||||
|
tag: str | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
stmt = select(NotificationTemplateRow)
|
||||||
|
count_stmt = select(func.count()).select_from(NotificationTemplateRow)
|
||||||
|
|
||||||
|
if keyword:
|
||||||
|
kw = f"%{keyword}%"
|
||||||
|
filt = or_(
|
||||||
|
NotificationTemplateRow.name.ilike(kw),
|
||||||
|
NotificationTemplateRow.title_template.ilike(kw),
|
||||||
|
NotificationTemplateRow.content_template.ilike(kw),
|
||||||
|
)
|
||||||
|
stmt = stmt.where(filt)
|
||||||
|
count_stmt = count_stmt.where(filt)
|
||||||
|
|
||||||
|
if enabled is not None:
|
||||||
|
stmt = stmt.where(NotificationTemplateRow.enabled == enabled)
|
||||||
|
count_stmt = count_stmt.where(NotificationTemplateRow.enabled == enabled)
|
||||||
|
|
||||||
|
if tag:
|
||||||
|
all_rows_stmt = stmt
|
||||||
|
all_rows_count_stmt = count_stmt
|
||||||
|
# Filter by tag in JSON array — load all and filter in Python for portability
|
||||||
|
stmt = select(NotificationTemplateRow)
|
||||||
|
count_stmt = select(func.count()).select_from(NotificationTemplateRow)
|
||||||
|
if keyword:
|
||||||
|
kw = f"%{keyword}%"
|
||||||
|
filt = or_(
|
||||||
|
NotificationTemplateRow.name.ilike(kw),
|
||||||
|
NotificationTemplateRow.title_template.ilike(kw),
|
||||||
|
NotificationTemplateRow.content_template.ilike(kw),
|
||||||
|
)
|
||||||
|
stmt = stmt.where(filt)
|
||||||
|
count_stmt = count_stmt.where(filt)
|
||||||
|
if enabled is not None:
|
||||||
|
stmt = stmt.where(NotificationTemplateRow.enabled == enabled)
|
||||||
|
count_stmt = count_stmt.where(enabled == NotificationTemplateRow.enabled)
|
||||||
|
|
||||||
|
async with self._sf() as session:
|
||||||
|
result = await session.execute(stmt.order_by(NotificationTemplateRow.created_at.desc()))
|
||||||
|
all_rows = list(result.scalars())
|
||||||
|
filtered = [r for r in all_rows if tag in (r.tags or [])]
|
||||||
|
total = len(filtered)
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
page_rows = filtered[offset : offset + page_size]
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"items": [_to_dict(r) for r in page_rows],
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
async with self._sf() as session:
|
||||||
|
total_result = await session.execute(count_stmt)
|
||||||
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
stmt = stmt.order_by(NotificationTemplateRow.created_at.desc()).offset(offset).limit(page_size)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
rows = list(result.scalars())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"items": [_to_dict(r) for r in rows],
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_template(self, template_id: str) -> dict[str, Any] | None:
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = await session.get(NotificationTemplateRow, template_id)
|
||||||
|
return _to_dict(row) if row else None
|
||||||
|
|
||||||
|
async def create_template(self, data: dict[str, Any], *, created_by: str | None = None) -> dict[str, Any]:
|
||||||
|
row = NotificationTemplateRow(
|
||||||
|
id=str(data.get("id") or uuid.uuid4().hex),
|
||||||
|
name=str(data["name"]),
|
||||||
|
title_template=str(data.get("title_template") or ""),
|
||||||
|
content_template=str(data.get("content_template") or ""),
|
||||||
|
tags=list(data.get("tags") or []),
|
||||||
|
enabled=bool(data.get("enabled", True)),
|
||||||
|
version=1,
|
||||||
|
created_by=created_by,
|
||||||
|
updated_by=created_by,
|
||||||
|
)
|
||||||
|
async with self._sf() as session:
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return _to_dict(row)
|
||||||
|
|
||||||
|
async def update_template(
|
||||||
|
self, template_id: str, data: dict[str, Any], *, expected_version: int, updated_by: str | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = await session.get(NotificationTemplateRow, template_id)
|
||||||
|
if row is None:
|
||||||
|
raise ValueError("NOT_FOUND")
|
||||||
|
if row.version != expected_version:
|
||||||
|
raise ValueError("VERSION_CONFLICT")
|
||||||
|
if "name" in data:
|
||||||
|
row.name = str(data["name"])
|
||||||
|
if "title_template" in data:
|
||||||
|
row.title_template = str(data["title_template"])
|
||||||
|
if "content_template" in data:
|
||||||
|
row.content_template = str(data["content_template"])
|
||||||
|
if "tags" in data:
|
||||||
|
row.tags = list(data["tags"])
|
||||||
|
if "enabled" in data:
|
||||||
|
row.enabled = bool(data["enabled"])
|
||||||
|
row.version = row.version + 1
|
||||||
|
row.updated_by = updated_by
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return _to_dict(row)
|
||||||
|
|
||||||
|
async def delete_template(self, template_id: str) -> bool:
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = await session.get(NotificationTemplateRow, template_id)
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
await session.delete(row)
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def duplicate_template(self, template_id: str, *, created_by: str | None = None) -> dict[str, Any]:
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = await session.get(NotificationTemplateRow, template_id)
|
||||||
|
if row is None:
|
||||||
|
raise ValueError("NOT_FOUND")
|
||||||
|
base_name = f"{row.name} - 副本"
|
||||||
|
new_name = base_name
|
||||||
|
counter = 1
|
||||||
|
while True:
|
||||||
|
existing = await session.execute(
|
||||||
|
select(NotificationTemplateRow).where(NotificationTemplateRow.name == new_name)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none() is None:
|
||||||
|
break
|
||||||
|
counter += 1
|
||||||
|
new_name = f"{base_name} {counter}"
|
||||||
|
|
||||||
|
new_row = NotificationTemplateRow(
|
||||||
|
id=uuid.uuid4().hex,
|
||||||
|
name=new_name,
|
||||||
|
title_template=row.title_template,
|
||||||
|
content_template=row.content_template,
|
||||||
|
tags=list(row.tags or []),
|
||||||
|
enabled=row.enabled,
|
||||||
|
version=1,
|
||||||
|
created_by=created_by,
|
||||||
|
updated_by=created_by,
|
||||||
|
)
|
||||||
|
session.add(new_row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(new_row)
|
||||||
|
return _to_dict(new_row)
|
||||||
|
|
||||||
|
async def name_exists(self, name: str, exclude_id: str | None = None) -> bool:
|
||||||
|
async with self._sf() as session:
|
||||||
|
stmt = select(NotificationTemplateRow).where(NotificationTemplateRow.name == name)
|
||||||
|
if exclude_id:
|
||||||
|
stmt = stmt.where(NotificationTemplateRow.id != exclude_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none() is not None
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
"""Stage 4 migration for the MAF roundtable experiment (R3-Q quality gate).
|
||||||
|
|
||||||
|
Idempotent migration that brings a Stage-3 deployment up to Stage-4 schema:
|
||||||
|
|
||||||
|
1. Create the ``maf_roundtable_seat_tasks`` table (seat task contracts +
|
||||||
|
quality state machine rows) with its unique (run_id, task_key) constraint
|
||||||
|
and closed status CHECK constraint.
|
||||||
|
2. Extend the ``maf_roundtable_seat_runs.execution_stage`` CHECK constraint to
|
||||||
|
allow the two new Stage-4 stages ``rework`` and ``manager_review``.
|
||||||
|
|
||||||
|
Dialect handling
|
||||||
|
----------------
|
||||||
|
- **PostgreSQL / MySQL**: ``CREATE TABLE IF NOT EXISTS`` + ``ALTER TABLE`` to
|
||||||
|
swap the execution_stage CHECK. Each step is guarded by introspection so
|
||||||
|
re-runs are no-ops.
|
||||||
|
- **SQLite**: a CHECK constraint cannot be dropped in place, so the seat_runs
|
||||||
|
table is rebuilt (same recipe as the Phase-3 migration): a temp table with
|
||||||
|
the widened CHECK is created, data is copied as-is, the old table is
|
||||||
|
dropped and the temp renamed. Fully idempotent — the rebuild only runs when
|
||||||
|
the widened CHECK is absent.
|
||||||
|
|
||||||
|
**Deployment order**: run AFTER the Phase-3 migration and BEFORE enabling the
|
||||||
|
Stage-4 code path.
|
||||||
|
|
||||||
|
Usage (from ``offline-backend-20260512/backend/``)::
|
||||||
|
|
||||||
|
# Preview (no writes)
|
||||||
|
PYTHONPATH=. uv run python scripts/migrate_maf_roundtable_stage4.py --dry-run
|
||||||
|
|
||||||
|
# Execute
|
||||||
|
PYTHONPATH=. uv run python scripts/migrate_maf_roundtable_stage4.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||||
|
logger = logging.getLogger("migrate_maf_roundtable_stage4")
|
||||||
|
|
||||||
|
SEAT_TASKS_TABLE = "maf_roundtable_seat_tasks"
|
||||||
|
SEAT_RUNS_TABLE = "maf_roundtable_seat_runs"
|
||||||
|
EXECUTION_STAGE_CHECK_NAME = "ck_maf_seat_runs_execution_stage"
|
||||||
|
SEAT_TASKS_STATUS_CHECK_NAME = "ck_maf_seat_tasks_status"
|
||||||
|
STAGE4_STAGES = "'legacy','planner','expert','format_repair'," \
|
||||||
|
"'cross_review','cross_review_repair','finalizer','group_chat'," \
|
||||||
|
"'rework','manager_review'"
|
||||||
|
# The two stages Stage 4 adds on top of the Stage-3 enum. A constraint that
|
||||||
|
# does not contain BOTH of them is still a Stage-3 constraint, even when it
|
||||||
|
# carries the same constraint name.
|
||||||
|
STAGE4_NEW_STAGES = ("rework", "manager_review")
|
||||||
|
SEAT_TASKS_STATUSES = (
|
||||||
|
"assigned", "delivering", "submitted", "manager_review",
|
||||||
|
"accepted", "rework", "reassigned", "exhausted", "controlled_gap",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _dialect_name(conn_or_dialect: Any) -> str:
|
||||||
|
return str(getattr(getattr(conn_or_dialect, "dialect", conn_or_dialect), "name", "sqlite")).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _timestamp_sql(dialect: str) -> str:
|
||||||
|
if dialect == "postgresql":
|
||||||
|
return "TIMESTAMP WITH TIME ZONE"
|
||||||
|
if dialect in {"mysql", "mariadb"}:
|
||||||
|
return "DATETIME(6)"
|
||||||
|
return "DATETIME"
|
||||||
|
|
||||||
|
|
||||||
|
def _json_sql(dialect: str) -> str:
|
||||||
|
if dialect == "postgresql":
|
||||||
|
return "JSONB"
|
||||||
|
if dialect in {"mysql", "mariadb"}:
|
||||||
|
return "JSON"
|
||||||
|
return "TEXT"
|
||||||
|
|
||||||
|
|
||||||
|
def _boolean_default_sql(dialect: str) -> str:
|
||||||
|
"""Dialect-correct literal for a BOOLEAN column default.
|
||||||
|
|
||||||
|
PostgreSQL has a real BOOLEAN type whose literal is ``TRUE`` — ``DEFAULT 1``
|
||||||
|
is rejected there. SQLite/MySQL store booleans as integers.
|
||||||
|
"""
|
||||||
|
if dialect == "postgresql":
|
||||||
|
return "TRUE"
|
||||||
|
return "1"
|
||||||
|
|
||||||
|
|
||||||
|
def _seat_tasks_ddl(dialect: str) -> str:
|
||||||
|
ts = _timestamp_sql(dialect)
|
||||||
|
js = _json_sql(dialect)
|
||||||
|
bool_default = _boolean_default_sql(dialect)
|
||||||
|
return f"""
|
||||||
|
CREATE TABLE {SEAT_TASKS_TABLE} (
|
||||||
|
id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||||
|
session_id VARCHAR(64) NOT NULL,
|
||||||
|
run_id VARCHAR(64) NOT NULL,
|
||||||
|
task_key VARCHAR(128) NOT NULL,
|
||||||
|
agent_id VARCHAR(64) NOT NULL,
|
||||||
|
original_agent_id VARCHAR(64) NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'assigned',
|
||||||
|
required BOOLEAN NOT NULL DEFAULT {bool_default},
|
||||||
|
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
rework_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
reassign_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
contract_json {js},
|
||||||
|
delivery_json {js},
|
||||||
|
deterministic_check_json {js},
|
||||||
|
review_decision_json {js},
|
||||||
|
history_json {js},
|
||||||
|
gap_json {js},
|
||||||
|
created_at {ts} NOT NULL,
|
||||||
|
updated_at {ts} NOT NULL,
|
||||||
|
CONSTRAINT uq_maf_seat_tasks_run_task UNIQUE (run_id, task_key),
|
||||||
|
CONSTRAINT ck_maf_seat_tasks_status CHECK (
|
||||||
|
status IN ('assigned','delivering','submitted','manager_review',
|
||||||
|
'accepted','rework','reassigned','exhausted','controlled_gap')
|
||||||
|
),
|
||||||
|
FOREIGN KEY (session_id) REFERENCES maf_roundtable_sessions (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (run_id) REFERENCES maf_roundtable_runs (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _seat_tasks_indexes_ddl(dialect: str) -> list[str]:
|
||||||
|
return [
|
||||||
|
f"CREATE INDEX ix_maf_seat_tasks_session ON {SEAT_TASKS_TABLE} (session_id)",
|
||||||
|
f"CREATE INDEX ix_maf_seat_tasks_run ON {SEAT_TASKS_TABLE} (run_id)",
|
||||||
|
f"CREATE INDEX ix_maf_seat_tasks_agent ON {SEAT_TASKS_TABLE} (agent_id)",
|
||||||
|
f"CREATE INDEX ix_maf_seat_tasks_status ON {SEAT_TASKS_TABLE} (status)",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _ddl_add_execution_stage_check(dialect: str) -> str:
|
||||||
|
if dialect in {"mysql", "mariadb"}:
|
||||||
|
return (
|
||||||
|
f"ALTER TABLE {SEAT_RUNS_TABLE} "
|
||||||
|
f"ADD CONSTRAINT ck_maf_seat_runs_execution_stage "
|
||||||
|
f"CHECK (execution_stage IN ({STAGE4_STAGES}))"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"ALTER TABLE {SEAT_RUNS_TABLE} "
|
||||||
|
f"ADD CONSTRAINT ck_maf_seat_runs_execution_stage "
|
||||||
|
f"CHECK (execution_stage IN ({STAGE4_STAGES}))"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ddl_drop_execution_stage_check(dialect: str) -> str:
|
||||||
|
if dialect in {"mysql", "mariadb"}:
|
||||||
|
return f"ALTER TABLE {SEAT_RUNS_TABLE} DROP CHECK ck_maf_seat_runs_execution_stage"
|
||||||
|
return f"ALTER TABLE {SEAT_RUNS_TABLE} DROP CONSTRAINT ck_maf_seat_runs_execution_stage"
|
||||||
|
|
||||||
|
|
||||||
|
def _check_constraint_sql(insp: Any, table: str, name: str) -> str | None:
|
||||||
|
"""Return the SQL text of a CHECK constraint, or ``None`` when absent.
|
||||||
|
|
||||||
|
The Stage-3 migration created a constraint with the SAME name
|
||||||
|
(``ck_maf_seat_runs_execution_stage``) but the old stage list, so the name
|
||||||
|
alone can never prove a table is already on Stage 4 — only the SQL text can.
|
||||||
|
"""
|
||||||
|
getter = getattr(insp, "get_check_constraints", None)
|
||||||
|
if getter is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
for ck in getter(table):
|
||||||
|
if ck.get("name") == name:
|
||||||
|
return str(ck.get("sqltext") or "")
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _execution_stage_check_sql(insp: Any) -> str | None:
|
||||||
|
"""SQL text of the seat_runs ``execution_stage`` CHECK, or ``None``."""
|
||||||
|
return _check_constraint_sql(insp, SEAT_RUNS_TABLE, EXECUTION_STAGE_CHECK_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_stage4_execution_stage_check(insp: Any) -> bool:
|
||||||
|
"""True only when the constraint exists AND already allows BOTH Stage-4 stages.
|
||||||
|
|
||||||
|
A same-named Stage-3 constraint (no ``rework`` / ``manager_review``) is
|
||||||
|
treated as the old constraint and must be widened — never reported as
|
||||||
|
"already widened".
|
||||||
|
"""
|
||||||
|
sql = _execution_stage_check_sql(insp)
|
||||||
|
if sql is None:
|
||||||
|
return False
|
||||||
|
return all(stage in sql for stage in STAGE4_NEW_STAGES)
|
||||||
|
|
||||||
|
|
||||||
|
def _widen_check_ddls(dialect: str, *, check_exists: bool, is_stage4: bool) -> list[str]:
|
||||||
|
"""Pure decision function: DDL needed to widen the execution_stage CHECK.
|
||||||
|
|
||||||
|
- already Stage 4 → no-op;
|
||||||
|
- SQLite → rebuild the table (a CHECK cannot be dropped in place), whether
|
||||||
|
the old constraint exists or not;
|
||||||
|
- PostgreSQL / MySQL → drop the old constraint first when it exists, then
|
||||||
|
add the Stage-4 constraint; when it does not exist, just add it.
|
||||||
|
|
||||||
|
Kept free of I/O so the PostgreSQL / MySQL branches are unit-testable
|
||||||
|
without a live database.
|
||||||
|
"""
|
||||||
|
if is_stage4:
|
||||||
|
return []
|
||||||
|
if dialect == "sqlite":
|
||||||
|
return _sqlite_rebuild_seat_runs_with_stage4()
|
||||||
|
ddls: list[str] = []
|
||||||
|
if check_exists:
|
||||||
|
ddls.append(_ddl_drop_execution_stage_check(dialect))
|
||||||
|
ddls.append(_ddl_add_execution_stage_check(dialect))
|
||||||
|
return ddls
|
||||||
|
|
||||||
|
|
||||||
|
def _sqlite_rebuild_seat_runs_with_stage4() -> list[str]:
|
||||||
|
"""Rebuild seat_runs with the widened execution_stage CHECK (SQLite only).
|
||||||
|
|
||||||
|
Same recipe as the Phase-3 migration: no column shape changes, only the
|
||||||
|
CHECK constraint is widened, so the data copy is a straight ``SELECT *``.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
f"""
|
||||||
|
CREATE TABLE {SEAT_RUNS_TABLE}__stage4 (
|
||||||
|
id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||||
|
maf_run_id VARCHAR(64) NOT NULL,
|
||||||
|
agent_id VARCHAR(64) NOT NULL,
|
||||||
|
turn_index INTEGER NOT NULL,
|
||||||
|
execution_stage VARCHAR(32) NOT NULL DEFAULT 'legacy',
|
||||||
|
deerflow_run_id VARCHAR(64),
|
||||||
|
deerflow_thread_id VARCHAR(64),
|
||||||
|
sandbox_id VARCHAR(64),
|
||||||
|
workflow_checkpoint_id VARCHAR(128),
|
||||||
|
status VARCHAR(32) DEFAULT 'pending' NOT NULL,
|
||||||
|
result_snapshot TEXT,
|
||||||
|
started_at DATETIME,
|
||||||
|
finished_at DATETIME,
|
||||||
|
error TEXT,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL,
|
||||||
|
FOREIGN KEY (maf_run_id) REFERENCES maf_roundtable_runs (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT uq_maf_seat_runs_run_agent_turn_stage
|
||||||
|
UNIQUE (maf_run_id, agent_id, turn_index, execution_stage),
|
||||||
|
CONSTRAINT ck_maf_seat_runs_execution_stage CHECK (execution_stage IN ({STAGE4_STAGES}))
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
f"""
|
||||||
|
INSERT INTO {SEAT_RUNS_TABLE}__stage4 (
|
||||||
|
id, maf_run_id, agent_id, turn_index, execution_stage,
|
||||||
|
deerflow_run_id, deerflow_thread_id, sandbox_id, workflow_checkpoint_id,
|
||||||
|
status, result_snapshot, started_at, finished_at, error, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, maf_run_id, agent_id, turn_index, execution_stage,
|
||||||
|
deerflow_run_id, deerflow_thread_id, sandbox_id, workflow_checkpoint_id,
|
||||||
|
status, result_snapshot, started_at, finished_at, error, created_at, updated_at
|
||||||
|
FROM {SEAT_RUNS_TABLE}
|
||||||
|
""",
|
||||||
|
f"DROP TABLE {SEAT_RUNS_TABLE}",
|
||||||
|
f"ALTER TABLE {SEAT_RUNS_TABLE}__stage4 RENAME TO {SEAT_RUNS_TABLE}",
|
||||||
|
f"CREATE INDEX ix_maf_seat_runs_maf_run_id ON {SEAT_RUNS_TABLE} (maf_run_id)",
|
||||||
|
f"CREATE INDEX ix_maf_seat_runs_agent_id ON {SEAT_RUNS_TABLE} (agent_id)",
|
||||||
|
f"CREATE INDEX ix_maf_seat_runs_deerflow_run_id ON {SEAT_RUNS_TABLE} (deerflow_run_id)",
|
||||||
|
f"CREATE INDEX ix_maf_seat_runs_status ON {SEAT_RUNS_TABLE} (status)",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_schema(inspector: Any) -> list[str]:
|
||||||
|
"""Return contract violations found by SQLAlchemy inspector.
|
||||||
|
|
||||||
|
Validates constraint CONTENT, not just names: a same-named Stage-3 CHECK is
|
||||||
|
reported as a violation until it really contains the Stage-4 stages.
|
||||||
|
"""
|
||||||
|
issues: list[str] = []
|
||||||
|
tables = set(inspector.get_table_names())
|
||||||
|
|
||||||
|
if SEAT_TASKS_TABLE not in tables:
|
||||||
|
issues.append(f"missing table: {SEAT_TASKS_TABLE}")
|
||||||
|
else:
|
||||||
|
cols = {c["name"] for c in inspector.get_columns(SEAT_TASKS_TABLE)}
|
||||||
|
for required in (
|
||||||
|
"id", "session_id", "run_id", "task_key", "agent_id",
|
||||||
|
"original_agent_id", "status", "required", "attempt_count",
|
||||||
|
"rework_count", "reassign_count", "created_at", "updated_at",
|
||||||
|
):
|
||||||
|
if required not in cols:
|
||||||
|
issues.append(f"{SEAT_TASKS_TABLE}: missing column {required}")
|
||||||
|
uq_cols = [
|
||||||
|
set(u.get("column_names", []))
|
||||||
|
for u in inspector.get_unique_constraints(SEAT_TASKS_TABLE)
|
||||||
|
]
|
||||||
|
idx_cols = [
|
||||||
|
set(idx.get("column_names", []))
|
||||||
|
for idx in inspector.get_indexes(SEAT_TASKS_TABLE)
|
||||||
|
if idx.get("unique")
|
||||||
|
]
|
||||||
|
if {"run_id", "task_key"} not in (uq_cols + idx_cols):
|
||||||
|
issues.append(f"{SEAT_TASKS_TABLE}: missing UNIQUE(run_id, task_key)")
|
||||||
|
|
||||||
|
status_sql = _check_constraint_sql(
|
||||||
|
inspector, SEAT_TASKS_TABLE, SEAT_TASKS_STATUS_CHECK_NAME
|
||||||
|
)
|
||||||
|
if status_sql is None:
|
||||||
|
issues.append(f"{SEAT_TASKS_TABLE}: missing status CHECK constraint")
|
||||||
|
else:
|
||||||
|
missing = [s for s in SEAT_TASKS_STATUSES if s not in status_sql]
|
||||||
|
if missing:
|
||||||
|
issues.append(
|
||||||
|
f"{SEAT_TASKS_TABLE}: status CHECK missing statuses: {', '.join(missing)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
fk_targets: dict[str, str] = {}
|
||||||
|
try:
|
||||||
|
for fk in inspector.get_foreign_keys(SEAT_TASKS_TABLE):
|
||||||
|
for local in fk.get("constrained_columns") or []:
|
||||||
|
fk_targets[local] = fk.get("referred_table") or ""
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
if fk_targets.get("session_id") != "maf_roundtable_sessions":
|
||||||
|
issues.append(
|
||||||
|
f"{SEAT_TASKS_TABLE}: session_id must reference maf_roundtable_sessions"
|
||||||
|
)
|
||||||
|
if fk_targets.get("run_id") != "maf_roundtable_runs":
|
||||||
|
issues.append(
|
||||||
|
f"{SEAT_TASKS_TABLE}: run_id must reference maf_roundtable_runs"
|
||||||
|
)
|
||||||
|
|
||||||
|
if SEAT_RUNS_TABLE in tables:
|
||||||
|
if not _has_stage4_execution_stage_check(inspector):
|
||||||
|
issues.append(
|
||||||
|
f"{SEAT_RUNS_TABLE}: execution_stage CHECK missing or not widened "
|
||||||
|
f"to include {', '.join(STAGE4_NEW_STAGES)}"
|
||||||
|
)
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def _do_migrate_sync(
|
||||||
|
sync_conn: Any,
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
logger: logging.Logger,
|
||||||
|
changes_ref: list[int],
|
||||||
|
) -> None:
|
||||||
|
"""Run all migration steps on a sync connection (called via run_sync).
|
||||||
|
|
||||||
|
Mirrors the Phase-3 migration's sync entry point so tests can drive the
|
||||||
|
migration against an arbitrary engine.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
|
||||||
|
insp = inspect(sync_conn)
|
||||||
|
dialect = _dialect_name(sync_conn)
|
||||||
|
|
||||||
|
def _exec(sql: str) -> None:
|
||||||
|
if dry_run:
|
||||||
|
logger.info(" [dry-run] would execute: %s", sql.strip()[:300])
|
||||||
|
return
|
||||||
|
sync_conn.execute(text(sql))
|
||||||
|
changes_ref[0] += 1
|
||||||
|
|
||||||
|
existing = set(insp.get_table_names())
|
||||||
|
|
||||||
|
if SEAT_TASKS_TABLE not in existing:
|
||||||
|
logger.info("Creating %s", SEAT_TASKS_TABLE)
|
||||||
|
_exec(_seat_tasks_ddl(dialect))
|
||||||
|
for index_sql in _seat_tasks_indexes_ddl(dialect):
|
||||||
|
_exec(index_sql)
|
||||||
|
else:
|
||||||
|
logger.info("Already exists: %s", SEAT_TASKS_TABLE)
|
||||||
|
|
||||||
|
if SEAT_RUNS_TABLE in existing:
|
||||||
|
# The Stage-3 constraint carries the SAME name — only the SQL text
|
||||||
|
# (must contain both new stages) proves the table is already Stage 4.
|
||||||
|
check_sql = _execution_stage_check_sql(insp)
|
||||||
|
is_stage4 = _has_stage4_execution_stage_check(insp)
|
||||||
|
ddls = _widen_check_ddls(
|
||||||
|
dialect, check_exists=check_sql is not None, is_stage4=is_stage4
|
||||||
|
)
|
||||||
|
if not ddls:
|
||||||
|
logger.info("execution_stage CHECK already widened — no-op.")
|
||||||
|
elif dialect == "sqlite":
|
||||||
|
logger.info("SQLite: rebuilding %s to widen execution_stage CHECK", SEAT_RUNS_TABLE)
|
||||||
|
for stmt in ddls:
|
||||||
|
_exec(stmt)
|
||||||
|
else:
|
||||||
|
if check_sql is not None:
|
||||||
|
logger.info("Dropping stale execution_stage CHECK on %s", SEAT_RUNS_TABLE)
|
||||||
|
logger.info("Adding Stage-4 execution_stage CHECK on %s", SEAT_RUNS_TABLE)
|
||||||
|
for stmt in ddls:
|
||||||
|
_exec(stmt)
|
||||||
|
|
||||||
|
logger.info("Migration steps: %d applied.", changes_ref[0])
|
||||||
|
|
||||||
|
|
||||||
|
async def run_migration(database_url: str, *, dry_run: bool = False) -> None:
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
|
engine = create_async_engine(database_url, poolclass=NullPool)
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
logger.info("Dialect: %s", _dialect_name(conn))
|
||||||
|
# Single code path: the sync entry point owns all detection / DDL
|
||||||
|
# decisions (including the Stage-3 same-name constraint case).
|
||||||
|
changes_ref = [0]
|
||||||
|
await conn.run_sync(
|
||||||
|
_do_migrate_sync,
|
||||||
|
dry_run=dry_run,
|
||||||
|
logger=logger,
|
||||||
|
changes_ref=changes_ref,
|
||||||
|
)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
logger.info("[DRY RUN] No statements were executed.")
|
||||||
|
return
|
||||||
|
|
||||||
|
def _validate(sync_conn: Any) -> list[str]:
|
||||||
|
return validate_schema(inspect(sync_conn))
|
||||||
|
|
||||||
|
issues = await conn.run_sync(_validate)
|
||||||
|
if issues:
|
||||||
|
logger.error("Schema validation FAILED:")
|
||||||
|
for issue in issues:
|
||||||
|
logger.error(" - %s", issue)
|
||||||
|
sys.exit(1)
|
||||||
|
logger.info("Schema validation passed.")
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="MAF roundtable Stage 4 migration")
|
||||||
|
parser.add_argument("--database-url", default=None, help="Async database URL (default: from config)")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Print DDL without executing")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
database_url = args.database_url
|
||||||
|
if database_url is None:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
config_path = Path(__file__).resolve().parent.parent / "config.yaml"
|
||||||
|
if config_path.exists():
|
||||||
|
with open(config_path) as f:
|
||||||
|
cfg = yaml.safe_load(f) or {}
|
||||||
|
db_cfg = cfg.get("database", {})
|
||||||
|
database_url = db_cfg.get("url", "sqlite+aiosqlite:///./data/deerflow.db")
|
||||||
|
else:
|
||||||
|
database_url = "sqlite+aiosqlite:///./data/deerflow.db"
|
||||||
|
|
||||||
|
logger.info("Database: %s", database_url.split("?")[0])
|
||||||
|
asyncio.run(run_migration(database_url, dry_run=args.dry_run))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -875,8 +875,9 @@ async def test_sse_generator_realtime_terminal_stops(svc, store, hub):
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_p0_1_atomic_projection_both_events_or_neither(svc, store):
|
async def test_p0_1_atomic_projection_both_events_or_neither(svc, store):
|
||||||
"""agent.message.completed must atomically project both
|
"""A genuine seat delivery (agent.message.completed with a seat delivery
|
||||||
agent.message.complete AND seat.delivery.submitted."""
|
execution_stage) must atomically project both agent.message.complete AND
|
||||||
|
seat.delivery.submitted."""
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -914,7 +915,11 @@ async def test_p0_1_atomic_projection_both_events_or_neither(svc, store):
|
|||||||
"event_type": "agent.message.completed",
|
"event_type": "agent.message.completed",
|
||||||
"agent_id": "seat_a",
|
"agent_id": "seat_a",
|
||||||
"turn_index": 1,
|
"turn_index": 1,
|
||||||
"payload": {"content": "result", "schema_status": "valid"},
|
"payload": {
|
||||||
|
"content": "result",
|
||||||
|
"schema_status": "valid",
|
||||||
|
"execution_stage": "expert",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
written = await svc.bridge_workflow_event(
|
written = await svc.bridge_workflow_event(
|
||||||
@@ -1020,7 +1025,7 @@ async def test_p0_1_projection_failure_does_not_duplicate_on_retry(svc, store):
|
|||||||
id="audit_retry_1", session_id=session.id, run_id=run.id,
|
id="audit_retry_1", session_id=session.id, run_id=run.id,
|
||||||
seq=1, event_type="agent.message.completed",
|
seq=1, event_type="agent.message.completed",
|
||||||
agent_id="seat_a", turn_index=0,
|
agent_id="seat_a", turn_index=0,
|
||||||
payload_json={"content": "done"},
|
payload_json={"content": "done", "execution_stage": "expert"},
|
||||||
))
|
))
|
||||||
|
|
||||||
await svc.bridge_workflow_event(
|
await svc.bridge_workflow_event(
|
||||||
@@ -1029,7 +1034,7 @@ async def test_p0_1_projection_failure_does_not_duplicate_on_retry(svc, store):
|
|||||||
"event_type": "agent.message.completed",
|
"event_type": "agent.message.completed",
|
||||||
"agent_id": "seat_a",
|
"agent_id": "seat_a",
|
||||||
"turn_index": 0,
|
"turn_index": 0,
|
||||||
"payload": {"content": "done"},
|
"payload": {"content": "done", "execution_stage": "expert"},
|
||||||
},
|
},
|
||||||
source_audit_event_id="audit_retry_1",
|
source_audit_event_id="audit_retry_1",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,436 @@
|
|||||||
|
"""Acceptance tests for the Stage-4 migration script (R3-Q quality gate).
|
||||||
|
|
||||||
|
P0-1: the tests start from a REAL Stage-3 schema — the seat_runs table is
|
||||||
|
created by hand with the Stage-3 ``execution_stage`` CHECK (same constraint
|
||||||
|
NAME as Stage 4, but WITHOUT ``rework`` / ``manager_review``). ``Base.metadata
|
||||||
|
.create_all()`` is never used to fake the Stage-3 seat_runs table, because the
|
||||||
|
current ORM metadata already carries the widened Stage-4 CHECK.
|
||||||
|
|
||||||
|
Verified behaviour:
|
||||||
|
|
||||||
|
1. A same-named Stage-3 constraint is detected as NOT widened (sqltext must
|
||||||
|
contain both new stages — the name alone never proves Stage 4).
|
||||||
|
2. SQLite: the incomplete constraint triggers the table rebuild; the original
|
||||||
|
seat-run rows survive; indexes are recreated; a second run is a no-op.
|
||||||
|
3. After migration, inserting ``rework`` and ``manager_review`` succeeds while
|
||||||
|
an unknown stage is still rejected by the CHECK.
|
||||||
|
4. Two consecutive runs produce no errors and no duplicate tables/indexes.
|
||||||
|
5. ``validate_schema`` checks constraint CONTENT plus the seat_tasks status
|
||||||
|
CHECK, unique key and foreign keys.
|
||||||
|
6. PostgreSQL / MySQL DDL generation is asserted without a live database
|
||||||
|
(drop+recreate on an existing old constraint, plain add when absent, no-op
|
||||||
|
when already Stage 4; PostgreSQL boolean default is ``TRUE`` not ``1``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import inspect as sa_inspect
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from deerflow.persistence.maf_roundtable.model import (
|
||||||
|
Base,
|
||||||
|
MafRoundtableRunRow,
|
||||||
|
MafRoundtableSeatRunRow,
|
||||||
|
MafRoundtableSessionRow,
|
||||||
|
)
|
||||||
|
|
||||||
|
STAGE3_STAGES = (
|
||||||
|
"'legacy','planner','expert','format_repair',"
|
||||||
|
"'cross_review','cross_review_repair','finalizer','group_chat'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Real Stage-3 seat_runs DDL — identical shape to the Phase-3 output, with the
|
||||||
|
# Stage-3 (unwidened) execution_stage CHECK.
|
||||||
|
STAGE3_SEAT_RUNS_DDL = f"""
|
||||||
|
CREATE TABLE maf_roundtable_seat_runs (
|
||||||
|
id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||||
|
maf_run_id VARCHAR(64) NOT NULL,
|
||||||
|
agent_id VARCHAR(64) NOT NULL,
|
||||||
|
turn_index INTEGER NOT NULL,
|
||||||
|
execution_stage VARCHAR(32) NOT NULL DEFAULT 'legacy',
|
||||||
|
deerflow_run_id VARCHAR(64),
|
||||||
|
deerflow_thread_id VARCHAR(64),
|
||||||
|
sandbox_id VARCHAR(64),
|
||||||
|
workflow_checkpoint_id VARCHAR(128),
|
||||||
|
status VARCHAR(32) DEFAULT 'pending' NOT NULL,
|
||||||
|
result_snapshot TEXT,
|
||||||
|
started_at DATETIME,
|
||||||
|
finished_at DATETIME,
|
||||||
|
error TEXT,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL,
|
||||||
|
FOREIGN KEY (maf_run_id) REFERENCES maf_roundtable_runs (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT uq_maf_seat_runs_run_agent_turn_stage
|
||||||
|
UNIQUE (maf_run_id, agent_id, turn_index, execution_stage),
|
||||||
|
CONSTRAINT ck_maf_seat_runs_execution_stage
|
||||||
|
CHECK (execution_stage IN ({STAGE3_STAGES}))
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
STAGE3_SEAT_RUNS_INDEXES = [
|
||||||
|
"CREATE INDEX ix_maf_seat_runs_maf_run_id ON maf_roundtable_seat_runs (maf_run_id)",
|
||||||
|
"CREATE INDEX ix_maf_seat_runs_agent_id ON maf_roundtable_seat_runs (agent_id)",
|
||||||
|
"CREATE INDEX ix_maf_seat_runs_deerflow_run_id ON maf_roundtable_seat_runs (deerflow_run_id)",
|
||||||
|
"CREATE INDEX ix_maf_seat_runs_status ON maf_roundtable_seat_runs (status)",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_migration_module():
|
||||||
|
"""Load scripts/migrate_maf_roundtable_stage4.py as a module."""
|
||||||
|
here = Path(__file__).resolve().parent.parent
|
||||||
|
script = here / "scripts" / "migrate_maf_roundtable_stage4.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("migrate_maf_roundtable_stage4", script)
|
||||||
|
assert spec and spec.loader, "could not load migration script"
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_stage3_schema(engine) -> None:
|
||||||
|
"""Create a genuine Stage-3 schema.
|
||||||
|
|
||||||
|
sessions / runs come from the ORM metadata (they did not change in Stage 4);
|
||||||
|
seat_runs is created BY HAND with the Stage-3 CHECK; seat_tasks does not
|
||||||
|
exist yet.
|
||||||
|
"""
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(
|
||||||
|
lambda sync_conn: Base.metadata.create_all(
|
||||||
|
sync_conn,
|
||||||
|
tables=[
|
||||||
|
MafRoundtableSessionRow.__table__,
|
||||||
|
MafRoundtableRunRow.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await conn.execute(text(STAGE3_SEAT_RUNS_DDL))
|
||||||
|
for ddl in STAGE3_SEAT_RUNS_INDEXES:
|
||||||
|
await conn.execute(text(ddl))
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_stage3_data(engine) -> None:
|
||||||
|
sf = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
now = datetime(2026, 1, 1)
|
||||||
|
async with sf() as session:
|
||||||
|
session.add(MafRoundtableSessionRow(
|
||||||
|
id="s1", user_id="u1", title="t", task_text="t",
|
||||||
|
mode="structured", status="draft", write_policy="confirm_high_risk",
|
||||||
|
max_rounds=8, created_at=now, updated_at=now,
|
||||||
|
))
|
||||||
|
session.add(MafRoundtableRunRow(
|
||||||
|
id="r1", session_id="s1", status="completed",
|
||||||
|
created_at=now, updated_at=now,
|
||||||
|
))
|
||||||
|
session.add(MafRoundtableSeatRunRow(
|
||||||
|
id="sr1", maf_run_id="r1", agent_id="seat-a", turn_index=1,
|
||||||
|
execution_stage="expert", status="completed",
|
||||||
|
created_at=now, updated_at=now,
|
||||||
|
))
|
||||||
|
session.add(MafRoundtableSeatRunRow(
|
||||||
|
id="sr2", maf_run_id="r1", agent_id="manager-1", turn_index=1,
|
||||||
|
execution_stage="cross_review", status="completed",
|
||||||
|
created_at=now, updated_at=now,
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_migrate(engine, mod) -> int:
|
||||||
|
changes_ref = [0]
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(
|
||||||
|
mod._do_migrate_sync, dry_run=False, logger=mod.logger, changes_ref=changes_ref
|
||||||
|
)
|
||||||
|
return changes_ref[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _seat_runs_check_sql(insp) -> str | None:
|
||||||
|
for ck in insp.get_check_constraints("maf_roundtable_seat_runs"):
|
||||||
|
if ck.get("name") == "ck_maf_seat_runs_execution_stage":
|
||||||
|
return str(ck.get("sqltext") or "")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── detection: same-named Stage-3 constraint is NOT Stage 4 ────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stage3_same_name_constraint_detected_as_not_stage4():
|
||||||
|
mod = _load_migration_module()
|
||||||
|
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
|
||||||
|
try:
|
||||||
|
await _create_stage3_schema(engine)
|
||||||
|
|
||||||
|
def _detect(sync_conn):
|
||||||
|
insp = sa_inspect(sync_conn)
|
||||||
|
return (
|
||||||
|
mod._execution_stage_check_sql(insp),
|
||||||
|
mod._has_stage4_execution_stage_check(insp),
|
||||||
|
)
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
sql, is_stage4 = await conn.run_sync(_detect)
|
||||||
|
|
||||||
|
# The Stage-3 constraint exists under the same name ...
|
||||||
|
assert sql is not None
|
||||||
|
# ... but it does NOT allow the Stage-4 stages.
|
||||||
|
assert "rework" not in sql
|
||||||
|
assert "manager_review" not in sql
|
||||||
|
assert is_stage4 is False
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# ── SQLite: real Stage-3 → Stage-4, idempotent, data preserved ─────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migration_widens_real_stage3_and_is_idempotent():
|
||||||
|
mod = _load_migration_module()
|
||||||
|
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
|
||||||
|
try:
|
||||||
|
await _create_stage3_schema(engine)
|
||||||
|
await _seed_stage3_data(engine)
|
||||||
|
|
||||||
|
# Sanity: before migration, 'rework' is rejected by the Stage-3 CHECK.
|
||||||
|
sf = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
async with sf() as session:
|
||||||
|
session.add(MafRoundtableSeatRunRow(
|
||||||
|
id="sr-pre", maf_run_id="r1", agent_id="seat-x", turn_index=9,
|
||||||
|
execution_stage="rework", status="pending",
|
||||||
|
created_at=datetime(2026, 1, 1), updated_at=datetime(2026, 1, 1),
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Run the migration twice — the second run must be a no-op.
|
||||||
|
first_changes = await _run_migrate(engine, mod)
|
||||||
|
assert first_changes > 0, "first run must apply changes on a Stage-3 schema"
|
||||||
|
|
||||||
|
def _index_names(sync_conn):
|
||||||
|
return sorted(
|
||||||
|
idx["name"]
|
||||||
|
for idx in sa_inspect(sync_conn).get_indexes("maf_roundtable_seat_runs")
|
||||||
|
)
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
indexes_after_first = await conn.run_sync(_index_names)
|
||||||
|
|
||||||
|
second_changes = await _run_migrate(engine, mod)
|
||||||
|
assert second_changes == 0, "second run must be a no-op"
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
indexes_after_second = await conn.run_sync(_index_names)
|
||||||
|
assert indexes_after_first == indexes_after_second, "no duplicate indexes"
|
||||||
|
|
||||||
|
def _check(sync_conn):
|
||||||
|
insp = sa_inspect(sync_conn)
|
||||||
|
tables = set(insp.get_table_names())
|
||||||
|
# No leftover rebuild artefacts, exactly one seat_runs table.
|
||||||
|
assert "maf_roundtable_seat_runs__stage4" not in tables
|
||||||
|
assert "maf_roundtable_seat_tasks" in tables
|
||||||
|
|
||||||
|
# The widened CHECK contains BOTH new stages.
|
||||||
|
sql = _seat_runs_check_sql(insp)
|
||||||
|
assert sql is not None
|
||||||
|
assert "rework" in sql
|
||||||
|
assert "manager_review" in sql
|
||||||
|
|
||||||
|
# seat_tasks contract: columns, unique key, status CHECK, FKs.
|
||||||
|
cols = {c["name"] for c in insp.get_columns("maf_roundtable_seat_tasks")}
|
||||||
|
for required in (
|
||||||
|
"id", "session_id", "run_id", "task_key", "agent_id",
|
||||||
|
"original_agent_id", "status", "required", "contract_json",
|
||||||
|
"delivery_json", "history_json", "gap_json",
|
||||||
|
):
|
||||||
|
assert required in cols, f"missing column {required}"
|
||||||
|
uq = [
|
||||||
|
set(u.get("column_names", []))
|
||||||
|
for u in insp.get_unique_constraints("maf_roundtable_seat_tasks")
|
||||||
|
]
|
||||||
|
assert {"run_id", "task_key"} in uq
|
||||||
|
|
||||||
|
# Original Stage-3 rows survived the rebuild untouched.
|
||||||
|
rows = sync_conn.execute(text(
|
||||||
|
"SELECT id, execution_stage, status FROM maf_roundtable_seat_runs ORDER BY id"
|
||||||
|
)).fetchall()
|
||||||
|
assert rows == [
|
||||||
|
("sr1", "expert", "completed"),
|
||||||
|
("sr2", "cross_review", "completed"),
|
||||||
|
]
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(_check)
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migration_allows_stage4_stages_and_rejects_unknown():
|
||||||
|
mod = _load_migration_module()
|
||||||
|
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
|
||||||
|
try:
|
||||||
|
await _create_stage3_schema(engine)
|
||||||
|
await _seed_stage3_data(engine)
|
||||||
|
await _run_migrate(engine, mod)
|
||||||
|
|
||||||
|
sf = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
now = datetime(2026, 1, 2)
|
||||||
|
async with sf() as session:
|
||||||
|
session.add(MafRoundtableSeatRunRow(
|
||||||
|
id="sr-rework", maf_run_id="r1", agent_id="seat-a", turn_index=2,
|
||||||
|
execution_stage="rework", status="completed",
|
||||||
|
created_at=now, updated_at=now,
|
||||||
|
))
|
||||||
|
session.add(MafRoundtableSeatRunRow(
|
||||||
|
id="sr-review", maf_run_id="r1", agent_id="manager-1", turn_index=2,
|
||||||
|
execution_stage="manager_review", status="completed",
|
||||||
|
created_at=now, updated_at=now,
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async with sf() as session:
|
||||||
|
stages = (
|
||||||
|
(await session.execute(text(
|
||||||
|
"SELECT execution_stage FROM maf_roundtable_seat_runs ORDER BY id"
|
||||||
|
))).scalars().all()
|
||||||
|
)
|
||||||
|
assert "rework" in stages
|
||||||
|
assert "manager_review" in stages
|
||||||
|
|
||||||
|
# The CHECK is still enforced — an unknown stage must be rejected.
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
async with sf() as session:
|
||||||
|
session.add(MafRoundtableSeatRunRow(
|
||||||
|
id="sr-bogus", maf_run_id="r1", agent_id="seat-a", turn_index=3,
|
||||||
|
execution_stage="bogus_stage", status="pending",
|
||||||
|
created_at=now, updated_at=now,
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# ── validate_schema: content checks, not just names ────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_schema_flags_stage3_then_passes_stage4():
|
||||||
|
mod = _load_migration_module()
|
||||||
|
engine = create_async_engine("sqlite+aiosqlite://", echo=False)
|
||||||
|
try:
|
||||||
|
await _create_stage3_schema(engine)
|
||||||
|
await _seed_stage3_data(engine)
|
||||||
|
|
||||||
|
def _validate(sync_conn):
|
||||||
|
return mod.validate_schema(sa_inspect(sync_conn))
|
||||||
|
|
||||||
|
# Before migration: seat_tasks missing AND the CHECK not widened.
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
issues = await conn.run_sync(_validate)
|
||||||
|
assert any("missing table" in i for i in issues)
|
||||||
|
assert any("not widened" in i for i in issues)
|
||||||
|
|
||||||
|
await _run_migrate(engine, mod)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
issues = await conn.run_sync(_validate)
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
# Dropping the status CHECK must be reported (content validation).
|
||||||
|
# SQLite cannot DROP CONSTRAINT, so simulate by checking a tampered
|
||||||
|
# inspector view: rebuild without the constraint is out of scope here;
|
||||||
|
# instead verify the FK/unique violations are reported when the table
|
||||||
|
# is dropped entirely.
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(text("DROP TABLE maf_roundtable_seat_tasks"))
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
issues = await conn.run_sync(_validate)
|
||||||
|
assert any("missing table" in i for i in issues)
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# ── PostgreSQL / MySQL DDL generation (no live database) ───────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_widen_check_ddls_postgresql():
|
||||||
|
mod = _load_migration_module()
|
||||||
|
# Old same-named constraint present → drop then re-add.
|
||||||
|
ddls = mod._widen_check_ddls("postgresql", check_exists=True, is_stage4=False)
|
||||||
|
assert len(ddls) == 2
|
||||||
|
assert "DROP CONSTRAINT ck_maf_seat_runs_execution_stage" in ddls[0]
|
||||||
|
assert "ADD CONSTRAINT ck_maf_seat_runs_execution_stage" in ddls[1]
|
||||||
|
for stage in ("rework", "manager_review"):
|
||||||
|
assert stage in ddls[1]
|
||||||
|
# No constraint at all → plain add.
|
||||||
|
ddls = mod._widen_check_ddls("postgresql", check_exists=False, is_stage4=False)
|
||||||
|
assert len(ddls) == 1
|
||||||
|
assert ddls[0].startswith("ALTER TABLE")
|
||||||
|
assert "DROP" not in ddls[0]
|
||||||
|
# Already Stage 4 → no-op.
|
||||||
|
assert mod._widen_check_ddls("postgresql", check_exists=True, is_stage4=True) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_widen_check_ddls_mysql():
|
||||||
|
mod = _load_migration_module()
|
||||||
|
ddls = mod._widen_check_ddls("mysql", check_exists=True, is_stage4=False)
|
||||||
|
assert len(ddls) == 2
|
||||||
|
assert "DROP CHECK ck_maf_seat_runs_execution_stage" in ddls[0]
|
||||||
|
assert "ADD CONSTRAINT ck_maf_seat_runs_execution_stage" in ddls[1]
|
||||||
|
assert mod._widen_check_ddls("mysql", check_exists=True, is_stage4=True) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_widen_check_ddls_sqlite_rebuilds():
|
||||||
|
mod = _load_migration_module()
|
||||||
|
ddls = mod._widen_check_ddls("sqlite", check_exists=True, is_stage4=False)
|
||||||
|
joined = ";\n".join(ddls)
|
||||||
|
assert "CREATE TABLE maf_roundtable_seat_runs__stage4" in joined
|
||||||
|
assert "INSERT INTO maf_roundtable_seat_runs__stage4" in joined
|
||||||
|
assert "DROP TABLE maf_roundtable_seat_runs" in joined
|
||||||
|
assert "RENAME TO maf_roundtable_seat_runs" in joined
|
||||||
|
assert "CREATE INDEX" in joined
|
||||||
|
assert mod._widen_check_ddls("sqlite", check_exists=True, is_stage4=True) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_seat_tasks_boolean_default_per_dialect():
|
||||||
|
mod = _load_migration_module()
|
||||||
|
pg = mod._seat_tasks_ddl("postgresql")
|
||||||
|
assert "required BOOLEAN NOT NULL DEFAULT TRUE" in pg
|
||||||
|
assert "DEFAULT 1" not in pg
|
||||||
|
sqlite = mod._seat_tasks_ddl("sqlite")
|
||||||
|
assert "required BOOLEAN NOT NULL DEFAULT 1" in sqlite
|
||||||
|
mysql = mod._seat_tasks_ddl("mysql")
|
||||||
|
assert "required BOOLEAN NOT NULL DEFAULT 1" in mysql
|
||||||
|
|
||||||
|
|
||||||
|
def test_fake_inspector_detection_matrix():
|
||||||
|
mod = _load_migration_module()
|
||||||
|
|
||||||
|
class FakeInspector:
|
||||||
|
def __init__(self, checks):
|
||||||
|
self._checks = checks
|
||||||
|
|
||||||
|
def get_check_constraints(self, table):
|
||||||
|
return self._checks.get(table, [])
|
||||||
|
|
||||||
|
old = FakeInspector({
|
||||||
|
"maf_roundtable_seat_runs": [
|
||||||
|
{"name": "ck_maf_seat_runs_execution_stage", "sqltext": f"execution_stage IN ({STAGE3_STAGES})"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
new = FakeInspector({
|
||||||
|
"maf_roundtable_seat_runs": [
|
||||||
|
{"name": "ck_maf_seat_runs_execution_stage", "sqltext": f"execution_stage IN ({mod.STAGE4_STAGES})"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
none = FakeInspector({})
|
||||||
|
|
||||||
|
assert mod._has_stage4_execution_stage_check(old) is False
|
||||||
|
assert mod._has_stage4_execution_stage_check(new) is True
|
||||||
|
assert mod._has_stage4_execution_stage_check(none) is False
|
||||||
|
assert mod._execution_stage_check_sql(none) is None
|
||||||
@@ -250,6 +250,9 @@ async def test_workflow_projection_whitelists_user_visible_event_data(store):
|
|||||||
"payload": {
|
"payload": {
|
||||||
"content": "answer",
|
"content": "answer",
|
||||||
"metadata": {"thread_id": "thread-secret", "deerflow_run_id": "df-secret"},
|
"metadata": {"thread_id": "thread-secret", "deerflow_run_id": "df-secret"},
|
||||||
|
# A genuine seat delivery stage — only these may project a
|
||||||
|
# seat.delivery.submitted companion event.
|
||||||
|
"execution_stage": "expert",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,14 +11,11 @@ P1-2: A clarification checkpoint must restore the original prompt without
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
from datetime import datetime
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from maf_test_helpers import make_maf_service
|
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
from app.maf_roundtable.workflow_executors import (
|
from app.maf_roundtable.workflow_executors import (
|
||||||
@@ -27,12 +24,6 @@ from app.maf_roundtable.workflow_executors import (
|
|||||||
)
|
)
|
||||||
from deerflow.persistence.base import Base
|
from deerflow.persistence.base import Base
|
||||||
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
||||||
from deerflow.persistence.maf_roundtable.model import (
|
|
||||||
MafRoundtableParticipantRow,
|
|
||||||
MafRoundtableRunRow,
|
|
||||||
MafRoundtableSessionRow,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Fixtures ─────────────────────────────────────────────────────────────────
|
# ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,12 @@ from app.maf_roundtable.workflow_executors import (
|
|||||||
SeatExecutor,
|
SeatExecutor,
|
||||||
)
|
)
|
||||||
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
||||||
from deerflow.persistence.maf_roundtable.model import Base, MafRoundtableRunRow, MafRoundtableSessionRow
|
from deerflow.persistence.maf_roundtable.model import (
|
||||||
|
Base,
|
||||||
|
MafRoundtableParticipantRow,
|
||||||
|
MafRoundtableRunRow,
|
||||||
|
MafRoundtableSessionRow,
|
||||||
|
)
|
||||||
|
|
||||||
MANAGER = "manager-1"
|
MANAGER = "manager-1"
|
||||||
SEATS = ["seat-a", "seat-b"]
|
SEATS = ["seat-a", "seat-b"]
|
||||||
@@ -271,49 +276,56 @@ async def test_manager_role_wired_through_state(svc):
|
|||||||
async def v2_svc(tmp_path):
|
async def v2_svc(tmp_path):
|
||||||
"""Service with a V2 session: manager + 3 candidate seats, dispatch_contract_enabled."""
|
"""Service with a V2 session: manager + 3 candidate seats, dispatch_contract_enabled."""
|
||||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'v2runner.db'}", echo=False)
|
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'v2runner.db'}", echo=False)
|
||||||
async with engine.begin() as conn:
|
try:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
async with engine.begin() as conn:
|
||||||
sf = async_sessionmaker(engine, expire_on_commit=False)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
store = make_maf_roundtable_store(sf)
|
sf = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
service = make_maf_service(store)
|
store = make_maf_roundtable_store(sf)
|
||||||
now = datetime(2026, 1, 1)
|
service = make_maf_service(store)
|
||||||
async with sf() as session:
|
now = datetime(2026, 1, 1)
|
||||||
session.add(MafRoundtableSessionRow(
|
async with sf() as session:
|
||||||
id="v2-sess", user_id="u1", title="v2", task_text="分析市场趋势",
|
session.add(MafRoundtableSessionRow(
|
||||||
mode="structured", status="pending", write_policy="auto",
|
id="v2-sess", user_id="u1", title="v2", task_text="分析市场趋势",
|
||||||
manager_agent_id=MANAGER, max_rounds=8, created_at=now, updated_at=now,
|
mode="structured", status="pending", write_policy="auto",
|
||||||
task_payload=json.dumps({
|
manager_agent_id=MANAGER, max_rounds=8, created_at=now, updated_at=now,
|
||||||
"maf_v2": {
|
task_payload_json={
|
||||||
"dispatch_contract_enabled": True,
|
"maf_v2": {
|
||||||
"candidate_set": {
|
"dispatch_contract_enabled": True,
|
||||||
"manager_agent_id": MANAGER,
|
# Stage-3 topology tests freeze the pre-Stage-4 behavior:
|
||||||
"candidate_seat_agent_ids": ["seat-a", "seat-b", "seat-c"],
|
# plain seat fan-out, no quality gate.
|
||||||
"mode": "structured",
|
"quality_gate_enabled": False,
|
||||||
"max_rounds": 8,
|
"candidate_set": {
|
||||||
|
"manager_agent_id": MANAGER,
|
||||||
|
"candidate_seat_agent_ids": ["seat-a", "seat-b", "seat-c"],
|
||||||
|
"mode": "structured",
|
||||||
|
"max_rounds": 8,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
))
|
|
||||||
session.add(MafRoundtableRunRow(
|
|
||||||
id="v2-run", session_id="v2-sess", status="pending",
|
|
||||||
created_at=now, updated_at=now,
|
|
||||||
))
|
|
||||||
# Persist ALL candidates (simulating old V2 behavior).
|
|
||||||
from deerflow.persistence.maf_roundtable.model import MafRoundtableParticipantRow
|
|
||||||
for i, (agent_id, role) in enumerate([(MANAGER, "manager"), ("seat-a", "seat"), ("seat-b", "seat"), ("seat-c", "seat")]):
|
|
||||||
session.add(MafRoundtableParticipantRow(
|
|
||||||
id=f"p-{i}", session_id="v2-sess", agent_id=agent_id,
|
|
||||||
role=role, sort_order=i, created_at=now, updated_at=now,
|
|
||||||
))
|
))
|
||||||
await session.commit()
|
session.add(MafRoundtableRunRow(
|
||||||
yield service
|
id="v2-run", session_id="v2-sess", status="pending",
|
||||||
await engine.dispose()
|
created_at=now, updated_at=now,
|
||||||
|
))
|
||||||
|
# Persist ALL candidates (simulating old V2 behavior).
|
||||||
|
for i, (agent_id, role) in enumerate([(MANAGER, "manager"), ("seat-a", "seat"), ("seat-b", "seat"), ("seat-c", "seat")]):
|
||||||
|
session.add(MafRoundtableParticipantRow(
|
||||||
|
id=f"p-{i}", session_id="v2-sess", agent_id=agent_id,
|
||||||
|
role=role, sort_order=i, created_at=now, updated_at=now,
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
yield service
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
def _make_v2_runner(v2_svc, monkeypatch, *, dispatch_decision: dict):
|
def _make_v2_runner(v2_svc, monkeypatch, *, dispatch_decision: dict | None = None):
|
||||||
"""Create a WorkflowRunner with a mocked adapter for V2 tests."""
|
"""Create a WorkflowRunner with a mocked adapter for V2 tests.
|
||||||
|
|
||||||
|
When ``dispatch_decision`` is None any model call raises — used by
|
||||||
|
recovery tests that must prove the manager is never re-invoked.
|
||||||
|
"""
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
|
|
||||||
from app.maf_roundtable.workflow_runner import WorkflowRunner
|
from app.maf_roundtable.workflow_runner import WorkflowRunner
|
||||||
|
|
||||||
@@ -324,13 +336,15 @@ def _make_v2_runner(v2_svc, monkeypatch, *, dispatch_decision: dict):
|
|||||||
|
|
||||||
async def _run_seat(**kwargs):
|
async def _run_seat(**kwargs):
|
||||||
calls.append(kwargs)
|
calls.append(kwargs)
|
||||||
|
if dispatch_decision is None:
|
||||||
|
raise AssertionError("manager/seat model must not be called in this test")
|
||||||
stage = kwargs.get("execution_stage", "")
|
stage = kwargs.get("execution_stage", "")
|
||||||
agent_id = kwargs["agent_id"]
|
agent_id = kwargs["agent_id"]
|
||||||
if stage == "manager_dispatch":
|
if stage == "manager_dispatch":
|
||||||
return SeatResponse(agent_id=agent_id, turn_index=0, content=json.dumps(dispatch_decision), tool_calls=[], usage={"input_tokens": 1, "output_tokens": 1})
|
return SeatResponse(agent_id=agent_id, turn_index=0, content=json.dumps(dispatch_decision), tool_calls=[], usage={"input_tokens": 1, "output_tokens": 1})
|
||||||
if stage == "direct_answer":
|
if stage == "direct_answer":
|
||||||
return SeatResponse(agent_id=agent_id, turn_index=0, content="manager direct answer", tool_calls=[], usage={"input_tokens": 1, "output_tokens": 1})
|
return SeatResponse(agent_id=agent_id, turn_index=0, content="manager direct answer", tool_calls=[], usage={"input_tokens": 1, "output_tokens": 1})
|
||||||
if stage == "expert":
|
if stage in ("expert", "group_chat"):
|
||||||
return SeatResponse(agent_id=agent_id, turn_index=1, content=json.dumps({"summary": f"{agent_id} result", "findings": ["f"]}), tool_calls=[], usage={"input_tokens": 1, "output_tokens": 1})
|
return SeatResponse(agent_id=agent_id, turn_index=1, content=json.dumps({"summary": f"{agent_id} result", "findings": ["f"]}), tool_calls=[], usage={"input_tokens": 1, "output_tokens": 1})
|
||||||
if stage == "cross_review":
|
if stage == "cross_review":
|
||||||
return SeatResponse(agent_id=agent_id, turn_index=2, content=json.dumps({"summary": "ok", "confirmed_candidates": []}), tool_calls=[], usage={"input_tokens": 1, "output_tokens": 1})
|
return SeatResponse(agent_id=agent_id, turn_index=2, content=json.dumps({"summary": "ok", "confirmed_candidates": []}), tool_calls=[], usage={"input_tokens": 1, "output_tokens": 1})
|
||||||
@@ -343,7 +357,18 @@ def _make_v2_runner(v2_svc, monkeypatch, *, dispatch_decision: dict):
|
|||||||
async def _ensure_threads(session_id, run_id, participants):
|
async def _ensure_threads(session_id, run_id, participants):
|
||||||
agent_ids = [p["agent_id"] for p in participants]
|
agent_ids = [p["agent_id"] for p in participants]
|
||||||
ensure_threads_calls.append(agent_ids)
|
ensure_threads_calls.append(agent_ids)
|
||||||
return {p["agent_id"]: f"thread-{p['agent_id']}" for p in participants}
|
|
||||||
|
result = {}
|
||||||
|
for participant in participants:
|
||||||
|
agent_id = participant["agent_id"]
|
||||||
|
thread_id = f"thread-{agent_id}"
|
||||||
|
await v2_svc.store.participants.bind_thread(
|
||||||
|
session_id,
|
||||||
|
agent_id,
|
||||||
|
thread_id=thread_id,
|
||||||
|
)
|
||||||
|
result[agent_id] = thread_id
|
||||||
|
return result
|
||||||
|
|
||||||
monkeypatch.setattr(runner._adapter, "run_seat_turn", AsyncMock(side_effect=_run_seat))
|
monkeypatch.setattr(runner._adapter, "run_seat_turn", AsyncMock(side_effect=_run_seat))
|
||||||
monkeypatch.setattr(runner._adapter, "ensure_threads", AsyncMock(side_effect=_ensure_threads))
|
monkeypatch.setattr(runner._adapter, "ensure_threads", AsyncMock(side_effect=_ensure_threads))
|
||||||
@@ -395,11 +420,23 @@ async def test_runner_parallel_consultation_only_selected_seats(v2_svc, monkeypa
|
|||||||
final_run = await v2_svc.store.runs.get_run("v2-run")
|
final_run = await v2_svc.store.runs.get_run("v2-run")
|
||||||
assert final_run["status"] == "completed"
|
assert final_run["status"] == "completed"
|
||||||
|
|
||||||
|
# 5. Database state: threads are bound ONLY for manager + selected seats.
|
||||||
|
# seat-c keeps its historical participant row (P1-3 never deletes history),
|
||||||
|
# but it must have zero seat threads and zero seat execution.
|
||||||
|
participants = await v2_svc.store.participants.list_participants("v2-sess")
|
||||||
|
by_agent = {row["agent_id"]: row for row in participants}
|
||||||
|
|
||||||
|
assert by_agent[MANAGER]["thread_id"] == f"thread-{MANAGER}"
|
||||||
|
assert by_agent["seat-a"]["thread_id"] == "thread-seat-a"
|
||||||
|
assert by_agent["seat-b"]["thread_id"] == "thread-seat-b"
|
||||||
|
assert by_agent["seat-c"]["thread_id"] is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runner_direct_answer_zero_seats(v2_svc, monkeypatch):
|
async def test_runner_direct_answer_zero_seats(v2_svc, monkeypatch):
|
||||||
"""direct_answer: manager_only — zero seat participants, zero seat threads,
|
"""direct_answer: manager_only — zero seat threads, zero seat execution;
|
||||||
zero seat runs; final answer comes from manager."""
|
historical seat participant rows may remain, but never get threads; final
|
||||||
|
answer comes from manager."""
|
||||||
pytest.importorskip("agent_framework")
|
pytest.importorskip("agent_framework")
|
||||||
|
|
||||||
dispatch_decision = {
|
dispatch_decision = {
|
||||||
@@ -482,6 +519,158 @@ async def test_runner_clarification_waiting_input(v2_svc, monkeypatch):
|
|||||||
assert plan.get("clarification_prompt") == "请明确分析的目标市场和时间范围。"
|
assert plan.get("clarification_prompt") == "请明确分析的目标市场和时间范围。"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runner_group_chat_executes_only_selected_seats(v2_svc, monkeypatch):
|
||||||
|
"""group_chat: history participants are manager+A+B+C; the manager selects
|
||||||
|
only A/B with execution_mode=group_chat. Executed through
|
||||||
|
WorkflowRunner.execute(): initial ensure_threads gets only the manager,
|
||||||
|
only A/B later receive threads and speak as coordinator speakers, and C
|
||||||
|
never gets a thread or a group_chat execution stage."""
|
||||||
|
pytest.importorskip("agent_framework")
|
||||||
|
|
||||||
|
# Switch the fixture session to a group_chat route.
|
||||||
|
await v2_svc.store.sessions.update_session("v2-sess", user_id="u1", fields={
|
||||||
|
"mode": "group_chat",
|
||||||
|
"max_rounds": 2,
|
||||||
|
"task_payload": {
|
||||||
|
"maf_v2": {
|
||||||
|
"dispatch_contract_enabled": True,
|
||||||
|
"candidate_set": {
|
||||||
|
"manager_agent_id": MANAGER,
|
||||||
|
"candidate_seat_agent_ids": ["seat-a", "seat-b", "seat-c"],
|
||||||
|
"mode": "group_chat",
|
||||||
|
"max_rounds": 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
dispatch_decision = {
|
||||||
|
"intent_type": "parallel_consultation",
|
||||||
|
"selected_seat_agent_ids": ["seat-a", "seat-b"],
|
||||||
|
"assignments": [
|
||||||
|
{"agent_id": "seat-a", "task_goal": "market analysis", "delivery_requirements": ["data"], "order": 1},
|
||||||
|
{"agent_id": "seat-b", "task_goal": "competitor review", "delivery_requirements": ["sources"], "order": 2},
|
||||||
|
],
|
||||||
|
"execution_mode": "group_chat",
|
||||||
|
"convergence_path": ["manager synthesis"],
|
||||||
|
"reason": "group discussion needed",
|
||||||
|
}
|
||||||
|
|
||||||
|
runner, calls, ensure_threads_calls = _make_v2_runner(v2_svc, monkeypatch, dispatch_decision=dispatch_decision)
|
||||||
|
|
||||||
|
session = await v2_svc.store.sessions.get_session("v2-sess", user_id="u1")
|
||||||
|
run = await v2_svc.store.runs.get_run("v2-run")
|
||||||
|
await runner.execute(session, run)
|
||||||
|
|
||||||
|
# 1. First ensure_threads call: only manager.
|
||||||
|
assert ensure_threads_calls[0] == [MANAGER], \
|
||||||
|
f"First ensure_threads must only get manager, got {ensure_threads_calls[0]}"
|
||||||
|
|
||||||
|
# 2. seat-c never appears in any ensure_threads call.
|
||||||
|
all_ensured = [aid for call in ensure_threads_calls for aid in call]
|
||||||
|
assert "seat-c" not in all_ensured, "seat-c must never receive a thread"
|
||||||
|
|
||||||
|
# 3. Only A/B speak in the group_chat stage; C never executes.
|
||||||
|
speakers = [c["agent_id"] for c in calls if c.get("execution_stage") == "group_chat"]
|
||||||
|
assert speakers, "selected group_chat seats must speak"
|
||||||
|
assert set(speakers) == {"seat-a", "seat-b"}, f"unexpected speakers {speakers}"
|
||||||
|
assert "seat-c" not in speakers
|
||||||
|
|
||||||
|
# 4. Run completed.
|
||||||
|
final_run = await v2_svc.store.runs.get_run("v2-run")
|
||||||
|
assert final_run["status"] == "completed"
|
||||||
|
|
||||||
|
# 5. Database state: threads bound only for manager + selected seats; the
|
||||||
|
# historical seat-c participant row remains but holds zero seat threads.
|
||||||
|
participants = await v2_svc.store.participants.list_participants("v2-sess")
|
||||||
|
by_agent = {row["agent_id"]: row for row in participants}
|
||||||
|
assert by_agent[MANAGER]["thread_id"] == f"thread-{MANAGER}"
|
||||||
|
assert by_agent["seat-a"]["thread_id"] == "thread-seat-a"
|
||||||
|
assert by_agent["seat-b"]["thread_id"] == "thread-seat-b"
|
||||||
|
assert by_agent["seat-c"]["thread_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("has_prompt", [True, False], ids=["with_prompt", "without_prompt"])
|
||||||
|
async def test_runner_recover_clarification_checkpoint(v2_svc, monkeypatch, has_prompt):
|
||||||
|
"""WorkflowRunner.recover() on a run stuck 'running' whose checkpoint is a
|
||||||
|
completed clarification dispatch must land run+session in waiting_input,
|
||||||
|
never re-call the manager model, and never create seat threads. A valid
|
||||||
|
original prompt survives untouched; a missing prompt degrades to the
|
||||||
|
unified safe clarification."""
|
||||||
|
pytest.importorskip("agent_framework")
|
||||||
|
|
||||||
|
original_prompt = "请明确分析的目标市场和时间范围。"
|
||||||
|
dispatch_plan = {
|
||||||
|
"execution_mode": "manager_only",
|
||||||
|
"convergence_path": ["clarification"],
|
||||||
|
"reason": "insufficient_task_detail",
|
||||||
|
"source": "manager",
|
||||||
|
}
|
||||||
|
if has_prompt:
|
||||||
|
dispatch_plan["clarification_prompt"] = original_prompt
|
||||||
|
|
||||||
|
state = RoundtableState(
|
||||||
|
session_id="v2-sess", run_id="v2-run", task_text="分析市场趋势",
|
||||||
|
mode="structured", model_name=None, reasoning_effort=None,
|
||||||
|
max_rounds=8, write_policy="auto", manager_agent_id=MANAGER,
|
||||||
|
seat_agent_ids=["seat-a", "seat-b", "seat-c"],
|
||||||
|
thread_map={}, capability_snapshot=None,
|
||||||
|
seat_timeout_seconds=5, schema_repair_enabled=True,
|
||||||
|
intent_type="clarification",
|
||||||
|
dispatch_plan=dispatch_plan,
|
||||||
|
dispatch_contract_enabled=True,
|
||||||
|
)
|
||||||
|
state.selected_seat_agent_ids = []
|
||||||
|
state.status = "waiting_input"
|
||||||
|
state.stage = "waiting_input"
|
||||||
|
state.mark_completed("manager_dispatch")
|
||||||
|
|
||||||
|
# Simulate a crash: the run row is stuck "running" while the checkpoint
|
||||||
|
# already records a completed clarification dispatch.
|
||||||
|
await v2_svc.store.runs.update_run("v2-run", {
|
||||||
|
"status": "running",
|
||||||
|
"maf_checkpoint": state.to_dict(),
|
||||||
|
})
|
||||||
|
|
||||||
|
# dispatch_decision=None → any model call raises.
|
||||||
|
runner, calls, ensure_threads_calls = _make_v2_runner(v2_svc, monkeypatch)
|
||||||
|
|
||||||
|
final_run = await runner.recover("v2-run")
|
||||||
|
|
||||||
|
# 1. Run and session both end as waiting_input.
|
||||||
|
assert final_run["status"] == "waiting_input"
|
||||||
|
final_session = await v2_svc.store.sessions.get_session("v2-sess", user_id="u1")
|
||||||
|
assert final_session["status"] == "waiting_input"
|
||||||
|
|
||||||
|
# 2. Manager model was never re-invoked.
|
||||||
|
assert calls == []
|
||||||
|
runner._adapter.run_seat_turn.assert_not_awaited()
|
||||||
|
|
||||||
|
# 3. No seat threads — only the manager may hold a thread.
|
||||||
|
ensured = [aid for call in ensure_threads_calls for aid in call]
|
||||||
|
assert "seat-a" not in ensured
|
||||||
|
assert "seat-b" not in ensured
|
||||||
|
assert "seat-c" not in ensured
|
||||||
|
participants = await v2_svc.store.participants.list_participants("v2-sess")
|
||||||
|
by_agent = {row["agent_id"]: row for row in participants}
|
||||||
|
for seat_id in ("seat-a", "seat-b", "seat-c"):
|
||||||
|
assert by_agent[seat_id]["thread_id"] is None
|
||||||
|
|
||||||
|
# 4. Checkpoint outcome: valid prompt preserved / missing prompt degraded.
|
||||||
|
checkpoint = final_run.get("maf_checkpoint") or {}
|
||||||
|
assert checkpoint.get("intent_type") == "clarification"
|
||||||
|
assert checkpoint.get("status") == "waiting_input"
|
||||||
|
plan = checkpoint.get("dispatch_plan") or {}
|
||||||
|
if has_prompt:
|
||||||
|
assert plan.get("clarification_prompt") == original_prompt
|
||||||
|
assert plan.get("reason") == "insufficient_task_detail"
|
||||||
|
else:
|
||||||
|
assert plan.get("clarification_prompt")
|
||||||
|
assert plan.get("reason") == "checkpoint_clarification_prompt_missing"
|
||||||
|
|
||||||
|
|
||||||
# ── P1-3: _participants_requiring_threads unit tests ────────────────────────
|
# ── P1-3: _participants_requiring_threads unit tests ────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -513,8 +702,6 @@ def test_participants_requiring_threads_legacy_returns_all():
|
|||||||
|
|
||||||
def test_participants_requiring_threads_v2_no_plan_only_manager():
|
def test_participants_requiring_threads_v2_no_plan_only_manager():
|
||||||
"""V2 session without a validated plan: only manager gets threads."""
|
"""V2 session without a validated plan: only manager gets threads."""
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
from app.maf_roundtable.workflow_runner import WorkflowRunner
|
from app.maf_roundtable.workflow_runner import WorkflowRunner
|
||||||
|
|
||||||
runner = WorkflowRunner.__new__(WorkflowRunner)
|
runner = WorkflowRunner.__new__(WorkflowRunner)
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
"""Stage 4 (R3-Q) P1: real conversation SSE projection tests.
|
||||||
|
|
||||||
|
The Stage-4 quality events must reach the USER-VISIBLE conversation event
|
||||||
|
stream (``maf_roundtable_conversation_events``) — not just the per-run audit
|
||||||
|
table — and they must reach it safely:
|
||||||
|
|
||||||
|
1. all five Stage-4 event types are projected into the conversation table;
|
||||||
|
2. each source audit event is projected exactly ONCE (idempotent re-bridge);
|
||||||
|
3. only whitelisted fields are exposed in ``data``;
|
||||||
|
4. thread ids, run ids, internal action ids and tool parameters never leak;
|
||||||
|
5. ordinary manager / finalizer / review messages are never mislabelled as
|
||||||
|
``seat.delivery.submitted`` (only genuine seat delivery stages are).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.maf_roundtable.conversation_service import MafConversationService
|
||||||
|
from app.maf_roundtable.event_normalizer import (
|
||||||
|
make_manager_review_decision,
|
||||||
|
make_seat_delivery_submitted,
|
||||||
|
make_seat_reassigned,
|
||||||
|
make_seat_rework_assigned,
|
||||||
|
make_stage_quality_gate,
|
||||||
|
make_turn_completed,
|
||||||
|
)
|
||||||
|
from deerflow.persistence.base import Base
|
||||||
|
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
||||||
|
from deerflow.persistence.maf_roundtable.model import (
|
||||||
|
MafRoundtableConversationRow,
|
||||||
|
MafRoundtableRunRow,
|
||||||
|
MafRoundtableSessionRow,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeMafConfig:
|
||||||
|
enabled = True
|
||||||
|
default_mode = "structured"
|
||||||
|
default_model_name = "test-model"
|
||||||
|
default_reasoning_effort = None
|
||||||
|
default_manager_agent_id = "mgr-agent"
|
||||||
|
default_max_rounds = 8
|
||||||
|
default_write_policy = "confirm_high_risk"
|
||||||
|
admin_diagnostic_access = True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def proj_env(tmp_path):
|
||||||
|
"""Store + conversation service + one conversation bound to one run."""
|
||||||
|
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'proj.db'}")
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
import deerflow.persistence.maf_roundtable.model # noqa: F401
|
||||||
|
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
sf = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
store = make_maf_roundtable_store(sf)
|
||||||
|
svc = MafConversationService(store, maf_config=FakeMafConfig(), event_hub=None)
|
||||||
|
|
||||||
|
conv = await store.conversations.create_conversation({
|
||||||
|
"owner_user_id": "u1", "title": "projection", "task_context": None,
|
||||||
|
})
|
||||||
|
now = datetime(2026, 1, 1)
|
||||||
|
async with sf() as session:
|
||||||
|
session.add(MafRoundtableSessionRow(
|
||||||
|
id="proj-sess", user_id="u1", title="t", task_text="t",
|
||||||
|
mode="structured", status="running", write_policy="auto",
|
||||||
|
manager_agent_id="manager-1", max_rounds=8,
|
||||||
|
created_at=now, updated_at=now,
|
||||||
|
))
|
||||||
|
session.add(MafRoundtableRunRow(
|
||||||
|
id="proj-run", session_id="proj-sess", status="running",
|
||||||
|
created_at=now, updated_at=now,
|
||||||
|
))
|
||||||
|
row = await session.get(MafRoundtableConversationRow, conv["id"])
|
||||||
|
row.internal_session_id = "proj-sess"
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
yield svc, store, conv["id"]
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _stage4_events() -> list[tuple[str, dict]]:
|
||||||
|
"""(source_audit_event_id, event) for the five Stage-4 quality events.
|
||||||
|
|
||||||
|
Payloads are deliberately seeded with sensitive fields that must be
|
||||||
|
stripped by the projection whitelist.
|
||||||
|
"""
|
||||||
|
sensitive = {
|
||||||
|
"thread_id": "thread-secret",
|
||||||
|
"run_id": "run-secret",
|
||||||
|
"session_id": "sess-secret",
|
||||||
|
"action_id": "action-secret",
|
||||||
|
"approval_id": "approval-secret",
|
||||||
|
"target_key": "hmac-target-key",
|
||||||
|
"idempotency_key": "idem-secret",
|
||||||
|
"tool_arguments": {"url": "http://internal", "token": "abc"},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _with_sensitive(event: dict) -> dict:
|
||||||
|
event = dict(event)
|
||||||
|
event["payload"] = {**event.get("payload", {}), **sensitive}
|
||||||
|
return event
|
||||||
|
|
||||||
|
return [
|
||||||
|
("audit-delivery", _with_sensitive(make_seat_delivery_submitted(
|
||||||
|
"seat-a", 1, task_key="01:seat-a", attempt=1,
|
||||||
|
deterministic_passed=False, failure_codes=["below_min_length"],
|
||||||
|
))),
|
||||||
|
("audit-review", _with_sensitive(make_manager_review_decision(
|
||||||
|
"seat-a", 1, task_key="01:seat-a", decision="rework",
|
||||||
|
reason="结论过短", source="manager", attempt=1,
|
||||||
|
))),
|
||||||
|
("audit-rework", _with_sensitive(make_seat_rework_assigned(
|
||||||
|
"seat-a", 1, task_key="01:seat-a", rework_count=1,
|
||||||
|
requirements=["请补充证据"],
|
||||||
|
))),
|
||||||
|
("audit-reassigned", _with_sensitive(make_seat_reassigned(
|
||||||
|
"seat-c", 1, task_key="01:seat-a", from_agent_id="seat-a",
|
||||||
|
reassign_count=1,
|
||||||
|
))),
|
||||||
|
("audit-gate", _with_sensitive(make_stage_quality_gate(
|
||||||
|
"manager-1", 1, decision="passed_with_gaps", total=2,
|
||||||
|
accepted=1, controlled_gap=1, blocked_required=[],
|
||||||
|
))),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
_FORBIDDEN_KEYS = {
|
||||||
|
"thread_id", "run_id", "session_id", "action_id", "approval_id",
|
||||||
|
"target_key", "idempotency_key", "tool_arguments", "params", "arguments",
|
||||||
|
"capability_snapshot", "secret", "token",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stage4_events_projected_into_conversation_stream(proj_env):
|
||||||
|
svc, store, conv_id = proj_env
|
||||||
|
|
||||||
|
for source_id, event in _stage4_events():
|
||||||
|
written = await svc.bridge_workflow_event(
|
||||||
|
run_id="proj-run", event=event, source_audit_event_id=source_id,
|
||||||
|
)
|
||||||
|
assert written is True, f"{event['event_type']} was not projected"
|
||||||
|
|
||||||
|
events = await store.conversation_events.list_events(conv_id, after_seq=0)
|
||||||
|
types = [e["type"] for e in events]
|
||||||
|
assert "seat.delivery.submitted" in types
|
||||||
|
assert "manager.review.decision" in types
|
||||||
|
assert "seat.rework.assigned" in types
|
||||||
|
assert "seat.reassigned" in types
|
||||||
|
assert "stage.quality_gate" in types
|
||||||
|
|
||||||
|
# Whitelisted fields survive; the projection carries the quality payload.
|
||||||
|
delivery = next(e for e in events if e["type"] == "seat.delivery.submitted")
|
||||||
|
assert delivery["data"].get("task_key") == "01:seat-a"
|
||||||
|
assert delivery["data"].get("deterministic_passed") is False
|
||||||
|
assert delivery["data"].get("failure_codes") == ["below_min_length"]
|
||||||
|
review = next(e for e in events if e["type"] == "manager.review.decision")
|
||||||
|
assert review["data"].get("decision") == "rework"
|
||||||
|
assert review["data"].get("source") == "manager"
|
||||||
|
gate = next(e for e in events if e["type"] == "stage.quality_gate")
|
||||||
|
assert gate["data"].get("decision") == "passed_with_gaps"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stage4_projection_is_idempotent_per_source_event(proj_env):
|
||||||
|
svc, store, conv_id = proj_env
|
||||||
|
|
||||||
|
# Bridge every event, then bridge the SAME audit events again (retry /
|
||||||
|
# reconcile after restart). Each source event projects exactly once.
|
||||||
|
for _round in range(2):
|
||||||
|
for source_id, event in _stage4_events():
|
||||||
|
await svc.bridge_workflow_event(
|
||||||
|
run_id="proj-run", event=event, source_audit_event_id=source_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
events = await store.conversation_events.list_events(conv_id, after_seq=0)
|
||||||
|
projected = [
|
||||||
|
e for e in events
|
||||||
|
if e["type"] in {
|
||||||
|
"seat.delivery.submitted", "manager.review.decision",
|
||||||
|
"seat.rework.assigned", "seat.reassigned", "stage.quality_gate",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert len(projected) == 5, f"expected exactly 5 projections, got {len(projected)}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stage4_projection_exposes_only_whitelist_fields(proj_env):
|
||||||
|
svc, store, conv_id = proj_env
|
||||||
|
|
||||||
|
for source_id, event in _stage4_events():
|
||||||
|
await svc.bridge_workflow_event(
|
||||||
|
run_id="proj-run", event=event, source_audit_event_id=source_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
events = await store.conversation_events.list_events(conv_id, after_seq=0)
|
||||||
|
assert events
|
||||||
|
for e in events:
|
||||||
|
data = e.get("data") or {}
|
||||||
|
leaked = _FORBIDDEN_KEYS & set(data.keys())
|
||||||
|
assert not leaked, f"{e['type']} leaks forbidden keys: {leaked}"
|
||||||
|
# Defense in depth: forbidden VALUES never appear anywhere either.
|
||||||
|
blob = str(data)
|
||||||
|
assert "thread-secret" not in blob
|
||||||
|
assert "run-secret" not in blob
|
||||||
|
assert "hmac-target-key" not in blob
|
||||||
|
assert "idem-secret" not in blob
|
||||||
|
assert "action-secret" not in blob
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_manager_and_finalizer_messages_not_seat_deliveries(proj_env):
|
||||||
|
svc, store, conv_id = proj_env
|
||||||
|
|
||||||
|
# Ordinary manager-side messages: none of them may become a seat delivery.
|
||||||
|
non_delivery_stages = [
|
||||||
|
"manager_dispatch", "manager_review", "cross_review",
|
||||||
|
"cross_review_repair", "finalizer", "format_repair", "planner",
|
||||||
|
]
|
||||||
|
for i, stage in enumerate(non_delivery_stages):
|
||||||
|
await svc.bridge_workflow_event(
|
||||||
|
run_id="proj-run",
|
||||||
|
event=make_turn_completed(
|
||||||
|
"manager-1", i + 1, content=f"{stage} message",
|
||||||
|
execution_stage=stage,
|
||||||
|
),
|
||||||
|
source_audit_event_id=f"audit-msg-{stage}",
|
||||||
|
)
|
||||||
|
# A completed message WITHOUT a stage (legacy audit row) is fail-closed:
|
||||||
|
# it must not be guessed to be a seat delivery either.
|
||||||
|
await svc.bridge_workflow_event(
|
||||||
|
run_id="proj-run",
|
||||||
|
event=make_turn_completed("manager-1", 99, content="unknown stage"),
|
||||||
|
source_audit_event_id="audit-msg-nostage",
|
||||||
|
)
|
||||||
|
|
||||||
|
# A genuine seat delivery stage still projects both events.
|
||||||
|
await svc.bridge_workflow_event(
|
||||||
|
run_id="proj-run",
|
||||||
|
event=make_turn_completed(
|
||||||
|
"seat-a", 1, content='{"summary": "交付"}', execution_stage="expert",
|
||||||
|
),
|
||||||
|
source_audit_event_id="audit-msg-expert",
|
||||||
|
)
|
||||||
|
|
||||||
|
events = await store.conversation_events.list_events(conv_id, after_seq=0)
|
||||||
|
deliveries = [e for e in events if e["type"] == "seat.delivery.submitted"]
|
||||||
|
assert len(deliveries) == 1, (
|
||||||
|
"only the expert seat turn may project seat.delivery.submitted, got "
|
||||||
|
f"{len(deliveries)}"
|
||||||
|
)
|
||||||
|
assert deliveries[0]["data"].get("agent_id") == "seat-a"
|
||||||
|
|
||||||
|
# All ordinary messages still appear as agent messages.
|
||||||
|
completions = [e for e in events if e["type"] == "agent.message.complete"]
|
||||||
|
assert len(completions) == len(non_delivery_stages) + 2 # + nostage + expert
|
||||||
@@ -0,0 +1,748 @@
|
|||||||
|
"""Stage 4 (R3-Q) runner-level tests: seat task quality loop + phase gate.
|
||||||
|
|
||||||
|
Drives the REAL workflow (QualitySeatTasksExecutor → QualityGateExecutor →
|
||||||
|
CrossReviewExecutor → ApprovalGateExecutor → FinalizerExecutor) through
|
||||||
|
WorkflowRunner.execute() with a fake adapter, proving the acceptance
|
||||||
|
criteria end-to-end against the real database:
|
||||||
|
|
||||||
|
- a good delivery is accepted and the run completes with quality_decision=accept;
|
||||||
|
- a short / evidence-free delivery is sent back, reworked up to the frozen cap,
|
||||||
|
reassigned to a backup seat once, and finally ends as a traceable
|
||||||
|
controlled_gap that is carried into the final report (never as a fact);
|
||||||
|
- an ``ask_user`` fallback pauses the run in waiting_input;
|
||||||
|
- a required task left non-terminal blocks the phase quality gate;
|
||||||
|
- a missing/invalid manager review NEVER auto-accepts: the server forces
|
||||||
|
system reworks and exhausts into a controlled gap (source=system events).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from maf_test_helpers import make_maf_service
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.maf_roundtable.deerflow_agent_adapter import SeatResponse
|
||||||
|
from app.maf_roundtable.workflow_executors import QualityGateExecutor, RoundtableState
|
||||||
|
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
||||||
|
from deerflow.persistence.maf_roundtable.model import (
|
||||||
|
Base,
|
||||||
|
MafRoundtableParticipantRow,
|
||||||
|
MafRoundtableRunRow,
|
||||||
|
MafRoundtableSessionRow,
|
||||||
|
)
|
||||||
|
|
||||||
|
MANAGER = "manager-1"
|
||||||
|
SEATS = ["seat-a", "seat-b", "seat-c"]
|
||||||
|
|
||||||
|
# ``@TOOL_REF@`` is replaced per-turn with ``tool:kb_search:<turn>`` — the
|
||||||
|
# trusted evidence ref created by the seat's (fake) successful tool call, so a
|
||||||
|
# good delivery cites ONLY evidence the fact ledger actually backs (P1).
|
||||||
|
GOOD_DELIVERY = json.dumps({
|
||||||
|
"summary": "结论:该市场存在明确增长机会,建议优先进入华东区域并配套本地化运营资源投入,同时预留华南试点预算",
|
||||||
|
"findings": ["依据一:华东区销售数据连续三个月增长", "依据二:竞品在华东投放低于均值"],
|
||||||
|
"evidence_refs": ["@TOOL_REF@"],
|
||||||
|
"open_questions": [],
|
||||||
|
"self_check": "已覆盖全部验收标准",
|
||||||
|
})
|
||||||
|
SHORT_DELIVERY = json.dumps({"summary": "好", "findings": []})
|
||||||
|
|
||||||
|
DISPATCH_PARALLEL = {
|
||||||
|
"intent_type": "parallel_consultation",
|
||||||
|
"selected_seat_agent_ids": ["seat-a", "seat-b"],
|
||||||
|
"assignments": [
|
||||||
|
{"agent_id": "seat-a", "task_goal": "市场增长机会分析", "delivery_requirements": ["证据", "优先级"], "order": 1},
|
||||||
|
{"agent_id": "seat-b", "task_goal": "竞品定价分析", "delivery_requirements": ["证据", "建议"], "order": 2},
|
||||||
|
],
|
||||||
|
"execution_mode": "parallel",
|
||||||
|
"convergence_path": ["manager synthesis"],
|
||||||
|
"reason": "需要两位专家",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def q_svc(tmp_path):
|
||||||
|
"""Service with a V2 session: manager + 3 candidates, quality gate ON."""
|
||||||
|
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'q4.db'}", echo=False)
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
sf = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
store = make_maf_roundtable_store(sf)
|
||||||
|
service = make_maf_service(store)
|
||||||
|
now = datetime(2026, 1, 1)
|
||||||
|
async with sf() as session:
|
||||||
|
session.add(MafRoundtableSessionRow(
|
||||||
|
id="q-sess", user_id="u1", title="q", task_text="分析市场趋势",
|
||||||
|
mode="structured", status="pending", write_policy="auto",
|
||||||
|
manager_agent_id=MANAGER, max_rounds=8, created_at=now, updated_at=now,
|
||||||
|
task_payload_json={
|
||||||
|
"maf_v2": {
|
||||||
|
"dispatch_contract_enabled": True,
|
||||||
|
"quality_gate_enabled": True,
|
||||||
|
"candidate_set": {
|
||||||
|
"manager_agent_id": MANAGER,
|
||||||
|
"candidate_seat_agent_ids": list(SEATS),
|
||||||
|
"mode": "structured",
|
||||||
|
"max_rounds": 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
))
|
||||||
|
session.add(MafRoundtableRunRow(
|
||||||
|
id="q-run", session_id="q-sess", status="pending",
|
||||||
|
created_at=now, updated_at=now,
|
||||||
|
))
|
||||||
|
for i, agent_id in enumerate([MANAGER, *SEATS]):
|
||||||
|
session.add(MafRoundtableParticipantRow(
|
||||||
|
id=f"qp-{i}", session_id="q-sess", agent_id=agent_id,
|
||||||
|
role="manager" if agent_id == MANAGER else "seat",
|
||||||
|
sort_order=i, created_at=now, updated_at=now,
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
yield service
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_q_runner(
|
||||||
|
q_svc,
|
||||||
|
monkeypatch,
|
||||||
|
*,
|
||||||
|
deliveries: dict[str, list[str]],
|
||||||
|
review_plan: list[dict[str, dict[str, object]]],
|
||||||
|
fallback_policy: str = "reassign_then_gap",
|
||||||
|
cross_review_ok: bool = True,
|
||||||
|
emit_real: bool = False,
|
||||||
|
config_overrides: dict | None = None,
|
||||||
|
):
|
||||||
|
"""Runner with a fake adapter driven by explicit delivery/review plans.
|
||||||
|
|
||||||
|
``deliveries``: agent_id → list of JSON payloads, one per delivery attempt.
|
||||||
|
``review_plan``: one entry per quality round → {agent_id: {"decision", ...}}.
|
||||||
|
"""
|
||||||
|
from app.maf_roundtable.workflow_runner import WorkflowRunner
|
||||||
|
|
||||||
|
app = SimpleNamespace(state=SimpleNamespace(maf_roundtable_service=q_svc, checkpointer=None))
|
||||||
|
runner = WorkflowRunner(app, service=q_svc, user_id="u1")
|
||||||
|
|
||||||
|
calls: list[tuple[str, str, int]] = []
|
||||||
|
stage_messages: dict[str, list[str]] = {}
|
||||||
|
delivery_idx: dict[str, int] = {}
|
||||||
|
review_round: list[int] = [0]
|
||||||
|
|
||||||
|
cfg_values = {
|
||||||
|
"seat_timeout_seconds": 5,
|
||||||
|
"schema_repair_enabled": True,
|
||||||
|
"quality_gate_enabled": True,
|
||||||
|
"max_rework_attempts": 2,
|
||||||
|
"max_reassignments": 1,
|
||||||
|
"min_delivery_chars": 40,
|
||||||
|
"min_delivery_evidence": 1,
|
||||||
|
"default_fallback_policy": fallback_policy,
|
||||||
|
}
|
||||||
|
cfg_values.update(config_overrides or {})
|
||||||
|
fake_cfg = SimpleNamespace(maf_roundtable=SimpleNamespace(**cfg_values))
|
||||||
|
monkeypatch.setattr("deerflow.config.get_app_config", lambda: fake_cfg)
|
||||||
|
|
||||||
|
async def _run_seat(**kwargs):
|
||||||
|
stage = kwargs.get("execution_stage", "")
|
||||||
|
agent_id = kwargs["agent_id"]
|
||||||
|
turn_index = kwargs["turn_index"]
|
||||||
|
calls.append((stage, agent_id, turn_index))
|
||||||
|
stage_messages.setdefault(stage, []).append(str(kwargs.get("message") or ""))
|
||||||
|
if stage == "manager_dispatch":
|
||||||
|
return SeatResponse(agent_id=agent_id, turn_index=0, content=json.dumps(DISPATCH_PARALLEL), tool_calls=[], usage={"input_tokens": 1, "output_tokens": 1})
|
||||||
|
if stage in ("expert", "rework"):
|
||||||
|
seq = deliveries.get(agent_id) or [GOOD_DELIVERY]
|
||||||
|
idx = delivery_idx.get(agent_id, 0)
|
||||||
|
delivery_idx[agent_id] = idx + 1
|
||||||
|
content = seq[min(idx, len(seq) - 1)]
|
||||||
|
# The seat ran a real (fake) tool this turn: its successful result
|
||||||
|
# becomes the trusted evidence ref the delivery may cite.
|
||||||
|
content = content.replace("@TOOL_REF@", f"tool:kb_search:{turn_index}")
|
||||||
|
return SeatResponse(
|
||||||
|
agent_id=agent_id,
|
||||||
|
turn_index=turn_index,
|
||||||
|
content=content,
|
||||||
|
tool_calls=[{"id": f"tc-{agent_id}-{turn_index}", "name": "kb_search"}],
|
||||||
|
usage={"input_tokens": 1, "output_tokens": 1},
|
||||||
|
)
|
||||||
|
if stage == "manager_review":
|
||||||
|
rnd = review_round[0]
|
||||||
|
review_round[0] = rnd + 1
|
||||||
|
plan = review_plan[rnd] if rnd < len(review_plan) else {}
|
||||||
|
decisions = [
|
||||||
|
{
|
||||||
|
"agent_id": agent,
|
||||||
|
"decision": d.get("decision", "accept"),
|
||||||
|
"reason": str(d.get("reason") or "r")[:1000],
|
||||||
|
"revision_requirements": list(d.get("requirements") or []),
|
||||||
|
}
|
||||||
|
for agent, d in plan.items()
|
||||||
|
]
|
||||||
|
return SeatResponse(agent_id=agent_id, turn_index=turn_index, content=json.dumps({"decisions": decisions}), tool_calls=[], usage={"input_tokens": 1, "output_tokens": 1})
|
||||||
|
if stage == "cross_review":
|
||||||
|
content = (
|
||||||
|
json.dumps({"summary": "ok", "evidence_ok": True, "confirmed_candidates": []})
|
||||||
|
if cross_review_ok
|
||||||
|
else json.dumps({"summary": "有缺口", "evidence_ok": False, "gaps": ["证据不足"], "confirmed_candidates": []})
|
||||||
|
)
|
||||||
|
return SeatResponse(agent_id=agent_id, turn_index=turn_index, content=content, tool_calls=[], usage={"input_tokens": 1, "output_tokens": 1})
|
||||||
|
if stage == "finalizer":
|
||||||
|
return SeatResponse(agent_id=agent_id, turn_index=turn_index, content="final report", tool_calls=[], usage={"input_tokens": 1, "output_tokens": 1})
|
||||||
|
return SeatResponse(agent_id=agent_id, turn_index=turn_index, content=GOOD_DELIVERY, tool_calls=[], usage={"input_tokens": 1, "output_tokens": 1})
|
||||||
|
|
||||||
|
ensure_threads_calls: list[list[str]] = []
|
||||||
|
|
||||||
|
async def _ensure_threads(session_id, run_id, participants):
|
||||||
|
agent_ids = [p["agent_id"] for p in participants]
|
||||||
|
ensure_threads_calls.append(agent_ids)
|
||||||
|
result = {}
|
||||||
|
for participant in participants:
|
||||||
|
agent_id = participant["agent_id"]
|
||||||
|
thread_id = f"thread-{agent_id}"
|
||||||
|
await q_svc.store.participants.bind_thread(session_id, agent_id, thread_id=thread_id)
|
||||||
|
result[agent_id] = thread_id
|
||||||
|
return result
|
||||||
|
|
||||||
|
monkeypatch.setattr(runner._adapter, "run_seat_turn", AsyncMock(side_effect=_run_seat))
|
||||||
|
monkeypatch.setattr(runner._adapter, "ensure_threads", AsyncMock(side_effect=_ensure_threads))
|
||||||
|
if emit_real:
|
||||||
|
real_emit_critical = runner._adapter._emit_critical
|
||||||
|
monkeypatch.setattr(runner._adapter, "_emit_critical", AsyncMock(side_effect=real_emit_critical))
|
||||||
|
else:
|
||||||
|
monkeypatch.setattr(runner._adapter, "_emit_critical", AsyncMock())
|
||||||
|
monkeypatch.setattr(runner._adapter, "_emit", AsyncMock())
|
||||||
|
runner.stage_messages = stage_messages
|
||||||
|
return runner, calls, ensure_threads_calls
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(q_svc, monkeypatch, **kwargs):
|
||||||
|
runner, calls, ensure_threads_calls = _make_q_runner(q_svc, monkeypatch, **kwargs)
|
||||||
|
session = await q_svc.store.sessions.get_session("q-sess", user_id="u1")
|
||||||
|
run = await q_svc.store.runs.get_run("q-run")
|
||||||
|
await runner.execute(session, run)
|
||||||
|
final_run = await q_svc.store.runs.get_run("q-run")
|
||||||
|
final_session = await q_svc.store.sessions.get_session("q-sess", user_id="u1")
|
||||||
|
return runner, calls, ensure_threads_calls, final_run, final_session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runner_quality_accepts_good_deliveries(q_svc, monkeypatch):
|
||||||
|
pytest.importorskip("agent_framework")
|
||||||
|
|
||||||
|
_, calls, _, final_run, final_session = await _run(
|
||||||
|
q_svc, monkeypatch,
|
||||||
|
deliveries={"seat-a": [GOOD_DELIVERY], "seat-b": [GOOD_DELIVERY]},
|
||||||
|
review_plan=[{"seat-a": {"decision": "accept"}, "seat-b": {"decision": "accept"}}],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert final_run["status"] == "completed"
|
||||||
|
assert final_session["status"] == "completed"
|
||||||
|
|
||||||
|
tasks = await q_svc.store.seat_tasks.list_by_run("q-run")
|
||||||
|
by_key = {t["task_key"]: t for t in tasks}
|
||||||
|
assert set(by_key) == {"01:seat-a", "02:seat-b"}
|
||||||
|
assert all(t["status"] == "accepted" for t in tasks)
|
||||||
|
assert all(t["attempt_count"] == 1 for t in tasks)
|
||||||
|
assert all(t["rework_count"] == 0 for t in tasks)
|
||||||
|
|
||||||
|
checkpoint = final_run.get("maf_checkpoint") or {}
|
||||||
|
assert checkpoint.get("seat_task_statuses", {}).get("01:seat-a", {}).get("status") == "accepted"
|
||||||
|
assert checkpoint.get("controlled_gaps") == []
|
||||||
|
|
||||||
|
result = final_session.get("final_result") or {}
|
||||||
|
assert result.get("quality_decision") == "accept"
|
||||||
|
assert result.get("controlled_gaps") == []
|
||||||
|
|
||||||
|
review_stages = [s for s, a, t in calls if s == "manager_review"]
|
||||||
|
assert len(review_stages) == 1, "one batched manager review round"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2. short answer → reworks → reassign → controlled gap ───────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runner_quality_rework_reassign_controlled_gap(q_svc, monkeypatch):
|
||||||
|
pytest.importorskip("agent_framework")
|
||||||
|
|
||||||
|
# seat-a stays short across every attempt; seat-b is always good.
|
||||||
|
# Manager keeps returning return_for_rework for seat-a (cap 2), then seat-a
|
||||||
|
# is reassigned to seat-c whose delivery also fails → controlled gap.
|
||||||
|
_, calls, ensure_threads_calls, final_run, final_session = await _run(
|
||||||
|
q_svc, monkeypatch,
|
||||||
|
deliveries={
|
||||||
|
"seat-a": [SHORT_DELIVERY, SHORT_DELIVERY, SHORT_DELIVERY, SHORT_DELIVERY],
|
||||||
|
"seat-b": [GOOD_DELIVERY],
|
||||||
|
"seat-c": [SHORT_DELIVERY],
|
||||||
|
},
|
||||||
|
review_plan=[
|
||||||
|
{"seat-a": {"decision": "return_for_rework", "reason": "太短", "requirements": ["请补充证据"]}, "seat-b": {"decision": "accept"}},
|
||||||
|
{"seat-a": {"decision": "return_for_rework", "reason": "仍太短", "requirements": ["请展开分析"]}},
|
||||||
|
{"seat-a": {"decision": "return_for_rework", "reason": "第三次不合格"}},
|
||||||
|
{"seat-c": {"decision": "return_for_rework", "reason": "新席位也短"}},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run completes via the gap path (controlled gaps do NOT block the phase).
|
||||||
|
assert final_run["status"] == "completed"
|
||||||
|
tasks = await q_svc.store.seat_tasks.list_by_run("q-run")
|
||||||
|
by_key = {t["task_key"]: t for t in tasks}
|
||||||
|
|
||||||
|
task_a = by_key["01:seat-a"]
|
||||||
|
assert task_a["status"] == "controlled_gap"
|
||||||
|
assert task_a["attempt_count"] == 4 # seat-a 3 + seat-c 1
|
||||||
|
assert task_a["rework_count"] == 2 # capped at max_rework_attempts
|
||||||
|
assert task_a["reassign_count"] == 1 # exactly one reassignment
|
||||||
|
assert task_a["agent_id"] == "seat-c" # reassigned to backup
|
||||||
|
assert task_a["original_agent_id"] == "seat-a"
|
||||||
|
assert task_a["gap"] is not None
|
||||||
|
assert "不得作为已验证事实" in task_a["gap"]["risk"]
|
||||||
|
|
||||||
|
task_b = by_key["02:seat-b"]
|
||||||
|
assert task_b["status"] == "accepted"
|
||||||
|
assert task_b["attempt_count"] == 1
|
||||||
|
|
||||||
|
# seat-c received a thread (backup handover) and delivered exactly once.
|
||||||
|
assert "seat-c" in [aid for call in ensure_threads_calls for aid in call]
|
||||||
|
seat_c_turns = [(s, a, t) for s, a, t in calls if a == "seat-c" and s in ("expert", "rework")]
|
||||||
|
assert len(seat_c_turns) == 1
|
||||||
|
|
||||||
|
# The quality gate passed with gaps and the final report carries them.
|
||||||
|
checkpoint = final_run.get("maf_checkpoint") or {}
|
||||||
|
gaps = checkpoint.get("controlled_gaps") or []
|
||||||
|
assert len(gaps) == 1
|
||||||
|
assert gaps[0]["task_key"] == "01:seat-a"
|
||||||
|
|
||||||
|
result = final_session.get("final_result") or {}
|
||||||
|
assert result.get("quality_decision") == "controlled_gap"
|
||||||
|
assert len(result.get("controlled_gaps") or []) == 1
|
||||||
|
assert result.get("degraded") is True
|
||||||
|
|
||||||
|
# Manager review was batched once per quality round.
|
||||||
|
review_stages = [t for s, a, t in calls if s == "manager_review"]
|
||||||
|
assert len(review_stages) == 4
|
||||||
|
|
||||||
|
|
||||||
|
# ── 3. ask_user fallback pauses in waiting_input ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runner_quality_ask_user_waits_for_input(q_svc, monkeypatch):
|
||||||
|
pytest.importorskip("agent_framework")
|
||||||
|
|
||||||
|
_, calls, _, final_run, final_session = await _run(
|
||||||
|
q_svc, monkeypatch,
|
||||||
|
deliveries={"seat-a": [SHORT_DELIVERY, SHORT_DELIVERY, SHORT_DELIVERY], "seat-b": [GOOD_DELIVERY]},
|
||||||
|
review_plan=[
|
||||||
|
{"seat-a": {"decision": "return_for_rework", "reason": "太短"}, "seat-b": {"decision": "accept"}},
|
||||||
|
{"seat-a": {"decision": "return_for_rework", "reason": "仍太短"}},
|
||||||
|
{"seat-a": {"decision": "return_for_rework", "reason": "第三次不合格"}},
|
||||||
|
],
|
||||||
|
fallback_policy="ask_user",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert final_run["status"] == "waiting_input"
|
||||||
|
assert final_session["status"] == "waiting_input"
|
||||||
|
|
||||||
|
tasks = await q_svc.store.seat_tasks.list_by_run("q-run")
|
||||||
|
by_key = {t["task_key"]: t for t in tasks}
|
||||||
|
assert by_key["01:seat-a"]["status"] == "exhausted"
|
||||||
|
assert by_key["02:seat-b"]["status"] == "accepted"
|
||||||
|
|
||||||
|
# The finalizer must never run on the ask_user path.
|
||||||
|
finalizer_stages = [s for s, a, t in calls if s == "finalizer"]
|
||||||
|
assert finalizer_stages == []
|
||||||
|
# No finished_at for a non-terminal pause.
|
||||||
|
assert final_run.get("finished_at") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 4. phase gate blocks on non-terminal required task ──────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_quality_gate_blocks_non_terminal_required_task(q_svc, monkeypatch):
|
||||||
|
pytest.importorskip("agent_framework")
|
||||||
|
|
||||||
|
state = RoundtableState(
|
||||||
|
session_id="q-sess", run_id="q-run", task_text="t",
|
||||||
|
mode="structured", model_name=None, reasoning_effort=None,
|
||||||
|
max_rounds=8, write_policy="auto", manager_agent_id=MANAGER,
|
||||||
|
seat_agent_ids=["seat-a"], thread_map={}, capability_snapshot=None,
|
||||||
|
seat_timeout_seconds=5, schema_repair_enabled=True,
|
||||||
|
intent_type="parallel_consultation",
|
||||||
|
dispatch_plan={"execution_mode": "parallel"},
|
||||||
|
dispatch_contract_enabled=True, quality_gate_enabled=True,
|
||||||
|
)
|
||||||
|
state.seat_task_statuses = {
|
||||||
|
"01:seat-a": {
|
||||||
|
"task_key": "01:seat-a", "status": "submitted",
|
||||||
|
"agent_id": "seat-a", "required": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
adapter = SimpleNamespace(
|
||||||
|
_service=SimpleNamespace(store=SimpleNamespace(seat_tasks=SimpleNamespace())),
|
||||||
|
_emit_critical=AsyncMock(),
|
||||||
|
)
|
||||||
|
gate = QualityGateExecutor(adapter, state)
|
||||||
|
ctx = SimpleNamespace(send_message=AsyncMock(), yield_output=AsyncMock())
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="quality gate blocked"):
|
||||||
|
await gate.handle({}, ctx)
|
||||||
|
|
||||||
|
# The blocked gate must not masquerade as passed.
|
||||||
|
assert state.status != "completed"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 5. invalid/missing manager review NEVER auto-accepts ───────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _good_variant(n: int) -> str:
|
||||||
|
"""A deterministic-pass delivery whose conclusion differs per attempt, so
|
||||||
|
duplicate detection never interferes with the missing-review behaviour."""
|
||||||
|
return json.dumps({
|
||||||
|
"summary": (
|
||||||
|
f"结论{n}:该市场存在明确增长机会,建议优先进入华东区域并配套本地化运营资源投入,"
|
||||||
|
f"同时预留华南试点预算,第{n}次补充细化执行路径与里程碑安排"
|
||||||
|
),
|
||||||
|
"findings": [f"依据{n}:华东区销售数据连续三个月增长", "依据二:竞品在华东投放低于均值"],
|
||||||
|
"evidence_refs": ["@TOOL_REF@"],
|
||||||
|
"open_questions": [],
|
||||||
|
"self_check": "已覆盖全部验收标准",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runner_quality_missing_review_never_accepts(q_svc, monkeypatch):
|
||||||
|
pytest.importorskip("agent_framework")
|
||||||
|
|
||||||
|
# The manager review returns garbage (empty decisions) every round. Even
|
||||||
|
# though every delivery passes the deterministic length/format/evidence
|
||||||
|
# checks, NO task may be accepted without a valid manager review: the
|
||||||
|
# server forces system reworks, then exhausts into controlled gaps.
|
||||||
|
variants_a = [_good_variant(i) for i in range(1, 7)]
|
||||||
|
variants_b = [_good_variant(i + 10) for i in range(1, 7)]
|
||||||
|
_, calls, _, final_run, final_session = await _run(
|
||||||
|
q_svc, monkeypatch,
|
||||||
|
deliveries={
|
||||||
|
"seat-a": variants_a + [_good_variant(90)],
|
||||||
|
"seat-b": variants_b + [_good_variant(91)],
|
||||||
|
"seat-c": [_good_variant(80)],
|
||||||
|
},
|
||||||
|
review_plan=[{}],
|
||||||
|
emit_real=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks = await q_svc.store.seat_tasks.list_by_run("q-run")
|
||||||
|
assert tasks, "seat tasks must exist"
|
||||||
|
for task in tasks:
|
||||||
|
assert task["status"] != "accepted", (
|
||||||
|
f"task {task['task_key']} was accepted without a valid manager review"
|
||||||
|
)
|
||||||
|
assert task["status"] == "controlled_gap"
|
||||||
|
assert task["rework_count"] == 2
|
||||||
|
assert task["reassign_count"] == 1
|
||||||
|
decision = task.get("review_decision") or {}
|
||||||
|
assert decision.get("source") == "system"
|
||||||
|
assert decision.get("reason") == "manager_review_missing_or_invalid"
|
||||||
|
|
||||||
|
# The run still terminates (gaps do not block the phase) — but only as a
|
||||||
|
# degraded controlled-gap result, never as a clean accept.
|
||||||
|
assert final_run["status"] == "completed"
|
||||||
|
result = final_session.get("final_result") or {}
|
||||||
|
assert result.get("quality_decision") == "controlled_gap"
|
||||||
|
assert len(result.get("controlled_gaps") or []) == len(tasks)
|
||||||
|
|
||||||
|
# The persisted review events carry source=system + the safe reason.
|
||||||
|
events = await q_svc.store.events.list_events(run_id="q-run")
|
||||||
|
review_events = [e for e in events if e["event_type"] == "manager.review.decision"]
|
||||||
|
assert review_events
|
||||||
|
system_events = [e for e in review_events if e["payload"].get("source") == "system"]
|
||||||
|
assert system_events
|
||||||
|
assert all(
|
||||||
|
e["payload"].get("reason") == "manager_review_missing_or_invalid"
|
||||||
|
for e in system_events
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 6. unaccepted deliveries never reach facts / review / final report ─────
|
||||||
|
|
||||||
|
|
||||||
|
PLAUSIBLE_GAP_DELIVERY = json.dumps({
|
||||||
|
# Fails below_min_length on purpose, but carries "plausible" findings and
|
||||||
|
# an invented evidence ref — none of it may survive into the ledger, the
|
||||||
|
# cross-review prompt or the final synthesis.
|
||||||
|
"summary": "好",
|
||||||
|
"findings": ["PLAUSIBLE_FINDING: 已核实华东区增长数据并确认竞品全面撤离"],
|
||||||
|
"evidence_refs": ["plausible-ref-001"],
|
||||||
|
"open_questions": [],
|
||||||
|
"self_check": "ok",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runner_quality_gap_content_isolated_from_facts_and_report(q_svc, monkeypatch):
|
||||||
|
pytest.importorskip("agent_framework")
|
||||||
|
|
||||||
|
runner, calls, _, final_run, final_session = await _run(
|
||||||
|
q_svc, monkeypatch,
|
||||||
|
deliveries={
|
||||||
|
"seat-a": [PLAUSIBLE_GAP_DELIVERY],
|
||||||
|
"seat-b": [GOOD_DELIVERY],
|
||||||
|
"seat-c": [PLAUSIBLE_GAP_DELIVERY],
|
||||||
|
},
|
||||||
|
review_plan=[
|
||||||
|
{"seat-a": {"decision": "return_for_rework", "reason": "太短"}, "seat-b": {"decision": "accept"}},
|
||||||
|
{"seat-a": {"decision": "return_for_rework", "reason": "仍太短"}},
|
||||||
|
{"seat-a": {"decision": "return_for_rework", "reason": "第三次不合格"}},
|
||||||
|
{"seat-c": {"decision": "return_for_rework", "reason": "新席位也不合格"}},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert final_run["status"] == "completed"
|
||||||
|
tasks = await q_svc.store.seat_tasks.list_by_run("q-run")
|
||||||
|
by_key = {t["task_key"]: t for t in tasks}
|
||||||
|
assert by_key["01:seat-a"]["status"] == "controlled_gap"
|
||||||
|
assert by_key["02:seat-b"]["status"] == "accepted"
|
||||||
|
|
||||||
|
# 1) Fact ledger: the gapped seat's claims/refs are absent; the accepted
|
||||||
|
# seat's findings ARE present (merge happens on acceptance).
|
||||||
|
ledger = final_session.get("fact_ledger") or {}
|
||||||
|
candidate_texts = [
|
||||||
|
str(c.get("text") or "") for c in ledger.get("candidate_facts") or []
|
||||||
|
]
|
||||||
|
confirmed_texts = [
|
||||||
|
str(f.get("text") or "") for f in ledger.get("confirmed_facts") or []
|
||||||
|
]
|
||||||
|
evidence_texts = [
|
||||||
|
str(e.get("ref") or e.get("text") or "") for e in ledger.get("evidence") or []
|
||||||
|
]
|
||||||
|
assert not any("PLAUSIBLE_FINDING" in t for t in candidate_texts)
|
||||||
|
assert not any("PLAUSIBLE_FINDING" in t for t in confirmed_texts)
|
||||||
|
assert not any("plausible-ref-001" in t for t in evidence_texts)
|
||||||
|
assert any("华东区销售数据连续三个月增长" in t for t in candidate_texts)
|
||||||
|
|
||||||
|
# 2) Cross-review prompt: accepted seat only, never the gapped content.
|
||||||
|
cross_review_msgs = runner.stage_messages.get("cross_review") or []
|
||||||
|
assert cross_review_msgs
|
||||||
|
assert all("PLAUSIBLE_FINDING" not in m for m in cross_review_msgs)
|
||||||
|
assert all("plausible-ref-001" not in m for m in cross_review_msgs)
|
||||||
|
assert any("华东区销售数据连续三个月增长" in m for m in cross_review_msgs)
|
||||||
|
|
||||||
|
# 3) Final synthesis: accepted seat only + traceable gap risk section.
|
||||||
|
finalizer_msgs = runner.stage_messages.get("finalizer") or []
|
||||||
|
assert finalizer_msgs
|
||||||
|
assert all("PLAUSIBLE_FINDING" not in m for m in finalizer_msgs)
|
||||||
|
assert all("plausible-ref-001" not in m for m in finalizer_msgs)
|
||||||
|
assert any("01:seat-a" in m for m in finalizer_msgs), "gap must be traceable"
|
||||||
|
assert any("受控缺口" in m for m in finalizer_msgs)
|
||||||
|
|
||||||
|
# 4) Checkpoint: exactly one accepted result (the accepted seat).
|
||||||
|
checkpoint = final_run.get("maf_checkpoint") or {}
|
||||||
|
accepted_results = checkpoint.get("accepted_task_results") or []
|
||||||
|
assert len(accepted_results) == 1
|
||||||
|
assert accepted_results[0]["task_key"] == "02:seat-b"
|
||||||
|
assert accepted_results[0]["accepted"] is True
|
||||||
|
assert "PLAUSIBLE_FINDING" not in str(accepted_results)
|
||||||
|
|
||||||
|
# 5) Final result carries the gap as risk, never as a verified fact.
|
||||||
|
result = final_session.get("final_result") or {}
|
||||||
|
assert result.get("quality_decision") == "controlled_gap"
|
||||||
|
gaps = result.get("controlled_gaps") or []
|
||||||
|
assert len(gaps) == 1
|
||||||
|
assert gaps[0]["task_key"] == "01:seat-a"
|
||||||
|
assert "不得作为已验证事实" in gaps[0]["risk"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runner_quality_events_persisted_and_projected(q_svc, monkeypatch):
|
||||||
|
"""The Stage-4 audit events persist to maf_roundtable_events."""
|
||||||
|
pytest.importorskip("agent_framework")
|
||||||
|
|
||||||
|
_, calls, _, final_run, final_session = await _run(
|
||||||
|
q_svc, monkeypatch,
|
||||||
|
deliveries={"seat-a": [SHORT_DELIVERY], "seat-b": [GOOD_DELIVERY]},
|
||||||
|
review_plan=[{"seat-a": {"decision": "return_for_rework", "requirements": ["补证据"]}, "seat-b": {"decision": "accept"}}],
|
||||||
|
emit_real=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
events = await q_svc.store.events.list_events(run_id="q-run")
|
||||||
|
types = {e["event_type"] for e in events}
|
||||||
|
assert "seat.delivery.submitted" in types
|
||||||
|
assert "manager.review.decision" in types
|
||||||
|
assert "seat.rework.assigned" in types
|
||||||
|
assert "stage.quality_gate" in types
|
||||||
|
|
||||||
|
# The short delivery was flagged deterministically in the persisted event.
|
||||||
|
deliveries = [e for e in events if e["event_type"] == "seat.delivery.submitted"]
|
||||||
|
seat_a = [e for e in deliveries if e.get("agent_id") == "seat-a"]
|
||||||
|
assert seat_a and seat_a[0]["payload"].get("deterministic_passed") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── 7. P1: zero config values are honoured, not coerced to defaults ────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_quality_settings_honour_zero_config_values(monkeypatch):
|
||||||
|
from app.maf_roundtable.workflow_runner import WorkflowRunner
|
||||||
|
|
||||||
|
fake_cfg = SimpleNamespace(maf_roundtable=SimpleNamespace(
|
||||||
|
quality_gate_enabled=True,
|
||||||
|
max_rework_attempts=0,
|
||||||
|
max_reassignments=0,
|
||||||
|
min_delivery_chars=0,
|
||||||
|
min_delivery_evidence=0,
|
||||||
|
default_fallback_policy="controlled_gap",
|
||||||
|
))
|
||||||
|
monkeypatch.setattr("deerflow.config.get_app_config", lambda: fake_cfg)
|
||||||
|
|
||||||
|
runner = WorkflowRunner.__new__(WorkflowRunner) # no __init__ side effects
|
||||||
|
settings = runner._quality_settings()
|
||||||
|
assert settings["max_rework_attempts"] == 0
|
||||||
|
assert settings["max_reassignments"] == 0
|
||||||
|
assert settings["min_delivery_chars"] == 0
|
||||||
|
assert settings["min_delivery_evidence"] == 0
|
||||||
|
assert settings["fallback_policy"] == "controlled_gap"
|
||||||
|
|
||||||
|
|
||||||
|
def test_quality_settings_none_falls_back_to_defaults(monkeypatch):
|
||||||
|
from app.maf_roundtable.workflow_runner import WorkflowRunner
|
||||||
|
|
||||||
|
# Attributes absent entirely (getattr → None) must fall back to defaults.
|
||||||
|
fake_cfg = SimpleNamespace(maf_roundtable=SimpleNamespace(
|
||||||
|
quality_gate_enabled=True,
|
||||||
|
))
|
||||||
|
monkeypatch.setattr("deerflow.config.get_app_config", lambda: fake_cfg)
|
||||||
|
|
||||||
|
runner = WorkflowRunner.__new__(WorkflowRunner)
|
||||||
|
settings = runner._quality_settings()
|
||||||
|
assert settings["max_rework_attempts"] == 2
|
||||||
|
assert settings["max_reassignments"] == 1
|
||||||
|
assert settings["min_delivery_chars"] == 40
|
||||||
|
assert settings["min_delivery_evidence"] == 1
|
||||||
|
assert settings["fallback_policy"] == "reassign_then_gap"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runner_quality_zero_rework_cap_no_rework_granted(q_svc, monkeypatch):
|
||||||
|
pytest.importorskip("agent_framework")
|
||||||
|
|
||||||
|
# max_rework_attempts=0 must be honoured: a failing delivery is NOT sent
|
||||||
|
# back for rework — it exhausts straight into the fallback (controlled gap
|
||||||
|
# here, no backup reassignment budget either).
|
||||||
|
_, calls, _, final_run, final_session = await _run(
|
||||||
|
q_svc, monkeypatch,
|
||||||
|
deliveries={"seat-a": [SHORT_DELIVERY], "seat-b": [GOOD_DELIVERY]},
|
||||||
|
review_plan=[
|
||||||
|
{"seat-a": {"decision": "return_for_rework", "reason": "太短"}, "seat-b": {"decision": "accept"}},
|
||||||
|
],
|
||||||
|
config_overrides={"max_rework_attempts": 0, "max_reassignments": 0},
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks = await q_svc.store.seat_tasks.list_by_run("q-run")
|
||||||
|
by_key = {t["task_key"]: t for t in tasks}
|
||||||
|
task_a = by_key["01:seat-a"]
|
||||||
|
assert task_a["status"] == "controlled_gap"
|
||||||
|
assert task_a["attempt_count"] == 1, "no rework delivery may happen with a 0 cap"
|
||||||
|
assert task_a["rework_count"] == 0
|
||||||
|
assert task_a["reassign_count"] == 0
|
||||||
|
# Only the initial delivery + the (ignored) rework request — no rework turn.
|
||||||
|
expert_turns_a = [s for s, a, _t in calls if a == "seat-a" and s in ("expert", "rework")]
|
||||||
|
assert len(expert_turns_a) == 1
|
||||||
|
|
||||||
|
result = final_session.get("final_result") or {}
|
||||||
|
assert result.get("quality_decision") == "controlled_gap"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runner_quality_zero_min_evidence_accepts_refless_delivery(q_svc, monkeypatch):
|
||||||
|
pytest.importorskip("agent_framework")
|
||||||
|
|
||||||
|
# min_delivery_evidence=0 must be honoured: a conclusion-only delivery with
|
||||||
|
# no evidence refs passes the deterministic evidence gate.
|
||||||
|
no_ref_delivery = json.dumps({
|
||||||
|
"summary": "结论:该市场存在明确增长机会,建议优先进入华东区域并配套本地化运营资源投入,同时预留华南试点预算",
|
||||||
|
"findings": [],
|
||||||
|
"evidence_refs": [],
|
||||||
|
"open_questions": [],
|
||||||
|
"self_check": "ok",
|
||||||
|
})
|
||||||
|
_, _calls, _, final_run, _final_session = await _run(
|
||||||
|
q_svc, monkeypatch,
|
||||||
|
deliveries={"seat-a": [no_ref_delivery], "seat-b": [no_ref_delivery]},
|
||||||
|
review_plan=[{"seat-a": {"decision": "accept"}, "seat-b": {"decision": "accept"}}],
|
||||||
|
config_overrides={"min_delivery_evidence": 0},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert final_run["status"] == "completed"
|
||||||
|
tasks = await q_svc.store.seat_tasks.list_by_run("q-run")
|
||||||
|
assert all(t["status"] == "accepted" for t in tasks)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 8. P1: restart recovery re-submission hits duplicate via digest ────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runner_quality_restart_resubmission_hits_duplicate_digest(q_svc, monkeypatch):
|
||||||
|
pytest.importorskip("agent_framework")
|
||||||
|
|
||||||
|
from app.maf_roundtable.quality_contracts import build_task_contract, conclusion_digest
|
||||||
|
|
||||||
|
good = json.loads(GOOD_DELIVERY.replace("@TOOL_REF@", "tool:kb_search:1"))
|
||||||
|
digest = conclusion_digest(good["summary"])
|
||||||
|
|
||||||
|
# Simulate the pre-restart state: seat-a's first delivery was rejected and
|
||||||
|
# its conclusion digest was persisted to the task history; then the process
|
||||||
|
# restarted. The task row (with history) survives in the DB.
|
||||||
|
contract = build_task_contract(
|
||||||
|
task_goal="市场增长机会分析", delivery_requirements=["证据", "优先级"],
|
||||||
|
max_rework_attempts=2, fallback_policy="reassign_then_gap",
|
||||||
|
)
|
||||||
|
seeded = await q_svc.store.seat_tasks.create_task(
|
||||||
|
session_id="q-sess", run_id="q-run", task_key="01:seat-a",
|
||||||
|
agent_id="seat-a", contract=contract.model_dump(), required=True,
|
||||||
|
)
|
||||||
|
await q_svc.store.seat_tasks.update_task(seeded["id"], {
|
||||||
|
"status": "rework",
|
||||||
|
"attempt_count": 1,
|
||||||
|
"rework_count": 1,
|
||||||
|
"history": [{
|
||||||
|
"attempt": 1, "agent_id": "seat-a", "round": 1,
|
||||||
|
"schema_status": "ok", "failure_codes": ["below_min_length"],
|
||||||
|
"conclusion_sha256": digest,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
|
||||||
|
# After the restart the seat re-submits the SAME conclusion. It must be
|
||||||
|
# flagged duplicate_content from the persisted digest — even though the
|
||||||
|
# in-memory prior_conclusions starts empty on a fresh process.
|
||||||
|
_, _calls, _, final_run, _final_session = await _run(
|
||||||
|
q_svc, monkeypatch,
|
||||||
|
deliveries={"seat-a": [GOOD_DELIVERY], "seat-b": [GOOD_DELIVERY]},
|
||||||
|
review_plan=[
|
||||||
|
{"seat-a": {"decision": "accept"}, "seat-b": {"decision": "accept"}},
|
||||||
|
{"seat-a": {"decision": "accept"}},
|
||||||
|
{"seat-a": {"decision": "accept"}},
|
||||||
|
{"seat-a": {"decision": "accept"}},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks = await q_svc.store.seat_tasks.list_by_run("q-run")
|
||||||
|
by_key = {t["task_key"]: t for t in tasks}
|
||||||
|
task_a = by_key["01:seat-a"]
|
||||||
|
# The re-submission was deterministically flagged as a duplicate.
|
||||||
|
check = task_a.get("deterministic_check") or {}
|
||||||
|
history_codes = [
|
||||||
|
code
|
||||||
|
for h in (task_a.get("history") or [])
|
||||||
|
for code in (h.get("failure_codes") or [])
|
||||||
|
]
|
||||||
|
assert "duplicate_content" in (check.get("failure_codes") or []) or "duplicate_content" in history_codes
|
||||||
|
# And a duplicate can never be accepted, even with the manager saying yes.
|
||||||
|
assert task_a["status"] != "accepted"
|
||||||
|
assert final_run["status"] == "completed"
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
"""Stage 4 (R3-Q) P0-4: atomic seat-task initialization and safe recovery.
|
||||||
|
|
||||||
|
The old initializer created tasks one by one and short-circuited with
|
||||||
|
``if existing: return existing`` — a crash after the first create permanently
|
||||||
|
dropped the remaining required seats on recovery, and the quality gate could
|
||||||
|
then pass on an incomplete task set.
|
||||||
|
|
||||||
|
These tests prove:
|
||||||
|
|
||||||
|
- ``get_or_create_tasks`` creates the FULL expected set in one transaction;
|
||||||
|
- after a simulated partial initialization (only the first task exists),
|
||||||
|
recovery fills the missing tasks and never touches existing rows;
|
||||||
|
- a frozen contract / lifecycle progress is NEVER overwritten;
|
||||||
|
- two concurrent recovery calls converge on one complete set without raising
|
||||||
|
unique-key violations;
|
||||||
|
- ``QualitySeatTasksExecutor._load_or_create_tasks`` derives the expected set
|
||||||
|
from the dispatch assignments and fills gaps;
|
||||||
|
- the quality gate blocks when an expected required task is missing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.maf_roundtable.workflow_executors import (
|
||||||
|
QualityGateExecutor,
|
||||||
|
QualitySeatTasksExecutor,
|
||||||
|
RoundtableState,
|
||||||
|
)
|
||||||
|
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
||||||
|
from deerflow.persistence.maf_roundtable.model import (
|
||||||
|
Base,
|
||||||
|
MafRoundtableRunRow,
|
||||||
|
MafRoundtableSessionRow,
|
||||||
|
)
|
||||||
|
|
||||||
|
CONTRACT_A = {
|
||||||
|
"objective": "原始合同目标 A",
|
||||||
|
"expected_deliverable": "结论+证据",
|
||||||
|
"acceptance_criteria": ["证据"],
|
||||||
|
"required": True,
|
||||||
|
"max_rework_attempts": 2,
|
||||||
|
"fallback_policy": "reassign_then_gap",
|
||||||
|
}
|
||||||
|
CONTRACT_B = {
|
||||||
|
"objective": "试图覆盖的合同 B",
|
||||||
|
"expected_deliverable": "别的",
|
||||||
|
"acceptance_criteria": [],
|
||||||
|
"required": True,
|
||||||
|
"max_rework_attempts": 0,
|
||||||
|
"fallback_policy": "controlled_gap",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def task_store(tmp_path):
|
||||||
|
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'tasks.db'}", echo=False)
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
sf = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
store = make_maf_roundtable_store(sf)
|
||||||
|
now = datetime(2026, 1, 1)
|
||||||
|
async with sf() as session:
|
||||||
|
session.add(MafRoundtableSessionRow(
|
||||||
|
id="t-sess", user_id="u1", title="t", task_text="t",
|
||||||
|
mode="structured", status="pending", write_policy="auto",
|
||||||
|
manager_agent_id="manager-1", max_rounds=8,
|
||||||
|
created_at=now, updated_at=now,
|
||||||
|
))
|
||||||
|
session.add(MafRoundtableRunRow(
|
||||||
|
id="t-run", session_id="t-sess", status="pending",
|
||||||
|
created_at=now, updated_at=now,
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
yield store
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_specs() -> list[dict]:
|
||||||
|
return [
|
||||||
|
{"task_key": "01:seat-a", "agent_id": "seat-a", "contract": dict(CONTRACT_A), "required": True},
|
||||||
|
{"task_key": "02:seat-b", "agent_id": "seat-b", "contract": dict(CONTRACT_A), "required": True},
|
||||||
|
{"task_key": "03:seat-c", "agent_id": "seat-c", "contract": dict(CONTRACT_A), "required": True},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── repository: atomic full-set creation ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_or_create_tasks_creates_full_set(task_store):
|
||||||
|
tasks = await task_store.seat_tasks.get_or_create_tasks(
|
||||||
|
session_id="t-sess", run_id="t-run", expected=_expected_specs(),
|
||||||
|
)
|
||||||
|
assert [t["task_key"] for t in tasks] == ["01:seat-a", "02:seat-b", "03:seat-c"]
|
||||||
|
assert all(t["status"] == "assigned" for t in tasks)
|
||||||
|
assert all(t["contract"]["objective"] == "原始合同目标 A" for t in tasks)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_or_create_tasks_recovers_partial_initialization(task_store):
|
||||||
|
# Simulate the old crash mode: only the FIRST task survived initialization.
|
||||||
|
await task_store.seat_tasks.create_task(
|
||||||
|
session_id="t-sess", run_id="t-run", task_key="01:seat-a",
|
||||||
|
agent_id="seat-a", contract=dict(CONTRACT_A), required=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks = await task_store.seat_tasks.get_or_create_tasks(
|
||||||
|
session_id="t-sess", run_id="t-run", expected=_expected_specs(),
|
||||||
|
)
|
||||||
|
# The complete set comes back — the missing seats are filled in.
|
||||||
|
assert [t["task_key"] for t in tasks] == ["01:seat-a", "02:seat-b", "03:seat-c"]
|
||||||
|
by_key = {t["task_key"]: t for t in tasks}
|
||||||
|
# The pre-existing row kept its identity and frozen contract.
|
||||||
|
assert by_key["01:seat-a"]["contract"]["objective"] == "原始合同目标 A"
|
||||||
|
assert by_key["01:seat-a"]["status"] == "assigned"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_or_create_tasks_never_overwrites_frozen_contract(task_store):
|
||||||
|
existing = await task_store.seat_tasks.create_task(
|
||||||
|
session_id="t-sess", run_id="t-run", task_key="01:seat-a",
|
||||||
|
agent_id="seat-c", contract=dict(CONTRACT_A), required=True,
|
||||||
|
)
|
||||||
|
# Lifecycle progressed (e.g. before a restart): reassignment + attempts.
|
||||||
|
await task_store.seat_tasks.update_task(existing["id"], {
|
||||||
|
"status": "accepted",
|
||||||
|
"attempt_count": 3,
|
||||||
|
"rework_count": 2,
|
||||||
|
"reassign_count": 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
specs = _expected_specs()
|
||||||
|
specs[0]["contract"] = dict(CONTRACT_B) # recovery must NOT apply this
|
||||||
|
tasks = await task_store.seat_tasks.get_or_create_tasks(
|
||||||
|
session_id="t-sess", run_id="t-run", expected=specs,
|
||||||
|
)
|
||||||
|
by_key = {t["task_key"]: t for t in tasks}
|
||||||
|
kept = by_key["01:seat-a"]
|
||||||
|
assert kept["id"] == existing["id"]
|
||||||
|
assert kept["contract"]["objective"] == "原始合同目标 A"
|
||||||
|
assert kept["agent_id"] == "seat-c"
|
||||||
|
assert kept["status"] == "accepted"
|
||||||
|
assert kept["attempt_count"] == 3
|
||||||
|
assert kept["rework_count"] == 2
|
||||||
|
assert kept["reassign_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_or_create_tasks_concurrent_recovery(task_store):
|
||||||
|
# Two recoveries race on the same empty run: both must return the complete
|
||||||
|
# set, neither may raise a unique-key violation, and exactly one row per
|
||||||
|
# task_key may exist afterwards.
|
||||||
|
results = await asyncio.gather(
|
||||||
|
task_store.seat_tasks.get_or_create_tasks(
|
||||||
|
session_id="t-sess", run_id="t-run", expected=_expected_specs(),
|
||||||
|
),
|
||||||
|
task_store.seat_tasks.get_or_create_tasks(
|
||||||
|
session_id="t-sess", run_id="t-run", expected=_expected_specs(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for tasks in results:
|
||||||
|
assert [t["task_key"] for t in tasks] == ["01:seat-a", "02:seat-b", "03:seat-c"]
|
||||||
|
|
||||||
|
final = await task_store.seat_tasks.list_by_run("t-run")
|
||||||
|
keys = [t["task_key"] for t in final]
|
||||||
|
assert sorted(keys) == ["01:seat-a", "02:seat-b", "03:seat-c"]
|
||||||
|
assert len(keys) == len(set(keys)), "no duplicate tasks"
|
||||||
|
|
||||||
|
|
||||||
|
# ── executor: expected set from dispatch assignments ────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _make_state(**overrides) -> RoundtableState:
|
||||||
|
kwargs = {
|
||||||
|
"session_id": "t-sess",
|
||||||
|
"run_id": "t-run",
|
||||||
|
"task_text": "分析市场",
|
||||||
|
"mode": "structured",
|
||||||
|
"model_name": None,
|
||||||
|
"reasoning_effort": None,
|
||||||
|
"max_rounds": 8,
|
||||||
|
"write_policy": "auto",
|
||||||
|
"manager_agent_id": "manager-1",
|
||||||
|
"seat_agent_ids": ["seat-a", "seat-b"],
|
||||||
|
"thread_map": {},
|
||||||
|
"capability_snapshot": None,
|
||||||
|
"seat_timeout_seconds": 5,
|
||||||
|
"schema_repair_enabled": True,
|
||||||
|
"intent_type": "parallel_consultation",
|
||||||
|
"dispatch_plan": {
|
||||||
|
"execution_mode": "parallel",
|
||||||
|
"assignments": [
|
||||||
|
{"agent_id": "seat-a", "task_goal": "目标一", "delivery_requirements": ["证据"]},
|
||||||
|
{"agent_id": "seat-b", "task_goal": "目标二", "delivery_requirements": ["建议"]},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"dispatch_contract_enabled": True,
|
||||||
|
"quality_gate_enabled": True,
|
||||||
|
}
|
||||||
|
kwargs.update(overrides)
|
||||||
|
return RoundtableState(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_executor_load_or_create_fills_missing_tasks(task_store):
|
||||||
|
# Partial init: only the first assignment's task exists.
|
||||||
|
await task_store.seat_tasks.create_task(
|
||||||
|
session_id="t-sess", run_id="t-run", task_key="01:seat-a",
|
||||||
|
agent_id="seat-a", contract=dict(CONTRACT_A), required=True,
|
||||||
|
)
|
||||||
|
state = _make_state()
|
||||||
|
adapter = SimpleNamespace(_service=SimpleNamespace(store=task_store))
|
||||||
|
executor = QualitySeatTasksExecutor(adapter, state)
|
||||||
|
|
||||||
|
tasks = await executor._load_or_create_tasks()
|
||||||
|
assert [t["task_key"] for t in tasks] == ["01:seat-a", "02:seat-b"]
|
||||||
|
by_key = {t["task_key"]: t for t in tasks}
|
||||||
|
# Existing contract untouched; missing task created from the assignment.
|
||||||
|
assert by_key["01:seat-a"]["contract"]["objective"] == "原始合同目标 A"
|
||||||
|
assert by_key["02:seat-b"]["contract"]["objective"] == "目标二"
|
||||||
|
assert by_key["02:seat-b"]["status"] == "assigned"
|
||||||
|
|
||||||
|
# Idempotent second recovery.
|
||||||
|
again = await executor._load_or_create_tasks()
|
||||||
|
assert [t["id"] for t in again] == [t["id"] for t in tasks]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_quality_gate_blocks_missing_required_task(task_store):
|
||||||
|
state = _make_state()
|
||||||
|
# Only ONE of the two expected required tasks is represented.
|
||||||
|
state.seat_task_statuses = {
|
||||||
|
"01:seat-a": {
|
||||||
|
"task_key": "01:seat-a", "status": "accepted",
|
||||||
|
"agent_id": "seat-a", "required": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
adapter = SimpleNamespace(
|
||||||
|
_service=SimpleNamespace(store=SimpleNamespace(seat_tasks=SimpleNamespace())),
|
||||||
|
_emit_critical=AsyncMock(),
|
||||||
|
)
|
||||||
|
gate = QualityGateExecutor(adapter, state)
|
||||||
|
ctx = SimpleNamespace(send_message=AsyncMock(), yield_output=AsyncMock())
|
||||||
|
with pytest.raises(RuntimeError, match="quality gate blocked"):
|
||||||
|
await gate.handle({}, ctx)
|
||||||
|
assert state.status != "completed"
|
||||||
@@ -0,0 +1,565 @@
|
|||||||
|
"""Stage 4 (R3-Q) pure-policy tests: task contracts, deterministic quality
|
||||||
|
checks, manager review schema, and server-owned decision finalization.
|
||||||
|
|
||||||
|
These tests exercise ``quality_contracts`` directly (no MAF SDK, no I/O) and
|
||||||
|
prove the weak-model protections:
|
||||||
|
|
||||||
|
- a delivery that failed deterministic checks can never be accepted, no matter
|
||||||
|
what the manager model claims;
|
||||||
|
- rework is bounded by the frozen contract's ``max_rework_attempts``;
|
||||||
|
- reassignment is bounded by the server-configured cap;
|
||||||
|
- an exhausted task ends as ``controlled_gap`` / ``ask_user``, never as a
|
||||||
|
disguised success.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.maf_roundtable.quality_contracts import (
|
||||||
|
OUTCOME_ACCEPTED,
|
||||||
|
OUTCOME_ASK_USER,
|
||||||
|
OUTCOME_CONTROLLED_GAP,
|
||||||
|
OUTCOME_REASSIGNED,
|
||||||
|
OUTCOME_REWORK,
|
||||||
|
InvalidTaskContractError,
|
||||||
|
MafManagerReviewItem,
|
||||||
|
MafManagerReviewOutput,
|
||||||
|
MafSeatDelivery,
|
||||||
|
MafTaskContract,
|
||||||
|
build_manager_review_prompt,
|
||||||
|
build_task_contract,
|
||||||
|
check_delivery_deterministic,
|
||||||
|
conclusion_digest,
|
||||||
|
delivery_from_seat_output,
|
||||||
|
finalize_review_decision,
|
||||||
|
make_controlled_gap,
|
||||||
|
parse_manager_review,
|
||||||
|
repair_requirements_for_codes,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── task contract freezing ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_task_contract_freezes_from_dispatch_assignment():
|
||||||
|
contract = build_task_contract(
|
||||||
|
task_goal="评估竞品定价策略",
|
||||||
|
delivery_requirements=["风险清单", "依据", "优先级", "建议"],
|
||||||
|
required=True,
|
||||||
|
max_rework_attempts=2,
|
||||||
|
fallback_policy="reassign_then_gap",
|
||||||
|
)
|
||||||
|
assert contract.objective == "评估竞品定价策略"
|
||||||
|
assert contract.expected_deliverable
|
||||||
|
assert contract.acceptance_criteria == ["风险清单", "依据", "优先级", "建议"]
|
||||||
|
assert contract.required is True
|
||||||
|
assert contract.max_rework_attempts == 2
|
||||||
|
assert contract.fallback_policy == "reassign_then_gap"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_task_contract_rejects_empty_objective():
|
||||||
|
try:
|
||||||
|
build_task_contract(task_goal=" ", delivery_requirements=[])
|
||||||
|
except InvalidTaskContractError:
|
||||||
|
return
|
||||||
|
raise AssertionError("empty objective must raise InvalidTaskContractError")
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_task_contract_strips_blank_criteria():
|
||||||
|
contract = build_task_contract(task_goal="t", delivery_requirements=["a", " ", "b"])
|
||||||
|
assert contract.acceptance_criteria == ["a", "b"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── delivery mapping ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_delivery_from_seat_output_maps_all_fields():
|
||||||
|
delivery = delivery_from_seat_output(
|
||||||
|
structured_output={
|
||||||
|
"summary": "结论:竞品定价偏高",
|
||||||
|
"findings": ["find-1", {"text": "find-2"}],
|
||||||
|
"evidence_refs": [{"ref": "ref-a"}, "ref-b"],
|
||||||
|
"open_questions": ["q1"],
|
||||||
|
"self_check": "已覆盖优先级标准",
|
||||||
|
},
|
||||||
|
raw_content='{"summary": "结论:竞品定价偏高"}',
|
||||||
|
)
|
||||||
|
assert delivery.conclusion == "结论:竞品定价偏高"
|
||||||
|
assert delivery.evidence == ["find-1", "find-2", "ref-a", "ref-b"]
|
||||||
|
# P1: only evidence_refs can satisfy the evidence requirement.
|
||||||
|
assert delivery.evidence_refs == ["ref-a", "ref-b"]
|
||||||
|
assert delivery.unresolved_items == ["q1"]
|
||||||
|
assert delivery.self_check == "已覆盖优先级标准"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delivery_from_seat_output_schema_invalid_is_empty():
|
||||||
|
delivery = delivery_from_seat_output(structured_output=None, raw_content="not json")
|
||||||
|
assert delivery.conclusion == ""
|
||||||
|
assert delivery.evidence == []
|
||||||
|
assert delivery.evidence_refs == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── deterministic checks ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _good_delivery() -> MafSeatDelivery:
|
||||||
|
return MafSeatDelivery(
|
||||||
|
conclusion="结论:该市场存在明确增长机会,建议优先进入华东区域并配套本地化运营资源投入,同时预留华南试点预算",
|
||||||
|
evidence=["ref-1", "ref-2"],
|
||||||
|
evidence_refs=["ref-1", "ref-2"],
|
||||||
|
unresolved_items=[],
|
||||||
|
self_check="ok",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_delivery_deterministic_passes_well_formed():
|
||||||
|
delivery = _good_delivery()
|
||||||
|
result = check_delivery_deterministic(
|
||||||
|
delivery=delivery, raw_content='{"summary":"x"}', schema_status="ok",
|
||||||
|
)
|
||||||
|
assert result["passed"] is True
|
||||||
|
assert result["failure_codes"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_delivery_deterministic_empty_content():
|
||||||
|
result = check_delivery_deterministic(
|
||||||
|
delivery=_good_delivery(), raw_content=" ", schema_status="ok",
|
||||||
|
)
|
||||||
|
assert result["passed"] is False
|
||||||
|
assert "empty_content" in result["failure_codes"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_delivery_deterministic_schema_invalid():
|
||||||
|
result = check_delivery_deterministic(
|
||||||
|
delivery=_good_delivery(), raw_content="not json", schema_status="schema_invalid",
|
||||||
|
)
|
||||||
|
assert result["passed"] is False
|
||||||
|
assert "schema_invalid" in result["failure_codes"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_delivery_deterministic_missing_conclusion():
|
||||||
|
delivery = MafSeatDelivery(conclusion="", evidence=["e"], self_check="")
|
||||||
|
result = check_delivery_deterministic(
|
||||||
|
delivery=delivery, raw_content='{"summary":""}', schema_status="ok",
|
||||||
|
)
|
||||||
|
assert "missing_conclusion" in result["failure_codes"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_delivery_deterministic_below_min_length():
|
||||||
|
delivery = MafSeatDelivery(conclusion="好", evidence=["e"], self_check="")
|
||||||
|
result = check_delivery_deterministic(
|
||||||
|
delivery=delivery, raw_content='{"summary":"好"}', schema_status="ok", min_length=40,
|
||||||
|
)
|
||||||
|
assert "below_min_length" in result["failure_codes"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_delivery_deterministic_missing_evidence():
|
||||||
|
delivery = MafSeatDelivery(conclusion="这是一个足够长的结论用于通过最小长度校验", evidence=[], self_check="")
|
||||||
|
result = check_delivery_deterministic(
|
||||||
|
delivery=delivery, raw_content='{"summary":"x"}', schema_status="ok", min_evidence=1,
|
||||||
|
)
|
||||||
|
assert "missing_evidence" in result["failure_codes"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_delivery_findings_alone_never_satisfy_evidence():
|
||||||
|
# P1: findings are candidate claims only — a delivery full of findings but
|
||||||
|
# without evidence_refs still fails the evidence requirement.
|
||||||
|
delivery = MafSeatDelivery(
|
||||||
|
conclusion="这是一个足够长的结论用于通过最小长度校验,附带大量候选主张作为干扰项",
|
||||||
|
evidence=["我查到 A", "我查到 B", "find-3"],
|
||||||
|
evidence_refs=[],
|
||||||
|
self_check="",
|
||||||
|
)
|
||||||
|
result = check_delivery_deterministic(
|
||||||
|
delivery=delivery, raw_content='{"summary":"x"}', schema_status="ok", min_evidence=1,
|
||||||
|
)
|
||||||
|
assert result["passed"] is False
|
||||||
|
assert "missing_evidence" in result["failure_codes"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_delivery_invented_evidence_ref_fails_unverified():
|
||||||
|
# P1: a cited ref that the fact ledger does not back is a hallucination.
|
||||||
|
delivery = MafSeatDelivery(
|
||||||
|
conclusion="这是一个足够长的结论用于通过最小长度校验,引用了不存在的证据",
|
||||||
|
evidence=["fake-ref-001"],
|
||||||
|
evidence_refs=["fake-ref-001"],
|
||||||
|
self_check="",
|
||||||
|
)
|
||||||
|
result = check_delivery_deterministic(
|
||||||
|
delivery=delivery,
|
||||||
|
raw_content='{"summary":"x"}',
|
||||||
|
schema_status="ok",
|
||||||
|
min_evidence=1,
|
||||||
|
verified_evidence_refs={"tool:kb_search:1", "ref-real"},
|
||||||
|
)
|
||||||
|
assert result["passed"] is False
|
||||||
|
assert "unverified_evidence_ref" in result["failure_codes"]
|
||||||
|
# The invented ref also cannot count toward the evidence minimum.
|
||||||
|
assert "missing_evidence" in result["failure_codes"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_delivery_verified_evidence_ref_passes():
|
||||||
|
delivery = MafSeatDelivery(
|
||||||
|
conclusion="这是一个足够长的结论用于通过最小长度校验,引用了事实账本中真实存在的证据引用,满足最小证据数量要求",
|
||||||
|
evidence=["tool:kb_search:1"],
|
||||||
|
evidence_refs=["tool:kb_search:1"],
|
||||||
|
self_check="",
|
||||||
|
)
|
||||||
|
result = check_delivery_deterministic(
|
||||||
|
delivery=delivery,
|
||||||
|
raw_content='{"summary":"x"}',
|
||||||
|
schema_status="ok",
|
||||||
|
min_evidence=1,
|
||||||
|
verified_evidence_refs={"tool:kb_search:1"},
|
||||||
|
)
|
||||||
|
assert result["passed"] is True
|
||||||
|
assert result["failure_codes"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_delivery_deterministic_duplicate_content_by_digest():
|
||||||
|
delivery = MafSeatDelivery(
|
||||||
|
conclusion="结论:与上次完全相同的内容", evidence=["e"], evidence_refs=["e"], self_check="",
|
||||||
|
)
|
||||||
|
result = check_delivery_deterministic(
|
||||||
|
delivery=delivery,
|
||||||
|
raw_content="x",
|
||||||
|
schema_status="ok",
|
||||||
|
prior_conclusion_digests={conclusion_digest("结论:与上次完全相同的内容")},
|
||||||
|
)
|
||||||
|
assert "duplicate_content" in result["failure_codes"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_conclusion_digest_normalizes_whitespace():
|
||||||
|
assert conclusion_digest("结论:内容 相同") == conclusion_digest("结论:内容 相同\n")
|
||||||
|
assert conclusion_digest("a") != conclusion_digest("b")
|
||||||
|
|
||||||
|
|
||||||
|
def test_repair_requirements_cover_failure_codes():
|
||||||
|
reqs = repair_requirements_for_codes(["missing_evidence", "below_min_length"])
|
||||||
|
assert len(reqs) == 2
|
||||||
|
assert any("证据" in r for r in reqs)
|
||||||
|
|
||||||
|
|
||||||
|
# ── manager review parsing ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_manager_review_valid():
|
||||||
|
model, errors = parse_manager_review("""{"decisions": [
|
||||||
|
{"agent_id": "seat-a", "decision": "return_for_rework",
|
||||||
|
"reason": "缺依据", "revision_requirements": ["补证据"]}
|
||||||
|
]}""")
|
||||||
|
assert errors == []
|
||||||
|
assert model is not None
|
||||||
|
assert model.decisions[0].agent_id == "seat-a"
|
||||||
|
assert model.decisions[0].decision == "return_for_rework"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_manager_review_fenced_json():
|
||||||
|
model, errors = parse_manager_review("```json\n{\"decisions\": []}\n```")
|
||||||
|
assert errors == []
|
||||||
|
assert model is not None
|
||||||
|
assert model.decisions == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_manager_review_invalid_decision_value():
|
||||||
|
model, errors = parse_manager_review("""{"decisions": [
|
||||||
|
{"agent_id": "seat-a", "decision": "fake_decision", "reason": "x"}
|
||||||
|
]}""")
|
||||||
|
assert model is None
|
||||||
|
assert errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_manager_review_missing_reason():
|
||||||
|
model, errors = parse_manager_review("""{"decisions": [
|
||||||
|
{"agent_id": "seat-a", "decision": "accept"}
|
||||||
|
]}""")
|
||||||
|
assert model is None
|
||||||
|
assert errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_manager_review_not_json():
|
||||||
|
model, errors = parse_manager_review("definitely not json")
|
||||||
|
assert model is None
|
||||||
|
assert errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_manager_review_prompt_contains_contract_and_check():
|
||||||
|
prompt = build_manager_review_prompt(
|
||||||
|
task_text="分析市场",
|
||||||
|
review_items=[{
|
||||||
|
"agent_id": "seat-a",
|
||||||
|
"task_key": "01:seat-a",
|
||||||
|
"attempt": 1,
|
||||||
|
"contract": {"objective": "评估竞品"},
|
||||||
|
"delivery": {"conclusion": "结论"},
|
||||||
|
"check": {"passed": False, "failure_codes": ["missing_evidence"]},
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
assert "评估竞品" in prompt
|
||||||
|
assert "missing_evidence" in prompt
|
||||||
|
assert "seat-a" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
# ── server-owned decision finalization ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _contract(**overrides) -> MafTaskContract:
|
||||||
|
base = MafTaskContract(
|
||||||
|
objective="评估竞品",
|
||||||
|
expected_deliverable="结论+证据",
|
||||||
|
acceptance_criteria=["证据"],
|
||||||
|
required=True,
|
||||||
|
max_rework_attempts=2,
|
||||||
|
fallback_policy="reassign_then_gap",
|
||||||
|
)
|
||||||
|
return base.model_copy(update=overrides)
|
||||||
|
|
||||||
|
|
||||||
|
def _item(decision: str, reason: str = "r", requirements: list[str] | None = None) -> MafManagerReviewItem:
|
||||||
|
return MafManagerReviewItem(
|
||||||
|
agent_id="seat-a", decision=decision, reason=reason,
|
||||||
|
revision_requirements=list(requirements or []),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check(passed: bool, codes: list[str] | None = None) -> dict:
|
||||||
|
return {"passed": passed, "failure_codes": list(codes or [])}
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_deterministic_fail_never_accepted_even_if_manager_accepts():
|
||||||
|
final = finalize_review_decision(
|
||||||
|
contract=_contract(),
|
||||||
|
check_result=_check(False, ["below_min_length"]),
|
||||||
|
review_item=_item("accept", "看起来可以"),
|
||||||
|
rework_count=0,
|
||||||
|
reassign_count=0,
|
||||||
|
has_backup_seat=True,
|
||||||
|
)
|
||||||
|
assert final["decision"] == OUTCOME_REWORK
|
||||||
|
assert final["source"] == "deterministic"
|
||||||
|
assert final["revision_requirements"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_deterministic_fail_uses_manager_rework_requirements():
|
||||||
|
final = finalize_review_decision(
|
||||||
|
contract=_contract(),
|
||||||
|
check_result=_check(False, ["below_min_length"]),
|
||||||
|
review_item=_item("return_for_rework", "太短", ["请补充具体依据"]),
|
||||||
|
rework_count=0,
|
||||||
|
reassign_count=0,
|
||||||
|
has_backup_seat=True,
|
||||||
|
)
|
||||||
|
assert final["decision"] == OUTCOME_REWORK
|
||||||
|
assert final["revision_requirements"] == ["请补充具体依据"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_deterministic_fail_at_cap_reassigns():
|
||||||
|
final = finalize_review_decision(
|
||||||
|
contract=_contract(max_rework_attempts=2),
|
||||||
|
check_result=_check(False, ["below_min_length"]),
|
||||||
|
review_item=None,
|
||||||
|
rework_count=2,
|
||||||
|
reassign_count=0,
|
||||||
|
has_backup_seat=True,
|
||||||
|
)
|
||||||
|
assert final["decision"] == OUTCOME_REASSIGNED
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_deterministic_fail_at_cap_no_backup_controlled_gap():
|
||||||
|
final = finalize_review_decision(
|
||||||
|
contract=_contract(),
|
||||||
|
check_result=_check(False, ["below_min_length"]),
|
||||||
|
review_item=None,
|
||||||
|
rework_count=2,
|
||||||
|
reassign_count=0,
|
||||||
|
has_backup_seat=False,
|
||||||
|
)
|
||||||
|
assert final["decision"] == OUTCOME_CONTROLLED_GAP
|
||||||
|
assert final["source"] == "deterministic"
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_deterministic_fail_at_cap_ask_user_fallback():
|
||||||
|
final = finalize_review_decision(
|
||||||
|
contract=_contract(fallback_policy="ask_user"),
|
||||||
|
check_result=_check(False, ["below_min_length"]),
|
||||||
|
review_item=None,
|
||||||
|
rework_count=2,
|
||||||
|
reassign_count=1,
|
||||||
|
has_backup_seat=False,
|
||||||
|
)
|
||||||
|
assert final["decision"] == OUTCOME_ASK_USER
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_pass_manager_accept_accepted():
|
||||||
|
final = finalize_review_decision(
|
||||||
|
contract=_contract(),
|
||||||
|
check_result=_check(True),
|
||||||
|
review_item=_item("accept", "达标"),
|
||||||
|
rework_count=0,
|
||||||
|
reassign_count=0,
|
||||||
|
has_backup_seat=True,
|
||||||
|
)
|
||||||
|
assert final["decision"] == OUTCOME_ACCEPTED
|
||||||
|
assert final["source"] == "manager"
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_pass_no_valid_review_never_accepts_forces_rework():
|
||||||
|
# Deterministic checks passed, but the manager review is missing/invalid:
|
||||||
|
# under the rework cap the server forces a system rework — never accepts.
|
||||||
|
final = finalize_review_decision(
|
||||||
|
contract=_contract(),
|
||||||
|
check_result=_check(True),
|
||||||
|
review_item=None,
|
||||||
|
rework_count=0,
|
||||||
|
reassign_count=0,
|
||||||
|
has_backup_seat=True,
|
||||||
|
)
|
||||||
|
assert final["decision"] == OUTCOME_REWORK
|
||||||
|
assert final["source"] == "system"
|
||||||
|
assert final["reason"] == "manager_review_missing_or_invalid"
|
||||||
|
assert final["revision_requirements"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_pass_no_valid_review_at_cap_follows_fallback():
|
||||||
|
# Rework cap exhausted: the contract fallback policy decides — reassign
|
||||||
|
# first, then controlled_gap, then ask_user. Never accepted.
|
||||||
|
final = finalize_review_decision(
|
||||||
|
contract=_contract(),
|
||||||
|
check_result=_check(True),
|
||||||
|
review_item=None,
|
||||||
|
rework_count=2,
|
||||||
|
reassign_count=0,
|
||||||
|
has_backup_seat=True,
|
||||||
|
)
|
||||||
|
assert final["decision"] == OUTCOME_REASSIGNED
|
||||||
|
assert final["source"] == "system"
|
||||||
|
assert final["reason"] == "manager_review_missing_or_invalid"
|
||||||
|
|
||||||
|
final_gap = finalize_review_decision(
|
||||||
|
contract=_contract(),
|
||||||
|
check_result=_check(True),
|
||||||
|
review_item=None,
|
||||||
|
rework_count=2,
|
||||||
|
reassign_count=1,
|
||||||
|
has_backup_seat=False,
|
||||||
|
)
|
||||||
|
assert final_gap["decision"] == OUTCOME_CONTROLLED_GAP
|
||||||
|
assert final_gap["source"] == "system"
|
||||||
|
|
||||||
|
final_ask = finalize_review_decision(
|
||||||
|
contract=_contract(fallback_policy="ask_user"),
|
||||||
|
check_result=_check(True),
|
||||||
|
review_item=None,
|
||||||
|
rework_count=2,
|
||||||
|
reassign_count=1,
|
||||||
|
has_backup_seat=False,
|
||||||
|
)
|
||||||
|
assert final_ask["decision"] == OUTCOME_ASK_USER
|
||||||
|
assert final_ask["source"] == "system"
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_pass_manager_rework_under_cap():
|
||||||
|
final = finalize_review_decision(
|
||||||
|
contract=_contract(),
|
||||||
|
check_result=_check(True),
|
||||||
|
review_item=_item("return_for_rework", "再补充"),
|
||||||
|
rework_count=1,
|
||||||
|
reassign_count=0,
|
||||||
|
has_backup_seat=True,
|
||||||
|
)
|
||||||
|
assert final["decision"] == OUTCOME_REWORK
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_pass_manager_rework_at_cap_reassigns_then_gap():
|
||||||
|
final = finalize_review_decision(
|
||||||
|
contract=_contract(),
|
||||||
|
check_result=_check(True),
|
||||||
|
review_item=_item("return_for_rework", "仍不足"),
|
||||||
|
rework_count=2,
|
||||||
|
reassign_count=0,
|
||||||
|
has_backup_seat=True,
|
||||||
|
)
|
||||||
|
assert final["decision"] == OUTCOME_REASSIGNED
|
||||||
|
|
||||||
|
final2 = finalize_review_decision(
|
||||||
|
contract=_contract(),
|
||||||
|
check_result=_check(True),
|
||||||
|
review_item=_item("return_for_rework", "仍不足"),
|
||||||
|
rework_count=2,
|
||||||
|
reassign_count=1,
|
||||||
|
has_backup_seat=False,
|
||||||
|
)
|
||||||
|
assert final2["decision"] == OUTCOME_CONTROLLED_GAP
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_manager_reassign_without_backup_degrades():
|
||||||
|
final = finalize_review_decision(
|
||||||
|
contract=_contract(),
|
||||||
|
check_result=_check(True),
|
||||||
|
review_item=_item("reassign", "换人"),
|
||||||
|
rework_count=2,
|
||||||
|
reassign_count=0,
|
||||||
|
has_backup_seat=False,
|
||||||
|
)
|
||||||
|
assert final["decision"] == OUTCOME_CONTROLLED_GAP
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_manager_reassign_with_backup():
|
||||||
|
final = finalize_review_decision(
|
||||||
|
contract=_contract(),
|
||||||
|
check_result=_check(True),
|
||||||
|
review_item=_item("reassign", "换 seat-c"),
|
||||||
|
rework_count=2,
|
||||||
|
reassign_count=0,
|
||||||
|
has_backup_seat=True,
|
||||||
|
)
|
||||||
|
assert final["decision"] == OUTCOME_REASSIGNED
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_manager_ask_user_passthrough():
|
||||||
|
final = finalize_review_decision(
|
||||||
|
contract=_contract(),
|
||||||
|
check_result=_check(True),
|
||||||
|
review_item=_item("ask_user", "需要用户补充"),
|
||||||
|
rework_count=2,
|
||||||
|
reassign_count=1,
|
||||||
|
has_backup_seat=False,
|
||||||
|
)
|
||||||
|
assert final["decision"] == OUTCOME_ASK_USER
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_manager_controlled_gap_passthrough():
|
||||||
|
final = finalize_review_decision(
|
||||||
|
contract=_contract(),
|
||||||
|
check_result=_check(True),
|
||||||
|
review_item=_item("controlled_gap", "无法完成"),
|
||||||
|
rework_count=2,
|
||||||
|
reassign_count=1,
|
||||||
|
has_backup_seat=False,
|
||||||
|
)
|
||||||
|
assert final["decision"] == OUTCOME_CONTROLLED_GAP
|
||||||
|
assert final["source"] == "manager"
|
||||||
|
|
||||||
|
|
||||||
|
# ── controlled gap records ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_controlled_gap_marks_risk():
|
||||||
|
gap = make_controlled_gap(
|
||||||
|
task_key="01:seat-a",
|
||||||
|
agent_id="seat-a",
|
||||||
|
objective="评估竞品",
|
||||||
|
reason="返工上限仍无合格交付",
|
||||||
|
failure_codes=["below_min_length"],
|
||||||
|
attempts=3,
|
||||||
|
)
|
||||||
|
assert gap["task_key"] == "01:seat-a"
|
||||||
|
assert gap["agent_id"] == "seat-a"
|
||||||
|
assert gap["attempts"] == 3
|
||||||
|
assert "不得作为已验证事实" in gap["risk"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mamager_review_output_model_roundtrip():
|
||||||
|
output = MafManagerReviewOutput(decisions=[
|
||||||
|
{"agent_id": "a", "decision": "accept", "reason": "ok"},
|
||||||
|
])
|
||||||
|
dumped = output.model_dump()
|
||||||
|
assert dumped["decisions"][0]["agent_id"] == "a"
|
||||||
@@ -169,6 +169,7 @@ async def test_maf_tables_isolated_from_legacy_roundtable(store):
|
|||||||
"maf_roundtable_action_leases",
|
"maf_roundtable_action_leases",
|
||||||
"maf_roundtable_approvals",
|
"maf_roundtable_approvals",
|
||||||
"maf_roundtable_seat_runs",
|
"maf_roundtable_seat_runs",
|
||||||
|
"maf_roundtable_seat_tasks",
|
||||||
"maf_roundtable_action_secrets",
|
"maf_roundtable_action_secrets",
|
||||||
"maf_roundtable_conversations",
|
"maf_roundtable_conversations",
|
||||||
"maf_roundtable_messages",
|
"maf_roundtable_messages",
|
||||||
|
|||||||
Reference in new issue
Block a user