mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:21:58 +00:00
合入数据目录闭环第一步:表助手(表详情页对话完善一张表,成批提出经校验的修改提案、拿不准的反过来问,只在开了数据剖析的源上经只读守卫跑查询、结果先遮,确认后一次保存并把依据另存为这一项的「确认时的依据」);审阅队列(按使用次数、风险、来源排序并写明理由,批量确认驳回逐表乐观锁,表被哪些运行用过,审阅进度);数据目录导出导入(先预览,确认和驳回的照带、冲突保留目标、推断只填空缺、对不上的跳过并写原因);起草时提议有效记录条件和去重规则(sqlglot 校验,开了剖析的源上核对排除比例);起草遗留:宽表逐段落进目录、停止和中断不丢、按比例止损、块进度、同步起草与任务双向互斥;剖析不改人确认过的外键关系的基数
This commit is contained in:
@@ -277,7 +277,11 @@ flowchart LR
|
||||
| 环节 | 做法 |
|
||||
|---|---|
|
||||
| 起草 | 探查时读外键和唯一约束;按 `xxx_id`、`xxxId` 推断关联,表名带业务前缀的也认;可选让模型起草中文名、粒度、表类型、业务主键、业务日期和每列的含义、单位、度量类型。导入表格的源已有系统按核对结果生成的说明,目录不重复、不改写它 |
|
||||
| 模型起草 | 作为后台任务逐表进行,每张表记下状态和原因,写完一张就并入目录;关掉对话框不停,可以停止、只重试失败的表,清单上标出上次起草失败或只完成一部分的表。服务重启时没跑完的任务标成中断,不会自己续跑,点重试接上。保留模型的思考,输出额度 32768(服务方不收时退回 8192);每次调用最多 40 列,宽表先起草表级的项、再把列均分成几段起草,按表的先后排队,不插到窄表前面;额度用完或超时就拆小再问(超时的两半同时问,拆到每段 10 列仍接连超时就不再拆),累计两次后新开始的段减半(最低 10 列),每段连同它分开、拆小的各块最多调用 8 次,缺的表和列只补问缺的,被截断的回复先留下已经写完整的部分;最近 6 次调用有 4 次没写出来时提醒换模型。没写出来的原因写清是第几段、哪几列。重新起草时模型这次留空或没通过校验的项不删以前起草的;模型拿不准、留空的列(目录里还没有中文名和含义的)逐表记下,可以补充起草要求后重起草。库注释就是表名、列名本身或者是标识符写法的,不记进目录(不当中文名,也不当说明、含义)。数据源上存的「起草要求」和这次的补充说明一起交给模型,帮它理解业务、统一用词,不能推翻「只记数据事实」的规矩,交回的内容照样逐项校验。用哪个模型在「设置 → 偏好设置」的「起草模型」里选,空着跟随助手的模型;按配方导入表格的 AI 起草也用它 |
|
||||
| 模型起草 | 作为后台任务逐表进行,每张表记下状态和原因,写完一张就并入目录,宽表每写完一段就并入;关掉对话框不停,可以停止、只重试失败的表,清单上标出上次起草失败或只完成一部分的表。停止、服务重启时已经写进目录的段保留;服务重启时没跑完的任务标成中断,不会自己续跑,点重试重新起草这些表(已写进的不会丢)。不设总时长上限,靠止损和手动停止:最近 10 次调用有 7 次没写出来时后面的段不再拆小,同一张表接连 3 段都没写出来就跳过它剩下的段。保留模型的思考,输出额度 32768(服务方不收时退回 8192);每次调用最多 40 列,宽表先起草表级的项、再把列均分成几段起草,按表的先后排队,不插到窄表前面;额度用完或超时就拆小再问(超时的两半同时问,拆到每段 10 列仍接连超时就不再拆),累计两次后新开始的段减半(最低 10 列),每段连同它分开、拆小的各块最多调用 8 次,进度上看得到段被分成了几块,缺的表和列只补问缺的,被截断的回复先留下已经写完整的部分;最近 6 次调用有 4 次没写出来时提醒换模型。没写出来的原因写清是第几段、哪几列,「已拆小重试」只跟在真拆过的段后面。同步起草和后台任务互斥。重新起草时模型这次留空或没通过校验的项不删以前起草的;模型拿不准、留空的列(目录里还没有中文名和含义的)逐表记下,可以补充起草要求后重起草。库注释就是表名、列名本身或者是标识符写法的,不记进目录(不当中文名,也不当说明、含义)。数据源上存的「起草要求」和这次的补充说明一起交给模型,帮它理解业务、统一用词,不能推翻「只记数据事实」的规矩,交回的内容照样逐项校验。用哪个模型在「设置 → 偏好设置」的「起草模型」里选,空着跟随助手的模型;按配方导入表格的 AI 起草也用它 |
|
||||
| 防错事实的提议 | 模型起草时另提议「有效记录条件」(一段 SQL 条件)和「去重规则」,各带依据;开了剖析的源把目录里的码值一起交给模型(遮罩列不给)。条件按数据源方言用 sqlglot 解析,只能引用这张表的列,不能有子查询、聚合、窗口函数,只允许几个常用函数,不过就不收(逐表说明里写原因);开了剖析的源上经只读守卫数出排除比例,排除 0% 或 100% 的不收,收下的在说明里写「剖析核对:排除 x% 的行」。这些都记为推断,SQL 检查的错误级规则只认人确认过的,提议在审阅队列里排在前面 |
|
||||
| 审阅队列 | 只列推断的项,按「表被运行用到的次数 × 这一项多影响数字 × 来源」排序:有效记录条件、去重规则、业务日期、关联关系最前,业务主键、度量类型次之,中文名、含义、单位靠后,每项写明排在这里的理由;按类别和表筛选,多选批量确认、驳回或撤销审阅(逐表乐观锁,冲突逐条标出);看得到一张表被哪些运行用过,以及常用表、全部和影响数字的项各确认了多少 |
|
||||
| 表助手 | 表详情页里和助手对话完善这一张表:它看得到表结构、目录现状(每项的来源和状态)、关联关系、剖析结果、起草要求和上次起草留空的列(遮罩列只给列名),按你的指示成批提出修改,每项带改前、改后和依据(照目录的校验:公式不收、列名要在表结构里),拿不准的会反过来问你;只在开了剖析的源上经只读守卫跑查询核实(每次请求最多 5 条,结果先遮)。提案攒在「待保存的修改」里,确认后一次保存(一次最多 200 项),记为人工确认,每项的依据另存为这一项「确认时的依据」(和备注分开,表详情里看得到;撤销审阅回到推断时去掉)。对话不存服务端,只改目录、不碰业务库 |
|
||||
| 导出与导入 | 导出成一个 JSON 文件(全部表的目录、各项状态和起草要求),在另一个实例或数据源上导入:先预览哪些表、列对得上,每一项会新增、更新还是保留,冲突和跳过的逐条列出;人确认、驳回过的项照带,目标里已经确认过而值不同的保留目标、记成冲突,文件里推断的项只填空缺,表结构里没有的列跳过并写原因;有起草在进行时不能导入。起草要求只在目标没有时写入 |
|
||||
| 数据剖析 | 默认关闭,按数据源开启。只读、经过守卫,单条查询和整次剖析都有上限,大表用统计信息估算行数,遮罩的列不取样。抽样核实推断出的关联,补上覆盖率和基数,站得住的升为已验证;状态列给出码值候选,含义等人填写 |
|
||||
| 对话里纠正 | 用户说「status=9 表示作废」,助手给出一条目录修改提案,列出改前、改后和理由,人确认后才保存;公式不收,留给口径卡 |
|
||||
| 版本与提醒 | 目录随表结构快照冻结进证据,每次运行用的是哪一版查得到。发布时记下模板引用的表的目录版本,正式运行时目录有变化就提醒;agent 运行时才写 SQL 的,按「可能涉及」单独提醒。只提醒,不拦运行 |
|
||||
|
||||
+180
-12
@@ -8,11 +8,19 @@
|
||||
- POST /catalog/{table}/patch 保存目录修改提案(乐观锁),记为人工确认
|
||||
- GET /catalog/{table}/impact 影响面:引用这张表的已发布、受管模板(data/catalog_impact.py)
|
||||
- POST /catalog/draft 同步起草(注释、外键、命名推断,可选模型)。界面改用下面的后台起草任务,这个留着;
|
||||
同一个源有排队或进行中的后台任务时 409 catalog_draft_busy
|
||||
和后台任务双向互斥:同一个源有排队或进行中的后台任务(或正在同步起草)时 409
|
||||
catalog_draft_busy,同步起草期间建任务、重试、导入也是 409
|
||||
- PUT /catalog/guidance 数据源级的起草要求(使用者补充的背景和要求,起草时交给模型)
|
||||
- POST /catalog/draft-jobs 建后台起草任务(202);GET /draft-jobs/active、/latest、/{job_id} 读任务;
|
||||
POST /draft-jobs/{job_id}/cancel 停止,/retry 只重试没写好的表(data/catalog_jobs.py)
|
||||
- POST /catalog/profile 同步剖析:对业务库发少量只读查询,核对关系、取码值候选(data/catalog_profile.py)
|
||||
- GET /catalog/review-queue 审阅队列:推断的项按使用次数、风险、来源排好;POST /catalog/review/batch 批量审阅,
|
||||
GET /catalog/review-progress 审阅进度,GET /catalog/{table}/usage 用过这张表的运行
|
||||
(data/catalog_review.py)
|
||||
- GET /catalog/export 导出数据目录(JSON 附件);POST /catalog/import/preview 导入预览(只读),
|
||||
POST /catalog/import 导入(data/catalog_transfer.py)
|
||||
- POST /catalog/{table}/assistant 表助手:对话完善这张表的目录(text/event-stream),提案不写目录
|
||||
(data/catalog_assistant.py)
|
||||
|
||||
规则都在 data/catalog.py,这里只做取源、转换形状和把异常翻成状态码:版本不符 409,结构不合规、审阅路径
|
||||
不对 422,源或表不存在 404。所有写操作记录请求头 X-Actor 的署名(自报、未认证)。剖析没开启、正在剖析 409。
|
||||
@@ -24,7 +32,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Header, HTTPException
|
||||
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -35,6 +43,7 @@ from app.api.coded import (
|
||||
)
|
||||
from app.api.runs import actor_of
|
||||
from app.data import catalog, catalog_impact, catalog_jobs, catalog_profile, introspect, table_versions
|
||||
from app.data import catalog_assistant, catalog_review, catalog_transfer
|
||||
from app.data.engine import SnapshotTampered
|
||||
from app.db.base import get_session
|
||||
from app.db.models import DataSource
|
||||
@@ -234,6 +243,92 @@ async def list_catalog(source_id: str, session: AsyncSession = Depends(get_sessi
|
||||
}
|
||||
|
||||
|
||||
# ---- 审阅队列、审阅进度(要登记在 GET /catalog/{table} 前面:不然 review-queue、review-progress 会被当成表名)
|
||||
|
||||
|
||||
@router.get("/{source_id}/catalog/review-queue")
|
||||
async def catalog_review_queue(source_id: str,
|
||||
limit: int = Query(50, ge=1, le=catalog_review.QUEUE_LIMIT_MAX),
|
||||
offset: int = Query(0, ge=0),
|
||||
kind: Literal["all", "facts", "names"] = Query("all"),
|
||||
table: str | None = Query(None, max_length=255),
|
||||
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||||
"""审阅队列:只列推断的项,按使用次数、风险、来源排好(data/catalog_review.py)。kind 筛影响数字的项(facts)或名称
|
||||
和说明(names),table 只看一张表。"""
|
||||
_, source = await _resolved(session, source_id)
|
||||
return await catalog_review.review_queue(session, source, kind=kind, table=table or None, limit=limit,
|
||||
offset=offset)
|
||||
|
||||
|
||||
@router.get("/{source_id}/catalog/review-progress")
|
||||
async def catalog_review_progress(source_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||||
"""审阅进度:常用 20 张表、全部、影响数字的项各自确认了多少。"""
|
||||
_, source = await _resolved(session, source_id)
|
||||
return await catalog_review.review_progress(session, source)
|
||||
|
||||
|
||||
# ---- 导出导入(导出同样要登记在 GET /catalog/{table} 前面)
|
||||
|
||||
|
||||
@router.get("/{source_id}/catalog/export")
|
||||
async def export_catalog(source_id: str, session: AsyncSession = Depends(get_session)) -> Any:
|
||||
"""导出这个数据源的数据目录:JSON 附件「<数据源名>-数据目录.json」,含全部状态的项(data/catalog_transfer.py)。"""
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
row = await _source_row(session, source_id)
|
||||
body = await catalog_transfer.export_catalog(session, row)
|
||||
name = catalog_transfer.attachment_name(row.name)
|
||||
return JSONResponse(body, headers={"Content-Disposition": catalog_transfer.content_disposition(name, row.id)})
|
||||
|
||||
|
||||
class CatalogImportIn(BaseModel):
|
||||
#: 导出的那个对象(格式在 data/catalog_transfer.read_file 里核对,不对 422,说明是中文)
|
||||
content: Any = None
|
||||
#: 目标还没有起草要求时,是否写入文件里的起草要求
|
||||
include_guidance: bool = False
|
||||
|
||||
|
||||
async def _import_source(session: AsyncSession, source_id: str) -> tuple[DataSource, Any]:
|
||||
row, source = await _resolved(session, source_id)
|
||||
cache = source.schema_cache or {}
|
||||
if not cache.get("tables"):
|
||||
raise CodedHTTPException(409, f"{introspect.why_empty(cache)},无法导入数据目录。请先在数据源卡片上点「探查结构」",
|
||||
SCHEMA_MISSING)
|
||||
return row, source
|
||||
|
||||
|
||||
@router.post("/{source_id}/catalog/import/preview")
|
||||
async def preview_catalog_import(source_id: str, payload: CatalogImportIn,
|
||||
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||||
"""导入预览(只读):每张对上的表新增、更新、保留几项,冲突和跳过的逐条列出。文件格式不对 422;没有表结构 409。"""
|
||||
row, source = await _import_source(session, source_id)
|
||||
try:
|
||||
report = await catalog_transfer.plan_import(session, row, source, payload.content,
|
||||
include_guidance=payload.include_guidance)
|
||||
except catalog_transfer.TransferInvalid as e:
|
||||
raise HTTPException(422, str(e)) from e
|
||||
return report.as_dict()
|
||||
|
||||
|
||||
@router.post("/{source_id}/catalog/import")
|
||||
async def import_catalog(source_id: str, payload: CatalogImportIn, x_actor: str | None = Header(default=None),
|
||||
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||||
"""导入:规则同预览,逐表写入(乐观锁),返回同样形状的报告和每张表写入后的版本。有排队或进行中的起草任务
|
||||
409 catalog_draft_busy;文件格式不对 422;没有表结构 409。"""
|
||||
row, source = await _import_source(session, source_id)
|
||||
try:
|
||||
await catalog_jobs.ensure_idle(session, row)
|
||||
except catalog_jobs.DraftBusy as e:
|
||||
raise CodedHTTPException(409, str(e), CATALOG_DRAFT_BUSY) from e
|
||||
try:
|
||||
report = await catalog_transfer.run_import(session, row, source, payload.content,
|
||||
include_guidance=payload.include_guidance,
|
||||
actor=actor_of(x_actor))
|
||||
except catalog_transfer.TransferInvalid as e:
|
||||
raise HTTPException(422, str(e)) from e
|
||||
return report.as_dict()
|
||||
|
||||
|
||||
@router.get("/{source_id}/catalog/{table}")
|
||||
async def get_catalog_table(source_id: str, table: str,
|
||||
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||||
@@ -419,20 +514,21 @@ async def draft_catalog(source_id: str, payload: CatalogDraftIn,
|
||||
model_items:模型给了这张表几项可用内容(没用模型、模型这部分失败时为 null)。界面据此区分「模型给了内容、
|
||||
只是和现有目录一致」(model_items > 0、三个数都是 0)和「模型没给内容」(model_error 有值)。
|
||||
|
||||
同一个源有排队或进行中的后台起草任务时 409 catalog_draft_busy(和建任务同一句话):两边同时写同一个源的目录、
|
||||
抢模型名额。
|
||||
双向互斥(catalog_jobs.sync_draft,进程内登记):同一个源有排队或进行中的后台起草任务、或者正在同步起草时 409
|
||||
catalog_draft_busy(和建任务同一句话);反过来,同步起草进行期间同一个源建任务、重试、导入目录也是 409。两边同时写
|
||||
同一个源的目录、抢模型名额。
|
||||
"""
|
||||
row, source = await _resolved(session, source_id)
|
||||
cache = source.schema_cache or {}
|
||||
try:
|
||||
await catalog_jobs.ensure_idle(session, row)
|
||||
async with catalog_jobs.sync_draft(session, row):
|
||||
if not cache.get("tables"):
|
||||
raise _no_schema(cache)
|
||||
report = await catalog.draft_catalog(session, source, tables=payload.tables or None,
|
||||
use_model=payload.use_model, actor=actor_of(x_actor),
|
||||
guidance=catalog_jobs.guidance_of(row))
|
||||
except catalog_jobs.DraftBusy as e:
|
||||
raise CodedHTTPException(409, str(e), CATALOG_DRAFT_BUSY) from e
|
||||
cache = source.schema_cache or {}
|
||||
if not cache.get("tables"):
|
||||
raise _no_schema(cache)
|
||||
report = await catalog.draft_catalog(session, source, tables=payload.tables or None,
|
||||
use_model=payload.use_model, actor=actor_of(x_actor),
|
||||
guidance=catalog_jobs.guidance_of(row))
|
||||
rows = [{"table_name": r.table, "added": r.added, "updated": r.updated, "removed": r.removed,
|
||||
"version": r.version, "error": r.error, "model_error": r.model_error, "model_items": r.model_items}
|
||||
for r in report.tables]
|
||||
@@ -527,6 +623,38 @@ async def review_catalog_item(source_id: str, table: str, payload: CatalogReview
|
||||
return await _detail(session, source, key, entry)
|
||||
|
||||
|
||||
class CatalogBatchReviewItem(BaseModel):
|
||||
table_name: str = Field(min_length=1, max_length=255)
|
||||
path: str = Field(min_length=1, max_length=600)
|
||||
action: Literal["confirm", "reject", "reset"]
|
||||
if_version: int = Field(ge=0)
|
||||
|
||||
|
||||
class CatalogBatchReviewIn(BaseModel):
|
||||
items: list[CatalogBatchReviewItem] = Field(min_length=1, max_length=catalog_review.BATCH_MAX)
|
||||
|
||||
|
||||
@router.post("/{source_id}/catalog/review/batch")
|
||||
async def review_catalog_batch(source_id: str, payload: CatalogBatchReviewIn,
|
||||
x_actor: str | None = Header(default=None),
|
||||
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||||
"""批量审阅:逐条结果和请求一一对应。逐表按 if_version 乐观锁,同一张表的几条一次写入;一张表冲突、路径不对、表不存在
|
||||
只记在那几条上(ok 为假,code 是 catalog_conflict / catalog_path_invalid / table_missing),不影响别的。"""
|
||||
_, source = await _resolved(session, source_id)
|
||||
items = [catalog_review.BatchItem(i.table_name, i.path, i.action, i.if_version) for i in payload.items]
|
||||
return {"results": await catalog_review.review_batch(session, source, items, actor=actor_of(x_actor))}
|
||||
|
||||
|
||||
@router.get("/{source_id}/catalog/{table}/usage")
|
||||
async def catalog_table_runs(source_id: str, table: str,
|
||||
limit: int = Query(20, ge=1, le=catalog_review.RUNS_LIMIT_MAX),
|
||||
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||||
"""用过这张表的运行(total 是几次运行),新的在前;取法同使用次数(catalog.table_usage)。"""
|
||||
_, source = await _resolved(session, source_id)
|
||||
key, _ = await _table_key(session, source, table)
|
||||
return await catalog_review.table_runs(session, source, key, limit=limit)
|
||||
|
||||
|
||||
def _tables_of(source: Any) -> dict[str, Any] | None:
|
||||
"""核对提案用的表结构;没有表结构(尚未探查)时不核对列名。"""
|
||||
return ((source.schema_cache or {}).get("tables") or None) if source is not None else None
|
||||
@@ -552,7 +680,8 @@ async def preview_catalog_patch(source_id: str, table: str, payload: CatalogPatc
|
||||
async def save_catalog_patch(source_id: str, table: str, payload: CatalogPatchIn,
|
||||
x_actor: str | None = Header(default=None),
|
||||
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||||
"""保存一条目录修改提案:改动过的项记为人工填写、已确认。版本不符 409;有一项不合法 422,整条不写。"""
|
||||
"""保存一条目录修改提案:改动过的项记为人工填写、已确认,写了依据的项依据另存成这一项确认时的依据(reason,和备注
|
||||
分开,规则见 catalog.apply_patch)。版本不符 409;有一项不合法 422,整条不写。"""
|
||||
_, source = await _resolved(session, source_id)
|
||||
key, _ = await _table_key(session, source, table)
|
||||
try:
|
||||
@@ -566,6 +695,45 @@ async def save_catalog_patch(source_id: str, table: str, payload: CatalogPatchIn
|
||||
return await _detail(session, source, key, entry)
|
||||
|
||||
|
||||
class AssistantMessageIn(BaseModel):
|
||||
role: Literal["user", "assistant"]
|
||||
content: str = Field(max_length=catalog_assistant.MESSAGE_MAX)
|
||||
|
||||
|
||||
class AssistantIn(BaseModel):
|
||||
#: 整段对话(服务端不存);没有使用者的话时助手先开口
|
||||
messages: list[AssistantMessageIn] = Field(default_factory=list, max_length=catalog_assistant.MAX_MESSAGES)
|
||||
#: 界面上还没保存的修改,让模型知道。不设上限:宽表上几轮攒下来常常超过一次保存的 200 项,不能因此不让问(背景里只列
|
||||
#: 最近的 catalog_assistant.PENDING_SHOWN 项,码值照全部合并)
|
||||
pending: list[CatalogPatchChange] = Field(default_factory=list)
|
||||
|
||||
|
||||
@router.post("/{source_id}/catalog/{table}/assistant")
|
||||
async def catalog_table_assistant(source_id: str, table: str, payload: AssistantIn,
|
||||
session: AsyncSession = Depends(get_session)) -> Any:
|
||||
"""表助手:和使用者对话完善这张表的目录,返回 text/event-stream(事件见 data/catalog_assistant.py)。提案只交给界面,
|
||||
不写目录;保存走 /catalog/{table}/patch。开始前:表不在 404;对话不合规 422;起草模型用不了 409
|
||||
catalog_draft_model_unavailable(说法同起草任务)。"""
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
row, source = await _resolved(session, source_id)
|
||||
key = catalog.resolve_table_name(source.schema_cache, table)
|
||||
if key is None:
|
||||
raise HTTPException(404, f"数据源中没有表 {table},可能已被删除或尚未探查结构")
|
||||
messages = [m.model_dump() for m in payload.messages]
|
||||
if problem := catalog_assistant.conversation_problem(messages):
|
||||
raise HTTPException(422, problem)
|
||||
try:
|
||||
model, model_id = await catalog_assistant.resolve_model(session)
|
||||
except catalog.CatalogModelUnavailable as e:
|
||||
raise CodedHTTPException(409, str(e), CATALOG_DRAFT_MODEL_UNAVAILABLE) from e
|
||||
context = await catalog_assistant.prepare(session, row, source, key,
|
||||
pending=[c.model_dump() for c in payload.pending])
|
||||
return StreamingResponse(catalog_assistant.stream(model, model_id, context, messages),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
||||
|
||||
|
||||
@router.get("/{source_id}/catalog/{table}/impact")
|
||||
async def catalog_table_impact(source_id: str, table: str,
|
||||
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||||
|
||||
+211
-47
@@ -7,7 +7,7 @@
|
||||
**只记数据事实,不放计算公式。** 公式(指标怎么算)在口径卡里;这里只描述数据本身。所以 notes 的
|
||||
字段是固定的一组,不认识的字段一律拒收(validate_notes)。
|
||||
|
||||
notes 结构(每个「项」都是 {value, source, status},可选 note、updated_at)::
|
||||
notes 结构(每个「项」都是 {value, source, status},可选 note、reason、updated_at)::
|
||||
|
||||
{"label": 项(str), "description": 项(str), "grain": 项(str), "keys": 项(list[str]),
|
||||
"kind": 项("fact"|"dimension"|"snapshot"|"log"|"config"),
|
||||
@@ -18,7 +18,10 @@ notes 结构(每个「项」都是 {value, source, status},可选 note、upd
|
||||
"codes": 项(dict[码值, 含义];含义可为空串,表示待填写;项上可带 complete: true,
|
||||
表示已列出全部取值)}},
|
||||
"relations": [{"id", "columns", "to_table", "to_columns", "cardinality", "coverage",
|
||||
"source", "status", "note"?, "cardinality_checked"?}]}
|
||||
"source", "status", "note"?, "reason"?, "cardinality_checked"?}]}
|
||||
|
||||
note 是机器写的说明(模型起草的依据、数据剖析的测量、双盲复算的出处……);reason 是保存修改提案时使用者确认的依据
|
||||
(界面上叫「确认时的依据」,最多 500 字),只由 apply_patch 写,两者分开存,规矩见 apply_patch。
|
||||
|
||||
关系上的 cardinality_checked: true 只由数据剖析写:基数是用数据核实的(子表一侧数过 COUNT 和 COUNT(DISTINCT),
|
||||
或者有主键、唯一约束)。外键约束、命名推断给的基数是按表结构推的,子表一侧可能每个键恰好一行;SQL 检查只在
|
||||
@@ -87,16 +90,18 @@ COLUMN_FIELD_LABEL = {"label": "中文名", "meaning": "含义", "unit": "单位
|
||||
#: 各来源起草出来的项的初始状态:外键约束是数据库强制的,算有确证;人工填写即确认;其余都是推断
|
||||
_INITIAL_STATUS = {"fk": "verified", "human": "confirmed"}
|
||||
|
||||
_ITEM_KEYS = frozenset({"value", "source", "status", "note", "updated_at"})
|
||||
_ITEM_KEYS = frozenset({"value", "source", "status", "note", "reason", "updated_at"})
|
||||
#: 码值项另有一个「已列全」标记(complete: true):码值表列出了这一列的全部取值。只有人工明确确认、或者数据剖析
|
||||
#: 没截断地看完了全表取值时才有;没有它的码值表只是「已知的一部分」,SQL 检查不能拿它判断某个值「不在码值表里」
|
||||
_CODES_ITEM_KEYS = _ITEM_KEYS | {"complete"}
|
||||
_RELATION_KEYS = frozenset({"id", "columns", "to_table", "to_columns", "cardinality", "coverage", "source",
|
||||
"status", "note", "updated_at", "cardinality_checked"})
|
||||
"status", "note", "reason", "updated_at", "cardinality_checked"})
|
||||
_BUSINESS_DATE_KEYS = frozenset({"column", "rule", "timezone"})
|
||||
#: 文本项的长度上限。目录是给模型和人读的短说明,长篇大论放口径卡或知识库
|
||||
_TEXT_MAX = 500
|
||||
_LONG_TEXT_MAX = 2000
|
||||
#: 确认时的依据(项上的 reason)的长度上限:同文本项
|
||||
REASON_MAX = _TEXT_MAX
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
@@ -137,12 +142,15 @@ def unreviewed_status(item: Mapping[str, Any]) -> str:
|
||||
|
||||
|
||||
def make_item(value: Any, source: str, status: str | None = None, *, note: str | None = None,
|
||||
at: str | None = None, complete: bool = False) -> dict[str, Any]:
|
||||
at: str | None = None, complete: bool = False, reason: str | None = None) -> dict[str, Any]:
|
||||
"""构造一个项。status 不给时按来源取初始状态(initial_status)。complete 只用于码值项(已列出全部取值),
|
||||
为假时不写这个键:没有标记和标记为否是一回事,写法统一,冻结进快照的哈希才稳定。"""
|
||||
为假时不写这个键:没有标记和标记为否是一回事,写法统一,冻结进快照的哈希才稳定。reason 是确认时的依据
|
||||
(apply_patch 写),没有时不写这个键。"""
|
||||
out: dict[str, Any] = {"value": value, "source": source, "status": status or initial_status(source)}
|
||||
if note:
|
||||
out["note"] = note
|
||||
if reason:
|
||||
out["reason"] = reason
|
||||
if at:
|
||||
out["updated_at"] = at
|
||||
if complete:
|
||||
@@ -150,6 +158,15 @@ def make_item(value: Any, source: str, status: str | None = None, *, note: str |
|
||||
return out
|
||||
|
||||
|
||||
def clip_reason(reason: Any) -> str | None:
|
||||
"""提案里的依据 → 存进项里的 reason:去掉首尾空白,超过 REASON_MAX 字截成 REASON_MAX - 1 字加「…」。没有(空的、
|
||||
不是文字)为 None。预览(plan_patch)和保存(apply_patch)用的是同一句。"""
|
||||
text = reason.strip() if isinstance(reason, str) else ""
|
||||
if not text:
|
||||
return None
|
||||
return text if len(text) <= REASON_MAX else text[:REASON_MAX - 1] + "…"
|
||||
|
||||
|
||||
def codes_complete(item: Any) -> bool:
|
||||
"""码值项是不是标了「已列全」。"""
|
||||
return isinstance(item, Mapping) and item.get("complete") is True
|
||||
@@ -256,11 +273,20 @@ def _item_problems(where: str, item: Any, rule: tuple[Any, str], *,
|
||||
problems.append(f"{where}{hint}")
|
||||
if "note" in item and not isinstance(item["note"], str):
|
||||
problems.append(f"{where}的备注应为文字")
|
||||
if "reason" in item and not _reason_ok(item["reason"]):
|
||||
problems.append(f"{where}{_REASON_HINT}")
|
||||
if "updated_at" in item and not isinstance(item["updated_at"], str):
|
||||
problems.append(f"{where}的修改时间应为文字")
|
||||
return problems
|
||||
|
||||
|
||||
def _reason_ok(value: Any) -> bool:
|
||||
return isinstance(value, str) and len(value) <= REASON_MAX
|
||||
|
||||
|
||||
_REASON_HINT = f"确认时的依据应为不超过 {REASON_MAX} 字的文字"
|
||||
|
||||
|
||||
def _relation_problems(rel: Any, seen: set[str]) -> list[str]:
|
||||
if not isinstance(rel, dict):
|
||||
return ["关联关系应为对象"]
|
||||
@@ -297,6 +323,8 @@ def _relation_problems(rel: Any, seen: set[str]) -> list[str]:
|
||||
problems.append(f"{where}的状态「{rel.get('status')}」不在可选值内")
|
||||
if "note" in rel and not isinstance(rel["note"], str):
|
||||
problems.append(f"{where}的备注应为文字")
|
||||
if "reason" in rel and not _reason_ok(rel["reason"]):
|
||||
problems.append(f"{where}{_REASON_HINT}")
|
||||
if "cardinality_checked" in rel and not isinstance(rel["cardinality_checked"], bool):
|
||||
problems.append(f"{where}的「基数已用数据核实」应为是或否")
|
||||
if "updated_at" in rel and not isinstance(rel["updated_at"], str):
|
||||
@@ -609,7 +637,7 @@ def merge_notes(existing: Mapping[str, Any] | None, draft: Mapping[str, Any] | N
|
||||
"""把起草结果并入已有目录,返回 (新目录, 计数)。不改入参。
|
||||
|
||||
规则(逐个槽位):
|
||||
- 已有项是 confirmed 或 rejected:不动。驳回的留着,同一项下次起草就不会再冒出来。
|
||||
- 已有项是 confirmed 或 rejected:不动(确认时的依据 reason 跟着留着)。驳回的留着,同一项下次起草就不会再冒出来。
|
||||
- 起草结果里没有这一项:
|
||||
- 模型起草的项(llm)留着。模型这次留空、给的值没通过校验(_from_model 里就丢掉了),都不能把以前起草好的
|
||||
删掉——实测整库重跑一次,模型这次没给的几百项全被删掉,有的表覆盖率从八成多掉到五成。只有这一轮完整起草过
|
||||
@@ -698,7 +726,10 @@ def describe_slot(key: Slot) -> str:
|
||||
|
||||
|
||||
def review_item(notes: Mapping[str, Any] | None, path: str, action: str, *, at: str | None = None) -> dict[str, Any]:
|
||||
"""对一项做审阅,返回新目录(不改入参)。路径不对、项不存在、操作不认识都抛 CatalogPathError。"""
|
||||
"""对一项做审阅,返回新目录(不改入参)。路径不对、项不存在、操作不认识都抛 CatalogPathError。
|
||||
|
||||
确认时的依据(reason):确认、驳回不带依据,原有的留着;撤销审阅回到推断,去掉——推断项上挂着「使用者说:……」,
|
||||
看起来像使用者确认过它。"""
|
||||
if action not in REVIEW_ACTIONS:
|
||||
raise CatalogPathError("不支持这种审阅操作,只能确认、驳回或撤销审阅")
|
||||
key = parse_path(path)
|
||||
@@ -711,6 +742,8 @@ def review_item(notes: Mapping[str, Any] | None, path: str, action: str, *, at:
|
||||
del slots[key]
|
||||
return _assemble(slots)
|
||||
item["status"] = {"confirm": "confirmed", "reject": "rejected"}.get(action) or unreviewed_status(item)
|
||||
if action == "reset":
|
||||
item.pop("reason", None)
|
||||
item["updated_at"] = at or now_iso()
|
||||
return _assemble(slots)
|
||||
|
||||
@@ -755,6 +788,8 @@ def apply_human_edit(existing: Mapping[str, Any] | None, submitted: Mapping[str,
|
||||
或数据剖析。码值的「已列全」标记跟着值走:只勾上或取消它也算改过(_edit_value)。
|
||||
- 值没动的项:保持原来的来源和状态;提交里把状态改成确认、驳回或来源的初始状态的,照改(等同单项审阅);
|
||||
改成别的状态的不认(不能把推断改成「有确证」)。
|
||||
- 确认时的依据(reason)只由保存修改提案写(apply_patch),提交里带的不作数:值改过、新填的项去掉(原来的依据说的
|
||||
是旧值,界面原样带回来的也不认);值没动的留着库里的;状态改回来源的初始状态(等同撤销审阅)的去掉。
|
||||
- 已有、但提交里没有的表级项、列级项:删掉。
|
||||
- 已有、但提交里没有的关联关系:外键约束、命名推断、数据剖析、模型起草来的,转为驳回、保留编号;只有人工
|
||||
新建的才真正删掉。删掉的推断关系下次起草会再提出来,关系图(助手挑表、SQL 检查)也会按表结构现推回来,
|
||||
@@ -785,8 +820,9 @@ def apply_human_edit(existing: Mapping[str, Any] | None, submitted: Mapping[str,
|
||||
for key, item in new.items():
|
||||
prev = old.get(key)
|
||||
if prev is None or _edit_value(key, item) != _edit_value(key, prev):
|
||||
# 「基数已用数据核实」只由数据剖析写:人工改过的关系记成人工确认,提交里带的这个标记不作数
|
||||
fresh = {k: v for k, v in item.items() if k not in ("updated_at", "cardinality_checked")}
|
||||
# 「基数已用数据核实」只由数据剖析写:人工改过的关系记成人工确认,提交里带的这个标记不作数;确认时的依据只由
|
||||
# 保存修改提案写,说的是旧值,不留
|
||||
fresh = {k: v for k, v in item.items() if k not in ("updated_at", "cardinality_checked", "reason")}
|
||||
fresh.update(source="human", status="confirmed", updated_at=at)
|
||||
out[key] = fresh
|
||||
continue
|
||||
@@ -795,6 +831,8 @@ def apply_human_edit(existing: Mapping[str, Any] | None, submitted: Mapping[str,
|
||||
if status != prev.get("status") and status in ("confirmed", "rejected", unreviewed_status(prev)):
|
||||
kept["status"] = status
|
||||
kept["updated_at"] = at
|
||||
if status not in ("confirmed", "rejected"):
|
||||
kept.pop("reason", None) # 等同撤销审阅:回到推断,确认时的依据不留
|
||||
if item.get("note") != prev.get("note"):
|
||||
if item.get("note"):
|
||||
kept["note"] = item["note"]
|
||||
@@ -860,9 +898,9 @@ async def save_human_edit(session: AsyncSession, source_id: str, table: str, sub
|
||||
|
||||
#: 新增关联关系的路径:relations.new,值写两端;编号按两端算(relation_id),同一条关系不论谁提都是同一个编号
|
||||
NEW_RELATION_PATH = "relations.new"
|
||||
#: 一条提案最多几项:用户说的是一两条事实,几十项的「提案」多半是模型在重写整张表
|
||||
PATCH_MAX_CHANGES = 20
|
||||
_REASON_MAX = 500
|
||||
#: 一条提案最多几项(预览、保存都按它)。对话里的纠正通常是一两条事实;表助手(data/catalog_assistant.py)会逐列补中文名、
|
||||
#: 含义,界面上跨几轮攒下的待保存修改一次保存,宽表上常常几十上百项,所以放到 200
|
||||
PATCH_MAX_CHANGES = 200
|
||||
#: 看起来像计算公式的文字:「转化率=下单数/访问数」「= SUM(amount)」。公式进口径卡,目录只记数据事实。
|
||||
#: 不认减号:「上线日期=2024-01-01」不是公式。有效记录条件本来就是 SQL 条件,不查它。
|
||||
#: 对话提案(plan_patch)、人工整份提交(apply_human_edit,只查改动过的项)、模型起草(_from_model,丢掉这一项)
|
||||
@@ -877,9 +915,11 @@ class PatchChange:
|
||||
"""提案里的一项,核对过的。
|
||||
|
||||
- path:规范化后的路径(新增关系写成 relations.<编号>);
|
||||
- before / before_status:当前目录里这一项的值和状态(被驳回的和没有的都算没有,为 None);
|
||||
- before / before_status:当前目录里这一项的值和状态,没有这一项为 None。被驳回的照样给出原值、状态写 rejected
|
||||
(同导入预览「目标里这一项已经驳回」):只是给人看的,算改后、算 state 都按没有这一项,保存时换成人工确认的新值;
|
||||
- after:保存后的值(码值是补充后的完整对照);value:要交回保存的原样取值(码值只有补充的那几个,
|
||||
409 之后在最新的目录上重新补);
|
||||
- reason:提案的依据,按保存时的样子截过(clip_reason),没有为空串;
|
||||
- state:change 值有变化;confirm 值相同、但还不是已确认(保存即确认);same 已经是这个值且已确认。
|
||||
- note:服务端对这一项的说明,给人看;没有时为 None,as_dict 里也不出现。目前只有一种:新增的关联关系没写
|
||||
基数、又推算不出来(_relation_after)。
|
||||
@@ -1013,6 +1053,8 @@ def plan_patch(notes: Mapping[str, Any] | None, changes: Any, *, table: str,
|
||||
没有这个码时加进去,不会把已有的含义抹掉(_merge_codes)。
|
||||
- 关联关系不抹掉测量结果:覆盖率沿用目录里的,基数没写时沿用或推算(_relation_after)。两端、基数、覆盖率都和
|
||||
目录里一样时只是确认,来源不变。
|
||||
- 被驳回的项算没有:码值不在驳回的码值上补,和驳回的值一样也是 change(保存时换成人工确认的新值)。改前照样写出
|
||||
驳回的原值、before_status 为 rejected,不然界面上只看到「未填写」,看不出原来有一个被驳回的值。
|
||||
- 看起来像计算公式的文字不收(_FORMULA):公式进口径卡。
|
||||
- 同一个路径出现两次只认第一次。
|
||||
"""
|
||||
@@ -1033,8 +1075,7 @@ def plan_patch(notes: Mapping[str, Any] | None, changes: Any, *, table: str,
|
||||
continue
|
||||
path = raw["path"].strip()
|
||||
value = copy.deepcopy(raw["value"])
|
||||
reason = raw.get("reason")
|
||||
reason = reason.strip()[:_REASON_MAX] if isinstance(reason, str) else ""
|
||||
reason = clip_reason(raw.get("reason")) or ""
|
||||
if path == NEW_RELATION_PATH:
|
||||
rel, problem = _patch_relation(table, value, None, tables, columns)
|
||||
if problem:
|
||||
@@ -1093,8 +1134,10 @@ def plan_patch(notes: Mapping[str, Any] | None, changes: Any, *, table: str,
|
||||
state = "same" if live.get("status") == "confirmed" else "confirm"
|
||||
else:
|
||||
state = "change"
|
||||
out.append(PatchChange(path=norm, before=copy.deepcopy(before),
|
||||
before_status=live.get("status") if live is not None else None,
|
||||
# 改前给人看:被驳回的也写出原值(上面算改后、算 state 照旧按没有这一项)
|
||||
shown = live if live is not None else prev
|
||||
out.append(PatchChange(path=norm, before=copy.deepcopy(_value_of(key, shown)) if shown is not None else None,
|
||||
before_status=shown.get("status") if shown is not None else None,
|
||||
after=after, value=value, reason=reason, state=state, note=note))
|
||||
return PatchPlan(out, problems)
|
||||
|
||||
@@ -1105,6 +1148,11 @@ def apply_patch(notes: Mapping[str, Any] | None, changes: Any, *, table: str,
|
||||
|
||||
值有变化的项记为人工填写、已确认(和人工编辑同一条规矩:改动出自人的确认,不是模型起草);值没变的只把
|
||||
状态改成已确认、来源不变(等同单项确认);已经确认过的原样不动。
|
||||
|
||||
提案里的依据(reason,clip_reason 截过)记进这一项的 reason(确认时的依据),和备注 note 分开:note 是起草的依据、
|
||||
剖析的测量这类机器写的说明,照旧不拼依据——以前拼进 note,再剖析时测量的说法换不了新(catalog_profile._patch_confirmed
|
||||
只换剖析自己写的备注)、旧依据叠在新依据后面、撤销审阅后还挂在推断项上。依据每次保存整句换掉、不叠加:值改了的项
|
||||
(关联关系改了值也一样)是这一次的依据,没写就没有(原来的说的是旧值);值不变、保存即确认的也换成这一次的。
|
||||
"""
|
||||
plan = plan_patch(notes, changes, table=table, tables=tables)
|
||||
if plan.problems:
|
||||
@@ -1116,20 +1164,25 @@ def apply_patch(notes: Mapping[str, Any] | None, changes: Any, *, table: str,
|
||||
if change.state == "same":
|
||||
continue
|
||||
prev = slots.get(key)
|
||||
reason = change.reason or None
|
||||
if change.state == "confirm" and prev is not None:
|
||||
slots[key] = {**prev, "status": "confirmed", "updated_at": at}
|
||||
item = {k: v for k, v in prev.items() if k != "reason"}
|
||||
item.update(status="confirmed", updated_at=at, **({"reason": reason} if reason else {}))
|
||||
slots[key] = item
|
||||
continue
|
||||
if key[0] == "r":
|
||||
rel = {"id": key[1], **change.after, "source": "human", "status": "confirmed", "updated_at": at}
|
||||
if prev is not None and prev.get("note"):
|
||||
rel["note"] = prev["note"] # 覆盖率沿用了,说明它怎么测的那段备注也留着
|
||||
if reason:
|
||||
rel["reason"] = reason
|
||||
slots[key] = rel
|
||||
else:
|
||||
# 码值是补充、不删已有的码:原来标了「已列全」的,补充之后仍然列全。提案自己不能标「已列全」——
|
||||
# 用户在对话里说一个码值的意思,不等于确认这一列只有这些取值
|
||||
done = key[0] == "c" and key[2] == "codes" and prev is not None and prev.get("status") != "rejected" \
|
||||
and codes_complete(prev)
|
||||
slots[key] = make_item(change.after, "human", "confirmed", at=at, complete=done)
|
||||
slots[key] = make_item(change.after, "human", "confirmed", at=at, complete=done, reason=reason)
|
||||
out = _assemble(slots)
|
||||
if problems := validate_notes(out):
|
||||
raise CatalogInvalid(problems)
|
||||
@@ -1488,9 +1541,13 @@ MODEL_FALLBACK_MAX_TOKENS = 8192
|
||||
#: 一段没写出来(额度用完、只给空壳)时拆成两半再问:最多拆几层、拆到最少几列为止
|
||||
MODEL_SPLIT_DEPTH = 3
|
||||
MODEL_SPLIT_MIN_COLUMNS = 5
|
||||
#: 止损:连续这么多段拆到底仍一项没写出来,这次起草后面的段不再拆小重试(模型明显用不了时不该一段一段拆到底烧额度;
|
||||
#: 每段另有 MODEL_PART_MAX_CALLS 次封顶)
|
||||
MODEL_GIVE_UP_AFTER = 2
|
||||
#: 止损(按比例):这次起草最近 MODEL_HALT_WINDOW 次调用里有 MODEL_HALT_ON 次以上没写出来(额度用完、超时、空壳、出错),
|
||||
#: 之后的段不再拆小、每块只问一次,直到比例降下去。模型明显用不了时不该一段一段拆到底烧额度;以前按「连续几段」算,
|
||||
#: 偶尔一次写出来就清零,会思考的模型在宽表上 90 分钟停不下来。每段另有 MODEL_PART_MAX_CALLS 次封顶
|
||||
MODEL_HALT_WINDOW = 10
|
||||
MODEL_HALT_ON = 7
|
||||
#: 同一张表连续这么多个原始段都一项没写出来,这张表剩下的段跳过不问(可以换一个起草模型后只重试这张表)
|
||||
MODEL_TABLE_GIVE_UP_AFTER = 3
|
||||
#: 网络错误、429、5xx 最多重试几次,每次重试前等多久(秒,依次取用)。超时不原样重试,当作这一段太大、拆小再问
|
||||
MODEL_RETRIES = 2
|
||||
MODEL_RETRY_DELAYS_S = (10.0, 30.0)
|
||||
@@ -1512,6 +1569,10 @@ CATALOG_DRAFT_SYSTEM = """你在为一个业务数据库编写数据目录:说
|
||||
log(日志表)、config(配置表)之一。
|
||||
- keys:业务主键,即在业务上唯一确定一行的列(可以和数据库主键不同),写列名。
|
||||
- business_date:业务日期按哪一列算,rule 写一句规则,timezone 写时区(不确定就留空)。
|
||||
- valid_filter:有效行过滤。condition 写一段 SQL 条件,筛出业务上算数的行(例如 STATUS NOT IN ('C', 'X'),去掉作废、\
|
||||
测试的行):只用这张表自己的列、不带表名,只写比较、IN、BETWEEN、LIKE、IS NULL,用 AND、OR、NOT 连接,函数只用 UPPER、\
|
||||
LOWER、TRIM、COALESCE,不写子查询、聚合、窗口函数和分号;reason 写一句依据(例如哪条注释、哪些码值说明了这一点)。
|
||||
- dedup:去重规则,rule 写一句话(例如「同一订单号有多行时只取最后修改的那一行」),reason 写一句依据。
|
||||
- columns:每一列的 label(中文名)、meaning(含义,一句话)、unit(单位,例如「元」「人」,没有单位就留空)、\
|
||||
measure(度量类型:flow 流量,可跨期加总;stock 存量,时点值,不能跨期加总;ratio 比率;identifier 标识;\
|
||||
status 状态;attribute 属性)。
|
||||
@@ -1519,6 +1580,8 @@ status 状态;attribute 属性)。
|
||||
规矩:
|
||||
- 只写数据事实,不写计算公式,不写指标怎么算。
|
||||
- 拿不准的字段留空,不要猜。列名、表名照原样写,不要翻译或改写。
|
||||
- 有效行过滤、去重规则只在表结构、注释、码值能看出来时才写(例如状态列的注释写明了哪些取值是作废、测试),拿不准就不写,\
|
||||
没有就不写:写错了会把算数的行筛掉、把不该合并的行合并。
|
||||
- 请你写的每一列都在 columns 里写一项,拿不准的字段可以留空,但不要漏掉列:漏掉的列会被再问一次。
|
||||
- 标着「系统说明」的内容是导入时按核对结果生成的,以它为准:不要重复,也不要与之矛盾。
|
||||
- 标着「已确认」的目录项由人工确认过,以它为准。
|
||||
@@ -1537,6 +1600,7 @@ CATALOG_DRAFT_BACKGROUND_PROMPT = "使用者补充的背景和要求:\n{lines}
|
||||
CATALOG_DRAFT_TEXT_PROMPT = """只输出一个 JSON 对象,不要输出别的文字,格式如下(拿不准的字段不写):
|
||||
{"tables": [{"table": "表名", "label": "中文名", "grain": "一行代表什么", "kind": "表类型", "keys": ["列名"], \
|
||||
"business_date": {"column": "列名", "rule": "一句规则", "timezone": "时区"}, \
|
||||
"valid_filter": {"condition": "SQL 条件", "reason": "一句依据"}, "dedup": {"rule": "一句话", "reason": "一句依据"}, \
|
||||
"columns": [{"name": "列名", "label": "中文名", "meaning": "含义", "unit": "单位", "measure": "度量类型"}]}]}
|
||||
表类型写 fact、dimension、snapshot、log、config 之一;度量类型写 flow、stock、ratio、identifier、status、attribute 之一。"""
|
||||
|
||||
@@ -1556,11 +1620,14 @@ CATALOG_DRAFT_PROMPT_LABELS = {
|
||||
"unique": "唯一约束:{cols}",
|
||||
"confirmed": "已确认的目录项:",
|
||||
# 分段起草:表名下一行说这次只写哪部分(宽表的表级一段、列分段,拆小重问时的半段)
|
||||
"only_table": "这次只写表级的项(中文名、粒度、表类型、业务主键、业务日期),不写列",
|
||||
"only_table": "这次只写表级的项(中文名、粒度、表类型、业务主键、业务日期、有效行过滤、去重规则),不写列",
|
||||
"only_columns": "这次只写下面列出的 {n} 列(这张表共 {total} 列),不写表级的项",
|
||||
"table_and_columns": "这次只写表级的项和这 {n} 列:{cols}",
|
||||
"brief_columns": "列(共 {n} 列,这里只列名字和类型):",
|
||||
"drafted": "已起草的表级内容:",
|
||||
# 开了数据剖析的源:目录里记下的码值(剖析看到的取值和已知含义)交给模型,判断有效行过滤用;遮罩的列不给
|
||||
"codes": "目录里记下的码值(数据剖析看到的取值和已知含义,可据此判断有效行过滤):",
|
||||
"code": "- {name}:{codes}",
|
||||
# 使用者补充的背景和要求
|
||||
"description": "数据源说明:{text}",
|
||||
"guidance": "起草要求:{text}",
|
||||
@@ -1588,6 +1655,20 @@ CATALOG_DRAFT_PROMPT_SCHEMA: dict[str, Any] = {
|
||||
"timezone": {"type": "string"}},
|
||||
"required": ["column"],
|
||||
},
|
||||
"valid_filter": {
|
||||
"type": "object",
|
||||
"description": "有效行过滤:只在表结构、注释、码值能看出来时才写",
|
||||
"properties": {"condition": {"type": "string", "description": "SQL 条件,只用这张表的列"},
|
||||
"reason": {"type": "string", "description": "一句依据"}},
|
||||
"required": ["condition"],
|
||||
},
|
||||
"dedup": {
|
||||
"type": "object",
|
||||
"description": "去重规则:只在表结构、注释能看出来时才写",
|
||||
"properties": {"rule": {"type": "string", "description": "一句话"},
|
||||
"reason": {"type": "string", "description": "一句依据"}},
|
||||
"required": ["rule"],
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -1708,11 +1789,77 @@ def _clean_text(value: Any, limit: int = _TEXT_MAX) -> str | None:
|
||||
return text if text and len(text) <= limit else None
|
||||
|
||||
|
||||
def _from_model(entry: Mapping[str, Any], meta: Mapping[str, Any], *, system: bool) -> dict[str, Any]:
|
||||
#: 模型给的依据(有效行过滤、去重规则的 reason)写进这一项的 note
|
||||
FACT_REASON_NOTE = "依据:{reason}"
|
||||
#: 有效行过滤按数据源的写法整理(列名加了引号)以后超过目录里这一项的长度上限
|
||||
FACT_FILTER_TOO_LONG = "{label}按数据源的写法整理后超过 {limit} 字"
|
||||
|
||||
|
||||
def _model_fact(raw: Any, key: str) -> tuple[str, str | None] | None:
|
||||
"""模型给的有效行过滤、去重规则 → (取值, 依据)。约定是对象({condition 或 rule, reason}),只写了一段文字的也认;
|
||||
取值不是非空文字的不要。"""
|
||||
if isinstance(raw, str):
|
||||
text, reason = raw, None
|
||||
elif isinstance(raw, Mapping):
|
||||
text, reason = raw.get(key), raw.get("reason")
|
||||
if text is None:
|
||||
text = raw.get("value")
|
||||
else:
|
||||
return None
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
return None
|
||||
return text.strip(), _clean_text(reason)
|
||||
|
||||
|
||||
def _fact_item(value: str, reason: str | None) -> dict[str, Any]:
|
||||
return make_item(value, "llm", note=FACT_REASON_NOTE.format(reason=reason) if reason else None)
|
||||
|
||||
|
||||
def _model_facts(entry: Mapping[str, Any], meta: Mapping[str, Any], *, kind: str | None, table: str | None,
|
||||
dropped: list[tuple[str, str]] | None = None) -> dict[str, Any]:
|
||||
"""模型提议的有效行过滤、去重规则(llm / proposed,依据写进 note)。
|
||||
|
||||
有效行过滤按数据源的方言解析成一个条件、只能用这张表的列(sqlcheck.parse_valid_filter),不过就丢掉、不算这张表
|
||||
失败:服务端日志记一条,dropped 给了就记下 (原因, 模型写的条件),起草引擎把它写进这张表的日志(起草任务逐表的
|
||||
log)。存的是 sqlcheck.stored_valid_filter 整理过的写法:原文照旧,只把列换成表结构里的真名、不带表名,按数据源的
|
||||
需要加引号——剖析核对的、SQL 检查让模型照着加的都是这一条。去重规则是一句话,同文字项的规矩。"""
|
||||
from app.data import sqlcheck
|
||||
|
||||
out: dict[str, Any] = {}
|
||||
found = _model_fact(entry.get("valid_filter"), "condition")
|
||||
if found is not None:
|
||||
text, reason = found
|
||||
parsed = sqlcheck.parse_valid_filter(text, meta, kind=kind, table=table)
|
||||
problem = parsed.problem
|
||||
if problem is None:
|
||||
stored = sqlcheck.stored_valid_filter(parsed, kind)
|
||||
if _text_ok(stored):
|
||||
out["valid_filter"] = _fact_item(stored, reason)
|
||||
else:
|
||||
# 加了引号以后超过上限:写进去会让整张表写入失败(validate_notes),不收
|
||||
problem = FACT_FILTER_TOO_LONG.format(label=TABLE_FIELD_LABEL["valid_filter"], limit=_TEXT_MAX)
|
||||
if problem is not None:
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).info("起草数据目录:表 %s 的有效行过滤没有通过校验,已丢掉:%s(%s)",
|
||||
table or meta.get("qualified") or entry.get("table"), problem, text[:200])
|
||||
if dropped is not None:
|
||||
dropped.append((problem, text))
|
||||
found = _model_fact(entry.get("dedup"), "rule")
|
||||
if found is not None and (text := _clean_text(found[0])) is not None:
|
||||
out["dedup"] = _fact_item(text, found[1])
|
||||
return out
|
||||
|
||||
|
||||
def _from_model(entry: Mapping[str, Any], meta: Mapping[str, Any], *, system: bool, source_kind: str | None = None,
|
||||
table: str | None = None, dropped: list[tuple[str, str]] | None = None) -> dict[str, Any]:
|
||||
"""模型给的一张表 → 目录项(llm / proposed)。不合规的值整项丢掉,不硬塞:列名对不上表结构的、
|
||||
表类型和度量类型不在取值里的、业务主键里有不存在的列的、看起来是计算公式的(_formula_like,公式进口径卡)。
|
||||
该是文字的地方写成了数组、对象(列名、表类型、业务日期的列、度量类型……)同样丢掉,不抛异常:实测见过把列名
|
||||
写成数组的,以前在这里抛 TypeError,整张表记出错。导入表格的源,说明已经覆盖的字段也丢掉。"""
|
||||
写成数组的,以前在这里抛 TypeError,整张表记出错。导入表格的源,说明已经覆盖的字段也丢掉。
|
||||
|
||||
有效行过滤、去重规则见 _model_facts:source_kind 是数据源类型(按它的方言解析条件,不认识的类型不收有效行过滤),
|
||||
table 是表名(条件里的列可以带这张表的表名),dropped 收没通过校验的有效行过滤 (原因, 模型写的条件)。"""
|
||||
columns = {c["name"]: c for c in meta.get("columns") or []}
|
||||
notes: dict[str, Any] = {}
|
||||
table_has_system_note = system and bool((meta.get("comment") or "").strip())
|
||||
@@ -1734,6 +1881,7 @@ def _from_model(entry: Mapping[str, Any], meta: Mapping[str, Any], *, system: bo
|
||||
if (text := _clean_text(date.get(k))) is not None:
|
||||
value[k] = text
|
||||
notes["business_date"] = make_item(value, "llm")
|
||||
notes.update(_model_facts(entry, meta, kind=source_kind, table=table, dropped=dropped))
|
||||
out_cols: dict[str, dict[str, Any]] = {}
|
||||
listed = entry.get("columns")
|
||||
for col in listed if isinstance(listed, list) else []:
|
||||
@@ -1842,8 +1990,10 @@ async def write_draft(session: AsyncSession, source: Any, table: str, modeled: A
|
||||
没用模型时为 None)有可用内容的再并上。同步起草和后台起草任务(每张表一结束就调)共用这一处。
|
||||
|
||||
- 并入已有目录按 merge_notes 的规则:人工确认、驳回过的项不动。内容没变不写、不升版本。
|
||||
- 模型这次没给的项(留空、没通过校验)不删以前起草的;列已经不在表结构里的,模型各段都写出来时(covered 含 llm)
|
||||
照旧删掉。模型只写出一部分的不算完整起草过(covered 不含 llm)。
|
||||
- 模型这次没给的项(留空、没通过校验)不删以前起草的;列已经不在表结构里的(业务主键、业务日期指着的列也算),
|
||||
整张表起草完(各段都写出来的 ok、只写出一部分的 partial 都一样)就按完整覆盖的规则删掉:删的只是说已经不存在的
|
||||
列的模型起草项,和这次写没写全无关。后台任务里宽表每写出一段就先并入一次(catalog_jobs.write_part,那一次不删
|
||||
任何东西),这里是整张表的收尾。
|
||||
- 模型留空的列(blank)按并入之后的目录再筛一遍:已经有中文名或含义的(以前起草的、库注释、起草进行中有人填的)
|
||||
不算留空。
|
||||
- 写入撞上别人刚改过(乐观锁)就重读重并,最多三次。
|
||||
@@ -1859,8 +2009,8 @@ async def write_draft(session: AsyncSession, source: Any, table: str, modeled: A
|
||||
if modeled.items:
|
||||
result.model_items = modeled.items
|
||||
result.blank = list(getattr(modeled, "blank", None) or [])
|
||||
model_draft = TableDraft(notes=modeled.notes, covered=frozenset({"llm"}) if modeled.status == "ok"
|
||||
else frozenset())
|
||||
model_draft = TableDraft(notes=modeled.notes, covered=frozenset({"llm"})
|
||||
if modeled.status in ("ok", "partial") else frozenset())
|
||||
for _attempt in range(3):
|
||||
entry = await read_entry(session, source.id, table)
|
||||
version = entry.version if entry else 0
|
||||
@@ -2249,27 +2399,27 @@ def query_snapshot_meta(source: Any, sql: str) -> dict[str, Any]:
|
||||
return {"source_id": getattr(source, "id", None), "source": getattr(source, "name", None), "tables": tables}
|
||||
|
||||
|
||||
async def table_usage(session: AsyncSession, source: Any) -> dict[str, int]:
|
||||
"""每张表被运行查询过的次数:{schema_cache 的表名: 次数},没被查过的表不出现。
|
||||
async def query_snapshot_tables(session: AsyncSession, source: Any) -> list[tuple[str, frozenset[str]]]:
|
||||
"""这个源上属于运行的每一行查询快照引用:(运行 id, 这条查询用到的表——schema_cache 的表名),没对上任何表的不收。
|
||||
|
||||
取法:数据源查询工具每次成功查询都存一份查询快照工件(tools/datasource.py),工件引用表 artifacts 里
|
||||
按 (内容哈希, 运行) 各记一行。数「属于某次运行(runs 里有这次运行)、属于这个源、SQL 里用到了这张表」的
|
||||
行:同一次运行里结果一模一样的重复查询只算一次,工具库里的试用(不属于任何运行)不算。
|
||||
使用次数(table_usage)和「这张表被哪些运行用过」(data/catalog_review.py)共用这一份取法:数据源查询工具每次成功
|
||||
查询都存一份查询快照工件(tools/datasource.py),工件引用表 artifacts 里按 (内容哈希, 运行) 各记一行;只收属于某次运行
|
||||
(runs 里有这次运行)、属于这个源的行,工具库里的试用(不属于任何运行)不算。
|
||||
|
||||
表名在查询当时就记进了引用的 meta(query_snapshot_meta),这里只读 artifacts 一张表;升级前的老引用
|
||||
没有 meta,退回读工件内容里的源名和 SQL,读过的按工件 id 缓存(内容永不改变)。SQL 里写的表名去掉
|
||||
schema 前缀后按 name_key(只把 ASCII 字母转小写,和 SQLite 比较标识符的口径一致)对到表结构上,对不上的不计。
|
||||
表名在查询当时就记进了引用的 meta(query_snapshot_meta),这里只读 artifacts 一张表;升级前的老引用没有 meta,退回读
|
||||
工件内容里的源名和 SQL,读过的按工件 id 缓存(内容永不改变)。SQL 里写的表名去掉 schema 前缀后按 name_key(只把
|
||||
ASCII 字母转小写,和 SQLite 比较标识符的口径一致)对到表结构上,对不上的不计。
|
||||
"""
|
||||
rows = (await session.execute(
|
||||
select(Artifact.id, Artifact.meta).join(Run, Run.id == Artifact.run_id)
|
||||
select(Artifact.id, Artifact.meta, Artifact.run_id).join(Run, Run.id == Artifact.run_id)
|
||||
.where(Artifact.kind == QUERY_SNAPSHOT_KIND)
|
||||
)).all()
|
||||
tables = (getattr(source, "schema_cache", None) or {}).get("tables") or {}
|
||||
lookup: dict[str, str] = {}
|
||||
for name in tables:
|
||||
lookup.setdefault(name_key(name), name)
|
||||
counts: Counter[str] = Counter()
|
||||
for artifact_id, meta in rows:
|
||||
out: list[tuple[str, frozenset[str]]] = []
|
||||
for artifact_id, meta, run_id in rows:
|
||||
meta = meta if isinstance(meta, dict) else {}
|
||||
if isinstance(meta.get("tables"), list):
|
||||
source_id, source_name, names = meta.get("source_id"), meta.get("source"), meta["tables"]
|
||||
@@ -2281,8 +2431,21 @@ async def table_usage(session: AsyncSession, source: Any) -> dict[str, int]:
|
||||
continue
|
||||
elif source_name != source.name:
|
||||
continue
|
||||
hit = {lookup.get(name_key(str(n).rsplit(".", 1)[-1])) for n in names}
|
||||
counts.update(t for t in hit if t is not None)
|
||||
hit = frozenset(t for t in (lookup.get(name_key(str(n).rsplit(".", 1)[-1])) for n in names) if t is not None)
|
||||
if hit:
|
||||
out.append((str(run_id), hit))
|
||||
return out
|
||||
|
||||
|
||||
async def table_usage(session: AsyncSession, source: Any) -> dict[str, int]:
|
||||
"""每张表被运行查询过的次数:{schema_cache 的表名: 次数},没被查过的表不出现。
|
||||
|
||||
按查询快照引用的行数(query_snapshot_tables):同一次运行里结果一模一样的重复查询只算一次,同一次运行里不同的查询
|
||||
各算一次,工具库里的试用不算。
|
||||
"""
|
||||
counts: Counter[str] = Counter()
|
||||
for _run, hit in await query_snapshot_tables(session, source):
|
||||
counts.update(hit)
|
||||
return dict(counts)
|
||||
|
||||
|
||||
@@ -2324,10 +2487,11 @@ __all__ = [
|
||||
"CatalogEntry", "CatalogInvalid", "CatalogModelUnavailable", "CatalogPathError", "DRAFT_DEFAULT_TABLES",
|
||||
"DraftModel", "DraftReport", "INFERRED_MARK", "ITEM_SOURCES", "ITEM_STATUSES", "JoinEdge", "KIND_LABEL",
|
||||
"MEASURE_LABEL", "MODEL_BATCH_COLUMNS", "MODEL_BATCH_TABLES", "MODEL_CONCURRENCY", "MODEL_EMPTY_REASON",
|
||||
"MODEL_FALLBACK_MAX_TOKENS", "MODEL_MAX_TOKENS", "MODEL_MIN_PART_COLUMNS", "MODEL_NOTICE_OFF", "MODEL_NOTICE_ON",
|
||||
"MODEL_NOTICE_WINDOW", "MODEL_PART_MAX_CALLS", "MODEL_RETRIES", "MODEL_RETRY_DELAYS_S", "MODEL_SHRINK_AFTER",
|
||||
"MODEL_SPLIT_DEPTH", "MODEL_SPLIT_MIN_COLUMNS", "MODEL_TIMEOUT_S", "MergeStats", "NEW_RELATION_PATH",
|
||||
"PATCH_MAX_CHANGES",
|
||||
"FACT_REASON_NOTE", "MODEL_FALLBACK_MAX_TOKENS", "MODEL_HALT_ON", "MODEL_HALT_WINDOW", "MODEL_MAX_TOKENS",
|
||||
"MODEL_MIN_PART_COLUMNS", "MODEL_NOTICE_OFF", "MODEL_NOTICE_ON", "MODEL_NOTICE_WINDOW", "MODEL_PART_MAX_CALLS",
|
||||
"MODEL_RETRIES", "MODEL_RETRY_DELAYS_S", "MODEL_SHRINK_AFTER", "MODEL_SPLIT_DEPTH", "MODEL_SPLIT_MIN_COLUMNS",
|
||||
"MODEL_TABLE_GIVE_UP_AFTER", "MODEL_TIMEOUT_S", "MergeStats", "NEW_RELATION_PATH",
|
||||
"PATCH_MAX_CHANGES", "REASON_MAX", "clip_reason",
|
||||
"PROFILE_NOTE_PREFIX", "PROFILE_VERIFY_COVERAGE", "WRITE_CONFLICT_ERROR", "default_tables",
|
||||
"missing_table_error", "pick_tables", "write_draft",
|
||||
"PatchChange", "PatchPlan", "QUERY_SNAPSHOT_KIND", "REVIEW_ACTIONS", "TABLE_FIELDS", "TABLE_FIELD_LABEL",
|
||||
|
||||
@@ -0,0 +1,927 @@
|
||||
"""表助手:在表详情页和使用者对话,一起完善一张表的数据目录(POST /catalog/{table}/assistant,SSE)。
|
||||
|
||||
**为什么要有它。** 模型起草只看表结构,缩写列名、状态码的意思常常拿不准而留空;审阅时人知道答案,却要一项一项填。表助手
|
||||
把这张表的结构、目录现状(每项的来源和状态)、关联关系、剖析结果、起草要求、使用次数、上次起草留空的列交给模型,让它主动
|
||||
问拿不准的地方;使用者说出一条业务事实,它整理成成批的修改提案(形状同助手流里的 catalog_patch),使用者在界面上逐项看过、
|
||||
一次保存(PATCH /catalog/{table}/patch,记为人工确认,每项的 reason 另存成这一项的确认时的依据,见 catalog.apply_patch)。
|
||||
|
||||
**依据只引用这次对话。** 数据源的起草要求是以前写在数据源上的背景,背景里标明「不是这次对话里使用者说的话」;目录项上以前
|
||||
保存时记下的依据也一样,背景里写成「以前确认时的依据:……」,和备注分开。提示词要求 reason 里的原话只引用这次对话里使用者的
|
||||
消息,依据来自起草要求的写「起草要求写着:……」,来自以前的依据的写「以前确认时的依据写着:……」。验收时模型把起草要求写成了
|
||||
「使用者说:……」,保存之后看起来像是使用者亲口确认过。
|
||||
|
||||
**只改目录、不改业务库。** 模型只有两个工具:
|
||||
- propose_changes:一批修改提案。按 catalog.plan_patch 核对(取值格式、列名必须在表结构里、公式不收),遮罩列不给码值,已经
|
||||
是这个值又确认过的不提;有效记录条件是模型写的 SQL,另按起草的规矩核对(_Tools.filter_problem:一个条件、只用这张表的
|
||||
列、不能有子查询……)。核对过的作为 proposal 事件交给界面,**不写目录**。没收下的项和原因交回模型(不带任何取值)。
|
||||
界面上还没保存的修改(pending)里同一列的码值并进新提的码值:界面上同一路径以后一次为准,不能把先提的码值丢了。
|
||||
- run_query:只读查询,只有数据源开了数据剖析才给。经剖析的只读守卫和单条时限(catalog_profile._Runner:
|
||||
guard.check(readonly=True),单条时限取剖析设置和数据源查询时限中较小的一个),每次请求最多 MAX_QUERIES 条(守卫拒掉的
|
||||
也算),最多 QUERY_ROWS 行交给模型、SHOWN_ROWS 行交给界面。结果交给模型、界面之前先遮(engine/masking.mask_query_result:
|
||||
取自遮罩列的结果列整列换成「已遮罩」,改了名字的也认;这一次请求里前面遮掉的值按值换),报错原话里的值也换。没开剖析时
|
||||
提示词里写明只能根据表结构和目录推断。
|
||||
|
||||
**遮罩列只给列名。** 表结构里遮罩的列只写列名和类型(不给注释),目录里这些列的码值、带着取值的备注和确认时的依据一律不给
|
||||
(剖析的备注里有取值范围,使用者的原话里也可能有);界面上还没保存的修改里这些列的码值也不给。
|
||||
|
||||
**模型。** 取模型的顺序同起草(data/draft_model.py:起草模型 → 助手的模型 → 默认接入),用不了时的说法也同起草
|
||||
(先过 catalog.resolve_draft_model,接口回 409 catalog_draft_model_unavailable)。保留思考、温度 0,额度
|
||||
catalog.MODEL_MAX_TOKENS,服务方嫌大时退回 catalog.MODEL_FALLBACK_MAX_TOKENS(providers/budget.BudgetFallback);单次调用
|
||||
最多等 catalog.MODEL_TIMEOUT_S(同起草)。网络错误、429、5xx 在还没输出任何东西时重试(最多 len(RETRY_DELAYS_S) 次)。
|
||||
一次请求最多调用 MAX_ROUNDS 次模型(查询、提案之后接着答);倒数第二轮的工具结果里提醒它直接回答。
|
||||
|
||||
**事件**(每个事件一行 data: <JSON>):
|
||||
- {"type": "text", "delta"}:回答正文的增量。几轮之间补一个空行;
|
||||
- {"type": "query", "sql", "columns", "rows"(前 SHOWN_ROWS 行), "row_count", "truncated", "error"}:跑了一条只读查询;
|
||||
- {"type": "proposal", "source", "source_id", "table", "table_label", "version", "changes": [{path, label, before,
|
||||
before_status, after, value, reason, state, note?}]}:一批核对过的修改提案;
|
||||
- {"type": "done", "usage": {input_tokens, output_tokens}, "queries"};
|
||||
- {"type": "error", "message", "code"}:中途出错,流到此为止(之后没有 done)。code:assistant_model_error 调用模型出错、
|
||||
assistant_truncated 输出到了额度上限被截断、assistant_timeout 单次调用超过时限、assistant_rounds 调用次数用完还没答完、
|
||||
assistant_failed 意料之外的错误。
|
||||
等模型的时候每 HEARTBEAT_S 秒发一行 SSE 注释(「: keep-alive」,不是事件),免得中间的代理断开。
|
||||
|
||||
**对话不存服务端**:每次请求带上整段对话(最多 MAX_MESSAGES 条、每条最多 MESSAGE_MAX 字)。查询结果只在这一次请求里
|
||||
看得到,提示词里让模型把要用的结论写进回答。界面上待保存的修改不设上限(宽表上逐列补中文名、含义,几轮下来常常超过一次
|
||||
保存的 200 项,不能因此不让问):背景里只列最近的 PENDING_SHOWN 项(界面上新的提案接在后面),写明另有几项;码值照全部
|
||||
待保存的修改合并。
|
||||
|
||||
写给模型的文字都在名字带 _SYSTEM / _PROMPT 的常量里;这里的 ERROR_* 是给人看的。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncIterator, Iterable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.data import catalog, catalog_jobs, catalog_profile, sqlcheck
|
||||
from app.data.catalog_review import SOURCE_LABEL, item_label, slot_path, table_usage_counts
|
||||
from app.data.catalog_transfer import filter_columns_problem
|
||||
from app.data.draft_model import DRAFT_MODEL_PLACE
|
||||
from app.data.engine import SnapshotTampered, masked_columns
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: 对话最多几条、每条最多几个字;界面上待保存的修改在背景里最多列几项(最近的;请求里交多少都收)
|
||||
MAX_MESSAGES = 40
|
||||
MESSAGE_MAX = 4000
|
||||
PENDING_SHOWN = 200
|
||||
#: 每次请求最多调用几次模型、最多几条只读查询
|
||||
MAX_ROUNDS = 8
|
||||
MAX_QUERIES = 5
|
||||
#: 一条查询最多几行交给模型、几行交给界面;交给模型时每格最多几个字、一条结果最多几个字
|
||||
QUERY_ROWS = 50
|
||||
SHOWN_ROWS = 10
|
||||
CELL_MAX = 300
|
||||
TOOL_CHARS = 12_000
|
||||
#: 网络错误、429、5xx 重试前等几秒(依次取用,个数就是最多重试几次)。在对话里等,比起草(10、30 秒)短
|
||||
RETRY_DELAYS_S = (3.0, 10.0)
|
||||
#: 等模型的时候每隔几秒发一行 SSE 注释
|
||||
HEARTBEAT_S = 10.0
|
||||
#: 背景里「目录里还没有中文名和含义的列」最多列几个;别的表指过来的关联关系最多列几条;这张表的目录最多列几项
|
||||
#: (几百列的宽表全部起草过有上千项,全列出来提示词就太长了:表级的项、关联关系在前,列的项在后)
|
||||
UNNAMED_SHOWN = 30
|
||||
INCOMING_SHOWN = 30
|
||||
CATALOG_ITEMS_SHOWN = 600
|
||||
|
||||
QUERY_TOOL = "run_query"
|
||||
PROPOSE_TOOL = "propose_changes"
|
||||
|
||||
#: 错误事件的机读码
|
||||
CODE_MODEL = "assistant_model_error"
|
||||
CODE_TRUNCATED = "assistant_truncated"
|
||||
CODE_TIMEOUT = "assistant_timeout"
|
||||
CODE_ROUNDS = "assistant_rounds"
|
||||
CODE_FAILED = "assistant_failed"
|
||||
|
||||
# ---- 给人看的
|
||||
ERROR_MODEL = ("调用模型「{model}」失败:{reason}。请稍后重试;如持续失败,请在{place}中换一个模型,"
|
||||
"或到「设置 → 模型接入」测试该模型")
|
||||
ERROR_TRUNCATED = "{cut},回答没有写完。通常是思考占满了输出额度,可以把问题拆小一点再问,或在{place}中换一个模型"
|
||||
ERROR_TIMEOUT = "模型 {seconds} 秒内没有回答完,已停止这一轮。可以把问题拆小一点再问,或在{place}中换一个模型"
|
||||
ERROR_ROUNDS = "这一轮已经调用了 {n} 次模型仍没有答完,已停止。可以换个问法,或者分几次问"
|
||||
ERROR_FAILED = "表助手出错:{line}"
|
||||
#: 对话的最后一条是助手说的(接口 422)
|
||||
LAST_NOT_USER = "对话的最后一条应当是使用者的话,请重新发送"
|
||||
#: 起草模型刚才还能用、建流式模型时却用不了(期间设置被改了)时的说法
|
||||
ERROR_NO_MODEL = "起草模型暂时无法使用,请稍后重试,或到「设置 → 模型接入」检查"
|
||||
#: 查询事件里的报错
|
||||
QUERY_TAMPERED = "数据源「{name}」的{error}。请联系管理员检查数据目录"
|
||||
|
||||
# ---- 写给模型的
|
||||
ASSISTANT_SYSTEM = """你是 AgentLab 的「表助手」,和使用者一起完善一张表的数据目录:表和列的中文名、含义、单位、度量类型、码值、\
|
||||
粒度、表类型、业务主键、业务日期、有效记录条件、去重规则、关联关系。
|
||||
|
||||
规矩:
|
||||
- 你只改数据目录,不改业务库:不能写数据、建表、改结构。
|
||||
- 只记数据事实,不写计算公式:「转化率=下单数/访问数」这类指标怎么算的说法属于口径卡,不进数据目录。
|
||||
- 拿不准就问,不要猜。一次只问一两件最要紧的事,问得具体(例如「status 为 9 的行是不是作废的单子?」)。目录里留空的列、\
|
||||
标着「推断」的项是最值得问的。
|
||||
- 使用者说出一条业务事实时,用 propose_changes 把它整理成修改提案,reason 里引用使用者的原话(例如「使用者说:9 是作废」)。\
|
||||
reason 里引用使用者原话只能引用这次对话里使用者的消息;依据来自下面背景里的数据源起草要求时写「起草要求写着:……」,\
|
||||
来自背景里目录项下面的「以前确认时的依据」时写「以前确认时的依据写着:……」(那是以前保存时记下的,不是这次对话里\
|
||||
使用者说的话),都不要写成「使用者说」。一次可以提多项。提案只是建议:使用者在界面上看过、保存之后才写进数据目录,\
|
||||
记为人工确认,reason 会作为这一项确认时的依据一起保存。
|
||||
- 表名、列名照原样写,只能用下面表结构里有的列。
|
||||
- 标着「已确认」的项由人工确认过,使用者没有明确要改就不要提修改;标着「已驳回」的项使用者驳回过,不要再提。
|
||||
- 「界面上还没保存的修改」是使用者已经接受、还没保存的提案:不要重复提;要改其中一项就再提一次,写完整的新值。
|
||||
- 遮罩的列只给了列名:不要猜它的取值,也不要给它写码值。
|
||||
- 回答用中文,简短直接。查询结果只在这一轮看得到,后面要用的结论请写进回答里。
|
||||
|
||||
修改提案的写法(propose_changes 的 changes,每项 path、value、reason):
|
||||
- path:表级项直接写字段名(label 中文名、description 说明、grain 粒度、keys 业务主键的列名列表、kind 表类型 \
|
||||
fact/dimension/snapshot/log/config、business_date {"column","rule","timezone"}、valid_filter 有效记录的 SQL 条件、dedup 去重\
|
||||
规则);列级项写 columns.<列名>.<字段>(label、meaning 含义、unit 单位、measure 度量类型 \
|
||||
flow/stock/ratio/identifier/status/attribute、codes 码值 {"码":"含义"});已有的关联关系写 relations.<编号>;新增关联关系\
|
||||
写 relations.new,value 写 {"columns":[本表列],"to_table":"目标表","to_columns":[目标列],\
|
||||
"cardinality":"many_to_one/one_to_one/one_to_many"}。
|
||||
- codes 只写要补充或更正的码,原有的码会保留;valid_filter 要写成完整的条件(原有条件也要带上)。
|
||||
- valid_filter 只能用这张表的列(不带表名),由比较、IN、BETWEEN、LIKE、IS NULL 用 AND / OR / NOT 连起来,例如 \
|
||||
status NOT IN ('C', 'X');不能有子查询、聚合、窗口函数、分号,函数只能用 UPPER、LOWER、TRIM、COALESCE。"""
|
||||
|
||||
#: 查询工具的说明(开了剖析时接在系统提示后面)、没开剖析时的那句
|
||||
ASSISTANT_QUERY_PROMPT = """只读查询:run_query 在这个数据源上执行一条只读 SQL({dialect} 的写法),最多返回 {rows} 行,\
|
||||
这一轮最多 {limit} 条。用它核对码值的取值和分布、日期范围、关联对不对得上;不要取明细的个人信息。遮罩列的值显示为「已遮罩」。"""
|
||||
ASSISTANT_NO_QUERY_PROMPT = "这个数据源没开剖析,不能查数据,只能根据表结构和目录推断。"
|
||||
ASSISTANT_DISABLED_PROMPT = "这个数据源已停用,不能查数据,只能根据表结构和目录推断。"
|
||||
#: 使用者还没说话(刚打开表助手)时补的那句
|
||||
ASSISTANT_OPENING_PROMPT = ("(使用者刚打开表助手,还没有说话。)请用一两句话说明这张表的目录现在最缺什么,然后就最要紧的一两项"
|
||||
"向使用者提问;先不要提修改提案。")
|
||||
#: 对话从助手的开场白开始时,补在最前面的一句(各家的接口都要求从使用者这一边开始)
|
||||
ASSISTANT_OPENED_PROMPT = "(使用者打开了表助手。)"
|
||||
|
||||
#: 背景各段的写法
|
||||
ASSISTANT_PROMPT_LABELS = {
|
||||
"head": "下面是要完善的表。",
|
||||
"table": "数据源「{source}」({kind}),表 {table}{view}",
|
||||
"view": "(视图)",
|
||||
"comment": "数据库注释:{text}",
|
||||
"system": "系统说明:{text}",
|
||||
"usage": "这张表被 {runs} 次运行用到(查询 {n} 次)。",
|
||||
"usage_none": "这张表还没有被运行用到过。",
|
||||
"description": "数据源说明:{text}",
|
||||
"guidance": "数据源的起草要求(不是这次对话里使用者说的话):{text}",
|
||||
"structure": "表结构:",
|
||||
"column": "- {name} {type}{marks}",
|
||||
"pk": "主键",
|
||||
"not_null": "非空",
|
||||
"col_comment": "注释:{text}",
|
||||
"masked": "遮罩列:只给列名,不给任何取值",
|
||||
"unique": "唯一约束:{cols}",
|
||||
"fk": "外键:{cols} → {target}",
|
||||
"catalog": "这张表现在的数据目录(每项写明来源和状态;路径就是修改提案里的 path):",
|
||||
"catalog_empty": "这张表还没有数据目录。",
|
||||
"item": "- {path}({label}):{value}〔{source},{status}〕",
|
||||
"item_note": " 备注:{text}",
|
||||
"item_reason": " 以前确认时的依据:{text}",
|
||||
"catalog_more": "(还有 {n} 项没有列出:这张表的目录太长。要改的项照上面的路径写法提,核对时按当前目录算)",
|
||||
"relation": "{columns} → {to_table}.{to_columns}{extra}",
|
||||
"relation_extra": "({text})",
|
||||
"cardinality": "基数 {value}",
|
||||
"coverage": "覆盖率 {value}",
|
||||
"incoming": "别的表指向这张表的关联关系:",
|
||||
"incoming_item": "- {table}.{columns} → {target}.{to_columns}〔{source},{status}〕",
|
||||
"blank": "最近一次起草时模型拿不准、留空的列:{cols}(共 {n} 列)",
|
||||
"unnamed": "目录里还没有中文名和含义的列:{cols}(共 {n} 列{more})",
|
||||
"unnamed_more": ",这里列出前 {n} 个",
|
||||
"pending": "界面上还没保存的修改(使用者已经接受、还没保存):",
|
||||
"pending_more": "(另有 {n} 项更早的待保存修改没有列出:太多了。它们同样已经接受,不要重复提)",
|
||||
"pending_item": "- {path}:{value}(依据:{reason})",
|
||||
"status_proposed": "推断",
|
||||
"status_verified": "有确证",
|
||||
"status_confirmed": "已确认",
|
||||
"status_rejected": "已驳回",
|
||||
"field_relation": "关联关系",
|
||||
}
|
||||
|
||||
#: 工具结果的写法(交回模型)
|
||||
ASSISTANT_TOOL_PROMPT = {
|
||||
"proposed": "已把 {n} 项修改交给使用者确认(使用者保存后才写进数据目录):{paths}",
|
||||
"nothing": "没有可以交给使用者的修改。",
|
||||
"dropped": "没有收下的项:{reasons}",
|
||||
"masked_codes": "列 {column} 在数据源设置中被遮罩,不能给它写码值",
|
||||
"same": "{where}已经是这个值、也确认过了,不用再提",
|
||||
"no_changes": "提案里没有要修改的项",
|
||||
"query_failed": "查询失败:{error}",
|
||||
"query_capped": "未执行:这一轮最多查 {n} 条,不能再查了。请根据已经查到的结果回答",
|
||||
"query_disabled": "未执行:这个数据源没开数据剖析,不能查数据",
|
||||
"unknown_tool": "没有名为 {name} 的工具,可用的工具:{tools}",
|
||||
"last_round": "(这一轮只剩最后一步了:请直接回答使用者,不要再调用工具。)",
|
||||
"empty_sql": "未执行:没有给出 SQL",
|
||||
}
|
||||
|
||||
QUERY_TOOL_PROMPT: dict[str, Any] = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": QUERY_TOOL,
|
||||
"description": "在这个数据源上执行一条只读 SQL 查询,返回列名和前几行(遮罩列的值显示为「已遮罩」)。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"sql": {"type": "string", "description": "一条只读的 SELECT 语句"}},
|
||||
"required": ["sql"],
|
||||
},
|
||||
},
|
||||
}
|
||||
PROPOSE_TOOL_PROMPT: dict[str, Any] = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": PROPOSE_TOOL,
|
||||
"description": "把使用者说出的业务事实整理成一批数据目录修改提案,交给使用者确认后保存。不会直接写进数据目录。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"changes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "要改的项,写法见系统提示"},
|
||||
"value": {"description": "新的值:文字、列名列表、码值对照或关联关系"},
|
||||
"reason": {"type": "string",
|
||||
"description": "一句话依据:引用这次对话里使用者的原话(「使用者说:……」);"
|
||||
"来自数据源起草要求的写「起草要求写着:……」,来自以前确认时的依据"
|
||||
"的写「以前确认时的依据写着:……」"},
|
||||
},
|
||||
"required": ["path", "value", "reason"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["changes"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 模型
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
async def resolve_model(session: AsyncSession) -> tuple[Any, str]:
|
||||
"""(模型, 模型 id)。能不能用、用不了时的说法和起草完全一致:先过 catalog.resolve_draft_model(起草任务建任务时也是这样
|
||||
查的),用不了抛 catalog.CatalogModelUnavailable。再按同一个接入、模型建流式对话用的模型:保留思考、温度 0,大小两份额度
|
||||
(BudgetFallback),单次时限同起草。只建模型对象,不发请求。"""
|
||||
from app.data.draft_model import DraftModelUnavailable, draft_unavailable, resolve_draft_provider
|
||||
from app.providers.budget import BudgetFallback
|
||||
from app.providers.factory import ModelSpec, ProviderNotConfigured, build_chat_model
|
||||
|
||||
await catalog.resolve_draft_model(session)
|
||||
try:
|
||||
provider, choice = await resolve_draft_provider(session, none_reason=ERROR_NO_MODEL, mock_reason=ERROR_NO_MODEL)
|
||||
except DraftModelUnavailable as e:
|
||||
raise catalog.CatalogModelUnavailable(str(e)) from e
|
||||
# SDK 自己不重试(同起草):网络错误、429、5xx 由这里在还没输出时重试,两层叠起来实际次数就对不上了
|
||||
spec = ModelSpec(provider=choice.provider, model=choice.model, max_tokens=catalog.MODEL_MAX_TOKENS, temperature=0,
|
||||
timeout=int(catalog.MODEL_TIMEOUT_S), max_retries=0)
|
||||
try:
|
||||
big = build_chat_model(provider, spec)
|
||||
small = build_chat_model(provider, spec.model_copy(update={"max_tokens": catalog.MODEL_FALLBACK_MAX_TOKENS}))
|
||||
except ProviderNotConfigured as e:
|
||||
raise catalog.CatalogModelUnavailable(draft_unavailable(e, choice.origin)) from e
|
||||
model_id = spec.model or provider.default_model or ""
|
||||
return BudgetFallback(big, small, limits=(catalog.MODEL_MAX_TOKENS, catalog.MODEL_FALLBACK_MAX_TOKENS)), model_id
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 对话
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
def conversation_problem(messages: Iterable[Mapping[str, Any]]) -> str | None:
|
||||
"""对话本身的问题(接口 422):使用者说过话、最后一条却是助手说的。没有使用者的话是「刚打开表助手」,不算问题。"""
|
||||
said = [m for m in messages if str(m.get("content") or "").strip()]
|
||||
if any(m.get("role") == "user" for m in said) and said[-1].get("role") != "user":
|
||||
return LAST_NOT_USER
|
||||
return None
|
||||
|
||||
|
||||
def _history(messages: Iterable[Mapping[str, Any]]) -> list[Any]:
|
||||
"""对话 → 模型的消息:空的不要,同一方接连说的并成一条;从助手的开场白开始的,前面补一句使用者打开了表助手;使用者还没
|
||||
说话的,补一句开场的请求。"""
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
merged: list[tuple[str, str]] = []
|
||||
for m in messages:
|
||||
text = str(m.get("content") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
role = "user" if m.get("role") == "user" else "assistant"
|
||||
if merged and merged[-1][0] == role:
|
||||
merged[-1] = (role, f"{merged[-1][1]}\n\n{text}")
|
||||
else:
|
||||
merged.append((role, text))
|
||||
out: list[Any] = []
|
||||
if merged and merged[0][0] == "assistant":
|
||||
out.append(HumanMessage(content=ASSISTANT_OPENED_PROMPT))
|
||||
for role, text in merged:
|
||||
out.append(HumanMessage(content=text) if role == "user" else AIMessage(content=text))
|
||||
if not any(role == "user" for role, _ in merged):
|
||||
out.append(HumanMessage(content=ASSISTANT_OPENING_PROMPT))
|
||||
return out
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 背景
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
def _value_text(value: Any) -> str:
|
||||
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def _status_text(status: Any) -> str:
|
||||
return ASSISTANT_PROMPT_LABELS.get(f"status_{status}", str(status))
|
||||
|
||||
|
||||
def _source_text(source: Any) -> str:
|
||||
return SOURCE_LABEL.get(str(source), str(source))
|
||||
|
||||
|
||||
def _relation_line(rel: Mapping[str, Any]) -> str:
|
||||
labels = ASSISTANT_PROMPT_LABELS
|
||||
extra = []
|
||||
if rel.get("cardinality"):
|
||||
extra.append(labels["cardinality"].format(value=rel["cardinality"]))
|
||||
coverage = rel.get("coverage")
|
||||
if isinstance(coverage, (int, float)) and not isinstance(coverage, bool):
|
||||
extra.append(labels["coverage"].format(value=f"{coverage:.0%}"))
|
||||
return labels["relation"].format(
|
||||
columns=", ".join(str(c) for c in rel.get("columns") or []), to_table=rel.get("to_table"),
|
||||
to_columns=", ".join(str(c) for c in rel.get("to_columns") or []),
|
||||
extra=labels["relation_extra"].format(text=",".join(extra)) if extra else "")
|
||||
|
||||
|
||||
def _touches_masked(key: catalog.Slot, item: Mapping[str, Any], masked: set[str]) -> bool:
|
||||
"""这一项说的是不是遮罩列:列级的项看列名,业务主键、业务日期看指着的列,关联关系看本表这一端。"""
|
||||
if key[0] == "c":
|
||||
return key[1].lower() in masked
|
||||
if key[0] == "r":
|
||||
return any(str(c).lower() in masked for c in item.get("columns") or [])
|
||||
value = item.get("value")
|
||||
if key == ("t", "business_date") and isinstance(value, Mapping):
|
||||
return str(value.get("column") or "").lower() in masked
|
||||
if key == ("t", "keys") and isinstance(value, list):
|
||||
return any(str(c).lower() in masked for c in value)
|
||||
return False
|
||||
|
||||
|
||||
def _catalog_lines(notes: Mapping[str, Any] | None, masked: set[str]) -> list[str]:
|
||||
"""这张表的目录,每项一行:路径、字段、值、来源和状态,有备注、有确认时的依据的各另起一行。依据是以前保存时记下的,
|
||||
写成「以前确认时的依据:……」,不让模型当成这次对话里使用者说的话。遮罩列上的码值不给,说到遮罩列的项不给备注和
|
||||
依据(剖析的备注里有取值范围,使用者的原话里也可能有)。"""
|
||||
labels = ASSISTANT_PROMPT_LABELS
|
||||
lines: list[str] = []
|
||||
# 表级的项、关联关系在前,列的项在后(稳定排序,各自照目录里的顺序);遮罩列上的码值不算
|
||||
slots = [(key, item) for key, item in sorted(catalog._slots(notes).items(), key=lambda kv: kv[0][0] == "c")
|
||||
if not (key[0] == "c" and key[2] == "codes" and key[1].lower() in masked)]
|
||||
for shown, (key, item) in enumerate(slots):
|
||||
if shown >= CATALOG_ITEMS_SHOWN:
|
||||
lines.append(labels["catalog_more"].format(n=len(slots) - shown))
|
||||
break
|
||||
hidden = _touches_masked(key, item, masked)
|
||||
if key[0] == "r":
|
||||
label, value = labels["field_relation"], _relation_line(item)
|
||||
else:
|
||||
label = (catalog.TABLE_FIELD_LABEL if key[0] == "t" else catalog.COLUMN_FIELD_LABEL).get(key[-1], key[-1])
|
||||
value = _value_text(item.get("value"))
|
||||
lines.append(labels["item"].format(path=slot_path(key), label=label, value=value,
|
||||
source=_source_text(item.get("source")),
|
||||
status=_status_text(item.get("status"))))
|
||||
note = item.get("note")
|
||||
if isinstance(note, str) and note.strip() and not hidden:
|
||||
lines.append(labels["item_note"].format(text=note.strip()))
|
||||
reason = item.get("reason")
|
||||
if isinstance(reason, str) and reason.strip() and not hidden:
|
||||
lines.append(labels["item_reason"].format(text=reason.strip()))
|
||||
return lines
|
||||
|
||||
|
||||
def _structure_lines(meta: Mapping[str, Any], masked: set[str], *, system: bool) -> list[str]:
|
||||
labels = ASSISTANT_PROMPT_LABELS
|
||||
pk = {str(c) for c in meta.get("primary_key") or []}
|
||||
lines = [labels["structure"]]
|
||||
for col in meta.get("columns") or []:
|
||||
if not isinstance(col, Mapping) or col.get("name") is None:
|
||||
continue
|
||||
name = str(col["name"])
|
||||
marks: list[str] = []
|
||||
if name.lower() in masked:
|
||||
marks.append(labels["masked"])
|
||||
else:
|
||||
if name in pk:
|
||||
marks.append(labels["pk"])
|
||||
if not col.get("nullable", True):
|
||||
marks.append(labels["not_null"])
|
||||
text = str(col.get("comment") or "").strip()
|
||||
if text:
|
||||
marks.append((labels["system"] if system else labels["col_comment"]).format(text=text))
|
||||
lines.append(labels["column"].format(name=name, type=col.get("type") or "",
|
||||
marks=("," + ",".join(marks)) if marks else ""))
|
||||
for group in meta.get("unique") or []:
|
||||
if isinstance(group, list) and group:
|
||||
lines.append(labels["unique"].format(cols="、".join(str(c) for c in group)))
|
||||
for fk in meta.get("foreign_keys") or []:
|
||||
if isinstance(fk, Mapping) and fk.get("columns") and fk.get("to_table"):
|
||||
target = ", ".join(f"{fk['to_table']}.{c}" for c in fk.get("to_columns") or []) or str(fk["to_table"])
|
||||
lines.append(labels["fk"].format(cols=", ".join(str(c) for c in fk["columns"]), target=target))
|
||||
return lines
|
||||
|
||||
|
||||
async def _last_blank(session: AsyncSession, source_id: str, table: str) -> tuple[int, list[str]]:
|
||||
"""最近一次结束于 ok / partial 的起草里这张表模型留空的列:(几列, 最多 5 个列名)。停止、中断的那次不算(那次没起草完,
|
||||
留空数是 0)。没有是 (0, [])。"""
|
||||
from app.db.models import CatalogDraftJob, CatalogDraftTask
|
||||
|
||||
row = (await session.execute(
|
||||
select(CatalogDraftTask.blank_columns, CatalogDraftTask.blank_sample)
|
||||
.join(CatalogDraftJob, CatalogDraftJob.id == CatalogDraftTask.job_id)
|
||||
.where(CatalogDraftJob.source_id == source_id, CatalogDraftTask.table_name == table,
|
||||
CatalogDraftTask.status.in_(("ok", "partial")))
|
||||
.order_by(CatalogDraftJob.created_at.desc(), CatalogDraftTask.seq).limit(1)
|
||||
)).first()
|
||||
if row is None:
|
||||
return 0, []
|
||||
return int(row[0] or 0), [str(c) for c in row[1] or []]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssistantContext:
|
||||
"""一次请求要用到的东西,开流之前读好(流里不再用请求的会话)。"""
|
||||
|
||||
source_id: str
|
||||
source_name: str
|
||||
kind: str
|
||||
table: str
|
||||
tables: dict[str, Any]
|
||||
#: 读表结构、发查询用的源(上传源是绑定当前快照的视图)
|
||||
query_source: Any
|
||||
options: dict[str, Any]
|
||||
masked: set[str]
|
||||
can_query: bool
|
||||
settings: catalog_profile.ProfileSettings
|
||||
system: str
|
||||
pending_codes: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
def tools(self) -> list[dict[str, Any]]:
|
||||
return [QUERY_TOOL_PROMPT, PROPOSE_TOOL_PROMPT] if self.can_query else [PROPOSE_TOOL_PROMPT]
|
||||
|
||||
|
||||
async def prepare(session: AsyncSession, row: Any, source: Any, table: str,
|
||||
pending: Iterable[Mapping[str, Any]] = ()) -> AssistantContext:
|
||||
"""读好这次请求要用的背景:表结构、目录、别的表指过来的关联关系、使用次数、起草要求、留空的列、待保存的修改,拼成系统
|
||||
提示。row 是数据源记录(遮罩、剖析开关、起草要求按它),source 是读表结构和发查询用的源。table 是表结构里的键。"""
|
||||
labels = ASSISTANT_PROMPT_LABELS
|
||||
cache = getattr(source, "schema_cache", None) or {}
|
||||
tables = cache.get("tables") or {}
|
||||
meta = tables.get(table) or {}
|
||||
options = dict(getattr(row, "options", None) or {})
|
||||
masked = {c.lower() for c in masked_columns(options)}
|
||||
settings = catalog_profile.profile_settings(options)
|
||||
enabled = getattr(row, "enabled", True) is not False
|
||||
can_query = settings.enabled and enabled
|
||||
system_notes = catalog.system_notes_source(source)
|
||||
entries = await catalog.read_catalog(session, source.id)
|
||||
entry = entries.get(table)
|
||||
notes = entry.notes if entry else {}
|
||||
usage = await table_usage_counts(session, source)
|
||||
used, runs = usage.queries.get(table, 0), usage.runs.get(table, 0)
|
||||
blank_n, blank_cols = await _last_blank(session, source.id, table)
|
||||
|
||||
lines = [labels["head"], labels["table"].format(source=row.name, kind=row.kind, table=table,
|
||||
view=labels["view"] if meta.get("is_view") else "")]
|
||||
comment = str(meta.get("comment") or "").strip()
|
||||
if comment:
|
||||
lines.append((labels["system"] if system_notes else labels["comment"]).format(text=comment))
|
||||
lines.append(labels["usage"].format(runs=runs, n=used) if used else labels["usage_none"])
|
||||
description = str(getattr(row, "description", "") or "").strip()
|
||||
if description:
|
||||
lines.append(labels["description"].format(text=description))
|
||||
if guidance := catalog_jobs.guidance_of(row):
|
||||
lines.append(labels["guidance"].format(text=guidance))
|
||||
lines += ["", *_structure_lines(meta, masked, system=system_notes), ""]
|
||||
current = _catalog_lines(notes, masked)
|
||||
lines += [labels["catalog"], *current] if current else [labels["catalog_empty"]]
|
||||
incoming = []
|
||||
for other, other_entry in sorted(entries.items()):
|
||||
if other == table:
|
||||
continue
|
||||
for key, rel in catalog._slots(other_entry.notes).items():
|
||||
if key[0] == "r" and rel.get("to_table") == table:
|
||||
incoming.append(labels["incoming_item"].format(
|
||||
table=other, columns=", ".join(str(c) for c in rel.get("columns") or []), target=table,
|
||||
to_columns=", ".join(str(c) for c in rel.get("to_columns") or []),
|
||||
source=_source_text(rel.get("source")), status=_status_text(rel.get("status"))))
|
||||
if incoming:
|
||||
lines += ["", labels["incoming"], *incoming[:INCOMING_SHOWN]]
|
||||
named = catalog.visible_notes(notes).get("columns") or {}
|
||||
columns = [str(c["name"]) for c in meta.get("columns") or [] if isinstance(c, Mapping) and c.get("name") is not None]
|
||||
unnamed = [c for c in columns if not any(f in (named.get(c) or {}) for f in ("label", "meaning"))]
|
||||
blank = [c for c in blank_cols if c in unnamed]
|
||||
if blank_n and blank:
|
||||
lines += ["", labels["blank"].format(cols="、".join(blank), n=blank_n)]
|
||||
if unnamed:
|
||||
more = labels["unnamed_more"].format(n=UNNAMED_SHOWN) if len(unnamed) > UNNAMED_SHOWN else ""
|
||||
lines.append(labels["unnamed"].format(cols="、".join(unnamed[:UNNAMED_SHOWN]), n=len(unnamed), more=more))
|
||||
pending_codes: dict[str, dict[str, Any]] = {}
|
||||
shown_pending: list[str] = []
|
||||
for change in pending:
|
||||
path = str(change.get("path") or "").strip()
|
||||
value = change.get("value")
|
||||
try:
|
||||
key = catalog.parse_path(path)
|
||||
except catalog.CatalogPathError:
|
||||
continue
|
||||
if key[0] == "c" and key[2] == "codes":
|
||||
if key[1].lower() in masked:
|
||||
continue
|
||||
if isinstance(value, Mapping):
|
||||
pending_codes[path] = dict(value)
|
||||
reason = str(change.get("reason") or "").strip()
|
||||
shown_pending.append(labels["pending_item"].format(path=path, value=_value_text(value), reason=reason or "—"))
|
||||
if shown_pending:
|
||||
lines += ["", labels["pending"]]
|
||||
if len(shown_pending) > PENDING_SHOWN:
|
||||
lines.append(labels["pending_more"].format(n=len(shown_pending) - PENDING_SHOWN))
|
||||
lines += shown_pending[-PENDING_SHOWN:]
|
||||
if can_query:
|
||||
tools = ASSISTANT_QUERY_PROMPT.format(dialect=row.kind, rows=QUERY_ROWS, limit=MAX_QUERIES)
|
||||
else:
|
||||
tools = ASSISTANT_NO_QUERY_PROMPT if enabled or not settings.enabled else ASSISTANT_DISABLED_PROMPT
|
||||
system = "\n\n".join([ASSISTANT_SYSTEM, tools, "\n".join(lines)])
|
||||
return AssistantContext(source_id=source.id, source_name=row.name, kind=str(row.kind or ""), table=table,
|
||||
tables=tables, query_source=source, options=options, masked=masked, can_query=can_query,
|
||||
settings=settings, system=system, pending_codes=pending_codes)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 工具
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
def _cell(value: Any, limit: int | None = None) -> Any:
|
||||
"""一格交出去的样子:数、布尔、空照原样,别的写成文字(日期、Decimal……),长的截断。"""
|
||||
if value is None or isinstance(value, (bool, int, float)):
|
||||
return value
|
||||
if isinstance(value, (datetime, date)):
|
||||
text = value.isoformat()
|
||||
elif isinstance(value, Decimal):
|
||||
text = str(value)
|
||||
elif isinstance(value, (bytes, bytearray)):
|
||||
text = f"<{len(value)} 字节>"
|
||||
else:
|
||||
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
|
||||
if limit is not None and len(text) > limit:
|
||||
return text[:limit] + "…"
|
||||
return text
|
||||
|
||||
|
||||
def _rows(rows: Any, n: int, limit: int | None) -> list[list[Any]]:
|
||||
out: list[list[Any]] = []
|
||||
for row in (rows or [])[:n]:
|
||||
cells = row if isinstance(row, list) else list(row.values()) if isinstance(row, dict) else [row]
|
||||
out.append([_cell(v, limit) for v in cells])
|
||||
return out
|
||||
|
||||
|
||||
class _Tools:
|
||||
"""一次请求里的工具:查询次数、这一次遮掉的值(后面的查询按值换)。"""
|
||||
|
||||
def __init__(self, ctx: AssistantContext) -> None:
|
||||
from app.engine.masking import SeenValues
|
||||
|
||||
self.ctx = ctx
|
||||
self.queries = 0
|
||||
self.seen = SeenValues()
|
||||
|
||||
# ---------------------------------------------------------------- 查询
|
||||
|
||||
async def query(self, sql: Any) -> tuple[dict[str, Any] | None, str]:
|
||||
"""(给界面的 query 事件, 交回模型的结果)。"""
|
||||
prompts = ASSISTANT_TOOL_PROMPT
|
||||
if not self.ctx.can_query:
|
||||
return None, prompts["query_disabled"]
|
||||
if self.queries >= MAX_QUERIES:
|
||||
return None, prompts["query_capped"].format(n=MAX_QUERIES)
|
||||
sql = sql.strip() if isinstance(sql, str) else ""
|
||||
if not sql:
|
||||
return None, prompts["empty_sql"]
|
||||
self.queries += 1
|
||||
mask = masked_columns(self.ctx.options)
|
||||
known = await asyncio.to_thread(self.seen.pooled)
|
||||
known = known if known.strong else None
|
||||
error: str | None = None
|
||||
result: Any = None
|
||||
settings = dataclasses.replace(self.ctx.settings, max_queries=1)
|
||||
try:
|
||||
runner = catalog_profile._Runner(self.ctx.query_source, settings,
|
||||
catalog_profile.SqlDialect(self.ctx.kind))
|
||||
result = await runner(sql, max_rows=QUERY_ROWS)
|
||||
except catalog_profile._Skip as e:
|
||||
error = e.detail
|
||||
except catalog_profile._Stop as e:
|
||||
error = str(e)
|
||||
except SnapshotTampered as e:
|
||||
error = QUERY_TAMPERED.format(name=self.ctx.source_name, error=e)
|
||||
except ValueError as e: # 不支持的数据库类型
|
||||
error = str(e)
|
||||
if error is not None:
|
||||
if mask or known is not None:
|
||||
from app.engine.masking import mask_error_text
|
||||
|
||||
error = mask_error_text(error, sql, known)
|
||||
event = {"type": "query", "sql": sql, "columns": [], "rows": [], "row_count": 0, "truncated": False,
|
||||
"error": error}
|
||||
return event, prompts["query_failed"].format(error=error)
|
||||
payload = result.to_payload()
|
||||
if mask or known is not None:
|
||||
from app.engine.masking import mask_query_result
|
||||
|
||||
schema = getattr(self.ctx.query_source, "schema_cache", None)
|
||||
done = await asyncio.to_thread(mask_query_result, payload, mask, self.ctx.kind, known, schema=schema)
|
||||
payload = done.payload
|
||||
await asyncio.to_thread(self.seen.add, done.secrets)
|
||||
columns = [str(c) for c in payload.get("columns") or []]
|
||||
rows = payload.get("rows") or []
|
||||
event = {"type": "query", "sql": str(payload.get("sql") or sql), "columns": columns,
|
||||
"rows": _rows(rows, SHOWN_ROWS, None), "row_count": int(payload.get("row_count") or len(rows)),
|
||||
"truncated": bool(payload.get("truncated")), "error": None}
|
||||
given = {"columns": columns, "rows": _rows(rows, QUERY_ROWS, CELL_MAX), "row_count": event["row_count"],
|
||||
"truncated": event["truncated"]}
|
||||
text = json.dumps(given, ensure_ascii=False, default=str)
|
||||
while len(text) > TOOL_CHARS and given["rows"]:
|
||||
given["rows"] = given["rows"][:max(1, len(given["rows"]) // 2)] if len(given["rows"]) > 1 else []
|
||||
given["rows_shown"] = len(given["rows"])
|
||||
text = json.dumps(given, ensure_ascii=False, default=str)
|
||||
return event, text
|
||||
|
||||
# ---------------------------------------------------------------- 提案
|
||||
|
||||
def _with_pending(self, change: Any) -> Any:
|
||||
"""界面上还没保存的修改里同一列的码值,并进新提的码值(新提的同一个码为准)。"""
|
||||
if not isinstance(change, Mapping):
|
||||
return change
|
||||
path = str(change.get("path") or "").strip()
|
||||
before = self.ctx.pending_codes.get(path)
|
||||
value = change.get("value")
|
||||
if before is None or not isinstance(value, Mapping):
|
||||
return change
|
||||
return {**change, "value": {**before, **value}}
|
||||
|
||||
def filter_problem(self, condition: Any) -> str | None:
|
||||
"""模型提的有效记录条件哪里不合规(交回模型的一句话),合规是 None。
|
||||
|
||||
按模型起草时的规矩核对(sqlcheck.valid_filter_problem:一个条件、只用这张表的列、不能有子查询、聚合、窗口、分号,
|
||||
只能用比较、IN、BETWEEN、LIKE、IS NULL 和几个函数);sqlcheck 里还没有这条规矩时,至少核对用到的列都是这张表的、
|
||||
都在表结构里、没有子查询(catalog_transfer.filter_columns_problem,导入时核对人工写的条件也用它)。"""
|
||||
meta = self.ctx.tables.get(self.ctx.table) or {}
|
||||
strict = getattr(sqlcheck, "valid_filter_problem", None)
|
||||
if strict is not None:
|
||||
return strict(condition, meta, kind=self.ctx.kind, table=self.ctx.table)
|
||||
return filter_columns_problem(condition, meta, kind=self.ctx.kind, table=self.ctx.table)
|
||||
|
||||
async def propose(self, changes: Any) -> tuple[dict[str, Any] | None, str]:
|
||||
"""(给界面的 proposal 事件, 交回模型的结果)。提案不写目录。"""
|
||||
from app.db.base import SessionLocal
|
||||
|
||||
prompts = ASSISTANT_TOOL_PROMPT
|
||||
if not isinstance(changes, list) or not changes:
|
||||
return None, prompts["no_changes"]
|
||||
changes = [self._with_pending(c) for c in changes]
|
||||
async with SessionLocal() as session:
|
||||
entry = await catalog.read_entry(session, self.ctx.source_id, self.ctx.table)
|
||||
notes = entry.notes if entry else {}
|
||||
plan = catalog.plan_patch(notes, changes, table=self.ctx.table, tables=self.ctx.tables)
|
||||
dropped = list(plan.problems)
|
||||
kept: list[catalog.PatchChange] = []
|
||||
for change in plan.changes:
|
||||
key = catalog.parse_path(change.path)
|
||||
if key[0] == "c" and key[2] == "codes" and key[1].lower() in self.ctx.masked:
|
||||
dropped.append(prompts["masked_codes"].format(column=key[1]))
|
||||
continue
|
||||
if change.state == "same":
|
||||
dropped.append(prompts["same"].format(where=_where(key, change)))
|
||||
continue
|
||||
if key == ("t", "valid_filter") and (problem := self.filter_problem(change.after)) is not None:
|
||||
dropped.append(problem)
|
||||
continue
|
||||
kept.append(change)
|
||||
said: list[str] = []
|
||||
event: dict[str, Any] | None = None
|
||||
if kept:
|
||||
label = notes.get("label") if isinstance(notes.get("label"), Mapping) else {}
|
||||
event = {
|
||||
"type": "proposal", "source": self.ctx.source_name, "source_id": self.ctx.source_id,
|
||||
"table": self.ctx.table,
|
||||
"table_label": label.get("value") if label and label.get("status") != "rejected" else None,
|
||||
"version": entry.version if entry else 0,
|
||||
"changes": [{**c.as_dict(), "label": _where(catalog.parse_path(c.path), c)} for c in kept],
|
||||
}
|
||||
said.append(prompts["proposed"].format(n=len(kept), paths="、".join(c.path for c in kept)))
|
||||
else:
|
||||
said.append(prompts["nothing"])
|
||||
if dropped:
|
||||
said.append(prompts["dropped"].format(reasons=";".join(dropped)))
|
||||
return event, "\n".join(said)
|
||||
|
||||
|
||||
def _where(key: catalog.Slot, change: catalog.PatchChange) -> str:
|
||||
"""提案里一项的中文说法:「列 status 的码值」「表的有效记录条件」「指向 members 的关联关系」(同审阅队列的 field_label)。"""
|
||||
value = change.after if isinstance(change.after, Mapping) else change.before
|
||||
return item_label(key, value if isinstance(value, Mapping) else None)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 流
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
#: 上一轮的正文以这些结尾(说完了一句话)时,下一轮另起一段
|
||||
_SENTENCE_END = ("。", "!", "?", ";", ":", ".", "!", "?", ";", ":")
|
||||
|
||||
|
||||
def _sse(obj: Mapping[str, Any]) -> str:
|
||||
return f"data: {json.dumps(obj, ensure_ascii=False, default=str)}\n\n"
|
||||
|
||||
|
||||
class _CallFailed(Exception):
|
||||
"""一次模型调用没成:code 是错误事件的机读码,message 给人看。"""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
def _seconds(value: Any) -> str:
|
||||
number = float(value)
|
||||
return str(int(number)) if number.is_integer() else f"{number:g}"
|
||||
|
||||
|
||||
def _transient(e: BaseException) -> bool:
|
||||
from app.core.errors import NETWORK, RATE_LIMIT, SERVER, classify
|
||||
|
||||
return classify(e) in (RATE_LIMIT, SERVER, NETWORK)
|
||||
|
||||
|
||||
async def _pump(runner: Any, history: list[Any], queue: asyncio.Queue[tuple[str, Any]]) -> None:
|
||||
"""在单独的任务里读模型的流,一块一块放进队列;读完放 end,出错放 error。"""
|
||||
try:
|
||||
async for chunk in runner.astream(history):
|
||||
await queue.put(("chunk", chunk))
|
||||
await queue.put(("end", None))
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001 - 交给读队列的一方按原因处理
|
||||
await queue.put(("error", e))
|
||||
|
||||
|
||||
async def _round(runner: Any, history: list[Any], model_id: str) -> AsyncIterator[tuple[str, Any]]:
|
||||
"""调一次模型:交出 ("text", 增量)、("beat", None)(等得久了,发心跳)、最后 ("message", (合起来的消息, 截断了没有))。
|
||||
网络错误、429、5xx 在还没输出任何正文时重试;超时、别的错误抛 _CallFailed。"""
|
||||
from app.core.errors import TIMEOUT, classify, describe_exception
|
||||
from app.engine.state import message_text
|
||||
from app.providers.budget import cut_off
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
attempt = 0
|
||||
while True:
|
||||
queue: asyncio.Queue[tuple[str, Any]] = asyncio.Queue()
|
||||
task = asyncio.ensure_future(_pump(runner, history, queue))
|
||||
deadline = loop.time() + float(catalog.MODEL_TIMEOUT_S)
|
||||
message: Any = None
|
||||
cut = False
|
||||
started = False
|
||||
failure: BaseException | None = None
|
||||
try:
|
||||
while True:
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
raise _CallFailed(CODE_TIMEOUT, ERROR_TIMEOUT.format(seconds=_seconds(catalog.MODEL_TIMEOUT_S),
|
||||
place=DRAFT_MODEL_PLACE))
|
||||
try:
|
||||
kind, value = await asyncio.wait_for(queue.get(), timeout=min(remaining, HEARTBEAT_S))
|
||||
except asyncio.TimeoutError:
|
||||
yield "beat", None
|
||||
continue
|
||||
if kind == "end":
|
||||
break
|
||||
if kind == "error":
|
||||
failure = value
|
||||
break
|
||||
message = value if message is None else message + value
|
||||
cut = cut or bool(cut_off(value))
|
||||
text = message_text(value)
|
||||
if text:
|
||||
started = True
|
||||
yield "text", text
|
||||
finally:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await task
|
||||
if failure is None:
|
||||
yield "message", (message, cut)
|
||||
return
|
||||
if not started and attempt < len(RETRY_DELAYS_S) and _transient(failure):
|
||||
logger.info("表助手:调用模型出错(%s),%s 秒后重试", type(failure).__name__, RETRY_DELAYS_S[attempt])
|
||||
await asyncio.sleep(RETRY_DELAYS_S[attempt])
|
||||
attempt += 1
|
||||
continue
|
||||
if isinstance(failure, TimeoutError) or classify(failure) == TIMEOUT:
|
||||
raise _CallFailed(CODE_TIMEOUT, ERROR_TIMEOUT.format(seconds=_seconds(catalog.MODEL_TIMEOUT_S),
|
||||
place=DRAFT_MODEL_PLACE)) from failure
|
||||
raise _CallFailed(CODE_MODEL, ERROR_MODEL.format(model=model_id or "", reason=describe_exception(failure),
|
||||
place=DRAFT_MODEL_PLACE)) from failure
|
||||
|
||||
|
||||
async def stream(model: Any, model_id: str, ctx: AssistantContext,
|
||||
messages: Iterable[Mapping[str, Any]]) -> AsyncIterator[str]:
|
||||
"""表助手的一次请求:交出 SSE 的每一行(事件、心跳注释)。"""
|
||||
from langchain_core.messages import SystemMessage, ToolMessage, message_chunk_to_message
|
||||
|
||||
from app.providers.budget import cut_words
|
||||
|
||||
history: list[Any] = [SystemMessage(content=ctx.system), *_history(messages)]
|
||||
tools = _Tools(ctx)
|
||||
usage = {"input_tokens": 0, "output_tokens": 0}
|
||||
wrote = ""
|
||||
names = [t["function"]["name"] for t in ctx.tools()]
|
||||
try:
|
||||
# 绑工具也放在这里面:响应头已经发出去了,出了错要照实交一条错误事件,不能让流半路断掉
|
||||
runner = model.bind_tools(ctx.tools())
|
||||
for round_no in range(MAX_ROUNDS):
|
||||
fresh = True
|
||||
message: Any = None
|
||||
cut = False
|
||||
# 流在半路被关掉(使用者点了停止、断开)时,正在读的那次调用跟着停:aclosing 让 _round 的收尾马上执行
|
||||
async with contextlib.aclosing(_round(runner, history, model_id)) as rounds:
|
||||
async for kind, value in rounds:
|
||||
if kind == "beat":
|
||||
yield ": keep-alive\n\n"
|
||||
elif kind == "text":
|
||||
if fresh and wrote.endswith(_SENTENCE_END):
|
||||
# 上一轮(查询、提案之前)说完了一句话:这一轮另起一段
|
||||
yield _sse({"type": "text", "delta": "\n\n"})
|
||||
fresh = False
|
||||
wrote = value
|
||||
yield _sse({"type": "text", "delta": value})
|
||||
else:
|
||||
message, cut = value
|
||||
meta = getattr(message, "usage_metadata", None) or {}
|
||||
usage["input_tokens"] += int(meta.get("input_tokens") or 0)
|
||||
usage["output_tokens"] += int(meta.get("output_tokens") or 0)
|
||||
if cut:
|
||||
limit = getattr(model, "max_tokens", None)
|
||||
yield _sse({"type": "error", "code": CODE_TRUNCATED,
|
||||
"message": ERROR_TRUNCATED.format(cut=cut_words("表助手", limit), place=DRAFT_MODEL_PLACE)})
|
||||
return
|
||||
calls = list(getattr(message, "tool_calls", None) or []) if message is not None else []
|
||||
if not calls:
|
||||
yield _sse({"type": "done", "usage": usage, "queries": tools.queries})
|
||||
return
|
||||
if round_no == MAX_ROUNDS - 1:
|
||||
yield _sse({"type": "error", "code": CODE_ROUNDS, "message": ERROR_ROUNDS.format(n=MAX_ROUNDS)})
|
||||
return
|
||||
# 合起来的流式块换成普通消息再交回模型(不带半截的工具调用块)
|
||||
history.append(message_chunk_to_message(message))
|
||||
for call in calls:
|
||||
name = str(call.get("name") or "")
|
||||
args = call.get("args") if isinstance(call.get("args"), Mapping) else {}
|
||||
call_id = str(call.get("id") or f"{name}-{round_no}-{len(history)}")
|
||||
event: dict[str, Any] | None = None
|
||||
if name == QUERY_TOOL:
|
||||
event, content = await tools.query(args.get("sql"))
|
||||
elif name == PROPOSE_TOOL:
|
||||
event, content = await tools.propose(args.get("changes"))
|
||||
else:
|
||||
content = ASSISTANT_TOOL_PROMPT["unknown_tool"].format(name=name, tools="、".join(names))
|
||||
if event is not None:
|
||||
yield _sse(event)
|
||||
if round_no == MAX_ROUNDS - 2:
|
||||
content = f"{content}\n{ASSISTANT_TOOL_PROMPT['last_round']}"
|
||||
history.append(ToolMessage(content=content, tool_call_id=call_id))
|
||||
except _CallFailed as e:
|
||||
yield _sse({"type": "error", "code": e.code, "message": e.message})
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001 - 流已经开了,照实交一条错误事件
|
||||
from app.core.errors import describe_exception, first_line
|
||||
|
||||
logger.exception("表助手出错:数据源 %s 表 %s", ctx.source_id, ctx.table)
|
||||
yield _sse({"type": "error", "code": CODE_FAILED,
|
||||
"message": ERROR_FAILED.format(line=first_line(e) or describe_exception(e))})
|
||||
|
||||
|
||||
__all__ = ["AssistantContext", "CODE_FAILED", "CODE_MODEL", "CODE_ROUNDS", "CODE_TIMEOUT", "CODE_TRUNCATED",
|
||||
"HEARTBEAT_S", "MAX_MESSAGES", "MAX_QUERIES", "MAX_ROUNDS", "MESSAGE_MAX", "PENDING_SHOWN", "PROPOSE_TOOL",
|
||||
"QUERY_ROWS", "QUERY_TOOL", "SHOWN_ROWS", "conversation_problem", "prepare", "resolve_model", "stream"]
|
||||
+432
-141
@@ -42,19 +42,25 @@
|
||||
地方写成了数组、对象的值在校验(catalog._from_model)里丢掉,不算出错。
|
||||
- 封顶:每个原始段(计划里的整表段、表级那段或一个列分段,连同按缩小后的上限分开的各块、拆出来的、补问的、重试的)
|
||||
一共最多调用 MODEL_PART_MAX_CALLS 次,到了次数还没写完的记没写出来。
|
||||
- 止损:连续 MODEL_GIVE_UP_AFTER 段拆到底仍一项没写出来(模型明显用不了,例如思考总是吃满额度),这次起草后面的
|
||||
段不再拆小重试、没写出来就直接记下,报错建议更换起草模型;有一段写出来了就恢复。超时另有一条:拆到每段
|
||||
MODEL_MIN_PART_COLUMNS 列的调用接连超时 MODEL_GIVE_UP_AFTER 次(超时和段大小无关,再拆小也是一次一个时限),后面
|
||||
超时的不再拆小,超时拆出来、还没问的块也不再问;有一次调用写出来了就恢复。不然一直超时的模型首轮同时起草的几段
|
||||
要各自拆满次数,一段 40 列串着问约 7 个时限(420 秒时约 49 分钟)。
|
||||
- 止损按比例:这次起草最近 MODEL_HALT_WINDOW 次调用里有 MODEL_HALT_ON 次以上没写出来(额度用完、超时、空壳、出错),
|
||||
之后的段不再拆小、每块只问一次(不拆小、不补问漏掉的列、截断后不再补剩下的、空壳不再换纯文本原样再问;拆出来还没问
|
||||
的块也不再问),报错建议更换起草模型,直到比例降下去。以前按「连续几段拆到底」「接连超时」算,偶尔一次写出来就清零:
|
||||
实测会思考的模型在宽表上单次 1 到 7 分钟、四分之一超时,90 分钟停不下来。几处只管通信的调整照旧:服务方嫌额度
|
||||
太大退回小额度、结构化输出不支持或回了说明文字改走纯文本、网络错误退避重试;结构化输出只给空壳的,这次起草还没换
|
||||
纯文本问过的照旧问一次(看看是不是结构化输出的问题)。止损的窗口只记每次调用的最终结局:上面这几种重来之前的那次
|
||||
不花额度、也说明不了模型写不出来,照旧报给进度回调(用量、提醒照算),不进止损的窗口——以前都记成没写出来,额度上限
|
||||
8192、不支持 json_schema 的 OpenAI 兼容网关上,并发的头几次调用全是这种重来,第一次额度用完就凑满 10 次里 7 次,
|
||||
那一段直接记没写出来、不再拆小。重试用完仍出错的那一次是最终结局,照常算。
|
||||
- 同一张表连续 MODEL_TABLE_GIVE_UP_AFTER 个原始段都一项没写出来:这张表剩下还没开始的段跳过不问(宽表一段一段问下去
|
||||
只是烧额度),原因写清跳过了几段、可以换一个起草模型后只重试这张表。中间有一段写出来了就不算「接连」。
|
||||
- 提醒:最近 MODEL_NOTICE_WINDOW 次调用里有 MODEL_NOTICE_ON 次以上没写出来(额度用完、超时、空壳、出错),给一句
|
||||
提醒(次数和原因,建议换起草模型、缩小起草范围或停止),经进度回调交给任务;之后少于 MODEL_NOTICE_OFF 次时清掉。
|
||||
以前看不出调用在反复失败:一个任务 55 分钟只结束 1 张表,界面上只有段进度和用量。
|
||||
|
||||
**原因。** 没写出来的原因只写进这张表的 model_error:几段因为同一个原因没写出来时说「这几段」,分段起草的表(只写
|
||||
出一部分的、一项都没写出来的都一样)每段写上列的范围(「第 5 段(列名 到 列名,共 33 列)」,列名照表结构原样),
|
||||
真拆小重试过的才补「已拆小重试仍没写出」。只有一段的表,这一段就是整张表:只写出一部分的写清是哪几列,一项都没写
|
||||
出来的只写原因。
|
||||
真拆小重试过的才补「已拆小重试仍没写出」,而且只跟在拆过的那几段后面说(说到的段只有一部分拆过时写「其中第 3 段」)。
|
||||
只有一段的表,这一段就是整张表:只写出一部分的写清是哪几列,一项都没写出来的只写原因。
|
||||
|
||||
**留空的列。** 请模型写了、模型也列出了,中文名和含义却都没给(或者给的都没通过校验)的列是模型拿不准,不补问,
|
||||
记在 ModelOutcome.blank 里(表的状态不因此变)。目录里已经有中文名或含义的列不算(不论来源,驳回的不算有:以前模型
|
||||
@@ -67,11 +73,22 @@
|
||||
的不会等前一个排着的段全部调完。一次起草里按表的先后排队:宽表的表级那段写完,它的列分段排到已经排着的段后面
|
||||
(先来先服务),不插到窄表前面。
|
||||
|
||||
**进度。** 调用方给 Hooks:开始调用、每次调用的结果(用量、没写出来的原因、当前的提醒和列数上限)、一段结束、一张表
|
||||
的模型部分全部结束。后台起草任务据此落库,每张表一结束就并入目录(data/catalog_jobs.py)。
|
||||
**进度。** 调用方给 Hooks:开始调用、每次调用的结果(用量、没写出来的原因、当前的提醒和列数上限)、一段结束、宽表的
|
||||
一段写出来了(交出这张表到这时拿到的全部内容,调用方马上并入目录:停止、服务重启时已经写出来的段不丢)、块进度变了、
|
||||
一张表的模型部分全部结束。后台起草任务据此落库(data/catalog_jobs.py)。块:这张表实际分成几块来问——一开始是段数,
|
||||
一段被缩小的上限分开成几块、被拆成两半,块数就多几块;一块问完(写没写出来都算)算结束一块。
|
||||
|
||||
交给模型的只有表结构(列、类型、主键、外键、唯一约束、注释)和使用者补充的背景,不发任何数据值。写给模型的文字都在
|
||||
catalog.py 名字带 _SYSTEM / _PROMPT 的常量里;这里的 REASON_* 是给人看的(拼进这张表的 model_error)。
|
||||
**防错事实。** 表级的项里模型可以提议有效行过滤(valid_filter)和去重规则(dedup),各附一句依据(写进这一项的 note),
|
||||
校验见 catalog._from_model(有效行过滤按方言解析,不过就丢掉;存的是整理过的写法:列换成表结构里的真名、按方言加引号,
|
||||
剖析核对的也是这一条)。开了数据剖析的源(catalog_profile.profile_settings):目录里记下的码值交给模型做背景(遮罩的列
|
||||
不给,「已确认的目录项」里遮罩列的码值同样不给),表级那段写完后经剖析的只读守卫数一数有效行过滤排除了多少行
|
||||
(catalog_profile.check_valid_filter),排除 0% 或 100% 的不收,收下的 note 写上排除比例;没开剖析、连不上的照收,
|
||||
note 写没有核对的原因。没收下的(没通过校验、排除 0% 或 100%)不算失败,在这张表的日志里记一句(ModelOutcome.log,
|
||||
起草任务逐表的 log)。
|
||||
|
||||
交给模型的是表结构(列、类型、主键、外键、唯一约束、注释)和使用者补充的背景;开了数据剖析的源另给目录里记下的码值
|
||||
(剖析取过的取值和含义,遮罩的列不给),别的数据值一概不发。写给模型的文字都在 catalog.py 名字带 _SYSTEM / _PROMPT
|
||||
的常量里;这里的 REASON_* 是给人看的(拼进这张表的 model_error)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -111,21 +128,36 @@ SEG_ONE = "这一段"
|
||||
SEG_MANY = "这几段"
|
||||
REASON_EXHAUSTED = "模型的思考和输出用完了额度({budget} 个 token),{seg}没写出来"
|
||||
REASON_SPLIT = ";已拆小重试仍没写出"
|
||||
#: 说到的几段里只有一部分真拆小重试过:只说那几段
|
||||
REASON_SPLIT_PARTS = ";其中第 {parts} 段已拆小重试仍没写出"
|
||||
REASON_HOLLOW = "模型只给了空壳,没有写出可用的内容"
|
||||
REASON_TRUNCATED = "模型的输出被截断,{seg}没写完"
|
||||
REASON_FORMAT = "模型的回复不是约定的格式"
|
||||
REASON_MISSING = "模型的回复里没有这张表"
|
||||
REASON_UNANSWERED = "模型的回复里漏了这些列,补问一次仍没有给出"
|
||||
#: 止损期间每块只问一次:漏了的列不补问
|
||||
REASON_OMITTED = "模型的回复里漏了这些列"
|
||||
REASON_TIMEOUT = "模型 {seconds} 秒内没有写完{seg}"
|
||||
REASON_CALL = "调用模型出错:{line}"
|
||||
REASON_RETRIED = "(已重试 {n} 次)"
|
||||
REASON_CRASH = "起草这张表时出错:{line}"
|
||||
#: 一段到了调用次数的上限(MODEL_PART_MAX_CALLS)还没写完
|
||||
REASON_CAPPED = "每段最多调用 {n} 次模型,{seg}到了次数仍没写完,已停止重试"
|
||||
#: 止损(连续几段拆到底仍一项没写出来,后面的段不再拆小重试)时补在原因后面
|
||||
REASON_HALTED = ";模型连续 {n} 段都没写出来,已停止拆小重试,建议更换起草模型"
|
||||
#: 拆到每段 MODEL_MIN_PART_COLUMNS 列仍接连超时(超时和段大小无关,后面超时的不再拆小)时补在原因后面
|
||||
REASON_SLOW = ";拆到每段 {n} 列仍然超时,已停止拆小重试,建议更换起草模型"
|
||||
#: 止损(最近几次调用多数没写出来,之后的段不再拆小、每块只问一次)时补在原因后面
|
||||
REASON_HALTED = ";最近 {window} 次调用有 {on} 次以上没写出来,已停止拆小重试,建议更换起草模型"
|
||||
#: 同一张表接连几段都没写出来,剩下的段跳过不问
|
||||
REASON_ABANDONED = "这张表接连 {k} 段都没写出来,剩下的 {n} 段已跳过,可以换一个起草模型后只重试这张表"
|
||||
#: 跳过不问的段在「没写出来」记录里的原因(不拼进原因,见 _Run._outcome)
|
||||
_SKIPPED = "\0skipped"
|
||||
|
||||
# ---- 这张表起草时记下的几句话(不算失败),经 ModelOutcome.log 交给起草任务逐表的 log(界面看得到)
|
||||
#: 模型提议的有效记录条件没有收下:why 是原因(没通过校验的说法,或者剖析核对的结果)
|
||||
LOG_FILTER_DROPPED = "{why},模型提议的这一条没有收下:{condition}"
|
||||
#: 剖析核对排除了 0% 或 100% 的行
|
||||
LOG_FILTER_COUNTED = "{label}按剖析核对{what}(共 {rows} 行)"
|
||||
#: 一张表最多记几句;日志里的条件最多写几个字
|
||||
LOG_MAX = 10
|
||||
_LOG_CONDITION_MAX = 200
|
||||
|
||||
# ---- 提醒(最近几次调用多数没写出来),经进度回调交给任务(CatalogDraftJob.notice)
|
||||
NOTICE = ("最近 {calls} 次调用有 {failed} 次没写出来({kinds})。建议在{place}换一个模型、缩小起草范围,"
|
||||
@@ -154,8 +186,10 @@ class ModelOutcome:
|
||||
|
||||
status:ok 各段都写出来了;partial 有的段写出来、有的没有(error 写清哪部分、为什么);failed 一项都没写出来
|
||||
(error 写原因)。notes 是写出来的那些(已逐项校验,llm / proposed);items 是项数。parts_total 分几段起草
|
||||
(整表段是 1,宽表是 1 + 列分段数),parts_done 已经结束了几段。blank:模型拿不准、留空了的列(按表结构的顺序;
|
||||
表的状态不因此变)。"""
|
||||
(整表段是 1,宽表是 1 + 列分段数),parts_done 已经结束了几段,parts_written 其中写出了内容的段数。blank:模型拿
|
||||
不准、留空了的列(按表结构的顺序;表的状态不因此变)。chunks_total、chunks_done:这张表实际分成的块数和已结束的
|
||||
块数(段被缩小的上限分开、拆成两半后会变多)。log:起草时记下的几句话,不算失败(模型提议的有效记录条件没有收下的
|
||||
原因),最多 LOG_MAX 句。"""
|
||||
|
||||
status: str
|
||||
notes: dict[str, Any]
|
||||
@@ -164,6 +198,19 @@ class ModelOutcome:
|
||||
parts_total: int
|
||||
parts_done: int
|
||||
blank: list[str] = field(default_factory=list)
|
||||
parts_written: int = 0
|
||||
chunks_total: int = 0
|
||||
chunks_done: int = 0
|
||||
log: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PartWrite:
|
||||
"""宽表写出了一段(进度回调 part_written 收到的):notes 是这张表到这时拿到的全部模型起草(已校验、已核对,深拷贝),
|
||||
parts_written 是其中写出了内容的段数。调用方马上并入目录(这一次不删任何东西),整张表结束时另有 table_done 收尾。"""
|
||||
|
||||
notes: dict[str, Any]
|
||||
parts_written: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -192,12 +239,16 @@ class Hooks:
|
||||
- call_started(表名列表):一次调用拿到并发名额、马上要发出去了;
|
||||
- call_done(CallReport):每次调用一回(条数就是调用次数),带着用量、没写出来的原因、当前的提醒和列数上限;
|
||||
- part_done(表名):这张表的一段结束了(写没写出来都算);
|
||||
- part_written(表名, PartWrite):宽表写出了一段、后面还有段没结束(最后一段由 table_done 收尾;只有一段的表不调);
|
||||
- chunks_changed(表名, 块数, 已结束的块数):块进度变了;
|
||||
- table_done(表名, ModelOutcome):这张表的模型部分全部结束。"""
|
||||
|
||||
call_started: Callable[[list[str]], Awaitable[None]] | None = None
|
||||
call_done: Callable[[CallReport], Awaitable[None]] | None = None
|
||||
part_done: Callable[[str], Awaitable[None]] | None = None
|
||||
table_done: Callable[[str, ModelOutcome], Awaitable[None]] | None = None
|
||||
part_written: Callable[[str, PartWrite], Awaitable[None]] | None = None
|
||||
chunks_changed: Callable[[str, int, int], Awaitable[None]] | None = None
|
||||
|
||||
|
||||
def parts_for(columns: int) -> int:
|
||||
@@ -328,9 +379,9 @@ class _Reply:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Why:
|
||||
"""一部分没写出来的原因。base 给人看的原因(可以带 SEG,拼成一句时换成「这一段」「这几段」);split 真拆小重试过;
|
||||
halted 本来还能拆小、因为止损才没拆时止损的说明(REASON_HALTED:前面连续几段都没写出来;REASON_SLOW:拆到每段
|
||||
MODEL_MIN_PART_COLUMNS 列仍接连超时),没有是空串。"""
|
||||
"""一部分没写出来的原因。base 给人看的原因(可以带 SEG,拼成一句时换成「这一段」「这几段」;跳过不问的段是 _SKIPPED,
|
||||
不拼进原因);split 真拆小重试过;halted 本来还能拆小、补问或再问、因为止损才没问时止损的说明(REASON_HALTED:最近
|
||||
几次调用多数没写出来),没有是空串。"""
|
||||
|
||||
base: str
|
||||
split: bool = False
|
||||
@@ -341,18 +392,23 @@ def _join(failures: Iterable[tuple[_Why, int]]) -> str:
|
||||
"""几处没写出来的原因拼成一句。failures 是 (原因, 属于这张表的第几段)。
|
||||
|
||||
同样的原因只说一次(最多说两种);同一个原因落在几段上说「这几段」,同一段拆出来的几块仍是「这一段」;拆小重试过、
|
||||
止损的说明各在最后补一次(只看说出来的那几种;两种止损各说一次)。不然宽表的表级那段拆不开、列分段拆过时,同一句
|
||||
原因会说两遍。"""
|
||||
止损的说明各在最后补一次(只看说出来的那几种)。不然宽表的表级那段拆不开、列分段拆过时,同一句原因会说两遍。
|
||||
|
||||
「已拆小重试仍没写出」只跟在真拆过的那几段后面说:说到的段都拆过(只有一段的表就是这一段)时不点名,只有一部分拆过
|
||||
时写「其中第 3、4 段已拆小重试仍没写出」。以前一律补在最后,读起来像每一段都拆过。"""
|
||||
failures = list(failures)
|
||||
parts: dict[str, set[int]] = {}
|
||||
for why, part in failures:
|
||||
parts.setdefault(why.base, set()).add(part)
|
||||
shown = list(parts)[:2]
|
||||
told = [w for w, _ in failures if w.base in shown]
|
||||
told = [(w, part) for w, part in failures if w.base in shown]
|
||||
text = ";".join(base.replace(SEG, SEG_MANY if len(parts[base]) > 1 else SEG_ONE) for base in shown)
|
||||
if any(w.split for w in told):
|
||||
text += REASON_SPLIT
|
||||
for note in dict.fromkeys(w.halted for w in told if w.halted):
|
||||
split = sorted({part for w, part in told if w.split})
|
||||
if split:
|
||||
everywhere = {part for _, part in told}
|
||||
text += REASON_SPLIT if set(split) == everywhere else \
|
||||
REASON_SPLIT_PARTS.format(parts="、".join(str(part) for part in split))
|
||||
for note in dict.fromkeys(w.halted for w, _ in told if w.halted):
|
||||
text += note
|
||||
return text
|
||||
|
||||
@@ -706,6 +762,20 @@ class _Table:
|
||||
failed: dict[str, _Why] = field(default_factory=dict)
|
||||
parts_done: int = 0
|
||||
finished: bool = False
|
||||
#: 写出了内容的段(第几段)
|
||||
written_parts: set[int] = field(default_factory=set)
|
||||
#: 接连几个原始段一项都没写出来(写出来就清零);到了 MODEL_TABLE_GIVE_UP_AFTER 段,剩下的段跳过不问
|
||||
dead_parts: int = 0
|
||||
abandoned: bool = False
|
||||
#: 跳过不问的段(第几段)
|
||||
skipped_parts: list[int] = field(default_factory=list)
|
||||
#: 块进度:实际分成几块、结束了几块
|
||||
chunks_total: int = 0
|
||||
chunks_done: int = 0
|
||||
#: 有效行过滤核对过了(表级那段写完核对一次)
|
||||
vetted: bool = False
|
||||
#: 起草时记下的几句话(不算失败):模型提议的有效记录条件没有收下的原因
|
||||
log: list[str] = field(default_factory=list)
|
||||
|
||||
def whole(self, piece: _Piece) -> bool:
|
||||
return piece.table_level and len(piece.columns) == len(self.columns)
|
||||
@@ -753,6 +823,15 @@ class _Budget:
|
||||
left: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Chunk:
|
||||
"""一块:一次拿来问的几张表(多表拼成的段每张表各是一块)。补问、截断后补剩下的、换纯文本再问都还是这一块;拆成两半
|
||||
(单张表按列拆,多一块;多表的段按表拆,各张表仍是一块)后,这一块里被拆的表交给新的块,split 记下它们。"""
|
||||
|
||||
tables: tuple[str, ...]
|
||||
split: set[str] = field(default_factory=set)
|
||||
|
||||
|
||||
def _pack(pieces: list[_Piece], cap: int) -> list[list[_Piece]]:
|
||||
"""多表的段按顺序重新拼成每段不超过 cap 列(超过 cap 的表自成一段)。"""
|
||||
groups: list[list[_Piece]] = []
|
||||
@@ -800,31 +879,37 @@ class _Run:
|
||||
self.background = self._background(getattr(source, "description", None), background)
|
||||
#: 这次起草同时在等名额或正在进行的调用最多几个(全进程的名额另算,见 model_gate)
|
||||
self.gate = asyncio.Semaphore(max(1, int(C.MODEL_CONCURRENCY)))
|
||||
#: 连续几段拆到底仍一项没写出来(止损用,有一段写出来就清零)
|
||||
self.dead_streak = 0
|
||||
#: 拆到每段 MODEL_MIN_PART_COLUMNS 列(或更小)的调用接连超时了几次(有一次调用写出来就清零):超时和段大小
|
||||
#: 无关,再拆小也是一次一个时限,后面超时的不再拆小
|
||||
self.slow_streak = 0
|
||||
#: 新开始的段每段最多几列:额度用完或超时(strikes)累计 MODEL_SHRINK_AFTER 次减半一次
|
||||
self.part_columns = max(1, int(C.MODEL_BATCH_COLUMNS))
|
||||
self.strikes = 0
|
||||
#: 最近几次调用没写出来的原因种类(写出来了是 None),提醒看它
|
||||
self.recent: collections.deque[str | None] = collections.deque(maxlen=max(1, int(C.MODEL_NOTICE_WINDOW)))
|
||||
self.notice: str | None = None
|
||||
#: 最近 MODEL_HALT_WINDOW 次调用没写出来没有(止损按比例看它)
|
||||
self.window: collections.deque[bool] = collections.deque(maxlen=max(1, int(C.MODEL_HALT_WINDOW)))
|
||||
self.was_halted = False
|
||||
#: 这次起草里结构化输出只给空壳时换纯文本问过没有(止损期间只问这一次)
|
||||
self.text_probed = False
|
||||
#: 数据源:开了数据剖析的源把码值交给模型、核对有效行过滤的排除比例(遮罩的列都不碰)
|
||||
from app.data import catalog_profile
|
||||
from app.data.engine import masked_columns
|
||||
|
||||
def _halt_note(self, kind: str | None = None) -> str:
|
||||
"""止损的说明(拼在原因后面),没止损是空串。
|
||||
self.source = source
|
||||
self.kind = getattr(source, "kind", None)
|
||||
options = getattr(source, "options", None)
|
||||
self.profiling = catalog_profile.profile_settings(options if isinstance(options, dict) else None)
|
||||
self.masked = {c.lower() for c in masked_columns(options if isinstance(options, dict) else None)}
|
||||
|
||||
- 连续 MODEL_GIVE_UP_AFTER 段一项都没写出来:后面的段不再拆小重试(不论原因);
|
||||
- 超时的(kind 是 timeout)另看:拆到每段 MODEL_MIN_PART_COLUMNS 列的调用接连超时 MODEL_GIVE_UP_AFTER 次,
|
||||
超时就和段大小无关了,再拆小也是一次一个时限,后面超时的不再拆小,超时拆出来还没问的块也不再问。只管超时:
|
||||
额度用完、只给空壳照旧拆小(段小了思考和输出才放得下)。"""
|
||||
give_up = max(1, int(C.MODEL_GIVE_UP_AFTER))
|
||||
if self.dead_streak >= give_up:
|
||||
return REASON_HALTED.format(n=give_up)
|
||||
if kind == "timeout" and self.slow_streak >= give_up:
|
||||
return REASON_SLOW.format(n=self._floor())
|
||||
return ""
|
||||
def _halted(self) -> bool:
|
||||
"""止损:最近 MODEL_HALT_WINDOW 次调用里有 MODEL_HALT_ON 次以上没写出来。之后的段不再拆小、每块只问一次,直到
|
||||
比例降下去(偶尔一次写出来不会清零)。"""
|
||||
return sum(self.window) >= max(1, int(C.MODEL_HALT_ON))
|
||||
|
||||
def _halt_note(self) -> str:
|
||||
"""止损的说明(拼在原因后面),没止损是空串。"""
|
||||
if not self._halted():
|
||||
return ""
|
||||
return REASON_HALTED.format(window=max(1, int(C.MODEL_HALT_WINDOW)), on=max(1, int(C.MODEL_HALT_ON)))
|
||||
|
||||
@staticmethod
|
||||
def _floor() -> int:
|
||||
@@ -846,6 +931,8 @@ class _Run:
|
||||
head, *rest = self.tables[unit].parts
|
||||
# 表级那段先写:列分段拿它做背景
|
||||
queue.append(_Work([head], [_Work([p]) for p in rest]))
|
||||
for st in self.tables.values():
|
||||
st.chunks_total = len(st.parts)
|
||||
left = {name: len(st.parts) for name, st in self.tables.items()}
|
||||
busy = 0
|
||||
changed = asyncio.Event()
|
||||
@@ -898,9 +985,19 @@ class _Run:
|
||||
"""起草一段;它之后的段(宽表的列分段)排到队尾;一张表的段都结束了,这张表的模型部分就结束了。
|
||||
|
||||
这一段(原始段:计划里的整表段、表级那段或一个列分段)连同按缩小后的上限分开的各块、拆出来的、补问的、重试的,
|
||||
一共最多调用 MODEL_PART_MAX_CALLS 次(各块共用一个 _Budget);止损按原始段数(_tally)。"""
|
||||
await self._start(work.pieces, _Budget(max(1, int(C.MODEL_PART_MAX_CALLS))))
|
||||
self._tally(work.pieces)
|
||||
一共最多调用 MODEL_PART_MAX_CALLS 次(各块共用一个 _Budget)。表级那段写完先核对模型提议的有效行过滤(_vet),
|
||||
再交出去。接连几段没写出来的表(_tally),剩下的段跳过不问。宽表写出了一段、后面还有段没结束,就把这张表到这时
|
||||
拿到的内容交给调用方并入目录(part_written);最后一段结束由 table_done 收尾。"""
|
||||
asked = [p for p in work.pieces if not self.tables[p.table].abandoned]
|
||||
for piece in work.pieces:
|
||||
if self.tables[piece.table].abandoned:
|
||||
await self._skip_part(piece)
|
||||
if asked:
|
||||
await self._start(asked, _Budget(max(1, int(C.MODEL_PART_MAX_CALLS))))
|
||||
for piece in asked:
|
||||
if piece.table_level:
|
||||
await self._vet(self.tables[piece.table])
|
||||
self._tally(asked)
|
||||
for piece in work.pieces:
|
||||
await self._part_done(piece.table)
|
||||
queue.extend(work.then)
|
||||
@@ -908,6 +1005,8 @@ class _Run:
|
||||
left[piece.table] -= 1
|
||||
if not left[piece.table]:
|
||||
await self._table_done(piece.table)
|
||||
elif piece.part in self.tables[piece.table].written_parts:
|
||||
await self._part_written(piece.table)
|
||||
|
||||
async def _start(self, pieces: list[_Piece], budget: _Budget) -> None:
|
||||
"""开始起草一段。超过当前的列数上限(额度用完、超时多了会减半)的先按上限分开:多表的段按表重新拼,单张表的
|
||||
@@ -922,6 +1021,7 @@ class _Run:
|
||||
return
|
||||
piece = pieces[0]
|
||||
chunks = _even(piece.columns, -(-len(piece.columns) // cap))
|
||||
await self._more_chunks(piece.table, len(chunks) - 1)
|
||||
first = [_Piece(piece.table, chunks[0], piece.table_level, piece.part)]
|
||||
rest = [[_Piece(piece.table, cols, False, piece.part)] for cols in chunks[1:]]
|
||||
if piece.table_level:
|
||||
@@ -931,9 +1031,10 @@ class _Run:
|
||||
await asyncio.gather(*(self._guarded(group, budget) for group in [first, *rest]))
|
||||
|
||||
async def _guarded(self, pieces: list[_Piece], budget: _Budget) -> None:
|
||||
"""起草一段(或它按上限分开的一块);意料之外的错误只记这一块没写出来,不影响别的段和别的表。"""
|
||||
"""起草一块(一段,或它按上限分开的一块);意料之外的错误只记这一块没写出来,不影响别的段和别的表。"""
|
||||
chunk = _Chunk(tuple(dict.fromkeys(p.table for p in pieces)))
|
||||
try:
|
||||
await self._segment(pieces, 0, False, budget)
|
||||
await self._segment(pieces, 0, False, budget, chunk=chunk)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001
|
||||
@@ -941,49 +1042,80 @@ class _Run:
|
||||
|
||||
logger.exception("起草数据目录时出错:%s", "、".join(p.table for p in pieces))
|
||||
self._fail(pieces, REASON_CRASH.format(line=first_line(e) or describe_exception(e)))
|
||||
await self._chunk_done(chunk)
|
||||
|
||||
def _tally(self, pieces: list[_Piece]) -> None:
|
||||
"""一段(原始段,连同它分开的各块、拆小、补缺)结束:一项都没写出来的接着数,写出来了清零。"""
|
||||
if any(self.tables[p.table].wrote(p) for p in pieces):
|
||||
self.dead_streak = 0
|
||||
"""一段(原始段,连同它分开的各块、拆小、补缺)结束:记下这一段写出内容没有;同一张表接连
|
||||
MODEL_TABLE_GIVE_UP_AFTER 段一项都没写出来,这张表剩下的段跳过不问(写出来了清零)。"""
|
||||
give_up = max(1, int(C.MODEL_TABLE_GIVE_UP_AFTER))
|
||||
for piece in pieces:
|
||||
st = self.tables[piece.table]
|
||||
if st.wrote(piece):
|
||||
st.written_parts.add(piece.part)
|
||||
st.dead_parts = 0
|
||||
continue
|
||||
st.dead_parts += 1
|
||||
if st.dead_parts >= give_up and not st.abandoned and len(st.parts) > 1:
|
||||
st.abandoned = True
|
||||
logger.warning("起草数据目录:表 %s 接连 %d 段都没写出来,剩下的段跳过", st.name, st.dead_parts)
|
||||
|
||||
async def _skip_part(self, piece: _Piece) -> None:
|
||||
"""这张表接连几段都没写出来:这一段跳过不问(算一块结束)。"""
|
||||
st = self.tables[piece.table]
|
||||
st.skipped_parts.append(piece.part)
|
||||
self._fail([piece], _Why(_SKIPPED))
|
||||
st.chunks_done += 1
|
||||
await self._hook(self.hooks.chunks_changed, st.name, st.chunks_total, st.chunks_done)
|
||||
|
||||
# ---------------------------------------------------------------- 块进度
|
||||
|
||||
async def _more_chunks(self, table: str, n: int) -> None:
|
||||
if n <= 0:
|
||||
return
|
||||
self.dead_streak += 1
|
||||
if self.dead_streak == max(1, int(C.MODEL_GIVE_UP_AFTER)):
|
||||
logger.warning("起草数据目录:模型连续 %d 段都没写出来,这次起草后面的段不再拆小重试", self.dead_streak)
|
||||
st = self.tables[table]
|
||||
st.chunks_total += n
|
||||
await self._hook(self.hooks.chunks_changed, table, st.chunks_total, st.chunks_done)
|
||||
|
||||
async def _chunk_done(self, chunk: _Chunk) -> None:
|
||||
"""一块问完了:这一块里没被拆给新块的表,各结束一块。"""
|
||||
for table in chunk.tables:
|
||||
if table in chunk.split:
|
||||
continue
|
||||
st = self.tables[table]
|
||||
st.chunks_done += 1
|
||||
await self._hook(self.hooks.chunks_changed, table, st.chunks_total, st.chunks_done)
|
||||
|
||||
# ---------------------------------------------------------------- 一段
|
||||
|
||||
async def _segment(self, pieces: list[_Piece], depth: int, followup: bool, budget: _Budget, *,
|
||||
halved: bool = False, mode: str | None = None, last: bool = False,
|
||||
optional: bool = False) -> None:
|
||||
chunk: _Chunk, halved: bool = False, mode: str | None = None, last: bool = False,
|
||||
optional: str | None = None) -> None:
|
||||
"""问一段,拿到的并进各表;按失败原因拆开、补缺、记下没写出来的。
|
||||
|
||||
depth:已经拆了几层(截断后只补剩下的也算一层)。followup:这一段是在补缺(补过就不再补,见 _after_followup)。
|
||||
budget:这一段(原始段)还能调用几次。halved:这几块是真拆小(_split)出来的——原因里「已拆小重试」只在这时写。
|
||||
mode / last:换纯文本问的那一次——拆不开的空壳、结构化输出回了说明文字(再不行就记没写出来)。
|
||||
optional:超时拆出来的块,轮到它时已经止损就不问了(_ask)。"""
|
||||
budget:这一段(原始段)还能调用几次。chunk:这是哪一块(块进度)。halved:这几块是真拆小(_split)出来的——
|
||||
原因里「已拆小重试」只在这时写。mode / last:换纯文本问的那一次——拆不开的空壳、结构化输出回了说明文字(再不行
|
||||
就记没写出来)。optional:拆出来的块(值是拆它的原因),轮到它时已经止损就不问了(_ask)。
|
||||
|
||||
止损期间(_halted)每块只问一次:不拆小(_split)、不补问漏掉的列、截断后不再补剩下的。"""
|
||||
reply = await self._ask(pieces, budget, mode, optional=optional)
|
||||
if reply.capped:
|
||||
self._fail(pieces, self._capped(halved))
|
||||
return
|
||||
if reply.skipped:
|
||||
note = self._halt_note("timeout")
|
||||
self._fail(pieces, lambda p: self._reason("timeout", split=True, piece=p, halted=note))
|
||||
note = self._halt_note()
|
||||
self._fail(pieces, lambda p: self._reason(optional or "hollow", split=True, piece=p, halted=note))
|
||||
return
|
||||
if reply.timeout:
|
||||
# 先记下再报用量:报用量要写库、会让出事件循环,等名额的块醒来时要看到这一次
|
||||
if sum(len(p.columns) for p in pieces) <= self._floor():
|
||||
self.slow_streak += 1
|
||||
if self.slow_streak == max(1, int(C.MODEL_GIVE_UP_AFTER)):
|
||||
logger.warning("起草数据目录:拆到每段 %d 列仍接连超时 %d 次,后面超时的不再拆小",
|
||||
self._floor(), self.slow_streak)
|
||||
await self._report(reply, wrote=False)
|
||||
await self._split(pieces, depth, "timeout", followup, budget, halved)
|
||||
await self._split(pieces, depth, "timeout", followup, budget, halved, chunk)
|
||||
return
|
||||
if reply.error is not None:
|
||||
await self._report(reply, wrote=False)
|
||||
if reply.prose and not last:
|
||||
await self._prose_to_text(pieces, depth, followup, budget, halved)
|
||||
# 结构化输出回了说明文字、这一块马上改走纯文本再问:只管通信的重来,不进止损的窗口
|
||||
redo = reply.prose and not last
|
||||
await self._report(reply, wrote=False, halting=not redo)
|
||||
if redo:
|
||||
await self._prose_to_text(pieces, depth, followup, budget, halved, chunk)
|
||||
return
|
||||
self._fail(pieces, _Why(reply.error, split=halved))
|
||||
return
|
||||
@@ -1008,28 +1140,36 @@ class _Run:
|
||||
return
|
||||
kind = "exhausted" if reply.exhausted else "truncated"
|
||||
if not anything:
|
||||
await self._split(rest, depth, kind, followup, budget, halved)
|
||||
elif depth < int(C.MODEL_SPLIT_DEPTH):
|
||||
await self._split(rest, depth, kind, followup, budget, halved, chunk)
|
||||
elif depth < int(C.MODEL_SPLIT_DEPTH) and not self._halted():
|
||||
# 捡回了一部分:只补剩下的(不是拆小)
|
||||
await self._segment(rest, depth + 1, followup, budget, halved=halved)
|
||||
await self._segment(rest, depth + 1, followup, budget, chunk=chunk, halved=halved)
|
||||
else:
|
||||
self._fail(rest, self._reason(kind, split=halved))
|
||||
note = self._halt_note() if depth < int(C.MODEL_SPLIT_DEPTH) else ""
|
||||
self._fail(rest, self._reason(kind, split=halved, halted=note))
|
||||
return
|
||||
if not anything and not followup:
|
||||
if last:
|
||||
halted = self._stopped(pieces, depth)
|
||||
self._fail(pieces, lambda p: self._reason("hollow", split=halved, piece=p, halted=halted))
|
||||
return
|
||||
await self._split(pieces, depth, "hollow", followup, budget, halved)
|
||||
await self._split(pieces, depth, "hollow", followup, budget, halved, chunk)
|
||||
return
|
||||
if not rest:
|
||||
return
|
||||
if followup:
|
||||
await self._after_followup(rest, depth, budget, halved, last=last)
|
||||
await self._after_followup(rest, depth, budget, halved, chunk, last=last)
|
||||
return
|
||||
await self._segment(rest, depth, True, budget, halved=halved)
|
||||
if self._halted():
|
||||
# 止损期间每块只问一次:漏了的不补问
|
||||
note = self._halt_note()
|
||||
self._fail(rest, lambda p: _Why(REASON_OMITTED, split=halved, halted=note)
|
||||
if self.tables[p.table].has_any() else self._reason("hollow", split=halved, piece=p,
|
||||
halted=note))
|
||||
return
|
||||
await self._segment(rest, depth, True, budget, chunk=chunk, halved=halved)
|
||||
|
||||
async def _after_followup(self, rest: list[_Piece], depth: int, budget: _Budget, halved: bool, *,
|
||||
async def _after_followup(self, rest: list[_Piece], depth: int, budget: _Budget, halved: bool, chunk: _Chunk, *,
|
||||
last: bool) -> None:
|
||||
"""补问过仍然没给的部分(补过就不再补)。
|
||||
|
||||
@@ -1047,16 +1187,17 @@ class _Run:
|
||||
halted = self._stopped(empty, depth)
|
||||
self._fail(empty, lambda p: self._reason("hollow", split=halved, piece=p, halted=halted))
|
||||
return
|
||||
await self._split(empty, depth, "hollow", False, budget, halved)
|
||||
await self._split(empty, depth, "hollow", False, budget, halved, chunk)
|
||||
|
||||
async def _prose_to_text(self, pieces: list[_Piece], depth: int, followup: bool, budget: _Budget,
|
||||
halved: bool) -> None:
|
||||
halved: bool, chunk: _Chunk) -> None:
|
||||
"""结构化输出回了一段说明文字:网关接受了结构化输出的参数却没照做。这次起草后面都走纯文本(另带格式说明),
|
||||
这一段换纯文本问一次。纯文本额度用完了还能照常拆小(拆出来的半段也走纯文本,不再先问一遍结构化输出)。"""
|
||||
这一段换纯文本问一次(止损期间也问:这是通信方式的问题,不是模型写不出来)。纯文本额度用完了还能照常拆小
|
||||
(拆出来的半段也走纯文本,不再先问一遍结构化输出)。"""
|
||||
if self.mode != "text":
|
||||
self.mode = "text"
|
||||
logger.info("起草数据目录:结构化输出回了说明文字,后面改走纯文本")
|
||||
await self._segment(pieces, depth, followup, budget, halved=halved, mode="text", last=True)
|
||||
await self._segment(pieces, depth, followup, budget, chunk=chunk, halved=halved, mode="text", last=True)
|
||||
|
||||
def _stopped(self, pieces: list[_Piece], depth: int) -> str:
|
||||
"""这几段本来还能拆小、因为止损才没拆时,止损的说明;不是的话是空串。"""
|
||||
@@ -1065,36 +1206,57 @@ class _Run:
|
||||
return ""
|
||||
|
||||
async def _split(self, pieces: list[_Piece], depth: int, kind: str, followup: bool, budget: _Budget,
|
||||
halved: bool) -> None:
|
||||
halved: bool, chunk: _Chunk) -> None:
|
||||
"""拆成两半再问(额度用完、超时、只给空壳、截断后一项都没捡回来)。
|
||||
|
||||
- 同一张表按列拆、前一半带着表级的项:先写前一半,后一半拿它做背景。超时的不等:一次超时就是一个时限,40 → 20
|
||||
→ 10 一层一层串着问要好几个时限,两半同时问;拆出来的块轮到它时已经止损的不再问(optional)。
|
||||
- 拆不开的空壳再问一次:结构化输出给的换纯文本,已经走纯文本的原样再问。止损时本来能拆的不拆:结构化输出给的
|
||||
照旧换纯文本问一次(看看是不是结构化输出的问题),已经走纯文本的不再问。
|
||||
→ 10 一层一层串着问要好几个时限,两半同时问。拆出来的两半是新的块(单张表多一块);轮到它时已经止损的不再问
|
||||
(optional)。
|
||||
- 拆不开的空壳再问一次:结构化输出给的换纯文本,已经走纯文本的原样再问。
|
||||
- 止损期间(_halted)不拆小、每块只问一次:本来能拆的不拆;空壳不再原样再问,只有结构化输出给的、这次起草还没
|
||||
换纯文本问过的照旧问一次(看看是不是结构化输出的问题)。
|
||||
- 别的原因拆不开、止损了,就记没写出来。"""
|
||||
halves = _halves(pieces) if depth < int(C.MODEL_SPLIT_DEPTH) else None
|
||||
halted = self._halt_note(kind) if halves is not None else ""
|
||||
halted = self._halted()
|
||||
if halves is not None and not halted:
|
||||
first, second = halves
|
||||
timeout = kind == "timeout"
|
||||
if (not timeout and len(first) == len(second) == 1 and first[0].table == second[0].table
|
||||
await self._split_chunk(chunk, pieces)
|
||||
if (kind != "timeout" and len(first) == len(second) == 1 and first[0].table == second[0].table
|
||||
and first[0].table_level):
|
||||
await self._segment(first, depth + 1, followup, budget, halved=True)
|
||||
await self._segment(second, depth + 1, followup, budget, halved=True)
|
||||
await self._half(first, depth + 1, followup, budget, kind)
|
||||
await self._half(second, depth + 1, followup, budget, kind)
|
||||
return
|
||||
results = await asyncio.gather(
|
||||
self._segment(first, depth + 1, followup, budget, halved=True, optional=timeout),
|
||||
self._segment(second, depth + 1, followup, budget, halved=True, optional=timeout),
|
||||
return_exceptions=True)
|
||||
results = await asyncio.gather(self._half(first, depth + 1, followup, budget, kind),
|
||||
self._half(second, depth + 1, followup, budget, kind),
|
||||
return_exceptions=True)
|
||||
for result in results:
|
||||
if isinstance(result, BaseException):
|
||||
raise result
|
||||
return
|
||||
if kind == "hollow" and (not halted or self.mode == "structured"):
|
||||
await self._segment(pieces, depth, followup, budget, halved=halved, mode="text", last=True)
|
||||
if kind == "hollow" and (not halted or (self.mode == "structured" and not self.text_probed)):
|
||||
self.text_probed = True
|
||||
await self._segment(pieces, depth, followup, budget, chunk=chunk, halved=halved, mode="text", last=True)
|
||||
return
|
||||
self._fail(pieces, lambda p: self._reason(kind, split=halved, piece=p, halted=halted))
|
||||
note = self._halt_note() if halted and (halves is not None or kind == "hollow") else ""
|
||||
self._fail(pieces, lambda p: self._reason(kind, split=halved, piece=p, halted=note))
|
||||
|
||||
async def _half(self, pieces: list[_Piece], depth: int, followup: bool, budget: _Budget, kind: str) -> None:
|
||||
"""拆出来的一半:新的一块,问完算结束一块。"""
|
||||
chunk = _Chunk(tuple(dict.fromkeys(p.table for p in pieces)))
|
||||
try:
|
||||
await self._segment(pieces, depth, followup, budget, chunk=chunk, halved=True, optional=kind)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
await self._chunk_done(chunk) # 出错也算问完(_guarded 记没写出来),块进度对得上
|
||||
raise
|
||||
await self._chunk_done(chunk)
|
||||
|
||||
async def _split_chunk(self, chunk: _Chunk, pieces: list[_Piece]) -> None:
|
||||
"""这一块里的这几张表拆给了新的块:单张表按列拆成两半,多一块;多表的段按表拆,各张表仍是一块。"""
|
||||
chunk.split.update(dict.fromkeys(p.table for p in pieces))
|
||||
if len(pieces) == 1:
|
||||
await self._more_chunks(pieces[0].table, 1)
|
||||
|
||||
def _remainder(self, pieces: list[_Piece], gains: list[_Gain], *, truncated: bool,
|
||||
followup: bool) -> list[_Piece]:
|
||||
@@ -1118,13 +1280,14 @@ class _Run:
|
||||
# ---------------------------------------------------------------- 调用
|
||||
|
||||
async def _ask(self, pieces: list[_Piece], budget: _Budget, mode: str | None = None, *,
|
||||
optional: bool = False) -> _Reply:
|
||||
optional: str | None = None) -> _Reply:
|
||||
"""调一次模型(按原因重来:额度被拒、结构化输出不支持、网络错误……),交回读好的回复。
|
||||
|
||||
这一段的调用次数用完了交回 capped(不调用)。optional(超时拆出来的块):拿到名额时已经止损,交回 skipped(不调用,
|
||||
次数退回)——等名额的时候前面的块接连超时,再问也是一个时限。超时不原样重试:交回 timeout,由调用方当作这一段
|
||||
太大、拆小再问。重来之前的那次调用在这里就报给进度回调;交回的那次由调用方读完内容再报(写没写出来要看校验之后
|
||||
的内容)。"""
|
||||
这一段的调用次数用完了交回 capped(不调用)。optional(拆出来的块):拿到名额时已经止损,交回 skipped(不调用,
|
||||
次数退回)——等名额的时候前面的调用多数没写出来,不再拆小重试。超时不原样重试:交回 timeout,由调用方当作这一段
|
||||
太大、拆小再问。重来之前的那次调用在这里就报给进度回调(记进用量和提醒,不进止损的窗口:halting=False);交回的
|
||||
那次由调用方读完内容再报(写没写出来要看校验之后的内容)。服务方嫌额度太大、结构化输出不支持、网络错误的重来止损
|
||||
期间照旧(只管通信,不是模型写不出来)。"""
|
||||
mode = mode or self.mode
|
||||
retried = 0
|
||||
while True:
|
||||
@@ -1135,7 +1298,7 @@ class _Run:
|
||||
try:
|
||||
# 先占这次起草自己的名额,再去排全进程的:一次起草排在全进程名额上的调用最多 MODEL_CONCURRENCY 个
|
||||
async with self.gate, model_gate():
|
||||
if optional and self._halt_note("timeout"):
|
||||
if optional and self._halted():
|
||||
budget.left += 1
|
||||
return _Reply(skipped=True)
|
||||
await self._hook(self.hooks.call_started, [p.table for p in pieces])
|
||||
@@ -1151,16 +1314,16 @@ class _Run:
|
||||
if kind == "timeout":
|
||||
return _Reply(timeout=True, usage=(0, 0))
|
||||
if kind == "budget" and (getattr(self.model, "budget", None) != used or self._shrink()):
|
||||
await self._called((0, 0), "error")
|
||||
await self._called((0, 0), "error", halting=False)
|
||||
continue
|
||||
if kind == "unsupported" and mode == "structured":
|
||||
if self.mode == "structured":
|
||||
logger.info("起草数据目录:结构化输出用不了(%s),后面改走纯文本", str(e)[:200])
|
||||
self.mode = mode = "text"
|
||||
await self._called((0, 0), "error")
|
||||
await self._called((0, 0), "error", halting=False)
|
||||
continue
|
||||
if kind == "transient" and retried < int(C.MODEL_RETRIES):
|
||||
await self._called((0, 0), "error")
|
||||
await self._called((0, 0), "error", halting=False)
|
||||
delays = tuple(C.MODEL_RETRY_DELAYS_S) or (0.0,)
|
||||
await _sleep(delays[min(retried, len(delays) - 1)])
|
||||
retried += 1
|
||||
@@ -1212,8 +1375,9 @@ class _Run:
|
||||
|
||||
# ---------------------------------------------------------------- 每次调用之后:用量、段大小、提醒
|
||||
|
||||
async def _report(self, reply: _Reply, *, wrote: bool) -> None:
|
||||
"""交回的那次调用读完内容之后报给进度回调。"""
|
||||
async def _report(self, reply: _Reply, *, wrote: bool, halting: bool = True) -> None:
|
||||
"""交回的那次调用读完内容之后报给进度回调。halting:这次是不是这一块这次调用的最终结局(进不进止损的窗口,见
|
||||
_called)。"""
|
||||
if reply.usage is None:
|
||||
return
|
||||
if reply.timeout:
|
||||
@@ -1224,14 +1388,26 @@ class _Run:
|
||||
failure = None
|
||||
else:
|
||||
failure = "exhausted" if reply.exhausted else "hollow"
|
||||
await self._called(reply.usage, failure, strike=reply.timeout or reply.exhausted)
|
||||
await self._called(reply.usage, failure, strike=reply.timeout or reply.exhausted, halting=halting)
|
||||
|
||||
async def _called(self, usage: tuple[int, int], failure: str | None, *, strike: bool = False) -> None:
|
||||
async def _called(self, usage: tuple[int, int], failure: str | None, *, strike: bool = False,
|
||||
halting: bool = True) -> None:
|
||||
"""记下一次调用:额度用完或超时(strike)累计 MODEL_SHRINK_AFTER 次,新开始的段列数上限减半;最近几次调用多数
|
||||
没写出来时提醒,少了清掉。写出来了(failure 为 None)就清掉接连超时的计数。报给进度回调。"""
|
||||
没写出来时提醒,少了清掉;止损按最近 MODEL_HALT_WINDOW 次调用的比例(_halted)。报给进度回调。
|
||||
|
||||
halting=False:只管通信的重来之前的那次(服务方嫌额度太大、结构化输出不支持或回了说明文字、429 / 5xx 退避重试)。
|
||||
照旧记进用量和提醒,不进止损的窗口:它不花额度,也说明不了模型写不出来;止损只看每次调用的最终结局。"""
|
||||
self.recent.append(failure)
|
||||
if failure is None:
|
||||
self.slow_streak = 0
|
||||
if halting:
|
||||
self.window.append(failure is not None)
|
||||
halted = self._halted()
|
||||
if halted != self.was_halted:
|
||||
self.was_halted = halted
|
||||
if halted:
|
||||
logger.warning("起草数据目录:最近 %d 次调用有 %d 次没写出来,之后的段不再拆小、每块只问一次",
|
||||
len(self.window), sum(self.window))
|
||||
else:
|
||||
logger.info("起草数据目录:最近的调用多数写出来了,恢复拆小重试")
|
||||
if strike:
|
||||
self.strikes += 1
|
||||
width = max(1, int(C.MODEL_BATCH_COLUMNS))
|
||||
@@ -1280,7 +1456,14 @@ class _Run:
|
||||
gain = _Gain()
|
||||
for entry in by_table.get(piece.table, []):
|
||||
gain.named = st.named = True
|
||||
notes = _restrict(C._from_model(entry, st.meta, system=self.system), piece)
|
||||
dropped: list[tuple[str, str]] = []
|
||||
drafted = C._from_model(entry, st.meta, system=self.system, source_kind=self.kind, table=st.name,
|
||||
dropped=dropped)
|
||||
if piece.table_level and "valid_filter" not in st.notes:
|
||||
# 只在请它写表级的项时记:只写几列的那几段里多给的表级的项本来就不要
|
||||
for why, condition in dropped:
|
||||
self._log(st, why, condition)
|
||||
notes = _restrict(drafted, piece)
|
||||
cols = entry.get("columns") if isinstance(entry.get("columns"), list) else []
|
||||
gain.answered |= {c.get("name") for c in cols if isinstance(c, dict) and c.get("name") in piece.columns}
|
||||
slots = C._slots(notes)
|
||||
@@ -1344,21 +1527,33 @@ class _Run:
|
||||
failed_table = _TABLE in st.failed and not st.got_table
|
||||
total = len(st.parts) or 1
|
||||
part_of = st.part_of()
|
||||
progress = {"parts_written": len(st.written_parts), "chunks_total": st.chunks_total,
|
||||
"chunks_done": st.chunks_done, "log": list(dict.fromkeys(st.log))[:LOG_MAX]}
|
||||
abandoned = self._abandoned_text(st)
|
||||
if not items:
|
||||
failures = [(why, part_of.get(key, 1)) for key, why in st.failed.items()]
|
||||
failures = [(why, part_of.get(key, 1)) for key, why in st.failed.items() if why.base != _SKIPPED]
|
||||
reason = _join(failures) if failures else (C.MODEL_EMPTY_REASON if st.named else REASON_MISSING)
|
||||
if failures and len(st.parts) > 1:
|
||||
if abandoned:
|
||||
reason = f"{reason};{abandoned}"
|
||||
if st.failed and len(st.parts) > 1:
|
||||
# 宽表一项都没写出来:同样逐段写上列的范围
|
||||
said = self._parts_said(st, failed_cols, failed_table)
|
||||
every = "都" if len(said) == len(st.parts) else ""
|
||||
reason = f"这张表 {len(st.columns)} 列,分 {len(st.parts)} 段起草,{'、'.join(said)}{every}没写出来:{reason}"
|
||||
return ModelOutcome("failed", {}, 0, reason, total, st.parts_done)
|
||||
return ModelOutcome("failed", {}, 0, reason, total, st.parts_done, **progress)
|
||||
notes = copy.deepcopy(st.notes)
|
||||
blank = self._blank(st, set(failed_cols))
|
||||
if failed_cols or failed_table:
|
||||
return ModelOutcome("partial", notes, items, self._partial_text(st, failed_cols, failed_table), total,
|
||||
st.parts_done, blank)
|
||||
return ModelOutcome("ok", notes, items, None, total, st.parts_done, blank)
|
||||
return ModelOutcome("partial", notes, items, self._partial_text(st, failed_cols, failed_table, abandoned),
|
||||
total, st.parts_done, blank, **progress)
|
||||
return ModelOutcome("ok", notes, items, None, total, st.parts_done, blank, **progress)
|
||||
|
||||
@staticmethod
|
||||
def _abandoned_text(st: _Table) -> str:
|
||||
"""这张表接连几段没写出来、剩下的段跳过了:说清跳过了几段、怎么办。没跳过是空串。"""
|
||||
if not st.skipped_parts:
|
||||
return ""
|
||||
return REASON_ABANDONED.format(k=max(1, int(C.MODEL_TABLE_GIVE_UP_AFTER)), n=len(st.skipped_parts))
|
||||
|
||||
def _blank(self, st: _Table, failed: set[str]) -> list[str]:
|
||||
"""模型拿不准、留空了的列:请它写了(不在没写出来的里面),中文名和含义却都没给(或者给的都没通过校验)。
|
||||
@@ -1395,11 +1590,14 @@ class _Run:
|
||||
return said + [f"第 {k} 段({_columns_text(st.columns, cols, count=True)})" for k, cols in sorted(bad.items())]
|
||||
|
||||
@classmethod
|
||||
def _partial_text(cls, st: _Table, failed_cols: list[str], failed_table: bool) -> str:
|
||||
"""只写出一部分时说清哪部分没写出来(第几段、哪几列)、为什么、怎么办。"""
|
||||
def _partial_text(cls, st: _Table, failed_cols: list[str], failed_table: bool, abandoned: str = "") -> str:
|
||||
"""只写出一部分时说清哪部分没写出来(第几段、哪几列)、为什么、怎么办。剩下的段跳过了的,跳过的说明里已经说了
|
||||
怎么办(换一个起草模型后只重试这张表),不再另说「可以只重试这张表」。"""
|
||||
part_of = st.part_of()
|
||||
keys = ([_TABLE] if failed_table else []) + failed_cols
|
||||
reason = _join((st.failed[k], part_of.get(k, 1)) for k in keys)
|
||||
reason = _join((st.failed[k], part_of.get(k, 1)) for k in keys if st.failed[k].base != _SKIPPED)
|
||||
if abandoned:
|
||||
reason = f"{reason};{abandoned}" if reason else abandoned
|
||||
n = len(st.columns)
|
||||
if len(st.parts) > 1:
|
||||
said = cls._parts_said(st, failed_cols, failed_table)
|
||||
@@ -1411,7 +1609,7 @@ class _Run:
|
||||
head = f"这张表 {n} 列,有 {len(failed_cols)} 列({_columns_text(st.columns, failed_cols, count=False)})没写出来"
|
||||
else:
|
||||
head = f"这张表 {n} 列,表级的项没写出来"
|
||||
return f"{head}:{reason}。可以只重试这张表"
|
||||
return f"{head}:{reason}" if abandoned else f"{head}:{reason}。可以只重试这张表"
|
||||
|
||||
async def _part_done(self, name: str) -> None:
|
||||
self.tables[name].parts_done += 1
|
||||
@@ -1424,6 +1622,65 @@ class _Run:
|
||||
st.finished = True
|
||||
await self._hook(self.hooks.table_done, name, self._outcome(st))
|
||||
|
||||
async def _part_written(self, name: str) -> None:
|
||||
"""宽表写出了一段、后面还有段没结束:把这张表到这时拿到的全部内容交给调用方并入目录(停止、服务重启时不丢)。"""
|
||||
st = self.tables[name]
|
||||
await self._hook(self.hooks.part_written, name, PartWrite(copy.deepcopy(st.notes), len(st.written_parts)))
|
||||
|
||||
# ---------------------------------------------------------------- 防错事实:核对有效行过滤
|
||||
|
||||
async def _vet(self, st: _Table) -> None:
|
||||
"""表级那段写完,核对模型提议的有效行过滤(只核对一次)。
|
||||
|
||||
- 目录里人工确认、驳回过的有效行过滤:并入时不会采用这次的提议,不必再查库;
|
||||
- 没开数据剖析:照收,note 写「没有核对排除比例(数据源没有开数据剖析)」;
|
||||
- 开了:经剖析的只读守卫数排除比例(catalog_profile.check_valid_filter)。排除 0% 或 100% 的不收(这张表的日志
|
||||
记一句,不算失败);收下的 note 在依据后面写上剖析核对的结果,或者没有核对的原因。"""
|
||||
if st.vetted:
|
||||
return
|
||||
st.vetted = True
|
||||
item = st.notes.get("valid_filter")
|
||||
if not isinstance(item, dict):
|
||||
return
|
||||
held = (st.existing or {}).get("valid_filter")
|
||||
if isinstance(held, Mapping) and held.get("status") in ("confirmed", "rejected"):
|
||||
return
|
||||
from app.data import catalog_profile
|
||||
|
||||
if not self.profiling.enabled:
|
||||
found = catalog_profile.FilterCheck(True, catalog_profile.FILTER_UNCHECKED.format(
|
||||
why=catalog_profile.FILTER_NOT_ENABLED))
|
||||
else:
|
||||
try:
|
||||
found = await catalog_profile.check_valid_filter(self.source, st.name, dict(st.meta),
|
||||
str(item.get("value") or ""), settings=self.profiling)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001 - 核对是附加的:出了意外照收,写明没有核对
|
||||
from app.core.errors import describe_exception, first_line
|
||||
|
||||
logger.exception("起草数据目录:核对表 %s 的有效行过滤时出错", st.name)
|
||||
found = catalog_profile.FilterCheck(True, catalog_profile.FILTER_UNCHECKED.format(
|
||||
why=f"核对时出错:{first_line(e) or describe_exception(e)}"))
|
||||
if not found.keep:
|
||||
logger.info("起草数据目录:表 %s 的有效行过滤 %s 没有收下:%s", st.name, item.get("value"), found.dropped)
|
||||
st.notes.pop("valid_filter", None)
|
||||
why = found.dropped or found.note
|
||||
if found.rows is not None:
|
||||
why = LOG_FILTER_COUNTED.format(label=C.TABLE_FIELD_LABEL["valid_filter"], what=found.dropped,
|
||||
rows=f"{found.rows:,}")
|
||||
self._log(st, why, str(item.get("value") or ""))
|
||||
return
|
||||
item["note"] = _sentences(item.get("note"), found.note)
|
||||
|
||||
@staticmethod
|
||||
def _log(st: _Table, why: str, condition: str) -> None:
|
||||
"""这张表的日志记一句:模型提议的有效记录条件没有收下(不算失败)。"""
|
||||
condition = " ".join(condition.split())
|
||||
if len(condition) > _LOG_CONDITION_MAX:
|
||||
condition = condition[:_LOG_CONDITION_MAX] + "…"
|
||||
st.log.append(LOG_FILTER_DROPPED.format(why=why, condition=condition))
|
||||
|
||||
@staticmethod
|
||||
async def _hook(fn: Callable[..., Awaitable[None]] | None, *args: Any) -> None:
|
||||
if fn is None:
|
||||
@@ -1502,7 +1759,12 @@ class _Run:
|
||||
for group in meta.get("unique") or []:
|
||||
if isinstance(group, list) and group:
|
||||
lines.append(labels["unique"].format(cols="、".join(group)))
|
||||
confirmed = self._confirmed_lines(st, piece)
|
||||
shown = self._confirmed_for(st, piece)
|
||||
codes = self._codes_lines(st, piece, shown)
|
||||
if codes:
|
||||
lines.append(labels["codes"])
|
||||
lines += codes
|
||||
confirmed = C._notes_lines(shown, meta=st.meta, system_notes=False)
|
||||
if confirmed:
|
||||
lines.append(labels["confirmed"])
|
||||
lines += confirmed
|
||||
@@ -1522,19 +1784,48 @@ class _Run:
|
||||
line = labels["column"].format(name=col["name"], type=col.get("type") or "")
|
||||
return line + ("," + ",".join(marks) if marks else "")
|
||||
|
||||
@staticmethod
|
||||
def _confirmed_lines(st: _Table, piece: _Piece) -> list[str]:
|
||||
"""已确认、有确证的项(以此为准的背景):整表全给;其余给表级的项和这一段的列,关系只在要写表级的项时给。"""
|
||||
confirmed = C._confirmed_only(st.existing)
|
||||
if not st.whole(piece):
|
||||
wanted = set(piece.columns)
|
||||
kept = {key: item for key, item in C._slots(confirmed).items()
|
||||
if key[0] == "t" or (key[0] == "c" and key[1] in wanted) or (key[0] == "r" and piece.table_level)}
|
||||
confirmed = C._assemble(kept)
|
||||
return C._notes_lines(confirmed, meta=st.meta, system_notes=False)
|
||||
def _confirmed_for(self, st: _Table, piece: _Piece) -> dict[str, Any]:
|
||||
"""已确认、有确证的项(以此为准的背景):整表全给;其余给表级的项和这一段的列,关系只在要写表级的项时给。
|
||||
|
||||
遮罩的列上的码值不给,人工确认过的也一样:码值就是这一列的取值(同 _codes_lines,也同表助手的背景、导入目录的
|
||||
规矩)。以前只有新加的码值背景挡住了,这里照给。这一列别的确认过的项(中文名、含义……)照给。"""
|
||||
whole = st.whole(piece)
|
||||
wanted = set(piece.columns)
|
||||
kept = {key: item for key, item in C._slots(C._confirmed_only(st.existing)).items()
|
||||
if not (key[0] == "c" and key[2] == "codes" and key[1].lower() in self.masked)
|
||||
and (whole or key[0] == "t" or (key[0] == "c" and key[1] in wanted)
|
||||
or (key[0] == "r" and piece.table_level))}
|
||||
return C._assemble(kept)
|
||||
|
||||
def _codes_lines(self, st: _Table, piece: _Piece, shown: Mapping[str, Any]) -> list[str]:
|
||||
"""开了数据剖析的源:目录里记下的码值(剖析取过的取值和已知含义)交给模型,判断有效行过滤、列的含义用。要写表级
|
||||
的项的给全部列的,只写几列的给这几列的;遮罩的列、驳回的码值不给;「已确认的目录项」里已经有的不重复。"""
|
||||
if not self.profiling.enabled:
|
||||
return []
|
||||
labels = C.CATALOG_DRAFT_PROMPT_LABELS
|
||||
wanted = set(st.columns) if piece.table_level else set(piece.columns)
|
||||
known = C.visible_notes(st.existing).get("columns") or {}
|
||||
given = (shown.get("columns") or {}) if isinstance(shown.get("columns"), Mapping) else {}
|
||||
out: list[str] = []
|
||||
for col in st.columns:
|
||||
item = (known.get(col) or {}).get("codes")
|
||||
if col not in wanted or col.lower() in self.masked or not isinstance(item, Mapping):
|
||||
continue
|
||||
if "codes" in (given.get(col) or {}) or not isinstance(item.get("value"), Mapping) or not item["value"]:
|
||||
continue
|
||||
out.append(labels["code"].format(name=col, codes=C._codes_text(item["value"])))
|
||||
return out
|
||||
|
||||
|
||||
__all__ = ["BUDGET_WORDS", "Background", "CallReport", "FAILURE_LABEL", "Hooks", "ModelOutcome", "NOTICE", "REASON_CALL",
|
||||
"REASON_CAPPED", "REASON_EXHAUSTED", "REASON_FORMAT", "REASON_HALTED", "REASON_HOLLOW", "REASON_MISSING",
|
||||
"REASON_RETRIED", "REASON_SLOW", "REASON_SPLIT", "REASON_TIMEOUT", "REASON_TRUNCATED", "REASON_UNANSWERED",
|
||||
"SEG", "classify_failure", "model_gate", "parts_for", "run_model_draft"]
|
||||
def _sentences(*parts: Any) -> str:
|
||||
"""几句话连成一段:各句去掉句末的句号,用句号隔开(「依据:……。剖析核对:……」)。"""
|
||||
texts = [str(p).strip().rstrip("。") for p in parts if isinstance(p, str) and p.strip()]
|
||||
return "。".join(texts)
|
||||
|
||||
|
||||
__all__ = ["BUDGET_WORDS", "Background", "CallReport", "FAILURE_LABEL", "Hooks", "LOG_FILTER_COUNTED",
|
||||
"LOG_FILTER_DROPPED", "LOG_MAX", "ModelOutcome", "NOTICE", "PartWrite",
|
||||
"REASON_ABANDONED", "REASON_CALL", "REASON_CAPPED", "REASON_EXHAUSTED", "REASON_FORMAT", "REASON_HALTED",
|
||||
"REASON_HOLLOW", "REASON_MISSING", "REASON_OMITTED", "REASON_RETRIED", "REASON_SPLIT", "REASON_SPLIT_PARTS",
|
||||
"REASON_TIMEOUT", "REASON_TRUNCATED", "REASON_UNANSWERED", "SEG", "classify_failure", "model_gate",
|
||||
"parts_for", "run_model_draft"]
|
||||
|
||||
@@ -6,11 +6,15 @@
|
||||
- **建任务**(create_job):同一个数据源同时只有一个排队或进行中的任务(DraftBusy;部分唯一索引兜底)。不指定表时
|
||||
按使用次数取前 20 张;表结构里没有的表照样建进任务、状态 skipped。起草要求(数据源级)和本次说明随任务记下快照。
|
||||
- **在后台起草**(manager.start):进程内的 asyncio 任务,引擎是 data/catalog_draft.py(分段、按失败原因处理)。
|
||||
每张表一结束就并入目录(catalog.write_draft,规则和同步起草一样:人工确认、驳回过的不动,模型这次没给的项不删
|
||||
以前起草的,乐观锁冲突重读重并);逐表的状态、到第几段、计数、模型留空的列,任务的用量(含超时、没写出来的调用
|
||||
次数)、当前的列数上限、提醒随时落库,接口读的是库里的状态。
|
||||
宽表每写出一段就并入目录(write_part:这一次合并不删任何东西),整张表结束(不管写全没写全)再按完整覆盖的规则
|
||||
收尾一次(catalog.write_draft,规则和同步起草一样:人工确认、驳回过的不动,模型这次没给的项不删以前起草的,只删
|
||||
已经不在表结构里的列的模型项,乐观锁冲突重读重并);新增、更新、删除的计数是各次写入的合计。逐表的状态、到第几段、
|
||||
写进目录的段数、块进度、计数、模型留空的列,任务的用量(含超时、没写出来的调用次数)、当前的列数上限、提醒随时落库,
|
||||
接口读的是库里的状态。
|
||||
- **停止**(cancel):排队的直接 cancelled;进行中的停掉正在进行的模型调用,没开始和没写完的表记 cancelled,已经写进
|
||||
目录的不回滚,任务最后是 cancelled。
|
||||
目录的(含宽表已经写出来的段)不回滚,任务最后是 cancelled。写过段的表 model_error 说清写进了几段、剩几段没起草。
|
||||
- **和同步起草互斥**(sync_draft):同步起草(POST /catalog/draft)进行期间,同一个源建任务、重试、导入(ensure_idle)
|
||||
都是 DraftBusy;有排队或进行中的任务时同步起草也是。进程内登记(单进程部署)。
|
||||
- **重试**(retry_tables + create_job):新任务,只含原任务里 failed、partial、cancelled 的表;用不用模型、本次说明沿用,
|
||||
起草要求取数据源当前的。
|
||||
- **服务重启**:启动时(startup)把排队、进行中的任务标成 interrupted,没结束的表记 cancelled、写明「服务重启,起草
|
||||
@@ -26,11 +30,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy import delete, func, or_, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -62,6 +67,10 @@ BLANK_SAMPLE = 5
|
||||
STOP_GRACE_S = 10.0
|
||||
|
||||
INTERRUPTED_ERROR = "服务重启,起草中断"
|
||||
#: 停止、中断时写过段的表(宽表每写出一段就并入目录):写进了几段、剩几段没起草
|
||||
STOPPED_WRITTEN = "已停止:已写进目录 {n} 段,剩下 {m} 段没起草"
|
||||
INTERRUPTED_WRITTEN = "已中断:已写进目录 {n} 段,剩下 {m} 段没起草"
|
||||
_WRITTEN = {"cancelled": STOPPED_WRITTEN, "interrupted": INTERRUPTED_WRITTEN}
|
||||
UNFINISHED_ERROR = "起草结束时这张表还没有写完,请重试这张表"
|
||||
WRITE_ERROR = "写入数据目录时出错:{line}"
|
||||
JOB_ERROR = "起草任务出错:{line}"
|
||||
@@ -162,6 +171,11 @@ def task_dict(task: CatalogDraftTask) -> dict[str, Any]:
|
||||
"columns": task.column_count,
|
||||
"parts_total": task.parts_total,
|
||||
"parts_done": task.parts_done,
|
||||
# 宽表每写出一段就并入目录:已经写进目录的段数(停止、服务重启时这些段留着)
|
||||
"parts_written": task.parts_written or 0,
|
||||
# 块进度:这张表实际分成的块数和已结束的块数(段被缩小、拆成两半后会变多);不用模型时为 null
|
||||
"chunks_total": task.chunks_total,
|
||||
"chunks_done": task.chunks_done,
|
||||
"added": task.added,
|
||||
"updated": task.updated,
|
||||
"removed": task.removed,
|
||||
@@ -170,6 +184,8 @@ def task_dict(task: CatalogDraftTask) -> dict[str, Any]:
|
||||
# 模型拿不准、留空了的列(中文名和含义都没给):几列、最多 5 个列名(按表结构的顺序)。表的状态不因此变
|
||||
"blank_columns": task.blank_columns or 0,
|
||||
"blank_sample": list(task.blank_sample or []),
|
||||
# 起草时记下的几句话,不算失败(模型提议的有效记录条件没有收下的原因:没通过校验、剖析核对排除 0% 或 100%)
|
||||
"log": list(task.log or []),
|
||||
"error": task.error,
|
||||
"model_error": task.model_error,
|
||||
"started_at": _iso(task.started_at),
|
||||
@@ -237,21 +253,39 @@ async def job_brief(session: AsyncSession, job: CatalogDraftJob | None) -> dict[
|
||||
|
||||
|
||||
async def last_drafts(session: AsyncSession, source_id: str) -> dict[str, dict[str, Any]]:
|
||||
"""每张表最近一次起草任务里的那一项:{表名: {status, job_id, finished_at, error, model_error, blank_columns}}。
|
||||
任务还在进行,就是进行中的状态。只看最近 LAST_DRAFT_JOBS 个任务;每张表取哪一项在库里挑好(清单页起草时会反复取)。"""
|
||||
"""每张表最近一次起草任务里的那一项:{表名: {status, job_id, finished_at, error, model_error, blank_columns,
|
||||
parts_written, blank_columns_from}}。任务还在进行,就是进行中的状态。只看最近 LAST_DRAFT_JOBS 个任务;每张表取哪一项
|
||||
在库里挑好(清单页起草时会反复取)。
|
||||
|
||||
blank_columns_from:最近一次结束于 ok / partial 的用模型起草记下的留空列数(数字),没有是 null。之后的起草停止了、
|
||||
中断了、而且没有写进新内容(parts_written 为 0)时照样带着,界面据此继续提示留空的列;之后有起草写进了新内容(停止
|
||||
前已经写了几段、或者正在写)就是 null:以前的留空列数可能已经不对了。不用模型的起草不改模型留空的列,不算。"""
|
||||
recent = (select(CatalogDraftJob.id).where(CatalogDraftJob.source_id == source_id)
|
||||
.order_by(CatalogDraftJob.created_at.desc()).limit(LAST_DRAFT_JOBS)).scalar_subquery()
|
||||
rank = func.row_number().over(partition_by=CatalogDraftTask.table_name,
|
||||
order_by=(CatalogDraftJob.created_at.desc(), CatalogDraftTask.seq)).label("rank")
|
||||
|
||||
def ranked() -> Any:
|
||||
return func.row_number().over(partition_by=CatalogDraftTask.table_name,
|
||||
order_by=(CatalogDraftJob.created_at.desc(), CatalogDraftTask.seq)).label("rank")
|
||||
|
||||
inner = (select(CatalogDraftTask.table_name, CatalogDraftTask.status, CatalogDraftTask.job_id,
|
||||
CatalogDraftTask.finished_at, CatalogDraftTask.error, CatalogDraftTask.model_error,
|
||||
CatalogDraftTask.blank_columns, rank)
|
||||
CatalogDraftTask.blank_columns, CatalogDraftTask.parts_written, ranked())
|
||||
.join(CatalogDraftJob, CatalogDraftJob.id == CatalogDraftTask.job_id)
|
||||
.where(CatalogDraftJob.id.in_(recent))).subquery()
|
||||
rows = (await session.execute(select(inner).where(inner.c.rank == 1))).mappings()
|
||||
# 每张表最近一次「写进了模型起草的内容」的用模型起草:结束于 ok / partial 的,或者写过段的(停止、中断、进行中)
|
||||
wrote = (select(CatalogDraftTask.table_name, CatalogDraftTask.status, CatalogDraftTask.blank_columns, ranked())
|
||||
.join(CatalogDraftJob, CatalogDraftJob.id == CatalogDraftTask.job_id)
|
||||
.where(CatalogDraftJob.id.in_(recent), CatalogDraftTask.parts_total.is_not(None),
|
||||
or_(CatalogDraftTask.status.in_(("ok", "partial")),
|
||||
func.coalesce(CatalogDraftTask.parts_written, 0) > 0))).subquery()
|
||||
blank_from = {row["table_name"]: (row["blank_columns"] or 0) if row["status"] in ("ok", "partial") else None
|
||||
for row in (await session.execute(select(wrote).where(wrote.c.rank == 1))).mappings()}
|
||||
return {row["table_name"]: {"status": row["status"], "job_id": row["job_id"],
|
||||
"finished_at": _iso(row["finished_at"]), "error": row["error"],
|
||||
"model_error": row["model_error"], "blank_columns": row["blank_columns"] or 0}
|
||||
"model_error": row["model_error"], "blank_columns": row["blank_columns"] or 0,
|
||||
"parts_written": row["parts_written"] or 0,
|
||||
"blank_columns_from": blank_from.get(row["table_name"])}
|
||||
for row in rows}
|
||||
|
||||
|
||||
@@ -264,12 +298,43 @@ def busy_message(row: Any) -> str:
|
||||
return f"数据源「{getattr(row, 'name', '')}」正在起草数据目录,请等这次起草结束(或先停止它)再开始新的起草"
|
||||
|
||||
|
||||
def sync_busy_message(row: Any) -> str:
|
||||
return f"数据源「{getattr(row, 'name', '')}」正在起草数据目录(同步起草),请等这次起草结束再开始新的起草"
|
||||
|
||||
|
||||
#: 正在同步起草(POST /catalog/draft)的数据源。进程内登记:服务端是单进程部署,多进程部署时要换成库里的锁
|
||||
_SYNC: set[str] = set()
|
||||
|
||||
|
||||
def sync_drafting(source_id: str) -> bool:
|
||||
"""这个源正在同步起草。"""
|
||||
return source_id in _SYNC
|
||||
|
||||
|
||||
async def ensure_idle(session: AsyncSession, row: Any) -> None:
|
||||
"""这个源没有排队或进行中的任务;有就抛 DraftBusy。"""
|
||||
"""这个源没有在起草:没有排队或进行中的任务,也没有在同步起草。有就抛 DraftBusy。建任务、重试、导入目录之前都调它。"""
|
||||
if row.id in _SYNC:
|
||||
raise DraftBusy(sync_busy_message(row))
|
||||
if await active_job(session, row.id) is not None:
|
||||
raise DraftBusy(busy_message(row))
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def sync_draft(session: AsyncSession, row: Any) -> AsyncIterator[None]:
|
||||
"""同步起草期间登记这个源:同一个源建任务、重试、导入、再开一次同步起草都抛 DraftBusy;有排队或进行中的任务时
|
||||
这里就抛 DraftBusy。先登记、再查库:查库要让出事件循环,先登记才不会和同时建的任务互相看不见(建任务那边写库之后
|
||||
也再看一次,见 create_job)。"""
|
||||
if row.id in _SYNC:
|
||||
raise DraftBusy(sync_busy_message(row))
|
||||
_SYNC.add(row.id)
|
||||
try:
|
||||
if await active_job(session, row.id) is not None:
|
||||
raise DraftBusy(busy_message(row))
|
||||
yield
|
||||
finally:
|
||||
_SYNC.discard(row.id)
|
||||
|
||||
|
||||
async def check_model(session: AsyncSession) -> None:
|
||||
"""起草模型能不能用:不能用抛 catalog.CatalogModelUnavailable(只建模型对象,不发请求)。"""
|
||||
await catalog.resolve_draft_model(session)
|
||||
@@ -299,9 +364,10 @@ async def create_job(session: AsyncSession, row: Any, source: Any, *, tables: li
|
||||
continue
|
||||
seen.add(key)
|
||||
columns = len((all_tables.get(key) or {}).get("columns") or [])
|
||||
parts = catalog_draft.parts_for(columns) if use_model else None
|
||||
tasks.append(CatalogDraftTask(job_id=job.id, seq=len(tasks), table_name=key, status="pending",
|
||||
column_count=columns,
|
||||
parts_total=catalog_draft.parts_for(columns) if use_model else None))
|
||||
column_count=columns, parts_total=parts, parts_written=0,
|
||||
chunks_total=parts, chunks_done=0 if use_model else None))
|
||||
session.add(job)
|
||||
try:
|
||||
await session.flush()
|
||||
@@ -311,6 +377,12 @@ async def create_job(session: AsyncSession, row: Any, source: Any, *, tables: li
|
||||
# 部分唯一索引:另一个请求同时给这个源建了任务
|
||||
await session.rollback()
|
||||
raise DraftBusy(busy_message(row)) from e
|
||||
if row.id in _SYNC:
|
||||
# 写库的时候同一个源开始了同步起草(它查库时还没看到这个任务):这个任务不要了
|
||||
await session.execute(delete(CatalogDraftTask).where(CatalogDraftTask.job_id == job.id))
|
||||
await session.execute(delete(CatalogDraftJob).where(CatalogDraftJob.id == job.id))
|
||||
await session.commit()
|
||||
raise DraftBusy(sync_busy_message(row))
|
||||
return job, tasks
|
||||
|
||||
|
||||
@@ -347,8 +419,21 @@ async def cancel(session: AsyncSession, job: CatalogDraftJob) -> None:
|
||||
|
||||
async def _close(session: AsyncSession, job_id: str, job_status: str, job_error: str | None, task_status: str,
|
||||
task_error: str | None) -> None:
|
||||
"""收尾:还没结束的表记 task_status,任务(还没结束的话)记 job_status。"""
|
||||
"""收尾:还没结束的表记 task_status,任务(还没结束的话)记 job_status。停止、中断时写过段的表 model_error 说清写进了
|
||||
几段、剩几段没起草(已经写进去的段留在目录里)。"""
|
||||
now = utcnow()
|
||||
written = _WRITTEN.get(job_status)
|
||||
if written is not None:
|
||||
rows = (await session.execute(
|
||||
select(CatalogDraftTask.id, CatalogDraftTask.parts_written, CatalogDraftTask.parts_total)
|
||||
.where(CatalogDraftTask.job_id == job_id, CatalogDraftTask.status.in_(_OPEN),
|
||||
func.coalesce(CatalogDraftTask.parts_written, 0) > 0))).all()
|
||||
for task_id, n, total in rows:
|
||||
await session.execute(
|
||||
update(CatalogDraftTask).where(CatalogDraftTask.id == task_id, CatalogDraftTask.status.in_(_OPEN))
|
||||
.values(status=task_status, error=task_error, finished_at=now,
|
||||
model_error=written.format(n=n, m=max(0, int(total or n) - int(n))))
|
||||
.execution_options(synchronize_session=False))
|
||||
await session.execute(
|
||||
update(CatalogDraftTask).where(CatalogDraftTask.job_id == job_id, CatalogDraftTask.status.in_(_OPEN))
|
||||
.values(status=task_status, error=task_error, finished_at=now).execution_options(synchronize_session=False))
|
||||
@@ -389,7 +474,6 @@ class _JobRun:
|
||||
self._started: set[str] = set()
|
||||
self.source: Any = None
|
||||
self.actor: str | None = None
|
||||
self.at = ""
|
||||
|
||||
# ---------------------------------------------------------------- 库写入
|
||||
|
||||
@@ -468,7 +552,7 @@ class _JobRun:
|
||||
except catalog.CatalogModelUnavailable as e:
|
||||
raise _JobFailed(str(e)) from e
|
||||
existing = {t: e.notes for t, e in (await catalog.read_catalog(session, source.id)).items()}
|
||||
self.source, self.at = source, catalog.now_iso()
|
||||
self.source = source
|
||||
all_tables = (getattr(source, "schema_cache", None) or {}).get("tables") or {}
|
||||
names = [n for n in pending if isinstance(all_tables.get(n), dict)]
|
||||
gone = [n for n in pending if n not in names]
|
||||
@@ -478,7 +562,8 @@ class _JobRun:
|
||||
if use_model and names:
|
||||
await self._write(self._set_model, model_id)
|
||||
hooks = catalog_draft.Hooks(call_started=self._call_started, call_done=self._call_done,
|
||||
part_done=self._part_done, table_done=self._table_done)
|
||||
part_done=self._part_done, table_done=self._table_done,
|
||||
part_written=self._part_written, chunks_changed=self._chunks_changed)
|
||||
await catalog_draft.run_model_draft(model, source, names, existing=existing, background=background,
|
||||
hooks=hooks)
|
||||
else:
|
||||
@@ -505,6 +590,12 @@ class _JobRun:
|
||||
async def _table_done(self, table: str, outcome: catalog_draft.ModelOutcome | None) -> None:
|
||||
await self._write(self._write_table, table, outcome)
|
||||
|
||||
async def _part_written(self, table: str, written: catalog_draft.PartWrite) -> None:
|
||||
await self._write(self._write_part, table, written)
|
||||
|
||||
async def _chunks_changed(self, table: str, total: int, done: int) -> None:
|
||||
await self._write(self._set_chunks, table, total, done)
|
||||
|
||||
# ---------------------------------------------------------------- 库操作
|
||||
|
||||
def _task(self, *where: Any) -> Any:
|
||||
@@ -554,11 +645,41 @@ class _JobRun:
|
||||
.values(parts_done=CatalogDraftTask.parts_done + 1))
|
||||
await session.commit()
|
||||
|
||||
async def _set_chunks(self, session: AsyncSession, table: str, total: int, done: int) -> None:
|
||||
await session.execute(self._task(CatalogDraftTask.table_name == table, CatalogDraftTask.status.in_(_OPEN))
|
||||
.values(chunks_total=int(total), chunks_done=int(done)))
|
||||
await session.commit()
|
||||
|
||||
async def _write_part(self, session: AsyncSession, table: str, written: catalog_draft.PartWrite) -> None:
|
||||
"""宽表写出了一段:马上并入目录(这一次不删任何东西),记下写进去的段数、累加计数。写不进去只记日志:整张表结束
|
||||
时收尾那次还会把它带上。
|
||||
|
||||
新增、更新的项的修改时间是这一次写入的时间(每次写入各取一次):以前整个任务只在开始时取一次,起草了几十分钟的宽表,
|
||||
每一段写进去的项都记成任务开始那一刻,比任务的开始时间还早。"""
|
||||
try:
|
||||
found = await write_part(session, self.source, table, written.notes, at=catalog.now_iso(), actor=self.actor)
|
||||
except Exception: # noqa: BLE001 - 这一段写不进去不影响起草
|
||||
logger.exception("起草任务 %s 把表 %s 写出来的一段并入数据目录时出错", self.job_id, table)
|
||||
await session.rollback()
|
||||
return
|
||||
if found is None:
|
||||
logger.warning("起草任务 %s:表 %s 的数据目录正被修改,这一段等整张表写完时再并入", self.job_id, table)
|
||||
return
|
||||
stats, version = found
|
||||
await session.execute(
|
||||
self._task(CatalogDraftTask.table_name == table, CatalogDraftTask.status.in_(_OPEN))
|
||||
.values(parts_written=int(written.parts_written), version=version,
|
||||
added=CatalogDraftTask.added + stats.added, updated=CatalogDraftTask.updated + stats.updated,
|
||||
removed=CatalogDraftTask.removed + stats.removed))
|
||||
await session.commit()
|
||||
|
||||
async def _write_table(self, session: AsyncSession, table: str,
|
||||
outcome: catalog_draft.ModelOutcome | None) -> None:
|
||||
"""一张表结束:并入目录、写入,记下这张表的结果。"""
|
||||
"""一张表结束:并入目录、写入(宽表写过的段已经并入过,这里是整张表的收尾),记下这张表的结果。计数加在逐段写入
|
||||
的计数上。修改时间取这一次写入的时间(同 _write_part):逐段写进去、内容没变的项留着那一段的时间。"""
|
||||
try:
|
||||
result = await catalog.write_draft(session, self.source, table, outcome, at=self.at, actor=self.actor)
|
||||
result = await catalog.write_draft(session, self.source, table, outcome, at=catalog.now_iso(),
|
||||
actor=self.actor)
|
||||
except Exception as e: # noqa: BLE001 - 这一张写不进去不影响别的表
|
||||
from app.core.errors import describe_exception, first_line
|
||||
|
||||
@@ -568,13 +689,20 @@ class _JobRun:
|
||||
error=WRITE_ERROR.format(line=first_line(e) or describe_exception(e)),
|
||||
model_error=outcome.error if outcome is not None else None)
|
||||
values: dict[str, Any] = {
|
||||
"status": result.status, "added": result.added, "updated": result.updated, "removed": result.removed,
|
||||
"version": result.version, "model_items": result.model_items, "error": result.error,
|
||||
"status": result.status, "added": CatalogDraftTask.added + result.added,
|
||||
"updated": CatalogDraftTask.updated + result.updated, "removed": CatalogDraftTask.removed + result.removed,
|
||||
"model_items": result.model_items, "error": result.error,
|
||||
"model_error": result.model_error, "finished_at": utcnow(),
|
||||
"blank_columns": len(result.blank), "blank_sample": result.blank[:BLANK_SAMPLE] or None,
|
||||
}
|
||||
if result.version:
|
||||
values["version"] = result.version
|
||||
if outcome is not None:
|
||||
values["parts_done"] = outcome.parts_done
|
||||
values.update(parts_done=outcome.parts_done, chunks_total=outcome.chunks_total,
|
||||
chunks_done=outcome.chunks_done, log=list(outcome.log) or None)
|
||||
if not result.error:
|
||||
# 收尾写进去了:写出内容的段都在目录里了。没写进去(一直撞上别人刚改过)的照库里逐段写进去的段数
|
||||
values["parts_written"] = outcome.parts_written
|
||||
await session.execute(self._task(CatalogDraftTask.table_name == table, CatalogDraftTask.status.in_(_OPEN))
|
||||
.values(**values))
|
||||
await session.commit()
|
||||
@@ -594,6 +722,30 @@ class _JobRun:
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def write_part(session: AsyncSession, source: Any, table: str, notes: dict[str, Any], *, at: str,
|
||||
actor: str | None) -> tuple[catalog.MergeStats, int] | None:
|
||||
"""宽表写出来的一段并入目录:(计数, 写入后的版本);撞上别人刚改过、重读重并三次仍冲突返回 None。
|
||||
|
||||
数据库注释、外键、命名推断和这张表到这时拿到的模型起草,按 merge_notes 的规则并入(人工确认、驳回过的不动),
|
||||
**这一次不删任何东西**(covered 为空):删掉表结构里已经没有的、这一轮没再得出的,是整张表写完时 catalog.write_draft
|
||||
收尾那一次的事。内容没变不写、不升版本。"""
|
||||
structure = catalog.draft_structure(source, table)
|
||||
for _attempt in range(3):
|
||||
entry = await catalog.read_entry(session, source.id, table)
|
||||
version = entry.version if entry else 0
|
||||
merged, stats = catalog.merge_notes(entry.notes if entry else {}, structure.notes, at=at)
|
||||
merged, more = catalog.merge_notes(merged, notes, at=at)
|
||||
stats += more
|
||||
if entry is None and not merged:
|
||||
return stats, 0
|
||||
try:
|
||||
written = await catalog.write_entry(session, source.id, table, merged, if_version=version, actor=actor)
|
||||
except catalog.CatalogConflict:
|
||||
continue
|
||||
return stats, written.version
|
||||
return None
|
||||
|
||||
|
||||
class DraftJobs:
|
||||
"""进程内的起草任务(照 app/scheduler 的写法:一个 asyncio 任务一件事,停的时候给宽限)。"""
|
||||
|
||||
@@ -660,8 +812,8 @@ manager = DraftJobs()
|
||||
|
||||
|
||||
__all__ = ["ACTIVE", "BLANK_SAMPLE", "DraftBusy", "DraftFinished", "DraftJobs", "FINISHED", "GUIDANCE_MAX",
|
||||
"INTERRUPTED_ERROR", "MAX_TABLES", "NOTE_MAX", "NothingToRetry", "RETRYABLE", "TASK_DONE", "TASK_STATUSES",
|
||||
"TextTooLong",
|
||||
"INTERRUPTED_ERROR", "INTERRUPTED_WRITTEN", "MAX_TABLES", "NOTE_MAX", "NothingToRetry", "RETRYABLE",
|
||||
"STOPPED_WRITTEN", "TASK_DONE", "TASK_STATUSES", "TextTooLong",
|
||||
"active_job", "cancel", "check_model", "clean_text", "create_job", "ensure_idle", "guidance_of", "job_brief",
|
||||
"job_dict", "job_out", "last_drafts", "latest_job", "load", "manager", "retry_tables", "save_guidance",
|
||||
"startup", "tasks_of"]
|
||||
"startup", "sync_busy_message", "sync_draft", "sync_drafting", "tasks_of", "write_part"]
|
||||
|
||||
@@ -999,7 +999,8 @@ def _codes_finding(c: _CodesOutcome, value: dict[str, str]) -> dict[str, Any]:
|
||||
|
||||
def _patch_confirmed(rel: dict[str, Any], o: _RelationOutcome, at: str) -> bool:
|
||||
"""人工确认过的关系只补覆盖率和基数:覆盖率是测量值,照新的写;基数只在原来没有时补,不改人定的;
|
||||
备注只在原来为空、或者是剖析自己写的时候换成新的(只是日期变了不换)。状态、来源不动。返回改没改。"""
|
||||
备注只在原来为空、或者是剖析自己写的时候换成新的(只是日期变了不换)。状态、来源、确认时的依据(reason,保存提案时
|
||||
另存,不在备注里)不动。返回改没改。"""
|
||||
changed = False
|
||||
if rel.get("coverage") != o.coverage:
|
||||
rel["coverage"] = o.coverage
|
||||
@@ -1020,9 +1021,12 @@ def _patch_confirmed(rel: dict[str, Any], o: _RelationOutcome, at: str) -> bool:
|
||||
|
||||
def _patch_fk(rel: dict[str, Any], o: _RelationOutcome, at: str) -> bool:
|
||||
"""外键关系只补基数和「用数据核实过」:外键结构推的多对一可能其实是一对一,数据说了算。来源、状态、
|
||||
覆盖率都不动;备注同 _patch_confirmed(只是旧备注的日期不同不换)。返回改没改。"""
|
||||
覆盖率都不动;备注同 _patch_confirmed(只是旧备注的日期不同不换)。人工确认过的(值不变、保存即确认的外键关系,
|
||||
来源仍是 fk)同 _patch_confirmed:不改人定的基数,数据核实出的不一样时也不标「用数据核实过」,测量的说法照常写进
|
||||
备注。返回改没改。"""
|
||||
changed = False
|
||||
if o.cardinality_checked:
|
||||
overruled = rel.get("status") == "confirmed" and rel.get("cardinality") not in (None, o.cardinality)
|
||||
if o.cardinality_checked and not overruled:
|
||||
if rel.get("cardinality") != o.cardinality:
|
||||
rel["cardinality"] = o.cardinality
|
||||
changed = True
|
||||
@@ -1341,8 +1345,129 @@ async def _profile(session: AsyncSession, row: Any, source: Any, tables: list[st
|
||||
return report
|
||||
|
||||
|
||||
__all__ = ["CODES_MAX", "IN_CHUNK", "MAX_CODE_LEN", "MAX_LITERAL_LEN", "NOTE_PREFIX", "PROFILE_DEFAULT_TABLES",
|
||||
# ==========================================================================
|
||||
# 核对模型提议的有效行过滤:排除了多少行
|
||||
# ==========================================================================
|
||||
|
||||
#: 有效行过滤核对的说法(写进这一项的 note;前面是模型给的依据)
|
||||
FILTER_CHECKED = "剖析核对:排除 {share} 的行(共 {rows} 行)"
|
||||
FILTER_SAMPLED = "剖析核对:按前 {rows} 行统计,排除 {share} 的行"
|
||||
FILTER_SAMPLED_EDGE = "剖析核对:按前 {rows} 行统计,{what},请人工核对"
|
||||
FILTER_UNCHECKED = "没有核对排除比例({why})"
|
||||
FILTER_NOT_ENABLED = "数据源没有开数据剖析"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FilterCheck:
|
||||
"""核对一条有效行过滤的结果。
|
||||
|
||||
keep:收不收(排除 0% 或 100% 的不收:前者什么都没筛掉,后者把所有行都筛掉了,多半写错了);note:写进这一项 note
|
||||
的那句(剖析核对的排除比例,或者没有核对的原因);rows / excluded:数到的行数和其中被排除的行数(没核对是 None);
|
||||
sampled:表太大或不知道多大,只数了前若干行;dropped:不收的原因(记日志用)。"""
|
||||
|
||||
keep: bool
|
||||
note: str
|
||||
rows: int | None = None
|
||||
excluded: int | None = None
|
||||
sampled: bool = False
|
||||
dropped: str | None = None
|
||||
|
||||
|
||||
def excluded_share(excluded: int, rows: int) -> str:
|
||||
"""排除比例写成百分数:留一位小数(整数不带小数);不到 0.1% 的写「不足 0.1%」,不是全部的不四舍五入成 100%。"""
|
||||
if rows <= 0:
|
||||
return "0%"
|
||||
pct = excluded * 100 / rows
|
||||
if 0 < pct < 0.1:
|
||||
return "不足 0.1%"
|
||||
if 99.9 < pct < 100:
|
||||
return "超过 99.9%"
|
||||
return f"{pct:.1f}".rstrip("0").rstrip(".") + "%"
|
||||
|
||||
|
||||
def _unchecked(why: str) -> FilterCheck:
|
||||
return FilterCheck(True, FILTER_UNCHECKED.format(why=why))
|
||||
|
||||
|
||||
def _unsafe_literal(value: str) -> bool:
|
||||
"""拼不进核对查询的字符串取值:查询层用 SQLAlchemy 的 text() 执行,它会把字符串里的「:名字」当成绑定参数;
|
||||
反斜杠在 MySQL 上读法不定、守卫会整条拒掉;控制字符不该出现(同 sql_literal 的规矩)。"""
|
||||
return "\\" in value or bool(_CONTROL.search(value)) or bool(_BIND_LIKE.search(value))
|
||||
|
||||
|
||||
async def check_valid_filter(source: Any, table: str, meta: dict[str, Any] | None, condition: str, *,
|
||||
settings: ProfileSettings | None = None) -> FilterCheck:
|
||||
"""开了数据剖析的源:数一数这条有效行过滤排除了多少行(模型起草时提议的那条,data/catalog_draft.py 调)。从不抛异常。
|
||||
|
||||
经剖析的只读守卫(_Runner:guard.check(readonly=True)、单条时限、查询次数上限)发一条查询:只取条件里用到的列,
|
||||
先取前 max(sample_size, max_scan_rows) 行(大表不做整表扫描),再数总行数和不满足条件的行数(条件为 NULL 也算排除,
|
||||
和 WHERE 一样)。数到的不满前那么多行,就是整张表。
|
||||
|
||||
- 排除 0% 或 100% 的不收(keep 为假);只数了前若干行的照收,写明「请人工核对」;
|
||||
- 没开剖析、数据源停用、条件里有遮罩的列、视图、取值拼不进查询、查询失败或超时:照收,note 写没有核对的原因。
|
||||
"""
|
||||
from app.data import sqlcheck
|
||||
|
||||
options = getattr(source, "options", None)
|
||||
settings = settings or profile_settings(options)
|
||||
if not settings.enabled:
|
||||
return _unchecked(FILTER_NOT_ENABLED)
|
||||
if getattr(source, "enabled", True) is False:
|
||||
return _unchecked("数据源已停用")
|
||||
kind = getattr(source, "kind", None)
|
||||
parsed = sqlcheck.parse_valid_filter(condition, meta, kind=kind, table=table)
|
||||
if parsed.problem:
|
||||
return FilterCheck(False, parsed.problem, dropped=parsed.problem)
|
||||
masked = {c.lower() for c in data_engine.masked_columns(options)}
|
||||
if hit := [c for c in parsed.columns if c.lower() in masked]:
|
||||
return _unchecked(f"条件里的 {'、'.join(hit)} 在数据源设置中被遮罩,不取数")
|
||||
if (meta or {}).get("is_view"):
|
||||
return _unchecked("这张表是视图,剖析可能触发整个视图的计算")
|
||||
from sqlglot import exp
|
||||
|
||||
if any(n.is_string and _unsafe_literal(str(n.this)) for n in parsed.expression.find_all(exp.Literal)):
|
||||
return _unchecked("条件里的取值含有冒号或反斜杠,拼不进核对查询")
|
||||
try:
|
||||
dialect = SqlDialect(kind)
|
||||
except ValueError as e:
|
||||
return _unchecked(str(e))
|
||||
cap = max(int(settings.sample_size), int(settings.max_scan_rows))
|
||||
# 和存进目录的是同一条(catalog._model_facts 存的就是这个写法):列换成真名、按方言加引号,别的照原文
|
||||
cond = sqlcheck.canonical_valid_filter(parsed, quote=dialect.quote)
|
||||
cols = ", ".join(dialect.quote(c) for c in parsed.columns)
|
||||
inner = dialect.limit(f"SELECT {cols} FROM {dialect.table(meta, table)}", cap)
|
||||
sql = f"SELECT COUNT(*) AS n, SUM(CASE WHEN {cond} THEN 0 ELSE 1 END) AS x FROM ({inner}) s"
|
||||
run = _Runner(source, settings, dialect)
|
||||
try:
|
||||
res = await run(sql)
|
||||
except _Skip as e:
|
||||
return _unchecked(e.detail.removesuffix(",已跳过"))
|
||||
except _Stop as e:
|
||||
return _unchecked(_STOP_DETAIL[e.reason].format(max_queries=settings.max_queries,
|
||||
max_total_s=_fmt(settings.max_total_s)).removesuffix(",未检查"))
|
||||
except SnapshotTampered as e:
|
||||
return _unchecked(str(e))
|
||||
row = res.rows[0] if res.rows else [None, None]
|
||||
rows, excluded = _as_int(row[0]), _as_int(row[1]) if len(row) > 1 else None
|
||||
if not rows:
|
||||
return _unchecked("表里还没有数据")
|
||||
excluded = excluded or 0
|
||||
sampled = rows >= cap
|
||||
counted = f"{rows:,}"
|
||||
if excluded in (0, rows):
|
||||
what = "没有排除任何一行" if excluded == 0 else "所有行都被排除了"
|
||||
if sampled:
|
||||
return FilterCheck(True, FILTER_SAMPLED_EDGE.format(rows=counted, what=what), rows, excluded, True)
|
||||
return FilterCheck(False, FILTER_CHECKED.format(share=excluded_share(excluded, rows), rows=counted), rows,
|
||||
excluded, False, dropped=what)
|
||||
share = excluded_share(excluded, rows)
|
||||
note = (FILTER_SAMPLED if sampled else FILTER_CHECKED).format(share=share, rows=counted)
|
||||
return FilterCheck(True, note, rows, excluded, sampled)
|
||||
|
||||
|
||||
__all__ = ["CODES_MAX", "FILTER_CHECKED", "FILTER_NOT_ENABLED", "FILTER_SAMPLED", "FILTER_UNCHECKED", "FilterCheck",
|
||||
"IN_CHUNK", "MAX_CODE_LEN", "MAX_LITERAL_LEN", "NOTE_PREFIX", "PROFILE_DEFAULT_TABLES",
|
||||
"PROFILE_OPTION", "PROFILE_PROBE_TABLES", "ProfileBusy", "ProfileDisabled", "ProfileReport",
|
||||
"ProfileSettings", "ProfileSkip", "SqlDialect", "TableProfile", "TableSize", "ensure_enabled",
|
||||
"estimate_size", "profile_catalog", "profile_settings", "profile_settings_problem", "profile_settings_rejection",
|
||||
"sql_literal"]
|
||||
"ProfileSettings", "ProfileSkip", "SqlDialect", "TableProfile", "TableSize", "check_valid_filter",
|
||||
"ensure_enabled", "estimate_size", "excluded_share", "profile_catalog", "profile_settings",
|
||||
"profile_settings_problem", "profile_settings_rejection", "sql_literal"]
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
"""数据目录的审阅队列、批量审阅、表被哪些运行用过、审阅进度。
|
||||
|
||||
**为什么要有它。** 起草(数据库注释、外键、命名推断、数据剖析、模型)一次写出成百上千项,绝大多数停在「推断」:SQL 检查的
|
||||
错误级规则只认已确认、有确证的项,推断的项只作提示。逐表点开确认太慢,也看不出先确认哪几项。队列把推断的项摊平,按
|
||||
「这张表常不常用 × 这一项错了影响多大 × 这一项从哪来」排好,最该先看的在前面;勾几项一次确认、驳回或撤销审阅。
|
||||
|
||||
- **队列**(review_queue,GET /catalog/review-queue):只列推断(proposed)的项。priority = 使用次数权重 × 风险权重 ×
|
||||
来源权重,从高到低;同分按表名、路径排,结果稳定。
|
||||
- 使用次数权重:1 + log2(1 + 使用次数)。使用次数同目录清单(catalog.table_usage,清单的「使用 N 次」和排序、起草的默认
|
||||
范围都按它):按对数增长,常用表在前,但不至于让常用表上的中文名压过所有表上影响数字的项。
|
||||
- 风险权重(RISK_WEIGHT):有效记录条件、去重规则、业务日期、关联关系最高(直接改变数字),业务主键、度量类型次之
|
||||
(计数和能不能加总),码值、粒度、表类型再次,中文名、说明、含义、单位最低。
|
||||
- 来源权重(SOURCE_WEIGHT):模型起草、命名推断是猜的,比数据库注释优先;数据剖析(码值候选、业务日期、没核实的关系)
|
||||
居中。
|
||||
- 两组(kind):facts 影响数字的项(有效记录条件、去重规则、业务日期、关联关系、业务主键、度量类型);names 名称和说明
|
||||
(中文名、说明、含义、单位、码值、粒度、表类型)。表的「说明」和中文名、含义一样只是说明文字,归 names。
|
||||
- 表结构里已经没有的表、列上的项不列(确认了也没有用,目录页上另有清理的提示)。
|
||||
- field_label 是这一项给人看的位置:「表的有效记录条件」「列 status 的码值」「指向 members 的关联关系」(item_label,
|
||||
和表助手提案、导入冲突里的说法一致);列级的项另给 column。
|
||||
- table_usage 是所在表的使用次数(同清单)。reasons 用中文说为什么排在这里:常用表(被 N 次运行用到)、影响数字的项
|
||||
(有效记录条件)、模型起草,未确认……「被 N 次运行用到」数的是运行(同 /usage 的 total);同一次运行里查了几次、
|
||||
使用次数比运行次数多时两个都写(「被 2 次运行用到,使用 5 次」),和清单、运行列表上的数都对得上。
|
||||
- **批量审阅**(review_batch,POST /catalog/review/batch):逐表按 if_version 乐观锁,同一张表的几条在一次写入里完成,
|
||||
一张表冲突、写不进去不影响别的表;路径不对、表不存在逐条报(results 和请求一一对应)。审阅的规则同单项审阅
|
||||
(catalog.review_item)。
|
||||
- **被哪些运行用过**(table_runs,GET /catalog/{table}/usage):取法同使用次数(catalog.query_snapshot_tables),按运行
|
||||
去重,新的在前;total 是运行次数。
|
||||
- **进度**(review_progress,GET /catalog/review-progress):常用表(使用次数前 TOP_N 张,和起草对话框「使用次数最多的
|
||||
20 张表」是同一批:catalog.default_tables 的取法,用到的不够 TOP_N 张时按表结构的顺序补齐)、全部、影响数字的项各自的
|
||||
确认情况。项的范围和队列一样:表结构里没有的表、列不算。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections import Counter
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.data import catalog
|
||||
from app.db.models import Run
|
||||
|
||||
#: 队列的筛选:全部、影响数字的项、名称和说明
|
||||
KINDS = ("all", "facts", "names")
|
||||
#: 队列一页最多几项;批量审阅一次最多几条;「被哪些运行用过」一次最多几次运行
|
||||
QUEUE_LIMIT_MAX = 200
|
||||
BATCH_MAX = 200
|
||||
RUNS_LIMIT_MAX = 100
|
||||
#: 进度里的「常用表」取使用次数前几张(同起草的默认范围 catalog.DRAFT_DEFAULT_TABLES)
|
||||
TOP_N = 20
|
||||
|
||||
#: 每一项属于哪一组、风险几档:键是 (t, 字段) / (c, 字段) / (r, ""),档 4 最高
|
||||
FIELD_GROUP: dict[tuple[str, str], tuple[str, int]] = {
|
||||
("t", "valid_filter"): ("facts", 4), ("t", "dedup"): ("facts", 4), ("t", "business_date"): ("facts", 4),
|
||||
("r", ""): ("facts", 4),
|
||||
("t", "keys"): ("facts", 3), ("c", "measure"): ("facts", 3),
|
||||
("c", "codes"): ("names", 2), ("t", "grain"): ("names", 2), ("t", "kind"): ("names", 2),
|
||||
("t", "label"): ("names", 1), ("t", "description"): ("names", 1), ("c", "label"): ("names", 1),
|
||||
("c", "meaning"): ("names", 1), ("c", "unit"): ("names", 1),
|
||||
}
|
||||
RISK_WEIGHT = {4: 4.0, 3: 3.0, 2: 2.0, 1: 1.0}
|
||||
#: 来源权重:没写的(外键约束、人工填写,它们本来就不是推断)按 1
|
||||
SOURCE_WEIGHT = {"llm": 1.5, "name": 1.5, "profile": 1.25, "comment": 1.0}
|
||||
|
||||
#: 来源的界面叫法(和 frontend/src/lib/terms.ts 的 CATALOG_SOURCE_LABEL 一致,有测试核对)
|
||||
SOURCE_LABEL = {"comment": "数据库注释", "fk": "外键约束", "name": "命名推断", "profile": "数据剖析", "llm": "模型起草",
|
||||
"human": "人工填写"}
|
||||
RELATION_LABEL = "关联关系"
|
||||
#: reasons 的说法:常用表(被几次运行用到);使用次数比运行次数多(同一次运行里查了几次)时两个都写
|
||||
REASON_TOP = "常用表({used})"
|
||||
USED_RUNS = "被 {runs} 次运行用到"
|
||||
USED_RUNS_AND_QUERIES = "被 {runs} 次运行用到,使用 {n} 次"
|
||||
REASON_RISK = {4: "影响数字的项({label})", 3: "影响计数和汇总的项({label})", 2: "帮助理解取值的项({label})",
|
||||
1: "名称和说明({label})"}
|
||||
REASON_PENDING_CODES = "{n} 个码值的含义待填写"
|
||||
REASON_SOURCE = "{source},未确认"
|
||||
#: 码值含义为空(数据剖析写进来的候选)时的说法,同界面的 CODES_TEXT.pending
|
||||
CODE_PENDING = "含义待填写"
|
||||
|
||||
#: 批量审阅逐条结果的机读码
|
||||
CODE_CONFLICT = "catalog_conflict"
|
||||
CODE_PATH = "catalog_path_invalid"
|
||||
CODE_INVALID = "catalog_invalid"
|
||||
CODE_TABLE_MISSING = "table_missing"
|
||||
|
||||
Slot = catalog.Slot
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 一项的路径、叫法、显示
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
def slot_path(key: Slot) -> str:
|
||||
"""槽位 → 审阅接口的路径写法(catalog.parse_path 的反过来)。"""
|
||||
if key[0] == "t":
|
||||
return key[1]
|
||||
if key[0] == "c":
|
||||
return f"columns.{key[1]}.{key[2]}"
|
||||
return f"relations.{key[1]}"
|
||||
|
||||
|
||||
def field_of(key: Slot) -> tuple[str, str]:
|
||||
"""槽位 → FIELD_GROUP 的键。"""
|
||||
if key[0] == "r":
|
||||
return ("r", "")
|
||||
return (key[0], key[-1])
|
||||
|
||||
|
||||
def field_name(key: Slot) -> str:
|
||||
"""这一项的字段名:表级、列级的项是字段名,关联关系是 relation。"""
|
||||
return "relation" if key[0] == "r" else key[-1]
|
||||
|
||||
|
||||
def field_text(key: Slot) -> str:
|
||||
"""字段的界面叫法(不带列名):中文名、码值、有效记录条件、关联关系……"""
|
||||
if key[0] == "t":
|
||||
return catalog.TABLE_FIELD_LABEL.get(key[1], key[1])
|
||||
if key[0] == "c":
|
||||
return catalog.COLUMN_FIELD_LABEL.get(key[2], key[2])
|
||||
return RELATION_LABEL
|
||||
|
||||
|
||||
def item_label(key: Slot, item: Mapping[str, Any] | None = None) -> str:
|
||||
"""一项给人看的位置:「表的有效记录条件」「列 status 的码值」「指向 members 的关联关系」(不知道指向哪张表时写编号)。"""
|
||||
if key[0] == "r" and isinstance(item, Mapping) and item.get("to_table"):
|
||||
return f"指向 {item['to_table']} 的{RELATION_LABEL}"
|
||||
return catalog.describe_slot(key)
|
||||
|
||||
|
||||
def item_value(key: Slot, item: Mapping[str, Any]) -> Any:
|
||||
"""一项的值:关联关系是两端、基数和覆盖率,别的是 value。"""
|
||||
if key[0] == "r":
|
||||
return {k: item.get(k) for k in ("columns", "to_table", "to_columns", "cardinality", "coverage")}
|
||||
return item.get("value")
|
||||
|
||||
|
||||
def _percent(ratio: float) -> str:
|
||||
"""覆盖率写成百分数:整数不带小数,其余留一位;不到 100% 的不写成 100%。"""
|
||||
text = f"{ratio * 100:.1f}".rstrip("0").rstrip(".")
|
||||
return "99.9%" if text == "100" and ratio < 1 else f"{text}%"
|
||||
|
||||
|
||||
def relation_text(rel: Mapping[str, Any]) -> str:
|
||||
"""关联关系写成一行:member_id → members.id(多对一,覆盖率 98%)。"""
|
||||
left = "、".join(str(c) for c in rel.get("columns") or [])
|
||||
right = "、".join(f"{rel.get('to_table')}.{c}" for c in rel.get("to_columns") or [])
|
||||
extra = []
|
||||
if rel.get("cardinality") in catalog.CARDINALITY_LABEL:
|
||||
extra.append(catalog.CARDINALITY_LABEL[rel["cardinality"]])
|
||||
coverage = rel.get("coverage")
|
||||
if isinstance(coverage, (int, float)) and not isinstance(coverage, bool):
|
||||
extra.append(f"覆盖率 {_percent(float(coverage))}")
|
||||
return f"{left} → {right}" + (f"({','.join(extra)})" if extra else "")
|
||||
|
||||
|
||||
def display(key: Slot, value: Any) -> str:
|
||||
"""一项的值写成一行字,用界面上的说法(明细表、可累加、多对一……)。"""
|
||||
if key[0] == "r":
|
||||
return relation_text(value if isinstance(value, Mapping) else {})
|
||||
field_ = key[-1]
|
||||
if field_ == "kind":
|
||||
return catalog.UI_KIND_LABEL.get(value, str(value))
|
||||
if field_ == "measure":
|
||||
return catalog.UI_MEASURE_LABEL.get(value, str(value))
|
||||
if field_ == "keys" and isinstance(value, list):
|
||||
return "、".join(str(v) for v in value)
|
||||
if field_ == "codes" and isinstance(value, Mapping):
|
||||
return "、".join(f"{k}={v if str(v).strip() else CODE_PENDING}" for k, v in value.items())
|
||||
if field_ == "business_date" and isinstance(value, Mapping):
|
||||
parts = [str(value.get("column") or "")]
|
||||
if value.get("rule"):
|
||||
parts.append(f"规则:{value['rule']}")
|
||||
if value.get("timezone"):
|
||||
parts.append(f"时区:{value['timezone']}")
|
||||
return ";".join(parts)
|
||||
return value if isinstance(value, str) else str(value)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 排序
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
def usage_weight(usage: int) -> float:
|
||||
return 1.0 + math.log2(1 + max(0, int(usage or 0)))
|
||||
|
||||
|
||||
def priority(usage: int, key: Slot, source: Any) -> float:
|
||||
"""一项的优先级:使用次数权重 × 风险权重 × 来源权重(不取整;排序和交给界面时取四位小数)。"""
|
||||
_, tier = FIELD_GROUP[field_of(key)]
|
||||
return usage_weight(usage) * RISK_WEIGHT[tier] * SOURCE_WEIGHT.get(str(source), 1.0)
|
||||
|
||||
|
||||
def top_tables(tables: Mapping[str, Any], usage: Mapping[str, int]) -> list[str]:
|
||||
"""使用次数前 TOP_N 张表(同 catalog.default_tables,起草对话框的「使用次数最多的 20 张表」):使用次数从多到少,
|
||||
一样多的(包括都没用过的)按表结构里的顺序。表结构里不到 TOP_N 张时就是全部。"""
|
||||
order = {name: i for i, name in enumerate(tables)}
|
||||
return sorted(tables, key=lambda n: (-usage.get(n, 0), order[n]))[:TOP_N]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Usage:
|
||||
"""每张表用得多不多(没被用过的表不出现)。
|
||||
|
||||
queries:使用次数,同 catalog.table_usage(目录清单的「使用 N 次」、清单的排序、起草的默认范围);
|
||||
runs:用到它的运行有几次,同「被哪些运行用过」(table_runs)的 total。同一次运行里查了几次这张表,queries 比 runs 多。
|
||||
"""
|
||||
|
||||
queries: dict[str, int]
|
||||
runs: dict[str, int]
|
||||
|
||||
|
||||
async def table_usage_counts(session: AsyncSession, source: Any) -> Usage:
|
||||
"""一次读出每张表的使用次数和运行次数(同一份查询快照引用:catalog.query_snapshot_tables)。"""
|
||||
queries: Counter[str] = Counter()
|
||||
runs: dict[str, set[str]] = {}
|
||||
for run, hit in await catalog.query_snapshot_tables(session, source):
|
||||
queries.update(hit)
|
||||
for name in hit:
|
||||
runs.setdefault(name, set()).add(run)
|
||||
return Usage(dict(queries), {name: len(ids) for name, ids in runs.items()})
|
||||
|
||||
|
||||
def usage_text(queries: int, runs: int) -> str:
|
||||
"""「被 2 次运行用到」;使用次数比运行次数多时「被 2 次运行用到,使用 5 次」。"""
|
||||
if queries > runs:
|
||||
return USED_RUNS_AND_QUERIES.format(runs=f"{runs:,}", n=f"{queries:,}")
|
||||
return USED_RUNS.format(runs=f"{runs:,}")
|
||||
|
||||
|
||||
def _reasons(key: Slot, item: Mapping[str, Any], usage: int, runs: int, top: bool) -> list[str]:
|
||||
out: list[str] = []
|
||||
if usage > 0:
|
||||
used = usage_text(usage, runs)
|
||||
out.append(REASON_TOP.format(used=used) if top else used)
|
||||
_, tier = FIELD_GROUP[field_of(key)]
|
||||
out.append(REASON_RISK[tier].format(label=field_text(key)))
|
||||
if key[0] == "c" and key[2] == "codes" and isinstance(item.get("value"), Mapping):
|
||||
pending = sum(1 for v in item["value"].values() if not str(v).strip())
|
||||
if pending:
|
||||
out.append(REASON_PENDING_CODES.format(n=pending))
|
||||
source = str(item.get("source") or "")
|
||||
out.append(REASON_SOURCE.format(source=SOURCE_LABEL.get(source, source)))
|
||||
return out
|
||||
|
||||
|
||||
def live_slots(notes: Mapping[str, Any] | None, meta: Mapping[str, Any] | None) -> list[tuple[Slot, dict[str, Any]]]:
|
||||
"""一张表目录里还算数的项:表结构里已经没有的列上的项不算(不知道表结构时都算)。"""
|
||||
columns = catalog._column_names(meta)
|
||||
return [(key, item) for key, item in catalog._slots(notes).items()
|
||||
if not (key[0] == "c" and columns is not None and key[1] not in columns)]
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 队列
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
async def review_queue(session: AsyncSession, source: Any, *, kind: str = "all", table: str | None = None,
|
||||
limit: int = 50, offset: int = 0) -> dict[str, Any]:
|
||||
"""审阅队列:{total, items}。source 是读表结构用的源(上传源是绑定当前快照的视图)。"""
|
||||
cache = getattr(source, "schema_cache", None) or {}
|
||||
tables = cache.get("tables") or {}
|
||||
wanted = None
|
||||
if table:
|
||||
wanted = catalog.resolve_table_name(cache, table) or table
|
||||
entries = await catalog.read_catalog(session, source.id)
|
||||
usage = await table_usage_counts(session, source)
|
||||
top = set(top_tables(tables, usage.queries))
|
||||
items: list[dict[str, Any]] = []
|
||||
for name, entry in entries.items():
|
||||
meta = tables.get(name)
|
||||
if meta is None or (wanted is not None and name != wanted):
|
||||
continue
|
||||
used = usage.queries.get(name, 0)
|
||||
for key, item in live_slots(entry.notes, meta):
|
||||
if item.get("status") != "proposed":
|
||||
continue
|
||||
group, _ = FIELD_GROUP[field_of(key)]
|
||||
if kind != "all" and group != kind:
|
||||
continue
|
||||
value = item_value(key, item)
|
||||
items.append({
|
||||
"table_name": name,
|
||||
"path": slot_path(key),
|
||||
"field": field_name(key),
|
||||
"field_label": item_label(key, item),
|
||||
# 列级的项是哪一列(表级的项、关联关系为 null)
|
||||
"column": key[1] if key[0] == "c" else None,
|
||||
"value": value,
|
||||
"display": display(key, value),
|
||||
"source": item.get("source"),
|
||||
"status": item.get("status"),
|
||||
"note": item.get("note") if isinstance(item.get("note"), str) and item.get("note") else None,
|
||||
"priority": round(priority(used, key, item.get("source")), 4),
|
||||
"reasons": _reasons(key, item, used, usage.runs.get(name, 0), name in top),
|
||||
"table_usage": used,
|
||||
"version": entry.version,
|
||||
})
|
||||
items.sort(key=lambda i: (-i["priority"], i["table_name"], i["path"]))
|
||||
return {"total": len(items), "items": items[offset:offset + limit]}
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 批量审阅
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BatchItem:
|
||||
table_name: str
|
||||
path: str
|
||||
action: str
|
||||
if_version: int
|
||||
|
||||
|
||||
def _result(item: BatchItem, *, ok: bool, version: int | None = None, error: str | None = None,
|
||||
code: str | None = None) -> dict[str, Any]:
|
||||
return {"table_name": item.table_name, "path": item.path, "ok": ok, "version": version if ok else None,
|
||||
"error": error, "code": code}
|
||||
|
||||
|
||||
async def review_batch(session: AsyncSession, source: Any, items: Iterable[BatchItem], *,
|
||||
actor: str | None) -> list[dict[str, Any]]:
|
||||
"""批量审阅,返回逐条结果(和请求一一对应)。
|
||||
|
||||
表名按表结构认(全名、大小写不一致都认);表结构里没有、但还留着目录的也认(同单项审阅)。同一张表的几条按请求里的
|
||||
顺序在当前目录上依次审阅,一次写入:版本不符(if_version 和库里的不一样)、路径不对的那几条不做,其余照做;写入时撞上
|
||||
别人刚改过,这张表的几条都记冲突。"""
|
||||
items = list(items)
|
||||
cache = getattr(source, "schema_cache", None) or {}
|
||||
results: list[dict[str, Any] | None] = [None] * len(items)
|
||||
groups: dict[str, list[int]] = {}
|
||||
known: dict[str, str | None] = {}
|
||||
for i, item in enumerate(items):
|
||||
if item.table_name not in known:
|
||||
key = catalog.resolve_table_name(cache, item.table_name)
|
||||
if key is None and await catalog.read_entry(session, source.id, item.table_name) is not None:
|
||||
key = item.table_name
|
||||
known[item.table_name] = key
|
||||
key = known[item.table_name]
|
||||
if key is None:
|
||||
results[i] = _result(item, ok=False, code=CODE_TABLE_MISSING,
|
||||
error=f"数据源中没有表 {item.table_name},可能已被删除或尚未探查结构")
|
||||
continue
|
||||
groups.setdefault(key, []).append(i)
|
||||
at = catalog.now_iso()
|
||||
for key, indexes in groups.items():
|
||||
entry = await catalog.read_entry(session, source.id, key)
|
||||
current = entry.version if entry else 0
|
||||
notes: dict[str, Any] = entry.notes if entry else {}
|
||||
applied: list[int] = []
|
||||
for i in indexes:
|
||||
item = items[i]
|
||||
if item.if_version != current:
|
||||
results[i] = _result(item, ok=False, code=CODE_CONFLICT, error=str(catalog.CatalogConflict(key, current)))
|
||||
continue
|
||||
try:
|
||||
notes = catalog.review_item(notes, item.path, item.action, at=at)
|
||||
except catalog.CatalogPathError as e:
|
||||
results[i] = _result(item, ok=False, code=CODE_PATH, error=str(e))
|
||||
continue
|
||||
applied.append(i)
|
||||
if not applied:
|
||||
continue
|
||||
try:
|
||||
written = await catalog.write_entry(session, source.id, key, notes, if_version=current, actor=actor)
|
||||
except catalog.CatalogConflict as e:
|
||||
for i in applied:
|
||||
results[i] = _result(items[i], ok=False, code=CODE_CONFLICT, error=str(e))
|
||||
continue
|
||||
except catalog.CatalogInvalid as e:
|
||||
for i in applied:
|
||||
results[i] = _result(items[i], ok=False, code=CODE_INVALID, error=str(e))
|
||||
continue
|
||||
for i in applied:
|
||||
results[i] = _result(items[i], ok=True, version=written.version)
|
||||
return [r for r in results if r is not None]
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 被哪些运行用过
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
async def table_runs(session: AsyncSession, source: Any, table: str, *, limit: int = 20) -> dict[str, Any]:
|
||||
"""用过这张表的运行:{total, runs},新的在前(按开始时间,没开始的按建立时间)。取法同使用次数。"""
|
||||
run_ids = {run for run, hit in await catalog.query_snapshot_tables(session, source) if table in hit}
|
||||
if not run_ids:
|
||||
return {"total": 0, "runs": []}
|
||||
started = func.coalesce(Run.started_at, Run.created_at)
|
||||
rows = (await session.execute(
|
||||
select(Run.id, Run.workflow_id, Run.workflow_name, Run.run_class, Run.status, Run.started_at)
|
||||
.where(Run.id.in_(run_ids)).order_by(started.desc(), Run.id).limit(limit)
|
||||
)).all()
|
||||
return {
|
||||
"total": len(run_ids),
|
||||
"runs": [{"run_id": r.id, "workflow_id": r.workflow_id, "workflow_name": r.workflow_name or "",
|
||||
"run_class": r.run_class, "status": r.status,
|
||||
"started_at": r.started_at.isoformat() if r.started_at else None} for r in rows],
|
||||
}
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 进度
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
async def review_progress(session: AsyncSession, source: Any) -> dict[str, Any]:
|
||||
"""审阅进度:常用 TOP_N 张表(top_tables)、全部、影响数字的项,项按状态计数。全部的 items_total 里还有有确证的项
|
||||
(不单列)。"""
|
||||
tables = (getattr(source, "schema_cache", None) or {}).get("tables") or {}
|
||||
entries = await catalog.read_catalog(session, source.id)
|
||||
usage = await catalog.table_usage(session, source)
|
||||
top = set(top_tables(tables, usage))
|
||||
every: Counter[str] = Counter()
|
||||
head: Counter[str] = Counter()
|
||||
facts: Counter[str] = Counter()
|
||||
for name, entry in entries.items():
|
||||
meta = tables.get(name)
|
||||
if meta is None:
|
||||
continue
|
||||
for key, item in live_slots(entry.notes, meta):
|
||||
status = str(item.get("status"))
|
||||
every[status] += 1
|
||||
if name in top:
|
||||
head[status] += 1
|
||||
if FIELD_GROUP[field_of(key)][0] == "facts":
|
||||
facts[status] += 1
|
||||
return {
|
||||
"top_n": TOP_N,
|
||||
"top": {"tables": len(top), "items_total": sum(head.values()), "items_confirmed": head["confirmed"]},
|
||||
"all": {"items_total": sum(every.values()), "items_confirmed": every["confirmed"],
|
||||
"items_proposed": every["proposed"], "items_rejected": every["rejected"]},
|
||||
"facts": {"proposed": facts["proposed"], "confirmed": facts["confirmed"]},
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["BATCH_MAX", "BatchItem", "CODE_CONFLICT", "CODE_INVALID", "CODE_PATH", "CODE_TABLE_MISSING",
|
||||
"FIELD_GROUP", "KINDS", "QUEUE_LIMIT_MAX", "RISK_WEIGHT", "RUNS_LIMIT_MAX", "SOURCE_LABEL", "SOURCE_WEIGHT",
|
||||
"TOP_N", "Usage", "display", "field_name", "field_of", "field_text", "item_label", "item_value",
|
||||
"live_slots", "priority", "relation_text", "review_batch", "review_progress", "review_queue", "slot_path",
|
||||
"table_runs", "table_usage_counts", "top_tables", "usage_text", "usage_weight"]
|
||||
@@ -0,0 +1,545 @@
|
||||
"""数据目录的导出导入:本机整理好的目录带到部署实例上(GET /catalog/export、POST /catalog/import/preview、/import)。
|
||||
|
||||
**为什么要有它。** 目录(尤其是人工确认、驳回过的项)是在本机对着开发库一项项审出来的;部署实例连的是同一套业务库,
|
||||
却没有办法把这些结论带过去,只能在那边从头再审一遍。导出是一个 JSON 文件,导入先预览、再确认。
|
||||
|
||||
**文件**(export_catalog):{format: "agentlab-catalog", version: 1, exported_at, source: {name, kind}, guidance,
|
||||
tables: {表名: {notes, version}}},含全部状态的项,表结构里已经没有的表也带上(目录里有什么就导出什么)。
|
||||
|
||||
**导入的规矩**(plan_import,预览和导入共用这一份):
|
||||
|
||||
- 表按名字对:表结构里的写法、忽略大小写(name_key)、带 schema 前缀的对不上时去掉前缀再对(catalog.resolve_table_name)。
|
||||
只对目标数据源表结构里的表;对不上的列在 unmatched_tables。文件里两张表对上目标的同一张时,写法完全一样的那张优先,
|
||||
另一张算对不上。列名同样按表结构的写法、再忽略大小写对;业务主键、业务日期、关联关系两端的列和指向的表都换成目标的写法,
|
||||
关联关系的编号按目标的表名、列名重算(catalog.relation_id)。
|
||||
- 文件里已确认、已驳回的项照带:来源、状态、备注、确认时的依据(reason)、修改时间都不变。目标那一格空着就新增;目标是
|
||||
推断、有确证的就更新;目标也是已确认、已驳回的,和文件一样就保留,不一样(值或状态不同)就保留目标的、记一条冲突。关联
|
||||
关系的「一样」看两端和基数,覆盖率是测量值、不算。
|
||||
- 文件里推断、有确证的项只填目标的空缺,目标那一格有东西(哪怕是驳回)就保留目标的,不算冲突。这样的项不带确认时的依据
|
||||
(文件被手改过才会有):推断项上挂着依据,看起来像使用者确认过它。
|
||||
- 跳过并写原因:表结构里没有的列、业务主键和业务日期指着的列没有的、关联关系两端对不上的(指向的表、两端的列)、
|
||||
目标数据源遮罩的列上的码值(码值就是这一列的取值,遮罩了就不该进目录)、不认识的字段、取值不合规的项(说法同
|
||||
catalog.validate_notes)。
|
||||
- 有效记录条件是表级的一项、却写着列名,按列跳过的规矩拦不住它:按目标数据源的方言解析出用到的列
|
||||
(filter_columns_problem),不在目标的表结构里、是别的表的列、解析不了或不是一个条件、带子查询(没法核对子查询用到的表
|
||||
和列)的跳过。人工写的条件不限函数(模型起草时的那套白名单只管模型写的条件);列名大小写和目标不同、带本表表名的照带。
|
||||
- 逐表写(乐观锁,撞上别人刚改过就重读重算,最多三次),一张表写不进去只记在那张表上,不影响别的表。
|
||||
- 起草要求:勾了 include_guidance、文件里有、目标还没有时才写(action: replace);目标已有的不覆盖(keep);没勾或文件里
|
||||
没有是 none。
|
||||
- 文件格式不对(不是对象、格式标记或版本不对、表的目录不是对象、起草要求不是文字或超长)抛 TransferInvalid,接口回 422。
|
||||
|
||||
有排队或进行中的起草任务时不导入(接口先调 catalog_jobs.ensure_idle,409 catalog_draft_busy);目标数据源没有表结构时
|
||||
接口回 409(什么也对不上)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.data import catalog, catalog_jobs
|
||||
from app.data.catalog_review import item_label, slot_path
|
||||
from app.data.engine import masked_columns
|
||||
from app.data.names import name_key
|
||||
from app.data.sqlcheck import dialect_of
|
||||
|
||||
FORMAT = "agentlab-catalog"
|
||||
VERSION = 1
|
||||
FILE_SUFFIX = "-数据目录.json"
|
||||
_BAD_FILENAME = re.compile(r'[\\/:*?"<>|\x00-\x1f\x7f]')
|
||||
_NAME_MAX = 80
|
||||
_UNNAMED = "数据源"
|
||||
|
||||
#: 状态:照带的、只填空缺的
|
||||
_LOCKED = ("confirmed", "rejected")
|
||||
#: 状态的界面叫法(同 frontend/src/lib/terms.ts 的 CATALOG_STATUS_LABEL)
|
||||
_STATUS_LABEL = {"proposed": "推断", "verified": "已验证", "confirmed": "已确认", "rejected": "已驳回"}
|
||||
|
||||
#: 写入一直撞上别人刚改过(重读重算三次仍冲突)
|
||||
WRITE_CONFLICT_ERROR = "这张表的数据目录正被其他人修改,没有导入,请稍后重新导入"
|
||||
WRITE_ERROR = "写入这张表的数据目录时出错,没有导入:{line}"
|
||||
|
||||
|
||||
class TransferInvalid(ValueError):
|
||||
"""文件格式不对。message 给人看(接口回 422)。"""
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 导出
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
def attachment_name(source_name: str) -> str:
|
||||
"""附件名「<数据源名>-数据目录.json」。文件名里不能有的字换成下划线。"""
|
||||
name = _BAD_FILENAME.sub("_", source_name or "").strip(" .")[:_NAME_MAX].strip(" .")
|
||||
return f"{name or _UNNAMED}{FILE_SUFFIX}"
|
||||
|
||||
|
||||
def content_disposition(filename: str, source_id: str) -> str:
|
||||
"""附件头:中文名按 RFC 5987 写在 filename* 里;filename 给不认 filename* 的老客户端一个纯 ASCII 的名字。"""
|
||||
from urllib.parse import quote
|
||||
|
||||
ascii_id = re.sub(r"[^A-Za-z0-9]", "", source_id)[:8]
|
||||
return f"attachment; filename=\"agentlab-catalog-{ascii_id}.json\"; filename*=UTF-8''{quote(filename, safe='')}"
|
||||
|
||||
|
||||
async def export_catalog(session: AsyncSession, row: Any) -> dict[str, Any]:
|
||||
"""导出这个数据源的数据目录(row 是数据源记录,目录按它的 id 存)。"""
|
||||
entries = await catalog.read_catalog(session, row.id)
|
||||
return {
|
||||
"format": FORMAT,
|
||||
"version": VERSION,
|
||||
"exported_at": catalog.now_iso(),
|
||||
"source": {"name": row.name, "kind": row.kind},
|
||||
"guidance": catalog_jobs.guidance_of(row),
|
||||
"tables": {name: {"notes": entry.notes, "version": entry.version} for name, entry in sorted(entries.items())},
|
||||
}
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 读文件
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImportFile:
|
||||
tables: dict[str, dict[str, Any]]
|
||||
guidance: str | None
|
||||
|
||||
|
||||
def read_file(content: Any, *, include_guidance: bool) -> ImportFile:
|
||||
"""导出的那个对象 → ImportFile。界面直接交来文件的原文(一段 JSON 文字)也认。格式不对抛 TransferInvalid(说法给人看)。"""
|
||||
if isinstance(content, str):
|
||||
try:
|
||||
content = json.loads(content)
|
||||
except ValueError as e:
|
||||
raise TransferInvalid("文件内容不是合法的 JSON,请选择从 AgentLab 导出的数据目录文件") from e
|
||||
if not isinstance(content, Mapping):
|
||||
raise TransferInvalid("文件内容不是数据目录导出的格式,请选择从 AgentLab 导出的数据目录文件")
|
||||
if content.get("format") != FORMAT:
|
||||
raise TransferInvalid("这个文件不是 AgentLab 导出的数据目录(文件里的格式标记不对),请重新选择文件")
|
||||
version = content.get("version")
|
||||
if version != VERSION:
|
||||
raise TransferInvalid(f"这个数据目录文件的格式版本是「{version}」,当前只能导入第 {VERSION} 版的文件,"
|
||||
"请用同一版本的 AgentLab 重新导出")
|
||||
tables = content.get("tables")
|
||||
if not isinstance(tables, Mapping):
|
||||
raise TransferInvalid("数据目录文件里没有表的目录,文件可能不完整,请重新导出")
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for name, body in tables.items():
|
||||
notes = body.get("notes") if isinstance(body, Mapping) else None
|
||||
if not isinstance(name, str) or not name.strip() or not isinstance(notes, Mapping):
|
||||
raise TransferInvalid(f"数据目录文件里表「{str(name)[:60]}」的目录格式不正确,文件可能被改动过,请重新导出")
|
||||
out[name] = copy.deepcopy(dict(notes))
|
||||
guidance = content.get("guidance")
|
||||
if guidance is not None and not isinstance(guidance, str):
|
||||
raise TransferInvalid("数据目录文件里的起草要求格式不正确,文件可能被改动过,请重新导出")
|
||||
text = guidance.strip() if isinstance(guidance, str) else ""
|
||||
if include_guidance and len(text) > catalog_jobs.GUIDANCE_MAX:
|
||||
raise TransferInvalid(f"数据目录文件里的起草要求有 {len(text)} 字,超过了 {catalog_jobs.GUIDANCE_MAX} 字的上限,"
|
||||
"请删减后再导入,或者不导入起草要求")
|
||||
return ImportFile(tables=out, guidance=text or None)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 对表、对列
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
def match_tables(file_tables: list[str], schema_cache: Mapping[str, Any]) -> tuple[dict[str, str], list[str]]:
|
||||
"""文件里的表 → 目标表结构里的键。返回 ({文件里的表名: 目标表名}, 对不上的表名)。写法完全一样的先认领。"""
|
||||
tables = (schema_cache or {}).get("tables") or {}
|
||||
matched: dict[str, str] = {}
|
||||
claimed: set[str] = set()
|
||||
ordered = sorted(file_tables, key=lambda n: n not in tables)
|
||||
for name in ordered:
|
||||
key = catalog.resolve_table_name(schema_cache, name)
|
||||
if key is None or key in claimed:
|
||||
continue
|
||||
matched[name] = key
|
||||
claimed.add(key)
|
||||
unmatched = [name for name in file_tables if name not in matched]
|
||||
return {name: matched[name] for name in file_tables if name in matched}, unmatched
|
||||
|
||||
|
||||
class _Columns:
|
||||
"""一张目标表的列:按表结构的写法认,再忽略大小写认(只认唯一对上的)。"""
|
||||
|
||||
def __init__(self, meta: Mapping[str, Any] | None) -> None:
|
||||
names = [str(c.get("name")) for c in (meta or {}).get("columns") or [] if isinstance(c, Mapping)
|
||||
and c.get("name") is not None]
|
||||
self.names = set(names)
|
||||
self._loose: dict[str, list[str]] = {}
|
||||
for name in names:
|
||||
self._loose.setdefault(name_key(name), []).append(name)
|
||||
|
||||
def find(self, name: Any) -> str | None:
|
||||
if not isinstance(name, str):
|
||||
return None
|
||||
if name in self.names:
|
||||
return name
|
||||
found = self._loose.get(name_key(name)) or []
|
||||
return found[0] if len(found) == 1 else None
|
||||
|
||||
|
||||
def filter_columns_problem(condition: Any, meta: Mapping[str, Any] | None, *, kind: str | None, table: str,
|
||||
also: Iterable[str] = (), where: str = "表结构") -> str | None:
|
||||
"""有效记录条件用到的列是不是都在这张表的表结构里(给人看的一句话;没问题返回 None)。
|
||||
|
||||
按数据源的方言(sqlcheck.dialect_of)解析成一个条件:解析不了、不止一句、是一条查询、带子查询(没法核对子查询用到的
|
||||
表和列)的不收;列不带表名,或带这张表的表名(table、表结构里的 qualified、also 里的写法,比如文件里的表名;忽略大小写、
|
||||
去掉 schema 前缀),列名忽略大小写在表结构里。不限函数、运算。不是文字的交给合规检查说(返回 None);数据源类型
|
||||
没有对应的方言时没法解析,不核对。where 是说法里「表结构」的叫法(导入时是「目标的表结构」)。"""
|
||||
if not isinstance(condition, str) or not condition.strip():
|
||||
return None
|
||||
dialect = dialect_of(kind)
|
||||
if dialect is None:
|
||||
return None
|
||||
import sqlglot
|
||||
from sqlglot import exp
|
||||
|
||||
try:
|
||||
parsed = [s for s in sqlglot.parse(condition.strip(), read=dialect) if s is not None]
|
||||
except Exception: # noqa: BLE001 - 语法错、方言不支持的写法
|
||||
parsed = []
|
||||
if len(parsed) != 1 or isinstance(parsed[0], exp.Query) or not isinstance(parsed[0], exp.Condition):
|
||||
return f"有效记录条件不是一个能解析的 SQL 条件(按 {kind} 的写法),没法核对它用到的列"
|
||||
expression = parsed[0]
|
||||
if any(isinstance(node, (exp.Query, exp.Subquery)) for node in expression.walk()):
|
||||
return "有效记录条件里有子查询,没法核对子查询用到的表和列"
|
||||
own = {name_key(str(n).rsplit(".", 1)[-1]) for n in (table, (meta or {}).get("qualified"), *also) if n}
|
||||
columns = {str(c.get("name")).lower() for c in (meta or {}).get("columns") or []
|
||||
if isinstance(c, Mapping) and c.get("name") is not None}
|
||||
foreign: list[str] = []
|
||||
missing: list[str] = []
|
||||
for column in expression.find_all(exp.Column):
|
||||
if column.is_star:
|
||||
continue
|
||||
if column.table and name_key(column.table) not in own:
|
||||
foreign.append(column.sql(dialect=dialect))
|
||||
elif column.name.lower() not in columns:
|
||||
missing.append(column.name)
|
||||
if foreign:
|
||||
return f"有效记录条件引用了别的表的列({'、'.join(dict.fromkeys(foreign))}),只能用这张表的列"
|
||||
if missing:
|
||||
return f"有效记录条件里的列「{'、'.join(dict.fromkeys(missing))}」不在{where}里"
|
||||
return None
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 一张表怎么并
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class TablePlan:
|
||||
"""文件里一张表并进目标的计划。writes 是要写进目标的项(目标的槽位 → 项)。"""
|
||||
|
||||
table_name: str
|
||||
matched_as: str
|
||||
add: int = 0
|
||||
update: int = 0
|
||||
keep: int = 0
|
||||
conflicts: list[dict[str, Any]] = field(default_factory=list)
|
||||
skipped: list[dict[str, Any]] = field(default_factory=list)
|
||||
writes: dict[catalog.Slot, dict[str, Any]] = field(default_factory=dict)
|
||||
version: int = 0
|
||||
error: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {"table_name": self.table_name, "matched_as": self.matched_as, "add": self.add, "update": self.update,
|
||||
"keep": self.keep, "conflicts": self.conflicts, "skipped": self.skipped, "version": self.version,
|
||||
"error": self.error}
|
||||
|
||||
|
||||
def _raw_items(notes: Mapping[str, Any]) -> list[tuple[str, catalog.Slot | None, Any, str | None]]:
|
||||
"""文件里一张表的每一项:(文件里的路径, 槽位, 项, 不认的原因)。不认识的字段、不是对象的列也逐个列出来(槽位为 None)。"""
|
||||
out: list[tuple[str, catalog.Slot | None, Any, str | None]] = []
|
||||
for name, item in notes.items():
|
||||
if name in catalog.TABLE_FIELDS:
|
||||
out.append((name, ("t", name), item, None))
|
||||
elif name not in ("columns", "relations"):
|
||||
out.append((str(name), None, item, f"数据目录里不认识的字段「{name}」"))
|
||||
columns = notes.get("columns", {})
|
||||
if not isinstance(columns, Mapping):
|
||||
out.append(("columns", None, columns, "列的目录应为以列名为键的对象"))
|
||||
columns = {}
|
||||
for col, items in columns.items():
|
||||
if not isinstance(items, Mapping):
|
||||
out.append((f"columns.{col}", None, items, f"列 {col} 的目录应为对象"))
|
||||
continue
|
||||
for name, item in items.items():
|
||||
path = f"columns.{col}.{name}"
|
||||
if name in catalog.COLUMN_FIELDS:
|
||||
out.append((path, ("c", str(col), name), item, None))
|
||||
else:
|
||||
out.append((path, None, item, f"列 {col} 含有不认识的字段「{name}」"))
|
||||
relations = notes.get("relations", [])
|
||||
if not isinstance(relations, list):
|
||||
out.append(("relations", None, relations, "关联关系应为列表"))
|
||||
relations = []
|
||||
for rel in relations:
|
||||
rid = rel.get("id") if isinstance(rel, Mapping) else None
|
||||
out.append((f"relations.{rid}", ("r", str(rid)), rel, None))
|
||||
return out
|
||||
|
||||
|
||||
def _item_problem(key: catalog.Slot, item: Any) -> str | None:
|
||||
"""这一项本身合不合规(说法同 catalog.validate_notes)。"""
|
||||
if key[0] == "t":
|
||||
probe: dict[str, Any] = {key[1]: item}
|
||||
elif key[0] == "c":
|
||||
probe = {"columns": {key[1]: {key[2]: item}}}
|
||||
else:
|
||||
probe = {"relations": [item]}
|
||||
problems = catalog.validate_notes(probe)
|
||||
return problems[0] if problems else None
|
||||
|
||||
|
||||
def _decision(key: catalog.Slot, item: Mapping[str, Any]) -> Any:
|
||||
"""判断「一样不一样」看的部分:状态,加上值(码值另看「已列全」)。关联关系的两端由编号定了,再看基数;覆盖率不算。"""
|
||||
if key[0] == "r":
|
||||
return item.get("status"), item.get("cardinality")
|
||||
if key[0] == "c" and key[2] == "codes":
|
||||
return item.get("status"), item.get("value"), catalog.codes_complete(item)
|
||||
return item.get("status"), item.get("value")
|
||||
|
||||
|
||||
def _conflict_why(file_status: str, target_status: str) -> str:
|
||||
if target_status == "rejected":
|
||||
return f"目标里这一项已经驳回,文件里是{_STATUS_LABEL.get(file_status, file_status)},保留目标的"
|
||||
if file_status == "rejected":
|
||||
return "目标里这一项已经人工确认过,文件里是已驳回,保留目标的"
|
||||
return "目标里这一项已经人工确认过,和文件里的不一样,保留目标的"
|
||||
|
||||
|
||||
class _Mapper:
|
||||
"""把文件里的一项换成目标的写法:列名、业务主键和业务日期的列、关联关系两端和编号;有效记录条件核对用到的列。对不上
|
||||
返回原因。name 是文件里的表名(条件里带它当表名也认),kind 是目标数据源的类型(解析条件用它的方言)。"""
|
||||
|
||||
def __init__(self, name: str, target: str, schema_cache: Mapping[str, Any], masked: set[str],
|
||||
kind: str | None) -> None:
|
||||
self.name = name
|
||||
self.target = target
|
||||
self.cache = schema_cache
|
||||
self.tables = (schema_cache or {}).get("tables") or {}
|
||||
self.columns = _Columns(self.tables.get(target))
|
||||
self.masked = masked
|
||||
self.kind = kind
|
||||
|
||||
def __call__(self, key: catalog.Slot, item: Any) -> tuple[catalog.Slot | None, Any, str | None]:
|
||||
if not isinstance(item, Mapping):
|
||||
return None, item, None # 交给合规检查说
|
||||
item = copy.deepcopy(dict(item))
|
||||
if key[0] == "c":
|
||||
col = self.columns.find(key[1])
|
||||
if col is None:
|
||||
return None, item, f"目标的表结构里没有列 {key[1]},跳过"
|
||||
if key[2] == "codes" and col.lower() in self.masked:
|
||||
return None, item, f"列 {col} 在目标数据源的设置中被遮罩,码值是这一列的取值,不导入"
|
||||
return ("c", col, key[2]), item, None
|
||||
if key[0] == "t":
|
||||
value = item.get("value")
|
||||
if key[1] == "keys" and isinstance(value, list):
|
||||
mapped = [self.columns.find(c) for c in value]
|
||||
if missing := [str(c) for c, m in zip(value, mapped) if m is None]:
|
||||
return None, item, f"业务主键里的列「{'、'.join(missing)}」不在目标的表结构里,跳过"
|
||||
item["value"] = mapped
|
||||
elif key[1] == "business_date" and isinstance(value, Mapping) and isinstance(value.get("column"), str):
|
||||
col = self.columns.find(value["column"])
|
||||
if col is None:
|
||||
return None, item, f"业务日期的列「{value['column']}」不在目标的表结构里,跳过"
|
||||
item["value"] = {**value, "column": col}
|
||||
elif key[1] == "valid_filter":
|
||||
problem = filter_columns_problem(value, self.tables.get(self.target), kind=self.kind,
|
||||
table=self.target, also=(self.name,), where="目标的表结构")
|
||||
if problem is not None:
|
||||
return None, item, f"{problem},跳过"
|
||||
return key, item, None
|
||||
return self._relation(item)
|
||||
|
||||
def _relation(self, rel: dict[str, Any]) -> tuple[catalog.Slot | None, Any, str | None]:
|
||||
cols, to_table, to_cols = rel.get("columns"), rel.get("to_table"), rel.get("to_columns")
|
||||
if not (isinstance(cols, list) and isinstance(to_cols, list) and isinstance(to_table, str)):
|
||||
return None, rel, None
|
||||
target_table = catalog.resolve_table_name(self.cache, to_table)
|
||||
if target_table is None:
|
||||
return None, rel, f"关联关系指向的表 {to_table} 不在目标数据源的表结构里,跳过"
|
||||
mine = [self.columns.find(c) for c in cols]
|
||||
if missing := [str(c) for c, m in zip(cols, mine) if m is None]:
|
||||
return None, rel, f"关联关系的本表字段「{'、'.join(missing)}」不在目标的表结构里,跳过"
|
||||
theirs_columns = _Columns(self.tables.get(target_table))
|
||||
theirs = [theirs_columns.find(c) for c in to_cols]
|
||||
if missing := [str(c) for c, m in zip(to_cols, theirs) if m is None]:
|
||||
return None, rel, f"关联关系的目标字段「{'、'.join(missing)}」不在 {target_table} 的表结构里,跳过"
|
||||
rid = catalog.relation_id(self.target, mine, target_table, theirs)
|
||||
return ("r", rid), {**rel, "id": rid, "columns": mine, "to_table": target_table, "to_columns": theirs}, None
|
||||
|
||||
|
||||
def plan_table(table_name: str, target: str, file_notes: Mapping[str, Any], target_notes: Mapping[str, Any] | None,
|
||||
schema_cache: Mapping[str, Any], masked: set[str], kind: str | None = None) -> TablePlan:
|
||||
"""文件里一张表并进目标那张表的计划(纯函数,不写库)。kind 是目标数据源的类型(解析有效记录条件用)。"""
|
||||
plan = TablePlan(table_name=table_name, matched_as=target)
|
||||
existing = catalog._slots(target_notes)
|
||||
mapper = _Mapper(table_name, target, schema_cache, masked, kind)
|
||||
seen: set[catalog.Slot] = set()
|
||||
for path, key, item, why in _raw_items(file_notes):
|
||||
if why is not None or key is None:
|
||||
plan.skipped.append({"path": path, "why": why or "无法识别这一项"})
|
||||
continue
|
||||
mapped, item, why = mapper(key, item)
|
||||
if why is not None:
|
||||
plan.skipped.append({"path": path, "why": why})
|
||||
continue
|
||||
if isinstance(item, dict) and item.get("status") not in _LOCKED and "reason" in item:
|
||||
# 推断、有确证的项不带确认时的依据:在校验之前去掉,手改的文件里依据不合规也不把整项跳过
|
||||
item = {k: v for k, v in item.items() if k != "reason"}
|
||||
problem = _item_problem(mapped or key, item)
|
||||
if problem is not None or mapped is None:
|
||||
plan.skipped.append({"path": path, "why": problem or "无法识别这一项"})
|
||||
continue
|
||||
if mapped in seen:
|
||||
plan.skipped.append({"path": path, "why": f"和文件里前面的{catalog.describe_slot(mapped)}是同一项,只导入第一项"})
|
||||
continue
|
||||
seen.add(mapped)
|
||||
status = item.get("status")
|
||||
have = existing.get(mapped)
|
||||
if have is None:
|
||||
plan.add += 1
|
||||
plan.writes[mapped] = item
|
||||
elif status not in _LOCKED:
|
||||
plan.keep += 1 # 推断、有确证的只填空缺
|
||||
elif have.get("status") in _LOCKED:
|
||||
if _decision(mapped, have) == _decision(mapped, item):
|
||||
plan.keep += 1
|
||||
else:
|
||||
plan.conflicts.append({
|
||||
"path": slot_path(mapped), "label": item_label(mapped, item),
|
||||
"file_value": _shown_value(mapped, item), "target_value": _shown_value(mapped, have),
|
||||
"file_status": status, "target_status": have.get("status"),
|
||||
"why": _conflict_why(str(status), str(have.get("status"))),
|
||||
})
|
||||
elif _decision(mapped, have) == _decision(mapped, item):
|
||||
plan.keep += 1
|
||||
else:
|
||||
plan.update += 1
|
||||
plan.writes[mapped] = item
|
||||
return plan
|
||||
|
||||
|
||||
def _shown_value(key: catalog.Slot, item: Mapping[str, Any]) -> Any:
|
||||
if key[0] == "r":
|
||||
return {k: item.get(k) for k in ("columns", "to_table", "to_columns", "cardinality", "coverage")}
|
||||
return item.get("value")
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 预览、导入
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportReport:
|
||||
tables: list[TablePlan]
|
||||
unmatched: list[str]
|
||||
guidance: dict[str, Any]
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"tables": [t.as_dict() for t in self.tables],
|
||||
"unmatched_tables": self.unmatched,
|
||||
"totals": {"tables": len(self.tables), "add": sum(t.add for t in self.tables),
|
||||
"update": sum(t.update for t in self.tables), "keep": sum(t.keep for t in self.tables),
|
||||
"conflicts": sum(len(t.conflicts) for t in self.tables),
|
||||
"skipped": sum(len(t.skipped) for t in self.tables)},
|
||||
"guidance": self.guidance,
|
||||
}
|
||||
|
||||
|
||||
def _guidance(file: ImportFile, row: Any, include: bool) -> dict[str, Any]:
|
||||
target = catalog_jobs.guidance_of(row)
|
||||
if not include or file.guidance is None:
|
||||
action = "none"
|
||||
elif target:
|
||||
action = "keep"
|
||||
else:
|
||||
action = "replace"
|
||||
return {"file": file.guidance, "target": target, "action": action}
|
||||
|
||||
|
||||
async def plan_import(session: AsyncSession, row: Any, source: Any, content: Any, *,
|
||||
include_guidance: bool) -> ImportReport:
|
||||
"""预览:逐表算出要新增、更新、保留什么,冲突和跳过的项及原因。不写库。row 是数据源记录(遮罩、起草要求按它),
|
||||
source 是读表结构用的源(上传源是绑定当前快照的视图)。格式不对抛 TransferInvalid。"""
|
||||
file = read_file(content, include_guidance=include_guidance)
|
||||
cache = getattr(source, "schema_cache", None) or {}
|
||||
matched, unmatched = match_tables(list(file.tables), cache)
|
||||
masked = {c.lower() for c in masked_columns(getattr(row, "options", None))}
|
||||
plans: list[TablePlan] = []
|
||||
for name, target in matched.items():
|
||||
entry = await catalog.read_entry(session, source.id, target)
|
||||
plan = plan_table(name, target, file.tables[name], entry.notes if entry else None, cache, masked, row.kind)
|
||||
plan.version = entry.version if entry else 0
|
||||
plans.append(plan)
|
||||
return ImportReport(plans, unmatched, _guidance(file, row, include_guidance))
|
||||
|
||||
|
||||
async def run_import(session: AsyncSession, row: Any, source: Any, content: Any, *, include_guidance: bool,
|
||||
actor: str | None) -> ImportReport:
|
||||
"""导入:按 plan_import 的规矩逐表写入(乐观锁,撞上别人刚改过就重读重算,最多三次),再按需写起草要求。
|
||||
一张表写不进去只记在它的 error 上(新增、更新记 0),不影响别的表。"""
|
||||
file = read_file(content, include_guidance=include_guidance)
|
||||
cache = getattr(source, "schema_cache", None) or {}
|
||||
matched, unmatched = match_tables(list(file.tables), cache)
|
||||
masked = {c.lower() for c in masked_columns(getattr(row, "options", None))}
|
||||
plans: list[TablePlan] = []
|
||||
for name, target in matched.items():
|
||||
plans.append(await _import_table(session, source, name, target, file.tables[name], cache, masked, row.kind,
|
||||
actor))
|
||||
guidance = _guidance(file, row, include_guidance)
|
||||
if guidance["action"] == "replace":
|
||||
await catalog_jobs.save_guidance(session, row, file.guidance)
|
||||
return ImportReport(plans, unmatched, guidance)
|
||||
|
||||
|
||||
async def _import_table(session: AsyncSession, source: Any, name: str, target: str, notes: Mapping[str, Any],
|
||||
cache: Mapping[str, Any], masked: set[str], kind: str | None, actor: str | None) -> TablePlan:
|
||||
plan = TablePlan(table_name=name, matched_as=target)
|
||||
try:
|
||||
for _attempt in range(3):
|
||||
entry = await catalog.read_entry(session, source.id, target)
|
||||
version = entry.version if entry else 0
|
||||
plan = plan_table(name, target, notes, entry.notes if entry else None, cache, masked, kind)
|
||||
plan.version = version
|
||||
if not plan.writes:
|
||||
return plan
|
||||
slots = catalog._slots(entry.notes if entry else None)
|
||||
slots.update(plan.writes)
|
||||
try:
|
||||
written = await catalog.write_entry(session, source.id, target, catalog._assemble(slots),
|
||||
if_version=version, actor=actor)
|
||||
except catalog.CatalogConflict:
|
||||
continue
|
||||
plan.version = written.version
|
||||
return plan
|
||||
plan.error = WRITE_CONFLICT_ERROR
|
||||
except catalog.CatalogInvalid as e:
|
||||
await session.rollback()
|
||||
plan.error = WRITE_ERROR.format(line=str(e))
|
||||
except Exception as e: # noqa: BLE001 - 这一张写不进去不影响别的表
|
||||
from app.core.errors import describe_exception, first_line
|
||||
|
||||
await session.rollback()
|
||||
plan.error = WRITE_ERROR.format(line=first_line(e) or describe_exception(e))
|
||||
plan.add = plan.update = 0
|
||||
return plan
|
||||
|
||||
|
||||
__all__ = ["FORMAT", "ImportFile", "ImportReport", "TablePlan", "TransferInvalid", "VERSION", "attachment_name",
|
||||
"content_disposition", "export_catalog", "filter_columns_problem", "match_tables", "plan_import",
|
||||
"plan_table", "read_file", "run_import"]
|
||||
@@ -1131,5 +1131,274 @@ async def load_checkers_by_name(session: Any, names: Iterable[str]) -> dict[str,
|
||||
return await load_checkers(session, rows)
|
||||
|
||||
|
||||
__all__ = ["CHECK_CODES", "LEVELS", "MAX_CHECKS", "MAX_SQL_CHARS", "SqlCheck", "SqlChecker", "check_sql",
|
||||
"dialect_of", "graph_checks", "graph_sources", "load_checkers", "load_checkers_by_name", "tool_query"]
|
||||
# ==========================================================================
|
||||
# 有效行过滤(valid_filter)的校验:模型起草的条件要能进目录、能拼进剖析的核对查询
|
||||
# ==========================================================================
|
||||
|
||||
#: 有效行过滤最多几个字(同目录里文本项的上限)
|
||||
VALID_FILTER_MAX = 500
|
||||
#: 有效行过滤里能用的函数(其余函数、聚合、窗口一律不收)
|
||||
VALID_FILTER_FUNCTIONS = ("UPPER", "LOWER", "TRIM", "COALESCE")
|
||||
#: 模板占位:条件里不该有,有了也说不清最终拼出来的是什么(注释在解析之后按语法树认,字符串里的 -- 不算)
|
||||
_FILTER_TEMPLATE = re.compile(r"\{\{|\{%")
|
||||
|
||||
#: 不合规的说法以这一项在界面上的叫法开头(catalog.TABLE_FIELD_LABEL:「有效记录条件」):没收下的原因会记进起草任务
|
||||
#: 逐表的日志,界面上看得到
|
||||
_FILTER_PREFIX = catalog.TABLE_FIELD_LABEL["valid_filter"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ValidFilter:
|
||||
"""一段有效行过滤解析的结果。
|
||||
|
||||
expression:sqlglot 的条件(不合规时为 None);columns:条件里用到的列(表结构里的真名,按出现的先后去重);
|
||||
names:条件里写的列名 → 表结构里的真名;problem:不合规时给人看的一句话,合规是 None;dialect:解析用的 sqlglot
|
||||
方言。text:解析的原文(去掉首尾空白);spans:每处列引用(连同表名前缀)在原文里的位置 (起, 止(含), 真名),按
|
||||
书写的先后,有一处拿不到位置时为 None(canonical_valid_filter 退回按语法树重写)。
|
||||
"""
|
||||
|
||||
expression: Any = None
|
||||
columns: tuple[str, ...] = ()
|
||||
names: Mapping[str, str] = field(default_factory=dict)
|
||||
problem: str | None = None
|
||||
dialect: str | None = None
|
||||
text: str = ""
|
||||
spans: tuple[tuple[int, int, str], ...] | None = None
|
||||
|
||||
|
||||
def _bad_filter(problem: str, dialect: str | None = None) -> ValidFilter:
|
||||
return ValidFilter(problem=f"{_FILTER_PREFIX}{problem}", dialect=dialect)
|
||||
|
||||
|
||||
def _filter_node_problem(node: Any, *, predicate: bool) -> str | None:
|
||||
"""一个不在允许范围里的节点为什么不收(没有问题返回 None 的调用方不会走到这里)。"""
|
||||
from sqlglot import exp
|
||||
|
||||
if isinstance(node, exp.Query):
|
||||
return "应为一个条件,不能是一条查询"
|
||||
if any(isinstance(n, (exp.Query, exp.Subquery)) for n in node.walk()):
|
||||
return "里不能有子查询"
|
||||
if any(isinstance(n, exp.Window) for n in node.walk()):
|
||||
return "里不能有窗口函数"
|
||||
if any(isinstance(n, exp.AggFunc) for n in node.walk()):
|
||||
return "里不能有聚合函数"
|
||||
if isinstance(node, exp.Func):
|
||||
name = str(node.this) if isinstance(node, exp.Anonymous) else node.sql_name()
|
||||
return f"里只能用 {'、'.join(VALID_FILTER_FUNCTIONS)} 这几个函数,不能用 {name.upper()}"
|
||||
if predicate:
|
||||
return "应为一个条件(比较、IN、BETWEEN、LIKE、IS NULL,用 AND、OR、NOT 连接)"
|
||||
text = node.sql()
|
||||
return f"里有不支持的写法({text[:40]}{'…' if len(text) > 40 else ''})"
|
||||
|
||||
|
||||
def _filter_value_problem(node: Any) -> str | None:
|
||||
"""比较两边的一个值:列、字面量、负数、允许的几个函数(参数同样是值)。"""
|
||||
from sqlglot import exp
|
||||
|
||||
node = _unparen(node)
|
||||
if isinstance(node, (exp.Column, exp.Literal, exp.Null, exp.Boolean)):
|
||||
return None
|
||||
if isinstance(node, exp.Neg) and isinstance(_unparen(node.this), exp.Literal) and not _unparen(node.this).is_string:
|
||||
return None
|
||||
if isinstance(node, (exp.Upper, exp.Lower, exp.Trim, exp.Coalesce)):
|
||||
for arg in node.iter_expressions():
|
||||
if problem := _filter_value_problem(arg):
|
||||
return problem
|
||||
return None
|
||||
return _filter_node_problem(node, predicate=False)
|
||||
|
||||
|
||||
def _filter_predicate_problem(node: Any) -> str | None:
|
||||
"""一个条件:比较、IN(只能是值的列表)、BETWEEN、LIKE、IS NULL,或者用 AND / OR / NOT 连起来的条件。"""
|
||||
from sqlglot import exp
|
||||
|
||||
node = _unparen(node)
|
||||
if isinstance(node, (exp.And, exp.Or)):
|
||||
return _filter_predicate_problem(node.left) or _filter_predicate_problem(node.right)
|
||||
if isinstance(node, exp.Not):
|
||||
return _filter_predicate_problem(node.this)
|
||||
if isinstance(node, (exp.EQ, exp.NEQ, exp.GT, exp.GTE, exp.LT, exp.LTE)):
|
||||
return _filter_value_problem(node.left) or _filter_value_problem(node.right)
|
||||
if isinstance(node, exp.In):
|
||||
if any(node.args.get(k) for k in ("query", "unnest", "field")):
|
||||
return "里不能有子查询"
|
||||
if not node.expressions:
|
||||
return _filter_node_problem(node, predicate=True)
|
||||
return _filter_value_problem(node.this) or next(
|
||||
(p for p in (_filter_value_problem(e) for e in node.expressions) if p), None)
|
||||
if isinstance(node, exp.Between):
|
||||
return next((p for p in (_filter_value_problem(node.args.get(k)) for k in ("this", "low", "high")) if p), None)
|
||||
if isinstance(node, exp.Like):
|
||||
return _filter_value_problem(node.this) or _filter_value_problem(node.expression)
|
||||
if isinstance(node, exp.Is):
|
||||
if not isinstance(_unparen(node.expression), exp.Null):
|
||||
return "里的 IS 只能写 IS NULL 或 IS NOT NULL"
|
||||
return _filter_value_problem(node.this)
|
||||
return _filter_node_problem(node, predicate=True)
|
||||
|
||||
|
||||
def parse_valid_filter(condition: Any, meta: Mapping[str, Any] | None, *, kind: str | None,
|
||||
table: str | None = None) -> ValidFilter:
|
||||
"""按数据源的方言把有效行过滤解析成一个条件,并核对它只用了这张表的列。从不抛异常,不合规写在 problem 里。
|
||||
|
||||
规矩:一段文字、不超过 VALID_FILTER_MAX 字;不能有分号、模板占位、注释;解析出来恰好是一个条件,只由比较、IN(值的
|
||||
列表)、BETWEEN、LIKE、IS NULL、AND / OR / NOT 和 UPPER / LOWER / TRIM / COALESCE 组成(不能有子查询、聚合、窗口、
|
||||
别的函数、算术、参数占位);列不带表名或带这张表的表名(table),都在表结构里,至少用到一列。
|
||||
|
||||
列名先照原样对表结构,对不上再不分大小写对;表结构里有只差大小写的几列(PostgreSQL 上 "Status" 和 status 可以
|
||||
并存)、写法又和哪一列都不一样时说不清是哪一列,不收。
|
||||
"""
|
||||
from app.data.names import name_key as _key
|
||||
|
||||
dialect = dialect_of(kind)
|
||||
if not isinstance(condition, str) or not condition.strip():
|
||||
return _bad_filter("应为一段 SQL 条件", dialect)
|
||||
text = condition.strip()
|
||||
if len(text) > VALID_FILTER_MAX:
|
||||
return _bad_filter(f"超过 {VALID_FILTER_MAX} 字", dialect)
|
||||
if ";" in text:
|
||||
return _bad_filter("里不能有分号", dialect)
|
||||
if _FILTER_TEMPLATE.search(text):
|
||||
return _bad_filter("里不能有模板占位", dialect)
|
||||
if dialect is None:
|
||||
return _bad_filter(f"没法校验:数据源类型「{kind}」不支持", dialect)
|
||||
try:
|
||||
import sqlglot
|
||||
|
||||
statements = [s for s in sqlglot.parse(text, read=dialect) if s is not None]
|
||||
except Exception: # noqa: BLE001 - 语法错、方言不支持的写法:一律不收
|
||||
return _bad_filter("不是一段能解析的 SQL 条件", dialect)
|
||||
if len(statements) != 1:
|
||||
return _bad_filter("不是一段能解析的 SQL 条件", dialect)
|
||||
expression = statements[0]
|
||||
if any(node.comments for node in expression.walk()):
|
||||
return _bad_filter("里不能有注释", dialect)
|
||||
if problem := _filter_predicate_problem(expression):
|
||||
return _bad_filter(problem, dialect)
|
||||
from sqlglot import exp
|
||||
|
||||
real_names = [str(c.get("name")) for c in (meta or {}).get("columns") or []
|
||||
if isinstance(c, Mapping) and c.get("name") is not None]
|
||||
exact = set(real_names)
|
||||
by_lower: dict[str, list[str]] = {}
|
||||
for name in real_names:
|
||||
by_lower.setdefault(name.lower(), []).append(name)
|
||||
own = {_key(str(n).rsplit(".", 1)[-1]) for n in (table, (meta or {}).get("qualified")) if n}
|
||||
used: dict[str, str] = {}
|
||||
spans: list[tuple[int, int, str]] | None = []
|
||||
for column in expression.find_all(exp.Column, bfs=False): # 按书写的先后
|
||||
if column.is_star:
|
||||
return _bad_filter("里不能写 *", dialect)
|
||||
qualifier = column.table
|
||||
if column.args.get("db") or column.args.get("catalog") or (qualifier and _key(qualifier) not in own):
|
||||
return _bad_filter(f"只能引用这张表的列,不能引用 {column.sql(dialect=dialect)}", dialect)
|
||||
written = column.name
|
||||
if written in exact:
|
||||
real = written
|
||||
else:
|
||||
found = by_lower.get(written.lower()) or []
|
||||
if not found:
|
||||
return _bad_filter(f"里的列「{written}」不在表结构里", dialect)
|
||||
if len(found) > 1:
|
||||
return _bad_filter(f"里的列「{written}」在表结构里有只差大小写的几列({'、'.join(found)}),"
|
||||
"要照表结构的写法写清是哪一列", dialect)
|
||||
real = found[0]
|
||||
used.setdefault(written, real)
|
||||
span = _column_span(column, text)
|
||||
spans = None if spans is None or span is None else [*spans, (*span, real)]
|
||||
if not used:
|
||||
return _bad_filter("没有用到这张表的任何列", dialect)
|
||||
return ValidFilter(expression=expression, columns=tuple(dict.fromkeys(used.values())), names=used,
|
||||
dialect=dialect, text=text, spans=tuple(spans) if spans is not None else None)
|
||||
|
||||
|
||||
def _column_span(column: Any, text: str) -> tuple[int, int] | None:
|
||||
"""一处列引用(连同表名前缀)在原文里的位置 (起, 止(含)):sqlglot 在标识符的 meta 里记下了它在原文里的起止。
|
||||
拿不到、或者那一截原文里认不出这个列名时为 None。"""
|
||||
ident = column.this
|
||||
meta = getattr(ident, "meta", None) or {}
|
||||
start, end = meta.get("start"), meta.get("end")
|
||||
prefix = column.args.get("table")
|
||||
if prefix is not None:
|
||||
start = (getattr(prefix, "meta", None) or {}).get("start")
|
||||
if not (isinstance(start, int) and isinstance(end, int) and 0 <= start <= end < len(text)):
|
||||
return None
|
||||
if not text[start:end + 1].rstrip("\"`]").lower().endswith(str(column.name).lower()):
|
||||
return None
|
||||
return start, end
|
||||
|
||||
|
||||
def valid_filter_problem(condition: Any, meta: Mapping[str, Any] | None, *, kind: str | None,
|
||||
table: str | None = None) -> str | None:
|
||||
"""有效行过滤哪里不合规(给人看的一句话);合规返回 None。规矩见 parse_valid_filter。"""
|
||||
return parse_valid_filter(condition, meta, kind=kind, table=table).problem
|
||||
|
||||
|
||||
def render_valid_filter(parsed: ValidFilter, *, quoted: Any) -> str:
|
||||
"""把解析好的条件按数据源的方言重新写出来,列换成表结构里的真名、不带表名;quoted(真名) 为真的加引号(和剖析查询
|
||||
其余部分的写法一致:SqlDialect.quote 要加引号的才加)。按语法树重写,NOT IN、IS NOT NULL 会写成 NOT x IN、
|
||||
NOT x IS NULL:canonical_valid_filter 拿不到原文里的位置时才退回用它。"""
|
||||
from sqlglot import exp
|
||||
|
||||
if parsed.expression is None:
|
||||
raise ValueError(parsed.problem or "有效行过滤不合规")
|
||||
|
||||
def swap(node: Any) -> Any:
|
||||
if isinstance(node, exp.Column):
|
||||
real = parsed.names.get(node.name, node.name)
|
||||
return exp.Column(this=exp.Identifier(this=real, quoted=bool(quoted(real))))
|
||||
return node
|
||||
|
||||
return parsed.expression.copy().transform(swap).sql(dialect=parsed.dialect)
|
||||
|
||||
|
||||
def canonical_valid_filter(parsed: ValidFilter, *, quote: Any) -> str:
|
||||
"""有效行过滤存进目录、拼进剖析核对查询的写法(两处用的是同一条):原文照旧,只把每处列引用换成 quote(真名)——表结构
|
||||
里的真名、不带表名,按数据源的需要加引号(quote 是 catalog_profile.SqlDialect.quote,和剖析查询其余部分的写法一致)。
|
||||
|
||||
以前目录里存的是模型的原文,核对时却按真名加了引号:PostgreSQL 上混合大小写的列("OrderStatus")不加引号会被折成
|
||||
小写,存下的 orderstatus <> 'X' 一跑就报列不存在,核对过的却能跑通,SQL 检查还会让模型照抄存下的那一句。
|
||||
|
||||
拿不到原文里的位置、换完以后认不出原来那几列时,退回按语法树重写(render_valid_filter)。"""
|
||||
if parsed.expression is None:
|
||||
raise ValueError(parsed.problem or "有效行过滤不合规")
|
||||
if parsed.spans:
|
||||
text = parsed.text
|
||||
for start, end, real in sorted(parsed.spans, reverse=True):
|
||||
text = text[:start] + quote(real) + text[end + 1:]
|
||||
if _same_columns(text, parsed):
|
||||
return text
|
||||
return render_valid_filter(parsed, quoted=lambda name: quote(name) != name)
|
||||
|
||||
|
||||
def _same_columns(text: str, parsed: ValidFilter) -> bool:
|
||||
"""换过列名的原文还是一个条件,列依次是表结构里的那几列、都不带表名。"""
|
||||
import sqlglot
|
||||
from sqlglot import exp
|
||||
|
||||
try:
|
||||
tree = sqlglot.parse_one(text, read=parsed.dialect)
|
||||
except Exception: # noqa: BLE001 - 换坏了就退回按语法树重写
|
||||
return False
|
||||
columns = list(tree.find_all(exp.Column, bfs=False))
|
||||
return ([c.name for c in columns] == [real for _, _, real in parsed.spans or ()]
|
||||
and not any(c.table for c in columns))
|
||||
|
||||
|
||||
def stored_valid_filter(parsed: ValidFilter, kind: str | None) -> str:
|
||||
"""模型提议的有效行过滤存进目录的写法:canonical_valid_filter,按这个数据源的方言加引号(catalog_profile.SqlDialect:
|
||||
SQLAlchemy 的写法,要加才加——PostgreSQL、SQLite 的 "OrderStatus",MySQL 的 `OrderStatus`,Oracle 探查出来的小写名
|
||||
不加)。"""
|
||||
from app.data.catalog_profile import SqlDialect
|
||||
|
||||
try:
|
||||
dialect = SqlDialect(kind)
|
||||
except ValueError:
|
||||
return parsed.text
|
||||
return canonical_valid_filter(parsed, quote=dialect.quote)
|
||||
|
||||
|
||||
__all__ = ["CHECK_CODES", "LEVELS", "MAX_CHECKS", "MAX_SQL_CHARS", "SqlCheck", "SqlChecker", "VALID_FILTER_FUNCTIONS",
|
||||
"VALID_FILTER_MAX", "ValidFilter", "canonical_valid_filter", "check_sql", "dialect_of", "graph_checks",
|
||||
"graph_sources", "load_checkers", "load_checkers_by_name", "parse_valid_filter", "render_valid_filter",
|
||||
"stored_valid_filter", "tool_query", "valid_filter_problem"]
|
||||
|
||||
@@ -212,6 +212,12 @@ _COLUMN_MIGRATIONS: list[tuple[str, str, str]] = [
|
||||
("catalog_draft_jobs", "notice", "TEXT"),
|
||||
("catalog_draft_tasks", "blank_columns", "INTEGER DEFAULT 0"),
|
||||
("catalog_draft_tasks", "blank_sample", "JSON"),
|
||||
# 数据目录闭环第一步:宽表逐段落盘的段数、块进度。存量任务回填 0 和 NULL:升级前没记这些
|
||||
("catalog_draft_tasks", "parts_written", "INTEGER DEFAULT 0"),
|
||||
("catalog_draft_tasks", "chunks_total", "INTEGER"),
|
||||
("catalog_draft_tasks", "chunks_done", "INTEGER"),
|
||||
# 复核后:逐表的日志(模型提议的有效记录条件没有收下的原因)。存量任务回填 NULL,接口给空列表
|
||||
("catalog_draft_tasks", "log", "JSON"),
|
||||
]
|
||||
|
||||
#: 迁移补上的列要建的索引:(索引名, 表, 列)。create_all 不碰已有的表,表上新列的索引也就不会建。
|
||||
|
||||
@@ -410,9 +410,14 @@ class CatalogDraftTask(Base):
|
||||
table_name: Mapped[str] = mapped_column(String(255))
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||
column_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
# 模型分几段起草(不用模型时为 NULL)、已经结束了几段
|
||||
# 模型分几段起草(不用模型时为 NULL)、已经结束了几段、其中写出了内容并进了目录的段数(宽表每写出一段就并入目录,
|
||||
# 停止、服务重启时已经写进去的留着)
|
||||
parts_total: Mapped[int | None] = mapped_column(Integer, default=None)
|
||||
parts_done: Mapped[int] = mapped_column(Integer, default=0)
|
||||
parts_written: Mapped[int] = mapped_column(Integer, default=0)
|
||||
# 块进度:这张表实际分成几块来问、已经结束了几块(段被缩小、拆成两半后会变多);不用模型时为 NULL
|
||||
chunks_total: Mapped[int | None] = mapped_column(Integer, default=None)
|
||||
chunks_done: Mapped[int | None] = mapped_column(Integer, default=None)
|
||||
added: Mapped[int] = mapped_column(Integer, default=0)
|
||||
updated: Mapped[int] = mapped_column(Integer, default=0)
|
||||
removed: Mapped[int] = mapped_column(Integer, default=0)
|
||||
@@ -421,6 +426,8 @@ class CatalogDraftTask(Base):
|
||||
# 模型拿不准、留空了的列(中文名和含义都没给):几列,最多记 5 个列名。表的状态不因此变
|
||||
blank_columns: Mapped[int] = mapped_column(Integer, default=0)
|
||||
blank_sample: Mapped[list[Any] | None] = mapped_column(default=None)
|
||||
# 起草时记下的几句话,不算失败(模型提议的有效记录条件没有收下的原因),最多 10 句
|
||||
log: Mapped[list[Any] | None] = mapped_column(default=None)
|
||||
# error:整张表没起草(表结构里没有、写入一直冲突、服务重启中断);model_error:模型那部分没写出来的原因和办法
|
||||
error: Mapped[str | None] = mapped_column(Text, default=None)
|
||||
model_error: Mapped[str | None] = mapped_column(Text, default=None)
|
||||
|
||||
@@ -50,7 +50,9 @@ is_test = 0、is_valid = 1、deleted_at IS NULL);只涉及一张表。拿不
|
||||
原因,只有审批停下期间目录被人改了、数据源换了版本时才会和最后写的不一样:真写按写入时的目录、表结构和导入时认作
|
||||
日期的列判断。组合太多的(JOINT_LIMIT)那一项不预告,卡上有条件地说。
|
||||
|
||||
catalog.save_patch 不收备注,这里用它的同一套原料(read_entry、apply_patch、write_entry 的乐观锁)写,只多把备注放进项里。
|
||||
catalog.save_patch 把提案的依据另存成这一项确认时的依据(reason),这里要写的是备注里的出处(哪次运行、哪个节点),
|
||||
所以用它的同一套原料(read_entry、apply_patch、write_entry 的乐观锁)写,再把这一项整个换成带出处备注的人工确认项:
|
||||
以前确认时记下的依据说的是旧值,不留。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1051,11 +1053,11 @@ async def _write(session: Any, row: DataSource, fact: Fact, *, run_id: str, node
|
||||
note = note_of(kept, run_id=run_id, node_title=node_title)
|
||||
at = catalog.now_iso()
|
||||
try:
|
||||
merged = catalog.apply_patch(notes, [{"path": kept.item, "value": value, "reason": note}],
|
||||
merged = catalog.apply_patch(notes, [{"path": kept.item, "value": value}],
|
||||
table=kept.table, tables=tables, at=at)
|
||||
except catalog.CatalogInvalid as e:
|
||||
return None, [*reasons, f"「{kept.table}」的{label}未写入数据目录:{e}"]
|
||||
# 不论原来是谁提的:这一项现在出自人的选择,记为人工填写、已确认,备注写明出处
|
||||
# 不论原来是谁提的:这一项现在出自人的选择,记为人工填写、已确认,备注写明出处(整项换掉:以前确认时的依据不留)
|
||||
merged[kept.item] = catalog.make_item(value, "human", "confirmed", note=note, at=at)
|
||||
try:
|
||||
await catalog.write_entry(session, row.id, kept.table, merged, if_version=version, actor=actor)
|
||||
|
||||
@@ -0,0 +1,778 @@
|
||||
"""表助手(data/catalog_assistant.py,POST /api/datasources/{id}/catalog/{table}/assistant)。
|
||||
|
||||
- 开始前:表不在 404;起草模型用不了 409 catalog_draft_model_unavailable(说法同起草任务);对话超过 40 条、一条超过
|
||||
4000 字 422。界面上待保存的修改不设上限(宽表上攒到 200 项以上也照常对话):背景里只列最近的 200 项、写明另有几项,
|
||||
码值照全部待保存的修改合并
|
||||
- 流:text 增量、query(跑过的只读查询,前 10 行,遮罩列写「已遮罩」)、proposal(按 plan_patch 核对过的提案,形状同助手流
|
||||
的 catalog_patch:公式不收、列名必须在表结构里、遮罩列不给码值;有效记录条件要是只用这张表的列的一个条件,有起草的
|
||||
校验规矩 sqlcheck.valid_filter_problem 时按它)、done(用量、查询条数)、error(中途出错,之后没有 done)
|
||||
- 交给模型的背景:表结构、这张表的目录(每项的来源和状态)、关联关系(含别的表指过来的)、使用次数、数据源的起草要求、
|
||||
最近一次起草留空的列、界面上还没保存的修改;遮罩列只给列名、不给任何值
|
||||
- 只读查询:只有开了数据剖析的源才给这个工具;经剖析的只读守卫和单条时限;每次请求最多 5 条;最多 50 行交给模型;结果
|
||||
先遮再交给模型;没开剖析时提示词里写明不能查数据
|
||||
- 对话不存服务端;提案不写目录(保存走 PATCH,最多 200 项)
|
||||
|
||||
表名、列名、业务数字都是虚构的;号码是 139000000xx 这样的虚构号码。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sqlite3
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from langchain_core.messages import AIMessageChunk, HumanMessage, SystemMessage, ToolMessage
|
||||
|
||||
from app.data import catalog, catalog_assistant
|
||||
from app.data.catalog import make_item
|
||||
from app.data.engine import CATALOG_DRAFT_GUIDANCE_OPTION
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import CatalogDraftJob, CatalogDraftTask, DataSource
|
||||
from app.main import app as _fastapi_app
|
||||
from tests.fixtures.sources import drop_source
|
||||
|
||||
PHONES = [f"139000000{i:02d}" for i in range(1, 31)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 假模型:按剧本一轮一轮回
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class Turn:
|
||||
text: list[str] = field(default_factory=list)
|
||||
calls: list[tuple[str, dict]] = field(default_factory=list)
|
||||
cut: bool = False
|
||||
usage: tuple[int, int] = (100, 20)
|
||||
error: BaseException | None = None
|
||||
hang: bool = False
|
||||
|
||||
|
||||
class Scripted:
|
||||
"""bind_tools 记下交给它的工具;astream 每调一次按剧本回一轮,记下这一轮收到的消息。"""
|
||||
|
||||
def __init__(self, turns: list[Turn]) -> None:
|
||||
self.turns = list(turns)
|
||||
self.seen: list[list[Any]] = []
|
||||
self.tools: list[Any] | None = None
|
||||
|
||||
def bind_tools(self, tools: list[Any], **_: Any) -> "Scripted":
|
||||
self.tools = tools
|
||||
return self
|
||||
|
||||
async def astream(self, messages: list[Any]):
|
||||
self.seen.append(list(messages))
|
||||
turn = self.turns.pop(0)
|
||||
if turn.error is not None:
|
||||
raise turn.error
|
||||
if turn.hang:
|
||||
await asyncio.sleep(3600)
|
||||
for piece in turn.text:
|
||||
yield AIMessageChunk(content=piece)
|
||||
for i, (name, args) in enumerate(turn.calls):
|
||||
yield AIMessageChunk(content="", tool_call_chunks=[
|
||||
{"name": name, "args": json.dumps(args, ensure_ascii=False), "id": f"c{len(self.seen)}_{i}",
|
||||
"index": i}])
|
||||
yield AIMessageChunk(content="", usage_metadata={"input_tokens": turn.usage[0], "output_tokens": turn.usage[1],
|
||||
"total_tokens": sum(turn.usage)},
|
||||
response_metadata={"finish_reason": "length" if turn.cut else "stop"})
|
||||
|
||||
def tool_names(self) -> list[str]:
|
||||
return [t["function"]["name"] for t in self.tools or []]
|
||||
|
||||
|
||||
class _Unavailable(Exception):
|
||||
status_code = 503
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 夹具
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _shop_db(path) -> str:
|
||||
conn = sqlite3.connect(path)
|
||||
conn.executescript("""
|
||||
CREATE TABLE members (id INTEGER PRIMARY KEY, nick TEXT);
|
||||
CREATE TABLE orders (id INTEGER PRIMARY KEY, order_no TEXT NOT NULL, status INTEGER NOT NULL,
|
||||
amount REAL, buyer_phone TEXT, created_at TEXT,
|
||||
member_id INTEGER REFERENCES members(id));
|
||||
""")
|
||||
conn.executemany("INSERT INTO members VALUES (?, ?)", [(i, f"会员{i}") for i in range(1, 6)])
|
||||
conn.executemany("INSERT INTO orders VALUES (?, ?, ?, ?, ?, ?, ?)", [
|
||||
(i, f"NO{i:04d}", 9 if i % 10 == 0 else 1, 10.0 * i, PHONES[i - 1], f"2026-09-{i:02d}", i % 5 + 1)
|
||||
for i in range(1, 31)])
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return str(path)
|
||||
|
||||
|
||||
ORDERS_NOTES = {
|
||||
"label": make_item("订单", "llm"),
|
||||
"grain": make_item("每笔订单一行", "human", "confirmed"),
|
||||
"kind": make_item("fact", "llm", "rejected"),
|
||||
"columns": {
|
||||
"status": {"codes": make_item({"1": "已支付"}, "profile", note="数据剖析:全表 30 行,2 个取值")},
|
||||
"buyer_phone": {"label": make_item("联系电话", "llm"),
|
||||
"codes": make_item({PHONES[0]: "测试号"}, "human", "confirmed")},
|
||||
},
|
||||
}
|
||||
|
||||
_CREATED: list[str] = []
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def _cleanup():
|
||||
yield
|
||||
while _CREATED:
|
||||
await drop_source(_CREATED.pop())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
async with AsyncClient(transport=ASGITransport(app=_fastapi_app), base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
async def _source(client, tmp_path, *, profile: bool = True, guidance: str | None = "金额一律按元",
|
||||
notes: dict | None = None) -> str:
|
||||
options: dict[str, Any] = {"mask_columns": "buyer_phone"}
|
||||
if profile:
|
||||
options["catalog_profile"] = {"enabled": True}
|
||||
if guidance:
|
||||
options[CATALOG_DRAFT_GUIDANCE_OPTION] = guidance
|
||||
async with SessionLocal() as session:
|
||||
row = DataSource(name=f"shop_{uuid.uuid4().hex[:8]}", kind="sqlite", database=_shop_db(tmp_path / "shop.db"),
|
||||
options=options, description="虚构的网店库")
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
sid = row.id
|
||||
_CREATED.append(sid)
|
||||
r = await client.post(f"/api/datasources/{sid}/introspect")
|
||||
assert r.status_code == 200, r.text
|
||||
async with SessionLocal() as session:
|
||||
await catalog.write_entry(session, sid, "orders", notes if notes is not None else ORDERS_NOTES,
|
||||
if_version=0, actor=None)
|
||||
return sid
|
||||
|
||||
|
||||
def use(monkeypatch, model: Scripted) -> None:
|
||||
async def resolve(session):
|
||||
return model, "fake-chat"
|
||||
|
||||
monkeypatch.setattr(catalog_assistant, "resolve_model", resolve)
|
||||
|
||||
|
||||
def _url(sid: str, table: str = "orders") -> str:
|
||||
return f"/api/datasources/{sid}/catalog/{table}/assistant"
|
||||
|
||||
|
||||
async def _talk(client, sid: str, body: dict, table: str = "orders") -> tuple[list[dict], list[str]]:
|
||||
"""发一轮,返回 (事件, 原始行)。"""
|
||||
events: list[dict] = []
|
||||
lines: list[str] = []
|
||||
async with client.stream("POST", _url(sid, table), json=body) as r:
|
||||
assert r.status_code == 200, await r.aread()
|
||||
assert r.headers["content-type"].startswith("text/event-stream")
|
||||
async for line in r.aiter_lines():
|
||||
lines.append(line)
|
||||
if line.startswith("data: "):
|
||||
events.append(json.loads(line[6:]))
|
||||
return events, lines
|
||||
|
||||
|
||||
def _text(events: list[dict]) -> str:
|
||||
return "".join(e["delta"] for e in events if e["type"] == "text")
|
||||
|
||||
|
||||
def _ask(text: str, **extra) -> dict:
|
||||
return {"messages": [{"role": "user", "content": text}], **extra}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 开始前的检查
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_refusals_before_the_stream(client, tmp_path, monkeypatch):
|
||||
sid = await _source(client, tmp_path)
|
||||
use(monkeypatch, Scripted([]))
|
||||
assert (await client.post(_url(sid, "ghost"), json=_ask("你好"))).status_code == 404
|
||||
assert (await client.post("/api/datasources/nope/catalog/orders/assistant", json=_ask("x"))).status_code == 404
|
||||
too_many = {"messages": [{"role": "user", "content": "x"}] * 41}
|
||||
assert (await client.post(_url(sid), json=too_many)).status_code == 422
|
||||
too_long = _ask("长" * 4001)
|
||||
assert (await client.post(_url(sid), json=too_long)).status_code == 422
|
||||
wrong_role = {"messages": [{"role": "system", "content": "x"}]}
|
||||
assert (await client.post(_url(sid), json=wrong_role)).status_code == 422
|
||||
last_is_answer = {"messages": [{"role": "user", "content": "x"}, {"role": "assistant", "content": "y"}]}
|
||||
r = await client.post(_url(sid), json=last_is_answer)
|
||||
assert r.status_code == 422 and "最后一条" in r.json()["detail"]
|
||||
|
||||
|
||||
async def test_unavailable_draft_model_is_409_with_the_draft_wording(client, tmp_path, monkeypatch):
|
||||
sid = await _source(client, tmp_path)
|
||||
|
||||
async def unavailable(session):
|
||||
raise catalog.CatalogModelUnavailable("未配置模型接入,无法由模型起草数据目录。请到「设置 → 模型接入」添加")
|
||||
|
||||
monkeypatch.setattr(catalog, "resolve_draft_model", unavailable)
|
||||
r = await client.post(_url(sid), json=_ask("你好"))
|
||||
assert r.status_code == 409
|
||||
assert r.json()["code"] == "catalog_draft_model_unavailable" and "模型接入" in r.json()["detail"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mem(monkeypatch):
|
||||
"""单独的内存库:接入和设置只在这个用例里。"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
for key in ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENAI_API_KEY", "ANTHROPIC_BASE_URL"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:", poolclass=StaticPool)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)()
|
||||
yield session
|
||||
await session.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _provider(session, kind: str) -> None:
|
||||
from app.core.crypto import encrypt
|
||||
from app.data import draft_model
|
||||
from app.db.models import Provider, Setting
|
||||
|
||||
session.add(Provider(name="起草网关", kind=kind, base_url="http://gw.example.test/v1" if kind != "anthropic" else None,
|
||||
api_key=encrypt("sk-test"), models=[{"id": "模型甲"}], default_model="模型甲", enabled=True,
|
||||
extra={}))
|
||||
session.add(Setting(key=draft_model.CATALOG_DRAFT_SETTING_KEY, value={"provider": "起草网关", "model": None}))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_model_follows_the_draft_setting_keeps_thinking_and_a_big_budget(mem, monkeypatch):
|
||||
from app.providers import factory
|
||||
|
||||
await _provider(mem, "openai_compatible")
|
||||
specs: list[Any] = []
|
||||
real = factory.build_chat_model
|
||||
|
||||
def spy(provider, spec):
|
||||
specs.append(spec)
|
||||
return real(provider, spec)
|
||||
|
||||
monkeypatch.setattr(factory, "build_chat_model", spy)
|
||||
model, model_id = await catalog_assistant.resolve_model(mem)
|
||||
assert model_id == "模型甲" and model.max_tokens == catalog.MODEL_MAX_TOKENS == 32768
|
||||
mine = specs[-2:] # 前两份是 catalog.resolve_draft_model 查能不能用时建的
|
||||
assert [s.max_tokens for s in mine] == [catalog.MODEL_MAX_TOKENS, catalog.MODEL_FALLBACK_MAX_TOKENS]
|
||||
assert all(s.thinking is None and s.temperature == 0 and s.max_retries == 0 for s in mine)
|
||||
assert all(s.timeout == catalog.MODEL_TIMEOUT_S for s in mine)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["openai_compatible", "anthropic"])
|
||||
async def test_tools_bind_on_real_chat_models(mem, kind):
|
||||
"""两个工具的定义各家的模型都收(只绑定,不发请求)。"""
|
||||
await _provider(mem, kind)
|
||||
model, _ = await catalog_assistant.resolve_model(mem)
|
||||
tools = [catalog_assistant.QUERY_TOOL_PROMPT, catalog_assistant.PROPOSE_TOOL_PROMPT]
|
||||
bound = model.bind_tools(tools)
|
||||
given = bound.model.kwargs["tools"]
|
||||
names = [t.get("name") or t["function"]["name"] for t in given]
|
||||
assert names == ["run_query", "propose_changes"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 背景
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _blank_draft(sid: str) -> None:
|
||||
"""最近一次结束于 ok 的起草任务记下留空的列;之后一次停止了的不算。"""
|
||||
async with SessionLocal() as session:
|
||||
done = CatalogDraftJob(id=uuid.uuid4().hex, source_id=sid, status="succeeded",
|
||||
created_at=datetime(2026, 10, 1, tzinfo=timezone.utc))
|
||||
stopped = CatalogDraftJob(id=uuid.uuid4().hex, source_id=sid, status="cancelled",
|
||||
created_at=datetime(2026, 10, 2, tzinfo=timezone.utc))
|
||||
session.add_all([done, stopped])
|
||||
await session.flush()
|
||||
session.add_all([
|
||||
CatalogDraftTask(job_id=done.id, seq=0, table_name="orders", status="ok", blank_columns=2,
|
||||
blank_sample=["order_no", "created_at"]),
|
||||
CatalogDraftTask(job_id=stopped.id, seq=0, table_name="orders", status="cancelled", blank_columns=0),
|
||||
])
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_background_has_structure_catalog_and_hides_masked_values(client, tmp_path, monkeypatch):
|
||||
sid = await _source(client, tmp_path)
|
||||
await _blank_draft(sid)
|
||||
async with SessionLocal() as session:
|
||||
await catalog.write_entry(session, sid, "members", {"relations": [
|
||||
{"id": catalog.relation_id("members", ["id"], "orders", ["member_id"]), "columns": ["id"],
|
||||
"to_table": "orders", "to_columns": ["member_id"], "cardinality": "one_to_many", "coverage": None,
|
||||
"source": "human", "status": "confirmed"}]}, if_version=0, actor=None)
|
||||
model = Scripted([Turn(text=["这张表还缺 order_no 的含义。"])])
|
||||
use(monkeypatch, model)
|
||||
pending = [{"path": "columns.amount.unit", "value": "元", "reason": "用户说:金额是元"}]
|
||||
events, _ = await _talk(client, sid, _ask("看看还缺什么", pending=pending))
|
||||
assert _text(events) == "这张表还缺 order_no 的含义。"
|
||||
assert events[-1] == {"type": "done", "usage": {"input_tokens": 100, "output_tokens": 20}, "queries": 0}
|
||||
[messages] = model.seen
|
||||
assert isinstance(messages[0], SystemMessage) and isinstance(messages[-1], HumanMessage)
|
||||
system = messages[0].content
|
||||
# 规矩
|
||||
for rule in ("只改数据目录", "不改业务库", "公式", "拿不准", "原话"):
|
||||
assert rule in system
|
||||
# 有效记录条件的写法(同起草时的校验规矩),免得提了再被退回
|
||||
assert "只能用这张表的列" in system and "不能有子查询" in system and "UPPER、LOWER、TRIM、COALESCE" in system
|
||||
# 表结构、目录(来源和状态)、使用次数、起草要求、数据源说明
|
||||
for fact in ("order_no", "status", "amount", "member_id → members.id", "主键", "每笔订单一行", "人工填写", "已确认",
|
||||
"模型起草", "已驳回", "还没有被运行用到过", "金额一律按元", "虚构的网店库", "数据剖析:全表 30 行"):
|
||||
assert fact in system, fact
|
||||
# 别的表指过来的关联关系
|
||||
assert "members.id → orders.member_id" in system
|
||||
# 最近一次结束于 ok 的起草留空的列(之后停止了的那次不算)
|
||||
assert "order_no、created_at" in system
|
||||
# 界面上还没保存的修改
|
||||
assert "columns.amount.unit" in system and "用户说:金额是元" in system
|
||||
# 遮罩列只给列名:码值、取值一个都不给
|
||||
assert "buyer_phone" in system
|
||||
assert not any(p in system for p in PHONES) and "测试号" not in system
|
||||
# 没开剖析的源才写不能查数据;这里开了,给了查询工具
|
||||
assert model.tool_names() == ["run_query", "propose_changes"]
|
||||
assert "没开剖析" not in system
|
||||
|
||||
|
||||
async def test_the_draft_guidance_is_not_what_the_user_said(client, tmp_path, monkeypatch):
|
||||
"""数据源的起草要求是以前写在数据源上的背景,不是这次对话里使用者说的话:背景里标明白;系统提示词写清 reason 里引用的
|
||||
原话只能是这次对话里使用者的消息,依据来自起草要求时写「起草要求写着:……」。验收时模型把起草要求写成了
|
||||
「使用者说:……」,保存之后看起来像是使用者亲口确认过。"""
|
||||
sid = await _source(client, tmp_path, guidance="pay_type:1=现金,2=刷卡")
|
||||
model = Scripted([Turn(text=["好。"])])
|
||||
use(monkeypatch, model)
|
||||
await _talk(client, sid, _ask("看看还缺什么"))
|
||||
system = model.seen[0][0].content
|
||||
assert "数据源的起草要求(不是这次对话里使用者说的话):pay_type:1=现金,2=刷卡" in system
|
||||
assert "使用者补充的背景和要求" not in system
|
||||
assert "只能引用这次对话里使用者的消息" in system and "起草要求写着:" in system
|
||||
reason = (catalog_assistant.PROPOSE_TOOL_PROMPT["function"]["parameters"]["properties"]["changes"]["items"]
|
||||
["properties"]["reason"]["description"])
|
||||
assert "这次对话里使用者" in reason and "起草要求写着" in reason
|
||||
|
||||
|
||||
async def test_reasons_saved_earlier_are_not_what_the_user_said(client, tmp_path, monkeypatch):
|
||||
"""目录项上以前保存时记下的依据(reason)不是这次对话里使用者说的话:背景里在这一项下面标成「以前确认时的依据:……」,
|
||||
和备注分开;系统提示词、propose_changes 的说明写清 reason 里引用的原话只能是这次对话里使用者的消息,来自以前的依据时写
|
||||
「以前确认时的依据写着:……」。说到遮罩列的项不给依据(同备注:里面可能有取值)。"""
|
||||
notes = {**ORDERS_NOTES,
|
||||
"grain": make_item("每笔订单一行", "human", "confirmed", reason="使用者说:一笔订单一行"),
|
||||
"columns": {**ORDERS_NOTES["columns"],
|
||||
"buyer_phone": {"label": make_item("联系电话", "human", "confirmed",
|
||||
reason=f"使用者说:{PHONES[1]} 是客服电话")}}}
|
||||
sid = await _source(client, tmp_path, notes=notes)
|
||||
model = Scripted([Turn(text=["好。"])])
|
||||
use(monkeypatch, model)
|
||||
await _talk(client, sid, _ask("看看还缺什么"))
|
||||
system = model.seen[0][0].content
|
||||
assert "- grain(粒度):每笔订单一行〔人工填写,已确认〕\n 以前确认时的依据:使用者说:一笔订单一行" in system
|
||||
# 备注照旧另起一行,不和依据混在一起
|
||||
assert " 备注:数据剖析:全表 30 行,2 个取值" in system and "备注:使用者说" not in system
|
||||
assert PHONES[1] not in system and "客服电话" not in system
|
||||
assert "只能引用这次对话里使用者的消息" in system and "以前确认时的依据写着:" in system
|
||||
reason = (catalog_assistant.PROPOSE_TOOL_PROMPT["function"]["parameters"]["properties"]["changes"]["items"]
|
||||
["properties"]["reason"]["description"])
|
||||
assert "以前确认时的依据写着" in reason
|
||||
|
||||
|
||||
async def test_background_says_how_many_runs_used_the_table(client, tmp_path, monkeypatch):
|
||||
"""使用次数的口径同审阅队列:被几次运行用到(同「用过这张表的运行」的 total),查询几次(同目录清单的「使用 N 次」)。"""
|
||||
from app.db.models import Artifact, Run
|
||||
|
||||
sid = await _source(client, tmp_path)
|
||||
async with SessionLocal() as session:
|
||||
name = (await session.get(DataSource, sid)).name
|
||||
for i, n in enumerate((2, 1)):
|
||||
rid = uuid.uuid4().hex
|
||||
session.add(Run(id=rid, graph={}, workflow_name=f"周报{i}", status="succeeded"))
|
||||
for _ in range(n):
|
||||
session.add(Artifact(id=uuid.uuid4().hex, run_id=rid, kind=catalog.QUERY_SNAPSHOT_KIND,
|
||||
meta={"source_id": sid, "source": name, "tables": ["orders"]}))
|
||||
await session.commit()
|
||||
model = Scripted([Turn(text=["好。"])])
|
||||
use(monkeypatch, model)
|
||||
await _talk(client, sid, _ask("看看"))
|
||||
assert "这张表被 2 次运行用到(查询 3 次)。" in model.seen[0][0].content
|
||||
|
||||
|
||||
async def test_long_catalogs_are_cut_with_a_note(client, tmp_path, monkeypatch):
|
||||
sid = await _source(client, tmp_path)
|
||||
monkeypatch.setattr(catalog_assistant, "CATALOG_ITEMS_SHOWN", 2)
|
||||
model = Scripted([Turn(text=["好。"])])
|
||||
use(monkeypatch, model)
|
||||
await _talk(client, sid, _ask("看看"))
|
||||
system = model.seen[0][0].content
|
||||
# 表级的项在前;遮罩列的码值本来就不算;剩下的说明还有几项
|
||||
assert "- label(中文名):订单" in system and "- grain(粒度)" in system and "- kind(表类型)" not in system
|
||||
assert "还有 3 项没有列出" in system
|
||||
|
||||
|
||||
async def test_without_profiling_there_is_no_query_tool(client, tmp_path, monkeypatch):
|
||||
sid = await _source(client, tmp_path, profile=False)
|
||||
model = Scripted([Turn(text=["只能根据表结构推断。"])])
|
||||
use(monkeypatch, model)
|
||||
await _talk(client, sid, _ask("状态 9 是什么"))
|
||||
assert model.tool_names() == ["propose_changes"]
|
||||
assert "这个数据源没开剖析,不能查数据,只能根据表结构和目录推断" in model.seen[0][0].content
|
||||
|
||||
|
||||
async def test_opening_without_user_messages(client, tmp_path, monkeypatch):
|
||||
sid = await _source(client, tmp_path)
|
||||
model = Scripted([Turn(text=["order_no 是什么意思?"])])
|
||||
use(monkeypatch, model)
|
||||
events, _ = await _talk(client, sid, {"messages": []})
|
||||
assert _text(events) == "order_no 是什么意思?"
|
||||
assert isinstance(model.seen[0][-1], HumanMessage)
|
||||
# 界面先显示了一句开场白(助手说的):照样从使用者这一边开始
|
||||
model = Scripted([Turn(text=["好的。"])])
|
||||
use(monkeypatch, model)
|
||||
await _talk(client, sid, {"messages": [{"role": "assistant", "content": "有两列留空了"},
|
||||
{"role": "user", "content": "order_no 是订单号"}]})
|
||||
kinds = [type(m).__name__ for m in model.seen[0]]
|
||||
assert kinds == ["SystemMessage", "HumanMessage", "AIMessage", "HumanMessage"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 提案
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_proposals_are_checked_and_never_written(client, tmp_path, monkeypatch):
|
||||
sid = await _source(client, tmp_path)
|
||||
changes = [
|
||||
{"path": "columns.status.codes", "value": {"9": "作废"}, "reason": "用户说:9 是作废"},
|
||||
{"path": "valid_filter", "value": "status <> 9", "reason": "用户说:统计时排除作废"},
|
||||
{"path": "columns.amount.meaning", "value": "实收=单价*数量", "reason": "x"}, # 公式:不收
|
||||
{"path": "columns.ghost.label", "value": "幽灵", "reason": "x"}, # 列不存在:不收
|
||||
{"path": "columns.buyer_phone.codes", "value": {PHONES[1]: "客服"}, "reason": "x"}, # 遮罩列:不给码值
|
||||
{"path": "grain", "value": "每笔订单一行", "reason": "x"}, # 已经是这个值:不用提
|
||||
]
|
||||
model = Scripted([Turn(text=["好的,"], calls=[("propose_changes", {"changes": changes})]),
|
||||
Turn(text=["已经整理成提案。"])])
|
||||
use(monkeypatch, model)
|
||||
events, _ = await _talk(client, sid, _ask("9 是作废,统计时排除"))
|
||||
assert _text(events) == "好的,已经整理成提案。"
|
||||
[proposal] = [e for e in events if e["type"] == "proposal"]
|
||||
assert proposal["source_id"] == sid and proposal["table"] == "orders" and proposal["version"] == 1
|
||||
assert proposal["source"].startswith("shop_") and proposal["table_label"] == "订单"
|
||||
by = {c["path"]: c for c in proposal["changes"]}
|
||||
assert set(by) == {"columns.status.codes", "valid_filter"}
|
||||
codes = by["columns.status.codes"]
|
||||
assert codes["label"] == "列 status 的码值" and codes["reason"] == "用户说:9 是作废"
|
||||
assert codes["before"] == {"1": "已支付"} and codes["after"] == {"1": "已支付", "9": "作废"}
|
||||
assert codes["value"] == {"9": "作废"} and codes["state"] == "change"
|
||||
assert by["valid_filter"]["label"] == "表的有效记录条件" and by["valid_filter"]["before"] is None
|
||||
# 没收的几项照实告诉模型(不带遮罩列的任何取值)
|
||||
tool = model.seen[1][-1]
|
||||
assert isinstance(tool, ToolMessage)
|
||||
for said in ("2 项", "公式", "ghost", "遮罩", "已经是这个值"):
|
||||
assert said in tool.content, said
|
||||
assert not any(p in tool.content for p in PHONES)
|
||||
assert events[-1]["type"] == "done" and events[-1]["usage"] == {"input_tokens": 200, "output_tokens": 40}
|
||||
async with SessionLocal() as session:
|
||||
entry = await catalog.read_entry(session, sid, "orders")
|
||||
assert entry.version == 1 and "valid_filter" not in entry.notes
|
||||
|
||||
|
||||
async def test_proposal_codes_build_on_the_pending_ones(client, tmp_path, monkeypatch):
|
||||
sid = await _source(client, tmp_path)
|
||||
model = Scripted([Turn(calls=[("propose_changes", {"changes": [
|
||||
{"path": "columns.status.codes", "value": {"9": "作废"}, "reason": "用户说:9 是作废"}]})]),
|
||||
Turn(text=["好。"])])
|
||||
use(monkeypatch, model)
|
||||
pending = [{"path": "columns.status.codes", "value": {"2": "已退款"}, "reason": "用户说:2 是退款"}]
|
||||
events, _ = await _talk(client, sid, _ask("9 是作废", pending=pending))
|
||||
[proposal] = [e for e in events if e["type"] == "proposal"]
|
||||
[change] = proposal["changes"]
|
||||
assert change["value"] == {"2": "已退款", "9": "作废"}
|
||||
assert change["after"] == {"1": "已支付", "2": "已退款", "9": "作废"}
|
||||
|
||||
|
||||
async def test_many_pending_changes_do_not_block_the_conversation(client, tmp_path, monkeypatch):
|
||||
"""宽表上逐列补中文名、含义,待保存的修改攒到 200 项以上:照常对话(不 422)。背景里只列最近的 200 项、写明另有几项;
|
||||
码值照全部待保存的修改合并(最早那条码值没列进背景,新提的码值也不能把它丢了)。"""
|
||||
sid = await _source(client, tmp_path)
|
||||
oldest = {"path": "columns.status.codes", "value": {"2": "已退款"}, "reason": "用户说:2 是退款"}
|
||||
labels = [{"path": f"columns.c{i:03d}.label", "value": f"第 {i} 列", "reason": f"用户说:第 {i} 列"}
|
||||
for i in range(201)]
|
||||
model = Scripted([Turn(calls=[("propose_changes", {"changes": [
|
||||
{"path": "columns.status.codes", "value": {"9": "作废"}, "reason": "用户说:9 是作废"}]})]),
|
||||
Turn(text=["好。"])])
|
||||
use(monkeypatch, model)
|
||||
events, _ = await _talk(client, sid, _ask("9 是作废", pending=[oldest, *labels]))
|
||||
assert events[-1]["type"] == "done"
|
||||
system = model.seen[0][0].content
|
||||
# 最近的 200 项列出来了,最早的两项没列,写明另有 2 项
|
||||
assert "columns.c200.label" in system and "columns.c001.label" in system
|
||||
assert "columns.c000.label" not in system and "用户说:2 是退款" not in system
|
||||
assert "另有 2 项" in system
|
||||
[proposal] = [e for e in events if e["type"] == "proposal"]
|
||||
[change] = proposal["changes"]
|
||||
assert change["value"] == {"2": "已退款", "9": "作废"}
|
||||
# 再多也收
|
||||
model = Scripted([Turn(text=["好。"])])
|
||||
use(monkeypatch, model)
|
||||
many = [{"path": f"columns.c{i:04d}.meaning", "value": "说明", "reason": "x"} for i in range(1200)]
|
||||
events, _ = await _talk(client, sid, _ask("接着看", pending=many))
|
||||
assert events[-1]["type"] == "done" and "另有 1000 项" in model.seen[0][0].content
|
||||
|
||||
|
||||
async def _propose_filters(client, sid: str, monkeypatch, filters: list[str]) -> tuple[list[dict], Scripted]:
|
||||
"""模型一轮提一条有效记录条件(同一条提案里同一个路径只认第一次),最后一轮回答。"""
|
||||
turns = [Turn(calls=[("propose_changes", {"changes": [
|
||||
{"path": "valid_filter", "value": f, "reason": "用户说:统计时排除作废"}]})]) for f in filters]
|
||||
model = Scripted([*turns, Turn(text=["好。"])])
|
||||
use(monkeypatch, model)
|
||||
events, _ = await _talk(client, sid, _ask("统计时排除作废的单子"))
|
||||
return events, model
|
||||
|
||||
|
||||
async def test_valid_filter_proposals_must_be_a_condition_on_this_table(client, tmp_path, monkeypatch):
|
||||
"""有效记录条件是模型写的 SQL:引用表结构里没有的列、别的表的列、带子查询的不交给界面,原因交回模型;合规的照常提。"""
|
||||
sid = await _source(client, tmp_path)
|
||||
events, model = await _propose_filters(client, sid, monkeypatch, [
|
||||
"status <> 9 AND ghost_col = 1",
|
||||
"members.nick IS NOT NULL",
|
||||
"member_id IN (SELECT id FROM members)",
|
||||
"status <> 9",
|
||||
])
|
||||
proposals = [e for e in events if e["type"] == "proposal"]
|
||||
assert [[c["value"] for c in p["changes"]] for p in proposals] == [["status <> 9"]]
|
||||
said = [[m for m in seen if isinstance(m, ToolMessage)][-1].content for seen in model.seen[1:5]]
|
||||
assert "ghost_col" in said[0] and "不在表结构里" in said[0] and "没有可以交给使用者的修改" in said[0]
|
||||
assert "members.nick" in said[1]
|
||||
assert "子查询" in said[2]
|
||||
assert "已把 1 项修改交给使用者确认" in said[3]
|
||||
|
||||
|
||||
async def test_valid_filter_proposals_follow_the_drafting_rules(client, tmp_path, monkeypatch):
|
||||
"""有起草的校验规矩(sqlcheck.valid_filter_problem:只能用几个函数、不能有聚合……)时按它核对,说法照它的。"""
|
||||
from app.data import sqlcheck
|
||||
|
||||
sid = await _source(client, tmp_path)
|
||||
calls: list[tuple] = []
|
||||
|
||||
def strict(condition, meta, *, kind, table=None):
|
||||
calls.append((condition, [c["name"] for c in meta["columns"]], kind, table))
|
||||
return "有效行过滤里只能用 UPPER、LOWER、TRIM、COALESCE 这几个函数,不能用 ABS" if "ABS" in condition else None
|
||||
|
||||
monkeypatch.setattr(sqlcheck, "valid_filter_problem", strict, raising=False)
|
||||
events, model = await _propose_filters(client, sid, monkeypatch, ["ABS(amount) > 0", "status <> 9"])
|
||||
proposals = [e for e in events if e["type"] == "proposal"]
|
||||
assert [[c["value"] for c in p["changes"]] for p in proposals] == [["status <> 9"]]
|
||||
assert "不能用 ABS" in [m for m in model.seen[1] if isinstance(m, ToolMessage)][-1].content
|
||||
assert calls[0][0] == "ABS(amount) > 0" and calls[0][2:] == ("sqlite", "orders")
|
||||
assert "status" in calls[0][1] and "member_id" in calls[0][1]
|
||||
|
||||
|
||||
async def test_nothing_acceptable_means_no_proposal_event(client, tmp_path, monkeypatch):
|
||||
sid = await _source(client, tmp_path)
|
||||
model = Scripted([Turn(calls=[("propose_changes", {"changes": [{"path": "columns.ghost.label", "value": "x"}]})]),
|
||||
Turn(text=["那一列不存在。"])])
|
||||
use(monkeypatch, model)
|
||||
events, _ = await _talk(client, sid, _ask("ghost 列叫什么"))
|
||||
assert not [e for e in events if e["type"] == "proposal"]
|
||||
assert "没有可以交给使用者的修改" in model.seen[1][-1].content
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 只读查询
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_queries_are_read_only_masked_and_capped(client, tmp_path, monkeypatch):
|
||||
sid = await _source(client, tmp_path)
|
||||
queries = [
|
||||
("run_query", {"sql": "SELECT status, COUNT(*) AS n FROM orders GROUP BY status ORDER BY status"}),
|
||||
("run_query", {"sql": "SELECT id, buyer_phone AS phone, amount FROM orders ORDER BY id"}),
|
||||
("run_query", {"sql": "DELETE FROM orders"}),
|
||||
]
|
||||
model = Scripted([Turn(text=["我查一下。"], calls=queries),
|
||||
Turn(calls=[("run_query", {"sql": "SELECT 1"})] * 3),
|
||||
Turn(text=["9 占一成。"])])
|
||||
use(monkeypatch, model)
|
||||
events, _ = await _talk(client, sid, _ask("状态分布"))
|
||||
shown = [e for e in events if e["type"] == "query"]
|
||||
assert len(shown) == 5 # 每次请求最多 5 条
|
||||
first, second, third = shown[:3]
|
||||
assert first["columns"] == ["status", "n"] and first["rows"] == [[1, 27], [9, 3]]
|
||||
assert first["row_count"] == 2 and first["error"] is None
|
||||
# 遮罩列改了名字也认得出:值写「已遮罩」;界面只给前 10 行
|
||||
assert second["columns"] == ["id", "phone", "amount"] and len(second["rows"]) == 10
|
||||
assert {row[1] for row in second["rows"]} == {"已遮罩"} and second["row_count"] == 30
|
||||
# 只读守卫:写语句不执行
|
||||
assert third["error"] and "守卫" in third["error"] and third["rows"] == []
|
||||
# 交给模型的:最多 50 行、遮罩值看不到;第 6 条没执行
|
||||
second_round = model.seen[1]
|
||||
results = [m for m in second_round if isinstance(m, ToolMessage)]
|
||||
assert len(results) == 3
|
||||
assert json.loads(results[1].content)["rows"][29][1] == "已遮罩"
|
||||
assert not any(p in m.content for m in second_round for p in PHONES)
|
||||
capped = [m for m in model.seen[2] if isinstance(m, ToolMessage)][-1]
|
||||
assert "未执行" in capped.content and "5 条" in capped.content
|
||||
assert events[-1] == {"type": "done", "usage": {"input_tokens": 300, "output_tokens": 60}, "queries": 5}
|
||||
with sqlite3.connect(tmp_path / "shop.db") as conn:
|
||||
assert conn.execute("SELECT COUNT(*) FROM orders").fetchone()[0] == 30
|
||||
|
||||
|
||||
async def test_query_results_given_to_the_model_stop_at_50_rows(client, tmp_path, monkeypatch):
|
||||
sid = await _source(client, tmp_path)
|
||||
sql = "WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 80) SELECT i FROM n"
|
||||
model = Scripted([Turn(calls=[("run_query", {"sql": sql})]), Turn(text=["好。"])])
|
||||
use(monkeypatch, model)
|
||||
events, _ = await _talk(client, sid, _ask("数一下"))
|
||||
[query] = [e for e in events if e["type"] == "query"]
|
||||
assert query["row_count"] == 50 and query["truncated"] is True and len(query["rows"]) == 10
|
||||
result = json.loads([m for m in model.seen[1] if isinstance(m, ToolMessage)][0].content)
|
||||
assert len(result["rows"]) == 50 and result["truncated"] is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 中途出错
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_errors_end_the_stream_without_done(client, tmp_path, monkeypatch):
|
||||
sid = await _source(client, tmp_path)
|
||||
use(monkeypatch, Scripted([Turn(error=RuntimeError("接口不认这个模型"))]))
|
||||
events, _ = await _talk(client, sid, _ask("你好"))
|
||||
assert events[-1]["type"] == "error" and events[-1]["code"] == "assistant_model_error"
|
||||
assert "fake-chat" in events[-1]["message"] and not [e for e in events if e["type"] == "done"]
|
||||
use(monkeypatch, Scripted([Turn(text=["写到一半"], cut=True)]))
|
||||
events, _ = await _talk(client, sid, _ask("你好"))
|
||||
assert _text(events) == "写到一半"
|
||||
assert events[-1]["type"] == "error" and events[-1]["code"] == "assistant_truncated"
|
||||
assert "上限" in events[-1]["message"]
|
||||
|
||||
|
||||
async def test_a_model_without_tools_reports_an_error_event(client, tmp_path, monkeypatch):
|
||||
sid = await _source(client, tmp_path)
|
||||
|
||||
class NoTools(Scripted):
|
||||
def bind_tools(self, tools, **_):
|
||||
raise NotImplementedError("这个模型不支持工具调用")
|
||||
|
||||
use(monkeypatch, NoTools([]))
|
||||
events, _ = await _talk(client, sid, _ask("你好"))
|
||||
assert events == [{"type": "error", "code": "assistant_failed", "message": events[0]["message"]}]
|
||||
assert "工具调用" in events[0]["message"]
|
||||
|
||||
|
||||
async def test_transient_errors_are_retried_before_any_output(client, tmp_path, monkeypatch):
|
||||
sid = await _source(client, tmp_path)
|
||||
monkeypatch.setattr(catalog_assistant, "RETRY_DELAYS_S", (0.0, 0.0))
|
||||
model = Scripted([Turn(error=_Unavailable("服务暂时不可用")), Turn(text=["好了。"])])
|
||||
use(monkeypatch, model)
|
||||
events, _ = await _talk(client, sid, _ask("你好"))
|
||||
assert _text(events) == "好了。" and events[-1]["type"] == "done"
|
||||
assert len(model.seen) == 2
|
||||
|
||||
|
||||
async def test_a_call_over_the_time_limit_is_stopped(client, tmp_path, monkeypatch):
|
||||
sid = await _source(client, tmp_path)
|
||||
monkeypatch.setattr(catalog, "MODEL_TIMEOUT_S", 0.3)
|
||||
monkeypatch.setattr(catalog_assistant, "HEARTBEAT_S", 0.1)
|
||||
use(monkeypatch, Scripted([Turn(hang=True)]))
|
||||
events, lines = await _talk(client, sid, _ask("你好"))
|
||||
assert events == [events[-1]] and events[-1]["code"] == "assistant_timeout"
|
||||
assert "0.3 秒" in events[-1]["message"]
|
||||
# 等模型的时候按拍发 SSE 注释(不是事件),免得中间的代理断开
|
||||
assert any(line.startswith(":") for line in lines)
|
||||
|
||||
|
||||
async def test_steps_are_bounded(client, tmp_path, monkeypatch):
|
||||
sid = await _source(client, tmp_path)
|
||||
monkeypatch.setattr(catalog_assistant, "MAX_ROUNDS", 3)
|
||||
again = Turn(calls=[("propose_changes", {"changes": [{"path": "columns.ghost.label", "value": "x"}]})])
|
||||
model = Scripted([again, again, again])
|
||||
use(monkeypatch, model)
|
||||
events, _ = await _talk(client, sid, _ask("你好"))
|
||||
assert len(model.seen) == 3
|
||||
assert events[-1]["type"] == "error" and events[-1]["code"] == "assistant_rounds"
|
||||
# 倒数第二轮的工具结果里提醒模型直接回答
|
||||
assert "直接回答" in [m for m in model.seen[2] if isinstance(m, ToolMessage)][-1].content
|
||||
|
||||
|
||||
class _Slow(Scripted):
|
||||
"""说了半句就一直想:记下这次调用有没有被停掉。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__([])
|
||||
self.cancelled = False
|
||||
|
||||
async def astream(self, messages: list[Any]):
|
||||
self.seen.append(list(messages))
|
||||
yield AIMessageChunk(content="我想一想")
|
||||
try:
|
||||
await asyncio.sleep(3600)
|
||||
except asyncio.CancelledError:
|
||||
self.cancelled = True
|
||||
raise
|
||||
yield AIMessageChunk(content="……")
|
||||
|
||||
|
||||
async def test_stopping_the_stream_stops_the_model_call():
|
||||
"""使用者点停止(连接断开、流被关掉):正在进行的模型调用跟着停,不在后台接着花额度。"""
|
||||
from app.data import catalog_profile
|
||||
|
||||
model = _Slow()
|
||||
ctx = catalog_assistant.AssistantContext(
|
||||
source_id="s", source_name="虚构库", kind="sqlite", table="orders", tables={}, query_source=None, options={},
|
||||
masked=set(), can_query=False, settings=catalog_profile.ProfileSettings(), system="系统提示")
|
||||
events = catalog_assistant.stream(model, "fake-chat", ctx, [{"role": "user", "content": "你好"}])
|
||||
first = await events.__anext__()
|
||||
assert json.loads(first[len("data: "):]) == {"type": "text", "delta": "我想一想"}
|
||||
await events.aclose()
|
||||
assert model.cancelled
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 保存:PATCH 一次最多 200 项
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_patch_accepts_up_to_200_changes(client):
|
||||
"""表助手一次能提很多项(逐列补中文名),保存不再卡在 20 项:预览、保存都收 200 项,201 项 422。"""
|
||||
assert catalog.PATCH_MAX_CHANGES == 200
|
||||
columns = [f"c{i:03d}" for i in range(200)]
|
||||
table = {"qualified": "wide", "primary_key": [], "comment": None, "is_view": False, "foreign_keys": [],
|
||||
"unique": [], "columns": [{"name": c, "type": "TEXT", "nullable": True, "comment": None} for c in columns]}
|
||||
async with SessionLocal() as session:
|
||||
row = DataSource(name=f"wide_{uuid.uuid4().hex[:8]}", kind="sqlite", database=":memory:",
|
||||
schema_cache={"tables": {"wide": table}})
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
sid = row.id
|
||||
_CREATED.append(sid)
|
||||
base = f"/api/datasources/{sid}/catalog/wide"
|
||||
changes = [{"path": f"columns.{c}.label", "value": f"第 {i} 列", "reason": "用户逐列说明"}
|
||||
for i, c in enumerate(columns)]
|
||||
preview = await client.post(f"{base}/patch/preview", json={"changes": changes})
|
||||
assert preview.status_code == 200, preview.text
|
||||
assert len(preview.json()["changes"]) == 200 and preview.json()["problems"] == []
|
||||
saved = await client.post(f"{base}/patch", json={"changes": changes, "if_version": 0})
|
||||
assert saved.status_code == 200, saved.text
|
||||
assert len(saved.json()["notes"]["columns"]) == 200
|
||||
assert saved.json()["notes"]["columns"]["c199"]["label"]["value"] == "第 199 列"
|
||||
too_many = changes + [{"path": "label", "value": "宽表"}]
|
||||
assert (await client.post(f"{base}/patch/preview", json={"changes": too_many})).status_code == 422
|
||||
assert (await client.post(f"{base}/patch", json={"changes": too_many, "if_version": 1})).status_code == 422
|
||||
@@ -254,6 +254,7 @@ async def test_hollow_shell_splits_the_pack_by_table():
|
||||
|
||||
async def test_splitting_stops_at_three_levels_and_five_columns(monkeypatch):
|
||||
monkeypatch.setattr(catalog, "MODEL_PART_MAX_CALLS", 99)
|
||||
monkeypatch.setattr(catalog, "MODEL_HALT_ON", 99) # 不让按比例止损先停下来
|
||||
tables = {"wide40": _table(40)}
|
||||
model = Scripted(lambda call: EXHAUSTED)
|
||||
got = await D.run_model_draft(model, _source(tables), ["wide40"])
|
||||
@@ -272,7 +273,7 @@ async def test_splitting_stops_at_three_levels_and_five_columns(monkeypatch):
|
||||
async def test_one_part_makes_at_most_eight_calls(monkeypatch):
|
||||
"""一段(连同它拆出来的、补问的、重试的)最多调用 8 次:40 列一直用完额度,拆到第 8 次就停,剩下没问的记没写出来、
|
||||
原因写清。以前一段 40 列拆到底要 15 次。"""
|
||||
monkeypatch.setattr(catalog, "MODEL_GIVE_UP_AFTER", 99)
|
||||
monkeypatch.setattr(catalog, "MODEL_HALT_ON", 99)
|
||||
assert catalog.MODEL_PART_MAX_CALLS == 8
|
||||
model = Scripted(lambda call: EXHAUSTED)
|
||||
got = await D.run_model_draft(model, _source({"t40": _table(40)}), ["t40"])
|
||||
@@ -755,6 +756,7 @@ async def test_a_wide_table_with_one_failed_part_is_partial_and_says_which():
|
||||
async def test_reasons_name_each_part_and_say_these_parts(monkeypatch):
|
||||
"""几段因为同一个原因没写出来:原因里写「这几段」;每段写上列的范围(列名照表结构原样);拆过的才补「已拆小重试」。"""
|
||||
monkeypatch.setattr(catalog, "MODEL_SHRINK_AFTER", 99)
|
||||
monkeypatch.setattr(catalog, "MODEL_HALT_ON", 99)
|
||||
tables = {"wide95": _table(95)}
|
||||
ok = answering(tables)
|
||||
|
||||
@@ -827,33 +829,38 @@ async def test_tables_are_packed_up_to_40_columns_and_4_tables():
|
||||
|
||||
|
||||
async def test_the_same_reason_is_said_once_even_when_some_parts_were_split(monkeypatch):
|
||||
"""宽表的表级那段拆不开、列分段拆过:原因只说一次,拆过的在最后补一句。"""
|
||||
monkeypatch.setattr(catalog, "MODEL_GIVE_UP_AFTER", 99)
|
||||
"""宽表的表级那段拆不开、列分段拆过:原因只说一次,「已拆小重试」只跟在真拆过的那几段后面说。"""
|
||||
monkeypatch.setattr(catalog, "MODEL_HALT_ON", 99)
|
||||
monkeypatch.setattr(catalog, "MODEL_PART_MAX_CALLS", 99)
|
||||
model = Scripted(_hollow)
|
||||
got = await D.run_model_draft(model, _source({"wide45": _table(45)}), ["wide45"])
|
||||
o = got["wide45"]
|
||||
assert o.status == "failed"
|
||||
assert o.error == ("这张表 45 列,分 3 段起草,第 1 段(表级的项)、第 2 段(c001 到 c023,共 23 列)、"
|
||||
"第 3 段(c024 到 c045,共 22 列)都没写出来:模型只给了空壳,没有写出可用的内容;已拆小重试仍没写出")
|
||||
"第 3 段(c024 到 c045,共 22 列)都没写出来:模型只给了空壳,没有写出可用的内容;"
|
||||
"其中第 2、3 段已拆小重试仍没写出")
|
||||
|
||||
|
||||
def test_reasons_are_joined_by_their_base():
|
||||
"""(原因, 第几段):同样的原因只说一次;同一个原因落在几段上写「这几段」,同一段拆出来的几块仍是「这一段」。"""
|
||||
"""(原因, 第几段):同样的原因只说一次;同一个原因落在几段上写「这几段」,同一段拆出来的几块仍是「这一段」。
|
||||
「已拆小重试仍没写出」只跟在真拆过的那几段后面说:说到的段都拆过时不点名,只有一部分拆过时写「其中第几段」。"""
|
||||
W = D._Why
|
||||
ex = D.REASON_EXHAUSTED.format(budget=32768, seg=D.SEG)
|
||||
assert D._join([(W(D.REASON_HOLLOW), 1), (W(D.REASON_HOLLOW, split=True), 2)]) == \
|
||||
D.REASON_HOLLOW + D.REASON_SPLIT
|
||||
D.REASON_HOLLOW + ";其中第 2 段已拆小重试仍没写出"
|
||||
assert D._join([(W(ex, split=True), 2), (W(D.REASON_HOLLOW), 1)]) == \
|
||||
f"{_exhausted()};{D.REASON_HOLLOW}{D.REASON_SPLIT}"
|
||||
f"{_exhausted()};{D.REASON_HOLLOW};其中第 2 段已拆小重试仍没写出"
|
||||
assert D._join([(W(ex, split=True), 2), (W(ex, split=True), 3)]) == _exhausted("这几段") + D.REASON_SPLIT
|
||||
assert D._join([(W(ex, split=True), 3), (W(ex), 4), (W(ex, split=True), 2)]) == \
|
||||
_exhausted("这几段") + ";其中第 2、3 段已拆小重试仍没写出"
|
||||
assert D._join([(W(ex), 2), (W(ex), 3)]) == _exhausted("这几段")
|
||||
assert D._join([(W(ex), 2), (W(ex, split=True), 2)]) == _exhausted() + D.REASON_SPLIT
|
||||
assert D._join([(W(ex), 2), (W(ex, split=True), 2)]) == _exhausted() + D.REASON_SPLIT, "同一段里有一块拆过就算拆过"
|
||||
assert D._join([(W("甲"), 1), (W("乙"), 1), (W("丙", split=True), 1)]) == "甲;乙", "没说出来的原因不补后缀"
|
||||
halted = D.REASON_HALTED.format(n=catalog.MODEL_GIVE_UP_AFTER)
|
||||
halted = D.REASON_HALTED.format(window=catalog.MODEL_HALT_WINDOW, on=catalog.MODEL_HALT_ON)
|
||||
assert halted == ";最近 10 次调用有 7 次以上没写出来,已停止拆小重试,建议更换起草模型"
|
||||
assert D._join([(W(D.REASON_HOLLOW, halted=halted), 1), (W(D.REASON_HOLLOW), 2)]) == D.REASON_HOLLOW + halted
|
||||
slow = D.REASON_SLOW.format(n=catalog.MODEL_MIN_PART_COLUMNS)
|
||||
assert D._join([(W(D.REASON_HOLLOW, halted=halted), 1), (W(D.REASON_HOLLOW, halted=slow), 2),
|
||||
(W(D.REASON_HOLLOW, halted=halted), 3)]) == D.REASON_HOLLOW + halted + slow, "两种止损各说一次"
|
||||
assert D._join([(W(D.REASON_HOLLOW, halted=halted), 1), (W(D.REASON_HOLLOW, halted=halted), 3)]) == \
|
||||
D.REASON_HOLLOW + halted, "止损只说一次"
|
||||
assert D._join([(W("调用模型出错:{坏的}"), 1)]) == "调用模型出错:{坏的}", "报错原文里的花括号原样留着"
|
||||
|
||||
|
||||
@@ -865,50 +872,133 @@ def _by_table(model: Scripted) -> dict[str, int]:
|
||||
return out
|
||||
|
||||
|
||||
async def test_after_two_segments_in_a_row_come_to_nothing_the_rest_are_not_split(monkeypatch):
|
||||
"""模型明显用不了(思考总是吃满额度):连续 2 段拆到底仍一项没写出,后面的段只问一次、不再拆,报错说明已停止拆小重试。
|
||||
有一段写出来了就恢复拆小。一次只起草一段(并发 1),先后是确定的。"""
|
||||
HALTED = ";最近 10 次调用有 7 次以上没写出来,已停止拆小重试,建议更换起草模型"
|
||||
|
||||
|
||||
async def test_when_most_recent_calls_fail_the_rest_are_asked_once_until_the_ratio_drops(monkeypatch):
|
||||
"""按比例止损:这次起草最近 10 次调用有 7 次以上没写出来,之后的段不再拆小、每块只问一次,报错说明已停止拆小重试;
|
||||
比例降下去(最近 10 次里少于 7 次)就恢复拆小。偶尔一次写出来不会清零:t4 到 t6 写出来了,最近 10 次里仍有 7 次没写
|
||||
出来,照旧不拆;t7 写出来以后才降下去,t8 照常拆小。一次只起草一段(并发 1),先后是确定的。"""
|
||||
monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
|
||||
monkeypatch.setattr(catalog, "MODEL_BATCH_TABLES", 1)
|
||||
assert catalog.MODEL_GIVE_UP_AFTER == 2
|
||||
tables = {f"t{i}": _table(10) for i in range(1, 5)}
|
||||
exhausted = Reply(text="", finish="length", thinking="……")
|
||||
|
||||
model = Scripted(lambda call: exhausted)
|
||||
got = await D.run_model_draft(model, _source(tables), list(tables))
|
||||
assert _by_table(model) == {"t1": 3, "t2": 3, "t3": 1, "t4": 1}
|
||||
budget = _exhausted()
|
||||
assert got["t1"].error == budget + D.REASON_SPLIT
|
||||
assert got["t3"].error == budget + ";模型连续 2 段都没写出来,已停止拆小重试,建议更换起草模型"
|
||||
|
||||
assert (catalog.MODEL_HALT_WINDOW, catalog.MODEL_HALT_ON) == (10, 7)
|
||||
tables = {f"t{i}": _table(10) for i in range(1, 9)}
|
||||
ok = answering(tables)
|
||||
model = Scripted(lambda call: ok(call) if "t3" in call.asked else exhausted)
|
||||
bad = {"t1", "t2", "t3", "t8"}
|
||||
|
||||
model, reports = Scripted(lambda call: EXHAUSTED if set(call.asked) & bad else ok(call)), Reports()
|
||||
got = await D.run_model_draft(model, _source(tables), list(tables), hooks=reports.hooks())
|
||||
assert _by_table(model) == {"t1": 3, "t2": 3, "t3": 1, "t4": 1, "t5": 1, "t6": 1, "t7": 1, "t8": 3}
|
||||
assert got["t1"].error == _exhausted() + D.REASON_SPLIT
|
||||
assert got["t3"].error == _exhausted() + HALTED
|
||||
assert {got[f"t{i}"].status for i in range(4, 8)} == {"ok"}
|
||||
assert got["t8"].error == _exhausted() + D.REASON_SPLIT, "比例降下去以后照常拆小"
|
||||
assert len(reports.items) == len(model.calls)
|
||||
|
||||
|
||||
async def test_while_halted_a_chunk_is_not_asked_again_for_missing_columns(monkeypatch):
|
||||
"""止损期间每块只问一次:回复里漏了几列不补问,漏的列记没写出来、说明已停止拆小重试。"""
|
||||
monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
|
||||
monkeypatch.setattr(catalog, "MODEL_BATCH_TABLES", 1)
|
||||
tables = {**{f"x{i}": _table(4) for i in range(1, 8)}, "t8": _table(8)}
|
||||
ok = answering(tables)
|
||||
|
||||
def handle(call: Call) -> Reply:
|
||||
if "t8" not in call.asked:
|
||||
raise RuntimeError("网关拒绝了这次请求")
|
||||
reply = ok(call)
|
||||
reply.obj["tables"][0]["columns"] = reply.obj["tables"][0]["columns"][:6]
|
||||
return reply
|
||||
|
||||
model = Scripted(handle)
|
||||
got = await D.run_model_draft(model, _source(tables), list(tables))
|
||||
assert _by_table(model) == {"t1": 3, "t2": 3, "t3": 1, "t4": 3}, "t3 写出来了,t4 照常拆小"
|
||||
assert got["t3"].status == "ok" and got["t4"].error == budget + D.REASON_SPLIT
|
||||
assert _by_table(model)["t8"] == 1, "漏了 c007、c008 也不补问"
|
||||
o = got["t8"]
|
||||
assert o.status == "partial"
|
||||
assert o.error == ("这张表 8 列,有 2 列(c007、c008)没写出来:模型的回复里漏了这些列" + HALTED
|
||||
+ "。可以只重试这张表")
|
||||
|
||||
|
||||
#: 200 列的宽表分 6 段起草(表级一段、列分段 5 段),逐段写上列的范围
|
||||
WIDE200_PARTS = ("这张表 200 列,分 6 段起草,第 1 段(表级的项)、第 2 段(c001 到 c040,共 40 列)、"
|
||||
"第 3 段(c041 到 c080,共 40 列)、第 4 段(c081 到 c120,共 40 列)、第 5 段(c121 到 c160,共 40 列)、"
|
||||
"第 6 段(c161 到 c200,共 40 列)")
|
||||
|
||||
|
||||
async def test_a_wide_table_skips_its_remaining_parts_after_three_dead_parts_in_a_row(monkeypatch):
|
||||
"""同一张表连续 3 个原始段都一项没写出来:这张表剩下的段跳过不问,原因写清跳过了几段、可以换一个起草模型后只重试
|
||||
这张表。200 列分 6 段(表级一段、列分段 5 段)。"""
|
||||
monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
|
||||
assert catalog.MODEL_TABLE_GIVE_UP_AFTER == 3
|
||||
tables = {"wide200": _table(200)}
|
||||
|
||||
def refuse(call: Call) -> Reply:
|
||||
raise RuntimeError("网关拒绝")
|
||||
|
||||
model = Scripted(refuse)
|
||||
got = await D.run_model_draft(model, _source(tables), ["wide200"])
|
||||
o = got["wide200"]
|
||||
assert len(model.calls) == 3, "第 4 到 6 段跳过不问"
|
||||
assert (o.status, o.parts_total, o.parts_done) == ("failed", 6, 6)
|
||||
assert o.error == (WIDE200_PARTS + "都没写出来:调用模型出错:网关拒绝;"
|
||||
"这张表接连 3 段都没写出来,剩下的 3 段已跳过,可以换一个起草模型后只重试这张表")
|
||||
|
||||
# 表级那段写出来了、后面接连 3 段没写出来:只写出一部分,剩下的 2 段跳过;不再另说「可以只重试这张表」
|
||||
ok = answering(tables)
|
||||
model = Scripted(lambda call: ok(call) if call.n == 0 else refuse(call))
|
||||
got = await D.run_model_draft(model, _source(tables), ["wide200"])
|
||||
o = got["wide200"]
|
||||
assert len(model.calls) == 4 and o.status == "partial"
|
||||
assert o.error == ("这张表 200 列,分 6 段起草,第 2 段(c001 到 c040,共 40 列)、第 3 段(c041 到 c080,共 40 列)、"
|
||||
"第 4 段(c081 到 c120,共 40 列)、第 5 段(c121 到 c160,共 40 列)、第 6 段(c161 到 c200,共 40 列)"
|
||||
"没写出来:调用模型出错:网关拒绝;这张表接连 3 段都没写出来,剩下的 2 段已跳过,"
|
||||
"可以换一个起草模型后只重试这张表")
|
||||
|
||||
|
||||
async def test_a_part_that_writes_resets_the_dead_parts_of_its_table(monkeypatch):
|
||||
"""中间有一段写出来了,就不算「接连」:200 列的第 2、3、5、6 段没写出来、第 4 段写出来了,每段都照常问。"""
|
||||
monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
|
||||
tables = {"wide200": _table(200)}
|
||||
ok = answering(tables)
|
||||
|
||||
def handle(call: Call) -> Reply:
|
||||
part, cols = call.asked["wide200"]
|
||||
if part == "table" or "c081" in (cols or []):
|
||||
return ok(call)
|
||||
raise RuntimeError("网关拒绝")
|
||||
|
||||
model = Scripted(handle)
|
||||
got = await D.run_model_draft(model, _source(tables), ["wide200"])
|
||||
assert len(model.calls) == 6 and got["wide200"].status == "partial"
|
||||
assert "已跳过" not in got["wide200"].error
|
||||
|
||||
|
||||
async def test_the_column_parts_of_a_wide_table_stop_splitting_too(monkeypatch):
|
||||
"""宽表的列分段一段一段排队起草(一段拆到底再起草下一段),止损在宽表里同样起作用。
|
||||
|
||||
95 列:表级那段拆不开(结构化、纯文本各一次);第 2 段 32 列拆小、到 8 次封顶;这时已经连续 2 段没写出来,第 3、4 段
|
||||
只问一次、换纯文本一次。"""
|
||||
"""按比例止损在宽表里同样起作用,接连没写出来的宽表剩下的段跳过:95 列一直只给空壳(已经走纯文本),第 2 段拆小到
|
||||
止损;之后的第 3 段只问一次;接连 3 段没写出来,第 4 段跳过。"""
|
||||
monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
|
||||
model = Scripted(_hollow)
|
||||
model = Scripted(_text_only(_hollow))
|
||||
got = await D.run_model_draft(model, _source({"wide95": _table(95)}), ["wide95"])
|
||||
assert len(model.calls) == 2 + 8 + 2 + 2
|
||||
assert got["wide95"].error == (WIDE95_PARTS + "都没写出来:" + D.REASON_HOLLOW
|
||||
+ ";每段最多调用 8 次模型,这一段到了次数仍没写完,已停止重试"
|
||||
+ D.REASON_SPLIT + ";模型连续 2 段都没写出来,已停止拆小重试,建议更换起草模型")
|
||||
o = got["wide95"]
|
||||
asked = [c for c in model.calls if c.mode == "text"]
|
||||
assert len(asked) <= 2 + 8 + 1
|
||||
assert not any("c065" in (c.asked["wide95"][1] or []) for c in asked), "第 4 段没问"
|
||||
assert o.status == "failed" and o.error.startswith(WIDE95_PARTS + "都没写出来:")
|
||||
assert HALTED in o.error
|
||||
assert o.error.endswith("这张表接连 3 段都没写出来,剩下的 1 段已跳过,可以换一个起草模型后只重试这张表")
|
||||
|
||||
|
||||
async def test_a_wide_table_that_never_comes_back_is_bounded_by_the_first_wave():
|
||||
"""默认并发(3 段同时起草):294 列(8 个列分段)一直只给空壳,最多是表级那段 2 次、头 3 个列分段各 8 次(封顶)、
|
||||
其余 5 段各 2 次。以前所有列分段一下子都排上去、一层一层地拆,要 168 次。"""
|
||||
"""默认并发(3 段同时起草):294 列(8 个列分段)一直只给空壳:表级那段 2 次,头一批同时起草的列分段拆小到止损或
|
||||
封顶;接连 3 段没写出来以后,没开始的段跳过不问。以前所有列分段一下子都排上去、一层一层地拆,要 168 次。"""
|
||||
model = Scripted(_hollow)
|
||||
got = await D.run_model_draft(model, _source({"wide294": _table(294)}), ["wide294"])
|
||||
assert got["wide294"].status == "failed" and got["wide294"].parts_total == 9
|
||||
assert len(model.calls) <= 2 + 3 * 8 + 5 * 2
|
||||
o = got["wide294"]
|
||||
assert o.status == "failed" and o.parts_total == 9 and o.parts_done == 9
|
||||
assert len(model.calls) <= 2 + 4 * 8
|
||||
assert "已跳过" in o.error
|
||||
asked = {c for call in model.calls for c in call.asked["wide294"][1] or []}
|
||||
assert "c294" not in asked, "最后一段没问"
|
||||
|
||||
|
||||
async def test_a_big_draft_does_not_hold_the_model_ahead_of_a_later_one(monkeypatch):
|
||||
@@ -1042,7 +1132,7 @@ async def test_the_chunks_of_a_part_cut_by_the_smaller_cap_share_its_eight_calls
|
||||
"""每个原始段(连同段大小减半后按上限分开的各块、拆出来的、补问的、重试的)一共最多调用 8 次。以前分开的每一块各有
|
||||
8 次:上限缩到 10 列以后一直只给空壳,4 张窄表拼成的 40 列段调用了 20 次,单张 40 列的表也是 20 次。"""
|
||||
monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
|
||||
monkeypatch.setattr(catalog, "MODEL_GIVE_UP_AFTER", 99) # 不让止损先停下来
|
||||
monkeypatch.setattr(catalog, "MODEL_HALT_ON", 99) # 不让止损先停下来
|
||||
tables = {"w40": _table(40), **{f"n{i}": _table(10) for i in range(1, 5)}, "x40": _table(40)}
|
||||
|
||||
def handle(call: Call) -> Reply:
|
||||
@@ -1085,34 +1175,31 @@ class AlwaysSlow:
|
||||
raise TimeoutError()
|
||||
|
||||
|
||||
SLOW_STOP = ";拆到每段 10 列仍然超时,已停止拆小重试,建议更换起草模型"
|
||||
|
||||
|
||||
async def test_a_model_that_always_times_out_stops_after_three_rounds_not_seven():
|
||||
"""每次调用都超时、和段大小无关:超时拆出来的两半同时问(不再等带表级项的前一半写完才问后一半),拆到每段 10 列的
|
||||
调用接连超时 2 次就停止拆小,拆出来还没问的块不再问。40 列的表 3 轮、6 次调用就结束(时限 420 秒时约 21 分钟);
|
||||
以前 40→20→10→5 一层一层串着问,8 次调用约 7 轮(约 49 分钟)。"""
|
||||
async def test_a_model_that_always_times_out_is_bounded_by_the_part_cap():
|
||||
"""每次调用都超时:超时拆出来的两半同时问,最多拆到这一段的调用次数用完(8 次)或止损。以前 40→20→10→5 一层一层串着问,
|
||||
8 次调用约 7 轮;现在两半同时问,至多 4 轮。"""
|
||||
tables = {"t40": _table(40)}
|
||||
slow = AlwaysSlow(tables)
|
||||
model, reports = Scripted(slow), Reports()
|
||||
got = await D.run_model_draft(model, _source(tables), ["t40"], hooks=reports.hooks())
|
||||
assert [_width(c, tables) for c in model.calls] == [40, 20, 20, 10, 10, 10], "拆到每段 10 列仍超时就不再往下拆"
|
||||
assert slow.rounds == [1, 2, 2, 3, 3, 3], "两半同时问"
|
||||
assert reports.failures == ["timeout"] * 6 and len(reports.items) == len(model.calls)
|
||||
assert len(model.calls) <= catalog.MODEL_PART_MAX_CALLS and max(slow.rounds) <= 4
|
||||
assert [_width(c, tables) for c in model.calls[:3]] == [40, 20, 20], "两半同时问"
|
||||
assert set(reports.failures) == {"timeout"} and len(reports.items) == len(model.calls)
|
||||
o = got["t40"]
|
||||
assert o.status == "failed" and o.error == "模型 420 秒内没有写完这一段" + D.REASON_SPLIT + SLOW_STOP
|
||||
assert o.status == "failed" and "模型 420 秒内没有写完这一段" + D.REASON_SPLIT in o.error
|
||||
assert o.chunks_done == o.chunks_total > 1, "拆出来的块都结束了"
|
||||
|
||||
|
||||
async def test_three_parts_that_always_time_out_end_in_four_rounds():
|
||||
"""首轮同时起草的 3 段都一直超时:拆到每段 10 列仍超时就一起停下,不再各自拆满 8 次。12 次调用、4 轮;以前 24 次
|
||||
调用、约 8 轮(止损要等一整段结束才计数,首轮的 3 段都跑满)。"""
|
||||
async def test_three_parts_that_always_time_out_stop_together():
|
||||
"""首轮同时起草的 3 段都一直超时:最近 10 次调用 7 次以上没写出来以后,拆出来还没问的块不再问,不再各自拆满 8 次。
|
||||
以前(接连超时的止损)12 次调用;更早要 24 次。"""
|
||||
tables = {f"t{i}": _table(40) for i in range(1, 4)}
|
||||
slow = AlwaysSlow(tables)
|
||||
model = Scripted(slow)
|
||||
got = await D.run_model_draft(model, _source(tables), list(tables))
|
||||
assert len(model.calls) == 12 and max(slow.rounds) == 4
|
||||
assert len(model.calls) <= 9 and max(slow.rounds) <= 4
|
||||
assert {o.status for o in got.values()} == {"failed"}
|
||||
assert all(o.error.endswith(SLOW_STOP) for o in got.values())
|
||||
assert any(HALTED in o.error for o in got.values())
|
||||
|
||||
|
||||
async def test_timeouts_that_go_away_at_ten_columns_are_still_split_down_and_written():
|
||||
@@ -1152,7 +1239,7 @@ async def test_a_call_whose_reply_cannot_be_read_still_counts(monkeypatch):
|
||||
"""读回复时出了意料之外的错:这张表记出错,这次调用照样记进用量(算一次出错),调用次数和用量对得上。"""
|
||||
tables = {"t4": _table(4)}
|
||||
|
||||
def broken(entry, meta, *, system):
|
||||
def broken(entry, meta, *, system, **_):
|
||||
raise RuntimeError("校验时出了意外")
|
||||
|
||||
monkeypatch.setattr(catalog, "_from_model", broken)
|
||||
@@ -1190,24 +1277,54 @@ async def test_a_wide_table_that_wrote_nothing_names_every_part():
|
||||
|
||||
got = await D.run_model_draft(Scripted(handle), _source({"wide95": _table(95)}), ["wide95"])
|
||||
o = got["wide95"]
|
||||
assert o.status == "failed" and o.error == WIDE95_PARTS + "都没写出来:调用模型出错:网关拒绝"
|
||||
assert o.status == "failed" and o.error == (WIDE95_PARTS + "都没写出来:调用模型出错:网关拒绝;"
|
||||
"这张表接连 3 段都没写出来,剩下的 1 段已跳过,可以换一个起草模型后只重试这张表")
|
||||
|
||||
narrow = await D.run_model_draft(Scripted(handle), _source({"t8": _table(8)}), ["t8"])
|
||||
assert narrow["t8"].error == "调用模型出错:网关拒绝", "只有一段的表:这一段就是整张表,照旧只写原因"
|
||||
|
||||
|
||||
async def test_a_halted_part_that_could_be_split_is_not_asked_again_in_text_mode(monkeypatch):
|
||||
"""止损以后本来能拆小的段只给空壳:已经走纯文本的不再原样再问(约定只对拆不开的段再问一次);结构化输出的照旧换
|
||||
纯文本问一次,看看是不是结构化输出的问题(见 test_the_column_parts_of_a_wide_table_stop_splitting_too)。"""
|
||||
"""止损以后每块只问一次:已经走纯文本的空壳不再原样再问、也不拆小。t1:结构化不支持 1 次(只管通信的重来,不进
|
||||
止损的窗口)、纯文本 1 次、拆成两半各问一次再原样再问一次(5 次没写出来);t2 的第一次是第 6 次,照常拆小,前一半
|
||||
是第 7 次没写出来,之后止损:后一半不再问;t3 只问一次、不拆小。"""
|
||||
monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
|
||||
monkeypatch.setattr(catalog, "MODEL_BATCH_TABLES", 1)
|
||||
tables = {f"t{i}": _table(10) for i in range(1, 5)}
|
||||
model = Scripted(_text_only(_hollow))
|
||||
got = await D.run_model_draft(model, _source(tables), list(tables))
|
||||
# t1:结构化不支持 1 次、纯文本 1 次、拆成两半各问一次再原样再问一次;t2 同样(少了结构化那次);之后止损
|
||||
assert _by_table(model) == {"t1": 6, "t2": 5, "t3": 1, "t4": 1}
|
||||
assert got["t3"].error == (catalog.MODEL_EMPTY_REASON
|
||||
+ ";模型连续 2 段都没写出来,已停止拆小重试,建议更换起草模型")
|
||||
assert _by_table(model) == {"t1": 6, "t2": 2, "t3": 1, "t4": 1}
|
||||
assert got["t2"].error == D.REASON_HOLLOW + D.REASON_SPLIT + HALTED
|
||||
assert got["t3"].error == catalog.MODEL_EMPTY_REASON + HALTED
|
||||
|
||||
|
||||
async def test_while_halted_structured_output_still_gets_one_probe_in_text(monkeypatch):
|
||||
"""止损期间结构化输出只给空壳:这次起草还没换纯文本问过的,照旧换纯文本问一次(看看是不是结构化输出的问题);纯文本
|
||||
写出来了,后面都走纯文本。问过一次(纯文本也只给空壳)就不再问。"""
|
||||
monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
|
||||
monkeypatch.setattr(catalog, "MODEL_BATCH_TABLES", 1)
|
||||
tables = {**{f"x{i}": _table(4) for i in range(1, 8)}, "t8": _table(8), "t9": _table(8)}
|
||||
ok = answering(tables)
|
||||
|
||||
def handle(text_works: bool) -> Callable[[Call], Reply]:
|
||||
def go(call: Call) -> Reply:
|
||||
if next(iter(call.asked)).startswith("x"):
|
||||
raise RuntimeError("网关拒绝了这次请求")
|
||||
if call.mode == "structured":
|
||||
return _hollow(call)
|
||||
return ok(call) if text_works else _hollow(call)
|
||||
return go
|
||||
|
||||
model = Scripted(handle(True))
|
||||
got = await D.run_model_draft(model, _source(tables), list(tables))
|
||||
assert [c.mode for c in model.calls if "x1" not in c.asked and not next(iter(c.asked)).startswith("x")] == \
|
||||
["structured", "text", "text"]
|
||||
assert got["t8"].status == got["t9"].status == "ok"
|
||||
|
||||
model = Scripted(handle(False))
|
||||
got = await D.run_model_draft(model, _source(tables), list(tables))
|
||||
assert _by_table(model)["t8"] == 2 and _by_table(model)["t9"] == 1, "纯文本问过一次,不再问"
|
||||
assert got["t9"].error == catalog.MODEL_EMPTY_REASON + HALTED
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -1299,3 +1416,224 @@ def test_cut_values_are_dropped_and_complete_ones_kept():
|
||||
@pytest.mark.parametrize("text", ["好的,我来写", "{坏的 json}", ""])
|
||||
def test_not_json_is_not_a_reply(text):
|
||||
assert D._parse_text(text, cut=False) is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 闭环第一步的起草遗留:逐段交出去落盘、块进度、「已拆小重试」只说拆过的段
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Written:
|
||||
"""进度回调 part_written 收到的:(表名, 已写出内容的段数, 这时这张表拿到的列);chunks_changed 收到的块进度。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.parts: list[tuple[str, int, set[str], bool]] = []
|
||||
self.chunks: dict[str, list[tuple[int, int]]] = {}
|
||||
|
||||
async def part_written(self, table: str, written: D.PartWrite) -> None:
|
||||
cols = set((written.notes.get("columns") or {}))
|
||||
self.parts.append((table, written.parts_written, cols, "label" in written.notes))
|
||||
|
||||
async def chunks_changed(self, table: str, total: int, done: int) -> None:
|
||||
self.chunks.setdefault(table, []).append((total, done))
|
||||
|
||||
def hooks(self) -> D.Hooks:
|
||||
return D.Hooks(part_written=self.part_written, chunks_changed=self.chunks_changed)
|
||||
|
||||
|
||||
async def test_each_part_of_a_wide_table_is_handed_over_as_soon_as_it_is_written(monkeypatch):
|
||||
"""宽表每写出一段就交给调用方并入目录(最后一段由 table_done 收尾):交出去的是这张表到这时拿到的全部内容。"""
|
||||
monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
|
||||
tables = {"wide95": _table(95)}
|
||||
seen = Written()
|
||||
got = await D.run_model_draft(Scripted(answering(tables)), _source(tables), ["wide95"], hooks=seen.hooks())
|
||||
o = got["wide95"]
|
||||
assert o.status == "ok" and o.parts_written == 4
|
||||
assert [(t, n, len(cols), label) for t, n, cols, label in seen.parts] == [
|
||||
("wide95", 1, 0, True), ("wide95", 2, 32, True), ("wide95", 3, 64, True)]
|
||||
|
||||
def handle(call: Call) -> Reply:
|
||||
part, cols = call.asked["wide95"]
|
||||
if part == "columns" and "c033" in cols:
|
||||
raise RuntimeError("网关拒绝")
|
||||
return answering(tables)(call)
|
||||
|
||||
seen = Written()
|
||||
got = await D.run_model_draft(Scripted(handle), _source(tables), ["wide95"], hooks=seen.hooks())
|
||||
assert got["wide95"].status == "partial" and got["wide95"].parts_written == 3
|
||||
assert [n for _, n, _, _ in seen.parts] == [1, 2], "没写出来的那段不交"
|
||||
|
||||
|
||||
async def test_a_narrow_table_is_handed_over_only_once_at_the_end():
|
||||
tables = {"t8": _table(8)}
|
||||
seen = Written()
|
||||
got = await D.run_model_draft(Scripted(answering(tables)), _source(tables), ["t8"], hooks=seen.hooks())
|
||||
assert seen.parts == [] and got["t8"].parts_written == 1
|
||||
failed = await D.run_model_draft(Scripted(lambda call: EXHAUSTED), _source({"t4": _table(4)}), ["t4"])
|
||||
assert failed["t4"].parts_written == 0
|
||||
|
||||
|
||||
async def test_chunks_count_what_the_table_is_actually_asked_in(monkeypatch):
|
||||
"""块进度:一开始是这张表分的段数;一段被拆成两半多一块、被缩小的上限分开成几块就多几块;一块结束算一块。
|
||||
多表拼成的段按表拆开时,每张表还是一块。"""
|
||||
monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
|
||||
monkeypatch.setattr(catalog, "MODEL_BATCH_TABLES", 1)
|
||||
monkeypatch.setattr(catalog, "MODEL_SHRINK_AFTER", 1)
|
||||
tables = {"a12": _table(12), "b40": _table(40)}
|
||||
ok = answering(tables)
|
||||
seen = Written()
|
||||
got = await D.run_model_draft(Scripted(lambda call: EXHAUSTED if call.n == 0 else ok(call)), _source(tables),
|
||||
["a12", "b40"], hooks=seen.hooks())
|
||||
a, b = got["a12"], got["b40"]
|
||||
assert a.status == b.status == "ok"
|
||||
assert (a.chunks_total, a.chunks_done) == (2, 2), "额度用完拆成两半"
|
||||
assert (b.chunks_total, b.chunks_done) == (2, 2), "上限缩到 20 列,40 列的段分开成两块"
|
||||
assert seen.chunks["a12"][-1] == (2, 2) and seen.chunks["b40"][-1] == (2, 2)
|
||||
assert all(done <= total for pairs in seen.chunks.values() for total, done in pairs)
|
||||
|
||||
wide = {"wide95": _table(95)}
|
||||
got = await D.run_model_draft(Scripted(answering(wide)), _source(wide), ["wide95"])
|
||||
assert (got["wide95"].chunks_total, got["wide95"].chunks_done) == (4, 4)
|
||||
|
||||
monkeypatch.setattr(catalog, "MODEL_BATCH_TABLES", 4)
|
||||
packed = {"p6": _table(6), "q6": _table(6)}
|
||||
okp = answering(packed)
|
||||
model = Scripted(lambda call: _hollow(call) if call.n == 0 else okp(call))
|
||||
got = await D.run_model_draft(model, _source(packed), ["p6", "q6"])
|
||||
assert [set(c.asked) for c in model.calls] == [{"p6", "q6"}, {"p6"}, {"q6"}], "按表拆开"
|
||||
assert {(o.chunks_total, o.chunks_done) for o in got.values()} == {(1, 1)}
|
||||
|
||||
|
||||
async def test_only_the_parts_that_were_split_say_so(monkeypatch):
|
||||
"""一张表几段没写出来、只有一部分真拆小重试过:「已拆小重试仍没写出」只跟在拆过的那几段后面说。95 列:第 3 段一直用完
|
||||
额度(拆小重试过),第 4 段调用出错(没拆过)。"""
|
||||
monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
|
||||
monkeypatch.setattr(catalog, "MODEL_SHRINK_AFTER", 99)
|
||||
monkeypatch.setattr(catalog, "MODEL_HALT_ON", 99)
|
||||
tables = {"wide95": _table(95)}
|
||||
ok = answering(tables)
|
||||
|
||||
def handle(call: Call) -> Reply:
|
||||
part, cols = call.asked["wide95"]
|
||||
if part == "columns" and cols[0] >= "c065":
|
||||
raise RuntimeError("网关拒绝")
|
||||
if part == "columns" and cols[0] >= "c033":
|
||||
return EXHAUSTED
|
||||
return ok(call)
|
||||
|
||||
got = await D.run_model_draft(Scripted(handle), _source(tables), ["wide95"])
|
||||
o = got["wide95"]
|
||||
assert o.status == "partial"
|
||||
assert o.error == ("这张表 95 列,分 4 段起草,第 3 段(c033 到 c064,共 32 列)、第 4 段(c065 到 c095,共 31 列)没写出来:"
|
||||
+ _exhausted() + ";调用模型出错:网关拒绝;其中第 3 段已拆小重试仍没写出。可以只重试这张表")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 复核后的修正:止损的窗口只记每次调用的最终结局。通信层的重来(服务方嫌额度太大退回 8192、结构化输出不支持或回了
|
||||
# 说明文字改走纯文本、429 / 5xx 退避重试)不花额度,也说明不了模型写不出来:照旧报给进度回调(用量、提醒照算),
|
||||
# 不进止损的窗口。以前都记成没写出来,OpenAI 兼容网关(额度上限 8192、不支持 json_schema)上并发的头几次调用全是
|
||||
# 重来,第一次额度用完就凑满 10 次里 7 次,那一段直接记没写出来、不再拆小。
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _six_tables() -> dict:
|
||||
"""6 张 40 列的表:每张自成一段,默认并发 3 段同时起草。"""
|
||||
return {f"t{i}": _table(40, prefix=f"t{i}_") for i in range(6)}
|
||||
|
||||
|
||||
def _halved(model: Scripted, table: str) -> bool:
|
||||
"""这张表被拆成两半问过(表级的项加前 20 列那一半)。"""
|
||||
return any(part == "table+columns" and len(cols or []) == 20
|
||||
for call in model.calls for name, (part, cols) in call.asked.items() if name == table)
|
||||
|
||||
|
||||
async def test_budget_and_structured_output_fallbacks_do_not_halt_a_concurrent_draft():
|
||||
tables = _six_tables()
|
||||
ok = answering(tables)
|
||||
spent: set[str] = set()
|
||||
|
||||
async def refuse(call: Call) -> Reply:
|
||||
await asyncio.sleep(0.01)
|
||||
raise _Status(400, "max_tokens: 32768 > 8192, which is the maximum allowed number of output tokens")
|
||||
|
||||
async def small(call: Call) -> Reply:
|
||||
await asyncio.sleep(0.01)
|
||||
if call.mode == "structured":
|
||||
raise _Status(400, "This response_format type is unavailable now")
|
||||
if "t0" in call.asked and "t0" not in spent:
|
||||
spent.add("t0")
|
||||
return EXHAUSTED
|
||||
return ok(call)
|
||||
|
||||
big, sm, reports = Scripted(refuse), Scripted(small), Reports()
|
||||
got = await D.run_model_draft(catalog.DraftModel(big, sm, "m"), _source(tables), list(tables),
|
||||
hooks=reports.hooks())
|
||||
assert _status(got) == dict.fromkeys(tables, "ok"), {t: o.error for t, o in got.items()}
|
||||
assert _halved(sm, "t0"), "额度用完的那一段照常拆小重试"
|
||||
assert len(big.calls) == 3 and reports.failures.count("error") == 6, "退回小额度、改走纯文本的重来照旧报给进度回调"
|
||||
assert reports.failures.count("exhausted") == 1
|
||||
|
||||
|
||||
async def test_rate_limit_retries_do_not_halt_the_draft():
|
||||
tables = _six_tables()
|
||||
ok = answering(tables)
|
||||
limited: dict[str, int] = {}
|
||||
spent: set[str] = set()
|
||||
|
||||
async def handle(call: Call) -> Reply:
|
||||
await asyncio.sleep(0.01)
|
||||
name = next(iter(call.asked))
|
||||
if name in ("t0", "t1", "t2") and limited.get(name, 0) < 2:
|
||||
limited[name] = limited.get(name, 0) + 1
|
||||
raise _Status(429, "Too Many Requests")
|
||||
if name == "t0" and not spent:
|
||||
spent.add(name)
|
||||
return EXHAUSTED
|
||||
return ok(call)
|
||||
|
||||
model, reports = Scripted(handle), Reports()
|
||||
got = await D.run_model_draft(model, _source(tables), list(tables), hooks=reports.hooks())
|
||||
assert _status(got) == dict.fromkeys(tables, "ok"), {t: o.error for t, o in got.items()}
|
||||
assert _halved(model, "t0"), "额度用完的那一段照常拆小重试"
|
||||
assert reports.failures.count("error") == 6, "429 之后重试成功的那几次照旧报给进度回调"
|
||||
|
||||
|
||||
async def test_structured_output_that_comes_back_as_prose_does_not_count_toward_halting(monkeypatch):
|
||||
"""结构化输出回了说明文字、这一块改走纯文本再问:也是通信方式的问题,那一次不进止损的窗口(止损门槛调低到 3 次,
|
||||
并发的 3 块都回说明文字也不止损);纯文本写出来的、没写出来的照常算。"""
|
||||
monkeypatch.setattr(catalog, "MODEL_HALT_ON", 3)
|
||||
tables = _six_tables()
|
||||
ok = answering(tables)
|
||||
spent: set[str] = set()
|
||||
|
||||
async def handle(call: Call) -> Reply:
|
||||
await asyncio.sleep(0.01)
|
||||
if call.mode == "structured":
|
||||
return Reply(text="好的。这张表记录的是一笔笔业务,各列的含义如下:第一列是编号……")
|
||||
if "t0" in call.asked and "t0" not in spent:
|
||||
spent.add("t0")
|
||||
return EXHAUSTED
|
||||
return ok(call)
|
||||
|
||||
model, reports = Scripted(handle), Reports()
|
||||
got = await D.run_model_draft(model, _source(tables), list(tables), hooks=reports.hooks())
|
||||
assert _status(got) == dict.fromkeys(tables, "ok"), {t: o.error for t, o in got.items()}
|
||||
assert _halved(model, "t0")
|
||||
assert reports.failures.count("error") == 3, "回了说明文字的那几次照旧报给进度回调"
|
||||
|
||||
|
||||
async def test_a_call_that_still_fails_after_its_retries_counts_toward_halting(monkeypatch):
|
||||
"""重试用完仍然出错的那一次是这次调用的最终结局,照常进止损的窗口:一直 503 的网关,止损照样起作用。"""
|
||||
monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
|
||||
monkeypatch.setattr(catalog, "MODEL_BATCH_TABLES", 1)
|
||||
tables = {f"t{i}": _table(10) for i in range(1, 9)}
|
||||
ok = answering(tables)
|
||||
|
||||
def handle(call: Call) -> Reply:
|
||||
if set(call.asked) & {"t1", "t2", "t3", "t4", "t5", "t6", "t7"}:
|
||||
raise _Status(503, "Service Unavailable")
|
||||
return EXHAUSTED if "t8" in call.asked else ok(call)
|
||||
|
||||
got = await D.run_model_draft(Scripted(handle), _source(tables), list(tables))
|
||||
assert got["t7"].error == "调用模型出错:Service Unavailable(已重试 2 次)"
|
||||
assert got["t8"].error == _exhausted() + HALTED, "最近 10 次的最终结局有 7 次以上没写出来:t8 不再拆小"
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
"""防错事实的提议:模型起草时提议有效行过滤(valid_filter)、去重规则(dedup),附一句依据。
|
||||
|
||||
- 提示词写清两项只在表结构、注释、码值能看出来时才写;结构化输出、纯文本两条路都认这两项;
|
||||
- 有效行过滤按数据源的方言用 sqlglot 解析成一个条件:只能引用这张表的列,不能有子查询、聚合、窗口函数、分号,只允许
|
||||
比较、IN、BETWEEN、LIKE、IS NULL、AND / OR / NOT 和 UPPER / LOWER / TRIM / COALESCE,不超过 500 字。不过就丢掉;
|
||||
存的是整理过的写法:原文照旧,列换成表结构里的真名、不带表名、按方言加引号(剖析核对的也是这一条);
|
||||
- 开了数据剖析、能连上的源:经剖析的只读守卫数出排除比例,排除 0% 或 100% 的不收,收下的在 note 里写剖析核对的结果;
|
||||
没开剖析、连不上的照收,note 写没有核对的原因;
|
||||
- 没收下的(没通过校验、排除 0% 或 100%)不算失败,在这张表的日志里记一句(起草任务逐表的 log);
|
||||
- 开了剖析的源,目录里记下的码值交给模型做背景(遮罩的列不给;「已确认的目录项」里遮罩列的码值也不给);
|
||||
- 这两项照旧记模型起草 / 推断;SQL 检查只在人工确认、有确证时才把漏了有效行过滤报成警告,推断的只是提示。
|
||||
|
||||
用合成的景区业务库(tests/fixtures/catalog/scenic.py)和虚构的表结构,不调用真实模型。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.data import catalog, catalog_profile, sqlcheck
|
||||
from app.data import catalog_draft as D
|
||||
from app.data.catalog import make_item
|
||||
from app.data.catalog_profile import PROFILE_OPTION
|
||||
from app.data.engine import MASK_COLUMNS_OPTION, engines
|
||||
from app.data.introspect import introspect
|
||||
from tests.fixtures.catalog.fake_model import FakeModel, answer, requested
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 夹具
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _col(name: str, type_: str = "INTEGER", comment: str | None = None) -> dict:
|
||||
return {"name": name, "type": type_, "nullable": True, "comment": comment}
|
||||
|
||||
|
||||
def _orders() -> dict:
|
||||
return {"qualified": "orders", "schema": None, "is_view": False, "primary_key": ["id"], "foreign_keys": [],
|
||||
"comment": None,
|
||||
"columns": [_col("id"), _col("order_no", "TEXT"), _col("status", "TEXT", comment="C 作废,X 测试单"),
|
||||
_col("amount", "REAL"), _col("updated_at", "TEXT")]}
|
||||
|
||||
|
||||
def _plain(tables: dict, **kw) -> SimpleNamespace:
|
||||
"""不连库的源(没有 options:没开数据剖析)。"""
|
||||
return SimpleNamespace(id="facts-src", name="facts", kind=kw.pop("kind", "sqlite"), origin="manual",
|
||||
description="", schema_cache={"tables": tables}, **kw)
|
||||
|
||||
|
||||
_CACHE: dict[str, dict] = {}
|
||||
|
||||
|
||||
async def _scenic_cache(path: str) -> dict:
|
||||
if path not in _CACHE:
|
||||
probe = SimpleNamespace(id="facts-scenic-probe", name="scenic", kind="sqlite", database=path, host=None,
|
||||
port=None, username=None, password=None, options={}, readonly=True, description="",
|
||||
schema_cache={}, origin="manual")
|
||||
try:
|
||||
_CACHE[path] = await introspect(probe)
|
||||
finally:
|
||||
await engines.invalidate(probe.id)
|
||||
return _CACHE[path]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def scenic(scenic_db):
|
||||
"""接上景区库的源(开了数据剖析)。用完作废连接池。"""
|
||||
made: list[str] = []
|
||||
|
||||
async def make(*, options: dict | None = None, database: str | None = None, enabled: bool = True):
|
||||
sid = f"facts-{len(made)}-{id(made)}"
|
||||
made.append(sid)
|
||||
opts = {PROFILE_OPTION: {"enabled": True}} if options is None else options
|
||||
return SimpleNamespace(id=sid, name="scenic", kind="sqlite", database=database or scenic_db, host=None,
|
||||
port=None, username=None, password=None, options=opts, readonly=True, description="",
|
||||
schema_cache=await _scenic_cache(scenic_db), origin="manual", enabled=enabled)
|
||||
|
||||
yield make
|
||||
for sid in made:
|
||||
await engines.invalidate(sid)
|
||||
|
||||
|
||||
def _count(path: str, where: str) -> tuple[int, int]:
|
||||
"""(全表行数, 不满足条件的行数):直接用 sqlite3 数,核对剖析算出来的数。"""
|
||||
db = sqlite3.connect(path)
|
||||
try:
|
||||
total = db.execute("SELECT COUNT(*) FROM visits").fetchone()[0]
|
||||
kept = db.execute(f"SELECT COUNT(*) FROM visits WHERE {where}").fetchone()[0]
|
||||
finally:
|
||||
db.close()
|
||||
return total, total - kept
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 校验有效行过滤
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
META = _orders()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("condition, columns", [
|
||||
("status NOT IN ('C', 'X')", ["status"]),
|
||||
("STATUS NOT IN ('C', 'X')", ["status"]), # 列名不分大小写,记表结构里的写法
|
||||
("orders.status <> 'C'", ["status"]), # 带这张表的表名
|
||||
("status IS NOT NULL AND amount > 0", ["status", "amount"]),
|
||||
("NOT (status = 'C' OR status = 'X')", ["status"]),
|
||||
("amount BETWEEN 0 AND 100000", ["amount"]),
|
||||
("order_no NOT LIKE 'TEST%'", ["order_no"]),
|
||||
("UPPER(TRIM(status)) <> 'C'", ["status"]),
|
||||
("COALESCE(status, '') <> 'X' AND amount >= -1", ["status", "amount"]),
|
||||
("status = 'C' OR updated_at IS NULL", ["status", "updated_at"]),
|
||||
])
|
||||
def test_valid_filters_that_pass(condition, columns):
|
||||
parsed = sqlcheck.parse_valid_filter(condition, META, kind="sqlite", table="orders")
|
||||
assert parsed.problem is None, parsed.problem
|
||||
assert list(parsed.columns) == columns
|
||||
assert sqlcheck.valid_filter_problem(condition, META, kind="mysql", table="orders") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("condition, said", [
|
||||
("status = 'C'; DROP TABLE orders", "分号"),
|
||||
("status IN (SELECT code FROM codes)", "子查询"),
|
||||
("EXISTS (SELECT 1 FROM refunds)", "子查询"),
|
||||
("COUNT(status) > 1", "聚合"),
|
||||
("ROW_NUMBER() OVER (ORDER BY id) = 1", "窗口"),
|
||||
("LENGTH(status) > 0", "LENGTH"),
|
||||
("amount * 2 > 10", "不支持的写法"),
|
||||
("payments.status = 1", "只能引用这张表的列"),
|
||||
("nope = 1", "nope"),
|
||||
("1 = 1", "没有用到这张表的任何列"),
|
||||
("status", "一个条件"),
|
||||
("status = ?", "不支持的写法"),
|
||||
("status = 'C' -- 注释", "注释"),
|
||||
("status = '{{ input.code }}'", "模板"),
|
||||
("status = '" + "C" * 600 + "'", "500"),
|
||||
("status = (", "解析"),
|
||||
("status = 'C' UNION SELECT 1", "一个条件"),
|
||||
])
|
||||
def test_valid_filters_that_are_dropped(condition, said):
|
||||
problem = sqlcheck.valid_filter_problem(condition, META, kind="sqlite", table="orders")
|
||||
assert problem is not None and said in problem, problem
|
||||
|
||||
|
||||
def test_a_source_kind_without_a_dialect_cannot_be_checked():
|
||||
assert "不支持" in sqlcheck.valid_filter_problem("status = 'C'", META, kind="mssql", table="orders")
|
||||
|
||||
|
||||
def test_the_condition_is_rendered_with_the_columns_quoted_like_the_source():
|
||||
meta = {**META, "columns": [*META["columns"], _col("isTest")]}
|
||||
parsed = sqlcheck.parse_valid_filter("istest = 0 AND STATUS <> 'C'", meta, kind="mysql", table="orders")
|
||||
assert parsed.problem is None
|
||||
rendered = sqlcheck.render_valid_filter(parsed, quoted=lambda name: name != name.lower())
|
||||
assert rendered == "`isTest` = 0 AND status <> 'C'"
|
||||
|
||||
|
||||
#: 带一列混合大小写列名的订单表(ORM 建的 PostgreSQL 库里常见)
|
||||
MIXED = {**META, "columns": [*META["columns"], _col("OrderStatus", "TEXT")]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind, condition, stored", [
|
||||
# PostgreSQL 把没加引号的名字折成小写:混合大小写的列一律加引号,不论模型写的大小写对不对
|
||||
("postgresql", "orderstatus <> 'X' AND STATUS NOT IN ('C', 'X')",
|
||||
"\"OrderStatus\" <> 'X' AND status NOT IN ('C', 'X')"),
|
||||
("postgresql", "OrderStatus <> 'X'", "\"OrderStatus\" <> 'X'"),
|
||||
("postgresql", "orders.\"OrderStatus\" IS NOT NULL", "\"OrderStatus\" IS NOT NULL"),
|
||||
("mysql", "orderstatus <> 'X'", "`OrderStatus` <> 'X'"),
|
||||
("oracle", "ORDERS.STATUS <> 'C'", "status <> 'C'"),
|
||||
# 不用改的照原文:NOT IN、IS NOT NULL 这些写法不被改写
|
||||
("sqlite", "status NOT IN ('C', 'X') AND updated_at IS NOT NULL",
|
||||
"status NOT IN ('C', 'X') AND updated_at IS NOT NULL"),
|
||||
])
|
||||
def test_the_stored_filter_names_its_columns_the_way_the_source_needs(kind, condition, stored):
|
||||
"""存进目录的有效记录条件:原文照旧,只把列换成表结构里的真名、不带表名,按数据源的需要加引号(和剖析核对查询的写法
|
||||
一致)。以前存的是模型的原文:PostgreSQL 上 orderstatus 会被当成另一列,核对时却按真名加了引号,存下的和核对过的
|
||||
不是同一条,SQL 检查还会让模型照抄这一句。"""
|
||||
entry = {"table": "orders", "valid_filter": {"condition": condition, "reason": "注释写明"}}
|
||||
notes = catalog._from_model(entry, MIXED, system=False, source_kind=kind, table="orders")
|
||||
assert notes["valid_filter"]["value"] == stored
|
||||
again = sqlcheck.parse_valid_filter(stored, MIXED, kind=kind, table="orders")
|
||||
assert again.problem is None and sqlcheck.stored_valid_filter(again, kind) == stored, "存下的再整理一遍不变"
|
||||
|
||||
|
||||
def test_a_filter_that_grows_past_the_limit_once_quoted_is_dropped_not_written():
|
||||
"""整理时加了引号、超过 500 字的不收(记下原因):目录里这一项最多 500 字,写进去会让整张表写入失败。"""
|
||||
condition = "orderstatus IN (" + ", ".join(f"'{i:03d}'" for i in range(66)) + ") OR updated_at IS NULL"
|
||||
assert len(condition) == 499
|
||||
dropped: list[tuple[str, str]] = []
|
||||
notes = catalog._from_model({"table": "orders", "label": "订单", "valid_filter": condition}, MIXED, system=False,
|
||||
source_kind="postgresql", table="orders", dropped=dropped)
|
||||
assert "valid_filter" not in notes and notes["label"]["value"] == "订单"
|
||||
assert dropped == [("有效记录条件按数据源的写法整理后超过 500 字", condition)]
|
||||
assert not catalog.validate_notes(notes)
|
||||
|
||||
|
||||
def test_columns_that_differ_only_in_case_must_be_named_exactly():
|
||||
"""表结构里有只差大小写的两列:写得和其中一列一模一样的认那一列,否则说不清是哪一列,不收。"""
|
||||
meta = {**META, "columns": [*META["columns"], _col("Status", "TEXT")]}
|
||||
problem = sqlcheck.valid_filter_problem("STATUS = 'C'", meta, kind="postgresql", table="orders")
|
||||
assert problem == ("有效记录条件里的列「STATUS」在表结构里有只差大小写的几列(status、Status),"
|
||||
"要照表结构的写法写清是哪一列")
|
||||
for condition, stored in (("\"Status\" = 'C'", "\"Status\" = 'C'"), ("status = 'C'", "status = 'C'")):
|
||||
parsed = sqlcheck.parse_valid_filter(condition, meta, kind="postgresql", table="orders")
|
||||
assert parsed.problem is None and sqlcheck.stored_valid_filter(parsed, "postgresql") == stored
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 模型给的两项:校验、依据
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_model_fields_become_llm_items_with_their_reason():
|
||||
entry = {"table": "orders", "label": "订单",
|
||||
"valid_filter": {"condition": "status NOT IN ('C', 'X')", "reason": "状态注释写明 C 作废、X 测试单"},
|
||||
"dedup": {"rule": "同一订单号有多行时只取最后修改的一行", "reason": "订单号可能重复导出"}}
|
||||
notes = catalog._from_model(entry, META, system=False, source_kind="sqlite", table="orders")
|
||||
vf, dd = notes["valid_filter"], notes["dedup"]
|
||||
assert (vf["value"], vf["source"], vf["status"]) == ("status NOT IN ('C', 'X')", "llm", "proposed")
|
||||
assert vf["note"] == "依据:状态注释写明 C 作废、X 测试单"
|
||||
assert (dd["value"], dd["source"], dd["status"]) == ("同一订单号有多行时只取最后修改的一行", "llm", "proposed")
|
||||
assert dd["note"] == "依据:订单号可能重复导出"
|
||||
assert not catalog.validate_notes(notes)
|
||||
|
||||
|
||||
def test_plain_strings_are_accepted_and_bad_filters_dropped(caplog):
|
||||
entry = {"table": "orders", "label": "订单", "valid_filter": "status <> 'C'", "dedup": "按订单号去重"}
|
||||
notes = catalog._from_model(entry, META, system=False, source_kind="sqlite", table="orders")
|
||||
assert notes["valid_filter"]["value"] == "status <> 'C'" and "note" not in notes["valid_filter"]
|
||||
assert notes["dedup"]["value"] == "按订单号去重"
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="app.data.catalog"):
|
||||
notes = catalog._from_model({"table": "orders", "label": "订单",
|
||||
"valid_filter": {"condition": "status IN (SELECT 1)", "reason": "猜的"}},
|
||||
META, system=False, source_kind="sqlite", table="orders")
|
||||
assert "valid_filter" not in notes and notes["label"]["value"] == "订单", "丢掉这一项,别的照收"
|
||||
assert any("有效行过滤" in r.getMessage() and "orders" in r.getMessage() for r in caplog.records), "日志记一条"
|
||||
for junk in ({"condition": ""}, {"reason": "只有依据"}, ["status = 1"], 3):
|
||||
notes = catalog._from_model({"table": "orders", "valid_filter": junk, "dedup": junk}, META, system=False,
|
||||
source_kind="sqlite", table="orders")
|
||||
assert "valid_filter" not in notes and "dedup" not in notes, junk
|
||||
|
||||
|
||||
def test_the_prompt_asks_for_the_two_facts_only_when_evident():
|
||||
system = catalog.CATALOG_DRAFT_SYSTEM
|
||||
assert "valid_filter" in system and "dedup" in system
|
||||
assert "拿不准就不写" in system and "reason" in system
|
||||
assert "UPPER、LOWER、TRIM、COALESCE" in system
|
||||
props = catalog.CATALOG_DRAFT_PROMPT_SCHEMA["properties"]["tables"]["items"]["properties"]
|
||||
assert set(props["valid_filter"]["properties"]) == {"condition", "reason"}
|
||||
assert set(props["dedup"]["properties"]) == {"rule", "reason"}
|
||||
assert '"valid_filter": {"condition"' in catalog.CATALOG_DRAFT_TEXT_PROMPT
|
||||
assert '"dedup": {"rule"' in catalog.CATALOG_DRAFT_TEXT_PROMPT
|
||||
assert "有效行过滤" in catalog.CATALOG_DRAFT_PROMPT_LABELS["only_table"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 引擎:没开剖析照收、写明没有核对;开了剖析的源把码值交给模型
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
REPLY = {"label": "订单", "grain": "每笔订单一行",
|
||||
"valid_filter": {"condition": "status NOT IN ('C', 'X')", "reason": "状态注释写明 C 作废、X 测试单"},
|
||||
"dedup": {"rule": "同一订单号只取最后一行", "reason": "订单号可能重复"},
|
||||
"columns": [{"name": "status", "label": "订单状态"}]}
|
||||
|
||||
|
||||
async def test_without_profiling_the_filter_is_kept_and_says_it_was_not_checked():
|
||||
model = FakeModel({"orders": REPLY})
|
||||
got = await D.run_model_draft(model, _plain({"orders": _orders()}), ["orders"])
|
||||
o = got["orders"]
|
||||
assert o.status == "ok"
|
||||
vf = o.notes["valid_filter"]
|
||||
assert (vf["source"], vf["status"]) == ("llm", "proposed")
|
||||
assert vf["note"] == "依据:状态注释写明 C 作废、X 测试单。没有核对排除比例(数据源没有开数据剖析)"
|
||||
assert o.notes["dedup"]["note"] == "依据:订单号可能重复"
|
||||
|
||||
|
||||
async def test_an_invalid_filter_from_the_model_is_dropped_not_failed():
|
||||
reply = {**REPLY, "valid_filter": {"condition": "status IN (SELECT code FROM bad_codes)", "reason": "猜的"}}
|
||||
got = await D.run_model_draft(FakeModel({"orders": reply}), _plain({"orders": _orders()}), ["orders"])
|
||||
o = got["orders"]
|
||||
assert o.status == "ok" and o.error is None, "不算失败"
|
||||
assert "valid_filter" not in o.notes and o.notes["dedup"]["value"] == "同一订单号只取最后一行"
|
||||
|
||||
|
||||
def _codes_existing() -> dict:
|
||||
return {"orders": {"columns": {
|
||||
"status": {"codes": make_item({"C": "作废", "X": "", "P": "已支付"}, "profile", "proposed")},
|
||||
"order_no": {"codes": make_item({"A1": "甲"}, "profile", "proposed")},
|
||||
"amount": {"codes": make_item({"0": "零"}, "profile", "rejected")},
|
||||
}}}
|
||||
|
||||
|
||||
async def test_codes_go_to_the_model_when_profiling_is_on_but_not_masked_ones():
|
||||
on = {PROFILE_OPTION: {"enabled": True}, MASK_COLUMNS_OPTION: ["ORDER_NO"]}
|
||||
model = FakeModel({"orders": REPLY})
|
||||
await D.run_model_draft(model, _plain({"orders": _orders()}, options=on), ["orders"], existing=_codes_existing())
|
||||
text = model.calls[0]
|
||||
head = catalog.CATALOG_DRAFT_PROMPT_LABELS["codes"]
|
||||
assert head in text
|
||||
block = text[text.index(head):]
|
||||
assert "- status:C=作废、P=已支付,X 的含义待填写" in block
|
||||
assert "A1" not in text, "遮罩的列不给码值"
|
||||
assert "零" not in text, "驳回的码值不给"
|
||||
|
||||
plain = FakeModel({"orders": REPLY})
|
||||
await D.run_model_draft(plain, _plain({"orders": _orders()}), ["orders"], existing=_codes_existing())
|
||||
assert head not in plain.calls[0] and "已支付" not in plain.calls[0], "没开剖析不给码值"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("options", [
|
||||
{PROFILE_OPTION: {"enabled": True}, MASK_COLUMNS_OPTION: ["ORDER_NO"]},
|
||||
{MASK_COLUMNS_OPTION: "order_no"},
|
||||
], ids=["profiled", "not-profiled"])
|
||||
async def test_confirmed_codes_of_a_masked_column_do_not_reach_the_model_either(options):
|
||||
"""遮罩的列不给码值,人工确认过的也一样:码值就是这一列的取值。以前经「已确认的目录项」照给(开没开剖析都给);
|
||||
没遮罩的列确认过的码值照给(以此为准),遮罩列别的确认过的项(中文名)也照给。"""
|
||||
existing = {"orders": {"columns": {
|
||||
"order_no": {"codes": make_item({"ZX-0417": "内部测试单"}, "human", "confirmed"),
|
||||
"label": make_item("订单号", "human", "confirmed")},
|
||||
"status": {"codes": make_item({"C": "作废"}, "human", "confirmed")},
|
||||
}}}
|
||||
model = FakeModel({"orders": REPLY})
|
||||
await D.run_model_draft(model, _plain({"orders": _orders()}, options=options), ["orders"], existing=existing)
|
||||
text = model.calls[0]
|
||||
assert "ZX-0417" not in text and "内部测试单" not in text
|
||||
confirmed = text[text.index(catalog.CATALOG_DRAFT_PROMPT_LABELS["confirmed"]):]
|
||||
assert "status:码值:C=作废" in confirmed and "order_no:订单号" in confirmed
|
||||
|
||||
|
||||
async def test_codes_reach_the_table_part_of_a_wide_table_and_only_their_column_part():
|
||||
cols = [_col(f"c{i:03d}") for i in range(1, 46)] + [_col("state", "TEXT")]
|
||||
wide = {"qualified": "wide", "schema": None, "is_view": False, "primary_key": ["c001"], "foreign_keys": [],
|
||||
"comment": None, "columns": cols}
|
||||
existing = {"wide": {"columns": {"state": {"codes": make_item({"9": "作废"}, "profile", "proposed")}}}}
|
||||
on = {PROFILE_OPTION: {"enabled": True}}
|
||||
model = FakeModel({"wide": {"label": "宽表"}})
|
||||
await D.run_model_draft(model, _plain({"wide": wide}, options=on), ["wide"], existing=existing)
|
||||
head = catalog.CATALOG_DRAFT_PROMPT_LABELS["codes"]
|
||||
with_codes = [requested(c)["wide"] for c in model.calls if "9=作废" in c]
|
||||
assert ("table", None) in with_codes, "表级那段要提议有效行过滤:给全部码值"
|
||||
assert all(part == "table" or "state" in (cols or []) for part, cols in with_codes)
|
||||
assert any(head not in c for c in model.calls), "不含这一列的列分段不给"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 剖析核对排除比例
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_the_check_counts_excluded_rows_through_the_read_only_guard(scenic, scenic_db, monkeypatch):
|
||||
seen: list[tuple[str, bool]] = []
|
||||
real = catalog_profile.guard.check
|
||||
|
||||
def spy(sql, **kw):
|
||||
seen.append((sql, kw.get("readonly")))
|
||||
return real(sql, **kw)
|
||||
|
||||
monkeypatch.setattr(catalog_profile.guard, "check", spy)
|
||||
source = await scenic()
|
||||
meta = source.schema_cache["tables"]["visits"]
|
||||
found = await catalog_profile.check_valid_filter(source, "visits", meta, "status = 1")
|
||||
total, excluded = _count(scenic_db, "status = 1")
|
||||
assert 0 < excluded < total
|
||||
assert found.keep and (found.rows, found.excluded) == (total, excluded) and not found.sampled
|
||||
share = catalog_profile.excluded_share(excluded, total)
|
||||
assert found.note == f"剖析核对:排除 {share} 的行(共 {total:,} 行)"
|
||||
assert len(seen) == 1 and seen[0][1] is True, "一条查询,过只读守卫"
|
||||
assert "COUNT(*)" in seen[0][0] and "status = 1" in seen[0][0]
|
||||
|
||||
|
||||
async def test_the_check_runs_the_condition_as_it_is_stored(scenic, monkeypatch):
|
||||
"""剖析核对的就是存进目录的那一条:表名去掉、列换成表结构里的真名,别的照原文(NOT IN 不被改写成 NOT x IN)。"""
|
||||
seen: list[str] = []
|
||||
real = catalog_profile.guard.check
|
||||
|
||||
def spy(sql, **kw):
|
||||
seen.append(sql)
|
||||
return real(sql, **kw)
|
||||
|
||||
monkeypatch.setattr(catalog_profile.guard, "check", spy)
|
||||
source = await scenic()
|
||||
reply = {"label": "入园记录", "valid_filter": {"condition": "VISITS.STATUS NOT IN (0)", "reason": "0 是作废的检票"}}
|
||||
got = await D.run_model_draft(FakeModel({"visits": reply}), source, ["visits"])
|
||||
vf = got["visits"].notes["valid_filter"]
|
||||
assert vf["value"] == "status NOT IN (0)"
|
||||
assert len(seen) == 1 and f"CASE WHEN {vf['value']} THEN 0 ELSE 1 END" in seen[0]
|
||||
assert "剖析核对:排除" in vf["note"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("condition", ["status IN (0, 1)", "status = 7"])
|
||||
async def test_a_filter_that_excludes_nothing_or_everything_is_dropped(scenic, condition):
|
||||
source = await scenic()
|
||||
found = await catalog_profile.check_valid_filter(source, "visits", source.schema_cache["tables"]["visits"],
|
||||
condition)
|
||||
assert not found.keep and found.dropped
|
||||
|
||||
|
||||
async def test_a_big_table_is_checked_on_its_first_rows(scenic):
|
||||
source = await scenic(options={PROFILE_OPTION: {"enabled": True, "max_scan_rows": 100, "sample_size": 10}})
|
||||
found = await catalog_profile.check_valid_filter(source, "visits", source.schema_cache["tables"]["visits"],
|
||||
"status = 1")
|
||||
assert found.sampled and found.rows == 100
|
||||
assert found.note.startswith("剖析核对:按前 100 行统计,")
|
||||
|
||||
|
||||
async def test_unchecked_filters_are_kept_with_the_reason(scenic, tmp_path):
|
||||
meta_of = lambda s: s.schema_cache["tables"]["visits"] # noqa: E731
|
||||
off = await scenic(options={})
|
||||
found = await catalog_profile.check_valid_filter(off, "visits", meta_of(off), "status = 1")
|
||||
assert found.keep and found.note == "没有核对排除比例(数据源没有开数据剖析)"
|
||||
|
||||
masked = await scenic(options={PROFILE_OPTION: {"enabled": True}, MASK_COLUMNS_OPTION: "Status"})
|
||||
found = await catalog_profile.check_valid_filter(masked, "visits", meta_of(masked), "status = 1")
|
||||
assert found.keep and found.note == "没有核对排除比例(条件里的 status 在数据源设置中被遮罩,不取数)"
|
||||
|
||||
stopped = await scenic(enabled=False)
|
||||
found = await catalog_profile.check_valid_filter(stopped, "visits", meta_of(stopped), "status = 1")
|
||||
assert found.keep and found.note == "没有核对排除比例(数据源已停用)"
|
||||
|
||||
gone = await scenic(database=str(tmp_path / "missing" / "nope.db"))
|
||||
found = await catalog_profile.check_valid_filter(gone, "visits", meta_of(gone), "status = 1")
|
||||
assert found.keep and found.note.startswith("没有核对排除比例(查询失败:")
|
||||
|
||||
|
||||
async def test_the_engine_checks_the_filter_on_a_profiled_source(scenic, scenic_db):
|
||||
source = await scenic()
|
||||
good = {"label": "入园记录", "valid_filter": {"condition": "status = 1", "reason": "0 是作废的检票"}}
|
||||
got = await D.run_model_draft(FakeModel({"visits": good}), source, ["visits"])
|
||||
vf = got["visits"].notes["valid_filter"]
|
||||
total, excluded = _count(scenic_db, "status = 1")
|
||||
share = catalog_profile.excluded_share(excluded, total)
|
||||
assert vf["note"] == f"依据:0 是作废的检票。剖析核对:排除 {share} 的行(共 {total:,} 行)"
|
||||
|
||||
useless = {"label": "入园记录", "valid_filter": {"condition": "status IN (0, 1)", "reason": "猜的"}}
|
||||
got = await D.run_model_draft(FakeModel({"visits": useless}), source, ["visits"])
|
||||
assert got["visits"].status == "ok" and "valid_filter" not in got["visits"].notes, "排除 0% 的不收"
|
||||
assert got["visits"].notes["label"]["value"] == "入园记录"
|
||||
|
||||
|
||||
async def test_a_dropped_filter_is_written_into_the_table_log(scenic, scenic_db):
|
||||
"""没收下的有效记录条件不算失败,在这张表的日志里记一条(起草任务逐表的 log,界面看得到):没通过校验的写原因,
|
||||
剖析核对排除 0% 或 100% 的写数到了多少行。收下的、模型没提的不记。"""
|
||||
bad = {**REPLY, "valid_filter": {"condition": "status IN (SELECT code FROM bad_codes)", "reason": "猜的"}}
|
||||
got = await D.run_model_draft(FakeModel({"orders": bad}), _plain({"orders": _orders()}), ["orders"])
|
||||
assert got["orders"].status == "ok" and got["orders"].error is None
|
||||
assert got["orders"].log == [
|
||||
"有效记录条件里不能有子查询,模型提议的这一条没有收下:status IN (SELECT code FROM bad_codes)"]
|
||||
|
||||
source = await scenic()
|
||||
total, _ = _count(scenic_db, "1 = 1")
|
||||
for condition, what in (("status IN (0, 1)", "没有排除任何一行"), ("status = 7", "所有行都被排除了")):
|
||||
useless = {"label": "入园记录", "valid_filter": {"condition": condition, "reason": "猜的"}}
|
||||
got = await D.run_model_draft(FakeModel({"visits": useless}), source, ["visits"])
|
||||
assert got["visits"].log == [
|
||||
f"有效记录条件按剖析核对{what}(共 {total:,} 行),模型提议的这一条没有收下:{condition}"]
|
||||
|
||||
for reply in (REPLY, {"label": "订单"}):
|
||||
got = await D.run_model_draft(FakeModel({"orders": reply}), _plain({"orders": _orders()}), ["orders"])
|
||||
assert got["orders"].log == []
|
||||
|
||||
|
||||
async def test_a_confirmed_filter_is_not_checked_again(scenic, monkeypatch):
|
||||
source = await scenic()
|
||||
called: list[str] = []
|
||||
|
||||
async def counting(*args, **kw):
|
||||
called.append("x")
|
||||
return catalog_profile.FilterCheck(True, "不该用到")
|
||||
|
||||
monkeypatch.setattr(catalog_profile, "check_valid_filter", counting)
|
||||
existing = {"visits": {"valid_filter": make_item("status = 1", "human", "confirmed")}}
|
||||
reply = {"label": "入园记录", "valid_filter": {"condition": "status <> 0", "reason": "0 是作废"}}
|
||||
await D.run_model_draft(FakeModel({"visits": reply}), source, ["visits"], existing=existing)
|
||||
assert called == [], "人工确认过的有效行过滤,起草的提议并入时不会采用,不必再查库"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# SQL 检查:推断的有效行过滤只是提示,确认后才是警告
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_a_drafted_filter_only_hints_until_someone_confirms_it(scenic):
|
||||
source = await scenic(options={})
|
||||
entry = {"table": "visits", "valid_filter": {"condition": "status = 1", "reason": "0 是作废"}}
|
||||
meta = source.schema_cache["tables"]["visits"]
|
||||
notes = catalog._from_model(entry, meta, system=False, source_kind="sqlite", table="visits")
|
||||
sql = "SELECT COUNT(*) FROM visits"
|
||||
|
||||
def levels(table_notes: dict) -> list[tuple[str, str]]:
|
||||
checker = sqlcheck.SqlChecker(kind="sqlite", schema_cache=source.schema_cache, notes={"visits": table_notes})
|
||||
return [(c.code, c.level) for c in checker.check(sql)]
|
||||
|
||||
assert levels(notes) == [("missing_valid_filter", "info")]
|
||||
confirmed = catalog.review_item(notes, "valid_filter", "confirm")
|
||||
assert levels(confirmed) == [("missing_valid_filter", "warning")]
|
||||
assert levels(catalog.review_item(notes, "valid_filter", "reject")) == []
|
||||
@@ -17,7 +17,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import itertools
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import quote
|
||||
|
||||
@@ -441,6 +443,11 @@ def test_old_job_tables_get_the_new_columns():
|
||||
assert conn.execute(text("SELECT timeouts, failed_calls, part_columns, notice FROM catalog_draft_jobs")) \
|
||||
.one() == (0, 0, None, None)
|
||||
assert conn.execute(text("SELECT blank_columns, blank_sample FROM catalog_draft_tasks")).one() == (0, None)
|
||||
# 闭环第一步:逐段落盘的段数、块进度。存量行是 0 和 NULL:升级前没记这些
|
||||
assert conn.execute(text("SELECT parts_written, chunks_total, chunks_done FROM catalog_draft_tasks")) \
|
||||
.one() == (0, None, None)
|
||||
# 复核后:逐表的日志(没收下的有效记录条件)。存量行是 NULL,接口给空列表
|
||||
assert conn.execute(text("SELECT log FROM catalog_draft_tasks")).one() == (None,)
|
||||
|
||||
|
||||
async def test_a_job_that_cannot_start_is_failed_with_the_reason(client, monkeypatch):
|
||||
@@ -779,3 +786,295 @@ async def test_jobs_go_away_with_their_source(client):
|
||||
assert (await session.execute(select(CatalogDraftJob).where(CatalogDraftJob.id == job["id"]))).first() is None
|
||||
assert (await session.execute(select(CatalogDraftTask).where(CatalogDraftTask.job_id == job["id"]))).first() \
|
||||
is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 闭环第一步:宽表逐段落盘、停止后的说法、块进度、停止后的留空提示、同步起草与任务互斥
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _wide(n: int = 95) -> dict:
|
||||
return {"wide": {"qualified": "wide", "columns": [_col(f"c{i:03d}") for i in range(1, n + 1)],
|
||||
"primary_key": ["c001"], "comment": None, "is_view": False, "foreign_keys": []}}
|
||||
|
||||
|
||||
def _wide_reply(blank: set[str] = frozenset()) -> dict:
|
||||
"""95 列的宽表每列都写中文名;blank 里的列只列出、不写(模型拿不准)。"""
|
||||
return {"wide": {"label": "宽表", "kind": "fact",
|
||||
"columns": [{"name": f"c{i:03d}", "label": f"第{i}列"} for i in range(1, 96)
|
||||
if f"c{i:03d}" not in blank]}}
|
||||
|
||||
|
||||
class Hold:
|
||||
"""假模型:请写的表在 tables 里、或请写的列里有 columns 里的列时,等 release 才回(停止时这次调用被取消);别的照
|
||||
FakeModel 回。waiting:有调用在等了。"""
|
||||
|
||||
def __init__(self, replies: dict, *, tables: set[str] = frozenset(), columns: set[str] = frozenset()) -> None:
|
||||
self.inner = FakeModel(replies)
|
||||
self.tables, self.columns = set(tables), set(columns)
|
||||
self.release, self.waiting = asyncio.Event(), asyncio.Event()
|
||||
self.cancelled = 0
|
||||
self.model_name = "hold-model"
|
||||
|
||||
def with_structured_output(self, schema, **_):
|
||||
outer = self
|
||||
|
||||
class _S:
|
||||
async def ainvoke(self, messages):
|
||||
asked = requested("\n".join(str(m.content) for m in messages))
|
||||
cols = {c for _, listed in asked.values() for c in listed or []}
|
||||
if outer.tables & set(asked) or outer.columns & cols:
|
||||
outer.waiting.set()
|
||||
try:
|
||||
await outer.release.wait()
|
||||
except asyncio.CancelledError:
|
||||
outer.cancelled += 1
|
||||
raise
|
||||
return outer.inner._reply(messages, "structured")
|
||||
|
||||
return _S()
|
||||
|
||||
async def ainvoke(self, messages):
|
||||
return await self.inner.ainvoke(messages)
|
||||
|
||||
|
||||
async def _task(client, sid: str, job_id: str, table: str) -> dict:
|
||||
return next(t for t in (await get_job(client, sid, job_id))["tasks"] if t["table_name"] == table)
|
||||
|
||||
|
||||
async def _start_held_wide(client, sid: str, monkeypatch) -> tuple[str, Hold]:
|
||||
"""95 列的宽表分 4 段:表级那段和第 2 段照常写,第 3、4 段一直等着。等到前两段都写进目录才返回。"""
|
||||
model = Hold(_wide_reply(), columns={"c033", "c065"})
|
||||
use(monkeypatch, model)
|
||||
job = (await client.post(f"{_base(sid)}/draft-jobs", json={"tables": ["wide"], "use_model": True})).json()["job"]
|
||||
|
||||
async def two_written() -> bool:
|
||||
return (await _task(client, sid, job["id"], "wide"))["parts_written"] == 2
|
||||
|
||||
await until(two_written)
|
||||
return job["id"], model
|
||||
|
||||
|
||||
async def test_a_stopped_wide_table_keeps_the_parts_already_written(client, monkeypatch):
|
||||
"""宽表每写出一段就并入目录:停止时已经写进去的段留着,这张表照旧记停止,model_error 说清写进了几段、剩几段没起草。"""
|
||||
sid = await _source(_wide())
|
||||
job_id, model = await _start_held_wide(client, sid, monkeypatch)
|
||||
task = await _task(client, sid, job_id, "wide")
|
||||
assert task["status"] == "running" and task["parts_total"] == 4 and task["parts_done"] == 2
|
||||
assert (task["chunks_total"], task["chunks_done"]) == (4, 2)
|
||||
assert task["added"] == 2 + 32 and task["version"] >= 1, "表级两项 + 第 2 段 32 列"
|
||||
|
||||
r = await client.post(f"{_base(sid)}/draft-jobs/{job_id}/cancel")
|
||||
assert r.status_code == 200, r.text
|
||||
task = next(t for t in r.json()["job"]["tasks"] if t["table_name"] == "wide")
|
||||
assert task["status"] == "cancelled" and task["error"] is None and task["parts_written"] == 2
|
||||
assert task["model_error"] == "已停止:已写进目录 2 段,剩下 2 段没起草"
|
||||
assert model.cancelled == 2, "第 3、4 段正在进行的调用被停掉"
|
||||
notes = (await client.get(f"{_base(sid)}/wide")).json()["notes"]
|
||||
assert notes["label"]["value"] == "宽表" and notes["kind"]["value"] == "fact"
|
||||
assert set(notes["columns"]) == {f"c{i:03d}" for i in range(1, 33)}, "第 2 段写进去的留着"
|
||||
row = next(r for r in (await client.get(_base(sid))).json()["tables"] if r["table_name"] == "wide")
|
||||
assert row["last_draft"]["status"] == "cancelled" and row["last_draft"]["parts_written"] == 2
|
||||
assert row["last_draft"]["blank_columns_from"] is None, "停止前写进了新内容:以前的留空列数不再沿用"
|
||||
|
||||
|
||||
async def test_an_interrupted_wide_table_keeps_its_written_parts_too(client, monkeypatch):
|
||||
sid = await _source(_wide())
|
||||
job_id, _ = await _start_held_wide(client, sid, monkeypatch)
|
||||
await catalog_jobs.manager.shutdown(grace=5)
|
||||
task = await _task(client, sid, job_id, "wide")
|
||||
assert task["status"] == "cancelled" and task["error"] == "服务重启,起草中断"
|
||||
assert task["model_error"] == "已中断:已写进目录 2 段,剩下 2 段没起草"
|
||||
assert len((await client.get(f"{_base(sid)}/wide")).json()["notes"]["columns"]) == 32
|
||||
|
||||
# 进程没来得及收尾(直接退出):下次启动时同样写清
|
||||
async with SessionLocal() as session:
|
||||
job = CatalogDraftJob(source_id=sid, status="running", use_model=True)
|
||||
session.add(job)
|
||||
await session.flush()
|
||||
session.add(CatalogDraftTask(job_id=job.id, seq=0, table_name="wide", status="running", parts_total=3,
|
||||
parts_written=1))
|
||||
session.add(CatalogDraftTask(job_id=job.id, seq=1, table_name="gates", status="pending", parts_total=1))
|
||||
await session.commit()
|
||||
crashed = job.id
|
||||
await catalog_jobs.startup()
|
||||
by = {t["table_name"]: t for t in (await get_job(client, sid, crashed))["tasks"]}
|
||||
assert by["wide"]["model_error"] == "已中断:已写进目录 1 段,剩下 2 段没起草"
|
||||
assert by["gates"]["model_error"] is None and by["gates"]["error"] == "服务重启,起草中断"
|
||||
|
||||
|
||||
async def test_a_finished_wide_table_counts_every_write_once(client, monkeypatch):
|
||||
"""逐段落盘之后整张表收尾一次:新增、更新的计数是各次写入的合计(每一项只数一次),块进度和写进去的段数都记下。"""
|
||||
sid = await _source(_wide())
|
||||
job = await _draft(client, sid, monkeypatch, FakeModel(_wide_reply({"c010", "c040", "c070"})), ["wide"])
|
||||
task = job["tasks"][0]
|
||||
assert task["status"] == "ok" and task["parts_written"] == 4 and task["parts_done"] == 4
|
||||
assert (task["chunks_total"], task["chunks_done"]) == (4, 4)
|
||||
assert (task["added"], task["updated"], task["removed"]) == (2 + 92, 0, 0)
|
||||
assert task["blank_columns"] == 3 and task["blank_sample"] == ["c010", "c040", "c070"]
|
||||
notes = (await client.get(f"{_base(sid)}/wide")).json()["notes"]
|
||||
assert len(notes["columns"]) == 92
|
||||
|
||||
plain = (await client.post(f"{_base(sid)}/draft-jobs", json={"tables": ["wide"]})).json()["job"]
|
||||
await finished(plain["id"])
|
||||
task = (await get_job(client, sid, plain["id"]))["tasks"][0]
|
||||
assert (task["parts_written"], task["chunks_total"], task["chunks_done"]) == (0, None, None), "不用模型时为 null"
|
||||
|
||||
|
||||
async def test_chunk_progress_shows_a_part_that_was_split(client, monkeypatch):
|
||||
"""块进度:这张表实际分成的块数和已结束的块数,一段超时拆成两半后变多。"""
|
||||
monkeypatch.setattr(catalog, "MODEL_TIMEOUT_S", 0.05)
|
||||
sid = await _source(_tickets(24))
|
||||
replies = {"tickets": {"label": "门票", "columns": [{"name": f"c{i:02d}", "label": f"第{i}列"}
|
||||
for i in range(1, 25)]}}
|
||||
job = await _draft(client, sid, monkeypatch, FakeModel(replies, hang=1), ["tickets"])
|
||||
task = job["tasks"][0]
|
||||
assert task["status"] == "ok" and (task["chunks_total"], task["chunks_done"]) == (2, 2)
|
||||
assert task["parts_written"] == 1 and task["parts_total"] == 1
|
||||
|
||||
|
||||
async def test_a_partial_table_also_drops_model_items_of_columns_gone_from_the_schema(client, monkeypatch):
|
||||
"""整张表写完时按完整覆盖的规则收尾,只写出一部分的也一样:只删已经不在表结构里的列的模型起草项。"""
|
||||
sid = await _source(_tickets(6))
|
||||
full = {"label": "门票", "columns": [{"name": f"c{i:02d}", "label": f"第{i}列"} for i in range(1, 7)]}
|
||||
await _draft(client, sid, monkeypatch, FakeModel({"tickets": full}), ["tickets"])
|
||||
async with SessionLocal() as session:
|
||||
row = await session.get(DataSource, sid)
|
||||
row.schema_cache = {"tables": _tickets(5)} # 重新探查过:c06 已经没有了
|
||||
await session.commit()
|
||||
some = {"label": "门票", "columns": [{"name": f"c{i:02d}", "label": f"第{i}列"} for i in range(1, 5)]}
|
||||
job = await _draft(client, sid, monkeypatch, FakeModel({"tickets": some}, list_all=False), ["tickets"])
|
||||
task = job["tasks"][0]
|
||||
assert task["status"] == "partial" and task["removed"] == 1
|
||||
after = (await client.get(f"{_base(sid)}/tickets")).json()["notes"]["columns"]
|
||||
assert set(after) == {"c01", "c02", "c03", "c04", "c05"}, "c05 是以前起草的,这次没写出来也留着"
|
||||
|
||||
|
||||
async def test_blank_columns_from_carries_the_last_finished_draft_over_a_stop(client, monkeypatch):
|
||||
"""目录清单的 last_draft.blank_columns_from:最近一次结束于完成、部分完成的起草记下的留空列数。之后的起草停止了、
|
||||
没写进新内容时照样带着,界面据此继续提示;不用模型的起草不算。"""
|
||||
sid = await _source(_tickets(4))
|
||||
replies = {"tickets": {"label": "门票", "columns": [{"name": "c01", "label": "编号"}]}}
|
||||
await _draft(client, sid, monkeypatch, FakeModel(replies), ["tickets"])
|
||||
|
||||
async def last() -> dict:
|
||||
rows = (await client.get(_base(sid))).json()["tables"]
|
||||
return next(r for r in rows if r["table_name"] == "tickets")["last_draft"]
|
||||
|
||||
first = await last()
|
||||
assert first["status"] == "ok" and first["blank_columns"] == 3 and first["blank_columns_from"] == 3
|
||||
assert first["parts_written"] == 1
|
||||
|
||||
plain = (await client.post(f"{_base(sid)}/draft-jobs", json={"tables": ["tickets"]})).json()["job"]
|
||||
await finished(plain["id"])
|
||||
assert (await last())["blank_columns_from"] == 3, "不用模型的起草不改模型留空的列"
|
||||
|
||||
monkeypatch.setattr(catalog_jobs.manager, "start", lambda job_id: None)
|
||||
queued = (await client.post(f"{_base(sid)}/draft-jobs", json={"tables": ["tickets"], "use_model": True})).json()
|
||||
await client.post(f"{_base(sid)}/draft-jobs/{queued['job']['id']}/cancel")
|
||||
stopped = await last()
|
||||
assert stopped["status"] == "cancelled" and stopped["blank_columns"] == 0 and stopped["parts_written"] == 0
|
||||
assert stopped["blank_columns_from"] == 3
|
||||
|
||||
other = await _source(_tickets(4))
|
||||
rows = (await client.get(_base(other))).json()["tables"]
|
||||
assert rows[0]["last_draft"] is None
|
||||
|
||||
|
||||
async def test_a_sync_draft_and_draft_jobs_exclude_each_other(client, monkeypatch):
|
||||
"""同步起草进行期间,同一个源建任务、重试、再开一次同步起草都 409 catalog_draft_busy(导入照 ensure_idle 认);
|
||||
同步起草结束后照常。"""
|
||||
sid = await _source()
|
||||
use(monkeypatch, FakeModel(REPLIES))
|
||||
old = (await client.post(f"{_base(sid)}/draft-jobs", json={"tables": ["gates"], "use_model": True})).json()["job"]
|
||||
await finished(old["id"])
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(
|
||||
CatalogDraftTask.__table__.update().where(CatalogDraftTask.job_id == old["id"]).values(status="failed"))
|
||||
await session.commit()
|
||||
|
||||
model = Hold(REPLIES, tables={"visits"})
|
||||
use(monkeypatch, model)
|
||||
sync = asyncio.create_task(client.post(f"{_base(sid)}/draft", json={"tables": ["visits"], "use_model": True}))
|
||||
await asyncio.wait_for(model.waiting.wait(), 5)
|
||||
|
||||
busy = await client.post(f"{_base(sid)}/draft-jobs", json={"tables": ["gates"]})
|
||||
assert busy.status_code == 409 and busy.json()["code"] == "catalog_draft_busy", busy.text
|
||||
assert "正在起草" in busy.json()["detail"]
|
||||
retry = await client.post(f"{_base(sid)}/draft-jobs/{old['id']}/retry")
|
||||
assert retry.status_code == 409 and retry.json()["code"] == "catalog_draft_busy", retry.text
|
||||
again = await client.post(f"{_base(sid)}/draft", json={"tables": ["gates"]})
|
||||
assert again.status_code == 409 and again.json()["code"] == "catalog_draft_busy", again.text
|
||||
async with SessionLocal() as session:
|
||||
with pytest.raises(catalog_jobs.DraftBusy):
|
||||
await catalog_jobs.ensure_idle(session, await session.get(DataSource, sid))
|
||||
assert (await client.get(f"{_base(sid)}/draft-jobs/latest")).json()["job"]["id"] == old["id"], "没建新任务"
|
||||
|
||||
model.release.set()
|
||||
done = await sync
|
||||
assert done.status_code == 200, done.text
|
||||
after = await client.post(f"{_base(sid)}/draft-jobs", json={"tables": ["gates"]})
|
||||
assert after.status_code == 202, after.text
|
||||
await finished(after.json()["job"]["id"])
|
||||
|
||||
|
||||
async def test_a_wide_table_whose_closing_write_fails_keeps_the_count_of_parts_on_disk(client, monkeypatch):
|
||||
"""整张表收尾那一次写不进去(一直撞上别人刚改过):已经逐段写进去的段留着,写进目录的段数照库里的记,不按引擎写出来
|
||||
的段数虚报。"""
|
||||
monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1) # 一段一段来:最后一段只在收尾那次写
|
||||
sid = await _source(_wide())
|
||||
use(monkeypatch, FakeModel(_wide_reply()))
|
||||
real = catalog.write_draft
|
||||
|
||||
async def conflicted(session, source, table, modeled=None, *, at, actor):
|
||||
result = await real(session, source, table, None, at=at, actor=actor)
|
||||
result.error, result.status = catalog.WRITE_CONFLICT_ERROR, "failed"
|
||||
result.added = result.updated = result.removed = 0
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(catalog, "write_draft", conflicted)
|
||||
job = (await client.post(f"{_base(sid)}/draft-jobs", json={"tables": ["wide"], "use_model": True})).json()["job"]
|
||||
await finished(job["id"])
|
||||
task = await _task(client, sid, job["id"], "wide")
|
||||
assert task["status"] == "failed" and task["error"] == catalog.WRITE_CONFLICT_ERROR
|
||||
assert task["parts_written"] == 3, "最后一段没写进去"
|
||||
assert len((await client.get(f"{_base(sid)}/wide")).json()["notes"]["columns"]) == 64
|
||||
|
||||
|
||||
async def test_a_dropped_filter_is_logged_on_the_task_not_failed(client, monkeypatch):
|
||||
"""模型提议的有效记录条件没通过校验:丢掉、不算失败(表照样完成),逐表的 log 里记一条,接口看得到;没有就是
|
||||
空列表。"""
|
||||
sid = await _source()
|
||||
replies = {**REPLIES, "gates": {"label": "闸口",
|
||||
"valid_filter": {"condition": "gate_code IN (SELECT 1)", "reason": "猜的"}}}
|
||||
job = await _draft(client, sid, monkeypatch, FakeModel(replies), ["gates", "channels"])
|
||||
by = {t["table_name"]: t for t in job["tasks"]}
|
||||
assert by["gates"]["status"] == "ok" and by["gates"]["model_error"] is None
|
||||
assert by["gates"]["log"] == ["有效记录条件里不能有子查询,模型提议的这一条没有收下:gate_code IN (SELECT 1)"]
|
||||
assert by["channels"]["log"] == []
|
||||
notes = (await client.get(f"{_base(sid)}/gates")).json()["notes"]
|
||||
assert "valid_filter" not in notes and notes["label"]["value"] == "闸口"
|
||||
|
||||
|
||||
async def test_each_write_of_a_job_stamps_its_own_time(client, monkeypatch):
|
||||
"""起草任务写进目录的项,修改时间是那一次写入的时间:宽表逐段落盘的每一段、整张表收尾各取一次当时的时间。以前只在任务
|
||||
开始时取一次(截到秒),整个任务写进去的项都是开始那一刻,比任务的开始时间还早,也看不出哪一段是什么时候写的。
|
||||
|
||||
时钟换成每取一次走一分钟的假时钟;同时只起草一段,各段的先后是定的:表级那段、第 2、3 段逐段落盘,第 4 段由收尾写进去。"""
|
||||
ticks = itertools.count()
|
||||
start = datetime(2026, 10, 14, 3, tzinfo=timezone.utc)
|
||||
monkeypatch.setattr(catalog, "now_iso",
|
||||
lambda: (start + timedelta(minutes=next(ticks))).isoformat(timespec="seconds"))
|
||||
monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
|
||||
sid = await _source(_wide())
|
||||
job = await _draft(client, sid, monkeypatch, FakeModel(_wide_reply()), ["wide"])
|
||||
task = job["tasks"][0]
|
||||
assert task["status"] == "ok" and task["parts_written"] == 4
|
||||
notes = (await client.get(f"{_base(sid)}/wide")).json()["notes"]
|
||||
cols = notes["columns"]
|
||||
parts = [{cols[f"c{i:03d}"]["label"]["updated_at"] for i in range(lo, hi + 1)}
|
||||
for lo, hi in ((1, 32), (33, 64), (65, 95))]
|
||||
assert all(len(p) == 1 for p in parts), "同一段的项是同一次写进去的"
|
||||
assert notes["kind"]["updated_at"] == notes["label"]["updated_at"], "表级那段的两项是同一次写进去的"
|
||||
stamps = [notes["label"]["updated_at"], *(p.pop() for p in parts)]
|
||||
assert len(set(stamps)) == 4 and stamps == sorted(stamps), stamps
|
||||
|
||||
@@ -169,6 +169,39 @@ async def test_apply_patch_records_human_confirmed(scenic_db):
|
||||
catalog.apply_patch(proposed, [{"path": "kind", "value": "nope"}], table="visits", tables=tables)
|
||||
|
||||
|
||||
_PARKS = catalog.relation_id("visits", ["park_id"], "parks", ["id"])
|
||||
|
||||
|
||||
async def test_rejected_items_show_their_value_before(scenic_db):
|
||||
"""提案落在已驳回的项上:改前带上被驳回的原值、状态写已驳回(同导入预览「目标里这一项已经驳回」)。只改预览给出的
|
||||
信息,保存的规矩不变:照旧换成人工确认的新值;码值不在驳回的码值上补;和驳回的值一样的也不算「值不变」,照样存成
|
||||
人工确认。"""
|
||||
tables = (await _schema(scenic_db))["tables"]
|
||||
rejected = scenic_notes.notes("rejected")["visits"]
|
||||
rel_value = {"columns": ["park_id"], "to_table": "parks", "to_columns": ["id"], "cardinality": "many_to_one"}
|
||||
changes = [
|
||||
{"path": "columns.visitor_count.measure", "value": "stock"},
|
||||
{"path": "columns.status.codes", "value": {"9": "作废"}},
|
||||
{"path": "valid_filter", "value": "status = 1"}, # 和驳回的值一样
|
||||
{"path": f"relations.{_PARKS}", "value": rel_value}, # 两端、基数都和驳回的那条一样
|
||||
]
|
||||
plan = catalog.plan_patch(rejected, changes, table="visits", tables=tables)
|
||||
assert plan.problems == []
|
||||
measure, codes, valid, rel = plan.changes
|
||||
assert (measure.before, measure.before_status, measure.state) == ("flow", "rejected", "change")
|
||||
assert (codes.before, codes.before_status, codes.state) == ({"1": "有效", "0": "作废"}, "rejected", "change")
|
||||
assert codes.after == {"9": "作废"}, "码值不在驳回的码值上补"
|
||||
assert (valid.before, valid.before_status, valid.state) == ("status = 1", "rejected", "change")
|
||||
assert (rel.before, rel.before_status, rel.state) == ({**rel_value, "coverage": None}, "rejected", "change")
|
||||
assert rel.as_dict()["before_status"] == "rejected" and rel.as_dict()["before"]["to_table"] == "parks"
|
||||
out = catalog.apply_patch(rejected, changes, table="visits", tables=tables)
|
||||
saved = [out["columns"]["visitor_count"]["measure"], out["columns"]["status"]["codes"], out["valid_filter"],
|
||||
next(r for r in out["relations"] if r["id"] == _PARKS)]
|
||||
assert all((item["source"], item["status"]) == ("human", "confirmed") for item in saved)
|
||||
codes = out["columns"]["status"]["codes"]
|
||||
assert codes["value"] == {"9": "作废"} and "complete" not in codes
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 协议
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -337,6 +370,8 @@ async def test_preview_and_save(scenic_source):
|
||||
assert detail["version"] == version + 1 and detail["updated_by"] == "王敏"
|
||||
codes = detail["notes"]["columns"]["status"]["codes"]
|
||||
assert codes["value"]["9"] == "作废" and (codes["source"], codes["status"]) == ("human", "confirmed")
|
||||
# 依据随项另存(确认时的依据,规则见 test_catalog_reason.py),不拼进备注
|
||||
assert codes["reason"] == "作废单" and "note" not in codes
|
||||
|
||||
# 拿旧版本再存:409,什么都不写
|
||||
r = await client.post(f"{base}/patch", json={"changes": changes, "if_version": version})
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"""保存修改提案时的依据:目录项上单独的一个字段 reason(界面上叫「确认时的依据」),和备注 note 分开。
|
||||
|
||||
表助手、助手流里「建议更新数据目录」的提案经 /catalog/{table}/patch 保存,带了依据的项把依据记进这一项的 reason(最多
|
||||
500 字,超了截成 499 字加「…」);note 照旧是起草的依据、剖析的测量说明、双盲复算的出处这类机器写的话,不拼依据。
|
||||
|
||||
- 去留:值改了(关联关系改了值也一样)换成这次的依据,没有依据就去掉旧的;值不变、保存即确认的换成这次的(不叠加);
|
||||
单项审阅的确认、驳回留着,撤销审阅(回到推断)去掉;剖析、起草、导入以外的来源改写这一项(值变了)时去掉;剖析刷新
|
||||
关联关系的测量只动 note。
|
||||
- 校验认它(文字、最多 500 字);起草、剖析的合并不碰人工确认的项,依据留着;导入时已确认、已驳回的项连依据一起带,
|
||||
推断、有确证的项不带。
|
||||
|
||||
以前把依据拼进 note(「依据:……。数据剖析:……」):再剖析时测量的说法换不了新,和覆盖率那一栏对不上;改了值的关联关系
|
||||
旧依据叠在新依据后面;撤销审阅回到推断,「依据:使用者说:……」还挂在推断项上,看起来像使用者确认过。
|
||||
|
||||
表名、列名、数据全部虚构。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.data import catalog, catalog_profile, catalog_transfer
|
||||
from app.data.catalog import make_item
|
||||
|
||||
AT = "2026-10-14T08:00:00+00:00"
|
||||
LATER = "2026-10-15T08:00:00+00:00"
|
||||
|
||||
#: 提案核对用的表结构(只要列名和主键)
|
||||
TABLES = {
|
||||
"visits": {"qualified": "visits", "primary_key": ["id"],
|
||||
"columns": [{"name": c} for c in ("id", "ticket_no", "park_id", "visitor_count", "status")]},
|
||||
"parks": {"qualified": "parks", "primary_key": ["id"], "columns": [{"name": "id"}, {"name": "name"}]},
|
||||
}
|
||||
PARKS = catalog.relation_id("visits", ["park_id"], "parks", ["id"])
|
||||
PARK_ENDS = {"columns": ["park_id"], "to_table": "parks", "to_columns": ["id"]}
|
||||
PROFILE_NOTE = "数据剖析:抽样 1,000 行,覆盖率 97%。"
|
||||
FILTER_NOTE = "依据:码值 0 是作废。剖析核对:排除 3.2% 的行(共 1,000 行)"
|
||||
|
||||
|
||||
def _rel(**extra) -> dict:
|
||||
"""数据剖析核实过的入园记录 → 景区:覆盖率 97%,备注是测量的说法。"""
|
||||
return {"id": PARKS, **PARK_ENDS, "cardinality": "many_to_one", "coverage": 0.97, "source": "profile",
|
||||
"status": "verified", "note": PROFILE_NOTE, **extra}
|
||||
|
||||
|
||||
def _visits() -> dict:
|
||||
return {
|
||||
"grain": make_item("一张门票一行", "llm"),
|
||||
"valid_filter": make_item("status = 1", "llm", note=FILTER_NOTE),
|
||||
"columns": {"visitor_count": {"measure": make_item("flow", "llm")}},
|
||||
"relations": [_rel()],
|
||||
}
|
||||
|
||||
|
||||
def _patch(notes: dict, changes: list[dict], at: str = AT) -> dict:
|
||||
return catalog.apply_patch(notes, changes, table="visits", tables=TABLES, at=at)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 保存提案
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_saving_keeps_the_reason_apart_from_the_note():
|
||||
out = _patch(_visits(), [
|
||||
{"path": "columns.visitor_count.measure", "value": "stock", "reason": "使用者说:在园人数是存量"},
|
||||
{"path": "columns.visitor_count.unit", "value": "人"}, # 没写依据
|
||||
{"path": "valid_filter", "value": "status = 1", "reason": "使用者说:只算有效的"}, # 值不变:保存即确认
|
||||
{"path": f"relations.{PARKS}", "value": {**PARK_ENDS, "cardinality": "one_to_one"},
|
||||
"reason": "使用者说:一个景区一条"},
|
||||
{"path": "grain", "value": "一张门票的一次入园", "reason": "长" * 600},
|
||||
])
|
||||
measure = out["columns"]["visitor_count"]["measure"]
|
||||
assert measure["reason"] == "使用者说:在园人数是存量" and "note" not in measure
|
||||
assert (measure["value"], measure["source"], measure["status"]) == ("stock", "human", "confirmed")
|
||||
unit = out["columns"]["visitor_count"]["unit"]
|
||||
assert "reason" not in unit and "note" not in unit
|
||||
# 值不变的确认:来源、备注都不变,依据另记
|
||||
valid = out["valid_filter"]
|
||||
assert valid["note"] == FILTER_NOTE and valid["reason"] == "使用者说:只算有效的"
|
||||
assert (valid["source"], valid["status"]) == ("llm", "confirmed")
|
||||
# 关联关系改了基数:覆盖率和说明它怎么测的备注照旧沿用,备注里不拼依据
|
||||
rel = out["relations"][0]
|
||||
assert rel["note"] == PROFILE_NOTE and rel["reason"] == "使用者说:一个景区一条"
|
||||
assert (rel["cardinality"], rel["coverage"], rel["source"], rel["status"]) == (
|
||||
"one_to_one", 0.97, "human", "confirmed")
|
||||
# 超过 500 字:截成 499 字加「…」
|
||||
grain = out["grain"]
|
||||
assert grain["reason"] == "长" * 499 + "…" and "note" not in grain
|
||||
assert catalog.validate_notes(out) == []
|
||||
# 正好 500 字的不截;预览给出的依据和保存的是同一句
|
||||
exact = _patch(_visits(), [{"path": "grain", "value": "一行一张票", "reason": "短" * 500}])
|
||||
assert exact["grain"]["reason"] == "短" * 500
|
||||
plan = catalog.plan_patch(_visits(), [{"path": "grain", "value": "一行一张票", "reason": "长" * 600}],
|
||||
table="visits", tables=TABLES)
|
||||
assert plan.changes[0].reason == "长" * 499 + "…"
|
||||
|
||||
|
||||
def test_profiling_again_refreshes_the_measurement_and_keeps_the_reason():
|
||||
"""带依据保存的关联关系再剖析:覆盖率和备注里测量的说法一起换新(以前备注成了「依据:……。数据剖析:……」,剖析
|
||||
认不出是自己写的,覆盖率变了、备注却停在保存那一刻),依据不动,人定的基数不改。"""
|
||||
saved = _patch({"relations": [_rel()]}, [
|
||||
{"path": f"relations.{PARKS}", "value": {**PARK_ENDS, "cardinality": "one_to_one"},
|
||||
"reason": "使用者说:一个景区一条"},
|
||||
])
|
||||
outcome = catalog_profile._TableOutcome(relations=[catalog_profile._RelationOutcome(
|
||||
rid=PARKS, target="parks.id", coverage=0.99, cardinality="many_to_one", holds=True, sample=1000, matched=990,
|
||||
note="数据剖析:抽样 1,000 行,覆盖率 99%。")])
|
||||
merged, findings, stats = catalog_profile._apply(saved, outcome, at=LATER)
|
||||
rel = merged["relations"][0]
|
||||
assert rel["coverage"] == 0.99 and rel["note"] == "数据剖析:抽样 1,000 行,覆盖率 99%。"
|
||||
assert rel["reason"] == "使用者说:一个景区一条" and rel["cardinality"] == "one_to_one"
|
||||
assert (rel["source"], rel["status"], rel["updated_at"]) == ("human", "confirmed", LATER)
|
||||
assert stats.updated == 1 and findings[0]["confirmed"] is True
|
||||
|
||||
|
||||
def test_saving_again_replaces_the_reason_instead_of_piling_it_up():
|
||||
first = _patch(_visits(), [
|
||||
{"path": f"relations.{PARKS}", "value": {**PARK_ENDS, "cardinality": "one_to_one"},
|
||||
"reason": "使用者说:一个景区一条"},
|
||||
{"path": "columns.visitor_count.measure", "value": "stock", "reason": "使用者说:在园人数是存量"},
|
||||
{"path": "valid_filter", "value": "status = 1", "reason": "使用者说:只算有效的"},
|
||||
])
|
||||
again = _patch(first, [
|
||||
# 关联关系又改了值:换成这次的依据,旧的不叠在后面;备注照旧只是测量的说法
|
||||
{"path": f"relations.{PARKS}", "value": {**PARK_ENDS, "cardinality": "many_to_one"},
|
||||
"reason": "使用者说:一个景区有多条入园记录"},
|
||||
# 改了值、这次没写依据:旧的依据说的是旧值,去掉
|
||||
{"path": "columns.visitor_count.measure", "value": "ratio"},
|
||||
])
|
||||
rel = again["relations"][0]
|
||||
assert rel["reason"] == "使用者说:一个景区有多条入园记录" and rel["note"] == PROFILE_NOTE
|
||||
assert "reason" not in again["columns"]["visitor_count"]["measure"]
|
||||
# 撤销审阅回到推断:依据去掉;再一次保存即确认,只有这一次的依据,备注还是起草时的那句
|
||||
reset = catalog.review_item(again, "valid_filter", "reset", at=AT)
|
||||
assert reset["valid_filter"]["status"] == "proposed" and "reason" not in reset["valid_filter"]
|
||||
confirmed = _patch(reset, [{"path": "valid_filter", "value": "status = 1", "reason": "使用者说:确认无误"}])
|
||||
assert confirmed["valid_filter"]["reason"] == "使用者说:确认无误"
|
||||
assert confirmed["valid_filter"]["note"] == FILTER_NOTE
|
||||
# 推断项上不知怎么留着一句依据(手改过的数据):保存即确认时换成这一次的,不叠
|
||||
stray = {"grain": {**make_item("一张门票一行", "llm"), "reason": "旧的依据"}}
|
||||
assert _patch(stray, [{"path": "grain", "value": "一张门票一行", "reason": "新的依据"}])["grain"]["reason"] == "新的依据"
|
||||
assert "reason" not in _patch(stray, [{"path": "grain", "value": "一张门票一行"}])["grain"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 审阅、人工编辑
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_review_keeps_the_reason_until_the_review_is_undone():
|
||||
notes = {"grain": make_item("一张门票一行", "llm", "confirmed", reason="使用者说:一张票一行"),
|
||||
"label": make_item("入园记录", "human", "confirmed", reason="使用者说:就叫入园记录")}
|
||||
rejected = catalog.review_item(notes, "grain", "reject", at=AT)
|
||||
assert rejected["grain"]["status"] == "rejected" and rejected["grain"]["reason"] == "使用者说:一张票一行"
|
||||
again = catalog.review_item(rejected, "grain", "confirm", at=AT)
|
||||
assert again["grain"]["status"] == "confirmed" and again["grain"]["reason"] == "使用者说:一张票一行"
|
||||
reset = catalog.review_item(again, "grain", "reset", at=AT)
|
||||
assert reset["grain"]["status"] == "proposed" and "reason" not in reset["grain"]
|
||||
# 人工填写的项撤销就是删掉,依据一起没了
|
||||
assert "label" not in catalog.review_item(notes, "label", "reset", at=AT)
|
||||
|
||||
|
||||
def test_editing_by_hand_drops_the_reason_only_when_the_value_changes():
|
||||
"""整份提交(表详情里的编辑)不写依据:改了值的项去掉旧的依据(界面原样带回来的也不认),值没动的照旧留着库里的,
|
||||
状态改回推断等同撤销审阅、去掉;去掉的推断关系转为驳回,依据留着(同单项驳回)。"""
|
||||
rel = {"id": PARKS, **PARK_ENDS, "cardinality": "many_to_one", "coverage": None, "source": "name",
|
||||
"status": "confirmed", "reason": "使用者说:一个景区一条"}
|
||||
existing = {
|
||||
"label": make_item("入园记录", "human", "confirmed", reason="使用者说:就叫入园记录"),
|
||||
"grain": make_item("一张门票一行", "llm", "confirmed", reason="使用者说:一张票一行"),
|
||||
"kind": make_item("fact", "llm", "confirmed", reason="使用者说:是明细"),
|
||||
"relations": [rel],
|
||||
}
|
||||
submitted = {
|
||||
"label": {**existing["label"], "value": "入园流水"},
|
||||
"grain": {**existing["grain"], "reason": "界面上写不了依据"},
|
||||
"kind": {**existing["kind"], "status": "proposed"},
|
||||
"columns": {"status": {"codes": {"value": {"1": "有效"}, "reason": "新填的也不收"}}},
|
||||
}
|
||||
out = catalog.apply_human_edit(existing, submitted, table="visits", at=AT)
|
||||
assert out["label"]["value"] == "入园流水" and "reason" not in out["label"]
|
||||
assert out["grain"]["reason"] == "使用者说:一张票一行"
|
||||
assert out["kind"]["status"] == "proposed" and "reason" not in out["kind"]
|
||||
codes = out["columns"]["status"]["codes"]
|
||||
assert (codes["source"], codes["status"]) == ("human", "confirmed") and "reason" not in codes
|
||||
[kept] = out["relations"]
|
||||
assert kept["status"] == "rejected" and kept["reason"] == "使用者说:一个景区一条"
|
||||
assert catalog.validate_notes(out) == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 校验、合并、导入
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_reason_is_text_of_at_most_500_characters():
|
||||
ok = {"label": make_item("入园记录", "human", "confirmed", reason="短" * 500),
|
||||
"columns": {"status": {"codes": make_item({"1": "有效"}, "human", "confirmed", complete=True,
|
||||
reason="使用者说:1 是有效")}},
|
||||
"relations": [_rel(status="confirmed", reason="使用者说:一个景区一条")]}
|
||||
assert catalog.validate_notes(ok) == []
|
||||
bad = catalog.validate_notes({"label": {**make_item("入园记录", "human", "confirmed"), "reason": "长" * 501},
|
||||
"grain": {**make_item("一行", "human", "confirmed"), "reason": 3},
|
||||
"relations": [_rel(reason=["使用者说"])]})
|
||||
assert len(bad) == 3 and all("确认时的依据" in p for p in bad), bad
|
||||
|
||||
|
||||
def test_drafting_and_profiling_leave_the_reason_of_reviewed_items_alone():
|
||||
existing = {"label": make_item("入园记录", "llm", "confirmed", reason="使用者说:就叫入园记录"),
|
||||
"grain": make_item("一张门票一行", "llm", "rejected", reason="使用者说:一张票一行"),
|
||||
"columns": {"status": {"codes": make_item({"1": "有效"}, "profile", "confirmed",
|
||||
reason="使用者说:1 是有效")}}}
|
||||
draft = {"label": make_item("入园流水", "llm"), "grain": make_item("每次刷闸一行", "llm"),
|
||||
"kind": make_item("fact", "llm")}
|
||||
merged, _ = catalog.merge_notes(existing, draft, covered={"llm"}, at=AT, columns=["id", "status"])
|
||||
assert merged["label"] == existing["label"] and merged["grain"] == existing["grain"]
|
||||
assert "reason" not in merged["kind"]
|
||||
outcome = catalog_profile._TableOutcome(codes=[catalog_profile._CodesOutcome(
|
||||
column="status", values=[("1", 900), ("0", 100)], rows=1000, note="数据剖析:全表 1,000 行,2 个取值。",
|
||||
complete=True)])
|
||||
profiled, _, _ = catalog_profile._apply(merged, outcome, at=LATER)
|
||||
assert profiled["columns"]["status"]["codes"] == existing["columns"]["status"]["codes"]
|
||||
|
||||
|
||||
def test_importing_carries_the_reason_of_confirmed_and_rejected_items_only():
|
||||
cache = {"tables": {"visits": TABLES["visits"]}}
|
||||
file_notes = {
|
||||
"label": make_item("入园记录", "human", "confirmed", reason="使用者说:就叫入园记录"),
|
||||
"grain": make_item("一张门票一行", "llm", "rejected", reason="使用者说:一张票一行"),
|
||||
# 推断项上带着依据(文件被手改过):只填空缺,不带确认时的依据
|
||||
"description": {**make_item("每次刷闸记一行", "llm"), "reason": "手改的文件"},
|
||||
}
|
||||
plan = catalog_transfer.plan_table("visits", "visits", file_notes, {}, cache, set(), "sqlite")
|
||||
assert plan.skipped == [] and plan.add == 3
|
||||
writes = {key[1]: item for key, item in plan.writes.items()}
|
||||
assert writes["label"]["reason"] == "使用者说:就叫入园记录"
|
||||
assert writes["grain"]["reason"] == "使用者说:一张票一行"
|
||||
assert writes["description"]["value"] == "每次刷闸记一行" and "reason" not in writes["description"]
|
||||
|
||||
|
||||
def test_profiling_keeps_the_cardinality_of_a_confirmed_foreign_key():
|
||||
"""外键来的关联关系经「值不变、保存即确认」带依据确认之后,来源仍是 fk。再剖析时数据核实出的基数不同:以前 _patch_fk
|
||||
不看状态,直接改写人确认过的基数,依据还挂着,前后矛盾。现在同 _patch_confirmed,不改人定的基数、也不标「用数据核实过」,
|
||||
依据留着;测量的说法照常写进备注。没确认过的外键关系照旧按数据改基数、标核实过。"""
|
||||
fk = {k: v for k, v in _rel(source="fk", status="verified").items() if k not in ("coverage", "note")}
|
||||
saved = _patch({"relations": [fk]}, [
|
||||
{"path": f"relations.{PARKS}", "value": {**PARK_ENDS, "cardinality": "many_to_one"},
|
||||
"reason": "使用者说:一个景区有多条入园记录"},
|
||||
])
|
||||
rel = saved["relations"][0]
|
||||
assert (rel["source"], rel["status"], rel["reason"]) == ("fk", "confirmed", "使用者说:一个景区有多条入园记录")
|
||||
outcome = catalog_profile._TableOutcome(relations=[catalog_profile._RelationOutcome(
|
||||
rid=PARKS, target="parks.id", coverage=None, cardinality="one_to_one", cardinality_checked=True, holds=True,
|
||||
sample=1000, matched=1000, note="数据剖析:子表一侧每个键都只有一行。")])
|
||||
rel = catalog_profile._apply(saved, outcome, at=LATER)[0]["relations"][0]
|
||||
assert rel["cardinality"] == "many_to_one" and rel.get("cardinality_checked") is not True
|
||||
assert (rel["source"], rel["status"], rel["reason"]) == ("fk", "confirmed", "使用者说:一个景区有多条入园记录")
|
||||
assert rel["note"] == "数据剖析:子表一侧每个键都只有一行。"
|
||||
|
||||
plain = catalog_profile._apply({"relations": [fk]}, outcome, at=LATER)[0]["relations"][0]
|
||||
assert plain["cardinality"] == "one_to_one" and plain["cardinality_checked"] is True
|
||||
assert (plain["source"], plain["status"]) == ("fk", "verified")
|
||||
@@ -0,0 +1,397 @@
|
||||
"""数据目录的审阅队列、批量审阅、表被哪些运行用过、审阅进度(data/catalog_review.py)。
|
||||
|
||||
- GET /catalog/review-queue:只列推断(proposed)的项,按「使用次数权重 × 风险权重 × 来源权重」从高到低,同分按表名、
|
||||
路径;facts(有效记录条件、去重规则、业务日期、关联关系、业务主键、度量类型)/ names(中文名、说明、含义、单位、码值、
|
||||
粒度、表类型)两组;按表筛选;分页;field_label 是给人看的位置(「列 status 的码值」);reasons 写为什么排在这里
|
||||
(被几次运行用到,和 /usage 的 total 一致);table_usage 是使用次数(同目录清单);表结构里已经没有的表、列不列
|
||||
- POST /catalog/review/batch:逐表按 if_version 乐观锁,一张表冲突不影响别的表;同一张表的几条一次写入;路径不对、表不存在
|
||||
逐条报
|
||||
- GET /catalog/{table}/usage:用过这张表的运行,新的在前,取法同 catalog.table_usage
|
||||
- GET /catalog/review-progress:常用 20 张表(使用次数前 20 张,同起草的默认范围:用到的不够 20 张时按表结构的顺序
|
||||
补齐)、全部、影响数字的项各自的确认情况
|
||||
|
||||
表名、列名、业务数字都是虚构的。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.data import catalog, catalog_review
|
||||
from app.data.catalog import make_item
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import Artifact, DataSource, Run
|
||||
from app.main import app as _fastapi_app
|
||||
from tests.fixtures.sources import drop_source
|
||||
|
||||
ACTOR = {"X-Actor": quote("林晓")}
|
||||
TERMS = Path(__file__).resolve().parents[2] / "frontend" / "src" / "lib" / "terms.ts"
|
||||
|
||||
|
||||
def _col(name: str, type_: str = "INTEGER") -> dict:
|
||||
return {"name": name, "type": type_, "nullable": True, "comment": None}
|
||||
|
||||
|
||||
def _tables() -> dict:
|
||||
return {
|
||||
"orders": {"qualified": "orders", "primary_key": ["id"], "comment": None, "is_view": False,
|
||||
"foreign_keys": [], "unique": [],
|
||||
"columns": [_col("id"), _col("status"), _col("amount", "REAL"), _col("member_id"),
|
||||
_col("paid_at", "TEXT")]},
|
||||
"members": {"qualified": "members", "primary_key": ["id"], "comment": None, "is_view": False,
|
||||
"foreign_keys": [], "unique": [],
|
||||
"columns": [_col("id"), _col("nick", "TEXT"), _col("tier")]},
|
||||
"coupons": {"qualified": "coupons", "primary_key": ["id"], "comment": None, "is_view": False,
|
||||
"foreign_keys": [], "unique": [], "columns": [_col("id"), _col("face_value", "REAL")]},
|
||||
}
|
||||
|
||||
|
||||
def _member_rel(status: str = "proposed", source: str = "name") -> dict:
|
||||
return {"id": catalog.relation_id("orders", ["member_id"], "members", ["id"]), "columns": ["member_id"],
|
||||
"to_table": "members", "to_columns": ["id"], "cardinality": "many_to_one", "coverage": 0.98,
|
||||
"source": source, "status": status}
|
||||
|
||||
|
||||
ORDERS = {
|
||||
"label": make_item("订单", "llm"),
|
||||
"grain": make_item("每笔订单一行", "llm", "confirmed"),
|
||||
"valid_filter": make_item("status <> 9", "llm", note="剖析核对:排除 3.2% 的行(共 12,345 行)"),
|
||||
"kind": make_item("fact", "llm"),
|
||||
"columns": {
|
||||
"status": {"label": make_item("状态", "comment"), "codes": make_item({"1": "已支付", "9": ""}, "profile")},
|
||||
"amount": {"measure": make_item("flow", "llm"), "unit": make_item("元", "llm", "rejected")},
|
||||
# 表结构里已经没有这一列:不进队列
|
||||
"legacy_flag": {"label": make_item("旧标记", "llm")},
|
||||
},
|
||||
"relations": [_member_rel()],
|
||||
}
|
||||
MEMBERS = {"label": make_item("会员", "llm"), "keys": make_item(["id"], "name"),
|
||||
"columns": {"nick": {"label": make_item("昵称", "comment")}}}
|
||||
COUPONS = {"label": make_item("优惠券", "comment")}
|
||||
#: 表结构里已经没有这张表:不进队列
|
||||
GONE = {"label": make_item("旧表", "llm")}
|
||||
|
||||
_CREATED: list[str] = []
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def _cleanup():
|
||||
yield
|
||||
while _CREATED:
|
||||
await drop_source(_CREATED.pop())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
async with AsyncClient(transport=ASGITransport(app=_fastapi_app), base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
async def _write(sid: str, table: str, notes: dict) -> None:
|
||||
async with SessionLocal() as session:
|
||||
entry = await catalog.read_entry(session, sid, table)
|
||||
await catalog.write_entry(session, sid, table, notes, if_version=entry.version if entry else 0, actor=None)
|
||||
|
||||
|
||||
async def _use(sid: str, name: str, table_runs: dict[str, int]) -> list[str]:
|
||||
"""给每张表造几次运行查询它(每次运行一份查询快照),返回运行 id(先建的在前、开始得早)。"""
|
||||
runs: list[str] = []
|
||||
base = datetime(2026, 9, 1, tzinfo=timezone.utc)
|
||||
async with SessionLocal() as session:
|
||||
for table, n in table_runs.items():
|
||||
for _ in range(n):
|
||||
rid = uuid.uuid4().hex
|
||||
session.add(Run(id=rid, graph={}, workflow_name=f"周报-{table}", run_class="formal",
|
||||
status="succeeded", started_at=base + timedelta(hours=len(runs))))
|
||||
session.add(Artifact(id=uuid.uuid4().hex, run_id=rid, kind=catalog.QUERY_SNAPSHOT_KIND,
|
||||
meta={"source_id": sid, "source": name, "tables": [table]}))
|
||||
runs.append(rid)
|
||||
await session.commit()
|
||||
return runs
|
||||
|
||||
|
||||
async def _source(*, usage: dict[str, int] | None = None) -> str:
|
||||
name = f"rq_{uuid.uuid4().hex[:10]}"
|
||||
async with SessionLocal() as session:
|
||||
row = DataSource(name=name, kind="sqlite", database=":memory:", schema_cache={"tables": _tables()})
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
sid = row.id
|
||||
_CREATED.append(sid)
|
||||
for table, notes in (("orders", ORDERS), ("members", MEMBERS), ("coupons", COUPONS), ("gone_table", GONE)):
|
||||
await _write(sid, table, notes)
|
||||
if usage:
|
||||
await _use(sid, name, usage)
|
||||
return sid
|
||||
|
||||
|
||||
def _base(sid: str) -> str:
|
||||
return f"/api/datasources/{sid}/catalog"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 排序规则(纯函数)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_priority_multiplies_usage_risk_and_source_weights():
|
||||
p = catalog_review.priority
|
||||
# 风险:有效记录条件 > 业务主键 > 码值 > 中文名
|
||||
assert p(0, ("t", "valid_filter"), "llm") > p(0, ("t", "keys"), "llm") > p(0, ("c", "x", "codes"), "llm") \
|
||||
> p(0, ("c", "x", "label"), "llm")
|
||||
assert p(0, ("r", "r1"), "name") == p(0, ("t", "dedup"), "name") == p(0, ("t", "business_date"), "name")
|
||||
assert p(0, ("c", "x", "measure"), "llm") == p(0, ("t", "keys"), "llm")
|
||||
assert p(0, ("t", "grain"), "llm") == p(0, ("t", "kind"), "llm") == p(0, ("c", "x", "codes"), "llm")
|
||||
assert p(0, ("c", "x", "meaning"), "llm") == p(0, ("c", "x", "unit"), "llm") == p(0, ("t", "label"), "llm")
|
||||
# 来源:模型起草、命名推断比数据库注释优先
|
||||
assert p(0, ("t", "label"), "llm") == p(0, ("t", "label"), "name") > p(0, ("t", "label"), "comment")
|
||||
# 使用次数:用得多的在前,但按对数增长,不压过风险
|
||||
assert p(37, ("t", "label"), "llm") > p(1, ("t", "label"), "llm") > p(0, ("t", "label"), "llm")
|
||||
assert p(37, ("t", "label"), "llm") == pytest.approx(
|
||||
p(0, ("t", "label"), "llm") * p(37, ("t", "label"), "comment") / p(0, ("t", "label"), "comment"))
|
||||
|
||||
|
||||
def test_source_labels_follow_the_ui():
|
||||
text = TERMS.read_text(encoding="utf-8")
|
||||
block = re.search(r"export const CATALOG_SOURCE_LABEL\b[^=]*=\s*\{(.*?)\n\}", text, re.S)
|
||||
assert block
|
||||
ui = dict(re.findall(r"(\w+):\s*'([^']+)'", block.group(1)))
|
||||
assert ui == catalog_review.SOURCE_LABEL
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 队列
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_queue_lists_only_proposed_items_by_priority(client):
|
||||
sid = await _source(usage={"orders": 37, "members": 2})
|
||||
r = await client.get(f"{_base(sid)}/review-queue")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
items = body["items"]
|
||||
paths = [(i["table_name"], i["path"]) for i in items]
|
||||
# 已确认、已驳回的不列;表结构里没有的表、列不列
|
||||
assert ("orders", "grain") not in paths and ("orders", "columns.amount.unit") not in paths
|
||||
assert not any(t == "gone_table" for t, _ in paths)
|
||||
assert ("orders", "columns.legacy_flag.label") not in paths
|
||||
assert body["total"] == len(items) == 11
|
||||
assert all(i["status"] == "proposed" for i in items)
|
||||
# 从高到低,同分按表名、路径
|
||||
keys = [(-i["priority"], i["table_name"], i["path"]) for i in items]
|
||||
assert keys == sorted(keys)
|
||||
first = items[0]
|
||||
rel_id = _member_rel()["id"]
|
||||
assert (first["table_name"], first["path"]) in {("orders", "valid_filter"), ("orders", f"relations.{rel_id}")}
|
||||
vf = next(i for i in items if i["path"] == "valid_filter")
|
||||
assert vf["field"] == "valid_filter" and vf["field_label"] == "表的有效记录条件"
|
||||
assert vf["value"] == "status <> 9" and vf["display"] == "status <> 9"
|
||||
assert vf["source"] == "llm" and vf["note"] == "剖析核对:排除 3.2% 的行(共 12,345 行)"
|
||||
assert vf["table_usage"] == 37 and vf["version"] == 1
|
||||
assert vf["reasons"] == ["常用表(被 37 次运行用到)", "影响数字的项(有效记录条件)", "模型起草,未确认"]
|
||||
# 常用表上的中文名排在没人用的表上的中文名前面;同一张表上影响数字的项排在中文名前面
|
||||
order = [(i["table_name"], i["path"]) for i in items]
|
||||
assert order.index(("orders", "label")) < order.index(("coupons", "label"))
|
||||
assert order.index(("members", "keys")) < order.index(("members", "label"))
|
||||
|
||||
|
||||
async def test_queue_values_are_displayed_in_ui_words(client):
|
||||
sid = await _source()
|
||||
items = {(i["table_name"], i["path"]): i for i in (await client.get(f"{_base(sid)}/review-queue")).json()["items"]}
|
||||
rel = items[("orders", f"relations.{_member_rel()['id']}")]
|
||||
assert rel["field"] == "relation" and rel["field_label"] == "指向 members 的关联关系"
|
||||
assert rel["value"] == {"columns": ["member_id"], "to_table": "members", "to_columns": ["id"],
|
||||
"cardinality": "many_to_one", "coverage": 0.98}
|
||||
assert rel["display"] == "member_id → members.id(多对一,覆盖率 98%)"
|
||||
assert rel["reasons"][-1] == "命名推断,未确认"
|
||||
kind = items[("orders", "kind")]
|
||||
assert kind["display"] == "明细表" and kind["value"] == "fact"
|
||||
codes = items[("orders", "columns.status.codes")]
|
||||
assert codes["field"] == "codes" and codes["field_label"] == "列 status 的码值" and codes["column"] == "status"
|
||||
assert codes["display"] == "1=已支付、9=含义待填写"
|
||||
assert "1 个码值的含义待填写" in codes["reasons"] and "数据剖析,未确认" in codes["reasons"]
|
||||
assert items[("orders", "columns.amount.measure")]["display"] == "可累加"
|
||||
assert items[("members", "keys")]["display"] == "id" and items[("members", "keys")]["field_label"] == "表的业务主键"
|
||||
assert items[("orders", "columns.amount.measure")]["field_label"] == "列 amount 的度量类型"
|
||||
assert items[("members", "label")]["reasons"] == ["名称和说明(中文名)", "模型起草,未确认"]
|
||||
assert items[("coupons", "label")]["reasons"] == ["名称和说明(中文名)", "数据库注释,未确认"]
|
||||
|
||||
|
||||
async def test_queue_reasons_count_runs_like_the_usage_list(client):
|
||||
"""同一次运行里查了几次这张表:原因写「被几次运行用到」(和 /usage 的 total 一样),table_usage 是使用次数(和目录清单的
|
||||
「使用 N 次」一样),两个数不一样时原因里两个都写;优先级按使用次数(同清单的排序)。"""
|
||||
sid = await _source()
|
||||
async with SessionLocal() as session:
|
||||
name = (await session.get(DataSource, sid)).name
|
||||
runs = await _use(sid, name, {"orders": 2, "members": 1})
|
||||
async with SessionLocal() as session:
|
||||
for _ in range(3):
|
||||
session.add(Artifact(id=uuid.uuid4().hex, run_id=runs[0], kind=catalog.QUERY_SNAPSHOT_KIND,
|
||||
meta={"source_id": sid, "source": name, "tables": ["orders"]}))
|
||||
await session.commit()
|
||||
usage = await catalog.table_usage(session, await session.get(DataSource, sid))
|
||||
assert usage["orders"] == 5
|
||||
items = {(i["table_name"], i["path"]): i for i in (await client.get(f"{_base(sid)}/review-queue")).json()["items"]}
|
||||
vf = items[("orders", "valid_filter")]
|
||||
assert vf["table_usage"] == 5
|
||||
assert vf["reasons"][0] == "常用表(被 2 次运行用到,使用 5 次)"
|
||||
assert items[("members", "label")]["reasons"][0] == "常用表(被 1 次运行用到)"
|
||||
assert vf["priority"] == round(catalog_review.priority(5, ("t", "valid_filter"), "llm"), 4)
|
||||
assert (await client.get(f"{_base(sid)}/orders/usage")).json()["total"] == 2
|
||||
|
||||
|
||||
async def test_queue_filters_by_kind_and_table_and_pages(client):
|
||||
sid = await _source(usage={"orders": 3})
|
||||
facts = (await client.get(f"{_base(sid)}/review-queue", params={"kind": "facts"})).json()
|
||||
assert {i["field"] for i in facts["items"]} == {"valid_filter", "relation", "measure", "keys"}
|
||||
names = (await client.get(f"{_base(sid)}/review-queue", params={"kind": "names"})).json()
|
||||
assert {i["field"] for i in names["items"]} == {"label", "codes", "kind"}
|
||||
assert facts["total"] + names["total"] == 11
|
||||
only = (await client.get(f"{_base(sid)}/review-queue", params={"table": "MEMBERS"})).json()
|
||||
assert {i["table_name"] for i in only["items"]} == {"members"} and only["total"] == 3
|
||||
everything = (await client.get(f"{_base(sid)}/review-queue")).json()["items"]
|
||||
page = (await client.get(f"{_base(sid)}/review-queue", params={"limit": 4, "offset": 4})).json()
|
||||
assert page["total"] == 11 and page["items"] == everything[4:8]
|
||||
bad = await client.get(f"{_base(sid)}/review-queue", params={"kind": "other"})
|
||||
assert bad.status_code == 422
|
||||
assert (await client.get(f"{_base(sid)}/review-queue", params={"limit": 0})).status_code == 422
|
||||
missing = await client.get("/api/datasources/nope/catalog/review-queue")
|
||||
assert missing.status_code == 404
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 批量审阅
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_batch_review_writes_each_table_once_and_isolates_conflicts(client):
|
||||
sid = await _source()
|
||||
rel_path = f"relations.{_member_rel()['id']}"
|
||||
body = {"items": [
|
||||
{"table_name": "orders", "path": "valid_filter", "action": "confirm", "if_version": 1},
|
||||
{"table_name": "orders", "path": rel_path, "action": "reject", "if_version": 1},
|
||||
{"table_name": "orders", "path": "columns.nothing.label", "action": "confirm", "if_version": 1},
|
||||
{"table_name": "members", "path": "label", "action": "confirm", "if_version": 7}, # 版本不对
|
||||
{"table_name": "coupons", "path": "label", "action": "reject", "if_version": 1},
|
||||
{"table_name": "ghost", "path": "label", "action": "confirm", "if_version": 0},
|
||||
]}
|
||||
r = await client.post(f"{_base(sid)}/review/batch", json=body, headers=ACTOR)
|
||||
assert r.status_code == 200, r.text
|
||||
results = r.json()["results"]
|
||||
assert [(x["table_name"], x["path"], x["ok"]) for x in results] == [
|
||||
("orders", "valid_filter", True), ("orders", rel_path, True), ("orders", "columns.nothing.label", False),
|
||||
("members", "label", False), ("coupons", "label", True), ("ghost", "label", False)]
|
||||
# 同一张表的两条一次写进去:版本只加 1
|
||||
assert results[0]["version"] == results[1]["version"] == 2
|
||||
assert results[2]["code"] == "catalog_path_invalid" and "找不到" in results[2]["error"]
|
||||
assert results[2]["version"] is None
|
||||
assert results[3]["code"] == "catalog_conflict" and "重新载入" in results[3]["error"]
|
||||
assert results[4]["version"] == 2 and results[4]["error"] is None and results[4]["code"] is None
|
||||
assert results[5]["code"] == "table_missing"
|
||||
async with SessionLocal() as session:
|
||||
orders = await catalog.read_entry(session, sid, "orders")
|
||||
members = await catalog.read_entry(session, sid, "members")
|
||||
coupons = await catalog.read_entry(session, sid, "coupons")
|
||||
assert orders.version == 2 and orders.updated_by == "林晓"
|
||||
assert orders.notes["valid_filter"]["status"] == "confirmed"
|
||||
assert orders.notes["relations"][0]["status"] == "rejected"
|
||||
assert members.version == 1 and members.notes["label"]["status"] == "proposed"
|
||||
assert coupons.notes["label"]["status"] == "rejected"
|
||||
# 撤销审阅:回到来源的初始状态
|
||||
again = await client.post(f"{_base(sid)}/review/batch", json={"items": [
|
||||
{"table_name": "orders", "path": "valid_filter", "action": "reset", "if_version": 2}]})
|
||||
assert again.json()["results"][0]["ok"] is True
|
||||
async with SessionLocal() as session:
|
||||
assert (await catalog.read_entry(session, sid, "orders")).notes["valid_filter"]["status"] == "proposed"
|
||||
|
||||
|
||||
async def test_batch_review_limits(client):
|
||||
sid = await _source()
|
||||
many = [{"table_name": "orders", "path": "label", "action": "confirm", "if_version": 1}] * 201
|
||||
assert (await client.post(f"{_base(sid)}/review/batch", json={"items": many})).status_code == 422
|
||||
assert (await client.post(f"{_base(sid)}/review/batch", json={"items": []})).status_code == 422
|
||||
wrong = [{"table_name": "orders", "path": "label", "action": "approve", "if_version": 1}]
|
||||
assert (await client.post(f"{_base(sid)}/review/batch", json={"items": wrong})).status_code == 422
|
||||
ok = [{"table_name": "orders", "path": "label", "action": "confirm", "if_version": 1}] * 200
|
||||
r = await client.post(f"{_base(sid)}/review/batch", json={"items": ok})
|
||||
assert r.status_code == 200 and all(x["ok"] for x in r.json()["results"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 被哪些运行用过
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_table_usage_lists_runs_newest_first(client):
|
||||
sid = await _source()
|
||||
async with SessionLocal() as session:
|
||||
name = (await session.get(DataSource, sid)).name
|
||||
runs = await _use(sid, name, {"orders": 3, "members": 1})
|
||||
# 同一次运行查了两次 orders:只算一次运行
|
||||
async with SessionLocal() as session:
|
||||
session.add(Artifact(id=uuid.uuid4().hex, run_id=runs[0], kind=catalog.QUERY_SNAPSHOT_KIND,
|
||||
meta={"source_id": sid, "source": name, "tables": ["orders", "members"]}))
|
||||
# 别的数据源的同名表不算
|
||||
other = uuid.uuid4().hex
|
||||
session.add(Run(id=other, graph={}, workflow_name="别处", status="succeeded"))
|
||||
session.add(Artifact(id=uuid.uuid4().hex, run_id=other, kind=catalog.QUERY_SNAPSHOT_KIND,
|
||||
meta={"source_id": "elsewhere", "source": "elsewhere", "tables": ["orders"]}))
|
||||
await session.commit()
|
||||
r = await client.get(f"{_base(sid)}/orders/usage")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["total"] == 3
|
||||
assert [x["run_id"] for x in body["runs"]] == [runs[2], runs[1], runs[0]]
|
||||
first = body["runs"][0]
|
||||
assert first["workflow_name"] == "周报-orders" and first["run_class"] == "formal"
|
||||
assert first["status"] == "succeeded" and first["started_at"].startswith("2026-09-01T02:00")
|
||||
assert set(first) == {"run_id", "workflow_id", "workflow_name", "run_class", "status", "started_at"}
|
||||
limited = (await client.get(f"{_base(sid)}/orders/usage", params={"limit": 1})).json()
|
||||
assert limited["total"] == 3 and len(limited["runs"]) == 1
|
||||
members = (await client.get(f"{_base(sid)}/members/usage")).json()
|
||||
assert members["total"] == 2
|
||||
assert (await client.get(f"{_base(sid)}/ghost/usage")).status_code == 404
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 进度
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_review_progress_counts_top_tables_all_and_facts(client, monkeypatch):
|
||||
sid = await _source(usage={"orders": 5})
|
||||
r = await client.get(f"{_base(sid)}/review-progress")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["top_n"] == 20
|
||||
# 表结构里只有 3 张表:都算常用表(使用次数前 20 张);表结构里没有的表、列不算
|
||||
assert body["top"] == {"tables": 3, "items_total": 13, "items_confirmed": 1}
|
||||
assert body["all"] == {"items_total": 13, "items_confirmed": 1, "items_proposed": 11, "items_rejected": 1}
|
||||
# 影响数字的项:有效记录条件、关联关系、度量类型、业务主键
|
||||
assert body["facts"] == {"proposed": 4, "confirmed": 0}
|
||||
await client.post(f"{_base(sid)}/review/batch", json={"items": [
|
||||
{"table_name": "orders", "path": "valid_filter", "action": "confirm", "if_version": 1}]})
|
||||
after = (await client.get(f"{_base(sid)}/review-progress")).json()
|
||||
assert after["top"]["items_confirmed"] == 2 and after["facts"] == {"proposed": 3, "confirmed": 1}
|
||||
# 只取前 2 张:用得多的在前,没人用的按表结构的顺序补(同起草的默认范围)
|
||||
monkeypatch.setattr(catalog_review, "TOP_N", 2)
|
||||
used = await _source(usage={"coupons": 1})
|
||||
top = (await client.get(f"{_base(used)}/review-progress")).json()["top"]
|
||||
assert top == {"tables": 2, "items_total": 1 + 9, "items_confirmed": 1}
|
||||
|
||||
|
||||
def test_top_tables_are_the_draft_default_scope():
|
||||
"""常用表和起草对话框「使用次数最多的 20 张表」是同一批:使用次数从多到少,一样多(包括都没用过)的按表结构里的顺序。"""
|
||||
tables = {f"t{i:02d}": {} for i in range(30)}
|
||||
usage = {"t29": 4, "t05": 4, "t17": 9}
|
||||
top = catalog_review.top_tables(tables, usage)
|
||||
assert catalog_review.TOP_N == catalog.DRAFT_DEFAULT_TABLES == 20
|
||||
assert top[:3] == ["t17", "t05", "t29"] and len(top) == 20
|
||||
assert top[3:] == [n for n in tables if n not in usage][:17]
|
||||
assert catalog_review.top_tables({"a": {}, "b": {}}, {}) == ["a", "b"]
|
||||
@@ -0,0 +1,432 @@
|
||||
"""数据目录的导出导入(data/catalog_transfer.py):本机整理好的目录带到部署实例上。
|
||||
|
||||
- GET /catalog/export:附件「<数据源名>-数据目录.json」,{format: agentlab-catalog, version: 1, exported_at,
|
||||
source: {name, kind}, guidance, tables: {表名: {notes, version}}},含全部状态的项(表结构里已经没有的表也带)
|
||||
- POST /catalog/import/preview:只读,报告每张对上的表新增、更新、保留、冲突、跳过了什么
|
||||
- POST /catalog/import:同样的报告,加每张表写入后的版本。规则:
|
||||
- 表按名字对(忽略大小写;带 schema 前缀的对不上时去掉前缀再对),对不上的列在 unmatched_tables
|
||||
- 文件里已确认、已驳回的项照带(来源、状态不变);目标里已确认、已驳回的项和文件不一样时保留目标的、记冲突
|
||||
- 文件里推断、有确证的项只填目标的空缺,不覆盖目标已有的
|
||||
- 表结构里没有的列、关系两端对不上的跳过并写原因;目标数据源遮罩的列,码值不导入;有效记录条件用到的列(按目标的方言
|
||||
解析)不在目标的表结构里、引用别的表的列、带子查询的跳过(人工写的条件不限函数)
|
||||
- 逐表乐观锁,一张表写不进去不影响别的;有排队或进行中的起草任务时 409 catalog_draft_busy;文件格式不对 422
|
||||
- 起草要求:勾了导入、目标还没有时才写;目标已有的不覆盖
|
||||
|
||||
表名、列名、业务数字都是虚构的。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import uuid
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.data import catalog, catalog_jobs, catalog_transfer
|
||||
from app.data.catalog import make_item
|
||||
from app.data.engine import CATALOG_DRAFT_GUIDANCE_OPTION
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import CatalogDraftJob, DataSource
|
||||
from app.main import app as _fastapi_app
|
||||
from tests.fixtures.sources import drop_source
|
||||
|
||||
ACTOR = {"X-Actor": quote("沈岚")}
|
||||
|
||||
|
||||
def _col(name: str, type_: str = "INTEGER") -> dict:
|
||||
return {"name": name, "type": type_, "nullable": True, "comment": None}
|
||||
|
||||
|
||||
def _tables(*, upper: bool = False) -> dict:
|
||||
"""两边的表结构:目标那边(upper)表名、列名写法不同,还少了一列 tier。"""
|
||||
def n(name: str) -> str:
|
||||
return name.upper() if upper else name
|
||||
|
||||
members_cols = [_col(n("id")), _col(n("nick"), "TEXT")] + ([] if upper else [_col("tier")])
|
||||
return {
|
||||
n("orders"): {"qualified": n("orders"), "primary_key": [n("id")], "comment": None, "is_view": False,
|
||||
"foreign_keys": [], "unique": [],
|
||||
"columns": [_col(n("id")), _col(n("status")), _col(n("amount"), "REAL"), _col(n("member_id")),
|
||||
_col(n("buyer_phone"), "TEXT")]},
|
||||
n("members"): {"qualified": n("members"), "primary_key": [n("id")], "comment": None, "is_view": False,
|
||||
"foreign_keys": [], "unique": [], "columns": members_cols},
|
||||
n("refunds"): {"qualified": n("refunds"), "primary_key": [n("id")], "comment": None, "is_view": False,
|
||||
"foreign_keys": [], "unique": [], "columns": [_col(n("id")), _col(n("order_id"))]},
|
||||
}
|
||||
|
||||
|
||||
def _rel(to_table: str, columns: list[str], to_columns: list[str], *, status: str, source: str = "name",
|
||||
table: str = "orders") -> dict:
|
||||
return {"id": catalog.relation_id(table, columns, to_table, to_columns), "columns": columns,
|
||||
"to_table": to_table, "to_columns": to_columns, "cardinality": "many_to_one", "coverage": None,
|
||||
"source": source, "status": status}
|
||||
|
||||
|
||||
#: 本机(源)的目录
|
||||
SOURCE_ORDERS = {
|
||||
"label": make_item("订单", "human", "confirmed", at="2026-10-01T08:00:00+00:00"),
|
||||
"grain": make_item("每笔订单一行", "llm"),
|
||||
# 确认、驳回过的项带着保存提案时记下的依据:导出、导入照带
|
||||
"valid_filter": make_item("status <> 9", "human", "confirmed", reason="使用者说:9 是作废,统计时排除"),
|
||||
"dedup": make_item("同一订单号取最后一次", "llm", "rejected", reason="使用者说:同一订单号只有一行"),
|
||||
"kind": make_item("fact", "llm", "confirmed"),
|
||||
"columns": {
|
||||
"status": {"codes": make_item({"1": "已支付", "9": "作废"}, "human", "confirmed", complete=True),
|
||||
"label": make_item("状态", "llm")},
|
||||
"amount": {"unit": make_item("元", "human", "confirmed"), "measure": make_item("flow", "llm")},
|
||||
"buyer_phone": {"codes": make_item({"13900000001": "测试号"}, "human", "confirmed"),
|
||||
"label": make_item("联系电话", "human", "confirmed")},
|
||||
# 目标表结构里没有这一列
|
||||
"gift_flag": {"label": make_item("赠品标记", "human", "confirmed")},
|
||||
},
|
||||
"relations": [
|
||||
_rel("members", ["member_id"], ["id"], status="confirmed"),
|
||||
# 目标那边没有这张表
|
||||
_rel("stores", ["member_id"], ["id"], status="rejected"),
|
||||
],
|
||||
}
|
||||
SOURCE_MEMBERS = {"label": make_item("会员", "llm"), "keys": make_item(["tier"], "human", "confirmed"),
|
||||
"columns": {"nick": {"label": make_item("昵称", "llm", "verified")}}}
|
||||
#: 目标那边没有的表
|
||||
SOURCE_GHOST = {"label": make_item("旧表", "human", "confirmed")}
|
||||
|
||||
#: 目标(部署实例)上已有的目录
|
||||
TARGET_ORDERS = {
|
||||
"label": make_item("订单表", "human", "confirmed"), # 和文件不一样、都确认过:冲突,保留目标
|
||||
"grain": make_item("一行一单", "llm"), # 文件里是推断:保留目标
|
||||
"valid_filter": make_item("STATUS <> 9", "llm"), # 目标是推断、文件确认过:更新
|
||||
"kind": make_item("fact", "human", "confirmed"), # 一样:保留
|
||||
"columns": {"STATUS": {"label": make_item("状态码", "comment")}},
|
||||
}
|
||||
|
||||
_CREATED: list[str] = []
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def _cleanup():
|
||||
yield
|
||||
while _CREATED:
|
||||
await drop_source(_CREATED.pop())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
async with AsyncClient(transport=ASGITransport(app=_fastapi_app), base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
async def _write(sid: str, table: str, notes: dict) -> None:
|
||||
async with SessionLocal() as session:
|
||||
entry = await catalog.read_entry(session, sid, table)
|
||||
await catalog.write_entry(session, sid, table, notes, if_version=entry.version if entry else 0, actor=None)
|
||||
|
||||
|
||||
async def _source(name: str, tables: dict, entries: dict[str, dict], **kw) -> str:
|
||||
async with SessionLocal() as session:
|
||||
row = DataSource(name=name, kind="sqlite", database=":memory:", schema_cache={"tables": tables}, **kw)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
sid = row.id
|
||||
_CREATED.append(sid)
|
||||
for table, notes in entries.items():
|
||||
await _write(sid, table, notes)
|
||||
return sid
|
||||
|
||||
|
||||
async def _pair() -> tuple[str, str]:
|
||||
local = await _source(f"本机库_{uuid.uuid4().hex[:6]}", _tables(),
|
||||
{"orders": SOURCE_ORDERS, "members": SOURCE_MEMBERS, "ghost_table": SOURCE_GHOST},
|
||||
options={CATALOG_DRAFT_GUIDANCE_OPTION: "金额一律按元"})
|
||||
remote = await _source(f"deploy_{uuid.uuid4().hex[:6]}", _tables(upper=True), {"ORDERS": TARGET_ORDERS},
|
||||
options={"mask_columns": "buyer_phone"})
|
||||
return local, remote
|
||||
|
||||
|
||||
def _base(sid: str) -> str:
|
||||
return f"/api/datasources/{sid}/catalog"
|
||||
|
||||
|
||||
async def _export(client, sid: str) -> dict:
|
||||
r = await client.get(f"{_base(sid)}/export")
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 导出
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_export_is_a_json_attachment_with_every_item(client):
|
||||
local, _ = await _pair()
|
||||
r = await client.get(f"{_base(local)}/export")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.headers["content-type"].startswith("application/json")
|
||||
disposition = r.headers["content-disposition"]
|
||||
assert disposition.startswith("attachment;") and "filename=\"agentlab-catalog-" in disposition
|
||||
async with SessionLocal() as session:
|
||||
name = (await session.get(DataSource, local)).name
|
||||
assert unquote(disposition.split("filename*=UTF-8''", 1)[1]) == f"{name}-数据目录.json"
|
||||
body = r.json()
|
||||
assert body["format"] == "agentlab-catalog" and body["version"] == 1
|
||||
assert body["exported_at"] and body["source"] == {"name": name, "kind": "sqlite"}
|
||||
assert body["guidance"] == "金额一律按元"
|
||||
assert set(body["tables"]) == {"orders", "members", "ghost_table"}
|
||||
assert body["tables"]["orders"]["version"] == 1
|
||||
# 含全部状态的项:驳回的也带
|
||||
assert body["tables"]["orders"]["notes"]["dedup"]["status"] == "rejected"
|
||||
assert body["tables"]["orders"]["notes"] == SOURCE_ORDERS
|
||||
assert body["tables"]["ghost_table"] == {"notes": SOURCE_GHOST, "version": 1}
|
||||
|
||||
|
||||
def test_attachment_name_drops_characters_files_cannot_have():
|
||||
assert catalog_transfer.attachment_name('景区/客流:库?') == "景区_客流_库_-数据目录.json"
|
||||
assert catalog_transfer.attachment_name(" ") == "数据源-数据目录.json"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 预览与导入
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _by(report: dict) -> dict[str, dict]:
|
||||
return {t["matched_as"]: t for t in report["tables"]}
|
||||
|
||||
|
||||
async def test_preview_reports_and_writes_nothing(client):
|
||||
local, remote = await _pair()
|
||||
content = await _export(client, local)
|
||||
r = await client.post(f"{_base(remote)}/import/preview", json={"content": content, "include_guidance": True})
|
||||
assert r.status_code == 200, r.text
|
||||
report = r.json()
|
||||
assert report["unmatched_tables"] == ["ghost_table"]
|
||||
tables = _by(report)
|
||||
assert set(tables) == {"ORDERS", "MEMBERS"}
|
||||
orders = tables["ORDERS"]
|
||||
assert orders["table_name"] == "orders"
|
||||
conflicts = {c["path"]: c for c in orders["conflicts"]}
|
||||
assert set(conflicts) == {"label"}
|
||||
label = conflicts["label"]
|
||||
assert label["label"] == "表的中文名" and label["file_value"] == "订单" and label["target_value"] == "订单表"
|
||||
assert "保留" in label["why"]
|
||||
skipped = {s["path"]: s["why"] for s in orders["skipped"]}
|
||||
assert "columns.gift_flag.label" in skipped and "表结构里没有列 gift_flag" in skipped["columns.gift_flag.label"]
|
||||
assert "遮罩" in skipped["columns.buyer_phone.codes"]
|
||||
stores = next(p for p in skipped if p.startswith("relations."))
|
||||
assert "stores" in skipped[stores]
|
||||
# 新增:dedup(驳回的照带)、status 码值、amount 两项、联系电话的中文名、关联关系;更新:有效记录条件
|
||||
# 保留:粒度(文件里是推断,目标已有)、表类型(一样)、STATUS 的中文名(文件里是推断,目标已有)
|
||||
assert (orders["add"], orders["update"], orders["keep"]) == (6, 1, 3)
|
||||
members = tables["MEMBERS"]
|
||||
# 业务主键指着的列 tier 目标那边没有:跳过;会员的中文名和昵称填空缺
|
||||
assert (members["add"], members["update"], members["keep"]) == (2, 0, 0)
|
||||
assert [s["path"] for s in members["skipped"]] == ["keys"]
|
||||
assert report["totals"] == {"tables": 2, "add": 8, "update": 1, "keep": 3, "conflicts": 1,
|
||||
"skipped": len(orders["skipped"]) + 1}
|
||||
assert report["guidance"] == {"file": "金额一律按元", "target": None, "action": "replace"}
|
||||
async with SessionLocal() as session:
|
||||
assert (await catalog.read_entry(session, remote, "ORDERS")).version == 1
|
||||
assert await catalog.read_entry(session, remote, "MEMBERS") is None
|
||||
assert catalog_jobs.guidance_of(await session.get(DataSource, remote)) is None
|
||||
|
||||
|
||||
async def test_import_writes_by_the_rules_and_reports_versions(client):
|
||||
local, remote = await _pair()
|
||||
content = await _export(client, local)
|
||||
r = await client.post(f"{_base(remote)}/import", json={"content": content, "include_guidance": True},
|
||||
headers=ACTOR)
|
||||
assert r.status_code == 200, r.text
|
||||
report = r.json()
|
||||
tables = _by(report)
|
||||
assert tables["ORDERS"]["version"] == 2 and tables["MEMBERS"]["version"] == 1
|
||||
assert tables["ORDERS"]["error"] is None
|
||||
assert report["guidance"]["action"] == "replace"
|
||||
async with SessionLocal() as session:
|
||||
orders = await catalog.read_entry(session, remote, "ORDERS")
|
||||
members = await catalog.read_entry(session, remote, "MEMBERS")
|
||||
row = await session.get(DataSource, remote)
|
||||
notes = orders.notes
|
||||
assert orders.updated_by == "沈岚"
|
||||
assert notes["label"]["value"] == "订单表" # 冲突:保留目标的
|
||||
assert notes["grain"]["value"] == "一行一单" # 推断只填空缺
|
||||
assert notes["valid_filter"] == {**SOURCE_ORDERS["valid_filter"]} # 照带:来源、状态、依据不变
|
||||
assert notes["dedup"]["status"] == "rejected" and notes["dedup"]["source"] == "llm"
|
||||
assert notes["dedup"]["reason"] == "使用者说:同一订单号只有一行"
|
||||
codes = notes["columns"]["STATUS"]["codes"]
|
||||
assert codes["value"] == {"1": "已支付", "9": "作废"} and codes["complete"] is True
|
||||
assert notes["columns"]["STATUS"]["label"]["value"] == "状态码"
|
||||
assert notes["columns"]["AMOUNT"]["measure"] == SOURCE_ORDERS["columns"]["amount"]["measure"]
|
||||
# 遮罩列:中文名照带,码值不导入
|
||||
assert notes["columns"]["BUYER_PHONE"] == {"label": SOURCE_ORDERS["columns"]["buyer_phone"]["label"]}
|
||||
assert "GIFT_FLAG" not in notes["columns"] and "gift_flag" not in notes["columns"]
|
||||
[rel] = notes["relations"]
|
||||
assert rel["id"] == catalog.relation_id("ORDERS", ["MEMBER_ID"], "MEMBERS", ["ID"])
|
||||
assert (rel["columns"], rel["to_table"], rel["to_columns"]) == (["MEMBER_ID"], "MEMBERS", ["ID"])
|
||||
assert rel["status"] == "confirmed" and rel["source"] == "name"
|
||||
assert catalog.validate_notes(notes) == []
|
||||
assert members.notes["label"]["status"] == "proposed" and "keys" not in members.notes
|
||||
assert members.notes["columns"]["NICK"]["label"]["status"] == "verified"
|
||||
assert catalog_jobs.guidance_of(row) == "金额一律按元"
|
||||
# 再导一次:都对上了,什么都不写;目标已经有起草要求,不覆盖
|
||||
again = (await client.post(f"{_base(remote)}/import", json={"content": content, "include_guidance": True})).json()
|
||||
assert again["totals"]["add"] == again["totals"]["update"] == 0
|
||||
assert _by(again)["ORDERS"]["version"] == 2
|
||||
assert again["guidance"] == {"file": "金额一律按元", "target": "金额一律按元", "action": "keep"}
|
||||
|
||||
|
||||
async def test_target_rejection_beats_file_confirmation_and_guidance_is_optional(client):
|
||||
local, remote = await _pair()
|
||||
await _write(remote, "MEMBERS", {"label": make_item("会员", "llm", "rejected")})
|
||||
async with SessionLocal() as session:
|
||||
row = await session.get(DataSource, remote)
|
||||
await catalog_jobs.save_guidance(session, row, "部署实例自己的要求")
|
||||
content = await _export(client, local)
|
||||
content["tables"]["members"]["notes"]["label"] = make_item("会员", "human", "confirmed")
|
||||
report = (await client.post(f"{_base(remote)}/import",
|
||||
json={"content": content, "include_guidance": False})).json()
|
||||
members = _by(report)["MEMBERS"]
|
||||
[conflict] = members["conflicts"]
|
||||
assert conflict["path"] == "label" and conflict["target_status"] == "rejected"
|
||||
assert conflict["file_status"] == "confirmed" and "驳回" in conflict["why"]
|
||||
assert report["guidance"] == {"file": "金额一律按元", "target": "部署实例自己的要求", "action": "none"}
|
||||
async with SessionLocal() as session:
|
||||
entry = await catalog.read_entry(session, remote, "MEMBERS")
|
||||
assert entry.notes["label"]["status"] == "rejected"
|
||||
assert catalog_jobs.guidance_of(await session.get(DataSource, remote)) == "部署实例自己的要求"
|
||||
|
||||
|
||||
async def test_one_table_failing_does_not_stop_the_others(client, monkeypatch):
|
||||
local, remote = await _pair()
|
||||
content = await _export(client, local)
|
||||
real = catalog.write_entry
|
||||
|
||||
async def flaky(session, source_id, table, notes, **kw):
|
||||
if table == "ORDERS":
|
||||
raise catalog.CatalogConflict(table, 99)
|
||||
return await real(session, source_id, table, notes, **kw)
|
||||
|
||||
monkeypatch.setattr(catalog, "write_entry", flaky)
|
||||
report = (await client.post(f"{_base(remote)}/import", json={"content": content})).json()
|
||||
tables = _by(report)
|
||||
assert tables["ORDERS"]["error"] and "稍后" in tables["ORDERS"]["error"]
|
||||
assert tables["ORDERS"]["add"] == tables["ORDERS"]["update"] == 0
|
||||
assert tables["MEMBERS"]["error"] is None and tables["MEMBERS"]["version"] == 1
|
||||
|
||||
|
||||
async def test_import_waits_for_draft_jobs_and_rejects_bad_files(client):
|
||||
local, remote = await _pair()
|
||||
content = await _export(client, local)
|
||||
async with SessionLocal() as session:
|
||||
session.add(CatalogDraftJob(id=uuid.uuid4().hex, source_id=remote, status="running"))
|
||||
await session.commit()
|
||||
busy = await client.post(f"{_base(remote)}/import", json={"content": content})
|
||||
assert busy.status_code == 409 and busy.json()["code"] == "catalog_draft_busy"
|
||||
# 预览是只读的,照常给
|
||||
assert (await client.post(f"{_base(remote)}/import/preview", json={"content": content})).status_code == 200
|
||||
bad = [
|
||||
"不是对象",
|
||||
{**content, "format": "something-else"},
|
||||
{**content, "version": 2},
|
||||
{**content, "tables": []},
|
||||
{**content, "tables": {"orders": {"notes": "x"}}},
|
||||
{**content, "guidance": 3},
|
||||
]
|
||||
for body in bad:
|
||||
r = await client.post(f"{_base(local)}/import/preview", json={"content": body})
|
||||
assert r.status_code == 422, body
|
||||
assert any("一" <= ch <= "鿿" for ch in r.json()["detail"])
|
||||
# 界面直接交来文件原文也认;不是 JSON 的 422
|
||||
as_text = await client.post(f"{_base(local)}/import/preview", json={"content": json.dumps(content)})
|
||||
assert as_text.status_code == 200 and as_text.json()["totals"]["tables"] == 2
|
||||
not_json = await client.post(f"{_base(local)}/import/preview", json={"content": "{不是 JSON"})
|
||||
assert not_json.status_code == 422 and "JSON" in not_json.json()["detail"]
|
||||
too_long = {**content, "guidance": "长" * (catalog_jobs.GUIDANCE_MAX + 1)}
|
||||
r = await client.post(f"{_base(local)}/import/preview", json={"content": too_long, "include_guidance": True})
|
||||
assert r.status_code == 422 and "起草要求" in r.json()["detail"]
|
||||
|
||||
|
||||
async def test_invalid_items_are_skipped_not_fatal(client):
|
||||
local, remote = await _pair()
|
||||
content = await _export(client, local)
|
||||
notes = content["tables"]["orders"]["notes"]
|
||||
notes["kind"] = {"value": "event", "source": "human", "status": "confirmed"} # 取值不在可选值内
|
||||
notes["formula"] = {"value": "sum(amount)", "source": "human", "status": "confirmed"} # 不认识的字段
|
||||
notes["columns"]["amount"]["colour"] = {"value": "红", "source": "human", "status": "confirmed"}
|
||||
report = (await client.post(f"{_base(remote)}/import/preview", json={"content": content})).json()
|
||||
skipped = {s["path"]: s["why"] for s in _by(report)["ORDERS"]["skipped"]}
|
||||
assert "明细表" in skipped["kind"]
|
||||
assert "formula" in skipped["formula"] and "columns.amount.colour" in skipped
|
||||
|
||||
|
||||
async def test_valid_filter_columns_must_be_in_the_target_table(client):
|
||||
"""有效记录条件是表级的一项,按列跳过的规矩拦不住它:解析出用到的列,不在目标的表结构里、是别的表的列、带子查询的都跳过
|
||||
并写原因。人工写的条件不限函数;带本表表名(文件里的写法、目标的写法都认)、大小写不同的照带。"""
|
||||
local, remote = await _pair()
|
||||
content = await _export(client, local)
|
||||
|
||||
async def outcome(condition: str) -> tuple[str | None, int]:
|
||||
body = copy.deepcopy(content)
|
||||
body["tables"]["orders"]["notes"]["valid_filter"] = make_item(condition, "human", "confirmed")
|
||||
report = (await client.post(f"{_base(remote)}/import/preview", json={"content": body})).json()
|
||||
orders = _by(report)["ORDERS"]
|
||||
why = {s["path"]: s["why"] for s in orders["skipped"]}.get("valid_filter")
|
||||
return why, orders["update"]
|
||||
|
||||
why, update = await outcome("gift_flag = 0 AND status <> 9")
|
||||
assert why and "gift_flag" in why and "目标的表结构" in why and update == 0
|
||||
why, _ = await outcome("members.tier = 1")
|
||||
assert why and "members.tier" in why and "别的表" in why
|
||||
why, _ = await outcome("member_id IN (SELECT id FROM members)")
|
||||
assert why and "子查询" in why
|
||||
why, _ = await outcome("status <> 9; DELETE FROM orders")
|
||||
assert why and "条件" in why
|
||||
for fine in ("orders.status <> 9", "ORDERS.STATUS <> 9 AND ABS(amount) > 0", "buyer_phone IS NOT NULL"):
|
||||
why, update = await outcome(fine)
|
||||
assert why is None and update == 1, fine
|
||||
|
||||
|
||||
async def test_import_needs_a_schema(client):
|
||||
local, _ = await _pair()
|
||||
content = await _export(client, local)
|
||||
empty = await _source(f"empty_{uuid.uuid4().hex[:6]}", {}, {})
|
||||
r = await client.post(f"{_base(empty)}/import/preview", json={"content": content})
|
||||
assert r.status_code == 409 and r.json()["code"] == "schema_missing"
|
||||
|
||||
|
||||
async def test_round_trip_into_an_empty_copy_reproduces_the_catalog(client):
|
||||
local, _ = await _pair()
|
||||
content = await _export(client, local)
|
||||
copy_sid = await _source(f"copy_{uuid.uuid4().hex[:6]}", _tables(), {})
|
||||
report = (await client.post(f"{_base(copy_sid)}/import", json={"content": json.loads(json.dumps(content)),
|
||||
"include_guidance": True})).json()
|
||||
assert report["unmatched_tables"] == ["ghost_table"]
|
||||
assert all(not t["conflicts"] for t in report["tables"])
|
||||
async with SessionLocal() as session:
|
||||
mine = await catalog.read_entry(session, local, "orders")
|
||||
theirs = await catalog.read_entry(session, copy_sid, "orders")
|
||||
# 关系两端都对得上的照带;指向目标那边没有的表的跳过
|
||||
expected = copy.deepcopy(mine.notes)
|
||||
expected["relations"] = [r for r in mine.notes["relations"] if r["to_table"] != "stores"]
|
||||
del expected["columns"]["gift_flag"] # 表结构里没有这一列
|
||||
assert theirs.notes == expected
|
||||
|
||||
|
||||
async def test_a_bad_reason_on_an_inferred_item_is_dropped_not_the_item(client):
|
||||
"""手改过的文件里,推断的项带着不合规的确认时的依据(推断项本来就不该有依据):去掉依据、照常填空缺,不把整项跳过。
|
||||
已确认的项依据不合规照旧跳过(依据是它的一部分,照带就得合规)。"""
|
||||
local, remote = await _pair()
|
||||
content = await _export(client, local)
|
||||
notes = content["tables"]["orders"]["notes"]
|
||||
notes["grain"] = {"value": "每笔订单一行", "source": "llm", "status": "proposed", "reason": "依" * 600}
|
||||
notes["description"] = {"value": "订单头", "source": "human", "status": "confirmed", "reason": 42}
|
||||
report = (await client.post(f"{_base(remote)}/import/preview", json={"content": content})).json()
|
||||
skipped = {s["path"]: s["why"] for s in _by(report)["ORDERS"]["skipped"]}
|
||||
assert "grain" not in skipped
|
||||
assert "description" in skipped and "依据" in skipped["description"]
|
||||
r = await client.post(f"{_base(remote)}/import", json={"content": content})
|
||||
assert r.status_code == 200, r.text
|
||||
assert "grain" not in {s["path"] for s in _by(r.json())["ORDERS"]["skipped"]}
|
||||
got = (await client.get(f"{_base(remote)}/ORDERS")).json()["notes"]
|
||||
assert "reason" not in got["grain"] # 目标原有的推断粒度保留(推断只填空缺),也没被挂上依据
|
||||
@@ -631,6 +631,20 @@ async def test_a_rejected_item_is_not_brought_back_but_a_rejected_other_value_do
|
||||
assert (await entry_of(source_id)).notes["business_date"]["value"] == {"column": "order_date"}
|
||||
|
||||
|
||||
async def test_writing_over_an_item_drops_the_reason_it_was_confirmed_with(shop):
|
||||
"""双盲复算的选择改写这一项(值变了):以前确认时记下的依据说的是旧值,不留;出处照旧写在备注里。"""
|
||||
name, source_id = shop
|
||||
await seed(source_id, {"business_date": catalog.make_item({"column": "paid_date"}, "llm", "rejected",
|
||||
reason="使用者说:按支付日算")})
|
||||
out = await record_choices([candidate(BY_ORDER_DATE, RIGHT, "a", source=name)], run_id="run-7",
|
||||
node_title="复核", actor=None)
|
||||
assert out["written"] == [{"source": name, "table": "orders", "item": "business_date",
|
||||
"value": {"column": "order_date"}}]
|
||||
item = (await entry_of(source_id)).notes["business_date"]
|
||||
assert item["value"] == {"column": "order_date"} and (item["source"], item["status"]) == ("human", "confirmed")
|
||||
assert "reason" not in item and "run-7" in item["note"]
|
||||
|
||||
|
||||
async def test_contradictory_choices_in_one_decision_write_nothing(shop):
|
||||
"""同一次裁决里,一个指标采纳了按下单日、另一个采纳了按支付日:一张表只有一个业务日期,写哪个都是替人拿主意,
|
||||
都不写。"""
|
||||
|
||||
+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()
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅一次运行的事件流。
|
||||
*
|
||||
|
||||
+362
-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,13 @@ 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 +2554,8 @@ export const CATALOG_DRAFT_TEXT = {
|
||||
/** 上次起草有留空的列:清单上是轻提示(不是失败标记),表详情顶部同一句 */
|
||||
lastBlank: (n: number) => `上次起草有 ${formatNumber(n)} 列模型拿不准、留空了`,
|
||||
lastBlankAdvice: '可以在起草要求里补充缩写和术语的含义,再重新起草这张表,已有的内容不会丢',
|
||||
/** 最近一次起草停止了、没有新写入:留空的列是再之前那次写完的起草记下的 */
|
||||
lastBlankEarlier: '最近一次起草已停止,留空的列按再之前那次起草记下的',
|
||||
draftGuidance: '补充起草要求',
|
||||
draftGuidanceHint: '打开起草设置,范围是这张表:先在起草要求里补充缩写和术语的含义,再开始起草',
|
||||
redraft: '重新起草这张表',
|
||||
@@ -2593,6 +2610,8 @@ export const CATALOG_PATCH_TEXT = {
|
||||
after: '改后',
|
||||
reason: '理由',
|
||||
empty: '未填写',
|
||||
/** 改前是一个被驳回的值(保存后换成人工确认的新值):同导入预览「目标里这一项已经驳回」 */
|
||||
beforeRejected: (value: string) => `已驳回:${value}`,
|
||||
stateConfirm: '值不变,保存即确认',
|
||||
stateSame: '已是这个值',
|
||||
save: '保存到数据目录',
|
||||
@@ -2953,6 +2972,344 @@ export const CATALOG_UI_TEXT = {
|
||||
cardinalityCheckedShort: '已核实',
|
||||
cardinalityCheckedHint: '基数由数据剖析用数据核实(子表一侧确有重复或确实唯一),一对多关联后重复计算的检查据此报错误',
|
||||
emptyTables: (n: number) => `挑表时跳过 ${formatNumber(n)} 张空表`,
|
||||
// ---- 表详情里有效记录条件、去重规则的说明(模型起草时附的依据、数据剖析核对的排除比例)
|
||||
fieldNote: '说明',
|
||||
// ---- 保存修改提案时记下的依据(目录项的 reason):和上面的说明分开写,表详情、状态弹层同一个叫法
|
||||
fieldReason: '确认时的依据',
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 表助手(表详情里「和助手一起完善」):和助手对话完善一张表的数据目录。
|
||||
//
|
||||
// 术语:
|
||||
// - 面板叫「表助手」,入口写「和助手一起完善」。助手只改数据目录、不改业务库;只记数据事实,不写公式。
|
||||
// - 助手提出的修改叫「修改提案」,每一项写「改前 / 改后 / 依据」,可以逐项「去掉」「加回」。
|
||||
// - 「待保存的修改」:跨多轮累积,同一项以最后一次提案为准;「保存 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: '说出你知道的业务事实(某一列的含义、码值、有效记录条件……),助手整理成修改提案,你确认后才写进数据目录。助手只记数据事实,不写计算公式。',
|
||||
/**
|
||||
* 上次起草有留空的列:开场引导先问这几列(assistant.ts 的 blankLeadOf)。例子只举起草时记下的留空列里还没补上的;
|
||||
* 拿不到起草时记下的列名时,改说目录里还没有中文名和含义的列——那里面还有没写出来的列,不能说成留空的列
|
||||
*/
|
||||
blankLead: (b: { from: 'draft' | 'catalog'; n: number; examples: string[]; remaining: number | null; unnamed: number }) => {
|
||||
const head = `上次起草有 ${formatNumber(b.n)} 列模型拿不准、留空了`
|
||||
const cols = b.examples.join('、')
|
||||
if (b.from === 'catalog') {
|
||||
return b.unnamed > 0
|
||||
? `${head}。目录里还有 ${formatNumber(b.unnamed)} 列没有中文名和含义${cols ? `,例如 ${cols}` : ''},可以先从这些列开始。`
|
||||
: `${head},现在目录里的列都已有中文名或含义。`
|
||||
}
|
||||
if (b.remaining === 0) return `${head},现在都已补上中文名或含义。`
|
||||
if (!cols) return `${head}。`
|
||||
if (b.remaining == null) return `${head},例如 ${cols}。可以先从这几列开始。`
|
||||
return b.remaining < b.n
|
||||
? `${head},还有 ${formatNumber(b.remaining)} 列没补上:${cols}。可以先从这几列开始。`
|
||||
: `${head}:${cols}。可以先从这几列开始。`
|
||||
},
|
||||
suggestLabel: '可以这样开始',
|
||||
/** 「补上……」按钮:上面的数字就是点名的列数(不知道一共几列时不写数字) */
|
||||
suggestBlank: (from: 'draft' | 'catalog', total: number | null) => (from === 'catalog'
|
||||
? `补上没有中文名和含义的 ${formatNumber(total ?? 0)} 列`
|
||||
: total == null ? '补上留空的列' : `补上留空的 ${formatNumber(total)} 列`),
|
||||
suggestBlankText: (from: 'draft' | 'catalog', cols: string[], total: number | null) => {
|
||||
const named = cols.join('、')
|
||||
if (from === 'draft' && total == null) return `请帮我补上上次起草时模型留空的列的中文名和含义,例如 ${named}。拿不准的地方先问我。`
|
||||
const more = total != null && total > cols.length ? ` 等 ${formatNumber(total)} 列` : ''
|
||||
return `请帮我补上这几列的中文名和含义:${named}${more}。拿不准的地方先问我。`
|
||||
},
|
||||
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)} 次`,
|
||||
// ---- 只读查询(rows 是取回的行数;truncated:后面还有行,取回的只是前一截,不是结果的总行数)
|
||||
query: (rows: number, truncated = false) => (truncated
|
||||
? `查询了数据(取回前 ${formatNumber(rows)} 行,后面还有)` : `查询了数据(${formatNumber(rows)} 行)`),
|
||||
queryFailed: '查询没有成功',
|
||||
queryRows: (shown: number, total: number, truncated = false) => {
|
||||
const head = truncated ? `取回前 ${formatNumber(total)} 行(后面还有)` : `共 ${formatNumber(total)} 行`
|
||||
return total > shown ? `${head},显示前 ${formatNumber(shown)} 行` : head
|
||||
},
|
||||
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: '以下几项已无法保存,已从待保存的修改中去掉:',
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 审阅队列(数据目录页的「审阅队列」视图):跨表列出推断项,按表的使用次数、这一项对数字的影响和来源排好,
|
||||
// 可以成批确认、驳回、撤销审阅。
|
||||
//
|
||||
// 术语:
|
||||
// - 「影响数字的项」:有效记录条件、去重规则、业务日期、关联关系、业务主键、度量类型——写错了查出来的数就不对,排在最前;
|
||||
// 「名称和含义」:中文名、含义、单位、码值、粒度、表类型。
|
||||
// - 审阅过的项原地标出结果(已确认、已驳回、未生效),刷新队列后才移除;「撤销审阅」回到起草时的状态(推断)。
|
||||
// - 每一项的位置写「列 status 的码值」「表的有效记录条件」「指向 parks 的关联关系」(同提案卡片),不只写字段名。
|
||||
// - 「常用 N 张表」:被运行用过、使用次数最多的前几张(最多 20 张),N 写实际张数;没有运行用过的表时写明。
|
||||
// - 进度写「已确认 42%」,三行同一个口径:已确认 ÷(已确认 + 待确认);已驳回的、有确证的(外键约束)不计入。
|
||||
// - 「使用 N 次」是所在表被运行查询过的次数(按查询算,同表清单);「用过这张表的运行」写「共 N 次运行」(按运行算),
|
||||
// 两个数不一样,各自写明单位。
|
||||
// ===========================================================================
|
||||
|
||||
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: '审阅进度',
|
||||
/** 常用表:被运行用过、使用次数最多的前几张(最多 topN 张),n 是实际张数 */
|
||||
progressTop: (n: number) => `常用 ${formatNumber(n)} 张表`,
|
||||
progressTopHint: (n: number, topN: number) => (n > 0
|
||||
? `被运行用过、使用次数最多的 ${formatNumber(n)} 张表里的项(最多取 ${formatNumber(topN)} 张)`
|
||||
: `被运行用过、使用次数最多的表里的项(最多取 ${formatNumber(topN)} 张)`),
|
||||
progressTopNone: '常用表',
|
||||
progressTopEmpty: '还没有运行用过的表',
|
||||
progressAll: '全部表',
|
||||
progressAllHint: '这个数据源全部表里的项',
|
||||
progressFacts: '影响数字的项',
|
||||
progressFactsHint: '有效记录条件、去重规则、业务日期、关联关系、业务主键、度量类型',
|
||||
progressRule: '已确认 ÷(已确认 + 待确认):已驳回的、有确证的(外键约束)不用再确认,不计入',
|
||||
/** 一行进度:已确认百分比和待确认几项;不知道待确认几项时只写已确认几项 */
|
||||
progressText: (pct: number | null, confirmed: number, proposed: number | null, total: number | null) => {
|
||||
if (total === 0) return '还没有目录项'
|
||||
if (proposed == null) return `已确认 ${formatNumber(confirmed)} 项`
|
||||
if (pct == null) return '没有待确认的项'
|
||||
return `已确认 ${formatNumber(pct)}% · 待确认 ${formatNumber(proposed)} 项`
|
||||
},
|
||||
progressFailed: '无法读取审阅进度',
|
||||
// ---- 没选中项时的右栏
|
||||
overviewTitle: '从左侧队列挑一项开始审阅',
|
||||
overviewBody: '队列按所在表的使用次数、这一项对数字的影响和来源排序:常用表里影响数字的推断排在最前,模型起草、命名推断的比数据库注释的靠前。点开一项可以看它所在表的详情和用过这张表的运行。',
|
||||
// ---- 从队列打开的项
|
||||
focusMark: '从审阅队列打开',
|
||||
// ---- 表清单视图概览里的入口
|
||||
openQueue: '打开审阅队列',
|
||||
openQueueHint: '跨表按使用次数和对数字的影响排好,可以成批确认、驳回',
|
||||
}
|
||||
|
||||
/** 表详情里「用过这张表的运行」(GET /catalog/{table}/usage) */
|
||||
export const CATALOG_USAGE_TEXT = {
|
||||
title: '用过这张表的运行',
|
||||
hint: '新的在前',
|
||||
show: '查看用过这张表的运行',
|
||||
hide: '收起',
|
||||
none: '还没有运行查询过这张表',
|
||||
/** 按运行算(一次运行查询了几次这张表都只算一次),和表头按查询算的「使用 N 次」不是一个数 */
|
||||
count: (shown: number, total: number) => (total > shown
|
||||
? `共 ${formatNumber(total)} 次运行,显示最近 ${formatNumber(shown)} 次` : `共 ${formatNumber(total)} 次运行`),
|
||||
countHint: '按运行算:一次运行查询了几次这张表都只算一次;表头的使用次数按查询算,两个数可能不一样',
|
||||
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: '导入完成',
|
||||
/**
|
||||
* 导入结果的合计。written 只数真正写进去的表(有新增或更新的项、版本前进了),没有变化的表不算(服务端的 totals.tables
|
||||
* 是对上的表数);一张都没写进去时说清没有需要写入的内容。failed 是写入出错、没有导入的表;guidance 为真时起草要求写进去了
|
||||
*/
|
||||
doneTotals: (written: number, t: { add: number; update: number; conflicts: number }, failed: number, guidance: boolean) => {
|
||||
const head = written
|
||||
? `写入 ${formatNumber(written)} 张表:新增 ${formatNumber(t.add)} 项、更新 ${formatNumber(t.update)} 项`
|
||||
: failed ? '没有写入任何表' : guidance ? '目录里没有需要写入的内容,只写入了起草要求' : '没有需要写入的内容'
|
||||
const tail = [
|
||||
t.conflicts ? `冲突 ${formatNumber(t.conflicts)} 项以这里为准` : '',
|
||||
failed ? `${formatNumber(failed)} 张表没有导入,原因见下` : '',
|
||||
].filter(Boolean)
|
||||
return [head, ...tail].join(';')
|
||||
},
|
||||
version: (v: number) => `第 ${formatNumber(v)} 版`,
|
||||
/** 导入结果里没有写进任何内容的表 */
|
||||
unchanged: '没有变化',
|
||||
tableFailed: (reason: string) => `这张表未导入:${reason}`,
|
||||
back: '重新选择文件',
|
||||
cancel: '取消',
|
||||
close: '完成',
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react'
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { ArrowLeft, BookMarked, Database, ListFilter as ListFilterIcon, ScanSearch, Sparkles, TriangleAlert } from 'lucide-react'
|
||||
import {
|
||||
ArrowLeft, BookMarked, Database, Download, ListFilter as ListFilterIcon, ScanSearch, Sparkles, TriangleAlert, Upload,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
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 type { CatalogDetail, CatalogList, CatalogReviewItem, CatalogReviewKind, CatalogTableRow } from '../../types'
|
||||
import { EmptyState, ErrorState, PageHeader, Skeleton, Spinner, rovingTarget, toast, useTicker } from '../../components/ui'
|
||||
import { useCatalog, useDatasources, useOnReconnect } from '../../store/catalog'
|
||||
import { errorMessage } from '../../lib/errors'
|
||||
import { formatNumber } from '../../lib/format'
|
||||
import {
|
||||
CATALOG_DRAFT_TEXT as DT, CATALOG_TEXT as CT, PROFILE_STOP_LABEL, PROFILE_TEXT as PT, SCHEMA_PARTIAL_TEXT as SPT,
|
||||
CATALOG_DRAFT_TEXT as DT, CATALOG_REVIEW_TEXT as RT, CATALOG_TEXT as CT, CATALOG_TRANSFER_TEXT as XT, PROFILE_STOP_LABEL,
|
||||
PROFILE_TEXT as PT, SCHEMA_PARTIAL_TEXT as SPT,
|
||||
} from '../../lib/terms'
|
||||
import { StatusLegend, SystemNotesNotice } from './parts'
|
||||
import { DraftDialog } from './DraftDialog'
|
||||
@@ -20,6 +25,10 @@ import { ProfileSettingsDialog } from './ProfileSettings'
|
||||
import { fillKey, profileBlockOf, profileSettingsOf, secondsText } from './profile'
|
||||
import { TableDetail } from './TableDetail'
|
||||
import { TableIndex } from './TableIndex'
|
||||
import { ReviewOverview, ReviewQueue } from './ReviewQueue'
|
||||
import { ImportDialog } from './TransferDialog'
|
||||
import { itemKey, reviewParamsOf, writeReviewParams } from './review'
|
||||
import type { CatalogView } from './review'
|
||||
import { filterCounts, listParamsOf, progressOf, rowFromDetail, usageSummary, visibleRows, writeListParams } from './model'
|
||||
import type { ListFilter, ListParams, ListSort } from './model'
|
||||
|
||||
@@ -34,6 +43,10 @@ import type { ListFilter, ListParams, ListSort } from './model'
|
||||
//
|
||||
// 清单的搜索词、筛选、排序和「只看运行中查询过的表」记在地址的查询参数里(q / filter / sort / used),刷新、前进后退
|
||||
// 都不丢;在清单和表详情之间跳转时带着它们走。
|
||||
//
|
||||
// 左栏有两个视图:表清单(逐表看)和审阅队列(view=review:跨表按优先级列出推断项,成批确认、驳回,筛选记在 kind /
|
||||
// rtable 里)。从队列点开一项,右栏是所在表的详情,地址带着那一项(item),详情滚到那一项、列出用过这张表的运行。
|
||||
// 页头的「导出目录」「导入目录」把整理好的目录带到另一个实例(导入先预览,确认后才写)。
|
||||
// ===========================================================================
|
||||
|
||||
export function CatalogPage() {
|
||||
@@ -98,10 +111,20 @@ export function CatalogPage() {
|
||||
const setFilter = useCallback((f: ListFilter) => setList((c) => ({ ...c, filter: f })), [])
|
||||
const setSort = useCallback((v: ListSort) => setList((c) => ({ ...c, sort: v })), [])
|
||||
const setUsedOnly = useCallback((v: boolean) => setList((c) => ({ ...c, used: v })), [])
|
||||
/** 跳到别的表、回到清单时带上清单的查询参数(按页面上的算:搜索词可能还没写进地址) */
|
||||
/**
|
||||
* 跳到别的表、回到清单时带上清单的查询参数(按页面上的算:搜索词可能还没写进地址)。审阅视图的参数(view、kind、rtable)
|
||||
* 照旧带着,从审阅队列打开的那一项(item)不带:换了表就不是那一项了
|
||||
*/
|
||||
const searchRef = useRef('')
|
||||
const search = writeListParams(params, list).toString()
|
||||
const carried = writeListParams(params, list)
|
||||
carried.delete('item')
|
||||
const search = carried.toString()
|
||||
searchRef.current = search ? `?${search}` : ''
|
||||
const carriedRef = useRef(carried)
|
||||
carriedRef.current = carried
|
||||
/** 审阅视图:view=review 时左栏是审阅队列;kind、rtable 是队列的筛选;item 是右栏从队列打开的那一项 */
|
||||
const review = useMemo(() => reviewParamsOf(params), [params])
|
||||
const inReview = review.view === 'review'
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set())
|
||||
/**
|
||||
* 起草对话框开着没有、打开时显示什么:setup 选范围开始;job 显示页面跟着的任务(进行中的进度、刚结束的结果)。
|
||||
@@ -142,12 +165,15 @@ export function CatalogPage() {
|
||||
const backToList = useCallback(() => navigate(`${base}${searchRef.current}`), [navigate, base])
|
||||
const back = source?.origin === 'upload' ? '/data/tables' : '/data/databases'
|
||||
|
||||
/** 右栏的表详情载入、改过之后的目录:审阅队列里这张表的项跟着换状态 */
|
||||
const [detailSync, setDetailSync] = useState<{ seq: number; detail: CatalogDetail } | null>(null)
|
||||
/** 写入、审阅之后按返回的单表目录更新清单里那一行 */
|
||||
const onDetail = useCallback((d: CatalogDetail) => {
|
||||
setData((cur) => cur && ({
|
||||
...cur,
|
||||
tables: cur.tables.map((r) => (r.table_name === d.table_name ? rowFromDetail(d, r) : r)),
|
||||
}))
|
||||
setDetailSync((cur) => ({ seq: (cur?.seq ?? 0) + 1, detail: d }))
|
||||
}, [])
|
||||
|
||||
/**
|
||||
@@ -306,6 +332,52 @@ export function CatalogPage() {
|
||||
return [list[i - 1]?.table_name ?? null, list[i + 1]?.table_name ?? null]
|
||||
}, [table, visible, rows, sort])
|
||||
|
||||
/**
|
||||
* 改地址的查询参数,按地址栏现在的样子改:setSearchParams 的函数式写法拿的是上一次渲染时的参数,连着改两样(先点
|
||||
* 「全部」、紧接着换表)时后一次会把前一次盖掉
|
||||
*/
|
||||
const patchSearch = useCallback((fn: (p: URLSearchParams) => URLSearchParams, opts?: { replace?: boolean }) => {
|
||||
const next = fn(new URLSearchParams(window.location.search)).toString()
|
||||
navigate({ search: next ? `?${next}` : '' }, opts)
|
||||
}, [navigate])
|
||||
/** 换视图(表清单 / 审阅队列):记进地址(后退回到上一个视图),右栏正在看的表不动 */
|
||||
const setView = useCallback((v: CatalogView) => {
|
||||
patchSearch((prev) => writeReviewParams(prev, { view: v, item: '' }))
|
||||
}, [patchSearch])
|
||||
/** 审阅队列的筛选:替换当前这条历史(筛选不该占满后退键) */
|
||||
const setReviewParams = useCallback((p: { kind?: CatalogReviewKind; table?: string }) => {
|
||||
patchSearch((prev) => writeReviewParams(prev, p), { replace: true })
|
||||
}, [patchSearch])
|
||||
/** 从审阅队列打开一项:右栏是所在表的详情,地址带着这一项 */
|
||||
const openReviewItem = useCallback((i: CatalogReviewItem) => {
|
||||
const q = writeReviewParams(carriedRef.current, { view: 'review', item: i.path }).toString()
|
||||
navigate(`${base}/${encodeURIComponent(i.table_name)}${q ? `?${q}` : ''}`)
|
||||
}, [navigate, base])
|
||||
/** 成批审阅、导入写进了这些表:重取清单,正在看的表重新载入(编辑中的不动,保存时服务端会用版本号拦下) */
|
||||
const onTablesWritten = useCallback((tables: string[]) => {
|
||||
setDrafted((cur) => ({ seq: cur.seq + 1, tables: new Set(tables) }))
|
||||
void load()
|
||||
}, [load])
|
||||
const activeReviewKey = inReview && table && review.item ? itemKey({ table_name: table, path: review.item }) : null
|
||||
const proposedTotal = useMemo(() => rows.reduce((n, r) => n + (r.in_schema ? r.counts.proposed : 0), 0), [rows])
|
||||
|
||||
/** 导出目录:下载服务端给的文件,文件名按服务端的,没给就按数据源名补 */
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const exportCatalog = useCallback(async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
const { blob, name } = await api.dataCatalog.exportFile(sourceId)
|
||||
const file = name ?? XT.fileName(source?.name ?? sourceId)
|
||||
saveFile(file, blob)
|
||||
toast.ok(XT.exported(file))
|
||||
} catch (e) {
|
||||
toast.error(XT.exportFailed(errorMessage(e)))
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}, [sourceId, source?.name])
|
||||
|
||||
const usage = useMemo(() => usageSummary(rows), [rows])
|
||||
const wideHeader = useMedia('(min-width: 640px)')
|
||||
/**
|
||||
@@ -375,42 +447,58 @@ export function CatalogPage() {
|
||||
body = (
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<aside className={clsx('min-h-0 w-full flex-col border-r lg:flex lg:w-[380px] lg:shrink-0 xl:w-[420px]', table ? 'hidden' : 'flex')}>
|
||||
{empty && (
|
||||
// 窄屏没有右栏的概览,空目录的下一步写在清单上面
|
||||
<div className="border-b px-3 py-2.5 text-xs lg:hidden" data-catalog-empty-banner="">
|
||||
<p className="font-medium">{CT.emptyTitle}</p>
|
||||
<p className="mt-0.5 text-2xs leading-relaxed text-faint">{CT.emptyBody}</p>
|
||||
<div className="mt-2">{draftButton(true)}</div>
|
||||
</div>
|
||||
)}
|
||||
<TableIndex
|
||||
rows={visible}
|
||||
total={rows.length}
|
||||
counts={counts}
|
||||
query={query}
|
||||
onQuery={setQuery}
|
||||
filter={filter}
|
||||
onFilter={setFilter}
|
||||
usedOnly={usedOnly}
|
||||
onUsedOnly={setUsedOnly}
|
||||
sort={sort}
|
||||
onSort={setSort}
|
||||
selected={selected}
|
||||
onSelected={setSelected}
|
||||
active={table}
|
||||
onOpen={openTable}
|
||||
onDraft={openDraft}
|
||||
onProfile={openProfile}
|
||||
/>
|
||||
<ViewTabs view={review.view} proposed={proposedTotal} onChange={setView} />
|
||||
<div role="tabpanel" id="catalog-view-panel" aria-labelledby={`catalog-view-tab-${review.view}`} className="flex min-h-0 flex-1 flex-col">
|
||||
{inReview
|
||||
? (
|
||||
<ReviewQueue sourceId={sourceId} rows={rows} kind={review.kind} table={review.table} activeKey={activeReviewKey}
|
||||
detailSync={detailSync} onParams={setReviewParams} onOpenItem={openReviewItem} onChanged={onTablesWritten} />
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{empty && (
|
||||
// 窄屏没有右栏的概览,空目录的下一步写在清单上面
|
||||
<div className="border-b px-3 py-2.5 text-xs lg:hidden" data-catalog-empty-banner="">
|
||||
<p className="font-medium">{CT.emptyTitle}</p>
|
||||
<p className="mt-0.5 text-2xs leading-relaxed text-faint">{CT.emptyBody}</p>
|
||||
<div className="mt-2">{draftButton(true)}</div>
|
||||
</div>
|
||||
)}
|
||||
<TableIndex
|
||||
rows={visible}
|
||||
total={rows.length}
|
||||
counts={counts}
|
||||
query={query}
|
||||
onQuery={setQuery}
|
||||
filter={filter}
|
||||
onFilter={setFilter}
|
||||
usedOnly={usedOnly}
|
||||
onUsedOnly={setUsedOnly}
|
||||
sort={sort}
|
||||
onSort={setSort}
|
||||
selected={selected}
|
||||
onSelected={setSelected}
|
||||
active={table}
|
||||
onOpen={openTable}
|
||||
onDraft={openDraft}
|
||||
onProfile={openProfile}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
<section className={clsx('min-h-0 min-w-0 flex-1 flex-col lg:flex', table ? 'flex' : 'hidden')} aria-label={table ?? CT.overviewTitle}>
|
||||
{table
|
||||
? (
|
||||
<TableDetail sourceId={sourceId} table={table} rows={rows} drafted={drafted} fillCodes={fillCodes} prev={prev} next={next}
|
||||
onFillDone={onFillDone} onOpen={openTable} onBack={backToList} onDetail={onDetail}
|
||||
draftBusy={!!stripJob} onRedraft={redraftTable} onDraftGuidance={draftWithGuidance} />
|
||||
draftBusy={!!stripJob} onRedraft={redraftTable} onDraftGuidance={draftWithGuidance}
|
||||
profileEnabled={profileSettings.enabled} focusPath={inReview ? review.item || null : null} usageOpen={inReview} />
|
||||
)
|
||||
: <Overview rows={rows} empty={empty} systemNotes={data.system_notes} draft={draftButton(true)} onOpen={openTable} />}
|
||||
: inReview
|
||||
? <ReviewOverview />
|
||||
: <Overview rows={rows} empty={empty} systemNotes={data.system_notes} draft={draftButton(true)} onOpen={openTable}
|
||||
onReview={() => setView('review')} />}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
@@ -427,6 +515,18 @@ export function CatalogPage() {
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{hasTables && headerProfile}
|
||||
{hasTables && headerDraft}
|
||||
{hasTables && (
|
||||
<button type="button" className="btn btn-sm" onClick={() => void exportCatalog()} disabled={exporting} aria-label={XT.export}
|
||||
title={XT.exportHint} data-catalog-export="">
|
||||
{exporting ? <Spinner size={11} /> : <Download size={11} aria-hidden />} <span className="hidden md:inline">{XT.export}</span>
|
||||
</button>
|
||||
)}
|
||||
{hasTables && (
|
||||
<button type="button" className="btn btn-sm" onClick={() => setImporting(true)} aria-label={XT.import} title={XT.importHint}
|
||||
data-catalog-import="">
|
||||
<Upload size={11} aria-hidden /> <span className="hidden md:inline">{XT.import}</span>
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-sm btn-ghost" onClick={() => navigate(back)} aria-label={CT.back} data-catalog-back="">
|
||||
<ArrowLeft size={12} aria-hidden /> <span className="hidden sm:inline">{CT.back}</span>
|
||||
</button>
|
||||
@@ -481,6 +581,9 @@ export function CatalogPage() {
|
||||
onReset={() => setJob(null)}
|
||||
/>
|
||||
)}
|
||||
{importing && (
|
||||
<ImportDialog sourceId={sourceId} onClose={() => setImporting(false)} onImported={onTablesWritten} />
|
||||
)}
|
||||
{profileSettingsOpen && source && (
|
||||
<ProfileSettingsDialog row={{ id: source.id, name: source.name, options }} onClose={() => setProfileSettingsOpen(false)}
|
||||
onSaved={onProfileSettingsSaved} />
|
||||
@@ -524,6 +627,51 @@ function DraftStrip({ job, notice, onOpen }: {
|
||||
)
|
||||
}
|
||||
|
||||
/** 存成文件(同运行记录里的下载):浏览器下载,文件名照给的 */
|
||||
function saveFile(name: string, blob: Blob) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = name
|
||||
a.click()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
|
||||
const VIEWS: CatalogView[] = ['list', 'review']
|
||||
|
||||
/**
|
||||
* 左栏上方的两个视图:表清单(逐表看)、审阅队列(跨表按优先级看推断项,带着还有几项待确认)。标签页语义,
|
||||
* ←→ Home End 切换
|
||||
*/
|
||||
function ViewTabs({ view, proposed, onChange }: { view: CatalogView; proposed: number; onChange: (v: CatalogView) => void }) {
|
||||
const refs = useRef<Record<string, HTMLButtonElement | null>>({})
|
||||
const move = (e: ReactKeyboardEvent, i: number) => {
|
||||
const j = rovingTarget(e, i, VIEWS.length, 'horizontal')
|
||||
if (j < 0) return
|
||||
e.preventDefault()
|
||||
onChange(VIEWS[j])
|
||||
refs.current[VIEWS[j]]?.focus()
|
||||
}
|
||||
return (
|
||||
<div className="flex shrink-0 gap-1 border-b px-2" role="tablist" aria-label={RT.viewLabel} data-catalog-view={view}>
|
||||
{VIEWS.map((v, i) => {
|
||||
const selected = view === v
|
||||
return (
|
||||
<button key={v} ref={(el) => { refs.current[v] = el }} type="button" role="tab" id={`catalog-view-tab-${v}`} aria-selected={selected}
|
||||
aria-controls="catalog-view-panel" tabIndex={selected ? 0 : -1} title={RT.viewHint[v]}
|
||||
onClick={() => onChange(v)} onKeyDown={(e) => move(e, i)} data-catalog-view-tab={v}
|
||||
className={clsx('relative px-3 py-2 text-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--accent)]',
|
||||
selected ? 'text-fg' : 'text-faint hover:text-dim')}>
|
||||
{RT.view[v]}
|
||||
{v === 'review' && proposed > 0 && <span className="tnum ml-1.5 text-2xs text-faint">{formatNumber(proposed)}</span>}
|
||||
{selected && <span className="absolute inset-x-2 -bottom-px h-0.5 rounded bg-[var(--accent)]" aria-hidden />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 媒体查询的当前结果,跟着窗口大小变 */
|
||||
function useMedia(query: string): boolean {
|
||||
const [on, setOn] = useState(() => typeof matchMedia !== 'function' || matchMedia(query).matches)
|
||||
@@ -601,13 +749,14 @@ function UsageSummary({ used, pending, none, active, onFocus }: {
|
||||
)
|
||||
}
|
||||
|
||||
/** 没有选中表时的右栏:空目录时引导起草;否则写审阅进度,给出下一张该审的表 */
|
||||
function Overview({ rows, empty, systemNotes, draft, onOpen }: {
|
||||
/** 没有选中表时的右栏:空目录时引导起草;否则写审阅进度,给出下一张该审的表,也可以去审阅队列跨表成批审阅 */
|
||||
function Overview({ rows, empty, systemNotes, draft, onOpen, onReview }: {
|
||||
rows: CatalogTableRow[]
|
||||
empty: boolean
|
||||
systemNotes: boolean
|
||||
draft: ReactNode
|
||||
onOpen: (table: string) => void
|
||||
onReview: () => void
|
||||
}) {
|
||||
const pendingNext = rows.find((r) => r.in_schema && r.counts.proposed > 0)
|
||||
const stats = { pending: 0, done: 0, none: 0 }
|
||||
@@ -634,6 +783,12 @@ function Overview({ rows, empty, systemNotes, draft, onOpen }: {
|
||||
<span className="text-2xs text-faint">{CT.reviewNextHint}</span>
|
||||
</div>
|
||||
)}
|
||||
{pendingNext && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button type="button" className="btn btn-sm" onClick={onReview} data-catalog-open-review="">{RT.openQueue}</button>
|
||||
<span className="text-2xs text-faint">{RT.openQueueHint}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{systemNotes && <SystemNotesNotice />}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { IconButton } from '../../components/ui'
|
||||
import {
|
||||
CATALOG_COLUMN_FIELD_LABEL, CATALOG_MEASURE_HINT, CATALOG_MEASURE_LABEL, CATALOG_TEXT as CT, CODES_TEXT as KT,
|
||||
} from '../../lib/terms'
|
||||
import { ItemMark } from './parts'
|
||||
import { FOCUS_RING, FOCUS_STYLE, ItemMark, NoteHint, ReasonHint, confirmedNote, confirmedReason } from './parts'
|
||||
import type { ReviewTarget } from './parts'
|
||||
import { COLUMN_FIELDS, codesComplete, columnPath, completeOf } from './model'
|
||||
import type { ColumnDraft, ColumnField } from './model'
|
||||
@@ -50,7 +50,16 @@ export interface ColumnModel {
|
||||
|
||||
type ColumnTexts = ColumnDraft
|
||||
|
||||
export function ColumnTable({ columns, form, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onFillCodes, onChange, onCodesComplete }: {
|
||||
/** 审阅路径 columns.<列名>.<字段> → [列名, 字段](列名里可以有句点,按最后一个句点切);不是列级项为 null */
|
||||
function columnOfPath(path: string | null | undefined): [string, string] | null {
|
||||
if (!path?.startsWith('columns.')) return null
|
||||
const rest = path.slice('columns.'.length)
|
||||
const at = rest.lastIndexOf('.')
|
||||
return at > 0 ? [rest.slice(0, at), rest.slice(at + 1)] : null
|
||||
}
|
||||
|
||||
export function ColumnTable({ columns, form, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onFillCodes, onChange, onCodesComplete,
|
||||
focusPath = null }: {
|
||||
columns: ColumnModel[]
|
||||
/** 编辑中:列名 → 字段 → 文字(form 是当前的,initial 是进入编辑时的);不在编辑时为 null */
|
||||
form: Record<string, ColumnTexts> | null
|
||||
@@ -67,7 +76,10 @@ export function ColumnTable({ columns, form, initial, problems, busy, systemNote
|
||||
onChange: (col: string, field: ColumnField, value: string) => void
|
||||
/** 编辑中勾选或取消码值的「已列出全部取值」 */
|
||||
onCodesComplete: (col: string, value: boolean) => void
|
||||
/** 从审阅队列打开的那一项(列级项的审阅路径):那一格标出来 */
|
||||
focusPath?: string | null
|
||||
}) {
|
||||
const focus = columnOfPath(focusPath)
|
||||
return (
|
||||
<div className="@container relative overflow-x-auto rounded-lg border" data-catalog-columns="">
|
||||
<table className={clsx('w-full border-collapse text-xs', NARROW.table)}>
|
||||
@@ -89,7 +101,7 @@ export function ColumnTable({ columns, form, initial, problems, busy, systemNote
|
||||
{columns.map((c) => (
|
||||
<ColumnRow key={c.name} col={c} editing={!!form} draft={form?.[c.name]} initial={initial?.[c.name]} problems={problems} busy={busy}
|
||||
systemNotes={systemNotes} onReview={onReview} onConfirmColumn={onConfirmColumn} onFillCodes={onFillCodes} onChange={onChange}
|
||||
onCodesComplete={onCodesComplete} />
|
||||
onCodesComplete={onCodesComplete} focusField={focus && focus[0] === c.name ? focus[1] : null} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -98,7 +110,7 @@ export function ColumnTable({ columns, form, initial, problems, busy, systemNote
|
||||
}
|
||||
|
||||
const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, problems, busy, systemNotes, onReview, onConfirmColumn, onFillCodes, onChange,
|
||||
onCodesComplete }: {
|
||||
onCodesComplete, focusField }: {
|
||||
col: ColumnModel
|
||||
editing: boolean
|
||||
/** 这一列的表单。只有改了这一列时引用才会变,别的行不重画 */
|
||||
@@ -112,6 +124,8 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble
|
||||
onFillCodes: (col: string) => void
|
||||
onChange: (col: string, field: ColumnField, value: string) => void
|
||||
onCodesComplete: (col: string, value: boolean) => void
|
||||
/** 从审阅队列打开的是这一列的哪一项 */
|
||||
focusField: string | null
|
||||
}) {
|
||||
const { name, structure, items } = col
|
||||
const pending = COLUMN_FIELDS.filter((f) => items?.[f]?.status === 'proposed').length
|
||||
@@ -121,16 +135,25 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble
|
||||
const where = CT.whereColumn(name, CATALOG_COLUMN_FIELD_LABEL[f])
|
||||
const problem = problems[`c:${name}:${f}`]
|
||||
const changed = editing && ((draft?.[f] ?? '') !== (initial?.[f] ?? '') || (f === 'codes' && completeOf(draft) !== completeOf(initial)))
|
||||
const focused = focusField === f
|
||||
// 人工确认的项:确认时的依据、备注(剖析的说明……)各一个图标,悬停看
|
||||
const reason = editing ? '' : confirmedReason(it)
|
||||
const note = editing ? '' : confirmedNote(it)
|
||||
return (
|
||||
// 窄框下没填的项不占行(编辑时照常列出来好填)
|
||||
<td className={clsx('px-2 py-1.5 align-top', NARROW.cell, !it && !editing && '@max-[40rem]:hidden')} data-cell={f} data-status={it?.status}>
|
||||
<td className={clsx('px-2 py-1.5 align-top', NARROW.cell, !it && !editing && '@max-[40rem]:hidden', focused && FOCUS_RING)}
|
||||
style={focused ? FOCUS_STYLE : undefined} data-cell={f} data-status={it?.status}
|
||||
data-review-focus={focused ? columnPath(name, f) : undefined}>
|
||||
<span className={NARROW.label}>{CT.columnHead[f]}</span>
|
||||
<div className="flex min-w-0 flex-1 items-start gap-1.5">
|
||||
<div className="min-w-0 flex-1">{editing ? input : view}</div>
|
||||
<ReasonHint reason={reason} attr="data-cell-reason" />
|
||||
{note && <NoteHint note={note} attr="data-cell-note" />}
|
||||
{it && !changed && (
|
||||
<span className="pt-0.5">
|
||||
<ItemMark compact disabled={busy || editing} disabledHint={editing ? CT.reviewWhileEditing : undefined}
|
||||
target={{ path: columnPath(name, f), where, source: it.source, status: it.status, note: it.note, updated_at: it.updated_at }}
|
||||
target={{ path: columnPath(name, f), where, source: it.source, status: it.status, note: it.note, reason: it.reason,
|
||||
updated_at: it.updated_at }}
|
||||
onReview={(a) => onReview({ path: columnPath(name, f), where, source: it.source, status: it.status }, a)} />
|
||||
</span>
|
||||
)}
|
||||
@@ -223,6 +246,7 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble
|
||||
</tr>
|
||||
)
|
||||
}, (a, b) => a.col === b.col && a.editing === b.editing && a.draft === b.draft && a.initial === b.initial && a.busy === b.busy
|
||||
&& a.focusField === b.focusField
|
||||
&& a.systemNotes === b.systemNotes && a.onReview === b.onReview && a.onConfirmColumn === b.onConfirmColumn && a.onFillCodes === b.onFillCodes
|
||||
&& a.onChange === b.onChange && a.onCodesComplete === b.onCodesComplete
|
||||
// 格式问题每次都是新对象:只比这一列的几格
|
||||
|
||||
@@ -9,8 +9,8 @@ import { errorMessage } from '../../lib/errors'
|
||||
import { formatTime } from '../../lib/format'
|
||||
import { CATALOG_DRAFT_TEXT as DT, CATALOG_TEXT as CT, DRAFT_GUIDANCE_MAX, DRAFT_NOTE_MAX } from '../../lib/terms'
|
||||
import {
|
||||
DRAFT_MAX_TABLES, DRAFT_MODEL_UNAVAILABLE, DRAFT_PART_COLUMNS, DRAFT_TOP_N, RETRYABLE, blankOf, isJobActive, jobBlank, needsLook,
|
||||
nothingWritten, retryableCount, taskReason,
|
||||
DRAFT_MAX_TABLES, DRAFT_MODEL_UNAVAILABLE, DRAFT_PART_COLUMNS, DRAFT_TOP_N, RETRYABLE, blankOf, chunksOf, isJobActive, jobBlank, needsLook,
|
||||
nothingWritten, retryableCount, taskReason, writtenOf,
|
||||
} from './draftJob'
|
||||
import type { DraftPreset, DraftStartBody } from './draftJob'
|
||||
|
||||
@@ -435,6 +435,9 @@ function JobView({ job, running, stopping, busy, blankTables, onRedraftBlank }:
|
||||
// 额度用完、超时多了,之后的段缩小了:进行中写一句,段数为什么变多看得明白
|
||||
const partColumns = running && job.use_model && job.part_columns != null && job.part_columns < DRAFT_PART_COLUMNS ? job.part_columns : null
|
||||
const blankTotal = jobBlank(job)
|
||||
// 停下来时已经写进了一部分段的表(宽表每写完一段就写进目录):几张、合计几段
|
||||
const stopped = job.tasks.filter((t) => t.status === 'cancelled' && writtenOf(t) > 0)
|
||||
const stoppedParts = { tables: stopped.length, parts: stopped.reduce((n, t) => n + writtenOf(t), 0) }
|
||||
return (
|
||||
<div className="min-w-0 space-y-3">
|
||||
{busy && <Notice tone="info" attr={{ 'data-draft-busy': '' }}>{DT.busy}</Notice>}
|
||||
@@ -468,7 +471,12 @@ function JobView({ job, running, stopping, busy, blankTables, onRedraftBlank }:
|
||||
{running && <p className="text-2xs leading-relaxed text-faint">{DT.hideHint}</p>}
|
||||
{job.status === 'failed' && job.error && <Notice tone="err" attr={{ 'data-draft-job-error': '' }}>{job.error}</Notice>}
|
||||
{job.status === 'interrupted' && <Notice tone="warn" attr={{ 'data-draft-interrupted': '' }}>{DT.interrupted}</Notice>}
|
||||
{job.status === 'cancelled' && <p className="text-2xs text-dim" data-draft-cancelled="">{DT.cancelled}</p>}
|
||||
{job.status === 'cancelled' && (
|
||||
<p className="text-2xs leading-relaxed text-dim" data-draft-cancelled="">
|
||||
{DT.cancelled}
|
||||
{stoppedParts.tables > 0 && <span className="block">{DT.cancelledParts(stoppedParts.tables, stoppedParts.parts)}</span>}
|
||||
</p>
|
||||
)}
|
||||
{problemText && (
|
||||
<p className="text-xs leading-relaxed" data-draft-problems="">{problemText}</p>
|
||||
)}
|
||||
@@ -523,10 +531,20 @@ const TASK_ICON: Record<CatalogDraftTaskStatus, { Icon: LucideIcon | null; color
|
||||
function TaskRow({ task: t, jobError }: { task: CatalogDraftTask; jobError: string | null }) {
|
||||
const { Icon, color } = TASK_ICON[t.status]
|
||||
const reason = jobError && t.error === jobError ? (t.model_error ?? '') : taskReason(t)
|
||||
const parts = t.parts_total != null && t.parts_total > 1 && t.status !== 'pending' && t.status !== 'skipped'
|
||||
const started = t.status !== 'pending' && t.status !== 'skipped'
|
||||
const parts = t.parts_total != null && t.parts_total > 1 && started
|
||||
// 段被缩小、拆半后实际的块(比段多时才写);已经写进目录的段:起草中、部分完成时写着(停止也不丢,部分完成的
|
||||
// 段进度是「都结束了」,看不出写进了几段),停下来时补一句剩下几段没起草。服务端的原因里已经写了的不重复,写完了的不写
|
||||
const chunks = started ? chunksOf(t) : null
|
||||
const written = writtenOf(t)
|
||||
const writtenText = written <= 0 || reason.includes(DT.partsWritten(written)) ? ''
|
||||
: t.status === 'running' || t.status === 'partial' ? DT.partsWritten(written)
|
||||
: t.status === 'cancelled' ? DT.partsWrittenStopped(written, Math.max(0, (t.parts_total ?? written) - written)) : ''
|
||||
const changes = DT.taskAdded(t.added, t.updated)
|
||||
const blank = blankOf(t)
|
||||
const sample = t.blank_sample?.filter(Boolean) ?? []
|
||||
// 起草时记下的几句话(服务端原话,不算失败):没收下的有效记录条件和原因,照原样列在这张表下面
|
||||
const notes = t.log?.filter(Boolean) ?? []
|
||||
return (
|
||||
<li className="min-w-0 px-3 py-1.5" data-draft-task={t.table_name} data-status={t.status}>
|
||||
<div className="flex min-w-0 items-center gap-2 text-xs">
|
||||
@@ -537,6 +555,11 @@ function TaskRow({ task: t, jobError }: { task: CatalogDraftTask; jobError: stri
|
||||
{DT.parts(t.parts_done, t.parts_total!)}
|
||||
</span>
|
||||
)}
|
||||
{chunks && (
|
||||
<span className="tnum shrink-0 text-2xs text-faint" title={DT.chunksHint(chunks.total)} data-draft-task-chunks="">
|
||||
{DT.chunks(chunks.done, chunks.total)}
|
||||
</span>
|
||||
)}
|
||||
{changes && (
|
||||
<span className="tnum shrink-0 text-2xs text-faint" title={DT.taskAddedHint(t.added, t.updated, t.removed)}>{changes}</span>
|
||||
)}
|
||||
@@ -545,6 +568,11 @@ function TaskRow({ task: t, jobError }: { task: CatalogDraftTask; jobError: stri
|
||||
{DT.taskStatus[t.status]}
|
||||
</span>
|
||||
</div>
|
||||
{writtenText && (
|
||||
<p className="mt-0.5 break-words pl-5 text-2xs leading-relaxed text-faint" title={DT.partsWrittenHint} data-draft-task-written="">
|
||||
{writtenText}
|
||||
</p>
|
||||
)}
|
||||
{reason && (
|
||||
<p className="mt-0.5 break-words pl-5 text-2xs leading-relaxed text-dim" data-draft-task-reason="">{reason}</p>
|
||||
)}
|
||||
@@ -556,6 +584,9 @@ function TaskRow({ task: t, jobError }: { task: CatalogDraftTask; jobError: stri
|
||||
</details>
|
||||
)
|
||||
: <p className="mt-0.5 pl-5 text-2xs leading-relaxed text-dim" data-draft-task-blank={blank}>{DT.blank(blank)}</p>)}
|
||||
{notes.map((line, i) => (
|
||||
<p key={i} className="mt-0.5 break-words pl-5 text-2xs leading-relaxed text-faint" data-draft-task-log="">{line}</p>
|
||||
))}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { AlertTriangle, CheckCheck, RotateCcw, RotateCw, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { api } from '../../api/client'
|
||||
import type {
|
||||
CatalogDetail, CatalogReviewAction, CatalogReviewItem, CatalogReviewKind, CatalogReviewProgress, CatalogTableRow,
|
||||
} from '../../types'
|
||||
import { EmptyState, ErrorState, Notice, Skeleton, Spinner, confirmDialog, toast, useRadioGroup } from '../../components/ui'
|
||||
import { errorMessage } from '../../lib/errors'
|
||||
import { CATALOG_REVIEW_TEXT as RT } from '../../lib/terms'
|
||||
import { StatusChip } from './parts'
|
||||
import {
|
||||
REVIEW_BATCH_MAX, REVIEW_KINDS, REVIEW_PAGE, applyResults, batchTargets, confirmProgress, itemKey, pendingTotal, reviewWhere,
|
||||
reviewedCount, statusOf, syncFromDetail,
|
||||
} from './review'
|
||||
import type { ItemOutcome } from './review'
|
||||
|
||||
// ===========================================================================
|
||||
// 审阅队列(数据目录页的「审阅队列」视图,左栏):跨表列出推断项,顺序由服务端排好(所在表的使用次数 × 这一项对数字的
|
||||
// 影响 × 来源),每一项写为什么排在这里。可以按项(影响数字的项 / 名称和含义)和表筛选;多选后成批确认、驳回,审阅过的
|
||||
// 原地标出结果(冲突的标出来),选中可以撤销审阅;刷新队列后审阅过的才移除,列表头的「N 项待确认」审阅成功一项就少一项
|
||||
// (pendingTotal,和视图标签、按表筛选的计数同一个口径)。顶上是审阅进度:常用表(使用次数最多、
|
||||
// 被运行用过的前 20 张)、全部表、影响数字的项各确认了多少,三行同一个口径。点开一项,右栏是所在表的详情(滚到那一项、
|
||||
// 标出来)和用过这张表的运行。
|
||||
// ===========================================================================
|
||||
|
||||
export function ReviewQueue({ sourceId, rows, kind, table, activeKey, detailSync, onParams, onOpenItem, onChanged }: {
|
||||
sourceId: string
|
||||
/** 目录清单的各行:按表筛选的候选(有推断项的表)、表的中文名 */
|
||||
rows: CatalogTableRow[]
|
||||
kind: CatalogReviewKind
|
||||
/** 只看这一张表;空串是全部表 */
|
||||
table: string
|
||||
/** 右栏正打开的那一项(itemKey) */
|
||||
activeKey: string | null
|
||||
/** 右栏的表详情载入、改过之后的目录:队列里这张表的项跟着换状态 */
|
||||
detailSync: { seq: number; detail: CatalogDetail } | null
|
||||
onParams: (p: { kind?: CatalogReviewKind; table?: string }) => void
|
||||
onOpenItem: (item: CatalogReviewItem) => void
|
||||
/** 成批审阅写进了这些表:页面重取清单、正在看的表重新载入 */
|
||||
onChanged: (tables: string[]) => void
|
||||
}) {
|
||||
const [items, setItems] = useState<CatalogReviewItem[] | null>(null)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loadError, setLoadError] = useState<unknown>(null)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [outcomes, setOutcomes] = useState<Map<string, ItemOutcome>>(() => new Map())
|
||||
const [versions, setVersions] = useState<Map<string, number>>(() => new Map())
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set())
|
||||
const [acting, setActing] = useState<CatalogReviewAction | null>(null)
|
||||
const [summary, setSummary] = useState<{ action: CatalogReviewAction; ok: number; conflicts: number; failed: number } | null>(null)
|
||||
const [batchError, setBatchError] = useState<string | null>(null)
|
||||
const [progress, setProgress] = useState<CatalogReviewProgress | null>(null)
|
||||
const [progressError, setProgressError] = useState(false)
|
||||
const [reloadSeq, setReloadSeq] = useState(0)
|
||||
const loadSeq = useRef(0)
|
||||
const itemsRef = useRef(items)
|
||||
itemsRef.current = items
|
||||
const outcomesRef = useRef(outcomes)
|
||||
outcomesRef.current = outcomes
|
||||
const versionsRef = useRef(versions)
|
||||
versionsRef.current = versions
|
||||
const allRef = useRef<HTMLInputElement>(null)
|
||||
const radio = useRadioGroup(REVIEW_KINDS, kind, (k) => onParams({ kind: k }))
|
||||
|
||||
const loadProgress = useCallback(() => {
|
||||
api.dataCatalog.reviewProgress(sourceId).then((p) => { setProgress(p); setProgressError(false) }, () => setProgressError(true))
|
||||
}, [sourceId])
|
||||
useEffect(() => { loadProgress() }, [loadProgress])
|
||||
|
||||
// 第一页:筛选变了、点了刷新就从头取,审阅过的标记、选择、汇总都清掉
|
||||
useEffect(() => {
|
||||
const mine = ++loadSeq.current
|
||||
setItems(null)
|
||||
setLoadError(null)
|
||||
api.dataCatalog.reviewQueue(sourceId, { limit: REVIEW_PAGE, offset: 0, kind, table: table || null }).then((q) => {
|
||||
if (mine !== loadSeq.current) return
|
||||
setItems(q.items)
|
||||
setTotal(q.total)
|
||||
setOutcomes(new Map())
|
||||
setVersions(new Map())
|
||||
setSelected(new Set())
|
||||
setSummary(null)
|
||||
setBatchError(null)
|
||||
}, (e) => { if (mine === loadSeq.current) setLoadError(e) })
|
||||
}, [sourceId, kind, table, reloadSeq])
|
||||
|
||||
const refresh = () => {
|
||||
setReloadSeq((n) => n + 1)
|
||||
loadProgress()
|
||||
}
|
||||
|
||||
/**
|
||||
* 再载入一页。服务端的队列里已经没有审阅成功、不再是推断的项:偏移按「这一页上还在服务端队列里的项」数,
|
||||
* 不会跳过没载入的项
|
||||
*/
|
||||
const loadMore = async () => {
|
||||
const list = itemsRef.current
|
||||
if (!list || loadingMore) return
|
||||
const gone = reviewedCount(list, outcomes)
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
const q = await api.dataCatalog.reviewQueue(sourceId, { limit: REVIEW_PAGE, offset: list.length - gone, kind, table: table || null })
|
||||
setItems((cur) => {
|
||||
const have = new Set((cur ?? []).map(itemKey))
|
||||
return [...(cur ?? []), ...q.items.filter((i) => !have.has(itemKey(i)))]
|
||||
})
|
||||
setTotal(q.total + gone)
|
||||
} catch (e) {
|
||||
toast.error(e)
|
||||
} finally {
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 右栏的表详情载入、改过:这张表的项按最新的目录换状态
|
||||
const syncSeen = useRef(detailSync?.seq ?? 0)
|
||||
useEffect(() => {
|
||||
if (!detailSync || detailSync.seq === syncSeen.current) return
|
||||
syncSeen.current = detailSync.seq
|
||||
const list = itemsRef.current
|
||||
if (!list) return
|
||||
const d = detailSync.detail
|
||||
if (!list.some((i) => i.table_name === d.table_name)) return
|
||||
const next = syncFromDetail(list, d.table_name, d.notes, d.version, outcomesRef.current, versionsRef.current)
|
||||
setOutcomes(next.outcomes)
|
||||
setVersions(next.versions)
|
||||
loadProgress()
|
||||
}, [detailSync, loadProgress])
|
||||
|
||||
const act = async (action: CatalogReviewAction) => {
|
||||
const list = itemsRef.current ?? []
|
||||
const targets = batchTargets(list, selected, action, outcomes)
|
||||
if (!targets.length || acting) return
|
||||
if (targets.length > REVIEW_BATCH_MAX) {
|
||||
setBatchError(RT.tooMany(REVIEW_BATCH_MAX))
|
||||
return
|
||||
}
|
||||
if (action !== 'reset') {
|
||||
const ok = await confirmDialog(action === 'confirm'
|
||||
? { title: RT.confirmTitle(targets.length), consequences: RT.confirmConsequences, confirmLabel: RT.confirmAction(targets.length) }
|
||||
: { title: RT.rejectTitle(targets.length), consequences: RT.rejectConsequences, confirmLabel: RT.rejectAction(targets.length), danger: true })
|
||||
if (!ok) return
|
||||
}
|
||||
setActing(action)
|
||||
setBatchError(null)
|
||||
try {
|
||||
const { results } = await api.dataCatalog.reviewBatch(sourceId, targets.map((i) => ({
|
||||
table_name: i.table_name, path: i.path, action, if_version: versions.get(i.table_name) ?? i.version,
|
||||
})))
|
||||
const next = applyResults(results, action, outcomes, versions)
|
||||
setOutcomes(next.outcomes)
|
||||
setVersions(next.versions)
|
||||
setSelected(new Set())
|
||||
const ok = results.filter((r) => r.ok)
|
||||
const conflicts = results.filter((r) => !r.ok && next.outcomes.get(itemKey(r))?.kind === 'conflict').length
|
||||
setSummary({ action, ok: ok.length, conflicts, failed: results.length - ok.length - conflicts })
|
||||
if (ok.length) onChanged([...new Set(ok.map((r) => r.table_name))])
|
||||
loadProgress()
|
||||
} catch (e) {
|
||||
setBatchError(RT.failedAll(errorMessage(e)))
|
||||
} finally {
|
||||
setActing(null)
|
||||
}
|
||||
}
|
||||
|
||||
// 按表筛选的候选:有推断项的表(使用次数多的在前),正在筛的那张不在里面也列上
|
||||
const tableOptions = useMemo(() => {
|
||||
const list = rows.filter((r) => r.counts.proposed > 0)
|
||||
if (table && !list.some((r) => r.table_name === table)) {
|
||||
const row = rows.find((r) => r.table_name === table)
|
||||
return [...list, row ?? ({ table_name: table, label: null, counts: { proposed: 0 } } as unknown as CatalogTableRow)]
|
||||
}
|
||||
return list
|
||||
}, [rows, table])
|
||||
const labelOf = useMemo(() => new Map(rows.map((r) => [r.table_name, r.label])), [rows])
|
||||
|
||||
const loaded = items ?? []
|
||||
const picked = loaded.filter((i) => selected.has(itemKey(i))).length
|
||||
const all = loaded.length > 0 && picked === loaded.length
|
||||
useEffect(() => {
|
||||
if (allRef.current) allRef.current.indeterminate = picked > 0 && !all
|
||||
}, [picked, all])
|
||||
const toggleAll = () => setSelected(all ? new Set() : new Set(loaded.map(itemKey)))
|
||||
const toggle = useCallback((key: string) => setSelected((cur) => {
|
||||
const next = new Set(cur)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
return next
|
||||
}), [])
|
||||
const counts = {
|
||||
confirm: batchTargets(loaded, selected, 'confirm', outcomes).length,
|
||||
reject: batchTargets(loaded, selected, 'reject', outcomes).length,
|
||||
reset: batchTargets(loaded, selected, 'reset', outcomes).length,
|
||||
}
|
||||
const filtered = kind !== 'all' || !!table
|
||||
// 还没载入的项(total 里含列表里审阅过、刷新前还留着的项);列表头写的是还待确认的(审阅过的减掉)
|
||||
const remaining = Math.max(0, total - loaded.length)
|
||||
const pending = pendingTotal(total, loaded, outcomes)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col" data-review-queue="" aria-label={RT.queueLabel}>
|
||||
<ProgressBlock progress={progress} failed={progressError} />
|
||||
<div className="space-y-2 border-b px-3 py-2.5">
|
||||
<div role="radiogroup" aria-label={RT.kindLabel} className="flex flex-wrap gap-1" data-review-kinds={kind}>
|
||||
{REVIEW_KINDS.map((k) => (
|
||||
<button key={k} type="button" {...radio(k)} title={RT.kindHint[k]} onClick={() => onParams({ kind: k })} data-review-kind={k}
|
||||
className={clsx(
|
||||
'inline-flex items-center rounded-md border px-2 py-0.5 text-2xs outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]',
|
||||
kind === k ? 'border-[var(--accent)] bg-accent-soft text-fg' : 'text-dim hover:bg-hover hover:text-fg',
|
||||
)}>
|
||||
{RT.kind[k]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="sr-only" htmlFor="catalog-review-table">{RT.tableLabel}</label>
|
||||
<select id="catalog-review-table" className="field min-w-0 flex-1 !py-0.5 !text-2xs" value={table}
|
||||
onChange={(e) => onParams({ table: e.target.value })} data-review-table="">
|
||||
<option value="">{RT.tableAll}</option>
|
||||
{tableOptions.map((r) => (
|
||||
<option key={r.table_name} value={r.table_name}>{RT.tableOption(r.label, r.table_name, r.counts.proposed)}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="btn btn-sm btn-ghost shrink-0 !px-1.5" onClick={refresh} aria-label={RT.refresh} title={RT.refreshHint}
|
||||
data-review-refresh="">
|
||||
<RotateCw size={12} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-2xs text-faint">
|
||||
<input ref={allRef} type="checkbox" checked={all} onChange={toggleAll} disabled={!loaded.length}
|
||||
aria-label={RT.selectAll} title={RT.selectAll} data-review-select-all="" />
|
||||
<span className="tnum min-w-0 flex-1 truncate" data-review-total="">
|
||||
{selected.size ? RT.selected(selected.size) : items ? RT.total(pending) : RT.loading}
|
||||
</span>
|
||||
{selected.size > 0 && (
|
||||
<button type="button" className="inline-flex items-center gap-0.5 rounded px-1 hover:bg-hover hover:text-fg"
|
||||
onClick={() => setSelected(new Set())} data-review-clear="">
|
||||
<X size={10} aria-hidden /> {RT.clearSelection}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{summary && (
|
||||
<p className={clsx('flex items-start gap-1.5 border-b px-3 py-2 text-2xs leading-relaxed', summary.conflicts || summary.failed ? 'text-[var(--warn)]' : 'text-dim')}
|
||||
role="status" data-review-summary="">
|
||||
{summary.conflicts || summary.failed
|
||||
? <AlertTriangle size={11} className="mt-0.5 shrink-0" aria-hidden />
|
||||
: <CheckCheck size={11} className="mt-0.5 shrink-0" style={{ color: 'var(--st-done)' }} aria-hidden />}
|
||||
<span className="min-w-0">{RT.summary(summary.action, summary.ok, summary.conflicts, summary.failed)}</span>
|
||||
</p>
|
||||
)}
|
||||
{batchError && <div className="border-b px-3 py-2"><Notice tone="err" attr={{ 'data-review-batch-error': '' }}>{batchError}</Notice></div>}
|
||||
|
||||
<div className="relative min-h-0 flex-1 overflow-y-auto">
|
||||
{loadError
|
||||
? <div className="p-3"><ErrorState error={loadError} onRetry={refresh} compact /></div>
|
||||
: !items
|
||||
? <div className="p-3"><Skeleton rows={6} height={44} gap={8} /></div>
|
||||
: !items.length
|
||||
? (
|
||||
<EmptyState icon={<CheckCheck size={20} />} title={filtered ? RT.emptyFiltered : RT.empty}
|
||||
body={filtered ? RT.emptyFilteredBody : RT.emptyBody} offline={false} className="!py-10" />
|
||||
)
|
||||
: (
|
||||
<ul aria-label={RT.queueLabel}>
|
||||
{items.map((i) => {
|
||||
const key = itemKey(i)
|
||||
return (
|
||||
<QueueRow key={key} item={i} itemKeyText={key} tableLabel={labelOf.get(i.table_name) ?? null}
|
||||
outcome={outcomes.get(key)} checked={selected.has(key)} active={activeKey === key}
|
||||
onToggle={toggle} onOpen={onOpenItem} />
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{items && remaining > 0 && (
|
||||
<div className="border-t px-3 py-2">
|
||||
<button type="button" className="btn btn-sm w-full" onClick={() => void loadMore()} disabled={loadingMore} data-review-more="">
|
||||
{loadingMore ? <><Spinner size={11} /> {RT.loading}</> : RT.more(Math.min(REVIEW_PAGE, remaining))}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selected.size > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 border-t px-3 py-2" data-review-batch="">
|
||||
<span className="tnum text-2xs text-faint">{RT.selected(selected.size)}</span>
|
||||
<span className="flex-1" />
|
||||
<button type="button" className="btn btn-sm btn-primary" disabled={!counts.confirm || !!acting} onClick={() => void act('confirm')}
|
||||
data-review-action="confirm">
|
||||
{acting === 'confirm' ? <Spinner size={11} /> : null}{RT.confirm(counts.confirm)}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm" disabled={!counts.reject || !!acting} onClick={() => void act('reject')}
|
||||
data-review-action="reject">
|
||||
{acting === 'reject' ? <Spinner size={11} /> : null}{RT.reject(counts.reject)}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm btn-ghost" disabled={!counts.reset || !!acting} onClick={() => void act('reset')}
|
||||
title={RT.resetHint} data-review-action="reset">
|
||||
{acting === 'reset' ? <Spinner size={11} /> : <RotateCcw size={11} aria-hidden />} {RT.reset(counts.reset)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 队列里的一行:复选框、位置和所在表、值、说明、为什么排在这里、来源和状态(审阅过的写结果)、所在表的使用次数 */
|
||||
const QueueRow = memo(function QueueRow({ item: i, itemKeyText, tableLabel, outcome, checked, active, onToggle, onOpen }: {
|
||||
item: CatalogReviewItem
|
||||
itemKeyText: string
|
||||
tableLabel: string | null
|
||||
outcome: ItemOutcome | undefined
|
||||
checked: boolean
|
||||
active: boolean
|
||||
onToggle: (key: string) => void
|
||||
onOpen: (item: CatalogReviewItem) => void
|
||||
}) {
|
||||
const status = statusOf(i, outcome)
|
||||
// 位置按 path 说(「列 status 的码值」):服务端的 field_label 只是字段的叫法,列级的项只写它分不清是哪一列
|
||||
const where = reviewWhere(i)
|
||||
const tableText = tableLabel ? `${tableLabel}(${i.table_name})` : i.table_name
|
||||
const mono = i.field === 'valid_filter' || i.field === 'keys'
|
||||
return (
|
||||
<li className={clsx('relative flex items-stretch border-b border-hairline', active ? 'bg-accent-soft' : 'hover:bg-hover')}
|
||||
data-review-item={`${i.table_name}::${i.path}`} data-review-result={outcome?.kind}>
|
||||
{active && <span className="absolute inset-y-0 left-0 w-0.5 bg-[var(--accent)]" aria-hidden />}
|
||||
<span className="flex shrink-0 items-start pl-3 pr-1 pt-2.5">
|
||||
<input type="checkbox" checked={checked} onChange={() => onToggle(itemKeyText)} aria-label={RT.select(where, i.table_name)}
|
||||
data-review-select="" />
|
||||
</span>
|
||||
<button type="button" className="min-w-0 flex-1 px-2 py-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--accent)]"
|
||||
onClick={() => onOpen(i)} aria-current={active ? 'true' : undefined} title={RT.open(where, i.table_name)} data-review-open="">
|
||||
<span className="flex items-baseline gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium text-fg" data-review-where="">{where}</span>
|
||||
<span className="tnum shrink-0 text-2xs text-faint" title={RT.usageHint}>{RT.usage(i.table_usage)}</span>
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-2xs text-faint" title={tableText}>{tableText}</span>
|
||||
<span className={clsx('mt-1 block break-words text-2xs text-dim', mono && 'mono', status === 'rejected' && 'line-through')}
|
||||
data-review-value="">{i.display || '—'}</span>
|
||||
{i.note && <span className="mt-0.5 block break-words text-2xs leading-relaxed text-faint" data-review-note="">{i.note}</span>}
|
||||
{i.reasons.length > 0 && (
|
||||
<span className="mt-1 block text-2xs leading-relaxed text-faint" data-review-reasons="">
|
||||
<span className="sr-only">{RT.reasonsLabel}:</span>{i.reasons.join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
<span className="mt-1 flex flex-wrap items-center gap-1.5" data-review-outcome="">
|
||||
<StatusChip status={status} source={i.source} />
|
||||
{outcome?.kind === 'ok' && <span className="text-2xs" style={{ color: 'var(--st-done)' }}>{RT.done[outcome.action]}</span>}
|
||||
{outcome?.kind === 'conflict' && (
|
||||
<span className="text-2xs text-[var(--warn)]" title={outcome.message ?? undefined}>{RT.conflict}</span>
|
||||
)}
|
||||
{outcome?.kind === 'error' && (
|
||||
<span className="text-2xs text-[var(--err)]">{outcome.message ? `${RT.failed}:${outcome.message}` : RT.failed}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* 审阅进度:常用表、全部表、影响数字的项,三行同一个口径——已确认 ÷(已确认 + 待确认),已驳回的、有确证的不计入,
|
||||
* 审完了就是 100%。常用表只算被运行用过的(最多 top_n 张),标签写实际几张,一张都没有时写明还没有运行用过的表。
|
||||
* 数字写在字里,色条只给眼睛看
|
||||
*/
|
||||
function ProgressBlock({ progress, failed }: { progress: CatalogReviewProgress | null; failed: boolean }) {
|
||||
if (!progress) {
|
||||
return (
|
||||
<div className="border-b px-3 py-2 text-2xs text-faint" data-review-progress={failed ? 'error' : 'loading'}>
|
||||
{failed ? RT.progressFailed : <Skeleton rows={2} height={10} gap={6} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const { top, all, facts } = progress
|
||||
const topTables = Math.max(0, top.tables ?? 0)
|
||||
const topLine = confirmProgress(top.items_confirmed, top.items_proposed)
|
||||
const allLine = confirmProgress(all.items_confirmed, all.items_proposed)
|
||||
const factsLine = confirmProgress(facts.confirmed, facts.proposed)
|
||||
return (
|
||||
<section className="space-y-1.5 border-b px-3 py-2.5" aria-label={RT.progressTitle} data-review-progress="">
|
||||
{topTables > 0
|
||||
? (
|
||||
<ProgressLine label={RT.progressTop(topTables)} hint={RT.progressTopHint(topTables, progress.top_n)} line={topLine}
|
||||
total={top.items_total} attr="top" />
|
||||
)
|
||||
: (
|
||||
<div className="flex items-baseline gap-2 text-2xs" data-review-progress-line="top" data-review-progress-top="" data-review-progress-none="">
|
||||
<span className="shrink-0 font-medium text-dim" title={RT.progressTopHint(0, progress.top_n)}>{RT.progressTopNone}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-right text-faint">{RT.progressTopEmpty}</span>
|
||||
</div>
|
||||
)}
|
||||
<ProgressLine label={RT.progressAll} hint={RT.progressAllHint} line={allLine} total={all.items_total} attr="all" />
|
||||
<ProgressLine label={RT.progressFacts} hint={RT.progressFactsHint} line={factsLine} total={null} attr="facts"
|
||||
warn={facts.proposed > 0} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressLine({ label, hint, line, total, attr, warn = false }: {
|
||||
label: string
|
||||
hint: string
|
||||
line: ReturnType<typeof confirmProgress>
|
||||
/** 这一行一共几项(全部状态):0 时写还没有目录项;不知道时为 null */
|
||||
total: number | null
|
||||
attr: string
|
||||
warn?: boolean
|
||||
}) {
|
||||
const text = RT.progressText(line.pct, line.confirmed, line.proposed, total)
|
||||
return (
|
||||
<div className="text-2xs" data-review-progress-line={attr} {...{ [`data-review-progress-${attr}`]: '' }}
|
||||
data-review-progress-pct={line.pct ?? ''}>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="shrink-0 font-medium text-dim" title={`${hint}\n${RT.progressRule}`}>{label}</span>
|
||||
<span className={clsx('tnum min-w-0 flex-1 truncate text-right', warn ? 'text-[var(--st-waiting)]' : 'text-faint')}
|
||||
title={`${text}\n${RT.progressRule}`}>{text}</span>
|
||||
</div>
|
||||
{line.pct != null && (
|
||||
<div className="mt-0.5 h-1 overflow-hidden rounded-full bg-hover" aria-hidden>
|
||||
<div className="h-full rounded-full bg-[var(--st-done)]" style={{ width: `${line.pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 审阅视图里没打开项时的右栏:怎么排的序、点开能看到什么 */
|
||||
export function ReviewOverview() {
|
||||
return (
|
||||
<div className="relative min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-xl space-y-3 p-6" data-review-overview="">
|
||||
<h2 className="text-sm font-semibold">{RT.overviewTitle}</h2>
|
||||
<p className="text-xs leading-relaxed text-dim">{RT.overviewBody}</p>
|
||||
<p className="tnum text-2xs text-faint">{RT.kindHint.facts}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
|
||||
import { ArrowUp, BookMarked, Database, Info, MessagesSquare, Minus, Plus, Square, Trash2, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { ApiError, api, streamCatalogAssistant } from '../../api/client'
|
||||
import type { CatalogAssistantEnd } from '../../api/client'
|
||||
import type { CatalogDetail } from '../../types'
|
||||
import { Notice, Spinner, confirmDialog, isComposing, toast } from '../../components/ui'
|
||||
import { useLeaveGuard } from '../../lib/leave'
|
||||
import { CATALOG_ASSISTANT_TEXT as T, CATALOG_STATUS_LABEL } from '../../lib/terms'
|
||||
import { Markdown } from '../../run/Markdown'
|
||||
import { beforeStatusShown, patchBeforeText, patchValueText } from '../../run/catalogPatch'
|
||||
import {
|
||||
ASSISTANT_MAX_CHARS, ASSISTANT_MAX_SAVE, QUERY_ROWS_SHOWN, applyEvent, applyPreview, assistantChangesOf, blankColumnsOf, blankLeadOf,
|
||||
changeStateOf, changeWhere, dropPending, historyOf, mergeProposal, pendingForAssistant, pendingSubmit, pendingVersion, savedKey,
|
||||
} from './assistant'
|
||||
import type { AssistantTurn, ChangeState, PendingChange, Turn, TurnPart } from './assistant'
|
||||
|
||||
// ===========================================================================
|
||||
// 表助手:在表详情里和助手对话完善这一张表的数据目录(POST /catalog/{table}/assistant,事件流)。
|
||||
//
|
||||
// 回答流式显示,能停止;助手跑过的只读查询(只有开了数据剖析的源才有)写 SQL 和前几行,默认收起;修改提案逐项写改前、
|
||||
// 改后、依据,可以逐项去掉、加回。提案并进「待保存的修改」:跨多轮累积,同一项以最后一次提案为准,一键保存(最多 200 项)。
|
||||
// 保存带待保存的修改里最早那条提案对照的版本:别人在那之后改过就 409,按最新内容重算(预览接口,只读)后停下来让人再看
|
||||
// 一眼;只重算交去保存的那几项,保存期间回答里又到的提案原样留着。对话不存服务端:每一轮把前面的对话和待保存的修改
|
||||
// (最多 200 项)一起交上去;关闭、换表、离开页面就没有了,有待保存的修改时先问。
|
||||
// 开场:上次起草有留空的列时,引导先问这几列——列名取那次起草记下的(任务里这张表的 blank_sample),拿不到时改说目录里
|
||||
// 还没有中文名和含义的列。
|
||||
// ===========================================================================
|
||||
|
||||
type SaveState =
|
||||
| { phase: 'idle'; error?: string | null; saved?: { n: number; version: number } | null }
|
||||
| { phase: 'saving' }
|
||||
| { phase: 'reloading' }
|
||||
| { phase: 'conflict'; problems: string[] }
|
||||
|
||||
const charCount = (s: string) => [...s.trim()].length
|
||||
|
||||
export function TableAssistant({ sourceId, table, tableLabel, detail, profileEnabled, lastBlank, editing, onSaved, onClose }: {
|
||||
sourceId: string
|
||||
table: string
|
||||
/** 表的中文名(没有就是表名):标题、提示里用 */
|
||||
tableLabel: string
|
||||
/** 页面上这张表最新的目录:留空的列、保存后更新 */
|
||||
detail: CatalogDetail
|
||||
/** 数据源开了数据剖析:助手才能发只读查询 */
|
||||
profileEnabled: boolean
|
||||
/**
|
||||
* 上次起草模型拿不准、留空的列数(n)和出处:开场引导先问这几列。jobId 是那次起草的任务(从中取起草时记下的列名);
|
||||
* earlier:最近一次停止了,n 是再之前那次起草记下的(不知道是哪一次,取不到列名)
|
||||
*/
|
||||
lastBlank: { n: number; earlier: boolean; jobId: string | null }
|
||||
/** 正在编辑这张表:先不让保存(同一张表两处同时改,保存会撞上版本冲突) */
|
||||
editing: boolean
|
||||
/** 保存成功:页面换上返回的目录 */
|
||||
onSaved: (d: CatalogDetail) => void
|
||||
/** 关上面板(有待保存的修改时这里先问过了) */
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [turns, setTurns] = useState<Turn[]>([])
|
||||
const [pending, setPending] = useState<PendingChange[]>([])
|
||||
const [saved, setSaved] = useState<ReadonlySet<string>>(() => new Set())
|
||||
const [text, setText] = useState('')
|
||||
const [save, setSave] = useState<SaveState>({ phase: 'idle' })
|
||||
const [trayOpen, setTrayOpen] = useState(true)
|
||||
const turnsRef = useRef(turns)
|
||||
turnsRef.current = turns
|
||||
const pendingRef = useRef(pending)
|
||||
pendingRef.current = pending
|
||||
const stopRef = useRef<(() => void) | null>(null)
|
||||
const seq = useRef(0)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const logRef = useRef<HTMLDivElement>(null)
|
||||
const titleId = useId()
|
||||
const streaming = turns.some((t) => t.role === 'assistant' && t.state === 'streaming')
|
||||
const dirty = pending.length > 0 || streaming
|
||||
|
||||
// 打开时焦点给输入框;卸下时停掉进行中的回答
|
||||
useEffect(() => { inputRef.current?.focus() }, [])
|
||||
useEffect(() => () => { stopRef.current?.() }, [])
|
||||
|
||||
useLeaveGuard(pending.length > 0, () => confirmDialog({
|
||||
title: T.leaveTitle, consequences: T.leaveConsequences, confirmLabel: T.leaveConfirm, danger: true,
|
||||
}))
|
||||
|
||||
// 新的内容到了:滚到底(人往上翻着看的时候不打扰)
|
||||
const lastLen = useRef(0)
|
||||
useEffect(() => {
|
||||
const el = logRef.current
|
||||
if (!el) return
|
||||
const size = turns.reduce((n, t) => n + (t.role === 'user' ? 1 : t.parts.length + 1), 0)
|
||||
const near = el.scrollHeight - el.scrollTop - el.clientHeight < 160
|
||||
if (size !== lastLen.current || near) el.scrollTop = el.scrollHeight
|
||||
lastLen.current = size
|
||||
}, [turns])
|
||||
|
||||
// 上次起草记下的留空列名:从那次起草的任务里取(null 是还没取回来;取不到是空数组,改说目录里还没有中文名和含义的列)
|
||||
const blankN = lastBlank.n
|
||||
const blankJob = blankN > 0 && !lastBlank.earlier ? lastBlank.jobId : null
|
||||
const [blankSample, setBlankSample] = useState<string[] | null>(null)
|
||||
useEffect(() => {
|
||||
if (!blankJob) return
|
||||
let live = true
|
||||
setBlankSample(null)
|
||||
api.dataCatalog.draftJob(sourceId, blankJob)
|
||||
.then(({ job }) => job.tasks.find((t) => t.table_name === table)?.blank_sample?.filter(Boolean) ?? [])
|
||||
.catch(() => [] as string[])
|
||||
.then((cols) => { if (live) setBlankSample(cols) })
|
||||
return () => { live = false }
|
||||
}, [sourceId, table, blankJob])
|
||||
// 没有留空的列、还在取那次起草记下的列名时不写
|
||||
const blankLead = useMemo(() => (blankN <= 0 || (blankJob && blankSample == null)
|
||||
? null : blankLeadOf(blankN, blankJob ? blankSample : null, blankColumnsOf(detail))), [blankN, blankJob, blankSample, detail])
|
||||
const hasProposed = useMemo(() => {
|
||||
const n = detail.notes
|
||||
const items = [n.label, n.description, n.grain, n.keys, n.kind, n.business_date, n.valid_filter, n.dedup,
|
||||
...Object.values(n.columns ?? {}).flatMap((c) => Object.values(c ?? {})), ...(n.relations ?? [])]
|
||||
return items.some((it) => !!it && (it as { status?: string }).status === 'proposed')
|
||||
}, [detail])
|
||||
|
||||
const updateTurn = useCallback((id: string, fn: (t: AssistantTurn) => AssistantTurn) => {
|
||||
setTurns((ts) => ts.map((t) => (t.id === id && t.role === 'assistant' ? fn(t) : t)))
|
||||
}, [])
|
||||
|
||||
const send = useCallback((raw: string) => {
|
||||
const question = raw.trim()
|
||||
if (!question || charCount(question) > ASSISTANT_MAX_CHARS) return
|
||||
if (turnsRef.current.some((t) => t.role === 'assistant' && t.state === 'streaming')) return
|
||||
const n = ++seq.current
|
||||
const userTurn: Turn = { id: `u${n}`, role: 'user', text: question }
|
||||
const reply: AssistantTurn = { id: `a${n}`, role: 'assistant', parts: [], state: 'streaming' }
|
||||
const messages = historyOf(turnsRef.current, question)
|
||||
setTurns((ts) => [...ts, userTurn, reply])
|
||||
setText('')
|
||||
let proposals = 0
|
||||
// 这一轮已经收尾(done、error):之后再来的事件不认
|
||||
let ended = false
|
||||
stopRef.current = streamCatalogAssistant(sourceId, table, { messages, pending: pendingForAssistant(pendingRef.current) }, (ev) => {
|
||||
if (ended) return
|
||||
if (ev.type === 'done' || ev.type === 'error') ended = true
|
||||
if (ev.type === 'proposal') {
|
||||
const pid = `${reply.id}.p${++proposals}`
|
||||
const changes = assistantChangesOf(ev.changes)
|
||||
updateTurn(reply.id, (t) => applyEvent(t, ev, pid))
|
||||
if (changes.length) {
|
||||
const version = typeof ev.version === 'number' && Number.isFinite(ev.version) ? ev.version : detail.version
|
||||
setPending((cur) => mergeProposal(cur, changes, pid, version))
|
||||
// 待保存的修改变了:上一次保存的结果、冲突的说明都不再对。正在保存、重算的不动(按钮照旧不能点,结果出来再说)
|
||||
setSave((s) => (s.phase === 'saving' || s.phase === 'reloading' ? s : { phase: 'idle' }))
|
||||
}
|
||||
return
|
||||
}
|
||||
updateTurn(reply.id, (t) => applyEvent(t, ev, ''))
|
||||
}, (end: CatalogAssistantEnd) => {
|
||||
stopRef.current = null
|
||||
updateTurn(reply.id, (t) => {
|
||||
if (t.state !== 'streaming') return t
|
||||
if (end.aborted) return { ...t, state: 'stopped' }
|
||||
if (end.error) return { ...t, state: 'error', error: end.error, code: end.code ?? null }
|
||||
// 流结束了却没有 done:这一轮没答完
|
||||
return { ...t, state: 'error', error: T.interrupted }
|
||||
})
|
||||
})
|
||||
}, [sourceId, table, detail.version, updateTurn])
|
||||
|
||||
const stop = () => stopRef.current?.()
|
||||
|
||||
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key !== 'Enter' || e.shiftKey || isComposing(e)) return
|
||||
e.preventDefault()
|
||||
if (!streaming) send(text)
|
||||
}
|
||||
|
||||
// 两个回调保持不变:流式的正文每来一段只重画正在回答的那一轮
|
||||
const drop = useCallback((path: string) => setPending((cur) => dropPending(cur, path)), [])
|
||||
const restore = useCallback((part: Extract<TurnPart, { kind: 'proposal' }>, path: string) => {
|
||||
const change = part.changes.find((c) => c.path === path)
|
||||
if (change) setPending((cur) => mergeProposal(cur, [change], part.id, part.version))
|
||||
}, [])
|
||||
const clearAll = async () => {
|
||||
if (!pendingRef.current.length) return
|
||||
if (!(await confirmDialog({ title: T.clearTitle(pendingRef.current.length), confirmLabel: T.clearConfirm, danger: true }))) return
|
||||
setPending([])
|
||||
setSave({ phase: 'idle' })
|
||||
}
|
||||
|
||||
const doSave = async () => {
|
||||
const list = pendingRef.current
|
||||
if (!list.length || list.length > ASSISTANT_MAX_SAVE || editing) return
|
||||
setSave({ phase: 'saving' })
|
||||
try {
|
||||
const d = await api.dataCatalog.patch(sourceId, table, pendingSubmit(list), pendingVersion(list))
|
||||
setSaved((cur) => new Set([...cur, ...list.map((p) => savedKey(p.origin, p.path))]))
|
||||
setPending((cur) => cur.filter((p) => !list.includes(p)))
|
||||
setSave({ phase: 'idle', saved: { n: list.length, version: d.version } })
|
||||
onSaved(d)
|
||||
toast.ok(T.savedToast(list.length, tableLabel))
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 409) {
|
||||
// 别人刚改过:按最新内容重算(只读),停下来让人再看一眼
|
||||
setSave({ phase: 'reloading' })
|
||||
try {
|
||||
const p = await api.dataCatalog.patchPreview(sourceId, table, pendingSubmit(list))
|
||||
// 只重算交去保存的那几项:保存、重算期间回答里又到的提案不在预览里,原样留着
|
||||
setPending((cur) => applyPreview(cur, p, list))
|
||||
setSave({ phase: 'conflict', problems: p.problems })
|
||||
} catch {
|
||||
setSave({ phase: 'idle', error: T.conflictFailed })
|
||||
}
|
||||
} else if (e instanceof ApiError && e.kind === 'http' && (e.status === 422 || e.status === 404)) {
|
||||
setSave({ phase: 'idle', error: e.message })
|
||||
} else {
|
||||
setSave({ phase: 'idle' })
|
||||
toast.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const close = async () => {
|
||||
if (dirty && !(await confirmDialog({ title: T.closeTitle, consequences: T.closeConsequences, confirmLabel: T.closeConfirm, danger: true }))) return
|
||||
stopRef.current?.()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const onPanelKey = (e: ReactKeyboardEvent<HTMLElement>) => {
|
||||
if (e.key !== 'Escape' || isComposing(e) || e.defaultPrevented) return
|
||||
e.preventDefault()
|
||||
void close()
|
||||
}
|
||||
|
||||
const fresh = turns.length === 0
|
||||
const len = charCount(text)
|
||||
const over = len > ASSISTANT_MAX_CHARS
|
||||
const lastAssistant = [...turns].reverse().find((t): t is AssistantTurn => t.role === 'assistant')
|
||||
const status = streaming ? T.answering : lastAssistant?.state === 'stopped' ? T.stopped : lastAssistant ? T.answered : ''
|
||||
|
||||
return (
|
||||
<aside
|
||||
// 贴着详情的右边:窄一些的屏幕盖在详情上面(手机上铺满);宽屏(xl 起)详情让出这一栏的宽度,不带阴影
|
||||
className={clsx('absolute inset-y-0 right-0 z-30 flex w-full min-h-0 flex-col border-l bg-panel shadow-elev-2 sm:w-[400px]',
|
||||
'xl:z-10 xl:w-[380px] xl:shadow-none 2xl:w-[420px]')}
|
||||
aria-labelledby={titleId}
|
||||
onKeyDown={onPanelKey}
|
||||
data-table-assistant={table}
|
||||
data-streaming={streaming ? 'true' : 'false'}
|
||||
>
|
||||
<header className="flex items-start gap-2 border-b px-3 py-2.5">
|
||||
<MessagesSquare size={14} className="mt-0.5 shrink-0" style={{ color: 'var(--accent)' }} aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 id={titleId} className="text-xs font-semibold">{T.title}</h3>
|
||||
<p className="truncate text-2xs text-faint" title={T.subtitle(tableLabel)}>{T.subtitle(tableLabel)}</p>
|
||||
</div>
|
||||
<button type="button" className="shrink-0 rounded p-1 text-faint outline-none hover:bg-hover hover:text-fg focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||
onClick={() => void close()} aria-label={T.close} title={T.close} data-assistant-close="">
|
||||
<X size={13} aria-hidden />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="border-b px-3 py-2 text-2xs leading-relaxed">
|
||||
{profileEnabled
|
||||
? (
|
||||
<p className="flex items-start gap-1.5 text-faint" data-assistant-profile-on="">
|
||||
<Database size={11} className="mt-0.5 shrink-0" aria-hidden /> <span className="min-w-0">{T.profileOn}</span>
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<p className="flex items-start gap-1.5 text-dim" data-assistant-no-profile="">
|
||||
<Info size={11} className="mt-0.5 shrink-0" style={{ color: 'var(--st-waiting)' }} aria-hidden /> <span className="min-w-0">{T.noProfile}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div ref={logRef} className="min-h-0 flex-1 space-y-3 overflow-y-auto px-3 py-3" role="log" aria-live="polite" aria-relevant="additions"
|
||||
aria-busy={streaming} aria-label={T.logLabel} data-assistant-log="">
|
||||
<div className="space-y-2 rounded-lg border bg-bg px-3 py-2.5 text-xs leading-relaxed" data-assistant-msg="intro">
|
||||
<p className="text-dim" data-assistant-greeting="">{T.greeting}</p>
|
||||
{blankLead && <p className="text-dim" data-assistant-blank={blankLead.from}>{T.blankLead(blankLead)}</p>}
|
||||
<p className="text-2xs text-faint">{T.notStored}</p>
|
||||
{fresh && (
|
||||
<div className="space-y-1 pt-0.5">
|
||||
<p className="text-2xs text-faint">{T.suggestLabel}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{blankLead?.ask && (
|
||||
<SuggestButton kind="blank" label={T.suggestBlank(blankLead.from, blankLead.ask.total)}
|
||||
onClick={() => send(T.suggestBlankText(blankLead.from, blankLead.ask!.columns, blankLead.ask!.total))} />
|
||||
)}
|
||||
<SuggestButton kind="facts" label={T.suggestFacts} onClick={() => send(T.suggestFactsText)} />
|
||||
{hasProposed && <SuggestButton kind="pending" label={T.suggestPending} onClick={() => send(T.suggestPendingText)} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{turns.map((t) => (t.role === 'user'
|
||||
? (
|
||||
<div key={t.id} className="flex justify-end" data-assistant-msg="user">
|
||||
<span className="sr-only">{T.you}:</span>
|
||||
<p className="max-w-[88%] whitespace-pre-wrap break-words rounded-lg bg-accent-soft px-3 py-2 text-xs leading-relaxed"
|
||||
data-assistant-user-text="">
|
||||
{t.text}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
: <AssistantTurnView key={t.id} turn={t} pending={pending} saved={saved} onDrop={drop} onRestore={restore} />))}
|
||||
</div>
|
||||
<p className="sr-only" role="status" data-assistant-status="">{status}</p>
|
||||
|
||||
<SaveNotices save={save} pendingCount={pending.length} />
|
||||
{pending.length > 0 && (
|
||||
<PendingTray pending={pending} open={trayOpen} onToggle={() => setTrayOpen((v) => !v)} save={save} editing={editing}
|
||||
onDrop={drop} onClear={() => void clearAll()} onSave={() => void doSave()} />
|
||||
)}
|
||||
|
||||
<div className="border-t px-3 py-2.5">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="field min-h-[2.75rem] flex-1 resize-none text-xs leading-relaxed"
|
||||
rows={2}
|
||||
value={text}
|
||||
placeholder={T.inputPlaceholder}
|
||||
aria-label={T.inputLabel}
|
||||
aria-invalid={over ? true : undefined}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
data-assistant-input=""
|
||||
/>
|
||||
{streaming
|
||||
? (
|
||||
<button type="button" className="btn btn-sm shrink-0" onClick={stop} aria-label={T.stop} title={T.stop} data-assistant-stop="">
|
||||
<Square size={11} aria-hidden /> <span className="hidden sm:inline">{T.stop}</span>
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<button type="button" className="btn btn-sm btn-primary shrink-0" onClick={() => send(text)} disabled={!text.trim() || over}
|
||||
aria-label={T.send} title={T.inputHint} data-assistant-send="">
|
||||
<ArrowUp size={12} aria-hidden /> <span className="hidden sm:inline">{T.send}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-2xs text-faint">
|
||||
<span className="min-w-0 flex-1 truncate">{over ? <span className="text-[var(--err)]" role="alert">{T.tooLong(ASSISTANT_MAX_CHARS)}</span> : T.inputHint}</span>
|
||||
<span className={clsx('tnum shrink-0', over && 'text-[var(--err)]')} aria-hidden>{T.count(len, ASSISTANT_MAX_CHARS)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function SuggestButton({ kind, label, onClick }: { kind: string; label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button type="button" className="btn btn-sm !text-2xs" onClick={onClick} data-assistant-suggest={kind}>{label}</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 一轮回答:正文、只读查询、修改提案按到来的先后排;末尾写这一轮的结局(出错、停止、用量)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AssistantTurnView = memo(function AssistantTurnView({ turn, pending, saved, onDrop, onRestore }: {
|
||||
turn: AssistantTurn
|
||||
pending: PendingChange[]
|
||||
saved: ReadonlySet<string>
|
||||
onDrop: (path: string) => void
|
||||
onRestore: (part: Extract<TurnPart, { kind: 'proposal' }>, path: string) => void
|
||||
}) {
|
||||
const usage = turn.usage
|
||||
return (
|
||||
<div className="space-y-2" data-assistant-msg="assistant" data-turn-state={turn.state}>
|
||||
<span className="sr-only">{T.assistant}:</span>
|
||||
{turn.parts.map((p, i) => {
|
||||
if (p.kind === 'text') {
|
||||
return (
|
||||
<div key={i} className="min-w-0 break-words text-xs" data-assistant-part="text" data-assistant-text="">
|
||||
<Markdown text={p.text} dense />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (p.kind === 'query') return <QueryBlock key={i} part={p} />
|
||||
return <ProposalCard key={p.id} part={p} pending={pending} saved={saved} onDrop={onDrop} onRestore={onRestore} />
|
||||
})}
|
||||
{turn.state === 'streaming' && (
|
||||
<p className="flex items-center gap-1.5 text-2xs text-faint" aria-hidden><Spinner size={11} /> {T.answering}</p>
|
||||
)}
|
||||
{turn.state === 'error' && (
|
||||
<Notice tone="err" attr={{ 'data-assistant-error': '' }}>
|
||||
{turn.error ?? T.interrupted}
|
||||
</Notice>
|
||||
)}
|
||||
{turn.state === 'stopped' && (
|
||||
<p className="text-2xs text-faint" data-assistant-turn-status="stopped">{T.stopped}</p>
|
||||
)}
|
||||
{turn.state === 'done' && (!!usage || (turn.queries ?? 0) > 0) && (
|
||||
<p className="tnum text-2xs text-faint" data-assistant-turn-status="done">
|
||||
{[usage ? T.usage(usage.input_tokens, usage.output_tokens) : '', turn.queries ? T.queries(turn.queries) : ''].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/** 助手跑过的一条只读查询:摘要写行数(失败写原因),展开看 SQL 和前几行 */
|
||||
function QueryBlock({ part }: { part: Extract<TurnPart, { kind: 'query' }> }) {
|
||||
const failed = !!part.error
|
||||
return (
|
||||
<details className="group rounded-lg border bg-bg text-2xs" data-assistant-part="query" data-assistant-query={failed ? 'error' : 'ok'}>
|
||||
<summary className="flex cursor-pointer select-none items-start gap-1.5 px-2.5 py-1.5 text-dim outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-[var(--accent)]">
|
||||
<Database size={11} className="mt-0.5 shrink-0" style={{ color: failed ? 'var(--err)' : 'var(--accent)' }} aria-hidden />
|
||||
<span className="min-w-0 break-words">{failed ? `${T.queryFailed}:${part.error}` : T.query(part.rowCount, part.truncated)}</span>
|
||||
</summary>
|
||||
<div className="space-y-1.5 border-t px-2.5 py-2">
|
||||
<pre className="mono max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-panel px-2 py-1.5 text-2xs" data-assistant-query-sql="">{part.sql}</pre>
|
||||
{!failed && (part.rows.length > 0 && part.columns.length > 0
|
||||
? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-2xs" data-assistant-query-rows="">
|
||||
<caption className="pb-1 text-left text-faint">
|
||||
{T.queryRows(Math.min(part.rows.length, QUERY_ROWS_SHOWN), part.rowCount, part.truncated)}
|
||||
</caption>
|
||||
<thead>
|
||||
<tr className="border-b text-left text-faint">
|
||||
{part.columns.map((c) => <th key={c} scope="col" className="mono px-1.5 py-1 font-medium">{c}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{part.rows.slice(0, QUERY_ROWS_SHOWN).map((r, i) => (
|
||||
<tr key={i} className="border-b border-hairline last:border-b-0">
|
||||
{part.columns.map((c, j) => <td key={c} className="mono px-1.5 py-1 align-top break-all">{cellText(r[j])}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
: <p className="text-faint">{part.rowCount > 0 ? T.queryRows(0, part.rowCount, part.truncated) : T.queryEmpty}</p>)}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
const cellText = (v: unknown): string => (v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v))
|
||||
|
||||
/** 一项的值写成一行(码值写成「1=已入园、9=已作废」);没有写「未填写」 */
|
||||
const valueText = (path: string, v: unknown) => patchValueText(path, v)
|
||||
|
||||
/** 一条修改提案:逐项写改前、改后、依据;可以逐项去掉、加回。已保存的项不能再加回 */
|
||||
function ProposalCard({ part, pending, saved, onDrop, onRestore }: {
|
||||
part: Extract<TurnPart, { kind: 'proposal' }>
|
||||
pending: PendingChange[]
|
||||
saved: ReadonlySet<string>
|
||||
onDrop: (path: string) => void
|
||||
onRestore: (part: Extract<TurnPart, { kind: 'proposal' }>, path: string) => void
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-lg border bg-bg" aria-label={T.proposal(part.changes.length)} data-assistant-part="proposal" data-assistant-proposal={part.id}>
|
||||
<header className="flex items-center gap-1.5 border-b px-2.5 py-1.5 text-2xs font-semibold">
|
||||
<BookMarked size={11} className="shrink-0" style={{ color: 'var(--accent)' }} aria-hidden />
|
||||
{T.proposal(part.changes.length)}
|
||||
</header>
|
||||
<ol className="divide-y divide-[var(--hairline)]">
|
||||
{part.changes.map((c) => {
|
||||
const state = changeStateOf(pending, saved, part.id, c.path)
|
||||
return (
|
||||
<ChangeRow key={c.path} change={c} state={state}
|
||||
onToggle={state === 'saved' ? undefined : () => (state === 'pending' ? onDrop(c.path) : onRestore(part, c.path))} />
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const STATE_TONE: Record<ChangeState, string> = {
|
||||
pending: 'var(--accent)', replaced: 'var(--text-faint)', dropped: 'var(--text-faint)', saved: 'var(--st-done)',
|
||||
}
|
||||
|
||||
function ChangeRow({ change: c, state, onToggle }: {
|
||||
change: Parameters<typeof changeWhere>[0]
|
||||
state: ChangeState
|
||||
onToggle?: () => void
|
||||
}) {
|
||||
const where = changeWhere(c)
|
||||
const off = state === 'dropped' || state === 'replaced'
|
||||
return (
|
||||
<li className="px-2.5 py-2 text-2xs" data-assistant-change={c.path} data-change-state={state}>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className={clsx('min-w-0 flex-1 break-words text-xs font-medium', off && 'text-faint line-through')}>{where}</span>
|
||||
<span className="shrink-0" style={{ color: STATE_TONE[state] }} data-change-state-label="">{T.state[state]}</span>
|
||||
{onToggle && (
|
||||
<button type="button" className="shrink-0 rounded px-1 text-dim outline-none hover:bg-hover hover:text-fg focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||
onClick={onToggle} aria-label={state === 'pending' ? T.dropLabel(where) : T.restoreLabel(where)} data-assistant-change-toggle="">
|
||||
{state === 'pending' ? <Minus size={10} className="mr-0.5 inline" aria-hidden /> : <Plus size={10} className="mr-0.5 inline" aria-hidden />}
|
||||
{state === 'pending' ? T.drop : T.restore}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<dl className={clsx('mt-1 grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-0.5 leading-relaxed', off && 'opacity-70')}>
|
||||
<dt className="text-faint">{T.before}</dt>
|
||||
<dd className={clsx('break-words', c.before == null || c.before_status === 'rejected' ? 'text-faint' : 'text-dim')} data-change-before=""
|
||||
data-change-before-status={c.before_status ?? undefined}>
|
||||
{patchBeforeText(c)}
|
||||
{beforeStatusShown(c) && <span className="text-faint">({CATALOG_STATUS_LABEL[c.before_status!]})</span>}
|
||||
</dd>
|
||||
<dt className="text-faint">{T.after}</dt>
|
||||
<dd className="break-words text-fg" data-change-after="">
|
||||
{valueText(c.path, c.after)}
|
||||
{c.state === 'confirm' && <span className="ml-1 text-faint">{T.stateConfirm}</span>}
|
||||
</dd>
|
||||
{c.reason && (
|
||||
<>
|
||||
<dt className="text-faint">{T.reason}</dt>
|
||||
<dd className="break-words text-dim" data-change-reason="">{c.reason}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
{c.note && <p className="mt-1 break-words text-faint" data-change-note="">{c.note}</p>}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 待保存的修改:跨多轮累积,可以逐项去掉;一键保存(最多 200 项)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 保存的结果:别人刚改过(409)后按最新内容重算了的说明(已经不合法、去掉了的项列出来)、保存被拒的原话、存上了几项。
|
||||
* 不放在「待保存的修改」里:重算之后可能一项都不剩,那一栏就收起了
|
||||
*/
|
||||
function SaveNotices({ save, pendingCount }: { save: SaveState; pendingCount: number }) {
|
||||
if (save.phase === 'conflict') {
|
||||
return (
|
||||
<div className="border-t px-3 py-2">
|
||||
<Notice tone="warn" attr={{ 'data-assistant-conflict': '' }}>
|
||||
<p className="font-medium">{T.conflictTitle}</p>
|
||||
<p className="text-dim">{T.conflictBody}</p>
|
||||
{save.problems.length > 0 && (
|
||||
<div data-assistant-problems={save.problems.length}>
|
||||
<p className="mt-1">{T.problems}</p>
|
||||
<ul className="list-disc pl-4 text-dim">{save.problems.map((x) => <li key={x}>{x}</li>)}</ul>
|
||||
</div>
|
||||
)}
|
||||
</Notice>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (save.phase !== 'idle') return null
|
||||
if (save.error) return <div className="border-t px-3 py-2"><Notice tone="err" attr={{ 'data-assistant-save-error': '' }}>{save.error}</Notice></div>
|
||||
if (save.saved && !pendingCount) {
|
||||
return <div className="border-t px-3 py-2"><Notice tone="info" attr={{ 'data-assistant-saved': '' }}>{T.saved(save.saved.n, save.saved.version)}</Notice></div>
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function PendingTray({ pending, open, onToggle, save, editing, onDrop, onClear, onSave }: {
|
||||
pending: PendingChange[]
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
save: SaveState
|
||||
editing: boolean
|
||||
onDrop: (path: string) => void
|
||||
onClear: () => void
|
||||
onSave: () => void
|
||||
}) {
|
||||
const n = pending.length
|
||||
const tooMany = n > ASSISTANT_MAX_SAVE
|
||||
const busy = save.phase === 'saving' || save.phase === 'reloading'
|
||||
const listId = useId()
|
||||
return (
|
||||
<section className="border-t bg-elev" aria-label={T.pending(n)} data-assistant-pending={n}>
|
||||
<div className="flex items-center gap-2 px-3 pt-2">
|
||||
<button type="button" className="min-w-0 flex-1 truncate rounded text-left text-xs font-medium outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||
onClick={onToggle} aria-expanded={open} aria-controls={listId} title={open ? T.pendingHide : T.pendingShow}
|
||||
data-assistant-pending-toggle="">
|
||||
{T.pending(n)}
|
||||
</button>
|
||||
<button type="button" className="shrink-0 rounded px-1 text-2xs text-faint outline-none hover:bg-hover hover:text-fg focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||
onClick={onClear} disabled={busy} data-assistant-clear="">
|
||||
<Trash2 size={10} className="mr-0.5 inline" aria-hidden />{T.clear}
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<ul id={listId} className="mx-3 mt-1.5 max-h-48 divide-y divide-[var(--hairline)] overflow-y-auto rounded-md border bg-panel">
|
||||
{pending.map((p) => {
|
||||
const where = changeWhere(p)
|
||||
return (
|
||||
<li key={p.path} className="flex items-start gap-2 px-2 py-1.5 text-2xs" data-assistant-pending-item={p.path}>
|
||||
<div className="min-w-0 flex-1 leading-relaxed">
|
||||
<p className="break-words font-medium text-fg">{where}</p>
|
||||
<p className="break-words text-dim">
|
||||
<span className="text-faint">{T.before} </span>{patchBeforeText(p)}
|
||||
<span className="text-faint"> → {T.after} </span><span className="text-fg">{valueText(p.path, p.after)}</span>
|
||||
</p>
|
||||
{p.reason && <p className="break-words text-faint"><span>{T.reason}:</span>{p.reason}</p>}
|
||||
</div>
|
||||
<button type="button" className="shrink-0 rounded px-1 text-dim outline-none hover:bg-hover hover:text-fg focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||
onClick={() => onDrop(p.path)} disabled={busy} aria-label={T.dropLabel(where)} title={T.dropLabel(where)}
|
||||
data-assistant-pending-remove="">
|
||||
{T.drop}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<div className="space-y-1.5 px-3 py-2">
|
||||
{tooMany && <p className="text-2xs text-[var(--err)]" role="alert" data-assistant-too-many="">{T.tooMany(n, ASSISTANT_MAX_SAVE)}</p>}
|
||||
{editing && <p className="text-2xs text-[var(--warn)]" data-assistant-editing="">{T.editing}</p>}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 text-2xs leading-relaxed text-faint">{T.pendingHint}</span>
|
||||
<button type="button" className="btn btn-sm btn-primary shrink-0" onClick={onSave} disabled={busy || tooMany || editing}
|
||||
data-assistant-save="">
|
||||
{busy ? <><Spinner size={11} /> {T.saving}</> : T.save(n)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { AlertTriangle, ArrowLeft, ChevronLeft, ChevronRight, GitBranch, ListChecks, Lock, Pencil, Plus, RotateCw, Search, Sparkles, X } from 'lucide-react'
|
||||
import {
|
||||
AlertTriangle, ArrowLeft, ChevronLeft, ChevronRight, GitBranch, Info, ListChecks, Lock, MessagesSquare, Pencil, Plus, RotateCw, Search, Sparkles, X,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { ApiError, api } from '../../api/client'
|
||||
import type {
|
||||
@@ -11,17 +13,23 @@ import { DeleteButton, EmptyState, ErrorState, Notice, Skeleton, Spinner, confir
|
||||
import { useLeaveGuard } from '../../lib/leave'
|
||||
import { formatDateTime, formatNumber, formatTime } from '../../lib/format'
|
||||
import {
|
||||
CATALOG_CARDINALITY_LABEL, CATALOG_DRAFT_TEXT as DT, CATALOG_IMPACT_TEXT, CATALOG_KIND_HINT, CATALOG_KIND_LABEL, CATALOG_TABLE_FIELD_HINT,
|
||||
CATALOG_TABLE_FIELD_LABEL, CATALOG_TEXT as CT, CATALOG_UI_TEXT as UT, CODES_TEXT as KT,
|
||||
CATALOG_ASSISTANT_TEXT as AT, CATALOG_CARDINALITY_LABEL, CATALOG_DRAFT_TEXT as DT, CATALOG_IMPACT_TEXT, CATALOG_KIND_HINT, CATALOG_KIND_LABEL,
|
||||
CATALOG_REVIEW_TEXT as RT, CATALOG_TABLE_FIELD_HINT, CATALOG_TABLE_FIELD_LABEL, CATALOG_TEXT as CT, CATALOG_UI_TEXT as UT,
|
||||
CATALOG_USAGE_TEXT, CODES_TEXT as KT,
|
||||
} from '../../lib/terms'
|
||||
import { CatalogImpactList } from '../../components/CatalogImpact'
|
||||
import { CountBadges, ItemMark, StatusChip, StatusLegend, SystemNotesNotice } from './parts'
|
||||
import {
|
||||
CountBadges, FOCUS_RING, FOCUS_STYLE, ItemMark, NoteHint, REASON_ICON, ReasonHint, StatusChip, StatusLegend, SystemNotesNotice, confirmedNote,
|
||||
confirmedReason,
|
||||
} from './parts'
|
||||
import type { ReviewTarget } from './parts'
|
||||
import { ColumnTable, NARROW, Value, pendingCodes } from './ColumnTable'
|
||||
import type { ColumnModel } from './ColumnTable'
|
||||
import { CodesDialog } from './CodesDialog'
|
||||
import { coverageText } from './profile'
|
||||
import { lastBlankOf, taskReason } from './draftJob'
|
||||
import { lastBlankInfo, taskReason } from './draftJob'
|
||||
import { TableAssistant } from './TableAssistant'
|
||||
import { TableUsageList } from './TableUsage'
|
||||
import {
|
||||
COLUMN_FIELDS, DATE_KEYS, TABLE_FIELDS, buildNotes, changeCount, columnPath, confirmProposed, countsOf, formFromNotes,
|
||||
relationDraft, relationPath, relationRemoval, tablePath, unknownKeys, validateForm,
|
||||
@@ -54,6 +62,7 @@ type Conflict = 'edit' | 'review'
|
||||
|
||||
export function TableDetail({
|
||||
sourceId, table, rows, drafted, fillCodes, onFillDone, prev, next, onOpen, onBack, onDetail, draftBusy = false, onRedraft, onDraftGuidance,
|
||||
profileEnabled = false, focusPath = null, usageOpen = false,
|
||||
}: {
|
||||
sourceId: string
|
||||
table: string
|
||||
@@ -84,6 +93,12 @@ export function TableDetail({
|
||||
onRedraft?: (table: string) => Promise<void>
|
||||
/** 上次起草有留空列时的「补充起草要求」:打开起草设置,范围只有这张表,先补起草要求再开始。表结构里已经没有的表不给 */
|
||||
onDraftGuidance?: (table: string) => void
|
||||
/** 数据源开了数据剖析:表助手才能发只读查询 */
|
||||
profileEnabled?: boolean
|
||||
/** 从审阅队列打开的那一项(审阅路径):载入后滚到那一项、标出来 */
|
||||
focusPath?: string | null
|
||||
/** 「用过这张表的运行」直接取(从审阅队列打开时) */
|
||||
usageOpen?: boolean
|
||||
}) {
|
||||
const [detail, setDetail] = useState<CatalogDetail | null>(null)
|
||||
const [loadError, setLoadError] = useState<unknown>(null)
|
||||
@@ -114,6 +129,10 @@ export function TableDetail({
|
||||
const [savedAt, setSavedAt] = useState<number | null>(null)
|
||||
const [impact, setImpact] = useState<{ version: number; n: number } | null>(null)
|
||||
const [redrafting, setRedrafting] = useState(false)
|
||||
/** 表助手开着没有:换表时照旧开着(对话按表重新开始) */
|
||||
const [assistantOpen, setAssistantOpen] = useState(false)
|
||||
const assistantEntry = useRef<HTMLButtonElement>(null)
|
||||
const focusAssistant = () => document.querySelector<HTMLElement>('[data-table-assistant] [data-assistant-input]')?.focus()
|
||||
const loadSeq = useRef(0)
|
||||
const onDetailRef = useRef(onDetail)
|
||||
onDetailRef.current = onDetail
|
||||
@@ -176,6 +195,18 @@ export function TableDetail({
|
||||
onFillDoneRef.current?.(fillCodes.seq, null)
|
||||
}, [fillCodes, table, detail])
|
||||
|
||||
// 从审阅队列打开的那一项:载入后滚过去(标出来的样式在各行上)
|
||||
const focusSeen = useRef('')
|
||||
useEffect(() => {
|
||||
if (!focusPath || !detail || detail.table_name !== table) return
|
||||
const key = `${table}\u0000${focusPath}`
|
||||
if (focusSeen.current === key) return
|
||||
focusSeen.current = key
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelector(`[data-review-focus="${CSS.escape(focusPath)}"]`)?.scrollIntoView({ block: 'center' })
|
||||
})
|
||||
}, [focusPath, detail, table])
|
||||
|
||||
const changes = edit ? changeCount(edit.initial, edit.form) : 0
|
||||
const problems = useMemo(() => (edit ? validateForm(edit.form, PROBLEM_TEXT) : {}), [edit])
|
||||
const problemCount = Object.keys(problems).length
|
||||
@@ -325,7 +356,8 @@ export function TableDetail({
|
||||
const lastDraft = rows.find((r) => r.table_name === table)?.last_draft ?? null
|
||||
/** 没写全(失败、部分完成);有留空的列(模型拿不准,表照样写完了) */
|
||||
const lastBad = lastDraft && (lastDraft.status === 'failed' || lastDraft.status === 'partial') ? lastDraft : null
|
||||
const lastBlank = lastBlankOf(lastDraft)
|
||||
const blankInfo = lastBlankInfo(lastDraft)
|
||||
const lastBlank = blankInfo.n
|
||||
const label = notes.label && notes.label.status !== 'rejected' ? notes.label.value : null
|
||||
const kind = notes.kind && notes.kind.status !== 'rejected' ? notes.kind.value : null
|
||||
const st = detail.structure
|
||||
@@ -384,8 +416,9 @@ export function TableDetail({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col" data-catalog-detail={table} data-version={detail.version}
|
||||
data-editing={edit ? 'true' : 'false'}>
|
||||
// 表助手开着时:宽屏(xl 起)停在右边,详情让出那一栏的宽度;窄一些的屏幕盖在详情上面
|
||||
<div className={clsx('relative flex min-h-0 flex-1 flex-col', assistantOpen && detail.in_schema && 'xl:pr-[380px] 2xl:pr-[420px]')}
|
||||
data-catalog-detail={table} data-version={detail.version} data-editing={edit ? 'true' : 'false'}>
|
||||
<header className="space-y-2 border-b bg-panel px-4 py-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<button type="button" className="btn btn-sm btn-ghost -ml-1 shrink-0 lg:hidden" onClick={onBack} aria-label={CT.backToList} data-catalog-detail-back="">
|
||||
@@ -446,6 +479,14 @@ export function TableDetail({
|
||||
<button type="button" className="btn btn-sm" disabled={!!busy} onClick={startEdit} data-catalog-edit="">
|
||||
<Pencil size={11} aria-hidden /> {CT.edit}
|
||||
</button>
|
||||
{detail.in_schema && (
|
||||
// 开着时再点:焦点回到表助手的输入框(关上走面板自己的「关闭」,有待保存的修改时先问)
|
||||
<button ref={assistantEntry} type="button" className={clsx('btn btn-sm', assistantOpen && 'border-[var(--accent)] text-fg')}
|
||||
onClick={() => (assistantOpen ? focusAssistant() : setAssistantOpen(true))} aria-expanded={assistantOpen} title={AT.openHint}
|
||||
data-catalog-assistant-open="header">
|
||||
<MessagesSquare size={11} aria-hidden /> {AT.open}
|
||||
</button>
|
||||
)}
|
||||
<span className="text-2xs text-faint">{CT.editHint}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -478,6 +519,7 @@ export function TableDetail({
|
||||
{lastBlank > 0 && (
|
||||
<>
|
||||
<p className={lastBad ? 'mt-0.5 text-dim' : 'font-medium'} data-catalog-last-draft-blank={lastBlank}>{DT.lastBlank(lastBlank)}</p>
|
||||
{blankInfo.earlier && <p className="mt-0.5 text-2xs text-faint" data-catalog-last-draft-earlier="">{DT.lastBlankEarlier}</p>}
|
||||
{detail.in_schema && <p className="mt-0.5 text-dim">{DT.lastBlankAdvice}</p>}
|
||||
</>
|
||||
)}
|
||||
@@ -498,6 +540,11 @@ export function TableDetail({
|
||||
<Pencil size={11} aria-hidden /> {DT.draftGuidance}
|
||||
</button>
|
||||
)}
|
||||
{/* 和助手一起完善:对话补上留空、拿不准的列,不用重新起草 */}
|
||||
<button type="button" className="btn btn-sm btn-ghost" onClick={() => (assistantOpen ? focusAssistant() : setAssistantOpen(true))}
|
||||
title={AT.openHint} data-catalog-assistant-open="notice">
|
||||
<MessagesSquare size={11} aria-hidden /> {AT.open}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-2xs text-faint">{edit ? DT.redraftEditing : draftBusy ? DT.redraftBusy : DT.redraftHint}</p>
|
||||
</>
|
||||
@@ -529,7 +576,8 @@ export function TableDetail({
|
||||
<dl className="divide-y divide-[var(--hairline)]">
|
||||
{TABLE_FIELDS.map((f) => (
|
||||
<TableFieldRow key={f} field={f} item={notes[f] as CatalogItem | undefined} edit={edit} busy={!!busy}
|
||||
problems={problems} columns={st?.columns ?? []} onChange={setTable} onReview={review} />
|
||||
problems={problems} columns={st?.columns ?? []} onChange={setTable} onReview={review}
|
||||
focused={focusPath === tablePath(f)} />
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
@@ -553,7 +601,7 @@ export function TableDetail({
|
||||
{shownColumns.length
|
||||
? (
|
||||
<ColumnTable columns={shownColumns} form={edit?.form.columns ?? null} initial={edit?.initial.columns ?? null}
|
||||
problems={problems} busy={!!busy} systemNotes={detail.system_notes}
|
||||
problems={problems} busy={!!busy} systemNotes={detail.system_notes} focusPath={focusPath}
|
||||
onReview={review} onConfirmColumn={confirmColumn} onFillCodes={fillCodesOf} onChange={setColumn}
|
||||
onCodesComplete={setCodesComplete} />
|
||||
)
|
||||
@@ -568,7 +616,8 @@ export function TableDetail({
|
||||
{edit
|
||||
? <RelationEditor drafts={edit.form.relations} initial={edit.initial.relations} problems={problems} rows={rows}
|
||||
onChange={setRelations} />
|
||||
: <RelationList relations={notes.relations ?? []} busy={!!busy} targetOf={targetOf} onOpen={onOpen} onReview={review} />}
|
||||
: <RelationList relations={notes.relations ?? []} busy={!!busy} targetOf={targetOf} onOpen={onOpen} onReview={review}
|
||||
focusPath={focusPath} />}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="catalog-sec-impact" data-catalog-section="impact">
|
||||
@@ -579,6 +628,14 @@ export function TableDetail({
|
||||
<CatalogImpactList sourceId={sourceId} table={detail.table_name} refreshKey={detail.version}
|
||||
onLoad={(d) => setImpact({ version: detail.version, n: d.templates.length })} />
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="catalog-sec-usage" data-catalog-section="usage">
|
||||
<div className="mb-2 flex flex-wrap items-baseline gap-2">
|
||||
<h3 id="catalog-sec-usage" className="text-xs font-semibold">{CATALOG_USAGE_TEXT.title}</h3>
|
||||
<span className="text-2xs text-faint">{CATALOG_USAGE_TEXT.hint}</span>
|
||||
</div>
|
||||
<TableUsageList sourceId={sourceId} table={detail.table_name} usage={detail.usage} autoLoad={usageOpen} />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -605,6 +662,21 @@ export function TableDetail({
|
||||
</button>
|
||||
</footer>
|
||||
)}
|
||||
{assistantOpen && detail.in_schema && (
|
||||
<TableAssistant key={`${sourceId}\u0000${table}`} sourceId={sourceId} table={table} tableLabel={label ?? detail.table_name} detail={detail}
|
||||
profileEnabled={profileEnabled} lastBlank={{ n: lastBlank, earlier: blankInfo.earlier, jobId: lastDraft?.job_id ?? null }}
|
||||
editing={!!edit}
|
||||
onSaved={(d) => {
|
||||
setDetail(d)
|
||||
setConflict(null)
|
||||
onDetailRef.current(d)
|
||||
setSavedAt(d.version)
|
||||
}}
|
||||
onClose={() => {
|
||||
setAssistantOpen(false)
|
||||
requestAnimationFrame(() => assistantEntry.current?.focus())
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -626,7 +698,14 @@ function blankCodes(notes: CatalogDetail['notes'], pick: (path: string) => boole
|
||||
// 表级各项
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TableFieldRow({ field, item, edit, busy, problems, columns, onChange, onReview }: {
|
||||
/**
|
||||
* 推断时也在值下面写说明的项:有效记录条件、去重规则(模型起草时附的依据、数据剖析核对的排除比例或没有核对的原因)。
|
||||
* 别的项人工确认过、带着备注(剖析的说明、双盲复算的出处……)时也写。确认时的依据(保存修改提案时记下的)另起一行写,
|
||||
* 不和说明混在一起
|
||||
*/
|
||||
const NOTED_FIELDS = new Set<TableField>(['valid_filter', 'dedup'])
|
||||
|
||||
function TableFieldRow({ field, item, edit, busy, problems, columns, onChange, onReview, focused = false }: {
|
||||
field: TableField
|
||||
item: CatalogItem | undefined
|
||||
edit: Editing | null
|
||||
@@ -636,6 +715,8 @@ function TableFieldRow({ field, item, edit, busy, problems, columns, onChange, o
|
||||
columns: { name: string }[]
|
||||
onChange: (k: TableKey, v: string) => void
|
||||
onReview: (t: ReviewTarget, a: CatalogReviewAction) => void
|
||||
/** 从审阅队列打开的就是这一项:标出来 */
|
||||
focused?: boolean
|
||||
}) {
|
||||
const where = CT.whereTable(CATALOG_TABLE_FIELD_LABEL[field])
|
||||
const keys: TableKey[] = field === 'business_date' ? [...DATE_KEYS] : [field as TableKey]
|
||||
@@ -692,9 +773,14 @@ function TableFieldRow({ field, item, edit, busy, problems, columns, onChange, o
|
||||
}
|
||||
}
|
||||
const unknown = edit && field === 'keys' ? unknownKeys(edit.form.table.keys ?? '', columns) : []
|
||||
const note = edit || !item || item.status === 'rejected' ? ''
|
||||
: NOTED_FIELDS.has(field) ? item.note?.trim() ?? '' : confirmedNote(item)
|
||||
const reason = edit ? '' : confirmedReason(item)
|
||||
|
||||
return (
|
||||
<div className="grid gap-x-4 gap-y-1 px-3 py-2.5 sm:grid-cols-[7.5rem_minmax(0,1fr)]" data-field={field} data-status={item?.status}>
|
||||
<div className={clsx('grid gap-x-4 gap-y-1 px-3 py-2.5 sm:grid-cols-[7.5rem_minmax(0,1fr)]', focused && FOCUS_RING)}
|
||||
style={focused ? FOCUS_STYLE : undefined} data-field={field} data-status={item?.status}
|
||||
data-review-focus={focused ? tablePath(field) : undefined} title={focused ? RT.focusMark : undefined}>
|
||||
<dt className="pt-0.5 text-2xs text-faint" title={CATALOG_TABLE_FIELD_HINT[field]}>
|
||||
{edit && field !== 'business_date'
|
||||
? <label htmlFor={inputId}>{CATALOG_TABLE_FIELD_LABEL[field]}</label>
|
||||
@@ -704,12 +790,25 @@ function TableFieldRow({ field, item, edit, busy, problems, columns, onChange, o
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1 text-xs">{edit ? input : <FieldValue field={field} item={item} />}</div>
|
||||
{item && !changed && (
|
||||
<ItemMark target={{ path: tablePath(field), where, source: item.source, status: item.status, note: item.note, updated_at: item.updated_at }}
|
||||
<ItemMark target={{ path: tablePath(field), where, source: item.source, status: item.status, note: item.note, reason: item.reason,
|
||||
updated_at: item.updated_at }}
|
||||
disabled={busy || !!edit} disabledHint={edit ? CT.reviewWhileEditing : undefined}
|
||||
onReview={(a) => onReview({ path: tablePath(field), where, source: item.source, status: item.status }, a)} />
|
||||
)}
|
||||
{changed && <span className="chip shrink-0 !text-2xs" style={{ color: 'var(--accent)' }} data-changed="">{CT.changedMark}</span>}
|
||||
</div>
|
||||
{reason && (
|
||||
<p className="mt-1 flex items-start gap-1 text-2xs leading-relaxed text-dim" data-field-reason="">
|
||||
<REASON_ICON size={10} className="mt-[3px] shrink-0 text-faint" aria-hidden />
|
||||
<span className="min-w-0 break-words"><span className="text-faint">{UT.fieldReason}:</span>{reason}</span>
|
||||
</p>
|
||||
)}
|
||||
{note && (
|
||||
<p className="mt-1 flex items-start gap-1 text-2xs leading-relaxed text-dim" data-field-note="">
|
||||
<Info size={10} className="mt-[3px] shrink-0 text-faint" aria-hidden />
|
||||
<span className="min-w-0 break-words"><span className="text-faint">{UT.fieldNote}:</span>{note}</span>
|
||||
</p>
|
||||
)}
|
||||
{problem && <div className="mt-1 text-2xs text-[var(--err)]" role="alert">{problem}</div>}
|
||||
{!problem && unknown.length > 0 && (
|
||||
<div className="mt-1 text-2xs text-[var(--warn)]" data-keys-unknown="">{CT.keysUnknown(unknown.join('、'))}</div>
|
||||
@@ -760,12 +859,14 @@ function FieldValue({ field, item }: { field: TableField; item: CatalogItem | un
|
||||
// 关联关系
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RelationList({ relations, busy, targetOf, onOpen, onReview }: {
|
||||
function RelationList({ relations, busy, targetOf, onOpen, onReview, focusPath = null }: {
|
||||
relations: CatalogRelation[]
|
||||
busy: boolean
|
||||
targetOf: (name: string) => CatalogTableRow | undefined
|
||||
onOpen: (table: string) => void
|
||||
onReview: (t: ReviewTarget, a: CatalogReviewAction) => void
|
||||
/** 从审阅队列打开的那一项 */
|
||||
focusPath?: string | null
|
||||
}) {
|
||||
if (!relations.length) return <p className="rounded-lg border px-3 py-4 text-xs text-faint" data-relations-empty="">{CT.noRelations}</p>
|
||||
// 窄框(< 40rem)下和列表格一样排成卡片:状态在第一行,下面每项一行「字段名 值」,不用横向滚动
|
||||
@@ -789,8 +890,11 @@ function RelationList({ relations, busy, targetOf, onOpen, onReview }: {
|
||||
const target = targetOf(r.to_table)
|
||||
const where = CT.whereRelation(r.to_table)
|
||||
const rejected = r.status === 'rejected'
|
||||
const focused = focusPath === relationPath(r.id)
|
||||
return (
|
||||
<tr key={r.id} className={clsx('border-b border-hairline last:border-b-0', NARROW.row)} data-relation={r.id} data-status={r.status}>
|
||||
<tr key={r.id} className={clsx('border-b border-hairline last:border-b-0', NARROW.row, focused && FOCUS_RING)}
|
||||
style={focused ? FOCUS_STYLE : undefined} data-relation={r.id} data-status={r.status}
|
||||
data-review-focus={focused ? relationPath(r.id) : undefined}>
|
||||
<td className={clsx(cell, 'px-3')}>
|
||||
{label(CT.relationHead.from)}
|
||||
<span className={clsx('mono min-w-0 break-all', rejected && 'text-faint line-through')}>{r.columns.join(', ')}</span>
|
||||
@@ -830,8 +934,14 @@ function RelationList({ relations, busy, targetOf, onOpen, onReview }: {
|
||||
: <span className="text-faint">—</span>}
|
||||
</td>
|
||||
<td className={clsx(cell, '@max-[40rem]:order-first @max-[40rem]:pb-1')} data-relation-status="">
|
||||
<ItemMark target={{ path: relationPath(r.id), where, source: r.source, status: r.status, note: r.note, updated_at: r.updated_at }}
|
||||
disabled={busy} onReview={(a) => onReview({ path: relationPath(r.id), where, source: r.source, status: r.status }, a)} />
|
||||
<span className="inline-flex items-start gap-1.5">
|
||||
<ItemMark target={{ path: relationPath(r.id), where, source: r.source, status: r.status, note: r.note, reason: r.reason,
|
||||
updated_at: r.updated_at }}
|
||||
disabled={busy} onReview={(a) => onReview({ path: relationPath(r.id), where, source: r.source, status: r.status }, a)} />
|
||||
{/* 人工确认的关系:确认时的依据、备注(沿用的测量说明)各一个图标,悬停看 */}
|
||||
<ReasonHint reason={confirmedReason(r)} attr="data-relation-reason" />
|
||||
{confirmedNote(r) && <NoteHint note={confirmedNote(r)} attr="data-relation-note" />}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '../../lib/terms'
|
||||
import { CountBadges } from './parts'
|
||||
import { LIST_FILTERS, LIST_SORTS, progressOf } from './model'
|
||||
import { lastBlankOf, taskReason } from './draftJob'
|
||||
import { lastBlankInfo, taskReason } from './draftJob'
|
||||
import type { ListFilter, ListSort } from './model'
|
||||
|
||||
// ===========================================================================
|
||||
@@ -213,10 +213,12 @@ const Row = memo(function Row({ row: r, index, active, checked, onToggle, onOpen
|
||||
const last = r.last_draft && (r.last_draft.status === 'failed' || r.last_draft.status === 'partial') ? r.last_draft : null
|
||||
const lastLabel = last ? (last.status === 'failed' ? DT.lastFailed : DT.lastPartial) : ''
|
||||
const lastText = last ? (taskReason(last) ? DT.lastWith(lastLabel, taskReason(last)) : lastLabel) : ''
|
||||
// 上次起草有留空的列:表照样写完了,只是模型对这几列拿不准。淡色的轻提示,和失败标记分开。表结构里已经没有的表
|
||||
// 起草不了:悬停里不建议重新起草
|
||||
const blank = lastBlankOf(r.last_draft)
|
||||
const blankTitle = r.in_schema ? `${DT.lastBlank(blank)}。${DT.lastBlankAdvice}` : DT.lastBlank(blank)
|
||||
// 上次起草有留空的列:表照样写完了,只是模型对这几列拿不准。淡色的轻提示,和失败标记分开。最近一次停止了、没有新写入时
|
||||
// 照旧按再之前那次写完的起草提示(悬停里写明)。表结构里已经没有的表起草不了:悬停里不建议重新起草
|
||||
const blankInfo = lastBlankInfo(r.last_draft)
|
||||
const blank = blankInfo.n
|
||||
const blankTitle = [DT.lastBlank(blank), blankInfo.earlier ? DT.lastBlankEarlier : '', r.in_schema ? DT.lastBlankAdvice : '']
|
||||
.filter(Boolean).join('。')
|
||||
return (
|
||||
<li
|
||||
className={clsx('relative flex items-stretch border-b border-hairline', active ? 'bg-accent-soft' : 'hover:bg-hover')}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { History } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
import type { CatalogTableUsage } from '../../types'
|
||||
import { Skeleton, StatusPill } from '../../components/ui'
|
||||
import { humanizeError } from '../../lib/errors'
|
||||
import { formatDateTime, formatTime } from '../../lib/format'
|
||||
import { CATALOG_USAGE_TEXT as T, RUN_CLASS_LABEL } from '../../lib/terms'
|
||||
|
||||
// ===========================================================================
|
||||
// 表详情里「用过这张表的运行」(GET /catalog/{table}/usage,新的在前):审阅一项之前看看这张表都被哪些运行查过,
|
||||
// 点进运行记录核对。默认不取,点「查看」才取(每张表都取一遍没必要);从审阅队列打开时直接取(autoLoad)。
|
||||
// 使用次数是 0 的不取,直接写还没有运行查询过。
|
||||
// ===========================================================================
|
||||
|
||||
const LIMIT = 20
|
||||
|
||||
export function TableUsageList({ sourceId, table, usage, autoLoad = false }: {
|
||||
sourceId: string
|
||||
table: string
|
||||
/** 清单上的使用次数:0 就不取 */
|
||||
usage: number
|
||||
autoLoad?: boolean
|
||||
}) {
|
||||
const [data, setData] = useState<CatalogTableUsage | null>(null)
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
const [wanted, setWanted] = useState(autoLoad)
|
||||
const seq = useRef(0)
|
||||
|
||||
const load = useCallback(() => {
|
||||
const mine = ++seq.current
|
||||
setError(null)
|
||||
api.dataCatalog.usage(sourceId, table, LIMIT)
|
||||
.then((d) => { if (mine === seq.current) setData(d) })
|
||||
.catch((e) => { if (mine === seq.current) setError(e) })
|
||||
}, [sourceId, table])
|
||||
|
||||
// 换表:清掉上一张的,按需要重新取
|
||||
useEffect(() => {
|
||||
setData(null)
|
||||
setError(null)
|
||||
setWanted(autoLoad)
|
||||
}, [sourceId, table, autoLoad])
|
||||
useEffect(() => {
|
||||
if (wanted && usage > 0) load()
|
||||
}, [wanted, usage, load])
|
||||
|
||||
if (usage <= 0) return <p className="rounded-lg border px-3 py-4 text-xs text-faint" data-table-usage="0">{T.none}</p>
|
||||
if (!wanted) {
|
||||
return (
|
||||
<button type="button" className="btn btn-sm" onClick={() => setWanted(true)} data-table-usage-show="">
|
||||
<History size={11} aria-hidden /> {T.show}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
if (error && !data) {
|
||||
return (
|
||||
<p className="flex flex-wrap items-center gap-2 text-2xs text-[var(--err)]" data-table-usage="error">
|
||||
{T.error}:{humanizeError(error).title}
|
||||
<button type="button" className="rounded px-1 text-dim hover:bg-hover hover:text-fg" onClick={load}>{T.retry}</button>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (!data) return <div data-table-usage="loading"><Skeleton rows={2} height={18} gap={6} /></div>
|
||||
if (!data.runs.length) return <p className="rounded-lg border px-3 py-4 text-xs text-faint" data-table-usage="0">{T.none}</p>
|
||||
return (
|
||||
<div data-table-usage={data.total}>
|
||||
<p className="mb-1 text-2xs text-faint" title={T.countHint} data-table-usage-count="">{T.count(data.runs.length, data.total)}</p>
|
||||
<ul className="divide-y divide-[var(--hairline)] rounded-lg border bg-panel">
|
||||
{data.runs.map((r) => {
|
||||
const name = r.workflow_name?.trim() || T.unnamed
|
||||
return (
|
||||
<li key={r.run_id} className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5 px-3 py-1.5 text-xs" data-usage-run={r.run_id}>
|
||||
<Link to={`/runs/${encodeURIComponent(r.run_id)}`} className="min-w-0 truncate font-medium hover:underline" title={T.open(name)}>
|
||||
{name}
|
||||
</Link>
|
||||
{r.run_class && <span className="shrink-0 text-2xs text-faint">{RUN_CLASS_LABEL[r.run_class] ?? r.run_class}</span>}
|
||||
<StatusPill status={r.status} short plain />
|
||||
<span className="flex-1" />
|
||||
{r.started_at && (
|
||||
<span className="tnum shrink-0 text-2xs text-faint" title={formatDateTime(r.started_at)}>{formatTime(r.started_at)}</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { useState } from 'react'
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { AlertTriangle, ArrowRight, FileJson, Upload } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { api } from '../../api/client'
|
||||
import type { CatalogImportReport, CatalogImportTable } from '../../types'
|
||||
import { Modal, Notice, Spinner } from '../../components/ui'
|
||||
import { errorMessage } from '../../lib/errors'
|
||||
import { formatDateTime, formatNumber } from '../../lib/format'
|
||||
import { CATALOG_TRANSFER_TEXT as T } from '../../lib/terms'
|
||||
import { patchTarget, patchValueText, patchWhere } from '../../run/catalogPatch'
|
||||
|
||||
// ===========================================================================
|
||||
// 导入数据目录(数据目录页的「导入目录」):选文件 → 预览(只读:对上了哪些表,各有几项新增、更新、保留、冲突、跳过,
|
||||
// 冲突逐条列出)→ 确认导入 → 结果(逐表写入后的版本)。规矩由服务端定(data/catalog_transfer.py),这里照预览写出来,
|
||||
// 写入前让人看清楚哪些会动、哪些以这里为准。
|
||||
//
|
||||
// 导出不在这里:页头的「导出目录」直接下载文件(CatalogPage)。
|
||||
// ===========================================================================
|
||||
|
||||
type Step = 'choose' | 'preview' | 'done'
|
||||
|
||||
interface Chosen {
|
||||
name: string
|
||||
content: unknown
|
||||
/** 文件里写的来源数据源和导出时间(认得出才有) */
|
||||
source: string | null
|
||||
exportedAt: string | null
|
||||
}
|
||||
|
||||
const COUNT_KEYS = ['add', 'update', 'keep', 'conflicts', 'skipped'] as const
|
||||
|
||||
/** 跳过的那一项写成界面上的说法(「列 gate_code 的中文名」);关联关系只有编号,照原样写 */
|
||||
const pathText = (path: string) => {
|
||||
const t = patchTarget(path)
|
||||
return t && t.kind !== 'relation' ? patchWhere({ path, before: null, after: null }) : path
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入真正写进去的表(文件中的表名):没出错、有新增或更新的项、版本比预览时前进了。服务端合计里的 tables 是对上的表数,
|
||||
* 文件中的项这里都有了的表也算在里面,不能拿它说「写入 N 张表」。preview 是确认导入之前那次预览(版本是导入前的);
|
||||
* 拿不到某张表导入前的版本、或者服务端没给版本时只看新增和更新
|
||||
*/
|
||||
export function writtenTables(result: CatalogImportReport, preview: CatalogImportReport | null): Set<string> {
|
||||
const before = new Map((preview?.tables ?? []).map((t) => [t.table_name, t.version]))
|
||||
return new Set(result.tables.filter((t) => {
|
||||
if (t.error || t.add + t.update <= 0) return false
|
||||
const was = before.get(t.table_name)
|
||||
return typeof t.version !== 'number' || typeof was !== 'number' || t.version > was
|
||||
}).map((t) => t.table_name))
|
||||
}
|
||||
|
||||
export function ImportDialog({ sourceId, onClose, onImported }: {
|
||||
sourceId: string
|
||||
onClose: () => void
|
||||
/** 导入写进了这些表(这个数据源里的表名):页面重取清单、正在看的表重新载入 */
|
||||
onImported: (tables: string[]) => void
|
||||
}) {
|
||||
const [step, setStep] = useState<Step>('choose')
|
||||
const [file, setFile] = useState<Chosen | null>(null)
|
||||
const [includeGuidance, setIncludeGuidance] = useState(true)
|
||||
const [report, setReport] = useState<CatalogImportReport | null>(null)
|
||||
const [result, setResult] = useState<CatalogImportReport | null>(null)
|
||||
const [busy, setBusy] = useState<'preview' | 'import' | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const choose = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!f) return
|
||||
setError(null)
|
||||
setFile(null)
|
||||
let text: string
|
||||
try {
|
||||
text = await f.text()
|
||||
} catch {
|
||||
setError(T.readFailed)
|
||||
return
|
||||
}
|
||||
let content: unknown
|
||||
try {
|
||||
content = JSON.parse(text)
|
||||
} catch {
|
||||
setError(T.notJson)
|
||||
return
|
||||
}
|
||||
const meta = content && typeof content === 'object' ? content as { source?: { name?: unknown }; exported_at?: unknown } : {}
|
||||
setFile({
|
||||
name: f.name, content,
|
||||
source: typeof meta.source?.name === 'string' ? meta.source.name : null,
|
||||
exportedAt: typeof meta.exported_at === 'string' ? meta.exported_at : null,
|
||||
})
|
||||
}
|
||||
|
||||
const preview = async () => {
|
||||
if (!file || busy) return
|
||||
setBusy('preview')
|
||||
setError(null)
|
||||
try {
|
||||
const r = await api.dataCatalog.importPreview(sourceId, { content: file.content, include_guidance: includeGuidance })
|
||||
setReport(r)
|
||||
setStep('preview')
|
||||
} catch (e) {
|
||||
// 文件格式不对(422)、没有表结构……服务端那句话照原样写,留在这一步
|
||||
setError(errorMessage(e))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const doImport = async () => {
|
||||
if (!file || busy) return
|
||||
setBusy('import')
|
||||
setError(null)
|
||||
try {
|
||||
const r = await api.dataCatalog.importCatalog(sourceId, { content: file.content, include_guidance: includeGuidance })
|
||||
setResult(r)
|
||||
setStep('done')
|
||||
const written = writtenTables(r, report)
|
||||
onImported(r.tables.filter((t) => written.has(t.table_name)).map((t) => t.matched_as ?? t.table_name))
|
||||
} catch (e) {
|
||||
// 有起草任务在进行(409)、别的原因:写服务端那句话,留在预览
|
||||
setError(errorMessage(e))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const back = () => {
|
||||
setStep('choose')
|
||||
setReport(null)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const importable = report ? report.tables.length : 0
|
||||
const footer = step === 'choose'
|
||||
? (
|
||||
<>
|
||||
<button type="button" className="btn" onClick={onClose}>{T.cancel}</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => void preview()} disabled={!file || !!busy} data-import-preview="">
|
||||
{busy === 'preview' ? <><Spinner size={11} /> {T.previewing}</> : T.preview}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
: step === 'preview'
|
||||
? (
|
||||
<>
|
||||
<button type="button" className="btn" onClick={back} disabled={!!busy} data-import-back="">{T.back}</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => void doImport()} disabled={!importable || !!busy} data-import-confirm="">
|
||||
{busy === 'import' ? <><Spinner size={11} /> {T.importing}</> : T.confirm(importable)}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
: <button type="button" className="btn btn-primary" onClick={onClose} data-autofocus data-import-close="">{T.close}</button>
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title={T.title} width={640} footer={footer}>
|
||||
<div className="min-w-0 space-y-3 text-xs" data-import-dialog="" data-step={step}>
|
||||
{step === 'choose' && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<p className="label">{T.chooseLabel}</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="btn btn-sm cursor-pointer focus-within:ring-2 focus-within:ring-[var(--accent)]">
|
||||
<Upload size={11} aria-hidden /> {T.choose}
|
||||
<input type="file" accept=".json,application/json" className="sr-only" onChange={(e) => void choose(e)}
|
||||
aria-label={T.chooseLabel} data-import-file="" />
|
||||
</label>
|
||||
<span className="text-2xs text-faint">{T.chooseHint}</span>
|
||||
</div>
|
||||
{file && (
|
||||
<p className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5 text-2xs text-dim" data-import-file-meta="">
|
||||
<FileJson size={11} className="shrink-0" aria-hidden />
|
||||
<span className="min-w-0 break-all">{T.chosen(file.name)}</span>
|
||||
{file.source && <span className="text-faint">{T.fileMeta(file.source, file.exportedAt ? formatDateTime(file.exportedAt) : '—')}</span>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<label className="flex cursor-pointer items-start gap-2 rounded-md border px-3 py-2" data-import-guidance="">
|
||||
<input type="checkbox" className="mt-0.5" checked={includeGuidance} onChange={(e) => setIncludeGuidance(e.target.checked)} />
|
||||
<span className="min-w-0">
|
||||
<span className="font-medium">{T.includeGuidance}</span>
|
||||
<span className="mt-0.5 block text-2xs text-faint">{T.includeGuidanceHint}</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="rounded-md border bg-bg px-3 py-2">
|
||||
<p className="text-2xs font-medium text-dim">{T.rulesTitle}</p>
|
||||
<ul className="mt-1 list-disc space-y-0.5 pl-4 text-2xs leading-relaxed text-faint" data-import-rules="">
|
||||
{T.rules.map((r) => <li key={r}>{r}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'preview' && report && <ReportView report={report} />}
|
||||
|
||||
{step === 'done' && result && <DoneView result={result} preview={report} />}
|
||||
|
||||
{error && <Notice tone="err" attr={{ 'data-import-error': '' }}>{error}</Notice>}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/** 导入结果:合计只数真正写进去的表,一张都没写进去时说清没有需要写入的内容;逐表写第几版或没有变化 */
|
||||
function DoneView({ result, preview }: { result: CatalogImportReport; preview: CatalogImportReport | null }) {
|
||||
const written = writtenTables(result, preview)
|
||||
const failed = result.tables.filter((t) => !!t.error).length
|
||||
return (
|
||||
<>
|
||||
<Notice tone="info" attr={{ 'data-import-done': '', 'data-import-written': String(written.size) }}>
|
||||
<p className="font-medium">{T.done}</p>
|
||||
<p className="mt-0.5 text-dim">{T.doneTotals(written.size, result.totals, failed, result.guidance.action === 'replace')}</p>
|
||||
</Notice>
|
||||
<ReportView report={result} done written={written} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览、导入结果:合计、逐表的计数(导入后写第几版,没写进去的写没有变化)、对不上的表、冲突逐条、跳过的原因、起草要求
|
||||
* 怎么处理。written:导入结果里真正写进去的表
|
||||
*/
|
||||
function ReportView({ report, done = false, written }: { report: CatalogImportReport; done?: boolean; written?: ReadonlySet<string> }) {
|
||||
const conflicts = report.tables.flatMap((t) => t.conflicts.map((c) => ({ t, c })))
|
||||
const skipped = report.tables.flatMap((t) => t.skipped.map((s) => ({ t, s })))
|
||||
return (
|
||||
<div className="min-w-0 space-y-3">
|
||||
{!done && <p className="tnum text-sm" data-import-totals="">{T.totals(report.totals)}</p>}
|
||||
{report.tables.length > 0
|
||||
? (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full border-collapse text-2xs">
|
||||
<thead>
|
||||
<tr className="border-b bg-elev text-left text-faint">
|
||||
<th scope="col" className="px-2.5 py-1.5 font-medium">{T.head.table}</th>
|
||||
{COUNT_KEYS.map((k) => <th key={k} scope="col" className="px-2 py-1.5 text-right font-medium">{T.head[k]}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.tables.map((t) => <TableRow key={t.table_name} t={t} done={done} written={!!written?.has(t.table_name)} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
: <p className="text-faint" data-import-nothing="">{T.nothing}</p>}
|
||||
{report.unmatched_tables.length > 0 && (
|
||||
<p className="break-words text-2xs leading-relaxed text-dim" data-import-unmatched="">
|
||||
{T.unmatched(report.unmatched_tables.length)}
|
||||
<span className="mono">{report.unmatched_tables.join('、')}</span>
|
||||
</p>
|
||||
)}
|
||||
{conflicts.length > 0 && (
|
||||
<section aria-label={T.conflictsTitle(conflicts.length)}>
|
||||
<p className="mb-1 flex items-center gap-1 text-2xs font-medium text-[var(--warn)]">
|
||||
<AlertTriangle size={11} aria-hidden /> {T.conflictsTitle(conflicts.length)}
|
||||
</p>
|
||||
<ul className="divide-y divide-[var(--hairline)] rounded-md border">
|
||||
{conflicts.map(({ t, c }) => (
|
||||
<li key={`${t.table_name}:${c.path}`} className="px-2.5 py-1.5 text-2xs leading-relaxed" data-import-conflict={`${t.table_name}:${c.path}`}>
|
||||
<p className="font-medium text-fg"><span className="mono">{t.table_name}</span> · {c.label || c.path}</p>
|
||||
<p className="flex flex-wrap items-center gap-x-1.5 text-dim">
|
||||
<span><span className="text-faint">{T.conflictFile}:</span>{patchValueText(c.path, c.file_value)}</span>
|
||||
<ArrowRight size={10} className="text-faint" aria-hidden />
|
||||
<span><span className="text-faint">{T.conflictTarget}:</span>{patchValueText(c.path, c.target_value)}</span>
|
||||
</p>
|
||||
{c.why && <p className="text-faint">{c.why}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{skipped.length > 0 && (
|
||||
<section aria-label={T.skippedTitle(skipped.length)}>
|
||||
<p className="mb-1 text-2xs font-medium text-dim">{T.skippedTitle(skipped.length)}</p>
|
||||
<ul className="divide-y divide-[var(--hairline)] rounded-md border">
|
||||
{skipped.map(({ t, s }) => (
|
||||
<li key={`${t.table_name}:${s.path}`} className="px-2.5 py-1.5 text-2xs leading-relaxed text-dim" data-import-skipped={`${t.table_name}:${s.path}`}>
|
||||
<span className="text-faint"><span className="mono">{t.table_name}</span> · {pathText(s.path)}</span>:{s.why}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
<p className="text-2xs text-dim" data-import-guidance-action={report.guidance.action}>{T.guidance[report.guidance.action] ?? T.guidance.none}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ t, done, written }: { t: CatalogImportTable; done: boolean; written: boolean }) {
|
||||
const renamed = t.matched_as && t.matched_as !== t.table_name ? t.matched_as : null
|
||||
const n = (k: typeof COUNT_KEYS[number]) => (k === 'conflicts' || k === 'skipped' ? t[k].length : t[k])
|
||||
return (
|
||||
<tr className="border-b border-hairline last:border-b-0" data-import-table={t.table_name} data-import-written={done ? String(written) : undefined}>
|
||||
<th scope="row" className="px-2.5 py-1.5 text-left align-top font-normal">
|
||||
<span className="mono break-all text-fg">{t.table_name}</span>
|
||||
{renamed && <span className="ml-1.5 text-faint">{T.matchedAs(renamed)}</span>}
|
||||
{done && t.error && <span className="block text-[var(--err)]">{T.tableFailed(t.error)}</span>}
|
||||
{done && !t.error && (written
|
||||
? typeof t.version === 'number' && <span className="block text-faint">{T.version(t.version)}</span>
|
||||
: <span className="block text-faint">{T.unchanged}</span>)}
|
||||
</th>
|
||||
{COUNT_KEYS.map((k) => (
|
||||
<td key={k} className={clsx('tnum px-2 py-1.5 text-right align-top', k === 'conflicts' && n(k) > 0 ? 'text-[var(--warn)]' : 'text-dim')}
|
||||
data-import-count={k}>
|
||||
{formatNumber(n(k))}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
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
|
||||
/** 每一轮最多交几项待保存的修改(服务端同一个上限,多了整个请求 422):多出来的只交最近的这么多项 */
|
||||
export const ASSISTANT_MAX_PENDING = 200
|
||||
/** 查询结果在界面上显示前几行(服务端最多给 10 行) */
|
||||
export const QUERY_ROWS_SHOWN = 10
|
||||
|
||||
/** 一轮回答里按先后到来的几块:正文、它跑的只读查询、修改提案 */
|
||||
export type TurnPart =
|
||||
| { kind: 'text'; text: string }
|
||||
/** rowCount 是取回的行数;truncated 为真时后面还有行(取回的只是前一截) */
|
||||
| { kind: 'query'; sql: string; columns: string[]; rows: unknown[][]; rowCount: number; truncated: boolean; 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),
|
||||
truncated: ev.truncated === true, 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 } : {}) }))
|
||||
}
|
||||
|
||||
/**
|
||||
* 交给助手的待保存的修改:最多 ASSISTANT_MAX_PENDING 项(服务端同一个上限,多了整个请求 422)。超出时交最近的那些
|
||||
* (列表按头一次提出的先后排,后面的是新近提出的);没交上去的模型不知道,再提同一项时以新的提案为准,不会丢
|
||||
*/
|
||||
export const pendingForAssistant = (pending: PendingChange[]): CatalogPatchSubmit[] =>
|
||||
pendingSubmit(pending.length > ASSISTANT_MAX_PENDING ? pending.slice(-ASSISTANT_MAX_PENDING) : pending)
|
||||
|
||||
/** 保存时带的版本:待保存的修改里最早的那条提案对照的版本 */
|
||||
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)也去掉,不用再存。
|
||||
*
|
||||
* submitted:交去保存、预览的那几项(同一个对象)。只重算它们:保存、重算期间又到的提案(新的项、同一项换成的新
|
||||
* 提案)不在预览里,原样留着,不能当成「已经不合法」悄悄去掉。不给时重算全部
|
||||
*/
|
||||
export function applyPreview(pending: PendingChange[], preview: CatalogPatchPreview, submitted?: readonly PendingChange[]): PendingChange[] {
|
||||
const byPath = new Map(preview.changes.map((c) => [c.path, c]))
|
||||
const mine = submitted ? new Set(submitted) : null
|
||||
const out: PendingChange[] = []
|
||||
for (const p of pending) {
|
||||
if (mine && !mine.has(p)) {
|
||||
out.push(p)
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 开场:上次起草有留空的列时,引导先问这几列
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 「补上……」按钮一次最多点名几列(再多的写「等 N 列」) */
|
||||
export const BLANK_ASK_MAX = 20
|
||||
/** 开场里最多举几列做例子 */
|
||||
export const BLANK_EXAMPLES = 5
|
||||
|
||||
/**
|
||||
* 开场引导的写法。留空的列(模型列出了、中文名和含义却都没给)只有起草时记下的那几个列名(blank_sample,最多 5 个)
|
||||
* 靠得住:目录里还没有中文名和含义的列里,还有没写出来的列(部分完成的宽表)、库注释已经说清的列,不能当成留空的列举例。
|
||||
* - draft:拿到了起草时记下的列名。例子是其中还没补上的。记全了(列名数不少于留空的列数)时 remaining 是还没补上的
|
||||
* 几列,按钮写这个数、点名的正是这几列;没记全时 remaining 为 null,按钮不写数字、只点名这几列(模型那边另有背景)。
|
||||
* - catalog:拿不到(最近一次停止了、用的是再之前那次的留空列数;取任务失败;老服务端没有列名)。例子和按钮都改说
|
||||
* 「目录里还没有中文名和含义的列」,按钮上的数字就是这些列的总数,点名其中前 BLANK_ASK_MAX 列、再写「等 N 列」。
|
||||
* ask 为 null:没有可以点名的列,不给按钮
|
||||
*/
|
||||
export interface BlankLead {
|
||||
from: 'draft' | 'catalog'
|
||||
/** 上次起草留空的列数 */
|
||||
n: number
|
||||
examples: string[]
|
||||
/** 记全了时,留空的列里还没补上的几列;没记全、拿不到列名时为 null */
|
||||
remaining: number | null
|
||||
/** 目录里还没有中文名和含义的列数 */
|
||||
unnamed: number
|
||||
ask: { columns: string[]; total: number | null } | null
|
||||
}
|
||||
|
||||
export function blankLeadOf(n: number, sample: readonly string[] | null, unnamed: readonly string[]): BlankLead {
|
||||
const named = sample?.filter((c) => !!c) ?? []
|
||||
if (named.length) {
|
||||
const open = new Set(unnamed)
|
||||
const still = named.filter((c) => open.has(c))
|
||||
const remaining = named.length >= n ? still.length : null
|
||||
return {
|
||||
from: 'draft', n, examples: still.slice(0, BLANK_EXAMPLES), remaining, unnamed: unnamed.length,
|
||||
ask: still.length ? { columns: still, total: remaining } : null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
from: 'catalog', n, examples: unnamed.slice(0, BLANK_EXAMPLES), remaining: null, unnamed: unnamed.length,
|
||||
ask: unnamed.length ? { columns: unnamed.slice(0, BLANK_ASK_MAX), total: unnamed.length } : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** 还没有中文名和含义的列(表结构里的顺序;被驳回的不算有) */
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -42,12 +42,41 @@ export const DRAFT_PART_COLUMNS = 40
|
||||
/** 模型拿不准、留空的列数(任务里的一张表、清单上的上次起草);老服务端没有这个键时为 0 */
|
||||
export const blankOf = (t: { blank_columns?: number } | null | undefined): number => Math.max(0, t?.blank_columns ?? 0)
|
||||
|
||||
type LastDraftLike = {
|
||||
status: CatalogDraftTaskStatus
|
||||
blank_columns?: number
|
||||
blank_columns_from?: number | null
|
||||
parts_written?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 清单、表详情上「上次起草有 N 列模型拿不准、留空了」的 N:这张表的那次起草结束了才算(还在起草的那一次写到一半,
|
||||
* 不说「上次」)
|
||||
* 清单、表详情上「上次起草有 N 列模型拿不准、留空了」的 N 和它的出处:这张表的那次起草结束了才算(还在起草的那一次
|
||||
* 写到一半,不说「上次」)。最近一次是停止、服务重启中断(表记已停止),又没有新写入(自己没记下留空的列、也没写进
|
||||
* 任何一段)时,留空的列照旧是再之前那次写完的起草记下的(blank_columns_from),earlier 为真
|
||||
*/
|
||||
export const lastBlankOf = (d: { status: CatalogDraftTaskStatus; blank_columns?: number } | null | undefined): number =>
|
||||
(d && TASK_ENDED.has(d.status) ? blankOf(d) : 0)
|
||||
export function lastBlankInfo(d: LastDraftLike | null | undefined): { n: number; earlier: boolean } {
|
||||
if (!d || !TASK_ENDED.has(d.status)) return { n: 0, earlier: false }
|
||||
const own = blankOf(d)
|
||||
if (d.status === 'cancelled' && own === 0 && (d.parts_written ?? 0) === 0) {
|
||||
const from = Math.max(0, d.blank_columns_from ?? 0)
|
||||
return { n: from, earlier: from > 0 }
|
||||
}
|
||||
return { n: own, earlier: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* 宽表的块进度:段被缩小、拆半后实际分成的块比段多,这时才写「已结束几块 / 共几块」(没拆过的段进度已经说清了)。
|
||||
* 不用模型、老服务端没有这两个键时为 null
|
||||
*/
|
||||
export function chunksOf(t: { parts_total: number | null; chunks_total?: number | null; chunks_done?: number | null }):
|
||||
{ done: number; total: number } | null {
|
||||
const total = t.chunks_total
|
||||
if (total == null || total <= Math.max(1, t.parts_total ?? 1)) return null
|
||||
return { done: Math.max(0, Math.min(total, t.chunks_done ?? 0)), total }
|
||||
}
|
||||
|
||||
/** 已经写进目录的段数(老服务端没有这个键时为 0) */
|
||||
export const writtenOf = (t: { parts_written?: number }): number => Math.max(0, t.parts_written ?? 0)
|
||||
|
||||
/** 一次起草里留空的列合计:服务端给了合计就用它,老服务端没有就按逐表加 */
|
||||
export const jobBlank = (j: CatalogDraftJob): number => (j.blank_columns != null ? blankOf(j) : j.tasks.reduce((n, t) => n + blankOf(t), 0))
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Check, CircleCheck, CircleDashed, CircleX, RotateCcw, ShieldCheck, Trash2, X } from 'lucide-react'
|
||||
import { Check, CircleCheck, CircleDashed, CircleX, Info, MessageSquareQuote, RotateCcw, ShieldCheck, Trash2, X } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import type { CatalogCounts, CatalogReviewAction, CatalogSource, CatalogStatus } from '../../types'
|
||||
import { Notice } from '../../components/ui'
|
||||
import { formatDateTime, formatNumber, formatTime } from '../../lib/format'
|
||||
import {
|
||||
CATALOG_SOURCE_LABEL, CATALOG_STATUS_HINT, CATALOG_STATUS_LABEL, CATALOG_TEXT as CT,
|
||||
CATALOG_SOURCE_LABEL, CATALOG_STATUS_HINT, CATALOG_STATUS_LABEL, CATALOG_TEXT as CT, CATALOG_UI_TEXT as UT,
|
||||
} from '../../lib/terms'
|
||||
import { STATUSES, initialStatus } from './model'
|
||||
|
||||
@@ -99,12 +99,14 @@ export interface ReviewTarget {
|
||||
source: CatalogSource
|
||||
status: CatalogStatus
|
||||
note?: string
|
||||
/** 确认时的依据(保存修改提案时记下的,和备注分开写) */
|
||||
reason?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 单项的来源和状态。触发按钮就是状态标识(compact 时只有图标,列表格里每格一个);点开是一个小弹层:
|
||||
* 位置、来源、状态说明、更新时间,以及确认 / 驳回 / 恢复。弹层挂在 body 上、按触发按钮的位置固定定位,
|
||||
* 位置、来源、状态说明、更新时间、备注和确认时的依据(各一块),以及确认 / 驳回 / 恢复。弹层挂在 body 上、按触发按钮的位置固定定位,
|
||||
* 不会被表格的横向滚动裁掉;滚动时跟着触发按钮走,滚出视野或改窗口大小时收起。键盘:Enter 打开,焦点落到第一个可点的操作,
|
||||
* ←→↑↓ 在操作之间移动,Tab 在弹层里循环,Esc 收起并把焦点还给触发按钮
|
||||
*/
|
||||
@@ -260,6 +262,12 @@ export function ItemMark({ target, compact = false, disabled = false, disabledHi
|
||||
<p className="tnum text-faint" title={formatDateTime(target.updated_at)} data-item-updated="">{CT.itemUpdated(formatTime(target.updated_at))}</p>
|
||||
)}
|
||||
{target.note && <p className="whitespace-pre-wrap break-words rounded border bg-bg px-2 py-1 text-dim">{target.note}</p>}
|
||||
{/* 确认时的依据:使用者确认时说的话,和上面机器写的备注分开(驳回的项只在这里看得到当初确认时的依据) */}
|
||||
{target.reason?.trim() && (
|
||||
<p className="whitespace-pre-wrap break-words rounded border bg-bg px-2 py-1 text-dim" data-item-reason="">
|
||||
<span className="text-faint">{UT.fieldReason}:</span>{target.reason.trim()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
<ActionButton icon={<Check size={11} />} disabled={disabled || status === 'confirmed'} onClick={() => act('confirm')} action="confirm">
|
||||
@@ -294,6 +302,53 @@ function ActionButton({ icon, children, disabled, onClick, action, title }: {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 人工确认的项带的备注:确认时留着的数据剖析说明、模型起草时附的依据、双盲复算写明的出处。确认过的项是 SQL 检查和助手的
|
||||
* 依据,为什么这么定要看得到,不能只藏在状态弹层里:表级的项写在值下面(TableFieldRow,同有效记录条件的说明),列的一格、
|
||||
* 关联关系悬停看(NoteHint)。没有备注、不是已确认的为空串
|
||||
*/
|
||||
export const confirmedNote = (item: { status: CatalogStatus; note?: string } | null | undefined): string =>
|
||||
(item && item.status === 'confirmed' ? item.note?.trim() ?? '' : '')
|
||||
|
||||
/**
|
||||
* 确认时的依据(目录项的 reason):保存修改提案时使用者确认的依据,服务端和备注分开存,这里也分开写——表级的项在值下面
|
||||
* 另起一行,列的一格、关联关系另一个悬停的图标(NoteHint,读屏念「确认时的依据:……」)。只在已确认的项上就近写;驳回的项
|
||||
* 值已划掉,当初确认时的依据只在状态弹层里看。没有为空串
|
||||
*/
|
||||
export const confirmedReason = (item: { status: CatalogStatus; reason?: string } | null | undefined): string =>
|
||||
(item && item.status === 'confirmed' ? item.reason?.trim() ?? '' : '')
|
||||
|
||||
/** 确认时的依据用的图标:引号气泡(使用者的话),和备注的「i」分得开 */
|
||||
export const REASON_ICON: LucideIcon = MessageSquareQuote
|
||||
|
||||
/**
|
||||
* 列的一格、关联关系上的备注或确认时的依据:只占一个小图标,悬停看全文;读屏念「说明:……」(label 换成「确认时的依据」
|
||||
* 时念「确认时的依据:……」)。attr 是给检查脚本定位的 data- 属性名
|
||||
*/
|
||||
export function NoteHint({ note, attr, label = UT.fieldNote, icon: Icon = Info }: {
|
||||
note: string
|
||||
attr: string
|
||||
label?: string
|
||||
icon?: LucideIcon
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex shrink-0 cursor-help items-center pt-[3px] text-faint hover:text-dim" title={`${label}:${note}`}
|
||||
{...{ [attr]: '' }}>
|
||||
<Icon size={11} aria-hidden />
|
||||
<span className="sr-only">{label}:{note}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** 列的一格、关联关系上确认时的依据(没有为 null) */
|
||||
export function ReasonHint({ reason, attr }: { reason: string; attr: string }) {
|
||||
return reason ? <NoteHint note={reason} attr={attr} label={UT.fieldReason} icon={REASON_ICON} /> : null
|
||||
}
|
||||
|
||||
/** 从审阅队列打开的那一项(表级项、列的一格、一条关系):一圈强调色的框,淡淡的底 */
|
||||
export const FOCUS_RING = 'rounded-md ring-2 ring-inset ring-[var(--accent)]'
|
||||
export const FOCUS_STYLE = { background: 'color-mix(in srgb, var(--accent) 7%, transparent)' }
|
||||
|
||||
/** 导入表格的源:说明是系统生成的,只读 */
|
||||
export function SystemNotesNotice() {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import type {
|
||||
CatalogNotes, CatalogReviewAction, CatalogReviewBatchResult, CatalogReviewItem, CatalogReviewKind, CatalogStatus,
|
||||
} from '../../types'
|
||||
import { CATALOG_TEXT } from '../../lib/terms'
|
||||
import { patchTarget, patchWhere } from '../../run/catalogPatch'
|
||||
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}`
|
||||
|
||||
/**
|
||||
* 队列里一项的位置,说法同提案卡片:「列 status 的码值」「表的有效记录条件」「指向 parks 的关联关系」。服务端的
|
||||
* field_label 只是字段的叫法(「码值」),列级的项占了队列的大半,只写它分不清是哪一列,所以按 path 说;认不出的
|
||||
* 路径再用 column 加 field_label
|
||||
*/
|
||||
export function reviewWhere(i: Pick<CatalogReviewItem, 'path' | 'value' | 'field_label'> & { column?: string | null }): string {
|
||||
if (patchTarget(i.path)) return patchWhere({ path: i.path, before: i.value ?? null, after: i.value ?? null })
|
||||
const label = i.field_label?.trim()
|
||||
if (i.column && label) return CATALOG_TEXT.whereColumn(i.column, label)
|
||||
return label || 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 const reviewedCount = (items: readonly CatalogReviewItem[], outcomes: ReadonlyMap<string, ItemOutcome>): number =>
|
||||
items.filter((i) => statusOf(i, outcomes.get(itemKey(i))) !== 'proposed').length
|
||||
|
||||
/**
|
||||
* 列表头「N 项待确认」:总数减掉列表里已经不是推断的项。total 是「列表里的项 + 服务端还没载入的项」(载入下一页时按
|
||||
* 服务端的新总数加回列表里审阅过的项,见 ReviewQueue),所以减完就是服务端现在的待确认项数,和视图标签、按表筛选的计数
|
||||
* 同一个口径。以前只在载入时设一次,成批审阅之后还写着审阅前的数
|
||||
*/
|
||||
export const pendingTotal = (total: number, items: readonly CatalogReviewItem[], outcomes: ReadonlyMap<string, ItemOutcome>): number =>
|
||||
Math.max(0, total - reviewedCount(items, outcomes))
|
||||
|
||||
/**
|
||||
* 一批选中的项里,这个动作要发哪几条:确认发还不是已确认的,驳回发还不是已驳回的,撤销审阅只发这一页上审阅成功过的
|
||||
* (队列里本来都是推断,没审阅过的撤销了也没有变化)。冲突过的照旧可以再发
|
||||
*/
|
||||
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)
|
||||
|
||||
/**
|
||||
* 审阅进度的一行(常用表、全部表、影响数字的项同一个口径):已确认 ÷(已确认 + 待确认)。已驳回的、有确证的(外键约束)
|
||||
* 不用再确认,不计入,审完了就是 100%。不知道待确认几项(服务端没给)时 pct 为 null,只写已确认几项
|
||||
*/
|
||||
export function confirmProgress(confirmed: number, proposed: number | null | undefined): {
|
||||
pct: number | null; confirmed: number; proposed: number | null
|
||||
} {
|
||||
const c = Math.max(0, confirmed || 0)
|
||||
if (typeof proposed !== 'number' || !Number.isFinite(proposed)) return { pct: null, confirmed: c, proposed: null }
|
||||
const p = Math.max(0, proposed)
|
||||
return { pct: percentOf(c, c + p), confirmed: c, proposed: p }
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import { CatalogImpactList } from '../components/CatalogImpact'
|
||||
import { CATALOG_PATCH_TEXT as T, CATALOG_STATUS_LABEL, CATALOG_UI_TEXT as UT } from '../lib/terms'
|
||||
import type { CatalogPatchChange } from '../types'
|
||||
import {
|
||||
catalogTableHref, codeMeaning, patchSubmit, patchTarget, patchValueText, patchWhere, type CatalogPatch,
|
||||
beforeStatusShown, catalogTableHref, codeMeaning, patchBeforeText, patchSubmit, patchTarget, patchValueText, patchWhere,
|
||||
type CatalogPatch,
|
||||
} from './catalogPatch'
|
||||
|
||||
// ===========================================================================
|
||||
@@ -183,13 +184,15 @@ function ChangeRow({ change: c, dense }: { change: CatalogPatchChange; dense: bo
|
||||
</div>
|
||||
<dl className="mt-1 grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-0.5 text-2xs leading-relaxed">
|
||||
<dt className="text-faint">{T.before}</dt>
|
||||
<dd className={clsx('break-words', c.before == null ? 'text-faint' : 'text-dim')} data-patch-before="">
|
||||
{patchValueText(c.path, c.before)}
|
||||
{c.before != null && c.before_status && <span className="text-faint">({CATALOG_STATUS_LABEL[c.before_status]})</span>}
|
||||
<dd className={clsx('break-words', c.before == null || c.before_status === 'rejected' ? 'text-faint' : 'text-dim')} data-patch-before=""
|
||||
data-patch-before-status={c.before_status ?? undefined}>
|
||||
{patchBeforeText(c)}
|
||||
{beforeStatusShown(c) && <span className="text-faint">({CATALOG_STATUS_LABEL[c.before_status!]})</span>}
|
||||
</dd>
|
||||
<dt className="text-faint">{T.after}</dt>
|
||||
<dd className="break-words text-fg" data-patch-after="">
|
||||
{isCodes ? <CodesAfter before={c.before} after={c.after} /> : patchValueText(c.path, c.after)}
|
||||
{/* 被驳回的码值不算:保存时整项换成新的,改后的码值都是新加的 */}
|
||||
{isCodes ? <CodesAfter before={c.before_status === 'rejected' ? null : c.before} after={c.after} /> : patchValueText(c.path, c.after)}
|
||||
</dd>
|
||||
{c.reason && (
|
||||
<>
|
||||
|
||||
@@ -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 {
|
||||
@@ -136,6 +141,20 @@ export function patchValueText(path: string, v: unknown): string {
|
||||
return typeof v === 'string' ? v : JSON.stringify(v)
|
||||
}
|
||||
|
||||
/**
|
||||
* 改前那一格(卡片、表助手的提案和待保存的修改都用它):被驳回的项写「已驳回:原值」——服务端把驳回的原值也交回来
|
||||
* (before_status 为 rejected),保存时照旧换成人工确认的新值;只写「未填写」就看不出原来有一个被驳回的值,和导入预览
|
||||
* 「目标里这一项已经驳回」对不上。别的照 patchValueText 写,没有值写「未填写」
|
||||
*/
|
||||
export function patchBeforeText(change: Pick<CatalogPatchChange, 'path' | 'before' | 'before_status'>): string {
|
||||
const text = patchValueText(change.path, change.before)
|
||||
return change.before_status === 'rejected' && change.before != null ? CATALOG_PATCH_TEXT.beforeRejected(text) : text
|
||||
}
|
||||
|
||||
/** 改前要不要跟一个状态(「(推断)」):有值、不是被驳回的(被驳回的状态已经写在 patchBeforeText 里) */
|
||||
export const beforeStatusShown = (change: Pick<CatalogPatchChange, 'before' | 'before_status'>) =>
|
||||
change.before != null && !!change.before_status && change.before_status !== 'rejected'
|
||||
|
||||
/** 交回保存、预览的那一份:路径、原样取值、理由 */
|
||||
export function patchSubmit(changes: CatalogPatchChange[]) {
|
||||
return changes.map((c) => ({ path: c.path, value: c.value, ...(c.reason ? { reason: c.reason } : {}) }))
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
claimTally,
|
||||
evidenceTally, issuanceLabel, nodeTypeLabel,
|
||||
} from '../lib/terms'
|
||||
import { catalogPatchOf, patchValueText, patchWhere } from './catalogPatch'
|
||||
import { catalogPatchOf, patchBeforeText, patchValueText, patchWhere } from './catalogPatch'
|
||||
import { claimCountsOf, isJudged, statsTally } from '../lib/evidence'
|
||||
import { isSqlCheckCode, sortSqlChecks, sqlCheckOf, sqlChecksOf, sqlCheckWhere, sqlRuleLabel } from '../lib/sqlcheck'
|
||||
|
||||
@@ -3268,13 +3268,14 @@ export function decodeCopilot(ops: CopilotOp[], opts?: {
|
||||
break
|
||||
}
|
||||
case 'catalog_patch': {
|
||||
// 目录修改提案:过程里记一行(哪张表、几项,展开看改前改后),保存、忽略在轮次里的卡片上做(catalogPatchesOf)
|
||||
// 目录修改提案:过程里记一行(哪张表、几项,展开看改前改后),保存、忽略在轮次里的卡片上做(catalogPatchesOf)。
|
||||
// 改前和卡片同一个写法(patchBeforeText):落在已驳回的项上写「已驳回:<原值>」,不写成像是当前值的原值
|
||||
const patch = catalogPatchOf(op, String(i - 1))
|
||||
if (!patch) break
|
||||
add({ id: `cpt-${i}`, seq: i, kind: 'schema', status: 'done', code: 'catalog_patch',
|
||||
title: CATALOG_PATCH_TEXT.step(patch.tableLabel ?? patch.table, patch.changes.length),
|
||||
sub: CATALOG_PATCH_TEXT.stepSub,
|
||||
detail: patch.changes.map((c) => `${patchWhere(c)}:${patchValueText(c.path, c.before)} → ${patchValueText(c.path, c.after)}`)
|
||||
detail: patch.changes.map((c) => `${patchWhere(c)}:${patchBeforeText(c)} → ${patchValueText(c.path, c.after)}`)
|
||||
.join('\n') })
|
||||
break
|
||||
}
|
||||
|
||||
+211
-1
@@ -2712,7 +2712,13 @@ export interface CatalogItem<T = unknown> {
|
||||
value: T
|
||||
source: CatalogSource
|
||||
status: CatalogStatus
|
||||
/** 备注:机器写的说明(模型起草的依据、数据剖析的测量、双盲复算的出处……) */
|
||||
note?: string
|
||||
/**
|
||||
* 确认时的依据:保存修改提案(表助手、助手流里「建议更新数据目录」)时使用者确认的依据,和备注分开存。值改了换成那一次的,
|
||||
* 撤销审阅回到推断时去掉;只由服务端写,整份提交时带上也不作数
|
||||
*/
|
||||
reason?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
@@ -2745,6 +2751,8 @@ export interface CatalogRelation {
|
||||
source: CatalogSource
|
||||
status: CatalogStatus
|
||||
note?: string
|
||||
/** 确认时的依据(同 CatalogItem.reason) */
|
||||
reason?: string
|
||||
updated_at?: string
|
||||
/**
|
||||
* 基数是数据剖析用数据核实的(子表一侧数过是否唯一),只由剖析写。外键、命名推断的基数没有这个键。
|
||||
@@ -2859,7 +2867,8 @@ export type CatalogReviewAction = 'confirm' | 'reject' | 'reset'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 起草任务(/api/datasources/{id}/catalog/draft-jobs):起草在服务端后台进行,逐表起草、每张表一结束就并入目录。
|
||||
// 关掉对话框不停任务;服务重启时还没结束的任务记 interrupted,不自动续跑,由人点「重试」接着起草。
|
||||
// 关掉对话框不停任务;服务重启时还没结束的任务记 interrupted,不自动续跑,由人点「重试」把没写全的表重新起草一遍
|
||||
// (重试不跳过已写进目录的段,整张表重新分段起草;已写进目录的内容不会丢)。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -2884,6 +2893,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
|
||||
@@ -2900,6 +2919,11 @@ export interface CatalogDraftTask {
|
||||
blank_columns?: number
|
||||
/** 留空的列里的前几个(最多 5 个,列名照表结构原样)。老服务端没有这个键 */
|
||||
blank_sample?: string[]
|
||||
/**
|
||||
* 这张表起草时记下的几句话(服务端整句中文,不算失败),比如模型提议的有效记录条件没有收下的原因。最多 10 句;
|
||||
* 老服务端没有这个键
|
||||
*/
|
||||
log?: string[]
|
||||
/** 整张表没起草的原因:表结构里没有、写入一直冲突、服务重启中断 */
|
||||
error: string | null
|
||||
/** 模型那部分没写出来的原因和办法(第几段、哪些列、为什么) */
|
||||
@@ -2984,6 +3008,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 +3077,185 @@ 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……;关联关系是 relation) */
|
||||
field: string
|
||||
/**
|
||||
* 字段的界面叫法(服务端写好的「中文名」「码值」「有效记录条件」「关联关系」),不含是哪一列:界面上的位置按 path
|
||||
* 说(「列 status 的码值」,review.ts 的 reviewWhere)
|
||||
*/
|
||||
field_label: string
|
||||
/** 列级的项是哪一列;表级的项、关联关系为 null。接口约定里没有写明,服务端给了才用 */
|
||||
column?: string | null
|
||||
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 张表、全部表、影响数字的项各审阅到哪儿。
|
||||
* 「常用表」只算被运行用过的:tables 可能比 top_n 少,没有运行时是 0。三行的进度都按「已确认 ÷(已确认 + 待确认)」算
|
||||
* (review.ts 的 confirmProgress):已驳回的、有确证的(外键约束)不用再确认,不计入
|
||||
*/
|
||||
export interface CatalogReviewProgress {
|
||||
top_n: number
|
||||
/** items_proposed、items_verified:接口约定里没有写明,服务端给了才用(没有待确认数时只写已确认几项) */
|
||||
top: { tables: number; items_total: number; items_confirmed: number; items_proposed?: number; items_verified?: number }
|
||||
all: { items_total: number; items_confirmed: number; items_proposed: number; items_rejected: number; items_verified?: 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件流里的一个事件。服务端等模型时另发 SSE 注释行(「: keep-alive」,不是事件),读流时跳过。
|
||||
* query 的 row_count 是取回的行数;truncated(接口约定里没有写明,服务端给了才用)为真时后面还有行,取回的只是前一截
|
||||
*/
|
||||
export type CatalogAssistantEvent =
|
||||
| { type: 'text'; delta: string }
|
||||
| { type: 'query'; sql: string; columns: string[]; rows: unknown[][]; row_count: number; truncated?: boolean; 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 }[]
|
||||
/** 界面上还没保存的修改:最多 200 项(服务端同一个上限,多了整个请求 422),多出来的只交最近的 200 项 */
|
||||
pending: CatalogPatchSubmit[]
|
||||
}
|
||||
|
||||
/** GET /datasources/{id}/catalog/{table}/impact */
|
||||
export interface CatalogImpact {
|
||||
table: string
|
||||
|
||||
@@ -81,6 +81,7 @@ const ORDER = [
|
||||
'manage', // 数据、工具、知识、设置
|
||||
'versions', // 上传表格的版本:回滚、清单、移除一期、清除原件
|
||||
'catalog', // 数据目录:表清单、起草、单项审阅、批量确认、编辑即确认、409
|
||||
'catalog-loop', // 数据目录闭环:表助手、审阅队列、导出导入、防错事实的说明
|
||||
// 复核套件与导出
|
||||
'schedules', // 定时任务
|
||||
'compare', // 运行对比
|
||||
@@ -97,7 +98,7 @@ const ORDER = [
|
||||
const ESTIMATE = {
|
||||
challenge: 30,
|
||||
stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87,
|
||||
evidence: 91, versions: 60, catalog: 140, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
|
||||
evidence: 91, versions: 60, catalog: 150, 'catalog-loop': 60, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
|
||||
// 占位,换成真检查时按实测改
|
||||
schedules: 60, compare: 45, verify: 71, inquiry: 60, bundle: 16, whatif: 33, 'report-export': 30,
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+147
-11
@@ -105,7 +105,13 @@ async function open(path, { handlers = [], viewport = { width: 1440, height: 900
|
||||
await page.goto(`${WEB}${path}`, { waitUntil: 'networkidle' })
|
||||
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), THEME)
|
||||
await page.waitForTimeout(400)
|
||||
return { page, sent, errors, natives, close: () => { opened.delete(ctx); return ctx.close() } }
|
||||
// 关之前先撤掉拦截、忽略还在路上的那几条:满负荷并行时,取真实返回的拦截可能在页面关掉之后才回来,
|
||||
// 没人接住的报错会把整个检查进程带崩(「Request context disposed」)
|
||||
return { page, sent, errors, natives, close: async () => {
|
||||
opened.delete(ctx)
|
||||
await page.unrouteAll({ behavior: 'ignoreErrors' }).catch(() => {})
|
||||
return ctx.close()
|
||||
} }
|
||||
}
|
||||
const goto = async (page, path) => {
|
||||
await page.goto(`${WEB}${path}`, { waitUntil: 'networkidle' })
|
||||
@@ -436,9 +442,12 @@ function newJob(state, src, { tables, use_model, note = null, retry_of = null })
|
||||
const tasks = tables.map((name) => {
|
||||
const t = state.tables[src].find((x) => x.name === name && !x.missing)
|
||||
const cols = t?.columns?.length ?? 0
|
||||
const partsTotal = t && use_model ? (cols > PART_COLUMNS ? 1 + Math.ceil(cols / PART_COLUMNS) : 1) : null
|
||||
return {
|
||||
table_name: name, status: t ? 'pending' : 'skipped', columns: cols,
|
||||
parts_total: t && use_model ? (cols > PART_COLUMNS ? 1 + Math.ceil(cols / PART_COLUMNS) : 1) : null, parts_done: 0,
|
||||
parts_total: partsTotal, parts_done: 0,
|
||||
// 逐段写进目录:已写进的段数;段被缩小、拆半后实际的块数和已结束的块数(不用模型时为 null)
|
||||
parts_written: 0, chunks_total: partsTotal, chunks_done: partsTotal == null ? null : 0,
|
||||
added: 0, updated: 0, removed: 0, version: t?.version ?? 0, model_items: null, blank_columns: 0, blank_sample: [],
|
||||
error: t ? null : `表结构里没有 ${name},请先重新探查结构`, model_error: null,
|
||||
started_at: null, finished_at: t ? null : isoNow(),
|
||||
@@ -507,7 +516,13 @@ function finishJob(job, status) {
|
||||
*/
|
||||
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 (t.status !== 'pending' && t.status !== 'running') continue
|
||||
// 写过段的表照旧记已停止,原因写一句已写进目录几段、剩下几段没起草(服务端停止、中断时的说法:catalog_jobs 的
|
||||
// STOPPED_WRITTEN、INTERRUPTED_WRITTEN)
|
||||
const written = t.parts_written ?? 0
|
||||
const modelError = written > 0 && t.parts_total
|
||||
? `${status === 'interrupted' ? '已中断' : '已停止'}:已写进目录 ${written} 段,剩下 ${t.parts_total - written} 段没起草` : t.model_error
|
||||
Object.assign(t, { status: 'cancelled', error, model_error: modelError, finished_at: isoNow() })
|
||||
}
|
||||
if (error) job.error = error
|
||||
delete job.stopAsked
|
||||
@@ -524,15 +539,19 @@ function failJob(job, error) {
|
||||
}
|
||||
|
||||
function lastDraftOf(state, src, name) {
|
||||
for (const job of [...state.jobs].reverse()) {
|
||||
if (job.source_id !== src) continue
|
||||
const jobs = [...state.jobs].reverse().filter((j) => j.source_id === src)
|
||||
const fixture = state.tables[src]?.find((x) => x.name === name)?.lastDraft ?? null
|
||||
// 最近一次这张表结束于完成、部分完成的起草记下的留空列数(服务端 last_drafts 的 blank_columns_from)
|
||||
const ended = jobs.map((j) => j.tasks.find((x) => x.table_name === name)).find((t) => t && (t.status === 'ok' || t.status === 'partial'))
|
||||
const from = ended ? ended.blank_columns : fixture?.blank_columns_from ?? null
|
||||
for (const job of jobs) {
|
||||
const t = job.tasks.find((x) => x.table_name === name)
|
||||
if (t) {
|
||||
return { status: t.status, job_id: job.id, finished_at: t.finished_at, error: t.error, model_error: t.model_error,
|
||||
blank_columns: t.blank_columns }
|
||||
blank_columns: t.blank_columns, blank_columns_from: from, parts_written: t.parts_written ?? 0 }
|
||||
}
|
||||
}
|
||||
return state.tables[src]?.find((x) => x.name === name)?.lastDraft ?? null
|
||||
return fixture
|
||||
}
|
||||
|
||||
const jobOf = (state, key) => {
|
||||
@@ -596,9 +615,11 @@ function jobHandlers(state, reply) {
|
||||
}],
|
||||
// 起草对话框写明用哪个起草模型:设置里的 catalog_draft(其余设置照真服务端的)、助手当前用的模型
|
||||
[/^GET \/settings$/, async (route) => {
|
||||
const real = await route.fetch()
|
||||
// 页面已经关了(上一节收尾时还在路上):这一条不用再答
|
||||
const real = await route.fetch().catch(() => null)
|
||||
if (!real) return route.abort().catch(() => {})
|
||||
const body = await real.json().catch(() => ({}))
|
||||
return route.fulfill({ response: real, json: { ...body, catalog_draft: state.draftModel } })
|
||||
return route.fulfill({ response: real, json: { ...body, catalog_draft: state.draftModel } }).catch(() => {})
|
||||
}],
|
||||
[/^GET \/copilot\/model$/, (route) => json(state.assistantModel)(route)],
|
||||
]
|
||||
@@ -1202,8 +1223,10 @@ await section('数据目录 · 起草:已有任务(409)、服务重启中
|
||||
check('上一次起草因服务重启中断:设置阶段写明,给「查看」', await until(async () => (await draftText(page, '[data-draft-last]')).includes('服务重启')),
|
||||
await draftText(page, '[data-draft-last]'))
|
||||
await draftBox(page).locator('[data-draft-last-open]').click()
|
||||
check('……点开:结果写明服务重启中断,可以接着重试', await until(async () => (await draftPhase(page)) === 'done')
|
||||
&& (await draftText(page, '[data-draft-title]')) === '服务重启,起草中断' && (await draftText(page, '[data-draft-interrupted]')).includes('重试'),
|
||||
// 重试不是续跑:服务端把这些表整张重新分段起草(不跳过已写进目录的段),不能说成「接着起草」剩下的部分
|
||||
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-interrupted]')).includes('已写进目录的内容不会丢') && !(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-status]')) === '已停止'
|
||||
@@ -1544,6 +1567,119 @@ await section('数据目录 · 起草:模型拿不准、留空的列', async (
|
||||
await close()
|
||||
})
|
||||
|
||||
await section('数据目录 · 起草:逐段写进目录、块进度与停止后的留空提示', async () => {
|
||||
const state = freshState()
|
||||
// 上一次起草写完了这两张表,各留了几列空(之后停止、又没有新写入时,界面接着用这次记下的留空列数提示)
|
||||
const old = newJob(state, S1, { tables: ['store_sales', 'members'], use_model: true })
|
||||
settle(state, old, 'store_sales', { added: 10, model_items: 40, blank_columns: 6, blank_sample: BLANK_SAMPLE })
|
||||
settle(state, old, 'members', { added: 2, model_items: 3, blank_columns: 3, blank_sample: ['member_no', 'joined_on'] })
|
||||
finishJob(old)
|
||||
const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
|
||||
await page.locator('[data-catalog-row]').first().waitFor()
|
||||
check('开始起草(门店销售、会员 2 张,用模型)', await startDraft(page, { select: ['store_sales', 'members'], model: true }))
|
||||
const job = state.jobs.at(-1)
|
||||
const task = (n) => draftBox(page).locator(`[data-draft-task="${n}"]`)
|
||||
const text = (n, sel) => task(n).locator(sel).first().innerText({ timeout: 500 }).then((t) => t.replace(/\s+/g, ' ').trim()).catch(() => '')
|
||||
|
||||
// 宽表第 1 段写完就写进目录:逐表写「已写进目录 1 段」;没拆过的段不写块进度(段进度已经说清了)
|
||||
settle(state, job, 'store_sales', { status: 'running', parts_done: 1, parts_written: 1, chunks_total: 3, chunks_done: 1 })
|
||||
check('宽表写完一段:逐表写「已写进目录 1 段」,段进度「1 / 3 段」,没拆过不写块进度',
|
||||
await until(async () => (await text('store_sales', '[data-draft-task-written]')) === '已写进目录 1 段')
|
||||
&& (await text('store_sales', '[data-draft-task-parts]')) === '1 / 3 段' && await task('store_sales').locator('[data-draft-task-chunks]').count() === 0,
|
||||
`${await text('store_sales', '[data-draft-task-written]')} · ${await text('store_sales', '[data-draft-task-parts]')}`)
|
||||
check('……「已写进目录」悬停写明停止、服务重启时保留', ((await task('store_sales').locator('[data-draft-task-written]').getAttribute('title')) ?? '').includes('停止或服务重启时已写进的段保留'))
|
||||
// 额度用完、超时后段被拆半:块比段多,写「已结束几块 / 共几块」,悬停说明为什么变多
|
||||
settle(state, job, 'store_sales', { status: 'running', parts_done: 1, parts_written: 1, chunks_total: 5, chunks_done: 2 })
|
||||
const chunks = task('store_sales').locator('[data-draft-task-chunks]')
|
||||
check('段被拆小:写块进度「2 / 5 块」,悬停说明实际分成 5 块', await until(async () => (await text('store_sales', '[data-draft-task-chunks]')) === '2 / 5 块')
|
||||
&& ((await chunks.getAttribute('title')) ?? '').includes('实际分成 5 块'), await text('store_sales', '[data-draft-task-chunks]'))
|
||||
settle(state, job, 'store_sales', { status: 'running', parts_done: 2, parts_written: 2, chunks_total: 5, chunks_done: 4 })
|
||||
check('……接着写:「已写进目录 2 段」「2 / 3 段」「4 / 5 块」', await until(async () => (await text('store_sales', '[data-draft-task-written]')) === '已写进目录 2 段')
|
||||
&& (await text('store_sales', '[data-draft-task-parts]')) === '2 / 3 段' && (await text('store_sales', '[data-draft-task-chunks]')) === '4 / 5 块')
|
||||
check('……还没开始的表不写段进度、块进度、已写进目录', await task('members').locator('[data-draft-task-parts], [data-draft-task-chunks], [data-draft-task-written]').count() === 0)
|
||||
// 起草时记下的几句话(服务端原话):没收下的有效记录条件和原因,列在这张表下面,不算失败
|
||||
settle(state, job, 'store_sales', { status: 'running', parts_done: 2, parts_written: 2, chunks_total: 5, chunks_done: 4,
|
||||
log: ['有效记录条件按剖析核对排除了全部行(共 1,200 行),模型提议的这一条没有收下:status <> 9'] })
|
||||
check('……起草时记下的说明(没收下的有效记录条件)照原话列在这张表下面', await until(async () => (await text('store_sales', '[data-draft-task-log]')).includes('模型提议的这一条没有收下:status <> 9')),
|
||||
await text('store_sales', '[data-draft-task-log]'))
|
||||
check('……没有说明的表不写这一行', await task('members').locator('[data-draft-task-log]').count() === 0)
|
||||
await shot(page, 'catalog-draft-chunks')
|
||||
|
||||
// 停止:已写进的段保留。说法照服务端:写过段的表记已停止,原因写已写进几段、剩下几段没起草
|
||||
await page.locator('[data-draft-stop]').click()
|
||||
check('点「停止」:写明正在停止、已写进目录的段保留', await until(async () => (await draftText(page, '[data-draft-title]')).includes('已写进目录的段保留')),
|
||||
await draftText(page, '[data-draft-title]'))
|
||||
stopJob(job)
|
||||
check('停下来:宽表记已停止,原因写「已写进目录 2 段,剩下 1 段没起草」', await until(async () => (await draftPhase(page)) === 'done')
|
||||
&& (await text('store_sales', '[data-draft-task-status]')) === '已停止'
|
||||
&& (await text('store_sales', '[data-draft-task-reason]')).includes('已写进目录 2 段,剩下 1 段没起草'),
|
||||
`${await text('store_sales', '[data-draft-task-status]')} · ${await text('store_sales', '[data-draft-task-reason]')}`)
|
||||
check('……服务端的原因已经写了,界面不再重复一行「已写进目录」', await task('store_sales').locator('[data-draft-task-written]').count() === 0)
|
||||
const cancelled = await draftText(page, '[data-draft-cancelled]')
|
||||
// 重试会把这张宽表整张重新起草(服务端不跳过已写进目录的段),不能说成「接着起草剩下的段」
|
||||
check('……汇总写明已写进的段保留:其中 1 张表已写进目录 2 段,只重试这些表时会重新起草整张表', cancelled.includes('每写完一段就写进目录')
|
||||
&& cancelled.includes('其中 1 张表已写进目录 2 段') && cancelled.includes('只重试这些表时会重新起草整张表,已写进的不会丢')
|
||||
&& !cancelled.includes('接着起草'), cancelled)
|
||||
check('……可以只重试这 2 张', (await page.locator('[data-draft-retry]').innerText().catch(() => '')).includes('(2 张)'))
|
||||
await page.locator('[data-draft-close]').click()
|
||||
|
||||
// 老服务端停下来时原因里没写:界面自己补一句。部分完成的表段进度是「都结束了」,看不出写进了几段:也写一句
|
||||
const legacy = newJob(state, S1, { tables: ['store_sales', 'members'], use_model: true })
|
||||
settle(state, legacy, 'members', { status: 'partial', parts_total: 2, parts_done: 2, parts_written: 1, model_error: CALL_ERR })
|
||||
settle(state, legacy, 'store_sales', { status: 'running', parts_done: 1, parts_written: 1 })
|
||||
stopJob(legacy)
|
||||
legacy.tasks[0].model_error = null
|
||||
await page.locator('[data-catalog-draft]').first().click()
|
||||
await draftBox(page).waitFor()
|
||||
await until(async () => (await draftBox(page).locator('[data-draft-last-open]').count()) === 1)
|
||||
await draftBox(page).locator('[data-draft-last-open]').click()
|
||||
check('服务端的原因里没写已写进几段:界面补「已写进目录 1 段,剩下 2 段没起草」', await until(async () => (await text('store_sales', '[data-draft-task-written]')) === '已写进目录 1 段,剩下 2 段没起草'),
|
||||
await text('store_sales', '[data-draft-task-written]'))
|
||||
check('……部分完成的表写「已写进目录 1 段」,原因照旧', (await text('members', '[data-draft-task-written]')) === '已写进目录 1 段'
|
||||
&& (await text('members', '[data-draft-task-reason]')).includes(CALL_ERR), await text('members', '[data-draft-task-written]'))
|
||||
await page.locator('[data-draft-close]').click()
|
||||
state.jobs.pop()
|
||||
|
||||
// 服务重启中断:写过段的宽表原因写「已中断:已写进目录 1 段,剩下 2 段没起草」(服务端中断时的说法),界面不再补一行;
|
||||
// 提示写明重试会重新起草整张表,不说接着起草
|
||||
const crashed = newJob(state, S1, { tables: ['store_sales'], use_model: true })
|
||||
settle(state, crashed, 'store_sales', { status: 'running', parts_done: 1, parts_written: 1 })
|
||||
stopJob(crashed, 'interrupted', '服务重启,起草中断')
|
||||
await page.locator('[data-catalog-draft]').first().click()
|
||||
await draftBox(page).waitFor()
|
||||
await until(async () => (await draftBox(page).locator('[data-draft-last-open]').count()) === 1)
|
||||
await draftBox(page).locator('[data-draft-last-open]').click()
|
||||
check('服务重启中断:宽表的原因写「已中断:已写进目录 1 段,剩下 2 段没起草」,不再补一行「已写进目录」',
|
||||
await until(async () => (await text('store_sales', '[data-draft-task-reason]')) === '已中断:已写进目录 1 段,剩下 2 段没起草')
|
||||
&& await task('store_sales').locator('[data-draft-task-written]').count() === 0, await text('store_sales', '[data-draft-task-reason]'))
|
||||
check('……提示写明只重试这些表会重新起草整张表,已写进目录的内容不会丢', (await draftText(page, '[data-draft-interrupted]')).includes('会重新起草整张表,已写进目录的内容不会丢'),
|
||||
await draftText(page, '[data-draft-interrupted]'))
|
||||
await page.locator('[data-draft-close]').click()
|
||||
state.jobs.pop()
|
||||
|
||||
// 清单:会员表这次还没开始就停了(没有新写入):接着用上一次写完时记下的留空列数提示;门店销售这次写进了 2 段,不再拿旧数提示
|
||||
const rowBlank = (n) => page.locator(`[data-catalog-row="${n}"] [data-row-blank]`)
|
||||
await goto(page, `/data/catalog/${S1}`)
|
||||
check('清单:停止、没有新写入的表接着提示「上次起草有 3 列模型拿不准、留空了」', await until(async () => (await rowBlank('members').innerText({ timeout: 500 }).catch(() => '')).includes('上次起草有 3 列模型拿不准、留空了')),
|
||||
await rowBlank('members').innerText({ timeout: 500 }).catch(() => ''))
|
||||
check('……这次写进了段的宽表不拿旧的留空列数提示', await rowBlank('store_sales').count() === 0)
|
||||
await page.locator('[data-catalog-row="members"] [data-catalog-open-row]').click()
|
||||
const tip = page.locator('[data-catalog-last-draft]')
|
||||
check('表详情:顶部照旧提示 3 列留空,写明是再之前那次起草记下的(最近一次已停止)',
|
||||
await until(async () => (await tip.innerText({ timeout: 500 }).catch(() => '')).includes('上次起草有 3 列模型拿不准、留空了'))
|
||||
&& (await tip.getAttribute('data-catalog-last-draft')) === 'cancelled'
|
||||
&& (await tip.locator('[data-catalog-last-draft-earlier]').innerText().catch(() => '')).includes('最近一次起草已停止'),
|
||||
await tip.innerText({ timeout: 500 }).catch(() => ''))
|
||||
check('……给「重新起草这张表」「补充起草要求」,旁边是「和助手一起完善」', await tip.locator('[data-catalog-redraft]').count() === 1
|
||||
&& await tip.locator('[data-catalog-draft-guidance]').count() === 1 && await tip.locator('[data-catalog-assistant-open="notice"]').count() === 1)
|
||||
await cleanCopy(page, '逐段写进目录的提示')
|
||||
check('只发过起草任务相关的写请求', writes(sent, /./).every((s) => /catalog\/(draft-jobs|guidance)/.test(s.key)),
|
||||
JSON.stringify(writes(sent, /./).map((s) => s.key)))
|
||||
check('没有原生对话框', natives.length === 0, natives.join(' | '))
|
||||
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
|
||||
await close()
|
||||
})
|
||||
|
||||
await section('数据目录 · 起草:表结构里已经没有的表', async () => {
|
||||
const state = freshState()
|
||||
const tOf = (n) => state.tables[S1].find((t) => t.name === n)
|
||||
|
||||
@@ -759,6 +759,17 @@ await section('Copilot:建议更新数据目录(catalog_patch 操作)', as
|
||||
&& row.detail.includes('列 visitor_count 的度量类型:可累加 → 可累加'), row?.detail)
|
||||
check('是看表结构那一类、标着规划阶段', row?.kind === 'schema' && row?.stage === 'plan' && row?.status === 'done')
|
||||
check('结局仍是「只回答」', mod.copilotOutcome(synthetic.COPILOT_CATALOG_PATCH).kind === 'reply')
|
||||
// 提案落在已驳回的项上(服务端照样交回驳回的原值,before_status 为 rejected):展开的明细和同一轮下面的卡片同一个说法
|
||||
// 「已驳回:可累加」,不写成像是当前值的「可累加 → 存量」
|
||||
const onRejected = mod.decodeCopilot([{ op: 'catalog_patch', source: 'scenic', source_id: 'src-scenic', table: 'visits', table_label: '入园记录',
|
||||
version: 3, changes: [
|
||||
{ path: 'columns.visitor_count.measure', before: 'flow', before_status: 'rejected', after: 'stock', value: 'stock', reason: '', state: 'change' },
|
||||
{ path: 'kind', before: null, before_status: null, after: 'fact', value: 'fact', reason: '', state: 'change' },
|
||||
] }], { context: 'canvas' }).find((s) => s.code === 'catalog_patch')
|
||||
check('改前是已驳回的值:展开写「已驳回:可累加 → 存量」,和卡片一致',
|
||||
!!onRejected?.detail?.split('\n').includes('列 visitor_count 的度量类型:已驳回:可累加 → 存量')
|
||||
&& cp.patchBeforeText({ path: 'columns.visitor_count.measure', before: 'flow', before_status: 'rejected' }) === '已驳回:可累加', onRejected?.detail)
|
||||
check('……原来没有的照旧写「未填写」', !!onRejected?.detail?.split('\n').includes('表的表类型:未填写 → 明细表'), onRejected?.detail)
|
||||
|
||||
const [patch] = cp.catalogPatchesOf(synthetic.COPILOT_CATALOG_PATCH)
|
||||
check('卡片拿到版本、数据源 id、表和中文名', patch?.version === 3 && patch.sourceId === 'src-scenic' && patch.table === 'visits'
|
||||
|
||||
@@ -822,12 +822,14 @@ await section('建议更新数据目录:卡片的保存、忽略和 409(cata
|
||||
const body = req.postDataJSON()
|
||||
sent.push({ url: req.url(), body, actor: req.headers()['x-actor'] })
|
||||
if (req.url().endsWith('/patch/preview')) {
|
||||
// 重新载入:别人刚把码值补了一个 8,改前改后按最新的算
|
||||
// 重新载入:别人刚把码值补了一个 8、把度量类型驳回了,改前改后按最新的算(驳回的项改前照样写出原值)
|
||||
return route.fulfill({ json: { table_name: 'visits', version: 4, problems: [], changes: [
|
||||
{ path: 'columns.status.codes', before: { 1: '有效', 0: '作废', 8: '退票' }, before_status: 'confirmed',
|
||||
after: { 1: '有效', 0: '作废', 8: '退票', 9: '作废' }, value: { 9: '作废' }, reason: '用户说明 status=9 表示作废', state: 'change' },
|
||||
{ path: 'valid_filter', before: 'status = 1', before_status: 'proposed', after: 'status = 1 AND status <> 9',
|
||||
value: 'status = 1 AND status <> 9', reason: '统计时要排除作废记录', state: 'change' },
|
||||
{ path: 'columns.visitor_count.measure', before: 'flow', before_status: 'rejected', after: 'flow', value: 'flow',
|
||||
reason: '', state: 'change' },
|
||||
] } })
|
||||
}
|
||||
if (conflictOnce) {
|
||||
@@ -871,6 +873,8 @@ await section('建议更新数据目录:卡片的保存、忽略和 409(cata
|
||||
check('重新载入交回的是助手原来的提案', sent[1]?.url.endsWith('/patch/preview')
|
||||
&& JSON.stringify(sent[1]?.body?.changes?.[0]?.value) === '{"9":"作废"}', JSON.stringify(sent[1]))
|
||||
check('重新载入后改前按最新的目录写', (await codes.locator('[data-patch-before]').innerText()).includes('8=退票'))
|
||||
check('……刚被驳回的项:改前写「已驳回:可累加」,不写「未填写」', (await card.locator('[data-patch-change="columns.visitor_count.measure"] [data-patch-before]')
|
||||
.innerText()) === '已驳回:可累加', await card.locator('[data-patch-change="columns.visitor_count.measure"]').innerText())
|
||||
await card.locator('[data-patch-save]').click()
|
||||
await card.locator('[data-patch-done]').waitFor({ timeout: 3000 })
|
||||
check('再保存带新版本', sent[2]?.body?.if_version === 4, JSON.stringify(sent[2]?.body))
|
||||
|
||||
Reference in New Issue
Block a user