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.
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
## 阶段 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,
|
||||
ChevronRight,
|
||||
ChevronsUpDown,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Gauge,
|
||||
@@ -236,6 +237,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
||||
按钮管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/notification-templates")}
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
通知模板管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/position-roles")}
|
||||
@@ -549,6 +558,12 @@ function LegacyAccordionSidebar({
|
||||
按钮管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/notification-templates")}>
|
||||
<FileText className="size-4" />
|
||||
通知模板管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/position-roles")}>
|
||||
<BriefcaseBusiness className="size-4" />
|
||||
|
||||
@@ -123,6 +123,7 @@ export function ArtifactFileDetail({
|
||||
className,
|
||||
filepath: filepathFromProps,
|
||||
threadId,
|
||||
mimeType = null,
|
||||
onClose,
|
||||
allowVirtualMarkdownEdit = false,
|
||||
disableDesensitize = false,
|
||||
@@ -131,6 +132,8 @@ export function ArtifactFileDetail({
|
||||
className?: string;
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
/** Server/tool supplied type wins over path guessing when available. */
|
||||
mimeType?: string | null;
|
||||
/** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */
|
||||
onClose?: () => void;
|
||||
/**
|
||||
@@ -153,12 +156,34 @@ export function ArtifactFileDetail({
|
||||
const isWriteFileVirtual = useMemo(() => {
|
||||
return filepathFromProps.startsWith("write-file:");
|
||||
}, [filepathFromProps]);
|
||||
// A streamed tool call can be opened before the path has been normalized by
|
||||
// the artifacts API. Keep the format supplied by the writer so Markdown is
|
||||
// rendered as Markdown instead of falling back to a TEXT source preview.
|
||||
const writeFileLanguageHint = useMemo(() => {
|
||||
if (!isWriteFileVirtual) return null;
|
||||
try {
|
||||
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);
|
||||
return decodeURIComponent(url.pathname).trim();
|
||||
}
|
||||
return filepathFromProps;
|
||||
// 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(() => {
|
||||
return filepath.endsWith(".skill");
|
||||
@@ -188,15 +213,20 @@ export function ArtifactFileDetail({
|
||||
}, [filepath, isWriteFileVirtual, editVirtualMarkdown]);
|
||||
const { isCodeFile, language } = useMemo(() => {
|
||||
if (useWriteFileStreaming) {
|
||||
let nextLanguage = checkCodeFile(filepath).language;
|
||||
let nextLanguage =
|
||||
mimeLanguageHint ?? writeFileLanguageHint ?? checkCodeFile(filepath).language;
|
||||
nextLanguage ??= "text";
|
||||
return { isCodeFile: true, language: nextLanguage };
|
||||
}
|
||||
if (isSkillFile) {
|
||||
return { isCodeFile: true, language: "markdown" };
|
||||
}
|
||||
return checkCodeFile(filepath);
|
||||
}, [filepath, useWriteFileStreaming, isSkillFile]);
|
||||
const detected = checkCodeFile(filepath);
|
||||
if (mimeLanguageHint) {
|
||||
return { isCodeFile: true as const, language: mimeLanguageHint };
|
||||
}
|
||||
return detected;
|
||||
}, [filepath, useWriteFileStreaming, isSkillFile, mimeLanguageHint, writeFileLanguageHint]);
|
||||
const isSupportPreview = useMemo(() => {
|
||||
return (
|
||||
language === "html" ||
|
||||
@@ -209,7 +239,8 @@ export function ArtifactFileDetail({
|
||||
// editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟
|
||||
// URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容
|
||||
// 不会被旧的内存 stub 值覆盖回退。
|
||||
filepath: editVirtualMarkdown ? filepath : filepathFromProps,
|
||||
filepath:
|
||||
editVirtualMarkdown || !isWriteFileVirtual ? filepath : filepathFromProps,
|
||||
enabled: isCodeFile && !useWriteFileStreaming,
|
||||
});
|
||||
|
||||
|
||||
@@ -28,8 +28,11 @@ import type { AgentThread, AgentThreadState } from "./types";
|
||||
export type ToolEndEvent = {
|
||||
name: string;
|
||||
data: unknown;
|
||||
toolCallId?: string;
|
||||
};
|
||||
|
||||
export type ToolStartEvent = ToolEndEvent;
|
||||
|
||||
export type ThreadStreamOptions = {
|
||||
threadId?: string | null | undefined;
|
||||
context: LocalSettings["context"];
|
||||
@@ -49,6 +52,7 @@ export type ThreadStreamOptions = {
|
||||
threadMetadata?: Record<string, unknown>;
|
||||
onStart?: (threadId: string) => void;
|
||||
onFinish?: (state: AgentThreadState) => void;
|
||||
onToolStart?: (event: ToolStartEvent) => void;
|
||||
onToolEnd?: (event: ToolEndEvent) => void;
|
||||
};
|
||||
|
||||
@@ -449,6 +453,7 @@ export function useThreadStream({
|
||||
threadMetadata,
|
||||
onStart,
|
||||
onFinish,
|
||||
onToolStart,
|
||||
onToolEnd,
|
||||
}: ThreadStreamOptions) {
|
||||
const { t } = useI18n();
|
||||
@@ -469,13 +474,14 @@ export function useThreadStream({
|
||||
const listeners = useRef({
|
||||
onStart,
|
||||
onFinish,
|
||||
onToolStart,
|
||||
onToolEnd,
|
||||
});
|
||||
|
||||
// Keep listeners ref updated with latest callbacks
|
||||
useEffect(() => {
|
||||
listeners.current = { onStart, onFinish, onToolEnd };
|
||||
}, [onStart, onFinish, onToolEnd]);
|
||||
listeners.current = { onStart, onFinish, onToolStart, onToolEnd };
|
||||
}, [onStart, onFinish, onToolStart, onToolEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedThreadId = threadId ?? null;
|
||||
@@ -545,12 +551,6 @@ export function useThreadStream({
|
||||
},
|
||||
onLangChainEvent(event) {
|
||||
markStreamActivity();
|
||||
if (event.event === "on_tool_end") {
|
||||
listeners.current.onToolEnd?.({
|
||||
name: event.name,
|
||||
data: event.data,
|
||||
});
|
||||
}
|
||||
// 操作日志(Y-Log):每一次大模型调用结束都各上报一条(一次 LLM 调用 = 一条),
|
||||
// 让问答日志尽量「多、全」——一个问答轮次里 lead agent / 子代理的每次模型调用
|
||||
// 都会落一条。门控与失败静默都在 writeYLog 内部处理,绝不影响问答。
|
||||
@@ -574,6 +574,30 @@ export function useThreadStream({
|
||||
}
|
||||
}
|
||||
},
|
||||
// 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) {
|
||||
markStreamActivity();
|
||||
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 MenuManagementPage from "./MenuManagementPage";
|
||||
import LoginWhitelistPage from "./LoginWhitelistPage";
|
||||
import NotificationTemplatesPage from "./NotificationTemplatesPage";
|
||||
import SensitiveWordsPage from "./SensitiveWordsPage";
|
||||
import SkillAddressesPage from "./SkillAddressesPage";
|
||||
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
|
||||
path="admin/position-roles"
|
||||
element={
|
||||
|
||||
@@ -132,10 +132,15 @@ PositionRoundtablePage
|
||||
|
||||
页面只维护一个 `sandboxArtifact`。`PositionNodeChat` 和 `PositionBuiltinAgentChat` 通过 `onPreviewArtifact` 上报预览目标;父页面统一渲染 `ArtifactFileDetail`。
|
||||
|
||||
**对接 / 重做页面 / 排障指南(接口约定、必守规则、自测与症状表):**
|
||||
[`docs/沙箱打开关闭与步骤条.md`](./docs/沙箱打开关闭与步骤条.md)
|
||||
|
||||
设计要点:
|
||||
|
||||
- 文件正在由 `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` 阻止同一线程立即把沙箱重新打开;
|
||||
- 切岗位、切节点、切收口阶段、新建会话和切换历史会话都会先调用 `closeSandbox()`。
|
||||
|
||||
|
||||
@@ -15,7 +15,11 @@ import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { useAgent } from "@/core/agents";
|
||||
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 { textOfMessage } from "@/core/threads/utils";
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
@@ -40,7 +44,6 @@ import {
|
||||
readConversationDraft,
|
||||
readConversationMessages,
|
||||
} from "@/position-roundtable/lib/conversation-snapshot";
|
||||
|
||||
interface PositionNodeSwitchOption {
|
||||
node: PositionRoundtableNode;
|
||||
agentName?: string | null;
|
||||
@@ -164,17 +167,100 @@ function artifactDisplayName(pathOrUrl: string): string {
|
||||
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 {
|
||||
const language = isMarkdownPath(path) ? "&language=markdown" : "";
|
||||
return new URL(
|
||||
`write-file:${path}?message_id=${encodeURIComponent(
|
||||
messageId,
|
||||
)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
|
||||
)}&tool_call_id=${encodeURIComponent(toolCallId)}${language}`,
|
||||
).toString();
|
||||
}
|
||||
|
||||
function isArtifactWriteTool(name: string | undefined): name is "write_file" | "str_replace" {
|
||||
return name === "write_file" || name === "str_replace";
|
||||
}
|
||||
|
||||
function latestStreamingWriteFile(
|
||||
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) {
|
||||
const message = messages[messageIndex] as (typeof messages)[number] & {
|
||||
id?: string;
|
||||
@@ -183,17 +269,33 @@ function latestStreamingWriteFile(
|
||||
if (message.type !== "ai" || !message.id || !Array.isArray(message.tool_calls)) continue;
|
||||
for (let toolIndex = message.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) {
|
||||
const toolCall = message.tool_calls[toolIndex];
|
||||
if (toolCall?.name !== "write_file" || !toolCall.id) continue;
|
||||
const rawPath = toolCall.args?.path;
|
||||
if (typeof rawPath !== "string" || !rawPath.trim()) continue;
|
||||
const content = toolCall.args?.content;
|
||||
if (typeof content !== "string" || content.length === 0) continue;
|
||||
const virtualPath = buildWriteFileVirtualUrl(rawPath, message.id, toolCall.id);
|
||||
if (!isArtifactWriteTool(toolCall?.name) || !toolCall.id) continue;
|
||||
const rawPath = typeof toolCall.args?.path === "string" ? toolCall.args.path : "";
|
||||
const rawContent = typeof toolCall.args?.content === "string" ? toolCall.args.content : "";
|
||||
const normalizedPath = normalizeWriteFilePath(rawPath);
|
||||
const hasUsablePath =
|
||||
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 {
|
||||
key: `${message.id}:${toolCall.id}`,
|
||||
path: virtualPath,
|
||||
name: artifactDisplayName(rawPath),
|
||||
mimeType: rawPath.toLowerCase().endsWith(".md") ? "text/markdown" : null,
|
||||
path: previewPath,
|
||||
sourcePath,
|
||||
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;
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -265,11 +367,21 @@ export function PositionNodeChat({
|
||||
);
|
||||
const [boundThreadId, setBoundThreadId] = useState<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 upstreamRevisionRef = useRef<Record<string, unknown> | null>(null);
|
||||
const autoPreviewedArtifactKeyRef = useRef<string | null>(null);
|
||||
const autoPreviewBaselineCallKeysRef = useRef<Set<string>>(new Set());
|
||||
const autoPreviewRunArmedRef = useRef(false);
|
||||
const writeFilePreviewSequenceRef = useRef(0);
|
||||
const activeWriteFilePreviewRef = useRef<{
|
||||
path: string;
|
||||
previewKey: string;
|
||||
toolCallId?: string;
|
||||
} | null>(null);
|
||||
const editMessage = useEditMessage();
|
||||
const [messageContentOverrides, setMessageContentOverrides] = useState<Record<string, string>>({});
|
||||
const persistedMessages = useMemo(
|
||||
@@ -288,12 +400,28 @@ export function PositionNodeChat({
|
||||
!node.invalidatedBy;
|
||||
|
||||
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);
|
||||
boundThreadIdRef.current = node.threadId;
|
||||
setInputDraft(readConversationDraft(node.conversationSnapshot));
|
||||
setMessageContentOverrides({});
|
||||
autoPreviewedArtifactKeyRef.current = null;
|
||||
autoPreviewBaselineCallKeysRef.current = new Set();
|
||||
autoPreviewRunArmedRef.current = false;
|
||||
writeFilePreviewSequenceRef.current = 0;
|
||||
activeWriteFilePreviewRef.current = null;
|
||||
// The component is keyed by nodeKey in the page. This reset is therefore
|
||||
// only a defensive guard for callers that reuse the component instance.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -314,6 +442,43 @@ export function PositionNodeChat({
|
||||
[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({
|
||||
threadId: boundThreadId,
|
||||
assistantId: node.agentId,
|
||||
@@ -334,15 +499,21 @@ export function PositionNodeChat({
|
||||
agent_id: node.agentId,
|
||||
},
|
||||
onStart: bindCreatedThread,
|
||||
onToolStart: handleToolStart,
|
||||
onFinish: (state) => {
|
||||
// A turn is not considered delivered by the UI alone. The persisted
|
||||
// completion call records the answer, file manifest and upstream version
|
||||
// so the service can decide whether this seat unlocks its downstream.
|
||||
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()
|
||||
.find((message) => message.type === "ai");
|
||||
const latestAnswer = latestAi ? textOfMessage(latestAi) ?? "" : "";
|
||||
.filter((message) => message.type === "ai")
|
||||
.map((message) => textOfMessage(message) ?? "")
|
||||
.find((content) => content.trim()) ?? "";
|
||||
if (!threadId || !latestAnswer.trim()) return;
|
||||
void (async () => {
|
||||
let artifactManifest: Array<Record<string, unknown>> = [];
|
||||
@@ -378,6 +549,10 @@ export function PositionNodeChat({
|
||||
|
||||
const activeThreadId =
|
||||
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 =
|
||||
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? null;
|
||||
const recoveredMessages = useMemo(
|
||||
@@ -566,32 +741,60 @@ export function PositionNodeChat({
|
||||
() => (thread.isLoading ? latestStreamingWriteFile(thread.messages) : null),
|
||||
[thread.isLoading, thread.messages],
|
||||
);
|
||||
const newStreamingArtifact =
|
||||
streamingArtifact && !autoPreviewBaselineCallKeysRef.current.has(streamingArtifact.key)
|
||||
? streamingArtifact
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!thread.isLoading ||
|
||||
!streamingArtifact ||
|
||||
!autoPreviewRunArmedRef.current ||
|
||||
autoPreviewBaselineCallKeysRef.current.has(streamingArtifact.key)
|
||||
!newStreamingArtifact ||
|
||||
!previewThreadId ||
|
||||
!autoPreviewRunArmedRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// 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.
|
||||
// A report is often rewritten to the same output path. The tool-call key
|
||||
// (not just the path) makes that a new previewable delivery.
|
||||
const artifactKey = `${activeThreadId}:${streamingArtifact.key}`;
|
||||
// A report is often rewritten to the same output path — identity must be the
|
||||
// tool-call key, not the path, otherwise a second write keeps the old panel
|
||||
// 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;
|
||||
autoPreviewedArtifactKeyRef.current = artifactKey;
|
||||
onPreviewArtifact?.({
|
||||
threadId: activeThreadId,
|
||||
path: streamingArtifact.path,
|
||||
name: streamingArtifact.name,
|
||||
mimeType: streamingArtifact.mimeType,
|
||||
previewKey: streamingArtifact.key,
|
||||
threadId: previewThreadId,
|
||||
path: newStreamingArtifact.path,
|
||||
name: newStreamingArtifact.name,
|
||||
mimeType: newStreamingArtifact.mimeType,
|
||||
previewKey,
|
||||
auto: true,
|
||||
});
|
||||
}, [activeThreadId, onPreviewArtifact, streamingArtifact, thread.isLoading]);
|
||||
}, [newStreamingArtifact, onPreviewArtifact, previewThreadId, thread.isLoading]);
|
||||
|
||||
// SDK thread creation is asynchronous. ``onStart`` normally binds it, but
|
||||
// watching the actual stream id as well closes the last race where a page
|
||||
@@ -628,6 +831,7 @@ export function PositionNodeChat({
|
||||
: null;
|
||||
// A thread retains historical write_file tool calls. Only files whose
|
||||
// call id appears after this exact submission may open the sandbox.
|
||||
activeWriteFilePreviewRef.current = null;
|
||||
autoPreviewBaselineCallKeysRef.current = writeFileCallKeys(thread.messages);
|
||||
autoPreviewRunArmedRef.current = true;
|
||||
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 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 { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
@@ -336,6 +344,72 @@ function sandboxCloseKey(
|
||||
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 {
|
||||
const messages = thread.messages ?? [];
|
||||
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 sandboxArtifactRef = useRef<PositionArtifactPreview | 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 pendingSandboxThreadSnapshotRef = useRef<SandboxThreadSnapshot | null>(null);
|
||||
const sandboxSnapshotTimerRef = useRef<number | null>(null);
|
||||
const sandboxSnapshotAppliedAtRef = useRef(0);
|
||||
const [flowDetailItem, setFlowDetailItem] = useState<ResultItem | null>(null);
|
||||
const [taskEditorOpen, setTaskEditorOpen] = useState(false);
|
||||
const [taskDraft, setTaskDraft] = useState<PositionTask>(fallbackTask);
|
||||
@@ -908,7 +983,10 @@ export default function PositionRoundtablePage({
|
||||
sandboxArtifactRef.current = sandboxArtifact;
|
||||
}, [sandboxArtifact]);
|
||||
const flushSandboxThreadSnapshot = useCallback(() => {
|
||||
sandboxSnapshotTimerRef.current = null;
|
||||
if (sandboxSnapshotTimerRef.current !== null) {
|
||||
window.clearTimeout(sandboxSnapshotTimerRef.current);
|
||||
sandboxSnapshotTimerRef.current = null;
|
||||
}
|
||||
const pending = pendingSandboxThreadSnapshotRef.current;
|
||||
pendingSandboxThreadSnapshotRef.current = null;
|
||||
if (!pending) return;
|
||||
@@ -916,21 +994,28 @@ export default function PositionRoundtablePage({
|
||||
if (!currentArtifact || currentArtifact.threadId !== pending.threadId) {
|
||||
return;
|
||||
}
|
||||
setSandboxThreadSnapshot((current) =>
|
||||
current?.threadId === pending.threadId && current.signature === pending.signature
|
||||
? current
|
||||
: pending,
|
||||
);
|
||||
}, []);
|
||||
sandboxSnapshotAppliedAtRef.current = Date.now();
|
||||
sandboxThreadStore.publish(pending);
|
||||
}, [sandboxThreadStore]);
|
||||
const queueSandboxThreadSnapshot = useCallback(
|
||||
(snapshot: SandboxThreadSnapshot) => {
|
||||
pendingSandboxThreadSnapshotRef.current = snapshot;
|
||||
if (sandboxSnapshotTimerRef.current !== null) return;
|
||||
if (typeof window === "undefined") {
|
||||
// Leading-edge throttle: publish straight away whenever the previous
|
||||
// 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();
|
||||
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],
|
||||
);
|
||||
@@ -938,6 +1023,7 @@ export default function PositionRoundtablePage({
|
||||
() => () => {
|
||||
if (sandboxSnapshotTimerRef.current !== null) {
|
||||
window.clearTimeout(sandboxSnapshotTimerRef.current);
|
||||
sandboxSnapshotTimerRef.current = null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
@@ -2014,7 +2100,7 @@ export default function PositionRoundtablePage({
|
||||
}
|
||||
closedSandboxKeyRef.current = null;
|
||||
setSandboxArtifact(null);
|
||||
setSandboxThreadSnapshot(null);
|
||||
sandboxThreadStore.publish(null);
|
||||
intent.resetIntent();
|
||||
hydratedIntentSessionRef.current = null;
|
||||
restoringIntentSessionRef.current = null;
|
||||
@@ -2102,7 +2188,7 @@ export default function PositionRoundtablePage({
|
||||
// 会话的窗口期仍接收旧 thread 增量;hydrate 入口还会再做一次兜底清理。
|
||||
intent.stopIntentStream();
|
||||
setSandboxArtifact(null);
|
||||
setSandboxThreadSnapshot(null);
|
||||
sandboxThreadStore.publish(null);
|
||||
setActiveNodeKey(null);
|
||||
setActiveBuiltinKind(null);
|
||||
setSelectedAgentId(null);
|
||||
@@ -2225,7 +2311,7 @@ export default function PositionRoundtablePage({
|
||||
return;
|
||||
}
|
||||
|
||||
const artifactKey = sandboxCloseKey(artifact);
|
||||
const artifactKey = sandboxCloseKey(artifact);
|
||||
if (artifact.auto) {
|
||||
// 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
|
||||
@@ -2236,22 +2322,47 @@ export default function PositionRoundtablePage({
|
||||
}
|
||||
closedSandboxKeyRef.current = null;
|
||||
|
||||
setSandboxArtifact((current) => {
|
||||
const isSamePreview =
|
||||
current?.threadId === artifact.threadId &&
|
||||
current.path === artifact.path &&
|
||||
current.previewKey === artifact.previewKey;
|
||||
return isSamePreview ? current : artifact;
|
||||
});
|
||||
setSandboxThreadSnapshot((current) =>
|
||||
current?.threadId === artifact.threadId ? current : null,
|
||||
);
|
||||
// Follow the same lifecycle as the regular conversation view. The
|
||||
// position workspace owns a custom visual container, but the artifact
|
||||
// selection/open state must still advance at tool-start so its preview is
|
||||
// not deferred until the final artifact list is written.
|
||||
const ctx = artifactsCtxRef.current;
|
||||
ctx?.select(artifact.path, true);
|
||||
ctx?.setOpen(true);
|
||||
|
||||
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;
|
||||
if (latestSnapshot?.threadId === artifact.threadId) {
|
||||
queueSandboxThreadSnapshot(latestSnapshot);
|
||||
}
|
||||
setRightPanelOpen(false);
|
||||
if (!isPathUpgrade) {
|
||||
setRightPanelOpen(false);
|
||||
}
|
||||
}, [queueSandboxThreadSnapshot]);
|
||||
const handlePreviewFlowArtifact = useCallback(
|
||||
(item: ResultItem) => {
|
||||
@@ -2322,18 +2433,6 @@ export default function PositionRoundtablePage({
|
||||
}) as unknown as BaseStream<AgentThreadState>,
|
||||
[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(
|
||||
(snapshot: { threadId: string; thread: BaseStream<AgentThreadState> }) => {
|
||||
const signature = sandboxThreadSignature(snapshot.thread);
|
||||
@@ -2356,10 +2455,16 @@ export default function PositionRoundtablePage({
|
||||
if (ctx?.open) {
|
||||
ctx?.setOpen(false);
|
||||
}
|
||||
if (sandboxSnapshotTimerRef.current !== null) {
|
||||
window.clearTimeout(sandboxSnapshotTimerRef.current);
|
||||
sandboxSnapshotTimerRef.current = null;
|
||||
}
|
||||
pendingSandboxThreadSnapshotRef.current = null;
|
||||
sandboxSnapshotAppliedAtRef.current = 0;
|
||||
sandboxArtifactRef.current = null;
|
||||
setSandboxArtifact(null);
|
||||
setSandboxThreadSnapshot(null);
|
||||
}, []);
|
||||
sandboxThreadStore.publish(null);
|
||||
}, [sandboxThreadStore]);
|
||||
const handleSelectBuiltinStage = useCallback(
|
||||
(kind: ClosureStageKind) => {
|
||||
// The closing agents are part of the testing-analysis workspace only;
|
||||
@@ -2993,18 +3098,23 @@ export default function PositionRoundtablePage({
|
||||
)}
|
||||
{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">
|
||||
<ThreadContext.Provider value={sandboxThreadContextValue}>
|
||||
<SandboxThreadProvider
|
||||
store={sandboxThreadStore}
|
||||
threadId={sandboxArtifact.threadId}
|
||||
fallbackThread={sandboxThreadStub}
|
||||
>
|
||||
<ArtifactFileDetail
|
||||
key={sandboxCloseKey(sandboxArtifact)}
|
||||
className="min-h-0 flex-1"
|
||||
filepath={sandboxArtifact.path}
|
||||
threadId={sandboxArtifact.threadId}
|
||||
mimeType={sandboxArtifact.mimeType}
|
||||
onClose={closeSandbox}
|
||||
allowVirtualMarkdownEdit
|
||||
disableDesensitize
|
||||
hideFileName
|
||||
/>
|
||||
</ThreadContext.Provider>
|
||||
</SandboxThreadProvider>
|
||||
</aside>
|
||||
)}
|
||||
<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,6 +57,7 @@ from app.gateway.routers import (
|
||||
menu_overrides,
|
||||
models,
|
||||
multi_agent,
|
||||
notification_templates,
|
||||
notifications,
|
||||
open_3qfx,
|
||||
open_chat,
|
||||
@@ -1609,6 +1610,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# Task-workspace configurable jump buttons (按钮管理) at /api/task-buttons
|
||||
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
|
||||
# roundtable; feature-flag-gated, six isolated tables) at /api/maf-roundtable
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
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({
|
||||
"agent.message.start", "agent.message.delta", "agent.message.complete",
|
||||
"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",
|
||||
"skill.selected", "skill.started", "skill.completed", "skill.failed",
|
||||
"artifact.created", "approval.required", "approval.decided", "clarification.required",
|
||||
@@ -437,6 +438,11 @@ class MafConversationService:
|
||||
"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]] = {
|
||||
"agent.message.start": {"thread_id": False},
|
||||
"agent.message.delta": {"text": True},
|
||||
@@ -463,10 +469,27 @@ class MafConversationService:
|
||||
"reason": True, "source": True, "clarification_prompt": 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},
|
||||
"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(
|
||||
@@ -501,19 +524,26 @@ class MafConversationService:
|
||||
"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":
|
||||
delivery_data: dict[str, Any] = {
|
||||
key: payload[key]
|
||||
for key in ("content", "schema_status")
|
||||
if key in payload
|
||||
}
|
||||
if event.get("agent_id"):
|
||||
delivery_data["agent_id"] = event["agent_id"]
|
||||
items.append({
|
||||
"event_type": "seat.delivery.submitted",
|
||||
"event_data": delivery_data,
|
||||
"source_event_id": f"{source_evt_id}:delivery" if source_evt_id else None,
|
||||
})
|
||||
stage = str(payload.get("execution_stage") or "")
|
||||
if stage in self._SEAT_DELIVERY_STAGES:
|
||||
delivery_data: dict[str, Any] = {
|
||||
key: payload[key]
|
||||
for key in ("content", "schema_status")
|
||||
if key in payload
|
||||
}
|
||||
if event.get("agent_id"):
|
||||
delivery_data["agent_id"] = event["agent_id"]
|
||||
items.append({
|
||||
"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
|
||||
|
||||
|
||||
@@ -275,6 +275,7 @@ class DeerFlowAgentAdapter:
|
||||
content=response.content,
|
||||
usage=response.usage,
|
||||
metadata=response.metadata,
|
||||
execution_stage=execution_stage,
|
||||
))
|
||||
return response
|
||||
|
||||
|
||||
@@ -75,12 +75,17 @@ def make_turn_completed(
|
||||
content: str,
|
||||
usage: dict[str, Any] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
execution_stage: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"content": content}
|
||||
if usage:
|
||||
payload["usage"] = usage
|
||||
if 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)
|
||||
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
# ── 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 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
open_questions: 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):
|
||||
@@ -134,7 +137,8 @@ def seat_output_instruction() -> str:
|
||||
"""Stage instruction fragment telling the model to emit SeatTurnOutput JSON."""
|
||||
return (
|
||||
"请以 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 只表示建议;不要把未真正由系统执行的外部写入标为已执行。"
|
||||
)
|
||||
|
||||
|
||||
@@ -32,14 +32,45 @@ from app.maf_roundtable.dispatch_contracts import (
|
||||
MafDispatchCatalog,
|
||||
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 (
|
||||
confirm_candidates,
|
||||
empty_ledger,
|
||||
known_evidence_refs,
|
||||
merge_seat_output,
|
||||
normalize_ledger,
|
||||
record_action_transition,
|
||||
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 (
|
||||
build_repair_prompt,
|
||||
parse_cross_review_output,
|
||||
@@ -85,6 +116,7 @@ class RoundtableState:
|
||||
dispatch_plan: dict[str, Any] | None = None,
|
||||
candidate_seat_agent_ids: list[str] | None = None,
|
||||
dispatch_contract_enabled: bool | None = None,
|
||||
quality_gate_enabled: bool | None = None,
|
||||
) -> None:
|
||||
self.session_id = session_id
|
||||
self.run_id = run_id
|
||||
@@ -110,6 +142,14 @@ class RoundtableState:
|
||||
if dispatch_contract_enabled is None
|
||||
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 [])
|
||||
self.candidate_seat_agent_ids = list(
|
||||
(dispatch_plan or {}).get("candidate_seat_agent_ids")
|
||||
@@ -132,6 +172,19 @@ class RoundtableState:
|
||||
self.stage: str = "planning"
|
||||
self.partial_failure: bool = False
|
||||
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:
|
||||
if node_id not in self.completed_nodes:
|
||||
@@ -159,6 +212,7 @@ class RoundtableState:
|
||||
"intent_type": self.intent_type,
|
||||
"dispatch_plan": self.dispatch_plan,
|
||||
"dispatch_contract_enabled": self.dispatch_contract_enabled,
|
||||
"quality_gate_enabled": self.quality_gate_enabled,
|
||||
"candidate_seat_agent_ids": list(self.candidate_seat_agent_ids),
|
||||
"selected_seat_agent_ids": self.selected_seat_agent_ids,
|
||||
"current_round": self.current_round,
|
||||
@@ -173,6 +227,9 @@ class RoundtableState:
|
||||
"stage": self.stage,
|
||||
"partial_failure": self.partial_failure,
|
||||
"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
|
||||
@@ -196,6 +253,7 @@ class RoundtableState:
|
||||
dispatch_plan=d.get("dispatch_plan"),
|
||||
candidate_seat_agent_ids=d.get("candidate_seat_agent_ids"),
|
||||
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.turn_results = d.get("turn_results", [])
|
||||
@@ -212,6 +270,9 @@ class RoundtableState:
|
||||
state.stage = d.get("stage") or "planning"
|
||||
state.partial_failure = bool(d.get("partial_failure", False))
|
||||
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(
|
||||
d.get("candidate_seat_agent_ids") or state.candidate_seat_agent_ids
|
||||
)
|
||||
@@ -358,6 +419,15 @@ class ManagerDispatchExecutor(Executor):
|
||||
|
||||
@handler(input=Any, output=dict)
|
||||
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):
|
||||
await ctx.send_message({
|
||||
"decision": self._dispatch_decision(),
|
||||
@@ -854,12 +924,24 @@ class SeatExecutor(Executor):
|
||||
agent_id: str,
|
||||
*,
|
||||
action_coordinator: ActionCoordinator | None = None,
|
||||
execution_stage: str | None = None,
|
||||
defer_fact_ledger_merge: bool = False,
|
||||
) -> None:
|
||||
super().__init__(id=f"seat_{agent_id}")
|
||||
self._adapter = adapter
|
||||
self._state = state
|
||||
self._agent_id = agent_id
|
||||
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)
|
||||
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||||
@@ -909,7 +991,7 @@ class SeatExecutor(Executor):
|
||||
run_id=self._state.run_id,
|
||||
agent_id=self._agent_id,
|
||||
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,
|
||||
thread_id=thread_id,
|
||||
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)
|
||||
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)
|
||||
|
||||
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
|
||||
# recovery can distinguish an expert's original output from its
|
||||
# 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,
|
||||
"tool_calls": response.tool_calls,
|
||||
"error": response.error,
|
||||
@@ -1151,8 +1233,21 @@ class CrossReviewExecutor(Executor):
|
||||
|
||||
self._state.stage = "review"
|
||||
|
||||
# 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
|
||||
# P0-3: with the Stage-4 quality gate the cross-review may only see
|
||||
# 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]
|
||||
for aid in missing:
|
||||
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 "", "")
|
||||
if manager_id and thread_id:
|
||||
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(
|
||||
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,
|
||||
)
|
||||
# Surface the candidate ids + the evidence refs the ledger has
|
||||
@@ -1248,6 +1350,9 @@ class CrossReviewExecutor(Executor):
|
||||
or review.get("conflicts")
|
||||
or review.get("tool_failures")
|
||||
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["reason"] = (
|
||||
@@ -1540,16 +1645,24 @@ async def generate_final_report(adapter: Any, state: RoundtableState) -> dict[st
|
||||
manager_id = state.manager_agent_id or "coordinator"
|
||||
thread_id = state.thread_map.get(manager_id, "")
|
||||
|
||||
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"]
|
||||
if state.quality_gate_enabled:
|
||||
# P0-3: Stage 4 synthesizes ONLY accepted task results. There is
|
||||
# deliberately NO fallback to ``turn_results`` — when no delivery was
|
||||
# accepted, the report must be built from the controlled-gap risk
|
||||
# section alone instead of smuggling rejected content back in.
|
||||
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(
|
||||
task_text=state.task_text,
|
||||
seat_results=usable or state.turn_results,
|
||||
seat_results=seat_results_for_synthesis,
|
||||
fact_ledger=state.fact_ledger,
|
||||
)
|
||||
if state.partial_failure:
|
||||
@@ -1564,6 +1677,32 @@ async def generate_final_report(adapter: Any, state: RoundtableState) -> dict[st
|
||||
if state.cross_review_result:
|
||||
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:
|
||||
response = await adapter.run_seat_turn(
|
||||
session_id=state.session_id,
|
||||
@@ -1586,11 +1725,9 @@ async def generate_final_report(adapter: Any, state: RoundtableState) -> dict[st
|
||||
"content": response.content,
|
||||
"agent_id": manager_id,
|
||||
"partial_failure": state.partial_failure,
|
||||
"degraded": state.partial_failure,
|
||||
"quality_decision": (
|
||||
(state.cross_review_result or {}).get("decision")
|
||||
or ("controlled_gap" if state.partial_failure else "accept")
|
||||
),
|
||||
"degraded": state.partial_failure or has_gaps,
|
||||
"quality_decision": quality_decision,
|
||||
"controlled_gaps": list(state.controlled_gaps),
|
||||
}
|
||||
else:
|
||||
state.final_result = {
|
||||
@@ -1598,11 +1735,9 @@ async def generate_final_report(adapter: Any, state: RoundtableState) -> dict[st
|
||||
"agent_id": manager_id,
|
||||
"note": "no_manager_thread",
|
||||
"partial_failure": state.partial_failure,
|
||||
"degraded": state.partial_failure,
|
||||
"quality_decision": (
|
||||
(state.cross_review_result or {}).get("decision")
|
||||
or ("controlled_gap" if state.partial_failure else "accept")
|
||||
),
|
||||
"degraded": state.partial_failure or has_gaps,
|
||||
"quality_decision": quality_decision,
|
||||
"controlled_gaps": list(state.controlled_gaps),
|
||||
}
|
||||
|
||||
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"})
|
||||
|
||||
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -55,11 +55,19 @@ def build_structured_workflow(
|
||||
dispatch_seat_entry: Any | None = None,
|
||||
manager_answer: Any | None = None,
|
||||
clarification_terminal: Any | None = None,
|
||||
quality_gate: Any | None = None,
|
||||
waiting_input_terminal: Any | None = None,
|
||||
) -> Any:
|
||||
"""Build a structured (fixed-topology) MAF Workflow.
|
||||
|
||||
Topology: planner → parallel seat executors → cross_review → approval gate
|
||||
→ finalizer | waiting_terminal.
|
||||
Topology (Stage 3): planner → parallel seat executors → cross_review →
|
||||
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
|
||||
|
||||
@@ -71,6 +79,8 @@ def build_structured_workflow(
|
||||
outputs.append(manager_answer)
|
||||
if clarification_terminal is not None:
|
||||
outputs.append(clarification_terminal)
|
||||
if waiting_input_terminal is not None:
|
||||
outputs.append(waiting_input_terminal)
|
||||
builder = WorkflowBuilder(
|
||||
start_executor=start,
|
||||
name="maf-structured-roundtable",
|
||||
@@ -97,7 +107,20 @@ def build_structured_workflow(
|
||||
builder.add_switch_case_edge_group(dispatch_router, cases)
|
||||
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])
|
||||
for previous, current in zip(seat_executors, seat_executors[1:]):
|
||||
builder.add_edge(previous, current)
|
||||
@@ -249,6 +272,8 @@ def create_workflow(
|
||||
dispatch_router: Any | None = None,
|
||||
dispatch_seat_entry: Any | None = None,
|
||||
clarification_terminal: Any | None = None,
|
||||
quality_gate: Any | None = None,
|
||||
waiting_input_terminal: Any | None = None,
|
||||
) -> Any:
|
||||
"""Factory: create the appropriate workflow for the given mode."""
|
||||
if mode == "manager_only":
|
||||
@@ -290,4 +315,6 @@ def create_workflow(
|
||||
dispatch_seat_entry=dispatch_seat_entry,
|
||||
manager_answer=manager_answer,
|
||||
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,
|
||||
ManagerDispatchExecutor,
|
||||
PlannerExecutor,
|
||||
QualityGateExecutor,
|
||||
QualitySeatTasksExecutor,
|
||||
RoundtableState,
|
||||
SeatExecutor,
|
||||
WaitingInputTerminalExecutor,
|
||||
WaitingTerminalExecutor,
|
||||
_agent_capabilities_from_snapshot,
|
||||
generate_final_report,
|
||||
@@ -165,6 +168,7 @@ class WorkflowRunner:
|
||||
or candidate_set
|
||||
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:
|
||||
@@ -220,6 +224,54 @@ class WorkflowRunner:
|
||||
except Exception: # noqa: BLE001
|
||||
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(
|
||||
self,
|
||||
participants: list[dict[str, Any]],
|
||||
@@ -278,7 +330,14 @@ class WorkflowRunner:
|
||||
|
||||
async def _run_manager_dispatch(self, state: RoundtableState) -> None:
|
||||
"""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
|
||||
workflow = build_manager_dispatch_workflow(
|
||||
manager_dispatch=ManagerDispatchExecutor(self._adapter, state),
|
||||
@@ -337,6 +396,37 @@ class WorkflowRunner:
|
||||
|
||||
state.current_round = max(state.current_round, 1)
|
||||
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(
|
||||
mode="structured",
|
||||
planner=DispatchSeatEntryExecutor(state),
|
||||
|
||||
+33
@@ -220,6 +220,39 @@ class MafRoundtableConfig(BaseModel):
|
||||
le=60,
|
||||
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")
|
||||
|
||||
+62
-1
@@ -31,6 +31,7 @@ from __future__ import annotations
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
@@ -368,7 +369,8 @@ class MafRoundtableSeatRunRow(Base):
|
||||
# migration / manual SQL honest. Keep in sync with VALID_EXECUTION_STAGES.
|
||||
CheckConstraint(
|
||||
"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",
|
||||
),
|
||||
)
|
||||
@@ -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) ────
|
||||
|
||||
|
||||
@@ -576,5 +636,6 @@ __all__ = [
|
||||
"MafRoundtableParticipantRow",
|
||||
"MafRoundtableRunRow",
|
||||
"MafRoundtableSeatRunRow",
|
||||
"MafRoundtableSeatTaskRow",
|
||||
"MafRoundtableSessionRow",
|
||||
]
|
||||
-2
@@ -144,8 +144,6 @@ class MafParticipantRepository:
|
||||
candidates: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Execute a conflict-safe INSERT for the given candidate rows."""
|
||||
from sqlalchemy import insert as sa_insert
|
||||
|
||||
now = _now()
|
||||
rows = [
|
||||
{
|
||||
|
||||
+7
@@ -36,6 +36,11 @@ CROSS_REVIEW = "cross_review"
|
||||
CROSS_REVIEW_REPAIR = "cross_review_repair"
|
||||
FINALIZER = "finalizer"
|
||||
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({
|
||||
LEGACY,
|
||||
@@ -46,6 +51,8 @@ VALID_EXECUTION_STAGES: frozenset[str] = frozenset({
|
||||
CROSS_REVIEW_REPAIR,
|
||||
FINALIZER,
|
||||
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.runs import MafRunRepository, run_to_dict
|
||||
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
|
||||
|
||||
# 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.approvals = MafApprovalRepository(session_factory)
|
||||
self.seat_runs = MafSeatRunRepository(session_factory)
|
||||
self.seat_tasks = MafSeatTaskRepository(session_factory)
|
||||
self.action_secrets = MafActionSecretRepository(session_factory)
|
||||
# V2 conversation aggregation (user-visible layer).
|
||||
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.menu_overrides.model import MenuOverrideRow
|
||||
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.parallel_agent_tasks.model import ParallelAgentTaskRow
|
||||
from deerflow.persistence.position_roles.model import PositionRoleRow
|
||||
@@ -124,6 +125,7 @@ __all__ = [
|
||||
"MafRoundtableSessionRow",
|
||||
"MenuOverrideRow",
|
||||
"NotificationRow",
|
||||
"NotificationTemplateRow",
|
||||
"ParallelAgentTaskRow",
|
||||
"PositionRoundtableNodeRow",
|
||||
"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
|
||||
async def test_p0_1_atomic_projection_both_events_or_neither(svc, store):
|
||||
"""agent.message.completed must atomically project both
|
||||
agent.message.complete AND seat.delivery.submitted."""
|
||||
"""A genuine seat delivery (agent.message.completed with a seat delivery
|
||||
execution_stage) must atomically project both agent.message.complete AND
|
||||
seat.delivery.submitted."""
|
||||
import uuid
|
||||
|
||||
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",
|
||||
"agent_id": "seat_a",
|
||||
"turn_index": 1,
|
||||
"payload": {"content": "result", "schema_status": "valid"},
|
||||
"payload": {
|
||||
"content": "result",
|
||||
"schema_status": "valid",
|
||||
"execution_stage": "expert",
|
||||
},
|
||||
}
|
||||
|
||||
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,
|
||||
seq=1, event_type="agent.message.completed",
|
||||
agent_id="seat_a", turn_index=0,
|
||||
payload_json={"content": "done"},
|
||||
payload_json={"content": "done", "execution_stage": "expert"},
|
||||
))
|
||||
|
||||
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",
|
||||
"agent_id": "seat_a",
|
||||
"turn_index": 0,
|
||||
"payload": {"content": "done"},
|
||||
"payload": {"content": "done", "execution_stage": "expert"},
|
||||
},
|
||||
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": {
|
||||
"content": "answer",
|
||||
"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
|
||||
|
||||
import asyncio
|
||||
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.workflow_executors import (
|
||||
@@ -27,12 +24,6 @@ from app.maf_roundtable.workflow_executors import (
|
||||
)
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
|
||||
from deerflow.persistence.maf_roundtable.model import (
|
||||
MafRoundtableParticipantRow,
|
||||
MafRoundtableRunRow,
|
||||
MafRoundtableSessionRow,
|
||||
)
|
||||
|
||||
|
||||
# ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -38,7 +38,12 @@ from app.maf_roundtable.workflow_executors import (
|
||||
SeatExecutor,
|
||||
)
|
||||
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"
|
||||
SEATS = ["seat-a", "seat-b"]
|
||||
@@ -271,49 +276,56 @@ async def test_manager_role_wired_through_state(svc):
|
||||
async def v2_svc(tmp_path):
|
||||
"""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)
|
||||
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="v2-sess", user_id="u1", title="v2", 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.dumps({
|
||||
"maf_v2": {
|
||||
"dispatch_contract_enabled": True,
|
||||
"candidate_set": {
|
||||
"manager_agent_id": MANAGER,
|
||||
"candidate_seat_agent_ids": ["seat-a", "seat-b", "seat-c"],
|
||||
"mode": "structured",
|
||||
"max_rounds": 8,
|
||||
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="v2-sess", user_id="u1", title="v2", 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,
|
||||
# Stage-3 topology tests freeze the pre-Stage-4 behavior:
|
||||
# plain seat fan-out, no quality gate.
|
||||
"quality_gate_enabled": False,
|
||||
"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()
|
||||
yield service
|
||||
await engine.dispose()
|
||||
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).
|
||||
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):
|
||||
"""Create a WorkflowRunner with a mocked adapter for V2 tests."""
|
||||
def _make_v2_runner(v2_svc, monkeypatch, *, dispatch_decision: dict | None = None):
|
||||
"""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 unittest.mock import AsyncMock
|
||||
|
||||
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):
|
||||
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", "")
|
||||
agent_id = kwargs["agent_id"]
|
||||
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})
|
||||
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})
|
||||
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})
|
||||
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})
|
||||
@@ -343,7 +357,18 @@ def _make_v2_runner(v2_svc, monkeypatch, *, dispatch_decision: dict):
|
||||
async def _ensure_threads(session_id, run_id, participants):
|
||||
agent_ids = [p["agent_id"] for p in participants]
|
||||
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, "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")
|
||||
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
|
||||
async def test_runner_direct_answer_zero_seats(v2_svc, monkeypatch):
|
||||
"""direct_answer: manager_only — zero seat participants, zero seat threads,
|
||||
zero seat runs; final answer comes from manager."""
|
||||
"""direct_answer: manager_only — zero seat threads, zero seat execution;
|
||||
historical seat participant rows may remain, but never get threads; final
|
||||
answer comes from manager."""
|
||||
pytest.importorskip("agent_framework")
|
||||
|
||||
dispatch_decision = {
|
||||
@@ -482,6 +519,158 @@ async def test_runner_clarification_waiting_input(v2_svc, monkeypatch):
|
||||
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 ────────────────────────
|
||||
|
||||
|
||||
@@ -513,8 +702,6 @@ def test_participants_requiring_threads_legacy_returns_all():
|
||||
|
||||
def test_participants_requiring_threads_v2_no_plan_only_manager():
|
||||
"""V2 session without a validated plan: only manager gets threads."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.maf_roundtable.workflow_runner import 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_approvals",
|
||||
"maf_roundtable_seat_runs",
|
||||
"maf_roundtable_seat_tasks",
|
||||
"maf_roundtable_action_secrets",
|
||||
"maf_roundtable_conversations",
|
||||
"maf_roundtable_messages",
|
||||
|
||||
Reference in new issue
Block a user