mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 01:11:59 +00:00
数据目录起草改成后台任务:新表 catalog_draft_jobs、catalog_draft_tasks 存任务和逐表状态,同一个源同时只有一个排队或进行中的任务(接口先查、部分唯一索引兜底);POST /catalog/draft-jobs 建任务(202),GET /draft-jobs/active、/latest、/{id} 读任务,/cancel 停止(排队的直接停,进行中的停掉正在进行的模型调用,已写进目录的不回滚),/retry 只重试失败、只写出一部分、停止了的表;同源 409 catalog_draft_busy、起草模型用不了 409 catalog_draft_model_unavailable、已结束 409 catalog_draft_finished;每张表一结束就并入目录,进度、计数、用量随时落库;服务启动时把没做完的任务标成中断、不自动续跑,关停时同样;目录清单每行加 last_draft,顶层加 guidance、active_job;数据源级的起草要求存在 options 里,经 PUT /catalog/guidance 维护(最多 2000 字,表名恰好叫 guidance 的整份提交照旧),数据源接口不显示、整份回写 options 时留着,拼连接串时摘掉;本次说明最多 1000 字,任务记下起草要求和本次说明的快照,同步起草也带上数据源的起草要求
This commit is contained in:
+197
-14
@@ -7,11 +7,15 @@
|
||||
- POST /catalog/{table}/patch/preview 目录修改提案的预览(只读):对着当前目录算改前、改后
|
||||
- POST /catalog/{table}/patch 保存目录修改提案(乐观锁),记为人工确认
|
||||
- GET /catalog/{table}/impact 影响面:引用这张表的已发布、受管模板(data/catalog_impact.py)
|
||||
- POST /catalog/draft 同步起草(注释、外键、命名推断,可选模型)
|
||||
- POST /catalog/draft 同步起草(注释、外键、命名推断,可选模型)。界面改用下面的后台起草任务,这个留着
|
||||
- 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 +23,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 +72,25 @@ 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
|
||||
|
||||
|
||||
class GuidanceIn(BaseModel):
|
||||
#: 数据源级的起草要求;空串(或 null)清除。最多 2000 字(超了 422,说明是中文)。必须写这一项:表名恰好叫
|
||||
#: guidance 时,整份提交那张表目录的请求也落到这个路径上,不能把它当成「清除起草要求」
|
||||
guidance: str | None
|
||||
|
||||
|
||||
class CatalogProfileIn(BaseModel):
|
||||
#: 要剖析的表;不给或为空时按使用次数取前 10 张目录里有待核实关系的表
|
||||
tables: list[str] | None = Field(default=None, max_length=200)
|
||||
@@ -100,7 +123,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 +143,8 @@ 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},没起草过是 null
|
||||
"last_draft": last_draft,
|
||||
}
|
||||
|
||||
|
||||
@@ -180,15 +205,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 +224,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 +239,155 @@ 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)
|
||||
try:
|
||||
payload = GuidanceIn.model_validate(body)
|
||||
except ValidationError as e:
|
||||
raise RequestValidationError(e.errors()) from e
|
||||
row = await _source_row(session, source_id)
|
||||
try:
|
||||
value = await catalog_jobs.save_guidance(session, row, payload.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 +409,19 @@ 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 有值)。
|
||||
"""
|
||||
_, source = await _resolved(session, source_id)
|
||||
row, source = await _resolved(session, source_id)
|
||||
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
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
"""数据目录的后台起草任务,以及数据源级的起草要求。
|
||||
|
||||
以前起草是一次同步长请求(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
|
||||
#: 停止进行中的任务时最多等它收尾多久(秒);关停服务时另给(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,
|
||||
"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),
|
||||
"usage": {"calls": job.calls or 0, "input_tokens": job.input_tokens or 0,
|
||||
"output_tokens": job.output_tokens or 0},
|
||||
"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}}。任务还在进行,
|
||||
就是进行中的状态。只看最近 LAST_DRAFT_JOBS 个任务。"""
|
||||
ids = list((await session.execute(
|
||||
select(CatalogDraftJob.id).where(CatalogDraftJob.source_id == source_id)
|
||||
.order_by(CatalogDraftJob.created_at.desc()).limit(LAST_DRAFT_JOBS)
|
||||
)).scalars())
|
||||
if not ids:
|
||||
return {}
|
||||
rank = {job_id: i for i, job_id in enumerate(ids)}
|
||||
rows = (await session.execute(select(CatalogDraftTask).where(CatalogDraftTask.job_id.in_(ids)))).scalars()
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for task in sorted(rows, key=lambda t: (rank[t.job_id], t.seq)):
|
||||
out.setdefault(task.table_name, {"status": task.status, "job_id": task.job_id,
|
||||
"finished_at": _iso(task.finished_at), "error": task.error,
|
||||
"model_error": task.model_error})
|
||||
return out
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 建任务、重试、停止
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
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()
|
||||
await self._settle()
|
||||
if self.stop_reason == "user":
|
||||
await self._write(_close, self.job_id, "cancelled", None, "cancelled", None)
|
||||
else:
|
||||
await self._write(_close, self.job_id, "interrupted", INTERRUPTED_ERROR, "cancelled", INTERRUPTED_ERROR)
|
||||
except _JobFailed as e:
|
||||
await self._settle()
|
||||
await self._write(_close, self.job_id, "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)
|
||||
await self._settle()
|
||||
reason = JOB_ERROR.format(line=first_line(e) or describe_exception(e))
|
||||
await self._write(_close, self.job_id, "failed", reason, "failed", reason)
|
||||
|
||||
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, usage=self._usage,
|
||||
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 _usage(self, input_tokens: int, output_tokens: int) -> None:
|
||||
await self._write(self._add_usage, input_tokens, output_tokens)
|
||||
|
||||
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).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_usage(self, session: AsyncSession, input_tokens: int, output_tokens: int) -> None:
|
||||
await session.execute(
|
||||
update(CatalogDraftJob).where(CatalogDraftJob.id == self.job_id)
|
||||
.values(calls=CatalogDraftJob.calls + 1, input_tokens=CatalogDraftJob.input_tokens + int(input_tokens),
|
||||
output_tokens=CatalogDraftJob.output_tokens + int(output_tokens))
|
||||
.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(),
|
||||
}
|
||||
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 = 5.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", "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"]
|
||||
@@ -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]+")
|
||||
|
||||
|
||||
@@ -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,76 @@ 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 算)
|
||||
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)
|
||||
# 任务整体出错的原因(逐表的原因在 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)
|
||||
# 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
|
||||
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
"""数据目录的后台起草任务(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
|
||||
- 起草要求存在数据源 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"
|
||||
|
||||
|
||||
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"] == "金额按元"
|
||||
assert (await client.put(f"{_base(sid)}/guidance", json={})).status_code == 422
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user