mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:41:58 +00:00
数据目录阶段 4A 前端:画布和问数据页的回答下显示「建议更新数据目录」卡片,逐项列出改前、改后和理由,保存带版本记为人工确认,遇到 409 可重新载入后再保存,也可忽略
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type {
|
||||
ActivateSnapshotBody, ActivateSnapshotOut, AiPreview, CatalogDetail, CatalogDraftOut, CatalogList, CatalogNotes,
|
||||
CatalogReviewAction, CommitOut, CurrentRecipe, EditPreview, EditRequest, ImportRecord,
|
||||
CatalogPatchPreview, CatalogPatchSubmit, CatalogReviewAction, CommitOut, CurrentRecipe, EditPreview, EditRequest, ImportRecord,
|
||||
ManifestOut, PurgeRawBody, PurgeRawOut, QuestionAnswer, Recipe, RedraftRulesOut, RemovePeriodBody, RemovePeriodOut,
|
||||
RevokeAcceptanceBody, RevokeAcceptanceOut, SnapshotOut, Staging,
|
||||
} from '../types'
|
||||
@@ -669,6 +669,14 @@ export const api = {
|
||||
/** 单项审阅。path:表级项写字段名(grain);列级项 columns.<列名>.<字段>;关系 relations.<编号> */
|
||||
review: (sourceId: string, table: string, body: { path: string; action: CatalogReviewAction; if_version: number }) =>
|
||||
post<CatalogDetail>(`/datasources/${encodeURIComponent(sourceId)}/catalog/${encodeURIComponent(table)}/review`, body),
|
||||
/** 目录修改提案的预览:对着当前目录重算改前、改后,不写库(保存遇到 409 后重新载入用) */
|
||||
patchPreview: (sourceId: string, table: string, changes: CatalogPatchSubmit[]) =>
|
||||
post<CatalogPatchPreview>(
|
||||
`/datasources/${encodeURIComponent(sourceId)}/catalog/${encodeURIComponent(table)}/patch/preview`, { changes }),
|
||||
/** 保存目录修改提案:改动过的项记为人工填写、已确认。版本不符 409,有一项不合法 422 */
|
||||
patch: (sourceId: string, table: string, changes: CatalogPatchSubmit[], ifVersion: number) =>
|
||||
post<CatalogDetail>(`/datasources/${encodeURIComponent(sourceId)}/catalog/${encodeURIComponent(table)}/patch`,
|
||||
{ changes, if_version: ifVersion }),
|
||||
/**
|
||||
* 同步起草(注释、外键、命名推断;use_model 时再请助手的模型起草)。不给 tables 时按使用次数取前 20 张。
|
||||
* 人工确认、驳回过的项不动。没有表结构时回 409
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AssistantStream, StreamEmpty, type StreamTurn } from '../run/AssistantS
|
||||
import { Markdown } from '../run/Markdown'
|
||||
import { ApprovalCard } from '../run/RunPanel'
|
||||
import { decodeCopilot, decodeRun } from '../run/decode'
|
||||
import { catalogPatchesOf } from '../run/catalogPatch'
|
||||
import { ToastHost } from '../components/ui'
|
||||
import type { RunEvent } from '../types'
|
||||
import fixtures from '../run/__tests__/fixtures.json'
|
||||
@@ -13,7 +14,7 @@ import mdSamples from '../run/__tests__/markdown-samples.json'
|
||||
import evidenceFixture from '../run/__tests__/evidence-doc.json'
|
||||
import evidenceJudge from '../run/__tests__/evidence-judge.json'
|
||||
import {
|
||||
CALIBER_ONE_OUTPUT, CALIBERS_OUTPUT, COPILOT_CONTEXT, COPILOT_CONTEXT_FALLBACK, COPILOT_STUCK, COPILOT_TOOLS_DROPPED, MIXED_OUTPUT, abandonedRun, cancelledRun, exhaustedTeam, longLoop,
|
||||
CALIBER_ONE_OUTPUT, CALIBERS_OUTPUT, COPILOT_CATALOG_PATCH, COPILOT_CONTEXT, COPILOT_CONTEXT_FALLBACK, COPILOT_STUCK, COPILOT_TOOLS_DROPPED, MIXED_OUTPUT, abandonedRun, cancelledRun, exhaustedTeam, longLoop,
|
||||
markupRun, mergeRun, mixedRun, pipelineRun, repairRun, teamRun, timeoutRun,
|
||||
} from '../run/__tests__/synthetic'
|
||||
import '../index.css'
|
||||
@@ -343,6 +344,12 @@ function synthetic(name: string): StreamTurn[] {
|
||||
// 大库按需求挑表:过程里一行「参考了 N 张表」,展开按数据源分组列表名
|
||||
return [{ id: 'copilot-context', question: '各渠道上月的入园人次', phase: 'done', status: '已应用到画布',
|
||||
steps: decodeCopilot(COPILOT_CONTEXT, { context: 'canvas' }) }]
|
||||
case 'catalog-patch':
|
||||
// 用户说了一条数据事实:助手只回答、不改图,附一张「建议更新数据目录」卡片
|
||||
return [{ id: 'catalog-patch', question: 'status=9 表示作废,统计时要排除', phase: 'done', status: '已回答(未修改画布)',
|
||||
steps: decodeCopilot(COPILOT_CATALOG_PATCH, { context: 'canvas' }),
|
||||
output: { 回答: '好的,已整理成数据目录的修改建议,确认后保存即可。' },
|
||||
catalogPatches: catalogPatchesOf(COPILOT_CATALOG_PATCH) }]
|
||||
case 'copilot-fallback':
|
||||
return [{ id: 'copilot-fallback', question: '各渠道上月的入园人次', phase: 'done', status: '已应用到画布',
|
||||
steps: decodeCopilot(COPILOT_CONTEXT_FALLBACK, { context: 'canvas' }) }]
|
||||
|
||||
@@ -2148,3 +2148,42 @@ export const CATALOG_TEXT = {
|
||||
reloadDiscardTitle: '放弃未保存的修改并重新载入?',
|
||||
reloadDiscardAction: '放弃修改并重新载入',
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 目录修改提案:用户在助手(画布)或问数据页说出一条数据事实时,助手提出的数据目录修改。
|
||||
//
|
||||
// 术语:
|
||||
// - 卡片标题写「建议更新数据目录」;两个动作是「保存到数据目录」和「忽略」。提案在保存之前不写入目录。
|
||||
// - 每一项写「改前 / 改后 / 理由」。保存后改动过的项记为人工填写、已确认(和直接编辑同一条规矩)。
|
||||
// - 别人在提案之后改过这张表:写「这张表刚被修改过」,给「重新载入」,按最新内容重算改前和改后再保存。
|
||||
// ===========================================================================
|
||||
|
||||
export const CATALOG_PATCH_TEXT = {
|
||||
title: '建议更新数据目录',
|
||||
/** 过程里的那一行 */
|
||||
step: (table: string, n: number) => `建议更新「${table}」的数据目录(${formatNumber(n)} 项)`,
|
||||
stepSub: '确认后才写入数据目录',
|
||||
before: '改前',
|
||||
after: '改后',
|
||||
reason: '理由',
|
||||
empty: '未填写',
|
||||
stateConfirm: '值不变,保存即确认',
|
||||
stateSame: '已是这个值',
|
||||
save: '保存到数据目录',
|
||||
saving: '正在保存…',
|
||||
ignore: '忽略',
|
||||
hint: '保存后改动的项记为人工填写、已确认,助手和 SQL 检查随即采用',
|
||||
saved: (version: number) => `已保存到数据目录(第 ${formatNumber(version)} 版)`,
|
||||
savedToast: (table: string) => `已更新「${table}」的数据目录`,
|
||||
ignored: '已忽略这条建议,数据目录未修改',
|
||||
undoIgnore: '重新查看',
|
||||
open: '在数据目录中查看',
|
||||
conflictTitle: '这张表刚被修改过',
|
||||
conflictBody: '其他人在这条建议之后修改了这张表的数据目录。重新载入后按最新内容重算改前和改后,确认后再保存。',
|
||||
reload: '重新载入',
|
||||
reloading: '正在重新载入…',
|
||||
allSame: '数据目录中已是这些值,无需保存',
|
||||
problems: '以下几项已无法按建议保存:',
|
||||
whereRelationNew: (to: string) => `新增指向 ${to} 的关联关系`,
|
||||
sourceOf: (source: string) => `数据源「${source}」`,
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ApprovalCard } from '../run/RunPanel'
|
||||
import { AssistantStream, type StreamTurn, type TurnFailure } from '../run/AssistantStream'
|
||||
import { PromptBox } from '../run/Composer'
|
||||
import { decodeCopilot, decodeRun, exitLabels } from '../run/decode'
|
||||
import { catalogPatchesOf } from '../run/catalogPatch'
|
||||
import { sourceHandles } from '../canvas/nodeDefs'
|
||||
import { isComposing, PageHeader, Skeleton, Spinner, StatusBadge, TechDetails, toast } from '../components/ui'
|
||||
import { humanizeError } from '../lib/errors'
|
||||
@@ -401,6 +402,8 @@ function toStreamTurn(turn: ChatTurn): StreamTurn {
|
||||
runClass: turn.run?.run_class,
|
||||
graph: turn.graph,
|
||||
graphNote: turn.explanation,
|
||||
// 助手提出的数据目录修改:每条一张卡片,人点保存才写入。不随「执行过程」收起——要人拿主意的事不能藏着
|
||||
catalogPatches: catalogPatchesOf(turn.ops),
|
||||
noQuery: turn.noQuery,
|
||||
review: turn.review,
|
||||
rawOutput: turn.rawOutput,
|
||||
|
||||
@@ -22,6 +22,7 @@ import { EvidenceHostContext, type EvidenceHost } from './evidenceHost'
|
||||
import {
|
||||
COPILOT_PHASE_TEXT, copilotOutcome, decodeCopilot, decodeRun, exitLabels, issueLine, SELF_CHECK_ROUNDS, type CopilotIssue, type CopilotOutcome,
|
||||
} from './decode'
|
||||
import { catalogPatchesOf } from './catalogPatch'
|
||||
import { ApprovalCard } from './RunPanel'
|
||||
import { useRunGlance } from './RunHud'
|
||||
import { isSettled, lastEventAt, liveAt, project, type RunPhase, type Trace } from './trace'
|
||||
@@ -204,6 +205,7 @@ function copilotTurn(
|
||||
statusCode,
|
||||
steps,
|
||||
output: reply ? { 回答: reply } : t.explanation ? { 说明: t.explanation } : null,
|
||||
catalogPatches: catalogPatchesOf(t.ops),
|
||||
error: errorOp
|
||||
? { error: errorOp.message, hint: errorOp.hint, detail: errorOp.raw }
|
||||
: t.error || undefined,
|
||||
|
||||
@@ -26,6 +26,8 @@ import { ClaimTally, EvidenceField, type EvidenceDocHandle } from './EvidenceDoc
|
||||
import { EVIDENCE_STATE, claimProblems, docTally, evidenceFields, issuanceMarks, type EvidenceTally } from '../lib/evidence'
|
||||
import type { EvidenceDocData } from '../types'
|
||||
import { useRunClock } from './useRunClock'
|
||||
import { CatalogPatchCard } from './CatalogPatchCard'
|
||||
import type { CatalogPatch } from './catalogPatch'
|
||||
import type { ReviewResult } from '../types'
|
||||
|
||||
/**
|
||||
@@ -98,6 +100,11 @@ export interface StreamTurn {
|
||||
noQuery?: boolean
|
||||
/** 复核结论:这次运行有什么不对,以及对这个答案意味着什么 */
|
||||
review?: ReviewResult | null
|
||||
/**
|
||||
* 助手这一轮提出的数据目录修改(catalog_patch)。每条一张「建议更新数据目录」卡片,人点保存才写入;
|
||||
* 从库里恢复的轮次没有操作流,也就没有卡片
|
||||
*/
|
||||
catalogPatches?: CatalogPatch[]
|
||||
/** 被复核重写之前的答案。改写是有损的,得能对照原件 */
|
||||
rawOutput?: Record<string, any> | null
|
||||
/**
|
||||
@@ -532,6 +539,10 @@ function TurnCard({ turn, last, approvals, onOpenGraph, onFollowUp }: {
|
||||
</details>
|
||||
)}
|
||||
|
||||
{turn.catalogPatches?.map((p) => (
|
||||
<CatalogPatchCard key={p.key} patch={p} turnId={turn.id} dense={dense} />
|
||||
))}
|
||||
|
||||
{!!turn.graph?.nodes?.length && (
|
||||
<GraphPeek graph={turn.graph} note={turn.graphNote}
|
||||
onOpen={onOpenGraph && ((g) => onOpenGraph(g, turn.question))} />
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { BookMarked, Check, ExternalLink, RotateCw } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { ApiError, api } from '../api/client'
|
||||
import { Notice, Spinner, toast } from '../components/ui'
|
||||
import { CATALOG_PATCH_TEXT as T, CATALOG_STATUS_LABEL } from '../lib/terms'
|
||||
import type { CatalogPatchChange } from '../types'
|
||||
import {
|
||||
catalogTableHref, patchSubmit, patchTarget, patchValueText, patchWhere, type CatalogPatch,
|
||||
} from './catalogPatch'
|
||||
|
||||
// ===========================================================================
|
||||
// 「建议更新数据目录」卡片:助手把用户说的一条数据事实整理成目录修改提案(catalog_patch),这里逐项列出改前、
|
||||
// 改后和理由,人点「保存到数据目录」才写入——服务端从不自动写。
|
||||
//
|
||||
// 保存带着提案对照的版本:别人在这之后改过这张表就 409,卡片写明「这张表刚被修改过」,「重新载入」按最新的
|
||||
// 目录重算改前和改后(预览接口,只读),人看过再保存。保存成功后给到数据目录那张表的入口。
|
||||
//
|
||||
// 卡片的结局(已保存、已忽略、重新载入后的内容)记在模块级的表里:画布右栏在对话层和运行层之间切换会卸载
|
||||
// 卡片,回来时不能又变回「未保存」,再点一次只会撞上 409。
|
||||
// ===========================================================================
|
||||
|
||||
type Phase = 'open' | 'saving' | 'conflict' | 'reloading' | 'saved' | 'ignored'
|
||||
|
||||
interface CardState {
|
||||
phase: Phase
|
||||
changes: CatalogPatchChange[]
|
||||
version: number
|
||||
/** 重新载入后已无法按建议保存的项(列已删除……) */
|
||||
problems: string[]
|
||||
/** 保存被拒(422)时服务端的原话 */
|
||||
error: string | null
|
||||
savedVersion?: number
|
||||
}
|
||||
|
||||
const remembered = new Map<string, CardState>()
|
||||
|
||||
export function CatalogPatchCard({ patch, turnId, dense = false }: {
|
||||
patch: CatalogPatch
|
||||
/** 所在轮次:和 patch.key 一起认出「同一张卡片」 */
|
||||
turnId: string
|
||||
dense?: boolean
|
||||
}) {
|
||||
const memo = `${turnId}:${patch.key}`
|
||||
const [state, setStateRaw] = useState<CardState>(() => remembered.get(memo)
|
||||
?? { phase: 'open', changes: patch.changes, version: patch.version, problems: [], error: null })
|
||||
const setState = useCallback((next: Partial<CardState>) => {
|
||||
setStateRaw((cur) => {
|
||||
const merged = { ...cur, ...next }
|
||||
remembered.set(memo, merged)
|
||||
return merged
|
||||
})
|
||||
}, [memo])
|
||||
|
||||
const label = patch.tableLabel ?? patch.table
|
||||
const pending = state.changes.filter((c) => c.state !== 'same')
|
||||
const href = catalogTableHref(patch.sourceId, patch.table)
|
||||
|
||||
const save = async () => {
|
||||
if (!pending.length) return
|
||||
setState({ phase: 'saving', error: null })
|
||||
try {
|
||||
const d = await api.dataCatalog.patch(patch.sourceId, patch.table, patchSubmit(state.changes), state.version)
|
||||
setState({ phase: 'saved', savedVersion: d.version })
|
||||
toast.ok(T.savedToast(label))
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 409) setState({ phase: 'conflict' })
|
||||
else if (e instanceof ApiError && e.kind === 'http' && (e.status === 422 || e.status === 404)) {
|
||||
setState({ phase: 'open', error: e.message })
|
||||
} else {
|
||||
setState({ phase: 'open' })
|
||||
toast.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reload = async () => {
|
||||
setState({ phase: 'reloading', error: null })
|
||||
try {
|
||||
// 交回的是助手原来的提案:码值的补充要落在最新的目录上重算,不能沿用上一次算好的「改后」
|
||||
const p = await api.dataCatalog.patchPreview(patch.sourceId, patch.table, patchSubmit(patch.changes))
|
||||
setState({ phase: 'open', changes: p.changes, version: p.version, problems: p.problems })
|
||||
} catch (e) {
|
||||
setState({ phase: 'conflict' })
|
||||
toast.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.phase === 'ignored') {
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 rounded-lg border border-dashed px-2.5 py-1.5 text-2xs text-faint"
|
||||
data-catalog-patch={patch.table} data-patch-phase="ignored">
|
||||
<BookMarked size={11} aria-hidden />
|
||||
<span className="min-w-0 flex-1">{T.ignored}</span>
|
||||
<button type="button" className="rounded px-1 text-dim hover:bg-hover hover:text-fg" onClick={() => setState({ phase: 'open' })}
|
||||
data-patch-unignore="">
|
||||
{T.undoIgnore}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const busy = state.phase === 'saving' || state.phase === 'reloading'
|
||||
const saved = state.phase === 'saved'
|
||||
const settled = saved || (!pending.length && state.phase === 'open')
|
||||
|
||||
return (
|
||||
<section className="mt-2 rounded-lg border bg-bg" aria-label={T.title}
|
||||
data-catalog-patch={patch.table} data-patch-phase={state.phase} data-patch-version={state.version}>
|
||||
<header className={clsx('flex flex-wrap items-baseline gap-x-2 gap-y-0.5 border-b', dense ? 'px-2.5 py-1.5' : 'px-3 py-2')}>
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-semibold">
|
||||
<BookMarked size={12} className="shrink-0" style={{ color: 'var(--accent)' }} aria-hidden />
|
||||
{T.title}
|
||||
</span>
|
||||
<span className="min-w-0 text-2xs text-dim">
|
||||
「{label}」{patch.tableLabel && <span className="mono text-faint"> {patch.table}</span>}
|
||||
<span className="text-faint"> · {T.sourceOf(patch.source)}</span>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<ol className={clsx('divide-y divide-[var(--hairline)]', dense ? 'px-2.5' : 'px-3')}>
|
||||
{state.changes.map((c) => <ChangeRow key={c.path} change={c} dense={dense} />)}
|
||||
</ol>
|
||||
|
||||
<div className={clsx('space-y-2 border-t', dense ? 'px-2.5 py-2' : 'px-3 py-2.5')}>
|
||||
{state.problems.length > 0 && (
|
||||
<Notice tone="warn" attr={{ 'data-patch-problems': String(state.problems.length) }}>
|
||||
<p>{T.problems}</p>
|
||||
<ul className="mt-0.5 list-disc pl-4 text-dim">{state.problems.map((p) => <li key={p}>{p}</li>)}</ul>
|
||||
</Notice>
|
||||
)}
|
||||
{state.error && <Notice tone="err" attr={{ 'data-patch-error': '' }}>{state.error}</Notice>}
|
||||
{state.phase === 'conflict' || state.phase === 'reloading'
|
||||
? (
|
||||
<Notice tone="warn" attr={{ 'data-patch-conflict': '' }}>
|
||||
<p className="font-medium">{T.conflictTitle}</p>
|
||||
<p className="mt-0.5 text-dim">{T.conflictBody}</p>
|
||||
<button type="button" className="btn btn-sm mt-2" disabled={busy} onClick={() => void reload()} data-patch-reload="">
|
||||
{state.phase === 'reloading' ? <><Spinner size={11} /> {T.reloading}</> : <><RotateCw size={11} aria-hidden /> {T.reload}</>}
|
||||
</button>
|
||||
</Notice>
|
||||
)
|
||||
: settled
|
||||
? (
|
||||
<div className="space-y-2">
|
||||
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs" data-patch-done="">
|
||||
<Check size={12} style={{ color: 'var(--ok)' }} aria-hidden />
|
||||
<span>{saved && state.savedVersion != null ? T.saved(state.savedVersion) : T.allSame}</span>
|
||||
<Link to={href} className="inline-flex items-center gap-1 text-2xs text-[var(--accent)] hover:underline" data-patch-open="">
|
||||
{T.open} <ExternalLink size={10} aria-hidden />
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button type="button" className="btn btn-sm btn-primary" disabled={busy} onClick={() => void save()} data-patch-save="">
|
||||
{state.phase === 'saving' ? <><Spinner size={11} /> {T.saving}</> : T.save}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => setState({ phase: 'ignored' })} data-patch-ignore="">
|
||||
{T.ignore}
|
||||
</button>
|
||||
<span className="min-w-0 flex-1 text-2xs text-faint">{T.hint}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** 一项:改到哪儿、改前、改后、理由 */
|
||||
function ChangeRow({ change: c, dense }: { change: CatalogPatchChange; dense: boolean }) {
|
||||
const isCodes = patchTarget(c.path)?.kind === 'column' && c.path.endsWith('.codes')
|
||||
return (
|
||||
<li className={clsx('text-xs', dense ? 'py-1.5' : 'py-2')} data-patch-change={c.path} data-patch-state={c.state}>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="font-medium">{patchWhere(c)}</span>
|
||||
{c.state === 'confirm' && <span className="text-2xs text-faint">{T.stateConfirm}</span>}
|
||||
{c.state === 'same' && <span className="text-2xs text-faint">{T.stateSame}</span>}
|
||||
</div>
|
||||
<dl className="mt-1 grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-0.5 text-2xs leading-relaxed">
|
||||
<dt className="text-faint">{T.before}</dt>
|
||||
<dd className={clsx('break-words', c.before == null ? 'text-faint' : 'text-dim')} data-patch-before="">
|
||||
{patchValueText(c.path, c.before)}
|
||||
{c.before != null && c.before_status && <span className="text-faint">({CATALOG_STATUS_LABEL[c.before_status]})</span>}
|
||||
</dd>
|
||||
<dt className="text-faint">{T.after}</dt>
|
||||
<dd className="break-words text-fg" data-patch-after="">
|
||||
{isCodes ? <CodesAfter before={c.before} after={c.after} /> : patchValueText(c.path, c.after)}
|
||||
</dd>
|
||||
{c.reason && (
|
||||
<>
|
||||
<dt className="text-faint">{T.reason}</dt>
|
||||
<dd className="break-words text-dim" data-patch-reason="">{c.reason}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/** 码值的改后:新加和改过的码加重,原有的照常写——一眼看出这条提案补了什么 */
|
||||
function CodesAfter({ before, after }: { before: unknown; after: unknown }) {
|
||||
const prev = (before && typeof before === 'object' ? before : {}) as Record<string, string>
|
||||
const entries = Object.entries((after && typeof after === 'object' ? after : {}) as Record<string, string>)
|
||||
return (
|
||||
<span>
|
||||
{entries.map(([k, v], i) => (
|
||||
<span key={k}>
|
||||
{i > 0 && '、'}
|
||||
<span className={clsx(prev[k] !== v && 'font-medium text-[var(--accent)]')} data-code-changed={prev[k] !== v ? k : undefined}>
|
||||
{k}={v}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -455,6 +455,24 @@ export const COPILOT_CONTEXT = [
|
||||
{ op: 'final', graph: { nodes: [{ id: 'q' }, { id: 'o' }] } },
|
||||
]
|
||||
|
||||
/**
|
||||
* 用户在助手里说了一条数据事实(「status=9 表示作废,统计时要排除」):服务端核对后转出目录修改提案
|
||||
* (catalog_patch,带版本、改前、改后),只回答、不改图。服务端没写库,要人在卡片上点保存
|
||||
*/
|
||||
export const COPILOT_CATALOG_PATCH = [
|
||||
{ op: 'model', model: 'claude-sonnet-4' },
|
||||
{ op: 'catalog_patch', source: 'scenic', source_id: 'src-scenic', table: 'visits', table_label: '入园记录', version: 3,
|
||||
changes: [
|
||||
{ path: 'columns.status.codes', before: { 1: '有效', 0: '作废' }, before_status: 'confirmed',
|
||||
after: { 1: '有效', 0: '作废', 9: '作废' }, value: { 9: '作废' }, reason: '用户说明 status=9 表示作废', state: 'change' },
|
||||
{ path: 'valid_filter', before: 'status = 1', before_status: 'proposed', after: 'status = 1 AND status <> 9',
|
||||
value: 'status = 1 AND status <> 9', reason: '统计时要排除作废记录', state: 'change' },
|
||||
{ path: 'columns.visitor_count.measure', before: 'flow', before_status: 'proposed', after: 'flow', value: 'flow',
|
||||
reason: '', state: 'confirm' },
|
||||
] },
|
||||
{ op: 'reply', text: '好的,已整理成数据目录的修改建议,确认后保存即可。' },
|
||||
]
|
||||
|
||||
/** 挑表失败(超时):只给了表名,原因随 context 带来;生成照常进行 */
|
||||
export const COPILOT_CONTEXT_FALLBACK = [
|
||||
{ op: 'model', model: 'claude-sonnet-4' },
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { CatalogBusinessDate, CatalogCardinality, CatalogMeasure, CatalogPatchChange, CatalogTableKind } from '../types'
|
||||
import {
|
||||
CATALOG_CARDINALITY_LABEL, CATALOG_COLUMN_FIELD_LABEL, CATALOG_KIND_LABEL, CATALOG_MEASURE_LABEL, CATALOG_PATCH_TEXT,
|
||||
CATALOG_TABLE_FIELD_LABEL, CATALOG_TEXT,
|
||||
} from '../lib/terms'
|
||||
|
||||
// ===========================================================================
|
||||
// 目录修改提案(助手流里的 catalog_patch 操作):解析、每一项的中文说法、值怎么写成一行字。
|
||||
//
|
||||
// 服务端(api/copilot.py 的 catalog_patch_op)已经核对过数据源、表、路径和取值格式,附上了版本、改前和改后,
|
||||
// 这里只认形状:认不出的项跳过,一项都不剩就整条不认(不出卡片,也不报错)。路径的写法和审阅接口一致:表级项
|
||||
// 写字段名,列级项 columns.<列名>.<字段>(列名里可以有句点,按最后一个句点切),关系 relations.<编号>。
|
||||
// ===========================================================================
|
||||
|
||||
export interface CatalogPatch {
|
||||
/** 在这一轮操作里的位置:卡片的键、保存状态都按「轮次 + 它」记 */
|
||||
key: string
|
||||
source: string
|
||||
sourceId: string
|
||||
table: string
|
||||
/** 表的中文名(目录里有、没被驳回时) */
|
||||
tableLabel?: string
|
||||
/** 提案对照的目录版本:保存时带上,别人在这之后改过就 409 */
|
||||
version: number
|
||||
changes: CatalogPatchChange[]
|
||||
}
|
||||
|
||||
const TABLE_FIELDS = Object.keys(CATALOG_TABLE_FIELD_LABEL) as (keyof typeof CATALOG_TABLE_FIELD_LABEL)[]
|
||||
const COLUMN_FIELDS = Object.keys(CATALOG_COLUMN_FIELD_LABEL) as (keyof typeof CATALOG_COLUMN_FIELD_LABEL)[]
|
||||
const STATES = new Set(['change', 'confirm', 'same'])
|
||||
|
||||
export type PatchTarget =
|
||||
| { kind: 'table'; field: keyof typeof CATALOG_TABLE_FIELD_LABEL }
|
||||
| { kind: 'column'; column: string; field: keyof typeof CATALOG_COLUMN_FIELD_LABEL }
|
||||
| { kind: 'relation'; id: string }
|
||||
|
||||
/** 路径 → 指的是哪一项。认不出返回 null */
|
||||
export function patchTarget(path: string): PatchTarget | null {
|
||||
if ((TABLE_FIELDS as string[]).includes(path)) return { kind: 'table', field: path as keyof typeof CATALOG_TABLE_FIELD_LABEL }
|
||||
if (path.startsWith('columns.')) {
|
||||
const rest = path.slice('columns.'.length)
|
||||
const at = rest.lastIndexOf('.')
|
||||
const field = rest.slice(at + 1)
|
||||
if (at > 0 && (COLUMN_FIELDS as string[]).includes(field)) {
|
||||
return { kind: 'column', column: rest.slice(0, at), field: field as keyof typeof CATALOG_COLUMN_FIELD_LABEL }
|
||||
}
|
||||
}
|
||||
if (path.startsWith('relations.') && path.length > 'relations.'.length) return { kind: 'relation', id: path.slice('relations.'.length) }
|
||||
return null
|
||||
}
|
||||
|
||||
function changeOf(x: unknown): CatalogPatchChange | null {
|
||||
if (!x || typeof x !== 'object') return null
|
||||
const c = x as Record<string, unknown>
|
||||
if (typeof c.path !== 'string' || !patchTarget(c.path) || !('after' in c)) return null
|
||||
return {
|
||||
path: c.path,
|
||||
before: c.before ?? null,
|
||||
before_status: typeof c.before_status === 'string' ? c.before_status as CatalogPatchChange['before_status'] : null,
|
||||
after: c.after,
|
||||
value: 'value' in c ? c.value : c.after,
|
||||
reason: typeof c.reason === 'string' ? c.reason : '',
|
||||
state: typeof c.state === 'string' && STATES.has(c.state) ? c.state as CatalogPatchChange['state'] : 'change',
|
||||
}
|
||||
}
|
||||
|
||||
/** catalog_patch 操作 → 提案。认不出的形状返回 null */
|
||||
export function catalogPatchOf(op: Record<string, any>, key = ''): CatalogPatch | null {
|
||||
if (typeof op.source !== 'string' || typeof op.source_id !== 'string' || typeof op.table !== 'string') return null
|
||||
const changes = (Array.isArray(op.changes) ? op.changes : []).map(changeOf).filter((c): c is CatalogPatchChange => !!c)
|
||||
if (!changes.length) return null
|
||||
const version = typeof op.version === 'number' && Number.isFinite(op.version) ? op.version : 0
|
||||
return {
|
||||
key, source: op.source, sourceId: op.source_id, table: op.table, version, changes,
|
||||
...(typeof op.table_label === 'string' && op.table_label ? { tableLabel: op.table_label } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** 一轮操作里的全部提案,按出现的先后 */
|
||||
export function catalogPatchesOf(ops: Record<string, any>[]): CatalogPatch[] {
|
||||
const out: CatalogPatch[] = []
|
||||
ops.forEach((op, i) => {
|
||||
if (op?.op !== 'catalog_patch') return
|
||||
const p = catalogPatchOf(op, String(i))
|
||||
if (p) out.push(p)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/** 这一项的中文说法:「列 status 的码值」「表的有效记录条件」「指向 visits 的关联关系」 */
|
||||
export function patchWhere(change: Pick<CatalogPatchChange, 'path' | 'after' | 'before'>): string {
|
||||
const t = patchTarget(change.path)
|
||||
if (!t) return change.path
|
||||
if (t.kind === 'table') return CATALOG_TEXT.whereTable(CATALOG_TABLE_FIELD_LABEL[t.field])
|
||||
if (t.kind === 'column') return CATALOG_TEXT.whereColumn(t.column, CATALOG_COLUMN_FIELD_LABEL[t.field])
|
||||
const rel = (change.after ?? change.before) as { to_table?: string } | null
|
||||
const to = rel?.to_table ?? ''
|
||||
return change.before == null ? CATALOG_PATCH_TEXT.whereRelationNew(to) : CATALOG_TEXT.whereRelation(to)
|
||||
}
|
||||
|
||||
/** 关联关系写成一行:visit_id → visits.id(多对一) */
|
||||
export function relationText(v: unknown): string {
|
||||
const r = v as { columns?: string[]; to_table?: string; to_columns?: string[]; cardinality?: CatalogCardinality | null }
|
||||
if (!r || typeof r !== 'object') return String(v)
|
||||
const left = (r.columns ?? []).join('、')
|
||||
const right = (r.to_columns ?? []).map((c) => `${r.to_table}.${c}`).join('、')
|
||||
const card = r.cardinality ? CATALOG_CARDINALITY_LABEL[r.cardinality] : ''
|
||||
return `${left} → ${right}${card ? `(${card})` : ''}`
|
||||
}
|
||||
|
||||
/** 码值写成一行:1=有效、9=作废 */
|
||||
export function codesText(v: unknown): string {
|
||||
if (!v || typeof v !== 'object') return String(v)
|
||||
return Object.entries(v as Record<string, string>).map(([k, x]) => `${k}=${x}`).join('、')
|
||||
}
|
||||
|
||||
/** 一项的值写成一行字(过程里的明细、卡片里的改前改后都用它)。没有值写「未填写」 */
|
||||
export function patchValueText(path: string, v: unknown): string {
|
||||
if (v == null || v === '') return CATALOG_PATCH_TEXT.empty
|
||||
const t = patchTarget(path)
|
||||
if (t?.kind === 'relation') return relationText(v)
|
||||
const field = t?.field
|
||||
if (field === 'kind') return CATALOG_KIND_LABEL[v as CatalogTableKind] ?? String(v)
|
||||
if (field === 'measure') return CATALOG_MEASURE_LABEL[v as CatalogMeasure] ?? String(v)
|
||||
if (field === 'keys' && Array.isArray(v)) return v.join('、')
|
||||
if (field === 'codes') return codesText(v)
|
||||
if (field === 'business_date' && typeof v === 'object') {
|
||||
const d = v as CatalogBusinessDate
|
||||
return [d.column, d.rule ? `${CATALOG_TEXT.dateRule}:${d.rule}` : '', d.timezone ? `${CATALOG_TEXT.dateTimezone}:${d.timezone}` : '']
|
||||
.filter(Boolean).join(';')
|
||||
}
|
||||
return typeof v === 'string' ? v : JSON.stringify(v)
|
||||
}
|
||||
|
||||
/** 交回保存、预览的那一份:路径、原样取值、理由 */
|
||||
export function patchSubmit(changes: CatalogPatchChange[]) {
|
||||
return changes.map((c) => ({ path: c.path, value: c.value, ...(c.reason ? { reason: c.reason } : {}) }))
|
||||
}
|
||||
|
||||
/** 数据目录页上这张表的地址 */
|
||||
export function catalogTableHref(sourceId: string, table: string): string {
|
||||
return `/data/catalog/${encodeURIComponent(sourceId)}/${encodeURIComponent(table)}`
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { RunEvent, TeamMember, TeamRound, TeamRun, ToolChange } from '../types'
|
||||
import type { NodeState, RunPhase } from './trace'
|
||||
import { formatDuration, formatNumber } from '../lib/format'
|
||||
import { JUDGE_TEXT, MERGE_TEXT, TYPE_LABEL, claimTally, evidenceTally, issuanceLabel, nodeTypeLabel } from '../lib/terms'
|
||||
import {
|
||||
CATALOG_PATCH_TEXT, JUDGE_TEXT, MERGE_TEXT, TYPE_LABEL, claimTally, evidenceTally, issuanceLabel, nodeTypeLabel,
|
||||
} from '../lib/terms'
|
||||
import { catalogPatchOf, patchValueText, patchWhere } from './catalogPatch'
|
||||
import { claimCountsOf, isJudged, statsTally } from '../lib/evidence'
|
||||
|
||||
// 泳道数据画布也要用(supervisor 节点要展开成协作矩阵),所以类型放在
|
||||
@@ -2678,6 +2681,17 @@ export function decodeCopilot(ops: CopilotOp[], opts?: {
|
||||
...(ctx.ms != null ? { ms: ctx.ms, meta: formatDuration(ctx.ms) } : {}) })
|
||||
break
|
||||
}
|
||||
case 'catalog_patch': {
|
||||
// 目录修改提案:过程里记一行(哪张表、几项,展开看改前改后),保存、忽略在轮次里的卡片上做(catalogPatchesOf)
|
||||
const patch = catalogPatchOf(op, String(i - 1))
|
||||
if (!patch) break
|
||||
add({ id: `cpt-${i}`, seq: i, kind: 'schema', status: 'done', code: 'catalog_patch',
|
||||
title: CATALOG_PATCH_TEXT.step(patch.tableLabel ?? patch.table, patch.changes.length),
|
||||
sub: CATALOG_PATCH_TEXT.stepSub,
|
||||
detail: patch.changes.map((c) => `${patchWhere(c)}:${patchValueText(c.path, c.before)} → ${patchValueText(c.path, c.after)}`)
|
||||
.join('\n') })
|
||||
break
|
||||
}
|
||||
case 'plan':
|
||||
add({ id: `cp-${i}`, seq: i, kind: 'note', status: 'done',
|
||||
title: clip(String(op.summary ?? '已完成规划'), 120) })
|
||||
|
||||
@@ -10,9 +10,10 @@ import { NODE_DEFS, sourceHandles } from '../canvas/nodeDefs'
|
||||
import { toast } from '../components/ui'
|
||||
import { humanizeError } from '../lib/errors'
|
||||
import { formatShortcut } from '../lib/keys'
|
||||
import { UPGRADE_TEXT } from '../lib/terms'
|
||||
import { CATALOG_PATCH_TEXT, UPGRADE_TEXT } from '../lib/terms'
|
||||
import type { CopilotOp } from '../run/decode'
|
||||
import { copilotContext, endingOf, reduceTeam, settleTeam } from '../run/decode'
|
||||
import { catalogPatchOf } from '../run/catalogPatch'
|
||||
import { activeEdgesOf, applyDerived } from '../run/derive'
|
||||
import {
|
||||
emptyTrace, finalizeTrace, foldEvent, isActivePhase, isSettled, isTerminal, runStatusOf,
|
||||
@@ -1894,6 +1895,13 @@ export const useStudio = create<StudioState>((set, get) => ({
|
||||
if (ctx) set({ copilot: { ...s.copilot, lastOp: ctx.title } })
|
||||
break
|
||||
}
|
||||
case 'catalog_patch': {
|
||||
// 助手把用户说的数据事实整理成了目录修改提案:浮条上先说一句,卡片在这一轮的对话里(catalogPatchesOf),
|
||||
// 人点「保存到数据目录」才写入。和画布无关,不动节点、不占撤销栈
|
||||
const patch = catalogPatchOf(op)
|
||||
if (patch) set({ copilot: { ...s.copilot, lastOp: CATALOG_PATCH_TEXT.step(patch.tableLabel ?? patch.table, patch.changes.length) } })
|
||||
break
|
||||
}
|
||||
case 'plan':
|
||||
set({ copilot: { ...s.copilot, phase: 'planning', lastOp: op.summary ?? '规划中' } })
|
||||
break
|
||||
|
||||
@@ -2705,3 +2705,37 @@ export interface CatalogDraftOut {
|
||||
/** 模型整体用不了(没有配置、已停用……):只按注释、外键和命名起草 */
|
||||
model_error: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 目录修改提案的一项(服务端 catalog.PatchChange)。助手转出的提案和预览接口都是这个形状。
|
||||
* path 是审阅接口的写法;新增关联关系已规范成 relations.<编号>
|
||||
*/
|
||||
export interface CatalogPatchChange {
|
||||
path: string
|
||||
/** 当前目录里的值;没有(或被驳回)为 null */
|
||||
before: unknown
|
||||
before_status: CatalogStatus | null
|
||||
/** 保存后的值(码值是补充后的完整对照) */
|
||||
after: unknown
|
||||
/** 交回保存的原样取值(码值只有补充的那几个) */
|
||||
value: unknown
|
||||
reason: string
|
||||
/** change 值有变化;confirm 值相同、还不是已确认(保存即确认);same 已经是这个值且已确认 */
|
||||
state: 'change' | 'confirm' | 'same'
|
||||
}
|
||||
|
||||
/** 保存、预览提案时交回的一项:路径、原样取值、理由 */
|
||||
export interface CatalogPatchSubmit {
|
||||
path: string
|
||||
value: unknown
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/** POST /datasources/{id}/catalog/{table}/patch/preview:对着当前目录重算改前、改后(只读) */
|
||||
export interface CatalogPatchPreview {
|
||||
table_name: string
|
||||
version: number
|
||||
changes: CatalogPatchChange[]
|
||||
/** 不合法的项的说明(目录刚被改过、列已删除……) */
|
||||
problems: string[]
|
||||
}
|
||||
|
||||
+64
-1
@@ -371,12 +371,14 @@ const TABLES = {
|
||||
|
||||
const log = {
|
||||
patches: [], cancels: [], continues: [], runStarts: [], archived: [], posts: [], approvalGets: [], runGets: [],
|
||||
deleted: [], gens: [], sourceGets: 0,
|
||||
deleted: [], gens: [], sourceGets: 0, catalogPatches: [],
|
||||
}
|
||||
const ctl = {
|
||||
slowMs: 0, failE: true, wsClosed: {}, approvals: [], thinkGo: false, writingGo: false, writingAt: 0,
|
||||
/** 列表只给前 8 个:筛选框刚好出现,删一个就不够 8 个了 */
|
||||
few: false, run500: true, reviewDelay: {}, launchFail: true, scope400: false,
|
||||
/** 目录修改提案第一次保存回 409(别人刚改过这张表) */
|
||||
patchConflict: false,
|
||||
/** 回收站里多摆两个(TRASH_MORE) */
|
||||
trashMore: false,
|
||||
/** 列表里多摆「护栏」那段的三个会话(GUARD_CONVS) */
|
||||
@@ -470,6 +472,18 @@ async function fakeApi(route) {
|
||||
return route.fulfill({ status: 200, contentType: 'text/event-stream',
|
||||
body: ops.map((o) => `data: ${JSON.stringify(o)}\n\n`).join('') })
|
||||
}
|
||||
if (String(b.instruction ?? '').includes('表示作废')) {
|
||||
// 用户说了一条数据事实:服务端核对后转出目录修改提案,只回答、不建图
|
||||
const ops = [
|
||||
{ op: 'heartbeat', phase: 'planning', elapsed_ms: 300 },
|
||||
{ op: 'catalog_patch', source: 'shop', source_id: 'src-shop', table: 'orders', table_label: '订单', version: 2, changes: [
|
||||
{ path: 'columns.status.codes', before: { 1: '已支付' }, before_status: 'confirmed', after: { 1: '已支付', 9: '作废' },
|
||||
value: { 9: '作废' }, reason: '用户说明 status=9 表示作废', state: 'change' }] },
|
||||
{ op: 'reply', text: '已整理成数据目录的修改建议,确认后保存即可。' },
|
||||
]
|
||||
return route.fulfill({ status: 200, contentType: 'text/event-stream',
|
||||
body: ops.map((o) => `data: ${JSON.stringify(o)}\n\n`).join('') })
|
||||
}
|
||||
const ops = [
|
||||
{ op: 'heartbeat', phase: 'planning', elapsed_ms: 800 },
|
||||
// 服务端按需求挑了表(copilot_context):过程里多一行「参考了 N 张表」
|
||||
@@ -483,6 +497,24 @@ async function fakeApi(route) {
|
||||
return route.fulfill({ status: 200, contentType: 'text/event-stream',
|
||||
body: ops.map((o) => `data: ${JSON.stringify(o)}\n\n`).join('') })
|
||||
}
|
||||
if (path === '/datasources/src-shop/catalog/orders/patch' && method === 'POST') {
|
||||
log.catalogPatches.push(body())
|
||||
if (ctl.patchConflict) {
|
||||
ctl.patchConflict = false
|
||||
return json({ detail: '表「orders」的数据目录刚被修改过,请重新载入后再提交' }, 409)
|
||||
}
|
||||
return json({ table_name: 'orders', in_schema: true, notes: {}, version: 4, updated_at: null, updated_by: null,
|
||||
structure: null, system_notes: false, usage: 0 })
|
||||
}
|
||||
if (path === '/datasources/src-shop/catalog/orders/patch/preview' && method === 'POST') {
|
||||
log.catalogPatches.push({ preview: body() })
|
||||
return json({ table_name: 'orders', version: 3, problems: [], changes: [
|
||||
{ path: 'columns.status.codes', before: { 1: '已支付', 2: '已退款' }, before_status: 'confirmed',
|
||||
after: { 1: '已支付', 2: '已退款', 9: '作废' }, value: { 9: '作废' }, reason: '用户说明 status=9 表示作废', state: 'change' }] })
|
||||
}
|
||||
if (path === '/datasources/src-shop/catalog/orders/impact' && method === 'GET') {
|
||||
return json({ table: 'orders', templates: [] })
|
||||
}
|
||||
if (path === '/copilot/review' && method === 'POST') {
|
||||
const wait = ctl.reviewDelay[body().run_id]
|
||||
if (wait) await new Promise((r) => setTimeout(r, wait))
|
||||
@@ -1387,6 +1419,37 @@ for (const theme of THEMES) {
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await section('catalogpatch', '说了一条数据事实:「建议更新数据目录」卡片,保存撞上 409 能重新载入', async () => {
|
||||
const { page, ctx, errors } = await open(theme)
|
||||
await goto(page, 'c0emptg')
|
||||
ctl.patchConflict = true
|
||||
const before = log.catalogPatches.length
|
||||
await send(page, 'status=9 表示作废,统计时要排除')
|
||||
const card = page.locator('[data-catalog-patch="orders"]')
|
||||
check('回答下面有「建议更新数据目录」卡片', await card.waitFor({ timeout: 6000 }).then(() => true, () => false)
|
||||
&& (await card.innerText()).includes('建议更新数据目录'))
|
||||
check('逐项写改前、改后、理由', (await card.locator('[data-patch-before]').innerText()).includes('1=已支付')
|
||||
&& (await card.locator('[data-patch-after]').innerText()).includes('9=作废')
|
||||
&& (await card.locator('[data-patch-reason]').innerText()).includes('status=9'))
|
||||
const t = (await chatState(page)).byConversation.c0emptg.at(-1)
|
||||
check('提案原样记进这一轮的操作流,回答照常交付', t.ops.some((o) => o.op === 'catalog_patch') && t.phase === 'done' && t.noQuery === true)
|
||||
await card.locator('[data-patch-save]').click()
|
||||
await card.locator('[data-patch-conflict]').waitFor({ timeout: 3000 })
|
||||
check('409:提示这张表刚被修改过', (await card.innerText()).includes('这张表刚被修改过'))
|
||||
await card.locator('[data-patch-reload]').click()
|
||||
await card.locator('[data-patch-save]').waitFor({ timeout: 3000 })
|
||||
check('重新载入后改前按最新的目录写', (await card.locator('[data-patch-before]').innerText()).includes('2=已退款'))
|
||||
await card.locator('[data-patch-save]').click()
|
||||
await card.locator('[data-patch-done]').waitFor({ timeout: 3000 })
|
||||
const sent = log.catalogPatches.slice(before)
|
||||
check('先带提案的版本,重新载入后带新版本', sent[0]?.if_version === 2 && !!sent[1]?.preview && sent[2]?.if_version === 3,
|
||||
JSON.stringify(sent))
|
||||
check('保存成功写明第几版、给到数据目录的入口', (await card.locator('[data-patch-done]').innerText()).includes('第 4 版')
|
||||
&& (await card.locator('[data-patch-open]').getAttribute('href')) === '/data/catalog/src-shop/orders')
|
||||
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await section('approval', '停在审批上:审批卡立刻出现,不等 4 秒轮询', async () => {
|
||||
const { page, ctx, errors } = await open(theme)
|
||||
ctl.approvals = []
|
||||
|
||||
@@ -691,6 +691,35 @@ await section('Copilot:这一轮参考了哪些表(context 操作)', async
|
||||
check('认不出的操作照旧忽略', mod.decodeCopilot([{ op: 'something_new', x: 1 }]).length === 0)
|
||||
})
|
||||
|
||||
await section('Copilot:建议更新数据目录(catalog_patch 操作)', async () => {
|
||||
// 用户在助手里说了一条数据事实,服务端核对后转出目录修改提案。过程里记一行,保存、忽略在卡片上做
|
||||
const cp = await import(await load('/src/run/catalogPatch.ts'))
|
||||
const steps = mod.decodeCopilot(synthetic.COPILOT_CATALOG_PATCH, { context: 'canvas' })
|
||||
const row = steps.find((s) => s.code === 'catalog_patch')
|
||||
check('一行说清建议改哪张表、几项', row?.title === '建议更新「入园记录」的数据目录(3 项)', row?.title)
|
||||
check('副标题说明确认后才写入', row?.sub === '确认后才写入数据目录', row?.sub)
|
||||
check('展开逐项写改前 → 改后,用界面上的叫法', !!row?.detail?.includes('列 status 的码值:0=作废、1=有效 → 0=作废、1=有效、9=作废')
|
||||
&& row.detail.includes('表的有效记录条件:status = 1 → status = 1 AND status <> 9')
|
||||
&& row.detail.includes('列 visitor_count 的度量类型:可累加 → 可累加'), row?.detail)
|
||||
check('是看表结构那一类、标着规划阶段', row?.kind === 'schema' && row?.stage === 'plan' && row?.status === 'done')
|
||||
check('结局仍是「只回答」', mod.copilotOutcome(synthetic.COPILOT_CATALOG_PATCH).kind === 'reply')
|
||||
|
||||
const [patch] = cp.catalogPatchesOf(synthetic.COPILOT_CATALOG_PATCH)
|
||||
check('卡片拿到版本、数据源 id、表和中文名', patch?.version === 3 && patch.sourceId === 'src-scenic' && patch.table === 'visits'
|
||||
&& patch.tableLabel === '入园记录' && patch.key === '1', JSON.stringify(patch && { ...patch, changes: patch.changes.length }))
|
||||
check('三项都认、状态照抄', patch?.changes.map((c) => c.state).join(',') === 'change,change,confirm')
|
||||
check('交回保存的是原样取值', JSON.stringify(cp.patchSubmit(patch.changes)[0]) === '{"path":"columns.status.codes","value":{"9":"作废"},"reason":"用户说明 status=9 表示作废"}')
|
||||
check('新增关联关系的说法', cp.patchWhere({ path: 'relations.r1', before: null, after: { to_table: 'visits' } }) === '新增指向 visits 的关联关系')
|
||||
check('关联关系写成一行', cp.patchValueText('relations.r1', { columns: ['visit_id'], to_table: 'visits', to_columns: ['id'],
|
||||
cardinality: 'many_to_one' }) === 'visit_id → visits.id(多对一)')
|
||||
check('表类型、度量类型写界面上的叫法', cp.patchValueText('kind', 'fact') === '明细表' && cp.patchValueText('columns.a.measure', 'stock') === '存量')
|
||||
check('没有值写「未填写」', cp.patchValueText('grain', null) === '未填写')
|
||||
check('列名里有句点照样认', JSON.stringify(cp.patchTarget('columns.a.b.unit')) === '{"kind":"column","column":"a.b","field":"unit"}')
|
||||
check('形状不对不出卡片、不出行', cp.catalogPatchesOf([
|
||||
{ op: 'catalog_patch' }, { op: 'catalog_patch', source: 's', source_id: 'i', table: 't', changes: [{ path: 'nope', after: 1 }] },
|
||||
]).length === 0 && mod.decodeCopilot([{ op: 'catalog_patch', source: 's', table: 't' }]).length === 0)
|
||||
})
|
||||
|
||||
await section('Copilot:心跳穿插、回话、少了一步、报错', async () => {
|
||||
const steps = mod.decodeCopilot(synthetic.COPILOT_STUCK, { context: 'canvas' })
|
||||
check('心跳穿插的思考仍然并成一条', steps.filter((s) => s.kind === 'think').length === 1,
|
||||
|
||||
@@ -768,6 +768,85 @@ await section('参考了哪些表:画布右栏和问数据页(context 操作
|
||||
await q.page.close()
|
||||
})
|
||||
|
||||
await section('建议更新数据目录:卡片的保存、忽略和 409(catalog_patch 操作)', async () => {
|
||||
// 保存、预览都在浏览器层答掉:预览页没有真的数据源,检查也不许写库
|
||||
const detail = (version) => ({ table_name: 'visits', in_schema: true, notes: {}, version, updated_at: null, updated_by: '检查脚本',
|
||||
structure: null, system_notes: false, usage: 0 })
|
||||
const c = await open('syn=catalog-patch&dense=1', { w: 380, h: 900, name: 'catalog-patch' })
|
||||
const sent = []
|
||||
let conflictOnce = true
|
||||
await c.page.route('**/api/datasources/src-scenic/catalog/visits/patch**', async (route) => {
|
||||
const req = route.request()
|
||||
const body = req.postDataJSON()
|
||||
sent.push({ url: req.url(), body, actor: req.headers()['x-actor'] })
|
||||
if (req.url().endsWith('/patch/preview')) {
|
||||
// 重新载入:别人刚把码值补了一个 8,改前改后按最新的算
|
||||
return route.fulfill({ json: { table_name: 'visits', version: 4, problems: [], changes: [
|
||||
{ path: 'columns.status.codes', before: { 1: '有效', 0: '作废', 8: '退票' }, before_status: 'confirmed',
|
||||
after: { 1: '有效', 0: '作废', 8: '退票', 9: '作废' }, value: { 9: '作废' }, reason: '用户说明 status=9 表示作废', state: 'change' },
|
||||
{ path: 'valid_filter', before: 'status = 1', before_status: 'proposed', after: 'status = 1 AND status <> 9',
|
||||
value: 'status = 1 AND status <> 9', reason: '统计时要排除作废记录', state: 'change' },
|
||||
] } })
|
||||
}
|
||||
if (conflictOnce) {
|
||||
conflictOnce = false
|
||||
return route.fulfill({ status: 409, json: { detail: '表「visits」的数据目录刚被修改过,请重新载入后再提交' } })
|
||||
}
|
||||
return route.fulfill({ json: detail(5) })
|
||||
})
|
||||
await c.page.route('**/api/datasources/src-scenic/catalog/visits/impact', (route) =>
|
||||
route.fulfill({ json: { table: 'visits', templates: [] } }))
|
||||
const card = c.page.locator('[data-catalog-patch="visits"]')
|
||||
check('卡片在:标题写「建议更新数据目录」', await card.count() === 1 && (await card.innerText()).includes('建议更新数据目录'))
|
||||
const codes = card.locator('[data-patch-change="columns.status.codes"]')
|
||||
check('逐项写改到哪儿:「列 status 的码值」', (await codes.innerText()).includes('列 status 的码值'))
|
||||
check('改前、改后、理由都在', (await codes.locator('[data-patch-before]').innerText()).includes('0=作废、1=有效')
|
||||
&& (await codes.locator('[data-patch-after]').innerText()).includes('9=作废')
|
||||
&& (await codes.locator('[data-patch-reason]').innerText()).includes('status=9'))
|
||||
check('新补的码值加重显示', await codes.locator('[data-code-changed="9"]').count() === 1
|
||||
&& await codes.locator('[data-code-changed="1"]').count() === 0)
|
||||
check('值不变的项写明保存即确认', (await card.locator('[data-patch-state="confirm"]').innerText()).includes('值不变,保存即确认'))
|
||||
check('改前写状态(推断)', (await card.locator('[data-patch-change="valid_filter"] [data-patch-before]').innerText()).includes('(推断)'))
|
||||
check('过程里也记了一行', (await c.page.locator('[data-step-code="catalog_patch"]').innerText()).includes('建议更新「入园记录」的数据目录(3 项)'))
|
||||
const overflow = await c.page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||
check('卡片不撑破 380px 窄栏', overflow <= 0, `${overflow}px`)
|
||||
|
||||
// 第一次保存撞上 409:写明「这张表刚被修改过」,给「重新载入」。署名在设置页填,这里直接写进本机
|
||||
await c.page.evaluate(() => localStorage.setItem('agentlab_actor', '检查脚本'))
|
||||
await card.locator('[data-patch-save]').click()
|
||||
await card.locator('[data-patch-conflict]').waitFor({ timeout: 3000 })
|
||||
check('409:提示这张表刚被修改过', (await card.innerText()).includes('这张表刚被修改过'))
|
||||
check('保存带着提案对照的版本、原样取值和署名', sent[0]?.body?.if_version === 3
|
||||
&& JSON.stringify(sent[0]?.body?.changes?.[0]?.value) === '{"9":"作废"}' && !!sent[0]?.actor, JSON.stringify(sent[0]))
|
||||
await card.locator('[data-patch-reload]').click()
|
||||
await c.page.waitForFunction(() => document.querySelector('[data-catalog-patch="visits"]')?.getAttribute('data-patch-version') === '4',
|
||||
null, { timeout: 3000 })
|
||||
check('重新载入交回的是助手原来的提案', sent[1]?.url.endsWith('/patch/preview')
|
||||
&& JSON.stringify(sent[1]?.body?.changes?.[0]?.value) === '{"9":"作废"}', JSON.stringify(sent[1]))
|
||||
check('重新载入后改前按最新的目录写', (await codes.locator('[data-patch-before]').innerText()).includes('8=退票'))
|
||||
await card.locator('[data-patch-save]').click()
|
||||
await card.locator('[data-patch-done]').waitFor({ timeout: 3000 })
|
||||
check('再保存带新版本', sent[2]?.body?.if_version === 4, JSON.stringify(sent[2]?.body))
|
||||
check('保存成功:写明第几版,给到数据目录的入口', (await card.locator('[data-patch-done]').innerText()).includes('已保存到数据目录(第 5 版)')
|
||||
&& (await card.locator('[data-patch-open]').getAttribute('href')) === '/data/catalog/src-scenic/visits')
|
||||
check('保存后不再有保存按钮', await card.locator('[data-patch-save]').count() === 0)
|
||||
check('没有运行时报错', c.errors.length === 0, c.errors.join(' | '))
|
||||
await c.page.close()
|
||||
|
||||
// 忽略:收成一行,可以重新查看;不发任何请求
|
||||
const g = await open('syn=catalog-patch&dense=1', { w: 380, h: 900 })
|
||||
let writes = 0
|
||||
await g.page.route('**/api/datasources/**', (route) => { writes++; return route.abort() })
|
||||
const gc = g.page.locator('[data-catalog-patch="visits"]')
|
||||
await gc.locator('[data-patch-ignore]').click()
|
||||
check('忽略:收成一行说明', (await gc.getAttribute('data-patch-phase')) === 'ignored'
|
||||
&& (await gc.innerText()).includes('已忽略这条建议'))
|
||||
await gc.locator('[data-patch-unignore]').click()
|
||||
check('重新查看:卡片回来', await gc.locator('[data-patch-save]').count() === 1)
|
||||
check('忽略不发请求', writes === 0, `${writes}`)
|
||||
await g.page.close()
|
||||
})
|
||||
|
||||
await section('步骤行和画布联动', async () => {
|
||||
const { page } = await open('syn=mixed&link=1', { w: 1100, h: 800 })
|
||||
const row = page.locator('[data-node-id="agent"]').first()
|
||||
|
||||
@@ -1112,6 +1112,37 @@ await section('助手改图:一步撤销、回执、只回话、失败退回
|
||||
check('reply:这一轮记下回答', t2.reply?.includes('input.goal') && t2.outcome === 'answered', t2.outcome)
|
||||
check('reply:画布没动、不占撤销栈', (await st(page)).nodes.length === b2.nodes.length && (await st(page)).past === b2.past)
|
||||
|
||||
// 2b) 用户说了一条数据事实:助手只回答,附一条目录修改提案。卡片在助手栏这一轮里,点保存才写(这里在浏览器层答掉)
|
||||
state.stream = [
|
||||
{ op: 'model', model: 'fake-model' },
|
||||
{ op: 'catalog_patch', source: 'shop', source_id: 'src-shop', table: 'orders', table_label: '订单', version: 2, changes: [
|
||||
{ path: 'columns.status.codes', before: { 1: '已支付' }, before_status: 'confirmed', after: { 1: '已支付', 9: '作废' },
|
||||
value: { 9: '作废' }, reason: '用户说明 status=9 表示作废', state: 'change' }] },
|
||||
{ op: 'reply', text: '已整理成数据目录的修改建议。' },
|
||||
]
|
||||
const patchSent = []
|
||||
await page.route(/\/api\/datasources\/src-shop\/catalog\/orders\/patch$/, (route) => {
|
||||
patchSent.push(route.request().postDataJSON())
|
||||
return route.fulfill({ json: { table_name: 'orders', in_schema: true, notes: {}, version: 3, updated_at: null, updated_by: null,
|
||||
structure: null, system_notes: false, usage: 0 } })
|
||||
})
|
||||
await page.route(/\/api\/datasources\/src-shop\/catalog\/orders\/impact$/, (route) =>
|
||||
route.fulfill({ json: { table: 'orders', templates: [] } }))
|
||||
const b2b = await st(page)
|
||||
await S(page, () => window.__studio.getState().runCopilot('status=9 表示作废', true))
|
||||
await page.waitForFunction(() => window.__studio.getState().copilotTurns.at(-1)?.phase !== 'running')
|
||||
const t2b = await S(page, () => window.__studio.getState().copilotTurns.at(-1))
|
||||
check('目录修改提案:这一轮照样记下回答', t2b.outcome === 'answered' && t2b.ops.some((o) => o.op === 'catalog_patch'), t2b.outcome)
|
||||
check('目录修改提案:画布没动', (await st(page)).nodes.length === b2b.nodes.length && (await st(page)).past === b2b.past)
|
||||
const patchCard = page.locator('[data-catalog-patch="orders"]').last()
|
||||
check('助手栏里有「建议更新数据目录」卡片', await patchCard.waitFor({ timeout: 3000 }).then(() => true, () => false)
|
||||
&& (await patchCard.innerText()).includes('列 status 的码值'))
|
||||
await patchCard.locator('[data-patch-save]').click()
|
||||
await patchCard.locator('[data-patch-done]').waitFor({ timeout: 3000 })
|
||||
check('保存带版本和原样取值', patchSent[0]?.if_version === 2 && JSON.stringify(patchSent[0]?.changes?.[0]?.value) === '{"9":"作废"}',
|
||||
JSON.stringify(patchSent[0]))
|
||||
check('保存后给到数据目录的入口', (await patchCard.locator('[data-patch-open]').getAttribute('href')) === '/data/catalog/src-shop/orders')
|
||||
|
||||
// 3) 从头生成,搭到一半失败:画布退回原样,半成品能 ⇧⌘Z 找回
|
||||
state.stream = [
|
||||
{ op: 'model', model: 'fake-model' },
|
||||
|
||||
Reference in New Issue
Block a user