mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 01:11:59 +00:00
数据目录闭环(前端)复核修正(表助手):保存撞上 409 后只按预览重算交去保存的那几项,保存、重算期间回答里又到的提案原样留着、不再被当成不合法悄悄去掉,提案到了也不把正在进行的保存翻回可以点;交给助手的待保存的修改最多 200 项(服务端同一个上限,多了整个请求 422),超出时只交最近的 200 项;开场的留空列取那次起草记下的列名(从那次起草任务里这张表的 blank_sample 取),没写出来的列不再当成留空的列举例,「补上留空的 N 列」点名的正是这 N 列,记全了时只算还没补上的;拿不到(最近一次停止了、任务不在了、老服务端没有列名)时改说「目录里还有 M 列没有中文名和含义」,按钮上的数字就是点名的列数;只读查询后面还有行时写「取回前 50 行,后面还有」,不说成结果一共 50 行;check-catalog-loop 的假事件流照服务端夹 SSE 注释行(「: keep-alive」),提案带 source、table_label,出错码用 assistant_*,假服务端照服务端的上限回 422,补回答进行中保存撞上 409、留空列的三种出处、待保存超过 200 项接着问的检查
This commit is contained in:
+36
-10
@@ -3002,13 +3002,36 @@ export const CATALOG_ASSISTANT_TEXT = {
|
||||
profileOn: '这个数据源开了数据剖析:助手可以发少量只读查询核对数据,遮罩列不取值',
|
||||
notStored: '对话不保存在服务端,关闭表助手或离开页面后就没有了',
|
||||
greeting: '说出你知道的业务事实(某一列的含义、码值、有效记录条件……),助手整理成修改提案,你确认后才写进数据目录。助手只记数据事实,不写计算公式。',
|
||||
/** 上次起草有留空的列:开场引导先问这几列 */
|
||||
blankLead: (n: number, cols: string[]) =>
|
||||
`上次起草有 ${formatNumber(n)} 列模型拿不准、留空了${cols.length ? `,例如 ${cols.join('、')}` : ''}。可以先从这几列开始。`,
|
||||
/**
|
||||
* 上次起草有留空的列:开场引导先问这几列(assistant.ts 的 blankLeadOf)。例子只举起草时记下的留空列里还没补上的;
|
||||
* 拿不到起草时记下的列名时,改说目录里还没有中文名和含义的列——那里面还有没写出来的列,不能说成留空的列
|
||||
*/
|
||||
blankLead: (b: { from: 'draft' | 'catalog'; n: number; examples: string[]; remaining: number | null; unnamed: number }) => {
|
||||
const head = `上次起草有 ${formatNumber(b.n)} 列模型拿不准、留空了`
|
||||
const cols = b.examples.join('、')
|
||||
if (b.from === 'catalog') {
|
||||
return b.unnamed > 0
|
||||
? `${head}。目录里还有 ${formatNumber(b.unnamed)} 列没有中文名和含义${cols ? `,例如 ${cols}` : ''},可以先从这些列开始。`
|
||||
: `${head},现在目录里的列都已有中文名或含义。`
|
||||
}
|
||||
if (b.remaining === 0) return `${head},现在都已补上中文名或含义。`
|
||||
if (!cols) return `${head}。`
|
||||
if (b.remaining == null) return `${head},例如 ${cols}。可以先从这几列开始。`
|
||||
return b.remaining < b.n
|
||||
? `${head},还有 ${formatNumber(b.remaining)} 列没补上:${cols}。可以先从这几列开始。`
|
||||
: `${head}:${cols}。可以先从这几列开始。`
|
||||
},
|
||||
suggestLabel: '可以这样开始',
|
||||
suggestBlank: (n: number) => `补上留空的 ${formatNumber(n)} 列`,
|
||||
suggestBlankText: (cols: string[], total: number) =>
|
||||
`请帮我补上这几列的中文名和含义:${cols.join('、')}${total > cols.length ? ` 等 ${formatNumber(total)} 列` : ''}。拿不准的地方先问我。`,
|
||||
/** 「补上……」按钮:上面的数字就是点名的列数(不知道一共几列时不写数字) */
|
||||
suggestBlank: (from: 'draft' | 'catalog', total: number | null) => (from === 'catalog'
|
||||
? `补上没有中文名和含义的 ${formatNumber(total ?? 0)} 列`
|
||||
: total == null ? '补上留空的列' : `补上留空的 ${formatNumber(total)} 列`),
|
||||
suggestBlankText: (from: 'draft' | 'catalog', cols: string[], total: number | null) => {
|
||||
const named = cols.join('、')
|
||||
if (from === 'draft' && total == null) return `请帮我补上上次起草时模型留空的列的中文名和含义,例如 ${named}。拿不准的地方先问我。`
|
||||
const more = total != null && total > cols.length ? ` 等 ${formatNumber(total)} 列` : ''
|
||||
return `请帮我补上这几列的中文名和含义:${named}${more}。拿不准的地方先问我。`
|
||||
},
|
||||
suggestFacts: '核对有效记录条件和去重规则',
|
||||
suggestFactsText: '这张表的有效记录条件和去重规则应该怎么写?拿不准的地方先问我。',
|
||||
suggestPending: '看看哪些推断可以确认',
|
||||
@@ -3031,11 +3054,14 @@ export const CATALOG_ASSISTANT_TEXT = {
|
||||
usage: (input: number, output: number) =>
|
||||
`输入 ${formatTokens(input, { compact: true })}、输出 ${formatTokens(output, { compact: true })}`,
|
||||
queries: (n: number) => `查询 ${formatNumber(n)} 次`,
|
||||
// ---- 只读查询
|
||||
query: (rows: number) => `查询了数据(${formatNumber(rows)} 行)`,
|
||||
// ---- 只读查询(rows 是取回的行数;truncated:后面还有行,取回的只是前一截,不是结果的总行数)
|
||||
query: (rows: number, truncated = false) => (truncated
|
||||
? `查询了数据(取回前 ${formatNumber(rows)} 行,后面还有)` : `查询了数据(${formatNumber(rows)} 行)`),
|
||||
queryFailed: '查询没有成功',
|
||||
queryRows: (shown: number, total: number) => (total > shown
|
||||
? `共 ${formatNumber(total)} 行,显示前 ${formatNumber(shown)} 行` : `共 ${formatNumber(total)} 行`),
|
||||
queryRows: (shown: number, total: number, truncated = false) => {
|
||||
const head = truncated ? `取回前 ${formatNumber(total)} 行(后面还有)` : `共 ${formatNumber(total)} 行`
|
||||
return total > shown ? `${head},显示前 ${formatNumber(shown)} 行` : head
|
||||
},
|
||||
queryEmpty: '没有返回行',
|
||||
// ---- 修改提案
|
||||
proposal: (n: number) => `修改提案(${formatNumber(n)} 项)`,
|
||||
|
||||
@@ -11,8 +11,8 @@ import { CATALOG_ASSISTANT_TEXT as T, CATALOG_STATUS_LABEL } from '../../lib/ter
|
||||
import { Markdown } from '../../run/Markdown'
|
||||
import { patchValueText } from '../../run/catalogPatch'
|
||||
import {
|
||||
ASSISTANT_MAX_CHARS, ASSISTANT_MAX_SAVE, QUERY_ROWS_SHOWN, applyEvent, applyPreview, assistantChangesOf, blankColumnsOf, changeStateOf,
|
||||
changeWhere, dropPending, historyOf, mergeProposal, pendingSubmit, pendingVersion, savedKey,
|
||||
ASSISTANT_MAX_CHARS, ASSISTANT_MAX_SAVE, QUERY_ROWS_SHOWN, applyEvent, applyPreview, assistantChangesOf, blankColumnsOf, blankLeadOf,
|
||||
changeStateOf, changeWhere, dropPending, historyOf, mergeProposal, pendingForAssistant, pendingSubmit, pendingVersion, savedKey,
|
||||
} from './assistant'
|
||||
import type { AssistantTurn, ChangeState, PendingChange, Turn, TurnPart } from './assistant'
|
||||
|
||||
@@ -22,7 +22,10 @@ import type { AssistantTurn, ChangeState, PendingChange, Turn, TurnPart } from '
|
||||
// 回答流式显示,能停止;助手跑过的只读查询(只有开了数据剖析的源才有)写 SQL 和前几行,默认收起;修改提案逐项写改前、
|
||||
// 改后、依据,可以逐项去掉、加回。提案并进「待保存的修改」:跨多轮累积,同一项以最后一次提案为准,一键保存(最多 200 项)。
|
||||
// 保存带待保存的修改里最早那条提案对照的版本:别人在那之后改过就 409,按最新内容重算(预览接口,只读)后停下来让人再看
|
||||
// 一眼。对话不存服务端:每一轮把前面的对话和待保存的修改一起交上去;关闭、换表、离开页面就没有了,有待保存的修改时先问。
|
||||
// 一眼;只重算交去保存的那几项,保存期间回答里又到的提案原样留着。对话不存服务端:每一轮把前面的对话和待保存的修改
|
||||
// (最多 200 项)一起交上去;关闭、换表、离开页面就没有了,有待保存的修改时先问。
|
||||
// 开场:上次起草有留空的列时,引导先问这几列——列名取那次起草记下的(任务里这张表的 blank_sample),拿不到时改说目录里
|
||||
// 还没有中文名和含义的列。
|
||||
// ===========================================================================
|
||||
|
||||
type SaveState =
|
||||
@@ -42,8 +45,11 @@ export function TableAssistant({ sourceId, table, tableLabel, detail, profileEna
|
||||
detail: CatalogDetail
|
||||
/** 数据源开了数据剖析:助手才能发只读查询 */
|
||||
profileEnabled: boolean
|
||||
/** 上次起草模型拿不准、留空的列数:开场引导先问这几列 */
|
||||
lastBlank: number
|
||||
/**
|
||||
* 上次起草模型拿不准、留空的列数(n)和出处:开场引导先问这几列。jobId 是那次起草的任务(从中取起草时记下的列名);
|
||||
* earlier:最近一次停止了,n 是再之前那次起草记下的(不知道是哪一次,取不到列名)
|
||||
*/
|
||||
lastBlank: { n: number; earlier: boolean; jobId: string | null }
|
||||
/** 正在编辑这张表:先不让保存(同一张表两处同时改,保存会撞上版本冲突) */
|
||||
editing: boolean
|
||||
/** 保存成功:页面换上返回的目录 */
|
||||
@@ -88,7 +94,23 @@ export function TableAssistant({ sourceId, table, tableLabel, detail, profileEna
|
||||
lastLen.current = size
|
||||
}, [turns])
|
||||
|
||||
const blankCols = useMemo(() => (lastBlank > 0 ? blankColumnsOf(detail) : []), [lastBlank, detail])
|
||||
// 上次起草记下的留空列名:从那次起草的任务里取(null 是还没取回来;取不到是空数组,改说目录里还没有中文名和含义的列)
|
||||
const blankN = lastBlank.n
|
||||
const blankJob = blankN > 0 && !lastBlank.earlier ? lastBlank.jobId : null
|
||||
const [blankSample, setBlankSample] = useState<string[] | null>(null)
|
||||
useEffect(() => {
|
||||
if (!blankJob) return
|
||||
let live = true
|
||||
setBlankSample(null)
|
||||
api.dataCatalog.draftJob(sourceId, blankJob)
|
||||
.then(({ job }) => job.tasks.find((t) => t.table_name === table)?.blank_sample?.filter(Boolean) ?? [])
|
||||
.catch(() => [] as string[])
|
||||
.then((cols) => { if (live) setBlankSample(cols) })
|
||||
return () => { live = false }
|
||||
}, [sourceId, table, blankJob])
|
||||
// 没有留空的列、还在取那次起草记下的列名时不写
|
||||
const blankLead = useMemo(() => (blankN <= 0 || (blankJob && blankSample == null)
|
||||
? null : blankLeadOf(blankN, blankJob ? blankSample : null, blankColumnsOf(detail))), [blankN, blankJob, blankSample, detail])
|
||||
const hasProposed = useMemo(() => {
|
||||
const n = detail.notes
|
||||
const items = [n.label, n.description, n.grain, n.keys, n.kind, n.business_date, n.valid_filter, n.dedup,
|
||||
@@ -113,7 +135,7 @@ export function TableAssistant({ sourceId, table, tableLabel, detail, profileEna
|
||||
let proposals = 0
|
||||
// 这一轮已经收尾(done、error):之后再来的事件不认
|
||||
let ended = false
|
||||
stopRef.current = streamCatalogAssistant(sourceId, table, { messages, pending: pendingSubmit(pendingRef.current) }, (ev) => {
|
||||
stopRef.current = streamCatalogAssistant(sourceId, table, { messages, pending: pendingForAssistant(pendingRef.current) }, (ev) => {
|
||||
if (ended) return
|
||||
if (ev.type === 'done' || ev.type === 'error') ended = true
|
||||
if (ev.type === 'proposal') {
|
||||
@@ -123,8 +145,8 @@ export function TableAssistant({ sourceId, table, tableLabel, detail, profileEna
|
||||
if (changes.length) {
|
||||
const version = typeof ev.version === 'number' && Number.isFinite(ev.version) ? ev.version : detail.version
|
||||
setPending((cur) => mergeProposal(cur, changes, pid, version))
|
||||
// 待保存的修改变了:上一次保存的结果、冲突的说明都不再对
|
||||
setSave({ phase: 'idle' })
|
||||
// 待保存的修改变了:上一次保存的结果、冲突的说明都不再对。正在保存、重算的不动(按钮照旧不能点,结果出来再说)
|
||||
setSave((s) => (s.phase === 'saving' || s.phase === 'reloading' ? s : { phase: 'idle' }))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -179,7 +201,8 @@ export function TableAssistant({ sourceId, table, tableLabel, detail, profileEna
|
||||
setSave({ phase: 'reloading' })
|
||||
try {
|
||||
const p = await api.dataCatalog.patchPreview(sourceId, table, pendingSubmit(list))
|
||||
setPending((cur) => applyPreview(cur, p))
|
||||
// 只重算交去保存的那几项:保存、重算期间回答里又到的提案不在预览里,原样留着
|
||||
setPending((cur) => applyPreview(cur, p, list))
|
||||
setSave({ phase: 'conflict', problems: p.problems })
|
||||
} catch {
|
||||
setSave({ phase: 'idle', error: T.conflictFailed })
|
||||
@@ -210,7 +233,6 @@ export function TableAssistant({ sourceId, table, tableLabel, detail, profileEna
|
||||
const over = len > ASSISTANT_MAX_CHARS
|
||||
const lastAssistant = [...turns].reverse().find((t): t is AssistantTurn => t.role === 'assistant')
|
||||
const status = streaming ? T.answering : lastAssistant?.state === 'stopped' ? T.stopped : lastAssistant ? T.answered : ''
|
||||
const sampleCols = blankCols.slice(0, Math.min(Math.max(lastBlank, 1), 5))
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -252,15 +274,15 @@ export function TableAssistant({ sourceId, table, tableLabel, detail, profileEna
|
||||
aria-busy={streaming} aria-label={T.logLabel} data-assistant-log="">
|
||||
<div className="space-y-2 rounded-lg border bg-bg px-3 py-2.5 text-xs leading-relaxed" data-assistant-msg="intro">
|
||||
<p className="text-dim" data-assistant-greeting="">{T.greeting}</p>
|
||||
{lastBlank > 0 && <p className="text-dim" data-assistant-blank="">{T.blankLead(lastBlank, sampleCols)}</p>}
|
||||
{blankLead && <p className="text-dim" data-assistant-blank={blankLead.from}>{T.blankLead(blankLead)}</p>}
|
||||
<p className="text-2xs text-faint">{T.notStored}</p>
|
||||
{fresh && (
|
||||
<div className="space-y-1 pt-0.5">
|
||||
<p className="text-2xs text-faint">{T.suggestLabel}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{lastBlank > 0 && blankCols.length > 0 && (
|
||||
<SuggestButton kind="blank" label={T.suggestBlank(lastBlank)}
|
||||
onClick={() => send(T.suggestBlankText(blankCols.slice(0, 20), blankCols.length))} />
|
||||
{blankLead?.ask && (
|
||||
<SuggestButton kind="blank" label={T.suggestBlank(blankLead.from, blankLead.ask.total)}
|
||||
onClick={() => send(T.suggestBlankText(blankLead.from, blankLead.ask!.columns, blankLead.ask!.total))} />
|
||||
)}
|
||||
<SuggestButton kind="facts" label={T.suggestFacts} onClick={() => send(T.suggestFactsText)} />
|
||||
{hasProposed && <SuggestButton kind="pending" label={T.suggestPending} onClick={() => send(T.suggestPendingText)} />}
|
||||
@@ -383,7 +405,7 @@ function QueryBlock({ part }: { part: Extract<TurnPart, { kind: 'query' }> }) {
|
||||
<details className="group rounded-lg border bg-bg text-2xs" data-assistant-part="query" data-assistant-query={failed ? 'error' : 'ok'}>
|
||||
<summary className="flex cursor-pointer select-none items-start gap-1.5 px-2.5 py-1.5 text-dim outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-[var(--accent)]">
|
||||
<Database size={11} className="mt-0.5 shrink-0" style={{ color: failed ? 'var(--err)' : 'var(--accent)' }} aria-hidden />
|
||||
<span className="min-w-0 break-words">{failed ? `${T.queryFailed}:${part.error}` : T.query(part.rowCount)}</span>
|
||||
<span className="min-w-0 break-words">{failed ? `${T.queryFailed}:${part.error}` : T.query(part.rowCount, part.truncated)}</span>
|
||||
</summary>
|
||||
<div className="space-y-1.5 border-t px-2.5 py-2">
|
||||
<pre className="mono max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-panel px-2 py-1.5 text-2xs" data-assistant-query-sql="">{part.sql}</pre>
|
||||
@@ -391,7 +413,9 @@ function QueryBlock({ part }: { part: Extract<TurnPart, { kind: 'query' }> }) {
|
||||
? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-2xs" data-assistant-query-rows="">
|
||||
<caption className="pb-1 text-left text-faint">{T.queryRows(Math.min(part.rows.length, QUERY_ROWS_SHOWN), part.rowCount)}</caption>
|
||||
<caption className="pb-1 text-left text-faint">
|
||||
{T.queryRows(Math.min(part.rows.length, QUERY_ROWS_SHOWN), part.rowCount, part.truncated)}
|
||||
</caption>
|
||||
<thead>
|
||||
<tr className="border-b text-left text-faint">
|
||||
{part.columns.map((c) => <th key={c} scope="col" className="mono px-1.5 py-1 font-medium">{c}</th>)}
|
||||
@@ -407,7 +431,7 @@ function QueryBlock({ part }: { part: Extract<TurnPart, { kind: 'query' }> }) {
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
: <p className="text-faint">{part.rowCount > 0 ? T.queryRows(0, part.rowCount) : T.queryEmpty}</p>)}
|
||||
: <p className="text-faint">{part.rowCount > 0 ? T.queryRows(0, part.rowCount, part.truncated) : T.queryEmpty}</p>)}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
|
||||
@@ -661,7 +661,8 @@ export function TableDetail({
|
||||
)}
|
||||
{assistantOpen && detail.in_schema && (
|
||||
<TableAssistant key={`${sourceId}\u0000${table}`} sourceId={sourceId} table={table} tableLabel={label ?? detail.table_name} detail={detail}
|
||||
profileEnabled={profileEnabled} lastBlank={lastBlank} editing={!!edit}
|
||||
profileEnabled={profileEnabled} lastBlank={{ n: lastBlank, earlier: blankInfo.earlier, jobId: lastDraft?.job_id ?? null }}
|
||||
editing={!!edit}
|
||||
onSaved={(d) => {
|
||||
setDetail(d)
|
||||
setConflict(null)
|
||||
|
||||
@@ -18,13 +18,16 @@ export const ASSISTANT_MAX_MESSAGES = 40
|
||||
export const ASSISTANT_MAX_CHARS = 4000
|
||||
/** 一次最多保存几项(服务端 PATCH_MAX_CHANGES) */
|
||||
export const ASSISTANT_MAX_SAVE = 200
|
||||
/** 每一轮最多交几项待保存的修改(服务端同一个上限,多了整个请求 422):多出来的只交最近的这么多项 */
|
||||
export const ASSISTANT_MAX_PENDING = 200
|
||||
/** 查询结果在界面上显示前几行(服务端最多给 10 行) */
|
||||
export const QUERY_ROWS_SHOWN = 10
|
||||
|
||||
/** 一轮回答里按先后到来的几块:正文、它跑的只读查询、修改提案 */
|
||||
export type TurnPart =
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'query'; sql: string; columns: string[]; rows: unknown[][]; rowCount: number; error: string | null }
|
||||
/** rowCount 是取回的行数;truncated 为真时后面还有行(取回的只是前一截) */
|
||||
| { kind: 'query'; sql: string; columns: string[]; rows: unknown[][]; rowCount: number; truncated: boolean; error: string | null }
|
||||
| { kind: 'proposal'; id: string; version: number; changes: CatalogAssistantChange[] }
|
||||
|
||||
export interface UserTurn { id: string; role: 'user'; text: string }
|
||||
@@ -77,7 +80,7 @@ export function applyEvent(turn: AssistantTurn, ev: CatalogAssistantEvent, propo
|
||||
const rows = Array.isArray(ev.rows) ? ev.rows.filter(Array.isArray).slice(0, QUERY_ROWS_SHOWN) as unknown[][] : []
|
||||
parts.push({
|
||||
kind: 'query', sql: typeof ev.sql === 'string' ? ev.sql : '', columns, rows, rowCount: num(ev.row_count, rows.length),
|
||||
error: typeof ev.error === 'string' && ev.error.trim() ? ev.error : null,
|
||||
truncated: ev.truncated === true, error: typeof ev.error === 'string' && ev.error.trim() ? ev.error : null,
|
||||
})
|
||||
return { ...turn, parts }
|
||||
}
|
||||
@@ -166,6 +169,13 @@ export function pendingSubmit(pending: PendingChange[]): CatalogPatchSubmit[] {
|
||||
return pending.map((p) => ({ path: p.path, value: p.value, ...(p.reason ? { reason: p.reason } : {}) }))
|
||||
}
|
||||
|
||||
/**
|
||||
* 交给助手的待保存的修改:最多 ASSISTANT_MAX_PENDING 项(服务端同一个上限,多了整个请求 422)。超出时交最近的那些
|
||||
* (列表按头一次提出的先后排,后面的是新近提出的);没交上去的模型不知道,再提同一项时以新的提案为准,不会丢
|
||||
*/
|
||||
export const pendingForAssistant = (pending: PendingChange[]): CatalogPatchSubmit[] =>
|
||||
pendingSubmit(pending.length > ASSISTANT_MAX_PENDING ? pending.slice(-ASSISTANT_MAX_PENDING) : pending)
|
||||
|
||||
/** 保存时带的版本:待保存的修改里最早的那条提案对照的版本 */
|
||||
export const pendingVersion = (pending: PendingChange[]): number =>
|
||||
(pending.length ? Math.min(...pending.map((p) => p.version)) : 0)
|
||||
@@ -184,12 +194,20 @@ export const savedKey = (origin: string, path: string) => `${origin}\u0000${path
|
||||
|
||||
/**
|
||||
* 保存遇到 409(别人刚改过这张表)后按预览重算:预览里有的项换上最新的改前、改后和状态,版本记成预览的;
|
||||
* 预览里没有的(已经不合法了,原因在 problems 里)去掉;已经是这个值且已确认的(same)也去掉,不用再存
|
||||
* 预览里没有的(已经不合法了,原因在 problems 里)去掉;已经是这个值且已确认的(same)也去掉,不用再存。
|
||||
*
|
||||
* submitted:交去保存、预览的那几项(同一个对象)。只重算它们:保存、重算期间又到的提案(新的项、同一项换成的新
|
||||
* 提案)不在预览里,原样留着,不能当成「已经不合法」悄悄去掉。不给时重算全部
|
||||
*/
|
||||
export function applyPreview(pending: PendingChange[], preview: CatalogPatchPreview): PendingChange[] {
|
||||
export function applyPreview(pending: PendingChange[], preview: CatalogPatchPreview, submitted?: readonly PendingChange[]): PendingChange[] {
|
||||
const byPath = new Map(preview.changes.map((c) => [c.path, c]))
|
||||
const mine = submitted ? new Set(submitted) : null
|
||||
const out: PendingChange[] = []
|
||||
for (const p of pending) {
|
||||
if (mine && !mine.has(p)) {
|
||||
out.push(p)
|
||||
continue
|
||||
}
|
||||
const c: CatalogPatchChange | undefined = byPath.get(p.path)
|
||||
if (!c || c.state === 'same') continue
|
||||
out.push({ ...p, before: c.before, before_status: c.before_status, after: c.after, value: c.value, state: c.state,
|
||||
@@ -202,6 +220,49 @@ export function applyPreview(pending: PendingChange[], preview: CatalogPatchPrev
|
||||
// 开场:上次起草有留空的列时,引导先问这几列
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 「补上……」按钮一次最多点名几列(再多的写「等 N 列」) */
|
||||
export const BLANK_ASK_MAX = 20
|
||||
/** 开场里最多举几列做例子 */
|
||||
export const BLANK_EXAMPLES = 5
|
||||
|
||||
/**
|
||||
* 开场引导的写法。留空的列(模型列出了、中文名和含义却都没给)只有起草时记下的那几个列名(blank_sample,最多 5 个)
|
||||
* 靠得住:目录里还没有中文名和含义的列里,还有没写出来的列(部分完成的宽表)、库注释已经说清的列,不能当成留空的列举例。
|
||||
* - draft:拿到了起草时记下的列名。例子是其中还没补上的。记全了(列名数不少于留空的列数)时 remaining 是还没补上的
|
||||
* 几列,按钮写这个数、点名的正是这几列;没记全时 remaining 为 null,按钮不写数字、只点名这几列(模型那边另有背景)。
|
||||
* - catalog:拿不到(最近一次停止了、用的是再之前那次的留空列数;取任务失败;老服务端没有列名)。例子和按钮都改说
|
||||
* 「目录里还没有中文名和含义的列」,按钮上的数字就是这些列的总数,点名其中前 BLANK_ASK_MAX 列、再写「等 N 列」。
|
||||
* ask 为 null:没有可以点名的列,不给按钮
|
||||
*/
|
||||
export interface BlankLead {
|
||||
from: 'draft' | 'catalog'
|
||||
/** 上次起草留空的列数 */
|
||||
n: number
|
||||
examples: string[]
|
||||
/** 记全了时,留空的列里还没补上的几列;没记全、拿不到列名时为 null */
|
||||
remaining: number | null
|
||||
/** 目录里还没有中文名和含义的列数 */
|
||||
unnamed: number
|
||||
ask: { columns: string[]; total: number | null } | null
|
||||
}
|
||||
|
||||
export function blankLeadOf(n: number, sample: readonly string[] | null, unnamed: readonly string[]): BlankLead {
|
||||
const named = sample?.filter((c) => !!c) ?? []
|
||||
if (named.length) {
|
||||
const open = new Set(unnamed)
|
||||
const still = named.filter((c) => open.has(c))
|
||||
const remaining = named.length >= n ? still.length : null
|
||||
return {
|
||||
from: 'draft', n, examples: still.slice(0, BLANK_EXAMPLES), remaining, unnamed: unnamed.length,
|
||||
ask: still.length ? { columns: still, total: remaining } : null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
from: 'catalog', n, examples: unnamed.slice(0, BLANK_EXAMPLES), remaining: null, unnamed: unnamed.length,
|
||||
ask: unnamed.length ? { columns: unnamed.slice(0, BLANK_ASK_MAX), total: unnamed.length } : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** 还没有中文名和含义的列(表结构里的顺序;被驳回的不算有) */
|
||||
export function blankColumnsOf(detail: CatalogDetail): string[] {
|
||||
const live = (it: { status: string } | undefined) => !!it && it.status !== 'rejected'
|
||||
|
||||
@@ -3225,9 +3225,13 @@ export interface CatalogAssistantChange extends CatalogPatchChange {
|
||||
label?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件流里的一个事件。服务端等模型时另发 SSE 注释行(「: keep-alive」,不是事件),读流时跳过。
|
||||
* query 的 row_count 是取回的行数;truncated(接口约定里没有写明,服务端给了才用)为真时后面还有行,取回的只是前一截
|
||||
*/
|
||||
export type CatalogAssistantEvent =
|
||||
| { type: 'text'; delta: string }
|
||||
| { type: 'query'; sql: string; columns: string[]; rows: unknown[][]; row_count: number; error: string | null }
|
||||
| { type: 'query'; sql: string; columns: string[]; rows: unknown[][]; row_count: number; truncated?: boolean; error: string | null }
|
||||
| { type: 'proposal'; source_id: string; table: string; version: number; changes: CatalogAssistantChange[] }
|
||||
| { type: 'done'; usage: { input_tokens: number; output_tokens: number }; queries: number }
|
||||
| { type: 'error'; message: string; code?: string | null }
|
||||
@@ -3235,7 +3239,7 @@ export type CatalogAssistantEvent =
|
||||
/** 交给表助手的一轮请求 */
|
||||
export interface CatalogAssistantRequest {
|
||||
messages: { role: 'user' | 'assistant'; content: string }[]
|
||||
/** 界面上还没保存的修改 */
|
||||
/** 界面上还没保存的修改:最多 200 项(服务端同一个上限,多了整个请求 422),多出来的只交最近的 200 项 */
|
||||
pending: CatalogPatchSubmit[]
|
||||
}
|
||||
|
||||
|
||||
+221
-19
@@ -182,13 +182,24 @@ function scenicTables() {
|
||||
}
|
||||
function profiledTables() {
|
||||
return [
|
||||
// 上一次起草留了 2 列空:开场引导先问这几列
|
||||
table('gates', 12, [pk(), col('gate_code', 'TEXT'), col('gate_kind', 'TEXT'), col('park_id')], {
|
||||
// 上一次起草留了 2 列空(gate_code、gate_kind):开场引导先问这几列。opened_on 也还没有中文名和含义,但它是那次
|
||||
// 没写出来的列、不是模型留空的——排在表结构的前面,按「目录里还没有中文名和含义的前几列」举例就会举错
|
||||
table('gates', 12, [pk(), col('opened_on', 'TEXT'), col('gate_code', 'TEXT'), col('gate_kind', 'TEXT'), col('park_id')], {
|
||||
label: item('闸机'), columns: { id: { label: item('编号') }, park_id: { label: item('景区编号') } },
|
||||
}, { lastDraft: { status: 'ok', job_id: 'check-job-old', finished_at: T0, error: null, model_error: null, blank_columns: 2 } }),
|
||||
table('stores', 4, [pk(), col('store_code', 'TEXT')], null),
|
||||
]
|
||||
}
|
||||
/** 那次起草的任务(GET draft-jobs/{id}):只有闸机表这一行要紧——起草时记下的留空列名 */
|
||||
const OLD_JOB = {
|
||||
id: 'check-job-old', source_id: 'check-loop-profiled', status: 'succeeded', use_model: true, model: 'check-model', guidance: null, note: null,
|
||||
created_at: T0, started_at: T0, finished_at: T0, created_by: '检查脚本', total: 1, done: 1,
|
||||
counts: { pending: 0, running: 0, ok: 1, partial: 0, failed: 0, skipped: 0, cancelled: 0 }, added: 3, updated: 0, removed: 0,
|
||||
usage: { calls: 1, input_tokens: 1000, output_tokens: 200 }, error: null, blank_columns: 2,
|
||||
tasks: [{ table_name: 'gates', status: 'ok', columns: 5, parts_total: 1, parts_done: 1, parts_written: 1, chunks_total: 1, chunks_done: 1,
|
||||
added: 3, updated: 0, removed: 0, version: 2, model_items: 3, blank_columns: 2, blank_sample: ['gate_code', 'gate_kind'],
|
||||
error: null, model_error: null, started_at: T0, finished_at: T0 }],
|
||||
}
|
||||
|
||||
const blank = {
|
||||
kind: 'sqlite', host: null, port: null, username: null, readonly: true, description: '', enabled: true,
|
||||
@@ -301,10 +312,14 @@ function previewOf(t, changes) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 表助手的事件流:每个事件一行 data: <JSON> */
|
||||
/**
|
||||
* 表助手的事件流:每个事件一行 data: <JSON>。服务端等模型时发 SSE 注释行(「: keep-alive」,不是事件):开头、每个事件
|
||||
* 之间都夹一行,读流时得跳过
|
||||
*/
|
||||
const KEEP_ALIVE = ': keep-alive\n\n'
|
||||
const sse = (events) => (route) => route.fulfill({
|
||||
status: 200, headers: { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache' },
|
||||
body: events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join(''),
|
||||
body: KEEP_ALIVE + events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join(KEEP_ALIVE),
|
||||
})
|
||||
const done = (queries = 0) => ({ type: 'done', usage: { input_tokens: 2400, output_tokens: 600 }, queries })
|
||||
const MODEL_DOWN = { detail: '未配置模型接入,无法由模型起草数据目录。请到「设置 → 模型接入」添加', code: 'catalog_draft_model_unavailable' }
|
||||
@@ -320,6 +335,12 @@ function loopHandlers(state, replies = {}) {
|
||||
[/^GET \/datasources$/, (route) => json(state.sources)(route)],
|
||||
[/^GET \/datasources\/check-loop-[a-z]+\/catalog$/, (route, { key }) => json(listOf(state, parts(key)[2]))(route)],
|
||||
[/^GET \/datasources\/check-loop-[a-z]+\/catalog\/draft-jobs\/(active|latest)$/, json({ job: null })],
|
||||
// 某一次起草任务:表助手开场从中取起草时记下的留空列名。只认 state.jobs 里有的,别的 404(同服务端:任务不在了)
|
||||
[/^GET \/datasources\/check-loop-[a-z]+\/catalog\/draft-jobs\/[^/]+$/, (route, { key }) => {
|
||||
const id = parts(key)[5]
|
||||
const job = (state.jobs ?? [OLD_JOB]).find((j) => j.id === id)
|
||||
return job ? json({ job })(route) : json({ detail: '找不到这次起草任务' }, 404)(route)
|
||||
}],
|
||||
// 审阅队列、进度、成批审阅、导出导入:放在单表的 GET 前面
|
||||
[/^GET \/datasources\/check-loop-[a-z]+\/catalog\/review-queue$/, reply('queue', (route, { key, url }) => {
|
||||
const src = parts(key)[2]
|
||||
@@ -377,8 +398,14 @@ function loopHandlers(state, replies = {}) {
|
||||
const runs = state.usage?.[parts(key)[4]] ?? []
|
||||
return json({ total: runs.length, runs: runs.slice(0, Number(url.searchParams.get('limit') ?? 20)) })(route)
|
||||
})],
|
||||
// 表助手:每一轮按 replies.assistant 依次答(没配就是 503,检查不能让请求落到真服务端)
|
||||
[/^POST \/datasources\/check-loop-[a-z]+\/catalog\/[^/]+\/assistant$/, reply('assistant', json({ detail: '检查脚本没有为这一轮准备答复' }, 503))],
|
||||
// 表助手:每一轮按 replies.assistant 依次答(没配就是 503,检查不能让请求落到真服务端)。请求不合规的照服务端的
|
||||
// 校验回 422:对话最多 40 条、每条最多 4000 字,待保存的修改最多 200 项
|
||||
[/^POST \/datasources\/check-loop-[a-z]+\/catalog\/[^/]+\/assistant$/, (route, ctx) => {
|
||||
const b = ctx.body ?? {}
|
||||
const bad = (b.messages?.length ?? 0) > 40 || (b.messages ?? []).some((m) => [...(m.content ?? '')].length > 4000) || (b.pending?.length ?? 0) > 200
|
||||
if (bad) return json({ detail: [{ type: 'too_long', loc: ['body', (b.pending?.length ?? 0) > 200 ? 'pending' : 'messages'], msg: 'too long' }] }, 422)(route)
|
||||
return reply('assistant', json({ detail: '检查脚本没有为这一轮准备答复' }, 503))(route, ctx)
|
||||
}],
|
||||
[/^POST \/datasources\/check-loop-[a-z]+\/catalog\/[^/]+\/patch\/preview$/, reply('preview', (route, { key, body }) => {
|
||||
const { t } = findTable(state, key)
|
||||
return json(previewOf(t, body.changes))(route)
|
||||
@@ -599,6 +626,32 @@ await section('纯函数 · 对话怎么截、待保存的修改怎么累积、
|
||||
]
|
||||
// 审阅进度:三行同一个口径——已确认 ÷(已确认 + 待确认);不知道待确认几项时不算百分比
|
||||
out.progress = [rv.confirmProgress(120, 150), rv.confirmProgress(30, 0), rv.confirmProgress(0, 0), rv.confirmProgress(5, undefined)]
|
||||
// 409 后只重算交去保存的那几项:保存期间又到的提案原样留着
|
||||
const A = { ...c('label', '甲'), origin: 'p1', version: 2 }
|
||||
const B = { ...c('grain', '一行'), origin: 'p2', version: 3 }
|
||||
const pv2 = { table_name: 't', version: 9, problems: [], changes: [{ ...c('label', '甲'), before: '丙', before_status: 'proposed' }] }
|
||||
out.only = { kept: a.applyPreview([A, B], pv2, [A]).map((x) => `${x.path}@${x.version}`).join(','),
|
||||
all: a.applyPreview([A, B], pv2).map((x) => x.path).join(',') }
|
||||
// 交给助手的待保存的修改最多 200 项:只交最近的
|
||||
const many = Array.from({ length: 202 }, (_, i) => ({ ...c(`columns.c${i}.label`, `列 ${i}`), origin: 'p', version: 1 }))
|
||||
const capped = a.pendingForAssistant(many)
|
||||
out.capped = { n: capped.length, first: capped[0].path, last: capped.at(-1).path, few: a.pendingForAssistant(many.slice(0, 3)).length }
|
||||
// 开场的留空列:只举起草时记下的;拿不到时改说目录里还没有中文名和含义的列,按钮上的数字就是点名的列数
|
||||
const T = (await import('/src/lib/terms.ts')).CATALOG_ASSISTANT_TEXT
|
||||
const lead = (n, sample, unnamed) => {
|
||||
const b = a.blankLeadOf(n, sample, unnamed)
|
||||
return { from: b.from, text: T.blankLead(b), button: b.ask ? T.suggestBlank(b.from, b.ask.total) : null,
|
||||
ask: b.ask ? T.suggestBlankText(b.from, b.ask.columns, b.ask.total) : null }
|
||||
}
|
||||
out.blankLead = [
|
||||
lead(2, ['gate_code', 'gate_kind'], ['opened_on', 'gate_code', 'gate_kind']),
|
||||
lead(2, ['gate_code', 'gate_kind'], ['opened_on', 'gate_kind']),
|
||||
lead(2, ['gate_code', 'gate_kind'], ['opened_on']),
|
||||
lead(12, ['c1', 'c2', 'c3', 'c4', 'c5'], ['c0', 'c2', 'c5', 'c9']),
|
||||
lead(2, null, ['opened_on', 'gate_code', 'gate_kind']),
|
||||
lead(2, [], []),
|
||||
lead(2, null, Array.from({ length: 25 }, (_, i) => `k${i}`)),
|
||||
]
|
||||
return out
|
||||
})
|
||||
// 51 条取后 40 条,打头的那条是助手的回答,去掉:39 条
|
||||
@@ -630,13 +683,35 @@ await section('纯函数 · 对话怎么截、待保存的修改怎么累积、
|
||||
check('审阅进度三行同一个口径:已确认 ÷(已确认 + 待确认),审完是 100%,没有可确认的、不知道待确认几项时不算',
|
||||
JSON.stringify(r.progress.map((x) => x.pct)) === JSON.stringify([44, 100, null, null]) && r.progress[3].proposed === null,
|
||||
JSON.stringify(r.progress))
|
||||
check('409 后只重算交去保存的那几项:保存期间又到的提案(粒度)原样留着、版本不变;不给时照旧全部重算',
|
||||
r.only.kept === 'label@9,grain@3' && r.only.all === 'label', JSON.stringify(r.only))
|
||||
check('交给助手的待保存的修改最多 200 项,只交最近的;不到 200 项的全交', r.capped.n === 200 && r.capped.first === 'columns.c2.label'
|
||||
&& r.capped.last === 'columns.c201.label' && r.capped.few === 3, JSON.stringify(r.capped))
|
||||
const bl = r.blankLead
|
||||
check('留空列(记全了):举的就是起草时记下的列,没写出来的 opened_on 不算;按钮上的 2 列就是点名的 2 列',
|
||||
bl[0].from === 'draft' && bl[0].text === '上次起草有 2 列模型拿不准、留空了:gate_code、gate_kind。可以先从这几列开始。'
|
||||
&& bl[0].button === '补上留空的 2 列' && bl[0].ask === '请帮我补上这几列的中文名和含义:gate_code、gate_kind。拿不准的地方先问我。', JSON.stringify(bl[0]))
|
||||
check('……补上了一列:写还有 1 列没补上,按钮是「补上留空的 1 列」、只点名它', bl[1].text === '上次起草有 2 列模型拿不准、留空了,还有 1 列没补上:gate_kind。可以先从这几列开始。'
|
||||
&& bl[1].button === '补上留空的 1 列' && bl[1].ask === '请帮我补上这几列的中文名和含义:gate_kind。拿不准的地方先问我。', JSON.stringify(bl[1]))
|
||||
check('……都补上了:写明都已补上,不给按钮', bl[2].text === '上次起草有 2 列模型拿不准、留空了,现在都已补上中文名或含义。' && bl[2].button === null, JSON.stringify(bl[2]))
|
||||
check('……没记全(12 列只记了 5 个):举还没补上的那几个,按钮不写数字',
|
||||
bl[3].text === '上次起草有 12 列模型拿不准、留空了,例如 c2、c5。可以先从这几列开始。' && bl[3].button === '补上留空的列'
|
||||
&& bl[3].ask === '请帮我补上上次起草时模型留空的列的中文名和含义,例如 c2、c5。拿不准的地方先问我。', JSON.stringify(bl[3]))
|
||||
check('拿不到起草时记下的列名:改说目录里还没有中文名和含义的列,按钮上的数字就是这些列的总数',
|
||||
bl[4].from === 'catalog' && bl[4].text === '上次起草有 2 列模型拿不准、留空了。目录里还有 3 列没有中文名和含义,例如 opened_on、gate_code、gate_kind,可以先从这些列开始。'
|
||||
&& bl[4].button === '补上没有中文名和含义的 3 列' && bl[4].ask === '请帮我补上这几列的中文名和含义:opened_on、gate_code、gate_kind。拿不准的地方先问我。',
|
||||
JSON.stringify(bl[4]))
|
||||
check('……目录里的列都有了:不给按钮', bl[5].text === '上次起草有 2 列模型拿不准、留空了,现在目录里的列都已有中文名或含义。' && bl[5].button === null, JSON.stringify(bl[5]))
|
||||
check('……超过 20 列:点名前 20 列、写「等 25 列」,和按钮上的 25 列一致', bl[6].button === '补上没有中文名和含义的 25 列'
|
||||
&& bl[6].ask.includes('k19 等 25 列') && !bl[6].ask.includes('k20'), JSON.stringify(bl[6]))
|
||||
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
|
||||
await close()
|
||||
})
|
||||
|
||||
// 第一轮:用户说出码值,助手整理成两项提案
|
||||
// 形状照服务端:另带 source(数据源名)、table_label(表的中文名),界面不用
|
||||
const PROPOSAL_1 = {
|
||||
type: 'proposal', source_id: S1, table: 'visits', version: 2, changes: [
|
||||
type: 'proposal', source: NAME1, source_id: S1, table: 'visits', table_label: '入园记录', version: 2, changes: [
|
||||
{ path: 'columns.status.codes', label: '列 status 的码值', before: { 1: '已入园' }, before_status: 'proposed', after: { 1: '已入园', 9: '已作废' },
|
||||
value: { 9: '已作废' }, reason: '用户说「status 为 9 表示作废」', state: 'change' },
|
||||
{ path: 'columns.status.meaning', label: '列 status 的含义', before: null, before_status: null, after: '入园状态:1 已入园,9 已作废',
|
||||
@@ -645,7 +720,7 @@ const PROPOSAL_1 = {
|
||||
}
|
||||
// 第二轮:码值再补一个(同一项,以这一次为准),有效记录条件跟着改
|
||||
const PROPOSAL_2 = {
|
||||
type: 'proposal', source_id: S1, table: 'visits', version: 2, changes: [
|
||||
type: 'proposal', source: NAME1, source_id: S1, table: 'visits', table_label: '入园记录', version: 2, changes: [
|
||||
{ path: 'columns.status.codes', label: '列 status 的码值', before: { 1: '已入园' }, before_status: 'proposed', after: { 1: '已入园', 8: '已退票', 9: '已作废' },
|
||||
value: { 8: '已退票', 9: '已作废' }, reason: '用户说「8 是退票」', state: 'change' },
|
||||
{ path: 'valid_filter', label: '有效记录条件', before: 'status NOT IN (9)', before_status: 'proposed', after: 'status NOT IN (8, 9)',
|
||||
@@ -779,7 +854,7 @@ await section('表助手 · 停止、出错、模型用不了与离开前确认'
|
||||
// 一直不答完:等着点「停止回答」
|
||||
async (route) => { await held; await route.fulfill({ status: 200, contentType: 'text/event-stream', body: '' }).catch(() => {}) },
|
||||
// 流里出错
|
||||
sse([{ type: 'text', delta: '我先看一下这张表' }, { type: 'error', message: '调用模型出错:服务暂时不可用', code: 'model_error' }]),
|
||||
sse([{ type: 'text', delta: '我先看一下这张表' }, { type: 'error', message: '调用模型出错:服务暂时不可用', code: 'assistant_model_error' }]),
|
||||
// 有一项提案:之后离开、关闭时要先问
|
||||
sse([{ type: 'text', delta: '整理好了:' }, PROPOSAL_1, done()]),
|
||||
],
|
||||
@@ -829,13 +904,77 @@ await section('表助手 · 停止、出错、模型用不了与离开前确认'
|
||||
await close()
|
||||
})
|
||||
|
||||
await section('表助手 · 回答进行中保存、撞上 409 时新到的提案不丢', async () => {
|
||||
const state = freshState()
|
||||
const t = state.tables[S1].find((x) => x.name === 'visits')
|
||||
let releaseStream = null
|
||||
const streamGate = new Promise((r) => { releaseStream = r })
|
||||
let releasePreview = null
|
||||
const previewGate = new Promise((r) => { releasePreview = r })
|
||||
// 第二轮回答里的提案:别人改过之后才提的,对照第 3 版
|
||||
const GRAIN = {
|
||||
type: 'proposal', source: NAME1, source_id: S1, table: 'visits', table_label: '入园记录', version: 3, changes: [
|
||||
{ path: 'grain', label: '表的粒度', before: '一张门票的一次入园', before_status: 'proposed', after: '一次刷闸', value: '一次刷闸',
|
||||
reason: '用户说「一次刷闸记一行」', state: 'change' },
|
||||
],
|
||||
}
|
||||
const replies = {
|
||||
assistant: [
|
||||
sse([{ type: 'text', delta: '整理好了:' }, PROPOSAL_1, done()]),
|
||||
// 第二轮:等点了保存、撞上 409、正在按最新内容重算时才答出来
|
||||
async (route) => { await streamGate; await sse([{ type: 'text', delta: '粒度也改一下:' }, GRAIN, done()])(route).catch(() => {}) },
|
||||
],
|
||||
// 重算(预览):等第二轮的提案到了再答
|
||||
preview: [async (route, { key, body }) => { await previewGate; await json(previewOf(findTable(state, key).t, body.changes))(route).catch(() => {}) }],
|
||||
}
|
||||
const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}/visits`, { handlers: loopHandlers(state, replies) })
|
||||
await until(async () => (await page.locator('[data-catalog-detail="visits"][data-version]').count()) > 0)
|
||||
await page.locator('[data-catalog-assistant-open="header"]').click()
|
||||
await panel(page).waitFor()
|
||||
await say(page, SAY_1)
|
||||
check('第一轮:待保存 2 项', await pendingCount(page) === 2)
|
||||
// 别人刚改过这张表(第 3 版);接着问第二轮,回答还没出来时点保存
|
||||
writeEntry(t, { ...t.notes, description: item('别人刚补的说明', 'human') })
|
||||
await say(page, '一次刷闸记一行', { wait: false })
|
||||
await until(async () => (await streaming(page)) === 'true')
|
||||
const save = panel(page).locator('[data-assistant-save]')
|
||||
await save.click()
|
||||
check('回答进行中点保存:带提案对照的第 2 版,撞上 409,按最新内容重算(预览)', await until(async () => count(sent, /\/visits\/patch\/preview$/) === 1)
|
||||
&& lastSent(sent, /\/visits\/patch$/)?.body?.if_version === 2 && lastSent(sent, /\/visits\/patch\/preview$/)?.body?.changes?.length === 2)
|
||||
// 重算还没回来时,第二轮的提案到了
|
||||
releaseStream()
|
||||
check('重算期间第二轮的提案到了:待保存 3 项', await until(async () => (await pendingCount(page)) === 3))
|
||||
check('……保存按钮照旧是「正在保存…」、不能点(提案到了不把正在进行的保存翻回可以点)', await save.isDisabled()
|
||||
&& (await textOf(save)).includes('正在保存'), await textOf(save))
|
||||
releasePreview()
|
||||
const conflict = panel(page).locator('[data-assistant-conflict]')
|
||||
check('重算回来:写明这张表刚被修改过、已按最新内容重算', await until(async () => (await conflict.count()) === 1))
|
||||
check('……重算期间新到的提案(粒度)还在待保存的修改里,没被当成不合法悄悄去掉', await pendingCount(page) === 3
|
||||
&& await panel(page).locator('[data-assistant-pending-item="grain"]').count() === 1 && (await changeState(page, 'grain')) === 'pending',
|
||||
`${await pendingCount(page)} · ${await changeState(page, 'grain')}`)
|
||||
check('……没有说有哪几项已无法保存(预览里的都还合法)', await conflict.locator('[data-assistant-problems]').count() === 0)
|
||||
await save.click()
|
||||
const body = await until(async () => (count(sent, /\/visits\/patch$/) === 2 ? lastSent(sent, /\/visits\/patch$/)?.body : null))
|
||||
check('再点保存:带最新的第 3 版,3 项都交上去(含新到的粒度)', body?.if_version === 3 && JSON.stringify(body.changes.map((c) => c.path))
|
||||
=== JSON.stringify(['columns.status.codes', 'columns.status.meaning', 'grain']), JSON.stringify(body))
|
||||
check('……存上了:待保存的清空,写明已保存 3 项(第 4 版)', await until(async () => (await panel(page).locator('[data-assistant-saved]').count()) === 1)
|
||||
&& (await textOf(panel(page).locator('[data-assistant-saved]'))).includes('已保存 3 项修改(第 4 版)'))
|
||||
check('只发过表助手、保存、预览的写请求', writes(sent).every((s) => /\/(assistant|patch|patch\/preview)$/.test(s.key)), JSON.stringify(writes(sent).map((s) => s.key)))
|
||||
check('没有原生对话框', natives.length === 0, natives.join(' | '))
|
||||
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
|
||||
await close()
|
||||
})
|
||||
|
||||
await section('表助手 · 开了剖析的源:只读查询与留空列的引导', async () => {
|
||||
const state = freshState()
|
||||
// 形状照服务端:query 带 truncated(后面还有行时为真,row_count 只是取回的行数)
|
||||
const QUERY = { type: 'query', sql: 'SELECT gate_kind, COUNT(*) AS n FROM gates GROUP BY gate_kind', columns: ['gate_kind', 'n', 'gate_code'],
|
||||
rows: [['E', 6, '已遮罩'], ['X', 2, '已遮罩']], row_count: 2, error: null }
|
||||
rows: [['E', 6, '已遮罩'], ['X', 2, '已遮罩']], row_count: 2, truncated: false, error: null }
|
||||
const LONG = { type: 'query', sql: 'SELECT gate_code, park_id FROM gates', columns: ['gate_code', 'park_id'],
|
||||
rows: Array.from({ length: 10 }, (_, i) => ['已遮罩', i + 1]), row_count: 50, truncated: true, error: null }
|
||||
const replies = {
|
||||
assistant: [sse([QUERY, { type: 'query', sql: 'SELECT COUNT(*) FROM gates WHERE park_id IS NULL', columns: [], rows: [], row_count: 0, error: '查询超时(10 秒)' },
|
||||
{ type: 'text', delta: 'gate_kind 只有 E、X 两种取值,分别是什么意思?' }, done(2)])],
|
||||
assistant: [sse([QUERY, { type: 'query', sql: 'SELECT COUNT(*) FROM gates WHERE park_id IS NULL', columns: [], rows: [], row_count: 0, truncated: false, error: '查询超时(10 秒)' },
|
||||
LONG, { type: 'text', delta: 'gate_kind 只有 E、X 两种取值,分别是什么意思?' }, done(3)])],
|
||||
}
|
||||
const { page, sent, errors, close } = await open(`/data/catalog/${S2}/gates`, { handlers: loopHandlers(state, replies) })
|
||||
await until(async () => (await page.locator('[data-catalog-detail="gates"][data-version]').count()) > 0)
|
||||
@@ -845,30 +984,81 @@ await section('表助手 · 开了剖析的源:只读查询与留空列的引
|
||||
await panel(page).waitFor()
|
||||
check('开了剖析的源:不写「不能查数据」,写明可以发少量只读查询', await panel(page).locator('[data-assistant-no-profile]').count() === 0
|
||||
&& (await textOf(panel(page).locator('[data-assistant-profile-on]'))).includes('只读查询'))
|
||||
const lead = await textOf(panel(page).locator('[data-assistant-blank]'))
|
||||
check('开场引导先问留空的列:写明上次起草有 2 列留空,例如 gate_code、gate_kind', lead.includes('上次起草有 2 列模型拿不准、留空了') && lead.includes('gate_code、gate_kind'), lead)
|
||||
// 留空的列名取那次起草记下的(任务里这张表的 blank_sample),不取「目录里还没有中文名和含义的列」——那里面还有没写出来的 opened_on
|
||||
const leadBox = panel(page).locator('[data-assistant-blank]')
|
||||
// 开发模式的 StrictMode 会把副作用多跑一遍,取任务可能是两次
|
||||
check('开场先取那次起草的任务(从中取起草时记下的留空列名)', await until(async () => count(sent, /draft-jobs\/check-job-old$/) >= 1)
|
||||
&& await until(async () => (await leadBox.getAttribute('data-assistant-blank').catch(() => null)) === 'draft'))
|
||||
const lead = await textOf(leadBox)
|
||||
check('开场引导先问留空的列:写明上次起草有 2 列留空,就是 gate_code、gate_kind,不把没写出来的 opened_on 当成留空的列',
|
||||
lead === '上次起草有 2 列模型拿不准、留空了:gate_code、gate_kind。可以先从这几列开始。' && !lead.includes('opened_on'), lead)
|
||||
const suggest = panel(page).locator('[data-assistant-suggest="blank"]')
|
||||
check('……给一个按钮「补上留空的 2 列」', (await textOf(suggest)) === '补上留空的 2 列')
|
||||
check('……给一个按钮「补上留空的 2 列」', (await textOf(suggest)) === '补上留空的 2 列', await textOf(suggest))
|
||||
await suggest.click()
|
||||
await until(async () => (await streaming(page)) === 'false' && count(sent, /\/assistant$/) === 1)
|
||||
const first = lastSent(sent, /\/assistant$/)?.body?.messages?.[0]?.content ?? ''
|
||||
check('……点了就把这几列交给助手', first.includes('gate_code、gate_kind') && first.includes('拿不准的地方先问我'), first)
|
||||
check('……点了就把这 2 列交给助手:点名的正是按钮上说的 2 列,没有别的列', first === '请帮我补上这几列的中文名和含义:gate_code、gate_kind。拿不准的地方先问我。', first)
|
||||
const q = lastTurn(page).locator('[data-assistant-query]')
|
||||
check('跑过的只读查询:两条,默认收起,摘要写行数', await until(async () => (await q.count()) === 2)
|
||||
check('跑过的只读查询:三条,默认收起,摘要写行数(服务端等模型时发的注释行跳过了)', await until(async () => (await q.count()) === 3)
|
||||
&& !(await q.first().evaluate((el) => el.open)) && (await textOf(q.first().locator('summary'))).includes('查询了数据(2 行)'))
|
||||
check('……查询没成功的写明原因', (await textOf(q.nth(1).locator('summary'))).includes('查询没有成功') && (await textOf(q.nth(1))).includes('查询超时(10 秒)'))
|
||||
check('……后面还有行的(truncated):写「取回前 50 行,后面还有」,不说成结果一共 50 行', (await textOf(q.nth(2).locator('summary'))).includes('查询了数据(取回前 50 行,后面还有)'),
|
||||
await textOf(q.nth(2).locator('summary')))
|
||||
await q.first().locator('summary').click()
|
||||
check('……展开:SQL 和前几行,遮罩列的值写「已遮罩」', await until(async () => q.first().locator('[data-assistant-query-sql]').isVisible())
|
||||
&& (await textOf(q.first().locator('[data-assistant-query-sql]'))).startsWith('SELECT gate_kind')
|
||||
&& await q.first().locator('[data-assistant-query-rows] tbody tr').count() === 2
|
||||
&& (await textOf(q.first().locator('[data-assistant-query-rows]'))).includes('已遮罩'))
|
||||
check('……写明这一轮查询了 2 次', (await textOf(lastTurn(page).locator('[data-assistant-turn-status]'))).includes('查询 2 次'),
|
||||
await q.nth(2).locator('summary').click()
|
||||
check('……后面还有行的展开:写取回前 50 行(后面还有),显示前 10 行', await until(async () => q.nth(2).locator('[data-assistant-query-rows]').isVisible())
|
||||
&& (await textOf(q.nth(2).locator('[data-assistant-query-rows] caption'))) === '取回前 50 行(后面还有),显示前 10 行',
|
||||
await textOf(q.nth(2).locator('[data-assistant-query-rows] caption')))
|
||||
check('……写明这一轮查询了 3 次', (await textOf(lastTurn(page).locator('[data-assistant-turn-status]'))).includes('查询 3 次'),
|
||||
await textOf(lastTurn(page).locator('[data-assistant-turn-status]')))
|
||||
await shot(page, 'catalog-assistant-query')
|
||||
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
|
||||
await close()
|
||||
})
|
||||
|
||||
await section('表助手 · 拿不到起草时记下的留空列名', async () => {
|
||||
const state = freshState()
|
||||
const gates = state.tables[S2].find((t) => t.name === 'gates')
|
||||
const leadOf = async (page) => {
|
||||
await page.locator('[data-catalog-assistant-open="header"]').click()
|
||||
await panel(page).waitFor()
|
||||
const box = panel(page).locator('[data-assistant-blank]')
|
||||
await until(async () => (await box.getAttribute('data-assistant-blank').catch(() => null)) !== null)
|
||||
return { from: await box.getAttribute('data-assistant-blank').catch(() => null), text: await textOf(box) }
|
||||
}
|
||||
// 那次起草的任务已经不在了(404):改说目录里还没有中文名和含义的列,按钮上的数字就是点名的列数
|
||||
gates.lastDraft = { ...gates.lastDraft, job_id: 'check-job-gone' }
|
||||
const replies = { assistant: [sse([{ type: 'text', delta: '好的。' }, done()])] }
|
||||
const { page, sent, errors, close } = await open(`/data/catalog/${S2}/gates`, { handlers: loopHandlers(state, replies) })
|
||||
await until(async () => (await page.locator('[data-catalog-detail="gates"][data-version]').count()) > 0)
|
||||
const gone = await leadOf(page)
|
||||
check('任务不在了:不再说这几列是留空的列,改说目录里还有 3 列没有中文名和含义', gone.from === 'catalog'
|
||||
&& gone.text === '上次起草有 2 列模型拿不准、留空了。目录里还有 3 列没有中文名和含义,例如 opened_on、gate_code、gate_kind,可以先从这些列开始。', gone.text)
|
||||
const suggest = panel(page).locator('[data-assistant-suggest="blank"]')
|
||||
check('……按钮写「补上没有中文名和含义的 3 列」(不写成「补上留空的 2 列」)', (await textOf(suggest)) === '补上没有中文名和含义的 3 列', await textOf(suggest))
|
||||
await suggest.click()
|
||||
await until(async () => (await streaming(page)) === 'false' && count(sent, /\/assistant$/) === 1)
|
||||
const msg = lastSent(sent, /\/assistant$/)?.body?.messages?.[0]?.content ?? ''
|
||||
check('……点名的就是这 3 列', msg === '请帮我补上这几列的中文名和含义:opened_on、gate_code、gate_kind。拿不准的地方先问我。', msg)
|
||||
await close()
|
||||
|
||||
// 最近一次停止了、没有新写入:留空列数是再之前那次的,不知道是哪一次——不去取停止的那次任务,改说目录里的
|
||||
gates.lastDraft = { status: 'cancelled', job_id: 'check-job-stopped', finished_at: T0, error: null, model_error: null,
|
||||
blank_columns: 0, blank_columns_from: 2, parts_written: 0 }
|
||||
const again = await open(`/data/catalog/${S2}/gates`, { handlers: loopHandlers(state, {}) })
|
||||
await until(async () => (await again.page.locator('[data-catalog-detail="gates"][data-version]').count()) > 0)
|
||||
const earlier = await leadOf(again.page)
|
||||
check('最近一次停止了:用再之前那次的留空列数,不去取停止的那次任务,改说目录里还没有中文名和含义的列',
|
||||
earlier.from === 'catalog' && earlier.text.startsWith('上次起草有 2 列模型拿不准、留空了。目录里还有 3 列没有中文名和含义')
|
||||
&& count(again.sent, /draft-jobs\/check-job-stopped$/) === 0, `${earlier.text} · ${count(again.sent, /draft-jobs\//)}`)
|
||||
check('没有运行时报错', errors.length === 0 && again.errors.length === 0, errors[0] ?? again.errors[0] ?? '')
|
||||
await again.close()
|
||||
})
|
||||
|
||||
await section('表助手 · 一次最多保存 200 项', async () => {
|
||||
const state = freshState()
|
||||
const changes = []
|
||||
@@ -877,7 +1067,12 @@ await section('表助手 · 一次最多保存 200 项', async () => {
|
||||
changes.push({ path: `columns.${c}.label`, before: null, before_status: null, after: `属性 ${i}`, value: `属性 ${i}`, reason: '按列名推断', state: 'change' })
|
||||
changes.push({ path: `columns.${c}.meaning`, before: null, before_status: null, after: `促销属性 ${i}`, value: `促销属性 ${i}`, reason: '按列名推断', state: 'change' })
|
||||
}
|
||||
const replies = { assistant: [sse([{ type: 'proposal', source_id: S1, table: 'promo_attrs', version: 2, changes }, done()])] }
|
||||
const replies = {
|
||||
assistant: [
|
||||
sse([{ type: 'proposal', source_id: S1, table: 'promo_attrs', version: 2, changes }, done()]),
|
||||
sse([{ type: 'text', delta: '好的,先说这几列。' }, done()]),
|
||||
],
|
||||
}
|
||||
const { page, sent, errors, close } = await open(`/data/catalog/${S1}/promo_attrs`, { handlers: loopHandlers(state, replies) })
|
||||
await until(async () => (await page.locator('[data-catalog-detail="promo_attrs"][data-version]').count()) > 0)
|
||||
await page.locator('[data-catalog-assistant-open="header"]').click()
|
||||
@@ -885,6 +1080,13 @@ await section('表助手 · 一次最多保存 200 项', async () => {
|
||||
await say(page, '把这些列都补上中文名和含义')
|
||||
check('202 项待保存:保存按钮不能点,写明一次最多 200 项', await pendingCount(page) === 202 && await panel(page).locator('[data-assistant-save]').isDisabled()
|
||||
&& (await textOf(panel(page).locator('[data-assistant-too-many]'))).includes('一次最多保存 200 项'))
|
||||
// 待保存的超过 200 项时接着问:服务端一轮最多收 200 项待保存的修改(多了整个请求 422),只交最近的 200 项
|
||||
await say(page, '先说 attr_101 是什么')
|
||||
const asked = lastSent(sent, /\/assistant$/)?.body?.pending ?? []
|
||||
check('……接着问:只交最近的 200 项待保存的修改,这一轮照常答出来', asked.length === 200 && asked[0]?.path === 'columns.attr_002.label'
|
||||
&& asked.at(-1)?.path === 'columns.attr_101.meaning' && (await lastTurn(page).getAttribute('data-turn-state')) === 'done',
|
||||
`${asked.length} · ${asked[0]?.path} · ${asked.at(-1)?.path} · ${await lastTurn(page).getAttribute('data-turn-state')}`)
|
||||
check('……界面上的待保存照旧 202 项(只是没全交给助手)', await pendingCount(page) === 202)
|
||||
for (const p of ['columns.attr_101.meaning', 'columns.attr_101.label']) {
|
||||
await panel(page).locator(`[data-assistant-pending-item="${p}"] [data-assistant-pending-remove]`).click()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user