From 1d59ba23dc9fba16c73a79d33bdad540e76da5aa Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 14 Oct 2026 10:10:38 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95=E9=97=AD?= =?UTF-8?q?=E7=8E=AF=EF=BC=88=E5=89=8D=E7=AB=AF=EF=BC=89=E5=A4=8D=E6=A0=B8?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=EF=BC=88=E8=A1=A8=E5=8A=A9=E6=89=8B=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E4=BF=9D=E5=AD=98=E6=92=9E=E4=B8=8A=20409=20=E5=90=8E?= =?UTF-8?q?=E5=8F=AA=E6=8C=89=E9=A2=84=E8=A7=88=E9=87=8D=E7=AE=97=E4=BA=A4?= =?UTF-8?q?=E5=8E=BB=E4=BF=9D=E5=AD=98=E7=9A=84=E9=82=A3=E5=87=A0=E9=A1=B9?= =?UTF-8?q?=EF=BC=8C=E4=BF=9D=E5=AD=98=E3=80=81=E9=87=8D=E7=AE=97=E6=9C=9F?= =?UTF-8?q?=E9=97=B4=E5=9B=9E=E7=AD=94=E9=87=8C=E5=8F=88=E5=88=B0=E7=9A=84?= =?UTF-8?q?=E6=8F=90=E6=A1=88=E5=8E=9F=E6=A0=B7=E7=95=99=E7=9D=80=E3=80=81?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=A2=AB=E5=BD=93=E6=88=90=E4=B8=8D=E5=90=88?= =?UTF-8?q?=E6=B3=95=E6=82=84=E6=82=84=E5=8E=BB=E6=8E=89=EF=BC=8C=E6=8F=90?= =?UTF-8?q?=E6=A1=88=E5=88=B0=E4=BA=86=E4=B9=9F=E4=B8=8D=E6=8A=8A=E6=AD=A3?= =?UTF-8?q?=E5=9C=A8=E8=BF=9B=E8=A1=8C=E7=9A=84=E4=BF=9D=E5=AD=98=E7=BF=BB?= =?UTF-8?q?=E5=9B=9E=E5=8F=AF=E4=BB=A5=E7=82=B9=EF=BC=9B=E4=BA=A4=E7=BB=99?= =?UTF-8?q?=E5=8A=A9=E6=89=8B=E7=9A=84=E5=BE=85=E4=BF=9D=E5=AD=98=E7=9A=84?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=9C=80=E5=A4=9A=20200=20=E9=A1=B9=EF=BC=88?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=E5=90=8C=E4=B8=80=E4=B8=AA=E4=B8=8A?= =?UTF-8?q?=E9=99=90=EF=BC=8C=E5=A4=9A=E4=BA=86=E6=95=B4=E4=B8=AA=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=20422=EF=BC=89=EF=BC=8C=E8=B6=85=E5=87=BA=E6=97=B6?= =?UTF-8?q?=E5=8F=AA=E4=BA=A4=E6=9C=80=E8=BF=91=E7=9A=84=20200=20=E9=A1=B9?= =?UTF-8?q?=EF=BC=9B=E5=BC=80=E5=9C=BA=E7=9A=84=E7=95=99=E7=A9=BA=E5=88=97?= =?UTF-8?q?=E5=8F=96=E9=82=A3=E6=AC=A1=E8=B5=B7=E8=8D=89=E8=AE=B0=E4=B8=8B?= =?UTF-8?q?=E7=9A=84=E5=88=97=E5=90=8D=EF=BC=88=E4=BB=8E=E9=82=A3=E6=AC=A1?= =?UTF-8?q?=E8=B5=B7=E8=8D=89=E4=BB=BB=E5=8A=A1=E9=87=8C=E8=BF=99=E5=BC=A0?= =?UTF-8?q?=E8=A1=A8=E7=9A=84=20blank=5Fsample=20=E5=8F=96=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E6=B2=A1=E5=86=99=E5=87=BA=E6=9D=A5=E7=9A=84=E5=88=97?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=BD=93=E6=88=90=E7=95=99=E7=A9=BA=E7=9A=84?= =?UTF-8?q?=E5=88=97=E4=B8=BE=E4=BE=8B=EF=BC=8C=E3=80=8C=E8=A1=A5=E4=B8=8A?= =?UTF-8?q?=E7=95=99=E7=A9=BA=E7=9A=84=20N=20=E5=88=97=E3=80=8D=E7=82=B9?= =?UTF-8?q?=E5=90=8D=E7=9A=84=E6=AD=A3=E6=98=AF=E8=BF=99=20N=20=E5=88=97?= =?UTF-8?q?=EF=BC=8C=E8=AE=B0=E5=85=A8=E4=BA=86=E6=97=B6=E5=8F=AA=E7=AE=97?= =?UTF-8?q?=E8=BF=98=E6=B2=A1=E8=A1=A5=E4=B8=8A=E7=9A=84=EF=BC=9B=E6=8B=BF?= =?UTF-8?q?=E4=B8=8D=E5=88=B0=EF=BC=88=E6=9C=80=E8=BF=91=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E5=81=9C=E6=AD=A2=E4=BA=86=E3=80=81=E4=BB=BB=E5=8A=A1=E4=B8=8D?= =?UTF-8?q?=E5=9C=A8=E4=BA=86=E3=80=81=E8=80=81=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E6=B2=A1=E6=9C=89=E5=88=97=E5=90=8D=EF=BC=89=E6=97=B6=E6=94=B9?= =?UTF-8?q?=E8=AF=B4=E3=80=8C=E7=9B=AE=E5=BD=95=E9=87=8C=E8=BF=98=E6=9C=89?= =?UTF-8?q?=20M=20=E5=88=97=E6=B2=A1=E6=9C=89=E4=B8=AD=E6=96=87=E5=90=8D?= =?UTF-8?q?=E5=92=8C=E5=90=AB=E4=B9=89=E3=80=8D=EF=BC=8C=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E4=B8=8A=E7=9A=84=E6=95=B0=E5=AD=97=E5=B0=B1=E6=98=AF=E7=82=B9?= =?UTF-8?q?=E5=90=8D=E7=9A=84=E5=88=97=E6=95=B0=EF=BC=9B=E5=8F=AA=E8=AF=BB?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E5=90=8E=E9=9D=A2=E8=BF=98=E6=9C=89=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=E5=86=99=E3=80=8C=E5=8F=96=E5=9B=9E=E5=89=8D=2050=20?= =?UTF-8?q?=E8=A1=8C=EF=BC=8C=E5=90=8E=E9=9D=A2=E8=BF=98=E6=9C=89=E3=80=8D?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E8=AF=B4=E6=88=90=E7=BB=93=E6=9E=9C=E4=B8=80?= =?UTF-8?q?=E5=85=B1=2050=20=E8=A1=8C=EF=BC=9Bcheck-catalog-loop=20?= =?UTF-8?q?=E7=9A=84=E5=81=87=E4=BA=8B=E4=BB=B6=E6=B5=81=E7=85=A7=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E7=AB=AF=E5=A4=B9=20SSE=20=E6=B3=A8=E9=87=8A=E8=A1=8C?= =?UTF-8?q?=EF=BC=88=E3=80=8C:=20keep-alive=E3=80=8D=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E6=8F=90=E6=A1=88=E5=B8=A6=20source=E3=80=81table=5Flabel?= =?UTF-8?q?=EF=BC=8C=E5=87=BA=E9=94=99=E7=A0=81=E7=94=A8=20assistant=5F*?= =?UTF-8?q?=EF=BC=8C=E5=81=87=E6=9C=8D=E5=8A=A1=E7=AB=AF=E7=85=A7=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E7=AB=AF=E7=9A=84=E4=B8=8A=E9=99=90=E5=9B=9E=20422?= =?UTF-8?q?=EF=BC=8C=E8=A1=A5=E5=9B=9E=E7=AD=94=E8=BF=9B=E8=A1=8C=E4=B8=AD?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E6=92=9E=E4=B8=8A=20409=E3=80=81=E7=95=99?= =?UTF-8?q?=E7=A9=BA=E5=88=97=E7=9A=84=E4=B8=89=E7=A7=8D=E5=87=BA=E5=A4=84?= =?UTF-8?q?=E3=80=81=E5=BE=85=E4=BF=9D=E5=AD=98=E8=B6=85=E8=BF=87=20200=20?= =?UTF-8?q?=E9=A1=B9=E6=8E=A5=E7=9D=80=E9=97=AE=E7=9A=84=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/lib/terms.ts | 46 +++- frontend/src/pages/catalog/TableAssistant.tsx | 60 +++-- frontend/src/pages/catalog/TableDetail.tsx | 3 +- frontend/src/pages/catalog/assistant.ts | 69 ++++- frontend/src/types.ts | 8 +- scripts/check-catalog-loop.mjs | 240 ++++++++++++++++-- 6 files changed, 372 insertions(+), 54 deletions(-) diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 0e1c6428..0071a7c9 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -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)} 项)`, diff --git a/frontend/src/pages/catalog/TableAssistant.tsx b/frontend/src/pages/catalog/TableAssistant.tsx index caf55712..2d0e0648 100644 --- a/frontend/src/pages/catalog/TableAssistant.tsx +++ b/frontend/src/pages/catalog/TableAssistant.tsx @@ -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(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 (