diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 689a7c01..5820bd34 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -2260,6 +2260,21 @@ export const CATALOG_DRIFT_TEXT = { possibleNone: (source: string) => `「${source}」发布时还没有目录`, } +/** + * 表结构只探查了一部分:探查结构时每个数据源最多取 200 张表(服务端 introspect._MAX_TABLES),多出来的表不在 + * 表结构里,数据目录列不出,助手也看不到它们的字段。数据目录页顶部和助手「参考了哪些表」那一行都要说出来。 + */ +export const SCHEMA_PARTIAL_TEXT = { + /** 数据目录页顶部:「这个数据源共有 205 张表,只探查了前 200 张」 */ + title: (total: number, explored: number) => + `这个数据源共有 ${formatNumber(total)} 张表,只探查了前 ${formatNumber(explored)} 张`, + body: (rest: number) => `探查结构时每个数据源最多取 200 张表(按数据库列出的顺序):生产库动辄上千张,全部取回既慢也看不完。` + + `其余 ${formatNumber(rest)} 张不在数据目录中,助手也看不到它们的字段。`, + next: '所需的表不在清单中时,请回到数据源卡片换一个 schema,或改用只能看到所需表的数据库账号缩小范围,然后重新「探查结构」。', + /** 助手「参考了哪些表」那一行:「共 205 张,只探查了 200 张」 */ + context: (total: number, explored: number) => `共 ${formatNumber(total)} 张,只探查了 ${formatNumber(explored)} 张`, +} + // =========================================================================== // 数据剖析(数据源设置里的开关和预算;数据目录页的「数据剖析」) // diff --git a/frontend/src/pages/catalog/CatalogPage.tsx b/frontend/src/pages/catalog/CatalogPage.tsx index afcaeed1..24bd2dc9 100644 --- a/frontend/src/pages/catalog/CatalogPage.tsx +++ b/frontend/src/pages/catalog/CatalogPage.tsx @@ -7,7 +7,7 @@ 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 } from '../../lib/terms' +import { 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 { ProfileDialog } from './ProfileDialog' @@ -308,6 +308,10 @@ export function CatalogPage() { )} /> + {hasTables && data?.schema_truncated && ( + r.in_schema).length} + onGoSource={() => navigate(back)} /> + )} {hasTables && usage.used > 0 && !empty && } {body} {drafting && data && ( @@ -347,6 +351,29 @@ export function CatalogPage() { ) } +/** + * 表结构只探查了一部分(每个数据源最多取 200 张表):没探查到的表不在清单里,助手也看不到。顶部常驻一条说明, + * 写清一共几张、探查了几张、为什么只取了这些,以及下一步——回到数据源卡片换 schema 或缩小范围后重新探查 + */ +function SchemaPartialNotice({ total, explored, onGoSource }: { total: number; explored: number; onGoSource: () => void }) { + const shownTotal = Math.max(total, explored) + return ( +
+ +
+

{SPT.title(shownTotal, explored)}

+

{SPT.body(Math.max(shownTotal - explored, 0))}

+

{SPT.next}

+
+ +
+ ) +} + /** * 「用到但没确认」:运行中查询过的表里还有几张有推断项、几张没有目录。这些表的推断项会被助手当真用,最该先审。 * 两段各是一个按钮,点了切到清单的对应筛选、按使用次数排;都审完了只写一句,不给按钮 diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index 8bea3225..3c697991 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -2,8 +2,8 @@ import type { CatalogDriftTable, RunEvent, SqlCheckItem, TeamMember, TeamRound, import type { NodeState, RunPhase } from './trace' import { formatDuration, formatNumber } from '../lib/format' import { - CATALOG_DRIFT_TEXT, CATALOG_PATCH_TEXT, JUDGE_TEXT, MERGE_TEXT, SQL_CHECK_TEXT, TYPE_LABEL, claimTally, evidenceTally, - issuanceLabel, nodeTypeLabel, + CATALOG_DRIFT_TEXT, CATALOG_PATCH_TEXT, JUDGE_TEXT, MERGE_TEXT, SCHEMA_PARTIAL_TEXT, SQL_CHECK_TEXT, TYPE_LABEL, claimTally, + evidenceTally, issuanceLabel, nodeTypeLabel, } from '../lib/terms' import { catalogPatchOf, patchValueText, patchWhere } from './catalogPatch' import { claimCountsOf, isJudged, statsTally } from '../lib/evidence' @@ -2605,8 +2605,10 @@ export interface CopilotContextSource { tables: string[] /** model:按需求挑的;all:库不大,全部表都带字段;fallback:没能挑出来,只给了表名 */ selectedBy: 'model' | 'all' | 'fallback' - /** 这个库一共几张表 */ + /** 这个库一共几张表(数据库里的真实总数) */ total?: number + /** 探查结构截断了时,探查到几张(服务端只在截断时给):模型只看得到这几张 */ + explored?: number /** 没能挑出来的原因 */ reason?: string } @@ -2620,26 +2622,34 @@ function contextSources(op: CopilotOp): CopilotContextSource[] { .filter((x): x is Record => !!x && typeof x === 'object' && typeof (x as any).source === 'string') .map((x) => { const total = num(x.total) + const explored = num(x.explored) return { source: x.source, tables: Array.isArray(x.tables) ? x.tables.map(String) : [], selectedBy: x.selected_by === 'model' || x.selected_by === 'fallback' ? x.selected_by : 'all', ...(total != null ? { total } : {}), + ...(total != null && explored != null && explored < total ? { explored } : {}), ...(typeof x.reason === 'string' && x.reason ? { reason: x.reason } : {}), } }) } -/** 一个库在展开区里的那一行:怎么来的 + 表名 */ +/** + * 一个库在展开区里的那一行:怎么来的 + 表名。探查结构截断了的库写明「共 205 张,只探查了 200 张」: + * 模型只看得到探查到的那些,用户说的表不在里面时,先得知道是没探查到,不是库里没有 + */ function contextLine(g: CopilotContextSource): string { const names = g.tables.join('、') - const of = g.total != null ? `共 ${g.total} 张表` : '' + const partial = g.total != null && g.explored != null ? SCHEMA_PARTIAL_TEXT.context(g.total, g.explored) : '' + const of = partial || (g.total != null ? `共 ${g.total} 张表` : '') switch (g.selectedBy) { case 'all': - return `「${g.source}」全部 ${g.tables.length} 张表:${names}` + return `「${g.source}」全部 ${g.tables.length} 张表${partial ? `(${partial})` : ''}:${names}` case 'model': return g.tables.length - ? `「${g.source}」按需求${g.total != null ? `从 ${g.total} 张表中` : ''}挑出 ${g.tables.length} 张:${names}` + ? partial + ? `「${g.source}」${partial},按需求挑出 ${g.tables.length} 张:${names}` + : `「${g.source}」按需求${g.total != null ? `从 ${g.total} 张表中` : ''}挑出 ${g.tables.length} 张:${names}` : `「${g.source}」${of ? `${of},` : ''}未挑中与需求相关的表,已提供全部表名` default: return `「${g.source}」${of ? `${of},` : ''}${CONTEXT_FALLBACK_TEXT}` diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 6fb841b9..8053d369 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -2671,6 +2671,10 @@ export interface CatalogList { system_notes: boolean /** 没有表结构时的原因(「尚未探查结构」「结构探查失败:…」);有表结构时为 null */ schema_note: string | null + /** 探查结构截断了(每个数据源最多取 200 张表):没探查到的表不在清单里,助手也看不到。老后端没有这一项 */ + schema_truncated?: boolean + /** 数据库里一共几张表(含视图);没截断时等于探查到的张数 */ + schema_total?: number } /** 表结构里的一列 */ diff --git a/scripts/check-catalog.mjs b/scripts/check-catalog.mjs index 65b980f0..2680c5e8 100644 --- a/scripts/check-catalog.mjs +++ b/scripts/check-catalog.mjs @@ -347,7 +347,11 @@ function listOf(state, src) { const all = state.tables[src] const rows = all.filter((t) => !t.missing).sort((a, b) => b.usage - a.usage).map(rowOf) rows.push(...all.filter((t) => t.missing).map(rowOf)) - return { tables: rows, system_notes: isUpload(src), schema_note: all.length ? null : '尚未探查结构' } + // 探查结构截断了的源(state.schemaTotal 里给了数据库里的总数):清单只有探查到的那部分 + const explored = all.filter((t) => !t.missing).length + const total = state.schemaTotal?.[src] ?? explored + return { tables: rows, system_notes: isUpload(src), schema_note: all.length ? null : '尚未探查结构', + schema_truncated: total > explored, schema_total: total } } const detailOf = (src, t) => ({ table_name: t.name, in_schema: !t.missing, notes: t.notes ?? {}, version: t.version, updated_at: t.updated_at, updated_by: t.updated_by, @@ -1186,6 +1190,38 @@ const profilePhase = (page) => profileBox(page).getAttribute('data-profile-dialo /** 报告里不该露的机读码:停止原因、跳过原因、状态、基数的英文值 */ const PROFILE_CODE_RE = /\b(?:budget|deadline|failed|masked|too_large|high_cardinality|no_data|unsupported|verified|proposed|many_to_one|row_estimate)\b/ +await section('数据目录 · 只探查了一部分表', async () => { + // 探查结构每个数据源最多取 200 张表:多出来的不在清单里,助手也看不到。顶部要说出来,并给出下一步 + const state = freshState() + const explored = state.tables[S1].filter((t) => !t.missing).length + const total = explored + 57 + state.schemaTotal = { [S1]: total } + const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) }) + const bar = page.locator('[data-catalog-schema-partial]') + check('顶部有一条说明', await bar.waitFor({ timeout: 3000 }).then(() => true, () => false)) + check('……数字:一共几张、探查了几张', (await bar.getAttribute('data-catalog-schema-partial')) === `${total},${explored}`, + await bar.getAttribute('data-catalog-schema-partial')) + const text = await bar.innerText().catch(() => '') + check('……写成「共有 N 张表,只探查了前 M 张」', text.includes(`这个数据源共有 ${total} 张表,只探查了前 ${explored} 张`), text) + check('……说明为什么只取了这些、其余的助手也看不到', text.includes('最多取 200 张表') && text.includes('其余 57 张不在数据目录中') + && text.includes('助手也看不到'), text.replace(/\s+/g, ' ')) + check('……给出下一步:换 schema 或缩小范围后重新探查', text.includes('换一个 schema') && text.includes('缩小范围') + && text.includes('探查结构'), text.replace(/\s+/g, ' ')) + await shot(page, 'catalog-schema-partial') + await cleanCopy(page, '只探查了一部分表') + await bar.locator('[data-catalog-schema-partial-go]').click() + await page.waitForURL('**/data/databases', { timeout: 5000 }).catch(() => {}) + check('……按钮回到数据源卡片', new URL(page.url()).pathname === '/data/databases', page.url()) + // 没截断的库不出这一条 + await goto(page, `/data/catalog/${S4}`) + await page.locator('[data-catalog-overview]').waitFor() + check('没截断的库不出这一条', await page.locator('[data-catalog-schema-partial]').count() === 0) + check('只读、不发写请求', writes(sent, /./).length === 0) + check('没有原生对话框', natives.length === 0, natives.join(' | ')) + check('没有运行时报错', errors.length === 0, errors.join(' | ')) + await close() +}) + await section('数据目录 · 数据剖析设置', async () => { const state = freshState() const replies = { patch: [] } diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index 0bf2a0a8..69c59810 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -685,6 +685,16 @@ await section('Copilot:这一轮参考了哪些表(context 操作)', async { source: 'b', tables: [], selected_by: 'model', total: 70 }] }) check('挑表成功但某个库一张没挑中:说清只给了表名', !!none?.detail.includes('「b」共 70 张表,未挑中与需求相关的表,已提供全部表名'), none?.detail) + // 探查结构截断了(每个数据源最多取 200 张表):total 是数据库里的总数,explored 是探查到的 + const partial = mod.copilotContext({ op: 'context', sources: [ + { source: 'wide', tables: ['t001', 't002'], selected_by: 'model', total: 205, explored: 200 }, + { source: 'wide2', tables: [], selected_by: 'fallback', total: 260, explored: 200, reason: 'r' }] }) + check('截断了的库写「共 N 张,只探查了 200 张」', !!partial?.detail.includes('「wide」共 205 张,只探查了 200 张,按需求挑出 2 张:t001、t002') + && partial.detail.includes('「wide2」共 260 张,只探查了 200 张,未能按需求挑选'), partial?.detail) + const whole = mod.copilotContext({ op: 'context', sources: [ + { source: 'a', tables: ['x'], selected_by: 'model', total: 60, explored: 60 }] }) + check('探查到的和总数一样:照旧写「从 N 张表中挑出」', !!whole?.detail.includes('「a」按需求从 60 张表中挑出 1 张') && !whole.detail.includes('只探查了'), + whole?.detail) check('形状不对不出行、不报错', mod.decodeCopilot([ { op: 'context' }, { op: 'context', sources: 'x' }, { op: 'context', sources: [null, 3, { tables: ['t'] }] }, ]).length === 0)