mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:11:58 +00:00
合入数据目录闭环第一步(后端 B):表助手(表详情页对话,流式返回,成批提出经校验的修改提案,只在开了剖析的源上经只读守卫跑查询、结果先遮);审阅队列(按使用次数、风险、来源排序,批量确认驳回逐表乐观锁,表被哪些运行用过,审阅进度);数据目录导出导入(先预览,确认和驳回的照带、冲突保留目标、推断只填空缺、对不上的跳过并写原因);一次修改上限提到 200 项
This commit is contained in:
+166
-1
@@ -14,6 +14,13 @@
|
||||
- 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。
|
||||
@@ -25,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
|
||||
@@ -36,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
|
||||
@@ -235,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]:
|
||||
@@ -529,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
|
||||
@@ -568,6 +694,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]:
|
||||
|
||||
+29
-15
@@ -860,8 +860,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
|
||||
#: 一条提案最多几项(预览、保存都按它)。对话里的纠正通常是一两条事实;表助手(data/catalog_assistant.py)会逐列补中文名、
|
||||
#: 含义,界面上跨几轮攒下的待保存修改一次保存,宽表上常常几十上百项,所以放到 200
|
||||
PATCH_MAX_CHANGES = 200
|
||||
_REASON_MAX = 500
|
||||
#: 看起来像计算公式的文字:「转化率=下单数/访问数」「= SUM(amount)」。公式进口径卡,目录只记数据事实。
|
||||
#: 不认减号:「上线日期=2024-01-01」不是公式。有效记录条件本来就是 SQL 条件,不查它。
|
||||
@@ -2346,27 +2347,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"]
|
||||
@@ -2378,8 +2379,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)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,911 @@
|
||||
"""表助手:在表详情页和使用者对话,一起完善一张表的数据目录(POST /catalog/{table}/assistant,SSE)。
|
||||
|
||||
**为什么要有它。** 模型起草只看表结构,缩写列名、状态码的意思常常拿不准而留空;审阅时人知道答案,却要一项一项填。表助手
|
||||
把这张表的结构、目录现状(每项的来源和状态)、关联关系、剖析结果、起草要求、使用次数、上次起草留空的列交给模型,让它主动
|
||||
问拿不准的地方;使用者说出一条业务事实,它整理成成批的修改提案(形状同助手流里的 catalog_patch),使用者在界面上逐项看过、
|
||||
一次保存(PATCH /catalog/{table}/patch,记为人工确认)。
|
||||
|
||||
**只改目录、不改业务库。** 模型只有两个工具:
|
||||
- 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 是作废」)。\
|
||||
一次可以提多项。提案只是建议:使用者在界面上看过、保存之后才写进数据目录,记为人工确认。
|
||||
- 表名、列名照原样写,只能用下面表结构里有的列。
|
||||
- 标着「已确认」的项由人工确认过,使用者没有明确要改就不要提修改;标着「已驳回」的项使用者驳回过,不要再提。
|
||||
- 「界面上还没保存的修改」是使用者已经接受、还没保存的提案:不要重复提;要改其中一项就再提一次,写完整的新值。
|
||||
- 遮罩的列只给了列名:不要猜它的取值,也不要给它写码值。
|
||||
- 回答用中文,简短直接。查询结果只在这一轮看得到,后面要用的结论请写进回答里。
|
||||
|
||||
修改提案的写法(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}",
|
||||
"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()))
|
||||
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"]
|
||||
@@ -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,541 @@
|
||||
"""数据目录的导出导入:本机整理好的目录带到部署实例上(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)。
|
||||
- 文件里已确认、已驳回的项照带:来源、状态、备注、修改时间都不变。目标那一格空着就新增;目标是推断、有确证的就更新;
|
||||
目标也是已确认、已驳回的,和文件一样就保留,不一样(值或状态不同)就保留目标的、记一条冲突。关联关系的「一样」看两端和
|
||||
基数,覆盖率是测量值、不算。
|
||||
- 文件里推断、有确证的项只填目标的空缺,目标那一格有东西(哪怕是驳回)就保留目标的,不算冲突。
|
||||
- 跳过并写原因:表结构里没有的列、业务主键和业务日期指着的列没有的、关联关系两端对不上的(指向的表、两端的列)、
|
||||
目标数据源遮罩的列上的码值(码值就是这一列的取值,遮罩了就不该进目录)、不认识的字段、取值不合规的项(说法同
|
||||
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
|
||||
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"]
|
||||
@@ -0,0 +1,737 @@
|
||||
"""表助手(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, contact_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 个取值")},
|
||||
"contact_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": "contact_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 "contact_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_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.contact_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, contact_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
|
||||
@@ -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,411 @@
|
||||
"""数据目录的导出导入(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("contact_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"),
|
||||
"dedup": make_item("同一订单号取最后一次", "llm", "rejected"),
|
||||
"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")},
|
||||
"contact_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": "contact_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.contact_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"
|
||||
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"]["CONTACT_PHONE"] == {"label": SOURCE_ORDERS["columns"]["contact_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", "contact_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
|
||||
Reference in New Issue
Block a user