mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:11:59 +00:00
数据目录闭环(前端):类型、接口、文案和纯函数——起草任务的逐段落盘、块进度字段和清单的 blank_columns_from;审阅队列、成批审阅、审阅进度、用过这张表的运行;导出导入(下载文件、预览、导入);表助手的事件流(按行读 data: <JSON>,开流前被拒带状态码和机读码,能停止);表助手的对话怎么截(最多 40 条、每条 4000 字、从用户的话开始)、待保存的修改怎么累积(同一路径以最后一次提案为准、保存带最早那条提案的版本、409 后按预览重算);审阅视图的地址参数、成批结果和表详情改过之后的状态同步;文案进 lib/terms.ts
This commit is contained in:
+127
-3
@@ -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<CatalogProfileOut>(`/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<CatalogReviewQueue>(`${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<CatalogReviewProgress>(`${catalogBase(sourceId)}/review-progress`, opts),
|
||||
/** 用过这张表的运行,新的在前 */
|
||||
usage: (sourceId: string, table: string, limit = 20, opts?: RequestOptions) =>
|
||||
get<CatalogTableUsage>(`${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<CatalogImportReport>(`${catalogBase(sourceId)}/import/preview`, body),
|
||||
/**
|
||||
* 导入:规则同预览,逐表乐观锁,一张表失败不影响别的。有排队、进行中的起草任务时 409(catalog_draft_busy);
|
||||
* 文件格式不对 422(detail 是一句中文)
|
||||
*/
|
||||
importCatalog: (sourceId: string, body: { content: unknown; include_guidance: boolean }) =>
|
||||
post<CatalogImportReport>(`${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: <JSON>,到一个回调
|
||||
* 一个(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()
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅一次运行的事件流。
|
||||
*
|
||||
|
||||
+299
-5
@@ -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<CatalogDraftJobStatus, string>,
|
||||
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: '完成',
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
@@ -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<string, unknown> => !!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<string, unknown> | 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<TurnPart, { kind: 'text' }> => p.kind === 'text').map((p) => p.text).join('')
|
||||
|
||||
/** 这一轮里的提案 */
|
||||
export const turnProposals = (turn: AssistantTurn) =>
|
||||
turn.parts.filter((p): p is Extract<TurnPart, { kind: 'proposal' }> => 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<string>, 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)
|
||||
})
|
||||
}
|
||||
@@ -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<ReviewParams>): 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<CatalogReviewAction, CatalogStatus> = { confirm: 'confirmed', reject: 'rejected', reset: 'proposed' }
|
||||
|
||||
/** 服务端的机读码说的是不是版本冲突 */
|
||||
export const isConflict = (r: Pick<CatalogReviewBatchResult, 'code'>): boolean => !!r.code && /conflict|version/i.test(r.code)
|
||||
|
||||
/**
|
||||
* 逐条的结果并进去:outcomes 按项的键记处境,versions 按表记最新的版本(之后再审阅这张表的项带它)。返回新的两份,不改入参
|
||||
*/
|
||||
export function applyResults(
|
||||
results: CatalogReviewBatchResult[], action: CatalogReviewAction,
|
||||
outcomes: ReadonlyMap<string, ItemOutcome>, versions: ReadonlyMap<string, number>,
|
||||
): { outcomes: Map<string, ItemOutcome>; versions: Map<string, number> } {
|
||||
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<string>, action: CatalogReviewAction,
|
||||
outcomes: ReadonlyMap<string, ItemOutcome>): 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<string, ItemOutcome>, versions: ReadonlyMap<string, number>,
|
||||
): { outcomes: Map<string, ItemOutcome>; versions: Map<string, number> } {
|
||||
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)
|
||||
@@ -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<string, any>, 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 {
|
||||
|
||||
@@ -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<string, { notes: CatalogNotes; version: number }>
|
||||
}
|
||||
|
||||
/** 一处冲突:这里已确认、已驳回的项和文件中的不一样,以这里为准 */
|
||||
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: <JSON>。对话不存服务端,每一轮都把前面的对话和还没保存的修改带上。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 提案里的一项:和助手流里的 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
|
||||
|
||||
Reference in New Issue
Block a user