mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:01:58 +00:00
问数据时间线的分析步骤与出具横幅的三项(分析能力一轮)。时间线(run/decode):新步骤种类 analysis——Agent 的五个分析工具一次调用一行,标题写算子的叫法(契约的叫法,不写 analysis_decompose),做成后照 tool.end 的 analysis 接上本节点的编号(A1,和交回模型的「【分析 A1】」同一个)、副标题是服务端渲染的判定语,结论工件可下钻,参数(没填的不列)进展开区、Agent 写的假设另列;算不了的是一条结论(提醒色,判定语就是「…没有做成:原因」,下一步说照实写原因、不能据此下判断),紧跟着的 analysis_refused 提醒折进那一行;写错的调用(tool.error 带 call_error)副标题写没有记成结论、下一步写 Agent 会照着改、原话在展开区;老服务端没有 analysis 那一段时只照参数写叫法,不从 preview(结论 JSON 的前一截)里拼。分析卡每项一行(analysis.end,不再落成「未识别的事件」):叫法和这一项的标识、判定语、结论工件。报告的分析说法重写后仍有(report_analysis_claims)、口径卡的可比性没做成(metric_comparability_skipped)说成人话。回答流:分析的图标、展开看判定语和假设,长列表折起来时分析那几行照样露出来(不算出了问题);运行中的状态字写「正在执行分析:变化拆解(可加)…」。出具横幅(IssuanceBanner)照出具声明列三项:分析说法核对(strict 记缺口、check 只标注,几处、原话、种类的叫法同审计)、指标的两期可比性(判定语和原因)、报告引用的指标没追到原始数据(追到哪为止、记没记缺口)。夹具:fixtures.json 加两次真跑的运行(后端测试库里的虚构零售库 shop、模型换成脚本:Agent 查库、写错一次拆解、拆解、波动基线算不了、可比性检查,报告 3 处说法标注;分析卡两项),synthetic 加协作成员调分析工具,dev/fixtures/analysis.tsx 三个预览用例。check-decode、check-stream 各加分析的几节(check-stream 加 STREAM_ONLY 只跑几段)
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import type { JSX } from 'react'
|
||||
import fixtures from '../../run/__tests__/fixtures.json'
|
||||
import { AssistantStream, type StreamTurn } from '../../run/AssistantStream'
|
||||
import { decodeRun } from '../../run/decode'
|
||||
import { analysisTeamRun } from '../../run/__tests__/synthetic'
|
||||
import type { RunEvent } from '../../types'
|
||||
|
||||
/**
|
||||
* 分析能力一轮在问数据时间线、出具横幅上的样子,scripts/check-stream.mjs 按它检查:
|
||||
*
|
||||
* - ?syn=analysis:两轮真跑出来的事件(fixtures.json 的 analysis_agent、analysis_card,后端测试库里的虚构零售库 shop、模型换成
|
||||
* 脚本录下的)。第一轮 Agent 查两次库、写错一次变化拆解(调用有误)、做变化拆解、波动基线(算不了)、可比性检查,报告有 3 处
|
||||
* 分析说法标注(check,不影响档位);第二轮分析卡两项(波动基线算不了、按地区拆解)
|
||||
* - ?syn=analysis-issued:出具横幅里分析能力一轮的三项都在——分析说法 strict 记了缺口、指标两期有条件可比和不可比、报告引用的
|
||||
* 指标没追到原始数据(受管级别记了缺口)
|
||||
* - ?syn=analysis-team:协作成员调分析工具(编号是成员自己的)
|
||||
*
|
||||
* 地区、门店、指标、数字都是虚构的;说法照服务端的写法
|
||||
*/
|
||||
|
||||
type Fixture = Record<string, RunEvent[]>
|
||||
const F = fixtures as unknown as Fixture
|
||||
|
||||
const outputOf = (events: RunEvent[]) => (events.find((e) => e.type === 'run.finished')?.data as { output?: Record<string, unknown> })?.output
|
||||
|
||||
function recordedTurns(): StreamTurn[] {
|
||||
const agent = F.analysis_agent
|
||||
const card = F.analysis_card
|
||||
return [
|
||||
{ id: 'analysis-agent', question: '本周销售额为什么下降', phase: 'done', status: '已完成',
|
||||
steps: decodeRun(agent, { status: 'succeeded' }), output: outputOf(agent), runId: 'syn-analysis-agent', runClass: 'exploratory' },
|
||||
{ id: 'analysis-card', question: '按模板出本周的异动分析', phase: 'done', status: '已完成',
|
||||
steps: decodeRun(card, { status: 'succeeded' }), output: outputOf(card), runId: 'syn-analysis-card', runClass: 'exploratory' },
|
||||
]
|
||||
}
|
||||
|
||||
/** 三项都在的出具声明(照 engine/nodes/io.py 的形状;strict 的那几处原话照服务端 analysis_claims 的写法) */
|
||||
export const ANALYSIS_ISSUANCE = {
|
||||
mode: 'citations', tier: 'degraded', matched_numbers: 3, metrics_checked: 1, missing_required: [], missing_expected: [],
|
||||
unmatched_numbers: [], calibers: [],
|
||||
gaps: [
|
||||
'报告「写周报」有 1 处分析说法未通过核对(1 处与所挂结论的判定对不上):判断要挂对应的分析结论并照判定写,原因要有知识库原话或运行输入作依据',
|
||||
'指标「环比」两期不可比:两边除了日期,筛选的还不一样',
|
||||
'指标「客单价」的输入没追到原始数据:vars.aov(停在「沙箱估算」)',
|
||||
],
|
||||
analysis_claims: {
|
||||
mode: 'strict', count: 1, counts: { analysis_claim_mismatch: 1 }, counted: true,
|
||||
violations: [{ code: 'analysis_claim_mismatch', text: '主要来自', claim: 'source', op: 'decompose', analyses: ['A1'], unit: 'u3',
|
||||
segment: 's12', span: [80, 84], message: '「主要来自华南」与所挂的分析结论 A1 对不上:判定的主要来源是华东,不是华南',
|
||||
context: '其中下降主要来自华南。' }],
|
||||
},
|
||||
comparability: [
|
||||
{ metric: 'wow', name: '环比', verdict: 'incomparable', phrase: '两期不可比:两边除了日期,筛选的还不一样', reasons: ['两边除了日期,筛选的还不一样'] },
|
||||
{ metric: 'mom', name: '月环比', verdict: 'conditional', phrase: '两期有条件可比:两段的周末天数不同:4 天对 6 天',
|
||||
reasons: ['两段的周末天数不同:4 天对 6 天'] },
|
||||
],
|
||||
untraced: { metrics: [{ metric: 'aov', name: '客单价', stops: [{ path: 'vars.aov', node_id: 'estimate' }] }], counted: true },
|
||||
}
|
||||
|
||||
function issuedTurn(): StreamTurn {
|
||||
const at = Date.now() / 1000 - 30
|
||||
const output = { 周报: '本周销售额下降,主要来自华东。', _issuance: ANALYSIS_ISSUANCE }
|
||||
const events: RunEvent[] = [
|
||||
{ seq: 1, type: 'run.started', node_id: null, ts: at, data: { nodes: 1 } },
|
||||
{ seq: 2, type: 'node.started', node_id: 'done', ts: at + 1, data: { node_type: 'output', label: '成果' } },
|
||||
{ seq: 3, type: 'issuance', node_id: 'done', ts: at + 1.2, data: { tier: 'degraded', mode: 'citations',
|
||||
gaps: ANALYSIS_ISSUANCE.gaps, analysis_claims: { mode: 'strict', count: 1, counted: true } } },
|
||||
{ seq: 4, type: 'node.finished', node_id: 'done', ts: at + 1.3, data: { duration_ms: 300 } },
|
||||
{ seq: 5, type: 'run.finished', node_id: null, ts: at + 1.4, data: { output, duration_ms: 1400 } },
|
||||
]
|
||||
return { id: 'analysis-issued', question: '本周销售周报', phase: 'done', status: '已完成',
|
||||
steps: decodeRun(events, { status: 'succeeded' }), output, runId: 'syn-analysis-issued', runClass: 'formal' }
|
||||
}
|
||||
|
||||
function teamTurn(): StreamTurn {
|
||||
const ev = analysisTeamRun()
|
||||
return { id: 'analysis-team', question: '销售额为什么下降', phase: 'done', status: '已完成',
|
||||
steps: decodeRun(ev, { status: 'succeeded' }), runId: 'syn-analysis-team' }
|
||||
}
|
||||
|
||||
export const cases: Record<string, () => JSX.Element> = {
|
||||
analysis: () => (
|
||||
<div className="h-screen" data-fixture="analysis"><AssistantStream turns={recordedTurns()} /></div>
|
||||
),
|
||||
'analysis-issued': () => (
|
||||
<div className="h-screen" data-fixture="analysis-issued"><AssistantStream turns={[issuedTurn()]} /></div>
|
||||
),
|
||||
'analysis-team': () => (
|
||||
<div className="h-screen" data-fixture="analysis-team"><AssistantStream turns={[teamTurn()]} /></div>
|
||||
),
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
AlertCircle, AlertTriangle, ArrowDown, Brain, ChevronRight, CircleCheck, CircleDot, CornerDownRight,
|
||||
AlertCircle, AlertTriangle, ArrowDown, Brain, ChartColumnStacked, ChevronRight, CircleCheck, CircleDot, CornerDownRight,
|
||||
Database, Download, ExternalLink, FileCode, FileDown, GitBranch, Hand, Info, ListChecks, Play, Settings2,
|
||||
Scale, ShieldCheck, Sparkles, Table2, Terminal, UserX, Users, Wrench, XCircle,
|
||||
} from 'lucide-react'
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
nodeTypeLabel,
|
||||
} from '../lib/terms'
|
||||
import { SqlCheckList } from './SqlChecks'
|
||||
import { ANALYSIS_TEXT } from '../lib/termsAnalysis'
|
||||
import {
|
||||
ISSUANCE_HINT, childrenByExec, compactSteps, parseQueryResult, progressOf, splitIssuanceGaps, spread, teamVerdictOf,
|
||||
type Exec, type FixKind, type ResultTable as Table, type Step, type StepKind, type TeamMemberEx,
|
||||
@@ -28,7 +29,9 @@ import {
|
||||
import { CODE_COLUMN, CopyChip, LEADING_ZERO, Markdown, type MarkSpec } from './Markdown'
|
||||
import { ClaimTally, EvidenceField, type EvidenceDocHandle } from './EvidenceDoc'
|
||||
import { EVIDENCE_STATE, claimProblems, docTally, evidenceFields, issuanceMarks, type EvidenceTally } from '../lib/evidence'
|
||||
import type { EvidenceDocData, IssuanceSqlCheck } from '../types'
|
||||
import type {
|
||||
EvidenceDocData, IssuanceAnalysisClaims, IssuanceComparability, IssuanceSqlCheck, IssuanceUntraced,
|
||||
} from '../types'
|
||||
import { useRunClock } from './useRunClock'
|
||||
import { CatalogPatchCard } from './CatalogPatchCard'
|
||||
import type { CatalogPatch } from './catalogPatch'
|
||||
@@ -131,7 +134,7 @@ export interface StreamTurn {
|
||||
}
|
||||
|
||||
const ICONS: Record<StepKind, typeof Wrench> = {
|
||||
node: CircleDot, think: Brain, llm: Sparkles, query: Database, schema: Table2,
|
||||
node: CircleDot, think: Brain, llm: Sparkles, query: Database, schema: Table2, analysis: ChartColumnStacked,
|
||||
tool: Wrench, code: Terminal, branch: GitBranch, human: Hand,
|
||||
issuance: FileCode, note: CircleDot, error: XCircle, lifecycle: CircleCheck,
|
||||
}
|
||||
@@ -1011,6 +1014,11 @@ const REVEAL_TRIES = 4
|
||||
const notable = (s: Step): boolean =>
|
||||
s.status === 'failed' || s.status === 'running' || s.status === 'waiting' || s.status === 'rejected'
|
||||
|| s.level === 'error' || s.level === 'warn'
|
||||
/**
|
||||
* 折起来时也留着的:上面那些,加上分析的那几行——判定语就是这一步查出来的结论(变化来自哪里、算不算异常),收进「前面的 N 条」
|
||||
* 就看不到 Agent 是怎么下的判断。它们不算出了问题(分轮的警示标记只看 notable)
|
||||
*/
|
||||
const kept = (s: Step): boolean => notable(s) || s.kind === 'analysis'
|
||||
|
||||
function StepList({ steps, depth, turnMs, fold = false }: {
|
||||
steps: Step[]; depth: number; turnMs?: number
|
||||
@@ -1025,7 +1033,7 @@ function StepList({ steps, depth, turnMs, fold = false }: {
|
||||
// 问题的一律留着。反过来只画前 20 条的话,长运行跑到后面,正在跑的那一步反而
|
||||
// 被收进「展开其余」里看不见
|
||||
const keep = fold && rows.length > 6 ? 3 : rows.length > LIST_CAP ? LIST_HEAD : 0
|
||||
const shown = !all && keep ? rows.filter((s, i) => notable(s) || i >= rows.length - keep) : rows
|
||||
const shown = !all && keep ? rows.filter((s, i) => kept(s) || i >= rows.length - keep) : rows
|
||||
const hidden = rows.length - shown.length
|
||||
// 画布上点名要看的节点正好收在前面那一截里:先展开,上面的定位下一帧就找得到它
|
||||
useLayoutEffect(() => {
|
||||
@@ -1086,7 +1094,8 @@ function StepLine({ step, depth, turnMs }: { step: Step; depth: number; turnMs?:
|
||||
const [open, setOpen] = useState(false)
|
||||
const Icon = ICONS[step.kind] ?? CircleDot
|
||||
const result = step.result
|
||||
const expandable = !!(step.detail || result || step.raw || step.artifact || step.checks?.length)
|
||||
const expandable = !!(step.detail || result || step.raw || step.artifact || step.checks?.length
|
||||
|| step.analysis?.phrase || step.analysis?.hypothesis)
|
||||
const table = result ? parseQueryResult(result) : null
|
||||
const status = step.status ?? 'done'
|
||||
// 失败的节点:收着的时候就说为什么(和轮次顶上的报错同一份 lib/explain),展开再看原因、
|
||||
@@ -1136,6 +1145,7 @@ function StepLine({ step, depth, turnMs }: { step: Step; depth: number; turnMs?:
|
||||
data-node-id={step.nodeId}
|
||||
data-step-status={failed ? 'failed' : status}
|
||||
data-step-code={step.code}
|
||||
data-step-kind={step.kind}
|
||||
aria-busy={running || undefined}
|
||||
>
|
||||
<button
|
||||
@@ -1539,8 +1549,26 @@ function StepDetail({ step, table, explained }: {
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{step.analysis && (step.analysis.phrase || step.analysis.hypothesis) && (
|
||||
// 分析:判定语(服务端写好的一句,收着时副标题可能被截断)、Agent 写的假设(只在这里和证据面板显示,不是结论)
|
||||
<dl className="space-y-0.5 text-2xs leading-relaxed" data-step-analysis={step.analysis.alias ?? step.analysis.item ?? ''}>
|
||||
{step.analysis.phrase && (
|
||||
<div className="flex gap-1.5">
|
||||
<dt className="shrink-0 text-faint">{ANALYSIS_TEXT.step.phrase}</dt>
|
||||
<dd className="min-w-0 text-fg [overflow-wrap:anywhere]" data-step-analysis-phrase="">{step.analysis.phrase}</dd>
|
||||
</div>
|
||||
)}
|
||||
{step.analysis.hypothesis && (
|
||||
<div className="flex gap-1.5">
|
||||
<dt className="shrink-0 text-faint">{step.analysis.item ? ANALYSIS_TEXT.step.itemHypothesis : ANALYSIS_TEXT.step.hypothesis}</dt>
|
||||
<dd className="min-w-0 text-dim [overflow-wrap:anywhere]" data-step-analysis-hypothesis="">{step.analysis.hypothesis}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
{detail && (
|
||||
<div className="group/detail relative">
|
||||
{step.kind === 'analysis' && <div className="mb-0.5 text-2xs text-dim">{ANALYSIS_TEXT.step.args}</div>}
|
||||
{step.kind === 'query' && (
|
||||
<div className="mb-0.5 flex items-center gap-2 text-2xs text-dim">
|
||||
<span className={step.merge ? undefined : 'mono'}>{step.merge ? MERGE_TEXT.sql : 'SQL'}</span>
|
||||
@@ -2362,6 +2390,75 @@ export function IssuanceSqlChecks({ tier, checks }: { tier: string; checks: Issu
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 出具声明里分析能力一轮的三项(engine/nodes/io.py),都只在有的时候带:
|
||||
* - analysis_claims:分析说法核对,strict 或有违规时有——几处、记没记缺口(strict 记缺口、check 只标注)、前几处的原话
|
||||
* - comparability:报告引用的指标里两期有条件可比、不可比的,判定语和原因(不可比的另记了缺口,在其余缺口里)
|
||||
* - untraced:报告引用的指标里输入没追到原始数据的,追到哪个节点为止(受管级别的正式运行记缺口)
|
||||
* 说法都是服务端写好的(违规的原话、判定语、原因),这里只排版;颜色跟着档位,同 IssuanceSqlChecks
|
||||
*/
|
||||
export function IssuanceAnalysis({ tier, claims, comparability, untraced }: {
|
||||
tier: string; claims?: IssuanceAnalysisClaims | null; comparability?: IssuanceComparability[]; untraced?: IssuanceUntraced | null
|
||||
}) {
|
||||
const I = ANALYSIS_TEXT.issuance
|
||||
const tone = tier === 'withheld' ? 'var(--st-failed)' : 'var(--st-waiting)'
|
||||
const violations = (claims?.violations ?? []).filter((v) => v && typeof v === 'object')
|
||||
const count = typeof claims?.count === 'number' ? claims.count : violations.length
|
||||
const metrics = (untraced?.metrics ?? []).filter((m) => m && typeof m === 'object')
|
||||
if (!(claims && (count > 0 || claims.counted)) && !comparability?.length && !metrics.length) return null
|
||||
const box = { borderColor: `color-mix(in srgb, ${tone} 45%, var(--border))`, background: `color-mix(in srgb, ${tone} 6%, transparent)` }
|
||||
return (
|
||||
<>
|
||||
{claims && (count > 0 || claims.counted) && (
|
||||
<div className="mt-1.5 rounded border px-2 py-1.5 text-2xs leading-relaxed" style={box}
|
||||
data-issuance-analysis-claims={claims.counted ? 'counted' : 'flagged'}>
|
||||
<div className="flex items-center gap-1 font-medium" style={{ color: tone }}>
|
||||
<AlertTriangle size={11} aria-hidden /> {count > 0 ? I.claimsTitle(count, !!claims.counted) : I.claimsStrictClean}
|
||||
</div>
|
||||
{violations.length > 0 && (
|
||||
<ul className="mt-0.5 space-y-0.5">
|
||||
{violations.map((v, i) => (
|
||||
<li key={`${v.code}:${v.segment ?? i}`} className="[overflow-wrap:anywhere]" data-issuance-analysis-claim={v.code}>
|
||||
{v.text && <span className="font-medium text-fg">「{v.text}」</span>}
|
||||
<span className="text-dim">({ANALYSIS_TEXT.claim.label[v.code] ?? v.code})</span>
|
||||
{v.message && <span className="text-fg">:{v.message}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{count > 0 && <div className="mt-0.5 text-dim">{I.claimsNext}</div>}
|
||||
</div>
|
||||
)}
|
||||
{!!comparability?.length && (
|
||||
<div className="mt-1.5 text-2xs leading-relaxed" style={{ color: tone }} data-issuance-comparability={comparability.length}>
|
||||
<div className="font-medium">{I.comparabilityTitle}</div>
|
||||
<ul className="mt-0.5 space-y-0.5 pl-2 [overflow-wrap:anywhere]">
|
||||
{comparability.map((c, i) => (
|
||||
<li key={`${c.metric ?? ''}:${i}`} data-issuance-comparability-item={c.verdict ?? ''}>
|
||||
· {I.comparabilityItem(c.name || c.metric || '', c.phrase || ANALYSIS_TEXT.evidence.comparabilityLabel[c.verdict ?? ''] || '')}
|
||||
{!!c.reasons?.length && <span className="text-dim">({c.reasons.join(';')})</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{metrics.length > 0 && (
|
||||
<div className="mt-1.5 text-2xs leading-relaxed" style={{ color: tone }} data-issuance-untraced={untraced?.counted ? 'counted' : 'flagged'}>
|
||||
<div className="font-medium">{I.untracedTitle(!!untraced?.counted)}</div>
|
||||
<ul className="mt-0.5 space-y-0.5 pl-2 [overflow-wrap:anywhere]">
|
||||
{metrics.map((m, i) => (
|
||||
<li key={`${m.metric ?? ''}:${i}`}>
|
||||
· {I.untracedItem(m.name || m.metric || '', (m.stops ?? []).map((st) => EVIDENCE_TEXT.originStop(st.path ?? '',
|
||||
EVIDENCE_TEXT.originNode(st.label || st.node_id || '', st.type ? nodeTypeLabel(st.type) : ''))))}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 出具横幅里双盲复算、反方审稿报上来的一组缺口:failed「核查未通过」、unfinished「核查未完成」。排在「校验未全部完成」
|
||||
* 上面,样子和它一样(同一种提醒色、小标题加圆点列表),一条都没有时不画
|
||||
@@ -2495,6 +2592,9 @@ export function IssuanceBanner({ issuance, runClass, evidence }: {
|
||||
)}
|
||||
|
||||
{!!sqlChecks.length && <IssuanceSqlChecks tier={tier} checks={sqlChecks} />}
|
||||
<IssuanceAnalysis tier={tier} claims={issuance?.analysis_claims}
|
||||
comparability={Array.isArray(issuance?.comparability) ? issuance.comparability : undefined}
|
||||
untraced={issuance?.untraced} />
|
||||
<IssuanceGaps kind="failed" title={DUAL_CHECK_TEXT.verifyGaps.failed} gaps={verifyFailed} />
|
||||
<IssuanceGaps kind="unfinished" title={DUAL_CHECK_TEXT.verifyGaps.unfinished} gaps={verifyUnfinished} />
|
||||
{!!gaps.length && (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -715,3 +715,44 @@ export const COPILOT_OBJECT_ISSUES = [
|
||||
{ op: 'remove_node', id: 'old' },
|
||||
{ op: 'final', graph: { nodes: [{ id: 'lp' }, { id: 'ask' }] }, issues: [] },
|
||||
]
|
||||
|
||||
/**
|
||||
* 协作成员调分析工具(分析能力一轮):分析员查一次按地区两周的销售额,做一次变化拆解、一次波动基线(两周历史不够,算不了)。
|
||||
* 字段照 engine/nodes/multi.py:tool.end 带 agent、analysis_artifact 和判定那一小段(tools/analysis.event_view,编号是成员自己的
|
||||
* 分析容器里的);算不了的另有一条 analysis_refused 的提醒(带 agent、call_id)。preview 是结论 JSON 的前一截,判定语在截掉的那段里
|
||||
*/
|
||||
export const TEAM_PHRASE = '销售额下降 340(-12.6%),主要来自华东(-300,占 88.2%)'
|
||||
export function analysisTeamRun(): RunEvent[] {
|
||||
const { out, ev } = builder('syn-analysis-team')
|
||||
const sql = 'SELECT region, week, SUM(amount) AS gmv FROM orders GROUP BY region, week ORDER BY region, week'
|
||||
ev('run.started', null, { nodes: 3, resumed: false })
|
||||
ev('node.started', 'team', { node_type: 'supervisor', label: '销售分析团队' })
|
||||
ev('agent.route.start', 'team', { round: 0 })
|
||||
ev('agent.route.end', 'team', { round: 0, duration_ms: 900, agents: ['分析员'], parallel: 1, done: false, reason: '先拆一下变化来自哪里' }, 0.9)
|
||||
ev('agent.step.start', 'team', { agent: '分析员', instruction: '拆一下销售额的变化', round: 0, parallel: 1 })
|
||||
ev('tool.start', 'team', { tool: 'db_query__shop', agent: '分析员', call_id: 'm1', args: { sql } }, 0.2)
|
||||
ev('tool.end', 'team', { tool: 'db_query__shop', agent: '分析员', call_id: 'm1', duration_ms: 30,
|
||||
preview: JSON.stringify({ columns: ['region', 'week', 'gmv'], rows: [['华东', '2026-W41', 1000], ['华东', '2026-W42', 700]], row_count: 8 }) }, 0.03)
|
||||
ev('tool.start', 'team', { tool: 'analysis_decompose', agent: '分析员', call_id: 'm2', args: {
|
||||
mode: 'additive', query: 'Q1', by: ['region'], period: 'week', base: '2026-W41', target: '2026-W42', measure: 'gmv',
|
||||
label: '销售额', hypothesis: '下降是不是集中在某个地区', parent: null } }, 0.2)
|
||||
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,
|
||||
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,
|
||||
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 期' })
|
||||
ev('agent.step.end', 'team', { agent: '分析员', duration_ms: 1200, round: 0, parallel: 1, preview: TEAM_PHRASE }, 0.3)
|
||||
ev('agent.route.start', 'team', { round: 1 })
|
||||
ev('agent.route.end', 'team', { round: 1, duration_ms: 600, agents: [], parallel: 0, done: true, reason: '已经说清来源' }, 0.6)
|
||||
ev('node.finished', 'team', { duration_ms: 3200, attempt: 1, preview: TEAM_PHRASE })
|
||||
ev('run.finished', null, { output: { 结论: TEAM_PHRASE }, timing: { wall_ms: 3400, active_ms: 3400, wait_ms: 0 } })
|
||||
return out
|
||||
}
|
||||
|
||||
+143
-1
@@ -15,6 +15,7 @@ import { catalogPatchOf, patchBeforeText, patchValueText, patchWhere } from './c
|
||||
import { LEDGER_FEEDBACK_TEXT, RECALL_TEXT } from '../lib/termsRecall'
|
||||
import { claimCountsOf, isJudged, statsTally } from '../lib/evidence'
|
||||
import { isSqlCheckCode, sortSqlChecks, sqlCheckOf, sqlChecksOf, sqlCheckWhere, sqlRuleLabel } from '../lib/sqlcheck'
|
||||
import { ANALYSIS_TEXT, analysisMethodLabel, analysisToolOp } from '../lib/termsAnalysis'
|
||||
|
||||
// 泳道数据画布也要用(supervisor 节点要展开成协作矩阵),所以类型放在
|
||||
// types.ts 里;这里再导出一遍,老引用不用改
|
||||
@@ -80,6 +81,7 @@ export type StepKind =
|
||||
| 'llm' // 模型调用
|
||||
| 'query' // 数据库查询
|
||||
| 'schema' // 看表结构
|
||||
| 'analysis' // 分析:Agent 的分析工具、分析卡的一项
|
||||
| 'tool' // 其他工具
|
||||
| 'code' // 沙箱代码
|
||||
| 'branch' // 分支走向
|
||||
@@ -163,6 +165,8 @@ export interface Step {
|
||||
tier?: string
|
||||
/** 合并查询那一行(merge.end):合并了哪几个输入、几条警告。SQL 在 detail,预览在 result */
|
||||
merge?: MergeStep
|
||||
/** 分析那一行(Agent 分析工具的 tool.start / tool.end、分析卡一项的 analysis.end):算子、判定语、编号。参数在 detail */
|
||||
analysis?: AnalysisStep
|
||||
/** 对照数据目录的 SQL 检查结果:查询那一行(tool.end.checks)、指标所依据的查询没通过检查那一行(log.checks) */
|
||||
checks?: SqlCheckItem[]
|
||||
/** fix 要去的不是这一行所在的节点(指标的问题出在来源查询节点的 SQL 上):去哪个节点、落到哪一栏 */
|
||||
@@ -181,6 +185,25 @@ export interface MergeStepInput {
|
||||
source?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析的一步:判定语、编号、算不了的原因码都是服务端给的(tool.end 的 analysis,tools/analysis.event_view;分析卡的 analysis.end),
|
||||
* 界面只排版。写错的调用(tool.error 带 call_error)没有判定,callError 是服务端的代码
|
||||
*/
|
||||
export interface AnalysisStep {
|
||||
op?: string
|
||||
mode?: string | null
|
||||
/** Agent 工具的结论在本节点的编号(A1);分析卡的一项用 item */
|
||||
alias?: string | null
|
||||
item?: string
|
||||
status?: string
|
||||
verdict?: string | null
|
||||
phrase?: string
|
||||
reasonCode?: string | null
|
||||
callError?: string
|
||||
/** Agent 写的「这次在验证什么」(调用参数里的 hypothesis) */
|
||||
hypothesis?: string
|
||||
}
|
||||
|
||||
export interface MergeStep {
|
||||
inputs: MergeStepInput[]
|
||||
/** 执行时的警告条数。警告的原文由同一节点紧接着的 log(merge_key_type…)各占一行,不在这里重复 */
|
||||
@@ -889,6 +912,35 @@ function explainLog(code: string | undefined, message: string, d: Record<string,
|
||||
next: MASKED_CELL_TEXT.next,
|
||||
}
|
||||
}
|
||||
case 'report_analysis_claims': {
|
||||
// 判断词没挂对分析结论、原因没有依据,重写后仍有(report.py):读结构化字段 count、texts,不从原话里认。check 照常产出、
|
||||
// 只标注;strict 另记了出具缺口(原话里说了,详情里有)
|
||||
const n = typeof d.count === 'number' && d.count > 0 ? d.count : undefined
|
||||
const texts = strs(d.texts)
|
||||
return {
|
||||
title: ANALYSIS_TEXT.log.claims(n, d.mode === 'strict'),
|
||||
sub: texts.length ? texts.map((t) => `「${t}」`).join('、') : undefined,
|
||||
next: ANALYSIS_TEXT.issuance.claimsNext,
|
||||
}
|
||||
}
|
||||
case 'analysis_refused': {
|
||||
// 分析那一行不在(老事件)时才走到这里:照结构化字段写哪一种分析没有做成、原因
|
||||
const tool = analysisToolOp(d.tool)
|
||||
const op = typeof d.op === 'string' ? d.op : tool
|
||||
return {
|
||||
title: ANALYSIS_TEXT.log.refused(analysisMethodLabel(op), str(d.item)),
|
||||
sub: str(d.reason),
|
||||
next: ANALYSIS_TEXT.step.refusedNext,
|
||||
}
|
||||
}
|
||||
case 'metric_comparability_skipped': {
|
||||
// 口径卡的环比、同比用到两份同形查询,可比性检查没做成(metrics.py):指标照常算,两期能不能直接比要自己核对
|
||||
return {
|
||||
title: ANALYSIS_TEXT.log.comparabilitySkipped(str(d.metric_name) ?? str(d.metric) ?? ''),
|
||||
sub: str(d.reason),
|
||||
next: ANALYSIS_TEXT.log.comparabilitySkippedNext,
|
||||
}
|
||||
}
|
||||
case 'report_catalog_trimmed': {
|
||||
// 写作目录超出篇幅(report.py _trimmed_message)。读结构化字段(queries、omitted、nodes),不从原话里认
|
||||
const total = typeof d.queries === 'number' ? d.queries : undefined
|
||||
@@ -1318,11 +1370,48 @@ function toolStep(seq: number, tool: string, args: Record<string, any>): Step {
|
||||
return { ...base, kind: 'schema', title: clip(`查看 ${tables.length} 张表的字段:${head}…`),
|
||||
detail: tables.join('\n') }
|
||||
}
|
||||
const op = analysisToolOp(tool)
|
||||
if (op) {
|
||||
// Agent 的分析工具:标题写算子的叫法(契约的叫法),不写 analysis_decompose;参数里没填的不列。判定语、编号等 tool.end
|
||||
const shown = Object.fromEntries(Object.entries(args ?? {}).filter(([k, v]) => v != null && k !== 'hypothesis'))
|
||||
const hypothesis = typeof args?.hypothesis === 'string' && args.hypothesis.trim() ? args.hypothesis.trim() : undefined
|
||||
return {
|
||||
...base, kind: 'analysis', title: analysisMethodLabel(op, args?.mode),
|
||||
detail: Object.keys(shown).length ? JSON.stringify(shown, null, 2) : undefined,
|
||||
analysis: { op, ...(args?.mode ? { mode: String(args.mode) } : {}), ...(hypothesis ? { hypothesis } : {}) },
|
||||
}
|
||||
}
|
||||
const argText = Object.keys(args ?? {}).length
|
||||
? JSON.stringify(args, null, 2) : ''
|
||||
return { ...base, kind: 'tool', title: clip(`调用 ${tool}`), detail: argText || undefined }
|
||||
}
|
||||
|
||||
/**
|
||||
* tool.end 的 analysis(tools/analysis.event_view)折进分析那一行:标题接上编号,副标题是判定语(算不了的就是「…没有做成:原因」,
|
||||
* 服务端写好的);算不了的用提醒色。老服务端没有这一段:preview 只是结论的前一截,判定语多半被截掉,只照参数写叫法,不从里面拼
|
||||
*/
|
||||
function settleAnalysis(step: Step, d: Record<string, any>): void {
|
||||
const view = d.analysis && typeof d.analysis === 'object' ? d.analysis as Record<string, any> : null
|
||||
const info: AnalysisStep = { ...(step.analysis ?? {}) }
|
||||
if (view) {
|
||||
if (typeof view.op === 'string') info.op = view.op
|
||||
if (view.mode != null) info.mode = String(view.mode)
|
||||
if (typeof view.alias === 'string' && view.alias) info.alias = view.alias
|
||||
if (typeof view.status === 'string') info.status = view.status
|
||||
if (typeof view.verdict === 'string') info.verdict = view.verdict
|
||||
if (typeof view.phrase === 'string' && view.phrase) info.phrase = view.phrase
|
||||
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)
|
||||
if (info.phrase) step.sub = step.sub ? `${info.phrase} · ${step.sub}` : info.phrase
|
||||
if (info.status === 'refused') {
|
||||
step.level = 'warn'
|
||||
step.next = ANALYSIS_TEXT.step.refusedNext
|
||||
}
|
||||
if (typeof d.analysis_artifact === 'string' && d.analysis_artifact) step.artifact = d.analysis_artifact
|
||||
}
|
||||
|
||||
/** 低于这个毫秒数的节点不参与并行分组:瞬时节点谁跟谁都"重叠",是噪音不是信息 */
|
||||
const CONCURRENT_MIN_MS = 30
|
||||
/** 开始时刻相差在这个数以内算"同一批出发"。实测 fan-out 的几路相差不到 1ms */
|
||||
@@ -1739,6 +1828,11 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt
|
||||
const escalated = new Map<string, { step: Step; tool: string; reason: string }[]>()
|
||||
/** node_id → 起止时刻。用来事后认出"哪几个节点是同时跑的" */
|
||||
const spans = new Map<string, { start: number; end: number; ms: number }>()
|
||||
/**
|
||||
* 分析那一行(Agent 工具按 call_id,分析卡按节点 + 项):紧跟着的那条「没有做成」的提醒(log analysis_refused)说的是同一件事,
|
||||
* 那一行已经写了判定语(「…没有做成:原因」),不再另起一行
|
||||
*/
|
||||
const analysisSteps = new Map<string, Step>()
|
||||
|
||||
/** 协作团队的状态只有一个来源:reduceTeam。画布那边调的是同一个函数 */
|
||||
const trackTeam = (event: RunEvent): void => {
|
||||
@@ -2216,7 +2310,24 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt
|
||||
step.sub = step.sub ? `${step.sub} · ${counts}` : counts
|
||||
if (n('error') || n('warning')) step.level = 'warn'
|
||||
}
|
||||
if (step.kind === 'query' || step.kind === 'schema' || failed) {
|
||||
if (step.kind === 'analysis') {
|
||||
// 分析:成了的照 tool.end 的 analysis 写判定语和编号(结论工件在 analysis_artifact,preview 是结论 JSON 的前一截,
|
||||
// 不摆出来)。写错的调用(call_error)和别的工具报错一样算这一次没成,但它没有记成结论、原话交回了 Agent,Agent 会
|
||||
// 照着改了再调:副标题说清没记成结论,下一步写在行下面,原话在展开区
|
||||
if (failed) {
|
||||
const why = String(d.error ?? preview)
|
||||
step.sub = d.call_error ? ANALYSIS_TEXT.step.callError : step.sub
|
||||
step.result = why
|
||||
if (d.call_error) {
|
||||
step.analysis = { ...(step.analysis ?? {}), callError: String(d.call_error) }
|
||||
step.next = ANALYSIS_TEXT.step.callErrorNext
|
||||
}
|
||||
} else {
|
||||
settleAnalysis(step, d)
|
||||
analysisSteps.set(key, step)
|
||||
}
|
||||
step.meta = dur(step.ms)
|
||||
} else if (step.kind === 'query' || step.kind === 'schema' || failed) {
|
||||
// 查询保留 SQL 作为 detail,结果另挂;失败时结果就是错误原因
|
||||
step.detail = failed ? `${step.detail ?? ''}\n\n${preview}`.trim() : step.detail
|
||||
step.result = preview
|
||||
@@ -2720,6 +2831,12 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt
|
||||
break
|
||||
}
|
||||
}
|
||||
if (d.code === 'analysis_refused') {
|
||||
// 一份分析没有做成:分析那一行已经写了「…没有做成:原因」,同一件事不再另起一行。找不到那一行(老事件)才照下面说
|
||||
const key = typeof d.call_id === 'string' && d.call_id ? `call:${d.call_id}`
|
||||
: `${nodeId ?? '_'}:item:${String(d.item ?? '')}`
|
||||
if (analysisSteps.has(key)) break
|
||||
}
|
||||
// 双盲复算、反方审稿的日志按 code 和结构化字段解读:info 的也上时间线,没有原话的老日志照样说得出来。
|
||||
// 后端标了 warn / error 的照它的来,explainLog 认为该提醒的(有缺口、有推翻)也用提醒色
|
||||
if (typeof d.code === 'string' && VERIFY_LOG_CODES.has(d.code)) {
|
||||
@@ -2863,6 +2980,31 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt
|
||||
break
|
||||
}
|
||||
|
||||
case 'analysis.end': {
|
||||
// 分析卡的一项做完了(engine/nodes/analysis.py):标题写算子的叫法和这一项的标识,副标题是判定语(服务端写好的,算不了
|
||||
// 的是「…没有做成:原因」)。结论工件可以点开
|
||||
const item = typeof d.item === 'string' ? d.item : ''
|
||||
const info: AnalysisStep = {
|
||||
...(typeof d.op === 'string' ? { op: d.op } : {}), ...(d.mode != null ? { mode: String(d.mode) } : {}),
|
||||
...(item ? { item } : {}), ...(typeof d.status === 'string' ? { status: d.status } : {}),
|
||||
...(typeof d.verdict === 'string' ? { verdict: d.verdict } : {}),
|
||||
...(typeof d.phrase === 'string' && d.phrase ? { phrase: d.phrase } : {}),
|
||||
...(typeof d.reason_code === 'string' ? { reasonCode: d.reason_code } : {}),
|
||||
}
|
||||
const refused = d.status === 'refused'
|
||||
const row: Step = {
|
||||
id: `an-${seq}`, seq, kind: 'analysis', nodeId, status: 'done',
|
||||
title: ANALYSIS_TEXT.step.title(analysisMethodLabel(d.op, d.mode), item),
|
||||
...(info.phrase ? { sub: info.phrase } : {}),
|
||||
...(refused ? { level: 'warn' as const, next: ANALYSIS_TEXT.step.refusedNext } : {}),
|
||||
...(typeof d.analysis_artifact === 'string' && d.analysis_artifact ? { artifact: d.analysis_artifact } : {}),
|
||||
analysis: info,
|
||||
}
|
||||
analysisSteps.set(`${nodeId ?? '_'}:item:${item}`, row)
|
||||
push(row, nodeId)
|
||||
break
|
||||
}
|
||||
|
||||
case 'merge.end': {
|
||||
// 合并查询:几次查询的结果在库外按键合并。和查询同一类(kind query):detail 是合并 SQL,result 是预览,
|
||||
// 工件是合并结果的查询快照。输入列在 merge 里,展开区单列一块;警告的原文由紧跟着的 log 各占一行
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { create } from 'zustand'
|
||||
import { ANALYSIS_TEXT, analysisMethodLabel, analysisToolOp } from '../lib/termsAnalysis'
|
||||
import { ApiError, api, streamRun } from '../api/client'
|
||||
import { streamChatTurn, type ChatTurnEvent, type ChatTurnStart } from '../api/chatTurns'
|
||||
import type { CopilotOp, FixKind, RunFinal } from '../run/decode'
|
||||
@@ -1649,9 +1650,12 @@ function watch(flow: Flow, runId: string, after = 0): () => void {
|
||||
switch (event.type) {
|
||||
case 'tool.start': {
|
||||
const tool = String(d.tool ?? '')
|
||||
// 分析工具写算子的叫法(契约的叫法,lib/termsAnalysis),不写 analysis_decompose
|
||||
const op = analysisToolOp(tool)
|
||||
patch((t) => (t.phase !== 'running' ? {} : {
|
||||
status: tool.startsWith('db_query') ? '正在查询数据…'
|
||||
: tool.startsWith('db_schema') ? '正在读取数据结构…'
|
||||
: op ? ANALYSIS_TEXT.step.running(analysisMethodLabel(op, d.args?.mode))
|
||||
: tool ? `正在调用 ${tool}…` : '正在运行…',
|
||||
}))
|
||||
break
|
||||
|
||||
@@ -112,6 +112,68 @@ await section('知识检索', async () => {
|
||||
check('未知事件的类型名不上标题和副标题', !unknown.some((x) => x.title.includes('brand.new') || x.sub?.includes('brand.new')))
|
||||
})
|
||||
|
||||
await section('分析:Agent 的分析工具、分析卡的每一项', async () => {
|
||||
// 真跑出来的两次运行(fixtures.analysis_agent / analysis_card:后端测试库里的虚构零售库 shop,模型换成脚本)。Agent 查两次库、
|
||||
// 写错一次变化拆解(列不存在)、做变化拆解、波动基线(两周历史不够,算不了)、可比性检查;分析卡两项。判定语、编号都是服务端
|
||||
// 给的(tool.end 的 analysis、analysis.end),preview 只是结论 JSON 的前一截,不拿来拼
|
||||
const all = flatten(mod.decodeRun(fixtures.analysis_agent, { status: 'succeeded' }))
|
||||
const rows = all.filter((s) => s.kind === 'analysis')
|
||||
check('四次分析调用各一行(kind analysis),挂在 Agent 节点下', rows.length === 4 && rows.every((s) => s.nodeId === 'bot'),
|
||||
rows.map((s) => s.title).join(' | '))
|
||||
const [wrong, decompose, baseline, comparable] = rows
|
||||
check('标题写算子的叫法(契约),不写 analysis_decompose', rows.every((s) => !s.title.includes('analysis_')) && wrong?.title === '变化拆解(可加)',
|
||||
wrong?.title)
|
||||
check('写错的调用:这一次没成、副标题说没有记成结论,下一步写 Agent 会照着改', wrong?.status === 'failed'
|
||||
&& 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('……副标题是服务端渲染的判定语', 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}`)
|
||||
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('分析那几行是正常的子步骤:不当成运行出错(运行照样完成)', all.some((s) => s.kind === 'lifecycle' && s.title === '完成'))
|
||||
const claims = all.find((s) => s.code === 'report_analysis_claims')
|
||||
check('报告的分析说法重写后仍有:说成人话(几处、已在报告中标注),副标题点名判断词', claims?.title === '报告有 3 处分析说法未通过核对,已在报告中标注'
|
||||
&& claims.sub === '「主要来自」、「异常」、「由于」' && !!claims.next?.includes('判断要挂对应的分析结论'), `${claims?.title} ${claims?.sub}`)
|
||||
|
||||
// 老服务端:tool.end 没有 analysis 那一小段。只照参数写叫法,不编号、不从 preview 里拼判定语
|
||||
const legacy = fixtures.analysis_agent.map((e) => (e.type === 'tool.end' && e.data?.analysis
|
||||
? { ...e, data: Object.fromEntries(Object.entries(e.data).filter(([k]) => k !== 'analysis')) } : e))
|
||||
const old = flatten(mod.decodeRun(legacy, { status: 'succeeded' })).filter((s) => s.kind === 'analysis')
|
||||
check('老服务端:照参数写叫法,没有编号和判定语,也不摆 preview', old[1]?.title === '变化拆解(可加)' && !old[1].sub && !old[1].result,
|
||||
`${old[1]?.title} ${old[1]?.sub ?? ''}`)
|
||||
// 老事件里「没有做成」的提醒找不到那一行时照结构化字段说
|
||||
const lone = flatten(mod.decodeRun([
|
||||
{ seq: 1, type: 'node.started', node_id: 'bot', ts: 1, data: { node_type: 'agent', label: '问数据' } },
|
||||
{ seq: 2, type: 'log', node_id: 'bot', ts: 2, data: { level: 'warn', code: 'analysis_refused', tool: 'analysis_trend', call_id: 'x9',
|
||||
reason_code: 'history_short', reason: '只有 3 期历史,至少要 6 期', message: 'analysis_trend 没有做成:只有 3 期历史,至少要 6 期' } },
|
||||
])).find((s) => s.code === 'analysis_refused')
|
||||
check('……那一行不在时:照结构化字段说哪一种分析没有做成、原因', lone?.title === '趋势判断没有做成' && lone.sub === '只有 3 期历史,至少要 6 期',
|
||||
`${lone?.title} ${lone?.sub}`)
|
||||
|
||||
// 分析卡:每项一行(analysis.end),标题写算子的叫法和这一项的标识
|
||||
const card = flatten(mod.decodeRun(fixtures.analysis_card, { status: 'succeeded' }))
|
||||
const items = card.filter((s) => s.kind === 'analysis')
|
||||
check('分析卡的两项各一行,挂在分析卡下', items.length === 2 && items.every((s) => s.nodeId === 'card'), items.map((s) => s.title).join(' | '))
|
||||
check('……标题写叫法和标识,副标题是判定语', items[0]?.title === '波动基线 · wow' && items[1]?.title === '变化拆解(可加) · where'
|
||||
&& items[1].sub === '销售额下降 340(-12.6%),主要来自华东(-300,占 88.2%)', items.map((s) => `${s.title}:${s.sub}`).join(' | '))
|
||||
check('……算不了的那一项提醒色,结论工件可下钻', items[0]?.level === 'warn' && !!items[0].artifact && !!items[1]?.artifact)
|
||||
check('……analysis.end 不再落成「未识别的事件」,紧跟着的提醒也不另起一行', !card.some((s) => s.title === '未识别的事件')
|
||||
&& !card.some((s) => s.code === 'analysis_refused'))
|
||||
|
||||
// 协作成员的分析:编号是成员自己的,副标题接着写是谁调用的
|
||||
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(' | '))
|
||||
})
|
||||
|
||||
await section('合并查询', async () => {
|
||||
// merge.end 是合并查询节点做完的那一条:哪几个输入、合并 SQL、几行。和查询同一类(kind query),
|
||||
// 合并 SQL 进详情、预览进结果、工件可下钻;警告的原文由紧跟着的 log 各占一行,这一行只说有几条
|
||||
|
||||
@@ -25,7 +25,10 @@ const check = (name, cond, detail = '') => {
|
||||
* 一节一节地跑:某一节里元素找不到、等待超时,只记成这一节失败,接着跑下一节,
|
||||
* 不让一处卡住把后面的检查一起吞掉。各节自己开页面
|
||||
*/
|
||||
// STREAM_ONLY=分析,出具横幅 只跑段名里含这些字的段(逗号分隔),改一处时不用整本跑;check-all 不会把它传下来
|
||||
const ONLY = (process.env.STREAM_ONLY ?? '').split(',').filter(Boolean)
|
||||
async function section(name, fn) {
|
||||
if (ONLY.length && !ONLY.some((k) => name.includes(k))) return
|
||||
console.log(`\n=== ${name} ===`)
|
||||
try {
|
||||
await fn()
|
||||
@@ -735,6 +738,84 @@ await section('合并查询:输入、合并 SQL、结果预览,警告各占
|
||||
await narrow.page.close()
|
||||
})
|
||||
|
||||
await section('分析:Agent 的分析工具、分析卡的每一项一行,判定语在副标题,展开看判定语、假设和参数;算不了、调用有误照实说', async () => {
|
||||
// 用例 analysis(dev/fixtures/analysis.tsx):两轮真跑出来的事件(fixtures.json 的 analysis_agent、analysis_card)
|
||||
const { page, errors } = await open('syn=analysis', { w: 1100, h: 1000, name: 'analysis' })
|
||||
const rows = page.locator('[data-step-kind="analysis"]')
|
||||
await rows.first().waitFor({ timeout: 8000 }).catch(() => {})
|
||||
// 没成的那一行图标位上是失败的标记(同别的步骤),其余五行是分析的图标
|
||||
check('六行分析(Agent 四次调用、分析卡两项),没成的那行之外都是分析的图标', await rows.count() === 6
|
||||
&& 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')
|
||||
&& (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()
|
||||
await page.waitForTimeout(200)
|
||||
const open1 = rows.nth(1)
|
||||
check('展开:判定语完整写出、Agent 写的假设另列', (await open1.locator('[data-step-analysis-phrase]').innerText().catch(() => ''))
|
||||
.startsWith('销售额下降 340') && (await open1.locator('[data-step-analysis-hypothesis]').innerText().catch(() => '')) === '下降是不是集中在某个地区')
|
||||
check('……参数(没填的不列)、结论工件可点开', (await open1.innerText()).includes('参数') && !(await open1.innerText()).includes('null')
|
||||
&& await open1.getByText(/查看完整证据/).count() === 1)
|
||||
const wrong = rows.nth(0)
|
||||
check('调用有误:这一次没成,副标题写没有记成结论,下一步写在行下面', (await wrong.getAttribute('data-step-status')) === 'failed'
|
||||
&& (await line(0)).includes('调用有误,没有记成结论')
|
||||
&& (await wrong.locator('[data-step-next]').innerText().catch(() => '')).includes('Agent 会按这条说明改正参数'), await line(0))
|
||||
await wrong.locator('button').first().click()
|
||||
await page.waitForTimeout(150)
|
||||
check('……展开看服务端的原话', (await wrong.innerText()).includes('Q2 里没有「sales」这一列'))
|
||||
const refused = rows.nth(2)
|
||||
check('算不了:提醒色(不是失败),判定语就是原因,下一步说照实写原因', (await refused.getAttribute('data-step-status')) === 'done'
|
||||
&& (await line(2)).includes('波动基线没有做成:只有 0 期历史,至少要 8 期')
|
||||
&& (await refused.locator('[data-step-next]').innerText().catch(() => '')).includes('算不了也是一条结论'), await line(2))
|
||||
const card = await line(5)
|
||||
check('分析卡的一项:标题写叫法和这一项的标识,副标题是判定语', card.includes('变化拆解(可加) · where') && card.includes('主要来自华东'), card)
|
||||
const body = await page.locator('body').innerText()
|
||||
check('没有「未识别的事件」,「没有做成」的提醒不另起一行', !body.includes('未识别的事件') && !body.includes('analysis_baseline 没有做成'))
|
||||
check('报告的分析说法:一行说几处、已标注,下一步写怎么改', body.includes('报告有 3 处分析说法未通过核对,已在报告中标注'))
|
||||
const banner = page.locator('[data-issuance-banner]').first()
|
||||
const claims = (await banner.locator('[data-issuance-analysis-claims]').innerText().catch(() => '')).replace(/\s+/g, ' ')
|
||||
check('出具横幅:check 的分析说法单列一块,写不影响档位,三处各一条(种类的叫法同审计)', claims.includes('3 处分析说法未通过核对(已标注,不影响出具档位)')
|
||||
&& claims.includes('「主要来自」') && claims.includes('判断与分析结论不符') && claims.includes('因果说法没有依据'), claims)
|
||||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||||
await page.close()
|
||||
|
||||
const narrow = await open('syn=analysis&dense=1', { w: 380, h: 900 })
|
||||
await narrow.page.locator('[data-step-kind="analysis"] button').nth(1).click()
|
||||
await narrow.page.waitForTimeout(200)
|
||||
const overflow = await narrow.page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||
check('窄栏展开分析那一行:判定语、参数都不撑出横向滚动', overflow <= 0, `${overflow}px`)
|
||||
await narrow.page.close()
|
||||
|
||||
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)
|
||||
await team.page.close()
|
||||
})
|
||||
|
||||
await section('出具横幅:分析说法(strict 记缺口)、指标的两期可比性、追不到原始数据的指标各列一块', async () => {
|
||||
for (const [w, label] of [[1100, '宽栏'], [390, '390px']]) {
|
||||
const { page, errors } = await open('syn=analysis-issued', { w, h: 900, name: w === 1100 ? 'analysis-issued' : undefined })
|
||||
const banner = page.locator('[data-issuance-banner=degraded]')
|
||||
await banner.waitFor({ timeout: 8000 }).catch(() => {})
|
||||
const claims = (await banner.locator('[data-issuance-analysis-claims="counted"]').innerText().catch(() => '')).replace(/\s+/g, ' ')
|
||||
check(`${label}:strict 的分析说法写「已计入出具缺口」,原话照服务端`, claims.includes('1 处分析说法未通过核对,已计入出具缺口')
|
||||
&& claims.includes('「主要来自华南」与所挂的分析结论 A1 对不上'), claims)
|
||||
const comp = (await banner.locator('[data-issuance-comparability]').innerText().catch(() => '')).replace(/\s+/g, ' ')
|
||||
check(`${label}:指标的两期可比性一条一个,判定语和原因照服务端`, comp.includes('指标的两期可比性')
|
||||
&& comp.includes('指标「环比」:两期不可比') && comp.includes('指标「月环比」:两期有条件可比')
|
||||
&& await banner.locator('[data-issuance-comparability-item]').count() === 2, comp)
|
||||
const untraced = (await banner.locator('[data-issuance-untraced="counted"]').innerText().catch(() => '')).replace(/\s+/g, ' ')
|
||||
check(`${label}:追不到原始数据的指标写追到哪为止、已计入缺口`, untraced.includes('报告引用的指标未追到原始数据(已计入出具缺口)')
|
||||
&& untraced.includes('指标「客单价」') && untraced.includes('vars.aov 的出处追到「estimate」为止'), untraced)
|
||||
const over = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||
check(`${label}:页面不横向溢出`, over <= 0, `${over}px`)
|
||||
check(`${label}:没有运行时报错`, errors.length === 0, errors.join(' | '))
|
||||
await page.close()
|
||||
}
|
||||
})
|
||||
|
||||
await section('并行查询各配各的;查询的 SQL 检查;指标的问题指到来源查询的 SQL(A1、B2)', async () => {
|
||||
const { page, errors } = await open('syn=parallel&link=1', { w: 1100, h: 900, name: 'parallel' })
|
||||
const qa = page.locator('[data-node-id="q_a"]').filter({ hasText: 'SQL 检查:1 处错误' }).last()
|
||||
|
||||
Reference in New Issue
Block a user