mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:21:58 +00:00
前端:分析卡节点(分析能力一轮)。节点库「把关」一类加「分析卡」(类型色 --nt-analysis,亮暗两套),卡片摘要写做哪几种分析(「波动基线、变化拆解(可加)」,叫法照契约);检查器的分析项编辑器:每项标识、分析方法(五个算子)、模式(有模式的才给)、输入只列上游的查询节点(调用数据库查询工具的「调用工具」节点、合并查询,删掉或挪到下游的照实列出),按方法摆参数格(analysisItems.paramSpecs:维度列、期次、基期、本期、度量列或分子分母、乘积和因子、比较方式、粒度、对照组、几对……,期次可以写 {{ }}),可加、比率的变化拆解另有总数核对两格和下钻(上一层只列排在前面的变化拆解),变化拆解、可比性检查可以写成基期、本期两份查询,换方法、模式时只留新方法用得到的参数;在验证什么一句话;阈值覆盖只列写了的几项、从没写的里选,范围当场按契约说(两两之间的大小交给服务端),全删掉不留空对象。服务端按 items[i].<键> 报的问题落在那一格下面,定位退不到的子键(params.parent.key)逐段往上退;查询节点连到分析卡时还没选输入的第一项选上它(同合并查询,和连线同一步撤销)。报告节点加「分析说法核对」(缺省重写一次后标出)、「分析范围」(高级,缺省有分析结论时附上),选项以 engine/labels.py 的短说法开头。叫法、范围只照契约一份(lib/termsAnalysis:OP_LABELS / MODE_LABELS / LABELS / RANGES,check-studio 读后端源码逐项比对)。check-studio 加两节:叫法与契约一致、分析卡的画布编辑与校验定位
This commit is contained in:
@@ -15,7 +15,15 @@ import { formatShortcut } from '../lib/keys'
|
||||
import { INPUT_FIELD_TEXT, JUDGE_FIELD_LABEL, JUDGE_ON_UNSUPPORTED_HINT, JUDGE_ON_UNSUPPORTED_LABEL, JUDGE_SETTING_TEXT, MERGE_TEXT, CATALOG_UI_TEXT, NODE_TYPE_LABEL, PROVIDER_REF_TEXT, SUBGRAPH_UPGRADE_HELP, judgeByModelText, upgradeNewerText } from '../lib/terms'
|
||||
import { TemplateText } from './TemplateText'
|
||||
import { MERGE_ALIAS, isQueryNode, mergeAlias } from './mergeInputs'
|
||||
import type { NodeType, Provider, ValidationIssue, WorkflowVersion } from '../types'
|
||||
import {
|
||||
ITEM_ID, TWO_QUERY_SKIP, allowsHeadline, allowsParent, allowsTwoQueries, itemsOf, nextItemId, paramSpecs, paramText,
|
||||
paramValue, withMode, withOp, type ParamSpec,
|
||||
} from './analysisItems'
|
||||
import {
|
||||
ANALYSIS_MODES, ANALYSIS_MODE_LABEL, ANALYSIS_OPS, ANALYSIS_OP_LABEL, ANALYSIS_SETTING_KEYS, ANALYSIS_SETTING_LABEL,
|
||||
ANALYSIS_TEXT, isAnalysisOp, settingRangeText, settingValueProblem, type AnalysisSettingKey,
|
||||
} from '../lib/termsAnalysis'
|
||||
import type { AnalysisItem, NodeType, Provider, ValidationIssue, WorkflowVersion } from '../types'
|
||||
|
||||
/** 检查器里一条落到字段上的问题 */
|
||||
type FieldIssue = ValidationIssue & { at: FieldRef | null }
|
||||
@@ -151,6 +159,34 @@ function NodeRefsInput({ nodeId, type, value, onChange }: {
|
||||
)
|
||||
}
|
||||
|
||||
/** 上游能当查询输入的节点(选了数据库查询工具的「调用工具」节点、合并查询):合并查询、分析卡共用 */
|
||||
function useUpstreamQueries(nodeId: string): { value: string; label: string; hint?: string }[] {
|
||||
const nodes = useStudio((s) => s.nodes)
|
||||
const edges = useStudio((s) => s.edges)
|
||||
return useMemo(() => {
|
||||
const parents = new Map<string, string[]>()
|
||||
for (const e of edges) parents.set(e.target, [...(parents.get(e.target) ?? []), e.source])
|
||||
const seen = new Set<string>()
|
||||
const stack = [...(parents.get(nodeId) ?? [])]
|
||||
while (stack.length) {
|
||||
const id = stack.pop()!
|
||||
if (seen.has(id) || id === nodeId) continue
|
||||
seen.add(id)
|
||||
stack.push(...(parents.get(id) ?? []))
|
||||
}
|
||||
return nodes
|
||||
.filter((n) => seen.has(n.id) && isQueryNode(n))
|
||||
.map((n) => {
|
||||
const label = String(n.data?.label || n.id)
|
||||
const tool = String((n.data?.config as Record<string, any> | undefined)?.tool ?? '')
|
||||
return {
|
||||
value: n.id, label: label === n.id ? label : `${label}(${n.id})`,
|
||||
hint: n.data?.nodeType === 'merge' ? NODE_TYPE_LABEL.merge : tool,
|
||||
}
|
||||
})
|
||||
}, [nodes, edges, nodeId])
|
||||
}
|
||||
|
||||
interface MergeRow { alias: string; node: string }
|
||||
|
||||
/** config.inputs({别名: 节点 id})→ 行。对象的键顺序就是行的顺序 */
|
||||
@@ -178,28 +214,7 @@ function MergeInputList({ nodeId, value, issues, onChange }: {
|
||||
const nodes = useStudio((s) => s.nodes)
|
||||
const edges = useStudio((s) => s.edges)
|
||||
const uid = useId()
|
||||
const options = useMemo(() => {
|
||||
const parents = new Map<string, string[]>()
|
||||
for (const e of edges) parents.set(e.target, [...(parents.get(e.target) ?? []), e.source])
|
||||
const seen = new Set<string>()
|
||||
const stack = [...(parents.get(nodeId) ?? [])]
|
||||
while (stack.length) {
|
||||
const id = stack.pop()!
|
||||
if (seen.has(id) || id === nodeId) continue
|
||||
seen.add(id)
|
||||
stack.push(...(parents.get(id) ?? []))
|
||||
}
|
||||
return nodes
|
||||
.filter((n) => seen.has(n.id) && isQueryNode(n))
|
||||
.map((n) => {
|
||||
const label = String(n.data?.label || n.id)
|
||||
const tool = String((n.data?.config as Record<string, any> | undefined)?.tool ?? '')
|
||||
return {
|
||||
value: n.id, label: label === n.id ? label : `${label}(${n.id})`,
|
||||
hint: n.data?.nodeType === 'merge' ? NODE_TYPE_LABEL.merge : tool,
|
||||
}
|
||||
})
|
||||
}, [nodes, edges, nodeId])
|
||||
const options = useUpstreamQueries(nodeId)
|
||||
|
||||
const external = useMemo(() => mergeRowsOf(value), [value])
|
||||
const [rows, setRows] = useState<MergeRow[]>(external)
|
||||
@@ -295,6 +310,319 @@ function MergeInputList({ nodeId, value, issues, onChange }: {
|
||||
)
|
||||
}
|
||||
|
||||
const AT = ANALYSIS_TEXT.node
|
||||
|
||||
/** 选一个上游查询节点。已经选了、后来删掉或挪到下游的节点照样列出来并说明,好让人看见并改掉 */
|
||||
function QueryPick({ id, value, options, onChange }: {
|
||||
id: string; value: string; options: { value: string; label: string }[]; onChange: (v: string) => void
|
||||
}) {
|
||||
const known = options.some((o) => o.value === value)
|
||||
return (
|
||||
<select id={id} className="field text-xs" value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="" disabled>{AT.pickFrom}</option>
|
||||
{options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
{value && !known && <option value={value} disabled>{AT.missingNode(value)}</option>}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析卡的分析项(engine/schema.analysis_issues 的配置):每一项一个标识、一种分析方法(有模式的再选模式)、要分析的上游
|
||||
* 查询节点(或基期、本期两份查询),再按方法摆参数格(analysisItems.paramSpecs);可加、比率的变化拆解另有总数核对、下钻。
|
||||
* 阈值不在这里写(统一写在阈值覆盖里)。服务端按 items[i].<键> 报的问题落在那一格下面,定位按 [data-item] [data-sub] 落进来
|
||||
*/
|
||||
function AnalysisItemList({ nodeId, value, issues, onChange }: {
|
||||
nodeId: string; value: any; issues: FieldIssue[]; onChange: (v: any) => void
|
||||
}) {
|
||||
const uid = useId()
|
||||
const options = useUpstreamQueries(nodeId)
|
||||
const items = itemsOf(value)
|
||||
const commit = (next: AnalysisItem[]) => onChange(next)
|
||||
const update = (i: number, next: AnalysisItem) => commit(items.map((it, j) => (j === i ? next : it)))
|
||||
const setParam = (i: number, key: string, v: unknown) => {
|
||||
const params = { ...(items[i].params ?? {}) }
|
||||
if (v === undefined) delete params[key]
|
||||
else params[key] = v
|
||||
update(i, { ...items[i], params })
|
||||
}
|
||||
const add = () => commit([...items, { id: nextItemId(items), params: {} }])
|
||||
return (
|
||||
<div>
|
||||
{items.map((item, i) => {
|
||||
const own = issues.filter((x) => x.at?.index === i)
|
||||
const at = (sub: string) => own.filter((x) => x.at?.sub === sub || x.at?.sub?.startsWith(`${sub}.`))
|
||||
const lines = (sub: string) => at(sub).map((x, k) => <IssueLine key={k} issue={x} />)
|
||||
const op = isAnalysisOp(item.op) ? item.op : null
|
||||
const modes = op ? ANALYSIS_MODES[op] : undefined
|
||||
const params = (item.params && typeof item.params === 'object' ? item.params : {}) as Record<string, any>
|
||||
const two = !!op && allowsTwoQueries(op) && ('base_query' in params || 'target_query' in params)
|
||||
const id = String(item.id ?? '')
|
||||
const dup = !!id && items.some((other, j) => j !== i && String(other.id ?? '') === id)
|
||||
const local = !id || !ITEM_ID.test(id) ? AT.idInvalid : dup ? AT.idDuplicate(id) : ''
|
||||
const tone = own.some((x) => x.level === 'error') || local ? 'error' : own.length ? 'warning' : undefined
|
||||
const before = items.slice(0, i).filter((it) => it.op === 'decompose' && it.id)
|
||||
const parent = params.parent && typeof params.parent === 'object' ? params.parent as { of?: string; key?: unknown } : null
|
||||
const field = (spec: ParamSpec) => {
|
||||
const fid = `${uid}-${i}-p-${spec.key}`
|
||||
const label = AT.param[spec.key] ?? spec.key
|
||||
const text = paramText(spec.kind, params[spec.key])
|
||||
const set = (t: string) => setParam(i, spec.key, paramValue(spec.kind, t))
|
||||
let control
|
||||
if (spec.kind === 'basis' || spec.kind === 'grain') {
|
||||
const opts = spec.kind === 'basis' ? AT.basis : AT.grain
|
||||
control = (
|
||||
<select id={fid} className="field text-xs" value={text} onChange={(e) => set(e.target.value)}>
|
||||
{Object.entries(opts).filter(([k]) => !(spec.kind === 'basis' && k === 'change')).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
{text && !(text in opts) && <option value={text} disabled>{text}</option>}
|
||||
</select>
|
||||
)
|
||||
} else if (spec.kind === 'pairs') {
|
||||
control = <textarea id={fid} className="field mono text-xs" rows={2} value={text} spellCheck={false}
|
||||
placeholder="华东、华南" onChange={(e) => set(e.target.value)} />
|
||||
} else if (spec.kind === 'value') {
|
||||
// 期次、对照组、叫法可以按运行输入写 {{ }}(执行时先渲染再分析)
|
||||
control = <TemplateText id={fid} multiline={false} nodeId={nodeId} className="text-xs" value={text}
|
||||
placeholder={AT.paramPlaceholder[spec.key]} onChange={set} />
|
||||
} else {
|
||||
control = <input id={fid} className="field mono text-xs" value={text} spellCheck={false}
|
||||
inputMode={spec.kind === 'int' ? 'numeric' : undefined}
|
||||
placeholder={AT.paramPlaceholder[spec.key]} onChange={(e) => set(e.target.value)} />
|
||||
}
|
||||
const hint = spec.kind === 'columns' ? AT.listHint : spec.kind === 'pairs' ? AT.pairsHint : AT.paramHint[spec.key]
|
||||
return (
|
||||
<div key={spec.key} className="min-w-0">
|
||||
<Sub label={label} htmlFor={fid} sub={`params.${spec.key}`}>
|
||||
{control}
|
||||
{hint && <div className="mt-0.5 text-2xs leading-snug text-faint">{hint}</div>}
|
||||
</Sub>
|
||||
{lines(`params.${spec.key}`)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const specs = op ? paramSpecs(op, item.mode).filter((p) => !(two && TWO_QUERY_SKIP.has(p.key))) : []
|
||||
const headline = params.headline && typeof params.headline === 'object' ? params.headline as Record<string, string> : null
|
||||
const setHeadline = (side: 'base' | 'target', t: string) => {
|
||||
const next = { base: headline?.base ?? '', target: headline?.target ?? '', [side]: t.trim() }
|
||||
setParam(i, 'headline', next.base || next.target ? next : undefined)
|
||||
}
|
||||
const toggleTwo = (on: boolean) => {
|
||||
const next = { ...params }
|
||||
if (on) {
|
||||
next.base_query = item.from ?? ''
|
||||
next.target_query = ''
|
||||
for (const k of TWO_QUERY_SKIP) delete next[k]
|
||||
update(i, { ...item, from: undefined, params: next })
|
||||
} else {
|
||||
const from = typeof next.base_query === 'string' ? next.base_query : ''
|
||||
delete next.base_query
|
||||
delete next.target_query
|
||||
update(i, { ...item, from, params: next })
|
||||
}
|
||||
}
|
||||
// 落在某一格下面的问题在那一格下面说,其余的(格式不对、多写了这种分析没有的参数……)放在这一项的最后
|
||||
const placed = ['id', 'op', 'mode', 'hypothesis', ...(two ? ['params.base_query', 'params.target_query'] : ['from']),
|
||||
...specs.map((p) => `params.${p.key}`),
|
||||
...(op && allowsHeadline(op, item.mode) ? ['params.headline'] : []),
|
||||
...(op && allowsParent(op, item.mode) && (before.length > 0 || parent) ? ['params.parent'] : [])]
|
||||
const rest = own.filter((x) => !placed.some((sub) => x.at?.sub === sub || x.at?.sub?.startsWith(`${sub}.`)))
|
||||
return (
|
||||
<Row key={i} item={i} removeLabel={AT.removeItem(id)} tone={tone}
|
||||
onRemove={() => commit(items.filter((_, j) => j !== i))}>
|
||||
<div className="flex gap-1.5" data-analysis-item={id || i}>
|
||||
<Sub label={AT.id} htmlFor={`${uid}-${i}-id`} className="w-20 shrink-0" sub="id">
|
||||
<input id={`${uid}-${i}-id`} className="field mono text-xs" value={id} spellCheck={false}
|
||||
aria-invalid={!!local || undefined}
|
||||
onChange={(e) => update(i, { ...item, id: e.target.value.trim() })} />
|
||||
</Sub>
|
||||
<Sub label={AT.op} htmlFor={`${uid}-${i}-op`} className="min-w-0 flex-1" sub="op">
|
||||
<select id={`${uid}-${i}-op`} className="field text-xs" value={item.op ?? ''}
|
||||
onChange={(e) => update(i, withOp(item, e.target.value))}>
|
||||
<option value="" disabled>{AT.pickOp}</option>
|
||||
{ANALYSIS_OPS.map((o) => <option key={o} value={o}>{ANALYSIS_OP_LABEL[o]}</option>)}
|
||||
{!!item.op && !op && <option value={item.op} disabled>{AT.unknownOp(item.op)}</option>}
|
||||
</select>
|
||||
</Sub>
|
||||
{modes && (
|
||||
<Sub label={AT.mode} htmlFor={`${uid}-${i}-mode`} className="w-[5.5rem] shrink-0" sub="mode">
|
||||
<select id={`${uid}-${i}-mode`} className="field text-xs" value={item.mode ?? ''}
|
||||
onChange={(e) => update(i, withMode(item, e.target.value))}>
|
||||
<option value="" disabled>{AT.pickMode}</option>
|
||||
{modes.map((m) => <option key={m} value={m}>{ANALYSIS_MODE_LABEL[m]}</option>)}
|
||||
{!!item.mode && !modes.includes(item.mode) && <option value={item.mode} disabled>{AT.unknownMode(item.mode)}</option>}
|
||||
</select>
|
||||
</Sub>
|
||||
)}
|
||||
</div>
|
||||
{local && !at('id').length && (
|
||||
<div className="flex items-start gap-1 text-2xs leading-snug" style={{ color: 'var(--err)' }}>
|
||||
<XCircle size={11} className="mt-px shrink-0" aria-hidden />
|
||||
<span className="min-w-0 [overflow-wrap:anywhere]">{local}</span>
|
||||
</div>
|
||||
)}
|
||||
{lines('id')}
|
||||
{lines('op')}
|
||||
{lines('mode')}
|
||||
{id && ITEM_ID.test(id) && (
|
||||
<div className="mono truncate text-2xs text-faint" title={AT.idHint(nodeId, id)}>{AT.idHint(nodeId, id)}</div>
|
||||
)}
|
||||
{two ? (
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<div className="min-w-0">
|
||||
<Sub label={AT.baseQuery} htmlFor={`${uid}-${i}-bq`} sub="params.base_query">
|
||||
<QueryPick id={`${uid}-${i}-bq`} value={String(params.base_query ?? '')} options={options}
|
||||
onChange={(v) => setParam(i, 'base_query', v)} />
|
||||
</Sub>
|
||||
{lines('params.base_query')}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<Sub label={AT.targetQuery} htmlFor={`${uid}-${i}-tq`} sub="params.target_query">
|
||||
<QueryPick id={`${uid}-${i}-tq`} value={String(params.target_query ?? '')} options={options}
|
||||
onChange={(v) => setParam(i, 'target_query', v)} />
|
||||
</Sub>
|
||||
{lines('params.target_query')}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Sub label={AT.from} htmlFor={`${uid}-${i}-from`} sub="from">
|
||||
<QueryPick id={`${uid}-${i}-from`} value={String(item.from ?? '')} options={options}
|
||||
onChange={(v) => update(i, { ...item, from: v })} />
|
||||
</Sub>
|
||||
{lines('from')}
|
||||
</div>
|
||||
)}
|
||||
{op && allowsTwoQueries(op) && (
|
||||
<label className="flex items-center gap-1.5 text-2xs text-dim">
|
||||
<input type="checkbox" checked={two} onChange={(e) => toggleTwo(e.target.checked)} data-analysis-two="" />
|
||||
{AT.twoQueries}
|
||||
</label>
|
||||
)}
|
||||
{!options.length && <div className="text-2xs leading-snug text-faint">{AT.noUpstream}</div>}
|
||||
{specs.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-1.5" data-analysis-params="">
|
||||
{specs.map(field)}
|
||||
</div>
|
||||
)}
|
||||
{op && allowsHeadline(op, item.mode) && (
|
||||
<div data-sub="params.headline">
|
||||
<div className="mb-0.5 text-2xs text-faint">{AT.headline}</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{(['base', 'target'] as const).map((side) => (
|
||||
<Sub key={side} label={side === 'base' ? AT.headlineBase : AT.headlineTarget}
|
||||
htmlFor={`${uid}-${i}-h-${side}`} sub={`params.headline.${side}`}>
|
||||
<input id={`${uid}-${i}-h-${side}`} className="field mono text-xs" spellCheck={false}
|
||||
value={headline?.[side] ?? ''} placeholder={side === 'base' ? 'weekly.r0.gmv' : 'weekly.r1.gmv'}
|
||||
onChange={(e) => setHeadline(side, e.target.value)} />
|
||||
</Sub>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-0.5 text-2xs leading-snug text-faint">{AT.headlineHint}。{AT.cellHint}</div>
|
||||
{lines('params.headline')}
|
||||
</div>
|
||||
)}
|
||||
{op && allowsParent(op, item.mode) && (before.length > 0 || parent) && (
|
||||
<div data-sub="params.parent">
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<Sub label={AT.parentOf} htmlFor={`${uid}-${i}-po`}>
|
||||
<select id={`${uid}-${i}-po`} className="field text-xs" value={parent?.of ?? ''}
|
||||
onChange={(e) => setParam(i, 'parent', e.target.value
|
||||
? { of: e.target.value, key: Array.isArray(parent?.key) ? parent!.key : [] } : undefined)}>
|
||||
<option value="">{AT.noParent}</option>
|
||||
{before.map((it) => <option key={String(it.id)} value={String(it.id)}>{String(it.id)}</option>)}
|
||||
{parent?.of && !before.some((it) => it.id === parent.of) && <option value={parent.of} disabled>{parent.of}</option>}
|
||||
</select>
|
||||
</Sub>
|
||||
{parent && (
|
||||
<Sub label={AT.parentKey} htmlFor={`${uid}-${i}-pk`}>
|
||||
<input id={`${uid}-${i}-pk`} className="field mono text-xs" spellCheck={false} placeholder="华东"
|
||||
value={paramText('columns', parent.key)}
|
||||
onChange={(e) => setParam(i, 'parent', { of: parent.of, key: paramValue('columns', e.target.value) ?? [] })} />
|
||||
</Sub>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-2xs leading-snug text-faint">{AT.parentHint}</div>
|
||||
{lines('params.parent')}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Sub label={AT.hypothesis} htmlFor={`${uid}-${i}-hyp`} sub="hypothesis">
|
||||
<input id={`${uid}-${i}-hyp`} className="field text-xs" value={item.hypothesis ?? ''}
|
||||
placeholder={AT.hypothesisPlaceholder}
|
||||
onChange={(e) => update(i, { ...item, hypothesis: e.target.value || undefined })} />
|
||||
</Sub>
|
||||
<div className="mt-0.5 text-2xs leading-snug text-faint">{AT.hypothesisHint}</div>
|
||||
{lines('hypothesis')}
|
||||
</div>
|
||||
{rest.map((x, k) => <IssueLine key={k} issue={x} />)}
|
||||
</Row>
|
||||
)
|
||||
})}
|
||||
<button type="button" className="btn btn-sm w-full justify-center" onClick={add} data-analysis-add="">
|
||||
<Plus size={11} /> {AT.addItem}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析卡的阈值覆盖:只列写了的几项,「添加阈值覆盖」从没写的里面选。键和叫法同设置里的分析一组(contract.LABELS),范围先在
|
||||
* 这里按 contract.RANGES 说,两两之间的大小(「明显超出」要大于「超出常规波动」……)由服务端核、问题落在这一栏下面。
|
||||
* 全部删掉写回 undefined:没覆盖就不留一个空对象
|
||||
*/
|
||||
function AnalysisOverrides({ value, issues, onChange }: { value: any; issues: FieldIssue[]; onChange: (v: any) => void }) {
|
||||
const uid = useId()
|
||||
const current: Record<string, unknown> = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
||||
// 正在填的字先记在本地:「0.」这种还不是数的字写回去会被转成别的
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({})
|
||||
const keys = Object.keys(current)
|
||||
const free = ANALYSIS_SETTING_KEYS.filter((k) => !(k in current))
|
||||
const write = (next: Record<string, unknown>) => onChange(Object.keys(next).length ? next : undefined)
|
||||
const set = (key: string, text: string) => {
|
||||
setDrafts((d) => ({ ...d, [key]: text }))
|
||||
const t = text.trim()
|
||||
write({ ...current, [key]: t !== '' && Number.isFinite(Number(t)) ? Number(t) : t })
|
||||
}
|
||||
const remove = (key: string) => {
|
||||
const next = { ...current }
|
||||
delete next[key]
|
||||
setDrafts((d) => { const c = { ...d }; delete c[key]; return c })
|
||||
write(next)
|
||||
}
|
||||
return (
|
||||
<div data-analysis-overrides="">
|
||||
{keys.map((key, i) => {
|
||||
const known = (ANALYSIS_SETTING_KEYS as readonly string[]).includes(key)
|
||||
const label = known ? ANALYSIS_SETTING_LABEL[key as AnalysisSettingKey] : key
|
||||
const text = drafts[key] ?? (current[key] == null ? '' : String(current[key]))
|
||||
const problem = known ? settingValueProblem(key as AnalysisSettingKey, text) : null
|
||||
const fid = `${uid}-${i}`
|
||||
return (
|
||||
<Row key={key} removeLabel={AT.removeOverride(label)} tone={problem ? 'error' : undefined} onRemove={() => remove(key)}>
|
||||
<Sub label={label} htmlFor={fid} sub={key}>
|
||||
<input id={fid} className="field tnum text-xs" inputMode="decimal" value={text} aria-invalid={!!problem || undefined}
|
||||
data-analysis-override={key} onChange={(e) => set(key, e.target.value)} />
|
||||
</Sub>
|
||||
<div className="text-2xs leading-snug" style={{ color: problem ? 'var(--err)' : 'var(--text-faint)' }}>
|
||||
{problem ?? (known ? settingRangeText(key as AnalysisSettingKey) : '')}
|
||||
</div>
|
||||
</Row>
|
||||
)
|
||||
})}
|
||||
{!keys.length && <div className="mb-1.5 text-2xs text-faint">{AT.noOverrides}</div>}
|
||||
{issues.map((x, k) => <IssueLine key={k} issue={x} />)}
|
||||
{free.length > 0 ? (
|
||||
<select className="field text-xs" value="" aria-label={AT.addOverride} data-analysis-add-override=""
|
||||
onChange={(e) => { if (e.target.value) set(e.target.value, '') }}>
|
||||
<option value="">{AT.addOverride}</option>
|
||||
{free.map((k) => <option key={k} value={k}>{ANALYSIS_SETTING_LABEL[k]}</option>)}
|
||||
</select>
|
||||
) : <div className="text-2xs text-faint">{AT.allOverridden}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 子工作流钉住版本后的升版处置:和口径卡的是同一个下拉——直接借口径卡的字段定义,选项、
|
||||
* 文案一字不差,只换说明里「和谁同一套」那半句。上游在钉住的那一版之后又发了版时,正式运行
|
||||
@@ -790,6 +1118,12 @@ function FieldInput({ field, id, nodeId, syntax, value, config, invalid, describ
|
||||
case 'mergeInputs':
|
||||
return <MergeInputList nodeId={nodeId} value={value} issues={issues} onChange={onChange} />
|
||||
|
||||
case 'analysisItems':
|
||||
return <AnalysisItemList nodeId={nodeId} value={value} issues={issues} onChange={onChange} />
|
||||
|
||||
case 'analysisOverrides':
|
||||
return <AnalysisOverrides value={value} issues={issues} onChange={onChange} />
|
||||
|
||||
case 'caliberFrom':
|
||||
return <CaliberFromPicker id={id} value={value} invalid={invalid} describedBy={describedBy} />
|
||||
|
||||
|
||||
@@ -67,8 +67,13 @@ export function revealField(nodeId: string, field?: FieldRef | string | null, op
|
||||
const sheet = document.querySelector('[data-inspector-sheet]') ?? document
|
||||
const box = sheet.querySelector<HTMLElement>(`[data-field="${CSS.escape(at.key)}"]`)
|
||||
const item = at.index != null ? box?.querySelector<HTMLElement>(`[data-item="${at.index}"]`) : null
|
||||
// 只有子键没有序号的(合并查询的 inputs.<别名>):在整个字段里按子键找那一行
|
||||
const part = at.sub ? (item ?? box)?.querySelector<HTMLElement>(`[data-sub="${CSS.escape(at.sub)}"]`) : null
|
||||
// 只有子键没有序号的(合并查询的 inputs.<别名>):在整个字段里按子键找那一行。子键带点号的(分析项的 params.headline.base、
|
||||
// params.parent.key)找不到那一格时逐段往上退,落到它所在的那一块(params.parent)
|
||||
const scope = item ?? box
|
||||
let part: HTMLElement | null | undefined = null
|
||||
for (let sub = at.sub; sub && !part; sub = sub.includes('.') ? sub.slice(0, sub.lastIndexOf('.')) : '') {
|
||||
part = scope?.querySelector<HTMLElement>(`[data-sub="${CSS.escape(sub)}"]`)
|
||||
}
|
||||
const target = part ?? item ?? box
|
||||
if (!target) return
|
||||
const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
CHALLENGE_TEXT, claimTally, DUAL_CHECK_TEXT, issuanceLabel, REJECTED_TEXT, reportStampText, RUN_CLASS_LABEL, UPGRADE_POLICY_LABEL,
|
||||
} from '../lib/terms'
|
||||
import { useReportStamp, type ReportStamp } from './reportStamp'
|
||||
import { analysisCardSummary } from './analysisItems'
|
||||
import { ApprovalCard } from '../run/RunPanel'
|
||||
import type { NodeState, NodeTrace } from '../run/trace'
|
||||
import { useRunClock } from '../run/useRunClock'
|
||||
@@ -86,6 +87,9 @@ function summarize(type: NodeType, config: Record<string, any>): string {
|
||||
}
|
||||
return `${config.caliber || '口径'} ${config.caliber_version || 'v1'} · ${(config.metrics ?? []).length} 个指标`
|
||||
}
|
||||
case 'analysis':
|
||||
// 做哪几种分析:「波动基线、变化拆解(可加)」,叫法照契约(lib/termsAnalysis)
|
||||
return analysisCardSummary(config)
|
||||
case 'report': {
|
||||
const from = Array.isArray(config.metrics_from) ? config.metrics_from.length : 0
|
||||
return `${first(config.instructions) || '未填写作要求'} · ${from ? `${from} 张口径卡` : '上游全部口径卡'}`
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// ===========================================================================
|
||||
// 分析卡(节点类型 analysis)的一项:每种分析在检查器里给哪些参数格、参数怎么在格子和配置之间转换、卡片上的摘要。
|
||||
//
|
||||
// 参数名和服务端 tools/builtin/analysis.py 各参数模型里的字段一致(分析卡按同一份参数模型校验,engine/schema.analysis_issues):
|
||||
// 主输入写在 from(对应 Agent 工具的 query),基期、本期是两份查询时写在 params 的 base_query、target_query;阈值不写在参数里,
|
||||
// 统一写在卡上的阈值覆盖。这里只决定界面上摆哪几格、按什么先后,规则以服务端校验为准(写错了检查器照服务端的说法提示)。
|
||||
// ===========================================================================
|
||||
|
||||
import { ANALYSIS_MODES, ANALYSIS_TEXT, analysisMethodLabel, isAnalysisOp, type AnalysisOp } from '../lib/termsAnalysis'
|
||||
import type { AnalysisItem } from '../types'
|
||||
|
||||
/** 一格参数的写法:列名、几列(顿号、逗号分隔)、期次的值(可以写 {{ }})、整数、两种下拉、几对 */
|
||||
export type ParamKind = 'column' | 'columns' | 'value' | 'int' | 'basis' | 'grain' | 'pairs'
|
||||
|
||||
export interface ParamSpec {
|
||||
key: string
|
||||
kind: ParamKind
|
||||
}
|
||||
|
||||
const col = (key: string): ParamSpec => ({ key, kind: 'column' })
|
||||
const cols = (key: string): ParamSpec => ({ key, kind: 'columns' })
|
||||
const val = (key: string): ParamSpec => ({ key, kind: 'value' })
|
||||
|
||||
/** 两期写在同一份查询里时的期次三格(期次列、基期、本期) */
|
||||
const PERIODS: ParamSpec[] = [col('period'), val('base'), val('target')]
|
||||
|
||||
/**
|
||||
* 每种分析(算子 + 模式)摆哪几格,按这个先后。总数核对(headline)、下钻(parent)、两份查询另有自己的一块,不在这里。
|
||||
* label(度量的叫法)放在最后:留空用列名或数据目录里的名字
|
||||
*/
|
||||
export function paramSpecs(op: AnalysisOp, mode?: string | null): ParamSpec[] {
|
||||
switch (op) {
|
||||
case 'decompose':
|
||||
if (mode === 'ratio') return [cols('by'), ...PERIODS, col('numerator'), col('denominator'), val('label')]
|
||||
if (mode === 'factor') return [...PERIODS, col('product'), cols('factors'), val('label')]
|
||||
return [cols('by'), ...PERIODS, col('measure'), val('label')]
|
||||
case 'baseline':
|
||||
return [col('period'), col('measure'), val('target'), { key: 'basis', kind: 'basis' }, { key: 'grain', kind: 'grain' },
|
||||
{ key: 'lag', kind: 'int' }, val('label')]
|
||||
case 'trend':
|
||||
return [col('period'), col('measure'), { key: 'grain', kind: 'grain' }, val('label')]
|
||||
case 'differ': {
|
||||
const values = mode === 'mean' ? [col('mean'), col('sd'), col('sum'), col('sumsq'), col('n')]
|
||||
: mode === 'rows' ? [col('value')] : [col('numerator'), col('denominator')]
|
||||
return [cols('by'), val('reference'), { key: 'pairs', kind: 'pairs' }, ...values, ...(mode === 'rows' ? [] : PERIODS),
|
||||
val('label')]
|
||||
}
|
||||
case 'comparable':
|
||||
return [...PERIODS, col('measure'), { key: 'grain', kind: 'grain' }]
|
||||
}
|
||||
}
|
||||
|
||||
/** 这种分析能不能写成基期、本期两份查询(DecomposeArgs、ComparableArgs 有 base_query / target_query) */
|
||||
export const allowsTwoQueries = (op: AnalysisOp): boolean => op === 'decompose' || op === 'comparable'
|
||||
/** 有总数核对(headline)的:可加、比率的变化拆解 */
|
||||
export const allowsHeadline = (op: AnalysisOp, mode?: string | null): boolean =>
|
||||
op === 'decompose' && mode !== 'factor'
|
||||
/** 能下钻(parent)的:可加、比率的变化拆解(乘积拆解不按维度拆) */
|
||||
export const allowsParent = allowsHeadline
|
||||
|
||||
/** 两份查询时期次三格不写(两份各自只有一期) */
|
||||
export const TWO_QUERY_SKIP = new Set(['period', 'base', 'target'])
|
||||
|
||||
/** 这种分析会写到的全部参数键:换分析方法、模式时,这几样以外的参数去掉(服务端按多写的参数报错) */
|
||||
export function paramKeys(op: AnalysisOp, mode?: string | null): Set<string> {
|
||||
const keys = new Set(paramSpecs(op, mode).map((p) => p.key))
|
||||
if (allowsTwoQueries(op)) { keys.add('base_query'); keys.add('target_query') }
|
||||
if (allowsHeadline(op, mode)) keys.add('headline')
|
||||
if (allowsParent(op, mode)) keys.add('parent')
|
||||
return keys
|
||||
}
|
||||
|
||||
/** 「region、channel」「region, channel」→ ['region', 'channel'] */
|
||||
export function splitList(text: string): string[] {
|
||||
return text.split(/[、,,\s]+/).map((s) => s.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
/** 配置里的一格 → 格子里的字 */
|
||||
export function paramText(kind: ParamKind, value: unknown): string {
|
||||
if (value == null) return ''
|
||||
if (kind === 'columns') return Array.isArray(value) ? value.map(String).join('、') : String(value)
|
||||
if (kind === 'pairs') {
|
||||
return Array.isArray(value)
|
||||
? value.map((p) => (Array.isArray(p) ? p.map(String).join('、') : String(p))).join('\n')
|
||||
: String(value)
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/** 格子里的字 → 配置里的一格;空的返回 undefined(参数里不留这个键) */
|
||||
export function paramValue(kind: ParamKind, text: string): unknown {
|
||||
const t = text.trim()
|
||||
if (!t) return undefined
|
||||
if (kind === 'columns') return splitList(t)
|
||||
if (kind === 'pairs') return t.split('\n').map((line) => splitList(line)).filter((p) => p.length)
|
||||
// 整数格写了不是整数的字照原样交给服务端校验(它会说清该写什么),不在这里悄悄丢掉
|
||||
if (kind === 'int') return /^-?\d+$/.test(t) ? Number(t) : t
|
||||
return t
|
||||
}
|
||||
|
||||
/** 配置里的 items → 一项一项(不是对象的项原样留着,服务端会报「格式有误」) */
|
||||
export function itemsOf(value: unknown): AnalysisItem[] {
|
||||
return Array.isArray(value) ? value.map((v) => (v && typeof v === 'object' ? v as AnalysisItem : {})) : []
|
||||
}
|
||||
|
||||
/** 标识的写法(服务端 schema.ANALYSIS_ITEM_ID):下游按 nodes.<卡>.results.<标识> 取判定 */
|
||||
export const ITEM_ID = /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
|
||||
/** 新加一项的标识:a1、a2……不和已有的重名 */
|
||||
export function nextItemId(items: AnalysisItem[]): string {
|
||||
const taken = new Set(items.map((i) => String(i.id ?? '')))
|
||||
for (let k = items.length + 1; ; k += 1) if (!taken.has(`a${k}`)) return `a${k}`
|
||||
}
|
||||
|
||||
/** 换分析方法:有模式的取第一种模式,参数只留新方法用得到的,输入和假设留着 */
|
||||
export function withOp(item: AnalysisItem, op: string): AnalysisItem {
|
||||
if (!isAnalysisOp(op)) return { ...item, op, mode: undefined }
|
||||
const mode = ANALYSIS_MODES[op]?.[0]
|
||||
return withParams({ ...item, op, mode }, op, mode)
|
||||
}
|
||||
|
||||
/** 换模式:参数只留这种模式用得到的 */
|
||||
export function withMode(item: AnalysisItem, mode: string): AnalysisItem {
|
||||
return isAnalysisOp(item.op) ? withParams({ ...item, mode }, item.op, mode) : { ...item, mode }
|
||||
}
|
||||
|
||||
function withParams(item: AnalysisItem, op: AnalysisOp, mode: string | null | undefined): AnalysisItem {
|
||||
const keep = paramKeys(op, mode)
|
||||
const params = Object.fromEntries(Object.entries(item.params ?? {}).filter(([k]) => keep.has(k)))
|
||||
const next: AnalysisItem = { ...item, params }
|
||||
if (!ANALYSIS_MODES[op]) delete next.mode
|
||||
return next
|
||||
}
|
||||
|
||||
/** 卡片正文的摘要:「波动基线、变化拆解(可加)」,超过三项写「等 N 项」 */
|
||||
export function analysisCardSummary(config: Record<string, any>): string {
|
||||
const items = itemsOf(config.items)
|
||||
if (!items.length) return ANALYSIS_TEXT.node.summaryEmpty
|
||||
const names = items.map((i) => (i.op ? analysisMethodLabel(i.op, i.mode) : ANALYSIS_TEXT.node.summaryNoOp))
|
||||
const head = names.slice(0, 3).join('、')
|
||||
return names.length > 3 ? `${head}${ANALYSIS_TEXT.node.summaryMore(names.length)}` : head
|
||||
}
|
||||
|
||||
interface CardNodeLike {
|
||||
id: string
|
||||
data?: { nodeType?: string; label?: string; config?: Record<string, any> }
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询节点连到分析卡:第一项还没选输入(也不是两份查询)的分析项顺手选上它,和连线同一步撤销(同合并查询的 connectMergeInput)。
|
||||
* 已经有项用着它、或者每一项都选好了输入时不动
|
||||
*/
|
||||
export function connectAnalysisInput<T extends CardNodeLike>(nodes: T[], source: string | null, target: string | null,
|
||||
isQuery: (n: T | undefined) => boolean): T[] | null {
|
||||
if (!source || !target || source === target) return null
|
||||
const to = nodes.find((n) => n.id === target)
|
||||
if (to?.data?.nodeType !== 'analysis' || !isQuery(nodes.find((n) => n.id === source))) return null
|
||||
const items = itemsOf(to.data?.config?.items)
|
||||
const uses = (it: AnalysisItem) => [it.from, it.params?.base_query, it.params?.target_query].includes(source)
|
||||
if (items.some(uses)) return null
|
||||
const i = items.findIndex((it) => !String(it.from ?? '').trim() && !('base_query' in (it.params ?? {})))
|
||||
if (i < 0) return null
|
||||
const next = items.map((it, j) => (j === i ? { ...it, from: source } : it))
|
||||
return nodes.map((n) => (n.id === target ? { ...n, data: { ...n.data, config: { ...(n.data?.config ?? {}), items: next } } } : n))
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Bot, Braces, Brain, CheckCircle2, Code2, Combine, Database, FileInput, FileOutput, FileText,
|
||||
Bot, Braces, Brain, ChartColumnStacked, CheckCircle2, Code2, Combine, Database, FileInput, FileOutput, FileText,
|
||||
Gauge, GitBranch, GitCompareArrows, Hand, Repeat, Search, ShieldAlert, Shuffle, Users, Wrench,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
APPROVAL_POLICY_LABEL, CHALLENGE_TEXT, DUAL_CHECK_TEXT, MERGE_TEXT, NODE_TYPE_LABEL, REJECTED_TEXT, UPGRADE_POLICY_LABEL,
|
||||
} from '../lib/terms'
|
||||
import { ANALYSIS_FIELD_LABEL, ANALYSIS_OPTION_LABEL, ANALYSIS_TEXT } from '../lib/termsAnalysis'
|
||||
import type { NodeType } from '../types'
|
||||
import { dualCheckFields, dualCheckSummary } from './DualCheckFields'
|
||||
import { HUMAN_PROCESS_FIELDS, TOOL_WRITE_FIELDS } from './humanFields'
|
||||
@@ -15,6 +16,7 @@ export type FieldType =
|
||||
| 'text' | 'textarea' | 'prompt' | 'code' | 'number' | 'select' | 'switch'
|
||||
| 'json' | 'model' | 'tools' | 'skills' | 'collection'
|
||||
| 'ioFields' | 'cases' | 'agents' | 'metricsList' | 'nodeRefs' | 'caliberFrom' | 'judge' | 'mergeInputs'
|
||||
| 'analysisItems' | 'analysisOverrides'
|
||||
|
||||
/**
|
||||
* 这个字段里写的是什么语法。
|
||||
@@ -654,6 +656,29 @@ export const NODE_DEFS: Record<NodeType, NodeDef> = {
|
||||
+ '反引号中无从核对的名称标为「疑似不存在的名称」',
|
||||
unknownLabel: (v) => `${v}:无法识别的值,请从上方选项中选择`,
|
||||
},
|
||||
{
|
||||
// 后端认 off / check / strict(schema.ANALYSIS_CLAIMS_VALUES),没写按 check:缺省放第一项,没写时下拉正好落在它上面。
|
||||
// 选项以 engine/labels.py 的短说法开头
|
||||
key: 'analysis_claims', label: ANALYSIS_FIELD_LABEL.analysis_claims, type: 'select',
|
||||
options: [
|
||||
{ value: 'check', label: `${ANALYSIS_OPTION_LABEL.analysis_claims.check}(默认):仍有的在报告中标注,不影响出具档位` },
|
||||
{ value: 'strict', label: `${ANALYSIS_OPTION_LABEL.analysis_claims.strict}:按最多重写次数重写,出具随之降档` },
|
||||
{ value: 'off', label: ANALYSIS_OPTION_LABEL.analysis_claims.off },
|
||||
],
|
||||
help: '「主要来自」「超出常规波动」「持续上升」这类判断要挂对应的分析结论且判定对得上;「由于」「导致」这类原因要有'
|
||||
+ '知识库原话或运行输入作依据,否则写成待核实的推测。受管级别发布须选择「重写后仍有的计入缺口」',
|
||||
unknownLabel: (v) => `${v}:无法识别的值,请从上方选项中选择`,
|
||||
},
|
||||
{
|
||||
// 后端认 auto / off(schema.ANALYSIS_SCOPE_VALUES),没写按 auto
|
||||
key: 'analysis_scope', label: ANALYSIS_FIELD_LABEL.analysis_scope, type: 'select', advanced: true,
|
||||
options: [
|
||||
{ value: 'auto', label: `${ANALYSIS_OPTION_LABEL.analysis_scope.auto}(默认)` },
|
||||
{ value: 'off', label: ANALYSIS_OPTION_LABEL.analysis_scope.off },
|
||||
],
|
||||
help: '报告末尾由系统写一段:现象、拆过、排除、没拆、检验,写作者改不了,随报告封存',
|
||||
unknownLabel: (v) => `${v}:无法识别的值,请从上方选项中选择`,
|
||||
},
|
||||
...MODEL_FIELDS,
|
||||
{ key: 'system', label: '系统提示', type: 'textarea', advanced: true, placeholder: '你是…' },
|
||||
...COMMON_TAIL,
|
||||
@@ -698,6 +723,20 @@ export const NODE_DEFS: Record<NodeType, NodeDef> = {
|
||||
metrics: [{ id: 'total', name: '', unit: '', expression: '' }],
|
||||
},
|
||||
},
|
||||
analysis: {
|
||||
type: 'analysis', label: NODE_TYPE_LABEL.analysis, category: '把关', icon: ChartColumnStacked,
|
||||
description: ANALYSIS_TEXT.node.description,
|
||||
hasTarget: true, sources: [{ id: 'out', label: '' }],
|
||||
// 配置的键照服务端 engine/schema.analysis_issues:items(每项 id、op、mode、from、params、hypothesis)、overrides(阈值覆盖,
|
||||
// 键和范围同设置里的分析一组)。产出 nodes.<卡>.results.<项> = {status, verdict, direction, values, phrase, artifact}
|
||||
fields: [
|
||||
{ key: 'items', label: ANALYSIS_FIELD_LABEL.items, type: 'analysisItems', help: ANALYSIS_TEXT.node.itemsHint },
|
||||
{ key: 'overrides', label: ANALYSIS_FIELD_LABEL.overrides, type: 'analysisOverrides', help: ANALYSIS_TEXT.node.overridesHint },
|
||||
...COMMON_TAIL,
|
||||
],
|
||||
// 带一项空的:拖进画布就看得到一项要填什么(同口径卡带一个空指标)
|
||||
defaults: { items: [{ id: 'a1', params: {} }] },
|
||||
},
|
||||
dual_check: {
|
||||
type: 'dual_check', label: NODE_TYPE_LABEL.dual_check, category: '把关', icon: GitCompareArrows,
|
||||
description: DUAL_CHECK_TEXT.description,
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
--nt-human: #ce91e6;
|
||||
--nt-validate: #ebb1fd;
|
||||
--nt-metrics: #cbaddb;
|
||||
--nt-analysis: #c09dd9;
|
||||
--nt-report: #a9b0ec;
|
||||
--nt-dual_check: #a98ee0;
|
||||
--nt-challenge: #dcb7ef;
|
||||
@@ -151,6 +152,7 @@
|
||||
--nt-human: #6f3d81;
|
||||
--nt-validate: #885398;
|
||||
--nt-metrics: #6e557b;
|
||||
--nt-analysis: #674a82;
|
||||
--nt-report: #4f5694;
|
||||
--nt-dual_check: #5a4a91;
|
||||
--nt-challenge: #7d4c8f;
|
||||
@@ -214,6 +216,7 @@
|
||||
--nt-human: #6f3d81;
|
||||
--nt-validate: #885398;
|
||||
--nt-metrics: #6e557b;
|
||||
--nt-analysis: #674a82;
|
||||
--nt-report: #4f5694;
|
||||
--nt-dual_check: #5a4a91;
|
||||
--nt-challenge: #7d4c8f;
|
||||
@@ -428,6 +431,7 @@
|
||||
.nt-transform { --nt: var(--nt-transform); }
|
||||
.nt-subgraph { --nt: var(--nt-subgraph); }
|
||||
.nt-metrics { --nt: var(--nt-metrics); }
|
||||
.nt-analysis { --nt: var(--nt-analysis); }
|
||||
.nt-report { --nt: var(--nt-report); }
|
||||
.nt-dual_check { --nt: var(--nt-dual_check); }
|
||||
.nt-challenge { --nt: var(--nt-challenge); }
|
||||
|
||||
@@ -189,6 +189,7 @@ export const NODE_TYPE_LABEL: Record<NodeType, string> = {
|
||||
human: '人工审批',
|
||||
validate: '结构校验',
|
||||
metrics: '口径卡',
|
||||
analysis: '分析卡',
|
||||
report: '报告撰写',
|
||||
dual_check: '双盲复算',
|
||||
challenge: '反方审稿',
|
||||
@@ -1164,6 +1165,7 @@ export const EVIDENCE_KIND_LABEL: Record<string, string> = {
|
||||
entity: '表或字段',
|
||||
column: '字段',
|
||||
quote: '引文',
|
||||
analysis: '分析结论',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* 分析能力一轮(docs/design/analysis-round)的界面文案:分析卡的检查器和卡片、问数据时间线上的分析一步、证据面板的分析一步与
|
||||
* 口径卡指标上的可比性、正文里的说法违规与系统写的「分析范围」、出具声明里的三项、设置「分析」一节、运行对比的分析卡一节。
|
||||
*
|
||||
* 叫法只有服务端契约一份(backend/app/analysis/contract.py 的 OP_LABELS / MODE_LABELS / LABELS,engine/labels.py 的分析卡字段与
|
||||
* 报告节点两个新字段引用它们):这里的 ANALYSIS_OP_LABEL、ANALYSIS_MODE_LABEL、ANALYSIS_SETTING_LABEL、ANALYSIS_FIELD_LABEL 和它们
|
||||
* 一字不差(scripts/check-studio、check-manage 读后端源码逐项比对)。判定语、判定标准、算不了的原因、附带说明、代入式、闭合核对
|
||||
* 的数、说法违规的原话都是服务端写好的,界面照原样显示,不按判定码、原因码自己拼。规矩同 terms.ts 开头的术语表。
|
||||
*
|
||||
* 术语:一次分析的结果叫「分析结论」,编号 A1、A2……;系统写的那一句叫「判定语」;判定的依据叫「判定标准」;做不成的叫「没有做成」
|
||||
* (服务端判定语的写法),原因叫「算不了的原因」;写错的调用叫「调用有误」,不记成结论;Agent 写的那句叫「Agent 写的假设」。
|
||||
*/
|
||||
import { formatNumber } from './format'
|
||||
|
||||
/** 五个算子(contract.OPS),按契约的先后 */
|
||||
export const ANALYSIS_OPS = ['decompose', 'baseline', 'trend', 'differ', 'comparable'] as const
|
||||
export type AnalysisOp = (typeof ANALYSIS_OPS)[number]
|
||||
|
||||
/** 算子的叫法:contract.OP_LABELS */
|
||||
export const ANALYSIS_OP_LABEL: Record<AnalysisOp, string> = {
|
||||
decompose: '变化拆解', baseline: '波动基线', trend: '趋势判断', differ: '差异检验', comparable: '可比性检查',
|
||||
}
|
||||
|
||||
/** 模式的叫法:contract.MODE_LABELS(比率拆解和比率比较都叫「比率」) */
|
||||
export const ANALYSIS_MODE_LABEL: Record<string, string> = {
|
||||
additive: '可加', ratio: '比率', factor: '乘积', rate: '比率', mean: '均值', rows: '逐行明细',
|
||||
}
|
||||
|
||||
/** 有模式的算子和它们的模式:contract.MODES。其余算子没有模式 */
|
||||
export const ANALYSIS_MODES: Partial<Record<AnalysisOp, readonly string[]>> = {
|
||||
decompose: ['additive', 'ratio', 'factor'],
|
||||
differ: ['rate', 'mean', 'rows'],
|
||||
}
|
||||
|
||||
export const isAnalysisOp = (op: unknown): op is AnalysisOp => typeof op === 'string' && (ANALYSIS_OPS as readonly string[]).includes(op)
|
||||
|
||||
/**
|
||||
* 「变化拆解(可加)」「波动基线」:同 contract.method_label。认不出的算子、模式原样写出来,不编叫法。证据面板、导出页用服务端
|
||||
* 给的 method_label;事件里只有算子和模式的(时间线、运行对比)照这一份拼
|
||||
*/
|
||||
export function analysisMethodLabel(op: unknown, mode?: unknown): string {
|
||||
const name = isAnalysisOp(op) ? ANALYSIS_OP_LABEL[op] : String(op ?? '')
|
||||
if (mode == null || mode === '') return name
|
||||
return `${name}(${ANALYSIS_MODE_LABEL[String(mode)] ?? String(mode)})`
|
||||
}
|
||||
|
||||
/** Agent 分析工具的名字(contract.TOOL_NAMES:analysis_<算子>)→ 算子;不是分析工具为 null */
|
||||
export function analysisToolOp(tool: unknown): AnalysisOp | null {
|
||||
const m = typeof tool === 'string' ? /^analysis_([a-z]+)$/.exec(tool.trim()) : null
|
||||
return m && isAnalysisOp(m[1]) ? m[1] : null
|
||||
}
|
||||
|
||||
/** 分析卡、报告节点新字段的叫法:engine/labels.py 的 _FIELD_BY_TYPE['analysis'] 与 FIELD_LABEL */
|
||||
export const ANALYSIS_FIELD_LABEL = {
|
||||
items: '分析项',
|
||||
overrides: '阈值覆盖',
|
||||
analysis_claims: '分析说法核对',
|
||||
analysis_scope: '分析范围',
|
||||
} as const
|
||||
|
||||
/** 报告节点两个新字段取值的短说法:engine/labels.py 的 _OPTION_LABEL(下拉的选项以它开头,后面可以带一句用途) */
|
||||
export const ANALYSIS_OPTION_LABEL = {
|
||||
analysis_claims: { off: '不核对', check: '重写一次后标出', strict: '重写后仍有的计入缺口' },
|
||||
analysis_scope: { auto: '有分析结论时附上', off: '不附' },
|
||||
} as const
|
||||
|
||||
/** 分析设置的键(contract.DEFAULTS 的先后) */
|
||||
export const ANALYSIS_SETTING_KEYS = [
|
||||
'main_share', 'concentration', 'offset_ratio', 'flat_pct', 'k_warn', 'k_alert', 'min_history', 'max_history',
|
||||
'trend_min_points', 'trend_alpha', 'differ_alpha', 'min_n', 'min_expected', 'min_denominator', 'max_calls',
|
||||
'max_drill_depth', 'table_top_k',
|
||||
] as const
|
||||
export type AnalysisSettingKey = (typeof ANALYSIS_SETTING_KEYS)[number]
|
||||
|
||||
/** 每一项的叫法:contract.LABELS(设置页、阈值覆盖、服务端报错同一套) */
|
||||
export const ANALYSIS_SETTING_LABEL: Record<AnalysisSettingKey, string> = {
|
||||
main_share: '「主要来源」的占比',
|
||||
concentration: '「集中」的合计占比',
|
||||
offset_ratio: '记为「抵消」的比例',
|
||||
flat_pct: '「基本持平」的幅度',
|
||||
k_warn: '「超出常规波动」的倍数',
|
||||
k_alert: '「明显超出」的倍数',
|
||||
min_history: '至少几期历史',
|
||||
max_history: '最多用几期历史',
|
||||
trend_min_points: '趋势至少几期',
|
||||
trend_alpha: '趋势的显著性水平',
|
||||
differ_alpha: '差异检验的显著性水平',
|
||||
min_n: '均值比较的最小样本数',
|
||||
min_expected: '比率比较的最小期望频数',
|
||||
min_denominator: '比率拆解的最小分母',
|
||||
max_calls: 'Agent 每次执行最多分析几次',
|
||||
max_drill_depth: '最多下钻几层',
|
||||
table_top_k: '列出前几段',
|
||||
}
|
||||
|
||||
/** 每一项的类型和闭区间:contract.RANGES(整数项只收整数)。界面先按它说哪一项不对,两两之间的大小交给服务端核 */
|
||||
export const ANALYSIS_SETTING_RANGE: Record<AnalysisSettingKey, readonly ['float' | 'int', number, number]> = {
|
||||
main_share: ['float', 0.3, 0.9],
|
||||
concentration: ['float', 0.5, 0.99],
|
||||
offset_ratio: ['float', 0.05, 1.0],
|
||||
flat_pct: ['float', 0.0, 0.1],
|
||||
k_warn: ['float', 1.0, 5.0],
|
||||
k_alert: ['float', 1.5, 8.0],
|
||||
min_history: ['int', 4, 104],
|
||||
max_history: ['int', 8, 260],
|
||||
trend_min_points: ['int', 4, 104],
|
||||
trend_alpha: ['float', 0.001, 0.2],
|
||||
differ_alpha: ['float', 0.001, 0.2],
|
||||
min_n: ['int', 2, 10_000],
|
||||
min_expected: ['int', 1, 50],
|
||||
min_denominator: ['int', 1, 100_000],
|
||||
max_calls: ['int', 1, 50],
|
||||
max_drill_depth: ['int', 1, 6],
|
||||
table_top_k: ['int', 3, 30],
|
||||
}
|
||||
|
||||
/** 设置页把这几项分成几组(只是排版,键和叫法照上面) */
|
||||
export const ANALYSIS_SETTING_GROUPS: { title: string; keys: AnalysisSettingKey[] }[] = [
|
||||
{ title: '变化拆解', keys: ['main_share', 'concentration', 'offset_ratio', 'flat_pct', 'min_denominator', 'table_top_k'] },
|
||||
{ title: '波动基线与趋势判断', keys: ['k_warn', 'k_alert', 'min_history', 'max_history', 'trend_min_points', 'trend_alpha'] },
|
||||
{ title: '差异检验', keys: ['differ_alpha', 'min_n', 'min_expected'] },
|
||||
{ title: '次数与层数', keys: ['max_calls', 'max_drill_depth'] },
|
||||
]
|
||||
|
||||
/** 范围写成人话:「0.3 到 0.9 之间」「4 到 104 之间的整数」 */
|
||||
export function settingRangeText(key: AnalysisSettingKey): string {
|
||||
const [kind, lo, hi] = ANALYSIS_SETTING_RANGE[key]
|
||||
return kind === 'int' ? `${formatNumber(lo)} 到 ${formatNumber(hi)} 之间的整数` : `${lo} 到 ${hi} 之间`
|
||||
}
|
||||
|
||||
/** 一项填的值有什么问题(类型、范围),没问题为 null。说法同服务端 contract.settings_problem */
|
||||
export function settingValueProblem(key: AnalysisSettingKey, raw: string): string | null {
|
||||
const text = raw.trim()
|
||||
const [kind, lo, hi] = ANALYSIS_SETTING_RANGE[key]
|
||||
const label = ANALYSIS_SETTING_LABEL[key]
|
||||
if (!text || !Number.isFinite(Number(text))) return `${label}要填数字`
|
||||
const n = Number(text)
|
||||
if (kind === 'int' && !/^-?\d+$/.test(text)) return `${label}要填整数`
|
||||
if (n < lo || n > hi) return `${label}要在 ${lo} 到 ${hi} 之间`
|
||||
return null
|
||||
}
|
||||
|
||||
const short = (text: string, n = 24) => (text.length > n ? `${text.slice(0, n - 1)}…` : text)
|
||||
|
||||
export const ANALYSIS_TEXT = {
|
||||
/** 记录页工件清单里分析结论那一行的说明(工件没记算子时) */
|
||||
artifactHint: '分析结论:判定、方法、计算、输入和复算所需的一切',
|
||||
// ---- 分析卡:节点库、检查器、卡片 ----
|
||||
node: {
|
||||
description: '对上游查询节点的结果固定做几项确定性分析:变化拆解、波动基线、趋势判断、差异检验、可比性检查。判定由平台按设置的阈值算出,报告可引用、可查看出处',
|
||||
itemsHint: '每一项做一种分析:选分析方法、要分析的上游查询节点,再按方法填列名和期次。判定语由系统按平台的方法写好,报告引用它,不另写判断',
|
||||
overridesHint: '只对这张卡生效,写进工作流、随版本固定;未覆盖的项跟随「设置 → 偏好设置 → 分析」。Agent 的分析工具没有阈值覆盖',
|
||||
addItem: '添加分析项',
|
||||
removeItem: (id: string) => `删除分析项 ${id || '(未命名)'}`,
|
||||
id: '标识',
|
||||
idHint: (node: string, id: string) => `下游条件分支按 nodes.${node}.results.${id || '<标识>'}.verdict 取判定`,
|
||||
idInvalid: '标识只能用字母、数字和下划线,且不以数字开头',
|
||||
idDuplicate: (id: string) => `标识「${id}」与另一项重复,请换一个`,
|
||||
op: '分析方法',
|
||||
pickOp: '选择分析方法',
|
||||
mode: '模式',
|
||||
pickMode: '选择模式',
|
||||
unknownOp: (op: string) => `${op}:无法识别的分析方法,请从上方选项中选择`,
|
||||
unknownMode: (mode: string) => `${mode}:无法识别的模式,请从上方选项中选择`,
|
||||
from: '输入',
|
||||
pickFrom: '选择上游的查询节点',
|
||||
noUpstream: '上游没有查询节点。请先在本节点之前连接选择了数据库查询工具的「调用工具」节点,或「合并查询」节点',
|
||||
missingNode: (id: string) => `节点「${id}」不在上游或已删除,请重新选择`,
|
||||
twoQueries: '基期、本期是两份查询',
|
||||
baseQuery: '基期查询',
|
||||
targetQuery: '本期查询',
|
||||
hypothesis: '在验证什么',
|
||||
hypothesisHint: '一句话,只在证据面板显示,不进报告正文',
|
||||
hypothesisPlaceholder: '下降是不是集中在某个地区',
|
||||
params: '参数',
|
||||
listHint: '多列用顿号或逗号分隔',
|
||||
cellHint: '写成「节点 ID.r行号.列名」,行号从 0 数',
|
||||
headline: '总数核对',
|
||||
headlineHint: '给出基期、本期总数那两格,系统核对拆开后各段之和是否对得上',
|
||||
headlineBase: '基期总数那一格',
|
||||
headlineTarget: '本期总数那一格',
|
||||
parent: '下钻',
|
||||
parentHint: '这一项是前面某个变化拆解里某一段的拆解',
|
||||
parentOf: '上一层分析项',
|
||||
parentKey: '那一段的维度值',
|
||||
noParent: '不下钻',
|
||||
pairsHint: '每行一对,两个组之间用顿号或逗号分隔',
|
||||
/** 参数的叫法(tools/builtin/analysis.py 各参数模型里的字段,分析卡按同一份校验) */
|
||||
param: {
|
||||
by: '维度列', period: '期次列', base: '基期', target: '本期', measure: '度量列', numerator: '分子列', denominator: '分母列',
|
||||
product: '乘积列', factors: '因子列', label: '度量的叫法', basis: '比较方式', grain: '期次粒度', lag: '和往前第几期比',
|
||||
pairs: '只比这几对', reference: '对照组', mean: '均值列', sd: '标准差列', sum: '合计列', sumsq: '平方和列', n: '样本数列',
|
||||
value: '值列',
|
||||
} as Record<string, string>,
|
||||
paramPlaceholder: {
|
||||
by: 'region', period: 'week', base: '2026-W41', target: '2026-W42', measure: 'gmv', numerator: 'refunds',
|
||||
denominator: 'orders', product: 'gmv', factors: 'orders、aov', label: '销售额', lag: '1', reference: '华东',
|
||||
mean: 'avg_amount', sd: 'sd_amount', sum: 'sum_amount', sumsq: 'sumsq_amount', n: 'orders', value: 'amount',
|
||||
} as Record<string, string>,
|
||||
paramHint: {
|
||||
label: '系统写判定语时用;不写数字、百分号和单位,留空则用列名或数据目录中的名称',
|
||||
target: '留空取最后一期',
|
||||
lag: '日缺省 7(和上周同一天比),周、月缺省 1;同比写 52(周)或 12(月)',
|
||||
reference: '其余各组各和它比;留空则各组与其余各组合计比',
|
||||
} as Record<string, string>,
|
||||
basis: { '': '和比较期比的变化率(缺省)', change: '和比较期比的变化率', level: '值本身(存量、比率用)' } as Record<string, string>,
|
||||
grain: { '': '按期次的写法推断', day: '日', week: '周', month: '月', quarter: '季度' } as Record<string, string>,
|
||||
/** differ 均值模式:给均值、标准差、样本数,或合计、平方和、样本数 */
|
||||
meanShape: { sd: '均值、标准差、样本数', sumsq: '合计、平方和、样本数' } as Record<string, string>,
|
||||
// ---- 阈值覆盖 ----
|
||||
addOverride: '添加阈值覆盖',
|
||||
pickOverride: '选择要覆盖的阈值',
|
||||
removeOverride: (label: string) => `删除阈值覆盖「${label}」`,
|
||||
noOverrides: '未覆盖:全部跟随设置',
|
||||
allOverridden: '全部阈值都已覆盖',
|
||||
// ---- 卡片 ----
|
||||
summaryEmpty: '未配置分析项',
|
||||
summaryNoOp: '未选择分析方法',
|
||||
summaryMore: (n: number) => `等 ${formatNumber(n)} 项`,
|
||||
},
|
||||
|
||||
// ---- 问数据时间线:Agent 的分析工具、分析卡的每一项 ----
|
||||
step: {
|
||||
/** 运行中的状态字(store/chat) */
|
||||
running: (label: string) => `正在执行分析:${label}…`,
|
||||
/** 一步的标题:「变化拆解(可加)· A1」;分析卡的一项:「变化拆解(可加)· where」 */
|
||||
title: (method: string, alias?: string | null) => (alias ? `${method} · ${alias}` : method),
|
||||
callError: '调用有误,没有记成结论',
|
||||
callErrorNext: '这次调用没有记成结论,Agent 会按这条说明改正参数后重新调用',
|
||||
refused: '没有做成',
|
||||
refusedNext: '算不了也是一条结论:报告里可以照实写出原因,不能据此下判断',
|
||||
hypothesis: 'Agent 写的假设',
|
||||
itemHypothesis: '在验证什么',
|
||||
args: '参数',
|
||||
phrase: '判定语',
|
||||
/** 分析卡那一项的标识 */
|
||||
item: (id: string) => `分析项「${id}」`,
|
||||
},
|
||||
|
||||
// ---- 时间线上的几条提醒(log 的 code:report_analysis_claims、analysis_refused、metric_comparability_skipped) ----
|
||||
log: {
|
||||
claims: (n: number | undefined, strict: boolean) => `报告${n ? `有 ${formatNumber(n)} 处` : '有'}分析说法未通过核对,`
|
||||
+ (strict ? '已计入出具缺口' : '已在报告中标注'),
|
||||
refused: (method: string, item?: string) => `${method}${item ? `(分析项「${item}」)` : ''}没有做成`,
|
||||
comparabilitySkipped: (metric: string) => `指标${metric ? `「${metric}」` : ''}用到的两次查询没有做成可比性检查`,
|
||||
comparabilitySkippedNext: '指标照常计算,两期能不能直接比请自行核对',
|
||||
},
|
||||
|
||||
// ---- 证据面板的分析一步(照导出页 report_export/runtime.js 的说法) ----
|
||||
evidence: {
|
||||
title: '分析结论',
|
||||
titleOf: (alias: string, method: string) => `${['分析结论', alias].filter(Boolean).join(' ')} · ${method || '—'}`,
|
||||
phrase: '判定语',
|
||||
criterion: '判定标准',
|
||||
subjects: '点名的段',
|
||||
direction: '方向',
|
||||
directionLabel: { up: '上升', down: '下降' } as Record<string, string>,
|
||||
caveats: '附带说明',
|
||||
reason: '算不了的原因',
|
||||
citedPart: '这一处引用的是',
|
||||
citedPhrase: '判定语',
|
||||
citedValue: (key: string) => `具名的数「${key}」`,
|
||||
citedCell: (row: number, column: string) => `结果表第 ${formatNumber(row + 1)} 行「${column}」`,
|
||||
who: '谁做的',
|
||||
origin: { card: '分析卡(模板定好的分析)', agent: 'Agent 现场做的分析', metric: '口径卡执行时自动做的可比性检查' } as Record<string, string>,
|
||||
hypothesis: 'Agent 写的假设',
|
||||
refused: '这次分析没有做成',
|
||||
renderOk: '与按结论渲染的结果一致',
|
||||
renderBad: '与按结论渲染的结果不一致',
|
||||
method: '方法',
|
||||
methodVersion: '方法与版本',
|
||||
params: '参数',
|
||||
settings: '实际用的阈值',
|
||||
probes: '现查结果',
|
||||
probeBasis: { live: '现查', profile: '剖析', observed: '取值观察' } as Record<string, string>,
|
||||
probeBound: (expr: string, value: string) => `按运行当天现算的界 ${expr} = ${value}`,
|
||||
probeEmpty: '这一列一个值都没有',
|
||||
probeRange: (min: string, max: string) => `${min} 至 ${max}`,
|
||||
calc: '计算',
|
||||
values: '具名的数',
|
||||
formulas: '关键数的代入式',
|
||||
resultTable: '结果表',
|
||||
resultRows: (shown: number, total: number) => `列出 ${formatNumber(shown)} 行,共 ${formatNumber(total)} 行`,
|
||||
input: '输入',
|
||||
inputOf: (ref: string) => `输入 ${ref}`.trim(),
|
||||
role: '角色',
|
||||
ref: '调用里写的名字',
|
||||
query: '查询',
|
||||
columnsUsed: '用到的列',
|
||||
rowsUsed: '用到的行',
|
||||
allRows: '全部行',
|
||||
rowsList: (rows: number[]) => rows.map((r) => `第 ${formatNumber(r + 1)} 行`).join('、'),
|
||||
headline: '总数那一格',
|
||||
headlineRatio: '整体比率那一格',
|
||||
cellAt: (row: number, column: string) => `第 ${formatNumber(row + 1)} 行「${column}」`,
|
||||
closure: '闭合核对',
|
||||
closureOk: (h: string, t: string) => `总数 ${h} 与各段之和 ${t} 闭合`,
|
||||
closureBad: (h: string, t: string) => `总数 ${h} 与各段之和 ${t} 对不上`,
|
||||
ratioClosureOk: (h: string, t: string) => `整体比率 ${h} 与各段分子之和 ÷ 分母之和 ${t} 闭合`,
|
||||
ratioClosureBad: (h: string, t: string) => `整体比率 ${h} 与各段分子之和 ÷ 分母之和 ${t} 对不上`,
|
||||
parent: '下钻自',
|
||||
gotoInput: (alias: string) => `查看输入 ${alias}`,
|
||||
inputOfAnalysis: (alias: string) => `分析结论 ${alias} 的输入`,
|
||||
recompute: '复算',
|
||||
seal: '封存',
|
||||
artifact: '结论工件',
|
||||
inLedger: '封存范围内的台账记过这份分析结论',
|
||||
notInLedger: '封存范围内的台账里没有这份分析结论',
|
||||
viaCard: '记在口径卡的指标集里,随卡封存(不进台账)',
|
||||
chainPending: '正在加载判定、方法和输入…',
|
||||
// ---- 口径卡指标上的两期可比性 ----
|
||||
comparability: '两期可比性',
|
||||
comparabilityLabel: { comparable: '两期可比', conditional: '两期有条件可比', incomparable: '两期不可比' } as Record<string, string>,
|
||||
comparabilityOpen: '展开这份可比性结论(判定、方法、计算、输入、复算、封存)',
|
||||
comparabilityClose: '收起可比性结论',
|
||||
comparabilityReasons: '原因',
|
||||
comparabilityUnsealed: '这份可比性结论不在封存范围内,不能作为证据',
|
||||
comparabilityHash: '可比性结论与哈希不一致,疑似被修改',
|
||||
},
|
||||
|
||||
// ---- 正文:说法违规的标注、系统写的「分析范围」 ----
|
||||
claim: {
|
||||
/** 标注的种类(服务端 analysis_claims.CODES):给人看的短说法同审计的标签 */
|
||||
label: {
|
||||
analysis_claim_uncited: '判断词未挂分析结论',
|
||||
analysis_claim_mismatch: '判断与分析结论不符',
|
||||
causal_unsupported: '因果说法没有依据',
|
||||
} as Record<string, string>,
|
||||
tag: '说法待核',
|
||||
hint: '这句话里的判断词没有挂对应的分析结论、判断与所挂结论对不上,或原因没有依据:系统无法核对,点开看原因',
|
||||
panelTitle: '说法核对',
|
||||
analyses: '所挂的分析结论',
|
||||
noAnalyses: '这句没有挂分析结论',
|
||||
count: (n: number) => `${formatNumber(n)} 处说法待核`,
|
||||
},
|
||||
scope: {
|
||||
title: '分析范围',
|
||||
hint: '系统按这次的分析结论写的一段,不是写作者写的,随报告封存;编号可点开看出处',
|
||||
phenomena: '现象',
|
||||
decomposed: '拆过',
|
||||
excluded: '排除',
|
||||
refused: '算不了',
|
||||
notDecomposed: '没拆',
|
||||
tests: '检验',
|
||||
notDecomposedList: (dims: string[]) => `候选维度里没拆过的:${dims.join('、')}`,
|
||||
notDecomposedNone: '候选维度都拆过了',
|
||||
notDecomposedUnknown: '候选维度核对不了',
|
||||
drill: (parent: string) => `下钻自 ${parent}`,
|
||||
dims: (dims: string[]) => `按${dims.join('、')}`,
|
||||
/** 服务端 ScopeBlock.corrected:只做了一次(一次里比了多对的已按次数校正)/ 不止一次(几次之间没有校正) */
|
||||
testsText: (n: number, corrected: boolean | null | undefined) => `做了 ${formatNumber(n)} 次差异检验${corrected === true
|
||||
? '(一次里比了几对的已按次数校正)' : corrected === false ? ',几次检验之间没有按次数校正' : ''}`,
|
||||
open: (alias: string) => `查看分析结论 ${alias} 的出处`,
|
||||
},
|
||||
|
||||
// ---- 出具声明的三项:分析说法核对、指标的可比性、追不到原始数据的指标 ----
|
||||
issuance: {
|
||||
claimsTitle: (n: number, counted: boolean) => (counted
|
||||
? `${formatNumber(n)} 处分析说法未通过核对,已计入出具缺口`
|
||||
: `${formatNumber(n)} 处分析说法未通过核对(已标注,不影响出具档位)`),
|
||||
claimsStrictClean: '分析说法已按严格核对,没有未通过的',
|
||||
claimsNext: '判断要挂对应的分析结论并照判定写;原因要有知识库原话或运行输入作依据,否则写成待核实的推测',
|
||||
comparabilityTitle: '指标的两期可比性',
|
||||
comparabilityItem: (name: string, phrase: string) => `指标「${name}」:${phrase}`,
|
||||
untracedTitle: (counted: boolean) => (counted ? '报告引用的指标未追到原始数据(已计入出具缺口)' : '报告引用的指标未追到原始数据'),
|
||||
untracedItem: (name: string, stops: string[]) => `指标「${name}」${stops.length ? `:${stops.join(';')}` : ''}`,
|
||||
},
|
||||
|
||||
// ---- 设置「分析」一节 ----
|
||||
settings: {
|
||||
title: '分析',
|
||||
hint: '分析工具和分析卡判定用的阈值、次数上限。发起运行时快照进这次运行;Agent 改不了这些阈值,分析卡上可以写阈值覆盖',
|
||||
invalid: '分析的设置中有无效值,请修正后再保存',
|
||||
current: (value: string) => `当前 ${value}`,
|
||||
},
|
||||
|
||||
// ---- 运行对比 ----
|
||||
compare: {
|
||||
title: '分析卡',
|
||||
hint: '两次运行的分析卡按节点和分析项对齐,只列出两次的判定和判定语,不拆变化来源',
|
||||
change: {
|
||||
same: '判定一致', verdict: '判定变了', phrase: '判定一致,判定语里的数变了', added: '这次新增', removed: '这次没有',
|
||||
} as Record<string, string>,
|
||||
base: '基准运行',
|
||||
target: '这次运行',
|
||||
none: '—',
|
||||
item: (title: string, item: string) => `${title} · ${item}`,
|
||||
},
|
||||
}
|
||||
|
||||
export const shortPhrase = short
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
} from '@xyflow/react'
|
||||
import { api, streamCopilot, streamRun } from '../api/client'
|
||||
import { copilotReceipt, mergeNodeConfig, toolChangesOf } from '../canvas/copilotMerge'
|
||||
import { connectMergeInput } from '../canvas/mergeInputs'
|
||||
import { connectMergeInput, isQueryNode } from '../canvas/mergeInputs'
|
||||
import { connectAnalysisInput } from '../canvas/analysisItems'
|
||||
import { contentSig, isMissingEndpoint, normalizeUpgrade } from '../canvas/issues'
|
||||
import { NODE_DEFS, sourceHandles } from '../canvas/nodeDefs'
|
||||
import { toast } from '../components/ui'
|
||||
@@ -1420,8 +1421,9 @@ export const useStudio = create<StudioState>((set, get) => ({
|
||||
onConnect: (conn) => {
|
||||
if (locked(get)) return
|
||||
commit(set, get, '连线')
|
||||
// 查询节点连到合并查询:顺手把它加成合并的输入(别名取有意义的短名),和连线同一步撤销
|
||||
// 查询节点连到合并查询:顺手把它加成合并的输入(别名取有意义的短名);连到分析卡:还没选输入的第一项选上它。和连线同一步撤销
|
||||
const nodes = connectMergeInput(get().nodes, conn.source, conn.target)
|
||||
?? connectAnalysisInput(get().nodes, conn.source, conn.target, isQueryNode)
|
||||
set({
|
||||
edges: addEdge({ ...conn, type: 'flow' }, get().edges),
|
||||
...(nodes ? { nodes } : {}),
|
||||
|
||||
+254
-4
@@ -1,7 +1,7 @@
|
||||
export type NodeType =
|
||||
| 'input' | 'output' | 'llm' | 'agent' | 'supervisor' | 'tool' | 'code' | 'merge'
|
||||
| 'branch' | 'loop' | 'subgraph' | 'memory' | 'retrieve' | 'transform'
|
||||
| 'human' | 'validate' | 'metrics' | 'report' | 'dual_check' | 'challenge'
|
||||
| 'human' | 'validate' | 'metrics' | 'analysis' | 'report' | 'dual_check' | 'challenge'
|
||||
|
||||
export interface GraphNode {
|
||||
id: string
|
||||
@@ -1771,9 +1771,9 @@ export interface EvidenceCitation {
|
||||
ref?: string
|
||||
/** 目录里的别名,如 m:gmv、i:week、Q1 */
|
||||
alias?: string
|
||||
/** 引文:hit 是检索快照里第几条命中,start / end 是引文在那条原文里的码点位置 */
|
||||
/** 引文:hit 是检索快照里第几条命中,start / end 是引文在那条原文里的码点位置;分析结论的具名的数:key([[a:A1.top_share]]) */
|
||||
locator?: { metric?: string; field?: string; row?: number; column?: string; table?: string
|
||||
hit?: number; start?: number; end?: number }
|
||||
hit?: number; start?: number; end?: number; key?: string }
|
||||
eid?: string
|
||||
/** metric / input / cell / table / column / quote / query */
|
||||
kind?: string
|
||||
@@ -2008,8 +2008,15 @@ export interface EvidenceBlock {
|
||||
}
|
||||
|
||||
export interface EvidenceViolation {
|
||||
/** uncited_number / unresolved_ref / render_mismatch / eid_mismatch / … */
|
||||
/**
|
||||
* uncited_number / unresolved_ref / render_mismatch / eid_mismatch / …;分析能力一轮的说法违规:analysis_claim_uncited
|
||||
* (判断词未挂分析结论)、analysis_claim_mismatch(判断与分析结论不符)、causal_unsupported(因果说法没有依据)
|
||||
*/
|
||||
code: string
|
||||
/** 说法违规:判断词的类(source、anomaly、causal……)、要挂的算子、所挂的分析结论编号 */
|
||||
claim?: string
|
||||
op?: string
|
||||
analyses?: string[]
|
||||
message?: string
|
||||
span?: [number, number]
|
||||
text?: string
|
||||
@@ -2021,6 +2028,8 @@ export interface EvidenceViolation {
|
||||
}
|
||||
|
||||
export interface EvidenceStats {
|
||||
/** 说法违规几处(只有记了分析说法核对的文档有) */
|
||||
analysis_claims?: number
|
||||
units?: number
|
||||
segments?: number
|
||||
claims?: number
|
||||
@@ -2062,6 +2071,10 @@ export interface EvidenceDocData {
|
||||
violations?: EvidenceViolation[]
|
||||
/** 四期:写作时结论句策略是 judge 才有 */
|
||||
judge?: EvidenceJudgeSummary
|
||||
/** 分析能力一轮:报告节点核分析说法时记下的处置(check / strict)和说法表版本;老文档没有 */
|
||||
analysis_claims?: { mode?: string; lexicon?: number }
|
||||
/** 系统写的「分析范围」:这次有分析结论、报告节点没关掉时才有 */
|
||||
analysis_scope?: AnalysisScope
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2371,6 +2384,207 @@ export interface EvidenceUntraced {
|
||||
type: string | null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 分析能力一轮(docs/design/analysis-round):分析结论在证据里的样子。形状照服务端 api/evidence.py 的 _analysis_chain、
|
||||
// _comparability_view,报告文档的 analysis_scope(analysis.contract.ScopeBlock),出具声明的 analysis_claims / comparability /
|
||||
// untraced(engine/nodes/io.py)。判定语、判定标准、原因、附带说明、代入式、闭合核对的数都是服务端写好的字,界面照原样显示
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 分析结论的判定:判定码、点名的段(一段一个列表,多维时几个值;乘积拆解是因子名)、方向 */
|
||||
export interface AnalysisVerdict {
|
||||
code?: string
|
||||
subjects?: (string | number | null)[][]
|
||||
direction?: 'up' | 'down' | null
|
||||
}
|
||||
|
||||
/** 附带说明:一种说法,服务端渲染好的一句 */
|
||||
export interface AnalysisCaveat { code?: string; text?: string }
|
||||
|
||||
/** 可比性检查现查的一条:一列的实际范围(table、column、min、max),或按运行当天现算的界(expr、value) */
|
||||
export interface AnalysisProbe {
|
||||
source?: string
|
||||
table?: string
|
||||
column?: string
|
||||
min?: string | null
|
||||
max?: string | null
|
||||
basis?: 'live' | 'profile' | 'observed' | string
|
||||
at?: string
|
||||
expr?: string
|
||||
value?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 出处链里分析结论的六步(step 依次是 analysis、analysis_method、analysis_calc、analysis_input(每份输入一步)、
|
||||
* analysis_recompute、analysis_seal)共用的形状:每一步只带自己那几项
|
||||
*/
|
||||
export interface EvidenceAnalysisStep {
|
||||
step: string
|
||||
alias?: string
|
||||
// ---- analysis:判定 ----
|
||||
op?: string
|
||||
mode?: string | null
|
||||
/** 「变化拆解(可加)」:服务端 contract.method_label */
|
||||
method_label?: string
|
||||
/** card 分析卡、agent Agent 现场做的、metric 口径卡执行时自动做的可比性检查 */
|
||||
origin?: 'card' | 'agent' | 'metric' | string
|
||||
item?: string
|
||||
call_id?: string
|
||||
node_id?: string | null
|
||||
label?: string | null
|
||||
artifact?: string | null
|
||||
eid?: string
|
||||
eid_ok?: boolean
|
||||
/** 这一处引用的是结论的哪一项:判定语、具名的数(key)、结果表的一格(row、column) */
|
||||
cited?: { what?: 'phrase' | 'value' | 'cell' | string; key?: string; row?: number; column?: string; raw?: unknown; conv?: string }
|
||||
rendered?: string
|
||||
render_ok?: boolean
|
||||
hash_ok?: boolean | null
|
||||
status?: string
|
||||
reason_code?: string | null
|
||||
reason?: string | null
|
||||
phrase?: string
|
||||
criterion?: string
|
||||
verdict?: AnalysisVerdict | null
|
||||
caveats?: AnalysisCaveat[]
|
||||
hypothesis?: string
|
||||
hypothesis_note?: string
|
||||
note?: string
|
||||
// ---- analysis_method:方法、参数、实际用的阈值、现查结果 ----
|
||||
method?: string
|
||||
params?: Record<string, unknown>
|
||||
settings?: { key: string; label?: string; value: unknown }[]
|
||||
probes?: AnalysisProbe[]
|
||||
// ---- analysis_calc:具名的数、代入式、结果表 ----
|
||||
values?: { key: string; raw?: unknown; rendered?: string; cited?: boolean }[]
|
||||
formulas?: { label?: string; text?: string }[]
|
||||
table?: {
|
||||
columns: string[]
|
||||
labels?: Record<string, string>
|
||||
total_rows?: number
|
||||
row_index?: number[]
|
||||
rows: unknown[][]
|
||||
masked?: string[]
|
||||
highlight?: { cells?: [number, string][] }
|
||||
window_truncated?: boolean
|
||||
}
|
||||
// ---- analysis_input:一份输入 ----
|
||||
/** 这一步是哪份结论的输入 */
|
||||
of?: string
|
||||
ref?: string
|
||||
role?: string
|
||||
role_label?: string
|
||||
columns?: string[]
|
||||
rows?: 'all' | number[]
|
||||
row?: number
|
||||
column?: string
|
||||
value?: string | null
|
||||
/** 输入落到报告目录的哪个 Q */
|
||||
query?: string | null
|
||||
sealed?: boolean
|
||||
/** headline 那一格和结论里总数的闭合核对:mode 可加对各段之和,比率对各段分子之和 ÷ 分母之和 */
|
||||
closure?: { mode?: string; total_key?: string; total?: string; headline?: string; closed?: boolean }
|
||||
// ---- analysis_recompute:复算(服务端算好的那一句) ----
|
||||
text?: string
|
||||
// ---- analysis_seal:封存 ----
|
||||
ledger?: boolean
|
||||
/** metric_set:口径卡指标上的可比性结论,记在指标集里、随卡封存 */
|
||||
via?: string
|
||||
}
|
||||
|
||||
/** 指标步骤上的两期可比性(api/evidence._comparability_view):判定、判定语、原因,连同那份可比性结论的出处链 */
|
||||
export interface EvidenceComparability {
|
||||
verdict?: 'comparable' | 'conditional' | 'incomparable' | string
|
||||
phrase?: string
|
||||
reasons?: string[]
|
||||
artifact?: string | null
|
||||
hash_ok?: boolean | null
|
||||
sealed?: boolean
|
||||
method?: string
|
||||
criterion?: string
|
||||
caveats?: AnalysisCaveat[]
|
||||
probes?: AnalysisProbe[]
|
||||
/** 判定 → 方法 → 计算 → 输入 → 复算 → 封存(两份输入快照的查询步骤指标链里已经有了,不再给) */
|
||||
steps?: EvidenceAnalysisStep[]
|
||||
}
|
||||
|
||||
/** 报告文档里系统写的「分析范围」的一条(contract.ScopeItem) */
|
||||
export interface AnalysisScopeItem {
|
||||
alias?: string
|
||||
op?: string
|
||||
mode?: string | null
|
||||
/** 拆解的维度(给人看的叫法) */
|
||||
dims?: string[]
|
||||
verdict?: string | null
|
||||
phrase?: string
|
||||
/** 下钻:上一层的编号 */
|
||||
parent?: string | null
|
||||
reason?: string | null
|
||||
}
|
||||
|
||||
/** 报告文档的 analysis_scope(contract.ScopeBlock):不进正文、不过裸数字和说法核对,随文档封存 */
|
||||
export interface AnalysisScope {
|
||||
phenomena?: AnalysisScopeItem[]
|
||||
decomposed?: AnalysisScopeItem[]
|
||||
excluded?: AnalysisScopeItem[]
|
||||
refused?: AnalysisScopeItem[]
|
||||
/** 候选维度里没拆过的叫法;null 是候选维度核对不了(看 note) */
|
||||
not_decomposed?: string[] | null
|
||||
note?: string | null
|
||||
/** 这次做了几次差异检验 */
|
||||
tests?: number
|
||||
/** 检验校正过没有:只做了一次为 true,不止一次为 false,没做为 null */
|
||||
corrected?: boolean | null
|
||||
}
|
||||
|
||||
/** 出具声明的 analysis_claims:处置、几处、记没记缺口、按种类的计数、前几处(同报告文档的说法违规) */
|
||||
export interface IssuanceAnalysisClaims {
|
||||
mode?: 'check' | 'strict' | string
|
||||
count?: number
|
||||
counts?: Record<string, number>
|
||||
counted?: boolean
|
||||
violations?: EvidenceViolation[]
|
||||
}
|
||||
|
||||
/** 出具声明的 comparability:两期有条件可比、不可比的指标,一条一个 */
|
||||
export interface IssuanceComparability {
|
||||
metric?: string
|
||||
name?: string
|
||||
verdict?: string
|
||||
phrase?: string
|
||||
reasons?: string[]
|
||||
artifact?: string
|
||||
}
|
||||
|
||||
/** 出具声明的 untraced:报告引用的指标里输入追不到原始数据的,counted 是记了缺口(受管级别的正式运行) */
|
||||
export interface IssuanceUntraced {
|
||||
metrics?: { metric?: string; name?: string; stops?: { path?: string; node_id?: string | null; label?: string | null; type?: string | null }[] }[]
|
||||
counted?: boolean
|
||||
}
|
||||
|
||||
/** Agent 分析工具 tool.end 的 analysis(服务端 tools/analysis.event_view):preview 只是结论的前一截,判定语从这里读 */
|
||||
export interface AnalysisEventView {
|
||||
/** 这份结论在本节点的编号(「【分析 An】」那个);不记台账的执行没有 */
|
||||
alias?: string | null
|
||||
op?: string
|
||||
mode?: string | null
|
||||
status?: 'ok' | 'refused' | string
|
||||
verdict?: string | null
|
||||
direction?: string | null
|
||||
phrase?: string
|
||||
reason_code?: string | null
|
||||
}
|
||||
|
||||
/** 分析卡的一项(节点配置 items[i],engine/schema.analysis_issues 校验) */
|
||||
export interface AnalysisItem {
|
||||
id?: string
|
||||
op?: string
|
||||
mode?: string | null
|
||||
/** 要分析的上游查询节点(调用数据库查询工具的「调用工具」节点,或「合并查询」);两份查询时写在 params 的 base_query、target_query */
|
||||
from?: string
|
||||
params?: Record<string, any>
|
||||
hypothesis?: string
|
||||
}
|
||||
|
||||
export interface EvidenceStep extends Omit<EvidenceInput, 'status'> {
|
||||
step: string
|
||||
/**
|
||||
@@ -2477,6 +2691,10 @@ export interface EvidenceStep extends Omit<EvidenceInput, 'status'> {
|
||||
merge?: EvidenceMerge
|
||||
/** 这一步是哪次合并查询的输入(合并结果在目录里的编号):排在那个合并步骤后面,高亮的是追到的格 */
|
||||
merged_into?: string
|
||||
/** query:这一步是哪份分析结论的输入(结论在目录里的编号 A…):接在那份结论的输入后面 */
|
||||
input_of?: string
|
||||
/** metric:跨两份同形查询算的环比、同比,口径卡执行时自动做的可比性检查。没做过检查的指标没有这个键 */
|
||||
comparability?: EvidenceComparability
|
||||
// ---- entity:表或字段(三期)----
|
||||
/** entity:table / column */
|
||||
kind?: string
|
||||
@@ -4371,12 +4589,38 @@ export interface RunCompareQuery {
|
||||
}
|
||||
|
||||
/** GET /runs/{id}/compare?base=。任一未封存或封存核对不一致 409;不是同一工作流、是同一次运行 422 */
|
||||
/** 运行对比里分析卡的一边(analysis.end 那一项):没有这一项为 null */
|
||||
export interface RunCompareAnalysisSide {
|
||||
status?: string | null
|
||||
verdict?: string | null
|
||||
direction?: string | null
|
||||
phrase?: string
|
||||
artifact?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行对比里分析卡的一项(engine/compare._analyses):两次运行的分析卡按节点和项的标识对齐。change:same 判定和判定语都一样、
|
||||
* verdict 状态或判定变了、phrase 判定一样而判定语里的数变了、added / removed 只在一边有
|
||||
*/
|
||||
export interface RunCompareAnalysis {
|
||||
node_id: string
|
||||
title?: string
|
||||
item: string
|
||||
op?: string | null
|
||||
mode?: string | null
|
||||
base: RunCompareAnalysisSide | null
|
||||
target: RunCompareAnalysisSide | null
|
||||
change: 'same' | 'verdict' | 'phrase' | 'added' | 'removed' | string
|
||||
}
|
||||
|
||||
export interface RunComparison {
|
||||
target: RunCompareRef
|
||||
base: RunCompareRef
|
||||
summary: RunCompareSummary
|
||||
template: RunCompareTemplate
|
||||
metrics: RunCompareMetric[]
|
||||
/** 分析卡按项对齐(分析能力一轮);老服务端没有 */
|
||||
analyses?: RunCompareAnalysis[]
|
||||
queries: RunCompareQuery[]
|
||||
notes: string[]
|
||||
}
|
||||
@@ -4505,6 +4749,12 @@ export interface DualCheckConfig {
|
||||
enabled?: boolean | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置里的 analysis 一组(GET /settings 给的是补齐缺省之后的完整一组):判定用的阈值、Agent 每次执行最多分析几次、最多下钻
|
||||
* 几层、列出前几段。键、叫法、范围见 lib/termsAnalysis(照服务端 analysis/contract.py)
|
||||
*/
|
||||
export type AnalysisSettings = Record<string, number>
|
||||
|
||||
/** 设置里的 verify 一组(GET /settings 给的是补齐默认之后的完整一组;上限为 null 表示不限) */
|
||||
export interface VerifySettings {
|
||||
provider: string | null
|
||||
|
||||
Reference in New Issue
Block a user