问数据时间线的分析步骤与出具横幅的三项(分析能力一轮)。时间线(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:
ThearchyHelios
2026-10-21 18:35:22 +08:00
parent ce5ef5e5f0
commit ee923ef6f8
8 changed files with 2265 additions and 6 deletions
+91
View File
@@ -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>
),
}
+105 -5
View File
@@ -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
+41
View File
@@ -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
View File
@@ -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 各占一行
+4
View File
@@ -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
+62
View File
@@ -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 各占一行,这一行只说有几条
+81
View File
@@ -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()