From 0dbfeffb23af3b3ca1cd64ed9eef75e47835b175 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 13 Oct 2026 16:34:46 +0800 Subject: [PATCH] =?UTF-8?q?check-catalog=EF=BC=9A409=20=E4=B9=8B=E5=90=8E?= =?UTF-8?q?=E3=80=8C=E5=8F=96=E7=9A=84=E6=98=AF=E8=BF=9B=E8=A1=8C=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=E9=82=A3=E4=B8=80=E4=B8=AA=E3=80=8D=E5=8E=9F=E6=9D=A5?= =?UTF-8?q?=E6=81=92=E4=B8=BA=E7=9C=9F=EF=BC=88=E8=AE=A1=E6=95=B0=20>=3D?= =?UTF-8?q?=200=EF=BC=89=EF=BC=8C=E6=94=B9=E6=88=90=E7=AD=89=E5=88=B0?= =?UTF-8?q?=E7=A1=AE=E5=AE=9E=E5=9C=A8=E5=8F=96=E9=82=A3=E4=B8=AA=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E7=9A=84=E8=BF=9B=E5=BA=A6=EF=BC=9B=E8=A1=A5=E3=80=8C?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E8=BF=9B=E8=A1=8C=E4=B8=AD=E7=A6=BB=E5=BC=80?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95=E9=A1=B5=EF=BC=88=E9=A1=B5?= =?UTF-8?q?=E5=86=85=E8=B7=B3=E8=BD=AC=EF=BC=8C=E9=A1=B5=E9=9D=A2=E5=8D=B8?= =?UTF-8?q?=E4=B8=8B=EF=BC=89=E4=BB=A5=E5=90=8E=E4=B8=8D=E5=86=8D=E5=8F=96?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E3=80=8D=EF=BC=8C=E4=B9=8B=E5=89=8D=E5=8F=AA?= =?UTF-8?q?=E6=9F=A5=E4=BA=86=E7=BB=93=E6=9D=9F=E4=BB=A5=E5=90=8E=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=8F=96=EF=BC=9B=E8=AE=BE=E7=BD=AE=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=E5=85=88=E7=AD=89=E4=B8=8A=E4=B8=80=E6=AC=A1=E8=B5=B7=E8=8D=89?= =?UTF-8?q?=E5=8F=96=E5=9B=9E=E6=9D=A5=E5=86=8D=E8=AE=A9=E5=88=AB=E5=A4=84?= =?UTF-8?q?=E5=BB=BA=E4=BB=BB=E5=8A=A1=EF=BC=8C=E5=85=8D=E5=BE=97=E5=AE=83?= =?UTF-8?q?=E6=99=9A=E5=88=B0=E3=80=81=E5=8F=96=E5=9B=9E=E7=9A=84=E5=B7=B2?= =?UTF-8?q?=E7=BB=8F=E6=98=AF=E8=BF=9B=E8=A1=8C=E4=B8=AD=E7=9A=84=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E8=80=8C=E8=B5=B0=E4=B8=8D=E5=88=B0=20409=E3=80=82?= =?UTF-8?q?=E5=81=87=E6=9C=8D=E5=8A=A1=E7=AB=AF=E7=9A=84=E8=AF=B4=E6=B3=95?= =?UTF-8?q?=E7=85=A7=E5=90=8E=E7=AB=AF=E7=9A=84=E5=8E=9F=E8=AF=9D=EF=BC=9A?= =?UTF-8?q?=E8=B5=B7=E8=8D=89=E6=A8=A1=E5=9E=8B=E7=94=A8=E4=B8=8D=E4=BA=86?= =?UTF-8?q?=E6=98=AF=E3=80=8C=E6=97=A0=E6=B3=95=E7=94=B1=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E8=B5=B7=E8=8D=89=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95=E3=80=8D?= =?UTF-8?q?=EF=BC=88=E5=8E=9F=E6=9D=A5=E8=BF=98=E6=98=AF=E3=80=8C=E7=94=B1?= =?UTF-8?q?=E5=8A=A9=E6=89=8B=E3=80=8D=EF=BC=89=EF=BC=8C=E5=B7=B2=E6=9C=89?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E3=80=81=E4=BB=BB=E5=8A=A1=E4=B8=8D=E5=AD=98?= =?UTF-8?q?=E5=9C=A8=E3=80=81=E5=B7=B2=E7=BB=8F=E7=BB=93=E6=9D=9F=E3=80=81?= =?UTF-8?q?=E6=B2=A1=E6=9C=89=E5=8F=AF=E9=87=8D=E8=AF=95=E7=9A=84=E8=A1=A8?= =?UTF-8?q?=E3=80=81=E8=B5=B7=E8=8D=89=E8=A6=81=E6=B1=82=E8=B6=85=E9=95=BF?= =?UTF-8?q?=E5=90=8C=E6=A0=B7=E5=AF=B9=E9=BD=90=EF=BC=9B=E8=B5=B7=E8=8D=89?= =?UTF-8?q?=E8=A6=81=E6=B1=82=E7=9A=84=E8=AF=B7=E6=B1=82=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=20guidance=20=E8=BF=99=E4=B8=80=E9=A1=B9=E6=97=B6=E5=90=8C?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E5=9B=9E=20422=EF=BC=9B=E9=87=8D=E8=AF=95?= =?UTF-8?q?=E5=BB=BA=E7=9A=84=E4=BB=BB=E5=8A=A1=E5=B8=A6=20retry=5Fof?= =?UTF-8?q?=E3=80=82types.ts=20=E7=9A=84=20CatalogDraftJob=20=E5=8A=A0?= =?UTF-8?q?=E5=8F=AF=E9=80=89=E7=9A=84=20retry=5Fof=EF=BC=88=E7=BA=A6?= =?UTF-8?q?=E5=AE=9A=E9=87=8C=E6=B2=A1=E6=9C=89=E3=80=81=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E5=A4=9A=E7=BB=99=E7=9A=84=EF=BC=8C=E7=95=8C=E9=9D=A2=E4=B8=8D?= =?UTF-8?q?=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()