RETRYABLE.has(t.status))
const [showAll, setShowAll] = useState(false)
@@ -342,6 +344,13 @@ function JobView({ job, running, stopping, busy }: { job: CatalogDraftJob; runni
const progress = running ? DT.progress(job.done, job.total) : DT.progressDone(ok, job.total)
const tone = job.status === 'failed' ? 'var(--err)' : running ? 'var(--accent)' : problems.length ? 'var(--warn)' : 'var(--st-done)'
const pct = job.total ? (value / job.total) * 100 : 0
+ const { usage } = job
+ const timeouts = usage.timeouts ?? 0
+ const failures = DT.usageFailures(timeouts, usage.failed_calls ?? 0)
+ // 调用接连没写出来时服务端给的提醒:进行中写在进度条下面(提醒,不是错误);好转后服务端清掉
+ const notice = running ? job.notice?.trim() ?? '' : ''
+ // 额度用完、超时多了,之后的段缩小了:进行中写一句,段数为什么变多看得明白
+ const partColumns = running && job.use_model && job.part_columns != null && job.part_columns < DRAFT_PART_COLUMNS ? job.part_columns : null
return (
{busy &&
{DT.busy}}
@@ -355,14 +364,22 @@ function JobView({ job, running, stopping, busy }: { job: CatalogDraftJob; runni
aria-valuemin={0} aria-valuemax={job.total} aria-valuenow={value}>
+ {notice &&
{notice}}
{summary}
- {(job.model || job.usage.calls > 0) && (
-
- {job.model && {DT.modelUsed(job.model)}}
- {job.usage.calls > 0 && (
- {DT.usage(job.usage.calls, job.usage.input_tokens, job.usage.output_tokens)}
- )}
-
+ {(job.model || usage.calls > 0 || failures) && (
+
+
+ {job.model && {DT.modelUsed(job.model)}}
+ {usage.calls > 0 && (
+ {DT.usage(usage.calls, usage.input_tokens, usage.output_tokens)}
+ )}
+ {failures && (
+ {failures}
+ )}
+
+ {timeouts > 0 &&
{DT.timeoutNote}
}
+ {partColumns != null &&
{DT.partColumns(partColumns)}
}
+
)}
{running &&
{DT.hideHint}
}
{job.status === 'failed' && job.error &&
{job.error}}
diff --git a/frontend/src/pages/catalog/draftJob.ts b/frontend/src/pages/catalog/draftJob.ts
index eaa2c5b4..1e56e56b 100644
--- a/frontend/src/pages/catalog/draftJob.ts
+++ b/frontend/src/pages/catalog/draftJob.ts
@@ -36,6 +36,9 @@ export const retryableCount = (j: CatalogDraftJob): number => j.tasks.filter((t)
/** 一张表这次为什么没写全:整张表没起草的原因在前,模型那部分的原因在后 */
export const taskReason = (t: { error: string | null; model_error: string | null }): string => t.error ?? t.model_error ?? ''
+/** 每段最多几列(服务端起草的起始上限)。任务里的当前上限比它小:额度用完、超时多了,之后的段缩小了 */
+export const DRAFT_PART_COLUMNS = 40
+
/**
* 一项都没写进去的任务该怎么说(只对写完了的任务:完成、部分完成)。null:照常写合计。
* 用模型、却没有一张表拿到模型的可用内容:目录没有更新、模型没给内容;有表没写全:目录没有更新、原因见下方;
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index ef13a6d2..baf7ed51 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -2901,6 +2901,19 @@ export interface CatalogDraftTask {
finished_at: string | null
}
+/** 起草任务的模型用量 */
+export interface CatalogDraftUsage {
+ /** 调用次数(没写出来的、超时的也算) */
+ calls: number
+ /** 超时的调用不计 token(照实记 0:服务方可能已经生成了内容) */
+ input_tokens: number
+ output_tokens: number
+ /** 超时的调用次数。老服务端没有这个键 */
+ timeouts?: number
+ /** 没写出来的调用次数:额度用完、只给空壳、超时、出错。老服务端没有这个键 */
+ failed_calls?: number
+}
+
/** 一次起草任务 */
export interface CatalogDraftJob {
id: string
@@ -2929,7 +2942,17 @@ export interface CatalogDraftJob {
updated: number
removed: number
/** 模型调用的用量;拿不到用量时照实是 0 */
- usage: { calls: number; input_tokens: number; output_tokens: number }
+ usage: CatalogDraftUsage
+ /**
+ * 当前每段最多几列:一次起草里额度用完或超时累计 2 次后,之后新开始的段减半(40 → 20 → 10,最低 10)。
+ * 不用模型时可能为 null;老服务端没有这个键
+ */
+ part_columns?: number | null
+ /**
+ * 调用接连没写出来时的提醒(一句中文:最近几次里几次没写出来、原因,建议换起草模型、缩小范围或停止);
+ * 好转后服务端清掉,为 null。老服务端没有这个键
+ */
+ notice?: string | null
/** 任务整体出错的原因 */
error: string | null
tasks: CatalogDraftTask[]
diff --git a/scripts/check-catalog.mjs b/scripts/check-catalog.mjs
index dc8dde8b..57963898 100644
--- a/scripts/check-catalog.mjs
+++ b/scripts/check-catalog.mjs
@@ -425,7 +425,12 @@ const JOB_ERR = '起草任务出错:database is locked'
/** 起草时每段最多几列(服务端的常量):超过的宽表拆成「表级一段 + 列分段」 */
const PART_COLUMNS = 40
-/** 建一个排队的任务:表结构里没有的表照样建进任务、记 skipped。重试建的记着原任务(retry_of,界面不用) */
+/**
+ * 建一个排队的任务:表结构里没有的表照样建进任务、记 skipped。重试建的记着原任务(retry_of,界面不用)。
+ * 超过 40 列的宽表均匀分段:列分段数 = ceil(列数 / 40),段进度是「表级一段 + 列分段」。
+ * 用模型的任务记着当前每段最多几列(part_columns,额度用完、超时多了服务端会减半)、调用接连没写出来时的提醒(notice),
+ * 用量里另记超时和没写出来的调用次数
+ */
function newJob(state, src, { tables, use_model, note = null, retry_of = null }) {
const id = `check-job-${++state.jobSeq}`
const tasks = tables.map((name) => {
@@ -442,7 +447,8 @@ function newJob(state, src, { tables, use_model, note = null, retry_of = 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,
- retry_of, usage: { calls: 0, input_tokens: 0, output_tokens: 0 }, error: null, tasks,
+ retry_of, part_columns: use_model ? PART_COLUMNS : null, notice: null,
+ usage: { calls: 0, input_tokens: 0, output_tokens: 0, timeouts: 0, failed_calls: 0 }, error: null, tasks,
}
state.jobs.push(job)
return job
@@ -677,8 +683,11 @@ async function startDraft(page, { select = null, model = false, note = '' } = {}
/** 起草用的几句话(虚构) */
const GUIDANCE2 = '景区票务库:「核销」指闸机检票入园;金额单位是元。会员相关的表统一写「会员」,不写「用户」'
const DRAFT_NOTE = '这一批是闸机和会员相关的表'
-const WIDE_ERR = '这张表 50 列,分 3 段起草,第 2 段没写出来(共 10 列):模型的思考和输出用完了额度(32768 个 token),已拆小重试仍没写出。可以只重试这张表'
+// 宽表没写出来的那一段写明列的范围(列名照表结构原样);只有真拆过才写「已拆小重试仍没写出」
+const WIDE_ERR = '这张表 50 列,分 3 段起草,第 3 段(promo_attr_19 到 promo_attr_43,共 25 列)没写出来:模型的思考和输出用完了额度(32768 个 token),已拆小重试仍没写出。可以只重试这张表'
const CALL_ERR = '调用模型出错:服务暂时不可用(已重试 2 次)'
+/** 调用接连没写出来时服务端给的提醒(虚构的一句,照约定写明次数、原因和办法) */
+const DRAFT_NOTICE = '最近 6 次调用有 4 次没写出来(3 次超时、1 次额度用完)。建议在「设置 → 偏好设置」的「起草模型」换一个模型、缩小起草范围,或者停止这次起草'
const detail = (page) => page.locator('[data-catalog-detail]')
const version = async (page) => Number(await detail(page).getAttribute('data-version'))
const panel = (page) => page.locator('[data-item-panel]')
@@ -1021,7 +1030,7 @@ await section('数据目录 · 起草:进度、结果与重试', async () => {
check('……写明哪些没写全:失败 1 张、部分完成 1 张', problems.includes('失败 1 张') && problems.includes('部分完成 1 张'), problems)
const shown = await draftBox(page).locator('[data-draft-task]').evaluateAll((els) => els.map((e) => e.getAttribute('data-draft-task')))
check('……逐表只列没写全的 2 张,各自写原因', shown.join(',') === 'store_sales,members'
- && (await draftText(page, '[data-draft-task="store_sales"] [data-draft-task-reason]')).includes('第 2 段没写出来')
+ && (await draftText(page, '[data-draft-task="store_sales"] [data-draft-task-reason]')).includes('第 3 段(promo_attr_19 到 promo_attr_43,共 25 列)没写出来')
&& (await draftText(page, '[data-draft-task="members"] [data-draft-task-reason]')).includes('已重试 2 次'), shown.join(','))
await draftBox(page).locator('[data-draft-show-all]').click()
check('……点「显示全部」列出 3 张', (await draftBox(page).locator('[data-draft-task]').count()) === 3)
@@ -1305,6 +1314,71 @@ await section('数据目录 · 起草:清单上的失败标记与单表重起
await close()
})
+await section('数据目录 · 起草:接连没写出来的提醒、超时与段缩小', async () => {
+ const state = freshState()
+ const { page, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
+ await page.locator('[data-catalog-row]').first().waitFor()
+ check('开始起草(闸机到会员 3 张,用模型)', await startDraft(page, { select: ['gates', 'members'], model: true }))
+ const job = state.jobs.at(-1)
+ const usageLine = () => draftText(page, '[data-draft-usage-line]')
+ settle(state, job, 'store_sales', { status: 'running', parts_done: 1 })
+ check('还没有超时、没写出来的调用:用量只写调用次数和 token,没有提醒、超时的说明和段大小的说明',
+ await until(async () => (await usageLine()).includes('调用模型 1 次')) && !(await usageLine()).includes('超时')
+ && !(await usageLine()).includes('没写出来')
+ && await draftBox(page).locator('[data-draft-notice], [data-draft-timeout-note], [data-draft-part-columns]').count() === 0, await usageLine())
+
+ // 宽表的段接连超时、用完额度:服务端记下超时和没写出来的次数,之后的段缩小到每段 20 列,并给出一句提醒
+ Object.assign(job.usage, { calls: 7, timeouts: 3, failed_calls: 4 })
+ Object.assign(job, { part_columns: 20, notice: DRAFT_NOTICE })
+ const notice = draftBox(page).locator('[data-draft-notice]')
+ check('接连没写出来:进行中醒目显示服务端的提醒,是提醒的样式(不是错误)',
+ await until(async () => (await draftText(page, '[data-draft-notice]')) === DRAFT_NOTICE)
+ && (await notice.getAttribute('data-notice')) === 'warn', await draftText(page, '[data-draft-notice]'))
+ const above = await page.evaluate(() => {
+ const n = document.querySelector('[data-draft-notice]')?.getBoundingClientRect()
+ const list = document.querySelector('[data-draft-tasks]')?.getBoundingClientRect()
+ return !!n && !!list && n.bottom <= list.top
+ })
+ check('……提醒在逐表进度上面,不用往下翻', above)
+ // 没写出来的次数里含超时的:写成「其中」,免得读成两个数相加
+ check('……用量写「没写出来 4 次(其中超时 3 次)」', (await usageLine()).includes('没写出来 4 次(其中超时 3 次)'), await usageLine())
+ check('……写明超时的调用不计用量', (await draftText(page, '[data-draft-timeout-note]')).includes('超时的调用不计用量'),
+ await draftText(page, '[data-draft-timeout-note]'))
+ check('……写明之后的段每段最多 20 列', (await draftText(page, '[data-draft-part-columns]')).includes('每段最多 20 列'),
+ await draftText(page, '[data-draft-part-columns]'))
+ await shot(page, 'catalog-draft-notice')
+
+ // 关掉对话框:上方的进度也带着提醒;好转后服务端清掉提醒,两处都收起
+ await page.locator('[data-draft-hide]').click()
+ const strip = page.locator('[data-catalog-draft-progress]')
+ const stripNotice = strip.locator('[data-catalog-draft-notice]')
+ check('在后台继续:上方的进度也带着提醒(全文在悬停里)', await until(async () => (await stripNotice.count()) === 1)
+ && (await stripNotice.getAttribute('title')) === DRAFT_NOTICE && (await stripNotice.innerText()).length > 0,
+ await stripNotice.getAttribute('title', { timeout: 500 }).catch(() => ''))
+ await shot(page, 'catalog-draft-strip-notice')
+ job.notice = null
+ check('……好转后服务端清掉提醒:上方的进度不再带着', await until(async () => (await stripNotice.count()) === 0))
+ await strip.locator('[data-catalog-draft-open]').click()
+ check('……点开对话框:提醒也不再显示,超时和没写出来的次数照旧', await until(async () => (await draftPhase(page)) === 'running')
+ && await notice.count() === 0 && (await usageLine()).includes('其中超时 3 次'), await usageLine())
+
+ // 又接连没写出来:提醒再出现;任务结束以后不再显示(停止、缩小范围的建议只对进行中的任务有用),用量里的次数和
+ // 超时不计用量的说明照旧,段缩小的说明收起
+ Object.assign(job.usage, { timeouts: 5, failed_calls: 6 })
+ Object.assign(job, { part_columns: 10, notice: DRAFT_NOTICE })
+ check('又接连没写出来:提醒再出现,段缩小到每段 10 列', await until(async () => (await draftText(page, '[data-draft-notice]')) === DRAFT_NOTICE)
+ && (await draftText(page, '[data-draft-part-columns]')).includes('每段最多 10 列'), await draftText(page, '[data-draft-part-columns]'))
+ for (const name of ['gates', 'store_sales', 'members']) settle(state, job, name, { added: 1, model_items: 1 })
+ finishJob(job)
+ check('结束:不再显示提醒和段缩小的说明,用量里的次数和超时不计用量的说明照旧', await until(async () => (await draftPhase(page)) === 'done')
+ && await notice.count() === 0 && await draftBox(page).locator('[data-draft-part-columns]').count() === 0
+ && (await usageLine()).includes('没写出来 6 次(其中超时 5 次)') && (await draftText(page, '[data-draft-timeout-note]')).includes('超时的调用不计用量'),
+ await usageLine())
+ check('没有原生对话框', natives.length === 0, natives.join(' | '))
+ check('没有运行时报错', errors.length === 0, errors[0] ?? '')
+ await close()
+})
+
await section('数据目录 · 表详情与单项审阅', async () => {
const state = freshState()
const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
@@ -2396,14 +2470,19 @@ await section('数据目录 · 窄屏', async () => {
const narrowJob = state.jobs.at(-1)
settle(state, narrowJob, 'visits', { added: 2 })
settle(state, narrowJob, 'store_sales', { status: 'partial', parts_done: 2, added: 4, model_error: WIDE_ERR })
- await until(async () => (await draftText(page, '[data-draft-task="store_sales"] [data-draft-task-reason]')).includes('第 2 段'))
+ // 接连没写出来的提醒、超时和没写出来的次数、段缩小的说明也都在
+ Object.assign(narrowJob.usage, { timeouts: 3, failed_calls: 4 })
+ Object.assign(narrowJob, { part_columns: 20, notice: DRAFT_NOTICE })
+ await until(async () => (await draftText(page, '[data-draft-task="store_sales"] [data-draft-task-reason]')).includes('第 3 段')
+ && (await draftText(page, '[data-draft-notice]')) === DRAFT_NOTICE)
const runFit = await draftFits()
- check('……起草进行中:段进度和长原因折行,弹窗不横向滚动', runFit.ok, JSON.stringify(runFit))
+ check('……起草进行中:段进度、长原因、提醒都折行,弹窗不横向滚动', runFit.ok, JSON.stringify(runFit))
await shot(page, 'catalog-narrow-draft')
await page.locator('[data-draft-hide]').click()
await until(async () => (await page.locator('[data-catalog-draft-progress]').count()) === 1)
- check('……上方的起草进度不撑宽页面', await overflow() <= 1 && !!(await page.locator('[data-catalog-draft-progress]').boundingBox())
- && (await page.locator('[data-catalog-draft-progress]').boundingBox()).width <= 390, String(await overflow()))
+ check('……上方的起草进度(带着提醒)不撑宽页面', await overflow() <= 1 && !!(await page.locator('[data-catalog-draft-progress]').boundingBox())
+ && (await page.locator('[data-catalog-draft-progress]').boundingBox()).width <= 390
+ && await page.locator('[data-catalog-draft-notice]').count() === 1, String(await overflow()))
stopJob(narrowJob)
await until(async () => (await page.locator('[data-catalog-draft-progress]').count()) === 0)
// 宽屏照旧是表格(表头在,列表格 860px 起,框里横向滚动)