From a9ffbcfa86f1ea1e4db6ca4e92dbc76b9b5ea0cf Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 14 Oct 2026 08:44:14 +0800 Subject: [PATCH 1/9] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E9=97=AD=E7=8E=AF=EF=BC=88=E5=89=8D=E7=AB=AF=EF=BC=89=EF=BC=9A?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E3=80=81=E6=8E=A5=E5=8F=A3=E3=80=81=E6=96=87?= =?UTF-8?q?=E6=A1=88=E5=92=8C=E7=BA=AF=E5=87=BD=E6=95=B0=E2=80=94=E2=80=94?= =?UTF-8?q?=E8=B5=B7=E8=8D=89=E4=BB=BB=E5=8A=A1=E7=9A=84=E9=80=90=E6=AE=B5?= =?UTF-8?q?=E8=90=BD=E7=9B=98=E3=80=81=E5=9D=97=E8=BF=9B=E5=BA=A6=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=92=8C=E6=B8=85=E5=8D=95=E7=9A=84=20blank=5Fcolumns?= =?UTF-8?q?=5Ffrom=EF=BC=9B=E5=AE=A1=E9=98=85=E9=98=9F=E5=88=97=E3=80=81?= =?UTF-8?q?=E6=88=90=E6=89=B9=E5=AE=A1=E9=98=85=E3=80=81=E5=AE=A1=E9=98=85?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E3=80=81=E7=94=A8=E8=BF=87=E8=BF=99=E5=BC=A0?= =?UTF-8?q?=E8=A1=A8=E7=9A=84=E8=BF=90=E8=A1=8C=EF=BC=9B=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=EF=BC=88=E4=B8=8B=E8=BD=BD=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E3=80=81=E9=A2=84=E8=A7=88=E3=80=81=E5=AF=BC=E5=85=A5=EF=BC=89?= =?UTF-8?q?=EF=BC=9B=E8=A1=A8=E5=8A=A9=E6=89=8B=E7=9A=84=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E6=B5=81=EF=BC=88=E6=8C=89=E8=A1=8C=E8=AF=BB=20data:=20?= =?UTF-8?q?=EF=BC=8C=E5=BC=80=E6=B5=81=E5=89=8D=E8=A2=AB=E6=8B=92=E5=B8=A6?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E7=A0=81=E5=92=8C=E6=9C=BA=E8=AF=BB=E7=A0=81?= =?UTF-8?q?=EF=BC=8C=E8=83=BD=E5=81=9C=E6=AD=A2=EF=BC=89=EF=BC=9B=E8=A1=A8?= =?UTF-8?q?=E5=8A=A9=E6=89=8B=E7=9A=84=E5=AF=B9=E8=AF=9D=E6=80=8E=E4=B9=88?= =?UTF-8?q?=E6=88=AA=EF=BC=88=E6=9C=80=E5=A4=9A=2040=20=E6=9D=A1=E3=80=81?= =?UTF-8?q?=E6=AF=8F=E6=9D=A1=204000=20=E5=AD=97=E3=80=81=E4=BB=8E?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=9A=84=E8=AF=9D=E5=BC=80=E5=A7=8B=EF=BC=89?= =?UTF-8?q?=E3=80=81=E5=BE=85=E4=BF=9D=E5=AD=98=E7=9A=84=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E6=80=8E=E4=B9=88=E7=B4=AF=E7=A7=AF=EF=BC=88=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E4=BB=A5=E6=9C=80=E5=90=8E=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E6=8F=90=E6=A1=88=E4=B8=BA=E5=87=86=E3=80=81=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E5=B8=A6=E6=9C=80=E6=97=A9=E9=82=A3=E6=9D=A1=E6=8F=90=E6=A1=88?= =?UTF-8?q?=E7=9A=84=E7=89=88=E6=9C=AC=E3=80=81409=20=E5=90=8E=E6=8C=89?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E9=87=8D=E7=AE=97=EF=BC=89=EF=BC=9B=E5=AE=A1?= =?UTF-8?q?=E9=98=85=E8=A7=86=E5=9B=BE=E7=9A=84=E5=9C=B0=E5=9D=80=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E3=80=81=E6=88=90=E6=89=B9=E7=BB=93=E6=9E=9C=E5=92=8C?= =?UTF-8?q?=E8=A1=A8=E8=AF=A6=E6=83=85=E6=94=B9=E8=BF=87=E4=B9=8B=E5=90=8E?= =?UTF-8?q?=E7=9A=84=E7=8A=B6=E6=80=81=E5=90=8C=E6=AD=A5=EF=BC=9B=E6=96=87?= =?UTF-8?q?=E6=A1=88=E8=BF=9B=20lib/terms.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/client.ts | 130 +++++++++- frontend/src/lib/terms.ts | 304 +++++++++++++++++++++++- frontend/src/pages/catalog/assistant.ts | 213 +++++++++++++++++ frontend/src/pages/catalog/review.ts | 132 ++++++++++ frontend/src/run/catalogPatch.ts | 7 +- frontend/src/types.ts | 182 ++++++++++++++ 6 files changed, 959 insertions(+), 9 deletions(-) create mode 100644 frontend/src/pages/catalog/assistant.ts create mode 100644 frontend/src/pages/catalog/review.ts diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index eb0f3abf..6827f719 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,6 +1,8 @@ import type { - ActivateSnapshotBody, ActivateSnapshotOut, AiPreview, CatalogDetail, CatalogDraftJob, CatalogImpact, CatalogList, CatalogNotes, - CatalogPatchPreview, CatalogPatchSubmit, CatalogProfileOut, CatalogReviewAction, CommitOut, CurrentRecipe, EditPreview, + ActivateSnapshotBody, ActivateSnapshotOut, AiPreview, CatalogAssistantEvent, CatalogAssistantRequest, CatalogDetail, CatalogDraftJob, + CatalogImpact, CatalogImportReport, CatalogList, CatalogNotes, CatalogPatchPreview, CatalogPatchSubmit, CatalogProfileOut, + CatalogReviewAction, CatalogReviewBatchItem, CatalogReviewBatchResult, CatalogReviewKind, CatalogReviewProgress, CatalogReviewQueue, + CatalogTableUsage, CommitOut, CurrentRecipe, EditPreview, EditRequest, ImportRecord, ManifestOut, PurgeRawBody, PurgeRawOut, QuestionAnswer, Recipe, RedraftRulesOut, RemovePeriodBody, RemovePeriodOut, RevokeAcceptanceBody, RevokeAcceptanceOut, SnapshotOut, Staging, @@ -15,7 +17,7 @@ import { ApiError, BASE, NETWORK_MESSAGE, actorHeader, codeOf, del, describeDetail, failure, get, isGatewayFailure, parseBody, patch, post, put, qs, report, request, upload, type RequestOptions, type UploadOptions, } from './http' -import { bundle } from './bundle' +import { bundle, dispositionName } from './bundle' import { compare } from './compare' import { inquiry } from './inquiry' import { schedules } from './schedules' @@ -451,6 +453,51 @@ export const api = { profile: (sourceId: string, body: { tables?: string[] }, opts?: RequestOptions) => request(`/datasources/${encodeURIComponent(sourceId)}/catalog/profile`, { method: 'POST', body: JSON.stringify(body), ...opts }), + // ---- 审阅队列(只列推断项,按表的使用次数、这一项对数字的影响和来源排好) + /** 一页待确认项。kind:全部 / 影响数字的项 / 名称和含义;table 只看这一张表 */ + reviewQueue: (sourceId: string, params: { limit?: number; offset?: number; kind?: CatalogReviewKind; table?: string | null }, + opts?: RequestOptions) => + get(`${catalogBase(sourceId)}/review-queue${qs({ limit: 50, offset: 0, kind: 'all', ...params })}`, opts), + /** + * 成批确认、驳回、撤销审阅(最多 200 条):逐表按 if_version 乐观锁,一张表冲突不影响别的表,同一张表的几条一次写入。 + * 逐条回结果:成功的带这张表写入后的版本,冲突、不合法的带原因和机读码 + */ + reviewBatch: (sourceId: string, items: CatalogReviewBatchItem[]) => + post<{ results: CatalogReviewBatchResult[] }>(`${catalogBase(sourceId)}/review/batch`, { items }), + /** 审阅进度:使用次数前 top_n 张表、全部表、影响数字的项 */ + reviewProgress: (sourceId: string, opts?: RequestOptions) => + get(`${catalogBase(sourceId)}/review-progress`, opts), + /** 用过这张表的运行,新的在前 */ + usage: (sourceId: string, table: string, limit = 20, opts?: RequestOptions) => + get(`${catalogBase(sourceId)}/${encodeURIComponent(table)}/usage${qs({ limit })}`, opts), + // ---- 导出导入 + /** + * 导出这个数据源的数据目录(GET /catalog/export,附件):回来的是文件本身和服务端给的文件名,没给就是 null,由调用方 + * 按数据源名补。原样存下,不解析:导入时交回的就是这份 + */ + exportFile: async (sourceId: string): Promise<{ blob: Blob; name: string | null }> => { + const path = `${catalogBase(sourceId)}/export` + let res: Response + try { + res = await fetch(BASE + path, { headers: actorHeader() }) + } catch (e: any) { + const err = new ApiError(0, NETWORK_MESSAGE, { kind: 'network', raw: `${e?.name ?? 'Error'}: ${e?.message ?? e}` }) + report(false, err) + throw err + } + if (!res.ok) throw failure(res.status, res.statusText, await res.text().catch(() => ''), path) + report(true) + return { blob: await res.blob(), name: dispositionName(res.headers.get('Content-Disposition')) } + }, + /** 导入的预览(只读):哪些表对上了,各有几项新增、更新、保留、冲突、跳过,冲突逐条列出 */ + importPreview: (sourceId: string, body: { content: unknown; include_guidance: boolean }) => + post(`${catalogBase(sourceId)}/import/preview`, body), + /** + * 导入:规则同预览,逐表乐观锁,一张表失败不影响别的。有排队、进行中的起草任务时 409(catalog_draft_busy); + * 文件格式不对 422(detail 是一句中文) + */ + importCatalog: (sourceId: string, body: { content: unknown; include_guidance: boolean }) => + post(`${catalogBase(sourceId)}/import`, body), }, runs: { @@ -867,6 +914,83 @@ export function streamCopilot( return () => controller.abort() } +/** 表助手一轮结束时的情况:error 是给人看的那句话(开流前被拒时带状态码和机读码);aborted 是点了停止 */ +export interface CatalogAssistantEnd { + error?: string + status?: number + code?: string + aborted?: boolean +} + +/** + * 表助手的一轮(POST /datasources/{id}/catalog/{table}/assistant):回答是事件流,每个事件一行 data: ,到一个回调 + * 一个(text 增量、query 只读查询、proposal 修改提案、done、error)。返回取消函数,就是「停止回答」。 + * + * 开流之前就被拒(表不在 404、起草模型用不了 409 catalog_draft_model_unavailable、请求太长 422)时 onEnd 带服务端那句话、 + * 状态码和机读码;流到一半断了和一开始就连不上一样,说无法连接服务。认不出的行(心跳、半截)跳过 + */ +export function streamCatalogAssistant( + sourceId: string, + table: string, + body: CatalogAssistantRequest, + onEvent: (event: CatalogAssistantEvent) => void, + onEnd: (end: CatalogAssistantEnd) => void, +): () => void { + const controller = new AbortController() + const path = `${catalogBase(sourceId)}/${encodeURIComponent(table)}/assistant` + const emit = (line: string) => { + const text = line.replace(/\r$/, '') + if (!text.startsWith('data:')) return + const payload = text.slice(5).trim() + if (!payload) return + try { + const event = parseBody(payload) + if (event && typeof event === 'object' && typeof event.type === 'string') onEvent(event as CatalogAssistantEvent) + } catch { /* 认不出的一行,跳过 */ } + } + void (async () => { + try { + const res = await fetch(BASE + path, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream', ...actorHeader() }, + body: JSON.stringify(body), + signal: controller.signal, + }) + if (!res.ok || !res.body) { + const err = failure(res.status, res.statusText, await res.text().catch(() => ''), path) + onEnd(err.kind === 'network' ? { error: NETWORK_MESSAGE } : { error: err.message, status: res.status, code: err.code }) + return + } + report(true) + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + for (;;) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + let idx: number + while ((idx = buffer.indexOf('\n')) >= 0) { + emit(buffer.slice(0, idx)) + buffer = buffer.slice(idx + 1) + } + } + buffer += decoder.decode() + if (buffer) emit(buffer) + onEnd({}) + } catch (e: any) { + if (e?.name === 'AbortError') { onEnd({ aborted: true }); return } + if (e instanceof TypeError) { + report(false, new ApiError(0, NETWORK_MESSAGE, { kind: 'network', raw: String(e) })) + onEnd({ error: NETWORK_MESSAGE }) + return + } + onEnd({ error: e?.message ?? NETWORK_MESSAGE }) + } + })() + return () => controller.abort() +} + /** * 订阅一次运行的事件流。 * diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 663b6329..148bc7bb 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -2445,14 +2445,24 @@ export const CATALOG_DRAFT_TEXT = { queued: '排队中,等待开始…', running: '正在起草…', // 服务端点了停止就取消进行中的模型调用(不等它答完,一次调用可能要几分钟),只等手上正在写入目录的那张表写完 - stopping: '正在停止:取消进行中的模型调用,正在写入目录的表写完后停止…', + // 宽表每写完一段就写进目录:停下来时已写进的段保留,只停掉进行中的调用 + stopping: '正在停止:取消进行中的模型调用,已写进目录的段保留…', progress: (done: number, total: number) => `已完成 ${formatNumber(done)} / ${formatNumber(total)} 张表`, /** 结束以后:全部写出来的表有几张(停止、失败、部分完成的不算) */ progressDone: (ok: number, total: number) => `写完 ${formatNumber(ok)} / ${formatNumber(total)} 张表`, parts: (done: number, total: number) => `${formatNumber(done)} / ${formatNumber(total)} 段`, - partsHint: '超过 40 列的表拆成表级一段和若干列分段,逐段起草', + partsHint: '超过 40 列的表拆成表级一段和若干列分段,逐段起草,每写完一段就写进目录', + /** 段被缩小、拆半后实际分成的块(比段多时才写) */ + chunks: (done: number, total: number) => `${formatNumber(done)} / ${formatNumber(total)} 块`, + chunksHint: (total: number) => `模型用完额度或超时后,段被缩小、拆半:这张表实际分成 ${formatNumber(total)} 块起草`, + /** 已经写进目录的段(停止、服务重启时保留) */ + partsWritten: (n: number) => `已写进目录 ${formatNumber(n)} 段`, + partsWrittenHint: '每写完一段就写进目录,停止或服务重启时已写进的段保留', + /** 停下来的宽表:服务端的原因里没写时,界面补这一句 */ + partsWrittenStopped: (n: number, rest: number) => (rest > 0 + ? `已写进目录 ${formatNumber(n)} 段,剩下 ${formatNumber(rest)} 段没起草` : `已写进目录 ${formatNumber(n)} 段`), hide: '在后台继续', - hideHint: '关闭对话框不会停止起草,进度显示在目录页上方;每张表起草完就写进目录', + hideHint: '关闭对话框不会停止起草,进度显示在目录页上方;每写完一段就写进目录', stop: '停止', busy: '这个数据源已有进行中的起草任务,下面显示的是它的进度', modelUsed: (model: string) => `模型:${model}`, @@ -2490,8 +2500,11 @@ export const CATALOG_DRAFT_TEXT = { cancelled: '已停止', interrupted: '服务重启,起草中断', } as Record, - interrupted: '服务重启时这次起草还没有完成,没写完的表未起草。已写进目录的内容保留;可以只重试这些表,接着起草', - cancelled: '已写进目录的内容保留,没写完的表未起草', + interrupted: '服务重启时这次起草还没有完成,没写完的部分未起草。已写进目录的内容保留(每写完一段就写进目录);可以只重试这些表,接着起草', + cancelled: '已写进目录的内容保留(每写完一段就写进目录),没写完的部分未起草', + /** 停下来时有表已经写进了一部分段 */ + cancelledParts: (tables: number, parts: number) => + `其中 ${formatNumber(tables)} 张表已写进目录 ${formatNumber(parts)} 段;只重试这些表时接着起草剩下的段,已写进的不会丢`, // 任务整体出错的原因(job.error)照服务端的原话写在标题「起草失败」下面,不再加前缀:服务端的原因有的已经是整句 // (「数据源不存在……」),有的自带「起草任务出错:」开头。没结束的表记失败、原因同任务的,逐表不再重复 /** 没写全的表:失败、部分完成、已停止各几张;跳过的(表结构里没有)另说 */ @@ -2539,6 +2552,8 @@ export const CATALOG_DRAFT_TEXT = { /** 上次起草有留空的列:清单上是轻提示(不是失败标记),表详情顶部同一句 */ lastBlank: (n: number) => `上次起草有 ${formatNumber(n)} 列模型拿不准、留空了`, lastBlankAdvice: '可以在起草要求里补充缩写和术语的含义,再重新起草这张表,已有的内容不会丢', + /** 最近一次起草停止了、没有新写入:留空的列是再之前那次写完的起草记下的 */ + lastBlankEarlier: '最近一次起草已停止,留空的列按再之前那次起草记下的', draftGuidance: '补充起草要求', draftGuidanceHint: '打开起草设置,范围是这张表:先在起草要求里补充缩写和术语的含义,再开始起草', redraft: '重新起草这张表', @@ -2953,6 +2968,285 @@ export const CATALOG_UI_TEXT = { cardinalityCheckedShort: '已核实', cardinalityCheckedHint: '基数由数据剖析用数据核实(子表一侧确有重复或确实唯一),一对多关联后重复计算的检查据此报错误', emptyTables: (n: number) => `挑表时跳过 ${formatNumber(n)} 张空表`, + // ---- 表详情里有效记录条件、去重规则的说明(模型起草时附的依据、数据剖析核对的排除比例) + fieldNote: '说明', +} + +// =========================================================================== +// 表助手(表详情里「和助手一起完善」):和助手对话完善一张表的数据目录。 +// +// 术语: +// - 面板叫「表助手」,入口写「和助手一起完善」。助手只改数据目录、不改业务库;只记数据事实,不写公式。 +// - 助手提出的修改叫「修改提案」,每一项写「改前 / 改后 / 依据」,可以逐项「去掉」「加回」。 +// - 「待保存的修改」:跨多轮累积,同一项以最后一次提案为准;「保存 N 项修改」一次写入,改动的项记为人工填写、已确认。 +// - 只读查询只在开了数据剖析的源上有:写「查询了数据」,SQL 和前几行可以展开;遮罩列的值写「已遮罩」(服务端给的)。 +// - 对话不保存在服务端:关闭表助手、换表、离开页面后就没有了。 +// =========================================================================== + +export const CATALOG_ASSISTANT_TEXT = { + open: '和助手一起完善', + openHint: '和表助手对话完善这张表的数据目录:说出业务事实,助手整理成修改提案,确认后再保存', + openMissing: '表结构中已没有这张表,不能和助手一起完善', + title: '表助手', + subtitle: (table: string) => `完善「${table}」的数据目录`, + close: '关闭表助手', + closeTitle: '关闭表助手?', + closeConsequences: ['待保存的修改和这段对话都会丢失'], + closeConfirm: '放弃并关闭', + leaveTitle: '表助手有未保存的修改', + leaveConsequences: ['离开后待保存的修改和这段对话都会丢失'], + leaveConfirm: '放弃并离开', + noProfile: '这个数据源没开数据剖析,助手只能根据表结构和目录推断,不能查数据', + profileOn: '这个数据源开了数据剖析:助手可以发少量只读查询核对数据,遮罩列不取值', + notStored: '对话不保存在服务端,关闭表助手或离开页面后就没有了', + greeting: '说出你知道的业务事实(某一列的含义、码值、有效记录条件……),助手整理成修改提案,你确认后才写进数据目录。助手只记数据事实,不写计算公式。', + /** 上次起草有留空的列:开场引导先问这几列 */ + blankLead: (n: number, cols: string[]) => + `上次起草有 ${formatNumber(n)} 列模型拿不准、留空了${cols.length ? `,例如 ${cols.join('、')}` : ''}。可以先从这几列开始。`, + suggestLabel: '可以这样开始', + suggestBlank: (n: number) => `补上留空的 ${formatNumber(n)} 列`, + suggestBlankText: (cols: string[]) => `请帮我补上这几列的中文名和含义:${cols.join('、')}。拿不准的地方先问我。`, + suggestFacts: '核对有效记录条件和去重规则', + suggestFactsText: '这张表的有效记录条件和去重规则应该怎么写?拿不准的地方先问我。', + suggestPending: '看看哪些推断可以确认', + suggestPendingText: '这张表还没确认的推断项里,哪些可以直接确认、哪些需要我补充?', + you: '你', + assistant: '助手', + logLabel: '和表助手的对话', + inputLabel: '给表助手的消息', + inputPlaceholder: '例如:status 为 9 表示作废,统计时要排除', + inputHint: 'Enter 发送,Shift+Enter 换行', + count: (n: number, max: number) => `${formatNumber(n)} / ${formatNumber(max)} 字`, + tooLong: (max: number) => `一条消息不超过 ${formatNumber(max)} 字`, + send: '发送', + stop: '停止回答', + answering: '助手正在回答…', + answered: '助手已回答', + stopped: '已停止回答', + interrupted: '连接中断,这一轮没有答完', + failed: (reason: string) => `这一轮没有完成:${reason}`, + usage: (input: number, output: number) => + `输入 ${formatTokens(input, { compact: true })}、输出 ${formatTokens(output, { compact: true })}`, + queries: (n: number) => `查询 ${formatNumber(n)} 次`, + // ---- 只读查询 + query: (rows: number) => `查询了数据(${formatNumber(rows)} 行)`, + queryFailed: '查询没有成功', + queryRows: (shown: number, total: number) => (total > shown + ? `共 ${formatNumber(total)} 行,显示前 ${formatNumber(shown)} 行` : `共 ${formatNumber(total)} 行`), + queryEmpty: '没有返回行', + // ---- 修改提案 + proposal: (n: number) => `修改提案(${formatNumber(n)} 项)`, + before: '改前', + after: '改后', + reason: '依据', + drop: '去掉', + dropLabel: (where: string) => `去掉:${where}`, + restore: '加回', + restoreLabel: (where: string) => `加回:${where}`, + state: { + pending: '待保存', + replaced: '已改用另一条提案的值', + dropped: '已去掉', + saved: '已保存', + } as Record<'pending' | 'replaced' | 'dropped' | 'saved', string>, + stateConfirm: '值不变,保存即确认', + // ---- 待保存的修改 + pending: (n: number) => `待保存的修改(${formatNumber(n)} 项)`, + pendingEmpty: '还没有待保存的修改', + pendingHint: '跨多轮累积,同一项以最后一次提案为准。保存后改动的项记为人工填写、已确认', + pendingShow: '展开待保存的修改', + pendingHide: '收起待保存的修改', + save: (n: number) => `保存 ${formatNumber(n)} 项修改`, + saving: '正在保存…', + clear: '全部去掉', + clearTitle: (n: number) => `去掉全部 ${formatNumber(n)} 项待保存的修改?`, + clearConfirm: '全部去掉', + tooMany: (n: number, max: number) => `有 ${formatNumber(n)} 项,一次最多保存 ${formatNumber(max)} 项,请先去掉一些`, + editing: '正在编辑这张表,保存或取消后再保存助手的修改', + saved: (n: number, version: number) => `已保存 ${formatNumber(n)} 项修改(第 ${formatNumber(version)} 版)`, + savedToast: (n: number, table: string) => `已保存 ${formatNumber(n)} 项修改到「${table}」的数据目录`, + conflictTitle: '这张表刚被修改过', + conflictBody: '已按最新内容重算改前和改后,请核对后再保存。', + conflictFailed: '无法按最新内容重算改前和改后,请稍后重试', + problems: '以下几项已无法保存,已从待保存的修改中去掉:', +} + +// =========================================================================== +// 审阅队列(数据目录页的「审阅队列」视图):跨表列出推断项,按表的使用次数、这一项对数字的影响和来源排好, +// 可以成批确认、驳回、撤销审阅。 +// +// 术语: +// - 「影响数字的项」:有效记录条件、去重规则、业务日期、关联关系、业务主键、度量类型——写错了查出来的数就不对,排在最前; +// 「名称和含义」:中文名、含义、单位、码值、粒度、表类型。 +// - 审阅过的项原地标出结果(已确认、已驳回、未生效),刷新队列后才移除;「撤销审阅」回到起草时的状态(推断)。 +// - 「常用 20 张表」:使用次数最多的 20 张。进度写「已确认 42%」。 +// =========================================================================== + +export const CATALOG_REVIEW_TEXT = { + viewLabel: '数据目录的视图', + view: { list: '表清单', review: '审阅队列' } as Record<'list' | 'review', string>, + viewHint: { + list: '逐表查看和编辑数据目录', + review: '跨表列出待确认的推断项,按使用次数和对数字的影响排好,可以成批确认、驳回', + } as Record<'list' | 'review', string>, + queueLabel: '待确认的推断项', + kindLabel: '按项筛选', + kind: { all: '全部', facts: '影响数字的项', names: '名称和含义' } as Record<'all' | 'facts' | 'names', string>, + kindHint: { + all: '全部待确认的推断项', + facts: '有效记录条件、去重规则、业务日期、关联关系、业务主键、度量类型:写错了查出来的数就不对', + names: '中文名、含义、单位、码值、粒度、表类型', + } as Record<'all' | 'facts' | 'names', string>, + tableLabel: '按表筛选', + tableAll: '全部表', + tableOption: (label: string | null, table: string, n: number) => `${label ? `${label}(${table})` : table} · ${formatNumber(n)} 项`, + total: (n: number) => `${formatNumber(n)} 项待确认`, + shown: (shown: number, total: number) => `显示 ${formatNumber(shown)} / ${formatNumber(total)} 项`, + empty: '没有待确认的推断项', + emptyBody: '推断的项都审阅过了。之后起草出的新推断会出现在这里。', + emptyFiltered: '没有符合条件的待确认项', + emptyFilteredBody: '请换个筛选条件。', + loadFailed: '无法读取审阅队列', + retry: '重试', + refresh: '刷新队列', + refreshHint: '审阅过的项刷新后从队列中移除', + more: (n: number) => `再载入 ${formatNumber(n)} 项`, + loading: '正在载入…', + selectAll: '全选当前列表', + select: (where: string, table: string) => `选择:${table} ${where}`, + selected: (n: number) => `已选 ${formatNumber(n)} 项`, + clearSelection: '取消选择', + usage: (n: number) => `使用 ${formatNumber(n)} 次`, + usageHint: '所在表被运行查询过的次数', + open: (where: string, table: string) => `打开「${table}」,查看${where}`, + reasonsLabel: '排在这里的原因', + // ---- 成批审阅 + confirm: (n: number) => `确认(${formatNumber(n)})`, + reject: (n: number) => `驳回(${formatNumber(n)})`, + reset: (n: number) => `撤销审阅(${formatNumber(n)})`, + resetHint: '把这一页上审阅过的项回到起草时的状态(推断)', + confirmTitle: (n: number) => `确认 ${formatNumber(n)} 项推断?`, + confirmConsequences: ['确认后这些项参与 SQL 检查', '来源保持不变,状态改为已确认', '之后仍可撤销审阅'], + confirmAction: (n: number) => `确认 ${formatNumber(n)} 项`, + rejectTitle: (n: number) => `驳回 ${formatNumber(n)} 项推断?`, + rejectConsequences: ['驳回后不提供给助手、不参与 SQL 检查', '之后起草也不会再提出', '之后仍可撤销审阅'], + rejectAction: (n: number) => `驳回 ${formatNumber(n)} 项`, + tooMany: (max: number) => `一次最多审阅 ${formatNumber(max)} 项,请少选一些`, + working: '正在提交…', + done: { confirm: '已确认', reject: '已驳回', reset: '已撤销审阅' } as Record<'confirm' | 'reject' | 'reset', string>, + conflict: '未生效:这张表刚被修改过,请刷新队列后再审阅', + failed: '未生效', + /** 一批的结果:成功几项、冲突几项、别的原因没成功几项 */ + summary: (action: 'confirm' | 'reject' | 'reset', ok: number, conflicts: number, failed: number) => { + const head = `${{ confirm: '已确认', reject: '已驳回', reset: '已撤销审阅' }[action]} ${formatNumber(ok)} 项` + const bad = [ + conflicts ? `${formatNumber(conflicts)} 项未生效(所在表刚被修改过)` : '', + failed ? `${formatNumber(failed)} 项未生效` : '', + ].filter(Boolean) + return bad.length ? `${head};${bad.join('、')},请刷新队列后再审阅` : head + }, + failedAll: (reason: string) => `未能提交:${reason}`, + // ---- 进度 + progressTitle: '审阅进度', + progressTop: (n: number) => `常用 ${formatNumber(n)} 张表`, + progressTopHint: (n: number) => `使用次数最多的 ${formatNumber(n)} 张表里的项`, + progressAll: '全部表', + progressFacts: '影响数字的项', + progressFactsHint: '有效记录条件、去重规则、业务日期、关联关系、业务主键、度量类型', + confirmedPct: (pct: number | null) => (pct == null ? '还没有目录项' : `已确认 ${formatNumber(pct)}%`), + itemsOf: (confirmed: number, total: number) => `${formatNumber(confirmed)} / ${formatNumber(total)} 项`, + allDetail: (proposed: number, rejected: number) => `推断 ${formatNumber(proposed)} 项、已驳回 ${formatNumber(rejected)} 项`, + factsDetail: (proposed: number, confirmed: number) => `待确认 ${formatNumber(proposed)} 项、已确认 ${formatNumber(confirmed)} 项`, + progressFailed: '无法读取审阅进度', + // ---- 没选中项时的右栏 + overviewTitle: '从左侧队列挑一项开始审阅', + overviewBody: '队列按所在表的使用次数、这一项对数字的影响和来源排序:常用表里影响数字的推断排在最前,模型起草、命名推断的比数据库注释的靠前。点开一项可以看它所在表的详情和用过这张表的运行。', + // ---- 从队列打开的项 + focusMark: '从审阅队列打开', + // ---- 表清单视图概览里的入口 + openQueue: '打开审阅队列', + openQueueHint: '跨表按使用次数和对数字的影响排好,可以成批确认、驳回', +} + +/** 表详情里「用过这张表的运行」(GET /catalog/{table}/usage) */ +export const CATALOG_USAGE_TEXT = { + title: '用过这张表的运行', + hint: '新的在前', + show: '查看用过这张表的运行', + hide: '收起', + none: '还没有运行查询过这张表', + count: (shown: number, total: number) => (total > shown + ? `最近 ${formatNumber(shown)} 次,共 ${formatNumber(total)} 次` : `共 ${formatNumber(total)} 次`), + error: '无法读取用过这张表的运行', + retry: '重试', + unnamed: '未保存的工作流', + open: (name: string) => `打开运行记录:${name}`, +} + +// =========================================================================== +// 导出导入数据目录(数据目录页的「导出目录」「导入目录」):本机整理好的目录带到另一个实例。 +// +// 术语: +// - 导出的是一个 .json 文件,含全部状态的项和起草要求。导入先「预览」(只读),确认后才写入。 +// - 预览逐表写「新增 / 更新 / 保留 / 冲突 / 跳过」:冲突是这里已确认、已驳回的项和文件里不一样,以这里为准; +// 跳过是表结构里没有的列、两端对不上的关联关系。 +// =========================================================================== + +export const CATALOG_TRANSFER_TEXT = { + export: '导出目录', + exportHint: '把这个数据源的数据目录导出成文件(含全部状态的项和起草要求),可以导入到另一个实例', + exporting: '正在导出…', + exported: (name: string) => `已导出「${name}」`, + exportFailed: (reason: string) => `未能导出数据目录:${reason}`, + fileName: (source: string) => `${source}-数据目录.json`, + import: '导入目录', + importHint: '从导出的文件导入数据目录:先预览,确认后再写入', + title: '导入数据目录', + chooseLabel: '导出的数据目录文件', + chooseHint: '「导出目录」得到的 .json 文件', + choose: '选择文件', + chosen: (name: string) => `已选择:${name}`, + includeGuidance: '同时导入起草要求', + includeGuidanceHint: '这个数据源还没有起草要求时才写入,已有的不覆盖', + rulesTitle: '导入的规矩', + rules: [ + '表按名字对上:不分大小写;带 schema 前缀的对不上时去掉前缀再对', + '文件中已确认、已驳回的项照原样带入,来源和状态不变', + '这里已确认、已驳回的项和文件中的不一样时以这里为准,记为冲突', + '文件中推断、已验证的项只填补这里的空缺,不覆盖已有的', + '表结构里没有的列、两端对不上的关联关系跳过,写明原因', + ], + preview: '预览', + previewing: '正在预览…', + notJson: '文件不是有效的 JSON,请选择「导出目录」得到的文件', + readFailed: '无法读取这个文件', + fileMeta: (source: string, at: string) => `导出自「${source}」 · ${at}`, + totals: (t: { tables: number; add: number; update: number; keep: number; conflicts: number; skipped: number }) => + `${formatNumber(t.tables)} 张表:新增 ${formatNumber(t.add)} 项、更新 ${formatNumber(t.update)} 项、保留 ${formatNumber(t.keep)} 项、` + + `冲突 ${formatNumber(t.conflicts)} 项、跳过 ${formatNumber(t.skipped)} 项`, + head: { table: '表', add: '新增', update: '更新', keep: '保留', conflicts: '冲突', skipped: '跳过' }, + matchedAs: (name: string) => `对上「${name}」`, + unmatched: (n: number) => `${formatNumber(n)} 张表在这个数据源里对不上,不导入:`, + conflictsTitle: (n: number) => `冲突 ${formatNumber(n)} 项:以这里为准,不导入`, + conflictFile: '文件中', + conflictTarget: '这里', + skippedTitle: (n: number) => `跳过 ${formatNumber(n)} 项`, + guidance: { + replace: '起草要求:写入文件中的起草要求(这个数据源还没有)', + keep: '起草要求:保留这个数据源现有的,不覆盖', + none: '起草要求:不导入', + } as Record<'keep' | 'replace' | 'none', string>, + confirm: (n: number) => `导入 ${formatNumber(n)} 张表`, + importing: '正在导入…', + nothing: '没有可以导入的内容:文件中的表在这个数据源里都对不上', + done: '导入完成', + doneTotals: (t: { tables: number; add: number; update: number; conflicts: number }) => + `写入 ${formatNumber(t.tables)} 张表:新增 ${formatNumber(t.add)} 项、更新 ${formatNumber(t.update)} 项;冲突 ${formatNumber(t.conflicts)} 项以这里为准`, + version: (v: number) => `第 ${formatNumber(v)} 版`, + tableFailed: (reason: string) => `这张表未导入:${reason}`, + back: '重新选择文件', + cancel: '取消', + close: '完成', } // =========================================================================== diff --git a/frontend/src/pages/catalog/assistant.ts b/frontend/src/pages/catalog/assistant.ts new file mode 100644 index 00000000..20840bb3 --- /dev/null +++ b/frontend/src/pages/catalog/assistant.ts @@ -0,0 +1,213 @@ +import type { + CatalogAssistantChange, CatalogAssistantEvent, CatalogAssistantRequest, CatalogDetail, CatalogPatchChange, CatalogPatchPreview, + CatalogPatchSubmit, +} from '../../types' +import { patchChangesOf, patchWhere } from '../../run/catalogPatch' + +// =========================================================================== +// 表助手的纯函数:一轮回答怎么由事件拼起来、交给服务端的对话怎么截、待保存的修改怎么累积(同一项以最后一次提案为准)、 +// 保存遇到 409 后怎么按预览重算。界面在 TableAssistant.tsx。 +// +// 对话不存服务端:每一轮把前面的对话(最多 40 条、每条最多 4000 字)和界面上还没保存的修改一起交上去,服务端据此 +// 知道已经提过什么、用户说过什么。 +// =========================================================================== + +/** 每一轮最多带几条对话(服务端同一个上限) */ +export const ASSISTANT_MAX_MESSAGES = 40 +/** 每条对话最多几个字(服务端同一个上限) */ +export const ASSISTANT_MAX_CHARS = 4000 +/** 一次最多保存几项(服务端 PATCH_MAX_CHANGES) */ +export const ASSISTANT_MAX_SAVE = 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 } + | { kind: 'proposal'; id: string; version: number; changes: CatalogAssistantChange[] } + +export interface UserTurn { id: string; role: 'user'; text: string } + +export interface AssistantTurn { + id: string + role: 'assistant' + parts: TurnPart[] + /** streaming 还在回答;done 答完;stopped 点了停止;error 出错(开流前被拒、流里的 error 事件、连接中断) */ + state: 'streaming' | 'done' | 'stopped' | 'error' + error?: string | null + code?: string | null + usage?: { input_tokens: number; output_tokens: number } | null + queries?: number +} + +export type Turn = UserTurn | AssistantTurn + +const isObj = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v) +const num = (v: unknown, fallback = 0): number => (typeof v === 'number' && Number.isFinite(v) ? v : fallback) + +/** 提案里的各项:形状同助手流里的 catalog_patch(认不出的项跳过),多一个给人看的位置 */ +export function assistantChangesOf(raw: unknown): CatalogAssistantChange[] { + const list = Array.isArray(raw) ? raw : [] + const parsed = patchChangesOf(list) + return parsed.map((c) => { + const src = list.find((x) => isObj(x) && x.path === c.path) as Record | undefined + const label = typeof src?.label === 'string' && src.label.trim() ? src.label.trim() : undefined + return label ? { ...c, label } : c + }) +} + +/** 一项给人看的位置:服务端写了就用它,没写按路径说 */ +export const changeWhere = (c: CatalogAssistantChange): string => c.label ?? patchWhere(c) + +/** 一个事件并进这一轮回答(返回新的一轮,不改入参)。proposalId:提案的编号(卡片、待保存的修改按它认出处) */ +export function applyEvent(turn: AssistantTurn, ev: CatalogAssistantEvent, proposalId: string): AssistantTurn { + const parts = [...turn.parts] + switch (ev.type) { + case 'text': { + const delta = typeof ev.delta === 'string' ? ev.delta : '' + if (!delta) return turn + const last = parts.at(-1) + if (last?.kind === 'text') parts[parts.length - 1] = { kind: 'text', text: last.text + delta } + else parts.push({ kind: 'text', text: delta }) + return { ...turn, parts } + } + case 'query': { + const columns = Array.isArray(ev.columns) ? ev.columns.map(String) : [] + 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, + }) + return { ...turn, parts } + } + case 'proposal': { + const changes = assistantChangesOf(ev.changes) + if (!changes.length) return turn + parts.push({ kind: 'proposal', id: proposalId, version: num(ev.version), changes }) + return { ...turn, parts } + } + case 'done': { + const usage = isObj(ev.usage) ? { input_tokens: num(ev.usage.input_tokens), output_tokens: num(ev.usage.output_tokens) } : null + return { ...turn, state: 'done', usage, queries: num(ev.queries) } + } + case 'error': + return { ...turn, state: 'error', error: typeof ev.message === 'string' && ev.message ? ev.message : null, code: ev.code ?? null } + default: + return turn + } +} + +/** 这一轮回答的正文(各段正文接起来) */ +export const turnText = (turn: AssistantTurn): string => + turn.parts.filter((p): p is Extract => p.kind === 'text').map((p) => p.text).join('') + +/** 这一轮里的提案 */ +export const turnProposals = (turn: AssistantTurn) => + turn.parts.filter((p): p is Extract => p.kind === 'proposal') + +/** + * 没有正文的一轮回答交给服务端时写成什么(只提了修改、只查了数据、没答完就停了):保持一问一答交替。这几句只给模型看 + */ +function placeholderOf(turn: AssistantTurn): string { + const proposals = turnProposals(turn).reduce((n, p) => n + p.changes.length, 0) + if (proposals) return `(提出了 ${proposals} 项修改提案)` + if (turn.parts.some((p) => p.kind === 'query')) return '(查询了数据,没有写出回答)' + return '(没有回答)' +} + +/** 截到上限:每条最多 4000 字 */ +const clip = (s: string) => ([...s].length > ASSISTANT_MAX_CHARS ? [...s].slice(0, ASSISTANT_MAX_CHARS).join('') : s) + +/** + * 交给服务端的对话:前面的对话加上这一条新问题,最多 40 条、每条最多 4000 字。超出时去掉最早的几条,并且总从用户的话 + * 开始(去掉打头的助手回答) + */ +export function historyOf(turns: Turn[], next: string): CatalogAssistantRequest['messages'] { + const out: CatalogAssistantRequest['messages'] = [] + for (const t of turns) { + if (t.role === 'user') out.push({ role: 'user', content: clip(t.text) }) + else out.push({ role: 'assistant', content: clip(turnText(t).trim() || placeholderOf(t)) }) + } + out.push({ role: 'user', content: clip(next) }) + let tail = out.slice(-ASSISTANT_MAX_MESSAGES) + while (tail.length > 1 && tail[0].role !== 'user') tail = tail.slice(1) + return tail +} + +// --------------------------------------------------------------------------- +// 待保存的修改:跨多轮累积,同一项(路径)以最后一次提案为准;可以逐项去掉、加回。保存成功后清空 +// --------------------------------------------------------------------------- + +export interface PendingChange extends CatalogAssistantChange { + /** 来自哪一条提案 */ + origin: string + /** 提案对照的目录版本:保存时带最早的那个,别人在那之后改过就 409,按最新内容重算后再保存 */ + version: number +} + +/** 一条提案并进待保存的修改:同一路径的换成这一条,位置不动;已经是这个值且已确认的(same)不收 */ +export function mergeProposal(pending: PendingChange[], changes: CatalogAssistantChange[], origin: string, version: number): PendingChange[] { + const out = [...pending] + for (const c of changes) { + if (c.state === 'same') continue + const next: PendingChange = { ...c, origin, version } + const i = out.findIndex((p) => p.path === c.path) + if (i >= 0) out[i] = next + else out.push(next) + } + return out +} + +export const dropPending = (pending: PendingChange[], path: string): PendingChange[] => pending.filter((p) => p.path !== path) + +/** 交回保存、交给助手的那一份:路径、原样取值、理由 */ +export function pendingSubmit(pending: PendingChange[]): CatalogPatchSubmit[] { + return pending.map((p) => ({ path: p.path, value: p.value, ...(p.reason ? { reason: p.reason } : {}) })) +} + +/** 保存时带的版本:待保存的修改里最早的那条提案对照的版本 */ +export const pendingVersion = (pending: PendingChange[]): number => + (pending.length ? Math.min(...pending.map((p) => p.version)) : 0) + +/** 提案卡片上一项的处境:待保存(就是这一条)、改用了另一条提案的值、去掉了、已保存 */ +export type ChangeState = 'pending' | 'replaced' | 'dropped' | 'saved' + +export function changeStateOf(pending: PendingChange[], saved: ReadonlySet, origin: string, path: string): ChangeState { + if (saved.has(savedKey(origin, path))) return 'saved' + const p = pending.find((x) => x.path === path) + if (!p) return 'dropped' + return p.origin === origin ? 'pending' : 'replaced' +} + +export const savedKey = (origin: string, path: string) => `${origin}\u0000${path}` + +/** + * 保存遇到 409(别人刚改过这张表)后按预览重算:预览里有的项换上最新的改前、改后和状态,版本记成预览的; + * 预览里没有的(已经不合法了,原因在 problems 里)去掉;已经是这个值且已确认的(same)也去掉,不用再存 + */ +export function applyPreview(pending: PendingChange[], preview: CatalogPatchPreview): PendingChange[] { + const byPath = new Map(preview.changes.map((c) => [c.path, c])) + const out: PendingChange[] = [] + for (const p of pending) { + 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, + ...(c.note ? { note: c.note } : {}), version: preview.version }) + } + return out +} + +// --------------------------------------------------------------------------- +// 开场:上次起草有留空的列时,引导先问这几列 +// --------------------------------------------------------------------------- + +/** 还没有中文名和含义的列(表结构里的顺序;被驳回的不算有) */ +export function blankColumnsOf(detail: CatalogDetail): string[] { + const live = (it: { status: string } | undefined) => !!it && it.status !== 'rejected' + return (detail.structure?.columns ?? []).map((c) => c.name) + .filter((name) => { + const items = detail.notes.columns?.[name] + return !live(items?.label) && !live(items?.meaning) + }) +} diff --git a/frontend/src/pages/catalog/review.ts b/frontend/src/pages/catalog/review.ts new file mode 100644 index 00000000..7aa844da --- /dev/null +++ b/frontend/src/pages/catalog/review.ts @@ -0,0 +1,132 @@ +import type { + CatalogNotes, CatalogReviewAction, CatalogReviewBatchResult, CatalogReviewItem, CatalogReviewKind, CatalogStatus, +} from '../../types' +import { slotsOf } from './model' + +// =========================================================================== +// 审阅队列的纯函数:地址里的视图和筛选、队列一项的键、成批审阅之后逐条的结果、表详情里改过之后队列跟着变、进度的百分比。 +// 界面在 ReviewQueue.tsx。 +// +// 队列只列推断项,顺序由服务端排好(表的使用次数 × 风险 × 来源)。审阅过的项不马上从列表里拿掉:原地标出结果 +// (已确认、已驳回、冲突),可以选中撤销;刷新队列后才移除。 +// =========================================================================== + +/** 数据目录页的两个视图:表清单(逐表看)、审阅队列(跨表按优先级看推断项) */ +export type CatalogView = 'list' | 'review' + +export const REVIEW_KINDS: readonly CatalogReviewKind[] = ['all', 'facts', 'names'] +/** 一页几项、一次成批审阅最多几条(服务端同一个上限) */ +export const REVIEW_PAGE = 50 +export const REVIEW_BATCH_MAX = 200 + +/** + * 审阅视图记在地址的查询参数里:view=review;kind 筛选(默认全部不写);rtable 只看一张表;item 从队列打开的那一项 + * (表详情据此滚到那一项、标出来)。和清单的 q / filter / sort / used 互不相干,各自只动自己的键 + */ +export interface ReviewParams { view: CatalogView; kind: CatalogReviewKind; table: string; item: string } + +export function reviewParamsOf(p: URLSearchParams): ReviewParams { + const kind = p.get('kind') as CatalogReviewKind | null + return { + view: p.get('view') === 'review' ? 'review' : 'list', + kind: kind && REVIEW_KINDS.includes(kind) ? kind : 'all', + table: p.get('rtable') ?? '', + item: p.get('item') ?? '', + } +} + +export function writeReviewParams(p: URLSearchParams, s: Partial): URLSearchParams { + const next = new URLSearchParams(p) + const put = (k: string, v: string | null | undefined) => (v ? next.set(k, v) : next.delete(k)) + if ('view' in s) put('view', s.view === 'review' ? 'review' : null) + if ('kind' in s) put('kind', s.kind === 'all' ? null : s.kind) + if ('table' in s) put('rtable', s.table) + if ('item' in s) put('item', s.item) + return next +} + +/** 队列一项的键(表名里、路径里都可能有句点,中间用一个不会出现的字符隔开) */ +export const itemKey = (i: { table_name: string; path: string }) => `${i.table_name}\u0000${i.path}` + +/** 一项审阅过之后的处境:成功(现在的状态、这张表写入后的版本)、冲突(这张表刚被改过)、没成功(服务端的原因) */ +export type ItemOutcome = + | { kind: 'ok'; action: CatalogReviewAction; status: CatalogStatus } + | { kind: 'conflict'; message: string | null } + | { kind: 'error'; message: string | null } + +/** 成批审阅之后这一项现在的状态:确认 → 已确认;驳回 → 已驳回;撤销审阅 → 回到来源的初始状态(队列里都是推断) */ +const STATUS_AFTER: Record = { confirm: 'confirmed', reject: 'rejected', reset: 'proposed' } + +/** 服务端的机读码说的是不是版本冲突 */ +export const isConflict = (r: Pick): boolean => !!r.code && /conflict|version/i.test(r.code) + +/** + * 逐条的结果并进去:outcomes 按项的键记处境,versions 按表记最新的版本(之后再审阅这张表的项带它)。返回新的两份,不改入参 + */ +export function applyResults( + results: CatalogReviewBatchResult[], action: CatalogReviewAction, + outcomes: ReadonlyMap, versions: ReadonlyMap, +): { outcomes: Map; versions: Map } { + const o = new Map(outcomes) + const v = new Map(versions) + for (const r of results) { + const key = itemKey(r) + if (r.ok) { + o.set(key, { kind: 'ok', action, status: STATUS_AFTER[action] }) + if (typeof r.version === 'number') v.set(r.table_name, Math.max(v.get(r.table_name) ?? 0, r.version)) + } else { + o.set(key, isConflict(r) ? { kind: 'conflict', message: r.error } : { kind: 'error', message: r.error }) + } + } + return { outcomes: o, versions: v } +} + +/** 一项现在显示的状态:审阅成功过的按结果,否则是队列里给的 */ +export function statusOf(item: CatalogReviewItem, outcome: ItemOutcome | undefined): CatalogStatus { + return outcome?.kind === 'ok' ? outcome.status : item.status +} + +/** + * 一批选中的项里,这个动作要发哪几条:确认发还不是已确认的,驳回发还不是已驳回的,撤销审阅只发这一页上审阅成功过的 + * (队列里本来都是推断,没审阅过的撤销了也没有变化)。冲突过的照旧可以再发 + */ +export function batchTargets(items: CatalogReviewItem[], selected: ReadonlySet, action: CatalogReviewAction, + outcomes: ReadonlyMap): CatalogReviewItem[] { + return items.filter((i) => { + if (!selected.has(itemKey(i))) return false + const o = outcomes.get(itemKey(i)) + const status = statusOf(i, o) + if (action === 'reset') return o?.kind === 'ok' && o.action !== 'reset' + return action === 'confirm' ? status !== 'confirmed' : status !== 'rejected' + }) +} + +/** + * 表详情里改过这张表(单项审阅、编辑、助手的修改):队列里这张表的项按最新的目录换状态——不再是推断的记成审阅成功, + * 版本换成最新的。目录里已经没有的项不动(刷新后服务端不会再列) + */ +export function syncFromDetail(items: CatalogReviewItem[], table: string, notes: CatalogNotes, version: number, + outcomes: ReadonlyMap, versions: ReadonlyMap, +): { outcomes: Map; versions: Map } { + const o = new Map(outcomes) + const v = new Map(versions) + v.set(table, version) + const slots = new Map(slotsOf(notes).map((s) => [s.path, s.item.status])) + for (const i of items) { + if (i.table_name !== table) continue + const status = slots.get(i.path) + if (!status) continue + const key = itemKey(i) + if (status === 'proposed') { + // 回到推断:之前审阅成功(确认、驳回)过的记成撤销审阅;冲突的照旧标着 + const cur = o.get(key) + if (cur?.kind === 'ok' && cur.status !== 'proposed') o.set(key, { kind: 'ok', action: 'reset', status: 'proposed' }) + } else { + o.set(key, { kind: 'ok', action: status === 'rejected' ? 'reject' : 'confirm', status }) + } + } + return { outcomes: o, versions: v } +} + +/** 百分比(四舍五入到整数);分母为 0 时为 null */ +export const percentOf = (n: number, total: number): number | null => (total > 0 ? Math.round((n / total) * 100) : null) diff --git a/frontend/src/run/catalogPatch.ts b/frontend/src/run/catalogPatch.ts index 900ae164..29821bc3 100644 --- a/frontend/src/run/catalogPatch.ts +++ b/frontend/src/run/catalogPatch.ts @@ -65,10 +65,15 @@ function changeOf(x: unknown): CatalogPatchChange | null { } } +/** 提案里的各项(catalog_patch、表助手的 proposal 事件同一个形状):认不出的项跳过 */ +export function patchChangesOf(raw: unknown): CatalogPatchChange[] { + return (Array.isArray(raw) ? raw : []).map(changeOf).filter((c): c is CatalogPatchChange => !!c) +} + /** catalog_patch 操作 → 提案。认不出的形状返回 null */ export function catalogPatchOf(op: Record, 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) + const changes = patchChangesOf(op.changes) if (!changes.length) return null const version = typeof op.version === 'number' && Number.isFinite(op.version) ? op.version : 0 return { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 4fd72ba3..e9ea3dbf 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -2884,6 +2884,16 @@ export interface CatalogDraftTask { /** 模型分几段起草(超过 40 列的宽表拆成表级一段加列分段);不用模型时为 null */ parts_total: number | null parts_done: number + /** + * 已经写进目录的段数:每写完一段就并入目录,停止、服务重启时已写进的段保留。老服务端没有这个键 + */ + parts_written?: number + /** + * 这张表实际分成的块数和已结束的块数:额度用完、超时后段被缩小、拆半,块会比段多。不用模型时为 null; + * 老服务端没有这两个键 + */ + chunks_total?: number | null + chunks_done?: number | null added: number updated: number removed: number @@ -2984,6 +2994,13 @@ export interface CatalogLastDraft { model_error: string | null /** 那次起草模型拿不准、留空的列数(同 CatalogDraftTask.blank_columns)。老服务端没有这个键 */ blank_columns?: number + /** + * 最近一次这张表结束于完成、部分完成的起草记下的留空列数(没有这样的起草为 null)。最近一次是停止、服务重启中断、 + * 又没有新写入时,界面用它接着提示留空的列。老服务端没有这个键 + */ + blank_columns_from?: number | null + /** 那次起草已写进目录的段数(同 CatalogDraftTask.parts_written)。接口约定里没有这一项,服务端给了才用 */ + parts_written?: number } /** @@ -3046,6 +3063,171 @@ export interface CatalogImpactTemplate { nodes: CatalogImpactNode[] } +// --------------------------------------------------------------------------- +// 审阅队列(/api/datasources/{id}/catalog/review-queue、review/batch、review-progress、{table}/usage,后端 +// data/catalog_review.py):跨表列出推断项,按表的使用次数、这一项对数字的影响和来源排好,可以成批确认、驳回。 +// --------------------------------------------------------------------------- + +/** 队列的筛选:全部、影响数字的项(有效记录条件、去重、业务日期、关联关系、业务主键、度量类型)、名称和含义 */ +export type CatalogReviewKind = 'all' | 'facts' | 'names' + +/** 队列里的一项(只列推断状态的项) */ +export interface CatalogReviewItem { + table_name: string + /** 审阅接口的路径写法 */ + path: string + /** 字段名(valid_filter、label……) */ + field: string + /** 给人看的位置(服务端写好的,比如「列 status 的码值」) */ + field_label: string + value: unknown + /** 值写成一行字(服务端写好的) */ + display: string + source: CatalogSource + status: CatalogStatus + note: string | null + /** 越大越靠前:表的使用次数权重 × 风险权重 × 来源权重 */ + priority: number + /** 为什么排在这里(中文,服务端写好的) */ + reasons: string[] + /** 所在表被运行用过的次数 */ + table_usage: number + /** 所在表的目录版本:成批审阅时逐表带上 */ + version: number +} + +/** GET /catalog/review-queue */ +export interface CatalogReviewQueue { + total: number + items: CatalogReviewItem[] +} + +/** POST /catalog/review/batch 的一项 */ +export interface CatalogReviewBatchItem { + table_name: string + path: string + action: CatalogReviewAction + if_version: number +} + +/** 成批审阅逐条的结果:一张表冲突不影响别的表;成功的写明这张表写入后的版本 */ +export interface CatalogReviewBatchResult { + table_name: string + path: string + ok: boolean + version: number | null + error: string | null + code: string | null +} + +/** 用过这张表的一次运行(新的在前) */ +export interface CatalogUsageRun { + run_id: string + workflow_id: string | null + workflow_name: string | null + run_class: 'formal' | 'exploratory' | null + status: string + started_at: string | null +} + +/** GET /catalog/{table}/usage */ +export interface CatalogTableUsage { + total: number + runs: CatalogUsageRun[] +} + +/** GET /catalog/review-progress:常用的前 top_n 张表、全部表、影响数字的项各审阅到哪儿 */ +export interface CatalogReviewProgress { + top_n: number + top: { tables: number; items_total: number; items_confirmed: number } + all: { items_total: number; items_confirmed: number; items_proposed: number; items_rejected: number } + facts: { proposed: number; confirmed: number } +} + +// --------------------------------------------------------------------------- +// 导出导入(/api/datasources/{id}/catalog/export、import/preview、import,后端 data/catalog_transfer.py): +// 本机整理好的目录带到另一个实例。先预览(只读),确认后逐表写入。 +// --------------------------------------------------------------------------- + +/** 导出的文件:含全部状态的项 */ +export interface CatalogExportFile { + format: 'agentlab-catalog' + version: 1 + exported_at: string + source: { name: string; kind: string } + guidance: string | null + tables: Record +} + +/** 一处冲突:这里已确认、已驳回的项和文件中的不一样,以这里为准 */ +export interface CatalogImportConflict { + path: string + label: string + file_value: unknown + target_value: unknown + why: string +} + +/** 预览、导入报告里的一张表 */ +export interface CatalogImportTable { + /** 文件中的表名 */ + table_name: string + /** 对上的这个数据源里的表名(名字不一样时才有意思);对不上为 null */ + matched_as: string | null + add: number + update: number + keep: number + conflicts: CatalogImportConflict[] + skipped: { path: string; why: string }[] + /** 导入后这张表的目录版本(只有导入的报告有) */ + version?: number | null + /** 这张表没导入的原因(逐表写入,一张表失败不影响别的)。接口约定里没有写明,服务端给了才显示 */ + error?: string | null +} + +export interface CatalogImportTotals { + tables: number + add: number + update: number + keep: number + conflicts: number + skipped: number +} + +/** POST /catalog/import/preview、/catalog/import */ +export interface CatalogImportReport { + tables: CatalogImportTable[] + /** 文件中有、这个数据源里对不上的表 */ + unmatched_tables: string[] + totals: CatalogImportTotals + /** 起草要求:写入文件中的(这里还没有)、保留这里的、不导入 */ + guidance: { file: string | null; target: string | null; action: 'keep' | 'replace' | 'none' } +} + +// --------------------------------------------------------------------------- +// 表助手(POST /api/datasources/{id}/catalog/{table}/assistant,后端 data/catalog_assistant.py):对话完善一张表的 +// 目录。回答是事件流,每个事件一行 data: 。对话不存服务端,每一轮都把前面的对话和还没保存的修改带上。 +// --------------------------------------------------------------------------- + +/** 提案里的一项:和助手流里的 catalog_patch 一样,多一个给人看的位置 */ +export interface CatalogAssistantChange extends CatalogPatchChange { + label?: string +} + +export type CatalogAssistantEvent = + | { type: 'text'; delta: string } + | { type: 'query'; sql: string; columns: string[]; rows: unknown[][]; row_count: number; 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 } + +/** 交给表助手的一轮请求 */ +export interface CatalogAssistantRequest { + messages: { role: 'user' | 'assistant'; content: string }[] + /** 界面上还没保存的修改 */ + pending: CatalogPatchSubmit[] +} + /** GET /datasources/{id}/catalog/{table}/impact */ export interface CatalogImpact { table: string From 1cc9fe1a3dfa8e6ba78752c1898244bbfa593783 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 14 Oct 2026 08:44:37 +0800 Subject: [PATCH 2/9] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E9=97=AD=E7=8E=AF=EF=BC=88=E5=89=8D=E7=AB=AF=EF=BC=89=EF=BC=9A?= =?UTF-8?q?=E8=A1=A8=E8=AF=A6=E6=83=85=E5=8A=A0=E3=80=8C=E8=A1=A8=E5=8A=A9?= =?UTF-8?q?=E6=89=8B=E3=80=8D=E2=80=94=E2=80=94=E9=A1=B6=E9=83=A8=E5=92=8C?= =?UTF-8?q?=E4=B8=8A=E6=AC=A1=E8=B5=B7=E8=8D=89=E7=9A=84=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E9=87=8C=E9=83=BD=E6=9C=89=E3=80=8C=E5=92=8C=E5=8A=A9=E6=89=8B?= =?UTF-8?q?=E4=B8=80=E8=B5=B7=E5=AE=8C=E5=96=84=E3=80=8D=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=EF=BC=8C=E5=AF=B9=E8=AF=9D=E6=B5=81=E5=BC=8F=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E3=80=81=E8=83=BD=E5=81=9C=E6=AD=A2=EF=BC=8C=E5=8A=A9=E6=89=8B?= =?UTF-8?q?=E8=B7=91=E8=BF=87=E7=9A=84=E5=8F=AA=E8=AF=BB=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E5=86=99=20SQL=20=E5=92=8C=E5=89=8D=E5=87=A0=E8=A1=8C=EF=BC=88?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E6=94=B6=E8=B5=B7=EF=BC=89=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=E6=8F=90=E6=A1=88=E9=80=90=E9=A1=B9=E5=86=99=E6=94=B9?= =?UTF-8?q?=E5=89=8D=E3=80=81=E6=94=B9=E5=90=8E=E3=80=81=E4=BE=9D=E6=8D=AE?= =?UTF-8?q?=E5=B9=B6=E8=83=BD=E9=80=90=E9=A1=B9=E5=8E=BB=E6=8E=89=E3=80=81?= =?UTF-8?q?=E5=8A=A0=E5=9B=9E=EF=BC=8C=E3=80=8C=E5=BE=85=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E7=9A=84=E4=BF=AE=E6=94=B9=E3=80=8D=E8=B7=A8=E5=A4=9A=E8=BD=AE?= =?UTF-8?q?=E7=B4=AF=E7=A7=AF=EF=BC=88=E5=90=8C=E4=B8=80=E9=A1=B9=E4=BB=A5?= =?UTF-8?q?=E6=9C=80=E5=90=8E=E4=B8=80=E6=AC=A1=E6=8F=90=E6=A1=88=E4=B8=BA?= =?UTF-8?q?=E5=87=86=EF=BC=89=EF=BC=8C=E4=B8=80=E9=94=AE=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=EF=BC=88=E6=9C=80=E5=A4=9A=20200=20=E9=A1=B9=EF=BC=8C409=20?= =?UTF-8?q?=E6=97=B6=E6=8C=89=E6=9C=80=E6=96=B0=E5=86=85=E5=AE=B9=E9=87=8D?= =?UTF-8?q?=E7=AE=97=E5=90=8E=E5=81=9C=E4=B8=8B=E6=9D=A5=E5=86=8D=E7=9C=8B?= =?UTF-8?q?=E4=B8=80=E7=9C=BC=EF=BC=89=EF=BC=8C=E6=B2=A1=E5=BC=80=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=89=96=E6=9E=90=E7=9A=84=E6=BA=90=E5=86=99=E6=98=8E?= =?UTF-8?q?=E5=8F=AA=E8=83=BD=E6=A0=B9=E6=8D=AE=E8=A1=A8=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E5=92=8C=E7=9B=AE=E5=BD=95=E6=8E=A8=E6=96=AD=E3=80=81=E4=B8=8D?= =?UTF-8?q?=E8=83=BD=E6=9F=A5=E6=95=B0=E6=8D=AE=EF=BC=8C=E6=9C=89=E7=95=99?= =?UTF-8?q?=E7=A9=BA=E5=88=97=E6=97=B6=E5=BC=80=E5=9C=BA=E5=BC=95=E5=AF=BC?= =?UTF-8?q?=E5=85=88=E9=97=AE=E8=BF=99=E5=87=A0=E5=88=97=EF=BC=8C=E6=9C=89?= =?UTF-8?q?=E5=BE=85=E4=BF=9D=E5=AD=98=E7=9A=84=E4=BF=AE=E6=94=B9=E6=97=B6?= =?UTF-8?q?=E6=8D=A2=E8=A1=A8=E3=80=81=E5=85=B3=E9=97=AD=E5=85=88=E9=97=AE?= =?UTF-8?q?=EF=BC=9B=E8=A1=A8=E8=AF=A6=E6=83=85=E5=8A=A0=E3=80=8C=E7=94=A8?= =?UTF-8?q?=E8=BF=87=E8=BF=99=E5=BC=A0=E8=A1=A8=E7=9A=84=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E3=80=8D=E3=80=81=E6=9C=89=E6=95=88=E8=AE=B0=E5=BD=95=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E5=92=8C=E5=8E=BB=E9=87=8D=E8=A7=84=E5=88=99=E7=9A=84?= =?UTF-8?q?=E6=8E=A8=E6=96=AD=E9=A1=B9=E5=9C=A8=E5=80=BC=E4=B8=8B=E9=9D=A2?= =?UTF-8?q?=E5=86=99=E8=AF=B4=E6=98=8E=EF=BC=88=E5=89=96=E6=9E=90=E6=A0=B8?= =?UTF-8?q?=E5=AF=B9=E7=9A=84=E6=8E=92=E9=99=A4=E6=AF=94=E4=BE=8B=E6=88=96?= =?UTF-8?q?=E6=B2=A1=E6=A0=B8=E5=AF=B9=E7=9A=84=E5=8E=9F=E5=9B=A0=EF=BC=89?= =?UTF-8?q?=E3=80=81=E4=BB=8E=E5=AE=A1=E9=98=85=E9=98=9F=E5=88=97=E6=89=93?= =?UTF-8?q?=E5=BC=80=E7=9A=84=E9=82=A3=E4=B8=80=E9=A1=B9=E6=A0=87=E5=87=BA?= =?UTF-8?q?=E6=9D=A5=EF=BC=9B=E8=B5=B7=E8=8D=89=E4=BB=BB=E5=8A=A1=E9=80=90?= =?UTF-8?q?=E8=A1=A8=E6=98=BE=E7=A4=BA=E5=9D=97=E8=BF=9B=E5=BA=A6=EF=BC=88?= =?UTF-8?q?=E6=AE=B5=E8=A2=AB=E6=8B=86=E5=B0=8F=E5=90=8E=E7=9C=8B=E5=BE=97?= =?UTF-8?q?=E5=87=BA=E6=9D=A5=EF=BC=89=E5=92=8C=E5=B7=B2=E5=86=99=E8=BF=9B?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E7=9A=84=E6=AE=B5=EF=BC=8C=E5=81=9C=E6=AD=A2?= =?UTF-8?q?=E5=90=8E=E5=86=99=E6=98=8E=E5=B7=B2=E5=86=99=E8=BF=9B=E5=87=A0?= =?UTF-8?q?=E6=AE=B5=E3=80=81=E5=8F=AA=E9=87=8D=E8=AF=95=E6=97=B6=E6=8E=A5?= =?UTF-8?q?=E7=9D=80=E8=B5=B7=E8=8D=89=EF=BC=8C=E6=B8=85=E5=8D=95=E5=92=8C?= =?UTF-8?q?=E8=A1=A8=E8=AF=A6=E6=83=85=E5=9C=A8=E6=9C=80=E8=BF=91=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=E5=81=9C=E6=AD=A2=E3=80=81=E4=B8=AD=E6=96=AD=E5=8F=88?= =?UTF-8?q?=E6=B2=A1=E6=9C=89=E6=96=B0=E5=86=99=E5=85=A5=E6=97=B6=E6=8E=A5?= =?UTF-8?q?=E7=9D=80=E7=94=A8=E5=86=8D=E4=B9=8B=E5=89=8D=E9=82=A3=E6=AC=A1?= =?UTF-8?q?=E8=B5=B7=E8=8D=89=E8=AE=B0=E4=B8=8B=E7=9A=84=E7=95=99=E7=A9=BA?= =?UTF-8?q?=E5=88=97=E6=95=B0=E6=8F=90=E7=A4=BA=EF=BC=9Bcheck-catalog=20?= =?UTF-8?q?=E5=8A=A0=E4=B8=80=E8=8A=82=E6=8C=89=E7=BA=A6=E5=AE=9A=E7=9A=84?= =?UTF-8?q?=E6=96=B0=E5=AD=97=E6=AE=B5=E6=8E=A8=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/catalog/ColumnTable.tsx | 28 +- frontend/src/pages/catalog/DraftDialog.tsx | 34 +- frontend/src/pages/catalog/TableAssistant.tsx | 591 ++++++++++++++++++ frontend/src/pages/catalog/TableDetail.tsx | 117 +++- frontend/src/pages/catalog/TableIndex.tsx | 12 +- frontend/src/pages/catalog/TableUsage.tsx | 90 +++ frontend/src/pages/catalog/draftJob.ts | 37 +- frontend/src/pages/catalog/parts.tsx | 4 + scripts/check-catalog.mjs | 109 +++- 9 files changed, 983 insertions(+), 39 deletions(-) create mode 100644 frontend/src/pages/catalog/TableAssistant.tsx create mode 100644 frontend/src/pages/catalog/TableUsage.tsx diff --git a/frontend/src/pages/catalog/ColumnTable.tsx b/frontend/src/pages/catalog/ColumnTable.tsx index cec5a1ba..56e64f27 100644 --- a/frontend/src/pages/catalog/ColumnTable.tsx +++ b/frontend/src/pages/catalog/ColumnTable.tsx @@ -9,7 +9,7 @@ import { IconButton } from '../../components/ui' import { CATALOG_COLUMN_FIELD_LABEL, CATALOG_MEASURE_HINT, CATALOG_MEASURE_LABEL, CATALOG_TEXT as CT, CODES_TEXT as KT, } from '../../lib/terms' -import { ItemMark } from './parts' +import { FOCUS_RING, FOCUS_STYLE, ItemMark } from './parts' import type { ReviewTarget } from './parts' import { COLUMN_FIELDS, codesComplete, columnPath, completeOf } from './model' import type { ColumnDraft, ColumnField } from './model' @@ -50,7 +50,16 @@ export interface ColumnModel { type ColumnTexts = ColumnDraft -export function ColumnTable({ columns, form, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onFillCodes, onChange, onCodesComplete }: { +/** 审阅路径 columns.<列名>.<字段> → [列名, 字段](列名里可以有句点,按最后一个句点切);不是列级项为 null */ +function columnOfPath(path: string | null | undefined): [string, string] | null { + if (!path?.startsWith('columns.')) return null + const rest = path.slice('columns.'.length) + const at = rest.lastIndexOf('.') + return at > 0 ? [rest.slice(0, at), rest.slice(at + 1)] : null +} + +export function ColumnTable({ columns, form, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onFillCodes, onChange, onCodesComplete, + focusPath = null }: { columns: ColumnModel[] /** 编辑中:列名 → 字段 → 文字(form 是当前的,initial 是进入编辑时的);不在编辑时为 null */ form: Record | null @@ -67,7 +76,10 @@ export function ColumnTable({ columns, form, initial, problems, busy, systemNote onChange: (col: string, field: ColumnField, value: string) => void /** 编辑中勾选或取消码值的「已列出全部取值」 */ onCodesComplete: (col: string, value: boolean) => void + /** 从审阅队列打开的那一项(列级项的审阅路径):那一格标出来 */ + focusPath?: string | null }) { + const focus = columnOfPath(focusPath) return (
@@ -89,7 +101,7 @@ export function ColumnTable({ columns, form, initial, problems, busy, systemNote {columns.map((c) => ( + onCodesComplete={onCodesComplete} focusField={focus && focus[0] === c.name ? focus[1] : null} /> ))}
@@ -98,7 +110,7 @@ export function ColumnTable({ columns, form, initial, problems, busy, systemNote } const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onFillCodes, onChange, - onCodesComplete }: { + onCodesComplete, focusField }: { col: ColumnModel editing: boolean /** 这一列的表单。只有改了这一列时引用才会变,别的行不重画 */ @@ -112,6 +124,8 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble onFillCodes: (col: string) => void onChange: (col: string, field: ColumnField, value: string) => void onCodesComplete: (col: string, value: boolean) => void + /** 从审阅队列打开的是这一列的哪一项 */ + focusField: string | null }) { const { name, structure, items } = col const pending = COLUMN_FIELDS.filter((f) => items?.[f]?.status === 'proposed').length @@ -121,9 +135,12 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble const where = CT.whereColumn(name, CATALOG_COLUMN_FIELD_LABEL[f]) const problem = problems[`c:${name}:${f}`] const changed = editing && ((draft?.[f] ?? '') !== (initial?.[f] ?? '') || (f === 'codes' && completeOf(draft) !== completeOf(initial))) + const focused = focusField === f return ( // 窄框下没填的项不占行(编辑时照常列出来好填) - + {CT.columnHead[f]}
{editing ? input : view}
@@ -223,6 +240,7 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble ) }, (a, b) => a.col === b.col && a.editing === b.editing && a.draft === b.draft && a.initial === b.initial && a.busy === b.busy + && a.focusField === b.focusField && a.systemNotes === b.systemNotes && a.onReview === b.onReview && a.onConfirmColumn === b.onConfirmColumn && a.onFillCodes === b.onFillCodes && a.onChange === b.onChange && a.onCodesComplete === b.onCodesComplete // 格式问题每次都是新对象:只比这一列的几格 diff --git a/frontend/src/pages/catalog/DraftDialog.tsx b/frontend/src/pages/catalog/DraftDialog.tsx index 3a7547fc..06371021 100644 --- a/frontend/src/pages/catalog/DraftDialog.tsx +++ b/frontend/src/pages/catalog/DraftDialog.tsx @@ -9,8 +9,8 @@ import { errorMessage } from '../../lib/errors' import { formatTime } from '../../lib/format' import { CATALOG_DRAFT_TEXT as DT, CATALOG_TEXT as CT, DRAFT_GUIDANCE_MAX, DRAFT_NOTE_MAX } from '../../lib/terms' import { - DRAFT_MAX_TABLES, DRAFT_MODEL_UNAVAILABLE, DRAFT_PART_COLUMNS, DRAFT_TOP_N, RETRYABLE, blankOf, isJobActive, jobBlank, needsLook, - nothingWritten, retryableCount, taskReason, + DRAFT_MAX_TABLES, DRAFT_MODEL_UNAVAILABLE, DRAFT_PART_COLUMNS, DRAFT_TOP_N, RETRYABLE, blankOf, chunksOf, isJobActive, jobBlank, needsLook, + nothingWritten, retryableCount, taskReason, writtenOf, } from './draftJob' import type { DraftPreset, DraftStartBody } from './draftJob' @@ -435,6 +435,9 @@ function JobView({ job, running, stopping, busy, blankTables, onRedraftBlank }: // 额度用完、超时多了,之后的段缩小了:进行中写一句,段数为什么变多看得明白 const partColumns = running && job.use_model && job.part_columns != null && job.part_columns < DRAFT_PART_COLUMNS ? job.part_columns : null const blankTotal = jobBlank(job) + // 停下来时已经写进了一部分段的表(宽表每写完一段就写进目录):几张、合计几段 + const stopped = job.tasks.filter((t) => t.status === 'cancelled' && writtenOf(t) > 0) + const stoppedParts = { tables: stopped.length, parts: stopped.reduce((n, t) => n + writtenOf(t), 0) } return (
{busy && {DT.busy}} @@ -468,7 +471,12 @@ function JobView({ job, running, stopping, busy, blankTables, onRedraftBlank }: {running &&

{DT.hideHint}

} {job.status === 'failed' && job.error && {job.error}} {job.status === 'interrupted' && {DT.interrupted}} - {job.status === 'cancelled' &&

{DT.cancelled}

} + {job.status === 'cancelled' && ( +

+ {DT.cancelled} + {stoppedParts.tables > 0 && {DT.cancelledParts(stoppedParts.tables, stoppedParts.parts)}} +

+ )} {problemText && (

{problemText}

)} @@ -523,7 +531,15 @@ const TASK_ICON: Record 1 && t.status !== 'pending' && t.status !== 'skipped' + const started = t.status !== 'pending' && t.status !== 'skipped' + const parts = t.parts_total != null && t.parts_total > 1 && started + // 段被缩小、拆半后实际的块(比段多时才写);已经写进目录的段:起草中写着(停止也不丢),停下来时服务端的原因里 + // 没说的才补一句(没写全的段为什么没写出来,服务端的原因里有),写完了的不写 + const chunks = started ? chunksOf(t) : null + const written = writtenOf(t) + const writtenText = written > 0 && t.status === 'running' ? DT.partsWritten(written) + : written > 0 && t.status === 'cancelled' && !reason.includes(DT.partsWritten(written)) + ? DT.partsWrittenStopped(written, Math.max(0, (t.parts_total ?? written) - written)) : '' const changes = DT.taskAdded(t.added, t.updated) const blank = blankOf(t) const sample = t.blank_sample?.filter(Boolean) ?? [] @@ -537,6 +553,11 @@ function TaskRow({ task: t, jobError }: { task: CatalogDraftTask; jobError: stri {DT.parts(t.parts_done, t.parts_total!)} )} + {chunks && ( + + {DT.chunks(chunks.done, chunks.total)} + + )} {changes && ( {changes} )} @@ -545,6 +566,11 @@ function TaskRow({ task: t, jobError }: { task: CatalogDraftTask; jobError: stri {DT.taskStatus[t.status]}
+ {writtenText && ( +

+ {writtenText} +

+ )} {reason && (

{reason}

)} diff --git a/frontend/src/pages/catalog/TableAssistant.tsx b/frontend/src/pages/catalog/TableAssistant.tsx new file mode 100644 index 00000000..db116014 --- /dev/null +++ b/frontend/src/pages/catalog/TableAssistant.tsx @@ -0,0 +1,591 @@ +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' +import type { KeyboardEvent as ReactKeyboardEvent } from 'react' +import { ArrowUp, BookMarked, Database, Info, MessagesSquare, Minus, Plus, Square, Trash2, X } from 'lucide-react' +import clsx from 'clsx' +import { ApiError, api, streamCatalogAssistant } from '../../api/client' +import type { CatalogAssistantEnd } from '../../api/client' +import type { CatalogDetail } from '../../types' +import { Notice, Spinner, confirmDialog, isComposing, toast } from '../../components/ui' +import { useLeaveGuard } from '../../lib/leave' +import { CATALOG_ASSISTANT_TEXT as T, CATALOG_STATUS_LABEL } from '../../lib/terms' +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, +} from './assistant' +import type { AssistantTurn, ChangeState, PendingChange, Turn, TurnPart } from './assistant' + +// =========================================================================== +// 表助手:在表详情里和助手对话完善这一张表的数据目录(POST /catalog/{table}/assistant,事件流)。 +// +// 回答流式显示,能停止;助手跑过的只读查询(只有开了数据剖析的源才有)写 SQL 和前几行,默认收起;修改提案逐项写改前、 +// 改后、依据,可以逐项去掉、加回。提案并进「待保存的修改」:跨多轮累积,同一项以最后一次提案为准,一键保存(最多 200 项)。 +// 保存带待保存的修改里最早那条提案对照的版本:别人在那之后改过就 409,按最新内容重算(预览接口,只读)后停下来让人再看 +// 一眼。对话不存服务端:每一轮把前面的对话和待保存的修改一起交上去;关闭、换表、离开页面就没有了,有待保存的修改时先问。 +// =========================================================================== + +type SaveState = + | { phase: 'idle'; error?: string | null; saved?: { n: number; version: number } | null } + | { phase: 'saving' } + | { phase: 'reloading' } + | { phase: 'conflict'; problems: string[] } + +const charCount = (s: string) => [...s.trim()].length + +export function TableAssistant({ sourceId, table, tableLabel, detail, profileEnabled, lastBlank, editing, onSaved, onClose }: { + sourceId: string + table: string + /** 表的中文名(没有就是表名):标题、提示里用 */ + tableLabel: string + /** 页面上这张表最新的目录:留空的列、保存后更新 */ + detail: CatalogDetail + /** 数据源开了数据剖析:助手才能发只读查询 */ + profileEnabled: boolean + /** 上次起草模型拿不准、留空的列数:开场引导先问这几列 */ + lastBlank: number + /** 正在编辑这张表:先不让保存(同一张表两处同时改,保存会撞上版本冲突) */ + editing: boolean + /** 保存成功:页面换上返回的目录 */ + onSaved: (d: CatalogDetail) => void + /** 关上面板(有待保存的修改时这里先问过了) */ + onClose: () => void +}) { + const [turns, setTurns] = useState([]) + const [pending, setPending] = useState([]) + const [saved, setSaved] = useState>(() => new Set()) + const [text, setText] = useState('') + const [save, setSave] = useState({ phase: 'idle' }) + const [trayOpen, setTrayOpen] = useState(true) + const turnsRef = useRef(turns) + turnsRef.current = turns + const pendingRef = useRef(pending) + pendingRef.current = pending + const stopRef = useRef<(() => void) | null>(null) + const seq = useRef(0) + const inputRef = useRef(null) + const logRef = useRef(null) + const titleId = useId() + const streaming = turns.some((t) => t.role === 'assistant' && t.state === 'streaming') + const dirty = pending.length > 0 || streaming + + // 打开时焦点给输入框;卸下时停掉进行中的回答 + useEffect(() => { inputRef.current?.focus() }, []) + useEffect(() => () => { stopRef.current?.() }, []) + + useLeaveGuard(pending.length > 0, () => confirmDialog({ + title: T.leaveTitle, consequences: T.leaveConsequences, confirmLabel: T.leaveConfirm, danger: true, + })) + + // 新的内容到了:滚到底(人往上翻着看的时候不打扰) + const lastLen = useRef(0) + useEffect(() => { + const el = logRef.current + if (!el) return + const size = turns.reduce((n, t) => n + (t.role === 'user' ? 1 : t.parts.length + 1), 0) + const near = el.scrollHeight - el.scrollTop - el.clientHeight < 160 + if (size !== lastLen.current || near) el.scrollTop = el.scrollHeight + lastLen.current = size + }, [turns]) + + const blankCols = useMemo(() => (lastBlank > 0 ? blankColumnsOf(detail) : []), [lastBlank, 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, + ...Object.values(n.columns ?? {}).flatMap((c) => Object.values(c ?? {})), ...(n.relations ?? [])] + return items.some((it) => !!it && (it as { status?: string }).status === 'proposed') + }, [detail]) + + const updateTurn = useCallback((id: string, fn: (t: AssistantTurn) => AssistantTurn) => { + setTurns((ts) => ts.map((t) => (t.id === id && t.role === 'assistant' ? fn(t) : t))) + }, []) + + const send = useCallback((raw: string) => { + const question = raw.trim() + if (!question || charCount(question) > ASSISTANT_MAX_CHARS) return + if (turnsRef.current.some((t) => t.role === 'assistant' && t.state === 'streaming')) return + const n = ++seq.current + const userTurn: Turn = { id: `u${n}`, role: 'user', text: question } + const reply: AssistantTurn = { id: `a${n}`, role: 'assistant', parts: [], state: 'streaming' } + const messages = historyOf(turnsRef.current, question) + setTurns((ts) => [...ts, userTurn, reply]) + setText('') + let proposals = 0 + // 这一轮已经收尾(done、error):之后再来的事件不认 + let ended = false + stopRef.current = streamCatalogAssistant(sourceId, table, { messages, pending: pendingSubmit(pendingRef.current) }, (ev) => { + if (ended) return + if (ev.type === 'done' || ev.type === 'error') ended = true + if (ev.type === 'proposal') { + const pid = `${reply.id}.p${++proposals}` + const changes = assistantChangesOf(ev.changes) + updateTurn(reply.id, (t) => applyEvent(t, ev, pid)) + 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' }) + } + return + } + updateTurn(reply.id, (t) => applyEvent(t, ev, '')) + }, (end: CatalogAssistantEnd) => { + stopRef.current = null + updateTurn(reply.id, (t) => { + if (t.state !== 'streaming') return t + if (end.aborted) return { ...t, state: 'stopped' } + if (end.error) return { ...t, state: 'error', error: end.error, code: end.code ?? null } + // 流结束了却没有 done:这一轮没答完 + return { ...t, state: 'error', error: T.interrupted } + }) + }) + }, [sourceId, table, detail.version, updateTurn]) + + const stop = () => stopRef.current?.() + + const onKeyDown = (e: ReactKeyboardEvent) => { + if (e.key !== 'Enter' || e.shiftKey || isComposing(e)) return + e.preventDefault() + if (!streaming) send(text) + } + + const drop = (path: string) => setPending((cur) => dropPending(cur, path)) + const restore = (part: Extract, path: string) => { + const change = part.changes.find((c) => c.path === path) + if (change) setPending((cur) => mergeProposal(cur, [change], part.id, part.version)) + } + const clearAll = async () => { + if (!pendingRef.current.length) return + if (!(await confirmDialog({ title: T.clearTitle(pendingRef.current.length), confirmLabel: T.clearConfirm, danger: true }))) return + setPending([]) + setSave({ phase: 'idle' }) + } + + const doSave = async () => { + const list = pendingRef.current + if (!list.length || list.length > ASSISTANT_MAX_SAVE || editing) return + setSave({ phase: 'saving' }) + try { + const d = await api.dataCatalog.patch(sourceId, table, pendingSubmit(list), pendingVersion(list)) + setSaved((cur) => new Set([...cur, ...list.map((p) => savedKey(p.origin, p.path))])) + setPending((cur) => cur.filter((p) => !list.includes(p))) + setSave({ phase: 'idle', saved: { n: list.length, version: d.version } }) + onSaved(d) + toast.ok(T.savedToast(list.length, tableLabel)) + } catch (e) { + if (e instanceof ApiError && e.status === 409) { + // 别人刚改过:按最新内容重算(只读),停下来让人再看一眼 + setSave({ phase: 'reloading' }) + try { + const p = await api.dataCatalog.patchPreview(sourceId, table, pendingSubmit(list)) + setPending((cur) => applyPreview(cur, p)) + setSave({ phase: 'conflict', problems: p.problems }) + } catch { + setSave({ phase: 'idle', error: T.conflictFailed }) + } + } else if (e instanceof ApiError && e.kind === 'http' && (e.status === 422 || e.status === 404)) { + setSave({ phase: 'idle', error: e.message }) + } else { + setSave({ phase: 'idle' }) + toast.error(e) + } + } + } + + const close = async () => { + if (dirty && !(await confirmDialog({ title: T.closeTitle, consequences: T.closeConsequences, confirmLabel: T.closeConfirm, danger: true }))) return + stopRef.current?.() + onClose() + } + + const onPanelKey = (e: ReactKeyboardEvent) => { + if (e.key !== 'Escape' || isComposing(e) || e.defaultPrevented) return + e.preventDefault() + void close() + } + + const fresh = turns.length === 0 + const len = charCount(text) + 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 ( +