mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:11:58 +00:00
合入数据目录起草改造:用模型起草改成后台任务,逐表记状态和原因,写完一张就并入目录,可以停止、只重试失败的表,服务重启时没跑完的标中断、不自动续跑;保留模型思考,输出额度 32768(服务方不收时退回 8192);列分段均分、宽表先起草表级再分段起草列,额度用完或超时就拆小再问、段大小随之减半、每段调用次数封顶,接连没写出来时给提醒;重新起草不丢已有的模型起草项;模型拿不准而留空的列逐表记下并提示;数据源上的起草要求和本次补充说明交给模型(不能推翻只记数据事实的规矩);设置里单独的起草模型(数据目录和按配方导入的 AI 起草共用);库注释就是名字本身或标识符写法的不当中文名;旧的同步起草遇到进行中的任务回 409
This commit is contained in:
@@ -276,7 +276,8 @@ flowchart LR
|
||||
|
||||
| 环节 | 做法 |
|
||||
|---|---|
|
||||
| 起草 | 探查时读外键和唯一约束;按 `xxx_id`、`xxxId` 推断关联,表名带业务前缀的也认;可选让模型起草中文名、粒度、度量类型。导入表格的源已有系统按核对结果生成的说明,目录不重复、不改写它 |
|
||||
| 起草 | 探查时读外键和唯一约束;按 `xxx_id`、`xxxId` 推断关联,表名带业务前缀的也认;可选让模型起草中文名、粒度、表类型、业务主键、业务日期和每列的含义、单位、度量类型。导入表格的源已有系统按核对结果生成的说明,目录不重复、不改写它 |
|
||||
| 模型起草 | 作为后台任务逐表进行,每张表记下状态和原因,写完一张就并入目录;关掉对话框不停,可以停止、只重试失败的表,清单上标出上次起草失败或只完成一部分的表。服务重启时没跑完的任务标成中断,不会自己续跑,点重试接上。保留模型的思考,输出额度 32768(服务方不收时退回 8192);每次调用最多 40 列,宽表先起草表级的项、再把列均分成几段起草,按表的先后排队,不插到窄表前面;额度用完或超时就拆小再问(超时的两半同时问,拆到每段 10 列仍接连超时就不再拆),累计两次后新开始的段减半(最低 10 列),每段连同它分开、拆小的各块最多调用 8 次,缺的表和列只补问缺的,被截断的回复先留下已经写完整的部分;最近 6 次调用有 4 次没写出来时提醒换模型。没写出来的原因写清是第几段、哪几列。重新起草时模型这次留空或没通过校验的项不删以前起草的;模型拿不准、留空的列(目录里还没有中文名和含义的)逐表记下,可以补充起草要求后重起草。库注释就是表名、列名本身或者是标识符写法的,不记进目录(不当中文名,也不当说明、含义)。数据源上存的「起草要求」和这次的补充说明一起交给模型,帮它理解业务、统一用词,不能推翻「只记数据事实」的规矩,交回的内容照样逐项校验。用哪个模型在「设置 → 偏好设置」的「起草模型」里选,空着跟随助手的模型;按配方导入表格的 AI 起草也用它 |
|
||||
| 数据剖析 | 默认关闭,按数据源开启。只读、经过守卫,单条查询和整次剖析都有上限,大表用统计信息估算行数,遮罩的列不取样。抽样核实推断出的关联,补上覆盖率和基数,站得住的升为已验证;状态列给出码值候选,含义等人填写 |
|
||||
| 对话里纠正 | 用户说「status=9 表示作废」,助手给出一条目录修改提案,列出改前、改后和理由,人确认后才保存;公式不收,留给口径卡 |
|
||||
| 版本与提醒 | 目录随表结构快照冻结进证据,每次运行用的是哪一版查得到。发布时记下模板引用的表的目录版本,正式运行时目录有变化就提醒;agent 运行时才写 SQL 的,按「可能涉及」单独提醒。只提醒,不拦运行 |
|
||||
|
||||
+208
-14
@@ -7,11 +7,16 @@
|
||||
- POST /catalog/{table}/patch/preview 目录修改提案的预览(只读):对着当前目录算改前、改后
|
||||
- POST /catalog/{table}/patch 保存目录修改提案(乐观锁),记为人工确认
|
||||
- GET /catalog/{table}/impact 影响面:引用这张表的已发布、受管模板(data/catalog_impact.py)
|
||||
- POST /catalog/draft 同步起草(注释、外键、命名推断,可选模型)
|
||||
- POST /catalog/draft 同步起草(注释、外键、命名推断,可选模型)。界面改用下面的后台起草任务,这个留着;
|
||||
同一个源有排队或进行中的后台任务时 409 catalog_draft_busy
|
||||
- PUT /catalog/guidance 数据源级的起草要求(使用者补充的背景和要求,起草时交给模型)
|
||||
- POST /catalog/draft-jobs 建后台起草任务(202);GET /draft-jobs/active、/latest、/{job_id} 读任务;
|
||||
POST /draft-jobs/{job_id}/cancel 停止,/retry 只重试没写好的表(data/catalog_jobs.py)
|
||||
- POST /catalog/profile 同步剖析:对业务库发少量只读查询,核对关系、取码值候选(data/catalog_profile.py)
|
||||
|
||||
规则都在 data/catalog.py,这里只做取源、转换形状和把异常翻成状态码:版本不符 409,结构不合规、审阅路径
|
||||
不对 422,源或表不存在 404。所有写操作记录请求头 X-Actor 的署名(自报、未认证)。剖析没开启、正在剖析 409。
|
||||
起草任务的 409 带机读码(api/coded.py):同一个源已有排队或进行中的任务、起草模型用不了、要停止的任务已经结束。
|
||||
|
||||
上传表格的源按当前快照的表结构回答(table_versions.resolve_source),和查询实际用的库是同一个版本。
|
||||
"""
|
||||
@@ -19,13 +24,17 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi import APIRouter, Body, Depends, Header, HTTPException
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.coded import PROFILE_BUSY, SCHEMA_MISSING, SNAPSHOT_TAMPERED, CodedHTTPException
|
||||
from app.api.coded import (
|
||||
CATALOG_DRAFT_BUSY, CATALOG_DRAFT_FINISHED, CATALOG_DRAFT_MODEL_UNAVAILABLE, PROFILE_BUSY, SCHEMA_MISSING,
|
||||
SNAPSHOT_TAMPERED, CodedHTTPException,
|
||||
)
|
||||
from app.api.runs import actor_of
|
||||
from app.data import catalog, catalog_impact, catalog_profile, introspect, table_versions
|
||||
from app.data import catalog, catalog_impact, catalog_jobs, catalog_profile, introspect, table_versions
|
||||
from app.data.engine import SnapshotTampered
|
||||
from app.db.base import get_session
|
||||
from app.db.models import DataSource
|
||||
@@ -64,10 +73,26 @@ class CatalogPatchIn(CatalogPatchPreviewIn):
|
||||
class CatalogDraftIn(BaseModel):
|
||||
#: 要起草的表;不给或为空时按使用次数取前 20 张
|
||||
tables: list[str] | None = Field(default=None, max_length=200)
|
||||
#: 是否请助手的模型起草中文名、粒度、列的含义等
|
||||
#: 是否请起草模型(设置里的起草模型,没设沿用助手的模型)写中文名、粒度、列的含义等
|
||||
use_model: bool = False
|
||||
|
||||
|
||||
class DraftJobIn(BaseModel):
|
||||
#: 要起草的表;不给或为空时按使用次数取前 20 张。最多 500 张(超了 422,说明是中文)
|
||||
tables: list[str] | None = None
|
||||
#: 是否请起草模型写中文名、粒度、列的含义等
|
||||
use_model: bool = False
|
||||
#: 这一次的补充说明,和数据源级的起草要求一起交给模型(最多 1000 字)
|
||||
note: str | None = None
|
||||
|
||||
|
||||
#: PUT /catalog/guidance 的请求 {guidance: string}:数据源级的起草要求,空串(或 null)清除,最多 2000 字。必须写
|
||||
#: 这一项:表名恰好叫 guidance 时,整份提交那张表目录的请求也落到这个路径上,不能把它当成「清除起草要求」。
|
||||
#: 没写、不是文字都回 422,说明是中文的一句话(同超长)
|
||||
GUIDANCE_MISSING = "请求里要写起草要求(guidance 一项,一段文字;要清除起草要求请传空串)"
|
||||
GUIDANCE_NOT_TEXT = "起草要求要是一段文字(要清除起草要求请传空串)"
|
||||
|
||||
|
||||
class CatalogProfileIn(BaseModel):
|
||||
#: 要剖析的表;不给或为空时按使用次数取前 10 张目录里有待核实关系的表
|
||||
tables: list[str] | None = Field(default=None, max_length=200)
|
||||
@@ -100,7 +125,7 @@ def _item_value(notes: dict[str, Any], name: str) -> tuple[Any, str | None]:
|
||||
|
||||
|
||||
def _summary_row(name: str, meta: dict[str, Any] | None, entry: catalog.CatalogEntry | None,
|
||||
usage: int) -> dict[str, Any]:
|
||||
usage: int, last_draft: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
notes = entry.notes if entry else {}
|
||||
label, label_status = _item_value(notes, "label")
|
||||
kind, _ = _item_value(notes, "kind")
|
||||
@@ -120,6 +145,9 @@ def _summary_row(name: str, meta: dict[str, Any] | None, entry: catalog.CatalogE
|
||||
"version": entry.version if entry else 0,
|
||||
"updated_at": _iso(entry.updated_at) if entry else None,
|
||||
"updated_by": entry.updated_by if entry else None,
|
||||
# 这张表最近一次起草任务里的那一项:{status, job_id, finished_at, error, model_error, blank_columns},没起草过
|
||||
# 是 null
|
||||
"last_draft": last_draft,
|
||||
}
|
||||
|
||||
|
||||
@@ -180,15 +208,18 @@ async def list_catalog(source_id: str, session: AsyncSession = Depends(get_sessi
|
||||
说明有没有截断,schema_total 是数据库里一共几张表(含视图),界面据此说明「只探查了前 200 张」。老缓存没记
|
||||
总数时按探查到的张数算。
|
||||
"""
|
||||
_, source = await _resolved(session, source_id)
|
||||
row, source = await _resolved(session, source_id)
|
||||
cache = source.schema_cache or {}
|
||||
tables = cache.get("tables") or {}
|
||||
entries = await catalog.read_catalog(session, source.id)
|
||||
usage = await catalog.table_usage(session, source)
|
||||
drafts = await catalog_jobs.last_drafts(session, source.id)
|
||||
order = {name: i for i, name in enumerate(tables)}
|
||||
rows = [_summary_row(name, meta, entries.get(name), usage.get(name, 0)) for name, meta in tables.items()]
|
||||
rows = [_summary_row(name, meta, entries.get(name), usage.get(name, 0), drafts.get(name))
|
||||
for name, meta in tables.items()]
|
||||
rows.sort(key=lambda r: (-r["usage"], order[r["table_name"]]))
|
||||
rows += [_summary_row(name, None, entry, 0) for name, entry in sorted(entries.items()) if name not in tables]
|
||||
rows += [_summary_row(name, None, entry, 0, drafts.get(name))
|
||||
for name, entry in sorted(entries.items()) if name not in tables]
|
||||
return {
|
||||
"tables": rows,
|
||||
"system_notes": catalog.system_notes_source(source),
|
||||
@@ -196,6 +227,10 @@ async def list_catalog(source_id: str, session: AsyncSession = Depends(get_sessi
|
||||
"schema_note": None if tables else introspect.why_empty(cache),
|
||||
"schema_truncated": bool(cache.get("truncated")),
|
||||
"schema_total": max(_int(cache.get("total")), len(tables)),
|
||||
# 数据源级的起草要求(没有是 null)
|
||||
"guidance": catalog_jobs.guidance_of(row),
|
||||
# 排队或进行中的起草任务 {id, status, total, done},界面据此显示进度;没有是 null
|
||||
"active_job": await catalog_jobs.job_brief(session, await catalog_jobs.active_job(session, source.id)),
|
||||
}
|
||||
|
||||
|
||||
@@ -207,6 +242,156 @@ async def get_catalog_table(source_id: str, table: str,
|
||||
return await _detail(session, source, key, entry)
|
||||
|
||||
|
||||
async def _source_row(session: AsyncSession, source_id: str) -> DataSource:
|
||||
row = await session.get(DataSource, source_id)
|
||||
if row is None:
|
||||
raise HTTPException(404, "数据源不存在,可能已被删除")
|
||||
return row
|
||||
|
||||
|
||||
@router.put("/{source_id}/catalog/guidance")
|
||||
async def put_catalog_guidance(source_id: str, body: dict[str, Any] = Body(...),
|
||||
x_actor: str | None = Header(default=None),
|
||||
session: AsyncSession = Depends(get_session)) -> Any:
|
||||
"""存数据源级的起草要求:去首尾空白,空串清除,最多 2000 字(超了 422)。上传表格的源存在它自己的数据源记录上。
|
||||
|
||||
要登记在 PUT /catalog/{table} 前面:不然「guidance」会被当成表名。反过来,表名恰好叫 guidance 时,整份提交那张表
|
||||
目录的请求({notes, if_version},没有 guidance 这一项)也落到这里:照旧当作那张表的整份提交。"""
|
||||
if "guidance" not in body and "notes" in body:
|
||||
try:
|
||||
table_payload = CatalogPutIn.model_validate(body)
|
||||
except ValidationError as e:
|
||||
raise RequestValidationError(e.errors()) from e
|
||||
return await put_catalog_table(source_id, "guidance", table_payload, x_actor=x_actor, session=session)
|
||||
if "guidance" not in body:
|
||||
raise HTTPException(422, GUIDANCE_MISSING)
|
||||
guidance = body["guidance"]
|
||||
if guidance is not None and not isinstance(guidance, str):
|
||||
raise HTTPException(422, GUIDANCE_NOT_TEXT)
|
||||
row = await _source_row(session, source_id)
|
||||
try:
|
||||
value = await catalog_jobs.save_guidance(session, row, guidance)
|
||||
except catalog_jobs.TextTooLong as e:
|
||||
raise HTTPException(422, str(e)) from e
|
||||
return {"guidance": value}
|
||||
|
||||
|
||||
async def _ensure_model(session: AsyncSession) -> None:
|
||||
"""起草模型用不了(没有接入、停用、只有演示接入……):409,不建任务。说法同同步起草时的 model_error。"""
|
||||
try:
|
||||
await catalog_jobs.check_model(session)
|
||||
except catalog.CatalogModelUnavailable as e:
|
||||
raise CodedHTTPException(409, str(e), CATALOG_DRAFT_MODEL_UNAVAILABLE) from e
|
||||
|
||||
|
||||
def _no_schema(cache: dict[str, Any]) -> HTTPException:
|
||||
return HTTPException(409, f"{introspect.why_empty(cache)},无法起草数据目录。请先在数据源卡片上点「探查结构」")
|
||||
|
||||
|
||||
@router.post("/{source_id}/catalog/draft-jobs", status_code=202)
|
||||
async def create_draft_job(source_id: str, payload: DraftJobIn, x_actor: str | None = Header(default=None),
|
||||
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||||
"""建后台起草任务,马上返回(202,任务是排队中)。同一个源已有排队或进行中的任务 409 catalog_draft_busy(界面改去
|
||||
GET /draft-jobs/active 显示那一个);用模型而起草模型用不了 409 catalog_draft_model_unavailable;没有表结构 409。
|
||||
表结构里没有的表照样建进任务、状态 skipped。"""
|
||||
if payload.tables and len(payload.tables) > catalog_jobs.MAX_TABLES:
|
||||
raise HTTPException(422, f"一次最多起草 {catalog_jobs.MAX_TABLES} 张表,当前 {len(payload.tables)} 张,"
|
||||
"请分几次起草")
|
||||
try:
|
||||
note = catalog_jobs.clean_text(payload.note, catalog_jobs.NOTE_MAX, "本次说明")
|
||||
except catalog_jobs.TextTooLong as e:
|
||||
raise HTTPException(422, str(e)) from e
|
||||
row, source = await _resolved(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
|
||||
cache = source.schema_cache or {}
|
||||
if not cache.get("tables"):
|
||||
raise _no_schema(cache)
|
||||
if payload.use_model:
|
||||
await _ensure_model(session)
|
||||
try:
|
||||
job, tasks = await catalog_jobs.create_job(session, row, source, tables=payload.tables or None,
|
||||
use_model=payload.use_model, note=note, actor=actor_of(x_actor))
|
||||
except catalog_jobs.DraftBusy as e:
|
||||
raise CodedHTTPException(409, str(e), CATALOG_DRAFT_BUSY) from e
|
||||
catalog_jobs.manager.start(job.id)
|
||||
return {"job": catalog_jobs.job_dict(job, tasks)}
|
||||
|
||||
|
||||
@router.get("/{source_id}/catalog/draft-jobs/active")
|
||||
async def active_draft_job(source_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||||
"""排队或进行中的那个起草任务,没有是 null。"""
|
||||
row = await _source_row(session, source_id)
|
||||
return {"job": await catalog_jobs.job_out(session, await catalog_jobs.active_job(session, row.id))}
|
||||
|
||||
|
||||
@router.get("/{source_id}/catalog/draft-jobs/latest")
|
||||
async def latest_draft_job(source_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||||
"""最近建的一个起草任务(任何状态),没有是 null。"""
|
||||
row = await _source_row(session, source_id)
|
||||
return {"job": await catalog_jobs.job_out(session, await catalog_jobs.latest_job(session, row.id))}
|
||||
|
||||
|
||||
async def _job_or_404(session: AsyncSession, source_id: str, job_id: str) -> tuple[DataSource, Any]:
|
||||
row = await _source_row(session, source_id)
|
||||
job = await catalog_jobs.load(session, row.id, job_id)
|
||||
if job is None:
|
||||
raise HTTPException(404, "起草任务不存在,可能属于别的数据源,或者已随数据源删除")
|
||||
return row, job
|
||||
|
||||
|
||||
@router.get("/{source_id}/catalog/draft-jobs/{job_id}")
|
||||
async def get_draft_job(source_id: str, job_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||||
_, job = await _job_or_404(session, source_id, job_id)
|
||||
return {"job": await catalog_jobs.job_out(session, job)}
|
||||
|
||||
|
||||
@router.post("/{source_id}/catalog/draft-jobs/{job_id}/cancel")
|
||||
async def cancel_draft_job(source_id: str, job_id: str,
|
||||
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||||
"""停止:排队的直接停止;进行中的停掉正在进行的模型调用,没开始和没写完的表记停止,已经写进目录的不回滚。
|
||||
已经结束的 409 catalog_draft_finished。返回停止之后的任务。"""
|
||||
row, job = await _job_or_404(session, source_id, job_id)
|
||||
owner = row.id
|
||||
try:
|
||||
await catalog_jobs.cancel(session, job)
|
||||
except catalog_jobs.DraftFinished as e:
|
||||
raise CodedHTTPException(409, str(e), CATALOG_DRAFT_FINISHED) from e
|
||||
session.expire_all() # 后台在别的会话里收的尾:重新读
|
||||
return {"job": await catalog_jobs.job_out(session, await catalog_jobs.load(session, owner, job_id))}
|
||||
|
||||
|
||||
@router.post("/{source_id}/catalog/draft-jobs/{job_id}/retry", status_code=202)
|
||||
async def retry_draft_job(source_id: str, job_id: str, x_actor: str | None = Header(default=None),
|
||||
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||||
"""重试:新任务,只含原任务里没写好(failed、partial)和停止了(cancelled)的表;用不用模型、本次说明沿用,起草要求
|
||||
取数据源当前的。有排队或进行中的任务 409 catalog_draft_busy;没有可重试的表 422;起草模型用不了 409。"""
|
||||
_, old = await _job_or_404(session, source_id, job_id)
|
||||
row, source = await _resolved(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:
|
||||
tables = await catalog_jobs.retry_tables(session, old)
|
||||
except catalog_jobs.NothingToRetry as e:
|
||||
raise HTTPException(422, str(e)) from e
|
||||
cache = source.schema_cache or {}
|
||||
if not cache.get("tables"):
|
||||
raise _no_schema(cache)
|
||||
if old.use_model:
|
||||
await _ensure_model(session)
|
||||
try:
|
||||
job, tasks = await catalog_jobs.create_job(session, row, source, tables=tables, use_model=old.use_model,
|
||||
note=old.note, actor=actor_of(x_actor), retry_of=old.id)
|
||||
except catalog_jobs.DraftBusy as e:
|
||||
raise CodedHTTPException(409, str(e), CATALOG_DRAFT_BUSY) from e
|
||||
catalog_jobs.manager.start(job.id)
|
||||
return {"job": catalog_jobs.job_dict(job, tasks)}
|
||||
|
||||
|
||||
@router.put("/{source_id}/catalog/{table}")
|
||||
async def put_catalog_table(source_id: str, table: str, payload: CatalogPutIn,
|
||||
x_actor: str | None = Header(default=None),
|
||||
@@ -228,17 +413,26 @@ async def put_catalog_table(source_id: str, table: str, payload: CatalogPutIn,
|
||||
async def draft_catalog(source_id: str, payload: CatalogDraftIn,
|
||||
x_actor: str | None = Header(default=None),
|
||||
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||||
"""同步起草。返回每张表新增、更新、删除了几项;模型用不了或某张表的模型起草失败,照实写在结果里。
|
||||
"""同步起草(界面改用后台起草任务,这个接口留着,返回形状不变)。引擎同后台任务(分段、按失败原因处理),起草要求
|
||||
取数据源级的那份。返回每张表新增、更新、删除了几项;模型用不了或某张表的模型起草失败,照实写在结果里。
|
||||
|
||||
model_items:模型给了这张表几项可用内容(没用模型、模型这部分失败时为 null)。界面据此区分「模型给了内容、
|
||||
只是和现有目录一致」(model_items > 0、三个数都是 0)和「模型没给内容」(model_error 有值)。
|
||||
|
||||
同一个源有排队或进行中的后台起草任务时 409 catalog_draft_busy(和建任务同一句话):两边同时写同一个源的目录、
|
||||
抢模型名额。
|
||||
"""
|
||||
_, source = await _resolved(session, source_id)
|
||||
row, source = await _resolved(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
|
||||
cache = source.schema_cache or {}
|
||||
if not cache.get("tables"):
|
||||
raise HTTPException(409, f"{introspect.why_empty(cache)},无法起草数据目录。请先在数据源卡片上点「探查结构」")
|
||||
raise _no_schema(cache)
|
||||
report = await catalog.draft_catalog(session, source, tables=payload.tables or None,
|
||||
use_model=payload.use_model, actor=actor_of(x_actor))
|
||||
use_model=payload.use_model, actor=actor_of(x_actor),
|
||||
guidance=catalog_jobs.guidance_of(row))
|
||||
rows = [{"table_name": r.table, "added": r.added, "updated": r.updated, "removed": r.removed,
|
||||
"version": r.version, "error": r.error, "model_error": r.model_error, "model_items": r.model_items}
|
||||
for r in report.tables]
|
||||
|
||||
@@ -19,6 +19,11 @@ DATASOURCE_INACTIVE = "datasource_inactive"
|
||||
SCHEMA_MISSING = "schema_missing"
|
||||
PROFILE_BUSY = "profile_busy"
|
||||
SNAPSHOT_TAMPERED = "snapshot_tampered"
|
||||
#: 数据目录的后台起草任务(api/catalog.py 的 /catalog/draft-jobs):同一个源已有排队或进行中的任务(409,界面改去
|
||||
#: GET /draft-jobs/active 显示那一个)、起草模型用不了(409,不建任务)、要停止的任务已经结束了(409)
|
||||
CATALOG_DRAFT_BUSY = "catalog_draft_busy"
|
||||
CATALOG_DRAFT_MODEL_UNAVAILABLE = "catalog_draft_model_unavailable"
|
||||
CATALOG_DRAFT_FINISHED = "catalog_draft_finished"
|
||||
|
||||
|
||||
class CodedHTTPException(HTTPException):
|
||||
|
||||
@@ -27,8 +27,8 @@ from app.data.catalog_profile import profile_settings_rejection
|
||||
from app.data import introspect as introspect_mod
|
||||
from app.data import table_versions
|
||||
from app.data.engine import (
|
||||
MASK_COLUMNS_OPTION, MAX_QUERY_TIMEOUT_S, QUERY_TIMEOUT_OPTION, SUPPORTED_KINDS, SnapshotTampered, build_url,
|
||||
engine_args, engines, mask_columns_problem, query_timeout_problem,
|
||||
CATALOG_DRAFT_GUIDANCE_OPTION, MASK_COLUMNS_OPTION, MAX_QUERY_TIMEOUT_S, QUERY_TIMEOUT_OPTION, SUPPORTED_KINDS,
|
||||
SnapshotTampered, build_url, engine_args, engines, mask_columns_problem, query_timeout_problem,
|
||||
)
|
||||
from app.db.base import get_session, new_id
|
||||
from app.db.models import DataSource, ImportStaging, SourceSnapshot, TableImport, TableRecipe
|
||||
@@ -124,6 +124,22 @@ def _import_mode(row: DataSource) -> str | None:
|
||||
return "simple" if _is_upload(row) else None
|
||||
|
||||
|
||||
#: options 里由别的接口维护的项:数据源表单不显示、不提交它们,整份回写 options 时原样留着(同模型接入 extra 里的
|
||||
#: last_check)。数据目录的起草要求只经 PUT /catalog/guidance 改(api/catalog.py):表单把认不得的项放进「高级参数」
|
||||
#: 的单行输入框,多行的要求会被压成一行
|
||||
_MANAGED_OPTIONS = (CATALOG_DRAFT_GUIDANCE_OPTION,)
|
||||
|
||||
|
||||
def _shown_options(options: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return {k: v for k, v in (options or {}).items() if k not in _MANAGED_OPTIONS}
|
||||
|
||||
|
||||
def _with_managed(options: dict[str, Any], saved: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""表单提交的 options + 存着的、由别的接口维护的项(提交里带了也不认)。"""
|
||||
kept = {k: v for k, v in (saved or {}).items() if k in _MANAGED_OPTIONS}
|
||||
return {**_shown_options(options), **kept}
|
||||
|
||||
|
||||
def _to_out(row: DataSource, snapshot: dict[str, Any] | None = None,
|
||||
recipe_info: dict[str, Any] | None = None) -> DataSourceOut:
|
||||
cache = row.schema_cache or {}
|
||||
@@ -137,7 +153,7 @@ def _to_out(row: DataSource, snapshot: dict[str, Any] | None = None,
|
||||
schema_error=str(cache.get("error") or "") if cache.get("failed") else "",
|
||||
available_schemas=list(cache.get("available_schemas") or []),
|
||||
id=row.id, name=row.name, kind=row.kind, host=row.host, port=row.port,
|
||||
database=row.database, username=row.username, options=row.options or {},
|
||||
database=row.database, username=row.username, options=_shown_options(row.options),
|
||||
readonly=row.readonly, description=row.description, enabled=row.enabled,
|
||||
password_masked=mask(row.password),
|
||||
has_password=bool(row.password),
|
||||
@@ -429,7 +445,8 @@ async def create_source(
|
||||
_refuse_reserved(payload.kind, payload.database)
|
||||
|
||||
row = DataSource(
|
||||
**payload.model_dump(exclude={"password"}),
|
||||
**payload.model_dump(exclude={"password", "options"}),
|
||||
options=_with_managed(payload.options, None),
|
||||
password=encrypt(payload.password),
|
||||
)
|
||||
_settle_oracle(row, None, None)
|
||||
@@ -472,6 +489,7 @@ async def update_source(
|
||||
raise HTTPException(422, _UPLOAD_DIR_READONLY)
|
||||
if "options" in data:
|
||||
_refuse_bad_options(data["options"])
|
||||
data["options"] = _with_managed(data["options"] or {}, row.options)
|
||||
if "password" in data:
|
||||
# 空串表示清空,非空表示换新的
|
||||
data["password"] = encrypt(data["password"]) if data["password"] else None
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.api.coded import CodedHTTPException
|
||||
from app.core.errors import explain, not_configured, raw
|
||||
from app.core.config import settings as app_settings
|
||||
from app.core.crypto import encrypt, mask
|
||||
from app.data.draft_model import CATALOG_DRAFT_SETTING_KEY, setting_pair
|
||||
from app.db.base import get_session
|
||||
from app.db.models import Provider, Setting
|
||||
from app.engine.judge import JUDGE_DEFAULTS, MAX_TOKENS as JUDGE_MAX_TOKENS
|
||||
@@ -569,6 +570,39 @@ async def verify_model_spec(session: AsyncSession, node_config: dict[str, Any] |
|
||||
return ModelSpec(provider=provider, model=model, max_tokens=JUDGE_MAX_TOKENS, thinking="off")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 起草模型:数据目录的「用模型起草」和按配方导入的 AI 起草用哪个模型
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
#: 设置里 catalog_draft 这一组:{provider, model}。都留空时沿用助手的模型,再没有就是默认接入(取值顺序见
|
||||
#: data/draft_model.py,两处起草共用)。只放接入和模型:额度、思考由两处起草各自定
|
||||
CATALOG_DRAFT_GROUP = CATALOG_DRAFT_SETTING_KEY
|
||||
CATALOG_DRAFT_DEFAULTS: dict[str, Any] = {"provider": None, "model": None}
|
||||
#: 设置页「起草模型」一节各项的叫法:报错时写它,不写键名
|
||||
CATALOG_DRAFT_LABEL = {"provider": "起草模型 · 接入", "model": "起草模型 · 模型"}
|
||||
|
||||
|
||||
def catalog_draft_problem(value: Any) -> str | None:
|
||||
"""设置页写进来的 catalog_draft 这一组有什么问题,没问题返回 None。点名的接入在不在由 _provider_problem 查。"""
|
||||
if not isinstance(value, dict):
|
||||
return f"「起草模型」设置格式有误,需要是一个对象,当前为「{value}」"
|
||||
for key, item in value.items():
|
||||
if key not in CATALOG_DRAFT_DEFAULTS:
|
||||
return (f"「起草模型」设置中不支持「{key}」,可设置的项:"
|
||||
+ "、".join(f"「{CATALOG_DRAFT_LABEL[k]}」" for k in CATALOG_DRAFT_DEFAULTS))
|
||||
if item is not None and not isinstance(item, str):
|
||||
# 「跟随助手的模型」是设置页接入下拉框空选项的原话
|
||||
what = "模型接入的名称" if key == "provider" else "模型 ID"
|
||||
return f"「{CATALOG_DRAFT_LABEL[key]}」需要填写{what},留空表示跟随助手的模型,当前为「{item}」"
|
||||
return None
|
||||
|
||||
|
||||
def catalog_draft_values(stored: Any) -> dict[str, Any]:
|
||||
"""库里存的 catalog_draft 一组 → 补齐默认、规范化(去掉首尾空白,空串和存坏了的当没填)。"""
|
||||
provider, model = setting_pair(stored)
|
||||
return {"provider": provider, "model": model}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 通知:定时任务推送 Webhook 时拼运行链接用的对外访问地址
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -661,6 +695,8 @@ DEFAULT_SETTINGS: dict[str, Any] = {
|
||||
VERIFY_GROUP: verify_values(None),
|
||||
# 通知:定时任务的 Webhook 里拼运行链接用的对外访问地址
|
||||
NOTIFY_GROUP: notify_values(None),
|
||||
# 起草模型:数据目录起草、按配方导入的 AI 起草用的模型,留空沿用助手的模型
|
||||
CATALOG_DRAFT_GROUP: catalog_draft_values(None),
|
||||
}
|
||||
|
||||
|
||||
@@ -717,6 +753,7 @@ async def get_settings(session: AsyncSession = Depends(get_session)) -> dict[str
|
||||
merged[JUDGE_GROUP] = judge_values(stored.get(JUDGE_GROUP))
|
||||
merged[VERIFY_GROUP] = verify_values(stored.get(VERIFY_GROUP))
|
||||
merged[NOTIFY_GROUP] = notify_values(stored.get(NOTIFY_GROUP))
|
||||
merged[CATALOG_DRAFT_GROUP] = catalog_draft_values(stored.get(CATALOG_DRAFT_GROUP))
|
||||
for key, value in stored.items():
|
||||
merged.setdefault(key, value)
|
||||
return merged
|
||||
@@ -731,8 +768,8 @@ PROVIDER_INVALID = "settings_provider_invalid"
|
||||
|
||||
|
||||
def _provider_fields() -> dict[tuple[str, str], tuple[str, str | None]]:
|
||||
"""设置里点名模型接入的项:(组, 键) → (这一项在界面上的叫法, 同一组模型那一项的叫法)。门控、裁判、核查的叫法和
|
||||
设置页一致;助手的模型由「编排」页助手栏的「助手使用的模型」单独设定(PUT /api/copilot/model),只有这一个下拉框
|
||||
"""设置里点名模型接入的项:(组, 键) → (这一项在界面上的叫法, 同一组模型那一项的叫法)。门控、裁判、核查、起草模型的
|
||||
叫法和设置页一致;助手的模型由「编排」页助手栏的「助手使用的模型」单独设定(PUT /api/copilot/model),只有这一个下拉框
|
||||
(模型那一项为 None),这里只防经设置接口写进去的那一份。"""
|
||||
from app.api.copilot import COPILOT_SETTING_KEY # 在这里导入:copilot 模块很大,设置模块被引擎各处导入
|
||||
from app.providers.factory import ASSISTANT_MODEL_FIELD
|
||||
@@ -741,6 +778,7 @@ def _provider_fields() -> dict[tuple[str, str], tuple[str, str | None]]:
|
||||
("run", "tool_gate_provider"): ("门控模型 · 接入", "门控模型 · 模型"),
|
||||
(JUDGE_GROUP, "provider"): (JUDGE_LABEL["provider"], JUDGE_LABEL["model"]),
|
||||
(VERIFY_GROUP, "provider"): (VERIFY_SETTING_LABEL["provider"], VERIFY_SETTING_LABEL["model"]),
|
||||
(CATALOG_DRAFT_GROUP, "provider"): (CATALOG_DRAFT_LABEL["provider"], CATALOG_DRAFT_LABEL["model"]),
|
||||
(COPILOT_SETTING_KEY, "provider"): (ASSISTANT_MODEL_FIELD, None),
|
||||
}
|
||||
|
||||
@@ -798,6 +836,9 @@ async def put_settings(
|
||||
raise HTTPException(422, problem)
|
||||
if VERIFY_GROUP in payload.values and (problem := verify_problem(payload.values[VERIFY_GROUP])):
|
||||
raise HTTPException(422, problem)
|
||||
if CATALOG_DRAFT_GROUP in payload.values and (
|
||||
problem := catalog_draft_problem(payload.values[CATALOG_DRAFT_GROUP])):
|
||||
raise HTTPException(422, problem)
|
||||
if NOTIFY_GROUP in payload.values:
|
||||
if problem := notify_problem(payload.values[NOTIFY_GROUP]):
|
||||
raise HTTPException(422, problem)
|
||||
|
||||
+332
-246
@@ -591,21 +591,41 @@ class MergeStats:
|
||||
return MergeStats(self.added + other.added, self.updated + other.updated, self.removed + other.removed)
|
||||
|
||||
|
||||
def _about_gone_columns(key: Slot, item: Mapping[str, Any], columns: set[str]) -> bool:
|
||||
"""这一项说的列已经不在表结构里了:列级的项看列名,业务主键、业务日期看它们指着的列。"""
|
||||
if key[0] == "c":
|
||||
return key[1] not in columns
|
||||
value = item.get("value")
|
||||
if key == ("t", "keys") and isinstance(value, list):
|
||||
return any(str(col) not in columns for col in value)
|
||||
if key == ("t", "business_date") and isinstance(value, Mapping) and value.get("column") is not None:
|
||||
return str(value["column"]) not in columns
|
||||
return False
|
||||
|
||||
|
||||
def merge_notes(existing: Mapping[str, Any] | None, draft: Mapping[str, Any] | None, *,
|
||||
covered: Iterable[str] = (), at: str | None = None) -> tuple[dict[str, Any], MergeStats]:
|
||||
covered: Iterable[str] = (), at: str | None = None,
|
||||
columns: Iterable[str] | None = None) -> tuple[dict[str, Any], MergeStats]:
|
||||
"""把起草结果并入已有目录,返回 (新目录, 计数)。不改入参。
|
||||
|
||||
规则(逐个槽位):
|
||||
- 已有项是 confirmed 或 rejected:不动。驳回的留着,同一项下次起草就不会再冒出来。
|
||||
- 起草结果里没有这一项:已有项的来源在 covered 里(这一轮完整算过这个来源)就删掉,否则留着。
|
||||
外键约束被删掉以后,原来那条「有确证」的关系不能还挂着;而这一轮没调模型,就不能因此删掉模型起草的项。
|
||||
- 起草结果里没有这一项:
|
||||
- 模型起草的项(llm)留着。模型这次留空、给的值没通过校验(_from_model 里就丢掉了),都不能把以前起草好的
|
||||
删掉——实测整库重跑一次,模型这次没给的几百项全被删掉,有的表覆盖率从八成多掉到五成。只有这一轮完整起草过
|
||||
(covered 含 llm)、而它说的列已经不在表结构里(columns:表结构里的列名;业务主键、业务日期指着的列也算)
|
||||
才删;不知道表结构(columns 为 None)时不删。
|
||||
- 别的来源:来源在 covered 里(这一轮完整算过这个来源)就删掉,否则留着。外键约束被删掉以后,原来那条「有确证」
|
||||
的关系不能还挂着;库注释改了,旧的那条注释也不能还挂着。
|
||||
- 同一来源:值或状态有变化就更新。数据剖析写的备注只是日期变了不算变化(same_profile_note),保留旧的项。
|
||||
- 不同来源:来源更可信的(_SOURCE_RANK)顶掉不如它的,反过来不行。
|
||||
|
||||
新增和更新的项盖上 at(缺省为当前时间)作为 updated_at;内容没变的项保持原样,修改时间也不动。
|
||||
removed 只数真删掉的项。
|
||||
"""
|
||||
at = at or now_iso()
|
||||
covered = set(covered)
|
||||
known = {str(c) for c in columns} if columns is not None else None
|
||||
old, new = _slots(existing), _slots(draft)
|
||||
out: dict[Slot, dict[str, Any]] = {}
|
||||
added = updated = removed = 0
|
||||
@@ -614,7 +634,11 @@ def merge_notes(existing: Mapping[str, Any] | None, draft: Mapping[str, Any] | N
|
||||
if item.get("status") in _LOCKED:
|
||||
out[key] = item
|
||||
elif cand is None:
|
||||
if item.get("source") in covered:
|
||||
if item.get("source") == "llm":
|
||||
gone = "llm" in covered and known is not None and _about_gone_columns(key, item, known)
|
||||
else:
|
||||
gone = item.get("source") in covered
|
||||
if gone:
|
||||
removed += 1
|
||||
else:
|
||||
out[key] = item
|
||||
@@ -1130,10 +1154,15 @@ async def save_patch(session: AsyncSession, source_id: str, table: str, changes:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TableDraft:
|
||||
"""一张表的起草结果。covered:这一轮完整算过的来源(merge_notes 据此删掉这些来源里已经没有的旧项)。"""
|
||||
"""一张表的起草结果。covered:这一轮完整算过的来源(merge_notes 据此删掉这些来源里已经没有的旧项;模型起草的项
|
||||
只在列已经不在表结构里时才删)。
|
||||
|
||||
error:模型起草只写出了一部分(有的段没写出来)时,没写出来的是哪部分、为什么。这时 covered 不含 llm:没写全
|
||||
就不算完整起草过。"""
|
||||
|
||||
notes: dict[str, Any]
|
||||
covered: frozenset[str] = frozenset()
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def system_notes_source(source: Any) -> bool:
|
||||
@@ -1176,6 +1205,34 @@ def _comment_is_label(text: str) -> bool:
|
||||
return len(text) <= _LABEL_MAX and not _SENTENCE_MARKS.search(text)
|
||||
|
||||
|
||||
#: 标识符写法的注释:只有字母、数字、下划线、点,不带空格(「member_card」「ord.status」「GMV」)
|
||||
_IDENTIFIER = re.compile(r"[A-Za-z0-9_.]+")
|
||||
#: 比较注释和名字时忽略的字符:下划线、连字符、空白
|
||||
_NAME_FILLER = re.compile(r"[\s_\-]+")
|
||||
|
||||
|
||||
def _bare_name(text: str) -> str:
|
||||
return _NAME_FILLER.sub("", text).lower()
|
||||
|
||||
|
||||
def comment_role(text: str, name: str) -> str | None:
|
||||
"""数据库注释在目录里记成什么:label 中文名;note 一段解释(表记进说明,列记进含义);None 不记。
|
||||
|
||||
- 注释就是名字本身(和表名、列名相同,忽略大小写、下划线、连字符和空白;表名带 schema 前缀的也比不带前缀的),
|
||||
或者是标识符写法(只有字母、数字、下划线、点,不带空格):说明不了什么,不记。以前这种注释短、不带句读,就当
|
||||
中文名记下了;库注释的级别又比模型起草高,模型起草的中文名永远进不了目录。交给模型的表结构里照旧有这条注释;
|
||||
- 短、不带句读的是名字(「实收金额」);
|
||||
- 否则是一段解释(「状态:1 表示有效…」)。
|
||||
|
||||
只影响以后的起草:已有的目录不另做迁移,起草到那张表时按这一轮的注释重新算(旧的那条随之删掉)。
|
||||
"""
|
||||
bare = _bare_name(text)
|
||||
names = {name, name.rsplit(".", 1)[-1]}
|
||||
if not bare or any(bare == _bare_name(n) for n in names) or _IDENTIFIER.fullmatch(text):
|
||||
return None
|
||||
return "label" if _comment_is_label(text) else "note"
|
||||
|
||||
|
||||
def _is_key(meta: Mapping[str, Any], columns: list[str]) -> bool:
|
||||
"""这几列恰好是这张表的主键或某个唯一约束(不分大小写、不计顺序)。"""
|
||||
wanted = {str(c).lower() for c in columns}
|
||||
@@ -1367,7 +1424,8 @@ def draft_structure(source: Any, table: str) -> TableDraft:
|
||||
"""只看表结构就能起草的部分:数据库注释、外键约束、命名推断。table 必须是 schema_cache["tables"] 的键。
|
||||
|
||||
- 数据库注释 → 中文名或含义(comment / proposed):短而不带句读的是名字,记进表或列的中文名;否则是
|
||||
一段解释,记进表的说明或列的含义。导入表格的源例外(system_notes_source):说明是系统生成的,不拷。
|
||||
一段解释,记进表的说明或列的含义;就是名字本身、标识符写法的不记(comment_role)。导入表格的源例外
|
||||
(system_notes_source):说明是系统生成的,不拷。
|
||||
- 外键约束 → 关系(fk / verified)。
|
||||
- 命名推断 → 关系(name / proposed)。
|
||||
"""
|
||||
@@ -1378,13 +1436,15 @@ def draft_structure(source: Any, table: str) -> TableDraft:
|
||||
notes: dict[str, Any] = {}
|
||||
if not system_notes_source(source):
|
||||
comment = (meta.get("comment") or "").strip()
|
||||
if comment:
|
||||
notes["label" if _comment_is_label(comment) else "description"] = make_item(comment, "comment")
|
||||
role = comment_role(comment, table) if comment else None
|
||||
if role is not None:
|
||||
notes["label" if role == "label" else "description"] = make_item(comment, "comment")
|
||||
columns: dict[str, dict[str, Any]] = {}
|
||||
for col in meta.get("columns") or []:
|
||||
text = (col.get("comment") or "").strip()
|
||||
if text:
|
||||
columns[col["name"]] = {("label" if _comment_is_label(text) else "meaning"): make_item(text, "comment")}
|
||||
role = comment_role(text, str(col["name"])) if text else None
|
||||
if role is not None:
|
||||
columns[col["name"]] = {("label" if role == "label" else "meaning"): make_item(text, "comment")}
|
||||
if columns:
|
||||
notes["columns"] = columns
|
||||
fks = fk_relations(cache, table)
|
||||
@@ -1401,15 +1461,39 @@ def draft_structure(source: Any, table: str) -> TableDraft:
|
||||
# 起草:模型(可选,按表分批,结构化输出)
|
||||
# ==========================================================================
|
||||
|
||||
#: 每批最多几张表、合计多少列。一批太大模型容易漏表、输出被截断;太小则调用次数多
|
||||
#: 每次调用最多几张表、合计多少列(data/catalog_draft.py 按它分段)。实测每列约 50 个输出 token(不算思考),
|
||||
#: 会思考的模型思考也算在输出额度里:一段太大,想完就没额度写正文。列数超过 MODEL_BATCH_COLUMNS 的宽表拆成
|
||||
#: 「表级一段 + 列分段」,列分段数是列数除以它向上取整,各段列数平均(相差不超过 1),不留很小的尾段
|
||||
MODEL_BATCH_TABLES = 4
|
||||
MODEL_BATCH_COLUMNS = 120
|
||||
#: 同时发出的批数
|
||||
MODEL_BATCH_COLUMNS = 40
|
||||
#: 一次起草里额度用完或超时累计这么多次,之后新开始的段列数上限减半(MODEL_BATCH_COLUMNS → 一半 → …),
|
||||
#: 最低 MODEL_MIN_PART_COLUMNS 列
|
||||
MODEL_SHRINK_AFTER = 2
|
||||
MODEL_MIN_PART_COLUMNS = 10
|
||||
#: 一段(连同它拆出来的、补问的、重试的)最多调用几次模型,到了次数还没写完的记没写出来
|
||||
MODEL_PART_MAX_CALLS = 8
|
||||
#: 提醒:最近 MODEL_NOTICE_WINDOW 次调用里有 MODEL_NOTICE_ON 次以上没写出来时提醒换模型;之后少于 MODEL_NOTICE_OFF
|
||||
#: 次时清掉
|
||||
MODEL_NOTICE_WINDOW = 6
|
||||
MODEL_NOTICE_ON = 4
|
||||
MODEL_NOTICE_OFF = 2
|
||||
#: 全进程同时进行的模型调用最多几个(所有起草任务、同步起草共用)
|
||||
MODEL_CONCURRENCY = 3
|
||||
#: 一批最多等多久
|
||||
MODEL_TIMEOUT_S = 120
|
||||
#: 输出额度。一批四张表的目录远用不完;给太大的额度有的服务方会直接拒收
|
||||
MODEL_MAX_TOKENS = 8192
|
||||
#: 单次调用最多等多久(秒)。会思考的模型想得久,一段 40 列常常要几分钟;超时了拆小再问(同额度用完)
|
||||
MODEL_TIMEOUT_S = 420
|
||||
#: 输出额度。会思考的模型(经 OpenAI 兼容网关的推理模型等)思考也算在里面:8192 时上百列的表常常想完就没额度
|
||||
#: 写正文。保留思考(不关),加大额度;服务方嫌额度太大时退回 MODEL_FALLBACK_MAX_TOKENS(同 copilot、recipe_ai)
|
||||
MODEL_MAX_TOKENS = 32768
|
||||
MODEL_FALLBACK_MAX_TOKENS = 8192
|
||||
#: 一段没写出来(额度用完、只给空壳)时拆成两半再问:最多拆几层、拆到最少几列为止
|
||||
MODEL_SPLIT_DEPTH = 3
|
||||
MODEL_SPLIT_MIN_COLUMNS = 5
|
||||
#: 止损:连续这么多段拆到底仍一项没写出来,这次起草后面的段不再拆小重试(模型明显用不了时不该一段一段拆到底烧额度;
|
||||
#: 每段另有 MODEL_PART_MAX_CALLS 次封顶)
|
||||
MODEL_GIVE_UP_AFTER = 2
|
||||
#: 网络错误、429、5xx 最多重试几次,每次重试前等多久(秒,依次取用)。超时不原样重试,当作这一段太大、拆小再问
|
||||
MODEL_RETRIES = 2
|
||||
MODEL_RETRY_DELAYS_S = (10.0, 30.0)
|
||||
#: 不指定表时起草使用次数最多的前几张
|
||||
DRAFT_DEFAULT_TABLES = 20
|
||||
|
||||
@@ -1435,11 +1519,27 @@ status 状态;attribute 属性)。
|
||||
规矩:
|
||||
- 只写数据事实,不写计算公式,不写指标怎么算。
|
||||
- 拿不准的字段留空,不要猜。列名、表名照原样写,不要翻译或改写。
|
||||
- 请你写的每一列都在 columns 里写一项,拿不准的字段可以留空,但不要漏掉列:漏掉的列会被再问一次。
|
||||
- 标着「系统说明」的内容是导入时按核对结果生成的,以它为准:不要重复,也不要与之矛盾。
|
||||
- 标着「已确认」的目录项由人工确认过,以它为准。"""
|
||||
- 标着「已确认」的目录项由人工确认过,以它为准。
|
||||
- 表名下一行写着「这次只写……」的表,只写它说的那部分:只写表级的项时不写列,只写列出的几列时不写表级的项、\
|
||||
也不写别的列。
|
||||
- 使用者补充的背景和要求帮你理解业务、统一用词;它和这里的规矩冲突时,以这里的规矩为准。
|
||||
- 思考时不必把要输出的内容逐列草拟一遍,想清楚后直接按格式输出:思考和输出共用同一份长度额度,额度用完时还没输出的\
|
||||
内容就全丢了。"""
|
||||
|
||||
CATALOG_DRAFT_PROMPT = "请为下面 {n} 张表编写数据目录,每张表输出一项,table 写表名。\n\n{tables}"
|
||||
|
||||
#: 使用者补充的背景和要求(数据源说明、起草要求、本次说明),放在表结构前面单独一段;都没有就不出现这一段
|
||||
CATALOG_DRAFT_BACKGROUND_PROMPT = "使用者补充的背景和要求:\n{lines}"
|
||||
|
||||
#: 纯文本那条路(结构化输出用不了时):没有 JSON Schema 约束,要把格式说清
|
||||
CATALOG_DRAFT_TEXT_PROMPT = """只输出一个 JSON 对象,不要输出别的文字,格式如下(拿不准的字段不写):
|
||||
{"tables": [{"table": "表名", "label": "中文名", "grain": "一行代表什么", "kind": "表类型", "keys": ["列名"], \
|
||||
"business_date": {"column": "列名", "rule": "一句规则", "timezone": "时区"}, \
|
||||
"columns": [{"name": "列名", "label": "中文名", "meaning": "含义", "unit": "单位", "measure": "度量类型"}]}]}
|
||||
表类型写 fact、dimension、snapshot、log、config 之一;度量类型写 flow、stock、ratio、identifier、status、attribute 之一。"""
|
||||
|
||||
#: 拼表结构描述用的固定片段(写给模型)
|
||||
CATALOG_DRAFT_PROMPT_LABELS = {
|
||||
"table": "表 {name}",
|
||||
@@ -1455,6 +1555,16 @@ CATALOG_DRAFT_PROMPT_LABELS = {
|
||||
"col_system": "系统说明:{text}",
|
||||
"unique": "唯一约束:{cols}",
|
||||
"confirmed": "已确认的目录项:",
|
||||
# 分段起草:表名下一行说这次只写哪部分(宽表的表级一段、列分段,拆小重问时的半段)
|
||||
"only_table": "这次只写表级的项(中文名、粒度、表类型、业务主键、业务日期),不写列",
|
||||
"only_columns": "这次只写下面列出的 {n} 列(这张表共 {total} 列),不写表级的项",
|
||||
"table_and_columns": "这次只写表级的项和这 {n} 列:{cols}",
|
||||
"brief_columns": "列(共 {n} 列,这里只列名字和类型):",
|
||||
"drafted": "已起草的表级内容:",
|
||||
# 使用者补充的背景和要求
|
||||
"description": "数据源说明:{text}",
|
||||
"guidance": "起草要求:{text}",
|
||||
"note": "本次说明:{text}",
|
||||
}
|
||||
|
||||
CATALOG_DRAFT_PROMPT_SCHEMA: dict[str, Any] = {
|
||||
@@ -1499,99 +1609,69 @@ CATALOG_DRAFT_PROMPT_SCHEMA: dict[str, Any] = {
|
||||
"required": ["tables"],
|
||||
}
|
||||
|
||||
_P = CATALOG_DRAFT_PROMPT_LABELS
|
||||
class DraftModel:
|
||||
"""起草用的模型:先按 MODEL_MAX_TOKENS 的额度调;服务方嫌额度太大(budget_rejected)时换成 MODEL_FALLBACK_MAX_TOKENS
|
||||
的那一个,之后的调用都用它(shrink)。一次起草(一个起草任务、一次同步起草)共用一个,退回之后不再先失败一次。
|
||||
|
||||
只转发起草用到的两个方法:with_structured_output 每次都取当前那个模型的,ainvoke 同理。"""
|
||||
|
||||
def __init__(self, big: Any, small: Any | None, model_name: str = "") -> None:
|
||||
self.model, self._small = big, small
|
||||
self.model_name = model_name
|
||||
self.budget = MODEL_MAX_TOKENS
|
||||
|
||||
def shrink(self) -> bool:
|
||||
"""换成小额度的模型。已经换过、没有小的,返回 False。"""
|
||||
if self._small is None or self.model is self._small:
|
||||
return False
|
||||
self.model, self.budget = self._small, MODEL_FALLBACK_MAX_TOKENS
|
||||
return True
|
||||
|
||||
def with_structured_output(self, schema: Any, **kwargs: Any) -> Any:
|
||||
return self.model.with_structured_output(schema, **kwargs)
|
||||
|
||||
async def ainvoke(self, messages: list[Any]) -> Any:
|
||||
return await self.model.ainvoke(messages)
|
||||
|
||||
|
||||
async def resolve_draft_model(session: AsyncSession) -> tuple[Any, str]:
|
||||
"""(模型, 模型 id):用设置里助手的模型,关掉思考、温度 0。没有可用的模型抛 CatalogModelUnavailable。
|
||||
"""(模型, 模型 id):用哪个接入、哪个模型按 data/draft_model.py 的顺序(设置里的起草模型 > 助手的模型 > 默认接入),
|
||||
和按配方导入的 AI 起草共用。没有可用的模型抛 CatalogModelUnavailable。
|
||||
|
||||
不关思考(spec 里不传 thinking,按接入默认)、温度 0、额度 MODEL_MAX_TOKENS,服务方嫌大时退回
|
||||
MODEL_FALLBACK_MAX_TOKENS(DraftModel)。单次调用的时限 MODEL_TIMEOUT_S 也交给 SDK,免得它先按全局的 5 分钟断开。
|
||||
SDK 自己不重试(max_retries=0):429、5xx、网络错误由起草引擎退避重试(最多 MODEL_RETRIES 次);超时当作这一段
|
||||
太大,拆小再问(data/catalog_draft.py),不原样重试。不然两层叠起来一段最多发 9 次请求,报错却写「已重试 2 次」,
|
||||
网关正在限流时还会放大流量。
|
||||
只建模型对象,不发请求。
|
||||
|
||||
和 recipe_ai.resolve_draft_model 一样不用 get_chat_model:它在没有接入时静默退回演示模型,拿演示模型
|
||||
起草出来的目录会被当成真的写进库里。数据层不导入接口层,设置键取 recipe_ai 里那份(与助手的同值,
|
||||
那边有测试核对)。
|
||||
起草出来的目录会被当成真的写进库里。
|
||||
"""
|
||||
from app.data.recipe_ai import COPILOT_SETTING_KEY
|
||||
from app.db.models import Setting
|
||||
from app.providers.factory import (
|
||||
ModelSpec, ProviderNotConfigured, assistant_unavailable, build_chat_model, resolve_provider,
|
||||
)
|
||||
from app.data.draft_model import DraftModelUnavailable, draft_unavailable, resolve_draft_provider
|
||||
from app.providers.factory import ModelSpec, ProviderNotConfigured, build_chat_model
|
||||
|
||||
row = await session.get(Setting, COPILOT_SETTING_KEY)
|
||||
saved = (row.value if row else None) or {}
|
||||
spec = ModelSpec(provider=saved.get("provider") or None, model=saved.get("model") or None,
|
||||
max_tokens=MODEL_MAX_TOKENS, temperature=0, thinking="off")
|
||||
try:
|
||||
# 存的接入名不存在、已停用,只存了模型名、提供它的接入已停用:resolve_provider 都会报出来
|
||||
provider = await resolve_provider(session, spec.provider, spec.model)
|
||||
except ProviderNotConfigured as e:
|
||||
raise CatalogModelUnavailable(assistant_unavailable(e)) from e
|
||||
if provider is None:
|
||||
raise CatalogModelUnavailable("未配置模型接入,无法由助手起草数据目录。请到「设置 → 模型接入」添加")
|
||||
if provider.kind == "mock":
|
||||
raise CatalogModelUnavailable("当前只有演示用的模型接入,无法用于起草数据目录。"
|
||||
"请到「设置 → 模型接入」添加真实的模型接入")
|
||||
provider, choice = await resolve_draft_provider(
|
||||
session, none_reason="未配置模型接入,无法由模型起草数据目录。请到「设置 → 模型接入」添加",
|
||||
mock_reason="当前只有演示用的模型接入,无法用于起草数据目录。请到「设置 → 模型接入」添加真实的模型接入")
|
||||
except DraftModelUnavailable as e:
|
||||
raise CatalogModelUnavailable(str(e)) from e
|
||||
spec = ModelSpec(provider=choice.provider, model=choice.model, max_tokens=MODEL_MAX_TOKENS, temperature=0,
|
||||
timeout=MODEL_TIMEOUT_S, max_retries=0)
|
||||
try:
|
||||
model = build_chat_model(provider, spec)
|
||||
big = build_chat_model(provider, spec)
|
||||
small = build_chat_model(provider, spec.model_copy(update={"max_tokens": MODEL_FALLBACK_MAX_TOKENS}))
|
||||
except ProviderNotConfigured as e:
|
||||
raise CatalogModelUnavailable(assistant_unavailable(e)) from e
|
||||
return model, spec.model or provider.default_model or ""
|
||||
raise CatalogModelUnavailable(draft_unavailable(e, choice.origin)) from e
|
||||
model_id = spec.model or provider.default_model or ""
|
||||
return DraftModel(big, small, model_id), model_id
|
||||
|
||||
|
||||
def _confirmed_only(notes: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
return _assemble({k: v for k, v in _slots(notes).items() if v.get("status") in ("confirmed", "verified")})
|
||||
|
||||
|
||||
def _describe_for_model(name: str, meta: Mapping[str, Any], *, system: bool, existing: Mapping[str, Any] | None) -> str:
|
||||
"""一张表交给模型的结构描述:列、类型、主键、外键、唯一约束、注释;不含任何数据值。"""
|
||||
lines = [(_P["view"] if meta.get("is_view") else _P["table"]).format(name=name)]
|
||||
comment = (meta.get("comment") or "").strip()
|
||||
if comment:
|
||||
lines.append((_P["system"] if system else _P["comment"]).format(text=comment))
|
||||
pk = set(meta.get("primary_key") or [])
|
||||
fk_of: dict[str, str] = {}
|
||||
for fk in meta.get("foreign_keys") or []:
|
||||
if isinstance(fk, dict) and len(fk.get("columns") or []) == 1:
|
||||
to_cols = fk.get("to_columns") or []
|
||||
fk_of[fk["columns"][0]] = f"{fk.get('to_table')}.{to_cols[0]}" if to_cols else str(fk.get("to_table"))
|
||||
lines.append(_P["columns"])
|
||||
for col in meta.get("columns") or []:
|
||||
marks = []
|
||||
if col["name"] in pk:
|
||||
marks.append(_P["pk"])
|
||||
if not col.get("nullable", True):
|
||||
marks.append(_P["not_null"])
|
||||
if col["name"] in fk_of:
|
||||
marks.append(_P["fk"].format(target=fk_of[col["name"]]))
|
||||
if text := (col.get("comment") or "").strip():
|
||||
marks.append((_P["col_system"] if system else _P["col_comment"]).format(text=text))
|
||||
line = _P["column"].format(name=col["name"], type=col.get("type") or "")
|
||||
lines.append(line + ("," + ",".join(marks) if marks else ""))
|
||||
for group in meta.get("unique") or []:
|
||||
if isinstance(group, list) and group:
|
||||
lines.append(_P["unique"].format(cols="、".join(group)))
|
||||
confirmed = _notes_lines(_confirmed_only(existing), meta=meta, system_notes=False)
|
||||
if confirmed:
|
||||
lines.append(_P["confirmed"])
|
||||
lines += confirmed
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _batches(names: list[str], tables: Mapping[str, Any]) -> list[list[str]]:
|
||||
"""按表分批:每批不超过 MODEL_BATCH_TABLES 张、合计不超过 MODEL_BATCH_COLUMNS 列(一张表超了就自成一批)。"""
|
||||
out: list[list[str]] = []
|
||||
batch: list[str] = []
|
||||
width = 0
|
||||
for name in names:
|
||||
n = len((tables.get(name) or {}).get("columns") or [])
|
||||
if batch and (len(batch) >= MODEL_BATCH_TABLES or width + n > MODEL_BATCH_COLUMNS):
|
||||
out.append(batch)
|
||||
batch, width = [], 0
|
||||
batch.append(name)
|
||||
width += n
|
||||
if batch:
|
||||
out.append(batch)
|
||||
return out
|
||||
|
||||
|
||||
#: 模型给了这张表、但一项可用内容都没有(只有表名的空壳,或者给的值全不合规):这张表记模型失败,不算起草成功。
|
||||
#: 不然界面只看到「新增 0 项」,会说成「没有变化」
|
||||
MODEL_EMPTY_REASON = "模型没有给出这张表的可用内容"
|
||||
@@ -1621,39 +1701,6 @@ def _model_tables(raw: Any) -> list[dict[str, Any]] | None:
|
||||
return [t for t in raw if isinstance(t, dict)]
|
||||
|
||||
|
||||
async def _ask_model(model: Any, messages: list[Any],
|
||||
usable: Callable[[list[dict[str, Any]]], bool] | None = None) -> list[dict[str, Any]]:
|
||||
"""问一批。先走结构化输出;模型不支持、调用报错、给回来的不是约定的结构,或者 usable 说它不够用(实测见过
|
||||
结构化输出只给 {"tables":[{"table":"x"}]} 这样的空壳),再退回纯文本、从正文里取 JSON。
|
||||
|
||||
结构化输出报错也退回(同 judge._ask):有的网关不支持工具调用,一调就报错,纯文本却能用。代价是真的
|
||||
网络故障会多试一次,两次加起来仍受 MODEL_TIMEOUT_S 约束。
|
||||
|
||||
两条路都拿到了东西时,结构化的在前、纯文本的在后一起交回(同一张表各有一份,调用方挑能用的那份);纯文本那次
|
||||
失败了就只交回结构化的那份(调用方据此给空壳的表记失败)。
|
||||
"""
|
||||
try:
|
||||
parsed = _model_tables(await model.with_structured_output(CATALOG_DRAFT_PROMPT_SCHEMA).ainvoke(messages))
|
||||
except Exception: # noqa: BLE001 - 退回纯文本
|
||||
parsed = None
|
||||
if parsed is not None and (usable is None or usable(parsed)):
|
||||
return parsed
|
||||
from app.engine.state import message_text
|
||||
|
||||
try:
|
||||
reply = await model.ainvoke(messages)
|
||||
except Exception: # noqa: BLE001 - 结构化那份还在:交回它,空壳的表由调用方记失败
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
raise
|
||||
text = _model_tables(message_text(reply))
|
||||
if text is None:
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
raise ValueError("模型的回复不是约定的格式")
|
||||
return [*(parsed or []), *text]
|
||||
|
||||
|
||||
def _clean_text(value: Any, limit: int = _TEXT_MAX) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
@@ -1664,7 +1711,8 @@ def _clean_text(value: Any, limit: int = _TEXT_MAX) -> str | None:
|
||||
def _from_model(entry: Mapping[str, Any], meta: Mapping[str, Any], *, system: bool) -> dict[str, Any]:
|
||||
"""模型给的一张表 → 目录项(llm / proposed)。不合规的值整项丢掉,不硬塞:列名对不上表结构的、
|
||||
表类型和度量类型不在取值里的、业务主键里有不存在的列的、看起来是计算公式的(_formula_like,公式进口径卡)。
|
||||
导入表格的源,说明已经覆盖的字段也丢掉。"""
|
||||
该是文字的地方写成了数组、对象(列名、表类型、业务日期的列、度量类型……)同样丢掉,不抛异常:实测见过把列名
|
||||
写成数组的,以前在这里抛 TypeError,整张表记出错。导入表格的源,说明已经覆盖的字段也丢掉。"""
|
||||
columns = {c["name"]: c for c in meta.get("columns") or []}
|
||||
notes: dict[str, Any] = {}
|
||||
table_has_system_note = system and bool((meta.get("comment") or "").strip())
|
||||
@@ -1673,21 +1721,23 @@ def _from_model(entry: Mapping[str, Any], meta: Mapping[str, Any], *, system: bo
|
||||
continue
|
||||
if (text := _clean_text(entry.get(name))) is not None and not _formula_like(("t", name), text):
|
||||
notes[name] = make_item(text, "llm")
|
||||
if entry.get("kind") in TABLE_KINDS:
|
||||
notes["kind"] = make_item(entry["kind"], "llm")
|
||||
kind = entry.get("kind")
|
||||
if isinstance(kind, str) and kind in TABLE_KINDS:
|
||||
notes["kind"] = make_item(kind, "llm")
|
||||
keys = entry.get("keys")
|
||||
if _str_list_ok(keys) and all(k in columns for k in keys):
|
||||
notes["keys"] = make_item(list(dict.fromkeys(keys)), "llm")
|
||||
date = entry.get("business_date")
|
||||
if isinstance(date, dict) and date.get("column") in columns:
|
||||
if isinstance(date, dict) and isinstance(date.get("column"), str) and date["column"] in columns:
|
||||
value = {"column": date["column"]}
|
||||
for k in ("rule", "timezone"):
|
||||
if (text := _clean_text(date.get(k))) is not None:
|
||||
value[k] = text
|
||||
notes["business_date"] = make_item(value, "llm")
|
||||
out_cols: dict[str, dict[str, Any]] = {}
|
||||
for col in entry.get("columns") or []:
|
||||
if not isinstance(col, dict) or col.get("name") not in columns:
|
||||
listed = entry.get("columns")
|
||||
for col in listed if isinstance(listed, list) else []:
|
||||
if not isinstance(col, dict) or not isinstance(col.get("name"), str) or col["name"] not in columns:
|
||||
continue
|
||||
name = col["name"]
|
||||
has_system_note = system and bool((columns[name].get("comment") or "").strip())
|
||||
@@ -1698,8 +1748,9 @@ def _from_model(entry: Mapping[str, Any], meta: Mapping[str, Any], *, system: bo
|
||||
text = _clean_text(col.get(field_name), limit)
|
||||
if text is not None and not _formula_like(("c", name, field_name), text):
|
||||
items[field_name] = make_item(text, "llm")
|
||||
if col.get("measure") in COLUMN_MEASURES:
|
||||
items["measure"] = make_item(col["measure"], "llm")
|
||||
measure = col.get("measure")
|
||||
if isinstance(measure, str) and measure in COLUMN_MEASURES:
|
||||
items["measure"] = make_item(measure, "llm")
|
||||
if items:
|
||||
out_cols[name] = items
|
||||
if out_cols:
|
||||
@@ -1708,65 +1759,30 @@ def _from_model(entry: Mapping[str, Any], meta: Mapping[str, Any], *, system: bo
|
||||
|
||||
|
||||
async def draft_with_model(model: Any, source: Any, tables: list[str], *,
|
||||
existing: Mapping[str, Mapping[str, Any]] | None = None) -> dict[str, TableDraft | str]:
|
||||
existing: Mapping[str, Mapping[str, Any]] | None = None,
|
||||
guidance: str | None = None, note: str | None = None) -> dict[str, TableDraft | str]:
|
||||
"""用模型起草这些表的中文名、粒度、表类型、业务主键、业务日期和每列的中文名、含义、单位、度量类型。
|
||||
|
||||
model 要有 with_structured_output(schema) 或 ainvoke(messages)(测试注入假模型)。按表分批(_batches),
|
||||
最多 MODEL_CONCURRENCY 批同时发。返回 {表名: TableDraft 或失败原因}:一批失败只影响这一批的表,
|
||||
其余照常;失败的表不算 covered,已有的模型起草项不会因此被删。
|
||||
实现在 data/catalog_draft.py(分段、按失败原因处理、捡回被截断的部分);这里是给老调用方的形状:返回
|
||||
{表名: TableDraft 或失败原因}。全部写出来的 covered 是 {"llm"};只写出一部分的仍是 TableDraft,covered 为空、
|
||||
error 写明哪部分没写出来;一项都没写出来的是失败原因。表结构里没有的表不在结果里。
|
||||
|
||||
交给模型的只有表结构(列、类型、主键、外键、唯一约束、注释),不发任何数据值;existing 里已确认、
|
||||
有确证的项作为「以此为准」的背景一起交过去。
|
||||
model 要有 with_structured_output(schema) 或 ainvoke(messages)(测试注入假模型)。交给模型的只有表结构和使用者
|
||||
补充的背景(数据源说明、guidance 起草要求、note 本次说明),不发任何数据值;existing 里已确认、有确证的项作为
|
||||
「以此为准」的背景一起交过去。
|
||||
"""
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
from app.data import catalog_draft
|
||||
|
||||
all_tables = (getattr(source, "schema_cache", None) or {}).get("tables") or {}
|
||||
system = system_notes_source(source)
|
||||
names = [t for t in tables if isinstance(all_tables.get(t), dict)]
|
||||
results: dict[str, TableDraft | str] = {}
|
||||
gate = asyncio.Semaphore(MODEL_CONCURRENCY)
|
||||
|
||||
async def run(batch: list[str]) -> None:
|
||||
described = "\n\n".join(_describe_for_model(n, all_tables[n], system=system,
|
||||
existing=(existing or {}).get(n)) for n in batch)
|
||||
messages = [SystemMessage(content=CATALOG_DRAFT_SYSTEM),
|
||||
HumanMessage(content=CATALOG_DRAFT_PROMPT.format(n=len(batch), tables=described))]
|
||||
def drafted(entries: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
"""这批里每张表能用的那份:同一张表给了几份(结构化、纯文本各一份)时取第一份有可用内容的。"""
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for entry in entries:
|
||||
name = str(entry.get("table"))
|
||||
if name in batch and name not in out:
|
||||
notes = _from_model(entry, all_tables[name], system=system)
|
||||
if notes:
|
||||
out[name] = notes
|
||||
return out
|
||||
|
||||
try:
|
||||
async with gate:
|
||||
got = await asyncio.wait_for(
|
||||
_ask_model(model, messages, usable=lambda entries: len(drafted(entries)) == len(batch)),
|
||||
timeout=MODEL_TIMEOUT_S)
|
||||
except asyncio.TimeoutError:
|
||||
for n in batch:
|
||||
results[n] = f"模型起草超时:{MODEL_TIMEOUT_S} 秒内没有收到回复"
|
||||
return
|
||||
except Exception as e: # noqa: BLE001 - 一批失败不影响其他批和其他来源
|
||||
from app.core.errors import describe_exception, first_line
|
||||
|
||||
for n in batch:
|
||||
results[n] = f"模型起草失败:{first_line(e) or describe_exception(e)}"
|
||||
return
|
||||
named = {str(t.get("table")) for t in got}
|
||||
usable = drafted(got)
|
||||
for n in batch:
|
||||
if n in usable:
|
||||
results[n] = TableDraft(notes=usable[n], covered=frozenset({"llm"}))
|
||||
else:
|
||||
results[n] = MODEL_EMPTY_REASON if n in named else "模型的回复里没有这张表"
|
||||
|
||||
await asyncio.gather(*(run(b) for b in _batches(names, all_tables)))
|
||||
return {n: results[n] for n in names if n in results}
|
||||
outcomes = await catalog_draft.run_model_draft(
|
||||
model, source, tables, existing=existing, background=catalog_draft.Background(guidance=guidance, note=note))
|
||||
out: dict[str, TableDraft | str] = {}
|
||||
for name, outcome in outcomes.items():
|
||||
if outcome.status == "failed":
|
||||
out[name] = outcome.error or MODEL_EMPTY_REASON
|
||||
else:
|
||||
out[name] = TableDraft(notes=outcome.notes, covered=frozenset({"llm"}) if outcome.status == "ok"
|
||||
else frozenset(), error=outcome.error)
|
||||
return out
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
@@ -1776,7 +1792,8 @@ async def draft_with_model(model: Any, source: Any, tables: list[str], *,
|
||||
|
||||
@dataclass
|
||||
class TableDraftResult:
|
||||
"""一张表这次起草的结果。error:这张表没起草(表结构里没有、写入一直冲突);model_error:只是模型那部分失败。"""
|
||||
"""一张表这次起草的结果。error:这张表没起草(表结构里没有、写入一直冲突);model_error:只是模型那部分失败,
|
||||
或者只写出了一部分(哪部分没写出来、为什么)。"""
|
||||
|
||||
table: str
|
||||
added: int = 0
|
||||
@@ -1785,9 +1802,17 @@ class TableDraftResult:
|
||||
version: int = 0
|
||||
error: str | None = None
|
||||
model_error: str | None = None
|
||||
#: 模型给了这张表几项可用内容(并入目录之前,按项数)。没用模型、模型这部分失败时为 None。
|
||||
#: 模型给了这张表几项可用内容(并入目录之前,按项数)。没用模型、模型这部分一项都没写出来时为 None。
|
||||
#: 大于 0 而新增、更新、删除都是 0,才是「模型给了内容、只是和现有目录一致」
|
||||
model_items: int | None = None
|
||||
#: ok 写入了(用模型时各段都写出来了)、partial 模型只写出一部分、failed 没写入或模型一段都没写出来、
|
||||
#: skipped 表结构里没有这张表
|
||||
status: str = "ok"
|
||||
#: 模型分几段起草(不用模型时为 None)
|
||||
parts_total: int | None = None
|
||||
#: 模型拿不准、留空了的列(catalog_draft.ModelOutcome.blank,按表结构的顺序),只留并入目录之后仍然没有中文名和
|
||||
#: 含义的;表的状态不因此变
|
||||
blank: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -1802,35 +1827,113 @@ class DraftReport:
|
||||
model_error: str | None = None
|
||||
|
||||
|
||||
async def draft_catalog(session: AsyncSession, source: Any, *, tables: list[str] | None = None,
|
||||
use_model: bool = False, model: Any = None, actor: str | None = None) -> DraftReport:
|
||||
"""起草这些表的目录并写入,同步完成。
|
||||
#: 表结构里没有要起草的表(被删了、改名了,或者名字写错了)
|
||||
def missing_table_error(name: str) -> str:
|
||||
return f"表结构里没有 {name},请先重新探查结构"
|
||||
|
||||
|
||||
#: 写入一直撞上别人刚改过(乐观锁重读重并三次仍冲突)
|
||||
WRITE_CONFLICT_ERROR = "这张表的数据目录正被其他人修改,请稍后再起草"
|
||||
|
||||
|
||||
async def write_draft(session: AsyncSession, source: Any, table: str, modeled: Any = None, *, at: str,
|
||||
actor: str | None) -> TableDraftResult:
|
||||
"""一张表的起草并入目录并写入:数据库注释、外键约束、命名推断照做,modeled(catalog_draft.ModelOutcome,
|
||||
没用模型时为 None)有可用内容的再并上。同步起草和后台起草任务(每张表一结束就调)共用这一处。
|
||||
|
||||
- source:手工源的 DataSource,或上传源绑定当前快照的视图(table_versions.resolve_source);按 source.id 存。
|
||||
- tables 为空时按使用次数(table_usage)取前 DRAFT_DEFAULT_TABLES 张,次数相同按表结构里的顺序。
|
||||
- 每张表:数据库注释、外键约束、命名推断照做;use_model 时再加模型起草。model 可注入(测试用假模型),
|
||||
不给就按设置解析(resolve_draft_model);模型用不了、某一批失败,都不影响其余来源照常写入。
|
||||
- 并入已有目录按 merge_notes 的规则:人工确认、驳回过的项不动。内容没变不写、不升版本。
|
||||
- 模型这次没给的项(留空、没通过校验)不删以前起草的;列已经不在表结构里的,模型各段都写出来时(covered 含 llm)
|
||||
照旧删掉。模型只写出一部分的不算完整起草过(covered 不含 llm)。
|
||||
- 模型留空的列(blank)按并入之后的目录再筛一遍:已经有中文名或含义的(以前起草的、库注释、起草进行中有人填的)
|
||||
不算留空。
|
||||
- 写入撞上别人刚改过(乐观锁)就重读重并,最多三次。
|
||||
"""
|
||||
cache = getattr(source, "schema_cache", None) or {}
|
||||
all_tables = cache.get("tables") or {}
|
||||
report = DraftReport()
|
||||
if tables:
|
||||
wanted = list(dict.fromkeys(str(t) for t in tables))
|
||||
result = TableDraftResult(table=table)
|
||||
structure = draft_structure(source, table)
|
||||
meta = ((getattr(source, "schema_cache", None) or {}).get("tables") or {}).get(table) or {}
|
||||
columns = [str(c["name"]) for c in meta.get("columns") or [] if isinstance(c, dict) and c.get("name") is not None]
|
||||
model_draft: TableDraft | None = None
|
||||
if modeled is not None:
|
||||
result.parts_total = modeled.parts_total
|
||||
result.model_error = modeled.error
|
||||
if modeled.items:
|
||||
result.model_items = modeled.items
|
||||
result.blank = list(getattr(modeled, "blank", None) or [])
|
||||
model_draft = TableDraft(notes=modeled.notes, covered=frozenset({"llm"}) if modeled.status == "ok"
|
||||
else frozenset())
|
||||
for _attempt in range(3):
|
||||
entry = await read_entry(session, source.id, table)
|
||||
version = entry.version if entry else 0
|
||||
merged, stats = merge_notes(entry.notes if entry else {}, structure.notes, covered=structure.covered, at=at,
|
||||
columns=columns)
|
||||
if model_draft is not None:
|
||||
merged, more = merge_notes(merged, model_draft.notes, covered=model_draft.covered, at=at, columns=columns)
|
||||
stats += more
|
||||
result.added, result.updated, result.removed = stats.added, stats.updated, stats.removed
|
||||
result.version = version
|
||||
if entry is None and not merged:
|
||||
break # 什么也没起草出来,不建空目录
|
||||
try:
|
||||
written = await write_entry(session, source.id, table, merged, if_version=version, actor=actor)
|
||||
except CatalogConflict:
|
||||
continue
|
||||
result.version = written.version
|
||||
break
|
||||
else:
|
||||
usage = await table_usage(session, source)
|
||||
order = {name: i for i, name in enumerate(all_tables)}
|
||||
wanted = sorted(all_tables, key=lambda n: (-usage.get(n, 0), order[n]))[:DRAFT_DEFAULT_TABLES]
|
||||
result.error = WRITE_CONFLICT_ERROR
|
||||
result.added = result.updated = result.removed = 0
|
||||
result.blank = []
|
||||
if result.blank:
|
||||
named = visible_notes(merged).get("columns") or {}
|
||||
result.blank = [c for c in result.blank if not any(f in (named.get(c) or {}) for f in ("label", "meaning"))]
|
||||
if result.error or (modeled is not None and modeled.status == "failed"):
|
||||
result.status = "failed"
|
||||
elif modeled is not None and modeled.status == "partial":
|
||||
result.status = "partial"
|
||||
return result
|
||||
|
||||
|
||||
def pick_tables(cache: Mapping[str, Any], wanted: Iterable[str]) -> tuple[list[str], list[str]]:
|
||||
"""要起草的表 → (表结构里的键,去重、按给的顺序;表结构里没有的原样名字)。"""
|
||||
picked: list[str] = []
|
||||
for name in wanted:
|
||||
unknown: list[str] = []
|
||||
for name in dict.fromkeys(str(t) for t in wanted):
|
||||
key = resolve_table_name(cache, name)
|
||||
if key is None:
|
||||
report.tables.append(TableDraftResult(table=name, error=f"表结构里没有 {name},请先重新探查结构"))
|
||||
unknown.append(name)
|
||||
elif key not in picked:
|
||||
picked.append(key)
|
||||
return picked, unknown
|
||||
|
||||
llm: dict[str, TableDraft | str] = {}
|
||||
|
||||
async def default_tables(session: AsyncSession, source: Any) -> list[str]:
|
||||
"""不指定表时起草哪些:按使用次数(table_usage)取前 DRAFT_DEFAULT_TABLES 张,次数相同按表结构里的顺序。"""
|
||||
all_tables = (getattr(source, "schema_cache", None) or {}).get("tables") or {}
|
||||
usage = await table_usage(session, source)
|
||||
order = {name: i for i, name in enumerate(all_tables)}
|
||||
return sorted(all_tables, key=lambda n: (-usage.get(n, 0), order[n]))[:DRAFT_DEFAULT_TABLES]
|
||||
|
||||
|
||||
async def draft_catalog(session: AsyncSession, source: Any, *, tables: list[str] | None = None,
|
||||
use_model: bool = False, model: Any = None, actor: str | None = None,
|
||||
guidance: str | None = None, note: str | None = None) -> DraftReport:
|
||||
"""起草这些表的目录并写入,同步完成(POST /catalog/draft;界面改用后台起草任务,见 data/catalog_jobs.py)。
|
||||
|
||||
- source:手工源的 DataSource,或上传源绑定当前快照的视图(table_versions.resolve_source);按 source.id 存。
|
||||
- tables 为空时按使用次数取前 DRAFT_DEFAULT_TABLES 张(default_tables)。
|
||||
- 每张表:数据库注释、外键约束、命名推断照做;use_model 时再加模型起草(data/catalog_draft.py:分段、按失败
|
||||
原因处理)。model 可注入(测试用假模型),不给就按设置解析(resolve_draft_model);模型用不了、某一段失败,
|
||||
都不影响其余来源照常写入。guidance 是数据源级的起草要求,note 是本次说明,和数据源说明一起交给模型。
|
||||
- 并入、写入见 write_draft。
|
||||
"""
|
||||
cache = getattr(source, "schema_cache", None) or {}
|
||||
report = DraftReport()
|
||||
wanted = list(tables) if tables else await default_tables(session, source)
|
||||
picked, unknown = pick_tables(cache, wanted)
|
||||
for name in unknown:
|
||||
report.tables.append(TableDraftResult(table=name, error=missing_table_error(name), status="skipped"))
|
||||
|
||||
outcomes: dict[str, Any] = {}
|
||||
if use_model and picked:
|
||||
try:
|
||||
if model is None:
|
||||
@@ -1840,41 +1943,17 @@ async def draft_catalog(session: AsyncSession, source: Any, *, tables: list[str]
|
||||
except CatalogModelUnavailable as e:
|
||||
report.model_error = str(e)
|
||||
else:
|
||||
from app.data import catalog_draft
|
||||
|
||||
report.model_used = True
|
||||
existing = await read_catalog(session, source.id)
|
||||
llm = await draft_with_model(model, source, picked, existing={t: e.notes for t, e in existing.items()})
|
||||
outcomes = await catalog_draft.run_model_draft(
|
||||
model, source, picked, existing={t: e.notes for t, e in existing.items()},
|
||||
background=catalog_draft.Background(guidance=guidance, note=note))
|
||||
|
||||
at = now_iso()
|
||||
for table in picked:
|
||||
result = TableDraftResult(table=table)
|
||||
structure = draft_structure(source, table)
|
||||
modeled = llm.get(table)
|
||||
if isinstance(modeled, str):
|
||||
result.model_error = modeled
|
||||
elif isinstance(modeled, TableDraft):
|
||||
result.model_items = len(_slots(modeled.notes))
|
||||
for _attempt in range(3):
|
||||
entry = await read_entry(session, source.id, table)
|
||||
version = entry.version if entry else 0
|
||||
merged, stats = merge_notes(entry.notes if entry else {}, structure.notes, covered=structure.covered,
|
||||
at=at)
|
||||
if isinstance(modeled, TableDraft):
|
||||
merged, more = merge_notes(merged, modeled.notes, covered=modeled.covered, at=at)
|
||||
stats += more
|
||||
result.added, result.updated, result.removed = stats.added, stats.updated, stats.removed
|
||||
result.version = version
|
||||
if entry is None and not merged:
|
||||
break # 什么也没起草出来,不建空目录
|
||||
try:
|
||||
written = await write_entry(session, source.id, table, merged, if_version=version, actor=actor)
|
||||
except CatalogConflict:
|
||||
continue
|
||||
result.version = written.version
|
||||
break
|
||||
else:
|
||||
result.error = "这张表的数据目录正被其他人修改,请稍后再起草"
|
||||
result.added = result.updated = result.removed = 0
|
||||
report.tables.append(result)
|
||||
report.tables.append(await write_draft(session, source, table, outcomes.get(table), at=at, actor=actor))
|
||||
return report
|
||||
|
||||
|
||||
@@ -2239,15 +2318,22 @@ def frozen_catalog(entries: Mapping[str, CatalogEntry], tables: Iterable[str]) -
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CARDINALITIES", "CARDINALITY_LABEL", "CATALOG_DRAFT_PROMPT", "CATALOG_DRAFT_PROMPT_SCHEMA",
|
||||
"CATALOG_DRAFT_SYSTEM", "COLUMN_FIELDS", "COLUMN_FIELD_LABEL", "COLUMN_MEASURES", "CatalogConflict",
|
||||
"CARDINALITIES", "CARDINALITY_LABEL", "CATALOG_DRAFT_BACKGROUND_PROMPT", "CATALOG_DRAFT_PROMPT",
|
||||
"CATALOG_DRAFT_PROMPT_LABELS", "CATALOG_DRAFT_PROMPT_SCHEMA", "CATALOG_DRAFT_SYSTEM", "CATALOG_DRAFT_TEXT_PROMPT",
|
||||
"COLUMN_FIELDS", "COLUMN_FIELD_LABEL", "COLUMN_MEASURES", "CatalogConflict",
|
||||
"CatalogEntry", "CatalogInvalid", "CatalogModelUnavailable", "CatalogPathError", "DRAFT_DEFAULT_TABLES",
|
||||
"DraftReport", "INFERRED_MARK", "ITEM_SOURCES", "ITEM_STATUSES", "JoinEdge", "KIND_LABEL", "MEASURE_LABEL",
|
||||
"MODEL_EMPTY_REASON", "MergeStats", "NEW_RELATION_PATH", "PATCH_MAX_CHANGES", "PROFILE_NOTE_PREFIX",
|
||||
"PROFILE_VERIFY_COVERAGE",
|
||||
"DraftModel", "DraftReport", "INFERRED_MARK", "ITEM_SOURCES", "ITEM_STATUSES", "JoinEdge", "KIND_LABEL",
|
||||
"MEASURE_LABEL", "MODEL_BATCH_COLUMNS", "MODEL_BATCH_TABLES", "MODEL_CONCURRENCY", "MODEL_EMPTY_REASON",
|
||||
"MODEL_FALLBACK_MAX_TOKENS", "MODEL_MAX_TOKENS", "MODEL_MIN_PART_COLUMNS", "MODEL_NOTICE_OFF", "MODEL_NOTICE_ON",
|
||||
"MODEL_NOTICE_WINDOW", "MODEL_PART_MAX_CALLS", "MODEL_RETRIES", "MODEL_RETRY_DELAYS_S", "MODEL_SHRINK_AFTER",
|
||||
"MODEL_SPLIT_DEPTH", "MODEL_SPLIT_MIN_COLUMNS", "MODEL_TIMEOUT_S", "MergeStats", "NEW_RELATION_PATH",
|
||||
"PATCH_MAX_CHANGES",
|
||||
"PROFILE_NOTE_PREFIX", "PROFILE_VERIFY_COVERAGE", "WRITE_CONFLICT_ERROR", "default_tables",
|
||||
"missing_table_error", "pick_tables", "write_draft",
|
||||
"PatchChange", "PatchPlan", "QUERY_SNAPSHOT_KIND", "REVIEW_ACTIONS", "TABLE_FIELDS", "TABLE_FIELD_LABEL",
|
||||
"TABLE_KINDS", "TableDraft", "TableDraftResult", "UI_KIND_LABEL", "UI_MEASURE_LABEL", "apply_human_edit",
|
||||
"apply_patch", "codes_complete", "describe_slot", "draft_catalog", "draft_structure", "draft_with_model",
|
||||
"apply_patch", "codes_complete", "comment_role", "describe_slot", "draft_catalog", "draft_structure",
|
||||
"draft_with_model",
|
||||
"fk_relations", "frozen_catalog", "infer_name_relations", "initial_status", "is_date_column", "is_profile_note",
|
||||
"join_paths",
|
||||
"make_item", "merge_notes", "name_words", "now_iso", "parse_path", "plan_patch", "profile_relation_holds",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,667 @@
|
||||
"""数据目录的后台起草任务,以及数据源级的起草要求。
|
||||
|
||||
以前起草是一次同步长请求(POST /catalog/draft,界面每 12 张表一个请求):一个请求出错整个对话框就停,关掉对话框
|
||||
之后没有任何记录哪些表失败、为什么。现在:
|
||||
|
||||
- **建任务**(create_job):同一个数据源同时只有一个排队或进行中的任务(DraftBusy;部分唯一索引兜底)。不指定表时
|
||||
按使用次数取前 20 张;表结构里没有的表照样建进任务、状态 skipped。起草要求(数据源级)和本次说明随任务记下快照。
|
||||
- **在后台起草**(manager.start):进程内的 asyncio 任务,引擎是 data/catalog_draft.py(分段、按失败原因处理)。
|
||||
每张表一结束就并入目录(catalog.write_draft,规则和同步起草一样:人工确认、驳回过的不动,模型这次没给的项不删
|
||||
以前起草的,乐观锁冲突重读重并);逐表的状态、到第几段、计数、模型留空的列,任务的用量(含超时、没写出来的调用
|
||||
次数)、当前的列数上限、提醒随时落库,接口读的是库里的状态。
|
||||
- **停止**(cancel):排队的直接 cancelled;进行中的停掉正在进行的模型调用,没开始和没写完的表记 cancelled,已经写进
|
||||
目录的不回滚,任务最后是 cancelled。
|
||||
- **重试**(retry_tables + create_job):新任务,只含原任务里 failed、partial、cancelled 的表;用不用模型、本次说明沿用,
|
||||
起草要求取数据源当前的。
|
||||
- **服务重启**:启动时(startup)把排队、进行中的任务标成 interrupted,没结束的表记 cancelled、写明「服务重启,起草
|
||||
中断」。不自动续跑:复制出来的数据目录(检查、验收)启动时不能自己去调模型;界面让人点「重试」续上。关停时
|
||||
(manager.shutdown)停掉进行中的任务,同样标成 interrupted。
|
||||
|
||||
任务的库写入一律经 _JobRun._write:一条一条来,shield 起来——停止任务时不会打断写到一半的库操作,收尾前等它们写完。
|
||||
|
||||
**起草要求**存在数据源 options 的 catalog_draft_guidance 里(上传表格的源存在它自己的 DataSource 行上),一段文字,
|
||||
去首尾空白、最多 GUIDANCE_MAX 字,空串等于没有。只经 PUT /catalog/guidance 改:数据源接口不显示它、整份回写 options
|
||||
时留着(api/datasources.py),拼连接串时摘掉(data/engine.py)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.data import catalog, catalog_draft
|
||||
from app.data.engine import CATALOG_DRAFT_GUIDANCE_OPTION
|
||||
from app.db.base import SessionLocal, new_id, utcnow
|
||||
from app.db.models import CatalogDraftJob, CatalogDraftTask, DataSource
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: 任务还没结束的状态(同一个源只能有一个)、结束了的状态
|
||||
ACTIVE = ("queued", "running")
|
||||
FINISHED = ("succeeded", "partial", "failed", "cancelled", "interrupted")
|
||||
#: 逐表的状态,以及其中算「已结束」的、重试时要带上的
|
||||
TASK_STATUSES = ("pending", "running", "ok", "partial", "failed", "skipped", "cancelled")
|
||||
TASK_DONE = ("ok", "partial", "failed", "skipped", "cancelled")
|
||||
RETRYABLE = ("failed", "partial", "cancelled")
|
||||
_OPEN = ("pending", "running")
|
||||
|
||||
#: 一个任务最多几张表;起草要求、本次说明最多几个字
|
||||
MAX_TABLES = 500
|
||||
GUIDANCE_MAX = 2000
|
||||
NOTE_MAX = 1000
|
||||
#: 目录清单上「这张表最近一次起草」只看最近这么多个任务
|
||||
LAST_DRAFT_JOBS = 100
|
||||
#: 逐表最多记几个模型留空的列名
|
||||
BLANK_SAMPLE = 5
|
||||
#: 停止进行中的任务时最多等它收尾多久(秒);关停服务时另给(shutdown 的 grace)
|
||||
STOP_GRACE_S = 10.0
|
||||
|
||||
INTERRUPTED_ERROR = "服务重启,起草中断"
|
||||
UNFINISHED_ERROR = "起草结束时这张表还没有写完,请重试这张表"
|
||||
WRITE_ERROR = "写入数据目录时出错:{line}"
|
||||
JOB_ERROR = "起草任务出错:{line}"
|
||||
SOURCE_GONE = "数据源不存在,可能已被删除"
|
||||
|
||||
|
||||
class DraftBusy(Exception):
|
||||
"""这个数据源已经有排队或进行中的起草任务。message 给人看。"""
|
||||
|
||||
|
||||
class DraftFinished(Exception):
|
||||
"""要停止的任务已经结束了。"""
|
||||
|
||||
|
||||
class NothingToRetry(Exception):
|
||||
"""原任务里没有需要重试的表。"""
|
||||
|
||||
|
||||
class TextTooLong(ValueError):
|
||||
"""起草要求、本次说明超长。message 给人看。"""
|
||||
|
||||
|
||||
class _JobFailed(Exception):
|
||||
"""任务整体出错(数据源没了、快照没了、模型用不了):message 就是任务的 error。"""
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 起草要求、本次说明
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
def clean_text(value: Any, limit: int, label: str) -> str | None:
|
||||
"""去首尾空白;空串(和不是字符串的)等于没有;超过 limit 个字抛 TextTooLong。"""
|
||||
text = value.strip() if isinstance(value, str) else ""
|
||||
if len(text) > limit:
|
||||
raise TextTooLong(f"{label}最多 {limit} 字,当前 {len(text)} 字,请删减后再保存")
|
||||
return text or None
|
||||
|
||||
|
||||
def guidance_of(row: Any) -> str | None:
|
||||
"""数据源级的起草要求(没有是 None)。"""
|
||||
raw = (getattr(row, "options", None) or {}).get(CATALOG_DRAFT_GUIDANCE_OPTION)
|
||||
return raw.strip() or None if isinstance(raw, str) else None
|
||||
|
||||
|
||||
async def save_guidance(session: AsyncSession, row: DataSource, value: Any) -> str | None:
|
||||
"""存起草要求,返回存下的(清除了是 None)。超长抛 TextTooLong,什么都不改。"""
|
||||
text = clean_text(value, GUIDANCE_MAX, "起草要求")
|
||||
options = {k: v for k, v in (row.options or {}).items() if k != CATALOG_DRAFT_GUIDANCE_OPTION}
|
||||
if text:
|
||||
options[CATALOG_DRAFT_GUIDANCE_OPTION] = text
|
||||
row.options = options
|
||||
await session.commit()
|
||||
return text
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 读任务
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
def _iso(value: Any) -> str | None:
|
||||
return value.isoformat() if value is not None else None
|
||||
|
||||
|
||||
async def active_job(session: AsyncSession, source_id: str) -> CatalogDraftJob | None:
|
||||
"""排队或进行中的那个(同一个源最多一个)。"""
|
||||
return (await session.execute(
|
||||
select(CatalogDraftJob).where(CatalogDraftJob.source_id == source_id, CatalogDraftJob.status.in_(ACTIVE))
|
||||
.order_by(CatalogDraftJob.created_at.desc()).limit(1)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
|
||||
async def latest_job(session: AsyncSession, source_id: str) -> CatalogDraftJob | None:
|
||||
"""最近建的一个(任何状态)。"""
|
||||
return (await session.execute(
|
||||
select(CatalogDraftJob).where(CatalogDraftJob.source_id == source_id)
|
||||
.order_by(CatalogDraftJob.created_at.desc()).limit(1)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
|
||||
async def load(session: AsyncSession, source_id: str, job_id: str) -> CatalogDraftJob | None:
|
||||
"""这个源的这个任务;不存在、不属于这个源都是 None。"""
|
||||
job = await session.get(CatalogDraftJob, job_id)
|
||||
return job if job is not None and job.source_id == source_id else None
|
||||
|
||||
|
||||
async def tasks_of(session: AsyncSession, job_id: str) -> list[CatalogDraftTask]:
|
||||
return list((await session.execute(
|
||||
select(CatalogDraftTask).where(CatalogDraftTask.job_id == job_id).order_by(CatalogDraftTask.seq)
|
||||
)).scalars())
|
||||
|
||||
|
||||
def task_dict(task: CatalogDraftTask) -> dict[str, Any]:
|
||||
return {
|
||||
"table_name": task.table_name,
|
||||
"status": task.status,
|
||||
"columns": task.column_count,
|
||||
"parts_total": task.parts_total,
|
||||
"parts_done": task.parts_done,
|
||||
"added": task.added,
|
||||
"updated": task.updated,
|
||||
"removed": task.removed,
|
||||
"version": task.version,
|
||||
"model_items": task.model_items,
|
||||
# 模型拿不准、留空了的列(中文名和含义都没给):几列、最多 5 个列名(按表结构的顺序)。表的状态不因此变
|
||||
"blank_columns": task.blank_columns or 0,
|
||||
"blank_sample": list(task.blank_sample or []),
|
||||
"error": task.error,
|
||||
"model_error": task.model_error,
|
||||
"started_at": _iso(task.started_at),
|
||||
"finished_at": _iso(task.finished_at),
|
||||
}
|
||||
|
||||
|
||||
def _counts(statuses: list[str]) -> dict[str, int]:
|
||||
counts = dict.fromkeys(TASK_STATUSES, 0)
|
||||
for status in statuses:
|
||||
counts[status] = counts.get(status, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
def job_dict(job: CatalogDraftJob, tasks: list[CatalogDraftTask]) -> dict[str, Any]:
|
||||
"""接口返回的任务(CatalogDraftJob):逐表的状态按起草顺序;计数、合计都按逐表的算。"""
|
||||
counts = _counts([t.status for t in tasks])
|
||||
return {
|
||||
"id": job.id,
|
||||
"source_id": job.source_id,
|
||||
"status": job.status,
|
||||
"use_model": bool(job.use_model),
|
||||
"model": job.model,
|
||||
"guidance": job.guidance,
|
||||
"note": job.note,
|
||||
"created_at": _iso(job.created_at),
|
||||
"started_at": _iso(job.started_at),
|
||||
"finished_at": _iso(job.finished_at),
|
||||
"created_by": job.created_by,
|
||||
"retry_of": job.retry_of,
|
||||
"total": len(tasks),
|
||||
"done": sum(counts.get(s, 0) for s in TASK_DONE),
|
||||
"counts": counts,
|
||||
"added": sum(t.added or 0 for t in tasks),
|
||||
"updated": sum(t.updated or 0 for t in tasks),
|
||||
"removed": sum(t.removed or 0 for t in tasks),
|
||||
"blank_columns": sum(t.blank_columns or 0 for t in tasks),
|
||||
# 超时的调用拿不到用量,token 按 0 算(网关那边可能已经生成了);failed_calls 含额度用完、只给空壳、超时、出错
|
||||
"usage": {"calls": job.calls or 0, "input_tokens": job.input_tokens or 0,
|
||||
"output_tokens": job.output_tokens or 0, "timeouts": job.timeouts or 0,
|
||||
"failed_calls": job.failed_calls or 0},
|
||||
# 新开始的段每段最多几列(不用模型、还没开始时为 null);提醒:最近几次调用多数没写出来时的一句话,没有为 null
|
||||
"part_columns": job.part_columns,
|
||||
"notice": job.notice,
|
||||
"error": job.error,
|
||||
"tasks": [task_dict(t) for t in tasks],
|
||||
}
|
||||
|
||||
|
||||
async def job_out(session: AsyncSession, job: CatalogDraftJob | None) -> dict[str, Any] | None:
|
||||
return None if job is None else job_dict(job, await tasks_of(session, job.id))
|
||||
|
||||
|
||||
async def job_brief(session: AsyncSession, job: CatalogDraftJob | None) -> dict[str, Any] | None:
|
||||
"""目录清单顶上的进度:{id, status, total, done}。"""
|
||||
if job is None:
|
||||
return None
|
||||
rows = (await session.execute(
|
||||
select(CatalogDraftTask.status, func.count()).where(CatalogDraftTask.job_id == job.id)
|
||||
.group_by(CatalogDraftTask.status)
|
||||
)).all()
|
||||
counts = {status: n for status, n in rows}
|
||||
return {"id": job.id, "status": job.status, "total": sum(counts.values()),
|
||||
"done": sum(counts.get(s, 0) for s in TASK_DONE)}
|
||||
|
||||
|
||||
async def last_drafts(session: AsyncSession, source_id: str) -> dict[str, dict[str, Any]]:
|
||||
"""每张表最近一次起草任务里的那一项:{表名: {status, job_id, finished_at, error, model_error, blank_columns}}。
|
||||
任务还在进行,就是进行中的状态。只看最近 LAST_DRAFT_JOBS 个任务;每张表取哪一项在库里挑好(清单页起草时会反复取)。"""
|
||||
recent = (select(CatalogDraftJob.id).where(CatalogDraftJob.source_id == source_id)
|
||||
.order_by(CatalogDraftJob.created_at.desc()).limit(LAST_DRAFT_JOBS)).scalar_subquery()
|
||||
rank = func.row_number().over(partition_by=CatalogDraftTask.table_name,
|
||||
order_by=(CatalogDraftJob.created_at.desc(), CatalogDraftTask.seq)).label("rank")
|
||||
inner = (select(CatalogDraftTask.table_name, CatalogDraftTask.status, CatalogDraftTask.job_id,
|
||||
CatalogDraftTask.finished_at, CatalogDraftTask.error, CatalogDraftTask.model_error,
|
||||
CatalogDraftTask.blank_columns, rank)
|
||||
.join(CatalogDraftJob, CatalogDraftJob.id == CatalogDraftTask.job_id)
|
||||
.where(CatalogDraftJob.id.in_(recent))).subquery()
|
||||
rows = (await session.execute(select(inner).where(inner.c.rank == 1))).mappings()
|
||||
return {row["table_name"]: {"status": row["status"], "job_id": row["job_id"],
|
||||
"finished_at": _iso(row["finished_at"]), "error": row["error"],
|
||||
"model_error": row["model_error"], "blank_columns": row["blank_columns"] or 0}
|
||||
for row in rows}
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 建任务、重试、停止
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
def busy_message(row: Any) -> str:
|
||||
return f"数据源「{getattr(row, 'name', '')}」正在起草数据目录,请等这次起草结束(或先停止它)再开始新的起草"
|
||||
|
||||
|
||||
async def ensure_idle(session: AsyncSession, row: Any) -> None:
|
||||
"""这个源没有排队或进行中的任务;有就抛 DraftBusy。"""
|
||||
if await active_job(session, row.id) is not None:
|
||||
raise DraftBusy(busy_message(row))
|
||||
|
||||
|
||||
async def check_model(session: AsyncSession) -> None:
|
||||
"""起草模型能不能用:不能用抛 catalog.CatalogModelUnavailable(只建模型对象,不发请求)。"""
|
||||
await catalog.resolve_draft_model(session)
|
||||
|
||||
|
||||
async def create_job(session: AsyncSession, row: Any, source: Any, *, tables: list[str] | None, use_model: bool,
|
||||
note: str | None, actor: str | None,
|
||||
retry_of: str | None = None) -> tuple[CatalogDraftJob, list[CatalogDraftTask]]:
|
||||
"""建一个排队的任务(还没开始,调用方接着 manager.start)。row 是数据源记录(起草要求取它的),source 是读表结构
|
||||
用的源(上传源是绑定当前快照的视图)。有排队或进行中的任务抛 DraftBusy。"""
|
||||
await ensure_idle(session, row)
|
||||
cache = getattr(source, "schema_cache", None) or {}
|
||||
all_tables = cache.get("tables") or {}
|
||||
wanted = list(tables) if tables else await catalog.default_tables(session, source)
|
||||
job = CatalogDraftJob(id=new_id(), source_id=row.id, status="queued", use_model=bool(use_model),
|
||||
guidance=guidance_of(row), note=note, created_by=actor, retry_of=retry_of)
|
||||
tasks: list[CatalogDraftTask] = []
|
||||
seen: set[str] = set()
|
||||
now = utcnow()
|
||||
for name in dict.fromkeys(str(t) for t in wanted):
|
||||
key = catalog.resolve_table_name(cache, name)
|
||||
if key is None:
|
||||
tasks.append(CatalogDraftTask(job_id=job.id, seq=len(tasks), table_name=name, status="skipped",
|
||||
error=catalog.missing_table_error(name), finished_at=now))
|
||||
continue
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
columns = len((all_tables.get(key) or {}).get("columns") or [])
|
||||
tasks.append(CatalogDraftTask(job_id=job.id, seq=len(tasks), table_name=key, status="pending",
|
||||
column_count=columns,
|
||||
parts_total=catalog_draft.parts_for(columns) if use_model else None))
|
||||
session.add(job)
|
||||
try:
|
||||
await session.flush()
|
||||
session.add_all(tasks)
|
||||
await session.commit()
|
||||
except IntegrityError as e:
|
||||
# 部分唯一索引:另一个请求同时给这个源建了任务
|
||||
await session.rollback()
|
||||
raise DraftBusy(busy_message(row)) from e
|
||||
return job, tasks
|
||||
|
||||
|
||||
async def retry_tables(session: AsyncSession, job: CatalogDraftJob) -> list[str]:
|
||||
"""重试要带上的表:原任务里 failed、partial、cancelled 的,按原来的顺序。没有抛 NothingToRetry。"""
|
||||
names = [t.table_name for t in await tasks_of(session, job.id) if t.status in RETRYABLE]
|
||||
if not names:
|
||||
raise NothingToRetry("这次起草里没有失败、只写出一部分或停止了的表,不需要重试")
|
||||
return names
|
||||
|
||||
|
||||
async def cancel(session: AsyncSession, job: CatalogDraftJob) -> None:
|
||||
"""停止一个任务。排队的直接 cancelled;进行中的交给 manager 停(停掉模型调用、没写完的表记 cancelled)。
|
||||
已经结束的抛 DraftFinished。"""
|
||||
if job.status in FINISHED:
|
||||
raise DraftFinished("这次起草已经结束了,不需要停止")
|
||||
if job.status == "queued":
|
||||
now = utcnow()
|
||||
claimed = await session.execute(
|
||||
update(CatalogDraftJob).where(CatalogDraftJob.id == job.id, CatalogDraftJob.status == "queued")
|
||||
.values(status="cancelled", finished_at=now).execution_options(synchronize_session=False))
|
||||
if claimed.rowcount == 1:
|
||||
await session.execute(
|
||||
update(CatalogDraftTask).where(CatalogDraftTask.job_id == job.id, CatalogDraftTask.status.in_(_OPEN))
|
||||
.values(status="cancelled", finished_at=now).execution_options(synchronize_session=False))
|
||||
await session.commit()
|
||||
return
|
||||
await session.rollback() # 刚被后台认领、开始了:按进行中的停
|
||||
if await manager.stop(job.id, "user", grace=STOP_GRACE_S):
|
||||
return
|
||||
# 库里说在进行,这个进程里却没有在跑它(记录对不上):直接记停止
|
||||
await _close(session, job.id, "cancelled", None, "cancelled", None)
|
||||
|
||||
|
||||
async def _close(session: AsyncSession, job_id: str, job_status: str, job_error: str | None, task_status: str,
|
||||
task_error: str | None) -> None:
|
||||
"""收尾:还没结束的表记 task_status,任务(还没结束的话)记 job_status。"""
|
||||
now = utcnow()
|
||||
await session.execute(
|
||||
update(CatalogDraftTask).where(CatalogDraftTask.job_id == job_id, CatalogDraftTask.status.in_(_OPEN))
|
||||
.values(status=task_status, error=task_error, finished_at=now).execution_options(synchronize_session=False))
|
||||
await session.execute(
|
||||
update(CatalogDraftJob).where(CatalogDraftJob.id == job_id, CatalogDraftJob.status.in_(ACTIVE))
|
||||
.values(status=job_status, error=job_error, finished_at=now).execution_options(synchronize_session=False))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def startup() -> int:
|
||||
"""服务启动时:排队、进行中的任务标成 interrupted,没结束的表记 cancelled、写明服务重启。不自动续跑。返回标了几个。"""
|
||||
async with SessionLocal() as session:
|
||||
ids = [job_id for job_id in (await session.execute(
|
||||
select(CatalogDraftJob.id).where(CatalogDraftJob.status.in_(ACTIVE)))).scalars()
|
||||
if not manager.running(job_id)]
|
||||
for job_id in ids:
|
||||
await _close(session, job_id, "interrupted", INTERRUPTED_ERROR, "cancelled", INTERRUPTED_ERROR)
|
||||
if ids:
|
||||
logger.warning("服务重启:%d 个数据目录起草任务没有做完,已标为中断,可在数据目录页重试", len(ids))
|
||||
return len(ids)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 在后台起草
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
class _JobRun:
|
||||
"""一个任务在后台的一次执行。"""
|
||||
|
||||
def __init__(self, job_id: str) -> None:
|
||||
self.job_id = job_id
|
||||
self.task: asyncio.Task[None] | None = None
|
||||
#: 为什么停:user 使用者点了停止,shutdown 服务关停(标中断)
|
||||
self.stop_reason: str | None = None
|
||||
self._writes: set[asyncio.Task[Any]] = set()
|
||||
self._lock = asyncio.Lock()
|
||||
self._started: set[str] = set()
|
||||
self.source: Any = None
|
||||
self.actor: str | None = None
|
||||
self.at = ""
|
||||
|
||||
# ---------------------------------------------------------------- 库写入
|
||||
|
||||
async def _write(self, fn: Callable[..., Awaitable[Any]], *args: Any) -> Any:
|
||||
"""一条库写入:同一个任务的写入一条一条来;shield 起来,停止任务时不会打断写到一半的库操作。"""
|
||||
async def go() -> Any:
|
||||
async with self._lock:
|
||||
async with SessionLocal() as session:
|
||||
return await fn(session, *args)
|
||||
|
||||
task = asyncio.ensure_future(go())
|
||||
self._writes.add(task)
|
||||
task.add_done_callback(self._writes.discard)
|
||||
return await asyncio.shield(task)
|
||||
|
||||
async def _settle(self) -> None:
|
||||
"""等手上的库写入都写完(停止、出错收尾之前)。"""
|
||||
while self._writes:
|
||||
await asyncio.gather(*list(self._writes), return_exceptions=True)
|
||||
|
||||
# ---------------------------------------------------------------- 主流程
|
||||
|
||||
async def run(self) -> None:
|
||||
try:
|
||||
await self._run()
|
||||
except asyncio.CancelledError:
|
||||
current = asyncio.current_task()
|
||||
if current is not None:
|
||||
current.uncancel()
|
||||
if self.stop_reason == "user":
|
||||
await self._end("cancelled", None, "cancelled", None)
|
||||
else:
|
||||
await self._end("interrupted", INTERRUPTED_ERROR, "cancelled", INTERRUPTED_ERROR)
|
||||
except _JobFailed as e:
|
||||
await self._end("failed", str(e), "failed", str(e))
|
||||
except Exception as e: # noqa: BLE001 - 任务整体出错:照实记下,不留在「起草中」
|
||||
from app.core.errors import describe_exception, first_line
|
||||
|
||||
logger.exception("数据目录起草任务 %s 出错", self.job_id)
|
||||
reason = JOB_ERROR.format(line=first_line(e) or describe_exception(e))
|
||||
await self._end("failed", reason, "failed", reason)
|
||||
|
||||
async def _end(self, job_status: str, job_error: str | None, task_status: str, task_error: str | None) -> None:
|
||||
"""停止、出错时收尾:等手上的库写入写完,再把没结束的表和任务记下来。收尾本身写不进去只记日志(下次启动时
|
||||
startup 会把它标成中断)。"""
|
||||
try:
|
||||
await self._settle()
|
||||
await self._write(_close, self.job_id, job_status, job_error, task_status, task_error)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("数据目录起草任务 %s 收尾时写库出错", self.job_id)
|
||||
|
||||
async def _run(self) -> None:
|
||||
if not await self._write(self._claim):
|
||||
return # 开始之前就被停止了
|
||||
async with SessionLocal() as session:
|
||||
job = await session.get(CatalogDraftJob, self.job_id)
|
||||
row = await session.get(DataSource, job.source_id) if job is not None else None
|
||||
if job is None or row is None:
|
||||
raise _JobFailed(SOURCE_GONE)
|
||||
from app.data import table_versions
|
||||
|
||||
try:
|
||||
source = await table_versions.resolve_source(session, row)
|
||||
except table_versions.SnapshotMissing as e:
|
||||
raise _JobFailed(str(e)) from e
|
||||
pending = [t.table_name for t in await tasks_of(session, job.id) if t.status == "pending"]
|
||||
use_model, background = bool(job.use_model), catalog_draft.Background(guidance=job.guidance, note=job.note)
|
||||
self.actor = job.created_by
|
||||
model: Any = None
|
||||
existing: dict[str, Any] = {}
|
||||
if use_model and pending:
|
||||
try:
|
||||
model, model_id = await catalog.resolve_draft_model(session)
|
||||
except catalog.CatalogModelUnavailable as e:
|
||||
raise _JobFailed(str(e)) from e
|
||||
existing = {t: e.notes for t, e in (await catalog.read_catalog(session, source.id)).items()}
|
||||
self.source, self.at = source, catalog.now_iso()
|
||||
all_tables = (getattr(source, "schema_cache", None) or {}).get("tables") or {}
|
||||
names = [n for n in pending if isinstance(all_tables.get(n), dict)]
|
||||
gone = [n for n in pending if n not in names]
|
||||
if gone:
|
||||
# 建任务之后表结构变了(重新探查过):这几张表跳过
|
||||
await self._write(self._skip, gone)
|
||||
if use_model and names:
|
||||
await self._write(self._set_model, model_id)
|
||||
hooks = catalog_draft.Hooks(call_started=self._call_started, call_done=self._call_done,
|
||||
part_done=self._part_done, table_done=self._table_done)
|
||||
await catalog_draft.run_model_draft(model, source, names, existing=existing, background=background,
|
||||
hooks=hooks)
|
||||
else:
|
||||
for name in names:
|
||||
await self._call_started([name])
|
||||
await self._table_done(name, None)
|
||||
await self._settle()
|
||||
await self._write(self._finish)
|
||||
|
||||
# ---------------------------------------------------------------- 进度回调(引擎调)
|
||||
|
||||
async def _call_started(self, tables: list[str]) -> None:
|
||||
fresh = [t for t in dict.fromkeys(tables) if t not in self._started]
|
||||
if fresh:
|
||||
self._started.update(fresh)
|
||||
await self._write(self._mark_running, fresh)
|
||||
|
||||
async def _call_done(self, report: catalog_draft.CallReport) -> None:
|
||||
await self._write(self._add_call, report)
|
||||
|
||||
async def _part_done(self, table: str) -> None:
|
||||
await self._write(self._bump_part, table)
|
||||
|
||||
async def _table_done(self, table: str, outcome: catalog_draft.ModelOutcome | None) -> None:
|
||||
await self._write(self._write_table, table, outcome)
|
||||
|
||||
# ---------------------------------------------------------------- 库操作
|
||||
|
||||
def _task(self, *where: Any) -> Any:
|
||||
return update(CatalogDraftTask).where(CatalogDraftTask.job_id == self.job_id, *where) \
|
||||
.execution_options(synchronize_session=False)
|
||||
|
||||
async def _claim(self, session: AsyncSession) -> bool:
|
||||
result = await session.execute(
|
||||
update(CatalogDraftJob).where(CatalogDraftJob.id == self.job_id, CatalogDraftJob.status == "queued")
|
||||
.values(status="running", started_at=utcnow()).execution_options(synchronize_session=False))
|
||||
await session.commit()
|
||||
return result.rowcount == 1
|
||||
|
||||
async def _set_model(self, session: AsyncSession, model_id: str) -> None:
|
||||
await session.execute(update(CatalogDraftJob).where(CatalogDraftJob.id == self.job_id)
|
||||
.values(model=model_id or None, part_columns=max(1, int(catalog.MODEL_BATCH_COLUMNS)))
|
||||
.execution_options(synchronize_session=False))
|
||||
await session.commit()
|
||||
|
||||
async def _skip(self, session: AsyncSession, names: list[str]) -> None:
|
||||
now = utcnow()
|
||||
for name in names:
|
||||
await session.execute(self._task(CatalogDraftTask.table_name == name, CatalogDraftTask.status == "pending")
|
||||
.values(status="skipped", error=catalog.missing_table_error(name), finished_at=now))
|
||||
await session.commit()
|
||||
|
||||
async def _mark_running(self, session: AsyncSession, names: list[str]) -> None:
|
||||
await session.execute(self._task(CatalogDraftTask.table_name.in_(names), CatalogDraftTask.status == "pending")
|
||||
.values(status="running", started_at=utcnow()))
|
||||
await session.commit()
|
||||
|
||||
async def _add_call(self, session: AsyncSession, report: catalog_draft.CallReport) -> None:
|
||||
"""一次调用:用量、超时、没写出来的次数累加,当前的列数上限和提醒照这次调用之后的写(按调用的先后写进来)。"""
|
||||
await session.execute(
|
||||
update(CatalogDraftJob).where(CatalogDraftJob.id == self.job_id)
|
||||
.values(calls=CatalogDraftJob.calls + 1,
|
||||
input_tokens=CatalogDraftJob.input_tokens + int(report.input_tokens),
|
||||
output_tokens=CatalogDraftJob.output_tokens + int(report.output_tokens),
|
||||
timeouts=func.coalesce(CatalogDraftJob.timeouts, 0) + int(report.timeout),
|
||||
failed_calls=func.coalesce(CatalogDraftJob.failed_calls, 0) + int(report.failure is not None),
|
||||
part_columns=report.part_columns, notice=report.notice)
|
||||
.execution_options(synchronize_session=False))
|
||||
await session.commit()
|
||||
|
||||
async def _bump_part(self, session: AsyncSession, table: str) -> None:
|
||||
await session.execute(self._task(CatalogDraftTask.table_name == table)
|
||||
.values(parts_done=CatalogDraftTask.parts_done + 1))
|
||||
await session.commit()
|
||||
|
||||
async def _write_table(self, session: AsyncSession, table: str,
|
||||
outcome: catalog_draft.ModelOutcome | None) -> None:
|
||||
"""一张表结束:并入目录、写入,记下这张表的结果。"""
|
||||
try:
|
||||
result = await catalog.write_draft(session, self.source, table, outcome, at=self.at, actor=self.actor)
|
||||
except Exception as e: # noqa: BLE001 - 这一张写不进去不影响别的表
|
||||
from app.core.errors import describe_exception, first_line
|
||||
|
||||
logger.exception("起草任务 %s 写入表 %s 的数据目录时出错", self.job_id, table)
|
||||
await session.rollback()
|
||||
result = catalog.TableDraftResult(table=table, status="failed",
|
||||
error=WRITE_ERROR.format(line=first_line(e) or describe_exception(e)),
|
||||
model_error=outcome.error if outcome is not None else None)
|
||||
values: dict[str, Any] = {
|
||||
"status": result.status, "added": result.added, "updated": result.updated, "removed": result.removed,
|
||||
"version": result.version, "model_items": result.model_items, "error": result.error,
|
||||
"model_error": result.model_error, "finished_at": utcnow(),
|
||||
"blank_columns": len(result.blank), "blank_sample": result.blank[:BLANK_SAMPLE] or None,
|
||||
}
|
||||
if outcome is not None:
|
||||
values["parts_done"] = outcome.parts_done
|
||||
await session.execute(self._task(CatalogDraftTask.table_name == table, CatalogDraftTask.status.in_(_OPEN))
|
||||
.values(**values))
|
||||
await session.commit()
|
||||
|
||||
async def _finish(self, session: AsyncSession) -> None:
|
||||
"""起草完:万一还有没收尾的表(进度回调写库失败之类)记失败;有 failed、partial、cancelled 的表是 partial,
|
||||
否则 succeeded。"""
|
||||
now = utcnow()
|
||||
await session.execute(self._task(CatalogDraftTask.status.in_(_OPEN))
|
||||
.values(status="failed", error=UNFINISHED_ERROR, finished_at=now))
|
||||
statuses = list((await session.execute(
|
||||
select(CatalogDraftTask.status).where(CatalogDraftTask.job_id == self.job_id))).scalars())
|
||||
status = "partial" if any(s in RETRYABLE for s in statuses) else "succeeded"
|
||||
await session.execute(
|
||||
update(CatalogDraftJob).where(CatalogDraftJob.id == self.job_id, CatalogDraftJob.status == "running")
|
||||
.values(status=status, finished_at=now).execution_options(synchronize_session=False))
|
||||
await session.commit()
|
||||
|
||||
|
||||
class DraftJobs:
|
||||
"""进程内的起草任务(照 app/scheduler 的写法:一个 asyncio 任务一件事,停的时候给宽限)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._runs: dict[str, _JobRun] = {}
|
||||
|
||||
def _alive(self, job_id: str) -> _JobRun | None:
|
||||
run = self._runs.get(job_id)
|
||||
if run is None or run.task is None or run.task.done():
|
||||
return None
|
||||
try:
|
||||
if run.task.get_loop() is not asyncio.get_running_loop():
|
||||
self._runs.pop(job_id, None) # 别的事件循环留下的(测试每个用例一个循环)
|
||||
return None
|
||||
except RuntimeError:
|
||||
return None
|
||||
return run
|
||||
|
||||
def start(self, job_id: str) -> None:
|
||||
"""开始在后台起草这个(排队中的)任务。"""
|
||||
run = _JobRun(job_id)
|
||||
run.task = asyncio.get_running_loop().create_task(run.run(), name=f"catalog-draft-{job_id}")
|
||||
self._runs[job_id] = run
|
||||
|
||||
def forget(_: asyncio.Task[None]) -> None:
|
||||
if self._runs.get(job_id) is run:
|
||||
self._runs.pop(job_id, None)
|
||||
|
||||
run.task.add_done_callback(forget)
|
||||
|
||||
def running(self, job_id: str) -> bool:
|
||||
return self._alive(job_id) is not None
|
||||
|
||||
async def stop(self, job_id: str, reason: str, *, grace: float = STOP_GRACE_S) -> bool:
|
||||
"""停掉这个进程里在跑的任务,等它收尾(最多 grace 秒)。不在这个进程里跑返回 False。"""
|
||||
run = self._alive(job_id)
|
||||
if run is None:
|
||||
return False
|
||||
if run.stop_reason is None:
|
||||
run.stop_reason = reason
|
||||
run.task.cancel() # type: ignore[union-attr]
|
||||
await asyncio.wait([run.task], timeout=grace) # type: ignore[list-item]
|
||||
return True
|
||||
|
||||
async def wait(self, job_id: str, timeout: float | None = None) -> None:
|
||||
"""等这个任务做完(测试、调试用)。不在跑直接返回。"""
|
||||
run = self._alive(job_id)
|
||||
if run is not None:
|
||||
await asyncio.wait([run.task], timeout=timeout) # type: ignore[list-item]
|
||||
|
||||
async def shutdown(self, *, grace: float = 3.0) -> None:
|
||||
"""关停:停掉进行中的任务(标中断),最多等 grace 秒收尾。"""
|
||||
runs = [run for job_id in list(self._runs) if (run := self._alive(job_id)) is not None]
|
||||
for run in runs:
|
||||
if run.stop_reason is None:
|
||||
run.stop_reason = "shutdown"
|
||||
run.task.cancel() # type: ignore[union-attr]
|
||||
if runs:
|
||||
await asyncio.wait([run.task for run in runs], timeout=grace) # type: ignore[misc]
|
||||
logger.info("已停止 %d 个数据目录起草任务", len(runs))
|
||||
|
||||
|
||||
manager = DraftJobs()
|
||||
|
||||
|
||||
__all__ = ["ACTIVE", "BLANK_SAMPLE", "DraftBusy", "DraftFinished", "DraftJobs", "FINISHED", "GUIDANCE_MAX",
|
||||
"INTERRUPTED_ERROR", "MAX_TABLES", "NOTE_MAX", "NothingToRetry", "RETRYABLE", "TASK_DONE", "TASK_STATUSES",
|
||||
"TextTooLong",
|
||||
"active_job", "cancel", "check_model", "clean_text", "create_job", "ensure_idle", "guidance_of", "job_brief",
|
||||
"job_dict", "job_out", "last_drafts", "latest_job", "load", "manager", "retry_tables", "save_guidance",
|
||||
"startup", "tasks_of"]
|
||||
@@ -0,0 +1,114 @@
|
||||
"""起草用哪个模型:数据目录的「用模型起草」和按配方导入的 AI 起草共用这一处取值。
|
||||
|
||||
顺序(哪一级写了就整组用哪一级,不跨级拼接入和模型):
|
||||
|
||||
1. 设置里的起草模型(catalog_draft 一组:{provider, model}),填了接入或模型就用它;
|
||||
2. 否则沿用助手的模型设置(copilot 一组,「编排」页助手栏的「助手使用的模型」);
|
||||
3. 再没有就是默认接入(providers/factory.resolve_provider 的回退)。
|
||||
|
||||
这里只决定「用哪个接入、哪个模型」。额度、思考、温度由两边各自定:数据目录起草保留思考、额度 32768
|
||||
(data/catalog.resolve_draft_model),配方 AI 起草照旧关思考(data/recipe_ai.resolve_draft_model)。
|
||||
|
||||
**为什么不用 get_chat_model。** 它在没有接入时静默退回演示模型,拿演示模型起草出来的东西会被当成真的。
|
||||
两边都自己解析接入:没有、停用、只有演示接入、缺 key,都在调用模型之前判定为不可用,说清是哪一级的设置出了问题。
|
||||
|
||||
数据层不导入接口层:设置键在这里定义,api/settings.py 从这里取(助手那一项和 api/copilot.py 同值,有测试核对)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
#: 设置里起草模型那一组的键
|
||||
CATALOG_DRAFT_SETTING_KEY = "catalog_draft"
|
||||
#: 助手的模型设置键(与 api/copilot.py 的 COPILOT_SETTING_KEY 同值)
|
||||
COPILOT_SETTING_KEY = "copilot"
|
||||
#: 起草模型在设置页上的位置。报错里让人去这里改;和前端设置页放这一组的地方一致
|
||||
DRAFT_MODEL_PLACE = "「设置 → 偏好设置」的「起草模型」"
|
||||
|
||||
#: 取自哪一级:draft 设置里的起草模型、assistant 助手的模型、default 默认接入
|
||||
ORIGINS = ("draft", "assistant", "default")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DraftModelChoice:
|
||||
"""起草用的接入和模型(都可以为 None:接入为 None 时按模型名找、再退回默认接入;模型为 None 时用接入的默认模型)。"""
|
||||
|
||||
provider: str | None
|
||||
model: str | None
|
||||
origin: str
|
||||
|
||||
|
||||
def _text(value: Any) -> str | None:
|
||||
"""设置里的一项:字符串去掉首尾空白,空串和别的类型(存坏了的)都当没填。"""
|
||||
return value.strip() or None if isinstance(value, str) else None
|
||||
|
||||
|
||||
def setting_pair(stored: Any) -> tuple[str | None, str | None]:
|
||||
"""库里存的一组 → (接入, 模型),规范化同 _text。"""
|
||||
stored = stored if isinstance(stored, dict) else {}
|
||||
return _text(stored.get("provider")), _text(stored.get("model"))
|
||||
|
||||
|
||||
async def draft_model_choice(session: AsyncSession) -> DraftModelChoice:
|
||||
"""按上面的顺序取。只读设置,不查接入在不在(resolve_provider 去查)。"""
|
||||
from app.db.models import Setting
|
||||
|
||||
for key, origin in ((CATALOG_DRAFT_SETTING_KEY, "draft"), (COPILOT_SETTING_KEY, "assistant")):
|
||||
row = await session.get(Setting, key)
|
||||
provider, model = setting_pair(row.value if row is not None else None)
|
||||
if provider or model:
|
||||
return DraftModelChoice(provider, model, origin)
|
||||
return DraftModelChoice(None, None, "default")
|
||||
|
||||
|
||||
def draft_unavailable(e: BaseException, origin: str) -> str:
|
||||
"""取到的接入用不了(点名的不存在、已停用,缺 key、没有模型……)时给人看的那句话。
|
||||
|
||||
沿用助手的模型(或默认接入)时,和助手取不到模型时同一句(factory.assistant_unavailable);用的是设置里的起草
|
||||
模型时,照实说是起草模型那一项、去设置页哪里改。"""
|
||||
from app.core.errors import not_configured
|
||||
from app.providers.factory import NAMED_FIX, assistant_unavailable, model_fact
|
||||
|
||||
if origin != "draft":
|
||||
return assistant_unavailable(e)
|
||||
code = getattr(e, "code", "")
|
||||
if code in NAMED_FIX:
|
||||
said = f"起草模型使用的{getattr(e, 'reason', '') or e}。"
|
||||
if owner := getattr(e, "model_owner", ""):
|
||||
return (f"{said}{NAMED_FIX[code]};{model_fact(getattr(e, 'name', ''), owner)},"
|
||||
f"也可在{DRAFT_MODEL_PLACE}中把它填进模型一栏")
|
||||
return f"{said}{NAMED_FIX[code]},或在{DRAFT_MODEL_PLACE}中重新选择"
|
||||
return (f"起草模型尚未配置完成:{not_configured(e, with_hint=False)}。请到「设置 → 模型接入」检查,"
|
||||
f"或在{DRAFT_MODEL_PLACE}中重新选择")
|
||||
|
||||
|
||||
class DraftModelUnavailable(RuntimeError):
|
||||
"""没有可用于起草的模型。message 给人看;在调用模型之前判定。"""
|
||||
|
||||
|
||||
async def resolve_draft_provider(session: AsyncSession, *, none_reason: str, mock_reason: str) -> tuple[Any, DraftModelChoice]:
|
||||
"""(接入, 取值)。用不了时抛 DraftModelUnavailable:没有任何启用的接入说 none_reason,只有演示接入说 mock_reason
|
||||
(两边各有各的说法),点名的接入不存在、已停用按取自哪一级说(draft_unavailable)。
|
||||
|
||||
只解析接入,不建模型对象:建的时候缺 key 之类的问题由调用方经 draft_unavailable 说。"""
|
||||
from app.providers.factory import ProviderNotConfigured, resolve_provider
|
||||
|
||||
choice = await draft_model_choice(session)
|
||||
try:
|
||||
# 存的接入名不存在、已停用,只存了模型名、提供它的接入已停用:resolve_provider 都会报出来
|
||||
provider = await resolve_provider(session, choice.provider, choice.model)
|
||||
except ProviderNotConfigured as e:
|
||||
raise DraftModelUnavailable(draft_unavailable(e, choice.origin)) from e
|
||||
if provider is None:
|
||||
raise DraftModelUnavailable(none_reason)
|
||||
if provider.kind == "mock":
|
||||
raise DraftModelUnavailable(mock_reason)
|
||||
return provider, choice
|
||||
|
||||
|
||||
__all__ = ["CATALOG_DRAFT_SETTING_KEY", "COPILOT_SETTING_KEY", "DRAFT_MODEL_PLACE", "DraftModelChoice",
|
||||
"DraftModelUnavailable", "draft_model_choice", "draft_unavailable", "resolve_draft_provider",
|
||||
"setting_pair"]
|
||||
@@ -62,12 +62,18 @@ MASK_COLUMNS_OPTION = "mask_columns"
|
||||
#: data/catalog_profile.py;放在这里只为让拼连接串时摘掉它
|
||||
CATALOG_PROFILE_OPTION = "catalog_profile"
|
||||
|
||||
#: 数据源 options 里的数据目录起草要求:使用者补充的背景和要求,起草时交给模型(一段文字,最多 2000 字)。
|
||||
#: 只经 PUT /api/datasources/{id}/catalog/guidance 维护,见 data/catalog_jobs.py;放在这里只为让拼连接串时摘掉它
|
||||
CATALOG_DRAFT_GUIDANCE_OPTION = "catalog_draft_guidance"
|
||||
|
||||
# options 里这些 key 是 AgentLab 自己的配置,不是驱动参数,拼 URL 时要摘掉。
|
||||
# schema:探查哪个 schema(企业库里只读账号名下常常什么都没有,数据在别处)
|
||||
# query_timeout_s:查询时限,由数据层按语句下发给数据库,见 _server_deadline
|
||||
# mask_columns:证据面板遮罩的列,驱动不认识它,拼进连接串会被当成未知参数拒掉
|
||||
# catalog_profile:数据剖析设置,同上;它还是个对象,拼进去连 URL 都不成形
|
||||
_NON_DRIVER_OPTIONS = frozenset({"schema", QUERY_TIMEOUT_OPTION, MASK_COLUMNS_OPTION, CATALOG_PROFILE_OPTION})
|
||||
# catalog_draft_guidance:数据目录的起草要求,一段中文,拼进去连接直接失败
|
||||
_NON_DRIVER_OPTIONS = frozenset({"schema", QUERY_TIMEOUT_OPTION, MASK_COLUMNS_OPTION, CATALOG_PROFILE_OPTION,
|
||||
CATALOG_DRAFT_GUIDANCE_OPTION})
|
||||
|
||||
_MASK_SPLIT = re.compile(r"[,,、;;\n]+")
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
必须人工确认之后才能启用。每月重传按已确认的配方重放,不再调用模型。
|
||||
|
||||
**为什么不用 get_chat_model。** 它在没有接入时会静默退回演示模型;拿演示模型起草等于假装起草了。这里
|
||||
自己按助手的模型设置解析接入,没有、停用、只有演示接入、缺 key 都在调用模型之前判定为不可用。
|
||||
自己解析接入(取值顺序和数据目录起草共用 data/draft_model.py:设置里的起草模型 > 助手的模型 > 默认接入),
|
||||
没有、停用、只有演示接入、缺 key 都在调用模型之前判定为不可用。
|
||||
|
||||
**调用参数照 c837cdb 修过的问题来**:关思考(thinking="off")、额度 32768、服务方嫌额度大时退回 8192;
|
||||
正文一律经 message_text 抽取(回复可能是块列表);输出被截断单独记一种失败,不当成「不是 JSON」去修订。
|
||||
@@ -42,6 +43,7 @@ from app.data.recipe_types import (
|
||||
GridCell,
|
||||
Recipe,
|
||||
)
|
||||
from app.data.draft_model import COPILOT_SETTING_KEY as _DRAFT_COPILOT_KEY
|
||||
from app.data.tabular import EXCEL_ERRORS
|
||||
from app.data.xlsx_scan import WorkbookScan, cell_ref, col_letter
|
||||
|
||||
@@ -58,8 +60,9 @@ class AiTooLarge(ValueError):
|
||||
|
||||
# 没有 AiFailed:调用之后的任何失败都体现在返回值里(draft=None、error、usage 照记)
|
||||
|
||||
#: 助手的模型设置键(与 app/api/copilot.py 的 COPILOT_SETTING_KEY 同值;数据层不导入接口层,测试断言两者相等)
|
||||
COPILOT_SETTING_KEY = "copilot"
|
||||
#: 助手的模型设置键(与 app/api/copilot.py 的 COPILOT_SETTING_KEY 同值;数据层不导入接口层,测试断言两者相等)。
|
||||
#: 取模型时由 data/draft_model.py 读,这里留着这个名字给测试和老代码用
|
||||
COPILOT_SETTING_KEY = _DRAFT_COPILOT_KEY
|
||||
#: 1 次起草 + 最多 2 次修订
|
||||
AI_MAX_ATTEMPTS = 3
|
||||
#: 单次调用的超时(秒);测试 monkeypatch 调小
|
||||
@@ -252,37 +255,27 @@ class _BudgetFallback:
|
||||
return await self.model.ainvoke(messages)
|
||||
|
||||
|
||||
def _unconfigured(e: BaseException) -> str:
|
||||
"""和 copilot._unconfigured 同一句(providers/factory.assistant_unavailable)。"""
|
||||
from app.providers.factory import assistant_unavailable
|
||||
|
||||
return assistant_unavailable(e)
|
||||
|
||||
|
||||
async def resolve_draft_model(session: AsyncSession) -> tuple[Any | None, str, AiAvailability]:
|
||||
"""(可调用的模型, 模型 id, 可用性);不可用时模型为 None。只建模型对象,不发请求。"""
|
||||
from app.db.models import Setting
|
||||
from app.providers.factory import ModelSpec, ProviderNotConfigured, build_chat_model, resolve_provider
|
||||
"""(可调用的模型, 模型 id, 可用性);不可用时模型为 None。只建模型对象,不发请求。
|
||||
|
||||
用哪个接入、哪个模型和数据目录起草共用一处取值(data/draft_model.py:起草模型 > 助手的模型 > 默认接入);
|
||||
额度、关思考照旧是这里自己的。"""
|
||||
from app.data.draft_model import DraftModelUnavailable, draft_unavailable, resolve_draft_provider
|
||||
from app.providers.factory import ModelSpec, ProviderNotConfigured, build_chat_model
|
||||
|
||||
row = await session.get(Setting, COPILOT_SETTING_KEY)
|
||||
saved = (row.value if row else None) or {}
|
||||
spec = ModelSpec(provider=saved.get("provider") or None, model=saved.get("model") or None,
|
||||
max_tokens=AI_MAX_TOKENS, temperature=0, thinking="off")
|
||||
try:
|
||||
provider = await resolve_provider(session, spec.provider, spec.model)
|
||||
except ProviderNotConfigured as e:
|
||||
# 存的接入名不存在、已停用,只存了模型名、提供它的接入已停用:resolve_provider 都会报出来
|
||||
return None, "", AiAvailability(False, reason=_unconfigured(e))
|
||||
if provider is None:
|
||||
return None, "", AiAvailability(False, reason="未配置模型接入,无法使用 AI 起草。请到「设置 → 模型接入」添加")
|
||||
if provider.kind == "mock":
|
||||
return None, "", AiAvailability(False, reason="当前只有演示用的模型接入,无法用于 AI 起草。"
|
||||
"请到「设置 → 模型接入」添加真实的模型接入")
|
||||
provider, choice = await resolve_draft_provider(
|
||||
session, none_reason="未配置模型接入,无法使用 AI 起草。请到「设置 → 模型接入」添加",
|
||||
mock_reason="当前只有演示用的模型接入,无法用于 AI 起草。请到「设置 → 模型接入」添加真实的模型接入")
|
||||
except DraftModelUnavailable as e:
|
||||
return None, "", AiAvailability(False, reason=str(e))
|
||||
spec = ModelSpec(provider=choice.provider, model=choice.model, max_tokens=AI_MAX_TOKENS, temperature=0,
|
||||
thinking="off")
|
||||
try:
|
||||
big = build_chat_model(provider, spec)
|
||||
small = build_chat_model(provider, spec.model_copy(update={"max_tokens": AI_FALLBACK_MAX_TOKENS}))
|
||||
except ProviderNotConfigured as e:
|
||||
return None, "", AiAvailability(False, reason=_unconfigured(e))
|
||||
return None, "", AiAvailability(False, reason=draft_unavailable(e, choice.origin))
|
||||
model_id = spec.model or provider.default_model or ""
|
||||
return (_BudgetFallback(big, small, model_id), model_id,
|
||||
AiAvailability(True, model=model_id, provider=provider.name))
|
||||
|
||||
@@ -204,6 +204,14 @@ _COLUMN_MIGRATIONS: list[tuple[str, str, str]] = [
|
||||
# 没有选项,也不是定时任务发起的
|
||||
("runs", "options", "JSON DEFAULT '{}'"),
|
||||
("runs", "schedule_id", "VARCHAR(32)"),
|
||||
# 数据目录起草任务(验收后修正):超时、没写出来的调用次数,当前的列数上限,提醒;逐表的模型留空的列。
|
||||
# 存量任务回填 0 和 NULL:升级前没记这些
|
||||
("catalog_draft_jobs", "timeouts", "INTEGER DEFAULT 0"),
|
||||
("catalog_draft_jobs", "failed_calls", "INTEGER DEFAULT 0"),
|
||||
("catalog_draft_jobs", "part_columns", "INTEGER"),
|
||||
("catalog_draft_jobs", "notice", "TEXT"),
|
||||
("catalog_draft_tasks", "blank_columns", "INTEGER DEFAULT 0"),
|
||||
("catalog_draft_tasks", "blank_sample", "JSON"),
|
||||
]
|
||||
|
||||
#: 迁移补上的列要建的索引:(索引名, 表, 列)。create_all 不碰已有的表,表上新列的索引也就不会建。
|
||||
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
Float, ForeignKey, Index, Integer, LargeBinary, String, Text, UniqueConstraint,
|
||||
Float, ForeignKey, Index, Integer, LargeBinary, String, Text, UniqueConstraint, text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@@ -348,6 +348,86 @@ class CatalogNote(Base, TimestampMixin):
|
||||
updated_by: Mapped[str | None] = mapped_column(String(100), default=None)
|
||||
|
||||
|
||||
class CatalogDraftJob(Base, TimestampMixin):
|
||||
"""数据目录的一次后台起草任务(data/catalog_jobs.py)。逐表的状态在 catalog_draft_tasks。
|
||||
|
||||
以前起草是一次同步长请求,出错整个对话框就停、关掉之后没有任何记录哪些表失败、为什么;现在建任务后在后台起草,
|
||||
进度(每张表到第几段、计数、用量)随时落库,接口读的是库里的状态。
|
||||
|
||||
同一个数据源同时只有一个排队或进行中的任务:接口先查一遍,部分唯一索引兜底(两个请求同时建,后提交的撞上)。
|
||||
服务重启时还没结束的标成 interrupted,不自动续跑。挂外键、级联删除:任务是数据源的附属记录,源删掉了留着没有意义。
|
||||
"""
|
||||
|
||||
__tablename__ = "catalog_draft_jobs"
|
||||
__table_args__ = (
|
||||
Index("ix_catalog_draft_jobs_source_created", "source_id", "created_at"),
|
||||
Index("ux_catalog_draft_jobs_active", "source_id", unique=True,
|
||||
sqlite_where=text("status IN ('queued', 'running')")),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
|
||||
source_id: Mapped[str] = mapped_column(ForeignKey("data_sources.id", ondelete="CASCADE"))
|
||||
# queued | running | succeeded | partial | failed | cancelled | interrupted
|
||||
status: Mapped[str] = mapped_column(String(20), default="queued")
|
||||
use_model: Mapped[bool] = mapped_column(default=False)
|
||||
# 实际用的模型 id(用模型时开始起草后写上)
|
||||
model: Mapped[str | None] = mapped_column(String(200), default=None)
|
||||
# 建任务时的快照:数据源级的起草要求、这一次的补充说明
|
||||
guidance: Mapped[str | None] = mapped_column(Text, default=None)
|
||||
note: Mapped[str | None] = mapped_column(Text, default=None)
|
||||
# 请求头 X-Actor 的署名(自报、未认证)
|
||||
created_by: Mapped[str | None] = mapped_column(String(100), default=None)
|
||||
started_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
|
||||
# 用量:调用次数、输入和输出 token(拿不到用量的调用按 0 算,超时的调用也是 0)、超时几次、没写出来几次(额度用完、
|
||||
# 只给空壳、超时、出错)
|
||||
calls: Mapped[int] = mapped_column(Integer, default=0)
|
||||
input_tokens: Mapped[int] = mapped_column(Integer, default=0)
|
||||
output_tokens: Mapped[int] = mapped_column(Integer, default=0)
|
||||
timeouts: Mapped[int] = mapped_column(Integer, default=0)
|
||||
failed_calls: Mapped[int] = mapped_column(Integer, default=0)
|
||||
# 新开始的段每段最多几列(额度用完、超时多了会减半;用模型时开始起草后写上)
|
||||
part_columns: Mapped[int | None] = mapped_column(Integer, default=None)
|
||||
# 提醒:最近几次调用多数没写出来时的一句话(建议换起草模型、减少表或停止),少了清掉
|
||||
notice: Mapped[str | None] = mapped_column(Text, default=None)
|
||||
# 任务整体出错的原因(逐表的原因在 catalog_draft_tasks)
|
||||
error: Mapped[str | None] = mapped_column(Text, default=None)
|
||||
# 重试建的任务记下是重试哪一个
|
||||
retry_of: Mapped[str | None] = mapped_column(String(32), default=None)
|
||||
|
||||
|
||||
class CatalogDraftTask(Base):
|
||||
"""起草任务里的一张表。按 seq(起草顺序)排。状态:pending 排队、running 起草中、ok、partial 模型只写出一部分、
|
||||
failed、skipped 表结构里没有、cancelled(停止或服务重启)。"""
|
||||
|
||||
__tablename__ = "catalog_draft_tasks"
|
||||
__table_args__ = (UniqueConstraint("job_id", "seq"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
|
||||
job_id: Mapped[str] = mapped_column(ForeignKey("catalog_draft_jobs.id", ondelete="CASCADE"), index=True)
|
||||
seq: Mapped[int] = mapped_column(Integer)
|
||||
# 表结构里的键;表结构里没有的(skipped)是请求里的原样名字
|
||||
table_name: Mapped[str] = mapped_column(String(255))
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||
column_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
# 模型分几段起草(不用模型时为 NULL)、已经结束了几段
|
||||
parts_total: Mapped[int | None] = mapped_column(Integer, default=None)
|
||||
parts_done: Mapped[int] = mapped_column(Integer, default=0)
|
||||
added: Mapped[int] = mapped_column(Integer, default=0)
|
||||
updated: Mapped[int] = mapped_column(Integer, default=0)
|
||||
removed: Mapped[int] = mapped_column(Integer, default=0)
|
||||
version: Mapped[int] = mapped_column(Integer, default=0)
|
||||
model_items: Mapped[int | None] = mapped_column(Integer, default=None)
|
||||
# 模型拿不准、留空了的列(中文名和含义都没给):几列,最多记 5 个列名。表的状态不因此变
|
||||
blank_columns: Mapped[int] = mapped_column(Integer, default=0)
|
||||
blank_sample: Mapped[list[Any] | None] = mapped_column(default=None)
|
||||
# error:整张表没起草(表结构里没有、写入一直冲突、服务重启中断);model_error:模型那部分没写出来的原因和办法
|
||||
error: Mapped[str | None] = mapped_column(Text, default=None)
|
||||
model_error: Mapped[str | None] = mapped_column(Text, default=None)
|
||||
started_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
|
||||
|
||||
|
||||
class Setting(Base, TimestampMixin):
|
||||
"""键值形式的用户设置,前端 Settings 页直接读写。"""
|
||||
|
||||
|
||||
+11
-1
@@ -70,6 +70,15 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("上传表格的版本整理失败,服务照常启动")
|
||||
|
||||
# 上次没做完的数据目录起草任务:标成中断(没结束的表记停止),不自动续跑——拷来的数据目录(检查脚本、验收副本)
|
||||
# 启动时不能替人去调模型。界面上点「重试」接着起草没写好的表
|
||||
from app.data import catalog_jobs
|
||||
|
||||
try:
|
||||
await catalog_jobs.startup()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("数据目录起草任务的启动整理失败,服务照常启动")
|
||||
|
||||
await run_manager.setup()
|
||||
from app.sandbox.manager import sandbox_manager as _sbm
|
||||
|
||||
@@ -84,8 +93,9 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# 先停调度,再停运行:停运行的途中不该再有新运行被发起
|
||||
# 先停调度,再停运行:停运行的途中不该再有新运行被发起。起草任务停下来标成中断,下次启动不会再续
|
||||
await scheduler.stop()
|
||||
await catalog_jobs.manager.shutdown()
|
||||
await run_manager.shutdown()
|
||||
from app.sandbox.manager import sandbox_manager
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ class ModelSpec(BaseModel):
|
||||
thinking: str | None = None # off | adaptive
|
||||
effort: str | None = None # low | medium | high | xhigh | max
|
||||
timeout: int | None = None
|
||||
# SDK 自己遇到 429、5xx、连接错误、超时时重试几次;None 照 SDK 默认(openai、anthropic 都是 2 次)。
|
||||
# 自己按失败原因重试的调用方(数据目录起草)设 0,免得两层重试叠在一起、实际次数和报错写的对不上
|
||||
max_retries: int | None = None
|
||||
extra: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -227,6 +230,8 @@ def build_chat_model(provider: Provider, spec: ModelSpec) -> BaseChatModel:
|
||||
# 以前只有节点显式配了才传,而没有任何地方给 spec.timeout 赋过值——所有调用
|
||||
# 都走 SDK 默认的 10 分钟
|
||||
kwargs["timeout"] = spec.timeout or settings.model_timeout_seconds
|
||||
if spec.max_retries is not None:
|
||||
kwargs["max_retries"] = spec.max_retries
|
||||
|
||||
# 采样参数:能力允许才传
|
||||
if spec.temperature is not None and catalog.supports_sampling(model):
|
||||
@@ -314,7 +319,8 @@ def build_chat_model(provider: Provider, spec: ModelSpec) -> BaseChatModel:
|
||||
# 且 api_key=None 时不会发出 x-api-key。
|
||||
import anthropic
|
||||
|
||||
client_args = {"base_url": base_url, "api_key": None, "auth_token": api_key}
|
||||
client_args = {"base_url": base_url, "api_key": None, "auth_token": api_key,
|
||||
"max_retries": chat.max_retries}
|
||||
chat._client = anthropic.Anthropic(**client_args)
|
||||
chat._async_client = anthropic.AsyncAnthropic(**client_args)
|
||||
return chat
|
||||
|
||||
+106
-10
@@ -3,10 +3,14 @@
|
||||
model = FakeModel({"visits": {"label": "入园记录", "kind": "fact"}}, fail={"orders"})
|
||||
await catalog.draft_with_model(model, source, ["visits"])
|
||||
|
||||
- 结构化输出(with_structured_output(...).ainvoke)从提示词里认出这一批有哪些表(「表 <表名>」),
|
||||
返回 {"tables": [{"table": 表名, **replies[表名]}, …]};
|
||||
- 结构化输出(with_structured_output(...).ainvoke)从提示词里认出这一段请它写哪些表、每张表写哪部分(requested:
|
||||
整张表、只写表级的项、只写列出的几列、表级的项加几列),返回 {"tables": [{"table": 表名, …}, …]},只给请它写的
|
||||
那部分(整张表给 replies[表名] 的全部;只写几列时只给 replies 里这几列);
|
||||
- 照提示词的要求,请它写的每一列都列出来:replies 里没写的列只给列名({"name": 列名},字段留空,是拿不准),
|
||||
不整列漏掉。list_all=False 时只给 replies 里有的列(模拟漏列的模型:漏掉的列会被补问一次,补问后仍没给的算没写出来);
|
||||
- 直接 ainvoke 是纯文本那条路:同样的内容写成一段正文(AIMessage),前面带一句话;
|
||||
- fail 里的表所在的那一批抛异常,用来验证「一批失败只影响这一批」;
|
||||
- fail 里的表所在的那一段抛异常,用来验证「一段失败只影响这一段」;
|
||||
- hang=N:头 N 次调用一直不回(等起草引擎按时限掐掉,当作超时);
|
||||
- calls 记下每次收到的提示词全文,用来核对交给模型的内容(只有结构,没有数据);modes 记下每次走的是
|
||||
structured 还是 text。
|
||||
|
||||
@@ -19,15 +23,95 @@ structured、text 模拟实测见过的几种回复(两条路各自设):
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from app.data.catalog import CATALOG_DRAFT_PROMPT_LABELS as _P
|
||||
|
||||
#: 表名下一行写着「这次只写…」时,请模型写的是哪一部分(data/catalog_draft.py 按这几句写提示词)
|
||||
_ONLY_TABLE = _P["only_table"]
|
||||
_ONLY_COLUMNS = _P["only_columns"].split("{")[0]
|
||||
_TABLE_AND = _P["table_and_columns"].split("{")[0]
|
||||
|
||||
|
||||
def requested(text: str) -> dict[str, tuple[str, list[str] | None]]:
|
||||
"""提示词里请模型写哪些表、每张表写哪部分:{表名: (哪部分, 列)}。哪部分是 full(整张表)、table(只写表级的项)、
|
||||
columns(只写列出的列,列是表结构描述里列出的那几列)、table+columns(表级的项加「这次只写」那一行点名的列)。"""
|
||||
out: dict[str, tuple[str, list[str] | None]] = {}
|
||||
for chunk in text.split("\n\n"):
|
||||
lines = chunk.split("\n")
|
||||
head = lines[0]
|
||||
if not head.startswith("表 "):
|
||||
continue
|
||||
name = head[2:]
|
||||
if name.endswith("(视图)"):
|
||||
name = name[:-len("(视图)")]
|
||||
second = lines[1] if len(lines) > 1 else ""
|
||||
listed = [line[2:].split(" ")[0] for line in lines if line.startswith("- ")]
|
||||
if second.startswith(_ONLY_TABLE):
|
||||
out[name] = ("table", None)
|
||||
elif second.startswith(_ONLY_COLUMNS):
|
||||
out[name] = ("columns", listed)
|
||||
elif second.startswith(_TABLE_AND):
|
||||
out[name] = ("table+columns", second.split(":", 1)[1].split("、"))
|
||||
else:
|
||||
out[name] = ("full", None)
|
||||
return out
|
||||
|
||||
|
||||
def answer(reply: dict, name: str, part: str, columns: list[str] | None) -> dict:
|
||||
"""按请它写的那部分,从 replies[表名] 里取。"""
|
||||
if part == "full":
|
||||
return {"table": name, **reply}
|
||||
out: dict[str, Any] = {"table": name}
|
||||
if part in ("table", "table+columns"):
|
||||
out.update({k: v for k, v in reply.items() if k != "columns"})
|
||||
if part in ("columns", "table+columns"):
|
||||
wanted = set(columns or [])
|
||||
out["columns"] = [c for c in reply.get("columns") or [] if isinstance(c, dict) and c.get("name") in wanted]
|
||||
return out
|
||||
|
||||
|
||||
def listed(text: str) -> dict[str, list[str]]:
|
||||
"""提示词里每张表的结构描述列出了哪些列(「- 列名 类型」那几行)。"""
|
||||
out: dict[str, list[str]] = {}
|
||||
for chunk in text.split("\n\n"):
|
||||
lines = chunk.split("\n")
|
||||
if not lines[0].startswith("表 "):
|
||||
continue
|
||||
name = lines[0][2:]
|
||||
if name.endswith("(视图)"):
|
||||
name = name[:-len("(视图)")]
|
||||
out[name] = [line[2:].split(" ")[0] for line in lines if line.startswith("- ")]
|
||||
return out
|
||||
|
||||
|
||||
def wanted_columns(text: str, name: str, part: str, columns: list[str] | None) -> list[str]:
|
||||
"""这一段请它写这张表的哪几列:整张表是结构描述里的全部列;只写表级的项时没有。"""
|
||||
if part == "full":
|
||||
return listed(text).get(name, [])
|
||||
if part in ("columns", "table+columns"):
|
||||
return list(columns or [])
|
||||
return []
|
||||
|
||||
|
||||
def with_every_column(entry: dict, wanted: list[str]) -> dict:
|
||||
"""请写的列都列出来:没写的只给列名(字段留空)。"""
|
||||
have = {c.get("name") for c in entry.get("columns") or [] if isinstance(c, dict)}
|
||||
missing = [{"name": c} for c in wanted if c not in have]
|
||||
if not missing:
|
||||
return entry
|
||||
return {**entry, "columns": list(entry.get("columns") or []) + missing}
|
||||
|
||||
|
||||
class FakeModel:
|
||||
def __init__(self, replies: dict[str, dict], fail: set[str] = frozenset(), *,
|
||||
structured: str = "tables", text: str = "tables") -> None:
|
||||
structured: str = "tables", text: str = "tables", list_all: bool = True, hang: int = 0) -> None:
|
||||
self.replies, self.fail, self.calls = replies, set(fail), []
|
||||
self.structured, self.text = structured, text
|
||||
self.list_all = list_all
|
||||
self.hang = hang
|
||||
self.modes: list[str] = []
|
||||
|
||||
def with_structured_output(self, schema: Any, **_: Any) -> "_Structured":
|
||||
@@ -37,24 +121,36 @@ class FakeModel:
|
||||
async def ainvoke(self, messages: list[Any]) -> Any:
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
reply = self._reply(messages, "text")
|
||||
reply = await self._answer(messages, "text")
|
||||
return AIMessage(content="草稿如下:" + json.dumps(reply, ensure_ascii=False))
|
||||
|
||||
async def _answer(self, messages: list[Any], mode: str) -> dict:
|
||||
if self.hang > 0:
|
||||
self.hang -= 1
|
||||
self.calls.append("\n".join(str(getattr(m, "content", m)) for m in messages))
|
||||
self.modes.append(mode)
|
||||
await asyncio.sleep(3600)
|
||||
return self._reply(messages, mode)
|
||||
|
||||
def _reply(self, messages: list[Any], mode: str) -> dict:
|
||||
text = "\n".join(str(getattr(m, "content", m)) for m in messages)
|
||||
self.calls.append(text)
|
||||
self.modes.append(mode)
|
||||
names = [t for t in self.replies if f"表 {t}\n" in text or f"表 {t}(" in text]
|
||||
if any(t in self.fail for t in names):
|
||||
asked = requested(text)
|
||||
names = [t for t in asked if t in self.replies]
|
||||
if any(t in self.fail for t in asked):
|
||||
raise RuntimeError("网关超时")
|
||||
shape = self.structured if mode == "structured" else self.text
|
||||
if shape == "error":
|
||||
raise NotImplementedError("这条路用不了")
|
||||
if shape == "hollow":
|
||||
return {"tables": [{"table": t} for t in names]}
|
||||
answers = [answer(self.replies[t], t, *asked[t]) for t in names]
|
||||
if self.list_all:
|
||||
answers = [with_every_column(a, wanted_columns(text, t, *asked[t])) for a, t in zip(answers, names)]
|
||||
if shape == "single":
|
||||
return {"table": names[0], **self.replies[names[0]]} if names else {}
|
||||
return {"tables": [{"table": t, **self.replies[t]} for t in names]}
|
||||
return answers[0] if answers else {}
|
||||
return {"tables": answers}
|
||||
|
||||
|
||||
class _Structured:
|
||||
@@ -62,4 +158,4 @@ class _Structured:
|
||||
self.owner = owner
|
||||
|
||||
async def ainvoke(self, messages: list[Any]) -> dict:
|
||||
return self.owner._reply(messages, "structured")
|
||||
return await self.owner._answer(messages, "structured")
|
||||
|
||||
@@ -67,6 +67,48 @@ def test_long_table_comment_goes_to_description():
|
||||
assert draft.notes["description"]["value"].startswith("游客每次检票入园")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("comment, role", [
|
||||
("实收金额", "label"),
|
||||
("Paid amount", "label"), # 带空格的英文短语照旧当名字
|
||||
("状态:1 表示有效,0 表示作废", "note"),
|
||||
("paid_amt", None), ("PAID-AMT", None), ("Paid Amt", None), # 和列名相同(忽略大小写、下划线、连字符、空白)
|
||||
("member_level_code", None), ("ord.status", None), ("GMV", None), ("v2", None), # 标识符写法
|
||||
("member-level", "label"), # 带连字符、和名字不同:不是标识符写法
|
||||
])
|
||||
def test_comment_role_by_text_and_name(comment, role):
|
||||
assert catalog.comment_role(comment, "paid_amt") == role
|
||||
|
||||
|
||||
def test_a_comment_that_is_just_the_name_or_an_identifier_is_not_a_label():
|
||||
"""注释就是表名本身(列注释就是列名)、或者是标识符写法:不当中文名,也不当说明(不然它比模型起草可信,模型起草的
|
||||
中文名、含义永远进不了目录);交给模型的表结构里照旧有这条注释。"""
|
||||
src = _source({"member_card": _table(
|
||||
_col("id"), _col("card_no", "TEXT", comment="CARD-NO"), _col("open_dt", "TEXT", comment="open_date"),
|
||||
_col("bal", "REAL", comment="卡内余额"), comment="MEMBER CARD")})
|
||||
draft = catalog.draft_structure(src, "member_card")
|
||||
assert "label" not in draft.notes and "description" not in draft.notes
|
||||
assert draft.notes["columns"] == {"bal": {"label": make_item("卡内余额", "comment")}}
|
||||
assert "comment" in draft.covered
|
||||
# schema 前缀不算:注释写的是不带前缀的表名,同样不当中文名
|
||||
src = _source({"core.member_card": _table(_col("id"), comment="MEMBER_CARD")})
|
||||
assert "label" not in catalog.draft_structure(src, "core.member_card").notes
|
||||
|
||||
|
||||
async def test_a_redraft_drops_an_old_label_that_was_just_the_name_and_takes_the_model_one():
|
||||
"""以前按老规矩把「注释就是表名」记成了中文名(注释的级别高于模型起草):以后起草时这条注释不再当中文名,旧的那一项
|
||||
随这一轮的注释一起删掉,模型起草的中文名就能进目录。已有目录不另做迁移,起草到这张表时才变。"""
|
||||
tables = {"member_card": _table(_col("id"), _col("bal", "REAL"), comment="MEMBER_CARD", fks=[])}
|
||||
row = await _db_source(tables)
|
||||
async with SessionLocal() as session:
|
||||
await catalog.write_entry(session, row.id, "member_card", {"label": make_item("MEMBER_CARD", "comment")},
|
||||
if_version=0, actor=None)
|
||||
report = await catalog.draft_catalog(session, row, tables=["member_card"], use_model=True,
|
||||
model=FakeModel({"member_card": {"label": "会员卡"}}))
|
||||
entry = await catalog.read_entry(session, row.id, "member_card")
|
||||
assert entry.notes["label"]["value"] == "会员卡" and entry.notes["label"]["source"] == "llm"
|
||||
assert (report.tables[0].added, report.tables[0].removed) == (1, 1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kw", [{"origin": "upload"}, {"import_mode": "recipe"}])
|
||||
def test_imported_table_comments_are_not_copied(kw):
|
||||
"""导入表格的说明是按核对结果生成的(recipe_notes),数据目录不重复它们。"""
|
||||
@@ -274,11 +316,15 @@ async def test_model_single_table_object_without_wrapper_is_recognized():
|
||||
|
||||
|
||||
async def test_hollow_structured_output_retries_with_text():
|
||||
"""结构化输出只给了 {"tables":[{"table":"visits"}]} 这样的空壳:改走纯文本再问一次,用纯文本给的内容。"""
|
||||
model = FakeModel({"visits": {"label": "入园记录", "kind": "fact"}}, structured="hollow", text="tables")
|
||||
"""结构化输出只给了 {"tables":[{"table":"visits"}]} 这样的空壳、这一段又拆不开(不到 10 列):改走纯文本再问一次,
|
||||
用纯文本给的内容。纯文本只给了表级的项、一列都没列出来:再补问一次没给的列(只问缺的,data/catalog_draft.py);
|
||||
补问后仍没给,这几列算没写出来,表是只写出一部分(旧的模型起草项不删)。"""
|
||||
model = FakeModel({"visits": {"label": "入园记录", "kind": "fact"}}, structured="hollow", text="tables",
|
||||
list_all=False)
|
||||
got = await catalog.draft_with_model(model, _source(_scenic_tables()), ["visits"])
|
||||
assert model.modes == ["structured", "text"]
|
||||
assert model.modes == ["structured", "text", "text"]
|
||||
assert got["visits"].notes == {"label": make_item("入园记录", "llm"), "kind": make_item("fact", "llm")}
|
||||
assert got["visits"].covered == frozenset() and "有 5 列(id 到 status)没写出来" in got["visits"].error
|
||||
|
||||
|
||||
async def test_hollow_structured_then_single_text_object():
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,781 @@
|
||||
"""数据目录的后台起草任务(data/catalog_jobs.py、/api/datasources/{id}/catalog/draft-jobs)与起草要求。
|
||||
|
||||
- 建任务 202;同一个源已有排队或进行中的任务 409 catalog_draft_busy;起草模型用不了 409
|
||||
catalog_draft_model_unavailable(不建任务);没有表结构 409;表结构里没有的表照样建进任务、状态 skipped
|
||||
- 在后台起草,每张表一结束就并入目录;进度、计数、用量落库,接口读的是库里的状态
|
||||
- 停止:排队的直接 cancelled;进行中的停掉正在进行的模型调用,没写完的表记 cancelled,已经写进目录的不回滚;已结束的 409
|
||||
- 服务启动时把没结束的任务标成 interrupted(没结束的表记 cancelled、写明服务重启),不自动续跑;关停时同样
|
||||
- 重试:只含原任务里 failed、partial、cancelled 的表,本次说明沿用、起草要求取数据源当前的;没有可重试的 422
|
||||
- 目录清单:每行 last_draft(带模型留空的列数),顶层 guidance、active_job
|
||||
- 重新起草时模型这次留空、给的值没通过校验:以前起草好的模型项留着,删除只在列已经不在表结构里时才有
|
||||
- 用量记超时次数、没写出来的调用次数;任务记当前的列数上限、提醒(最近 6 次调用 4 次没写出来);逐表记模型留空的列
|
||||
- 旧的同步起草:同一个源有排队或进行中的任务时 409 catalog_draft_busy
|
||||
- 起草要求存在数据源 options 里(上传表格的源存在它自己那一行),数据源接口不显示、整份回写 options 时留着,拼连接串时摘掉
|
||||
|
||||
表名、列名、说明文字都是虚构的。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import quote
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.data import catalog, catalog_jobs
|
||||
from app.data.engine import CATALOG_DRAFT_GUIDANCE_OPTION, build_url
|
||||
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.catalog.fake_model import FakeModel, requested
|
||||
from tests.fixtures.sources import drop_source
|
||||
|
||||
ACTOR = {"X-Actor": quote("王敏")}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 夹具
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _col(name: str, type_: str = "INTEGER", comment: str | None = None) -> dict:
|
||||
return {"name": name, "type": type_, "nullable": True, "comment": comment}
|
||||
|
||||
|
||||
def _tables() -> dict:
|
||||
return {
|
||||
"visits": {"qualified": "visits", "columns": [_col("id"), _col("ticket_no", "TEXT"), _col("visit_time", "TEXT"),
|
||||
_col("channel_id")],
|
||||
"primary_key": ["id"], "comment": None, "is_view": False,
|
||||
"foreign_keys": [{"columns": ["channel_id"], "to_table": "channels", "to_columns": ["id"]}]},
|
||||
"channels": {"qualified": "channels", "columns": [_col("id"), _col("name", "TEXT")], "primary_key": ["id"],
|
||||
"comment": "售票渠道", "is_view": False, "foreign_keys": []},
|
||||
"gates": {"qualified": "gates", "columns": [_col("id"), _col("gate_code", "TEXT")], "primary_key": ["id"],
|
||||
"comment": None, "is_view": False, "foreign_keys": []},
|
||||
}
|
||||
|
||||
|
||||
REPLIES = {
|
||||
"visits": {"label": "入园记录", "kind": "fact", "columns": [{"name": "ticket_no", "label": "票号"}]},
|
||||
"channels": {"label": "渠道", "kind": "dimension"},
|
||||
"gates": {"label": "闸口", "kind": "dimension"},
|
||||
}
|
||||
|
||||
_CREATED: list[str] = []
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def _cleanup():
|
||||
yield
|
||||
# 后台任务先收完(停不下来的停掉),再删源:删源会级联删掉任务
|
||||
await catalog_jobs.manager.shutdown(grace=5)
|
||||
while _CREATED:
|
||||
await drop_source(_CREATED.pop())
|
||||
|
||||
|
||||
async def _source(tables: dict | None = None, **kw) -> str:
|
||||
async with SessionLocal() as session:
|
||||
row = DataSource(name=f"cdj_{uuid.uuid4().hex[:10]}", kind="sqlite", database=":memory:",
|
||||
schema_cache={"tables": tables if tables is not None else _tables()}, **kw)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
_CREATED.append(row.id)
|
||||
return row.id
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
async with AsyncClient(transport=ASGITransport(app=_fastapi_app), base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
def _base(sid: str) -> str:
|
||||
return f"/api/datasources/{sid}/catalog"
|
||||
|
||||
|
||||
def use(monkeypatch, model) -> None:
|
||||
"""起草模型换成假模型(建任务时查一次能不能用,开始起草时再取一次)。"""
|
||||
async def resolve(session):
|
||||
return model, "fake-model"
|
||||
|
||||
monkeypatch.setattr(catalog, "resolve_draft_model", resolve)
|
||||
|
||||
|
||||
async def finished(job_id: str) -> None:
|
||||
await catalog_jobs.manager.wait(job_id, timeout=10)
|
||||
|
||||
|
||||
async def get_job(client, sid: str, job_id: str) -> dict:
|
||||
r = await client.get(f"{_base(sid)}/draft-jobs/{job_id}")
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["job"]
|
||||
|
||||
|
||||
class Held:
|
||||
"""假模型:请求里有 hold 里的表时,等 release 才回;被停止时记下这次调用被取消了。"""
|
||||
|
||||
def __init__(self, replies: dict, hold: set[str]) -> None:
|
||||
self.inner, self.hold = FakeModel(replies), set(hold)
|
||||
self.release = asyncio.Event()
|
||||
self.cancelled = 0
|
||||
self.model_name = "held-model"
|
||||
|
||||
def with_structured_output(self, schema, **_):
|
||||
outer = self
|
||||
|
||||
class _S:
|
||||
async def ainvoke(self, messages):
|
||||
text = "\n".join(str(m.content) for m in messages)
|
||||
if outer.hold & set(requested(text)):
|
||||
try:
|
||||
await outer.release.wait()
|
||||
except asyncio.CancelledError:
|
||||
outer.cancelled += 1
|
||||
raise
|
||||
return outer.inner._reply(messages, "structured")
|
||||
|
||||
return _S()
|
||||
|
||||
async def ainvoke(self, messages):
|
||||
return await self.inner.ainvoke(messages)
|
||||
|
||||
|
||||
async def until(predicate, timeout: float = 5.0) -> None:
|
||||
for _ in range(int(timeout / 0.01)):
|
||||
if await predicate():
|
||||
return
|
||||
await asyncio.sleep(0.01)
|
||||
raise AssertionError("等不到")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 建任务、在后台起草
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_a_job_drafts_in_the_background_and_writes_each_table(client, monkeypatch):
|
||||
sid = await _source()
|
||||
model = FakeModel(REPLIES)
|
||||
use(monkeypatch, model)
|
||||
await client.put(f"{_base(sid)}/guidance", json={"guidance": "金额一律按元写单位"})
|
||||
r = await client.post(f"{_base(sid)}/draft-jobs", headers=ACTOR,
|
||||
json={"tables": ["visits", "nope", "channels"], "use_model": True, "note": " 看渠道 "})
|
||||
assert r.status_code == 202, r.text
|
||||
job = r.json()["job"]
|
||||
assert job["status"] in ("queued", "running") and job["source_id"] == sid
|
||||
assert job["use_model"] is True and job["guidance"] == "金额一律按元写单位" and job["note"] == "看渠道"
|
||||
assert job["created_by"] == "王敏" and job["total"] == 3
|
||||
assert [t["table_name"] for t in job["tasks"]] == ["visits", "nope", "channels"], "按起草顺序"
|
||||
await finished(job["id"])
|
||||
|
||||
job = await get_job(client, sid, job["id"])
|
||||
assert job["status"] == "succeeded" and job["error"] is None and job["model"] == "fake-model"
|
||||
assert job["done"] == 3 and job["counts"] == {"pending": 0, "running": 0, "ok": 2, "partial": 0, "failed": 0,
|
||||
"skipped": 1, "cancelled": 0}
|
||||
assert job["started_at"] and job["finished_at"] and job["usage"]["calls"] >= 1
|
||||
by = {t["table_name"]: t for t in job["tasks"]}
|
||||
assert by["nope"]["status"] == "skipped" and by["nope"]["error"] == "表结构里没有 nope,请先重新探查结构"
|
||||
visits = by["visits"]
|
||||
assert visits["status"] == "ok" and visits["parts_total"] == 1 and visits["parts_done"] == 1
|
||||
assert visits["model_items"] == 3 and visits["added"] == 4 and visits["version"] == 1 and visits["columns"] == 4
|
||||
assert visits["started_at"] and visits["finished_at"] and visits["error"] is None
|
||||
assert job["added"] == sum(t["added"] for t in job["tasks"])
|
||||
# 起草要求和本次说明都交给了模型
|
||||
assert "起草要求:金额一律按元写单位" in model.calls[0] and "本次说明:看渠道" in model.calls[0]
|
||||
|
||||
detail = (await client.get(f"{_base(sid)}/visits")).json()
|
||||
assert detail["notes"]["label"]["value"] == "入园记录" and detail["updated_by"] == "王敏"
|
||||
|
||||
|
||||
async def test_a_job_without_the_model_writes_structure_only(client):
|
||||
sid = await _source()
|
||||
r = await client.post(f"{_base(sid)}/draft-jobs", json={"tables": [], "use_model": False})
|
||||
assert r.status_code == 202, r.text
|
||||
job = r.json()["job"]
|
||||
assert job["total"] == 3, "不给表时按使用次数取前 20 张"
|
||||
await finished(job["id"])
|
||||
job = await get_job(client, sid, job["id"])
|
||||
assert job["status"] == "succeeded" and job["model"] is None and job["usage"]["calls"] == 0
|
||||
assert all(t["parts_total"] is None and t["model_items"] is None for t in job["tasks"])
|
||||
assert {t["table_name"]: t["added"] for t in job["tasks"]}["visits"] == 1 # 外键一条
|
||||
|
||||
|
||||
async def test_a_table_whose_model_part_failed_is_failed_but_structure_is_written(client, monkeypatch):
|
||||
sid = await _source()
|
||||
use(monkeypatch, FakeModel(REPLIES, fail={"visits"}))
|
||||
monkeypatch.setattr(catalog, "MODEL_BATCH_TABLES", 1)
|
||||
job = (await client.post(f"{_base(sid)}/draft-jobs", json={"tables": ["visits", "channels"],
|
||||
"use_model": True})).json()["job"]
|
||||
await finished(job["id"])
|
||||
job = await get_job(client, sid, job["id"])
|
||||
by = {t["table_name"]: t for t in job["tasks"]}
|
||||
assert job["status"] == "partial"
|
||||
assert by["visits"]["status"] == "failed" and "网关超时" in by["visits"]["model_error"]
|
||||
assert by["visits"]["error"] is None and by["visits"]["added"] == 1, "外键照常写入"
|
||||
assert by["channels"]["status"] == "ok"
|
||||
|
||||
|
||||
class HalfThenHollow:
|
||||
"""假模型:第一次只给前一半列,补问时只给一个空壳(表名对上、一项都没有)。"""
|
||||
|
||||
model_name = "half-model"
|
||||
|
||||
def __init__(self, table: str, reply: dict) -> None:
|
||||
self.table, self.reply, self.calls = table, reply, 0
|
||||
|
||||
def with_structured_output(self, schema, **_):
|
||||
outer = self
|
||||
|
||||
class _S:
|
||||
async def ainvoke(self, messages):
|
||||
outer.calls += 1
|
||||
if outer.calls == 1:
|
||||
cols = outer.reply["columns"]
|
||||
return {"tables": [{"table": outer.table, **outer.reply, "columns": cols[:len(cols) // 2]}]}
|
||||
return {"tables": [{"table": outer.table}]}
|
||||
|
||||
return _S()
|
||||
|
||||
async def ainvoke(self, messages):
|
||||
raise AssertionError("这里不该走纯文本")
|
||||
|
||||
|
||||
async def test_a_followup_that_comes_back_empty_keeps_the_earlier_model_items(client, monkeypatch):
|
||||
"""补问只给空壳:表是 partial、任务是 partial,以前起草过的列(待确认的模型起草项)不因这次没写全被删掉。"""
|
||||
cols = [_col(f"c{i:02d}") for i in range(1, 9)]
|
||||
sid = await _source({"tickets": {"qualified": "tickets", "columns": cols, "primary_key": ["c01"], "comment": None,
|
||||
"is_view": False, "foreign_keys": []}})
|
||||
full = {"label": "门票", "kind": "fact",
|
||||
"columns": [{"name": f"c{i:02d}", "label": f"第{i}列"} for i in range(1, 9)]}
|
||||
use(monkeypatch, FakeModel({"tickets": full}))
|
||||
first = (await client.post(f"{_base(sid)}/draft-jobs", json={"tables": ["tickets"], "use_model": True})).json()
|
||||
await finished(first["job"]["id"])
|
||||
before = (await client.get(f"{_base(sid)}/tickets")).json()["notes"]["columns"]
|
||||
assert {c for c, items in before.items() if "label" in items} == {f"c{i:02d}" for i in range(1, 9)}
|
||||
|
||||
model = HalfThenHollow("tickets", full)
|
||||
use(monkeypatch, model)
|
||||
second = (await client.post(f"{_base(sid)}/draft-jobs", json={"tables": ["tickets"], "use_model": True})).json()
|
||||
await finished(second["job"]["id"])
|
||||
job = await get_job(client, sid, second["job"]["id"])
|
||||
task = job["tasks"][0]
|
||||
assert model.calls == 2
|
||||
assert job["status"] == "partial" and task["status"] == "partial" and task["removed"] == 0
|
||||
assert "有 4 列(c05 到 c08)没写出来" in task["model_error"] and "可以只重试这张表" in task["model_error"]
|
||||
after = (await client.get(f"{_base(sid)}/tickets")).json()["notes"]["columns"]
|
||||
assert {c for c, items in after.items() if "label" in items} == {f"c{i:02d}" for i in range(1, 9)}
|
||||
|
||||
|
||||
def _tickets(n: int = 6) -> dict:
|
||||
return {"tickets": {"qualified": "tickets", "columns": [_col(f"c{i:02d}") for i in range(1, n + 1)],
|
||||
"primary_key": ["c01"], "comment": None, "is_view": False, "foreign_keys": []}}
|
||||
|
||||
|
||||
async def _draft(client, sid: str, monkeypatch, model, tables: list[str]) -> dict:
|
||||
use(monkeypatch, model)
|
||||
r = await client.post(f"{_base(sid)}/draft-jobs", json={"tables": tables, "use_model": True})
|
||||
assert r.status_code == 202, r.text
|
||||
await finished(r.json()["job"]["id"])
|
||||
return await get_job(client, sid, r.json()["job"]["id"])
|
||||
|
||||
|
||||
async def test_a_redraft_keeps_what_the_model_left_blank_this_time(client, monkeypatch):
|
||||
"""重新起草(比如补了起草要求再起草一次)时模型这次留空、或者给的值没通过校验:以前起草好的模型项留着,不算删除;
|
||||
给了合格新值的才替换。以前整库重跑一次删掉几百项,表仍记「已完成」。"""
|
||||
sid = await _source(_tickets())
|
||||
full = {"label": "门票", "kind": "fact",
|
||||
"columns": [{"name": f"c{i:02d}", "label": f"第{i}列", "meaning": f"第{i}列的含义"} for i in range(1, 6)]}
|
||||
first = await _draft(client, sid, monkeypatch, FakeModel({"tickets": full}), ["tickets"])
|
||||
assert first["tasks"][0]["status"] == "ok" and first["blank_columns"] == 1, "c06 第一次就留空了"
|
||||
before = (await client.get(f"{_base(sid)}/tickets")).json()["notes"]
|
||||
|
||||
# 这一次:表的中文名换了;c03 的中文名像公式(校验丢掉)、含义换了;c04 只给了中文名;其余列列出了但都留空
|
||||
again = {"label": "门票明细", "columns": [{"name": "c03", "label": "=c01+c02", "meaning": "新的含义"},
|
||||
{"name": "c04", "label": "第4列"}]}
|
||||
second = await _draft(client, sid, monkeypatch, FakeModel({"tickets": again}), ["tickets"])
|
||||
task = second["tasks"][0]
|
||||
assert task["status"] == "ok" and task["removed"] == 0 and second["removed"] == 0
|
||||
assert (task["added"], task["updated"]) == (0, 2)
|
||||
after = (await client.get(f"{_base(sid)}/tickets")).json()["notes"]
|
||||
assert after["label"]["value"] == "门票明细" and after["kind"] == before["kind"]
|
||||
assert after["columns"]["c03"]["label"] == before["columns"]["c03"]["label"]
|
||||
assert after["columns"]["c03"]["meaning"]["value"] == "新的含义"
|
||||
for col in ("c01", "c02", "c05"):
|
||||
assert after["columns"][col] == before["columns"][col], col
|
||||
# 留空的列只记目录里还是没有中文名、含义的(c06):c01、c02、c05 这次留空,以前起草好的还在,不算留空——不然照
|
||||
# 提示补了起草要求再起草,提示指着的却是目录里有中文名的列,一直消不掉
|
||||
assert task["blank_columns"] == 1 and task["blank_sample"] == ["c06"]
|
||||
|
||||
|
||||
async def test_model_items_of_a_column_gone_from_the_schema_are_removed(client, monkeypatch):
|
||||
sid = await _source(_tickets())
|
||||
full = {"label": "门票", "columns": [{"name": f"c{i:02d}", "label": f"第{i}列"} for i in range(1, 7)]}
|
||||
await _draft(client, sid, monkeypatch, FakeModel({"tickets": full}), ["tickets"])
|
||||
async with SessionLocal() as session:
|
||||
row = await session.get(DataSource, sid)
|
||||
row.schema_cache = {"tables": _tickets(5)} # 重新探查过:c06 已经没有了
|
||||
await session.commit()
|
||||
job = await _draft(client, sid, monkeypatch, FakeModel({"tickets": {"label": "门票"}}), ["tickets"])
|
||||
assert job["tasks"][0]["removed"] == 1
|
||||
after = (await client.get(f"{_base(sid)}/tickets")).json()["notes"]
|
||||
assert set(after["columns"]) == {"c01", "c02", "c03", "c04", "c05"}
|
||||
|
||||
|
||||
async def test_usage_counts_timeouts_and_failed_calls(client, monkeypatch):
|
||||
"""超时的调用拆小再问,用量里记超时几次、没写出来几次(超时的调用拿不到用量,token 记 0);额度用完或超时累计 2 次,
|
||||
新开始的段列数上限减半,任务上记着当前的上限。"""
|
||||
monkeypatch.setattr(catalog, "MODEL_TIMEOUT_S", 0.05)
|
||||
sid = await _source(_tickets(24))
|
||||
replies = {"tickets": {"label": "门票", "columns": [{"name": f"c{i:02d}", "label": f"第{i}列"}
|
||||
for i in range(1, 25)]}}
|
||||
model = FakeModel(replies, hang=2)
|
||||
job = await _draft(client, sid, monkeypatch, model, ["tickets"])
|
||||
assert job["status"] == "succeeded" and job["tasks"][0]["status"] == "ok"
|
||||
assert job["usage"] == {"calls": 5, "input_tokens": 0, "output_tokens": 0, "timeouts": 2, "failed_calls": 2}
|
||||
assert job["part_columns"] == 20 and job["notice"] is None
|
||||
# 24 列整张问超时 → 两半同时问:前 12 列(带表级的项)超时、后 12 列写出 → 前 12 列拆成 6、6
|
||||
assert [len(requested(c)["tickets"][1] or []) for c in model.calls] == [0, 12, 12, 6, 6]
|
||||
|
||||
plain = await _draft(client, sid, monkeypatch, FakeModel(replies), ["tickets"])
|
||||
assert plain["part_columns"] == 40 and plain["usage"]["timeouts"] == plain["usage"]["failed_calls"] == 0
|
||||
|
||||
|
||||
async def test_the_notice_is_kept_on_the_job(client, monkeypatch):
|
||||
"""最近 6 次调用有 4 次以上没写出来:任务上写一句提醒(次数、原因、建议换起草模型);不用模型的任务没有。"""
|
||||
monkeypatch.setattr(catalog, "MODEL_BATCH_TABLES", 1)
|
||||
tables = {f"t{i}": {"qualified": f"t{i}", "columns": [_col("id"), _col("v")], "primary_key": ["id"],
|
||||
"comment": None, "is_view": False, "foreign_keys": []} for i in range(1, 6)}
|
||||
sid = await _source(tables)
|
||||
replies = {name: {"label": f"表{name}"} for name in tables}
|
||||
job = await _draft(client, sid, monkeypatch, FakeModel(replies, fail={"t1", "t2", "t3", "t4"}), list(tables))
|
||||
assert job["usage"]["calls"] == 5 and job["usage"]["failed_calls"] == 4
|
||||
assert job["notice"] == ("最近 5 次调用有 4 次没写出来(出错 4 次)。建议在「设置 → 偏好设置」的「起草模型」换一个模型、"
|
||||
"缩小起草范围,或者停止这次起草")
|
||||
plain = (await client.post(f"{_base(sid)}/draft-jobs", json={"tables": ["t1"]})).json()["job"]
|
||||
await finished(plain["id"])
|
||||
plain = await get_job(client, sid, plain["id"])
|
||||
assert plain["notice"] is None and plain["part_columns"] is None and plain["blank_columns"] == 0
|
||||
|
||||
|
||||
async def test_blank_columns_reach_the_task_the_job_and_the_list(client, monkeypatch):
|
||||
"""模型列出了、中文名和含义都没给的列:逐表记列数和最多 5 个列名,任务上是合计,目录清单的 last_draft 也带列数。"""
|
||||
tables = {**_tickets(8), "gates": _tables()["gates"]}
|
||||
sid = await _source(tables)
|
||||
replies = {"tickets": {"label": "门票", "columns": [{"name": "c01", "label": "编号"}]},
|
||||
"gates": {"label": "闸口", "columns": [{"name": "id", "label": "编号"}]}}
|
||||
job = await _draft(client, sid, monkeypatch, FakeModel(replies), ["tickets", "gates"])
|
||||
by = {t["table_name"]: t for t in job["tasks"]}
|
||||
assert by["tickets"]["status"] == "ok" and by["tickets"]["blank_columns"] == 7
|
||||
assert by["tickets"]["blank_sample"] == ["c02", "c03", "c04", "c05", "c06"]
|
||||
assert by["gates"]["blank_columns"] == 1 and by["gates"]["blank_sample"] == ["gate_code"]
|
||||
assert job["blank_columns"] == 8
|
||||
rows = {r["table_name"]: r for r in (await client.get(_base(sid))).json()["tables"]}
|
||||
assert rows["tickets"]["last_draft"]["blank_columns"] == 7 and rows["gates"]["last_draft"]["blank_columns"] == 1
|
||||
|
||||
|
||||
async def test_blank_columns_are_counted_against_the_catalog_as_written(client, monkeypatch):
|
||||
"""留空的列按并入目录之后的结果算:起草进行中有人给某一列填了中文名,模型这次留空,这一列不算留空。"""
|
||||
sid = await _source(_tickets(4))
|
||||
model = Held({"tickets": {"label": "门票", "columns": [{"name": "c01", "label": "编号"}]}}, hold={"tickets"})
|
||||
use(monkeypatch, model)
|
||||
r = await client.post(f"{_base(sid)}/draft-jobs", json={"tables": ["tickets"], "use_model": True})
|
||||
job_id = r.json()["job"]["id"]
|
||||
|
||||
async def asking() -> bool:
|
||||
return (await get_job(client, sid, job_id))["tasks"][0]["status"] == "running"
|
||||
|
||||
await until(asking)
|
||||
saved = await client.put(f"{_base(sid)}/tickets", json={"notes": {"columns": {"c02": {"label": {"value": "闸机号"}}}},
|
||||
"if_version": 0})
|
||||
assert saved.status_code == 200, saved.text
|
||||
model.release.set()
|
||||
await finished(job_id)
|
||||
task = (await get_job(client, sid, job_id))["tasks"][0]
|
||||
assert task["status"] == "ok" and task["blank_columns"] == 2 and task["blank_sample"] == ["c03", "c04"]
|
||||
after = (await client.get(f"{_base(sid)}/tickets")).json()["notes"]["columns"]
|
||||
assert after["c02"]["label"]["value"] == "闸机号" and after["c01"]["label"]["value"] == "编号"
|
||||
|
||||
|
||||
async def test_the_sync_draft_is_409_while_a_job_is_queued_or_running(client, monkeypatch):
|
||||
"""旧的同步起草不和后台任务同时写同一个源的目录、抢模型名额:有排队或进行中的任务时 409(和建任务同一句话)。"""
|
||||
sid = await _source()
|
||||
start = catalog_jobs.manager.start
|
||||
monkeypatch.setattr(catalog_jobs.manager, "start", lambda job_id: None) # 排着、不开始
|
||||
queued = (await client.post(f"{_base(sid)}/draft-jobs", json={"tables": ["gates"]})).json()["job"]
|
||||
r = await client.post(f"{_base(sid)}/draft", json={"tables": ["channels"]})
|
||||
assert r.status_code == 409 and r.json()["code"] == "catalog_draft_busy", r.text
|
||||
busy = await client.post(f"{_base(sid)}/draft-jobs", json={"tables": ["gates"]})
|
||||
assert busy.status_code == 409 and r.json()["detail"] == busy.json()["detail"]
|
||||
assert (await client.get(f"{_base(sid)}/channels")).json()["version"] == 0, "什么都没写"
|
||||
await client.post(f"{_base(sid)}/draft-jobs/{queued['id']}/cancel")
|
||||
|
||||
monkeypatch.setattr(catalog_jobs.manager, "start", start)
|
||||
model = Held(REPLIES, hold={"visits"})
|
||||
use(monkeypatch, model)
|
||||
running = (await client.post(f"{_base(sid)}/draft-jobs", json={"tables": ["visits"], "use_model": True})).json()
|
||||
r = await client.post(f"{_base(sid)}/draft", json={"tables": ["channels"]})
|
||||
assert r.status_code == 409 and r.json()["code"] == "catalog_draft_busy", r.text
|
||||
model.release.set()
|
||||
await finished(running["job"]["id"])
|
||||
r = await client.post(f"{_base(sid)}/draft", json={"tables": ["channels"]})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_old_job_tables_get_the_new_columns():
|
||||
"""升级前建的起草任务表:新列补上,存量行的计数是 0、提醒和列数上限为空。"""
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
from app.db.base import _migrate
|
||||
|
||||
db = create_engine("sqlite://")
|
||||
with db.begin() as conn:
|
||||
conn.execute(text("CREATE TABLE catalog_draft_jobs (id VARCHAR(32) PRIMARY KEY, calls INTEGER)"))
|
||||
conn.execute(text("CREATE TABLE catalog_draft_tasks (id VARCHAR(32) PRIMARY KEY, removed INTEGER)"))
|
||||
conn.execute(text("INSERT INTO catalog_draft_jobs (id, calls) VALUES ('j1', 3)"))
|
||||
conn.execute(text("INSERT INTO catalog_draft_tasks (id, removed) VALUES ('t1', 0)"))
|
||||
_migrate(conn)
|
||||
_migrate(conn)
|
||||
assert conn.execute(text("SELECT timeouts, failed_calls, part_columns, notice FROM catalog_draft_jobs")) \
|
||||
.one() == (0, 0, None, None)
|
||||
assert conn.execute(text("SELECT blank_columns, blank_sample FROM catalog_draft_tasks")).one() == (0, None)
|
||||
|
||||
|
||||
async def test_a_job_that_cannot_start_is_failed_with_the_reason(client, monkeypatch):
|
||||
"""建任务时起草模型还能用,开始起草时已经用不了(接入刚被停用):任务整体 failed,error 写原因,没开始的表也记 failed。"""
|
||||
sid = await _source()
|
||||
calls: list[int] = []
|
||||
|
||||
async def flaky(session):
|
||||
calls.append(1)
|
||||
if len(calls) > 1:
|
||||
raise catalog.CatalogModelUnavailable("起草模型使用的模型接入「虚构接入」已停用")
|
||||
return FakeModel(REPLIES), "fake-model"
|
||||
|
||||
monkeypatch.setattr(catalog, "resolve_draft_model", flaky)
|
||||
job = (await client.post(f"{_base(sid)}/draft-jobs", json={"tables": ["visits", "gates"],
|
||||
"use_model": True})).json()["job"]
|
||||
await finished(job["id"])
|
||||
got = await get_job(client, sid, job["id"])
|
||||
assert got["status"] == "failed" and got["error"] == "起草模型使用的模型接入「虚构接入」已停用"
|
||||
assert {t["status"] for t in got["tasks"]} == {"failed"} and got["finished_at"]
|
||||
assert all(t["error"] == got["error"] for t in got["tasks"])
|
||||
retry = await client.post(f"{_base(sid)}/draft-jobs/{job['id']}/retry")
|
||||
assert retry.status_code == 409 and retry.json()["code"] == "catalog_draft_model_unavailable"
|
||||
|
||||
|
||||
async def test_busy_source_is_409_and_the_active_job_is_reachable(client, monkeypatch):
|
||||
sid = await _source()
|
||||
model = Held(REPLIES, hold={"visits"})
|
||||
use(monkeypatch, model)
|
||||
first = (await client.post(f"{_base(sid)}/draft-jobs", json={"tables": ["visits"], "use_model": True})).json()
|
||||
r = await client.post(f"{_base(sid)}/draft-jobs", json={"tables": ["gates"], "use_model": False})
|
||||
assert r.status_code == 409 and r.json()["code"] == "catalog_draft_busy", r.text
|
||||
active = (await client.get(f"{_base(sid)}/draft-jobs/active")).json()["job"]
|
||||
assert active["id"] == first["job"]["id"]
|
||||
listed = (await client.get(_base(sid))).json()
|
||||
assert listed["active_job"]["id"] == first["job"]["id"] and listed["active_job"]["total"] == 1
|
||||
assert listed["active_job"]["status"] in ("queued", "running") and listed["active_job"]["done"] == 0
|
||||
model.release.set()
|
||||
await finished(first["job"]["id"])
|
||||
assert (await client.get(f"{_base(sid)}/draft-jobs/active")).json()["job"] is None
|
||||
latest = (await client.get(f"{_base(sid)}/draft-jobs/latest")).json()["job"]
|
||||
assert latest["id"] == first["job"]["id"] and latest["status"] == "succeeded"
|
||||
assert (await client.get(_base(sid))).json()["active_job"] is None
|
||||
|
||||
|
||||
async def test_unavailable_model_is_409_and_no_job_is_created(client, monkeypatch):
|
||||
sid = await _source()
|
||||
|
||||
async def unavailable(session):
|
||||
raise catalog.CatalogModelUnavailable("未配置模型接入,无法由模型起草数据目录。请到「设置 → 模型接入」添加")
|
||||
|
||||
monkeypatch.setattr(catalog, "resolve_draft_model", unavailable)
|
||||
r = await client.post(f"{_base(sid)}/draft-jobs", json={"use_model": True})
|
||||
assert r.status_code == 409, r.text
|
||||
assert r.json()["code"] == "catalog_draft_model_unavailable" and "模型接入" in r.json()["detail"]
|
||||
assert (await client.get(f"{_base(sid)}/draft-jobs/latest")).json()["job"] is None
|
||||
|
||||
|
||||
async def test_without_schema_or_with_bad_input_nothing_is_created(client):
|
||||
sid = await _source(tables={})
|
||||
r = await client.post(f"{_base(sid)}/draft-jobs", json={"use_model": False})
|
||||
assert r.status_code == 409 and "探查结构" in r.json()["detail"]
|
||||
sid = await _source()
|
||||
r = await client.post(f"{_base(sid)}/draft-jobs", json={"use_model": False, "note": "说" * 1001})
|
||||
assert r.status_code == 422 and "1000" in r.json()["detail"]
|
||||
r = await client.post(f"{_base(sid)}/draft-jobs", json={"tables": [f"t{i}" for i in range(501)]})
|
||||
assert r.status_code == 422 and "500" in r.json()["detail"]
|
||||
assert (await client.get(f"{_base(sid)}/draft-jobs/latest")).json()["job"] is None
|
||||
assert (await client.get(f"{_base('missing-source')}/draft-jobs/latest")).status_code == 404
|
||||
assert (await client.get(f"{_base(sid)}/draft-jobs/not-a-job")).status_code == 404
|
||||
|
||||
|
||||
async def test_a_job_of_another_source_is_404(client):
|
||||
a, b = await _source(), await _source()
|
||||
job = (await client.post(f"{_base(a)}/draft-jobs", json={"tables": ["gates"]})).json()["job"]
|
||||
await finished(job["id"])
|
||||
assert (await client.get(f"{_base(b)}/draft-jobs/{job['id']}")).status_code == 404
|
||||
assert (await client.post(f"{_base(b)}/draft-jobs/{job['id']}/cancel")).status_code == 404
|
||||
assert (await client.post(f"{_base(b)}/draft-jobs/{job['id']}/retry")).status_code == 404
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 停止、服务重启
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_cancelling_a_queued_job(client, monkeypatch):
|
||||
sid = await _source()
|
||||
monkeypatch.setattr(catalog_jobs.manager, "start", lambda job_id: None) # 不让它开始
|
||||
job = (await client.post(f"{_base(sid)}/draft-jobs", json={"tables": ["gates", "channels"]})).json()["job"]
|
||||
assert job["status"] == "queued"
|
||||
r = await client.post(f"{_base(sid)}/draft-jobs/{job['id']}/cancel")
|
||||
assert r.status_code == 200, r.text
|
||||
job = r.json()["job"]
|
||||
assert job["status"] == "cancelled" and job["finished_at"]
|
||||
assert {t["status"] for t in job["tasks"]} == {"cancelled"}
|
||||
again = await client.post(f"{_base(sid)}/draft-jobs/{job['id']}/cancel")
|
||||
assert again.status_code == 409 and again.json()["code"] == "catalog_draft_finished"
|
||||
|
||||
|
||||
async def test_cancelling_a_running_job_stops_the_call_and_keeps_what_is_written(client, monkeypatch):
|
||||
sid = await _source()
|
||||
model = Held(REPLIES, hold={"visits"})
|
||||
use(monkeypatch, model)
|
||||
monkeypatch.setattr(catalog, "MODEL_BATCH_TABLES", 1)
|
||||
job = (await client.post(f"{_base(sid)}/draft-jobs",
|
||||
json={"tables": ["channels", "visits"], "use_model": True})).json()["job"]
|
||||
|
||||
async def channels_written() -> bool:
|
||||
tasks = {t["table_name"]: t for t in (await get_job(client, sid, job["id"]))["tasks"]}
|
||||
return tasks["channels"]["status"] == "ok" and tasks["visits"]["status"] == "running"
|
||||
|
||||
await until(channels_written)
|
||||
r = await client.post(f"{_base(sid)}/draft-jobs/{job['id']}/cancel")
|
||||
assert r.status_code == 200, r.text
|
||||
after = r.json()["job"]
|
||||
by = {t["table_name"]: t for t in after["tasks"]}
|
||||
assert after["status"] == "cancelled" and after["finished_at"]
|
||||
assert by["channels"]["status"] == "ok" and by["visits"]["status"] == "cancelled"
|
||||
assert model.cancelled == 1, "正在进行的模型调用被停掉"
|
||||
detail = (await client.get(f"{_base(sid)}/channels")).json()
|
||||
assert detail["notes"]["kind"]["value"] == "dimension", "已经写进目录的不回滚"
|
||||
assert (await client.get(f"{_base(sid)}/draft-jobs/active")).json()["job"] is None
|
||||
|
||||
|
||||
async def test_startup_marks_unfinished_jobs_interrupted_and_does_not_resume(client):
|
||||
sid = await _source()
|
||||
async with SessionLocal() as session:
|
||||
job = CatalogDraftJob(source_id=sid, status="running", use_model=True)
|
||||
session.add(job)
|
||||
await session.flush()
|
||||
for seq, (name, status) in enumerate([("visits", "ok"), ("channels", "running"), ("gates", "pending")]):
|
||||
session.add(CatalogDraftTask(job_id=job.id, seq=seq, table_name=name, status=status))
|
||||
await session.commit()
|
||||
job_id = job.id
|
||||
assert await catalog_jobs.startup() >= 1
|
||||
got = await get_job(client, sid, job_id)
|
||||
by = {t["table_name"]: t for t in got["tasks"]}
|
||||
assert got["status"] == "interrupted" and got["error"] == "服务重启,起草中断" and got["finished_at"]
|
||||
assert by["visits"]["status"] == "ok" and by["visits"]["error"] is None
|
||||
assert by["channels"]["status"] == by["gates"]["status"] == "cancelled"
|
||||
assert by["channels"]["error"] == by["gates"]["error"] == "服务重启,起草中断"
|
||||
assert not catalog_jobs.manager.running(job_id), "不自动续跑"
|
||||
|
||||
|
||||
async def test_shutdown_stops_running_jobs_as_interrupted(client, monkeypatch):
|
||||
sid = await _source()
|
||||
model = Held(REPLIES, hold={"visits"})
|
||||
use(monkeypatch, model)
|
||||
job = (await client.post(f"{_base(sid)}/draft-jobs", json={"tables": ["visits"], "use_model": True})).json()["job"]
|
||||
|
||||
async def running() -> bool:
|
||||
return (await get_job(client, sid, job["id"]))["tasks"][0]["status"] == "running"
|
||||
|
||||
await until(running)
|
||||
await catalog_jobs.manager.shutdown(grace=5)
|
||||
got = await get_job(client, sid, job["id"])
|
||||
assert got["status"] == "interrupted" and got["tasks"][0]["status"] == "cancelled"
|
||||
assert got["tasks"][0]["error"] == "服务重启,起草中断"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 重试
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_retry_takes_only_failed_partial_and_cancelled_tables(client, monkeypatch):
|
||||
sid = await _source()
|
||||
use(monkeypatch, FakeModel(REPLIES, fail={"visits"}))
|
||||
monkeypatch.setattr(catalog, "MODEL_BATCH_TABLES", 1)
|
||||
await client.put(f"{_base(sid)}/guidance", json={"guidance": "旧的要求"})
|
||||
first = (await client.post(f"{_base(sid)}/draft-jobs", json={
|
||||
"tables": ["visits", "channels", "nope"], "use_model": True, "note": "第一次"})).json()["job"]
|
||||
await finished(first["id"])
|
||||
await client.put(f"{_base(sid)}/guidance", json={"guidance": "新的要求"})
|
||||
assert (await get_job(client, sid, first["id"]))["guidance"] == "旧的要求", "任务记下的是当时的快照"
|
||||
|
||||
use(monkeypatch, FakeModel(REPLIES))
|
||||
r = await client.post(f"{_base(sid)}/draft-jobs/{first['id']}/retry", headers=ACTOR)
|
||||
assert r.status_code == 202, r.text
|
||||
retry = r.json()["job"]
|
||||
assert [t["table_name"] for t in retry["tasks"]] == ["visits"]
|
||||
assert retry["use_model"] is True and retry["note"] == "第一次" and retry["guidance"] == "新的要求"
|
||||
assert retry["retry_of"] == first["id"] and retry["created_by"] == "王敏"
|
||||
await finished(retry["id"])
|
||||
assert (await get_job(client, sid, retry["id"]))["status"] == "succeeded"
|
||||
|
||||
nothing = await client.post(f"{_base(sid)}/draft-jobs/{retry['id']}/retry")
|
||||
assert nothing.status_code == 422 and "没有" in nothing.json()["detail"]
|
||||
|
||||
|
||||
async def test_retry_while_busy_is_409(client, monkeypatch):
|
||||
sid = await _source()
|
||||
monkeypatch.setattr(catalog_jobs.manager, "start", lambda job_id: None)
|
||||
job = (await client.post(f"{_base(sid)}/draft-jobs", json={"tables": ["gates"]})).json()["job"]
|
||||
await client.post(f"{_base(sid)}/draft-jobs/{job['id']}/cancel")
|
||||
other = (await client.post(f"{_base(sid)}/draft-jobs", json={"tables": ["channels"]})).json()["job"]
|
||||
assert other["status"] == "queued"
|
||||
r = await client.post(f"{_base(sid)}/draft-jobs/{job['id']}/retry")
|
||||
assert r.status_code == 409 and r.json()["code"] == "catalog_draft_busy"
|
||||
|
||||
|
||||
async def test_retry_with_an_unavailable_model_is_409(client, monkeypatch):
|
||||
sid = await _source()
|
||||
monkeypatch.setattr(catalog_jobs.manager, "start", lambda job_id: None)
|
||||
use(monkeypatch, FakeModel(REPLIES))
|
||||
job = (await client.post(f"{_base(sid)}/draft-jobs", json={"tables": ["gates"], "use_model": True})).json()["job"]
|
||||
await client.post(f"{_base(sid)}/draft-jobs/{job['id']}/cancel")
|
||||
|
||||
async def unavailable(session):
|
||||
raise catalog.CatalogModelUnavailable("当前只有演示用的模型接入,无法用于起草数据目录")
|
||||
|
||||
monkeypatch.setattr(catalog, "resolve_draft_model", unavailable)
|
||||
r = await client.post(f"{_base(sid)}/draft-jobs/{job['id']}/retry")
|
||||
assert r.status_code == 409 and r.json()["code"] == "catalog_draft_model_unavailable"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 目录清单上的起草状态
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_the_list_carries_last_draft_per_table(client, monkeypatch):
|
||||
sid = await _source()
|
||||
use(monkeypatch, FakeModel(REPLIES, fail={"visits"}))
|
||||
monkeypatch.setattr(catalog, "MODEL_BATCH_TABLES", 1)
|
||||
first = (await client.post(f"{_base(sid)}/draft-jobs", json={"tables": ["visits", "channels"],
|
||||
"use_model": True})).json()["job"]
|
||||
await finished(first["id"])
|
||||
second = (await client.post(f"{_base(sid)}/draft-jobs", json={"tables": ["channels"]})).json()["job"]
|
||||
await finished(second["id"])
|
||||
body = (await client.get(_base(sid))).json()
|
||||
rows = {r["table_name"]: r for r in body["tables"]}
|
||||
assert rows["gates"]["last_draft"] is None
|
||||
visits = rows["visits"]["last_draft"]
|
||||
assert visits["status"] == "failed" and visits["job_id"] == first["id"] and visits["finished_at"]
|
||||
assert "网关超时" in visits["model_error"] and visits["error"] is None
|
||||
assert rows["channels"]["last_draft"]["job_id"] == second["id"], "取最近一次任务里的那一项"
|
||||
assert body["guidance"] is None and body["active_job"] is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 起草要求
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_guidance_is_saved_trimmed_cleared_and_limited(client):
|
||||
sid = await _source()
|
||||
r = await client.put(f"{_base(sid)}/guidance", json={"guidance": " 票务库,金额按元。\n状态 9 是作废 "})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {"guidance": "票务库,金额按元。\n状态 9 是作废"}
|
||||
assert (await client.get(_base(sid))).json()["guidance"] == "票务库,金额按元。\n状态 9 是作废"
|
||||
too_long = await client.put(f"{_base(sid)}/guidance", json={"guidance": "字" * 2001})
|
||||
assert too_long.status_code == 422 and "2000" in too_long.json()["detail"]
|
||||
assert (await client.get(_base(sid))).json()["guidance"] == "票务库,金额按元。\n状态 9 是作废"
|
||||
assert (await client.put(f"{_base(sid)}/guidance", json={"guidance": "字" * 2000})).status_code == 200
|
||||
cleared = await client.put(f"{_base(sid)}/guidance", json={"guidance": " "})
|
||||
assert cleared.json() == {"guidance": None}
|
||||
async with SessionLocal() as session:
|
||||
row = await session.get(DataSource, sid)
|
||||
assert CATALOG_DRAFT_GUIDANCE_OPTION not in (row.options or {})
|
||||
assert (await client.put(f"{_base('missing-source')}/guidance", json={"guidance": "x"})).status_code == 404
|
||||
|
||||
|
||||
async def test_a_table_named_guidance_can_still_be_saved(client):
|
||||
"""起草要求的路径和「整份提交一张表」撞名:表名恰好叫 guidance 时,{notes, if_version} 照旧是那张表的提交,
|
||||
不会被当成清除起草要求;两样都没写的 422。"""
|
||||
tables = _tables()
|
||||
tables["guidance"] = {"qualified": "guidance", "columns": [_col("id"), _col("body", "TEXT")],
|
||||
"primary_key": ["id"], "comment": None, "is_view": False, "foreign_keys": []}
|
||||
sid = await _source(tables)
|
||||
await client.put(f"{_base(sid)}/guidance", json={"guidance": "金额按元"})
|
||||
r = await client.put(f"{_base(sid)}/guidance", json={"notes": {"label": {"value": "操作指引"}}, "if_version": 0},
|
||||
headers=ACTOR)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["table_name"] == "guidance" and r.json()["notes"]["label"]["value"] == "操作指引"
|
||||
assert (await client.get(_base(sid))).json()["guidance"] == "金额按元"
|
||||
for body in ({}, {"guidance": 3}, {"guidance": ["金额按元"]}):
|
||||
r = await client.put(f"{_base(sid)}/guidance", json=body)
|
||||
assert r.status_code == 422, body
|
||||
assert isinstance(r.json()["detail"], str) and "起草要求" in r.json()["detail"], "说明是中文的一句话"
|
||||
assert (await client.get(_base(sid))).json()["guidance"] == "金额按元"
|
||||
|
||||
|
||||
async def test_guidance_of_an_uploaded_source_lives_on_its_own_row(client):
|
||||
sid = await _source(origin="upload")
|
||||
r = await client.put(f"{_base(sid)}/guidance", json={"guidance": "按期上传的客流日报"})
|
||||
assert r.status_code == 200, r.text
|
||||
async with SessionLocal() as session:
|
||||
row = await session.get(DataSource, sid)
|
||||
assert row.options[CATALOG_DRAFT_GUIDANCE_OPTION] == "按期上传的客流日报"
|
||||
|
||||
|
||||
async def test_the_datasource_form_neither_shows_nor_drops_the_guidance(client):
|
||||
sid = await _source(options={"schema": "main"})
|
||||
await client.put(f"{_base(sid)}/guidance", json={"guidance": "金额按元"})
|
||||
shown = (await client.get("/api/datasources")).json()
|
||||
mine = next(s for s in shown if s["id"] == sid)
|
||||
assert CATALOG_DRAFT_GUIDANCE_OPTION not in mine["options"] and mine["options"]["schema"] == "main"
|
||||
# 表单整份回写 options(不带起草要求,或者带着一份别的):起草要求留着,只认 PUT /catalog/guidance
|
||||
r = await client.patch(f"/api/datasources/{sid}", json={"options": {"schema": "other",
|
||||
CATALOG_DRAFT_GUIDANCE_OPTION: "篡改"}})
|
||||
assert r.status_code == 200, r.text
|
||||
assert CATALOG_DRAFT_GUIDANCE_OPTION not in r.json()["options"]
|
||||
assert (await client.get(_base(sid))).json()["guidance"] == "金额按元"
|
||||
async with SessionLocal() as session:
|
||||
assert (await session.get(DataSource, sid)).options == {"schema": "other",
|
||||
CATALOG_DRAFT_GUIDANCE_OPTION: "金额按元"}
|
||||
|
||||
|
||||
def test_the_guidance_never_reaches_the_driver():
|
||||
src = SimpleNamespace(kind="mysql", host="db.example", port=3306, database="shop", username="reader",
|
||||
password=None, options={"charset": "utf8mb4", CATALOG_DRAFT_GUIDANCE_OPTION: "金额按元"})
|
||||
url = build_url(src)
|
||||
assert "charset=utf8mb4" in url and CATALOG_DRAFT_GUIDANCE_OPTION not in url
|
||||
|
||||
|
||||
async def test_the_sync_draft_uses_the_source_guidance(client, monkeypatch):
|
||||
sid = await _source(description="虚构的景区票务库")
|
||||
model = FakeModel(REPLIES)
|
||||
use(monkeypatch, model)
|
||||
await client.put(f"{_base(sid)}/guidance", json={"guidance": "渠道表的名称是对外展示名"})
|
||||
r = await client.post(f"{_base(sid)}/draft", json={"tables": ["channels"], "use_model": True})
|
||||
assert r.status_code == 200, r.text
|
||||
assert "数据源说明:虚构的景区票务库\n起草要求:渠道表的名称是对外展示名" in model.calls[0]
|
||||
assert set(r.json()) == {"tables", "total", "model_used", "model", "model_error"}, "旧的同步接口返回形状不变"
|
||||
|
||||
|
||||
async def test_jobs_go_away_with_their_source(client):
|
||||
sid = await _source()
|
||||
job = (await client.post(f"{_base(sid)}/draft-jobs", json={"tables": ["gates"]})).json()["job"]
|
||||
await finished(job["id"])
|
||||
await drop_source(sid)
|
||||
_CREATED.remove(sid)
|
||||
async with SessionLocal() as session:
|
||||
assert (await session.execute(select(CatalogDraftJob).where(CatalogDraftJob.id == job["id"]))).first() is None
|
||||
assert (await session.execute(select(CatalogDraftTask).where(CatalogDraftTask.job_id == job["id"]))).first() \
|
||||
is None
|
||||
@@ -0,0 +1,270 @@
|
||||
"""起草模型(设置里的 catalog_draft 一组)与起草取模型的顺序。
|
||||
|
||||
- 设置组 catalog_draft:{provider, model},经 PUT /api/settings 写、GET 读,没存过补 {provider: null, model: null};
|
||||
provider 不是字符串、点名的接入不存在或已停用 → 422,说法写设置页上的叫法「起草模型 · 接入」「起草模型 · 模型」
|
||||
- 取模型的顺序(数据目录起草和按配方导入的 AI 起草共用 draft_model.draft_model_choice):起草模型填了接入或模型就整组
|
||||
用它;否则沿用助手的模型设置;再没有就是默认接入。不跨级拼接入和模型
|
||||
- 数据目录起草不关思考(按接入默认)、额度 32768,服务方嫌大退回 8192;配方 AI 起草只改取模型的顺序,额度、思考不动
|
||||
|
||||
接入名、模型名都是虚构的。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.data import catalog, draft_model
|
||||
from app.data import recipe_ai as A
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import Provider, Setting
|
||||
from app.main import app
|
||||
|
||||
LIVE, OFF, GONE = "起草用接入", "起草用停用接入", "起草用不存在的接入"
|
||||
|
||||
|
||||
async def _clean() -> None:
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(delete(Setting).where(Setting.key.in_([draft_model.CATALOG_DRAFT_SETTING_KEY])))
|
||||
await session.execute(delete(Provider).where(Provider.name.in_([LIVE, OFF])))
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def providers():
|
||||
"""一个启用、一个停用的演示类接入(不联网);用完删掉。"""
|
||||
await _clean()
|
||||
async with SessionLocal() as session:
|
||||
session.add_all([
|
||||
Provider(name=LIVE, kind="mock", models=[{"id": "起草模型甲"}], default_model="起草模型甲", enabled=True,
|
||||
extra={}),
|
||||
Provider(name=OFF, kind="mock", models=[{"id": "起草模型乙"}], default_model="起草模型乙", enabled=False,
|
||||
extra={}),
|
||||
])
|
||||
await session.commit()
|
||||
yield
|
||||
await _clean()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 设置组的读写与校验
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_the_group_is_exposed_with_empty_defaults(client, providers):
|
||||
got = (await client.get("/api/settings")).json()
|
||||
assert got["catalog_draft"] == {"provider": None, "model": None}
|
||||
|
||||
|
||||
async def test_the_group_round_trips(client, providers):
|
||||
r = await client.put("/api/settings", json={"values": {"catalog_draft": {"provider": LIVE, "model": "起草模型甲"}}})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["catalog_draft"] == {"provider": LIVE, "model": "起草模型甲"}
|
||||
got = (await client.get("/api/settings")).json()["catalog_draft"]
|
||||
assert got == {"provider": LIVE, "model": "起草模型甲"}
|
||||
# 只写模型、接入留空:照存,接入是 null
|
||||
r = await client.put("/api/settings", json={"values": {"catalog_draft": {"provider": " ", "model": "x"}}})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["catalog_draft"] == {"provider": None, "model": "x"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value,label", [
|
||||
({"provider": 3}, "起草模型 · 接入"),
|
||||
({"model": ["a"]}, "起草模型 · 模型"),
|
||||
({"temperature": 0}, "temperature"),
|
||||
("起草模型甲", "起草模型"),
|
||||
])
|
||||
async def test_bad_values_are_refused_with_the_settings_page_wording(client, providers, value, label):
|
||||
r = await client.put("/api/settings", json={"values": {"catalog_draft": value, "ui": {"theme": "dark"}}})
|
||||
assert r.status_code == 422, r.text
|
||||
assert label in r.json()["detail"]
|
||||
got = (await client.get("/api/settings")).json()
|
||||
assert got["catalog_draft"] == {"provider": None, "model": None} and got["ui"]["theme"] != "dark"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,reason,fix", [
|
||||
(GONE, f"模型接入「{GONE}」不存在", "请在「设置 → 模型接入」中核对名称"),
|
||||
(OFF, f"模型接入「{OFF}」已停用", "请在「设置 → 模型接入」中启用它"),
|
||||
])
|
||||
async def test_a_missing_or_disabled_provider_is_refused_with_the_field(client, providers, name, reason, fix):
|
||||
r = await client.put("/api/settings", json={"values": {"catalog_draft": {"provider": name}}})
|
||||
assert r.status_code == 422, r.text
|
||||
body = r.json()
|
||||
assert body["code"] == "settings_provider_invalid" and body["field"] == "catalog_draft.provider"
|
||||
assert body["detail"] == f"「起草模型 · 接入」指定的{reason}。{fix},或换一个接入"
|
||||
async with SessionLocal() as session:
|
||||
assert await session.get(Setting, "catalog_draft") is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 取模型的顺序
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _MemDB:
|
||||
"""独立的内存库:取模型只看这里的接入和设置,不受别的测试建的接入影响。"""
|
||||
|
||||
async def __aenter__(self):
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
import app.db.models # noqa: F401
|
||||
from app.db.base import Base
|
||||
|
||||
self.engine = create_async_engine("sqlite+aiosqlite:///:memory:", poolclass=StaticPool)
|
||||
async with self.engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
self.session = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession)()
|
||||
return self.session
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
await self.session.close()
|
||||
await self.engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mem():
|
||||
async with _MemDB() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_env_keys(monkeypatch):
|
||||
for k in ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENAI_API_KEY", "ANTHROPIC_BASE_URL"):
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
|
||||
|
||||
async def _setup(session, *, draft: dict | None = None, copilot: dict | None = None) -> None:
|
||||
from app.core.crypto import encrypt
|
||||
|
||||
for name, model in (("甲接入", "模型甲"), ("乙接入", "模型乙"), ("丙接入", "模型丙")):
|
||||
session.add(Provider(name=name, kind="anthropic", api_key=encrypt("sk-test"), models=[{"id": model}],
|
||||
default_model=model, enabled=True, extra={}))
|
||||
if draft is not None:
|
||||
session.add(Setting(key=draft_model.CATALOG_DRAFT_SETTING_KEY, value=draft))
|
||||
if copilot is not None:
|
||||
session.add(Setting(key=draft_model.COPILOT_SETTING_KEY, value=copilot))
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("draft,copilot,expected", [
|
||||
(None, None, (None, None, "default")),
|
||||
(None, {"provider": "乙接入", "model": ""}, ("乙接入", None, "assistant")),
|
||||
({"provider": None, "model": None}, {"provider": "", "model": "模型乙"}, (None, "模型乙", "assistant")),
|
||||
({"provider": "", "model": "模型丙"}, {"provider": "乙接入", "model": "模型乙"}, (None, "模型丙", "draft")),
|
||||
({"provider": "甲接入", "model": None}, {"provider": "乙接入", "model": "模型乙"}, ("甲接入", None, "draft")),
|
||||
({"provider": 7}, {"provider": "乙接入"}, ("乙接入", None, "assistant")), # 存坏了的按没填
|
||||
])
|
||||
async def test_choice_order(mem, draft, copilot, expected):
|
||||
await _setup(mem, draft=draft, copilot=copilot)
|
||||
got = await draft_model.draft_model_choice(mem)
|
||||
assert (got.provider, got.model, got.origin) == expected
|
||||
|
||||
|
||||
def _spy(monkeypatch) -> list[Any]:
|
||||
from app.providers import factory
|
||||
|
||||
specs: list[Any] = []
|
||||
|
||||
class Built:
|
||||
def __init__(self, provider, spec):
|
||||
self.provider, self.spec = provider.name, spec
|
||||
|
||||
def fake_build(provider, spec):
|
||||
specs.append((provider.name, spec))
|
||||
return Built(provider, spec)
|
||||
|
||||
monkeypatch.setattr(factory, "build_chat_model", fake_build)
|
||||
return specs
|
||||
|
||||
|
||||
async def test_catalog_draft_uses_the_draft_setting_keeps_thinking_and_a_big_budget(mem, no_env_keys, monkeypatch):
|
||||
await _setup(mem, draft={"provider": "甲接入", "model": None}, copilot={"provider": "乙接入", "model": "模型乙"})
|
||||
specs = _spy(monkeypatch)
|
||||
model, model_id = await catalog.resolve_draft_model(mem)
|
||||
assert model_id == "模型甲"
|
||||
assert [p for p, _ in specs] == ["甲接入", "甲接入"]
|
||||
assert [s.max_tokens for _, s in specs] == [catalog.MODEL_MAX_TOKENS, catalog.MODEL_FALLBACK_MAX_TOKENS]
|
||||
assert catalog.MODEL_MAX_TOKENS == 32768 and catalog.MODEL_FALLBACK_MAX_TOKENS == 8192
|
||||
assert all(s.thinking is None and s.temperature == 0 for _, s in specs), "不关思考,按接入默认"
|
||||
assert all(s.timeout == catalog.MODEL_TIMEOUT_S == 420 for _, s in specs)
|
||||
assert all(s.max_retries == 0 for _, s in specs), "重试只由起草引擎按失败原因来做,SDK 自己不再重试"
|
||||
assert model.budget == 32768 and model.model_name == "模型甲"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind,extra", [
|
||||
("openai_compatible", {}),
|
||||
("anthropic", {}),
|
||||
("anthropic", {"auth_style": "bearer"}),
|
||||
], ids=["openai-compatible", "anthropic", "anthropic-bearer"])
|
||||
def test_the_built_model_does_not_retry_on_its_own_when_asked(kind, extra):
|
||||
"""ModelSpec.max_retries 一直传到 SDK 客户端(Bearer 鉴权换上的客户端也是);不写就照 SDK 默认。"""
|
||||
from app.core.crypto import encrypt
|
||||
from app.providers.factory import ModelSpec, build_chat_model
|
||||
|
||||
provider = Provider(name="虚构接入", kind=kind, api_key=encrypt("sk-test"), base_url="https://gateway.example/v1",
|
||||
models=[], default_model="model-x", extra=extra)
|
||||
|
||||
def client_retries(chat: Any) -> int:
|
||||
client = chat.root_async_client if kind == "openai_compatible" else chat._async_client
|
||||
return client.max_retries
|
||||
|
||||
assert client_retries(build_chat_model(provider, ModelSpec(model="model-x", max_retries=0))) == 0
|
||||
assert client_retries(build_chat_model(provider, ModelSpec(model="model-x"))) == 2
|
||||
|
||||
|
||||
async def test_catalog_draft_falls_back_to_the_assistant_model(mem, no_env_keys, monkeypatch):
|
||||
await _setup(mem, copilot={"provider": "", "model": "模型乙"})
|
||||
specs = _spy(monkeypatch)
|
||||
_, model_id = await catalog.resolve_draft_model(mem)
|
||||
assert model_id == "模型乙" and specs[0][0] == "乙接入"
|
||||
|
||||
|
||||
async def test_recipe_ai_draft_follows_the_same_order_but_keeps_its_own_spec(mem, no_env_keys, monkeypatch):
|
||||
await _setup(mem, draft={"provider": "", "model": "模型丙"}, copilot={"provider": "乙接入", "model": "模型乙"})
|
||||
specs = _spy(monkeypatch)
|
||||
model, model_id, avail = await A.resolve_draft_model(mem)
|
||||
assert avail.available and model_id == "模型丙" and avail.provider == "丙接入"
|
||||
assert [s.max_tokens for _, s in specs] == [A.AI_MAX_TOKENS, A.AI_FALLBACK_MAX_TOKENS]
|
||||
assert all(s.thinking == "off" and s.temperature == 0 for _, s in specs), "配方 AI 起草的思考设置不动"
|
||||
assert all(s.max_retries is None for _, s in specs), "配方 AI 起草的重试照旧(SDK 默认)"
|
||||
|
||||
|
||||
async def test_a_broken_draft_setting_says_where_to_fix_it(mem, no_env_keys):
|
||||
"""起草模型点名的接入不存在:报错说的是起草模型,不是助手的模型。沿用助手模型时照旧说助手。"""
|
||||
from app.core.crypto import encrypt
|
||||
|
||||
mem.add(Provider(name="停用的接入", kind="anthropic", api_key=encrypt("k"), models=[{"id": "模型丁"}],
|
||||
default_model="模型丁", enabled=False, extra={}))
|
||||
mem.add(Setting(key=draft_model.CATALOG_DRAFT_SETTING_KEY, value={"provider": "停用的接入"}))
|
||||
await mem.commit()
|
||||
with pytest.raises(catalog.CatalogModelUnavailable) as e:
|
||||
await catalog.resolve_draft_model(mem)
|
||||
assert "起草模型" in str(e.value) and "停用的接入" in str(e.value) and "已停用" in str(e.value)
|
||||
assert "助手" not in str(e.value)
|
||||
_, _, avail = await A.resolve_draft_model(mem)
|
||||
assert not avail.available and "起草模型" in avail.reason and "助手" not in avail.reason
|
||||
|
||||
await mem.execute(delete(Setting))
|
||||
mem.add(Setting(key=draft_model.COPILOT_SETTING_KEY, value={"provider": "停用的接入"}))
|
||||
await mem.commit()
|
||||
with pytest.raises(catalog.CatalogModelUnavailable) as e:
|
||||
await catalog.resolve_draft_model(mem)
|
||||
assert "助手使用的" in str(e.value)
|
||||
|
||||
|
||||
async def test_only_a_demo_provider_is_refused(mem, no_env_keys):
|
||||
mem.add(Provider(name="演示", kind="mock", models=[{"id": "mock-fast"}], default_model="mock-fast", enabled=True,
|
||||
extra={}))
|
||||
await mem.commit()
|
||||
with pytest.raises(catalog.CatalogModelUnavailable) as e:
|
||||
await catalog.resolve_draft_model(mem)
|
||||
assert "演示" in str(e.value)
|
||||
@@ -87,11 +87,58 @@ def test_merge_removes_stale_items_only_for_covered_sources():
|
||||
|
||||
|
||||
def test_merge_keeps_column_entries_tidy():
|
||||
existing = {"columns": {"amount": {"unit": make_item("元", "llm")}}}
|
||||
merged, stats = catalog.merge_notes(existing, {}, covered={"llm"}, at=AT)
|
||||
existing = {"columns": {"amount": {"unit": make_item("元", "comment")}}}
|
||||
merged, stats = catalog.merge_notes(existing, {}, covered={"comment"}, at=AT)
|
||||
assert merged == {} and stats.removed == 1
|
||||
|
||||
|
||||
COLUMNS = ["id", "amount", "visit_date"]
|
||||
|
||||
|
||||
def test_a_redraft_keeps_model_items_the_model_left_blank_this_time():
|
||||
"""模型这次对某一项没给值(留空,或者给的值没通过校验、在 _from_model 里就丢掉了):以前模型起草的项留着,不算删除。
|
||||
只有给了合格的新值才替换。"""
|
||||
existing = {"label": make_item("入园记录", "llm"), "kind": make_item("fact", "llm"),
|
||||
"keys": make_item(["id"], "llm"), "business_date": make_item({"column": "visit_date"}, "llm"),
|
||||
"columns": {"amount": {"label": make_item("实收金额", "llm"), "unit": make_item("元", "llm")},
|
||||
"visit_date": {"meaning": make_item("游客检票入园的日期", "llm")}}}
|
||||
draft = {"label": make_item("入园明细", "llm"), "columns": {"amount": {"unit": make_item("元", "llm")}}}
|
||||
merged, stats = catalog.merge_notes(existing, draft, covered={"llm"}, at=AT, columns=COLUMNS)
|
||||
assert stats == catalog.MergeStats(added=0, updated=1, removed=0)
|
||||
assert merged["label"]["value"] == "入园明细" and merged["label"]["updated_at"] == AT
|
||||
for key in ("kind", "keys", "business_date"):
|
||||
assert merged[key] == existing[key], key
|
||||
assert merged["columns"] == existing["columns"]
|
||||
# 不知道表结构里有哪些列时更不删
|
||||
merged, stats = catalog.merge_notes(existing, {}, covered={"llm"}, at=AT)
|
||||
assert merged == existing and stats.removed == 0
|
||||
|
||||
|
||||
def test_model_items_of_columns_gone_from_the_schema_are_still_removed():
|
||||
"""表结构里已经没有的列:这一轮完整起草过(covered 含 llm)时,模型起草的这一列的项照旧删掉;业务主键、业务日期
|
||||
指着这一列的也删。没完整起草过(covered 不含 llm)的不删。"""
|
||||
existing = {"keys": make_item(["id", "old_no"], "llm"), "business_date": make_item({"column": "old_day"}, "llm"),
|
||||
"grain": make_item("每次入园一行", "llm"),
|
||||
"columns": {"old_no": {"label": make_item("旧编号", "llm"), "meaning": make_item("已经删掉的列", "llm")},
|
||||
"amount": {"label": make_item("实收金额", "llm")}}}
|
||||
merged, stats = catalog.merge_notes(existing, {}, covered={"llm"}, at=AT, columns=COLUMNS)
|
||||
assert stats.removed == 4
|
||||
assert set(merged) == {"grain", "columns"} and set(merged["columns"]) == {"amount"}
|
||||
merged, stats = catalog.merge_notes(existing, {}, covered=set(), at=AT, columns=COLUMNS)
|
||||
assert merged == existing and stats.removed == 0
|
||||
|
||||
|
||||
def test_other_sources_keep_their_covered_rule():
|
||||
"""只改模型起草的「覆盖即删」:数据库注释、外键、命名推断、剖析照旧——这一轮完整算过、却没有了的删掉。"""
|
||||
existing = {"label": make_item("入园记录", "comment"),
|
||||
"columns": {"amount": {"label": make_item("实收金额", "comment"),
|
||||
"meaning": make_item("模型写的含义", "llm")}},
|
||||
"relations": [_rel(["gate_id"], "gates", ["id"], source="name")]}
|
||||
merged, stats = catalog.merge_notes(existing, {}, covered={"comment", "name", "llm"}, at=AT, columns=COLUMNS)
|
||||
assert stats.removed == 3
|
||||
assert merged == {"columns": {"amount": {"meaning": make_item("模型写的含义", "llm")}}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 单项审阅
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
@@ -86,7 +87,8 @@ async def test_the_model_and_the_snapshot_see_only_masks_when_the_analysis_fails
|
||||
assert _clean(json.dumps(snapshot, ensure_ascii=False))
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
got = await client.get(f"/api/artifacts/{out.queries[0]['artifact']}")
|
||||
assert got.status_code == 200 and _clean(got.text) and "1200" not in got.text
|
||||
# 金额原值不出现。先去掉哈希再查:工件 id、快照里记的哈希是随机的十六进制,偶尔碰巧含「1200」
|
||||
assert got.status_code == 200 and _clean(got.text) and "1200" not in re.sub(r"[0-9a-f]{32,}", "", got.text)
|
||||
assert out.result["value"] is None
|
||||
assert out.cells[0]["reason"] == "Q1 的写法认不全,整份结果查询时已遮罩,不采信"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type {
|
||||
ActivateSnapshotBody, ActivateSnapshotOut, AiPreview, CatalogDetail, CatalogDraftOut, CatalogImpact, CatalogList, CatalogNotes,
|
||||
ActivateSnapshotBody, ActivateSnapshotOut, AiPreview, CatalogDetail, CatalogDraftJob, CatalogImpact, CatalogList, CatalogNotes,
|
||||
CatalogPatchPreview, CatalogPatchSubmit, CatalogProfileOut, CatalogReviewAction, CommitOut, CurrentRecipe, EditPreview,
|
||||
EditRequest, ImportRecord,
|
||||
ManifestOut, PurgeRawBody, PurgeRawOut, QuestionAnswer, Recipe, RedraftRulesOut, RemovePeriodBody, RemovePeriodOut,
|
||||
@@ -31,6 +31,9 @@ export type { ApiErrorKind, RequestOptions, UploadOptions, UploadProgress } from
|
||||
|
||||
const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
|
||||
|
||||
/** 数据目录的接口前缀:/datasources/{id}/catalog */
|
||||
const catalogBase = (sourceId: string) => `/datasources/${encodeURIComponent(sourceId)}/catalog`
|
||||
|
||||
/**
|
||||
* 上传表格被退回、要用户先选怎么处理(422 带 decision)时,取出那份决定;别的错误返回 null。
|
||||
*
|
||||
@@ -409,12 +412,38 @@ export const api = {
|
||||
post<CatalogDetail>(`/datasources/${encodeURIComponent(sourceId)}/catalog/${encodeURIComponent(table)}/patch`,
|
||||
{ changes, if_version: ifVersion }),
|
||||
/**
|
||||
* 同步起草(注释、外键、命名推断;use_model 时再请助手的模型起草)。不给 tables 时按使用次数取前 20 张。
|
||||
* 人工确认、驳回过的项不动。没有表结构时回 409
|
||||
* 建一个起草任务(后台进行:注释、外键、命名推断;use_model 时再请起草模型起草,带上数据源的起草要求和这次的 note)。
|
||||
* 不给 tables 时按使用次数取前 20 张;表结构里没有的表照样建进任务、记 skipped。人工确认、驳回过的项不动。
|
||||
* 409:同一个数据源已有排队、进行中的任务(code 为 catalog_draft_busy,改去取 activeDraftJob);起草模型用不了
|
||||
* (catalog_draft_model_unavailable,不建任务);没有表结构
|
||||
*/
|
||||
draft: (sourceId: string, body: { tables?: string[]; use_model: boolean }, opts?: RequestOptions) =>
|
||||
request<CatalogDraftOut>(`/datasources/${encodeURIComponent(sourceId)}/catalog/draft`,
|
||||
{ method: 'POST', body: JSON.stringify(body), ...opts }),
|
||||
createDraftJob: (sourceId: string, body: { tables?: string[]; use_model: boolean; note?: string }) =>
|
||||
post<{ job: CatalogDraftJob }>(`${catalogBase(sourceId)}/draft-jobs`, body),
|
||||
/** 排队或进行中的那个起草任务;没有为 null */
|
||||
activeDraftJob: (sourceId: string, opts?: RequestOptions) =>
|
||||
get<{ job: CatalogDraftJob | null }>(`${catalogBase(sourceId)}/draft-jobs/active`, opts),
|
||||
/** 最近建的一个起草任务(任何状态);没有为 null */
|
||||
latestDraftJob: (sourceId: string, opts?: RequestOptions) =>
|
||||
get<{ job: CatalogDraftJob | null }>(`${catalogBase(sourceId)}/draft-jobs/latest`, opts),
|
||||
/** 一个起草任务的当前状态;不存在或不属于这个数据源回 404 */
|
||||
draftJob: (sourceId: string, jobId: string, opts?: RequestOptions) =>
|
||||
get<{ job: CatalogDraftJob }>(`${catalogBase(sourceId)}/draft-jobs/${encodeURIComponent(jobId)}`, opts),
|
||||
/**
|
||||
* 停止起草:排队的直接停;进行中的停掉正在进行的模型调用,没开始和没写完的表记已停止,已经写进目录的不回滚。
|
||||
* 已经结束的回 409(catalog_draft_finished)
|
||||
*/
|
||||
cancelDraftJob: (sourceId: string, jobId: string) =>
|
||||
post<{ job: CatalogDraftJob }>(`${catalogBase(sourceId)}/draft-jobs/${encodeURIComponent(jobId)}/cancel`),
|
||||
/**
|
||||
* 只重试失败的表:新任务只含原任务里失败、部分完成、已停止的表,用模型和补充说明沿用,起草要求取数据源现在的。
|
||||
* 没有可重试的表 422;有进行中的任务 409(catalog_draft_busy);和建任务一样,起草模型用不了(原任务用模型时)
|
||||
* 409 catalog_draft_model_unavailable、没有表结构 409
|
||||
*/
|
||||
retryDraftJob: (sourceId: string, jobId: string) =>
|
||||
post<{ job: CatalogDraftJob }>(`${catalogBase(sourceId)}/draft-jobs/${encodeURIComponent(jobId)}/retry`),
|
||||
/** 存数据源的起草要求(去首尾空白,最多 2000 字,空串清除)。上传表格建的源存在它自己的数据源上 */
|
||||
putGuidance: (sourceId: string, guidance: string) =>
|
||||
put<{ guidance: string | null }>(`${catalogBase(sourceId)}/guidance`, { guidance }),
|
||||
/**
|
||||
* 同步剖析:对业务库发少量只读查询,核对关系、取码值候选,结论写进目录。不给 tables 时按使用次数取前 10 张
|
||||
* 有待核实关系的表。没开启、数据源停用、没有表结构、同一个数据源正在剖析、快照被改动时回 409(detail 是一句中文)
|
||||
|
||||
+205
-19
@@ -43,10 +43,11 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
CatalogCardinality, CatalogMeasure, CatalogProfileNumberKey, CatalogSource, CatalogStatus, CatalogTableKind, NodeType, SnapshotReasonCode,
|
||||
CatalogCardinality, CatalogDraftJobStatus, CatalogDraftTaskStatus, CatalogMeasure, CatalogProfileNumberKey, CatalogSource, CatalogStatus,
|
||||
CatalogTableKind, NodeType, SnapshotReasonCode,
|
||||
SqlCheckCode, SqlCheckLevel, ToolTrust,
|
||||
} from '../types'
|
||||
import { formatCost, formatNumber } from './format'
|
||||
import { formatCost, formatNumber, formatTokens } from './format'
|
||||
|
||||
export const TERMS = {
|
||||
workflow: '工作流',
|
||||
@@ -963,6 +964,8 @@ export const PROVIDER_REF_TEXT = {
|
||||
judgeBroken: '裁判调用将失败,所有结论句记为未裁判',
|
||||
// 和核查一组「不存在」那句(VERIFY_SETTING_TEXT.section.providerGone)同一个后果
|
||||
verifyBroken: '核查调用将失败',
|
||||
// 起草模型(数据目录的用模型起草、按配方导入的 AI 起草):开始起草时就报错,不建任务
|
||||
draftBroken: '用模型起草时将报错,无法起草',
|
||||
/**
|
||||
* 存着的接入名恰好是某个启用接入的模型(多半是把模型名填进了接入那一栏):照「启用它」去做,启用的是那条旧接入。
|
||||
* 和服务端保存时 422 的那一句(factory.model_note)一字不差;两栏写这一组的叫法(「门控模型 · 模型」「门控模型 · 接入」)
|
||||
@@ -2198,7 +2201,7 @@ export const CATALOG_TEXT = {
|
||||
noSchemaBody: (note: string) => `${note.replace(/[。.]$/, '')}。数据目录按表结构逐表记录,请先回到数据源卡片点击「探查结构」。`,
|
||||
noSchemaUploadBody: '这个表格还没有可用的表结构,请重新上传后再编写数据目录。',
|
||||
emptyTitle: '还没有数据目录',
|
||||
emptyBody: '先起草,再逐表确认。起草按数据库注释、外键约束和列名推断,也可以请助手的模型起草中文名和含义。',
|
||||
emptyBody: '先起草,再逐表确认。起草按数据库注释、外键约束和列名推断,也可以用模型起草中文名和含义。',
|
||||
// ---- 导入表格的说明
|
||||
systemTitle: '导入时生成的说明',
|
||||
systemBody: '这个数据源由导入表格生成,表和列的说明由系统按核对结果生成,只读,不能在数据目录中改写。',
|
||||
@@ -2260,29 +2263,17 @@ export const CATALOG_TEXT = {
|
||||
scopeAll: (n: number) => `全部 ${formatNumber(n)} 张表`,
|
||||
draftBase: '按数据库注释、外键约束和列名推断起草。已确认、已驳回的项不会被改动;起草出的项都是推断,需要逐项确认。',
|
||||
useModel: '用模型起草',
|
||||
useModelHint: '请助手的模型按表结构起草中文名、说明、粒度和列的含义。只发送表结构(表名、列名、类型、约束、注释),不发送数据;耗时较长,并产生模型费用。',
|
||||
useModelHint: '请起草模型按表结构起草中文名、说明、粒度和列的含义。只发送表结构(表名、列名、类型、约束、注释)和起草要求,不发送数据;在后台进行,耗时较长,并产生模型费用。',
|
||||
draftStart: (n: number) => `起草 ${formatNumber(n)} 张表`,
|
||||
drafting: '正在起草…',
|
||||
draftProgress: (done: number, total: number) => `已完成 ${formatNumber(done)} / ${formatNumber(total)} 张表`,
|
||||
draftStop: '停止',
|
||||
draftStopping: '当前这一批完成后停止…',
|
||||
draftDone: '起草完成',
|
||||
draftStopped: (done: number, total: number) => `已停止:完成 ${formatNumber(done)} / ${formatNumber(total)} 张表,其余未起草`,
|
||||
draftFailed: '起草中断',
|
||||
draftSummary: (added: number, updated: number, removed: number) =>
|
||||
`新增 ${formatNumber(added)} 项、更新 ${formatNumber(updated)} 项${removed ? `、删除 ${formatNumber(removed)} 项` : ''}`,
|
||||
// 只在起草确实给出了内容、只是和现有目录一致时用:有表的模型起草失败或没给出可用内容(model_error)时
|
||||
// 不能说「没有变化」,改用 draftNothingWritten
|
||||
// 只在起草确实给出了内容、只是和现有目录一致时用:模型没给出可用内容时说 draftNothingWritten,有表没写全时说
|
||||
// draftNothingWrittenFailed,不能说「没有变化」
|
||||
draftNoChange: '没有变化:起草出的内容与现有目录一致',
|
||||
draftNothingWritten: '目录没有更新:模型没有给出可用的内容,原因见下方',
|
||||
draftModelSkipped: (reason: string) => `模型未参与起草:${reason.replace(/[。.]$/, '')}。已按注释、外键和命名起草。`,
|
||||
draftModelUsed: (model: string) => `模型:${model}`,
|
||||
draftTableErrors: (n: number) => `${formatNumber(n)} 张表未能起草`,
|
||||
draftModelErrors: (n: number) => `${formatNumber(n)} 张表的模型起草失败,已按注释、外键和命名起草`,
|
||||
draftNothingWrittenFailed: '目录没有更新:部分表未写全,原因见下方',
|
||||
draftClose: '完成',
|
||||
moreTables: (n: number) => `…另有 ${formatNumber(n)} 张`,
|
||||
cancel: '取消',
|
||||
draftBackground: '起草仍在进行,当前这一批完成后停止',
|
||||
// ---- 表详情
|
||||
backToList: '返回表清单',
|
||||
prev: '上一张',
|
||||
@@ -2389,6 +2380,201 @@ export const CATALOG_TEXT = {
|
||||
reloadDiscardAction: '放弃修改并重新载入',
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 数据目录的起草任务(/api/datasources/{id}/catalog/draft-jobs):起草在服务端后台进行,关掉对话框不停;逐表起草,
|
||||
// 每张表一结束就写进目录。
|
||||
//
|
||||
// 术语:
|
||||
// - 「起草任务」的状态:排队中、正在起草、起草完成、部分表未写全、起草失败、已停止、服务重启中断。
|
||||
// 一张表:等待、起草中、已完成、部分完成、失败、已跳过(表结构里没有)、已停止。
|
||||
// - 「起草要求」:使用者补充给模型的业务背景和用词约定,存在数据源上,以后每次用模型起草都带上;
|
||||
// 「本次补充说明」只用于这一次(重试时沿用)。两者都只交给模型,不改起草规则:冲突时以规则为准。
|
||||
// - 「起草模型」:设置里单独的一项(偏好设置 → 起草模型),留空跟随助手的模型。
|
||||
// - 宽表的「段」:超过 40 列的表拆成表级一段和若干列分段,逐段起草;写的是「已完成 3 / 9 段」。模型多次用完额度或
|
||||
// 超时后,之后的段缩小(每段最多 20 列、10 列)。
|
||||
// - 「只重试失败的表」:重新起草失败、部分完成和已停止的表;已经写进目录的内容不回滚、不受影响。
|
||||
// - 「没写出来」:一次调用没有写出内容(额度用完、只给空壳、超时、出错)。接连没写出来时服务端给一句「提醒」,
|
||||
// 好转后收起;提醒是提醒的样式,不是错误。超时的调用不计用量。
|
||||
// - 「留空」:模型列出了一列(或这一段请它写了),中文名和含义却都没给,写「N 列模型拿不准、留空了」。表的状态不因此
|
||||
// 变,不算没写全;办法是在起草要求里补充缩写和术语的含义,再重新起草,已有的内容不会丢。
|
||||
// ===========================================================================
|
||||
|
||||
/** 起草要求、本次补充说明的字数上限(服务端同一个数:超长 422) */
|
||||
export const DRAFT_GUIDANCE_MAX = 2000
|
||||
export const DRAFT_NOTE_MAX = 1000
|
||||
|
||||
export const CATALOG_DRAFT_TEXT = {
|
||||
// ---- 设置阶段:起草要求、本次补充说明、起草模型
|
||||
guidance: '起草要求',
|
||||
guidanceHint: '写给模型的业务背景和用词约定,保存在这个数据源上,以后每次用模型起草都会带上。例如:「核销」指检票入园;金额单位是元',
|
||||
guidanceRule: '起草要求只帮助模型理解业务、统一用词;与起草规则冲突时以规则为准,起草出的项仍需逐项确认',
|
||||
guidancePlaceholder: '业务背景、常用说法、单位约定……',
|
||||
note: '本次补充说明',
|
||||
noteHint: '只用于这一次起草,例如「这一批是会员相关的表」;重试时沿用',
|
||||
notePlaceholder: '可不填',
|
||||
count: (n: number, max: number) => `${formatNumber(n)} / ${formatNumber(max)} 字`,
|
||||
tooLong: (max: number) => `不超过 ${formatNumber(max)} 字`,
|
||||
sourceDescription: (text: string) => `数据源说明也会一并交给模型:${text}`,
|
||||
model: '起草模型',
|
||||
/** 设置里没配起草模型:跟随助手的模型,写出当前是哪个(取不到就不写) */
|
||||
modelFollow: (current: string | null) => (current ? `跟随助手的模型(当前:${current})` : '跟随助手的模型'),
|
||||
/** 设置里配了:模型(没填模型就是那个接入的默认模型)和接入 */
|
||||
modelNamed: (model: string | null, provider: string | null) => (model
|
||||
? (provider ? `${model}(模型接入「${provider}」)` : model)
|
||||
: `模型接入「${provider}」的默认模型`),
|
||||
modelWhere: '可在「设置 → 偏好设置 → 起草模型」中更换',
|
||||
tooMany: (max: number) => `一次最多起草 ${formatNumber(max)} 张表,请缩小范围`,
|
||||
saveGuidanceFailed: (reason: string) => `起草要求未能保存,未开始起草:${reason}`,
|
||||
startFailed: (reason: string) => `未能开始起草:${reason}`,
|
||||
// ---- 上一次起草(设置阶段,没写全、有留空列的才提)
|
||||
last: (state: string, at: string) => `上一次起草(${at}):${state}`,
|
||||
/** 上一次的结果:状态,有留空的列时接着写几列 */
|
||||
lastState: (title: string, blank: number) => (blank ? `${title},${formatNumber(blank)} 列模型拿不准、留空了` : title),
|
||||
lastOpen: '查看',
|
||||
// ---- 从结果、表详情回到设置:范围就是那几张表
|
||||
scopeBlank: (n: number) => `有留空列的 ${formatNumber(n)} 张表`,
|
||||
scopeTable: (name: string) => `这张表:${name}`,
|
||||
/**
|
||||
* 预先选好的范围里有表已经不在表结构里(打开表详情以后别处重新探查了结构):写在那一项下面。一张都不剩时那一项
|
||||
* 照旧选着、不能开始,不退回别的范围
|
||||
*/
|
||||
presetMissing: (gone: number, total: number) => (gone < total
|
||||
? `其中 ${formatNumber(gone)} 张在表结构中已没有,不会起草`
|
||||
: total === 1 ? '表结构中已没有这张表,不能起草' : `表结构中已没有这 ${formatNumber(total)} 张表,不能起草`),
|
||||
// ---- 进行中
|
||||
queued: '排队中,等待开始…',
|
||||
running: '正在起草…',
|
||||
// 服务端点了停止就取消进行中的模型调用(不等它答完,一次调用可能要几分钟),只等手上正在写入目录的那张表写完
|
||||
stopping: '正在停止:取消进行中的模型调用,正在写入目录的表写完后停止…',
|
||||
progress: (done: number, total: number) => `已完成 ${formatNumber(done)} / ${formatNumber(total)} 张表`,
|
||||
/** 结束以后:全部写出来的表有几张(停止、失败、部分完成的不算) */
|
||||
progressDone: (ok: number, total: number) => `写完 ${formatNumber(ok)} / ${formatNumber(total)} 张表`,
|
||||
parts: (done: number, total: number) => `${formatNumber(done)} / ${formatNumber(total)} 段`,
|
||||
partsHint: '超过 40 列的表拆成表级一段和若干列分段,逐段起草',
|
||||
hide: '在后台继续',
|
||||
hideHint: '关闭对话框不会停止起草,进度显示在目录页上方;每张表起草完就写进目录',
|
||||
stop: '停止',
|
||||
busy: '这个数据源已有进行中的起草任务,下面显示的是它的进度',
|
||||
modelUsed: (model: string) => `模型:${model}`,
|
||||
usage: (calls: number, input: number, output: number) =>
|
||||
`调用模型 ${formatNumber(calls)} 次,输入 ${formatTokens(input, { compact: true })}、输出 ${formatTokens(output, { compact: true })}`,
|
||||
/**
|
||||
* 用量那一行接着写的:没写出来、超时的调用各几次(没有的不写)。没写出来的次数里含超时的,写成「其中」,免得
|
||||
* 读成两个数相加
|
||||
*/
|
||||
usageFailures: (timeouts: number, failed: number) => {
|
||||
if (failed && timeouts && timeouts <= failed) return `没写出来 ${formatNumber(failed)} 次(其中超时 ${formatNumber(timeouts)} 次)`
|
||||
return [failed ? `没写出来 ${formatNumber(failed)} 次` : '', timeouts ? `超时 ${formatNumber(timeouts)} 次` : ''].filter(Boolean).join('、')
|
||||
},
|
||||
usageFailuresHint: '没写出来:调用没有写出内容,包括额度用完、只给空壳、超时和出错',
|
||||
// 超时的调用服务端照实记 0 个 token,服务方却可能已经生成了内容
|
||||
timeoutNote: '超时的调用不计用量,实际用量可能更多',
|
||||
/** 额度用完、超时多了,服务端把之后的段缩小了 */
|
||||
partColumns: (n: number) => `模型多次用完额度或超时,之后的段每段最多 ${formatNumber(n)} 列`,
|
||||
// ---- 留空的列:模型列出了、中文名和含义却都没给。表的状态不因此变
|
||||
blank: (n: number) => `${formatNumber(n)} 列模型拿不准、留空了`,
|
||||
/** 留空的列名(服务端最多给 5 个);没列全时写一共几列 */
|
||||
blankSample: (sample: string[], n: number) =>
|
||||
`留空的列:${sample.join('、')}${n > sample.length ? ` 等 ${formatNumber(n)} 列` : ''}`,
|
||||
blankTotal: (columns: number, tables: number) => `共 ${formatNumber(columns)} 列模型拿不准、留空了(${formatNumber(tables)} 张表)`,
|
||||
blankAdvice: '可以在起草要求里补充缩写和术语的含义,再重新起草这几张表,已有的内容不会丢',
|
||||
blankRedraft: (n: number) => `重新起草这 ${formatNumber(n)} 张表`,
|
||||
blankRedraftHint: '回到起草设置,范围是这几张表:先在起草要求里补充缩写和术语的含义,再开始起草',
|
||||
// ---- 结束
|
||||
title: {
|
||||
queued: '排队中,等待开始…',
|
||||
running: '正在起草…',
|
||||
succeeded: '起草完成',
|
||||
partial: '起草完成,部分表未写全',
|
||||
failed: '起草失败',
|
||||
cancelled: '已停止',
|
||||
interrupted: '服务重启,起草中断',
|
||||
} as Record<CatalogDraftJobStatus, string>,
|
||||
interrupted: '服务重启时这次起草还没有完成,没写完的表未起草。已写进目录的内容保留;可以只重试这些表,接着起草',
|
||||
cancelled: '已写进目录的内容保留,没写完的表未起草',
|
||||
// 任务整体出错的原因(job.error)照服务端的原话写在标题「起草失败」下面,不再加前缀:服务端的原因有的已经是整句
|
||||
// (「数据源不存在……」),有的自带「起草任务出错:」开头。没结束的表记失败、原因同任务的,逐表不再重复
|
||||
/** 没写全的表:失败、部分完成、已停止各几张;跳过的(表结构里没有)另说 */
|
||||
problems: (c: { failed: number; partial: number; cancelled: number; skipped: number }) => {
|
||||
const parts = [
|
||||
c.failed ? `失败 ${formatNumber(c.failed)} 张` : '',
|
||||
c.partial ? `部分完成 ${formatNumber(c.partial)} 张` : '',
|
||||
c.cancelled ? `已停止 ${formatNumber(c.cancelled)} 张` : '',
|
||||
].filter(Boolean)
|
||||
const bad = parts.length ? `${formatNumber(c.failed + c.partial + c.cancelled)} 张表未写全:${parts.join('、')}` : ''
|
||||
const skipped = c.skipped ? `${formatNumber(c.skipped)} 张表已跳过(表结构里没有)` : ''
|
||||
return [bad, skipped].filter(Boolean).join(';')
|
||||
},
|
||||
retry: (n: number) => `只重试失败的表(${formatNumber(n)} 张)`,
|
||||
retryHint: '重新起草失败、部分完成和已停止的表;已写进目录的内容不受影响',
|
||||
showAll: (n: number) => `显示全部 ${formatNumber(n)} 张表`,
|
||||
showProblems: '只看未写全的表',
|
||||
showProblemsBlank: '只看未写全、有留空列的表',
|
||||
tasks: '逐表进度',
|
||||
taskStatus: {
|
||||
pending: '等待', running: '起草中', ok: '已完成', partial: '部分完成', failed: '失败', skipped: '已跳过', cancelled: '已停止',
|
||||
} as Record<CatalogDraftTaskStatus, string>,
|
||||
taskAdded: (added: number, updated: number) => (added || updated
|
||||
? `+${formatNumber(added)}${updated ? ` · 改 ${formatNumber(updated)}` : ''}` : ''),
|
||||
taskAddedHint: (added: number, updated: number, removed: number) =>
|
||||
`新增 ${formatNumber(added)} 项、更新 ${formatNumber(updated)} 项${removed ? `、删除 ${formatNumber(removed)} 项` : ''}`,
|
||||
// ---- 目录页上方的进度、结束提示
|
||||
strip: (done: number, total: number) => `正在起草:已完成 ${formatNumber(done)} / ${formatNumber(total)} 张表`,
|
||||
stripQueued: (total: number) => `起草任务排队中,共 ${formatNumber(total)} 张表`,
|
||||
stripOpen: '查看进度',
|
||||
toast: {
|
||||
succeeded: (summary: string) => `数据目录起草完成:${summary}`,
|
||||
partial: (n: number) => `数据目录起草完成,${formatNumber(n)} 张表未写全`,
|
||||
/** 完成、部分完成的提示后面接着写留空的列(没有就不写) */
|
||||
withBlank: (text: string, blank: number) => (blank ? `${text};${formatNumber(blank)} 列模型拿不准、留空了` : text),
|
||||
failed: (reason: string) => (reason ? `数据目录起草失败:${reason}` : '数据目录起草失败'),
|
||||
cancelled: '数据目录起草已停止',
|
||||
interrupted: '服务重启,数据目录起草中断',
|
||||
},
|
||||
viewResult: '查看结果',
|
||||
// ---- 清单上的标记、表详情里的说明
|
||||
lastFailed: '上次起草失败',
|
||||
lastPartial: '上次起草部分完成',
|
||||
lastWith: (label: string, reason: string) => `${label}:${reason}`,
|
||||
/** 上次起草有留空的列:清单上是轻提示(不是失败标记),表详情顶部同一句 */
|
||||
lastBlank: (n: number) => `上次起草有 ${formatNumber(n)} 列模型拿不准、留空了`,
|
||||
lastBlankAdvice: '可以在起草要求里补充缩写和术语的含义,再重新起草这张表,已有的内容不会丢',
|
||||
draftGuidance: '补充起草要求',
|
||||
draftGuidanceHint: '打开起草设置,范围是这张表:先在起草要求里补充缩写和术语的含义,再开始起草',
|
||||
redraft: '重新起草这张表',
|
||||
redraftHint: '用模型重新起草这张表(不带补充说明,带上数据源的起草要求);人工确认、驳回过的项不会被改动',
|
||||
redraftBusy: '已有起草任务在进行,结束后再重新起草这张表',
|
||||
redraftEditing: '正在编辑这张表,保存或取消后再重新起草',
|
||||
/** 表结构里已经没有的表(只剩目录):上次起草的结果照写,不给重新起草(服务端只会把它记成已跳过) */
|
||||
redraftMissing: '表结构中已没有这张表,不能重新起草',
|
||||
}
|
||||
|
||||
/** 设置页「偏好设置」里「起草模型」一节(服务端 api/settings.py 的 catalog_draft 一组) */
|
||||
export const DRAFT_MODEL_SETTING_TEXT = {
|
||||
title: '起草模型',
|
||||
hint: '数据目录的「用模型起草」和按配方导入表格时的 AI 起草使用这个模型,留空则跟随助手的模型。'
|
||||
+ '起草一次要写出几十到上百列的说明,建议选择输出额度大、能稳定按格式输出的模型;会思考的模型,思考也计入输出额度。',
|
||||
/** 和服务端 _provider_fields 里的叫法一字不差:保存被拒时的报错写的是它 */
|
||||
provider: '起草模型 · 接入',
|
||||
model: '起草模型 · 模型',
|
||||
notSet: '未单独配置,跟随助手的模型',
|
||||
providerDefault: '跟随助手的模型',
|
||||
providerFromModel: '按模型名找接入',
|
||||
providerOff: '(已停用)',
|
||||
providerGoneOption: (name: string) => `${name}(不存在)`,
|
||||
providerGone: (name: string) => `模型接入「${name}」不存在:${PROVIDER_REF_TEXT.draftBroken}`,
|
||||
modelDefault: '留空:用接入的默认模型',
|
||||
modelFollow: '留空:跟随助手的模型',
|
||||
modelOf: (model: string) => `留空则使用 ${model}`,
|
||||
/** 只填了起草模型、接入留空时实际用哪个接入(同核查一组的说法,只是上一级是助手的模型) */
|
||||
byModel: (model: string, found: { name: string; enabled: boolean } | null | undefined, fallback: string | null | undefined) => {
|
||||
if (found && !found.enabled) return `只填了模型:「${model}」属于已停用的接入「${found.name}」,${PROVIDER_REF_TEXT.draftBroken}。请启用该接入或选择其他接入`
|
||||
if (found) return `只填了模型:将按模型名使用接入「${found.name}」。「跟随助手的模型」仅在接入和模型都留空时生效`
|
||||
return `只填了模型:所有接入的模型列表中都没有「${model}」,将使用默认接入${fallback ? `「${fallback}」` : ''}调用。`
|
||||
+ '如该模型不属于默认接入,请选择对应的接入。「跟随助手的模型」仅在接入和模型都留空时生效'
|
||||
},
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 目录修改提案:用户在助手(画布)或问数据页说出一条数据事实时,助手提出的数据目录修改。
|
||||
//
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { modelOptions, providerOfModel, useCatalog } from '../store/catalog'
|
||||
import { Field, SectionBar } from '../components/ui'
|
||||
import { DRAFT_MODEL_SETTING_TEXT as T, PROVIDER_REF_TEXT } from '../lib/terms'
|
||||
import { ProviderOptions } from './VerifySettings'
|
||||
|
||||
/*
|
||||
* 设置页「偏好设置」里「起草模型」一节:数据目录的「用模型起草」和按配方导入表格时的 AI 起草用哪个模型
|
||||
* (服务端 api/settings.py 的 catalog_draft 一组:{provider, model})。
|
||||
*
|
||||
* 取模型的顺序(服务端):这一组填了接入或模型就用它(只填模型时按模型名找接入,不跨级拼);两项都留空跟随助手的
|
||||
* 模型;再没有就是默认接入。草稿由 PrefsTab 持有、和其余设置共用一条保存条,这里只画;两栏的叫法和服务端
|
||||
* _provider_fields 一字不差,保存被拒(422 带 field:catalog_draft.provider)时报错写的就是它
|
||||
*/
|
||||
|
||||
export function DraftModelSection({ provider, model, error, onEdit }: {
|
||||
provider: string
|
||||
model: string
|
||||
/** 服务端按这一项拒收时的整句(设置页从 422 的 field 落过来) */
|
||||
error?: string
|
||||
onEdit: (patch: { draftProvider?: string; draftModel?: string }) => void
|
||||
}) {
|
||||
const providers = useCatalog((s) => s.providers)
|
||||
const enabledProviders = providers.filter((p) => p.enabled)
|
||||
// 只填了模型、按模型名找不到接入时服务端用默认接入(第一个启用的真实接入,没有就第一个启用的)
|
||||
const defaultProvider = enabledProviders.find((p) => p.kind !== 'mock') ?? enabledProviders[0]
|
||||
const found = provider ? providers.find((p) => p.name === provider) : undefined
|
||||
const missing = !!provider && providers.length > 0 && !found
|
||||
// 存着的接入不存在、已停用:开始起草时就会报错(不建任务),在这一格下面说。名字恰好是某个启用接入的模型时
|
||||
// 补一句怎么用它(同设置页别的几组,和服务端保存时那句一致)
|
||||
const trap = found?.enabled === false || missing ? providerOfModel(providers, provider) : undefined
|
||||
const trapNote = trap?.enabled ? `。${PROVIDER_REF_TEXT.modelNote(provider, trap.provider.name, T.model, T.provider)}` : ''
|
||||
const providerError = error ?? (missing ? `${T.providerGone(provider)}${trapNote}`
|
||||
: found && !found.enabled ? `${PROVIDER_REF_TEXT.disabled(provider)}:${PROVIDER_REF_TEXT.draftBroken}${trapNote}` : undefined)
|
||||
const modelName = model.trim()
|
||||
const byModel = !provider && !!modelName
|
||||
const notSet = !provider && !modelName
|
||||
const owner = byModel ? providerOfModel(providers, modelName) : undefined
|
||||
const byModelHint = byModel && providers.length > 0
|
||||
? T.byModel(modelName, owner && { name: owner.provider.name, enabled: owner.enabled }, defaultProvider?.name) : ''
|
||||
// 接入留空时按模型名找接入:所有启用接入的模型都能选;选了接入只列这一家的
|
||||
const models = found ? (found.models ?? []).map((m) => m.id) : [...new Set(modelOptions(providers).map((o) => o.value))]
|
||||
const pickProvider = (name: string) => {
|
||||
// 换了接入,原来填的模型不在新接入的清单里就清掉:拿 A 家的模型名去调 B 家必然失败
|
||||
const next = name ? providers.find((p) => p.name === name) : undefined
|
||||
const keep = !name || !model || (next?.models ?? []).some((m) => m.id === model) || next?.default_model === model
|
||||
onEdit({ draftProvider: name, ...(keep ? {} : { draftModel: '' }) })
|
||||
}
|
||||
|
||||
return (
|
||||
<section data-draft-model-settings="">
|
||||
<SectionBar title={T.title} hint={T.hint} />
|
||||
<div role="group" aria-labelledby="pref-draft-model-label" aria-describedby={notSet ? 'pref-draft-model-hint' : undefined}
|
||||
data-draft-model-group={notSet ? 'follow' : 'set'}>
|
||||
<div id="pref-draft-model-label" className="sr-only">{T.title}</div>
|
||||
{notSet && <p id="pref-draft-model-hint" className="mb-2 text-2xs leading-relaxed text-faint">{T.notSet}</p>}
|
||||
<div className="grid grid-cols-2 gap-3 max-sm:grid-cols-1">
|
||||
<Field label={T.provider} htmlFor="pref-draft-provider" error={providerError}
|
||||
hint={byModelHint ? (
|
||||
<span data-draft-by-model={owner ? (owner.enabled ? 'found' : 'disabled') : 'default'}
|
||||
style={owner?.enabled ? undefined : { color: 'var(--st-waiting)' }}>{byModelHint}</span>
|
||||
) : undefined}>
|
||||
{(p) => (
|
||||
<select {...p} className="field" value={provider} onChange={(e) => pickProvider(e.target.value)}>
|
||||
<option value="">{byModel ? T.providerFromModel : T.providerDefault}</option>
|
||||
{missing && <option value={provider}>{T.providerGoneOption(provider)}</option>}
|
||||
<ProviderOptions providers={providers} current={provider} />
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
<Field label={T.model} htmlFor="pref-draft-model"
|
||||
hint={found?.enabled && found.default_model ? T.modelOf(found.default_model) : undefined}>
|
||||
{(p) => (
|
||||
<>
|
||||
<input {...p} className="field mono text-xs" value={model} list="pref-draft-models" spellCheck={false}
|
||||
placeholder={provider ? T.modelDefault : T.modelFollow} onChange={(e) => onEdit({ draftModel: e.target.value })} />
|
||||
<datalist id="pref-draft-models">{models.map((m) => <option key={m} value={m} />)}</datalist>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -23,12 +23,14 @@ import { normalizeTheme } from '../lib/theme'
|
||||
import { useLeaveGuard } from '../lib/leave'
|
||||
import { localActor, setLocalActor } from '../lib/actor'
|
||||
import {
|
||||
AGENT_GUARD_TEXT, JUDGE_SETTING_TEXT, PROVIDER_REF_TEXT, TOOL_GATE_TEXT, VERIFY_SETTING_TEXT, judgeByModelText, judgeUnlimitedText,
|
||||
AGENT_GUARD_TEXT, DRAFT_MODEL_SETTING_TEXT, JUDGE_SETTING_TEXT, PROVIDER_REF_TEXT, TOOL_GATE_TEXT, VERIFY_SETTING_TEXT, judgeByModelText,
|
||||
judgeUnlimitedText,
|
||||
} from '../lib/terms'
|
||||
import type { ThemePref } from '../lib/theme'
|
||||
import {
|
||||
ProviderOptions, VerifySection, verifyChanged, verifyDraftOf, verifyErrors, verifyLabels, verifyPayload, type VerifyDraft,
|
||||
} from './VerifySettings'
|
||||
import { DraftModelSection } from './DraftModelSettings'
|
||||
|
||||
// 提到模块级:tab 名同时是 URL 的最后一段,两处各写一份迟早对不上。
|
||||
// 数据源搬去了顶级的「数据」页(/data),/settings/datasources 跳过去
|
||||
@@ -626,6 +628,8 @@ function PrefsTab() {
|
||||
judgeProvider: string; judgeModel: string
|
||||
// 证据裁判的五项上限:null 是不限(存 null),字符串是正在填的数
|
||||
jClaims: string | null; jCost: string | null; jTimeout: string | null; jClick: string | null; jDaily: string | null
|
||||
// 起草模型(数据目录的用模型起草、按配方导入的 AI 起草):空串 = 跟随助手的模型(存 null)
|
||||
draftProvider: string; draftModel: string
|
||||
} | null>(null)
|
||||
const [spend, setSpend] = useState<{ usd?: number; calls?: number; unpriced_calls?: number } | null>(null)
|
||||
// 核查一组(双盲复算、反方审稿、审批质询)的草稿另放一份,和上面的共用保存条;verifySaves 是存过几次,今日核查花费跟着再取
|
||||
@@ -673,6 +677,9 @@ function PrefsTab() {
|
||||
jTimeout: limitText(values?.judge, 'report_timeout_s'),
|
||||
jClick: limitText(values?.judge, 'click_max_cost_usd'),
|
||||
jDaily: limitText(values?.judge, 'daily_max_usd'),
|
||||
// 起草模型(服务端 catalog_draft 一组;老服务端没有这一组时两项都是空,这一节也不显示)
|
||||
draftProvider: typeof values?.catalog_draft?.provider === 'string' ? values.catalog_draft.provider : '',
|
||||
draftModel: typeof values?.catalog_draft?.model === 'string' ? values.catalog_draft.model : '',
|
||||
}
|
||||
const cur = draft ?? saved
|
||||
const stepCap = Number(values?.limits?.max_agent_steps) || 100
|
||||
@@ -748,7 +755,8 @@ function PrefsTab() {
|
||||
savedActor.current = actor
|
||||
}
|
||||
const judgeChanged = changed.some((k) => JUDGE_DRAFT.has(k))
|
||||
const runChanged = changed.some((k) => k !== 'actor' && !JUDGE_DRAFT.has(k))
|
||||
const draftChanged = changed.some((k) => DRAFT_MODEL_DRAFT.has(k))
|
||||
const runChanged = changed.some((k) => k !== 'actor' && !JUDGE_DRAFT.has(k) && !DRAFT_MODEL_DRAFT.has(k))
|
||||
try {
|
||||
// 裁判一组整组写:后端对没给的键回落到默认值,只发改过的那一项会把别的项冲回默认
|
||||
const judge = judgeChanged ? {
|
||||
@@ -762,7 +770,9 @@ function PrefsTab() {
|
||||
} : null
|
||||
// 核查一组同样整组写(VerifySettings 的 verifyPayload)
|
||||
const verify = verifyKeys.length ? verifyPayload(verifyCur) : null
|
||||
if (runChanged || judge || verify) {
|
||||
// 起草模型一组:两项一起写,空着的存 null(跟随助手的模型)
|
||||
const catalogDraft = draftChanged ? { provider: cur.draftProvider || null, model: cur.draftModel.trim() || null } : null
|
||||
if (runChanged || judge || verify || catalogDraft) {
|
||||
const run = {
|
||||
...(values.run ?? {}),
|
||||
default_memory_scope: cur.scope,
|
||||
@@ -774,12 +784,16 @@ function PrefsTab() {
|
||||
agent_budget_tokens: cur.budgetTokens.trim() === '' ? null : Number(cur.budgetTokens),
|
||||
agent_budget_usd: cur.budgetUsd.trim() === '' ? null : Number(cur.budgetUsd),
|
||||
}
|
||||
const out = await api.settings.put({ ...(runChanged ? { run } : {}), ...(judge ? { judge } : {}), ...(verify ? { verify } : {}) })
|
||||
const out = await api.settings.put({
|
||||
...(runChanged ? { run } : {}), ...(judge ? { judge } : {}), ...(verify ? { verify } : {}),
|
||||
...(catalogDraft ? { catalog_draft: catalogDraft } : {}),
|
||||
})
|
||||
setValues((v) => ({
|
||||
...(v ?? {}),
|
||||
...(runChanged ? { run: out?.run ?? run } : {}),
|
||||
...(judge ? { judge: out?.judge ?? judge } : {}),
|
||||
...(verify ? { verify: out?.verify ?? verify } : {}),
|
||||
...(catalogDraft ? { catalog_draft: out?.catalog_draft ?? catalogDraft } : {}),
|
||||
}))
|
||||
// 每日上限改了:旁边那句「今天已花」的分母跟着变,顺手再取一次
|
||||
if (judge) void loadSpend()
|
||||
@@ -1090,6 +1104,11 @@ function PrefsTab() {
|
||||
|
||||
{values.verify && <VerifySection cur={verifyCur} onEdit={editVerify} refresh={verifySaves}
|
||||
errors={fieldError?.field === 'verify.provider' ? { ...verifyErrs, provider: fieldError.message } : verifyErrs} />}
|
||||
|
||||
{values.catalog_draft && (
|
||||
<DraftModelSection provider={cur.draftProvider} model={cur.draftModel} onEdit={edit}
|
||||
error={fieldError?.field === 'catalog_draft.provider' ? fieldError.message : undefined} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1113,6 +1132,7 @@ function PrefsTab() {
|
||||
judgeProvider: '裁判模型的接入', judgeModel: '裁判模型',
|
||||
jClaims: '每份报告最多裁判句数', jCost: '每份报告的金额上限', jTimeout: '每份报告的时长上限',
|
||||
jClick: '每次点击的金额上限', jDaily: '每日金额上限',
|
||||
draftProvider: DRAFT_MODEL_SETTING_TEXT.provider, draftModel: DRAFT_MODEL_SETTING_TEXT.model,
|
||||
})[k]).concat(verifyLabels(verifyKeys)).join('、')}
|
||||
</span>
|
||||
</span>
|
||||
@@ -1147,10 +1167,13 @@ function PrefsTab() {
|
||||
/** 服务端按项拒收时(422 带 field)那一项在页面上是哪个控件:点名的模型接入的三格 */
|
||||
const PROVIDER_FIELD_IDS: Record<string, string> = {
|
||||
'run.tool_gate_provider': 'pref-gate-provider', 'judge.provider': 'pref-judge-provider', 'verify.provider': 'pref-verify-provider',
|
||||
'catalog_draft.provider': 'pref-draft-provider',
|
||||
}
|
||||
|
||||
/** 裁判那一组在草稿里的键:它们改了才写 judge 一组 */
|
||||
const JUDGE_DRAFT = new Set<string>(['judgeProvider', 'judgeModel', 'jClaims', 'jCost', 'jTimeout', 'jClick', 'jDaily'])
|
||||
/** 起草模型那一组在草稿里的键:它们改了才写 catalog_draft 一组 */
|
||||
const DRAFT_MODEL_DRAFT = new Set<string>(['draftProvider', 'draftModel'])
|
||||
|
||||
/** 勾掉「不限」时填回的数:同后端 JUDGE_DEFAULTS */
|
||||
const JUDGE_FALLBACK = {
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { ArrowLeft, BookMarked, Database, ListFilter as ListFilterIcon, ScanSearch, Sparkles } from 'lucide-react'
|
||||
import { ArrowLeft, BookMarked, Database, ListFilter as ListFilterIcon, ScanSearch, Sparkles, TriangleAlert } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { ApiError, api } from '../../api/client'
|
||||
import type { CatalogDetail, CatalogList, CatalogTableRow } from '../../types'
|
||||
import { EmptyState, ErrorState, PageHeader, Skeleton, Spinner, toast, useTicker } from '../../components/ui'
|
||||
import { useCatalog, useDatasources, useOnReconnect } from '../../store/catalog'
|
||||
import { CATALOG_TEXT as CT, PROFILE_STOP_LABEL, PROFILE_TEXT as PT, SCHEMA_PARTIAL_TEXT as SPT } from '../../lib/terms'
|
||||
import {
|
||||
CATALOG_DRAFT_TEXT as DT, CATALOG_TEXT as CT, PROFILE_STOP_LABEL, PROFILE_TEXT as PT, SCHEMA_PARTIAL_TEXT as SPT,
|
||||
} from '../../lib/terms'
|
||||
import { StatusLegend, SystemNotesNotice } from './parts'
|
||||
import { DraftDialog } from './DraftDialog'
|
||||
import { isJobActive, jobBlank, retryableCount, useDraftJob } from './draftJob'
|
||||
import type { DraftPreset, DraftStartBody } from './draftJob'
|
||||
import { ProfileDialog } from './ProfileDialog'
|
||||
import type { ProfileJob } from './ProfileDialog'
|
||||
import { ProfileSettingsDialog } from './ProfileSettings'
|
||||
@@ -99,7 +103,11 @@ export function CatalogPage() {
|
||||
const search = writeListParams(params, list).toString()
|
||||
searchRef.current = search ? `?${search}` : ''
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set())
|
||||
const [drafting, setDrafting] = useState(false)
|
||||
/**
|
||||
* 起草对话框开着没有、打开时显示什么:setup 选范围开始;job 显示页面跟着的任务(进行中的进度、刚结束的结果)。
|
||||
* busy:是撞上已有任务(409)转过去显示的;preset:设置阶段预先选好的范围(表详情里「补充起草要求」)
|
||||
*/
|
||||
const [draftOpen, setDraftOpen] = useState<{ view: 'setup' | 'job'; busy?: boolean; preset?: DraftPreset } | null>(null)
|
||||
/** 起草写进了这些表:详情据此重新载入(编辑中的不动,保存时服务端会用版本号拦下) */
|
||||
const [drafted, setDrafted] = useState<{ seq: number; tables: Set<string> }>({ seq: 0, tables: new Set() })
|
||||
const [profiling, setProfiling] = useState(false)
|
||||
@@ -142,11 +150,67 @@ export function CatalogPage() {
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const onDrafted = useCallback((tables: string[]) => {
|
||||
setDrafted((cur) => ({ seq: cur.seq + 1, tables: new Set(tables) }))
|
||||
setSelected(new Set())
|
||||
void load()
|
||||
}, [load])
|
||||
/**
|
||||
* 起草任务(后台进行,状态在页面上):每张表一结束就重取清单、正在看的表在里面就重新载入(编辑中的不动,保存时服务端
|
||||
* 会用版本号拦下);结束时重取一次清单。对话框关着时结束的,弹提示并给「查看结果」
|
||||
*/
|
||||
const draftOpenRef = useRef(draftOpen)
|
||||
draftOpenRef.current = draftOpen
|
||||
const draft = useDraftJob(sourceId, data?.active_job, {
|
||||
onTablesEnded: (tables) => {
|
||||
setDrafted((cur) => ({ seq: cur.seq + 1, tables: new Set(tables) }))
|
||||
void load()
|
||||
},
|
||||
onFinished: (job) => {
|
||||
void load()
|
||||
if (draftOpenRef.current) return
|
||||
const src = job.source_id
|
||||
const opts = {
|
||||
key: `catalog-draft:${src}`, duration: 10000,
|
||||
action: { label: DT.viewResult, onClick: () => { if (sourceRef.current === src) setDraftOpen({ view: 'job' }) } },
|
||||
}
|
||||
// 留空的列不算没写全,提示里接着写一句,点「查看结果」看是哪几张表、怎么办
|
||||
const blank = jobBlank(job)
|
||||
if (job.status === 'succeeded') toast.ok(DT.toast.withBlank(DT.toast.succeeded(CT.draftSummary(job.added, job.updated, job.removed)), blank), opts)
|
||||
else if (job.status === 'partial') toast.warn(DT.toast.withBlank(DT.toast.partial(retryableCount(job)), blank), opts)
|
||||
else if (job.status === 'failed') toast.error(DT.toast.failed(job.error ?? ''), opts)
|
||||
else if (job.status === 'interrupted') toast.warn(DT.toast.interrupted, opts)
|
||||
else toast.warn(DT.toast.cancelled, opts)
|
||||
},
|
||||
})
|
||||
const draftJob = draft.job
|
||||
const draftActive = isJobActive(draftJob)
|
||||
/** 打开起草对话框:有进行中的任务就直接显示它;上一次的结果看过了,再点就是重新开始 */
|
||||
const openDraft = useCallback(() => setDraftOpen({ view: draftActive ? 'job' : 'setup' }), [draftActive])
|
||||
const startDraft = useCallback(async (body: DraftStartBody) => {
|
||||
const r = await draft.start(body)
|
||||
// 选中的表已经交给起草任务了:清空,免得接着点「起草所选」又起草一遍
|
||||
if (!r.busy) setSelected(new Set())
|
||||
return r
|
||||
}, [draft.start])
|
||||
/** 详情里「重新起草这张表」:只含这张表、用模型、不带补充说明。模型用不了等开始不了的,直接提示原因 */
|
||||
const redraftTable = useCallback(async (t: string) => {
|
||||
try {
|
||||
const r = await draft.start({ tables: [t], use_model: true })
|
||||
setDraftOpen({ view: 'job', busy: r.busy })
|
||||
} catch (e) {
|
||||
toast.error(e)
|
||||
}
|
||||
}, [draft.start])
|
||||
/** 详情里「补充起草要求」(上次起草有留空的列):打开起草设置,范围只有这张表,先补起草要求再开始 */
|
||||
const draftWithGuidance = useCallback((t: string) => {
|
||||
setDraftOpen({ view: 'setup', preset: { tables: [t], label: DT.scopeTable(t) } })
|
||||
}, [])
|
||||
const onGuidanceSaved = useCallback((g: string | null) => {
|
||||
setData((cur) => cur && ({ ...cur, guidance: g }))
|
||||
}, [])
|
||||
// 上方的进度:跟着的任务在进行就按它;跟着的就是清单说的那一个、已经结束了,就不信清单(清单还没重取回来)
|
||||
const listActive = isJobActive(data?.active_job) ? data?.active_job ?? null : null
|
||||
const stripJob: { done: number; total: number; status: string } | null = draftJob && (draftActive || draftJob.id === listActive?.id)
|
||||
? (draftActive ? draftJob : null)
|
||||
: listActive
|
||||
// 调用接连没写出来的提醒:关着对话框时也要看得到(上方的进度带着它)
|
||||
const stripNotice = draftActive ? draftJob?.notice?.trim() || null : null
|
||||
|
||||
const options = savedOptions?.sourceId === sourceId ? savedOptions.options : source?.options
|
||||
const profileSettings = useMemo(() => profileSettingsOf(options), [options])
|
||||
@@ -262,7 +326,7 @@ export function CatalogPage() {
|
||||
const empty = rows.length > 0 && rows.every((r) => progressOf(r.counts) === 'none')
|
||||
const hasTables = rows.length > 0
|
||||
const draftButton = (primary = false) => (
|
||||
<button type="button" className={clsx('btn btn-sm', primary && 'btn-primary')} onClick={() => setDrafting(true)} data-catalog-draft="">
|
||||
<button type="button" className={clsx('btn btn-sm', primary && 'btn-primary')} onClick={openDraft} data-catalog-draft="">
|
||||
<Sparkles size={11} aria-hidden /> {CT.draft}
|
||||
</button>
|
||||
)
|
||||
@@ -281,7 +345,7 @@ export function CatalogPage() {
|
||||
)
|
||||
// 页头的「起草」窄屏只留图标,标题才放得下
|
||||
const headerDraft = (
|
||||
<button type="button" className="btn btn-sm" onClick={() => setDrafting(true)} aria-label={CT.draft} title={CT.draft} data-catalog-draft="">
|
||||
<button type="button" className="btn btn-sm" onClick={openDraft} aria-label={CT.draft} title={CT.draft} data-catalog-draft="">
|
||||
<Sparkles size={11} aria-hidden /> <span className="hidden sm:inline">{CT.draft}</span>
|
||||
</button>
|
||||
)
|
||||
@@ -335,7 +399,7 @@ export function CatalogPage() {
|
||||
onSelected={setSelected}
|
||||
active={table}
|
||||
onOpen={openTable}
|
||||
onDraft={() => setDrafting(true)}
|
||||
onDraft={openDraft}
|
||||
onProfile={openProfile}
|
||||
/>
|
||||
</aside>
|
||||
@@ -343,7 +407,8 @@ export function CatalogPage() {
|
||||
{table
|
||||
? (
|
||||
<TableDetail sourceId={sourceId} table={table} rows={rows} drafted={drafted} fillCodes={fillCodes} prev={prev} next={next}
|
||||
onFillDone={onFillDone} onOpen={openTable} onBack={backToList} onDetail={onDetail} />
|
||||
onFillDone={onFillDone} onOpen={openTable} onBack={backToList} onDetail={onDetail}
|
||||
draftBusy={!!stripJob} onRedraft={redraftTable} onDraftGuidance={draftWithGuidance} />
|
||||
)
|
||||
: <Overview rows={rows} empty={empty} systemNotes={data.system_notes} draft={draftButton(true)} onOpen={openTable} />}
|
||||
</section>
|
||||
@@ -373,16 +438,28 @@ export function CatalogPage() {
|
||||
onGoSource={() => navigate(back)} />
|
||||
)}
|
||||
{hasTables && usage.used > 0 && !empty && <UsageSummary {...usage} active={usedOnly ? filter : null} onFocus={focusUsed} />}
|
||||
{hasTables && stripJob && !draftOpen && <DraftStrip job={stripJob} notice={stripNotice} onOpen={openDraft} />}
|
||||
{body}
|
||||
{drafting && data && (
|
||||
{draftOpen && data && (
|
||||
<DraftDialog
|
||||
sourceId={sourceId}
|
||||
sourceDescription={source?.description ?? ''}
|
||||
guidance={data.guidance ?? ''}
|
||||
rows={rows}
|
||||
visible={visible}
|
||||
selected={[...selected]}
|
||||
filtered={filtered}
|
||||
onClose={() => setDrafting(false)}
|
||||
onDrafted={onDrafted}
|
||||
job={draftJob}
|
||||
initial={draftOpen.view}
|
||||
initialBusy={!!draftOpen.busy}
|
||||
preset={draftOpen.preset ?? null}
|
||||
stopping={draft.stopping}
|
||||
onStart={startDraft}
|
||||
onCancel={draft.cancel}
|
||||
onRetry={draft.retry}
|
||||
onTrack={draft.track}
|
||||
onGuidanceSaved={onGuidanceSaved}
|
||||
onClose={() => setDraftOpen(null)}
|
||||
/>
|
||||
)}
|
||||
{profiling && data && (
|
||||
@@ -412,6 +489,41 @@ export function CatalogPage() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 起草任务在进行:目录页上方一条进度(关掉对话框任务照常进行,每张表起草完就写进目录)。点「查看进度」回到对话框。
|
||||
* notice:调用接连没写出来时服务端给的提醒,跟着写一行(全文在悬停里,对话框里也有)
|
||||
*/
|
||||
function DraftStrip({ job, notice, onOpen }: {
|
||||
job: { done: number; total: number; status: string }
|
||||
notice: string | null
|
||||
onOpen: () => void
|
||||
}) {
|
||||
const text = job.status === 'queued' ? DT.stripQueued(job.total) : DT.strip(job.done, job.total)
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-2 text-xs"
|
||||
style={{ background: 'color-mix(in srgb, var(--accent) 6%, var(--bg-panel))' }}
|
||||
data-catalog-draft-progress={`${job.done}/${job.total}`}>
|
||||
<Spinner size={12} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium" title={text}>{text}</p>
|
||||
{notice && (
|
||||
<p className="mt-0.5 flex min-w-0 items-center gap-1 text-2xs text-[var(--warn)]" role="status" title={notice}
|
||||
data-catalog-draft-notice="">
|
||||
<TriangleAlert size={10} className="shrink-0" aria-hidden />
|
||||
<span className="min-w-0 truncate">{notice}</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-1 h-1 max-w-xs overflow-hidden rounded-full bg-hover" role="progressbar" aria-label={text}
|
||||
aria-valuemin={0} aria-valuemax={job.total} aria-valuenow={job.done}>
|
||||
<div className="h-full rounded-full bg-[var(--accent)] transition-[width] duration-300"
|
||||
style={{ width: `${job.total ? (job.done / job.total) * 100 : 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-sm shrink-0" onClick={onOpen} data-catalog-draft-open="">{DT.stripOpen}</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 媒体查询的当前结果,跟着窗口大小变 */
|
||||
function useMedia(query: string): boolean {
|
||||
const [on, setOn] = useState(() => typeof matchMedia !== 'function' || matchMedia(query).matches)
|
||||
|
||||
@@ -1,66 +1,58 @@
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { CircleCheck, CircleDashed, CircleMinus, CircleSlash, CircleX, Info, RotateCcw, Sparkles, TriangleAlert } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { api } from '../../api/client'
|
||||
import type { CatalogDraftRow, CatalogTableRow } from '../../types'
|
||||
import { Modal, Notice, Spinner } from '../../components/ui'
|
||||
import { ApiError, api } from '../../api/client'
|
||||
import type { CatalogDraftJob, CatalogDraftTask, CatalogDraftTaskStatus, CatalogTableRow } from '../../types'
|
||||
import { Field, Modal, Notice, Spinner } from '../../components/ui'
|
||||
import { errorMessage } from '../../lib/errors'
|
||||
import { CATALOG_TEXT as CT } from '../../lib/terms'
|
||||
import { formatTime } from '../../lib/format'
|
||||
import { CATALOG_DRAFT_TEXT as DT, CATALOG_TEXT as CT, DRAFT_GUIDANCE_MAX, DRAFT_NOTE_MAX } from '../../lib/terms'
|
||||
import {
|
||||
DRAFT_MAX_TABLES, DRAFT_MODEL_UNAVAILABLE, DRAFT_PART_COLUMNS, DRAFT_TOP_N, RETRYABLE, blankOf, isJobActive, jobBlank, needsLook,
|
||||
nothingWritten, retryableCount, taskReason,
|
||||
} from './draftJob'
|
||||
import type { DraftPreset, DraftStartBody } from './draftJob'
|
||||
|
||||
// ===========================================================================
|
||||
// 起草数据目录。接口是同步的,一次请求起草完才返回;表多时分批调用,每批回来就更新进度和累计的新增、更新、删除,
|
||||
// 中途可以停止(当前这一批做完后不再发下一批)。模型整体用不了时第一批就会知道:之后的批次不再请模型,结果里
|
||||
// 写明原因。已经写进去的批次不回滚——起草只增改推断项,人工确认、驳回过的项服务端不会动。
|
||||
// 起草数据目录。起草是服务端的后台任务:这里选范围、写起草要求和本次补充说明,建任务;之后显示页面跟着的那个任务
|
||||
// (进行中的进度、结束后的结果),任务本身的状态在页面上(draftJob.ts),关掉对话框照常进行。
|
||||
//
|
||||
// 三段:设置(范围、用不用模型;用模型时写起草要求、补充说明,写明用哪个起草模型)→ 进行中(总进度、合计、调用接连
|
||||
// 没写出来时的提醒、逐表的状态和宽表的段进度、没写全的原因、留空的列;可以停止、在后台继续)→ 结束(汇总、没写全的表
|
||||
// 和原因、留空的列合计、只重试失败的表)。
|
||||
// 已经写进目录的内容不回滚——起草只增改推断项,人工确认、驳回过的项服务端不会动。
|
||||
//
|
||||
// 留空的列(模型列出了、中文名和含义都没给)不算没写全,表照样是已完成;结果里写合计和办法——在起草要求里补充缩写和
|
||||
// 术语的含义,再重新起草这几张表。「重新起草这几张表」回到设置:范围就是那几张、勾着用模型、焦点在起草要求,
|
||||
// 补完再开始。表详情里的「补充起草要求」也是这样打开(范围是那一张,见 preset)。预先选好的范围里的表已经不在表结构
|
||||
// 里时不退回别的范围:那一项照旧选着、写明原因,不能开始。
|
||||
// ===========================================================================
|
||||
|
||||
/** 每批几张表。只按注释、外键和命名起草时只看探查缓存,很快;请模型时服务端每 4 张一批、最多 3 批并发 */
|
||||
const BATCH_PLAIN = 40
|
||||
const BATCH_MODEL = 12
|
||||
/** 服务端不给 tables 时取前 20 张,这里给出同样的范围,进度才算得出来 */
|
||||
const TOP_N = 20
|
||||
/** 结果里逐张列出的表最多几张,其余合成一句 */
|
||||
const LIST_MAX = 8
|
||||
type Scope = 'preset' | 'selected' | 'filtered' | 'top' | 'all'
|
||||
|
||||
type Scope = 'selected' | 'filtered' | 'top' | 'all'
|
||||
type Phase = 'setup' | 'running' | 'done'
|
||||
/** 字数按服务端的算法:去掉首尾空白后的字符数(按码位,不按 UTF-16 的长度) */
|
||||
const charCount = (s: string) => [...s.trim()].length
|
||||
|
||||
interface Outcome {
|
||||
done: number
|
||||
total: number
|
||||
added: number
|
||||
updated: number
|
||||
removed: number
|
||||
tableErrors: CatalogDraftRow[]
|
||||
modelErrors: CatalogDraftRow[]
|
||||
modelSkipped: string | null
|
||||
model: string | null
|
||||
failure: string | null
|
||||
stopped: boolean
|
||||
/** 这一次勾了「用模型起草」 */
|
||||
modelAsked: boolean
|
||||
/** 模型给了可用内容(model_items > 0)的表有几张;服务端没给 model_items(老服务端)时 modelItemsKnown 为假 */
|
||||
modelItems: number
|
||||
modelItemsKnown: boolean
|
||||
}
|
||||
|
||||
const fresh = (total: number, modelAsked = false): Outcome => ({
|
||||
done: 0, total, added: 0, updated: 0, removed: 0, tableErrors: [], modelErrors: [], modelSkipped: null, model: null,
|
||||
failure: null, stopped: false, modelAsked, modelItems: 0, modelItemsKnown: false,
|
||||
})
|
||||
/** 起草要求输入框的 id:预先选好范围回到设置时焦点放在这里 */
|
||||
const GUIDANCE_ID = 'catalog-draft-guidance'
|
||||
|
||||
/**
|
||||
* 一项都没写进去时怎么说。「没有变化」只在确实是「起草出了内容、和现有目录一致」时说:有表没起草、模型在某些表上
|
||||
* 没给出内容(model_error),或者勾了用模型、却没有一张表拿到模型的可用内容(模型整体用不了、model_items 都是 0),
|
||||
* 都说「目录没有更新」,原因写在下面
|
||||
* 建任务(开始、只重试失败的表)没建成怎么说。起草模型用不了(409,建任务和重试都会回):服务端那句话已经写了原因和
|
||||
* 去处,照原样,长句也不拆成「标题:原因」;别的错误前面写明没有开始
|
||||
*/
|
||||
function nothingWrittenText(out: Outcome): string {
|
||||
const failed = out.modelErrors.length > 0 || out.tableErrors.length > 0
|
||||
const modelEmpty = out.modelAsked && (!!out.modelSkipped || (out.modelItemsKnown && out.modelItems === 0))
|
||||
return failed || modelEmpty ? CT.draftNothingWritten : CT.draftNoChange
|
||||
}
|
||||
const startErrorText = (e: unknown) =>
|
||||
(e instanceof ApiError && e.code === DRAFT_MODEL_UNAVAILABLE ? e.message : DT.startFailed(errorMessage(e)))
|
||||
|
||||
export function DraftDialog({ sourceId, rows, visible, selected, filtered, onClose, onDrafted }: {
|
||||
export function DraftDialog({
|
||||
sourceId, sourceDescription, guidance, rows, visible, selected, filtered, job, initial, initialBusy = false, preset: initialPreset = null,
|
||||
stopping, onStart, onCancel, onRetry, onTrack, onGuidanceSaved, onClose,
|
||||
}: {
|
||||
sourceId: string
|
||||
/** 数据源说明:用模型起草时连同起草要求一起交给模型 */
|
||||
sourceDescription: string
|
||||
/** 数据源存着的起草要求(没有为空串) */
|
||||
guidance: string
|
||||
/** 全部表(服务端的顺序:使用次数多的在前) */
|
||||
rows: CatalogTableRow[]
|
||||
/** 当前筛选结果 */
|
||||
@@ -68,93 +60,233 @@ export function DraftDialog({ sourceId, rows, visible, selected, filtered, onClo
|
||||
selected: string[]
|
||||
/** 清单上有没有生效的搜索或筛选:有时才给「当前筛选结果」这一项 */
|
||||
filtered: boolean
|
||||
/** 页面跟着的起草任务(进行中的、刚结束的) */
|
||||
job: CatalogDraftJob | null
|
||||
/** 打开时显示什么:setup 选范围、开始;job 显示页面跟着的那个任务 */
|
||||
initial: 'setup' | 'job'
|
||||
/** 打开时显示的任务是撞上已有任务(409)转过来的:写明 */
|
||||
initialBusy?: boolean
|
||||
/**
|
||||
* 打开设置时预先选好的范围(表详情里「补充起草要求」:只有那张表)。给了就选它;里面有可起草的表时勾上用模型、
|
||||
* 焦点放在起草要求上(打开它就是为了先补起草要求再起草)。一张可起草的都没有(已不在表结构里)时照旧选着它、
|
||||
* 写明原因、不能开始,也不勾用模型——不退回别的范围
|
||||
*/
|
||||
preset?: DraftPreset | null
|
||||
/** 点了停止、服务端还没停下来 */
|
||||
stopping: boolean
|
||||
/** 建任务;409 已有任务时页面改取进行中的那一个(busy 为真) */
|
||||
onStart: (body: DraftStartBody) => Promise<{ job: CatalogDraftJob; busy: boolean }>
|
||||
onCancel: () => Promise<void>
|
||||
onRetry: (from: CatalogDraftJob) => Promise<{ job: CatalogDraftJob; busy: boolean }>
|
||||
/** 设置阶段点开了上一次起草的结果(或者发现别处建的进行中的任务):页面改跟这个任务 */
|
||||
onTrack: (job: CatalogDraftJob) => void
|
||||
/** 起草要求存好了:页面记下新的这一份 */
|
||||
onGuidanceSaved: (guidance: string | null) => void
|
||||
onClose: () => void
|
||||
/** 有批次写进去了:重取清单、刷新正在看的表 */
|
||||
onDrafted: (tables: string[]) => void
|
||||
}) {
|
||||
// 表结构里已经没有的表起草不了(服务端会回「表结构里没有」),不算进范围
|
||||
// 表结构里已经没有的表起草不了(服务端会记「已跳过」),不算进范围
|
||||
const live = useMemo(() => rows.filter((r) => r.in_schema).map((r) => r.table_name), [rows])
|
||||
const liveSet = useMemo(() => new Set(live), [live])
|
||||
/** 预先选好的范围:打开时给的,或者结果里点了「重新起草这几张表」 */
|
||||
const [preset, setPreset] = useState<DraftPreset | null>(initialPreset)
|
||||
const scopes = useMemo(() => {
|
||||
const out: { key: Scope; label: string; tables: string[] }[] = []
|
||||
const out: { key: Scope; label: string; tables: string[]; gone?: number }[] = []
|
||||
// 预先选好的范围总在第一项:里面的表已经不在表结构里的(打开表详情以后别处重新探查了结构)不起草、写明有几张;
|
||||
// 一张都不剩也留着这一项,不退回别的范围——以为只起草那一张,实际按别的范围用模型起草了
|
||||
if (preset) {
|
||||
const pre = preset.tables.filter((t) => liveSet.has(t))
|
||||
out.push({ key: 'preset', label: preset.label, tables: pre, gone: preset.tables.length - pre.length })
|
||||
}
|
||||
const sel = selected.filter((t) => liveSet.has(t))
|
||||
if (sel.length) out.push({ key: 'selected', label: CT.scopeSelected(sel.length), tables: sel })
|
||||
const vis = visible.filter((r) => r.in_schema).map((r) => r.table_name)
|
||||
if (filtered && vis.length && vis.length !== live.length) out.push({ key: 'filtered', label: CT.scopeFiltered(vis.length), tables: vis })
|
||||
if (live.length > TOP_N) out.push({ key: 'top', label: CT.scopeTop(TOP_N), tables: live.slice(0, TOP_N) })
|
||||
if (live.length > DRAFT_TOP_N) out.push({ key: 'top', label: CT.scopeTop(DRAFT_TOP_N), tables: live.slice(0, DRAFT_TOP_N) })
|
||||
out.push({ key: 'all', label: CT.scopeAll(live.length), tables: live })
|
||||
return out
|
||||
}, [selected, visible, filtered, live, liveSet])
|
||||
}, [preset, selected, visible, filtered, live, liveSet])
|
||||
const [scope, setScope] = useState<Scope>(() => scopes[0].key)
|
||||
const [useModel, setUseModel] = useState(false)
|
||||
const [phase, setPhase] = useState<Phase>('setup')
|
||||
const [out, setOut] = useState<Outcome>(() => fresh(0))
|
||||
const [stopping, setStopping] = useState(false)
|
||||
const stopRef = useRef(false)
|
||||
const tables = scopes.find((s) => s.key === scope)?.tables ?? scopes[0].tables
|
||||
/** 起草的就是这一项:选的那一项不在了(所选、筛选变了)就是第一项,单选框也照它勾,看得见起草的是哪些 */
|
||||
const current = scopes.find((s) => s.key === scope) ?? scopes[0]
|
||||
const tables = current.tables
|
||||
const tooMany = tables.length > DRAFT_MAX_TABLES
|
||||
/** 打开时给的范围里有可起草的表:勾上用模型、焦点放进起草要求(打开它就是为了先补起草要求);一张都没有就都不做 */
|
||||
const presetLive = !!initialPreset && initialPreset.tables.some((t) => liveSet.has(t))
|
||||
const [useModel, setUseModel] = useState(presetLive)
|
||||
/** 回到设置以后把焦点放进起草要求(预先选好范围的两种入口都是为了先补起草要求) */
|
||||
const [focusGuidance, setFocusGuidance] = useState(presetLive)
|
||||
const [guidanceText, setGuidanceText] = useState(guidance)
|
||||
const [note, setNote] = useState('')
|
||||
const guidanceLen = charCount(guidanceText)
|
||||
const noteLen = charCount(note)
|
||||
const guidanceOver = guidanceLen > DRAFT_GUIDANCE_MAX
|
||||
const noteOver = noteLen > DRAFT_NOTE_MAX
|
||||
const guidanceChanged = guidanceText.trim() !== guidance.trim()
|
||||
|
||||
const run = async () => {
|
||||
stopRef.current = false
|
||||
setStopping(false)
|
||||
setPhase('running')
|
||||
const acc = fresh(tables.length, useModel)
|
||||
setOut({ ...acc })
|
||||
const touched: string[] = []
|
||||
let withModel = useModel
|
||||
let i = 0
|
||||
while (i < tables.length) {
|
||||
if (stopRef.current) { acc.stopped = true; break }
|
||||
const batch = tables.slice(i, i + (withModel ? BATCH_MODEL : BATCH_PLAIN))
|
||||
try {
|
||||
const r = await api.dataCatalog.draft(sourceId, { tables: batch, use_model: withModel })
|
||||
acc.added += r.total.added
|
||||
acc.updated += r.total.updated
|
||||
acc.removed += r.total.removed
|
||||
acc.tableErrors.push(...r.tables.filter((t) => t.error))
|
||||
acc.modelErrors.push(...r.tables.filter((t) => !t.error && t.model_error))
|
||||
acc.modelItems += r.tables.filter((t) => !t.error && (t.model_items ?? 0) > 0).length
|
||||
if (r.tables.some((t) => t.model_items !== undefined)) acc.modelItemsKnown = true
|
||||
if (r.model) acc.model = r.model
|
||||
// 模型整体用不了(没配置、已停用……):后面的批次不再请模型,免得每批都撞一次同样的错
|
||||
if (withModel && r.model_error && !r.model_used) { acc.modelSkipped = r.model_error; withModel = false }
|
||||
touched.push(...r.tables.filter((t) => !t.error).map((t) => t.table_name))
|
||||
} catch (e) {
|
||||
acc.failure = errorMessage(e)
|
||||
break
|
||||
const [mode, setMode] = useState<'setup' | 'job'>(initial === 'job' && job ? 'job' : 'setup')
|
||||
const [starting, setStarting] = useState(false)
|
||||
const [startError, setStartError] = useState<string | null>(null)
|
||||
/** 是 409(已有任务)转过来显示的那一个 */
|
||||
const [busy, setBusy] = useState(initialBusy)
|
||||
const [acting, setActing] = useState(false)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
/** 上一次起草(没写全、有留空列的才提),设置阶段写一行、可以点开 */
|
||||
const [last, setLast] = useState<CatalogDraftJob | null>(null)
|
||||
/** 起草模型的说法:设置里的起草模型,没配就是跟随助手的模型 */
|
||||
const [modelLine, setModelLine] = useState<{ text: string; warn: string | null } | null>(null)
|
||||
const onTrackRef = useRef(onTrack)
|
||||
onTrackRef.current = onTrack
|
||||
|
||||
const inSetup = mode === 'setup' || !job
|
||||
// 设置阶段:取上一次起草。别处(另一个标签页)建的任务还在进行:直接显示它
|
||||
useEffect(() => {
|
||||
if (!inSetup) return
|
||||
let alive = true
|
||||
api.dataCatalog.latestDraftJob(sourceId).then(({ job: latest }) => {
|
||||
if (!alive || !latest) return
|
||||
if (isJobActive(latest)) {
|
||||
onTrackRef.current(latest)
|
||||
setBusy(true)
|
||||
setMode('job')
|
||||
} else if (latest.status !== 'succeeded' || jobBlank(latest) > 0) {
|
||||
setLast(latest)
|
||||
}
|
||||
i += batch.length
|
||||
acc.done = i
|
||||
setOut({ ...acc })
|
||||
}, () => {})
|
||||
return () => { alive = false }
|
||||
}, [inSetup, sourceId])
|
||||
|
||||
// 预先选好范围回到设置(打开时给的、结果里点的):焦点放进起草要求
|
||||
useEffect(() => {
|
||||
if (!focusGuidance || mode !== 'setup' || !useModel) return
|
||||
const el = document.getElementById(GUIDANCE_ID)
|
||||
if (!el) return
|
||||
el.focus()
|
||||
setFocusGuidance(false)
|
||||
}, [focusGuidance, mode, useModel])
|
||||
|
||||
// 起草模型:设置里的 catalog_draft 填了就是它;没填跟随助手的模型(写出当前是哪个,用不了时写原因)
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
void Promise.allSettled([api.settings.get(), api.copilot.getModel()]).then(([s, c]) => {
|
||||
if (!alive) return
|
||||
const cfg = s.status === 'fulfilled' ? s.value?.catalog_draft : null
|
||||
const provider = typeof cfg?.provider === 'string' && cfg.provider.trim() ? cfg.provider.trim() : null
|
||||
const model = typeof cfg?.model === 'string' && cfg.model.trim() ? cfg.model.trim() : null
|
||||
if (provider || model) {
|
||||
setModelLine({ text: DT.modelNamed(model, provider), warn: null })
|
||||
return
|
||||
}
|
||||
const assistant = c.status === 'fulfilled' ? c.value : null
|
||||
setModelLine({ text: DT.modelFollow(assistant?.effective_model ?? null), warn: assistant?.error ?? null })
|
||||
})
|
||||
return () => { alive = false }
|
||||
}, [])
|
||||
|
||||
const begin = async () => {
|
||||
if (starting || !tables.length || tooMany || (useModel && (guidanceOver || noteOver))) return
|
||||
setStarting(true)
|
||||
setStartError(null)
|
||||
try {
|
||||
// 起草要求改过:先存(存不上就不开始,免得模型拿着旧的那份起草)
|
||||
if (useModel && guidanceChanged) {
|
||||
try {
|
||||
const saved = await api.dataCatalog.putGuidance(sourceId, guidanceText.trim())
|
||||
onGuidanceSaved(saved.guidance)
|
||||
} catch (e) {
|
||||
setStartError(DT.saveGuidanceFailed(errorMessage(e)))
|
||||
return
|
||||
}
|
||||
}
|
||||
const extra = useModel && note.trim() ? { note: note.trim() } : {}
|
||||
const r = await onStart({ tables, use_model: useModel, ...extra })
|
||||
setBusy(r.busy)
|
||||
setMode('job')
|
||||
} catch (e) {
|
||||
// 起草模型用不了(没有接入、已停用、只有演示接入)时留在这一步,写明原因
|
||||
setStartError(startErrorText(e))
|
||||
} finally {
|
||||
setStarting(false)
|
||||
}
|
||||
setOut({ ...acc })
|
||||
setPhase('done')
|
||||
if (touched.length) onDrafted(touched)
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
stopRef.current = true
|
||||
setStopping(true)
|
||||
const act = async (fn: () => Promise<void>, describe: (e: unknown) => string = errorMessage) => {
|
||||
setActing(true)
|
||||
setActionError(null)
|
||||
try {
|
||||
await fn()
|
||||
} catch (e) {
|
||||
setActionError(describe(e))
|
||||
} finally {
|
||||
setActing(false)
|
||||
}
|
||||
}
|
||||
const stop = () => act(onCancel)
|
||||
// 重试是新建一个任务:没建成(起草模型用不了、没有表结构……)和开始时同一个说法,留在结果里
|
||||
const retry = (from: CatalogDraftJob) => act(async () => {
|
||||
const r = await onRetry(from)
|
||||
setBusy(r.busy)
|
||||
}, startErrorText)
|
||||
const openLast = (j: CatalogDraftJob) => {
|
||||
onTrack(j)
|
||||
setBusy(false)
|
||||
setMode('job')
|
||||
}
|
||||
/**
|
||||
* 结果里「重新起草这几张表」(有留空列的表):回到设置,范围就是这几张,勾上用模型,焦点放进起草要求——先补上
|
||||
* 缩写和术语的含义再开始。任务本身不动(页面照旧跟着它,开始新的起草才换)
|
||||
*/
|
||||
const redraftBlank = (names: string[]) => {
|
||||
setPreset({ tables: names, label: DT.scopeBlank(names.length) })
|
||||
setScope('preset')
|
||||
setUseModel(true)
|
||||
setFocusGuidance(true)
|
||||
setStartError(null)
|
||||
setActionError(null)
|
||||
setBusy(false)
|
||||
setLast(null)
|
||||
setMode('setup')
|
||||
}
|
||||
/** 结果里有留空列、还在表结构里的表(「重新起草这几张表」的范围) */
|
||||
const blankTables = useMemo(
|
||||
() => (job ? job.tasks.filter((t) => blankOf(t) > 0 && liveSet.has(t.table_name)).map((t) => t.table_name) : []),
|
||||
[job, liveSet],
|
||||
)
|
||||
|
||||
// 进行中关窗 = 停止:当前这一批做完再关,结果照常显示
|
||||
const requestClose = () => (phase === 'running' ? stop() : onClose())
|
||||
const phase: 'setup' | 'running' | 'done' = inSetup ? 'setup' : isJobActive(job) ? 'running' : 'done'
|
||||
const canStart = !!tables.length && !tooMany && !(useModel && (guidanceOver || noteOver)) && !starting
|
||||
const retryable = job && phase === 'done' ? retryableCount(job) : 0
|
||||
|
||||
const footer = phase === 'setup'
|
||||
? (
|
||||
<>
|
||||
<button className="btn" onClick={onClose}>{CT.cancel}</button>
|
||||
<button className="btn btn-primary" onClick={() => void run()} disabled={!tables.length} data-draft-start="">
|
||||
<Sparkles size={12} aria-hidden /> {CT.draftStart(tables.length)}
|
||||
<button className="btn btn-primary" onClick={() => void begin()} disabled={!canStart} data-draft-start="">
|
||||
{starting ? <Spinner size={11} /> : <Sparkles size={12} aria-hidden />} {CT.draftStart(tables.length)}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
: phase === 'running'
|
||||
? <button className="btn" onClick={stop} disabled={stopping} data-draft-stop="">{CT.draftStop}</button>
|
||||
: <button className="btn btn-primary" onClick={onClose} data-autofocus data-draft-close="">{CT.draftClose}</button>
|
||||
? (
|
||||
<>
|
||||
<button className="btn" onClick={onClose} title={DT.hideHint} data-draft-hide="">{DT.hide}</button>
|
||||
<button className="btn" onClick={() => void stop()} disabled={stopping || acting} data-draft-stop="">{DT.stop}</button>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{retryable > 0 && job && (
|
||||
<button className="btn" onClick={() => void retry(job)} disabled={acting} title={DT.retryHint} data-draft-retry="">
|
||||
{acting ? <Spinner size={11} /> : <RotateCcw size={11} aria-hidden />} {DT.retry(retryable)}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-primary" onClick={onClose} data-autofocus data-draft-close="">{CT.draftClose}</button>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal open onClose={requestClose} title={CT.draftTitle} width={560} footer={footer}>
|
||||
<div className="space-y-4" data-draft-dialog={phase}>
|
||||
<Modal open onClose={onClose} title={CT.draftTitle} width={600} footer={footer}
|
||||
dirty={phase === 'setup' && useModel && (guidanceChanged || !!note.trim())}>
|
||||
<div className="min-w-0 space-y-4" data-draft-dialog={phase}>
|
||||
{phase === 'setup' && (
|
||||
<>
|
||||
<fieldset>
|
||||
@@ -162,77 +294,268 @@ export function DraftDialog({ sourceId, rows, visible, selected, filtered, onClo
|
||||
<div className="space-y-1.5" role="radiogroup" aria-label={CT.draftScope}>
|
||||
{scopes.map((s) => (
|
||||
<label key={s.key} className={clsx('flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-xs',
|
||||
scope === s.key ? 'border-[var(--accent)] bg-accent-soft' : 'hover:bg-hover')} data-draft-scope={s.key}>
|
||||
<input type="radio" name="catalog-draft-scope" checked={scope === s.key} onChange={() => setScope(s.key)} />
|
||||
<span>{s.label}</span>
|
||||
current.key === s.key ? 'border-[var(--accent)] bg-accent-soft' : 'hover:bg-hover')} data-draft-scope={s.key}>
|
||||
<input type="radio" name="catalog-draft-scope" checked={current.key === s.key} onChange={() => setScope(s.key)} />
|
||||
<span className="min-w-0 break-words">
|
||||
{s.label}
|
||||
{!!s.gone && (
|
||||
<span className={clsx('block text-2xs', s.tables.length ? 'text-faint' : 'text-[var(--warn)]')} data-draft-preset-missing="">
|
||||
{DT.presetMissing(s.gone, s.gone + s.tables.length)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{tooMany && <p className="mt-1 text-2xs text-[var(--err)]" data-draft-too-many="">{DT.tooMany(DRAFT_MAX_TABLES)}</p>}
|
||||
</fieldset>
|
||||
<p className="text-2xs leading-relaxed text-faint">{CT.draftBase}</p>
|
||||
<label className="flex cursor-pointer items-start gap-2 rounded-md border px-3 py-2.5 text-xs hover:bg-hover" data-draft-model="">
|
||||
<input type="checkbox" className="mt-0.5" checked={useModel} onChange={(e) => setUseModel(e.target.checked)} />
|
||||
<span className="min-w-0">
|
||||
<span className="font-medium">{CT.useModel}</span>
|
||||
<span className="mt-0.5 block text-2xs leading-relaxed text-faint">{CT.useModelHint}</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="rounded-md border">
|
||||
<label className="flex cursor-pointer items-start gap-2 rounded-md px-3 py-2.5 text-xs hover:bg-hover" data-draft-model="">
|
||||
<input type="checkbox" className="mt-0.5" checked={useModel} onChange={(e) => setUseModel(e.target.checked)} />
|
||||
<span className="min-w-0">
|
||||
<span className="font-medium">{CT.useModel}</span>
|
||||
<span className="mt-0.5 block text-2xs leading-relaxed text-faint">{CT.useModelHint}</span>
|
||||
</span>
|
||||
</label>
|
||||
{useModel && (
|
||||
<div className="space-y-3 border-t px-3 py-3">
|
||||
<div className="text-2xs leading-relaxed" data-draft-model-name="">
|
||||
<span className="text-dim">{DT.model}:</span>
|
||||
<span className="break-words font-medium">{modelLine ? modelLine.text : '…'}</span>
|
||||
<span className="block text-faint">{DT.modelWhere}</span>
|
||||
{modelLine?.warn && <span className="block text-[var(--warn)]" data-draft-model-warn="">{modelLine.warn}</span>}
|
||||
</div>
|
||||
<div data-draft-guidance="">
|
||||
<Field label={DT.guidance} htmlFor={GUIDANCE_ID} hint={DT.guidanceHint}
|
||||
error={guidanceOver ? DT.tooLong(DRAFT_GUIDANCE_MAX) : undefined}>
|
||||
{(p) => (
|
||||
<textarea {...p} className="field min-h-[4.5rem] resize-y text-xs leading-relaxed" rows={3} value={guidanceText}
|
||||
placeholder={DT.guidancePlaceholder} onChange={(e) => setGuidanceText(e.target.value)} />
|
||||
)}
|
||||
</Field>
|
||||
<Counter n={guidanceLen} max={DRAFT_GUIDANCE_MAX} />
|
||||
</div>
|
||||
<div data-draft-note="">
|
||||
<Field label={DT.note} htmlFor="catalog-draft-note" hint={DT.noteHint}
|
||||
error={noteOver ? DT.tooLong(DRAFT_NOTE_MAX) : undefined}>
|
||||
{(p) => (
|
||||
<textarea {...p} className="field min-h-[3rem] resize-y text-xs leading-relaxed" rows={2} value={note}
|
||||
placeholder={DT.notePlaceholder} onChange={(e) => setNote(e.target.value)} />
|
||||
)}
|
||||
</Field>
|
||||
<Counter n={noteLen} max={DRAFT_NOTE_MAX} />
|
||||
</div>
|
||||
{sourceDescription.trim() && (
|
||||
<p className="break-words text-2xs leading-relaxed text-dim" data-draft-source-description="">
|
||||
{DT.sourceDescription(sourceDescription.trim())}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-2xs leading-relaxed text-faint">{DT.guidanceRule}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{last && (
|
||||
// 没写全的用警告色;都写完了、只是有留空列的是提示
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 rounded-md border px-3 py-2 text-2xs text-dim" data-draft-last={last.status}>
|
||||
{last.status === 'succeeded'
|
||||
? <Info size={11} className="shrink-0" style={{ color: 'var(--accent)' }} aria-hidden />
|
||||
: <TriangleAlert size={11} className="shrink-0" style={{ color: 'var(--warn)' }} aria-hidden />}
|
||||
<span className="min-w-0 flex-1">
|
||||
{DT.last(DT.lastState(DT.title[last.status], jobBlank(last)), formatTime(last.finished_at ?? last.created_at))}
|
||||
</span>
|
||||
<button type="button" className="btn btn-sm" onClick={() => openLast(last)} data-draft-last-open="">{DT.lastOpen}</button>
|
||||
</div>
|
||||
)}
|
||||
{startError && <Notice tone="err" attr={{ 'data-draft-start-error': '' }}>{startError}</Notice>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase !== 'setup' && (
|
||||
<div className="space-y-3" aria-live="polite">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{phase === 'running' && <Spinner size={13} />}
|
||||
<span className="font-medium" data-draft-title="">
|
||||
{phase === 'running'
|
||||
? (stopping ? CT.draftStopping : CT.drafting)
|
||||
: out.failure ? CT.draftFailed : out.stopped ? CT.draftStopped(out.done, out.total) : CT.draftDone}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="tnum text-2xs text-faint" data-draft-progress={`${out.done}/${out.total}`}>{CT.draftProgress(out.done, out.total)}</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-hover" role="progressbar" aria-label={CT.draftProgress(out.done, out.total)}
|
||||
aria-valuemin={0} aria-valuemax={out.total} aria-valuenow={out.done}>
|
||||
<div className="h-full rounded-full transition-[width] duration-300"
|
||||
style={{ width: `${out.total ? (out.done / out.total) * 100 : 0}%`, background: out.failure ? 'var(--err)' : 'var(--accent)' }} />
|
||||
</div>
|
||||
<p className="tnum text-sm" data-draft-summary="">
|
||||
{out.added + out.updated + out.removed === 0 && phase === 'done' && !out.failure
|
||||
? nothingWrittenText(out)
|
||||
: CT.draftSummary(out.added, out.updated, out.removed)}
|
||||
</p>
|
||||
{out.model && <p className="text-2xs text-faint">{CT.draftModelUsed(out.model)}</p>}
|
||||
{out.modelSkipped && (
|
||||
<Notice tone="warn" attr={{ 'data-draft-model-error': '' }}>{CT.draftModelSkipped(out.modelSkipped)}</Notice>
|
||||
)}
|
||||
{out.failure && <Notice tone="err" attr={{ 'data-draft-failure': '' }}>{out.failure}</Notice>}
|
||||
{out.tableErrors.length > 0 && (
|
||||
<Notice tone="err" attr={{ 'data-draft-table-errors': String(out.tableErrors.length) }}>
|
||||
<p>{CT.draftTableErrors(out.tableErrors.length)}</p>
|
||||
<ErrorList rows={out.tableErrors} pick={(r) => r.error ?? ''} />
|
||||
</Notice>
|
||||
)}
|
||||
{out.modelErrors.length > 0 && (
|
||||
<Notice tone="warn" attr={{ 'data-draft-table-model-errors': String(out.modelErrors.length) }}>
|
||||
<p>{CT.draftModelErrors(out.modelErrors.length)}</p>
|
||||
<ErrorList rows={out.modelErrors} pick={(r) => r.model_error ?? ''} />
|
||||
</Notice>
|
||||
)}
|
||||
</div>
|
||||
{phase !== 'setup' && job && (
|
||||
<JobView key={job.id} job={job} running={phase === 'running'} stopping={stopping} busy={busy}
|
||||
blankTables={blankTables} onRedraftBlank={redraftBlank} />
|
||||
)}
|
||||
{phase !== 'setup' && actionError && <Notice tone="err" attr={{ 'data-draft-action-error': '' }}>{actionError}</Notice>}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorList({ rows, pick }: { rows: CatalogDraftRow[]; pick: (r: CatalogDraftRow) => string }) {
|
||||
function Counter({ n, max }: { n: number; max: number }) {
|
||||
return (
|
||||
<ul className="mt-1 space-y-0.5 text-dim">
|
||||
{rows.slice(0, LIST_MAX).map((r) => (
|
||||
<li key={r.table_name} className="break-words"><span className="mono">{r.table_name}</span>:{pick(r)}</li>
|
||||
))}
|
||||
{rows.length > LIST_MAX && <li>{CT.moreTables(rows.length - LIST_MAX)}</li>}
|
||||
</ul>
|
||||
<div className={clsx('mt-0.5 text-right text-2xs tnum', n > max ? 'text-[var(--err)]' : 'text-faint')} aria-hidden>
|
||||
{DT.count(n, max)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务的进度或结果:标题、总进度、提醒(进行中)、合计、模型和用量(超时、没写出来的次数)、没写全的表、留空的列合计
|
||||
* (结束后)、逐表列表
|
||||
*/
|
||||
function JobView({ job, running, stopping, busy, blankTables, onRedraftBlank }: {
|
||||
job: CatalogDraftJob
|
||||
running: boolean
|
||||
stopping: boolean
|
||||
busy: boolean
|
||||
/** 有留空列、还在表结构里的表:结束后给「重新起草这几张表」 */
|
||||
blankTables: string[]
|
||||
onRedraftBlank: (tables: string[]) => void
|
||||
}) {
|
||||
const problems = job.tasks.filter((t) => RETRYABLE.has(t.status))
|
||||
// 结束后默认只列要看的表:没写全的、有留空列的(几百张表里找它们太费劲),可以展开全部
|
||||
const attention = job.tasks.filter(needsLook)
|
||||
const blankCount = job.tasks.filter((t) => blankOf(t) > 0).length
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
const onlyProblems = !running && attention.length > 0 && !showAll
|
||||
const tasks = onlyProblems ? attention : job.tasks
|
||||
const title = running ? (stopping ? DT.stopping : DT.title[job.status]) : DT.title[job.status]
|
||||
const empty = nothingWritten(job)
|
||||
const summary = empty === 'model' ? CT.draftNothingWritten
|
||||
: empty === 'failed' ? CT.draftNothingWrittenFailed
|
||||
: empty === 'same' ? CT.draftNoChange
|
||||
: CT.draftSummary(job.added, job.updated, job.removed)
|
||||
const problemText = !running ? DT.problems({
|
||||
failed: job.tasks.filter((t) => t.status === 'failed').length,
|
||||
partial: job.tasks.filter((t) => t.status === 'partial').length,
|
||||
cancelled: job.tasks.filter((t) => t.status === 'cancelled').length,
|
||||
skipped: job.tasks.filter((t) => t.status === 'skipped').length,
|
||||
}) : ''
|
||||
// 进行中按已结束的表数走;结束以后写全部写出来的有几张(停止、失败的不算「完成」)
|
||||
const ok = job.tasks.filter((t) => t.status === 'ok').length
|
||||
const value = running ? job.done : ok
|
||||
const progress = running ? DT.progress(job.done, job.total) : DT.progressDone(ok, job.total)
|
||||
const tone = job.status === 'failed' ? 'var(--err)' : running ? 'var(--accent)' : problems.length ? 'var(--warn)' : 'var(--st-done)'
|
||||
const pct = job.total ? (value / job.total) * 100 : 0
|
||||
const { usage } = job
|
||||
const timeouts = usage.timeouts ?? 0
|
||||
const failures = DT.usageFailures(timeouts, usage.failed_calls ?? 0)
|
||||
// 调用接连没写出来时服务端给的提醒:进行中写在进度条下面(提醒,不是错误);好转后服务端清掉
|
||||
const notice = running ? job.notice?.trim() ?? '' : ''
|
||||
// 额度用完、超时多了,之后的段缩小了:进行中写一句,段数为什么变多看得明白
|
||||
const partColumns = running && job.use_model && job.part_columns != null && job.part_columns < DRAFT_PART_COLUMNS ? job.part_columns : null
|
||||
const blankTotal = jobBlank(job)
|
||||
return (
|
||||
<div className="min-w-0 space-y-3">
|
||||
{busy && <Notice tone="info" attr={{ 'data-draft-busy': '' }}>{DT.busy}</Notice>}
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs" role="status" aria-live="polite">
|
||||
{running && <Spinner size={13} />}
|
||||
<span className="font-medium" data-draft-title="">{title}</span>
|
||||
<span className="flex-1" />
|
||||
<span className="tnum text-2xs text-faint" data-draft-progress={`${job.done}/${job.total}`}>{progress}</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-hover" role="progressbar" aria-label={progress}
|
||||
aria-valuemin={0} aria-valuemax={job.total} aria-valuenow={value}>
|
||||
<div className="h-full rounded-full transition-[width] duration-300" style={{ width: `${pct}%`, background: tone }} />
|
||||
</div>
|
||||
{notice && <Notice tone="warn" className="break-words" attr={{ 'data-draft-notice': '' }}>{notice}</Notice>}
|
||||
<p className="tnum text-sm" data-draft-summary="">{summary}</p>
|
||||
{(job.model || usage.calls > 0 || failures) && (
|
||||
<div className="space-y-0.5 text-2xs leading-relaxed text-faint">
|
||||
<p className="flex flex-wrap gap-x-3 gap-y-0.5" data-draft-usage-line="">
|
||||
{job.model && <span className="break-all" data-draft-model-used="">{DT.modelUsed(job.model)}</span>}
|
||||
{usage.calls > 0 && (
|
||||
<span className="tnum" data-draft-usage="">{DT.usage(usage.calls, usage.input_tokens, usage.output_tokens)}</span>
|
||||
)}
|
||||
{failures && (
|
||||
<span className="tnum text-[var(--warn)]" title={DT.usageFailuresHint} data-draft-usage-failures="">{failures}</span>
|
||||
)}
|
||||
</p>
|
||||
{timeouts > 0 && <p data-draft-timeout-note="">{DT.timeoutNote}</p>}
|
||||
{partColumns != null && <p data-draft-part-columns={partColumns}>{DT.partColumns(partColumns)}</p>}
|
||||
</div>
|
||||
)}
|
||||
{running && <p className="text-2xs leading-relaxed text-faint">{DT.hideHint}</p>}
|
||||
{job.status === 'failed' && job.error && <Notice tone="err" attr={{ 'data-draft-job-error': '' }}>{job.error}</Notice>}
|
||||
{job.status === 'interrupted' && <Notice tone="warn" attr={{ 'data-draft-interrupted': '' }}>{DT.interrupted}</Notice>}
|
||||
{job.status === 'cancelled' && <p className="text-2xs text-dim" data-draft-cancelled="">{DT.cancelled}</p>}
|
||||
{problemText && (
|
||||
<p className="text-xs leading-relaxed" data-draft-problems="">{problemText}</p>
|
||||
)}
|
||||
{!running && blankTotal > 0 && (
|
||||
// 留空的列不算没写全(表照样是已完成):写合计和办法,给「重新起草这几张表」回到设置先补起草要求
|
||||
<Notice tone="info" attr={{ 'data-draft-blank-summary': '' }}>
|
||||
<p className="font-medium">{DT.blankTotal(blankTotal, blankCount)}</p>
|
||||
<p className="mt-0.5 text-dim">{DT.blankAdvice}</p>
|
||||
{blankTables.length > 0 && (
|
||||
<button type="button" className="btn btn-sm mt-2" title={DT.blankRedraftHint} onClick={() => onRedraftBlank(blankTables)}
|
||||
data-draft-blank-redraft="">
|
||||
<Sparkles size={11} aria-hidden /> {DT.blankRedraft(blankTables.length)}
|
||||
</button>
|
||||
)}
|
||||
</Notice>
|
||||
)}
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="text-2xs font-medium text-dim" id="catalog-draft-tasks-label">{DT.tasks}</span>
|
||||
<span className="flex-1" />
|
||||
{!running && attention.length > 0 && attention.length < job.tasks.length && (
|
||||
<button type="button" className="rounded px-1 text-2xs text-[var(--accent)] outline-none hover:underline focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||
aria-pressed={showAll} onClick={() => setShowAll((v) => !v)} data-draft-show-all="">
|
||||
{showAll ? (blankCount ? DT.showProblemsBlank : DT.showProblems) : DT.showAll(job.tasks.length)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ul className="max-h-64 divide-y divide-[var(--hairline)] overflow-y-auto overflow-x-hidden rounded-md border"
|
||||
aria-labelledby="catalog-draft-tasks-label" data-draft-tasks="">
|
||||
{tasks.map((t) => <TaskRow key={t.table_name} task={t} jobError={job.error} />)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TASK_ICON: Record<CatalogDraftTaskStatus, { Icon: LucideIcon | null; color: string }> = {
|
||||
pending: { Icon: CircleDashed, color: 'var(--text-faint)' },
|
||||
running: { Icon: null, color: 'var(--accent)' },
|
||||
ok: { Icon: CircleCheck, color: 'var(--st-done)' },
|
||||
partial: { Icon: TriangleAlert, color: 'var(--warn)' },
|
||||
failed: { Icon: CircleX, color: 'var(--err)' },
|
||||
skipped: { Icon: CircleMinus, color: 'var(--text-faint)' },
|
||||
cancelled: { Icon: CircleSlash, color: 'var(--text-faint)' },
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐表的一行。jobError:任务整体的原因(整体出错、服务重启中断时服务端把没结束的表也记上同一句)。和它一样的不再逐表
|
||||
* 重复——几百张表各写一遍同一句,真正各有原因的那几张反而找不到;任务的原因已经写在上面(标题、提示)。
|
||||
* 有留空的列时再写一行「N 列模型拿不准、留空了」:列名悬停看得到,也可以展开(服务端最多给 5 个)
|
||||
*/
|
||||
function TaskRow({ task: t, jobError }: { task: CatalogDraftTask; jobError: string | null }) {
|
||||
const { Icon, color } = TASK_ICON[t.status]
|
||||
const reason = jobError && t.error === jobError ? (t.model_error ?? '') : taskReason(t)
|
||||
const parts = t.parts_total != null && t.parts_total > 1 && t.status !== 'pending' && t.status !== 'skipped'
|
||||
const changes = DT.taskAdded(t.added, t.updated)
|
||||
const blank = blankOf(t)
|
||||
const sample = t.blank_sample?.filter(Boolean) ?? []
|
||||
return (
|
||||
<li className="min-w-0 px-3 py-1.5" data-draft-task={t.table_name} data-status={t.status}>
|
||||
<div className="flex min-w-0 items-center gap-2 text-xs">
|
||||
{Icon ? <Icon size={12} className="shrink-0" style={{ color }} aria-hidden /> : <Spinner size={12} />}
|
||||
<span className="mono min-w-0 flex-1 truncate" title={t.table_name}>{t.table_name}</span>
|
||||
{parts && (
|
||||
<span className="tnum shrink-0 text-2xs text-faint" title={DT.partsHint} data-draft-task-parts="">
|
||||
{DT.parts(t.parts_done, t.parts_total!)}
|
||||
</span>
|
||||
)}
|
||||
{changes && (
|
||||
<span className="tnum shrink-0 text-2xs text-faint" title={DT.taskAddedHint(t.added, t.updated, t.removed)}>{changes}</span>
|
||||
)}
|
||||
<span className="shrink-0 text-2xs" style={{ color: t.status === 'pending' || t.status === 'skipped' || t.status === 'cancelled' ? undefined : color }}
|
||||
data-draft-task-status="">
|
||||
{DT.taskStatus[t.status]}
|
||||
</span>
|
||||
</div>
|
||||
{reason && (
|
||||
<p className="mt-0.5 break-words pl-5 text-2xs leading-relaxed text-dim" data-draft-task-reason="">{reason}</p>
|
||||
)}
|
||||
{blank > 0 && (sample.length
|
||||
? (
|
||||
<details className="mt-0.5 pl-5 text-2xs leading-relaxed text-dim" data-draft-task-blank={blank}>
|
||||
<summary className="cursor-pointer select-none hover:text-fg" title={DT.blankSample(sample, blank)}>{DT.blank(blank)}</summary>
|
||||
<p className="break-words text-faint" data-draft-task-blank-sample="">{DT.blankSample(sample, blank)}</p>
|
||||
</details>
|
||||
)
|
||||
: <p className="mt-0.5 pl-5 text-2xs leading-relaxed text-dim" data-draft-task-blank={blank}>{DT.blank(blank)}</p>)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { AlertTriangle, ArrowLeft, ChevronLeft, ChevronRight, GitBranch, ListChecks, Lock, Pencil, Plus, RotateCw, Search, X } from 'lucide-react'
|
||||
import { AlertTriangle, ArrowLeft, ChevronLeft, ChevronRight, GitBranch, ListChecks, Lock, Pencil, Plus, RotateCw, Search, Sparkles, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { ApiError, api } from '../../api/client'
|
||||
import type {
|
||||
@@ -11,7 +11,7 @@ import { DeleteButton, EmptyState, ErrorState, Notice, Skeleton, Spinner, confir
|
||||
import { useLeaveGuard } from '../../lib/leave'
|
||||
import { formatDateTime, formatNumber, formatTime } from '../../lib/format'
|
||||
import {
|
||||
CATALOG_CARDINALITY_LABEL, CATALOG_IMPACT_TEXT, CATALOG_KIND_HINT, CATALOG_KIND_LABEL, CATALOG_TABLE_FIELD_HINT,
|
||||
CATALOG_CARDINALITY_LABEL, CATALOG_DRAFT_TEXT as DT, CATALOG_IMPACT_TEXT, CATALOG_KIND_HINT, CATALOG_KIND_LABEL, CATALOG_TABLE_FIELD_HINT,
|
||||
CATALOG_TABLE_FIELD_LABEL, CATALOG_TEXT as CT, CATALOG_UI_TEXT as UT, CODES_TEXT as KT,
|
||||
} from '../../lib/terms'
|
||||
import { CatalogImpactList } from '../../components/CatalogImpact'
|
||||
@@ -21,6 +21,7 @@ import { ColumnTable, NARROW, Value, pendingCodes } from './ColumnTable'
|
||||
import type { ColumnModel } from './ColumnTable'
|
||||
import { CodesDialog } from './CodesDialog'
|
||||
import { coverageText } from './profile'
|
||||
import { lastBlankOf, taskReason } from './draftJob'
|
||||
import {
|
||||
COLUMN_FIELDS, DATE_KEYS, TABLE_FIELDS, buildNotes, changeCount, columnPath, confirmProposed, countsOf, formFromNotes,
|
||||
relationDraft, relationPath, relationRemoval, tablePath, unknownKeys, validateForm,
|
||||
@@ -51,7 +52,9 @@ const PROBLEM_TEXT = {
|
||||
type Editing = { initial: EditForm; form: EditForm }
|
||||
type Conflict = 'edit' | 'review'
|
||||
|
||||
export function TableDetail({ sourceId, table, rows, drafted, fillCodes, onFillDone, prev, next, onOpen, onBack, onDetail }: {
|
||||
export function TableDetail({
|
||||
sourceId, table, rows, drafted, fillCodes, onFillDone, prev, next, onOpen, onBack, onDetail, draftBusy = false, onRedraft, onDraftGuidance,
|
||||
}: {
|
||||
sourceId: string
|
||||
table: string
|
||||
/** 全部表:关联关系的目标表能点就跳过去,编辑时给目标表做候选 */
|
||||
@@ -72,6 +75,15 @@ export function TableDetail({ sourceId, table, rows, drafted, fillCodes, onFillD
|
||||
onBack: () => void
|
||||
/** 载入或写入之后的单表目录:清单据此更新这一行 */
|
||||
onDetail: (d: CatalogDetail) => void
|
||||
/** 这个数据源有排队、进行中的起草任务:「重新起草这张表」等它结束 */
|
||||
draftBusy?: boolean
|
||||
/**
|
||||
* 上次起草失败、部分完成或有留空列时的「重新起草这张表」:建只含这张表的起草任务(用模型、不带补充说明)。
|
||||
* 表结构里已经没有的表不给
|
||||
*/
|
||||
onRedraft?: (table: string) => Promise<void>
|
||||
/** 上次起草有留空列时的「补充起草要求」:打开起草设置,范围只有这张表,先补起草要求再开始。表结构里已经没有的表不给 */
|
||||
onDraftGuidance?: (table: string) => void
|
||||
}) {
|
||||
const [detail, setDetail] = useState<CatalogDetail | null>(null)
|
||||
const [loadError, setLoadError] = useState<unknown>(null)
|
||||
@@ -101,6 +113,7 @@ export function TableDetail({ sourceId, table, rows, drafted, fillCodes, onFillD
|
||||
*/
|
||||
const [savedAt, setSavedAt] = useState<number | null>(null)
|
||||
const [impact, setImpact] = useState<{ version: number; n: number } | null>(null)
|
||||
const [redrafting, setRedrafting] = useState(false)
|
||||
const loadSeq = useRef(0)
|
||||
const onDetailRef = useRef(onDetail)
|
||||
onDetailRef.current = onDetail
|
||||
@@ -308,6 +321,11 @@ export function TableDetail({ sourceId, table, rows, drafted, fillCodes, onFillD
|
||||
|
||||
const notes = detail.notes
|
||||
const counts = countsOf(notes)
|
||||
/** 这张表最近一次起草的结果(清单上那一行带的) */
|
||||
const lastDraft = rows.find((r) => r.table_name === table)?.last_draft ?? null
|
||||
/** 没写全(失败、部分完成);有留空的列(模型拿不准,表照样写完了) */
|
||||
const lastBad = lastDraft && (lastDraft.status === 'failed' || lastDraft.status === 'partial') ? lastDraft : null
|
||||
const lastBlank = lastBlankOf(lastDraft)
|
||||
const label = notes.label && notes.label.status !== 'rejected' ? notes.label.value : null
|
||||
const kind = notes.kind && notes.kind.status !== 'rejected' ? notes.kind.value : null
|
||||
const st = detail.structure
|
||||
@@ -446,6 +464,46 @@ export function TableDetail({ sourceId, table, rows, drafted, fillCodes, onFillD
|
||||
</Notice>
|
||||
)}
|
||||
{writeError && <Notice tone="err" attr={{ 'data-catalog-write-error': '' }}>{writeError}</Notice>}
|
||||
{lastDraft && (lastBad || lastBlank > 0) && (
|
||||
// 上次起草没写全:写明原因;有留空的列:写几列、怎么办(在起草要求里补充缩写和术语的含义)。只有留空的列时是
|
||||
// 提示(表照样写完了),不是失败。给「重新起草这张表」(只含这张表、用模型);有留空的列再给「补充起草要求」
|
||||
// (打开起草设置,范围是这张表)。有起草任务在进行、正在编辑这张表时先不给点:起草会写进这张表,编辑中的保存
|
||||
// 就会撞上版本冲突。表结构里已经没有这张表(只剩目录)时上次的结果照写,不给这两个按钮、不建议重新起草,
|
||||
// 写明为什么:服务端只会把它记成「已跳过」
|
||||
<Notice tone={lastBad ? (lastBad.status === 'failed' ? 'err' : 'warn') : 'info'} attr={{ 'data-catalog-last-draft': lastDraft.status }}>
|
||||
{lastBad && <p className="font-medium">{lastBad.status === 'failed' ? DT.lastFailed : DT.lastPartial}</p>}
|
||||
{lastBad && taskReason(lastBad) && (
|
||||
<p className="mt-0.5 break-words text-dim" data-catalog-last-draft-reason="">{taskReason(lastBad)}</p>
|
||||
)}
|
||||
{lastBlank > 0 && (
|
||||
<>
|
||||
<p className={lastBad ? 'mt-0.5 text-dim' : 'font-medium'} data-catalog-last-draft-blank={lastBlank}>{DT.lastBlank(lastBlank)}</p>
|
||||
{detail.in_schema && <p className="mt-0.5 text-dim">{DT.lastBlankAdvice}</p>}
|
||||
</>
|
||||
)}
|
||||
{!detail.in_schema && <p className="mt-1 text-2xs text-faint" data-catalog-redraft-missing="">{DT.redraftMissing}</p>}
|
||||
{detail.in_schema && onRedraft && (
|
||||
<>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<button type="button" className="btn btn-sm" disabled={draftBusy || redrafting || !!edit} title={DT.redraftHint}
|
||||
onClick={async () => {
|
||||
setRedrafting(true)
|
||||
try { await onRedraft(table) } finally { setRedrafting(false) }
|
||||
}} data-catalog-redraft="">
|
||||
{redrafting ? <Spinner size={11} /> : <Sparkles size={11} aria-hidden />} {DT.redraft}
|
||||
</button>
|
||||
{lastBlank > 0 && onDraftGuidance && (
|
||||
<button type="button" className="btn btn-sm btn-ghost" disabled={draftBusy || redrafting || !!edit} title={DT.draftGuidanceHint}
|
||||
onClick={() => onDraftGuidance(table)} data-catalog-draft-guidance="">
|
||||
<Pencil size={11} aria-hidden /> {DT.draftGuidance}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-2xs text-faint">{edit ? DT.redraftEditing : draftBusy ? DT.redraftBusy : DT.redraftHint}</p>
|
||||
</>
|
||||
)}
|
||||
</Notice>
|
||||
)}
|
||||
{!detail.in_schema && (
|
||||
<Notice tone="warn" attr={{ 'data-catalog-missing': '' }}>{CT.missingBody}</Notice>
|
||||
)}
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import { memo, useCallback, useEffect, useRef } from 'react'
|
||||
import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { AlertTriangle, History, ScanSearch, Search, Sparkles, X } from 'lucide-react'
|
||||
import { AlertTriangle, History, Info, ScanSearch, Search, Sparkles, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import type { CatalogTableRow } from '../../types'
|
||||
import { useRadioGroup } from '../../components/ui'
|
||||
import { formatNumber } from '../../lib/format'
|
||||
import { CATALOG_KIND_LABEL, CATALOG_TEXT as CT, CATALOG_UI_TEXT as UT, PROFILE_TEXT as PT } from '../../lib/terms'
|
||||
import {
|
||||
CATALOG_DRAFT_TEXT as DT, CATALOG_KIND_LABEL, CATALOG_TEXT as CT, CATALOG_UI_TEXT as UT, PROFILE_TEXT as PT,
|
||||
} from '../../lib/terms'
|
||||
import { CountBadges } from './parts'
|
||||
import { LIST_FILTERS, LIST_SORTS, progressOf } from './model'
|
||||
import { lastBlankOf, taskReason } from './draftJob'
|
||||
import type { ListFilter, ListSort } from './model'
|
||||
|
||||
// ===========================================================================
|
||||
// 表清单(数据目录页的左栏):搜索、按审阅进度筛选、排序、多选后批量起草。
|
||||
// 一行两层:上面是中文名和各状态的项数,下面是表名、类型、关联数和使用次数;还没填中文名的表上面直接写表名,
|
||||
// 下面淡淡地写「未填写中文名」——一屏几十行「未填写中文名」认不出是哪张表。键盘:↑↓ 在行之间移动,
|
||||
// Home / End 到两头,Enter 打开;复选框按住 Shift 点选可以连选一段。
|
||||
// 下面淡淡地写「未填写中文名」——一屏几十行「未填写中文名」认不出是哪张表。上次起草失败、部分完成的表再多一行,
|
||||
// 写明原因(全文在悬停里,点开表还有「重新起草这张表」);上次起草有留空列(模型拿不准)的表另有一行淡色的轻提示,
|
||||
// 不是失败标记。键盘:↑↓ 在行之间移动,Home / End 到两头,Enter 打开;复选框按住 Shift 点选可以连选一段。
|
||||
// ===========================================================================
|
||||
|
||||
export function TableIndex({
|
||||
@@ -205,6 +209,14 @@ const Row = memo(function Row({ row: r, index, active, checked, onToggle, onOpen
|
||||
onKey: (i: number, e: ReactKeyboardEvent<HTMLButtonElement>) => void
|
||||
}) {
|
||||
const progress = progressOf(r.counts)
|
||||
// 上次起草没写全:失败、部分完成各一种说法,后面接原因
|
||||
const last = r.last_draft && (r.last_draft.status === 'failed' || r.last_draft.status === 'partial') ? r.last_draft : null
|
||||
const lastLabel = last ? (last.status === 'failed' ? DT.lastFailed : DT.lastPartial) : ''
|
||||
const lastText = last ? (taskReason(last) ? DT.lastWith(lastLabel, taskReason(last)) : lastLabel) : ''
|
||||
// 上次起草有留空的列:表照样写完了,只是模型对这几列拿不准。淡色的轻提示,和失败标记分开。表结构里已经没有的表
|
||||
// 起草不了:悬停里不建议重新起草
|
||||
const blank = lastBlankOf(r.last_draft)
|
||||
const blankTitle = r.in_schema ? `${DT.lastBlank(blank)}。${DT.lastBlankAdvice}` : DT.lastBlank(blank)
|
||||
return (
|
||||
<li
|
||||
className={clsx('relative flex items-stretch border-b border-hairline', active ? 'bg-accent-soft' : 'hover:bg-hover')}
|
||||
@@ -247,6 +259,20 @@ const Row = memo(function Row({ row: r, index, active, checked, onToggle, onOpen
|
||||
<AlertTriangle size={10} aria-hidden /> {CT.missing}
|
||||
</span>
|
||||
)}
|
||||
{last && (
|
||||
<span className="mt-0.5 flex min-w-0 items-center gap-1 text-2xs" title={lastText} data-row-last-draft={last.status}
|
||||
style={{ color: last.status === 'failed' ? 'var(--err)' : 'var(--warn)' }}>
|
||||
<AlertTriangle size={10} className="shrink-0" aria-hidden />
|
||||
<span className="min-w-0 truncate">{lastText}</span>
|
||||
</span>
|
||||
)}
|
||||
{blank > 0 && (
|
||||
<span className="mt-0.5 flex min-w-0 items-center gap-1 text-2xs text-faint" title={blankTitle}
|
||||
data-row-blank={blank}>
|
||||
<Info size={10} className="shrink-0" aria-hidden />
|
||||
<span className="min-w-0 truncate">{DT.lastBlank(blank)}</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ApiError, api } from '../../api/client'
|
||||
import type { CatalogActiveDraftJob, CatalogDraftJob, CatalogDraftTask, CatalogDraftTaskStatus } from '../../types'
|
||||
|
||||
// ===========================================================================
|
||||
// 起草任务(/api/datasources/{id}/catalog/draft-jobs)在页面上的状态:建、停、重试,进行中每 1.5 秒取一次进度,
|
||||
// 结束就停。放在页面上而不是对话框里:关掉对话框任务照常进行,目录页上方的进度、结束时的提示都靠它;再打开对话框
|
||||
// 看到的还是同一个任务。
|
||||
//
|
||||
// 取回来的状态只往前走:已经结束的任务不会被一个晚到的「进行中」翻回去,已结束的表数不减少(停止接口的回答和
|
||||
// 正在路上的那次取进度谁先到都一样)。
|
||||
// ===========================================================================
|
||||
|
||||
/** 进行中多久取一次进度 */
|
||||
export const DRAFT_POLL_MS = 1500
|
||||
/** 一次最多起草几张表(服务端同一个上限) */
|
||||
export const DRAFT_MAX_TABLES = 500
|
||||
/** 不选表时起草使用次数最多的前几张(服务端不给 tables 时也是这个数) */
|
||||
export const DRAFT_TOP_N = 20
|
||||
|
||||
/** 服务端的机读码:同一个数据源已有排队、进行中的任务;起草模型用不了 */
|
||||
export const DRAFT_BUSY = 'catalog_draft_busy'
|
||||
export const DRAFT_MODEL_UNAVAILABLE = 'catalog_draft_model_unavailable'
|
||||
const DRAFT_FINISHED = 'catalog_draft_finished'
|
||||
|
||||
export const isJobActive = (j: { status: string } | null | undefined): boolean =>
|
||||
!!j && (j.status === 'queued' || j.status === 'running')
|
||||
|
||||
const TASK_ENDED = new Set<CatalogDraftTaskStatus>(['ok', 'partial', 'failed', 'skipped', 'cancelled'])
|
||||
/** 「只重试失败的表」带上的:失败、部分完成、已停止 */
|
||||
export const RETRYABLE = new Set<CatalogDraftTaskStatus>(['failed', 'partial', 'cancelled'])
|
||||
|
||||
export const isTaskEnded = (t: CatalogDraftTask): boolean => TASK_ENDED.has(t.status)
|
||||
export const retryableCount = (j: CatalogDraftJob): number => j.tasks.filter((t) => RETRYABLE.has(t.status)).length
|
||||
|
||||
/** 一张表这次为什么没写全:整张表没起草的原因在前,模型那部分的原因在后 */
|
||||
export const taskReason = (t: { error: string | null; model_error: string | null }): string => t.error ?? t.model_error ?? ''
|
||||
|
||||
/** 每段最多几列(服务端起草的起始上限)。任务里的当前上限比它小:额度用完、超时多了,之后的段缩小了 */
|
||||
export const DRAFT_PART_COLUMNS = 40
|
||||
|
||||
/** 模型拿不准、留空的列数(任务里的一张表、清单上的上次起草);老服务端没有这个键时为 0 */
|
||||
export const blankOf = (t: { blank_columns?: number } | null | undefined): number => Math.max(0, t?.blank_columns ?? 0)
|
||||
|
||||
/**
|
||||
* 清单、表详情上「上次起草有 N 列模型拿不准、留空了」的 N:这张表的那次起草结束了才算(还在起草的那一次写到一半,
|
||||
* 不说「上次」)
|
||||
*/
|
||||
export const lastBlankOf = (d: { status: CatalogDraftTaskStatus; blank_columns?: number } | null | undefined): number =>
|
||||
(d && TASK_ENDED.has(d.status) ? blankOf(d) : 0)
|
||||
|
||||
/** 一次起草里留空的列合计:服务端给了合计就用它,老服务端没有就按逐表加 */
|
||||
export const jobBlank = (j: CatalogDraftJob): number => (j.blank_columns != null ? blankOf(j) : j.tasks.reduce((n, t) => n + blankOf(t), 0))
|
||||
|
||||
/** 结束后默认列出的表:没写全的(失败、部分完成、已停止)和有留空列的 */
|
||||
export const needsLook = (t: CatalogDraftTask): boolean => RETRYABLE.has(t.status) || blankOf(t) > 0
|
||||
|
||||
/** 回到设置时预先选好的范围(结果里「重新起草这几张表」、表详情里「补充起草要求」) */
|
||||
export interface DraftPreset {
|
||||
tables: string[]
|
||||
/** 范围那一项的写法 */
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 一项都没写进去的任务该怎么说(只对写完了的任务:完成、部分完成)。null:照常写合计。
|
||||
* 用模型、却没有一张表拿到模型的可用内容:目录没有更新、模型没给内容;有表没写全:目录没有更新、原因见下方;
|
||||
* 都写完了只是和现有目录一致:没有变化
|
||||
*/
|
||||
export function nothingWritten(j: CatalogDraftJob): 'model' | 'failed' | 'same' | null {
|
||||
if (j.added + j.updated + j.removed > 0 || (j.status !== 'succeeded' && j.status !== 'partial')) return null
|
||||
const live = j.tasks.filter((t) => t.status !== 'skipped')
|
||||
if (j.use_model && live.length && !live.some((t) => (t.model_items ?? 0) > 0)) return 'model'
|
||||
if (j.tasks.some((t) => RETRYABLE.has(t.status))) return 'failed'
|
||||
return 'same'
|
||||
}
|
||||
|
||||
export interface DraftStartBody { tables?: string[]; use_model: boolean; note?: string }
|
||||
|
||||
export function useDraftJob(sourceId: string, active: CatalogActiveDraftJob | null | undefined, on: {
|
||||
/** 这一次取回来新结束的表(写进目录了,或者失败、停止了):重取清单,正在看的表在里面就重新载入 */
|
||||
onTablesEnded: (tables: string[]) => void
|
||||
/** 看着它从排队、进行中变成结束(这一页上亲眼看到的,进页面时已经结束的不算) */
|
||||
onFinished: (job: CatalogDraftJob) => void
|
||||
}) {
|
||||
// 任务挂在数据源上:换了数据源,上一个源的任务不再显示(那边照常进行)
|
||||
const [held, setHeld] = useState<{ src: string; job: CatalogDraftJob } | null>(null)
|
||||
const job = held?.src === sourceId ? held.job : null
|
||||
const jobRef = useRef(job)
|
||||
jobRef.current = job
|
||||
const sourceRef = useRef(sourceId)
|
||||
sourceRef.current = sourceId
|
||||
const onRef = useRef(on)
|
||||
onRef.current = on
|
||||
/** 点了停止、服务端还没停下来的任务 */
|
||||
const [stopping, setStopping] = useState<string | null>(null)
|
||||
/** 取进度失败(网络抖了一下)、或取回来的是旧状态时,原样再排一拍 */
|
||||
const [tick, setTick] = useState(0)
|
||||
|
||||
/** 换上取回来的状态。不往回走;返回是否换上了 */
|
||||
const accept = useCallback((next: CatalogDraftJob, src: string): boolean => {
|
||||
if (src !== sourceRef.current) return false
|
||||
const cur = jobRef.current
|
||||
const same = cur?.id === next.id
|
||||
if (same && cur) {
|
||||
if (!isJobActive(cur) && isJobActive(next)) return false
|
||||
if (isJobActive(next) && next.done < cur.done) return false
|
||||
}
|
||||
// 新结束的表:同一个任务比上一次多结束的;头一回见到的进行中任务(别处建的、刷新页面前建的)已经结束的那些也算,
|
||||
// 清单可能还没跟上。头一回见到的已结束任务(点开上一次的结果)不算,那些早就写进去了
|
||||
const before = new Map((same && cur ? cur.tasks : []).map((t) => [t.table_name, t.status]))
|
||||
const ended = same || isJobActive(next)
|
||||
? next.tasks
|
||||
.filter((t) => isTaskEnded(t) && t.status !== 'skipped' && !TASK_ENDED.has(before.get(t.table_name) ?? 'pending'))
|
||||
.map((t) => t.table_name)
|
||||
: []
|
||||
jobRef.current = next
|
||||
setHeld({ src, job: next })
|
||||
if (ended.length) onRef.current.onTablesEnded(ended)
|
||||
if (same && cur && isJobActive(cur) && !isJobActive(next)) onRef.current.onFinished(next)
|
||||
return true
|
||||
}, [])
|
||||
|
||||
/** 换成另一个任务(刚建的、重试建的、409 时进行中的那个、对话框里点开的上一次)。同一个任务照常只往前走 */
|
||||
const track = useCallback((next: CatalogDraftJob) => {
|
||||
accept(next, sourceRef.current)
|
||||
}, [accept])
|
||||
|
||||
// 进行中:隔 1.5 秒取一次,结束就停
|
||||
const jobId = job?.id
|
||||
const jobActive = isJobActive(job)
|
||||
useEffect(() => {
|
||||
if (!jobId || !jobActive) return
|
||||
const src = sourceId
|
||||
let alive = true
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const { job: next } = await api.dataCatalog.draftJob(src, jobId)
|
||||
if (alive && !accept(next, src)) setTick((n) => n + 1)
|
||||
} catch (e) {
|
||||
if (!alive) return
|
||||
// 任务没了(不属于这个数据源、库被换掉了):不再跟着它;别的失败(网络抖动、服务重启中)下一拍再取
|
||||
if (e instanceof ApiError && e.status === 404) setHeld((h) => (h?.job.id === jobId ? null : h))
|
||||
else setTick((n) => n + 1)
|
||||
}
|
||||
}, DRAFT_POLL_MS)
|
||||
return () => {
|
||||
alive = false
|
||||
clearTimeout(t)
|
||||
}
|
||||
}, [sourceId, jobId, jobActive, job, tick, accept])
|
||||
|
||||
// 清单说有进行中的任务、这一页还不知道(别的标签页建的、刷新了页面):取来跟着它
|
||||
const activeId = isJobActive(active) ? active?.id : undefined
|
||||
useEffect(() => {
|
||||
if (!activeId) return
|
||||
const cur = jobRef.current
|
||||
if (cur?.id === activeId) return
|
||||
const src = sourceId
|
||||
let alive = true
|
||||
api.dataCatalog.draftJob(src, activeId).then(({ job: next }) => { if (alive) accept(next, src) }, () => {})
|
||||
return () => { alive = false }
|
||||
}, [sourceId, activeId, accept])
|
||||
|
||||
/** 建任务。409 已有任务时改去取进行中的那一个(busy 为真);其余的错误照原样抛给调用方 */
|
||||
const start = useCallback(async (body: DraftStartBody): Promise<{ job: CatalogDraftJob; busy: boolean }> => {
|
||||
const src = sourceRef.current
|
||||
try {
|
||||
const { job: next } = await api.dataCatalog.createDraftJob(src, body)
|
||||
accept(next, src)
|
||||
return { job: next, busy: false }
|
||||
} catch (e) {
|
||||
if (!(e instanceof ApiError && e.status === 409 && e.code === DRAFT_BUSY)) throw e
|
||||
const { job: current } = await api.dataCatalog.activeDraftJob(src)
|
||||
if (!current) throw e
|
||||
accept(current, src)
|
||||
return { job: current, busy: true }
|
||||
}
|
||||
}, [accept])
|
||||
|
||||
/**
|
||||
* 只重试失败的表:同 start,409 已有任务时改去显示进行中的那一个;起草模型用不了(409)、没有可重试的表(422)等
|
||||
* 照原样抛给调用方
|
||||
*/
|
||||
const retry = useCallback(async (from: CatalogDraftJob): Promise<{ job: CatalogDraftJob; busy: boolean }> => {
|
||||
const src = sourceRef.current
|
||||
try {
|
||||
const { job: next } = await api.dataCatalog.retryDraftJob(src, from.id)
|
||||
accept(next, src)
|
||||
return { job: next, busy: false }
|
||||
} catch (e) {
|
||||
if (!(e instanceof ApiError && e.status === 409 && e.code === DRAFT_BUSY)) throw e
|
||||
const { job: current } = await api.dataCatalog.activeDraftJob(src)
|
||||
if (!current) throw e
|
||||
accept(current, src)
|
||||
return { job: current, busy: true }
|
||||
}
|
||||
}, [accept])
|
||||
|
||||
/** 停止。服务端停下正在进行的调用之前任务还是进行中:这期间 stopping 为这个任务。已经结束了(409)就取一次最新的 */
|
||||
const cancel = useCallback(async () => {
|
||||
const cur = jobRef.current
|
||||
if (!cur || !isJobActive(cur)) return
|
||||
const src = sourceRef.current
|
||||
setStopping(cur.id)
|
||||
try {
|
||||
const { job: next } = await api.dataCatalog.cancelDraftJob(src, cur.id)
|
||||
accept(next, src)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 409 && e.code === DRAFT_FINISHED) {
|
||||
const { job: next } = await api.dataCatalog.draftJob(src, cur.id)
|
||||
accept(next, src)
|
||||
return
|
||||
}
|
||||
setStopping(null)
|
||||
throw e
|
||||
}
|
||||
}, [accept])
|
||||
|
||||
return { job, track, start, retry, cancel, stopping: !!job && stopping === job.id && isJobActive(job) }
|
||||
}
|
||||
+121
-14
@@ -2790,6 +2790,8 @@ export interface CatalogTableRow {
|
||||
version: number
|
||||
updated_at: string | null
|
||||
updated_by: string | null
|
||||
/** 这张表最近一次起草任务里的那一项(任务还在进行就是进行中的状态);没起草过为 null。老服务端没有这个键 */
|
||||
last_draft?: CatalogLastDraft | null
|
||||
}
|
||||
|
||||
/** GET /datasources/{id}/catalog */
|
||||
@@ -2804,6 +2806,10 @@ export interface CatalogList {
|
||||
schema_truncated?: boolean
|
||||
/** 数据库里一共几张表(含视图);没截断时等于探查到的张数 */
|
||||
schema_total?: number
|
||||
/** 这个数据源的起草要求(使用者补充给模型的背景和要求);没填为 null。老服务端没有这一项 */
|
||||
guidance?: string | null
|
||||
/** 排队或进行中的起草任务(页面据此显示进度);没有为 null。老服务端没有这一项 */
|
||||
active_job?: CatalogActiveDraftJob | null
|
||||
}
|
||||
|
||||
/** 表结构里的一列 */
|
||||
@@ -2851,32 +2857,133 @@ export interface CatalogDetail {
|
||||
/** 单项审阅:确认、驳回、撤销审阅(回到来源的初始状态;人工填写的项直接删掉) */
|
||||
export type CatalogReviewAction = 'confirm' | 'reject' | 'reset'
|
||||
|
||||
/** 起草结果里的一张表 */
|
||||
export interface CatalogDraftRow {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 起草任务(/api/datasources/{id}/catalog/draft-jobs):起草在服务端后台进行,逐表起草、每张表一结束就并入目录。
|
||||
// 关掉对话框不停任务;服务重启时还没结束的任务记 interrupted,不自动续跑,由人点「重试」接着起草。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 起草任务的状态:排队、进行中、完成、部分完成(有失败、部分完成或停止的表)、失败(任务整体出错)、已停止(用户停止)、
|
||||
* 服务重启中断
|
||||
*/
|
||||
export type CatalogDraftJobStatus = 'queued' | 'running' | 'succeeded' | 'partial' | 'failed' | 'cancelled' | 'interrupted'
|
||||
|
||||
/**
|
||||
* 任务里一张表的状态。ok:写入成功(或没有东西要写),用模型时各段都写出来了;partial:用模型,有的段没写出来;
|
||||
* failed:整张表没起草(error),或用模型时一段都没写出来(model_error),按注释、外键、命名起草的部分照常写入;
|
||||
* skipped:表结构里没有这张表;cancelled:停止或服务重启时还没开始、没写完
|
||||
*/
|
||||
export type CatalogDraftTaskStatus = 'pending' | 'running' | 'ok' | 'partial' | 'failed' | 'skipped' | 'cancelled'
|
||||
|
||||
/** 起草任务里的一张表(按起草顺序) */
|
||||
export interface CatalogDraftTask {
|
||||
table_name: string
|
||||
status: CatalogDraftTaskStatus
|
||||
/** 列数 */
|
||||
columns: number
|
||||
/** 模型分几段起草(超过 40 列的宽表拆成表级一段加列分段);不用模型时为 null */
|
||||
parts_total: number | null
|
||||
parts_done: number
|
||||
added: number
|
||||
updated: number
|
||||
removed: number
|
||||
version: number
|
||||
/** 这张表没有起草(表结构里没有、写入一直冲突) */
|
||||
error: string | null
|
||||
/** 只是模型那部分失败,其余来源照常写入。模型给了这张表、却一项可用内容都没有,也记在这里 */
|
||||
model_error: string | null
|
||||
/**
|
||||
* 模型给了这张表几项可用内容(并入目录之前)。没用模型、模型这部分失败时为 null(老后端没有这个键)。
|
||||
* 模型给了这张表几项可用内容(并入目录之前)。没用模型、模型这部分失败时为 null。
|
||||
* 大于 0 而新增、更新、删除都是 0,才是「模型给了内容、只是和现有目录一致」
|
||||
*/
|
||||
model_items?: number | null
|
||||
model_items: number | null
|
||||
/**
|
||||
* 模型拿不准、留空的列数:模型列出了(或这一段请它写了),中文名和含义却都没给(给了却都没通过校验的也算)。
|
||||
* 表的状态不因此变(照旧可以是已完成)。老服务端没有这个键
|
||||
*/
|
||||
blank_columns?: number
|
||||
/** 留空的列里的前几个(最多 5 个,列名照表结构原样)。老服务端没有这个键 */
|
||||
blank_sample?: string[]
|
||||
/** 整张表没起草的原因:表结构里没有、写入一直冲突、服务重启中断 */
|
||||
error: string | null
|
||||
/** 模型那部分没写出来的原因和办法(第几段、哪些列、为什么) */
|
||||
model_error: string | null
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
}
|
||||
|
||||
/** POST /datasources/{id}/catalog/draft */
|
||||
export interface CatalogDraftOut {
|
||||
tables: CatalogDraftRow[]
|
||||
total: { added: number; updated: number; removed: number }
|
||||
model_used: boolean
|
||||
/** 起草任务的模型用量 */
|
||||
export interface CatalogDraftUsage {
|
||||
/** 调用次数(没写出来的、超时的也算) */
|
||||
calls: number
|
||||
/** 超时的调用不计 token(照实记 0:服务方可能已经生成了内容) */
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
/** 超时的调用次数。老服务端没有这个键 */
|
||||
timeouts?: number
|
||||
/** 没写出来的调用次数:额度用完、只给空壳、超时、出错。老服务端没有这个键 */
|
||||
failed_calls?: number
|
||||
}
|
||||
|
||||
/** 一次起草任务 */
|
||||
export interface CatalogDraftJob {
|
||||
id: string
|
||||
source_id: string
|
||||
status: CatalogDraftJobStatus
|
||||
use_model: boolean
|
||||
/** 实际用的模型 id;不用模型、还没开始时为 null */
|
||||
model: string | null
|
||||
/** 模型整体用不了(没有配置、已停用……):只按注释、外键和命名起草 */
|
||||
/** 这次用到的起草要求(数据源级那份的快照) */
|
||||
guidance: string | null
|
||||
/** 这次的补充说明 */
|
||||
note: string | null
|
||||
created_at: string
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
/** 谁建的(X-Actor) */
|
||||
created_by: string | null
|
||||
/** 重试建的任务记着原任务的 id,别的为 null。接口约定里没有这一项,服务端多给的;界面不用,老服务端没有这个键 */
|
||||
retry_of?: string | null
|
||||
/** 表数 */
|
||||
total: number
|
||||
/** 已结束的表数 */
|
||||
done: number
|
||||
counts: Record<CatalogDraftTaskStatus, number>
|
||||
added: number
|
||||
updated: number
|
||||
removed: number
|
||||
/** 各表模型拿不准、留空的列数合计。老服务端没有这个键 */
|
||||
blank_columns?: number
|
||||
/** 模型调用的用量;拿不到用量时照实是 0 */
|
||||
usage: CatalogDraftUsage
|
||||
/**
|
||||
* 当前每段最多几列:一次起草里额度用完或超时累计 2 次后,之后新开始的段减半(40 → 20 → 10,最低 10)。
|
||||
* 不用模型时可能为 null;老服务端没有这个键
|
||||
*/
|
||||
part_columns?: number | null
|
||||
/**
|
||||
* 调用接连没写出来时的提醒(一句中文:最近几次里几次没写出来、原因,建议换起草模型、缩小范围或停止);
|
||||
* 好转后服务端清掉,为 null。老服务端没有这个键
|
||||
*/
|
||||
notice?: string | null
|
||||
/** 任务整体出错的原因 */
|
||||
error: string | null
|
||||
tasks: CatalogDraftTask[]
|
||||
}
|
||||
|
||||
/** 目录清单顶上的进行中任务(GET /datasources/{id}/catalog 的 active_job) */
|
||||
export interface CatalogActiveDraftJob {
|
||||
id: string
|
||||
status: CatalogDraftJobStatus
|
||||
total: number
|
||||
done: number
|
||||
}
|
||||
|
||||
/** 清单一行上这张表最近一次起草的结果 */
|
||||
export interface CatalogLastDraft {
|
||||
status: CatalogDraftTaskStatus
|
||||
job_id: string
|
||||
finished_at: string | null
|
||||
error: string | null
|
||||
model_error: string | null
|
||||
/** 那次起草模型拿不准、留空的列数(同 CatalogDraftTask.blank_columns)。老服务端没有这个键 */
|
||||
blank_columns?: number
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -97,7 +97,7 @@ const ORDER = [
|
||||
const ESTIMATE = {
|
||||
challenge: 30,
|
||||
stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87,
|
||||
evidence: 91, versions: 60, catalog: 45, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
|
||||
evidence: 91, versions: 60, catalog: 140, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
|
||||
// 占位,换成真检查时按实测改
|
||||
schedules: 60, compare: 45, verify: 71, inquiry: 60, bundle: 16, whatif: 33, 'report-export': 30,
|
||||
}
|
||||
|
||||
+951
-114
File diff suppressed because it is too large
Load Diff
@@ -589,6 +589,96 @@ await section('设置 · 门控模型', async () => {
|
||||
await old.close()
|
||||
})
|
||||
|
||||
await section('设置 · 起草模型', async () => {
|
||||
// 数据目录「用模型起草」和按配方导入表格时的 AI 起草用的模型(catalog_draft 一组):两项都留空跟随助手的模型;只填模型时
|
||||
// 按模型名找接入;存着的接入停用了在那一格下面说;服务端按项拒收(422 带 field)时整句落到那一格下面。设置读写、接入表都是假的
|
||||
const PROVIDERS = [
|
||||
{ id: 'check-draft-p1', name: 'demo-main', kind: 'openai', base_url: 'https://example.com/v1', default_model: 'big-model',
|
||||
models: [{ id: 'big-model' }], enabled: true, extra: {}, api_key_masked: '', has_key: true },
|
||||
{ id: 'check-draft-p2', name: 'demo-wide', kind: 'openai', base_url: 'https://example.com/v1', default_model: 'wide-1',
|
||||
models: [{ id: 'wide-1' }, { id: 'wide-2' }], enabled: true, extra: {}, api_key_masked: '', has_key: true },
|
||||
{ id: 'check-draft-p3', name: 'demo-off', kind: 'openai', base_url: 'https://example.com/v1', default_model: 'off-model',
|
||||
models: [{ id: 'off-model' }], enabled: false, extra: {}, api_key_masked: '', has_key: true },
|
||||
]
|
||||
let stored = await get('/settings')
|
||||
stored = { ...stored, catalog_draft: { provider: null, model: null } }
|
||||
// 服务端的说法(api/settings._provider_problem):主语是这一项在设置页上的叫法
|
||||
const REFUSED = '「起草模型 · 接入」指定的模型接入「demo-wide」已停用。请在「设置 → 模型接入」中启用它,或换一个接入'
|
||||
let refuse = false
|
||||
const { page, sent, errors, close } = await open('/settings/prefs', {
|
||||
handlers: [
|
||||
[/^GET \/settings$/, (route) => json(stored)(route)],
|
||||
[/^PUT \/settings$/, (route, { body }) => {
|
||||
if (refuse) {
|
||||
refuse = false
|
||||
return route.fulfill({ status: 422, contentType: 'application/json',
|
||||
body: JSON.stringify({ detail: REFUSED, code: 'settings_provider_invalid', field: 'catalog_draft.provider' }) })
|
||||
}
|
||||
stored = { ...stored, ...body.values }
|
||||
return json(stored)(route)
|
||||
}],
|
||||
[/^GET \/providers$/, json(PROVIDERS)],
|
||||
],
|
||||
})
|
||||
const box = page.locator('[data-draft-model-settings]')
|
||||
await box.waitFor()
|
||||
const text = (await box.innerText()).replace(/\s+/g, ' ')
|
||||
check('偏好设置里有「起草模型」一节:说明数据目录和导入表格的起草都用它,留空跟随助手的模型',
|
||||
text.includes('起草模型') && text.includes('数据目录') && text.includes('导入表格') && text.includes('跟随助手的模型'), text.slice(0, 160))
|
||||
const provider = page.getByLabel('起草模型 · 接入')
|
||||
const model = page.getByLabel('起草模型 · 模型')
|
||||
check('……接入留空写「跟随助手的模型」,模型的占位也写明', (await provider.locator('option').first().innerText()) === '跟随助手的模型'
|
||||
&& (await model.getAttribute('placeholder')) === '留空:跟随助手的模型', await provider.locator('option').first().innerText())
|
||||
check('……停用的接入列着但选不了', await provider.locator('option[value="demo-off"]').evaluate((o) => o.disabled))
|
||||
// 只填模型:按模型名找接入,写明用的是哪家
|
||||
await model.fill('wide-2')
|
||||
check('只填模型:写明按模型名使用哪个接入', (await box.innerText()).includes('将按模型名使用接入「demo-wide」'), (await box.innerText()).replace(/\s+/g, ' ').slice(0, 200))
|
||||
check('……接入那一格的空选项改叫「按模型名找接入」', (await provider.locator('option').first().innerText()) === '按模型名找接入')
|
||||
await provider.selectOption('demo-wide')
|
||||
const bar = (await page.locator('[data-prefs-bar]').innerText().catch(() => '')).replace(/\s+/g, ' ')
|
||||
check('……改了两项:保存条写「起草模型 · 接入、起草模型 · 模型」', bar.includes('有 2 项未保存') && bar.includes('起草模型 · 接入')
|
||||
&& bar.includes('起草模型 · 模型'), bar)
|
||||
await page.getByRole('button', { name: /保存设置/ }).click()
|
||||
await page.waitForTimeout(500)
|
||||
const put = sent.filter((s) => s.key === 'PUT /settings').at(-1)
|
||||
check('保存只 PUT catalog_draft 一组:接入、模型', Object.keys(put?.body?.values ?? {}).join() === 'catalog_draft'
|
||||
&& JSON.stringify(put.body.values.catalog_draft) === JSON.stringify({ provider: 'demo-wide', model: 'wide-2' }), JSON.stringify(put?.body?.values))
|
||||
await shot(page, 'prefs-draft-model')
|
||||
// 换接入:原来填的模型不在新接入里就清掉;都清空存成 null(跟随助手的模型)
|
||||
await provider.selectOption('demo-main')
|
||||
check('换了接入,旧接入的模型清掉', await model.inputValue() === '', await model.inputValue())
|
||||
await provider.selectOption('')
|
||||
await page.getByRole('button', { name: /保存设置/ }).click()
|
||||
await page.waitForTimeout(500)
|
||||
const put2 = sent.filter((s) => s.key === 'PUT /settings').at(-1)?.body?.values?.catalog_draft
|
||||
check('……两项都留空:存成 null', JSON.stringify(put2) === JSON.stringify({ provider: null, model: null }), JSON.stringify(put2))
|
||||
// 服务端按项拒收:整句落到接入那一格下面,焦点移过去
|
||||
refuse = true
|
||||
await provider.selectOption('demo-wide')
|
||||
await page.getByRole('button', { name: /保存设置/ }).click()
|
||||
await page.waitForTimeout(500)
|
||||
const said = await page.locator('#pref-draft-provider-error').innerText().catch(() => '')
|
||||
check('服务端按项拒收(422 带 field):整句落到「起草模型 · 接入」下面', said === REFUSED, said)
|
||||
check('……焦点移到那一格', await page.locator('#pref-draft-provider').evaluate((el) => el === document.activeElement))
|
||||
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
|
||||
await close()
|
||||
|
||||
// 存着的接入后来停用了:那一格下面说清起草会怎样
|
||||
const disabled = { ...stored, catalog_draft: { provider: 'demo-off', model: null } }
|
||||
const off = await open('/settings/prefs', { handlers: [[/^GET \/settings$/, (route) => json(disabled)(route)], [/^GET \/providers$/, json(PROVIDERS)]] })
|
||||
await off.page.locator('[data-draft-model-settings]').waitFor().catch(() => {})
|
||||
const offSaid = await off.page.locator('#pref-draft-provider-error').innerText().catch(() => '')
|
||||
check('存着的接入已停用:那一格下面说清用模型起草会失败', offSaid.includes('模型接入「demo-off」已停用') && offSaid.includes('起草'), offSaid)
|
||||
check('没有运行时报错', off.errors.length === 0, off.errors[0] ?? '')
|
||||
await off.close()
|
||||
// 老服务端:设置里没有这一组,不显示这一节
|
||||
const legacy = Object.fromEntries(Object.entries(stored).filter(([k]) => k !== 'catalog_draft'))
|
||||
const old = await open('/settings/prefs', { handlers: [[/^GET \/settings$/, (route) => json(legacy)(route)], [/^GET \/providers$/, json(PROVIDERS)]] })
|
||||
check('老服务端没有这一组:不显示「起草模型」,也没有保存条', await old.page.locator('[data-draft-model-settings]').count() === 0
|
||||
&& await old.page.locator('[data-prefs-bar]').count() === 0)
|
||||
await old.close()
|
||||
})
|
||||
|
||||
await section('设置 · 点名的接入已停用或不存在', async () => {
|
||||
// 点名停用的接入以前照常调用,现在运行时照实报错(K1):存着的值后来被停用了,那一格下面要说;别的停用接入列着但选不了;
|
||||
// 服务端按项拒收(422 带 field)时整句落到那一格下面、焦点移过去,保存条上原因不截断。接入表、设置读写都是假的
|
||||
|
||||
Reference in New Issue
Block a user