diff --git a/backend/app/engine/nodes/multi.py b/backend/app/engine/nodes/multi.py index 11892067..eefdf394 100644 --- a/backend/app/engine/nodes/multi.py +++ b/backend/app/engine/nodes/multi.py @@ -462,9 +462,10 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: agent=name, call_id=cid, preview=content[:1500], duration_ms=int((time.perf_counter() - started) * 1000), **failure, **({"checks": checks} if checks else {}), - # 判定的那一小段同 Agent 节点(event_view);编号是成员自己的分析容器里的,紧接着 _numbered 按它编 + # 判定的那一小段同 Agent 节点(event_view)。不带编号:成员自己的分析容器里的 A1 不是报告目录里的 + # A1(协作成员的结论还进不了台账),时间线上照它写「· A1」会和报告里点名的结论对不上 **({"analysis_artifact": analysis["artifact"], - "analysis": event_view(analysis["result"], scope.next_alias())} if analysis else {})) + "analysis": event_view(analysis["result"])} if analysis else {})) if analysis is not None and analysis["result"].get("status") == "refused": ctx.emit(EventType.LOG, level="warn", code=ANALYSIS_REFUSED, agent=name, call_id=cid, tool=tname, reason_code=analysis["result"].get("reason_code"), diff --git a/backend/tests/test_analysis_tool_events.py b/backend/tests/test_analysis_tool_events.py index f41a1237..720809d3 100644 --- a/backend/tests/test_analysis_tool_events.py +++ b/backend/tests/test_analysis_tool_events.py @@ -70,7 +70,7 @@ async def test_tool_end_carries_the_phrase_and_the_same_number_the_model_got(mon assert error["call_error"] == "unknown_column" and "analysis" not in error -async def test_a_team_member_numbers_its_own_analyses(monkeypatch, shop): +async def test_a_team_member_analysis_carries_no_number(monkeypatch, shop): Script(monkeypatch, [(TOOL, {"sql": SQL}), (DECOMPOSE, DECOMPOSE_ARGS)], route={"assignments": [{"agent": "分析员", "instruction": "拆一下销售额的变化"}], "done": False}) nodes = [node("start", "input"), @@ -82,7 +82,10 @@ async def test_a_team_member_numbers_its_own_analyses(monkeypatch, shop): input_payload={}) row = await wait(run.id) assert row.status == "succeeded", row.error - [end] = [e for e in await events(row.id, "tool.end", "team") if e.get("analysis_artifact")] - assert end["agent"] == "分析员" - assert (end["analysis"]["alias"], end["analysis"]["op"], end["analysis"]["verdict"]) == ("A1", "decompose", "driver") + ends = [e for e in await events(row.id, "tool.end", "team") if e.get("analysis_artifact")] + assert [e["agent"] for e in ends] == ["分析员"] + end = ends[0] + # 不带编号:成员自己的分析容器里的 A1 不是报告目录里的 A1(协作成员的结论还进不了台账),报告目录另按整次运行的台账编 + assert end["analysis"]["alias"] is None + assert (end["analysis"]["op"], end["analysis"]["verdict"]) == ("decompose", "driver") assert json.dumps(end["analysis"], ensure_ascii=False).count("下降是不是集中在某个地区") == 0, "Agent 写的假设不带" diff --git a/frontend/src/lib/termsAnalysis.ts b/frontend/src/lib/termsAnalysis.ts index 6ff5d9a6..c09e1c93 100644 --- a/frontend/src/lib/termsAnalysis.ts +++ b/frontend/src/lib/termsAnalysis.ts @@ -222,8 +222,11 @@ export const ANALYSIS_TEXT = { step: { /** 运行中的状态字(store/chat) */ running: (label: string) => `正在执行分析:${label}…`, - /** 一步的标题:「变化拆解(可加)· A1」;分析卡的一项:「变化拆解(可加)· where」 */ - title: (method: string, alias?: string | null) => (alias ? `${method} · ${alias}` : method), + /** 分析卡的一项:「变化拆解(可加)· where」。标识是这一项在分析卡里的 id,不是报告目录里的编号 */ + title: (method: string, item?: string | null) => (item ? `${method} · ${item}` : method), + /** Agent 工具那一步:「变化拆解(可加)· 本节点 A1」。编号是这份 Agent 自己的分析容器里的(交回模型的「【分析 A1】」), + * 报告目录里的 A1 按整次运行的台账编,两者不一定是同一个(图里还有分析卡时就不是) */ + nodeTitle: (method: string, alias?: string | null) => (alias ? `${method} · 本节点 ${alias}` : method), callError: '调用有误,没有记成结论', callErrorNext: '这次调用没有记成结论,Agent 会按这条说明改正参数后重新调用', refused: '没有做成', diff --git a/frontend/src/run/__tests__/synthetic.ts b/frontend/src/run/__tests__/synthetic.ts index 8e82baea..a1f5fa49 100644 --- a/frontend/src/run/__tests__/synthetic.ts +++ b/frontend/src/run/__tests__/synthetic.ts @@ -739,13 +739,13 @@ export function analysisTeamRun(): RunEvent[] { ev('tool.end', 'team', { tool: 'analysis_decompose', agent: '分析员', call_id: 'm2', duration_ms: 8, preview: '{\n "analysis_artifact": "a1f0", \n "result": {\n "op": "decompose",\n "mode": "additive",\n "method": "decompose.additive@1",', analysis_artifact: 'a1f0c3d2b4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9', - analysis: { alias: 'A1', op: 'decompose', mode: 'additive', status: 'ok', verdict: 'driver', direction: 'down', phrase: TEAM_PHRASE, + analysis: { alias: null, op: 'decompose', mode: 'additive', status: 'ok', verdict: 'driver', direction: 'down', phrase: TEAM_PHRASE, reason_code: null } }, 0.01) ev('tool.start', 'team', { tool: 'analysis_baseline', agent: '分析员', call_id: 'm3', args: { query: 'Q1', period: 'week', measure: 'gmv' } }, 0.1) ev('tool.end', 'team', { tool: 'analysis_baseline', agent: '分析员', call_id: 'm3', duration_ms: 4, preview: '{\n "analysis_artifact": "b2e1",', analysis_artifact: 'b2e1d4c3a5f6e70819203b4c5d6e7f8091a2b3c4d5e6f70819203b4c5d6e7f80', - analysis: { alias: 'A2', op: 'baseline', mode: null, status: 'refused', verdict: null, direction: null, + analysis: { alias: null, op: 'baseline', mode: null, status: 'refused', verdict: null, direction: null, phrase: '波动基线没有做成:只有 0 期历史,至少要 8 期', reason_code: 'history_short' } }, 0.01) ev('log', 'team', { level: 'warn', code: 'analysis_refused', agent: '分析员', call_id: 'm3', tool: 'analysis_baseline', reason_code: 'history_short', reason: '只有 0 期历史,至少要 8 期', message: '分析员 的 analysis_baseline 没有做成:只有 0 期历史,至少要 8 期' }) diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index ed0ab060..dbcafc65 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -1387,8 +1387,9 @@ function toolStep(seq: number, tool: string, args: Record): Step { } /** - * tool.end 的 analysis(tools/analysis.event_view)折进分析那一行:标题接上编号,副标题是判定语(算不了的就是「…没有做成:原因」, - * 服务端写好的);算不了的用提醒色。老服务端没有这一段:preview 只是结论的前一截,判定语多半被截掉,只照参数写叫法,不从里面拼 + * tool.end 的 analysis(tools/analysis.event_view)折进分析那一行:标题接上这份 Agent 自己的编号(「本节点 A1」),副标题是 + * 判定语(算不了的就是「…没有做成:原因」,服务端写好的);算不了的用提醒色。老服务端没有这一段:preview 只是结论的前一截, + * 判定语多半被截掉,只照参数写叫法,不从里面拼 */ function settleAnalysis(step: Step, d: Record): void { const view = d.analysis && typeof d.analysis === 'object' ? d.analysis as Record : null @@ -1403,7 +1404,9 @@ function settleAnalysis(step: Step, d: Record): void { if (typeof view.reason_code === 'string') info.reasonCode = view.reason_code } step.analysis = info - step.title = ANALYSIS_TEXT.step.title(analysisMethodLabel(info.op, info.mode), info.alias) + // Agent 工具那一步的编号是这份 Agent 自己的分析容器里的(交回模型的「【分析 A1】」),报告目录里的 A1 按整次运行的台账编, + // 两者不一定相同(图里还有分析卡时就不是):标题写「本节点 A1」,不冒充报告里的编号 + step.title = ANALYSIS_TEXT.step.nodeTitle(analysisMethodLabel(info.op, info.mode), info.alias) if (info.phrase) step.sub = step.sub ? `${info.phrase} · ${step.sub}` : info.phrase if (info.status === 'refused') { step.level = 'warn' diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index d17f8fc0..81b70725 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -127,17 +127,17 @@ await section('分析:Agent 的分析工具、分析卡的每一项', async () && wrong.sub === '调用有误,没有记成结论' && !!wrong.next?.includes('Agent 会按这条说明改正参数'), `${wrong?.status} ${wrong?.sub}`) check('……服务端的原话留在展开区,调用错误的代码记在这一行', !!wrong?.result?.includes('Q2 里没有「sales」这一列') && wrong.analysis?.callError === 'unknown_column', wrong?.result) - check('做成的:标题接上本节点的编号(A1,和交回模型的「【分析 A1】」同一个)', decompose?.title === '变化拆解(可加) · A1', decompose?.title) + check('做成的:标题接上本节点的编号(A1,和交回模型的「【分析 A1】」同一个)', decompose?.title === '变化拆解(可加) · 本节点 A1', decompose?.title) check('……副标题是服务端渲染的判定语', decompose?.sub === '销售额下降 340(-12.6%),主要来自华东(-300,占 88.2%)', decompose?.sub) check('……结论工件可下钻,Agent 写的假设另记、不进参数', !!decompose?.artifact && decompose.analysis?.hypothesis === '下降是不是集中在某个地区' && !decompose.detail?.includes('hypothesis') && !decompose.detail?.includes('null'), decompose?.detail?.slice(0, 80)) check('……preview(结论 JSON 的前一截)不摆出来', !decompose?.result, decompose?.result?.slice(0, 40)) check('算不了的:一条结论(不是失败),提醒色,判定语就是「…没有做成:原因」', baseline?.status === 'done' && baseline.level === 'warn' - && baseline.title === '波动基线 · A2' && baseline.sub === '波动基线没有做成:只有 0 期历史,至少要 8 期', `${baseline?.title} ${baseline?.sub}`) + && baseline.title === '波动基线 · 本节点 A2' && baseline.sub === '波动基线没有做成:只有 0 期历史,至少要 8 期', `${baseline?.title} ${baseline?.sub}`) check('……下一步说能照实写原因、不能据此下判断', !!baseline?.next?.includes('算不了也是一条结论')) check('……紧跟着的「没有做成」提醒折进这一行,不另起一行', !all.some((s) => s.code === 'analysis_refused'), all.filter((s) => s.code === 'analysis_refused').map((s) => s.title).join(' | ')) - check('可比性检查也有编号和判定语', comparable?.title === '可比性检查 · A3' && !!comparable.sub?.startsWith('两期可比'), comparable?.title) + check('可比性检查也有编号和判定语', comparable?.title === '可比性检查 · 本节点 A3' && !!comparable.sub?.startsWith('两期可比'), comparable?.title) check('分析那几行是正常的子步骤:不当成运行出错(运行照样完成)', all.some((s) => s.kind === 'lifecycle' && s.title === '完成')) const claims = all.find((s) => s.code === 'report_analysis_claims') check('报告的分析说法重写后仍有:说成人话(几处、已在报告中标注),副标题点名判断词', claims?.title === '报告有 3 处分析说法未通过核对,已在报告中标注' @@ -170,8 +170,8 @@ await section('分析:Agent 的分析工具、分析卡的每一项', async () // 协作成员的分析:编号是成员自己的,副标题接着写是谁调用的 const team = flatten(mod.decodeRun(synthetic.analysisTeamRun(), { status: 'succeeded' })).filter((s) => s.kind === 'analysis') - check('协作成员调分析工具:编号是成员自己的,副标题写判定语和谁调用的', team[0]?.title === '变化拆解(可加) · A1' - && team[0].sub === `${synthetic.TEAM_PHRASE} · 分析员 调用` && team[1]?.title === '波动基线 · A2', team.map((s) => `${s.title}:${s.sub}`).join(' | ')) + check('协作成员调分析工具:不带编号(成员容器里的 A1 不是报告目录里的),副标题写判定语和谁调用的', team[0]?.title === '变化拆解(可加)' + && team[0].sub === `${synthetic.TEAM_PHRASE} · 分析员 调用` && team[1]?.title === '波动基线', team.map((s) => `${s.title}:${s.sub}`).join(' | ')) }) await section('合并查询', async () => { diff --git a/scripts/check-stream.mjs b/scripts/check-stream.mjs index cd4fcb0e..efc7a619 100644 --- a/scripts/check-stream.mjs +++ b/scripts/check-stream.mjs @@ -748,7 +748,7 @@ await section('分析:Agent 的分析工具、分析卡的每一项一行, && await page.locator('[data-step-kind="analysis"]:not([data-step-status="failed"]) svg.lucide-chart-column-stacked').count() === 5, String(await rows.count())) const line = async (i) => (await rows.nth(i).innerText().catch(() => '')).replace(/\s+/g, ' ') - check('做成的一行:标题写算子的叫法和编号,副标题是判定语', (await line(1)).includes('变化拆解(可加) · A1') + check('做成的一行:标题写算子的叫法和本节点的编号(报告目录里的编号另算),副标题是判定语', (await line(1)).includes('变化拆解(可加) · 本节点 A1') && (await line(1)).includes('主要来自华东(-300,占 88.2%)'), await line(1)) check('……标题里不出现工具名 analysis_decompose', !(await page.locator('[data-turn]').first().innerText()).includes('analysis_decompose')) await rows.nth(1).locator('button').first().click() @@ -790,7 +790,8 @@ await section('分析:Agent 的分析工具、分析卡的每一项一行, const team = await open('syn=analysis-team', { w: 1100, h: 900 }) const tl = (await team.page.locator('[data-step-kind="analysis"]').first().innerText().catch(() => '')).replace(/\s+/g, ' ') - check('协作成员的分析:编号是成员自己的,副标题接着写是谁调用的', tl.includes('变化拆解(可加) · A1') && tl.includes('分析员 调用'), tl) + check('协作成员的分析:不带编号(进不了台账,报告目录里的 A1 另算),副标题接着写是谁调用的', tl.includes('变化拆解(可加)') && !tl.includes('本节点 A1') + && tl.includes('分析员 调用'), tl) await team.page.close() })