From 90a560d07e6e07b8b5ebec82518cf7052f527ccf Mon Sep 17 00:00:00 2001
From: ThearchyHelios
Date: Tue, 13 Oct 2026 15:24:57 +0800
Subject: [PATCH 1/6] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95?=
=?UTF-8?q?=E8=B5=B7=E8=8D=89=E6=94=B9=E6=88=90=E5=90=8E=E5=8F=B0=E4=BB=BB?=
=?UTF-8?q?=E5=8A=A1=EF=BC=88=E5=89=8D=E7=AB=AF=EF=BC=89=EF=BC=9A=E5=AF=B9?=
=?UTF-8?q?=E8=AF=9D=E6=A1=86=E9=80=89=E8=8C=83=E5=9B=B4=E3=80=81=E5=86=99?=
=?UTF-8?q?=E8=B5=B7=E8=8D=89=E8=A6=81=E6=B1=82=E5=92=8C=E6=9C=AC=E6=AC=A1?=
=?UTF-8?q?=E8=A1=A5=E5=85=85=E8=AF=B4=E6=98=8E=E5=90=8E=E5=BB=BA=E4=BB=BB?=
=?UTF-8?q?=E5=8A=A1=EF=BC=8C=E4=B9=8B=E5=90=8E=E6=98=BE=E7=A4=BA=E4=BB=BB?=
=?UTF-8?q?=E5=8A=A1=E7=9A=84=E8=BF=9B=E5=BA=A6=E5=92=8C=E7=BB=93=E6=9E=9C?=
=?UTF-8?q?=E2=80=94=E2=80=94=E6=80=BB=E8=BF=9B=E5=BA=A6=E3=80=81=E6=96=B0?=
=?UTF-8?q?=E5=A2=9E=20/=20=E6=9B=B4=E6=96=B0=20/=20=E5=88=A0=E9=99=A4?=
=?UTF-8?q?=E5=90=88=E8=AE=A1=E3=80=81=E9=80=90=E8=A1=A8=E7=8A=B6=E6=80=81?=
=?UTF-8?q?=E3=80=81=E5=AE=BD=E8=A1=A8=E7=9A=84=E6=AE=B5=E8=BF=9B=E5=BA=A6?=
=?UTF-8?q?=E3=80=81=E6=B2=A1=E5=86=99=E5=85=A8=E7=9A=84=E5=8E=9F=E5=9B=A0?=
=?UTF-8?q?=EF=BC=9B=E5=8F=AF=E4=BB=A5=E5=81=9C=E6=AD=A2=E3=80=81=E5=9C=A8?=
=?UTF-8?q?=E5=90=8E=E5=8F=B0=E7=BB=A7=E7=BB=AD=EF=BC=8C=E5=85=B3=E6=8E=89?=
=?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=A1=86=E4=BB=BB=E5=8A=A1=E7=85=A7=E5=B8=B8?=
=?UTF-8?q?=E8=BF=9B=E8=A1=8C=EF=BC=8C=E7=9B=AE=E5=BD=95=E9=A1=B5=E4=B8=8A?=
=?UTF-8?q?=E6=96=B9=E6=98=BE=E7=A4=BA=E4=B8=80=E6=9D=A1=E8=BF=9B=E5=BA=A6?=
=?UTF-8?q?=EF=BC=8C=E7=82=B9=E5=BC=80=E5=9B=9E=E5=88=B0=E5=AF=B9=E8=AF=9D?=
=?UTF-8?q?=E6=A1=86=EF=BC=8C=E7=BB=93=E6=9D=9F=E6=97=B6=E6=8F=90=E7=A4=BA?=
=?UTF-8?q?=E5=B9=B6=E5=8F=AF=E6=9F=A5=E7=9C=8B=E7=BB=93=E6=9E=9C=EF=BC=9B?=
=?UTF-8?q?=E7=BB=93=E6=9E=9C=E9=87=8C=E5=8F=AA=E5=88=97=E6=B2=A1=E5=86=99?=
=?UTF-8?q?=E5=85=A8=E7=9A=84=E8=A1=A8=EF=BC=8C=E3=80=8C=E5=8F=AA=E9=87=8D?=
=?UTF-8?q?=E8=AF=95=E5=A4=B1=E8=B4=A5=E7=9A=84=E8=A1=A8=E3=80=8D=E6=96=B0?=
=?UTF-8?q?=E5=BB=BA=E5=8F=AA=E5=90=AB=E5=A4=B1=E8=B4=A5=E3=80=81=E9=83=A8?=
=?UTF-8?q?=E5=88=86=E5=AE=8C=E6=88=90=E3=80=81=E5=B7=B2=E5=81=9C=E6=AD=A2?=
=?UTF-8?q?=E7=9A=84=E8=A1=A8=E7=9A=84=E4=BB=BB=E5=8A=A1=EF=BC=9B=E6=9C=8D?=
=?UTF-8?q?=E5=8A=A1=E9=87=8D=E5=90=AF=E4=B8=AD=E6=96=AD=E7=9A=84=E4=BB=BB?=
=?UTF-8?q?=E5=8A=A1=E5=86=99=E6=98=8E=E5=8E=9F=E5=9B=A0=E3=80=81=E5=90=8C?=
=?UTF-8?q?=E6=A0=B7=E8=83=BD=E9=87=8D=E8=AF=95=EF=BC=9B=E5=B7=B2=E6=9C=89?=
=?UTF-8?q?=E4=BB=BB=E5=8A=A1=EF=BC=88409=EF=BC=89=E8=BD=AC=E5=8E=BB?=
=?UTF-8?q?=E6=98=BE=E7=A4=BA=E8=BF=9B=E8=A1=8C=E4=B8=AD=E7=9A=84=E9=82=A3?=
=?UTF-8?q?=E4=B8=80=E4=B8=AA=EF=BC=8C=E8=B5=B7=E8=8D=89=E6=A8=A1=E5=9E=8B?=
=?UTF-8?q?=E7=94=A8=E4=B8=8D=E4=BA=86=EF=BC=88409=EF=BC=89=E7=95=99?=
=?UTF-8?q?=E5=9C=A8=E8=AE=BE=E7=BD=AE=E9=98=B6=E6=AE=B5=E5=86=99=E6=98=8E?=
=?UTF-8?q?=E5=8E=9F=E5=9B=A0=EF=BC=9B=E8=B5=B7=E8=8D=89=E8=A6=81=E6=B1=82?=
=?UTF-8?q?=E6=94=B9=E8=BF=87=E5=85=88=E5=AD=98=E5=86=8D=E5=BB=BA=E4=BB=BB?=
=?UTF-8?q?=E5=8A=A1=EF=BC=8C=E5=86=99=E6=98=8E=E7=94=A8=E7=9A=84=E6=98=AF?=
=?UTF-8?q?=E5=93=AA=E4=B8=AA=E8=B5=B7=E8=8D=89=E6=A8=A1=E5=9E=8B=E3=80=81?=
=?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=BA=90=E8=AF=B4=E6=98=8E=E4=B9=9F=E4=B8=80?=
=?UTF-8?q?=E5=B9=B6=E4=BA=A4=E7=BB=99=E6=A8=A1=E5=9E=8B=EF=BC=9B=E6=B8=85?=
=?UTF-8?q?=E5=8D=95=E4=B8=8A=E6=A0=87=E5=87=BA=E4=B8=8A=E6=AC=A1=E8=B5=B7?=
=?UTF-8?q?=E8=8D=89=E5=A4=B1=E8=B4=A5=E3=80=81=E9=83=A8=E5=88=86=E5=AE=8C?=
=?UTF-8?q?=E6=88=90=E7=9A=84=E8=A1=A8=E5=B9=B6=E5=86=99=E5=8E=9F=E5=9B=A0?=
=?UTF-8?q?=EF=BC=8C=E8=A1=A8=E8=AF=A6=E6=83=85=E9=87=8C=E7=BB=99=E3=80=8C?=
=?UTF-8?q?=E9=87=8D=E6=96=B0=E8=B5=B7=E8=8D=89=E8=BF=99=E5=BC=A0=E8=A1=A8?=
=?UTF-8?q?=E3=80=8D=EF=BC=9B=E8=BF=9B=E8=A1=8C=E4=B8=AD=E6=AF=8F=201.5=20?=
=?UTF-8?q?=E7=A7=92=E5=8F=96=E4=B8=80=E6=AC=A1=EF=BC=8C=E5=8F=96=E5=9B=9E?=
=?UTF-8?q?=E7=9A=84=E7=8A=B6=E6=80=81=E5=8F=AA=E5=BE=80=E5=89=8D=E8=B5=B0?=
=?UTF-8?q?=E3=80=82check-catalog=20=E7=94=A8=E5=81=87=E7=9A=84=E4=BB=BB?=
=?UTF-8?q?=E5=8A=A1=E6=8E=A5=E5=8F=A3=E8=A6=86=E7=9B=96=E8=BF=99=E4=BA=9B?=
=?UTF-8?q?=EF=BC=88=E4=BB=BB=E5=8A=A1=E6=80=8E=E4=B9=88=E5=BE=80=E5=89=8D?=
=?UTF-8?q?=E8=B5=B0=E7=94=B1=E6=A3=80=E6=9F=A5=E8=87=AA=E5=B7=B1=E6=8E=A8?=
=?UTF-8?q?=EF=BC=89=EF=BC=8C=E6=97=A7=E7=9A=84=E5=90=8C=E6=AD=A5=E8=B5=B7?=
=?UTF-8?q?=E8=8D=89=E6=A3=80=E6=9F=A5=E6=8D=A2=E6=8E=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/src/api/client.ts | 40 +-
frontend/src/lib/terms.ts | 144 ++++-
frontend/src/pages/catalog/CatalogPage.tsx | 119 +++-
frontend/src/pages/catalog/DraftDialog.tsx | 508 +++++++++++-----
frontend/src/pages/catalog/TableDetail.tsx | 35 +-
frontend/src/pages/catalog/TableIndex.tsx | 21 +-
frontend/src/pages/catalog/draftJob.ts | 192 ++++++
frontend/src/types.ts | 99 +++-
scripts/check-catalog.mjs | 657 +++++++++++++++++----
9 files changed, 1479 insertions(+), 336 deletions(-)
create mode 100644 frontend/src/pages/catalog/draftJob.ts
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 00edd8da..e4e99d80 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -1,5 +1,5 @@
import type {
- ActivateSnapshotBody, ActivateSnapshotOut, AiPreview, CatalogDetail, CatalogDraftOut, CatalogImpact, CatalogList, CatalogNotes,
+ ActivateSnapshotBody, ActivateSnapshotOut, AiPreview, CatalogDetail, CatalogDraftJob, CatalogImpact, CatalogList, CatalogNotes,
CatalogPatchPreview, CatalogPatchSubmit, CatalogProfileOut, CatalogReviewAction, CommitOut, CurrentRecipe, EditPreview,
EditRequest, ImportRecord,
ManifestOut, PurgeRawBody, PurgeRawOut, QuestionAnswer, Recipe, RedraftRulesOut, RemovePeriodBody, RemovePeriodOut,
@@ -31,6 +31,9 @@ export type { ApiErrorKind, RequestOptions, UploadOptions, UploadProgress } from
const isObj = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v)
+/** 数据目录的接口前缀:/datasources/{id}/catalog */
+const catalogBase = (sourceId: string) => `/datasources/${encodeURIComponent(sourceId)}/catalog`
+
/**
* 上传表格被退回、要用户先选怎么处理(422 带 decision)时,取出那份决定;别的错误返回 null。
*
@@ -409,12 +412,37 @@ export const api = {
post(`/datasources/${encodeURIComponent(sourceId)}/catalog/${encodeURIComponent(table)}/patch`,
{ changes, if_version: ifVersion }),
/**
- * 同步起草(注释、外键、命名推断;use_model 时再请助手的模型起草)。不给 tables 时按使用次数取前 20 张。
- * 人工确认、驳回过的项不动。没有表结构时回 409
+ * 建一个起草任务(后台进行:注释、外键、命名推断;use_model 时再请起草模型起草,带上数据源的起草要求和这次的 note)。
+ * 不给 tables 时按使用次数取前 20 张;表结构里没有的表照样建进任务、记 skipped。人工确认、驳回过的项不动。
+ * 409:同一个数据源已有排队、进行中的任务(code 为 catalog_draft_busy,改去取 activeDraftJob);起草模型用不了
+ * (catalog_draft_model_unavailable,不建任务);没有表结构
*/
- draft: (sourceId: string, body: { tables?: string[]; use_model: boolean }, opts?: RequestOptions) =>
- request(`/datasources/${encodeURIComponent(sourceId)}/catalog/draft`,
- { method: 'POST', body: JSON.stringify(body), ...opts }),
+ createDraftJob: (sourceId: string, body: { tables?: string[]; use_model: boolean; note?: string }) =>
+ post<{ job: CatalogDraftJob }>(`${catalogBase(sourceId)}/draft-jobs`, body),
+ /** 排队或进行中的那个起草任务;没有为 null */
+ activeDraftJob: (sourceId: string, opts?: RequestOptions) =>
+ get<{ job: CatalogDraftJob | null }>(`${catalogBase(sourceId)}/draft-jobs/active`, opts),
+ /** 最近建的一个起草任务(任何状态);没有为 null */
+ latestDraftJob: (sourceId: string, opts?: RequestOptions) =>
+ get<{ job: CatalogDraftJob | null }>(`${catalogBase(sourceId)}/draft-jobs/latest`, opts),
+ /** 一个起草任务的当前状态;不存在或不属于这个数据源回 404 */
+ draftJob: (sourceId: string, jobId: string, opts?: RequestOptions) =>
+ get<{ job: CatalogDraftJob }>(`${catalogBase(sourceId)}/draft-jobs/${encodeURIComponent(jobId)}`, opts),
+ /**
+ * 停止起草:排队的直接停;进行中的停掉正在进行的模型调用,没开始和没写完的表记已停止,已经写进目录的不回滚。
+ * 已经结束的回 409(catalog_draft_finished)
+ */
+ cancelDraftJob: (sourceId: string, jobId: string) =>
+ post<{ job: CatalogDraftJob }>(`${catalogBase(sourceId)}/draft-jobs/${encodeURIComponent(jobId)}/cancel`),
+ /**
+ * 只重试失败的表:新任务只含原任务里失败、部分完成、已停止的表,用模型和补充说明沿用,起草要求取数据源现在的。
+ * 没有可重试的表 422;有进行中的任务 409(catalog_draft_busy)
+ */
+ retryDraftJob: (sourceId: string, jobId: string) =>
+ post<{ job: CatalogDraftJob }>(`${catalogBase(sourceId)}/draft-jobs/${encodeURIComponent(jobId)}/retry`),
+ /** 存数据源的起草要求(去首尾空白,最多 2000 字,空串清除)。上传表格建的源存在它自己的数据源上 */
+ putGuidance: (sourceId: string, guidance: string) =>
+ put<{ guidance: string | null }>(`${catalogBase(sourceId)}/guidance`, { guidance }),
/**
* 同步剖析:对业务库发少量只读查询,核对关系、取码值候选,结论写进目录。不给 tables 时按使用次数取前 10 张
* 有待核实关系的表。没开启、数据源停用、没有表结构、同一个数据源正在剖析、快照被改动时回 409(detail 是一句中文)
diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts
index 61073e35..bae8db47 100644
--- a/frontend/src/lib/terms.ts
+++ b/frontend/src/lib/terms.ts
@@ -43,10 +43,11 @@
*/
import type {
- CatalogCardinality, CatalogMeasure, CatalogProfileNumberKey, CatalogSource, CatalogStatus, CatalogTableKind, NodeType, SnapshotReasonCode,
+ CatalogCardinality, CatalogDraftJobStatus, CatalogDraftTaskStatus, CatalogMeasure, CatalogProfileNumberKey, CatalogSource, CatalogStatus,
+ CatalogTableKind, NodeType, SnapshotReasonCode,
SqlCheckCode, SqlCheckLevel, ToolTrust,
} from '../types'
-import { formatCost, formatNumber } from './format'
+import { formatCost, formatNumber, formatTokens } from './format'
export const TERMS = {
workflow: '工作流',
@@ -2198,7 +2199,7 @@ export const CATALOG_TEXT = {
noSchemaBody: (note: string) => `${note.replace(/[。.]$/, '')}。数据目录按表结构逐表记录,请先回到数据源卡片点击「探查结构」。`,
noSchemaUploadBody: '这个表格还没有可用的表结构,请重新上传后再编写数据目录。',
emptyTitle: '还没有数据目录',
- emptyBody: '先起草,再逐表确认。起草按数据库注释、外键约束和列名推断,也可以请助手的模型起草中文名和含义。',
+ emptyBody: '先起草,再逐表确认。起草按数据库注释、外键约束和列名推断,也可以用模型起草中文名和含义。',
// ---- 导入表格的说明
systemTitle: '导入时生成的说明',
systemBody: '这个数据源由导入表格生成,表和列的说明由系统按核对结果生成,只读,不能在数据目录中改写。',
@@ -2260,29 +2261,17 @@ export const CATALOG_TEXT = {
scopeAll: (n: number) => `全部 ${formatNumber(n)} 张表`,
draftBase: '按数据库注释、外键约束和列名推断起草。已确认、已驳回的项不会被改动;起草出的项都是推断,需要逐项确认。',
useModel: '用模型起草',
- useModelHint: '请助手的模型按表结构起草中文名、说明、粒度和列的含义。只发送表结构(表名、列名、类型、约束、注释),不发送数据;耗时较长,并产生模型费用。',
+ useModelHint: '请起草模型按表结构起草中文名、说明、粒度和列的含义。只发送表结构(表名、列名、类型、约束、注释)和起草要求,不发送数据;在后台进行,耗时较长,并产生模型费用。',
draftStart: (n: number) => `起草 ${formatNumber(n)} 张表`,
- drafting: '正在起草…',
- draftProgress: (done: number, total: number) => `已完成 ${formatNumber(done)} / ${formatNumber(total)} 张表`,
- draftStop: '停止',
- draftStopping: '当前这一批完成后停止…',
- draftDone: '起草完成',
- draftStopped: (done: number, total: number) => `已停止:完成 ${formatNumber(done)} / ${formatNumber(total)} 张表,其余未起草`,
- draftFailed: '起草中断',
draftSummary: (added: number, updated: number, removed: number) =>
`新增 ${formatNumber(added)} 项、更新 ${formatNumber(updated)} 项${removed ? `、删除 ${formatNumber(removed)} 项` : ''}`,
- // 只在起草确实给出了内容、只是和现有目录一致时用:有表的模型起草失败或没给出可用内容(model_error)时
- // 不能说「没有变化」,改用 draftNothingWritten
+ // 只在起草确实给出了内容、只是和现有目录一致时用:模型没给出可用内容时说 draftNothingWritten,有表没写全时说
+ // draftNothingWrittenFailed,不能说「没有变化」
draftNoChange: '没有变化:起草出的内容与现有目录一致',
draftNothingWritten: '目录没有更新:模型没有给出可用的内容,原因见下方',
- draftModelSkipped: (reason: string) => `模型未参与起草:${reason.replace(/[。.]$/, '')}。已按注释、外键和命名起草。`,
- draftModelUsed: (model: string) => `模型:${model}`,
- draftTableErrors: (n: number) => `${formatNumber(n)} 张表未能起草`,
- draftModelErrors: (n: number) => `${formatNumber(n)} 张表的模型起草失败,已按注释、外键和命名起草`,
+ draftNothingWrittenFailed: '目录没有更新:部分表未写全,原因见下方',
draftClose: '完成',
- moreTables: (n: number) => `…另有 ${formatNumber(n)} 张`,
cancel: '取消',
- draftBackground: '起草仍在进行,当前这一批完成后停止',
// ---- 表详情
backToList: '返回表清单',
prev: '上一张',
@@ -2389,6 +2378,123 @@ export const CATALOG_TEXT = {
reloadDiscardAction: '放弃修改并重新载入',
}
+// ===========================================================================
+// 数据目录的起草任务(/api/datasources/{id}/catalog/draft-jobs):起草在服务端后台进行,关掉对话框不停;逐表起草,
+// 每张表一结束就写进目录。
+//
+// 术语:
+// - 「起草任务」的状态:排队中、正在起草、起草完成、部分表未写全、起草失败、已停止、服务重启中断。
+// 一张表:等待、起草中、已完成、部分完成、失败、已跳过(表结构里没有)、已停止。
+// - 「起草要求」:使用者补充给模型的业务背景和用词约定,存在数据源上,以后每次用模型起草都带上;
+// 「本次补充说明」只用于这一次(重试时沿用)。两者都只交给模型,不改起草规则:冲突时以规则为准。
+// - 「起草模型」:设置里单独的一项(偏好设置 → 起草模型),留空跟随助手的模型。
+// - 宽表的「段」:超过 40 列的表拆成表级一段和若干列分段,逐段起草;写的是「已完成 3 / 9 段」。
+// - 「只重试失败的表」:重新起草失败、部分完成和已停止的表;已经写进目录的内容不回滚、不受影响。
+// ===========================================================================
+
+/** 起草要求、本次补充说明的字数上限(服务端同一个数:超长 422) */
+export const DRAFT_GUIDANCE_MAX = 2000
+export const DRAFT_NOTE_MAX = 1000
+
+export const CATALOG_DRAFT_TEXT = {
+ // ---- 设置阶段:起草要求、本次补充说明、起草模型
+ guidance: '起草要求',
+ guidanceHint: '写给模型的业务背景和用词约定,保存在这个数据源上,以后每次用模型起草都会带上。例如:「核销」指检票入园;金额单位是元',
+ guidanceRule: '起草要求只帮助模型理解业务、统一用词;与起草规则冲突时以规则为准,起草出的项仍需逐项确认',
+ guidancePlaceholder: '业务背景、常用说法、单位约定……',
+ note: '本次补充说明',
+ noteHint: '只用于这一次起草,例如「这一批是会员相关的表」;重试时沿用',
+ notePlaceholder: '可不填',
+ count: (n: number, max: number) => `${formatNumber(n)} / ${formatNumber(max)} 字`,
+ tooLong: (max: number) => `不超过 ${formatNumber(max)} 字`,
+ sourceDescription: (text: string) => `数据源说明也会一并交给模型:${text}`,
+ model: '起草模型',
+ /** 设置里没配起草模型:跟随助手的模型,写出当前是哪个(取不到就不写) */
+ modelFollow: (current: string | null) => (current ? `跟随助手的模型(当前:${current})` : '跟随助手的模型'),
+ /** 设置里配了:模型(没填模型就是那个接入的默认模型)和接入 */
+ modelNamed: (model: string | null, provider: string | null) => (model
+ ? (provider ? `${model}(模型接入「${provider}」)` : model)
+ : `模型接入「${provider}」的默认模型`),
+ modelWhere: '可在「设置 → 偏好设置 → 起草模型」中更换',
+ tooMany: (max: number) => `一次最多起草 ${formatNumber(max)} 张表,请缩小范围`,
+ saveGuidanceFailed: (reason: string) => `起草要求未能保存,未开始起草:${reason}`,
+ startFailed: (reason: string) => `未能开始起草:${reason}`,
+ // ---- 上一次起草(设置阶段,没写全的才提)
+ last: (state: string, at: string) => `上一次起草(${at}):${state}`,
+ lastOpen: '查看',
+ // ---- 进行中
+ queued: '排队中,等待开始…',
+ running: '正在起草…',
+ 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 列的表拆成表级一段和若干列分段,逐段起草',
+ hide: '在后台继续',
+ hideHint: '关闭对话框不会停止起草,进度显示在目录页上方;每张表起草完就写进目录',
+ stop: '停止',
+ busy: '这个数据源已有进行中的起草任务,下面显示的是它的进度',
+ modelUsed: (model: string) => `模型:${model}`,
+ usage: (calls: number, input: number, output: number) =>
+ `调用模型 ${formatNumber(calls)} 次,输入 ${formatTokens(input, { compact: true })}、输出 ${formatTokens(output, { compact: true })}`,
+ // ---- 结束
+ title: {
+ queued: '排队中,等待开始…',
+ running: '正在起草…',
+ succeeded: '起草完成',
+ partial: '起草完成,部分表未写全',
+ failed: '起草失败',
+ cancelled: '已停止',
+ interrupted: '服务重启,起草中断',
+ } as Record,
+ interrupted: '服务重启时这次起草还没有完成,没写完的表未起草。已写进目录的内容保留;可以只重试这些表,接着起草',
+ cancelled: '已写进目录的内容保留,没写完的表未起草',
+ jobFailed: (reason: string) => `起草任务出错:${reason}`,
+ /** 没写全的表:失败、部分完成、已停止各几张;跳过的(表结构里没有)另说 */
+ problems: (c: { failed: number; partial: number; cancelled: number; skipped: number }) => {
+ const parts = [
+ c.failed ? `失败 ${formatNumber(c.failed)} 张` : '',
+ c.partial ? `部分完成 ${formatNumber(c.partial)} 张` : '',
+ c.cancelled ? `已停止 ${formatNumber(c.cancelled)} 张` : '',
+ ].filter(Boolean)
+ const bad = parts.length ? `${formatNumber(c.failed + c.partial + c.cancelled)} 张表未写全:${parts.join('、')}` : ''
+ const skipped = c.skipped ? `${formatNumber(c.skipped)} 张表已跳过(表结构里没有)` : ''
+ return [bad, skipped].filter(Boolean).join(';')
+ },
+ retry: (n: number) => `只重试失败的表(${formatNumber(n)} 张)`,
+ retryHint: '重新起草失败、部分完成和已停止的表;已写进目录的内容不受影响',
+ showAll: (n: number) => `显示全部 ${formatNumber(n)} 张表`,
+ showProblems: '只看未写全的表',
+ tasks: '逐表进度',
+ taskStatus: {
+ pending: '等待', running: '起草中', ok: '已完成', partial: '部分完成', failed: '失败', skipped: '已跳过', cancelled: '已停止',
+ } as Record,
+ taskAdded: (added: number, updated: number) => (added || updated
+ ? `+${formatNumber(added)}${updated ? ` · 改 ${formatNumber(updated)}` : ''}` : ''),
+ taskAddedHint: (added: number, updated: number, removed: number) =>
+ `新增 ${formatNumber(added)} 项、更新 ${formatNumber(updated)} 项${removed ? `、删除 ${formatNumber(removed)} 项` : ''}`,
+ // ---- 目录页上方的进度、结束提示
+ strip: (done: number, total: number) => `正在起草:已完成 ${formatNumber(done)} / ${formatNumber(total)} 张表`,
+ stripQueued: (total: number) => `起草任务排队中,共 ${formatNumber(total)} 张表`,
+ stripOpen: '查看进度',
+ toast: {
+ succeeded: (summary: string) => `数据目录起草完成:${summary}`,
+ partial: (n: number) => `数据目录起草完成,${formatNumber(n)} 张表未写全`,
+ failed: (reason: string) => `数据目录起草失败:${reason}`,
+ cancelled: '数据目录起草已停止',
+ interrupted: '服务重启,数据目录起草中断',
+ },
+ viewResult: '查看结果',
+ // ---- 清单上的标记、表详情里的说明
+ lastFailed: '上次起草失败',
+ lastPartial: '上次起草部分完成',
+ lastWith: (label: string, reason: string) => `${label}:${reason}`,
+ redraft: '重新起草这张表',
+ redraftHint: '用模型重新起草这张表(不带补充说明,带上数据源的起草要求);人工确认、驳回过的项不会被改动',
+ redraftBusy: '已有起草任务在进行,结束后再重新起草这张表',
+}
+
// ===========================================================================
// 目录修改提案:用户在助手(画布)或问数据页说出一条数据事实时,助手提出的数据目录修改。
//
diff --git a/frontend/src/pages/catalog/CatalogPage.tsx b/frontend/src/pages/catalog/CatalogPage.tsx
index 48442f3a..15f8e24e 100644
--- a/frontend/src/pages/catalog/CatalogPage.tsx
+++ b/frontend/src/pages/catalog/CatalogPage.tsx
@@ -7,9 +7,13 @@ import { ApiError, api } from '../../api/client'
import type { CatalogDetail, CatalogList, CatalogTableRow } from '../../types'
import { EmptyState, ErrorState, PageHeader, Skeleton, Spinner, toast, useTicker } from '../../components/ui'
import { useCatalog, useDatasources, useOnReconnect } from '../../store/catalog'
-import { CATALOG_TEXT as CT, PROFILE_STOP_LABEL, PROFILE_TEXT as PT, SCHEMA_PARTIAL_TEXT as SPT } from '../../lib/terms'
+import {
+ CATALOG_DRAFT_TEXT as DT, CATALOG_TEXT as CT, PROFILE_STOP_LABEL, PROFILE_TEXT as PT, SCHEMA_PARTIAL_TEXT as SPT,
+} from '../../lib/terms'
import { StatusLegend, SystemNotesNotice } from './parts'
import { DraftDialog } from './DraftDialog'
+import { isJobActive, retryableCount, useDraftJob } from './draftJob'
+import type { DraftStartBody } from './draftJob'
import { ProfileDialog } from './ProfileDialog'
import type { ProfileJob } from './ProfileDialog'
import { ProfileSettingsDialog } from './ProfileSettings'
@@ -99,7 +103,11 @@ export function CatalogPage() {
const search = writeListParams(params, list).toString()
searchRef.current = search ? `?${search}` : ''
const [selected, setSelected] = useState>(() => new Set())
- const [drafting, setDrafting] = useState(false)
+ /**
+ * 起草对话框开着没有、打开时显示什么:setup 选范围开始;job 显示页面跟着的任务(进行中的进度、刚结束的结果)。
+ * busy:是撞上已有任务(409)转过去显示的
+ */
+ const [draftOpen, setDraftOpen] = useState<{ view: 'setup' | 'job'; busy?: boolean } | null>(null)
/** 起草写进了这些表:详情据此重新载入(编辑中的不动,保存时服务端会用版本号拦下) */
const [drafted, setDrafted] = useState<{ seq: number; tables: Set }>({ seq: 0, tables: new Set() })
const [profiling, setProfiling] = useState(false)
@@ -142,11 +150,59 @@ export function CatalogPage() {
}))
}, [])
- const onDrafted = useCallback((tables: string[]) => {
- setDrafted((cur) => ({ seq: cur.seq + 1, tables: new Set(tables) }))
- setSelected(new Set())
- void load()
- }, [load])
+ /**
+ * 起草任务(后台进行,状态在页面上):每张表一结束就重取清单、正在看的表在里面就重新载入(编辑中的不动,保存时服务端
+ * 会用版本号拦下);结束时重取一次清单。对话框关着时结束的,弹提示并给「查看结果」
+ */
+ const draftOpenRef = useRef(draftOpen)
+ draftOpenRef.current = draftOpen
+ const draft = useDraftJob(sourceId, data?.active_job, {
+ onTablesEnded: (tables) => {
+ setDrafted((cur) => ({ seq: cur.seq + 1, tables: new Set(tables) }))
+ void load()
+ },
+ onFinished: (job) => {
+ void load()
+ if (draftOpenRef.current) return
+ const src = job.source_id
+ const opts = {
+ key: `catalog-draft:${src}`, duration: 10000,
+ action: { label: DT.viewResult, onClick: () => { if (sourceRef.current === src) setDraftOpen({ view: 'job' }) } },
+ }
+ if (job.status === 'succeeded') toast.ok(DT.toast.succeeded(CT.draftSummary(job.added, job.updated, job.removed)), opts)
+ else if (job.status === 'partial') toast.warn(DT.toast.partial(retryableCount(job)), opts)
+ else if (job.status === 'failed') toast.error(DT.toast.failed(job.error ?? ''), opts)
+ else if (job.status === 'interrupted') toast.warn(DT.toast.interrupted, opts)
+ else toast.warn(DT.toast.cancelled, opts)
+ },
+ })
+ const draftJob = draft.job
+ const draftActive = isJobActive(draftJob)
+ /** 打开起草对话框:有进行中的任务就直接显示它;上一次的结果看过了,再点就是重新开始 */
+ const openDraft = useCallback(() => setDraftOpen({ view: draftActive ? 'job' : 'setup' }), [draftActive])
+ const startDraft = useCallback(async (body: DraftStartBody) => {
+ const r = await draft.start(body)
+ // 选中的表已经交给起草任务了:清空,免得接着点「起草所选」又起草一遍
+ if (!r.busy) setSelected(new Set())
+ return r
+ }, [draft.start])
+ /** 详情里「重新起草这张表」:只含这张表、用模型、不带补充说明。模型用不了等开始不了的,直接提示原因 */
+ const redraftTable = useCallback(async (t: string) => {
+ try {
+ const r = await draft.start({ tables: [t], use_model: true })
+ setDraftOpen({ view: 'job', busy: r.busy })
+ } catch (e) {
+ toast.error(e)
+ }
+ }, [draft.start])
+ const onGuidanceSaved = useCallback((g: string | null) => {
+ setData((cur) => cur && ({ ...cur, guidance: g }))
+ }, [])
+ // 上方的进度:跟着的任务在进行就按它;跟着的就是清单说的那一个、已经结束了,就不信清单(清单还没重取回来)
+ const listActive = isJobActive(data?.active_job) ? data?.active_job ?? null : null
+ const stripJob: { done: number; total: number; status: string } | null = draftJob && (draftActive || draftJob.id === listActive?.id)
+ ? (draftActive ? draftJob : null)
+ : listActive
const options = savedOptions?.sourceId === sourceId ? savedOptions.options : source?.options
const profileSettings = useMemo(() => profileSettingsOf(options), [options])
@@ -262,7 +318,7 @@ export function CatalogPage() {
const empty = rows.length > 0 && rows.every((r) => progressOf(r.counts) === 'none')
const hasTables = rows.length > 0
const draftButton = (primary = false) => (
-
)}
{running && {DT.hideHint}
}
- {job.status === 'failed' && job.error && {DT.jobFailed(job.error)}}
+ {job.status === 'failed' && job.error && {job.error}}
{job.status === 'interrupted' && {DT.interrupted}}
{job.status === 'cancelled' && {DT.cancelled}
}
{problemText && (
@@ -376,7 +384,7 @@ function JobView({ job, running, stopping, busy }: { job: CatalogDraftJob; runni
- {tasks.map((t) => )}
+ {tasks.map((t) => )}
@@ -393,9 +401,13 @@ const TASK_ICON: Record 1 && t.status !== 'pending' && t.status !== 'skipped'
const changes = DT.taskAdded(t.added, t.updated)
return (
diff --git a/frontend/src/pages/catalog/draftJob.ts b/frontend/src/pages/catalog/draftJob.ts
index 54a9c36e..eaa2c5b4 100644
--- a/frontend/src/pages/catalog/draftJob.ts
+++ b/frontend/src/pages/catalog/draftJob.ts
@@ -152,7 +152,10 @@ export function useDraftJob(sourceId: string, active: CatalogActiveDraftJob | nu
}
}, [accept])
- /** 只重试失败的表:同 start,409 已有任务时改去显示进行中的那一个 */
+ /**
+ * 只重试失败的表:同 start,409 已有任务时改去显示进行中的那一个;起草模型用不了(409)、没有可重试的表(422)等
+ * 照原样抛给调用方
+ */
const retry = useCallback(async (from: CatalogDraftJob): Promise<{ job: CatalogDraftJob; busy: boolean }> => {
const src = sourceRef.current
try {
diff --git a/scripts/check-catalog.mjs b/scripts/check-catalog.mjs
index afdba716..e90d0fc5 100644
--- a/scripts/check-catalog.mjs
+++ b/scripts/check-catalog.mjs
@@ -407,9 +407,18 @@ const JOB_ACTIVE = new Set(['queued', 'running'])
const TASK_END = new Set(['ok', 'partial', 'failed', 'skipped', 'cancelled'])
const RETRYABLE = new Set(['failed', 'partial', 'cancelled'])
const isoNow = () => new Date().toISOString()
-/** 服务端的两种 409(机读码 + 一句中文) */
+/**
+ * 服务端的几种 409(机读码 + 一句中文)。起草模型用不了有长短两句:没有接入时短(界面原样显示),设置里的起草模型所在
+ * 接入停用时长(带句号,也要原样显示)
+ */
const BUSY = { detail: '这个数据源已有进行中的起草任务,请等它结束或停止后再开始', code: 'catalog_draft_busy' }
const MODEL_DOWN = { detail: '未配置模型接入,无法由助手起草数据目录。请到「设置 → 模型接入」添加', code: 'catalog_draft_model_unavailable' }
+const MODEL_OFF = {
+ detail: '起草模型使用的模型接入「check-gw」已停用。请在「设置 → 模型接入」中启用它,或在「设置 → 偏好设置」的「起草模型」中重新选择',
+ code: 'catalog_draft_model_unavailable',
+}
+/** 任务整体出错时服务端写的原因(catalog_jobs.JOB_ERROR:已经带着「起草任务出错:」开头) */
+const JOB_ERR = '起草任务出错:database is locked'
/** 起草时每段最多几列(服务端的常量):超过的宽表拆成「表级一段 + 列分段」 */
const PART_COLUMNS = 40
@@ -483,15 +492,28 @@ function finishJob(job, status) {
job.started_at ??= job.finished_at
}
-/** 停下:没开始和没写完的表记 cancelled,已经写进目录的不动 */
+/**
+ * 停下:没开始和没写完的表记 cancelled,已经写进目录的不动。带原因的(服务重启中断)任务和表记同一句(服务端 _close 的
+ * 做法);使用者点的停止不带原因
+ */
function stopJob(job, status = 'cancelled', error = null) {
for (const t of job.tasks) {
if (t.status === 'pending' || t.status === 'running') Object.assign(t, { status: 'cancelled', error, finished_at: isoNow() })
}
+ if (error) job.error = error
delete job.stopAsked
finishJob(job, status)
}
+/** 任务整体出错(数据源没了、开始时起草模型已经用不了、意外出错):没结束的表也记失败,原因同任务的(服务端 _end 的做法) */
+function failJob(job, error) {
+ for (const t of job.tasks) {
+ if (t.status === 'pending' || t.status === 'running') Object.assign(t, { status: 'failed', error, finished_at: isoNow() })
+ }
+ job.error = error
+ finishJob(job, 'failed')
+}
+
function lastDraftOf(state, src, name) {
for (const job of [...state.jobs].reverse()) {
if (job.source_id !== src) continue
@@ -543,7 +565,7 @@ function jobHandlers(state, reply) {
else job.stopAsked = true
return json({ job: jobOut(job) })(route)
})],
- // 重试:新任务只含失败、部分完成、停止的表,沿用用模型和补充说明
+ // 重试:新任务只含失败、部分完成、停止的表,沿用用模型和补充说明。起草模型用不了的 409 由检查用 replies.retry 给
[/^POST \/datasources\/check-cat-[a-z]+\/catalog\/draft-jobs\/[^/]+\/retry$/, reply('retry', (route, { key }) => {
const old = jobOf(state, key)
if (!old) return NO_JOB(route)
@@ -931,7 +953,7 @@ await section('数据目录 · 起草:设置与开始', async () => {
// 起草模型用不了:409 带机读码,留在设置阶段写明原因,不进入进行中、不去取任务
replies.createJob.push(json(MODEL_DOWN, 409))
await page.locator('[data-draft-start]').click()
- check('起草模型用不了(409):留在设置阶段,写明原因', await until(async () => (await draftText(page, '[data-draft-start-error]')).includes('未配置模型接入'))
+ check('起草模型用不了(409):留在设置阶段,照原样写服务端那句话', await until(async () => (await draftText(page, '[data-draft-start-error]')) === MODEL_DOWN.detail)
&& (await draftPhase(page)) === 'setup', await draftText(page, '[data-draft-start-error]'))
const keys = writes(sent, /./).map((s) => s.key.replace(/^(\w+) .*\/catalog\//, '$1 '))
check('……起草要求改过:先存起草要求、再建任务', keys.join(',') === 'PUT guidance,POST draft-jobs', keys.join(','))
@@ -961,7 +983,8 @@ await section('数据目录 · 起草:设置与开始', async () => {
await section('数据目录 · 起草:进度、结果与重试', async () => {
const state = freshState()
- const { page, sent, errors, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
+ const replies = { retry: [] }
+ const { page, sent, errors, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state, replies) })
await page.locator('[data-catalog-row]').first().waitFor()
check('开始起草(闸机到会员 3 张,用模型,带补充说明)', await startDraft(page, { select: ['gates', 'members'], model: true, note: DRAFT_NOTE }))
const job = state.jobs.at(-1)
@@ -1010,9 +1033,17 @@ await section('数据目录 · 起草:进度、结果与重试', async () => {
// 只重试失败的表
const retry = page.locator('[data-draft-retry]')
check('给「只重试失败的表(2 张)」', (await retry.innerText().catch(() => '')).includes('只重试失败的表(2 张)'), await retry.innerText().catch(() => ''))
+ // 重试时起草模型已经用不了(服务端和建任务时一样回 409 catalog_draft_model_unavailable):留在结果里,照原样写服务端
+ // 那句话(长句也不拆成「标题:原因」),不建新任务
+ replies.retry.push(json(MODEL_OFF, 409))
+ await retry.click()
+ check('重试时起草模型用不了(409):留在结果里,照原样写服务端那句话,不建新任务',
+ await until(async () => (await draftText(page, '[data-draft-action-error]')) === MODEL_OFF.detail)
+ && (await draftPhase(page)) === 'done' && state.jobs.at(-1) === job, await draftText(page, '[data-draft-action-error]'))
await retry.click()
check('重试:调重试接口,进入新任务的进行中,只含没写全的 2 张', await until(async () => (await draftPhase(page)) === 'running'
- && (await draftBox(page).locator('[data-draft-task]').count()) === 2) && count(sent, new RegExp(`^POST .*draft-jobs/${job.id}/retry$`)) === 1)
+ && (await draftBox(page).locator('[data-draft-task]').count()) === 2) && count(sent, new RegExp(`^POST .*draft-jobs/${job.id}/retry$`)) === 2)
+ check('……上一次的报错收起', await draftBox(page).locator('[data-draft-action-error]').count() === 0)
const job2 = state.jobs.at(-1)
check('……新任务沿用用模型和补充说明', job2.id !== job.id && job2.use_model && job2.note === DRAFT_NOTE)
settle(state, job2, 'store_sales', { added: 1, model_items: 3 })
@@ -1082,9 +1113,11 @@ await section('数据目录 · 起草:停止、后台继续与再打开', asyn
await strip.locator('[data-catalog-draft-open]').click()
check('点开:回到对话框,直接显示进行中的任务', await until(async () => (await draftPhase(page)) === 'running')
&& (await draftText(page, '[data-draft-progress]')) === '已完成 2 / 20 张表', await draftText(page, '[data-draft-progress]'))
- // 停止:调停止接口;服务端停下正在进行的调用之前写「正在停止」
+ // 停止:调停止接口;服务端马上取消进行中的模型调用,只等手上那张表写完(停止接口等它收尾才答)。这期间写「正在停止」,
+ // 说法照服务端的做法:不是「等当前这次调用结束」——一次调用可能要几分钟
await page.locator('[data-draft-stop]').click()
- check('点「停止」:调停止接口,写「正在停止」,停止按钮不能再点', await until(async () => (await draftText(page, '[data-draft-title]')).startsWith('正在停止'))
+ check('点「停止」:调停止接口,写「正在停止:取消进行中的模型调用…」,停止按钮不能再点',
+ await until(async () => (await draftText(page, '[data-draft-title]')).startsWith('正在停止:取消进行中的模型调用'))
&& count(sent, new RegExp(`^POST .*draft-jobs/${job.id}/cancel$`)) === 1 && await page.locator('[data-draft-stop]').isDisabled(),
await draftText(page, '[data-draft-title]'))
stopJob(job)
@@ -1128,7 +1161,7 @@ await section('数据目录 · 起草:停止、后台继续与再打开', asyn
await close()
})
-await section('数据目录 · 起草:已有任务(409)与服务重启中断', async () => {
+await section('数据目录 · 起草:已有任务(409)、服务重启中断与整体出错', async () => {
const state = freshState()
// 上一次起草因服务重启中断:没结束的表记已停止,写明原因
const old = newJob(state, S1, { tables: ['visits', 'orders', 'parks'], use_model: true, note: DRAFT_NOTE })
@@ -1144,7 +1177,10 @@ await section('数据目录 · 起草:已有任务(409)与服务重启中
check('……点开:结果写明服务重启中断,可以接着重试', await until(async () => (await draftPhase(page)) === 'done')
&& (await draftText(page, '[data-draft-title]')) === '服务重启,起草中断' && (await draftText(page, '[data-draft-interrupted]')).includes('重试'),
`${await draftText(page, '[data-draft-title]')} · ${await draftText(page, '[data-draft-interrupted]')}`)
- check('……没写的 2 张写原因', (await draftText(page, '[data-draft-task="orders"] [data-draft-task-reason]')).includes('服务重启,起草中断'))
+ // 服务端把任务和没写完的表都记「服务重启,起草中断」:标题、提示已经写了,逐表只写「已停止」,不再各写一遍
+ check('……没写的 2 张记「已停止」,原因不再逐张重复', (await draftText(page, '[data-draft-task="orders"] [data-draft-task-status]')) === '已停止'
+ && (await draftText(page, '[data-draft-task="parks"] [data-draft-task-status]')) === '已停止'
+ && await draftBox(page).locator('[data-draft-task-reason]').count() === 0, await draftText(page, '[data-draft-tasks]'))
await shot(page, 'catalog-draft-interrupted')
await page.locator('[data-draft-retry]').click()
check('……「只重试失败的表」接着起草没写的 2 张', await until(async () => (await draftPhase(page)) === 'running')
@@ -1156,6 +1192,23 @@ await section('数据目录 · 起草:已有任务(409)与服务重启中
await until(async () => (await draftPhase(page)) === 'done')
await page.locator('[data-draft-close]').click()
+ // 任务整体出错(意外出错、开始时起草模型已经用不了……):服务端把没结束的表也记失败、原因同任务的。原因只在标题下面
+ // 写一次,照服务端的原话(它已经带着「起草任务出错:」开头,界面不再套一层);逐表不再重复同一句;这些表都能重试
+ check('开始起草(使用次数最多的 20 张,用模型)', await startDraft(page, { model: true }))
+ const broken = state.jobs.at(-1)
+ settle(state, broken, 'visits', { added: 2, model_items: 4 })
+ settle(state, broken, 'orders', { status: 'failed', model_items: 0, model_error: CALL_ERR })
+ failJob(broken, JOB_ERR)
+ check('任务整体出错:标题写「起草失败」,原因照服务端的原话写一次', await until(async () => (await draftPhase(page)) === 'done')
+ && (await draftText(page, '[data-draft-title]')) === '起草失败' && (await draftText(page, '[data-draft-job-error]')) === JOB_ERR,
+ `${await draftText(page, '[data-draft-title]')} · ${await draftText(page, '[data-draft-job-error]')}`)
+ check('……逐表只列没写的 19 张;和任务相同的原因不再逐张重复,各有原因的照样写', (await draftBox(page).locator('[data-draft-task]').count()) === 19
+ && await draftBox(page).locator('[data-draft-task-reason]').count() === 1
+ && (await draftText(page, '[data-draft-task="orders"] [data-draft-task-reason]')) === CALL_ERR, await draftText(page, '[data-draft-tasks]'))
+ check('……这 19 张都能重试', (await page.locator('[data-draft-retry]').innerText().catch(() => '')).includes('(19 张)'))
+ await shot(page, 'catalog-draft-failed')
+ await page.locator('[data-draft-close]').click()
+
// 别处(另一个标签页)又起了一个任务,这一页还不知道:点开始时服务端回 409,转去显示那一个
await page.locator('[data-catalog-draft]').first().click()
await draftBox(page).waitFor()
From 0dbfeffb23af3b3ca1cd64ed9eef75e47835b175 Mon Sep 17 00:00:00 2001
From: ThearchyHelios
Date: Tue, 13 Oct 2026 16:34:46 +0800
Subject: [PATCH 6/6] =?UTF-8?q?check-catalog=EF=BC=9A409=20=E4=B9=8B?=
=?UTF-8?q?=E5=90=8E=E3=80=8C=E5=8F=96=E7=9A=84=E6=98=AF=E8=BF=9B=E8=A1=8C?=
=?UTF-8?q?=E4=B8=AD=E7=9A=84=E9=82=A3=E4=B8=80=E4=B8=AA=E3=80=8D=E5=8E=9F?=
=?UTF-8?q?=E6=9D=A5=E6=81=92=E4=B8=BA=E7=9C=9F=EF=BC=88=E8=AE=A1=E6=95=B0?=
=?UTF-8?q?=20>=3D=200=EF=BC=89=EF=BC=8C=E6=94=B9=E6=88=90=E7=AD=89?=
=?UTF-8?q?=E5=88=B0=E7=A1=AE=E5=AE=9E=E5=9C=A8=E5=8F=96=E9=82=A3=E4=B8=AA?=
=?UTF-8?q?=E4=BB=BB=E5=8A=A1=E7=9A=84=E8=BF=9B=E5=BA=A6=EF=BC=9B=E8=A1=A5?=
=?UTF-8?q?=E3=80=8C=E4=BB=BB=E5=8A=A1=E8=BF=9B=E8=A1=8C=E4=B8=AD=E7=A6=BB?=
=?UTF-8?q?=E5=BC=80=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95=E9=A1=B5=EF=BC=88?=
=?UTF-8?q?=E9=A1=B5=E5=86=85=E8=B7=B3=E8=BD=AC=EF=BC=8C=E9=A1=B5=E9=9D=A2?=
=?UTF-8?q?=E5=8D=B8=E4=B8=8B=EF=BC=89=E4=BB=A5=E5=90=8E=E4=B8=8D=E5=86=8D?=
=?UTF-8?q?=E5=8F=96=E8=BF=9B=E5=BA=A6=E3=80=8D=EF=BC=8C=E4=B9=8B=E5=89=8D?=
=?UTF-8?q?=E5=8F=AA=E6=9F=A5=E4=BA=86=E7=BB=93=E6=9D=9F=E4=BB=A5=E5=90=8E?=
=?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=8F=96=EF=BC=9B=E8=AE=BE=E7=BD=AE=E9=98=B6?=
=?UTF-8?q?=E6=AE=B5=E5=85=88=E7=AD=89=E4=B8=8A=E4=B8=80=E6=AC=A1=E8=B5=B7?=
=?UTF-8?q?=E8=8D=89=E5=8F=96=E5=9B=9E=E6=9D=A5=E5=86=8D=E8=AE=A9=E5=88=AB?=
=?UTF-8?q?=E5=A4=84=E5=BB=BA=E4=BB=BB=E5=8A=A1=EF=BC=8C=E5=85=8D=E5=BE=97?=
=?UTF-8?q?=E5=AE=83=E6=99=9A=E5=88=B0=E3=80=81=E5=8F=96=E5=9B=9E=E7=9A=84?=
=?UTF-8?q?=E5=B7=B2=E7=BB=8F=E6=98=AF=E8=BF=9B=E8=A1=8C=E4=B8=AD=E7=9A=84?=
=?UTF-8?q?=E4=BB=BB=E5=8A=A1=E8=80=8C=E8=B5=B0=E4=B8=8D=E5=88=B0=20409?=
=?UTF-8?q?=E3=80=82=E5=81=87=E6=9C=8D=E5=8A=A1=E7=AB=AF=E7=9A=84=E8=AF=B4?=
=?UTF-8?q?=E6=B3=95=E7=85=A7=E5=90=8E=E7=AB=AF=E7=9A=84=E5=8E=9F=E8=AF=9D?=
=?UTF-8?q?=EF=BC=9A=E8=B5=B7=E8=8D=89=E6=A8=A1=E5=9E=8B=E7=94=A8=E4=B8=8D?=
=?UTF-8?q?=E4=BA=86=E6=98=AF=E3=80=8C=E6=97=A0=E6=B3=95=E7=94=B1=E6=A8=A1?=
=?UTF-8?q?=E5=9E=8B=E8=B5=B7=E8=8D=89=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95?=
=?UTF-8?q?=E3=80=8D=EF=BC=88=E5=8E=9F=E6=9D=A5=E8=BF=98=E6=98=AF=E3=80=8C?=
=?UTF-8?q?=E7=94=B1=E5=8A=A9=E6=89=8B=E3=80=8D=EF=BC=89=EF=BC=8C=E5=B7=B2?=
=?UTF-8?q?=E6=9C=89=E4=BB=BB=E5=8A=A1=E3=80=81=E4=BB=BB=E5=8A=A1=E4=B8=8D?=
=?UTF-8?q?=E5=AD=98=E5=9C=A8=E3=80=81=E5=B7=B2=E7=BB=8F=E7=BB=93=E6=9D=9F?=
=?UTF-8?q?=E3=80=81=E6=B2=A1=E6=9C=89=E5=8F=AF=E9=87=8D=E8=AF=95=E7=9A=84?=
=?UTF-8?q?=E8=A1=A8=E3=80=81=E8=B5=B7=E8=8D=89=E8=A6=81=E6=B1=82=E8=B6=85?=
=?UTF-8?q?=E9=95=BF=E5=90=8C=E6=A0=B7=E5=AF=B9=E9=BD=90=EF=BC=9B=E8=B5=B7?=
=?UTF-8?q?=E8=8D=89=E8=A6=81=E6=B1=82=E7=9A=84=E8=AF=B7=E6=B1=82=E6=B2=A1?=
=?UTF-8?q?=E6=9C=89=20guidance=20=E8=BF=99=E4=B8=80=E9=A1=B9=E6=97=B6?=
=?UTF-8?q?=E5=90=8C=E5=90=8E=E7=AB=AF=E5=9B=9E=20422=EF=BC=9B=E9=87=8D?=
=?UTF-8?q?=E8=AF=95=E5=BB=BA=E7=9A=84=E4=BB=BB=E5=8A=A1=E5=B8=A6=20retry?=
=?UTF-8?q?=5Fof=E3=80=82types.ts=20=E7=9A=84=20CatalogDraftJob=20?=
=?UTF-8?q?=E5=8A=A0=E5=8F=AF=E9=80=89=E7=9A=84=20retry=5Fof=EF=BC=88?=
=?UTF-8?q?=E7=BA=A6=E5=AE=9A=E9=87=8C=E6=B2=A1=E6=9C=89=E3=80=81=E5=90=8E?=
=?UTF-8?q?=E7=AB=AF=E5=A4=9A=E7=BB=99=E7=9A=84=EF=BC=8C=E7=95=8C=E9=9D=A2?=
=?UTF-8?q?=E4=B8=8D=E7=94=A8=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/src/types.ts | 2 ++
scripts/check-catalog.mjs | 63 ++++++++++++++++++++++++++-------------
2 files changed, 45 insertions(+), 20 deletions(-)
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 24774075..ef13a6d2 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -2918,6 +2918,8 @@ export interface CatalogDraftJob {
finished_at: string | null
/** 谁建的(X-Actor) */
created_by: string | null
+ /** 重试建的任务记着原任务的 id,别的为 null。接口约定里没有这一项,服务端多给的;界面不用,老服务端没有这个键 */
+ retry_of?: string | null
/** 表数 */
total: number
/** 已结束的表数 */
diff --git a/scripts/check-catalog.mjs b/scripts/check-catalog.mjs
index e90d0fc5..dc8dde8b 100644
--- a/scripts/check-catalog.mjs
+++ b/scripts/check-catalog.mjs
@@ -408,11 +408,14 @@ const TASK_END = new Set(['ok', 'partial', 'failed', 'skipped', 'cancelled'])
const RETRYABLE = new Set(['failed', 'partial', 'cancelled'])
const isoNow = () => new Date().toISOString()
/**
- * 服务端的几种 409(机读码 + 一句中文)。起草模型用不了有长短两句:没有接入时短(界面原样显示),设置里的起草模型所在
- * 接入停用时长(带句号,也要原样显示)
+ * 服务端的几种答复(机读码 + 一句中文),说法照 api/catalog.py、data/catalog_jobs.py、data/draft_model.py 的原话。
+ * 起草模型用不了有长短两句:没有接入时短(界面原样显示),设置里的起草模型所在接入停用时长(带句号,也要原样显示)
*/
-const BUSY = { detail: '这个数据源已有进行中的起草任务,请等它结束或停止后再开始', code: 'catalog_draft_busy' }
-const MODEL_DOWN = { detail: '未配置模型接入,无法由助手起草数据目录。请到「设置 → 模型接入」添加', code: 'catalog_draft_model_unavailable' }
+const busyOf = (state, src) => ({
+ detail: `数据源「${state.sources.find((s) => s.id === src)?.name ?? src}」正在起草数据目录,请等这次起草结束(或先停止它)再开始新的起草`,
+ code: 'catalog_draft_busy',
+})
+const MODEL_DOWN = { detail: '未配置模型接入,无法由模型起草数据目录。请到「设置 → 模型接入」添加', code: 'catalog_draft_model_unavailable' }
const MODEL_OFF = {
detail: '起草模型使用的模型接入「check-gw」已停用。请在「设置 → 模型接入」中启用它,或在「设置 → 偏好设置」的「起草模型」中重新选择',
code: 'catalog_draft_model_unavailable',
@@ -422,8 +425,8 @@ const JOB_ERR = '起草任务出错:database is locked'
/** 起草时每段最多几列(服务端的常量):超过的宽表拆成「表级一段 + 列分段」 */
const PART_COLUMNS = 40
-/** 建一个排队的任务:表结构里没有的表照样建进任务、记 skipped */
-function newJob(state, src, { tables, use_model, note = null }) {
+/** 建一个排队的任务:表结构里没有的表照样建进任务、记 skipped。重试建的记着原任务(retry_of,界面不用) */
+function newJob(state, src, { tables, use_model, note = null, retry_of = null }) {
const id = `check-job-${++state.jobSeq}`
const tasks = tables.map((name) => {
const t = state.tables[src].find((x) => x.name === name && !x.missing)
@@ -439,7 +442,7 @@ function newJob(state, src, { tables, use_model, note = null }) {
const job = {
id, source_id: src, status: 'queued', use_model: !!use_model, model: use_model ? 'check-draft-model' : null,
guidance: state.guidance[src] ?? null, note, created_at: isoNow(), started_at: null, finished_at: null, created_by: ACTOR,
- usage: { calls: 0, input_tokens: 0, output_tokens: 0 }, error: null, tasks,
+ retry_of, usage: { calls: 0, input_tokens: 0, output_tokens: 0 }, error: null, tasks,
}
state.jobs.push(job)
return job
@@ -530,19 +533,22 @@ const jobOf = (state, key) => {
const activeOf = (state, src) => state.jobs.find((j) => j.source_id === src && JOB_ACTIVE.has(j.status)) ?? null
function jobHandlers(state, reply) {
- const NO_JOB = json({ detail: '找不到这次起草任务' }, 404)
+ const NO_JOB = json({ detail: '起草任务不存在,可能属于别的数据源,或者已随数据源删除' }, 404)
return [
- // 起草要求(数据源级):空串清除,超长 422
+ // 起草要求(数据源级):空串清除,超长 422。请求里必须有 guidance 这一项(服务端同样:没有就是 pydantic 的英文 422)
[/^PUT \/datasources\/check-cat-[a-z]+\/catalog\/guidance$/, reply('guidance', (route, { key, body }) => {
- const g = String(body?.guidance ?? '').trim()
- if ([...g].length > 2000) return json({ detail: '起草要求不能超过 2,000 字' }, 422)(route)
+ if (!body || typeof body !== 'object' || !('guidance' in body)) {
+ return json({ detail: [{ type: 'missing', loc: ['guidance'], msg: 'Field required', input: body }] }, 422)(route)
+ }
+ const g = String(body.guidance ?? '').trim()
+ if ([...g].length > 2000) return json({ detail: `起草要求最多 2000 字,当前 ${[...g].length} 字,请删减后再保存` }, 422)(route)
state.guidance[parts(key)[2]] = g || null
return json({ guidance: g || null })(route)
})],
// 建任务:同一个源已有排队、进行中的 409;不给 tables 取使用次数前 20 张
[/^POST \/datasources\/check-cat-[a-z]+\/catalog\/draft-jobs$/, reply('createJob', (route, { key, body }) => {
const src = parts(key)[2]
- if (activeOf(state, src)) return json(BUSY, 409)(route)
+ if (activeOf(state, src)) return json(busyOf(state, src), 409)(route)
const tables = body?.tables?.length ? body.tables
: state.tables[src].filter((t) => !t.missing).sort((a, b) => b.usage - a.usage).slice(0, 20).map((t) => t.name)
const job = newJob(state, src, { tables, use_model: !!body?.use_model, note: body?.note?.trim() || null })
@@ -560,7 +566,7 @@ function jobHandlers(state, reply) {
[/^POST \/datasources\/check-cat-[a-z]+\/catalog\/draft-jobs\/[^/]+\/cancel$/, reply('cancel', (route, { key }) => {
const job = jobOf(state, key)
if (!job) return NO_JOB(route)
- if (!JOB_ACTIVE.has(job.status)) return json({ detail: '这次起草已经结束', code: 'catalog_draft_finished' }, 409)(route)
+ if (!JOB_ACTIVE.has(job.status)) return json({ detail: '这次起草已经结束了,不需要停止', code: 'catalog_draft_finished' }, 409)(route)
if (job.status === 'queued') stopJob(job)
else job.stopAsked = true
return json({ job: jobOut(job) })(route)
@@ -569,10 +575,10 @@ function jobHandlers(state, reply) {
[/^POST \/datasources\/check-cat-[a-z]+\/catalog\/draft-jobs\/[^/]+\/retry$/, reply('retry', (route, { key }) => {
const old = jobOf(state, key)
if (!old) return NO_JOB(route)
- if (activeOf(state, old.source_id)) return json(BUSY, 409)(route)
+ if (activeOf(state, old.source_id)) return json(busyOf(state, old.source_id), 409)(route)
const tables = old.tasks.filter((t) => RETRYABLE.has(t.status)).map((t) => t.table_name)
- if (!tables.length) return json({ detail: '这次起草没有需要重试的表' }, 422)(route)
- const job = newJob(state, old.source_id, { tables, use_model: old.use_model, note: old.note })
+ if (!tables.length) return json({ detail: '这次起草里没有失败、只写出一部分或停止了的表,不需要重试' }, 422)(route)
+ const job = newJob(state, old.source_id, { tables, use_model: old.use_model, note: old.note, retry_of: old.id })
return json({ job: jobOut(job) }, 202)(route)
})],
[/^GET \/datasources\/check-cat-[a-z]+\/catalog\/draft-jobs\/[^/]+$/, (route, { key }) => {
@@ -1045,7 +1051,7 @@ await section('数据目录 · 起草:进度、结果与重试', async () => {
&& (await draftBox(page).locator('[data-draft-task]').count()) === 2) && count(sent, new RegExp(`^POST .*draft-jobs/${job.id}/retry$`)) === 2)
check('……上一次的报错收起', await draftBox(page).locator('[data-draft-action-error]').count() === 0)
const job2 = state.jobs.at(-1)
- check('……新任务沿用用模型和补充说明', job2.id !== job.id && job2.use_model && job2.note === DRAFT_NOTE)
+ check('……新任务沿用用模型和补充说明', job2.id !== job.id && job2.use_model && job2.note === DRAFT_NOTE && job2.retry_of === job.id)
settle(state, job2, 'store_sales', { added: 1, model_items: 3 })
settle(state, job2, 'members', { added: 5, model_items: 5 })
finishJob(job2)
@@ -1161,7 +1167,7 @@ await section('数据目录 · 起草:停止、后台继续与再打开', asyn
await close()
})
-await section('数据目录 · 起草:已有任务(409)、服务重启中断与整体出错', async () => {
+await section('数据目录 · 起草:已有任务(409)、服务重启中断、整体出错与离开页面', async () => {
const state = freshState()
// 上一次起草因服务重启中断:没结束的表记已停止,写明原因
const old = newJob(state, S1, { tables: ['visits', 'orders', 'parks'], use_model: true, note: DRAFT_NOTE })
@@ -1209,16 +1215,33 @@ await section('数据目录 · 起草:已有任务(409)、服务重启中
await shot(page, 'catalog-draft-failed')
await page.locator('[data-draft-close]').click()
- // 别处(另一个标签页)又起了一个任务,这一页还不知道:点开始时服务端回 409,转去显示那一个
+ // 别处(另一个标签页)又起了一个任务,这一页还不知道:点开始时服务端回 409,转去显示那一个。先等设置阶段把上一次起草
+ // 取回来(写出「上一次起草…起草失败」),免得它晚到、取回的已经是别处那个进行中的任务,就走不到 409 了
await page.locator('[data-catalog-draft]').first().click()
await draftBox(page).waitFor()
+ check('上一次整体出错:设置阶段写明「起草失败」', await until(async () => (await draftText(page, '[data-draft-last]')).includes('起草失败')),
+ await draftText(page, '[data-draft-last]'))
const other = newJob(state, S1, { tables: ['gates', 'members'], use_model: true })
settle(state, other, 'gates', { added: 1 })
+ const pollsOf = (id) => count(sent, new RegExp(`^GET .*draft-jobs/${id}$`))
await page.locator('[data-draft-start]').click()
check('已有进行中的任务(409):转去显示那一个,并写明', await until(async () => (await draftPhase(page)) === 'running')
&& (await draftText(page, '[data-draft-busy]')).includes('已有进行中的起草任务')
&& (await draftText(page, '[data-draft-progress]')) === '已完成 1 / 2 张表', `${await draftText(page, '[data-draft-busy]')} · ${await draftText(page, '[data-draft-progress]')}`)
- check('……取的是进行中的那一个', count(sent, /draft-jobs\/active$/) >= 1 && count(sent, new RegExp(`^GET .*draft-jobs/${other.id}$`)) >= 0)
+ check('……先问进行中的是哪一个,之后跟着它取进度', count(sent, /draft-jobs\/active$/) >= 1 && await until(async () => pollsOf(other.id) >= 1),
+ `active ${count(sent, /draft-jobs\/active$/)} 次,取进度 ${pollsOf(other.id)} 次`)
+
+ // 任务进行中离开数据目录页(页内跳转,页面卸下,不是重新载入):不再取进度。卸下以后稍停,让卸下前已经发出的那次
+ // 取进度落进记录(停得太久,没清掉的那一拍可能恰好落在这段里、数不出来),再数两个多间隔
+ await page.locator('[data-draft-hide]').click()
+ await until(async () => (await draftBox(page).count()) === 0)
+ await page.locator('[data-catalog-back]').click()
+ const left = await until(async () => new URL(page.url()).pathname === '/data/databases' && (await page.locator('[data-catalog-page]').count()) === 0)
+ await sleep(100)
+ const polled = pollsOf(other.id)
+ await sleep(3200)
+ check('任务进行中离开数据目录页:页面卸下以后不再取进度', left && pollsOf(other.id) === polled,
+ `${left ? '' : `没有离开(${new URL(page.url()).pathname});`}${polled} → ${pollsOf(other.id)}`)
stopJob(other)
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()