diff --git a/frontend/src/dev/fixtures/analysis.tsx b/frontend/src/dev/fixtures/analysis.tsx new file mode 100644 index 00000000..38c7bb9e --- /dev/null +++ b/frontend/src/dev/fixtures/analysis.tsx @@ -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 +const F = fixtures as unknown as Fixture + +const outputOf = (events: RunEvent[]) => (events.find((e) => e.type === 'run.finished')?.data as { output?: Record })?.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 JSX.Element> = { + analysis: () => ( +
+ ), + 'analysis-issued': () => ( +
+ ), + 'analysis-team': () => ( +
+ ), +} diff --git a/frontend/src/run/AssistantStream.tsx b/frontend/src/run/AssistantStream.tsx index 79899ade..51d675d6 100644 --- a/frontend/src/run/AssistantStream.tsx +++ b/frontend/src/run/AssistantStream.tsx @@ -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 = { - 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} >