diff --git a/frontend-web/docs/测试-chouqu.md b/frontend-web/docs/测试-chouqu.md new file mode 100644 index 00000000..6bfebde0 --- /dev/null +++ b/frontend-web/docs/测试-chouqu.md @@ -0,0 +1,148 @@ +# 定时任务 — 模板填充「源数据抽取」手工测试清单 + +> 覆盖功能:定时任务「模板数据转换器」内置 agent —— 把用户传入的源数据转换成 HTML +> 模板的 `DATA` 结构并填充生成可预览页面。**重点测「源数据抽取」**:源可能是 +> ① 纯 JSON、② 含 JSON 的文本、③ 纯文本,三种形态都要正确处理。 +> +> 测试环境:后端 `http://localhost:8001`(`make dev`),前端 `http://localhost:5174`(`pnpm dev`)。 +> 相关代码:`deerflow/runtime/scheduler/template_fill.py`(`extract_embedded_json`)、 +> `service.py`(`_run_template_fill_attempt` / `_build_conversion_prompt`)、 +> `pages/ScheduledTasksPage.tsx`。 + +--- + +## 前置 — 进入表单 + +| # | 步骤 | 预期结果 | +|---|------|----------| +| 0.1 | 任务管理 → 新建定时任务 | 弹出「新建定时任务」对话框 | +| 0.2 | 「执行智能体」下拉选择 **模板数据转换器** | 表单出现「HTML 模板」下拉;「必须生成 HTML/Markdown」两个开关隐藏 | +| 0.3 | 「HTML 模板」下拉选择 **no.1** | 下方出现「上传 .json」按钮 + 源数据文本框;说明含「未提供源 JSON 时,将直接展示该模板的默认数据」| + +--- + +## T1 — 源形态①:纯 JSON + +| # | 步骤 / 输入 | 预期结果 | +|---|------|----------| +| 1.1 | 在源数据框粘贴纯 JSON(示例见下「样例 A」) | 文本框正常接收,无报错 | +| 1.2 | 保存任务 → 「立即执行」 | 执行记录新增一条,状态最终 succeeded | +| 1.3 | 打开执行记录 → HTML 预览 | 渲染出 no.1 大屏,**总览指标/各列表数据来自粘贴的 JSON**(而非模板默认值)| +| 1.4 | 执行记录正文 | 文案为「已根据源数据填充模板「no.1」生成页面」| + +## T2 — 源形态②:含 JSON 的文本(抽取重点) + +| # | 步骤 / 输入 | 预期结果 | +|---|------|----------| +| 2.1 | 粘贴「样例 B」(前后是中文说明,中间夹一段 JSON) | 文本框正常接收 | +| 2.2 | 保存 → 立即执行 → 查看 HTML | 页面数据以**夹带的 JSON 为准**填充;正文里的补充说明(如「重点突出 X」)被一并参考 | +| 2.3 | 验证抽取正确性:JSON 字符串值里含 `}`(如 `"备注": "进度 80%}尚未完成"`) | 不会被误判为 JSON 块提前结束,整段 JSON 被完整抽取 | + +## T3 — 源形态③:纯文本(无 JSON) + +| # | 步骤 / 输入 | 预期结果 | +|---|------|----------| +| 3.1 | 粘贴「样例 C」(一段纯中文描述,无任何 JSON) | 文本框正常接收 | +| 3.2 | 保存 → 立即执行 → 查看 HTML | 智能体**理解文本语义**后据实填充模板,产出完整可预览页面(数据可能较稀疏但结构完整)| +| 3.3 | 不应报「未返回 JSON」类硬失败(除非模型彻底失败重试耗尽)| 状态 succeeded | + +## T4 — 空源 → 模板默认数据 + +| # | 步骤 | 预期结果 | +|---|------|----------| +| 4.1 | 源数据框留空,保存 → 立即执行 | **不调用模型**,直接用 no.1 模板自带的默认数据生成 | +| 4.2 | 执行记录正文 | 「未提供源数据,已用模板「no.1」的默认数据生成页面」| +| 4.3 | HTML 预览 | 展示模板内置的默认大屏 | + +## T5 — 上传 .json 文件 + +| # | 步骤 | 预期结果 | +|---|------|----------| +| 5.1 | 点击「上传 .json 文件」,选一个合法 `.json` | 文件内容被读入源数据文本框 | +| 5.2 | 上传一个非法 JSON 内容的文件 | toast 提示「文件不是合法 JSON,已填入文本框请检查」,内容仍填入(可手改)| +| 5.3 | 连续上传同一个文件两次 | 第二次仍能触发读取(input 已被重置 value)| + +## T6 — no.2(只展示模板) + +| # | 步骤 | 预期结果 | +|---|------|----------| +| 6.1 | 「HTML 模板」下拉选 **no.2** | **隐藏**上传按钮与源数据文本框;显示「该模板暂为直接展示已有数据,无需填写」| +| 6.2 | 即使之前填过源数据,保存 → 立即执行 | 忽略源数据,**原样输出** no.2 现有页面 | +| 6.3 | 执行记录正文 | 「已展示模板「no.2」的现有数据」| + +## T7 — 编辑回显 + +| # | 步骤 | 预期结果 | +|---|------|----------| +| 7.1 | 对已保存的模板任务点「编辑」 | 「执行智能体」「HTML 模板」「源数据」均正确回显 | +| 7.2 | 把模板从 no.1 改成 no.2 再保存 | 切到 no.2 时源数据区即时隐藏;保存后执行走只展示分支 | + +--- + +## 样例数据(可直接复制粘贴到源数据框) + +### 样例 A — 纯 JSON + +```json +{ + "overview": { + "核心指标": [ + { "名称": "综合区域", "数值": 7, "单位": "区", "说明": "测试数据" }, + { "名称": "组织与单位", "数值": 188, "单位": "个", "说明": "测试数据" } + ] + }, + "regions": [ + { "name": "测试区域甲", "scope": "本岛西岸" } + ], + "equipment": [ + { "name_zh": "测试装备X", "category": "无人系统", "status": "列装" } + ] +} +``` + +> 预期:总览第一项「综合区域」显示 **7**,「组织与单位」显示 **188**,区域/装备出现「测试区域甲」「测试装备X」。 + +### 样例 B — 含 JSON 的文本 + +``` +这是本月台海态势数据,请据此填充大屏,重点突出离岛防卫: + +{"overview": {"核心指标": [{"名称": "综合区域", "数值": 9, "单位": "区", "说明": "含离岛"}]}, "regions": [{"name": "离岛防区", "scope": "外岛", "备注": "进度 80%}尚未完成"}]} + +以上数据来自 6 月例会,务必准确,不要臆造未提供的字段。 +``` + +> 预期:① 数据以中间 JSON 为准(综合区域=9、出现「离岛防区」);② 字符串里的 `}`(`进度 80%}`)不破坏抽取;③ 前后的「重点突出离岛防卫」「务必准确」作为补充说明被模型参考。 + +### 样例 C — 纯文本 + +``` +请整理一份台湾滨海防卫概览:覆盖本岛沿岸 9 个综合区域,组织与单位约 280 个, +装备条目近百项(含无人系统、防空、海军平台等类别),并列出若干关键节点与近期演习。 +数据以离线知识为准,没有的字段留空即可。 +``` + +> 预期:智能体理解语义后据实填充,产出结构完整的页面(具体数值取决于模型,可较稀疏),不报硬失败。 + +--- + +## 后端单元测试(自动化补充) + +```bash +# 在 offline-backend-20260512/backend/ 下运行 +PYTHONPATH=. uv run --no-sync pytest tests/test_template_fill.py -v # 引擎:抽取/校验/填充/注册表 +PYTHONPATH=. uv run --no-sync pytest tests/test_template_fill_attempt.py -v # 调度器执行分支:三形态/默认/只展示/重试 +PYTHONPATH=. uv run --no-sync pytest tests/test_template_builder_seed.py -v # 内置 agent 种子 +``` + +抽取相关关键用例: +- `test_extract_embedded_json_pure_object` / `_pure_array` —— 纯 JSON。 +- `test_extract_embedded_json_text_with_json` / `_fenced` —— 含 JSON 的文本 / ```json 围栏。 +- `test_extract_embedded_json_plain_text_returns_none` —— 纯文本返回 None。 +- `test_extract_embedded_json_ignores_braces_in_strings` —— 引号内 `}` 不误判。 +- `test_text_with_embedded_json_surfaced_in_prompt` —— 转换 prompt 单独标注「识别到的结构化数据」。 +- `test_plain_text_source_converts` —— 纯文本仍照常转换。 +- `test_empty_source_uses_template_default` —— 空源用模板默认数据。 +- `test_display_only_template_renders_as_is` —— no.2 原样输出。 + +三个文件应全部通过(0 failed)。 diff --git a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts index ff8615d0..4f843cfc 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts @@ -837,12 +837,22 @@ export function useStep2Orchestration( const findSeatArtifactSource = useCallback( ( rawPath: string, - ): { seatThreadId: string; messageId: string; toolCallId: string } | null => { + ): { + seatThreadId: string; + messageId: string; + toolCallId: string; + /** stub 里捕获的文件内容(仅 write_file 有;str_replace / 跨账号草稿恢复时常为 undefined)。 */ + content?: string; + } | null => { const target = rawPath.trim(); if (!target) return null; const targetBase = target.split("/").pop() ?? target; + const readContent = (tc: { args?: unknown }): string | undefined => { + const c = (tc.args as { content?: unknown })?.content; + return typeof c === "string" ? c : undefined; + }; let basenameMatch: - | { seatThreadId: string; messageId: string; toolCallId: string } + | { seatThreadId: string; messageId: string; toolCallId: string; content?: string } | null = null; for (const [seatThreadId, msgs] of Object.entries( seatStubMessagesRef.current, @@ -857,10 +867,10 @@ export function useStep2Orchestration( : ""; if (!p) continue; if (p === target) { - return { seatThreadId, messageId: msg.id, toolCallId: tc.id }; + return { seatThreadId, messageId: msg.id, toolCallId: tc.id, content: readContent(tc) }; } if (!basenameMatch && (p.split("/").pop() ?? p) === targetBase) { - basenameMatch = { seatThreadId, messageId: msg.id, toolCallId: tc.id }; + basenameMatch = { seatThreadId, messageId: msg.id, toolCallId: tc.id, content: readContent(tc) }; } } } @@ -879,10 +889,11 @@ export function useStep2Orchestration( if (!normalized) return; // present_files 呈现的文件多半是子智能体 write_file 写入的:真实内容/thread 在 stub 里。 - // 先反查 stub 走虚拟预览(内存内容)—— 关闭沙箱后再点文件卡也能显示,且不受 - // 总控 coordinator thread 在后端 artifacts 路由下 not found 的影响。 + // 先反查 stub:**仅当 stub 内存里确有内容**(流式期间 / 草稿恢复时捕获了 write_file content) + // 才走虚拟预览——关闭沙箱后再点也能显示,且不受总控 coordinator thread 在 artifacts 路由 + // not found 的影响。 const seatSource = findSeatArtifactSource(normalized); - if (seatSource) { + if (seatSource && typeof seatSource.content === "string" && seatSource.content.length > 0) { const virtualUrl = buildWriteFileVirtualUrl( normalized, seatSource.messageId, @@ -896,9 +907,14 @@ export function useStep2Orchestration( return; } - if (!threadId) return; + // stub 无内容(str_replace、或**别的账号**打开共享任务会商草稿时 stub 没带上内容)→ 落 HTTP + // artifacts 接口拉取。优先用真实「子席位 thread」(present_files 的文件物理落在子席位、且 + // 是 thread_type=roundtable 线程,后端按对话创建者解析路径 + 放行跨账号访问),它比 coordinator + // thread 更可能命中(coordinator 没有该文件 → 404)。这是修复「跨账号看不到第二步文件产物」的关键。 + const effectiveThreadId = seatSource?.seatThreadId || threadId; + if (!effectiveThreadId) return; - setActiveArtifactThreadId(threadId); + setActiveArtifactThreadId(effectiveThreadId); const prev = artifactPathsRef.current; if (!prev.includes(normalized)) { const next = [...prev, normalized];