From b940b27e242b78e87329e03b69d28b89bd267b41 Mon Sep 17 00:00:00 2001
From: ThearchyHelios
Date: Tue, 13 Oct 2026 15:00:38 +0800
Subject: [PATCH 01/29] =?UTF-8?q?=E8=B5=B7=E8=8D=89=E6=A8=A1=E5=9E=8B?=
=?UTF-8?q?=E8=AE=BE=E7=BD=AE=EF=BC=9A=E6=96=B0=E5=A2=9E=E8=AE=BE=E7=BD=AE?=
=?UTF-8?q?=E7=BB=84=20catalog=5Fdraft=EF=BC=88=E6=8E=A5=E5=85=A5=E3=80=81?=
=?UTF-8?q?=E6=A8=A1=E5=9E=8B=EF=BC=89=EF=BC=8C=E7=82=B9=E5=90=8D=E7=9A=84?=
=?UTF-8?q?=E6=8E=A5=E5=85=A5=E4=B8=8D=E5=AD=98=E5=9C=A8=E6=88=96=E5=B7=B2?=
=?UTF-8?q?=E5=81=9C=E7=94=A8=E6=97=B6=E4=BF=9D=E5=AD=98=E5=9B=9E=20422?=
=?UTF-8?q?=EF=BC=9B=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95=E8=B5=B7=E8=8D=89?=
=?UTF-8?q?=E5=92=8C=E6=8C=89=E9=85=8D=E6=96=B9=E5=AF=BC=E5=85=A5=E7=9A=84?=
=?UTF-8?q?=20AI=20=E8=B5=B7=E8=8D=89=E5=85=B1=E7=94=A8=E4=B8=80=E5=A4=84?=
=?UTF-8?q?=E5=8F=96=E6=A8=A1=E5=9E=8B=EF=BC=88=E8=B5=B7=E8=8D=89=E6=A8=A1?=
=?UTF-8?q?=E5=9E=8B=20>=20=E5=8A=A9=E6=89=8B=E7=9A=84=E6=A8=A1=E5=9E=8B?=
=?UTF-8?q?=20>=20=E9=BB=98=E8=AE=A4=E6=8E=A5=E5=85=A5=EF=BC=89=EF=BC=8C?=
=?UTF-8?q?=E5=8F=96=E8=87=AA=E8=B5=B7=E8=8D=89=E6=A8=A1=E5=9E=8B=E6=97=B6?=
=?UTF-8?q?=E6=8A=A5=E9=94=99=E7=85=A7=E5=AE=9E=E8=AF=B4=E8=B5=B7=E8=8D=89?=
=?UTF-8?q?=E6=A8=A1=E5=9E=8B=E9=82=A3=E4=B8=80=E9=A1=B9=EF=BC=9B=E6=95=B0?=
=?UTF-8?q?=E6=8D=AE=E7=9B=AE=E5=BD=95=E8=B5=B7=E8=8D=89=E4=B8=8D=E5=86=8D?=
=?UTF-8?q?=E5=85=B3=E6=80=9D=E8=80=83=EF=BC=8C=E9=A2=9D=E5=BA=A6=2032768?=
=?UTF-8?q?=EF=BC=8C=E6=9C=8D=E5=8A=A1=E6=96=B9=E5=AB=8C=E5=A4=A7=E6=97=B6?=
=?UTF-8?q?=E9=80=80=E5=9B=9E=208192=EF=BC=88=E4=B9=8B=E5=90=8E=E9=83=BD?=
=?UTF-8?q?=E7=94=A8=E5=B0=8F=E9=A2=9D=E5=BA=A6=EF=BC=89=EF=BC=8C=E5=8D=95?=
=?UTF-8?q?=E6=AC=A1=E8=B0=83=E7=94=A8=E6=97=B6=E9=99=90=20420=20=E7=A7=92?=
=?UTF-8?q?=E4=BA=A4=E7=BB=99=20SDK=EF=BC=9B=E9=85=8D=E6=96=B9=20AI=20?=
=?UTF-8?q?=E8=B5=B7=E8=8D=89=E7=9A=84=E9=A2=9D=E5=BA=A6=E3=80=81=E6=80=9D?=
=?UTF-8?q?=E8=80=83=E8=AE=BE=E7=BD=AE=E4=B8=8D=E5=8A=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/api/settings.py | 45 +++-
backend/app/data/catalog.py | 94 ++++---
backend/app/data/draft_model.py | 114 +++++++++
backend/app/data/recipe_ai.py | 47 ++--
backend/tests/test_catalog_draft_settings.py | 247 +++++++++++++++++++
5 files changed, 487 insertions(+), 60 deletions(-)
create mode 100644 backend/app/data/draft_model.py
create mode 100644 backend/tests/test_catalog_draft_settings.py
diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py
index 9a4ee8ad..e1fdcafb 100644
--- a/backend/app/api/settings.py
+++ b/backend/app/api/settings.py
@@ -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)
diff --git a/backend/app/data/catalog.py b/backend/app/data/catalog.py
index fb7e7063..281633a7 100644
--- a/backend/app/data/catalog.py
+++ b/backend/app/data/catalog.py
@@ -1401,15 +1401,25 @@ def draft_structure(source: Any, table: str) -> TableDraft:
# 起草:模型(可选,按表分批,结构化输出)
# ==========================================================================
-#: 每批最多几张表、合计多少列。一批太大模型容易漏表、输出被截断;太小则调用次数多
+#: 每次调用最多几张表、合计多少列(data/catalog_draft.py 按它分段)。实测每列约 50 个输出 token(不算思考),
+#: 会思考的模型思考也算在输出额度里:一段太大,想完就没额度写正文。列数超过 MODEL_BATCH_COLUMNS 的宽表拆成
+#: 「表级一段 + 列分段」,每个列分段也不超过这个列数
MODEL_BATCH_TABLES = 4
-MODEL_BATCH_COLUMNS = 120
-#: 同时发出的批数
+MODEL_BATCH_COLUMNS = 40
+#: 全进程同时进行的模型调用最多几个(所有起草任务、同步起草共用)
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
+#: 网络错误、429、5xx、超时最多重试几次,每次重试前等多久(秒,依次取用)
+MODEL_RETRIES = 2
+MODEL_RETRY_DELAYS_S = (10.0, 30.0)
#: 不指定表时起草使用次数最多的前几张
DRAFT_DEFAULT_TABLES = 20
@@ -1502,38 +1512,60 @@ CATALOG_DRAFT_PROMPT_SCHEMA: dict[str, Any] = {
_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 分钟断开。
+ 只建模型对象,不发请求。
和 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)
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]:
diff --git a/backend/app/data/draft_model.py b/backend/app/data/draft_model.py
new file mode 100644
index 00000000..da3be8be
--- /dev/null
+++ b/backend/app/data/draft_model.py
@@ -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"]
diff --git a/backend/app/data/recipe_ai.py b/backend/app/data/recipe_ai.py
index 44ec1f92..5cb8b42f 100644
--- a/backend/app/data/recipe_ai.py
+++ b/backend/app/data/recipe_ai.py
@@ -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))
diff --git a/backend/tests/test_catalog_draft_settings.py b/backend/tests/test_catalog_draft_settings.py
new file mode 100644
index 00000000..c4cbbec4
--- /dev/null
+++ b/backend/tests/test_catalog_draft_settings.py
@@ -0,0 +1,247 @@
+"""起草模型(设置里的 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 model.budget == 32768 and model.model_name == "模型甲"
+
+
+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 起草的思考设置不动"
+
+
+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)
From 6b1f63a9dcf3cbbbd3043f6c9256c2c8f683c08e Mon Sep 17 00:00:00 2001
From: ThearchyHelios
Date: Tue, 13 Oct 2026 15:19:06 +0800
Subject: [PATCH 02/29] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95?=
=?UTF-8?q?=E6=A8=A1=E5=9E=8B=E8=B5=B7=E8=8D=89=E6=8D=A2=E6=88=90=E5=88=86?=
=?UTF-8?q?=E6=AE=B5=E3=80=81=E6=8C=89=E5=A4=B1=E8=B4=A5=E5=8E=9F=E5=9B=A0?=
=?UTF-8?q?=E5=A4=84=E7=90=86=E7=9A=84=E5=BC=95=E6=93=8E=EF=BC=88data/cata?=
=?UTF-8?q?log=5Fdraft.py=EF=BC=89=EF=BC=9A=E6=AF=8F=E6=AC=A1=E6=9C=80?=
=?UTF-8?q?=E5=A4=9A=204=20=E5=BC=A0=E8=A1=A8=E3=80=8140=20=E5=88=97?=
=?UTF-8?q?=EF=BC=8C=E7=AA=84=E8=A1=A8=E6=8C=89=E9=A1=BA=E5=BA=8F=E6=8B=BC?=
=?UTF-8?q?=E6=AE=B5=EF=BC=8C=E5=AE=BD=E8=A1=A8=E6=8B=86=E6=88=90=E3=80=8C?=
=?UTF-8?q?=E8=A1=A8=E7=BA=A7=E4=B8=80=E6=AE=B5=20+=20=E6=AF=8F=2040=20?=
=?UTF-8?q?=E5=88=97=E4=B8=80=E6=AE=B5=E3=80=8D=EF=BC=8C=E5=88=97=E5=88=86?=
=?UTF-8?q?=E6=AE=B5=E5=B8=A6=E7=9D=80=E8=A1=A8=E6=B3=A8=E9=87=8A=E5=92=8C?=
=?UTF-8?q?=E8=A1=A8=E7=BA=A7=E8=B5=B7=E8=8D=89=E7=BB=93=E6=9E=9C=E5=81=9A?=
=?UTF-8?q?=E8=83=8C=E6=99=AF=EF=BC=8C=E6=9C=80=E5=90=8E=E5=90=88=E5=B9=B6?=
=?UTF-8?q?=E7=85=A7=E6=97=A7=E9=80=90=E9=A1=B9=E6=A0=A1=E9=AA=8C=EF=BC=9B?=
=?UTF-8?q?=E6=80=9D=E8=80=83=E5=92=8C=E8=BE=93=E5=87=BA=E7=94=A8=E5=AE=8C?=
=?UTF-8?q?=E9=A2=9D=E5=BA=A6=E3=80=81=E5=8F=AA=E7=BB=99=E7=A9=BA=E5=A3=B3?=
=?UTF-8?q?=E6=97=B6=E6=8B=86=E6=88=90=E4=B8=A4=E5=8D=8A=E5=86=8D=E9=97=AE?=
=?UTF-8?q?=EF=BC=88=E6=9C=80=E5=B0=91=205=20=E5=88=97=E3=80=81=E6=9C=80?=
=?UTF-8?q?=E5=A4=9A=203=20=E5=B1=82=EF=BC=89=EF=BC=8C=E6=8B=86=E4=B8=8D?=
=?UTF-8?q?=E5=BC=80=E7=9A=84=E7=A9=BA=E5=A3=B3=E6=8D=A2=E7=BA=AF=E6=96=87?=
=?UTF-8?q?=E6=9C=AC=E9=97=AE=E4=B8=80=E6=AC=A1=EF=BC=8C=E5=86=99=E5=87=BA?=
=?UTF-8?q?=E6=9D=A5=E4=BA=86=E5=90=8E=E9=9D=A2=E9=83=BD=E8=B5=B0=E7=BA=AF?=
=?UTF-8?q?=E6=96=87=E6=9C=AC=EF=BC=9B=E8=A2=AB=E6=88=AA=E6=96=AD=E7=9A=84?=
=?UTF-8?q?=20JSON=20=E5=85=88=E6=8D=A1=E5=9B=9E=E5=B7=B2=E5=AE=8C?=
=?UTF-8?q?=E6=95=B4=E7=9A=84=E8=A1=A8=E5=92=8C=E5=88=97=E3=80=81=E5=8F=AA?=
=?UTF-8?q?=E8=A1=A5=E5=89=A9=E4=B8=8B=E7=9A=84=EF=BC=9B=E7=BC=BA=E4=BA=86?=
=?UTF-8?q?=E7=9A=84=E8=A1=A8=E5=92=8C=E5=88=97=E5=8F=AA=E8=A1=A5=E7=BC=BA?=
=?UTF-8?q?=E7=9A=84=E3=80=81=E8=A1=A5=E4=B8=80=E6=AC=A1=EF=BC=9B=E6=9C=8D?=
=?UTF-8?q?=E5=8A=A1=E6=96=B9=E5=AB=8C=E9=A2=9D=E5=BA=A6=E5=A4=A7=E6=97=B6?=
=?UTF-8?q?=E9=80=80=E5=9B=9E=208192=E3=80=81=E7=BB=93=E6=9E=84=E5=8C=96?=
=?UTF-8?q?=E8=BE=93=E5=87=BA=E4=B8=8D=E6=94=AF=E6=8C=81=E6=97=B6=E6=94=B9?=
=?UTF-8?q?=E8=B5=B0=E7=BA=AF=E6=96=87=E6=9C=AC=EF=BC=8C=E9=83=BD=E7=AE=A1?=
=?UTF-8?q?=E5=88=B0=E8=BF=99=E6=AC=A1=E8=B5=B7=E8=8D=89=E7=BB=93=E6=9D=9F?=
=?UTF-8?q?=EF=BC=9B=E7=BD=91=E7=BB=9C=E9=94=99=E8=AF=AF=E3=80=81429?=
=?UTF-8?q?=E3=80=815xx=E3=80=81=E8=B6=85=E6=97=B6=E9=80=80=E9=81=BF?=
=?UTF-8?q?=E9=87=8D=E8=AF=95=E6=9C=80=E5=A4=9A=202=20=E6=AC=A1=EF=BC=9B?=
=?UTF-8?q?=E5=8D=95=E6=AC=A1=E8=B0=83=E7=94=A8=20420=20=E7=A7=92=EF=BC=9B?=
=?UTF-8?q?=E5=85=A8=E8=BF=9B=E7=A8=8B=E6=9C=80=E5=A4=9A=203=20=E4=B8=AA?=
=?UTF-8?q?=E8=B0=83=E7=94=A8=E5=90=8C=E6=97=B6=E8=BF=9B=E8=A1=8C=EF=BC=9B?=
=?UTF-8?q?=E6=8A=A5=E9=94=99=E5=86=99=E5=8E=9F=E5=9B=A0=E5=92=8C=E5=8A=9E?=
=?UTF-8?q?=E6=B3=95=EF=BC=88=E7=AC=AC=E5=87=A0=E6=AE=B5=E3=80=81=E5=85=B1?=
=?UTF-8?q?=E5=87=A0=E5=88=97=E6=B2=A1=E5=86=99=E5=87=BA=E6=9D=A5=E3=80=81?=
=?UTF-8?q?=E5=8F=AF=E4=BB=A5=E5=8F=AA=E9=87=8D=E8=AF=95=E8=BF=99=E5=BC=A0?=
=?UTF-8?q?=E8=A1=A8=EF=BC=89=EF=BC=9B=E6=8F=90=E7=A4=BA=E8=AF=8D=E5=8A=A0?=
=?UTF-8?q?=E4=B8=8A=E8=B5=B7=E8=8D=89=E8=A6=81=E6=B1=82=E4=B8=80=E6=AE=B5?=
=?UTF-8?q?=EF=BC=88=E6=95=B0=E6=8D=AE=E6=BA=90=E8=AF=B4=E6=98=8E=E3=80=81?=
=?UTF-8?q?=E8=B5=B7=E8=8D=89=E8=A6=81=E6=B1=82=E3=80=81=E6=9C=AC=E6=AC=A1?=
=?UTF-8?q?=E8=AF=B4=E6=98=8E=EF=BC=8C=E8=A7=84=E7=9F=A9=E5=9C=A8=E5=89=8D?=
=?UTF-8?q?=E3=80=81=E5=86=B2=E7=AA=81=E4=BB=A5=E8=A7=84=E7=9F=A9=E4=B8=BA?=
=?UTF-8?q?=E5=87=86=EF=BC=89=E5=92=8C=E3=80=8C=E6=80=9D=E8=80=83=E6=97=B6?=
=?UTF-8?q?=E4=B8=8D=E5=BF=85=E9=80=90=E5=88=97=E8=8D=89=E6=8B=9F=E3=80=8D?=
=?UTF-8?q?=EF=BC=9B=E5=90=8C=E6=AD=A5=E8=B5=B7=E8=8D=89=E6=94=B9=E7=94=A8?=
=?UTF-8?q?=E6=96=B0=E5=BC=95=E6=93=8E=EF=BC=8C=E5=86=99=E5=85=A5=E6=8A=BD?=
=?UTF-8?q?=E6=88=90=E6=AF=8F=E5=BC=A0=E8=A1=A8=E4=B8=80=E7=BB=93=E6=9D=9F?=
=?UTF-8?q?=E5=B0=B1=E8=83=BD=E8=B0=83=E7=9A=84=20write=5Fdraft?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/data/catalog.py | 357 +++---
backend/app/data/catalog_draft.py | 1048 ++++++++++++++++++
backend/tests/fixtures/catalog/fake_model.py | 62 +-
backend/tests/test_catalog_draft.py | 5 +-
backend/tests/test_catalog_draft_engine.py | 555 ++++++++++
5 files changed, 1819 insertions(+), 208 deletions(-)
create mode 100644 backend/app/data/catalog_draft.py
create mode 100644 backend/tests/test_catalog_draft_engine.py
diff --git a/backend/app/data/catalog.py b/backend/app/data/catalog.py
index 281633a7..c48b99bc 100644
--- a/backend/app/data/catalog.py
+++ b/backend/app/data/catalog.py
@@ -1130,10 +1130,14 @@ 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:
@@ -1446,10 +1450,25 @@ status 状态;attribute 属性)。
- 只写数据事实,不写计算公式,不写指标怎么算。
- 拿不准的字段留空,不要猜。列名、表名照原样写,不要翻译或改写。
- 标着「系统说明」的内容是导入时按核对结果生成的,以它为准:不要重复,也不要与之矛盾。
-- 标着「已确认」的目录项由人工确认过,以它为准。"""
+- 标着「已确认」的目录项由人工确认过,以它为准。
+- 表名下一行写着「这次只写……」的表,只写它说的那部分:只写表级的项时不写列,只写列出的几列时不写表级的项、\
+也不写别的列。
+- 使用者补充的背景和要求帮你理解业务、统一用词;它和这里的规矩冲突时,以这里的规矩为准。
+- 思考时不必把要输出的内容逐列草拟一遍,想清楚后直接按格式输出:思考和输出共用同一份长度额度,额度用完时还没输出的\
+内容就全丢了。"""
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": "fact", "keys": ["列名"], \
+"business_date": {"column": "列名", "rule": "一句规则", "timezone": "时区"}, \
+"columns": [{"name": "列名", "label": "中文名", "meaning": "含义", "unit": "单位", "measure": "flow"}]}]}
+kind 取 fact、dimension、snapshot、log、config 之一;measure 取 flow、stock、ratio、identifier、status、attribute 之一。"""
+
#: 拼表结构描述用的固定片段(写给模型)
CATALOG_DRAFT_PROMPT_LABELS = {
"table": "表 {name}",
@@ -1465,6 +1484,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] = {
@@ -1509,9 +1538,6 @@ 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)。一次起草(一个起草任务、一次同步起草)共用一个,退回之后不再先失败一次。
@@ -1572,58 +1598,6 @@ 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 = "模型没有给出这张表的可用内容"
@@ -1653,39 +1627,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
@@ -1740,65 +1681,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
# ==========================================================================
@@ -1808,7 +1714,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
@@ -1817,9 +1724,14 @@ 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
@dataclass
@@ -1834,35 +1746,102 @@ 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),已有的模型起草项不会因此被删。
- 写入撞上别人刚改过(乐观锁)就重读重并,最多三次。
"""
- 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)
+ 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
+ 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)
+ if model_draft is not None:
+ merged, more = merge_notes(merged, model_draft.notes, covered=model_draft.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:
- 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
+ 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:
@@ -1872,41 +1851,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
@@ -2271,12 +2226,16 @@ 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_RETRIES", "MODEL_RETRY_DELAYS_S", "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",
diff --git a/backend/app/data/catalog_draft.py b/backend/app/data/catalog_draft.py
new file mode 100644
index 00000000..2b1d71bf
--- /dev/null
+++ b/backend/app/data/catalog_draft.py
@@ -0,0 +1,1048 @@
+"""数据目录的模型起草:分段、按失败原因处理、捡回被截断的部分、记用量。
+
+对外的名字留在 data/catalog.py(draft_with_model、draft_catalog、write_draft、CATALOG_DRAFT_SYSTEM、各个 MODEL_*
+常量……),这里是实现。常量一律在调用时按 catalog.X 读:测试改 catalog 上的常量就能生效。
+
+**为什么要分段、按原因处理。** 实测(经 OpenAI 兼容网关的会思考的模型):思考算在输出额度里,一段上百列时思考就把
+额度全用掉,正文 0 字或只给一个空壳;同一批表有时成功有时失败,看模型这次想多久。以前失败了退回纯文本、发同一个提示词,
+同样失败、耗时翻倍;超时把第一次拿到的部分也丢掉;一批里有一张表是空的就整批重问。
+
+**分段。** 每次调用最多 MODEL_BATCH_TABLES 张表、合计 MODEL_BATCH_COLUMNS 列:列数不超过它的表按顺序拼在一起
+(整表段);超过它的宽表拆成「表级一段 + 列分段」——表级那段给全部列名和类型做背景、只要表级的项(中文名、粒度、
+表类型、业务主键、业务日期);列分段每段 MODEL_BATCH_COLUMNS 列,带着表名、表注释和表级起草结果做背景,只要这几列。
+最后合并成这张表的一份,照旧经 catalog._from_model 逐项校验(起草要求不能让模型绕过校验)。
+
+**按失败原因处理,不原样重发。**
+
+- 思考加输出用完额度(finish_reason=length、LengthFinishReasonError、正文为空而有思考)、只给空壳:把这一段拆成
+ 两半再问(多表的段按表拆,单表的按列拆),最小 MODEL_SPLIT_MIN_COLUMNS 列,最多拆 MODEL_SPLIT_DEPTH 层。
+ 拆不开的空壳(结构化输出给的)换纯文本问一次:有的网关的结构化输出只给空壳、纯文本却能写——写出来了,这次起草
+ 后面都走纯文本。
+- 被截断的 JSON:先把已经完整的表、列对象捡回来,只补剩下的。
+- 一段里缺了的表、列只补缺的,补一次。补过仍然没给的列算模型拿不准、留空,不算没写出来;整张表一项都没有才算。
+- 结构化输出这个接口不支持(400 / 422 提到 response_format、json_schema、tool……,或者模型类不支持):这次起草
+ 后面改走纯文本、从正文取 JSON(纯文本那条路另外说清 JSON 的格式)。
+- 服务方嫌额度太大:退回 MODEL_FALLBACK_MAX_TOKENS 重来,这次起草后面都用它(catalog.DraftModel.shrink)。
+- 网络错误、429、5xx、超时:等一会儿重试(最多 MODEL_RETRIES 次,退避)。单次调用的时限是 MODEL_TIMEOUT_S。
+- 别的错误:这一段没写出来,原因照实写。
+
+**并发。** 全进程最多 MODEL_CONCURRENCY 个调用同时进行(model_gate:按事件循环各一个信号量,所有起草任务和同步
+起草共用)。
+
+**进度。** 调用方给 Hooks:开始调用、每次调用的用量、一段结束、一张表的模型部分全部结束。后台起草任务据此落库,
+每张表一结束就并入目录(data/catalog_jobs.py)。
+
+交给模型的只有表结构(列、类型、主键、外键、唯一约束、注释)和使用者补充的背景,不发任何数据值。写给模型的文字都在
+catalog.py 名字带 _SYSTEM / _PROMPT 的常量里;这里的 REASON_* 是给人看的(拼进这张表的 model_error)。
+"""
+from __future__ import annotations
+
+import asyncio
+import copy
+import json
+import logging
+import re
+import weakref
+from collections.abc import Awaitable, Callable, Mapping, Sequence
+from dataclasses import dataclass, field
+from typing import Any
+
+from app.data import catalog as C
+from app.data.names import name_key
+
+logger = logging.getLogger(__name__)
+
+#: 退避等待用的 sleep(测试里换掉,不真等)
+_sleep = asyncio.sleep
+
+#: 服务方嫌额度太大时报错里会出现的字样(同 copilot._BUDGET_WORDS、recipe_ai._BUDGET_WORDS,有测试核对)
+BUDGET_WORDS = ("max_tokens", "max_completion_tokens", "max_output_tokens", "maximum context length")
+#: 结构化输出这个接口不支持时报错里会出现的字样:json_schema 的 response_format(有的服务只认 json_object)、
+#: 工具调用(有的网关不支持)、Anthropic 的 output_config
+STRUCTURED_WORDS = ("response_format", "json_schema", "json_object", "tool", "function", "output_config",
+ "output_format", "structured output")
+#: 一张表的表级的项在「没写出来」记录里的键(不会和列名撞上)
+_TABLE = "\0table"
+
+# ---- 一段没写出来的原因(给人看,拼进这张表的 model_error)
+REASON_EXHAUSTED = "模型的思考和输出用完了额度({budget} 个 token),这一段没写出来"
+REASON_SPLIT = ";已拆小重试仍没写出"
+REASON_HOLLOW = "模型只给了空壳,没有写出可用的内容"
+REASON_TRUNCATED = "模型的输出被截断,这一段没写完"
+REASON_FORMAT = "模型的回复不是约定的格式"
+REASON_MISSING = "模型的回复里没有这张表"
+REASON_TIMEOUT = "模型 {seconds} 秒内没有写完这一段"
+REASON_CALL = "调用模型出错:{line}"
+REASON_RETRIED = "(已重试 {n} 次)"
+REASON_CRASH = "起草这张表时出错:{line}"
+
+
+# ==========================================================================
+# 对外的形状
+# ==========================================================================
+
+
+@dataclass(frozen=True)
+class Background:
+ """使用者补充的背景和要求:guidance 是数据源级的起草要求,note 是这一次的补充说明。数据源说明(description)
+ 从源上读。都没有就不出现那一段。"""
+
+ guidance: str | None = None
+ note: str | None = None
+
+
+@dataclass
+class ModelOutcome:
+ """一张表的模型起草结果。
+
+ status:ok 各段都写出来了;partial 有的段写出来、有的没有(error 写清哪部分、为什么);failed 一项都没写出来
+ (error 写原因)。notes 是写出来的那些(已逐项校验,llm / proposed);items 是项数。parts_total 分几段起草
+ (整表段是 1,宽表是 1 + 列分段数),parts_done 已经结束了几段。"""
+
+ status: str
+ notes: dict[str, Any]
+ items: int
+ error: str | None
+ parts_total: int
+ parts_done: int
+
+
+@dataclass
+class Hooks:
+ """进度回调,都是 async,出错只记日志、不影响起草。
+
+ - call_started(表名列表):一次调用拿到并发名额、马上要发出去了;
+ - usage(输入 token, 输出 token):每次调用一回(拿不到用量的是 0),条数就是调用次数;
+ - part_done(表名):这张表的一段结束了(写没写出来都算);
+ - table_done(表名, ModelOutcome):这张表的模型部分全部结束。"""
+
+ call_started: Callable[[list[str]], Awaitable[None]] | None = None
+ usage: Callable[[int, int], Awaitable[None]] | None = None
+ part_done: Callable[[str], Awaitable[None]] | None = None
+ table_done: Callable[[str, ModelOutcome], Awaitable[None]] | None = None
+
+
+def parts_for(columns: int) -> int:
+ """一张有这么多列的表分几段起草:不超过 MODEL_BATCH_COLUMNS 列是 1 段,否则是表级一段加列分段。"""
+ width = max(1, int(C.MODEL_BATCH_COLUMNS))
+ return 1 if columns <= width else 1 + -(-columns // width)
+
+
+async def run_model_draft(model: Any, source: Any, tables: Sequence[str], *,
+ existing: Mapping[str, Mapping[str, Any]] | None = None,
+ background: Background | None = None,
+ hooks: Hooks | None = None) -> dict[str, ModelOutcome]:
+ """用模型起草这些表(schema_cache["tables"] 的键;表结构里没有的跳过,不在结果里)。
+
+ model 要有 with_structured_output(schema, include_raw=…) 和 ainvoke(messages);有 shrink() 的(catalog.DraftModel)
+ 在服务方嫌额度太大时换小额度。existing 是 {表名: 目录}:已确认、有确证的项作为「以此为准」的背景交给模型。
+ """
+ run = _Run(model, source, tables, existing or {}, background or Background(), hooks or Hooks())
+ return await run.run()
+
+
+# ==========================================================================
+# 并发名额
+# ==========================================================================
+
+_GATES: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, tuple[int, asyncio.Semaphore]]" = \
+ weakref.WeakKeyDictionary()
+
+
+def model_gate() -> asyncio.Semaphore:
+ """全进程的模型调用名额(MODEL_CONCURRENCY 个)。按事件循环各一个:asyncio 的信号量一旦在某个循环里等过,
+ 就不能换循环用(测试每个用例一个循环)。"""
+ loop = asyncio.get_running_loop()
+ limit = max(1, int(C.MODEL_CONCURRENCY))
+ held = _GATES.get(loop)
+ if held is None or held[0] != limit:
+ held = (limit, asyncio.Semaphore(limit))
+ _GATES[loop] = held
+ return held[1]
+
+
+# ==========================================================================
+# 失败的种类
+# ==========================================================================
+
+
+def _status_of(e: BaseException) -> int | None:
+ for obj in (e, getattr(e, "response", None)):
+ code = getattr(obj, "status_code", None)
+ if isinstance(code, int):
+ return code
+ return None
+
+
+def classify_failure(e: BaseException) -> str:
+ """调用模型抛出来的异常属于哪一种:length 额度用完(LengthFinishReasonError)、budget 服务方嫌额度太大、
+ unsupported 结构化输出不支持、timeout 超时、transient 网络错误 / 429 / 5xx、error 别的。"""
+ from app.core.errors import NETWORK, RATE_LIMIT, SERVER, TIMEOUT, classify
+
+ names = {c.__name__ for c in type(e).__mro__}
+ if "LengthFinishReasonError" in names:
+ return "length"
+ status = _status_of(e)
+ text = str(e).lower()
+ if status in (400, 422) and any(w in text for w in BUDGET_WORDS):
+ return "budget"
+ # thinking 打开时 Anthropic 不让强制调用工具,langchain 改成不强制,模型没调用工具就抛 OutputParserException
+ if isinstance(e, NotImplementedError) or "OutputParserException" in names:
+ return "unsupported"
+ if status in (400, 422) and any(w in text for w in STRUCTURED_WORDS):
+ return "unsupported"
+ if isinstance(e, TimeoutError):
+ return "timeout"
+ kind = classify(e)
+ if kind == TIMEOUT:
+ return "timeout"
+ if kind in (RATE_LIMIT, SERVER, NETWORK):
+ return "transient"
+ return "error"
+
+
+def _seconds(value: Any) -> Any:
+ number = float(value)
+ return int(number) if number.is_integer() else number
+
+
+# ==========================================================================
+# 读回复:结构化输出、正文里的 JSON、被截断的 JSON
+# ==========================================================================
+
+
+@dataclass
+class _Reply:
+ #: 回复里的表对象(截断的只含已经完整的字段和列)
+ entries: list[dict[str, Any]] = field(default_factory=list)
+ #: JSON 没写完(捡回了一部分)
+ truncated: bool = False
+ #: 思考加输出用完了额度
+ exhausted: bool = False
+ #: 这一段最终没写出来的原因(重试用完、别的错误、不是约定的格式)
+ error: str | None = None
+
+
+class _Cut:
+ """被截断的容器(对象或数组):value 里只有已经完整的成员,最后一个没写完的容器成员也包成 _Cut 留着,好往里捡。"""
+
+ __slots__ = ("value",)
+
+ def __init__(self, value: Any) -> None:
+ self.value = value
+
+
+class _Bad(ValueError):
+ """不是 JSON(不是写到一半,是写错了)。"""
+
+
+_NUMBER = re.compile(r"-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?")
+_LITERALS = (("true", True), ("false", False), ("null", None))
+_MAX_DEPTH = 64
+
+
+def _skip(text: str, i: int) -> int:
+ n = len(text)
+ while i < n and text[i] in " \t\r\n":
+ i += 1
+ return i
+
+
+def _string(text: str, i: int) -> tuple[Any, int, bool]:
+ n = len(text)
+ j = i + 1
+ while j < n:
+ ch = text[j]
+ if ch == "\\":
+ j += 2
+ continue
+ if ch == '"':
+ try:
+ return json.loads(text[i:j + 1]), j + 1, True
+ except ValueError as e:
+ raise _Bad from e
+ j += 1
+ return None, n, False
+
+
+def _value(text: str, i: int, depth: int) -> tuple[Any, int, bool]:
+ """从 text[i] 起读一个 JSON 值:(值, 读到哪, 写完了没有)。读到结尾还没写完的容器返回 _Cut。"""
+ if depth > _MAX_DEPTH:
+ raise _Bad
+ n = len(text)
+ i = _skip(text, i)
+ if i >= n:
+ return None, i, False
+ ch = text[i]
+ if ch == "{":
+ return _object(text, i + 1, depth + 1)
+ if ch == "[":
+ return _array(text, i + 1, depth + 1)
+ if ch == '"':
+ return _string(text, i)
+ for word, value in _LITERALS:
+ if text.startswith(word, i):
+ return value, i + len(word), True
+ if word.startswith(text[i:]):
+ return None, n, False
+ m = _NUMBER.match(text, i)
+ if not m:
+ raise _Bad
+ j = m.end()
+ if j >= n:
+ return None, j, False # 数字写到了结尾,可能还没写完
+ number = m.group()
+ return (float(number) if any(c in number for c in ".eE") else int(number)), j, True
+
+
+def _object(text: str, i: int, depth: int) -> tuple[Any, int, bool]:
+ out: dict[str, Any] = {}
+ n = len(text)
+ while True:
+ i = _skip(text, i)
+ if i >= n:
+ return _Cut(out), i, False
+ ch = text[i]
+ if ch == "}":
+ return out, i + 1, True
+ if ch == ",":
+ i += 1
+ continue
+ if ch != '"':
+ raise _Bad
+ key, i, ok = _string(text, i)
+ if not ok:
+ return _Cut(out), i, False
+ i = _skip(text, i)
+ if i >= n:
+ return _Cut(out), i, False
+ if text[i] != ":":
+ raise _Bad
+ value, i, ok = _value(text, i + 1, depth)
+ if not ok:
+ if isinstance(value, _Cut):
+ out[key] = value
+ return _Cut(out), i, False
+ out[key] = value
+
+
+def _array(text: str, i: int, depth: int) -> tuple[Any, int, bool]:
+ out: list[Any] = []
+ n = len(text)
+ while True:
+ i = _skip(text, i)
+ if i >= n:
+ return _Cut(out), i, False
+ ch = text[i]
+ if ch == "]":
+ return out, i + 1, True
+ if ch == ",":
+ i += 1
+ continue
+ value, i, ok = _value(text, i, depth)
+ if not ok:
+ if isinstance(value, _Cut):
+ out.append(value)
+ return _Cut(out), i, False
+ out.append(value)
+
+
+def _json_prefix(text: str) -> tuple[Any, bool] | None:
+ """正文里的第一个 JSON 值:(值, 写完了没有)。前面可以有一句话、围栏,后面可以有别的字;不是 JSON 返回 None。"""
+ for start in sorted(k for k in (text.find("{"), text.find("[")) if k >= 0):
+ try:
+ value, _, complete = _value(text, start, 0)
+ except (_Bad, RecursionError):
+ continue
+ return value, complete
+ return None
+
+
+def _entry(item: Any) -> dict[str, Any] | None:
+ """一个表对象:只留写完了的字段;columns 留写完了的列对象。没有表名的不要。"""
+ if isinstance(item, _Cut):
+ item = item.value
+ if not isinstance(item, dict) or not isinstance(item.get("table"), str):
+ return None
+ out: dict[str, Any] = {}
+ for key, value in item.items():
+ if key == "columns":
+ cols = value.value if isinstance(value, _Cut) else value
+ if isinstance(cols, list):
+ out["columns"] = [c for c in cols if isinstance(c, dict)]
+ elif not isinstance(value, _Cut):
+ out[key] = value
+ return out
+
+
+def _tables_of(root: Any) -> list[dict[str, Any]] | None:
+ """解析出来的根 → 表对象列表。{"tables": […]}、[…]、单个表对象(实测见过不带 tables 外壳的)都认。"""
+ if isinstance(root, _Cut):
+ root = root.value
+ if isinstance(root, dict):
+ if "tables" in root:
+ items = root["tables"]
+ elif isinstance(root.get("table"), str):
+ items = [root]
+ elif not root:
+ return []
+ else:
+ return None
+ else:
+ items = root
+ if isinstance(items, _Cut):
+ items = items.value
+ if not isinstance(items, list):
+ return None
+ return [e for e in (_entry(item) for item in items) if e is not None]
+
+
+def _parse_text(text: str, cut: bool) -> _Reply | None:
+ found = _json_prefix(text)
+ if found is None:
+ return None
+ root, complete = found
+ entries = _tables_of(root)
+ if entries is None:
+ return None
+ if complete:
+ return _Reply(entries=entries)
+ return _Reply(entries=entries, truncated=True, exhausted=cut)
+
+
+def _cut_off(raw: Any) -> bool:
+ """额度用完停下的:OpenAI 系是 finish_reason=length,Anthropic 是 stop_reason=max_tokens。"""
+ meta = getattr(raw, "response_metadata", None) or {}
+ return (meta.get("finish_reason") or meta.get("stop_reason")) in ("length", "max_tokens")
+
+
+def _thought(raw: Any) -> bool:
+ """这次回复有没有思考:思考内容(Anthropic 的 thinking 块、OpenAI 兼容系的 reasoning_content),或者用量里有推理 token。"""
+ from app.engine.state import thinking_text
+
+ if raw is None:
+ return False
+ if thinking_text(raw):
+ return True
+ details = (getattr(raw, "usage_metadata", None) or {}).get("output_token_details") or {}
+ return bool(details.get("reasoning"))
+
+
+def _text_of(raw: Any) -> str:
+ from app.engine.state import message_text
+
+ return message_text(raw) if raw is not None else ""
+
+
+def _raw_texts(raw: Any) -> list[str]:
+ """结构化输出没解析出来时,原始回复里可能还有 JSON 的地方:工具调用的参数(截断的在 invalid_tool_calls 里,是一段
+ 字符串)、正文。"""
+ out: list[str] = []
+ for call in getattr(raw, "tool_calls", None) or []:
+ args = call.get("args") if isinstance(call, dict) else None
+ if isinstance(args, dict) and args:
+ out.append(json.dumps(args, ensure_ascii=False))
+ for call in getattr(raw, "invalid_tool_calls", None) or []:
+ args = call.get("args") if isinstance(call, dict) else None
+ if isinstance(args, str) and args.strip():
+ out.append(args)
+ text = _text_of(raw)
+ if text.strip():
+ out.append(text)
+ return out
+
+
+def _entries_of(parsed: Any) -> list[dict[str, Any]] | None:
+ if parsed is None:
+ return None
+ if isinstance(parsed, dict) and not parsed:
+ return []
+ return C._model_tables(parsed)
+
+
+def _structured_reply(raw: Any, parsed: Any) -> _Reply:
+ entries = _entries_of(parsed)
+ if entries is not None:
+ return _Reply(entries=entries)
+ cut = _cut_off(raw)
+ texts = _raw_texts(raw)
+ for text in texts:
+ reply = _parse_text(text, cut)
+ if reply is not None:
+ return reply
+ if cut or _thought(raw):
+ return _Reply(exhausted=True)
+ if not texts:
+ return _Reply() # 什么都没写:按空壳处理
+ return _Reply(error=REASON_FORMAT)
+
+
+def _text_reply(raw: Any) -> _Reply:
+ text = _text_of(raw)
+ cut = _cut_off(raw)
+ if text.strip():
+ reply = _parse_text(text, cut)
+ if reply is not None:
+ return reply
+ if cut:
+ return _Reply(exhausted=True) # 写了字、额度就用完了,一个 JSON 对象都没捡回来
+ return _Reply(error=REASON_FORMAT)
+ return _Reply(exhausted=cut or _thought(raw))
+
+
+def _unpack(out: Any) -> tuple[Any, Any]:
+ """with_structured_output(include_raw=True) 交回 {raw, parsed, parsing_error};测试的假模型直接交回解析好的对象。"""
+ if isinstance(out, dict) and "raw" in out and ("parsed" in out or "parsing_error" in out):
+ return out.get("raw"), out.get("parsed")
+ return None, out
+
+
+# ==========================================================================
+# 一次起草
+# ==========================================================================
+
+
+@dataclass(frozen=True)
+class _Piece:
+ """一段里的一张表:要写哪些列、要不要表级的项、属于这张表的第几段(从 1 起)。"""
+
+ table: str
+ columns: tuple[str, ...]
+ table_level: bool
+ part: int
+
+
+@dataclass
+class _Gain:
+ """一次回复里某张表拿到了什么。"""
+
+ named: bool = False
+ items: int = 0
+ table_items: int = 0
+ answered: set[str] = field(default_factory=set)
+
+
+@dataclass
+class _Table:
+ name: str
+ meta: Mapping[str, Any]
+ columns: list[str]
+ existing: Mapping[str, Any] | None
+ parts: list[_Piece] = field(default_factory=list)
+ #: 已经拿到的模型起草(逐段合并)
+ notes: dict[str, Any] = field(default_factory=dict)
+ #: 模型的回复里出现过这张表
+ named: bool = False
+ got_table: bool = False
+ got_columns: set[str] = field(default_factory=set)
+ #: 没写出来的列(表级的项记在 _TABLE)→ 原因
+ failed: dict[str, str] = field(default_factory=dict)
+ parts_done: int = 0
+ finished: bool = False
+
+ def whole(self, piece: _Piece) -> bool:
+ return piece.table_level and len(piece.columns) == len(self.columns)
+
+
+def _restrict(notes: Mapping[str, Any], piece: _Piece) -> dict[str, Any]:
+ """只留这一段请模型写的部分:没请写表级的项就不要表级的项,列只要请写的那几列。"""
+ out: dict[str, Any] = {}
+ if piece.table_level:
+ out.update({k: v for k, v in notes.items() if k != "columns"})
+ wanted = set(piece.columns)
+ cols = {c: items for c, items in (notes.get("columns") or {}).items() if c in wanted}
+ if cols:
+ out["columns"] = cols
+ return out
+
+
+def _halves(pieces: list[_Piece]) -> tuple[list[_Piece], list[_Piece]] | None:
+ """拆成两半:多表的段按表拆;单表的按列拆,两半都至少 MODEL_SPLIT_MIN_COLUMNS 列(表级的项跟着前一半)。拆不开返回 None。"""
+ if len(pieces) > 1:
+ k = (len(pieces) + 1) // 2
+ return pieces[:k], pieces[k:]
+ piece = pieces[0]
+ n = len(piece.columns)
+ if n < 2 * max(1, int(C.MODEL_SPLIT_MIN_COLUMNS)):
+ return None
+ h = (n + 1) // 2
+ return ([_Piece(piece.table, piece.columns[:h], piece.table_level, piece.part)],
+ [_Piece(piece.table, piece.columns[h:], False, piece.part)])
+
+
+class _Run:
+ def __init__(self, model: Any, source: Any, tables: Sequence[str], existing: Mapping[str, Mapping[str, Any]],
+ background: Background, hooks: Hooks) -> None:
+ self.model = model
+ self.hooks = hooks
+ #: structured 结构化输出;text 纯文本、从正文取 JSON。结构化输出用不了时这次起草后面都改走纯文本
+ self.mode = "structured"
+ self.system = C.system_notes_source(source)
+ all_tables = (getattr(source, "schema_cache", None) or {}).get("tables") or {}
+ self.order = [t for t in dict.fromkeys(str(t) for t in tables) if isinstance(all_tables.get(t), dict)]
+ self.tables = {
+ name: _Table(name=name, meta=all_tables[name], existing=existing.get(name),
+ columns=[str(c["name"]) for c in all_tables[name].get("columns") or []
+ if isinstance(c, dict) and c.get("name") is not None])
+ for name in self.order
+ }
+ self.background = self._background(getattr(source, "description", None), background)
+
+ # ---------------------------------------------------------------- 总流程
+
+ async def run(self) -> dict[str, ModelOutcome]:
+ await asyncio.gather(*(self._unit(unit) for unit in self._plan()))
+ return {name: self._outcome(t) for name, t in self.tables.items()}
+
+ def _plan(self) -> list[list[str] | str]:
+ """分段:窄表按顺序拼成整表段(列表),宽表一个一项(表名)。各项按第一张表在 tables 里的位置排。"""
+ width = max(1, int(C.MODEL_BATCH_COLUMNS))
+ most = max(1, int(C.MODEL_BATCH_TABLES))
+ units: list[list[str] | str] = []
+ pack: list[str] | None = None
+ used = 0
+ for name in self.order:
+ st = self.tables[name]
+ n = len(st.columns)
+ if n > width:
+ st.parts = [_Piece(name, (), True, 1)] + [
+ _Piece(name, tuple(st.columns[i:i + width]), False, k + 2)
+ for k, i in enumerate(range(0, n, width))]
+ units.append(name)
+ continue
+ st.parts = [_Piece(name, tuple(st.columns), True, 1)]
+ if pack is None or len(pack) >= most or used + n > width:
+ pack, used = [], 0
+ units.append(pack)
+ pack.append(name)
+ used += n
+ return units
+
+ async def _unit(self, unit: list[str] | str) -> None:
+ names = unit if isinstance(unit, list) else [unit]
+ if isinstance(unit, list):
+ await self._guarded([self.tables[n].parts[0] for n in unit])
+ for name in unit:
+ await self._part_done(name)
+ else:
+ head, *rest = self.tables[unit].parts
+ await self._guarded([head]) # 表级那段先写:列分段拿它做背景
+ await self._part_done(unit)
+
+ async def column_part(piece: _Piece) -> None:
+ await self._guarded([piece])
+ await self._part_done(unit)
+
+ await asyncio.gather(*(column_part(p) for p in rest))
+ for name in names:
+ await self._table_done(name)
+
+ async def _guarded(self, pieces: list[_Piece]) -> None:
+ """起草一段;意料之外的错误只记这一段没写出来,不影响别的段和别的表。"""
+ try:
+ await self._segment(pieces, 0, False)
+ except asyncio.CancelledError:
+ raise
+ except Exception as e: # noqa: BLE001
+ from app.core.errors import describe_exception, first_line
+
+ logger.exception("起草数据目录时出错:%s", "、".join(p.table for p in pieces))
+ self._fail(pieces, REASON_CRASH.format(line=first_line(e) or describe_exception(e)))
+
+ # ---------------------------------------------------------------- 一段
+
+ async def _segment(self, pieces: list[_Piece], depth: int, followup: bool, *, mode: str | None = None,
+ last: bool = False) -> None:
+ """问一段,拿到的并进各表;按失败原因拆开、补缺、记下没写出来的。
+
+ depth:已经拆了几层(截断后只补剩下的也算一层)。followup:这一段是在补缺(补过就不再补,空着的算模型拿不准)。
+ mode / last:拆不开的空壳换纯文本问的那一次(再空就记没写出来)。"""
+ reply = await self._ask(pieces, mode)
+ if reply.error is not None:
+ self._fail(pieces, reply.error)
+ return
+ gains = self._take(pieces, reply.entries)
+ anything = any(g.items for g in gains)
+ if last and anything and self.mode != "text":
+ # 结构化输出只给空壳、纯文本写出来了:这个模型的结构化输出靠不住,这次起草后面都走纯文本
+ self.mode = "text"
+ logger.info("起草数据目录:结构化输出只给了空壳,纯文本写出来了,后面改走纯文本")
+ if reply.exhausted or reply.truncated:
+ rest = self._remainder(pieces, gains, truncated=True)
+ if not rest:
+ return
+ kind = "exhausted" if reply.exhausted else "truncated"
+ if not anything:
+ await self._split(rest, depth, kind, followup)
+ elif depth < int(C.MODEL_SPLIT_DEPTH):
+ await self._segment(rest, depth + 1, followup) # 捡回了一部分:只补剩下的
+ else:
+ self._fail(rest, self._reason(kind, split=True))
+ return
+ if not anything:
+ if followup:
+ return
+ if last:
+ self._fail(pieces, lambda p: self._reason("hollow", split=depth > 0, piece=p))
+ return
+ await self._split(pieces, depth, "hollow", followup)
+ return
+ if followup:
+ return
+ rest = self._remainder(pieces, gains, truncated=False)
+ if rest:
+ await self._segment(rest, depth, True)
+
+ async def _split(self, pieces: list[_Piece], depth: int, kind: str, followup: bool) -> None:
+ halves = _halves(pieces) if depth < int(C.MODEL_SPLIT_DEPTH) else None
+ if halves is not None:
+ first, second = halves
+ if (len(first) == len(second) == 1 and first[0].table == second[0].table and first[0].table_level):
+ # 同一张表按列拆、前一半带着表级的项:先写前一半,后一半拿它做背景
+ await self._segment(first, depth + 1, followup)
+ await self._segment(second, depth + 1, followup)
+ return
+ results = await asyncio.gather(self._segment(first, depth + 1, followup),
+ self._segment(second, depth + 1, followup), return_exceptions=True)
+ for result in results:
+ if isinstance(result, BaseException):
+ raise result
+ return
+ if kind == "hollow" and self.mode == "structured":
+ await self._segment(pieces, depth, followup, mode="text", last=True)
+ return
+ self._fail(pieces, lambda p: self._reason(kind, split=depth > 0, piece=p))
+
+ def _remainder(self, pieces: list[_Piece], gains: list[_Gain], *, truncated: bool) -> list[_Piece]:
+ """还没写出来的部分:回复里没有这张表、或者这张表一项可用内容都没有的整段重问;有内容的只问没给的列。
+ 截断的回复里表级的项也没拿到的,表级的项一起重问(没截断时表级空着算模型拿不准)。"""
+ out: list[_Piece] = []
+ for piece, gain in zip(pieces, gains):
+ if not gain.named or not gain.items:
+ out.append(piece)
+ continue
+ cols = tuple(c for c in piece.columns if c not in gain.answered)
+ table_level = piece.table_level and truncated and not gain.table_items
+ if cols or table_level:
+ out.append(_Piece(piece.table, cols, table_level, piece.part))
+ return out
+
+ # ---------------------------------------------------------------- 调用
+
+ async def _ask(self, pieces: list[_Piece], mode: str | None = None) -> _Reply:
+ """调一次模型(按原因重来:额度被拒、结构化输出不支持、网络错误……),交回读好的回复。"""
+ mode = mode or self.mode
+ retried = 0
+ while True:
+ used = getattr(self.model, "budget", None)
+ try:
+ async with model_gate():
+ await self._hook(self.hooks.call_started, [p.table for p in pieces])
+ return await asyncio.wait_for(self._call(pieces, mode), timeout=C.MODEL_TIMEOUT_S)
+ except asyncio.CancelledError:
+ raise
+ except Exception as e: # noqa: BLE001 - 按原因分别处理
+ kind = classify_failure(e)
+ if kind == "length":
+ return await self._length_reply(e)
+ await self._hook(self.hooks.usage, 0, 0)
+ if kind == "budget" and (getattr(self.model, "budget", None) != used or self._shrink()):
+ continue
+ if kind == "unsupported" and mode == "structured":
+ if self.mode == "structured":
+ logger.info("起草数据目录:结构化输出用不了(%s),后面改走纯文本", str(e)[:200])
+ self.mode = mode = "text"
+ continue
+ if kind in ("transient", "timeout") and retried < int(C.MODEL_RETRIES):
+ delays = tuple(C.MODEL_RETRY_DELAYS_S) or (0.0,)
+ await _sleep(delays[min(retried, len(delays) - 1)])
+ retried += 1
+ continue
+ return _Reply(error=self._call_error(e, kind, retried))
+
+ async def _call(self, pieces: list[_Piece], mode: str) -> _Reply:
+ messages = self._messages(pieces, mode)
+ if mode == "structured":
+ runnable = self.model.with_structured_output(C.CATALOG_DRAFT_PROMPT_SCHEMA, include_raw=True)
+ raw, parsed = _unpack(await runnable.ainvoke(messages))
+ await self._usage(raw)
+ return _structured_reply(raw, parsed)
+ raw = await self.model.ainvoke(messages)
+ await self._usage(raw)
+ return _text_reply(raw)
+
+ async def _length_reply(self, e: BaseException) -> _Reply:
+ """LengthFinishReasonError:额度用完了。异常里带着那次的回复,能捡回来的照捡。"""
+ completion = getattr(e, "completion", None)
+ usage = getattr(completion, "usage", None)
+ await self._hook(self.hooks.usage, int(getattr(usage, "prompt_tokens", 0) or 0),
+ int(getattr(usage, "completion_tokens", 0) or 0))
+ try:
+ text = completion.choices[0].message.content or "" # type: ignore[union-attr]
+ except (AttributeError, IndexError, TypeError):
+ text = ""
+ if text.strip():
+ reply = _parse_text(text, True)
+ if reply is not None and reply.entries:
+ return _Reply(entries=reply.entries, truncated=True, exhausted=True)
+ return _Reply(exhausted=True)
+
+ def _shrink(self) -> bool:
+ shrink = getattr(self.model, "shrink", None)
+ if callable(shrink) and shrink():
+ logger.info("起草数据目录:服务方不接受这么大的输出额度,这次起草改用 %s", getattr(self.model, "budget", ""))
+ return True
+ return False
+
+ def _budget(self) -> int:
+ return int(getattr(self.model, "budget", None) or C.MODEL_MAX_TOKENS)
+
+ def _call_error(self, e: BaseException, kind: str, retried: int) -> str:
+ from app.core.errors import describe_exception, first_line
+
+ tail = REASON_RETRIED.format(n=retried) if retried else ""
+ if kind == "timeout":
+ return REASON_TIMEOUT.format(seconds=_seconds(C.MODEL_TIMEOUT_S)) + tail
+ return REASON_CALL.format(line=first_line(e) or describe_exception(e)) + tail
+
+ async def _usage(self, raw: Any) -> None:
+ meta = getattr(raw, "usage_metadata", None) or {}
+ await self._hook(self.hooks.usage, int(meta.get("input_tokens") or 0), int(meta.get("output_tokens") or 0))
+
+ # ---------------------------------------------------------------- 拿到的内容
+
+ def _match(self, pieces: list[_Piece], entries: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
+ """回复里的表对象按表名对到这一段的表上。表名照原样写的直接对上;带了 schema 前缀、大小写不一致的,只在恰好
+ 对上一张时认。"""
+ names = [p.table for p in pieces]
+ out: dict[str, list[dict[str, Any]]] = {}
+ for entry in entries:
+ raw = entry.get("table")
+ if not isinstance(raw, str):
+ continue
+ name = raw if raw in names else None
+ if name is None:
+ short = raw.strip().rsplit(".", 1)[-1].strip('"`[] ')
+ found = [n for n in names if n == short or name_key(n) == name_key(short)]
+ name = found[0] if len(found) == 1 else None
+ if name is not None:
+ out.setdefault(name, []).append(entry)
+ return out
+
+ def _take(self, pieces: list[_Piece], entries: list[dict[str, Any]]) -> list[_Gain]:
+ """回复里能用的并进各表(逐项校验,只留这一段请写的部分),返回每张表这次拿到了什么。"""
+ by_table = self._match(pieces, entries)
+ gains: list[_Gain] = []
+ for piece in pieces:
+ st = self.tables[piece.table]
+ gain = _Gain()
+ for entry in by_table.get(piece.table, []):
+ gain.named = st.named = True
+ notes = _restrict(C._from_model(entry, st.meta, system=self.system), piece)
+ cols = entry.get("columns") if isinstance(entry.get("columns"), list) else []
+ gain.answered |= {c.get("name") for c in cols if isinstance(c, dict) and c.get("name") in piece.columns}
+ slots = C._slots(notes)
+ gain.items += len(slots)
+ gain.table_items += sum(1 for key in slots if key[0] == "t")
+ self._merge(st, notes)
+ gains.append(gain)
+ return gains
+
+ @staticmethod
+ def _merge(st: _Table, notes: Mapping[str, Any]) -> None:
+ for key, value in notes.items():
+ if key == "columns":
+ cols = st.notes.setdefault("columns", {})
+ for col, items in value.items():
+ target = cols.setdefault(col, {})
+ for name, item in items.items():
+ target.setdefault(name, item)
+ st.got_columns.add(col)
+ else:
+ st.notes.setdefault(key, value)
+ st.got_table = True
+
+ def _fail(self, pieces: list[_Piece], reason: str | Callable[[_Piece], str]) -> None:
+ """这几段没写出来:还没拿到内容的列(和表级的项)记下原因。"""
+ for piece in pieces:
+ st = self.tables[piece.table]
+ why = reason(piece) if callable(reason) else reason
+ for col in piece.columns:
+ if col not in st.got_columns:
+ st.failed.setdefault(col, why)
+ if piece.table_level and not st.got_table:
+ st.failed.setdefault(_TABLE, why)
+
+ def _reason(self, kind: str, *, split: bool, piece: _Piece | None = None) -> str:
+ if kind == "exhausted":
+ base = REASON_EXHAUSTED.format(budget=self._budget())
+ elif kind == "truncated":
+ base = REASON_TRUNCATED
+ else:
+ st = self.tables[piece.table] if piece is not None else None
+ if st is not None and st.whole(piece):
+ base = C.MODEL_EMPTY_REASON if st.named else REASON_MISSING
+ else:
+ base = REASON_HOLLOW
+ return base + (REASON_SPLIT if split else "")
+
+ # ---------------------------------------------------------------- 收尾
+
+ def _outcome(self, st: _Table) -> ModelOutcome:
+ items = len(C._slots(st.notes))
+ failed_cols = [c for c in st.columns if c in st.failed and c not in st.got_columns]
+ failed_table = _TABLE in st.failed and not st.got_table
+ total = len(st.parts) or 1
+ if not items:
+ reasons = list(dict.fromkeys(st.failed.values()))
+ reason = ";".join(reasons[:2]) if reasons else (C.MODEL_EMPTY_REASON if st.named else REASON_MISSING)
+ return ModelOutcome("failed", {}, 0, reason, total, st.parts_done)
+ notes = copy.deepcopy(st.notes)
+ if failed_cols or failed_table:
+ return ModelOutcome("partial", notes, items, self._partial_text(st, failed_cols, failed_table), total,
+ st.parts_done)
+ return ModelOutcome("ok", notes, items, None, total, st.parts_done)
+
+ @staticmethod
+ def _partial_text(st: _Table, failed_cols: list[str], failed_table: bool) -> str:
+ """只写出一部分时说清哪部分没写出来、为什么、怎么办。"""
+ keys = ([_TABLE] if failed_table else []) + failed_cols
+ reason = ";".join(list(dict.fromkeys(st.failed[k] for k in keys))[:2])
+ n = len(st.columns)
+ if len(st.parts) > 1:
+ part_of = {c: p.part for p in st.parts for c in p.columns}
+ bad = sorted({part_of[c] for c in failed_cols if c in part_of} | ({1} if failed_table else set()))
+ head = f"这张表 {n} 列,分 {len(st.parts)} 段起草,第 {'、'.join(str(b) for b in bad)} 段没写出来"
+ head += f"(共 {len(failed_cols)} 列)" if failed_cols else "(表级的项)"
+ elif failed_cols and failed_table:
+ head = f"这张表 {n} 列,有表级的项和 {len(failed_cols)} 列没写出来"
+ elif failed_cols:
+ head = f"这张表 {n} 列,有 {len(failed_cols)} 列没写出来"
+ else:
+ head = f"这张表 {n} 列,表级的项没写出来"
+ return f"{head}:{reason}。可以只重试这张表"
+
+ async def _part_done(self, name: str) -> None:
+ self.tables[name].parts_done += 1
+ await self._hook(self.hooks.part_done, name)
+
+ async def _table_done(self, name: str) -> None:
+ st = self.tables[name]
+ if st.finished:
+ return
+ st.finished = True
+ await self._hook(self.hooks.table_done, name, self._outcome(st))
+
+ @staticmethod
+ async def _hook(fn: Callable[..., Awaitable[None]] | None, *args: Any) -> None:
+ if fn is None:
+ return
+ try:
+ await fn(*args)
+ except asyncio.CancelledError:
+ raise
+ except Exception: # noqa: BLE001 - 进度回调出错不影响起草
+ logger.exception("起草数据目录的进度回调出错")
+
+ # ---------------------------------------------------------------- 提示词
+
+ @staticmethod
+ def _background(description: Any, background: Background) -> str:
+ labels = C.CATALOG_DRAFT_PROMPT_LABELS
+ lines = []
+ for key, text in (("description", description), ("guidance", background.guidance), ("note", background.note)):
+ text = text.strip() if isinstance(text, str) else ""
+ if text:
+ lines.append(labels[key].format(text=text))
+ return C.CATALOG_DRAFT_BACKGROUND_PROMPT.format(lines="\n".join(lines)) if lines else ""
+
+ def _messages(self, pieces: list[_Piece], mode: str) -> list[Any]:
+ from langchain_core.messages import HumanMessage, SystemMessage
+
+ text = C.CATALOG_DRAFT_PROMPT.format(n=len(pieces), tables="\n\n".join(self._describe(p) for p in pieces))
+ if self.background:
+ text = f"{self.background}\n\n{text}"
+ if mode == "text":
+ text = f"{text}\n\n{C.CATALOG_DRAFT_TEXT_PROMPT}"
+ return [SystemMessage(content=C.CATALOG_DRAFT_SYSTEM), HumanMessage(content=text)]
+
+ def _describe(self, piece: _Piece) -> str:
+ """一段里一张表的结构描述:列、类型、主键、外键、唯一约束、注释;不含任何数据值。
+
+ 整表:照旧的完整描述。只写表级的项:全部列只给名字和类型。表级的项加几列:完整描述,表名下一行点名这几列。
+ 只写几列:表注释、已起草的表级内容做背景,只列这几列。"""
+ labels = C.CATALOG_DRAFT_PROMPT_LABELS
+ st = self.tables[piece.table]
+ meta = st.meta
+ lines = [(labels["view"] if meta.get("is_view") else labels["table"]).format(name=st.name)]
+ brief = piece.table_level and not piece.columns
+ if not st.whole(piece):
+ if brief:
+ lines.append(labels["only_table"])
+ elif piece.table_level:
+ lines.append(labels["table_and_columns"].format(n=len(piece.columns), cols="、".join(piece.columns)))
+ else:
+ lines.append(labels["only_columns"].format(n=len(piece.columns), total=len(st.columns)))
+ comment = (meta.get("comment") or "").strip()
+ if comment:
+ lines.append((labels["system"] if self.system else labels["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"))
+ if brief:
+ lines.append(labels["brief_columns"].format(n=len(st.columns)))
+ wanted = set(st.columns)
+ else:
+ if not piece.table_level:
+ drafted = C._notes_lines({k: v for k, v in st.notes.items() if k in C.TABLE_FIELDS}, meta=meta,
+ system_notes=False)
+ if drafted:
+ lines.append(labels["drafted"])
+ lines += drafted
+ lines.append(labels["columns"])
+ wanted = set(st.columns) if piece.table_level else set(piece.columns)
+ for col in meta.get("columns") or []:
+ if isinstance(col, dict) and col.get("name") in wanted:
+ lines.append(self._column_line(col, pk, fk_of, brief=brief))
+ if piece.table_level:
+ for group in meta.get("unique") or []:
+ if isinstance(group, list) and group:
+ lines.append(labels["unique"].format(cols="、".join(group)))
+ confirmed = self._confirmed_lines(st, piece)
+ if confirmed:
+ lines.append(labels["confirmed"])
+ lines += confirmed
+ return "\n".join(lines)
+
+ def _column_line(self, col: Mapping[str, Any], pk: set[str], fk_of: Mapping[str, str], *, brief: bool) -> str:
+ labels = C.CATALOG_DRAFT_PROMPT_LABELS
+ marks = []
+ if col["name"] in pk:
+ marks.append(labels["pk"])
+ if not brief and not col.get("nullable", True):
+ marks.append(labels["not_null"])
+ if col["name"] in fk_of:
+ marks.append(labels["fk"].format(target=fk_of[col["name"]]))
+ if not brief and (text := (col.get("comment") or "").strip()):
+ marks.append((labels["col_system"] if self.system else labels["col_comment"]).format(text=text))
+ line = labels["column"].format(name=col["name"], type=col.get("type") or "")
+ return line + ("," + ",".join(marks) if marks else "")
+
+ @staticmethod
+ def _confirmed_lines(st: _Table, piece: _Piece) -> list[str]:
+ """已确认、有确证的项(以此为准的背景):整表全给;其余给表级的项和这一段的列,关系只在要写表级的项时给。"""
+ confirmed = C._confirmed_only(st.existing)
+ if not st.whole(piece):
+ wanted = set(piece.columns)
+ kept = {key: item for key, item in C._slots(confirmed).items()
+ if key[0] == "t" or (key[0] == "c" and key[1] in wanted) or (key[0] == "r" and piece.table_level)}
+ confirmed = C._assemble(kept)
+ return C._notes_lines(confirmed, meta=st.meta, system_notes=False)
+
+
+__all__ = ["BUDGET_WORDS", "Background", "Hooks", "ModelOutcome", "REASON_CALL", "REASON_EXHAUSTED", "REASON_FORMAT",
+ "REASON_HOLLOW", "REASON_MISSING", "REASON_RETRIED", "REASON_SPLIT", "REASON_TIMEOUT", "REASON_TRUNCATED",
+ "classify_failure", "model_gate", "parts_for", "run_model_draft"]
diff --git a/backend/tests/fixtures/catalog/fake_model.py b/backend/tests/fixtures/catalog/fake_model.py
index 42bfdec5..1ffd796d 100644
--- a/backend/tests/fixtures/catalog/fake_model.py
+++ b/backend/tests/fixtures/catalog/fake_model.py
@@ -3,10 +3,11 @@
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 里这几列);
- 直接 ainvoke 是纯文本那条路:同样的内容写成一段正文(AIMessage),前面带一句话;
-- fail 里的表所在的那一批抛异常,用来验证「一批失败只影响这一批」;
+- fail 里的表所在的那一段抛异常,用来验证「一段失败只影响这一段」;
- calls 记下每次收到的提示词全文,用来核对交给模型的内容(只有结构,没有数据);modes 记下每次走的是
structured 还是 text。
@@ -22,6 +23,51 @@ from __future__ import annotations
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
+
class FakeModel:
def __init__(self, replies: dict[str, dict], fail: set[str] = frozenset(), *,
@@ -44,17 +90,19 @@ class FakeModel:
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 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:
diff --git a/backend/tests/test_catalog_draft.py b/backend/tests/test_catalog_draft.py
index 96162564..85e98d74 100644
--- a/backend/tests/test_catalog_draft.py
+++ b/backend/tests/test_catalog_draft.py
@@ -274,10 +274,11 @@ async def test_model_single_table_object_without_wrapper_is_recognized():
async def test_hollow_structured_output_retries_with_text():
- """结构化输出只给了 {"tables":[{"table":"visits"}]} 这样的空壳:改走纯文本再问一次,用纯文本给的内容。"""
+ """结构化输出只给了 {"tables":[{"table":"visits"}]} 这样的空壳、这一段又拆不开(不到 10 列):改走纯文本再问一次,
+ 用纯文本给的内容。纯文本只给了表级的项、没给列:再补问一次没给的列(只问缺的,data/catalog_draft.py)。"""
model = FakeModel({"visits": {"label": "入园记录", "kind": "fact"}}, structured="hollow", text="tables")
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")}
diff --git a/backend/tests/test_catalog_draft_engine.py b/backend/tests/test_catalog_draft_engine.py
new file mode 100644
index 00000000..a1ac2272
--- /dev/null
+++ b/backend/tests/test_catalog_draft_engine.py
@@ -0,0 +1,555 @@
+"""数据目录模型起草的引擎(data/catalog_draft.py):分段、按失败原因处理、捡回被截断的部分、起草要求、用量。
+
+用假模型复刻实测查明的几种失败(不调用真实模型):
+- 思考加输出用完额度:finish_reason=length 且正文为空(只有思考)、LengthFinishReasonError → 拆成两半再问;
+- 只给空壳 → 拆开再问;拆不开的换纯文本问一次,写出来了这次起草后面都走纯文本;
+- 被截断的 JSON:已经完整的表、列对象捡回来,只补剩下的;
+- 只回一部分表:只补缺的,补一次;
+- 服务方嫌额度太大 → 退回 8192,后面都用它;结构化输出不支持 → 后面改走纯文本;
+- 网络错误、5xx、超时 → 退避重试,最多 2 次,报错写明重试过几次。
+
+表名、列名、说明文字都是虚构的。
+"""
+from __future__ import annotations
+
+import asyncio
+import inspect
+import json
+from dataclasses import dataclass, field
+from types import SimpleNamespace
+from typing import Any, Callable
+
+import pytest
+from langchain_core.messages import AIMessage
+
+from app.data import catalog
+from app.data import catalog_draft as D
+from tests.fixtures.catalog.fake_model import answer, requested
+
+
+# --------------------------------------------------------------------------
+# 夹具
+# --------------------------------------------------------------------------
+
+
+def _col(name: str, type_: str = "INTEGER") -> dict:
+ return {"name": name, "type": type_, "nullable": True, "comment": None}
+
+
+def _table(n: int, prefix: str = "c", comment: str | None = None) -> dict:
+ return {"qualified": "", "schema": None, "columns": [_col(f"{prefix}{i:03d}") for i in range(1, n + 1)],
+ "primary_key": [f"{prefix}001"], "comment": comment, "is_view": False, "foreign_keys": []}
+
+
+def _source(tables: dict, **kw) -> SimpleNamespace:
+ for name, meta in tables.items():
+ meta["qualified"] = name
+ return SimpleNamespace(id="src", name="shop", kind="sqlite", origin="manual",
+ description=kw.pop("description", ""), schema_cache={"tables": tables})
+
+
+def _reply(table: str, meta: dict) -> dict:
+ """一张表的完整草稿:表级三项 + 每列中文名和度量类型。"""
+ return {"label": f"{table}的中文名", "grain": "每条记录一行", "kind": "fact",
+ "columns": [{"name": c["name"], "label": f"{c['name']}的中文名", "measure": "attribute"}
+ for c in meta["columns"]]}
+
+
+@dataclass
+class Reply:
+ """一次回复。obj 是正常的 JSON 对象;text 给了就原样作正文(截断的 JSON 之类)。"""
+
+ obj: Any = None
+ text: str | None = None
+ finish: str = "stop"
+ thinking: str = ""
+ usage: tuple[int, int] = (0, 0)
+
+ def render(self, mode: str, include_raw: bool) -> Any:
+ if self.text is not None:
+ content = self.text
+ else:
+ content = json.dumps(self.obj, ensure_ascii=False) if self.obj is not None else ""
+ msg = AIMessage(
+ content=content,
+ additional_kwargs={"reasoning_content": self.thinking} if self.thinking else {},
+ response_metadata={"finish_reason": self.finish},
+ usage_metadata={"input_tokens": self.usage[0], "output_tokens": self.usage[1],
+ "total_tokens": sum(self.usage)},
+ )
+ if mode == "text":
+ return msg
+ parsed = self.obj if self.text is None else None
+ if not include_raw:
+ return parsed
+ return {"raw": msg, "parsed": parsed, "parsing_error": None if parsed is not None else ValueError("坏的")}
+
+
+@dataclass
+class Call:
+ mode: str
+ text: str
+ asked: dict
+ n: int
+
+
+class Scripted:
+ """每次调用交给 handler(call) 决定怎么回:返回 Reply,或者抛异常;handler 可以是协程。"""
+
+ def __init__(self, handler: Callable[[Call], Any], name: str = "scripted-model") -> None:
+ self.handler, self.model_name = handler, name
+ self.calls: list[Call] = []
+
+ def with_structured_output(self, schema: Any, include_raw: bool = False, **_: Any) -> Any:
+ owner = self
+
+ class _S:
+ async def ainvoke(self, messages):
+ return await owner._run(messages, "structured", include_raw)
+
+ return _S()
+
+ async def ainvoke(self, messages: list[Any]) -> Any:
+ return await self._run(messages, "text", False)
+
+ async def _run(self, messages: list[Any], mode: str, include_raw: bool) -> Any:
+ text = "\n".join(str(getattr(m, "content", m)) for m in messages)
+ call = Call(mode=mode, text=text, asked=requested(text), n=len(self.calls))
+ self.calls.append(call)
+ out = self.handler(call)
+ if inspect.isawaitable(out):
+ out = await out
+ return out.render(mode, include_raw)
+
+
+def answering(tables: dict, **kw) -> Callable[[Call], Reply]:
+ """照请求写:每张表按请它写的那部分给出 _reply 的内容。"""
+ def handle(call: Call) -> Reply:
+ return Reply(obj={"tables": [answer(_reply(t, tables[t]), t, *call.asked[t]) for t in call.asked]}, **kw)
+ return handle
+
+
+class _Status(Exception):
+ def __init__(self, status: int, text: str) -> None:
+ super().__init__(text)
+ self.status_code = status
+
+
+class LengthFinishReasonError(Exception):
+ """和 openai 的同名异常一样:解析结构化输出时发现 finish_reason=length。"""
+
+ def __init__(self) -> None:
+ super().__init__("Could not parse response content as the length limit was reached")
+ self.completion = None
+
+
+class ConnectError(Exception):
+ pass
+
+
+@pytest.fixture(autouse=True)
+def _no_wait(monkeypatch):
+ """退避不真等:记下要等多久。"""
+ waited: list[float] = []
+
+ async def fake_sleep(seconds: float) -> None:
+ waited.append(seconds)
+
+ monkeypatch.setattr(D, "_sleep", fake_sleep)
+ return waited
+
+
+def _status(outcomes: dict) -> dict:
+ return {t: o.status for t, o in outcomes.items()}
+
+
+# --------------------------------------------------------------------------
+# 思考用完额度、空壳:拆开再问
+# --------------------------------------------------------------------------
+
+
+async def test_thinking_used_up_the_budget_splits_the_segment():
+ tables = {"orders_x": _table(24)}
+ ok = answering(tables)
+
+ def handle(call: Call) -> Reply:
+ if call.n == 0: # 只想不写:正文为空、只有思考,额度用完
+ return Reply(text="", finish="length", thinking="先逐列想一遍……")
+ return ok(call)
+
+ model = Scripted(handle)
+ got = await D.run_model_draft(model, _source(tables), ["orders_x"])
+ o = got["orders_x"]
+ assert o.status == "ok" and o.error is None and o.items == 3 + 24 * 2
+ assert len(model.calls) == 3
+ cols = [f"c{i:03d}" for i in range(1, 25)]
+ assert model.calls[1].asked == {"orders_x": ("table+columns", cols[:12])}
+ assert model.calls[2].asked == {"orders_x": ("columns", cols[12:])}
+ # 后半段在前半段之后问,带着刚起草的表级内容做背景
+ assert catalog.CATALOG_DRAFT_PROMPT_LABELS["drafted"] in model.calls[2].text
+ assert "orders_x的中文名" in model.calls[2].text
+
+
+async def test_length_finish_reason_error_also_splits():
+ tables = {"orders_x": _table(24)}
+ ok = answering(tables)
+
+ def handle(call: Call) -> Reply:
+ if call.n == 0:
+ raise LengthFinishReasonError()
+ return ok(call)
+
+ model = Scripted(handle)
+ got = await D.run_model_draft(model, _source(tables), ["orders_x"])
+ assert got["orders_x"].status == "ok" and len(model.calls) == 3
+
+
+async def test_hollow_shell_splits_the_pack_by_table():
+ tables = {"a_tab": _table(6), "b_tab": _table(6)}
+ ok = answering(tables)
+
+ def handle(call: Call) -> Reply:
+ if call.n == 0:
+ return Reply(obj={"tables": [{"table": "a_tab"}, {"table": "b_tab"}]})
+ return ok(call)
+
+ model = Scripted(handle)
+ got = await D.run_model_draft(model, _source(tables), ["a_tab", "b_tab"])
+ assert _status(got) == {"a_tab": "ok", "b_tab": "ok"}
+ assert [set(c.asked) for c in model.calls] == [{"a_tab", "b_tab"}, {"a_tab"}, {"b_tab"}]
+ assert all(c.asked[t][0] == "full" for c in model.calls[1:] for t in c.asked)
+
+
+async def test_splitting_stops_at_three_levels_and_five_columns():
+ tables = {"wide40": _table(40)}
+ model = Scripted(lambda call: Reply(text="", finish="length", thinking="……"))
+ got = await D.run_model_draft(model, _source(tables), ["wide40"])
+ o = got["wide40"]
+ assert len(model.calls) == 1 + 2 + 4 + 8, "40 → 20 → 10 → 5 列,最多拆三层"
+ assert min(len(c.asked["wide40"][1] or []) for c in model.calls[1:]) == 5
+ assert o.status == "failed" and o.items == 0
+ assert o.error == "模型的思考和输出用完了额度(32768 个 token),这一段没写出来;已拆小重试仍没写出"
+
+ small = Scripted(lambda call: Reply(text="", finish="length"))
+ got = await D.run_model_draft(small, _source({"t9": _table(9)}), ["t9"])
+ assert len(small.calls) == 1, "不到 10 列拆不出两段 5 列"
+ assert got["t9"].error == "模型的思考和输出用完了额度(32768 个 token),这一段没写出来"
+
+
+async def test_an_unsplittable_hollow_shell_tries_text_once_then_stays_on_text():
+ """有的网关的结构化输出只给空壳:拆不开时换纯文本问一次,写出来了,这次起草后面直接走纯文本。
+
+ 用宽表看先后:表级那段(拆不开)写完之后才问列分段。"""
+ tables = {"wide45": _table(45)}
+ ok = answering(tables)
+
+ def handle(call: Call) -> Reply:
+ if call.mode == "structured":
+ return Reply(obj={"tables": [{"table": t} for t in call.asked]})
+ return ok(call)
+
+ model = Scripted(handle)
+ got = await D.run_model_draft(model, _source(tables), ["wide45"])
+ assert got["wide45"].status == "ok" and len(got["wide45"].notes["columns"]) == 45
+ assert [c.mode for c in model.calls] == ["structured", "text", "text", "text"]
+ assert catalog.CATALOG_DRAFT_TEXT_PROMPT in model.calls[1].text, "纯文本那条路要说清 JSON 的格式"
+
+
+# --------------------------------------------------------------------------
+# 截断、缺表、缺列
+# --------------------------------------------------------------------------
+
+
+async def test_truncated_json_keeps_complete_columns_and_asks_only_the_rest():
+ tables = {"t12": _table(12)}
+ full = json.dumps({"tables": [{"table": "t12", **_reply("t12", tables["t12"])}]}, ensure_ascii=False)
+ cut = full[:full.index('"c006"') + 8] # 第 6 列写到一半
+ ok = answering(tables)
+
+ def handle(call: Call) -> Reply:
+ if call.n == 0:
+ return Reply(text=cut, finish="length")
+ return ok(call)
+
+ model = Scripted(handle)
+ got = await D.run_model_draft(model, _source(tables), ["t12"])
+ o = got["t12"]
+ assert o.status == "ok" and set(o.notes["columns"]) == {f"c{i:03d}" for i in range(1, 13)}
+ assert o.notes["label"]["value"] == "t12的中文名"
+ assert len(model.calls) == 2
+ assert model.calls[1].asked == {"t12": ("columns", [f"c{i:03d}" for i in range(6, 13)])}
+
+
+async def test_truncated_tool_call_arguments_are_salvaged_too():
+ """结构化输出走工具调用时,截断的参数在 invalid_tool_calls 里(一段字符串)。"""
+ tables = {"t12": _table(12)}
+ full = json.dumps({"tables": [{"table": "t12", **_reply("t12", tables["t12"])}]}, ensure_ascii=False)
+ cut = full[:full.index('"c004"') + 3] # 第 4 列的列名写到一半
+ ok = answering(tables)
+
+ class ToolModel(Scripted):
+ async def _run(self, messages, mode, include_raw):
+ if not self.calls:
+ self.calls.append(Call(mode, "", {}, 0))
+ raw = AIMessage(content="", invalid_tool_calls=[{"name": "catalog_draft", "args": cut, "id": "x",
+ "error": None, "type": "invalid_tool_call"}],
+ response_metadata={"finish_reason": "length"})
+ return {"raw": raw, "parsed": None, "parsing_error": ValueError("截断")}
+ return await super()._run(messages, mode, include_raw)
+
+ model = ToolModel(ok)
+ got = await D.run_model_draft(model, _source(tables), ["t12"])
+ assert got["t12"].status == "ok"
+ assert model.calls[1].asked == {"t12": ("columns", [f"c{i:03d}" for i in range(4, 13)])}
+
+
+async def test_missing_tables_are_asked_again_once():
+ tables = {"a_tab": _table(4), "b_tab": _table(4), "c_tab": _table(4)}
+ ok = answering(tables)
+
+ def handle(call: Call) -> Reply:
+ reply = ok(call)
+ keep = {0: {"a_tab"}, 1: {"b_tab"}}.get(call.n, set())
+ reply.obj["tables"] = [e for e in reply.obj["tables"] if e["table"] in keep]
+ return reply
+
+ model = Scripted(handle)
+ got = await D.run_model_draft(model, _source(tables), ["a_tab", "b_tab", "c_tab"])
+ assert len(model.calls) == 2
+ assert model.calls[1].asked == {"b_tab": ("full", None), "c_tab": ("full", None)}
+ assert _status(got) == {"a_tab": "ok", "b_tab": "ok", "c_tab": "failed"}
+ assert got["c_tab"].error == "模型的回复里没有这张表"
+
+
+async def test_columns_the_model_skips_are_asked_once_and_then_left_empty():
+ tables = {"t8": _table(8)}
+ ok = answering(tables)
+
+ def handle(call: Call) -> Reply:
+ reply = ok(call)
+ for entry in reply.obj["tables"]:
+ entry["columns"] = [c for c in entry.get("columns", []) if c["name"] not in ("c007", "c008")]
+ return reply
+
+ model = Scripted(handle)
+ got = await D.run_model_draft(model, _source(tables), ["t8"])
+ assert len(model.calls) == 2 and model.calls[1].asked == {"t8": ("columns", ["c007", "c008"])}
+ o = got["t8"]
+ assert o.status == "ok" and o.error is None, "补问过仍然没给的列算拿不准、留空"
+ assert set(o.notes["columns"]) == {f"c{i:03d}" for i in range(1, 7)}
+
+
+# --------------------------------------------------------------------------
+# 额度被拒、结构化输出不支持、网络错误、超时
+# --------------------------------------------------------------------------
+
+
+async def test_budget_rejection_falls_back_to_8192_for_the_rest():
+ tables = {"wide45": _table(45)}
+
+ def refuse(call: Call) -> Reply:
+ raise _Status(400, "max_tokens: 32768 > 8192, which is the maximum allowed number of output tokens")
+
+ big, small = Scripted(refuse), Scripted(answering(tables))
+ model = catalog.DraftModel(big, small, "m")
+ got = await D.run_model_draft(model, _source(tables), ["wide45"])
+ assert got["wide45"].status == "ok"
+ assert len(big.calls) == 1 and len(small.calls) == 3, "退回之后,表级那段重来、两个列分段都用小额度"
+ assert model.budget == 8192
+
+
+async def test_exhausted_message_names_the_budget_actually_used():
+ def refuse(call: Call) -> Reply:
+ raise _Status(400, "max_tokens is too large")
+
+ model = catalog.DraftModel(Scripted(refuse), Scripted(lambda c: Reply(text="", finish="length")), "m")
+ got = await D.run_model_draft(model, _source({"t4": _table(4)}), ["t4"])
+ assert got["t4"].error == "模型的思考和输出用完了额度(8192 个 token),这一段没写出来"
+
+
+async def test_structured_output_unsupported_switches_to_text_for_the_rest():
+ tables = {"wide45": _table(45)}
+ ok = answering(tables)
+
+ def handle(call: Call) -> Reply:
+ if call.mode == "structured":
+ raise _Status(400, "This response_format type is unavailable now")
+ return ok(call)
+
+ model = Scripted(handle)
+ got = await D.run_model_draft(model, _source(tables), ["wide45"])
+ assert got["wide45"].status == "ok"
+ assert [c.mode for c in model.calls] == ["structured", "text", "text", "text"]
+
+
+async def test_a_model_without_structured_output_goes_to_text():
+ class TextOnly(Scripted):
+ def with_structured_output(self, schema, **_):
+ raise NotImplementedError
+
+ tables = {"t4": _table(4)}
+ model = TextOnly(answering(tables))
+ got = await D.run_model_draft(model, _source(tables), ["t4"])
+ assert got["t4"].status == "ok" and [c.mode for c in model.calls] == ["text"]
+
+
+async def test_network_errors_are_retried_with_backoff(_no_wait):
+ tables = {"t4": _table(4)}
+ ok = answering(tables)
+
+ def handle(call: Call) -> Reply:
+ if call.n < 2:
+ raise ConnectError("All connection attempts failed")
+ return ok(call)
+
+ model = Scripted(handle)
+ got = await D.run_model_draft(model, _source(tables), ["t4"])
+ assert got["t4"].status == "ok" and len(model.calls) == 3
+ assert _no_wait == list(catalog.MODEL_RETRY_DELAYS_S)
+
+
+async def test_server_errors_give_up_after_two_retries():
+ def handle(call: Call) -> Reply:
+ raise _Status(503, "Service Unavailable")
+
+ model = Scripted(handle)
+ got = await D.run_model_draft(model, _source({"t4": _table(4)}), ["t4"])
+ assert len(model.calls) == 3
+ assert got["t4"].status == "failed"
+ assert got["t4"].error == "调用模型出错:Service Unavailable(已重试 2 次)"
+
+
+async def test_other_errors_are_not_retried():
+ def handle(call: Call) -> Reply:
+ raise RuntimeError("网关拒绝了这次请求")
+
+ model = Scripted(handle)
+ got = await D.run_model_draft(model, _source({"t4": _table(4)}), ["t4"])
+ assert len(model.calls) == 1 and got["t4"].error == "调用模型出错:网关拒绝了这次请求"
+
+
+async def test_a_call_past_the_time_limit_is_retried_then_reported(monkeypatch):
+ monkeypatch.setattr(catalog, "MODEL_TIMEOUT_S", 0.05)
+
+ async def slow(call: Call) -> Reply:
+ await asyncio.sleep(1)
+ return Reply(obj={"tables": []})
+
+ model = Scripted(slow)
+ got = await D.run_model_draft(model, _source({"t4": _table(4)}), ["t4"])
+ assert len(model.calls) == 3
+ assert got["t4"].error == "模型 0.05 秒内没有写完这一段(已重试 2 次)"
+
+
+# --------------------------------------------------------------------------
+# 分段:宽表拆成表级一段 + 列分段,窄表拼段
+# --------------------------------------------------------------------------
+
+
+async def test_a_wide_table_is_split_into_a_table_part_and_column_parts():
+ tables = {"wide95": _table(95, comment="虚构的宽表")}
+ model = Scripted(answering(tables))
+ done: list[str] = []
+
+ async def part_done(table: str) -> None:
+ done.append(table)
+
+ got = await D.run_model_draft(model, _source(tables), ["wide95"], hooks=D.Hooks(part_done=part_done))
+ o = got["wide95"]
+ assert o.status == "ok" and o.parts_total == 4 and o.parts_done == 4 and done == ["wide95"] * 4
+ assert len(o.notes["columns"]) == 95 and o.notes["label"]["value"] == "wide95的中文名"
+ first, *rest = model.calls
+ assert first.asked == {"wide95": ("table", None)}
+ assert catalog.CATALOG_DRAFT_PROMPT_LABELS["brief_columns"].format(n=95) in first.text
+ assert sorted(len(c.asked["wide95"][1]) for c in rest) == [15, 40, 40]
+ for call in rest:
+ assert call.asked["wide95"][0] == "columns"
+ assert "wide95的中文名" in call.text and "虚构的宽表" in call.text, "列分段带着表注释和表级起草结果"
+ assert {c for call in rest for c in call.asked["wide95"][1]} == {f"c{i:03d}" for i in range(1, 96)}
+
+
+async def test_a_wide_table_with_one_failed_part_is_partial_and_says_which():
+ tables = {"wide95": _table(95)}
+ ok = answering(tables)
+
+ def handle(call: Call) -> Reply:
+ part, cols = call.asked["wide95"]
+ if part == "columns" and "c041" in cols:
+ raise RuntimeError("网关拒绝")
+ return ok(call)
+
+ model = Scripted(handle)
+ got = await D.run_model_draft(model, _source(tables), ["wide95"])
+ o = got["wide95"]
+ assert o.status == "partial" and len(o.notes["columns"]) == 55
+ assert o.error == "这张表 95 列,分 4 段起草,第 3 段没写出来(共 40 列):调用模型出错:网关拒绝。可以只重试这张表"
+ compat = (await catalog.draft_with_model(Scripted(handle), _source(tables), ["wide95"]))["wide95"]
+ assert compat.covered == frozenset() and compat.error == o.error, "没写全的不算完整起草过,旧的模型项不删"
+
+
+async def test_tables_are_packed_up_to_40_columns_and_4_tables():
+ widths = {"ta": 15, "tb": 15, "tc": 15, "td": 5, "te": 5, "tf": 5, "tg": 5, "th": 41}
+ tables = {name: _table(n) for name, n in widths.items()}
+ model = Scripted(answering(tables))
+ got = await D.run_model_draft(model, _source(tables), list(widths))
+ assert set(_status(got).values()) == {"ok"}
+ asked = sorted((tuple(sorted(c.asked)), c.asked[next(iter(c.asked))][0]) for c in model.calls)
+ assert asked == sorted([
+ (("ta", "tb"), "full"), (("tc", "td", "te", "tf"), "full"), (("tg",), "full"),
+ (("th",), "table"), (("th",), "columns"), (("th",), "columns"),
+ ])
+
+
+# --------------------------------------------------------------------------
+# 起草要求、用量
+# --------------------------------------------------------------------------
+
+
+async def test_guidance_goes_into_the_prompt_after_the_rules():
+ tables = {"t4": _table(4)}
+ model = Scripted(answering(tables))
+ src = _source(tables, description="虚构的景区票务库")
+ await D.run_model_draft(model, src, ["t4"],
+ background=D.Background(guidance="金额一律按元写单位", note="这次重点看会员相关的列"))
+ text = model.calls[0].text
+ block = ("使用者补充的背景和要求:\n数据源说明:虚构的景区票务库\n起草要求:金额一律按元写单位\n"
+ "本次说明:这次重点看会员相关的列")
+ assert block in text
+ rule = "它和这里的规矩冲突时,以这里的规矩为准"
+ assert rule in catalog.CATALOG_DRAFT_SYSTEM
+ assert text.index(rule) < text.index(block) < text.index("请为下面") < text.index("表 t4")
+
+ plain = Scripted(answering(tables))
+ await D.run_model_draft(plain, _source(tables), ["t4"])
+ assert "使用者补充的背景和要求:" not in plain.calls[0].text, "都没有就不出现这一段(系统提示词里那条规矩照旧在)"
+ only_note = Scripted(answering(tables))
+ await D.run_model_draft(only_note, _source(tables), ["t4"], background=D.Background(note="只看金额"))
+ assert "使用者补充的背景和要求:\n本次说明:只看金额\n\n请为下面" in only_note.calls[0].text
+
+
+async def test_the_prompt_asks_not_to_draft_every_column_while_thinking():
+ assert "思考时不必把要输出的内容逐列草拟一遍" in catalog.CATALOG_DRAFT_SYSTEM
+
+
+async def test_usage_is_added_up_through_the_hooks():
+ tables = {"orders_x": _table(24)}
+ ok = answering(tables, usage=(120, 60))
+
+ def handle(call: Call) -> Reply:
+ if call.n == 0:
+ return Reply(text="", finish="length", thinking="……", usage=(100, 32768))
+ return ok(call)
+
+ seen: list[tuple[int, int]] = []
+
+ async def usage(i: int, o: int) -> None:
+ seen.append((i, o))
+
+ await D.run_model_draft(Scripted(handle), _source(tables), ["orders_x"], hooks=D.Hooks(usage=usage))
+ assert seen == [(100, 32768), (120, 60), (120, 60)]
+
+
+def test_budget_words_match_the_other_drafts():
+ from app.data import recipe_ai
+
+ assert D.BUDGET_WORDS == recipe_ai._BUDGET_WORDS
From 90a560d07e6e07b8b5ebec82518cf7052f527ccf Mon Sep 17 00:00:00 2001
From: ThearchyHelios
Date: Tue, 13 Oct 2026 15:24:57 +0800
Subject: [PATCH 03/29] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95?=
=?UTF-8?q?=E8=B5=B7=E8=8D=89=E6=94=B9=E6=88=90=E5=90=8E=E5=8F=B0=E4=BB=BB?=
=?UTF-8?q?=E5=8A=A1=EF=BC=88=E5=89=8D=E7=AB=AF=EF=BC=89=EF=BC=9A=E5=AF=B9?=
=?UTF-8?q?=E8=AF=9D=E6=A1=86=E9=80=89=E8=8C=83=E5=9B=B4=E3=80=81=E5=86=99?=
=?UTF-8?q?=E8=B5=B7=E8=8D=89=E8=A6=81=E6=B1=82=E5=92=8C=E6=9C=AC=E6=AC=A1?=
=?UTF-8?q?=E8=A1=A5=E5=85=85=E8=AF=B4=E6=98=8E=E5=90=8E=E5=BB=BA=E4=BB=BB?=
=?UTF-8?q?=E5=8A=A1=EF=BC=8C=E4=B9=8B=E5=90=8E=E6=98=BE=E7=A4=BA=E4=BB=BB?=
=?UTF-8?q?=E5=8A=A1=E7=9A=84=E8=BF=9B=E5=BA=A6=E5=92=8C=E7=BB=93=E6=9E=9C?=
=?UTF-8?q?=E2=80=94=E2=80=94=E6=80=BB=E8=BF=9B=E5=BA=A6=E3=80=81=E6=96=B0?=
=?UTF-8?q?=E5=A2=9E=20/=20=E6=9B=B4=E6=96=B0=20/=20=E5=88=A0=E9=99=A4?=
=?UTF-8?q?=E5=90=88=E8=AE=A1=E3=80=81=E9=80=90=E8=A1=A8=E7=8A=B6=E6=80=81?=
=?UTF-8?q?=E3=80=81=E5=AE=BD=E8=A1=A8=E7=9A=84=E6=AE=B5=E8=BF=9B=E5=BA=A6?=
=?UTF-8?q?=E3=80=81=E6=B2=A1=E5=86=99=E5=85=A8=E7=9A=84=E5=8E=9F=E5=9B=A0?=
=?UTF-8?q?=EF=BC=9B=E5=8F=AF=E4=BB=A5=E5=81=9C=E6=AD=A2=E3=80=81=E5=9C=A8?=
=?UTF-8?q?=E5=90=8E=E5=8F=B0=E7=BB=A7=E7=BB=AD=EF=BC=8C=E5=85=B3=E6=8E=89?=
=?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=A1=86=E4=BB=BB=E5=8A=A1=E7=85=A7=E5=B8=B8?=
=?UTF-8?q?=E8=BF=9B=E8=A1=8C=EF=BC=8C=E7=9B=AE=E5=BD=95=E9=A1=B5=E4=B8=8A?=
=?UTF-8?q?=E6=96=B9=E6=98=BE=E7=A4=BA=E4=B8=80=E6=9D=A1=E8=BF=9B=E5=BA=A6?=
=?UTF-8?q?=EF=BC=8C=E7=82=B9=E5=BC=80=E5=9B=9E=E5=88=B0=E5=AF=B9=E8=AF=9D?=
=?UTF-8?q?=E6=A1=86=EF=BC=8C=E7=BB=93=E6=9D=9F=E6=97=B6=E6=8F=90=E7=A4=BA?=
=?UTF-8?q?=E5=B9=B6=E5=8F=AF=E6=9F=A5=E7=9C=8B=E7=BB=93=E6=9E=9C=EF=BC=9B?=
=?UTF-8?q?=E7=BB=93=E6=9E=9C=E9=87=8C=E5=8F=AA=E5=88=97=E6=B2=A1=E5=86=99?=
=?UTF-8?q?=E5=85=A8=E7=9A=84=E8=A1=A8=EF=BC=8C=E3=80=8C=E5=8F=AA=E9=87=8D?=
=?UTF-8?q?=E8=AF=95=E5=A4=B1=E8=B4=A5=E7=9A=84=E8=A1=A8=E3=80=8D=E6=96=B0?=
=?UTF-8?q?=E5=BB=BA=E5=8F=AA=E5=90=AB=E5=A4=B1=E8=B4=A5=E3=80=81=E9=83=A8?=
=?UTF-8?q?=E5=88=86=E5=AE=8C=E6=88=90=E3=80=81=E5=B7=B2=E5=81=9C=E6=AD=A2?=
=?UTF-8?q?=E7=9A=84=E8=A1=A8=E7=9A=84=E4=BB=BB=E5=8A=A1=EF=BC=9B=E6=9C=8D?=
=?UTF-8?q?=E5=8A=A1=E9=87=8D=E5=90=AF=E4=B8=AD=E6=96=AD=E7=9A=84=E4=BB=BB?=
=?UTF-8?q?=E5=8A=A1=E5=86=99=E6=98=8E=E5=8E=9F=E5=9B=A0=E3=80=81=E5=90=8C?=
=?UTF-8?q?=E6=A0=B7=E8=83=BD=E9=87=8D=E8=AF=95=EF=BC=9B=E5=B7=B2=E6=9C=89?=
=?UTF-8?q?=E4=BB=BB=E5=8A=A1=EF=BC=88409=EF=BC=89=E8=BD=AC=E5=8E=BB?=
=?UTF-8?q?=E6=98=BE=E7=A4=BA=E8=BF=9B=E8=A1=8C=E4=B8=AD=E7=9A=84=E9=82=A3?=
=?UTF-8?q?=E4=B8=80=E4=B8=AA=EF=BC=8C=E8=B5=B7=E8=8D=89=E6=A8=A1=E5=9E=8B?=
=?UTF-8?q?=E7=94=A8=E4=B8=8D=E4=BA=86=EF=BC=88409=EF=BC=89=E7=95=99?=
=?UTF-8?q?=E5=9C=A8=E8=AE=BE=E7=BD=AE=E9=98=B6=E6=AE=B5=E5=86=99=E6=98=8E?=
=?UTF-8?q?=E5=8E=9F=E5=9B=A0=EF=BC=9B=E8=B5=B7=E8=8D=89=E8=A6=81=E6=B1=82?=
=?UTF-8?q?=E6=94=B9=E8=BF=87=E5=85=88=E5=AD=98=E5=86=8D=E5=BB=BA=E4=BB=BB?=
=?UTF-8?q?=E5=8A=A1=EF=BC=8C=E5=86=99=E6=98=8E=E7=94=A8=E7=9A=84=E6=98=AF?=
=?UTF-8?q?=E5=93=AA=E4=B8=AA=E8=B5=B7=E8=8D=89=E6=A8=A1=E5=9E=8B=E3=80=81?=
=?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=BA=90=E8=AF=B4=E6=98=8E=E4=B9=9F=E4=B8=80?=
=?UTF-8?q?=E5=B9=B6=E4=BA=A4=E7=BB=99=E6=A8=A1=E5=9E=8B=EF=BC=9B=E6=B8=85?=
=?UTF-8?q?=E5=8D=95=E4=B8=8A=E6=A0=87=E5=87=BA=E4=B8=8A=E6=AC=A1=E8=B5=B7?=
=?UTF-8?q?=E8=8D=89=E5=A4=B1=E8=B4=A5=E3=80=81=E9=83=A8=E5=88=86=E5=AE=8C?=
=?UTF-8?q?=E6=88=90=E7=9A=84=E8=A1=A8=E5=B9=B6=E5=86=99=E5=8E=9F=E5=9B=A0?=
=?UTF-8?q?=EF=BC=8C=E8=A1=A8=E8=AF=A6=E6=83=85=E9=87=8C=E7=BB=99=E3=80=8C?=
=?UTF-8?q?=E9=87=8D=E6=96=B0=E8=B5=B7=E8=8D=89=E8=BF=99=E5=BC=A0=E8=A1=A8?=
=?UTF-8?q?=E3=80=8D=EF=BC=9B=E8=BF=9B=E8=A1=8C=E4=B8=AD=E6=AF=8F=201.5=20?=
=?UTF-8?q?=E7=A7=92=E5=8F=96=E4=B8=80=E6=AC=A1=EF=BC=8C=E5=8F=96=E5=9B=9E?=
=?UTF-8?q?=E7=9A=84=E7=8A=B6=E6=80=81=E5=8F=AA=E5=BE=80=E5=89=8D=E8=B5=B0?=
=?UTF-8?q?=E3=80=82check-catalog=20=E7=94=A8=E5=81=87=E7=9A=84=E4=BB=BB?=
=?UTF-8?q?=E5=8A=A1=E6=8E=A5=E5=8F=A3=E8=A6=86=E7=9B=96=E8=BF=99=E4=BA=9B?=
=?UTF-8?q?=EF=BC=88=E4=BB=BB=E5=8A=A1=E6=80=8E=E4=B9=88=E5=BE=80=E5=89=8D?=
=?UTF-8?q?=E8=B5=B0=E7=94=B1=E6=A3=80=E6=9F=A5=E8=87=AA=E5=B7=B1=E6=8E=A8?=
=?UTF-8?q?=EF=BC=89=EF=BC=8C=E6=97=A7=E7=9A=84=E5=90=8C=E6=AD=A5=E8=B5=B7?=
=?UTF-8?q?=E8=8D=89=E6=A3=80=E6=9F=A5=E6=8D=A2=E6=8E=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/src/api/client.ts | 40 +-
frontend/src/lib/terms.ts | 144 ++++-
frontend/src/pages/catalog/CatalogPage.tsx | 119 +++-
frontend/src/pages/catalog/DraftDialog.tsx | 508 +++++++++++-----
frontend/src/pages/catalog/TableDetail.tsx | 35 +-
frontend/src/pages/catalog/TableIndex.tsx | 21 +-
frontend/src/pages/catalog/draftJob.ts | 192 ++++++
frontend/src/types.ts | 99 +++-
scripts/check-catalog.mjs | 657 +++++++++++++++++----
9 files changed, 1479 insertions(+), 336 deletions(-)
create mode 100644 frontend/src/pages/catalog/draftJob.ts
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 00edd8da..e4e99d80 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -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 => !!v && typeof v === 'object' && !Array.isArray(v)
+/** 数据目录的接口前缀:/datasources/{id}/catalog */
+const catalogBase = (sourceId: string) => `/datasources/${encodeURIComponent(sourceId)}/catalog`
+
/**
* 上传表格被退回、要用户先选怎么处理(422 带 decision)时,取出那份决定;别的错误返回 null。
*
@@ -409,12 +412,37 @@ export const api = {
post(`/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(`/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)
+ */
+ 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 是一句中文)
diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts
index 61073e35..bae8db47 100644
--- a/frontend/src/lib/terms.ts
+++ b/frontend/src/lib/terms.ts
@@ -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: '工作流',
@@ -2198,7 +2199,7 @@ export const CATALOG_TEXT = {
noSchemaBody: (note: string) => `${note.replace(/[。.]$/, '')}。数据目录按表结构逐表记录,请先回到数据源卡片点击「探查结构」。`,
noSchemaUploadBody: '这个表格还没有可用的表结构,请重新上传后再编写数据目录。',
emptyTitle: '还没有数据目录',
- emptyBody: '先起草,再逐表确认。起草按数据库注释、外键约束和列名推断,也可以请助手的模型起草中文名和含义。',
+ emptyBody: '先起草,再逐表确认。起草按数据库注释、外键约束和列名推断,也可以用模型起草中文名和含义。',
// ---- 导入表格的说明
systemTitle: '导入时生成的说明',
systemBody: '这个数据源由导入表格生成,表和列的说明由系统按核对结果生成,只读,不能在数据目录中改写。',
@@ -2260,29 +2261,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 +2378,123 @@ export const CATALOG_TEXT = {
reloadDiscardAction: '放弃修改并重新载入',
}
+// ===========================================================================
+// 数据目录的起草任务(/api/datasources/{id}/catalog/draft-jobs):起草在服务端后台进行,关掉对话框不停;逐表起草,
+// 每张表一结束就写进目录。
+//
+// 术语:
+// - 「起草任务」的状态:排队中、正在起草、起草完成、部分表未写全、起草失败、已停止、服务重启中断。
+// 一张表:等待、起草中、已完成、部分完成、失败、已跳过(表结构里没有)、已停止。
+// - 「起草要求」:使用者补充给模型的业务背景和用词约定,存在数据源上,以后每次用模型起草都带上;
+// 「本次补充说明」只用于这一次(重试时沿用)。两者都只交给模型,不改起草规则:冲突时以规则为准。
+// - 「起草模型」:设置里单独的一项(偏好设置 → 起草模型),留空跟随助手的模型。
+// - 宽表的「段」:超过 40 列的表拆成表级一段和若干列分段,逐段起草;写的是「已完成 3 / 9 段」。
+// - 「只重试失败的表」:重新起草失败、部分完成和已停止的表;已经写进目录的内容不回滚、不受影响。
+// ===========================================================================
+
+/** 起草要求、本次补充说明的字数上限(服务端同一个数:超长 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}`,
+ lastOpen: '查看',
+ // ---- 进行中
+ 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 })}`,
+ // ---- 结束
+ title: {
+ queued: '排队中,等待开始…',
+ running: '正在起草…',
+ succeeded: '起草完成',
+ partial: '起草完成,部分表未写全',
+ failed: '起草失败',
+ cancelled: '已停止',
+ interrupted: '服务重启,起草中断',
+ } as Record,
+ interrupted: '服务重启时这次起草还没有完成,没写完的表未起草。已写进目录的内容保留;可以只重试这些表,接着起草',
+ cancelled: '已写进目录的内容保留,没写完的表未起草',
+ jobFailed: (reason: string) => `起草任务出错:${reason}`,
+ /** 没写全的表:失败、部分完成、已停止各几张;跳过的(表结构里没有)另说 */
+ 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: '只看未写全的表',
+ tasks: '逐表进度',
+ taskStatus: {
+ pending: '等待', running: '起草中', ok: '已完成', partial: '部分完成', failed: '失败', skipped: '已跳过', cancelled: '已停止',
+ } as Record,
+ 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)} 张表未写全`,
+ failed: (reason: string) => `数据目录起草失败:${reason}`,
+ cancelled: '数据目录起草已停止',
+ interrupted: '服务重启,数据目录起草中断',
+ },
+ viewResult: '查看结果',
+ // ---- 清单上的标记、表详情里的说明
+ lastFailed: '上次起草失败',
+ lastPartial: '上次起草部分完成',
+ lastWith: (label: string, reason: string) => `${label}:${reason}`,
+ redraft: '重新起草这张表',
+ redraftHint: '用模型重新起草这张表(不带补充说明,带上数据源的起草要求);人工确认、驳回过的项不会被改动',
+ redraftBusy: '已有起草任务在进行,结束后再重新起草这张表',
+}
+
// ===========================================================================
// 目录修改提案:用户在助手(画布)或问数据页说出一条数据事实时,助手提出的数据目录修改。
//
diff --git a/frontend/src/pages/catalog/CatalogPage.tsx b/frontend/src/pages/catalog/CatalogPage.tsx
index 48442f3a..15f8e24e 100644
--- a/frontend/src/pages/catalog/CatalogPage.tsx
+++ b/frontend/src/pages/catalog/CatalogPage.tsx
@@ -7,9 +7,13 @@ 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, retryableCount, useDraftJob } from './draftJob'
+import type { 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>(() => new Set())
- const [drafting, setDrafting] = useState(false)
+ /**
+ * 起草对话框开着没有、打开时显示什么:setup 选范围开始;job 显示页面跟着的任务(进行中的进度、刚结束的结果)。
+ * busy:是撞上已有任务(409)转过去显示的
+ */
+ const [draftOpen, setDraftOpen] = useState<{ view: 'setup' | 'job'; busy?: boolean } | null>(null)
/** 起草写进了这些表:详情据此重新载入(编辑中的不动,保存时服务端会用版本号拦下) */
const [drafted, setDrafted] = useState<{ seq: number; tables: Set }>({ seq: 0, tables: new Set() })
const [profiling, setProfiling] = useState(false)
@@ -142,11 +150,59 @@ 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' }) } },
+ }
+ if (job.status === 'succeeded') toast.ok(DT.toast.succeeded(CT.draftSummary(job.added, job.updated, job.removed)), opts)
+ else if (job.status === 'partial') toast.warn(DT.toast.partial(retryableCount(job)), 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 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 options = savedOptions?.sourceId === sourceId ? savedOptions.options : source?.options
const profileSettings = useMemo(() => profileSettingsOf(options), [options])
@@ -262,7 +318,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) => (
-
)}
{running && {DT.hideHint}
}
- {job.status === 'failed' && job.error && {DT.jobFailed(job.error)}}
+ {job.status === 'failed' && job.error && {job.error}}
{job.status === 'interrupted' && {DT.interrupted}}
{job.status === 'cancelled' && {DT.cancelled}
}
{problemText && (
@@ -376,7 +384,7 @@ function JobView({ job, running, stopping, busy }: { job: CatalogDraftJob; runni
- {tasks.map((t) => )}
+ {tasks.map((t) => )}
@@ -393,9 +401,13 @@ const TASK_ICON: Record 1 && t.status !== 'pending' && t.status !== 'skipped'
const changes = DT.taskAdded(t.added, t.updated)
return (
diff --git a/frontend/src/pages/catalog/draftJob.ts b/frontend/src/pages/catalog/draftJob.ts
index 54a9c36e..eaa2c5b4 100644
--- a/frontend/src/pages/catalog/draftJob.ts
+++ b/frontend/src/pages/catalog/draftJob.ts
@@ -152,7 +152,10 @@ export function useDraftJob(sourceId: string, active: CatalogActiveDraftJob | nu
}
}, [accept])
- /** 只重试失败的表:同 start,409 已有任务时改去显示进行中的那一个 */
+ /**
+ * 只重试失败的表:同 start,409 已有任务时改去显示进行中的那一个;起草模型用不了(409)、没有可重试的表(422)等
+ * 照原样抛给调用方
+ */
const retry = useCallback(async (from: CatalogDraftJob): Promise<{ job: CatalogDraftJob; busy: boolean }> => {
const src = sourceRef.current
try {
diff --git a/scripts/check-catalog.mjs b/scripts/check-catalog.mjs
index afdba716..e90d0fc5 100644
--- a/scripts/check-catalog.mjs
+++ b/scripts/check-catalog.mjs
@@ -407,9 +407,18 @@ const JOB_ACTIVE = new Set(['queued', 'running'])
const TASK_END = new Set(['ok', 'partial', 'failed', 'skipped', 'cancelled'])
const RETRYABLE = new Set(['failed', 'partial', 'cancelled'])
const isoNow = () => new Date().toISOString()
-/** 服务端的两种 409(机读码 + 一句中文) */
+/**
+ * 服务端的几种 409(机读码 + 一句中文)。起草模型用不了有长短两句:没有接入时短(界面原样显示),设置里的起草模型所在
+ * 接入停用时长(带句号,也要原样显示)
+ */
const BUSY = { detail: '这个数据源已有进行中的起草任务,请等它结束或停止后再开始', code: 'catalog_draft_busy' }
const MODEL_DOWN = { detail: '未配置模型接入,无法由助手起草数据目录。请到「设置 → 模型接入」添加', code: 'catalog_draft_model_unavailable' }
+const MODEL_OFF = {
+ detail: '起草模型使用的模型接入「check-gw」已停用。请在「设置 → 模型接入」中启用它,或在「设置 → 偏好设置」的「起草模型」中重新选择',
+ code: 'catalog_draft_model_unavailable',
+}
+/** 任务整体出错时服务端写的原因(catalog_jobs.JOB_ERROR:已经带着「起草任务出错:」开头) */
+const JOB_ERR = '起草任务出错:database is locked'
/** 起草时每段最多几列(服务端的常量):超过的宽表拆成「表级一段 + 列分段」 */
const PART_COLUMNS = 40
@@ -483,15 +492,28 @@ function finishJob(job, status) {
job.started_at ??= job.finished_at
}
-/** 停下:没开始和没写完的表记 cancelled,已经写进目录的不动 */
+/**
+ * 停下:没开始和没写完的表记 cancelled,已经写进目录的不动。带原因的(服务重启中断)任务和表记同一句(服务端 _close 的
+ * 做法);使用者点的停止不带原因
+ */
function stopJob(job, status = 'cancelled', error = null) {
for (const t of job.tasks) {
if (t.status === 'pending' || t.status === 'running') Object.assign(t, { status: 'cancelled', error, finished_at: isoNow() })
}
+ if (error) job.error = error
delete job.stopAsked
finishJob(job, status)
}
+/** 任务整体出错(数据源没了、开始时起草模型已经用不了、意外出错):没结束的表也记失败,原因同任务的(服务端 _end 的做法) */
+function failJob(job, error) {
+ for (const t of job.tasks) {
+ if (t.status === 'pending' || t.status === 'running') Object.assign(t, { status: 'failed', error, finished_at: isoNow() })
+ }
+ job.error = error
+ finishJob(job, 'failed')
+}
+
function lastDraftOf(state, src, name) {
for (const job of [...state.jobs].reverse()) {
if (job.source_id !== src) continue
@@ -543,7 +565,7 @@ function jobHandlers(state, reply) {
else job.stopAsked = true
return json({ job: jobOut(job) })(route)
})],
- // 重试:新任务只含失败、部分完成、停止的表,沿用用模型和补充说明
+ // 重试:新任务只含失败、部分完成、停止的表,沿用用模型和补充说明。起草模型用不了的 409 由检查用 replies.retry 给
[/^POST \/datasources\/check-cat-[a-z]+\/catalog\/draft-jobs\/[^/]+\/retry$/, reply('retry', (route, { key }) => {
const old = jobOf(state, key)
if (!old) return NO_JOB(route)
@@ -931,7 +953,7 @@ await section('数据目录 · 起草:设置与开始', async () => {
// 起草模型用不了:409 带机读码,留在设置阶段写明原因,不进入进行中、不去取任务
replies.createJob.push(json(MODEL_DOWN, 409))
await page.locator('[data-draft-start]').click()
- check('起草模型用不了(409):留在设置阶段,写明原因', await until(async () => (await draftText(page, '[data-draft-start-error]')).includes('未配置模型接入'))
+ check('起草模型用不了(409):留在设置阶段,照原样写服务端那句话', await until(async () => (await draftText(page, '[data-draft-start-error]')) === MODEL_DOWN.detail)
&& (await draftPhase(page)) === 'setup', await draftText(page, '[data-draft-start-error]'))
const keys = writes(sent, /./).map((s) => s.key.replace(/^(\w+) .*\/catalog\//, '$1 '))
check('……起草要求改过:先存起草要求、再建任务', keys.join(',') === 'PUT guidance,POST draft-jobs', keys.join(','))
@@ -961,7 +983,8 @@ await section('数据目录 · 起草:设置与开始', async () => {
await section('数据目录 · 起草:进度、结果与重试', async () => {
const state = freshState()
- const { page, sent, errors, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
+ const replies = { retry: [] }
+ const { page, sent, errors, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state, replies) })
await page.locator('[data-catalog-row]').first().waitFor()
check('开始起草(闸机到会员 3 张,用模型,带补充说明)', await startDraft(page, { select: ['gates', 'members'], model: true, note: DRAFT_NOTE }))
const job = state.jobs.at(-1)
@@ -1010,9 +1033,17 @@ await section('数据目录 · 起草:进度、结果与重试', async () => {
// 只重试失败的表
const retry = page.locator('[data-draft-retry]')
check('给「只重试失败的表(2 张)」', (await retry.innerText().catch(() => '')).includes('只重试失败的表(2 张)'), await retry.innerText().catch(() => ''))
+ // 重试时起草模型已经用不了(服务端和建任务时一样回 409 catalog_draft_model_unavailable):留在结果里,照原样写服务端
+ // 那句话(长句也不拆成「标题:原因」),不建新任务
+ replies.retry.push(json(MODEL_OFF, 409))
+ await retry.click()
+ check('重试时起草模型用不了(409):留在结果里,照原样写服务端那句话,不建新任务',
+ await until(async () => (await draftText(page, '[data-draft-action-error]')) === MODEL_OFF.detail)
+ && (await draftPhase(page)) === 'done' && state.jobs.at(-1) === job, await draftText(page, '[data-draft-action-error]'))
await retry.click()
check('重试:调重试接口,进入新任务的进行中,只含没写全的 2 张', await until(async () => (await draftPhase(page)) === 'running'
- && (await draftBox(page).locator('[data-draft-task]').count()) === 2) && count(sent, new RegExp(`^POST .*draft-jobs/${job.id}/retry$`)) === 1)
+ && (await draftBox(page).locator('[data-draft-task]').count()) === 2) && count(sent, new RegExp(`^POST .*draft-jobs/${job.id}/retry$`)) === 2)
+ check('……上一次的报错收起', await draftBox(page).locator('[data-draft-action-error]').count() === 0)
const job2 = state.jobs.at(-1)
check('……新任务沿用用模型和补充说明', job2.id !== job.id && job2.use_model && job2.note === DRAFT_NOTE)
settle(state, job2, 'store_sales', { added: 1, model_items: 3 })
@@ -1082,9 +1113,11 @@ await section('数据目录 · 起草:停止、后台继续与再打开', asyn
await strip.locator('[data-catalog-draft-open]').click()
check('点开:回到对话框,直接显示进行中的任务', await until(async () => (await draftPhase(page)) === 'running')
&& (await draftText(page, '[data-draft-progress]')) === '已完成 2 / 20 张表', await draftText(page, '[data-draft-progress]'))
- // 停止:调停止接口;服务端停下正在进行的调用之前写「正在停止」
+ // 停止:调停止接口;服务端马上取消进行中的模型调用,只等手上那张表写完(停止接口等它收尾才答)。这期间写「正在停止」,
+ // 说法照服务端的做法:不是「等当前这次调用结束」——一次调用可能要几分钟
await page.locator('[data-draft-stop]').click()
- check('点「停止」:调停止接口,写「正在停止」,停止按钮不能再点', await until(async () => (await draftText(page, '[data-draft-title]')).startsWith('正在停止'))
+ check('点「停止」:调停止接口,写「正在停止:取消进行中的模型调用…」,停止按钮不能再点',
+ await until(async () => (await draftText(page, '[data-draft-title]')).startsWith('正在停止:取消进行中的模型调用'))
&& count(sent, new RegExp(`^POST .*draft-jobs/${job.id}/cancel$`)) === 1 && await page.locator('[data-draft-stop]').isDisabled(),
await draftText(page, '[data-draft-title]'))
stopJob(job)
@@ -1128,7 +1161,7 @@ await section('数据目录 · 起草:停止、后台继续与再打开', asyn
await close()
})
-await section('数据目录 · 起草:已有任务(409)与服务重启中断', async () => {
+await section('数据目录 · 起草:已有任务(409)、服务重启中断与整体出错', async () => {
const state = freshState()
// 上一次起草因服务重启中断:没结束的表记已停止,写明原因
const old = newJob(state, S1, { tables: ['visits', 'orders', 'parks'], use_model: true, note: DRAFT_NOTE })
@@ -1144,7 +1177,10 @@ await section('数据目录 · 起草:已有任务(409)与服务重启中
check('……点开:结果写明服务重启中断,可以接着重试', await until(async () => (await draftPhase(page)) === 'done')
&& (await draftText(page, '[data-draft-title]')) === '服务重启,起草中断' && (await draftText(page, '[data-draft-interrupted]')).includes('重试'),
`${await draftText(page, '[data-draft-title]')} · ${await draftText(page, '[data-draft-interrupted]')}`)
- check('……没写的 2 张写原因', (await draftText(page, '[data-draft-task="orders"] [data-draft-task-reason]')).includes('服务重启,起草中断'))
+ // 服务端把任务和没写完的表都记「服务重启,起草中断」:标题、提示已经写了,逐表只写「已停止」,不再各写一遍
+ check('……没写的 2 张记「已停止」,原因不再逐张重复', (await draftText(page, '[data-draft-task="orders"] [data-draft-task-status]')) === '已停止'
+ && (await draftText(page, '[data-draft-task="parks"] [data-draft-task-status]')) === '已停止'
+ && await draftBox(page).locator('[data-draft-task-reason]').count() === 0, await draftText(page, '[data-draft-tasks]'))
await shot(page, 'catalog-draft-interrupted')
await page.locator('[data-draft-retry]').click()
check('……「只重试失败的表」接着起草没写的 2 张', await until(async () => (await draftPhase(page)) === 'running')
@@ -1156,6 +1192,23 @@ await section('数据目录 · 起草:已有任务(409)与服务重启中
await until(async () => (await draftPhase(page)) === 'done')
await page.locator('[data-draft-close]').click()
+ // 任务整体出错(意外出错、开始时起草模型已经用不了……):服务端把没结束的表也记失败、原因同任务的。原因只在标题下面
+ // 写一次,照服务端的原话(它已经带着「起草任务出错:」开头,界面不再套一层);逐表不再重复同一句;这些表都能重试
+ check('开始起草(使用次数最多的 20 张,用模型)', await startDraft(page, { model: true }))
+ const broken = state.jobs.at(-1)
+ settle(state, broken, 'visits', { added: 2, model_items: 4 })
+ settle(state, broken, 'orders', { status: 'failed', model_items: 0, model_error: CALL_ERR })
+ failJob(broken, JOB_ERR)
+ check('任务整体出错:标题写「起草失败」,原因照服务端的原话写一次', await until(async () => (await draftPhase(page)) === 'done')
+ && (await draftText(page, '[data-draft-title]')) === '起草失败' && (await draftText(page, '[data-draft-job-error]')) === JOB_ERR,
+ `${await draftText(page, '[data-draft-title]')} · ${await draftText(page, '[data-draft-job-error]')}`)
+ check('……逐表只列没写的 19 张;和任务相同的原因不再逐张重复,各有原因的照样写', (await draftBox(page).locator('[data-draft-task]').count()) === 19
+ && await draftBox(page).locator('[data-draft-task-reason]').count() === 1
+ && (await draftText(page, '[data-draft-task="orders"] [data-draft-task-reason]')) === CALL_ERR, await draftText(page, '[data-draft-tasks]'))
+ check('……这 19 张都能重试', (await page.locator('[data-draft-retry]').innerText().catch(() => '')).includes('(19 张)'))
+ await shot(page, 'catalog-draft-failed')
+ await page.locator('[data-draft-close]').click()
+
// 别处(另一个标签页)又起了一个任务,这一页还不知道:点开始时服务端回 409,转去显示那一个
await page.locator('[data-catalog-draft]').first().click()
await draftBox(page).waitFor()
From 0dbfeffb23af3b3ca1cd64ed9eef75e47835b175 Mon Sep 17 00:00:00 2001
From: ThearchyHelios
Date: Tue, 13 Oct 2026 16:34:46 +0800
Subject: [PATCH 11/29] =?UTF-8?q?check-catalog=EF=BC=9A409=20=E4=B9=8B?=
=?UTF-8?q?=E5=90=8E=E3=80=8C=E5=8F=96=E7=9A=84=E6=98=AF=E8=BF=9B=E8=A1=8C?=
=?UTF-8?q?=E4=B8=AD=E7=9A=84=E9=82=A3=E4=B8=80=E4=B8=AA=E3=80=8D=E5=8E=9F?=
=?UTF-8?q?=E6=9D=A5=E6=81=92=E4=B8=BA=E7=9C=9F=EF=BC=88=E8=AE=A1=E6=95=B0?=
=?UTF-8?q?=20>=3D=200=EF=BC=89=EF=BC=8C=E6=94=B9=E6=88=90=E7=AD=89?=
=?UTF-8?q?=E5=88=B0=E7=A1=AE=E5=AE=9E=E5=9C=A8=E5=8F=96=E9=82=A3=E4=B8=AA?=
=?UTF-8?q?=E4=BB=BB=E5=8A=A1=E7=9A=84=E8=BF=9B=E5=BA=A6=EF=BC=9B=E8=A1=A5?=
=?UTF-8?q?=E3=80=8C=E4=BB=BB=E5=8A=A1=E8=BF=9B=E8=A1=8C=E4=B8=AD=E7=A6=BB?=
=?UTF-8?q?=E5=BC=80=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95=E9=A1=B5=EF=BC=88?=
=?UTF-8?q?=E9=A1=B5=E5=86=85=E8=B7=B3=E8=BD=AC=EF=BC=8C=E9=A1=B5=E9=9D=A2?=
=?UTF-8?q?=E5=8D=B8=E4=B8=8B=EF=BC=89=E4=BB=A5=E5=90=8E=E4=B8=8D=E5=86=8D?=
=?UTF-8?q?=E5=8F=96=E8=BF=9B=E5=BA=A6=E3=80=8D=EF=BC=8C=E4=B9=8B=E5=89=8D?=
=?UTF-8?q?=E5=8F=AA=E6=9F=A5=E4=BA=86=E7=BB=93=E6=9D=9F=E4=BB=A5=E5=90=8E?=
=?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=8F=96=EF=BC=9B=E8=AE=BE=E7=BD=AE=E9=98=B6?=
=?UTF-8?q?=E6=AE=B5=E5=85=88=E7=AD=89=E4=B8=8A=E4=B8=80=E6=AC=A1=E8=B5=B7?=
=?UTF-8?q?=E8=8D=89=E5=8F=96=E5=9B=9E=E6=9D=A5=E5=86=8D=E8=AE=A9=E5=88=AB?=
=?UTF-8?q?=E5=A4=84=E5=BB=BA=E4=BB=BB=E5=8A=A1=EF=BC=8C=E5=85=8D=E5=BE=97?=
=?UTF-8?q?=E5=AE=83=E6=99=9A=E5=88=B0=E3=80=81=E5=8F=96=E5=9B=9E=E7=9A=84?=
=?UTF-8?q?=E5=B7=B2=E7=BB=8F=E6=98=AF=E8=BF=9B=E8=A1=8C=E4=B8=AD=E7=9A=84?=
=?UTF-8?q?=E4=BB=BB=E5=8A=A1=E8=80=8C=E8=B5=B0=E4=B8=8D=E5=88=B0=20409?=
=?UTF-8?q?=E3=80=82=E5=81=87=E6=9C=8D=E5=8A=A1=E7=AB=AF=E7=9A=84=E8=AF=B4?=
=?UTF-8?q?=E6=B3=95=E7=85=A7=E5=90=8E=E7=AB=AF=E7=9A=84=E5=8E=9F=E8=AF=9D?=
=?UTF-8?q?=EF=BC=9A=E8=B5=B7=E8=8D=89=E6=A8=A1=E5=9E=8B=E7=94=A8=E4=B8=8D?=
=?UTF-8?q?=E4=BA=86=E6=98=AF=E3=80=8C=E6=97=A0=E6=B3=95=E7=94=B1=E6=A8=A1?=
=?UTF-8?q?=E5=9E=8B=E8=B5=B7=E8=8D=89=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95?=
=?UTF-8?q?=E3=80=8D=EF=BC=88=E5=8E=9F=E6=9D=A5=E8=BF=98=E6=98=AF=E3=80=8C?=
=?UTF-8?q?=E7=94=B1=E5=8A=A9=E6=89=8B=E3=80=8D=EF=BC=89=EF=BC=8C=E5=B7=B2?=
=?UTF-8?q?=E6=9C=89=E4=BB=BB=E5=8A=A1=E3=80=81=E4=BB=BB=E5=8A=A1=E4=B8=8D?=
=?UTF-8?q?=E5=AD=98=E5=9C=A8=E3=80=81=E5=B7=B2=E7=BB=8F=E7=BB=93=E6=9D=9F?=
=?UTF-8?q?=E3=80=81=E6=B2=A1=E6=9C=89=E5=8F=AF=E9=87=8D=E8=AF=95=E7=9A=84?=
=?UTF-8?q?=E8=A1=A8=E3=80=81=E8=B5=B7=E8=8D=89=E8=A6=81=E6=B1=82=E8=B6=85?=
=?UTF-8?q?=E9=95=BF=E5=90=8C=E6=A0=B7=E5=AF=B9=E9=BD=90=EF=BC=9B=E8=B5=B7?=
=?UTF-8?q?=E8=8D=89=E8=A6=81=E6=B1=82=E7=9A=84=E8=AF=B7=E6=B1=82=E6=B2=A1?=
=?UTF-8?q?=E6=9C=89=20guidance=20=E8=BF=99=E4=B8=80=E9=A1=B9=E6=97=B6?=
=?UTF-8?q?=E5=90=8C=E5=90=8E=E7=AB=AF=E5=9B=9E=20422=EF=BC=9B=E9=87=8D?=
=?UTF-8?q?=E8=AF=95=E5=BB=BA=E7=9A=84=E4=BB=BB=E5=8A=A1=E5=B8=A6=20retry?=
=?UTF-8?q?=5Fof=E3=80=82types.ts=20=E7=9A=84=20CatalogDraftJob=20?=
=?UTF-8?q?=E5=8A=A0=E5=8F=AF=E9=80=89=E7=9A=84=20retry=5Fof=EF=BC=88?=
=?UTF-8?q?=E7=BA=A6=E5=AE=9A=E9=87=8C=E6=B2=A1=E6=9C=89=E3=80=81=E5=90=8E?=
=?UTF-8?q?=E7=AB=AF=E5=A4=9A=E7=BB=99=E7=9A=84=EF=BC=8C=E7=95=8C=E9=9D=A2?=
=?UTF-8?q?=E4=B8=8D=E7=94=A8=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/src/types.ts | 2 ++
scripts/check-catalog.mjs | 63 ++++++++++++++++++++++++++-------------
2 files changed, 45 insertions(+), 20 deletions(-)
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 24774075..ef13a6d2 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -2918,6 +2918,8 @@ export interface CatalogDraftJob {
finished_at: string | null
/** 谁建的(X-Actor) */
created_by: string | null
+ /** 重试建的任务记着原任务的 id,别的为 null。接口约定里没有这一项,服务端多给的;界面不用,老服务端没有这个键 */
+ retry_of?: string | null
/** 表数 */
total: number
/** 已结束的表数 */
diff --git a/scripts/check-catalog.mjs b/scripts/check-catalog.mjs
index e90d0fc5..dc8dde8b 100644
--- a/scripts/check-catalog.mjs
+++ b/scripts/check-catalog.mjs
@@ -408,11 +408,14 @@ const TASK_END = new Set(['ok', 'partial', 'failed', 'skipped', 'cancelled'])
const RETRYABLE = new Set(['failed', 'partial', 'cancelled'])
const isoNow = () => new Date().toISOString()
/**
- * 服务端的几种 409(机读码 + 一句中文)。起草模型用不了有长短两句:没有接入时短(界面原样显示),设置里的起草模型所在
- * 接入停用时长(带句号,也要原样显示)
+ * 服务端的几种答复(机读码 + 一句中文),说法照 api/catalog.py、data/catalog_jobs.py、data/draft_model.py 的原话。
+ * 起草模型用不了有长短两句:没有接入时短(界面原样显示),设置里的起草模型所在接入停用时长(带句号,也要原样显示)
*/
-const BUSY = { detail: '这个数据源已有进行中的起草任务,请等它结束或停止后再开始', code: 'catalog_draft_busy' }
-const MODEL_DOWN = { detail: '未配置模型接入,无法由助手起草数据目录。请到「设置 → 模型接入」添加', code: 'catalog_draft_model_unavailable' }
+const busyOf = (state, src) => ({
+ detail: `数据源「${state.sources.find((s) => s.id === src)?.name ?? src}」正在起草数据目录,请等这次起草结束(或先停止它)再开始新的起草`,
+ code: 'catalog_draft_busy',
+})
+const MODEL_DOWN = { detail: '未配置模型接入,无法由模型起草数据目录。请到「设置 → 模型接入」添加', code: 'catalog_draft_model_unavailable' }
const MODEL_OFF = {
detail: '起草模型使用的模型接入「check-gw」已停用。请在「设置 → 模型接入」中启用它,或在「设置 → 偏好设置」的「起草模型」中重新选择',
code: 'catalog_draft_model_unavailable',
@@ -422,8 +425,8 @@ const JOB_ERR = '起草任务出错:database is locked'
/** 起草时每段最多几列(服务端的常量):超过的宽表拆成「表级一段 + 列分段」 */
const PART_COLUMNS = 40
-/** 建一个排队的任务:表结构里没有的表照样建进任务、记 skipped */
-function newJob(state, src, { tables, use_model, note = null }) {
+/** 建一个排队的任务:表结构里没有的表照样建进任务、记 skipped。重试建的记着原任务(retry_of,界面不用) */
+function newJob(state, src, { tables, use_model, note = null, retry_of = null }) {
const id = `check-job-${++state.jobSeq}`
const tasks = tables.map((name) => {
const t = state.tables[src].find((x) => x.name === name && !x.missing)
@@ -439,7 +442,7 @@ function newJob(state, src, { tables, use_model, note = null }) {
const job = {
id, source_id: src, status: 'queued', use_model: !!use_model, model: use_model ? 'check-draft-model' : null,
guidance: state.guidance[src] ?? null, note, created_at: isoNow(), started_at: null, finished_at: null, created_by: ACTOR,
- usage: { calls: 0, input_tokens: 0, output_tokens: 0 }, error: null, tasks,
+ retry_of, usage: { calls: 0, input_tokens: 0, output_tokens: 0 }, error: null, tasks,
}
state.jobs.push(job)
return job
@@ -530,19 +533,22 @@ const jobOf = (state, key) => {
const activeOf = (state, src) => state.jobs.find((j) => j.source_id === src && JOB_ACTIVE.has(j.status)) ?? null
function jobHandlers(state, reply) {
- const NO_JOB = json({ detail: '找不到这次起草任务' }, 404)
+ const NO_JOB = json({ detail: '起草任务不存在,可能属于别的数据源,或者已随数据源删除' }, 404)
return [
- // 起草要求(数据源级):空串清除,超长 422
+ // 起草要求(数据源级):空串清除,超长 422。请求里必须有 guidance 这一项(服务端同样:没有就是 pydantic 的英文 422)
[/^PUT \/datasources\/check-cat-[a-z]+\/catalog\/guidance$/, reply('guidance', (route, { key, body }) => {
- const g = String(body?.guidance ?? '').trim()
- if ([...g].length > 2000) return json({ detail: '起草要求不能超过 2,000 字' }, 422)(route)
+ if (!body || typeof body !== 'object' || !('guidance' in body)) {
+ return json({ detail: [{ type: 'missing', loc: ['guidance'], msg: 'Field required', input: body }] }, 422)(route)
+ }
+ const g = String(body.guidance ?? '').trim()
+ if ([...g].length > 2000) return json({ detail: `起草要求最多 2000 字,当前 ${[...g].length} 字,请删减后再保存` }, 422)(route)
state.guidance[parts(key)[2]] = g || null
return json({ guidance: g || null })(route)
})],
// 建任务:同一个源已有排队、进行中的 409;不给 tables 取使用次数前 20 张
[/^POST \/datasources\/check-cat-[a-z]+\/catalog\/draft-jobs$/, reply('createJob', (route, { key, body }) => {
const src = parts(key)[2]
- if (activeOf(state, src)) return json(BUSY, 409)(route)
+ if (activeOf(state, src)) return json(busyOf(state, src), 409)(route)
const tables = body?.tables?.length ? body.tables
: state.tables[src].filter((t) => !t.missing).sort((a, b) => b.usage - a.usage).slice(0, 20).map((t) => t.name)
const job = newJob(state, src, { tables, use_model: !!body?.use_model, note: body?.note?.trim() || null })
@@ -560,7 +566,7 @@ function jobHandlers(state, reply) {
[/^POST \/datasources\/check-cat-[a-z]+\/catalog\/draft-jobs\/[^/]+\/cancel$/, reply('cancel', (route, { key }) => {
const job = jobOf(state, key)
if (!job) return NO_JOB(route)
- if (!JOB_ACTIVE.has(job.status)) return json({ detail: '这次起草已经结束', code: 'catalog_draft_finished' }, 409)(route)
+ if (!JOB_ACTIVE.has(job.status)) return json({ detail: '这次起草已经结束了,不需要停止', code: 'catalog_draft_finished' }, 409)(route)
if (job.status === 'queued') stopJob(job)
else job.stopAsked = true
return json({ job: jobOut(job) })(route)
@@ -569,10 +575,10 @@ function jobHandlers(state, reply) {
[/^POST \/datasources\/check-cat-[a-z]+\/catalog\/draft-jobs\/[^/]+\/retry$/, reply('retry', (route, { key }) => {
const old = jobOf(state, key)
if (!old) return NO_JOB(route)
- if (activeOf(state, old.source_id)) return json(BUSY, 409)(route)
+ if (activeOf(state, old.source_id)) return json(busyOf(state, old.source_id), 409)(route)
const tables = old.tasks.filter((t) => RETRYABLE.has(t.status)).map((t) => t.table_name)
- if (!tables.length) return json({ detail: '这次起草没有需要重试的表' }, 422)(route)
- const job = newJob(state, old.source_id, { tables, use_model: old.use_model, note: old.note })
+ if (!tables.length) return json({ detail: '这次起草里没有失败、只写出一部分或停止了的表,不需要重试' }, 422)(route)
+ const job = newJob(state, old.source_id, { tables, use_model: old.use_model, note: old.note, retry_of: old.id })
return json({ job: jobOut(job) }, 202)(route)
})],
[/^GET \/datasources\/check-cat-[a-z]+\/catalog\/draft-jobs\/[^/]+$/, (route, { key }) => {
@@ -1045,7 +1051,7 @@ await section('数据目录 · 起草:进度、结果与重试', async () => {
&& (await draftBox(page).locator('[data-draft-task]').count()) === 2) && count(sent, new RegExp(`^POST .*draft-jobs/${job.id}/retry$`)) === 2)
check('……上一次的报错收起', await draftBox(page).locator('[data-draft-action-error]').count() === 0)
const job2 = state.jobs.at(-1)
- check('……新任务沿用用模型和补充说明', job2.id !== job.id && job2.use_model && job2.note === DRAFT_NOTE)
+ check('……新任务沿用用模型和补充说明', job2.id !== job.id && job2.use_model && job2.note === DRAFT_NOTE && job2.retry_of === job.id)
settle(state, job2, 'store_sales', { added: 1, model_items: 3 })
settle(state, job2, 'members', { added: 5, model_items: 5 })
finishJob(job2)
@@ -1161,7 +1167,7 @@ await section('数据目录 · 起草:停止、后台继续与再打开', asyn
await close()
})
-await section('数据目录 · 起草:已有任务(409)、服务重启中断与整体出错', async () => {
+await section('数据目录 · 起草:已有任务(409)、服务重启中断、整体出错与离开页面', async () => {
const state = freshState()
// 上一次起草因服务重启中断:没结束的表记已停止,写明原因
const old = newJob(state, S1, { tables: ['visits', 'orders', 'parks'], use_model: true, note: DRAFT_NOTE })
@@ -1209,16 +1215,33 @@ await section('数据目录 · 起草:已有任务(409)、服务重启中
await shot(page, 'catalog-draft-failed')
await page.locator('[data-draft-close]').click()
- // 别处(另一个标签页)又起了一个任务,这一页还不知道:点开始时服务端回 409,转去显示那一个
+ // 别处(另一个标签页)又起了一个任务,这一页还不知道:点开始时服务端回 409,转去显示那一个。先等设置阶段把上一次起草
+ // 取回来(写出「上一次起草…起草失败」),免得它晚到、取回的已经是别处那个进行中的任务,就走不到 409 了
await page.locator('[data-catalog-draft]').first().click()
await draftBox(page).waitFor()
+ check('上一次整体出错:设置阶段写明「起草失败」', await until(async () => (await draftText(page, '[data-draft-last]')).includes('起草失败')),
+ await draftText(page, '[data-draft-last]'))
const other = newJob(state, S1, { tables: ['gates', 'members'], use_model: true })
settle(state, other, 'gates', { added: 1 })
+ const pollsOf = (id) => count(sent, new RegExp(`^GET .*draft-jobs/${id}$`))
await page.locator('[data-draft-start]').click()
check('已有进行中的任务(409):转去显示那一个,并写明', await until(async () => (await draftPhase(page)) === 'running')
&& (await draftText(page, '[data-draft-busy]')).includes('已有进行中的起草任务')
&& (await draftText(page, '[data-draft-progress]')) === '已完成 1 / 2 张表', `${await draftText(page, '[data-draft-busy]')} · ${await draftText(page, '[data-draft-progress]')}`)
- check('……取的是进行中的那一个', count(sent, /draft-jobs\/active$/) >= 1 && count(sent, new RegExp(`^GET .*draft-jobs/${other.id}$`)) >= 0)
+ check('……先问进行中的是哪一个,之后跟着它取进度', count(sent, /draft-jobs\/active$/) >= 1 && await until(async () => pollsOf(other.id) >= 1),
+ `active ${count(sent, /draft-jobs\/active$/)} 次,取进度 ${pollsOf(other.id)} 次`)
+
+ // 任务进行中离开数据目录页(页内跳转,页面卸下,不是重新载入):不再取进度。卸下以后稍停,让卸下前已经发出的那次
+ // 取进度落进记录(停得太久,没清掉的那一拍可能恰好落在这段里、数不出来),再数两个多间隔
+ await page.locator('[data-draft-hide]').click()
+ await until(async () => (await draftBox(page).count()) === 0)
+ await page.locator('[data-catalog-back]').click()
+ const left = await until(async () => new URL(page.url()).pathname === '/data/databases' && (await page.locator('[data-catalog-page]').count()) === 0)
+ await sleep(100)
+ const polled = pollsOf(other.id)
+ await sleep(3200)
+ check('任务进行中离开数据目录页:页面卸下以后不再取进度', left && pollsOf(other.id) === polled,
+ `${left ? '' : `没有离开(${new URL(page.url()).pathname});`}${polled} → ${pollsOf(other.id)}`)
stopJob(other)
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
From ec6838dc1091b16b7fbe89b71e2a68291e110357 Mon Sep 17 00:00:00 2001
From: ThearchyHelios
Date: Tue, 13 Oct 2026 16:40:07 +0800
Subject: [PATCH 12/29] =?UTF-8?q?=E8=B5=B7=E8=8D=89=E6=A8=A1=E5=9E=8B?=
=?UTF-8?q?=E7=9A=84=20SDK=20=E4=B8=8D=E5=86=8D=E8=87=AA=E5=B7=B1=E9=87=8D?=
=?UTF-8?q?=E8=AF=95=EF=BC=9AModelSpec=20=E5=8A=A0=20max=5Fretries?=
=?UTF-8?q?=EF=BC=8Cbuild=5Fchat=5Fmodel=20=E4=B8=80=E7=9B=B4=E4=BC=A0?=
=?UTF-8?q?=E5=88=B0=20SDK=20=E5=AE=A2=E6=88=B7=E7=AB=AF=EF=BC=88Bearer=20?=
=?UTF-8?q?=E9=89=B4=E6=9D=83=E6=8D=A2=E4=B8=8A=E7=9A=84=20Anthropic=20?=
=?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E4=B9=9F=E5=B8=A6=E4=B8=8A=EF=BC=89?=
=?UTF-8?q?=EF=BC=9B=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95=E8=B5=B7=E8=8D=89?=
=?UTF-8?q?=E5=BB=BA=E7=9A=84=E5=A4=A7=E3=80=81=E5=B0=8F=E4=B8=A4=E4=B8=AA?=
=?UTF-8?q?=E6=A8=A1=E5=9E=8B=E9=83=BD=E6=98=AF=20max=5Fretries=3D0?=
=?UTF-8?q?=EF=BC=8C429=E3=80=815xx=E3=80=81=E7=BD=91=E7=BB=9C=E9=94=99?=
=?UTF-8?q?=E8=AF=AF=E3=80=81=E8=B6=85=E6=97=B6=E5=8F=AA=E7=94=B1=E8=B5=B7?=
=?UTF-8?q?=E8=8D=89=E5=BC=95=E6=93=8E=E6=8C=89=E5=A4=B1=E8=B4=A5=E5=8E=9F?=
=?UTF-8?q?=E5=9B=A0=E9=87=8D=E8=AF=95=EF=BC=88=E6=9C=80=E5=A4=9A=202=20?=
=?UTF-8?q?=E6=AC=A1=EF=BC=89=E3=80=82=E4=BB=A5=E5=89=8D=20openai=E3=80=81?=
=?UTF-8?q?anthropic=20=E7=9A=84=20SDK=20=E5=90=84=E8=87=AA=E5=85=88?=
=?UTF-8?q?=E9=87=8D=E8=AF=95=202=20=E6=AC=A1=EF=BC=8C=E5=8F=A0=E5=9C=A8?=
=?UTF-8?q?=E5=BC=95=E6=93=8E=E7=9A=84=E9=87=8D=E8=AF=95=E4=B8=8A=EF=BC=8C?=
=?UTF-8?q?=E4=B8=80=E6=AE=B5=E6=9C=80=E5=A4=9A=E5=8F=91=209=20=E6=AC=A1?=
=?UTF-8?q?=E8=AF=B7=E6=B1=82=EF=BC=8C=E6=8A=A5=E9=94=99=E5=8D=B4=E5=86=99?=
=?UTF-8?q?=E3=80=8C=E5=B7=B2=E9=87=8D=E8=AF=95=202=20=E6=AC=A1=E3=80=8D?=
=?UTF-8?q?=EF=BC=8C=E7=BD=91=E5=85=B3=E9=99=90=E6=B5=81=E6=97=B6=E8=BF=98?=
=?UTF-8?q?=E6=94=BE=E5=A4=A7=E6=B5=81=E9=87=8F=E3=80=82=E4=B8=8D=E5=86=99?=
=?UTF-8?q?=20max=5Fretries=20=E7=9A=84=E8=B0=83=E7=94=A8=E7=85=A7=20SDK?=
=?UTF-8?q?=20=E9=BB=98=E8=AE=A4=EF=BC=8C=E9=85=8D=E6=96=B9=20AI=20?=
=?UTF-8?q?=E8=B5=B7=E8=8D=89=E4=B8=8D=E5=8A=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/data/catalog.py | 4 +++-
backend/app/data/catalog_draft.py | 3 ++-
backend/app/providers/factory.py | 8 ++++++-
backend/tests/test_catalog_draft_settings.py | 23 ++++++++++++++++++++
4 files changed, 35 insertions(+), 3 deletions(-)
diff --git a/backend/app/data/catalog.py b/backend/app/data/catalog.py
index 45eec1bb..058c8b16 100644
--- a/backend/app/data/catalog.py
+++ b/backend/app/data/catalog.py
@@ -1570,6 +1570,8 @@ async def resolve_draft_model(session: AsyncSession) -> tuple[Any, str]:
不关思考(spec 里不传 thinking,按接入默认)、温度 0、额度 MODEL_MAX_TOKENS,服务方嫌大时退回
MODEL_FALLBACK_MAX_TOKENS(DraftModel)。单次调用的时限 MODEL_TIMEOUT_S 也交给 SDK,免得它先按全局的 5 分钟断开。
+ SDK 自己不重试(max_retries=0):429、5xx、网络错误、超时由起草引擎按失败原因重试(最多 MODEL_RETRIES 次),
+ 不然两层叠起来一段最多发 9 次请求,报错却写「已重试 2 次」,网关正在限流时还会放大流量。
只建模型对象,不发请求。
和 recipe_ai.resolve_draft_model 一样不用 get_chat_model:它在没有接入时静默退回演示模型,拿演示模型
@@ -1585,7 +1587,7 @@ async def resolve_draft_model(session: AsyncSession) -> tuple[Any, str]:
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)
+ timeout=MODEL_TIMEOUT_S, max_retries=0)
try:
big = build_chat_model(provider, spec)
small = build_chat_model(provider, spec.model_copy(update={"max_tokens": MODEL_FALLBACK_MAX_TOKENS}))
diff --git a/backend/app/data/catalog_draft.py b/backend/app/data/catalog_draft.py
index 8833add9..4406c5c7 100644
--- a/backend/app/data/catalog_draft.py
+++ b/backend/app/data/catalog_draft.py
@@ -23,7 +23,8 @@
- 结构化输出这个接口不支持(400 / 422 提到 response_format、json_schema、tool……,或者模型类不支持):这次起草
后面改走纯文本、从正文取 JSON(纯文本那条路另外说清 JSON 的格式)。
- 服务方嫌额度太大:退回 MODEL_FALLBACK_MAX_TOKENS 重来,这次起草后面都用它(catalog.DraftModel.shrink)。
-- 网络错误、429、5xx、超时:等一会儿重试(最多 MODEL_RETRIES 次,退避)。单次调用的时限是 MODEL_TIMEOUT_S。
+- 网络错误、429、5xx、超时:等一会儿重试(最多 MODEL_RETRIES 次,退避)。单次调用的时限是 MODEL_TIMEOUT_S。SDK
+ 自己不再重试(catalog.resolve_draft_model 建模型时 max_retries=0),实际重试次数和报错写的一致。
- 别的错误:这一段没写出来,原因照实写。
**并发。** 全进程最多 MODEL_CONCURRENCY 个调用同时进行(model_gate:按事件循环各一个信号量,所有起草任务和同步
diff --git a/backend/app/providers/factory.py b/backend/app/providers/factory.py
index b94ae6d6..e7fbb29b 100644
--- a/backend/app/providers/factory.py
+++ b/backend/app/providers/factory.py
@@ -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
diff --git a/backend/tests/test_catalog_draft_settings.py b/backend/tests/test_catalog_draft_settings.py
index c4cbbec4..d36e73e1 100644
--- a/backend/tests/test_catalog_draft_settings.py
+++ b/backend/tests/test_catalog_draft_settings.py
@@ -196,9 +196,31 @@ async def test_catalog_draft_uses_the_draft_setting_keeps_thinking_and_a_big_bud
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)
@@ -213,6 +235,7 @@ async def test_recipe_ai_draft_follows_the_same_order_but_keeps_its_own_spec(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 c3105bba4f254bf72ab8db9e50fa869aa2b2b25a Mon Sep 17 00:00:00 2001
From: ThearchyHelios
Date: Tue, 13 Oct 2026 16:42:45 +0800
Subject: [PATCH 13/29] =?UTF-8?q?=E8=B5=B7=E8=8D=89=E5=BC=95=E6=93=8E?=
=?UTF-8?q?=E6=8C=89=E5=A4=B1=E8=B4=A5=E5=8E=9F=E5=9B=A0=E5=A4=84=E7=90=86?=
=?UTF-8?q?=E7=9A=84=E4=B8=89=E5=A4=84=E4=BF=AE=E6=AD=A3=EF=BC=9A=E7=BB=93?=
=?UTF-8?q?=E6=9E=84=E5=8C=96=E8=BE=93=E5=87=BA=E5=9B=9E=E4=BA=86=E4=B8=80?=
=?UTF-8?q?=E6=AE=B5=E8=AF=B4=E6=98=8E=E6=96=87=E5=AD=97=EF=BC=88=E4=B8=8D?=
=?UTF-8?q?=E6=98=AF=20JSON=E3=80=81=E4=B9=9F=E6=B2=A1=E5=81=9C=E5=9C=A8?=
=?UTF-8?q?=E9=A2=9D=E5=BA=A6=E4=B8=8A=EF=BC=8C=E4=BE=8B=E5=A6=82=E7=BD=91?=
=?UTF-8?q?=E5=85=B3=E6=8E=A5=E5=8F=97=E4=BA=86=20response=5Fformat=20?=
=?UTF-8?q?=E5=8D=B4=E6=B2=A1=E7=85=A7=E5=81=9A=EF=BC=89=E4=B8=8D=E5=86=8D?=
=?UTF-8?q?=E5=9B=A0=E4=B8=BA=E5=B8=A6=E7=9D=80=E6=80=9D=E8=80=83=E5=B0=B1?=
=?UTF-8?q?=E5=BD=93=E6=88=90=E9=A2=9D=E5=BA=A6=E7=94=A8=E5=AE=8C=E5=8E=BB?=
=?UTF-8?q?=E6=8B=86=EF=BC=8C=E4=B9=9F=E4=B8=8D=E7=9B=B4=E6=8E=A5=E5=88=A4?=
=?UTF-8?q?=E5=A4=B1=E8=B4=A5=EF=BC=8C=E8=BF=99=E6=AC=A1=E8=B5=B7=E8=8D=89?=
=?UTF-8?q?=E5=90=8E=E9=9D=A2=E6=94=B9=E8=B5=B0=E7=BA=AF=E6=96=87=E6=9C=AC?=
=?UTF-8?q?=E3=80=81=E8=BF=99=E4=B8=80=E6=AE=B5=E6=8D=A2=E7=BA=AF=E6=96=87?=
=?UTF-8?q?=E6=9C=AC=E9=97=AE=E4=B8=80=E6=AC=A1=EF=BC=88=E7=BA=AF=E6=96=87?=
=?UTF-8?q?=E6=9C=AC=E5=8F=A6=E5=B8=A6=E6=A0=BC=E5=BC=8F=E8=AF=B4=E6=98=8E?=
=?UTF-8?q?=EF=BC=89=EF=BC=9B=E5=8F=AA=E6=9C=89=E5=81=9C=E5=9C=A8=E9=A2=9D?=
=?UTF-8?q?=E5=BA=A6=E4=B8=8A=E3=80=81=E6=88=96=E8=80=85=E6=AD=A3=E6=96=87?=
=?UTF-8?q?=E4=B8=BA=E7=A9=BA=E8=80=8C=E6=9C=89=E6=80=9D=E8=80=83=E6=89=8D?=
=?UTF-8?q?=E7=AE=97=E9=A2=9D=E5=BA=A6=E7=94=A8=E5=AE=8C=E3=80=82=E8=A1=A5?=
=?UTF-8?q?=E9=97=AE=E4=B9=8B=E5=90=8E=E6=95=B4=E5=88=97=E6=B2=A1=E5=88=97?=
=?UTF-8?q?=E5=87=BA=E6=9D=A5=E7=9A=84=E5=88=97=E8=AE=B0=E6=B2=A1=E5=86=99?=
=?UTF-8?q?=E5=87=BA=E6=9D=A5=EF=BC=88=E3=80=8C=E6=A8=A1=E5=9E=8B=E7=9A=84?=
=?UTF-8?q?=E5=9B=9E=E5=A4=8D=E9=87=8C=E6=BC=8F=E4=BA=86=E8=BF=99=E4=BA=9B?=
=?UTF-8?q?=E5=88=97=EF=BC=8C=E8=A1=A5=E9=97=AE=E4=B8=80=E6=AC=A1=E4=BB=8D?=
=?UTF-8?q?=E6=B2=A1=E6=9C=89=E7=BB=99=E5=87=BA=E3=80=8D=EF=BC=89=EF=BC=8C?=
=?UTF-8?q?=E8=A1=A8=E6=98=AF=20partial=E3=80=81covered=20=E4=B8=8D?=
=?UTF-8?q?=E5=90=AB=20llm=EF=BC=8C=E4=BB=A5=E5=89=8D=E8=B5=B7=E8=8D=89?=
=?UTF-8?q?=E8=BF=87=E7=9A=84=E5=BE=85=E7=A1=AE=E8=AE=A4=E6=A8=A1=E5=9E=8B?=
=?UTF-8?q?=E9=A1=B9=E4=B8=8D=E5=86=8D=E8=A2=AB=E5=88=A0=EF=BC=88=E4=BB=A5?=
=?UTF-8?q?=E5=89=8D=E8=A1=A5=E9=97=AE=E5=8F=AA=E7=BB=99=E7=A9=BA=E5=A3=B3?=
=?UTF-8?q?=E4=B9=9F=E8=AE=B0=20ok=EF=BC=8C=E8=BF=98=E6=8A=8A=E8=BF=99?=
=?UTF-8?q?=E4=B8=80=E8=BD=AE=E6=B2=A1=E7=BB=99=E7=9A=84=E6=97=A7=E9=A1=B9?=
=?UTF-8?q?=E5=88=A0=E6=8E=89=EF=BC=89=EF=BC=9B=E5=88=97=E5=87=BA=E4=BA=86?=
=?UTF-8?q?=E3=80=81=E5=AD=97=E6=AE=B5=E7=95=99=E7=A9=BA=E7=9A=84=E7=85=A7?=
=?UTF-8?q?=E6=97=A7=E7=AE=97=E6=8B=BF=E4=B8=8D=E5=87=86=EF=BC=9B=E8=A1=A5?=
=?UTF-8?q?=E9=97=AE=E6=97=B6=E6=95=B4=E5=BC=A0=E8=A1=A8=E8=BF=98=E4=B8=80?=
=?UTF-8?q?=E9=A1=B9=E9=83=BD=E6=B2=A1=E6=9C=89=E7=9A=84=EF=BC=8C=E7=85=A7?=
=?UTF-8?q?=E5=8F=AA=E7=BB=99=E7=A9=BA=E5=A3=B3=E7=9A=84=E8=A7=84=E7=9F=A9?=
=?UTF-8?q?=E6=8B=86=E5=B0=8F=E3=80=81=E6=8D=A2=E7=BA=AF=E6=96=87=E6=9C=AC?=
=?UTF-8?q?=EF=BC=8C=E4=B8=8D=E7=9B=B4=E6=8E=A5=E8=AE=B0=E5=A4=B1=E8=B4=A5?=
=?UTF-8?q?=E3=80=82=E5=87=A0=E5=A4=84=E6=B2=A1=E5=86=99=E5=87=BA=E6=9D=A5?=
=?UTF-8?q?=E7=9A=84=E5=8E=9F=E5=9B=A0=E6=8C=89=E5=9F=BA=E7=A1=80=E5=8E=9F?=
=?UTF-8?q?=E5=9B=A0=E5=8E=BB=E9=87=8D=EF=BC=8C=E6=8B=86=E5=B0=8F=E9=87=8D?=
=?UTF-8?q?=E8=AF=95=E8=BF=87=E7=9A=84=E5=9C=A8=E6=9C=80=E5=90=8E=E8=A1=A5?=
=?UTF-8?q?=E4=B8=80=E5=8F=A5=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=87=BA=E7=8E=B0?=
=?UTF-8?q?=E5=90=8C=E4=B8=80=E5=8F=A5=E8=AF=B4=E4=B8=A4=E9=81=8D=E3=80=82?=
=?UTF-8?q?=E5=81=87=E6=A8=A1=E5=9E=8B=E7=85=A7=E6=8F=90=E7=A4=BA=E8=AF=8D?=
=?UTF-8?q?=E6=8A=8A=E8=AF=B7=E5=86=99=E7=9A=84=E5=88=97=E9=83=BD=E5=88=97?=
=?UTF-8?q?=E5=87=BA=E6=9D=A5=EF=BC=88=E5=AD=97=E6=AE=B5=E7=95=99=E7=A9=BA?=
=?UTF-8?q?=E6=98=AF=E6=8B=BF=E4=B8=8D=E5=87=86=EF=BC=89=EF=BC=8C=E8=A6=81?=
=?UTF-8?q?=E6=A8=A1=E6=8B=9F=E6=BC=8F=E5=88=97=E7=9A=84=E4=BC=A0=20list?=
=?UTF-8?q?=5Fall=3DFalse?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/data/catalog_draft.py | 115 +++++++++++++----
backend/tests/fixtures/catalog/fake_model.py | 39 +++++-
backend/tests/test_catalog_draft.py | 7 +-
backend/tests/test_catalog_draft_engine.py | 126 ++++++++++++++++++-
backend/tests/test_catalog_draft_jobs.py | 51 ++++++++
5 files changed, 307 insertions(+), 31 deletions(-)
diff --git a/backend/app/data/catalog_draft.py b/backend/app/data/catalog_draft.py
index 4406c5c7..2c08695c 100644
--- a/backend/app/data/catalog_draft.py
+++ b/backend/app/data/catalog_draft.py
@@ -18,8 +18,12 @@
两半再问(多表的段按表拆,单表的按列拆),最小 MODEL_SPLIT_MIN_COLUMNS 列,最多拆 MODEL_SPLIT_DEPTH 层。
拆不开的空壳(结构化输出给的)换纯文本问一次:有的网关的结构化输出只给空壳、纯文本却能写——写出来了,这次起草
后面都走纯文本。
+- 结构化输出回了一段说明文字(不是 JSON、也没停在额度上):网关接受了结构化输出的参数却没照做(例如开了丢弃不认识
+ 的参数),提示词里又没有格式说明,模型就写成了说明文字。这次起草后面改走纯文本,这一段换纯文本问一次。有思考
+ 不等于额度用完:只有停在额度上、或者正文为空而有思考才算。
- 被截断的 JSON:先把已经完整的表、列对象捡回来,只补剩下的。
-- 一段里缺了的表、列只补缺的,补一次。补过仍然没给的列算模型拿不准、留空,不算没写出来;整张表一项都没有才算。
+- 一段里缺了的表、列只补缺的,补一次。补问之后:模型列出了、字段留空的列是拿不准,不算没写出来;整列没列出来的
+ 算没写出来(这张表是 partial,旧的模型起草项不删);整张表还一项都没有的,照只给空壳处理(拆小、换纯文本)。
- 结构化输出这个接口不支持(400 / 422 提到 response_format、json_schema、tool……,或者模型类不支持):这次起草
后面改走纯文本、从正文取 JSON(纯文本那条路另外说清 JSON 的格式)。
- 服务方嫌额度太大:退回 MODEL_FALLBACK_MAX_TOKENS 重来,这次起草后面都用它(catalog.DraftModel.shrink)。
@@ -44,7 +48,7 @@ import json
import logging
import re
import weakref
-from collections.abc import Awaitable, Callable, Mapping, Sequence
+from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any
@@ -72,6 +76,7 @@ REASON_HOLLOW = "模型只给了空壳,没有写出可用的内容"
REASON_TRUNCATED = "模型的输出被截断,这一段没写完"
REASON_FORMAT = "模型的回复不是约定的格式"
REASON_MISSING = "模型的回复里没有这张表"
+REASON_UNANSWERED = "模型的回复里漏了这些列,补问一次仍没有给出"
REASON_TIMEOUT = "模型 {seconds} 秒内没有写完这一段"
REASON_CALL = "调用模型出错:{line}"
REASON_RETRIED = "(已重试 {n} 次)"
@@ -222,6 +227,28 @@ class _Reply:
exhausted: bool = False
#: 这一段最终没写出来的原因(重试用完、别的错误、不是约定的格式)
error: str | None = None
+ #: 结构化输出回了一段说明文字(error 是「不是约定的格式」):换纯文本还可能写出来
+ prose: bool = False
+
+
+@dataclass(frozen=True)
+class _Why:
+ """一部分没写出来的原因。base 给人看的原因;split 拆小重试过。"""
+
+ base: str
+ split: bool = False
+
+
+def _join(whys: Iterable[_Why]) -> str:
+ """几处没写出来的原因拼成一句:同样的原因只说一次(最多说两种),拆小重试过的在最后补一次(只看说出来的那几种)。
+ 不然宽表的表级那段拆不开、列分段拆过时,同一句原因会说两遍。"""
+ whys = list(whys)
+ shown = list(dict.fromkeys(w.base for w in whys))[:2]
+ told = [w for w in whys if w.base in shown]
+ text = ";".join(shown)
+ if any(w.split for w in told):
+ text += REASON_SPLIT
+ return text
class _Cut:
@@ -480,11 +507,13 @@ def _structured_reply(raw: Any, parsed: Any) -> _Reply:
reply = _parse_text(text, cut)
if reply is not None:
return reply
- if cut or _thought(raw):
- return _Reply(exhausted=True)
+ if cut or (not texts and _thought(raw)):
+ return _Reply(exhausted=True) # 停在额度上,或者正文为空而有思考(同 _text_reply)
if not texts:
return _Reply() # 什么都没写:按空壳处理
- return _Reply(error=REASON_FORMAT)
+ # 写了字却不是约定的格式、也没停在额度上:网关接受了结构化输出的参数却没照做,模型写成了说明文字。有没有思考都
+ # 一样,不是额度的问题;换纯文本(另带格式说明)还能写出来
+ return _Reply(error=REASON_FORMAT, prose=True)
def _text_reply(raw: Any) -> _Reply:
@@ -546,13 +575,17 @@ class _Table:
got_table: bool = False
got_columns: set[str] = field(default_factory=set)
#: 没写出来的列(表级的项记在 _TABLE)→ 原因
- failed: dict[str, str] = field(default_factory=dict)
+ failed: dict[str, _Why] = field(default_factory=dict)
parts_done: int = 0
finished: bool = False
def whole(self, piece: _Piece) -> bool:
return piece.table_level and len(piece.columns) == len(self.columns)
+ def has_any(self) -> bool:
+ """这张表已经拿到了模型起草的内容(表级的项或任何一列)。"""
+ return self.got_table or bool(self.got_columns)
+
def _restrict(notes: Mapping[str, Any], piece: _Piece) -> dict[str, Any]:
"""只留这一段请模型写的部分:没请写表级的项就不要表级的项,列只要请写的那几列。"""
@@ -665,10 +698,13 @@ class _Run:
last: bool = False) -> None:
"""问一段,拿到的并进各表;按失败原因拆开、补缺、记下没写出来的。
- depth:已经拆了几层(截断后只补剩下的也算一层)。followup:这一段是在补缺(补过就不再补,空着的算模型拿不准)。
- mode / last:拆不开的空壳换纯文本问的那一次(再空就记没写出来)。"""
+ depth:已经拆了几层(截断后只补剩下的也算一层)。followup:这一段是在补缺(补过就不再补,见 _after_followup)。
+ mode / last:换纯文本问的那一次——拆不开的空壳、结构化输出回了说明文字(再不行就记没写出来)。"""
reply = await self._ask(pieces, mode)
if reply.error is not None:
+ if reply.prose and not last:
+ await self._prose_to_text(pieces, depth, followup)
+ return
self._fail(pieces, reply.error)
return
gains = self._take(pieces, reply.entries)
@@ -689,19 +725,45 @@ class _Run:
else:
self._fail(rest, self._reason(kind, split=True))
return
- if not anything:
- if followup:
- return
+ if not anything and not followup:
if last:
self._fail(pieces, lambda p: self._reason("hollow", split=depth > 0, piece=p))
return
await self._split(pieces, depth, "hollow", followup)
return
- if followup:
- return
rest = self._remainder(pieces, gains, truncated=False)
- if rest:
- await self._segment(rest, depth, True)
+ if not rest:
+ return
+ if followup:
+ await self._after_followup(rest, depth, last=last)
+ return
+ await self._segment(rest, depth, True)
+
+ async def _after_followup(self, rest: list[_Piece], depth: int, *, last: bool) -> None:
+ """补问过仍然没给的部分(补过就不再补)。
+
+ - 这张表已经拿到了内容:没列出来的列记没写出来(这张表是 partial,旧的模型起草项不删)。模型列出了、字段留空
+ 的列是拿不准,不在这里(_remainder 只把没列出来的列交过来);
+ - 这张表到现在还一项都没有(回复里没有这张表、只给了空壳):照第一次只给空壳的规矩,拆小再问,拆不开换纯文本
+ 问一次。拆出来的半段是新的一段(还能补缺一次),层数照算。"""
+ given = [p for p in rest if self.tables[p.table].has_any()]
+ empty = [p for p in rest if not self.tables[p.table].has_any()]
+ if given:
+ self._fail(given, _Why(REASON_UNANSWERED))
+ if not empty:
+ return
+ if last:
+ self._fail(empty, lambda p: self._reason("hollow", split=depth > 0, piece=p))
+ return
+ await self._split(empty, depth, "hollow", False)
+
+ async def _prose_to_text(self, pieces: list[_Piece], depth: int, followup: bool) -> None:
+ """结构化输出回了一段说明文字:网关接受了结构化输出的参数却没照做。这次起草后面都走纯文本(另带格式说明),
+ 这一段换纯文本问一次。纯文本额度用完了还能照常拆小(拆出来的半段也走纯文本,不再先问一遍结构化输出)。"""
+ if self.mode != "text":
+ self.mode = "text"
+ logger.info("起草数据目录:结构化输出回了说明文字,后面改走纯文本")
+ await self._segment(pieces, depth, followup, mode="text", last=True)
async def _split(self, pieces: list[_Piece], depth: int, kind: str, followup: bool) -> None:
halves = _halves(pieces) if depth < int(C.MODEL_SPLIT_DEPTH) else None
@@ -724,8 +786,9 @@ class _Run:
self._fail(pieces, lambda p: self._reason(kind, split=depth > 0, piece=p))
def _remainder(self, pieces: list[_Piece], gains: list[_Gain], *, truncated: bool) -> list[_Piece]:
- """还没写出来的部分:回复里没有这张表、或者这张表一项可用内容都没有的整段重问;有内容的只问没给的列。
- 截断的回复里表级的项也没拿到的,表级的项一起重问(没截断时表级空着算模型拿不准)。"""
+ """还没写出来的部分:回复里没有这张表、或者这张表一项可用内容都没有的整段重问;有内容的只问没列出来的列
+ (列出了、字段留空的算模型拿不准,不再问)。截断的回复里表级的项也没拿到的,表级的项一起重问(没截断时表级
+ 空着算模型拿不准:这张表在回复里,表级的字段留空和列的字段留空一样)。"""
out: list[_Piece] = []
for piece, gain in zip(pieces, gains):
if not gain.named or not gain.items:
@@ -874,18 +937,20 @@ class _Run:
st.notes.setdefault(key, value)
st.got_table = True
- def _fail(self, pieces: list[_Piece], reason: str | Callable[[_Piece], str]) -> None:
+ def _fail(self, pieces: list[_Piece], reason: _Why | str | Callable[[_Piece], _Why]) -> None:
"""这几段没写出来:还没拿到内容的列(和表级的项)记下原因。"""
for piece in pieces:
st = self.tables[piece.table]
why = reason(piece) if callable(reason) else reason
+ if isinstance(why, str):
+ why = _Why(why)
for col in piece.columns:
if col not in st.got_columns:
st.failed.setdefault(col, why)
if piece.table_level and not st.got_table:
st.failed.setdefault(_TABLE, why)
- def _reason(self, kind: str, *, split: bool, piece: _Piece | None = None) -> str:
+ def _reason(self, kind: str, *, split: bool, piece: _Piece | None = None) -> _Why:
if kind == "exhausted":
base = REASON_EXHAUSTED.format(budget=self._budget())
elif kind == "truncated":
@@ -896,7 +961,7 @@ class _Run:
base = C.MODEL_EMPTY_REASON if st.named else REASON_MISSING
else:
base = REASON_HOLLOW
- return base + (REASON_SPLIT if split else "")
+ return _Why(base, split=split)
# ---------------------------------------------------------------- 收尾
@@ -906,8 +971,8 @@ class _Run:
failed_table = _TABLE in st.failed and not st.got_table
total = len(st.parts) or 1
if not items:
- reasons = list(dict.fromkeys(st.failed.values()))
- reason = ";".join(reasons[:2]) if reasons else (C.MODEL_EMPTY_REASON if st.named else REASON_MISSING)
+ whys = list(st.failed.values())
+ reason = _join(whys) if whys else (C.MODEL_EMPTY_REASON if st.named else REASON_MISSING)
return ModelOutcome("failed", {}, 0, reason, total, st.parts_done)
notes = copy.deepcopy(st.notes)
if failed_cols or failed_table:
@@ -919,7 +984,7 @@ class _Run:
def _partial_text(st: _Table, failed_cols: list[str], failed_table: bool) -> str:
"""只写出一部分时说清哪部分没写出来、为什么、怎么办。"""
keys = ([_TABLE] if failed_table else []) + failed_cols
- reason = ";".join(list(dict.fromkeys(st.failed[k] for k in keys))[:2])
+ reason = _join(st.failed[k] for k in keys)
n = len(st.columns)
if len(st.parts) > 1:
part_of = {c: p.part for p in st.parts for c in p.columns}
@@ -1056,5 +1121,5 @@ class _Run:
__all__ = ["BUDGET_WORDS", "Background", "Hooks", "ModelOutcome", "REASON_CALL", "REASON_EXHAUSTED", "REASON_FORMAT",
- "REASON_HOLLOW", "REASON_MISSING", "REASON_RETRIED", "REASON_SPLIT", "REASON_TIMEOUT", "REASON_TRUNCATED",
- "classify_failure", "model_gate", "parts_for", "run_model_draft"]
+ "REASON_HOLLOW", "REASON_MISSING", "REASON_RETRIED", "REASON_SPLIT", "REASON_TIMEOUT",
+ "REASON_TRUNCATED", "REASON_UNANSWERED", "classify_failure", "model_gate", "parts_for", "run_model_draft"]
diff --git a/backend/tests/fixtures/catalog/fake_model.py b/backend/tests/fixtures/catalog/fake_model.py
index 1ffd796d..1c4206f1 100644
--- a/backend/tests/fixtures/catalog/fake_model.py
+++ b/backend/tests/fixtures/catalog/fake_model.py
@@ -6,6 +6,8 @@
- 结构化输出(with_structured_output(...).ainvoke)从提示词里认出这一段请它写哪些表、每张表写哪部分(requested:
整张表、只写表级的项、只写列出的几列、表级的项加几列),返回 {"tables": [{"table": 表名, …}, …]},只给请它写的
那部分(整张表给 replies[表名] 的全部;只写几列时只给 replies 里这几列);
+- 照提示词的要求,请它写的每一列都列出来:replies 里没写的列只给列名({"name": 列名},字段留空,是拿不准),
+ 不整列漏掉。list_all=False 时只给 replies 里有的列(模拟漏列的模型:漏掉的列会被补问一次,补问后仍没给的算没写出来);
- 直接 ainvoke 是纯文本那条路:同样的内容写成一段正文(AIMessage),前面带一句话;
- fail 里的表所在的那一段抛异常,用来验证「一段失败只影响这一段」;
- calls 记下每次收到的提示词全文,用来核对交给模型的内容(只有结构,没有数据);modes 记下每次走的是
@@ -69,11 +71,44 @@ def answer(reply: dict, name: str, part: str, columns: list[str] | None) -> dict
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) -> None:
self.replies, self.fail, self.calls = replies, set(fail), []
self.structured, self.text = structured, text
+ self.list_all = list_all
self.modes: list[str] = []
def with_structured_output(self, schema: Any, **_: Any) -> "_Structured":
@@ -100,6 +135,8 @@ class FakeModel:
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 answers[0] if answers else {}
return {"tables": answers}
diff --git a/backend/tests/test_catalog_draft.py b/backend/tests/test_catalog_draft.py
index 85e98d74..f9b54aa8 100644
--- a/backend/tests/test_catalog_draft.py
+++ b/backend/tests/test_catalog_draft.py
@@ -275,11 +275,14 @@ async def test_model_single_table_object_without_wrapper_is_recognized():
async def test_hollow_structured_output_retries_with_text():
"""结构化输出只给了 {"tables":[{"table":"visits"}]} 这样的空壳、这一段又拆不开(不到 10 列):改走纯文本再问一次,
- 用纯文本给的内容。纯文本只给了表级的项、没给列:再补问一次没给的列(只问缺的,data/catalog_draft.py)。"""
- model = FakeModel({"visits": {"label": "入园记录", "kind": "fact"}}, structured="hollow", text="tables")
+ 用纯文本给的内容。纯文本只给了表级的项、一列都没列出来:再补问一次没给的列(只问缺的,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", "text"]
assert got["visits"].notes == {"label": make_item("入园记录", "llm"), "kind": make_item("fact", "llm")}
+ assert got["visits"].covered == frozenset() and "有 5 列没写出来" in got["visits"].error
async def test_hollow_structured_then_single_text_object():
diff --git a/backend/tests/test_catalog_draft_engine.py b/backend/tests/test_catalog_draft_engine.py
index 2818e566..b3e36962 100644
--- a/backend/tests/test_catalog_draft_engine.py
+++ b/backend/tests/test_catalog_draft_engine.py
@@ -304,6 +304,7 @@ async def test_truncated_tool_call_arguments_are_salvaged_too():
async def test_missing_tables_are_asked_again_once():
+ """缺了的表只补缺的、补一次;补问时整张表还是一项都没有,按只给空壳处理(拆不开就换纯文本问一次)。"""
tables = {"a_tab": _table(4), "b_tab": _table(4), "c_tab": _table(4)}
ok = answering(tables)
@@ -315,13 +316,34 @@ async def test_missing_tables_are_asked_again_once():
model = Scripted(handle)
got = await D.run_model_draft(model, _source(tables), ["a_tab", "b_tab", "c_tab"])
- assert len(model.calls) == 2
+ assert len(model.calls) == 3
assert model.calls[1].asked == {"b_tab": ("full", None), "c_tab": ("full", None)}
+ assert model.calls[2].asked == {"c_tab": ("full", None)} and model.calls[2].mode == "text"
assert _status(got) == {"a_tab": "ok", "b_tab": "ok", "c_tab": "failed"}
assert got["c_tab"].error == "模型的回复里没有这张表"
-async def test_columns_the_model_skips_are_asked_once_and_then_left_empty():
+async def test_a_table_still_missing_after_the_followup_is_split_like_a_hollow_shell():
+ """补问时整张表还是一项都没有:照第一次的规则拆小(这里按列拆成两半),不直接记失败。"""
+ tables = {"a_tab": _table(4), "b_tab": _table(12)}
+ ok = answering(tables)
+
+ def handle(call: Call) -> Reply:
+ reply = ok(call)
+ if call.n < 2:
+ reply.obj["tables"] = [e for e in reply.obj["tables"] if e["table"] == "a_tab"]
+ return reply
+
+ model = Scripted(handle)
+ got = await D.run_model_draft(model, _source(tables), ["a_tab", "b_tab"])
+ assert _status(got) == {"a_tab": "ok", "b_tab": "ok"}
+ cols = [f"c{i:03d}" for i in range(1, 13)]
+ assert [c.asked for c in model.calls[2:]] == [{"b_tab": ("table+columns", cols[:6])},
+ {"b_tab": ("columns", cols[6:])}]
+
+
+async def test_columns_still_missing_after_the_followup_are_not_written():
+ """补问过仍然没列出来的列算没写出来:表是 partial,说清哪部分没写出来(不算拿不准,旧的模型起草项不删)。"""
tables = {"t8": _table(8)}
ok = answering(tables)
@@ -335,8 +357,51 @@ async def test_columns_the_model_skips_are_asked_once_and_then_left_empty():
got = await D.run_model_draft(model, _source(tables), ["t8"])
assert len(model.calls) == 2 and model.calls[1].asked == {"t8": ("columns", ["c007", "c008"])}
o = got["t8"]
- assert o.status == "ok" and o.error is None, "补问过仍然没给的列算拿不准、留空"
+ assert o.status == "partial"
+ assert o.error == "这张表 8 列,有 2 列没写出来:模型的回复里漏了这些列,补问一次仍没有给出。可以只重试这张表"
assert set(o.notes["columns"]) == {f"c{i:03d}" for i in range(1, 7)}
+ compat = (await catalog.draft_with_model(Scripted(handle), _source(tables), ["t8"]))["t8"]
+ assert compat.covered == frozenset(), "没写全的不算完整起草过"
+
+
+async def test_a_followup_that_comes_back_as_an_empty_shell_leaves_the_table_partial():
+ """40 列的表第一次只回了 20 列,补问只给一个空壳:没写出来的 20 列照实记下,表是 partial。"""
+ tables = {"t40": _table(40)}
+ ok = answering(tables)
+
+ def handle(call: Call) -> Reply:
+ if call.n == 0:
+ reply = ok(call)
+ reply.obj["tables"][0]["columns"] = reply.obj["tables"][0]["columns"][:20]
+ return reply
+ return Reply(obj={"tables": [{"table": "t40"}]})
+
+ model = Scripted(handle)
+ got = await D.run_model_draft(model, _source(tables), ["t40"])
+ o = got["t40"]
+ assert len(model.calls) == 2
+ assert model.calls[1].asked == {"t40": ("columns", [f"c{i:03d}" for i in range(21, 41)])}
+ assert o.status == "partial" and len(o.notes["columns"]) == 20
+ assert o.error == "这张表 40 列,有 20 列没写出来:模型的回复里漏了这些列,补问一次仍没有给出。可以只重试这张表"
+
+
+async def test_columns_listed_with_empty_fields_are_left_to_the_model():
+ """列出了、字段留空的列是模型拿不准:不补问,也不算没写出来。"""
+ tables = {"t8": _table(8)}
+ ok = answering(tables)
+
+ def handle(call: Call) -> Reply:
+ reply = ok(call)
+ for entry in reply.obj["tables"]:
+ entry["columns"] = [c if c["name"] not in ("c007", "c008") else {"name": c["name"]}
+ for c in entry.get("columns", [])]
+ return reply
+
+ model = Scripted(handle)
+ got = await D.run_model_draft(model, _source(tables), ["t8"])
+ assert len(model.calls) == 1
+ assert got["t8"].status == "ok" and got["t8"].error is None
+ assert set(got["t8"].notes["columns"]) == {f"c{i:03d}" for i in range(1, 7)}
# --------------------------------------------------------------------------
@@ -382,6 +447,34 @@ async def test_structured_output_unsupported_switches_to_text_for_the_rest():
assert [c.mode for c in model.calls] == ["structured", "text", "text", "text"]
+@pytest.mark.parametrize("thinking", ["", "先想想每一列是什么……"], ids=["plain", "with-thinking"])
+async def test_structured_output_that_comes_back_as_prose_falls_back_to_text(thinking):
+ """网关接受了结构化输出的参数却没照做(模型写成了一段说明文字):换纯文本问,不当成额度用完去拆,也不直接判失败。"""
+ tables = {"t40": _table(40)}
+ ok = answering(tables)
+
+ def handle(call: Call) -> Reply:
+ if call.mode == "structured":
+ return Reply(text="好的。这张表记录的是一笔笔业务,各列的含义如下:第一列是编号……", thinking=thinking)
+ return ok(call)
+
+ model = Scripted(handle)
+ got = await D.run_model_draft(model, _source(tables), ["t40"])
+ assert got["t40"].status == "ok" and len(got["t40"].notes["columns"]) == 40
+ assert [c.mode for c in model.calls] == ["structured", "text"]
+ assert catalog.CATALOG_DRAFT_TEXT_PROMPT in model.calls[1].text
+
+
+async def test_prose_on_both_paths_is_a_format_failure_not_a_budget_one():
+ def handle(call: Call) -> Reply:
+ return Reply(text="这张表看起来是业务明细,具体含义需要结合业务确认。", thinking="……")
+
+ model = Scripted(handle)
+ got = await D.run_model_draft(model, _source({"t40": _table(40)}), ["t40"])
+ assert [c.mode for c in model.calls] == ["structured", "text"], "格式不对不拆小重试"
+ assert got["t40"].status == "failed" and got["t40"].error == "模型的回复不是约定的格式"
+
+
async def test_a_model_without_structured_output_goes_to_text():
class TextOnly(Scripted):
def with_structured_output(self, schema, **_):
@@ -500,6 +593,33 @@ async def test_tables_are_packed_up_to_40_columns_and_4_tables():
])
+# --------------------------------------------------------------------------
+# 报错去重
+# --------------------------------------------------------------------------
+
+
+def _hollow(call: Call) -> Reply:
+ return Reply(obj={"tables": [{"table": t} for t in call.asked]})
+
+
+async def test_the_same_reason_is_said_once_even_when_some_parts_were_split():
+ """宽表的表级那段拆不开、列分段拆过:原因只说一次,拆过的在最后补一句。"""
+ model = Scripted(_hollow)
+ got = await D.run_model_draft(model, _source({"wide45": _table(45)}), ["wide45"])
+ o = got["wide45"]
+ assert o.status == "failed"
+ assert o.error == "模型只给了空壳,没有写出可用的内容;已拆小重试仍没写出"
+
+
+def test_reasons_are_joined_by_their_base():
+ W = D._Why
+ exhausted = D.REASON_EXHAUSTED.format(budget=32768)
+ assert D._join([W(D.REASON_HOLLOW), W(D.REASON_HOLLOW, split=True)]) == D.REASON_HOLLOW + D.REASON_SPLIT
+ assert D._join([W(exhausted, split=True), W(D.REASON_HOLLOW)]) == \
+ f"{exhausted};{D.REASON_HOLLOW}{D.REASON_SPLIT}"
+ assert D._join([W("甲"), W("乙"), W("丙", split=True)]) == "甲;乙", "没说出来的原因不补后缀"
+
+
# --------------------------------------------------------------------------
# 起草要求、用量
# --------------------------------------------------------------------------
diff --git a/backend/tests/test_catalog_draft_jobs.py b/backend/tests/test_catalog_draft_jobs.py
index 27c14957..d766532b 100644
--- a/backend/tests/test_catalog_draft_jobs.py
+++ b/backend/tests/test_catalog_draft_jobs.py
@@ -215,6 +215,57 @@ async def test_a_table_whose_model_part_failed_is_failed_but_structure_is_writte
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 列没写出来" 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)}
+
+
async def test_a_job_that_cannot_start_is_failed_with_the_reason(client, monkeypatch):
"""建任务时起草模型还能用,开始起草时已经用不了(接入刚被停用):任务整体 failed,error 写原因,没开始的表也记 failed。"""
sid = await _source()
From 4857c578f6707b85fc410dc4aa1c8c6099b75df3 Mon Sep 17 00:00:00 2001
From: ThearchyHelios
Date: Tue, 13 Oct 2026 16:46:52 +0800
Subject: [PATCH 14/29] =?UTF-8?q?=E8=B5=B7=E8=8D=89=E7=9A=84=E6=AD=A2?=
=?UTF-8?q?=E6=8D=9F=E5=92=8C=E5=85=AC=E5=B9=B3=EF=BC=9A=E8=BF=9E=E7=BB=AD?=
=?UTF-8?q?=202=20=E6=AE=B5=EF=BC=88MODEL=5FGIVE=5FUP=5FAFTER=EF=BC=89?=
=?UTF-8?q?=E6=8B=86=E5=88=B0=E5=BA=95=E4=BB=8D=E4=B8=80=E9=A1=B9=E6=B2=A1?=
=?UTF-8?q?=E5=86=99=E5=87=BA=E6=9D=A5=EF=BC=8C=E8=BF=99=E6=AC=A1=E8=B5=B7?=
=?UTF-8?q?=E8=8D=89=E5=90=8E=E9=9D=A2=E7=9A=84=E6=AE=B5=E4=B8=8D=E5=86=8D?=
=?UTF-8?q?=E6=8B=86=E5=B0=8F=E9=87=8D=E8=AF=95=EF=BC=8C=E6=B2=A1=E5=86=99?=
=?UTF-8?q?=E5=87=BA=E6=9D=A5=E5=B0=B1=E7=9B=B4=E6=8E=A5=E8=AE=B0=E4=B8=8B?=
=?UTF-8?q?=EF=BC=8C=E5=8E=9F=E5=9B=A0=E5=90=8E=E9=9D=A2=E8=A1=A5=E3=80=8C?=
=?UTF-8?q?=E6=A8=A1=E5=9E=8B=E8=BF=9E=E7=BB=AD=202=20=E6=AE=B5=E9=83=BD?=
=?UTF-8?q?=E6=B2=A1=E5=86=99=E5=87=BA=E6=9D=A5=EF=BC=8C=E5=B7=B2=E5=81=9C?=
=?UTF-8?q?=E6=AD=A2=E6=8B=86=E5=B0=8F=E9=87=8D=E8=AF=95=EF=BC=8C=E5=BB=BA?=
=?UTF-8?q?=E8=AE=AE=E6=9B=B4=E6=8D=A2=E8=B5=B7=E8=8D=89=E6=A8=A1=E5=9E=8B?=
=?UTF-8?q?=E3=80=8D=EF=BC=9B=E6=9C=89=E4=B8=80=E6=AE=B5=E5=86=99=E5=87=BA?=
=?UTF-8?q?=E6=9D=A5=E5=B0=B1=E6=81=A2=E5=A4=8D=E6=8B=86=E5=B0=8F=E3=80=82?=
=?UTF-8?q?=E6=AF=8F=E6=AC=A1=E8=B5=B7=E8=8D=89=E5=90=8C=E6=97=B6=E5=8F=AA?=
=?UTF-8?q?=E8=B5=B7=E8=8D=89=203=20=E6=AE=B5=EF=BC=88MODEL=5FCONCURRENCY?=
=?UTF-8?q?=EF=BC=89=EF=BC=8C=E4=B8=80=E6=AE=B5=E8=BF=9E=E5=90=8C=E6=8B=86?=
=?UTF-8?q?=E5=B0=8F=E3=80=81=E8=A1=A5=E7=BC=BA=E5=81=9A=E5=AE=8C=E5=86=8D?=
=?UTF-8?q?=E5=8F=96=E4=B8=8B=E4=B8=80=E6=AE=B5=EF=BC=8C=E5=AE=BD=E8=A1=A8?=
=?UTF-8?q?=E7=9A=84=E8=A1=A8=E7=BA=A7=E9=82=A3=E6=AE=B5=E5=86=99=E5=AE=8C?=
=?UTF-8?q?=EF=BC=8C=E5=AE=83=E7=9A=84=E5=88=97=E5=88=86=E6=AE=B5=E6=8E=92?=
=?UTF-8?q?=E5=88=B0=E6=9C=80=E5=89=8D=E9=9D=A2=E4=B8=80=E6=AE=B5=E4=B8=80?=
=?UTF-8?q?=E6=AE=B5=E8=B5=B7=E8=8D=89=EF=BC=9B=E5=90=8C=E6=97=B6=E5=9C=A8?=
=?UTF-8?q?=E7=AD=89=E5=85=A8=E8=BF=9B=E7=A8=8B=E5=90=8D=E9=A2=9D=E7=9A=84?=
=?UTF-8?q?=E8=B0=83=E7=94=A8=E6=AF=8F=E6=AC=A1=E8=B5=B7=E8=8D=89=E6=9C=80?=
=?UTF-8?q?=E5=A4=9A=203=20=E4=B8=AA=EF=BC=8C=E5=87=A0=E6=AC=A1=E8=B5=B7?=
=?UTF-8?q?=E8=8D=89=EF=BC=88=E4=B8=8D=E5=90=8C=E6=95=B0=E6=8D=AE=E6=BA=90?=
=?UTF-8?q?=E7=9A=84=E4=BB=BB=E5=8A=A1=E3=80=81=E5=90=8C=E6=AD=A5=E8=B5=B7?=
=?UTF-8?q?=E8=8D=89=EF=BC=89=E4=BA=A4=E6=9B=BF=E6=8E=92=E9=98=9F=EF=BC=8C?=
=?UTF-8?q?=E5=90=8E=E5=BC=80=E5=A7=8B=E7=9A=84=E4=B8=8D=E5=86=8D=E7=AD=89?=
=?UTF-8?q?=E5=89=8D=E4=B8=80=E4=B8=AA=E6=8E=92=E7=9D=80=E7=9A=84=E4=B8=8A?=
=?UTF-8?q?=E7=99=BE=E6=AE=B5=E5=85=A8=E9=83=A8=E8=B0=83=E5=AE=8C=E3=80=82?=
=?UTF-8?q?=E5=AE=9E=E6=B5=8B=E4=B8=80=E7=9B=B4=E5=8F=AA=E7=BB=99=E7=A9=BA?=
=?UTF-8?q?=E5=A3=B3=E7=9A=84=20294=20=E5=88=97=E5=AE=BD=E8=A1=A8=E4=BB=8E?=
=?UTF-8?q?=20168=20=E6=AC=A1=E8=B0=83=E7=94=A8=E9=99=8D=E5=88=B0=2081=20?=
=?UTF-8?q?=E6=AC=A1=EF=BC=8C20=20=E5=BC=A0=20120=20=E5=88=97=E7=9A=84?=
=?UTF-8?q?=E5=AE=BD=E8=A1=A8=E4=B8=80=E7=9B=B4=E7=94=A8=E5=AE=8C=E9=A2=9D?=
=?UTF-8?q?=E5=BA=A6=E4=BB=8E=E7=BA=A6=20900=20=E6=AC=A1=E9=99=8D=E5=88=B0?=
=?UTF-8?q?=20122=20=E6=AC=A1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/data/catalog.py | 3 +
backend/app/data/catalog_draft.py | 138 ++++++++++++++++-----
backend/tests/test_catalog_draft_engine.py | 91 +++++++++++++-
3 files changed, 199 insertions(+), 33 deletions(-)
diff --git a/backend/app/data/catalog.py b/backend/app/data/catalog.py
index 058c8b16..3d0d4275 100644
--- a/backend/app/data/catalog.py
+++ b/backend/app/data/catalog.py
@@ -1421,6 +1421,9 @@ MODEL_FALLBACK_MAX_TOKENS = 8192
#: 一段没写出来(额度用完、只给空壳)时拆成两半再问:最多拆几层、拆到最少几列为止
MODEL_SPLIT_DEPTH = 3
MODEL_SPLIT_MIN_COLUMNS = 5
+#: 止损:连续这么多段拆到底仍一项没写出来,这次起草后面的段不再拆小重试(一段 40 列拆到底要 15 次调用,模型明显
+#: 用不了时不该照这个倍数烧额度)
+MODEL_GIVE_UP_AFTER = 2
#: 网络错误、429、5xx、超时最多重试几次,每次重试前等多久(秒,依次取用)
MODEL_RETRIES = 2
MODEL_RETRY_DELAYS_S = (10.0, 30.0)
diff --git a/backend/app/data/catalog_draft.py b/backend/app/data/catalog_draft.py
index 2c08695c..bbacc754 100644
--- a/backend/app/data/catalog_draft.py
+++ b/backend/app/data/catalog_draft.py
@@ -30,9 +30,14 @@
- 网络错误、429、5xx、超时:等一会儿重试(最多 MODEL_RETRIES 次,退避)。单次调用的时限是 MODEL_TIMEOUT_S。SDK
自己不再重试(catalog.resolve_draft_model 建模型时 max_retries=0),实际重试次数和报错写的一致。
- 别的错误:这一段没写出来,原因照实写。
+- 止损:连续 MODEL_GIVE_UP_AFTER 段拆到底仍一项没写出来(模型明显用不了,例如思考总是吃满额度),这次起草后面的
+ 段不再拆小重试、没写出来就直接记下,报错建议更换起草模型;有一段写出来了就恢复。不然一段拆到底要几十次调用,
+ 起草几十张宽表会烧掉数百万 token、跑几个小时。
**并发。** 全进程最多 MODEL_CONCURRENCY 个调用同时进行(model_gate:按事件循环各一个信号量,所有起草任务和同步
-起草共用)。
+起草共用,先来先放)。一次起草里同时只起草 MODEL_CONCURRENCY 段(一段拆小、补缺都做完再起草下一段),同时在等
+名额的调用也最多这么多个:不把几百段一下子都排上去,几次起草(不同数据源的任务、同步起草)的调用交替排队,后开始
+的不会等前一个排着的段全部调完。
**进度。** 调用方给 Hooks:开始调用、每次调用的用量、一段结束、一张表的模型部分全部结束。后台起草任务据此落库,
每张表一结束就并入目录(data/catalog_jobs.py)。
@@ -43,6 +48,7 @@ catalog.py 名字带 _SYSTEM / _PROMPT 的常量里;这里的 REASON_* 是给
from __future__ import annotations
import asyncio
+import collections
import copy
import json
import logging
@@ -81,6 +87,8 @@ REASON_TIMEOUT = "模型 {seconds} 秒内没有写完这一段"
REASON_CALL = "调用模型出错:{line}"
REASON_RETRIED = "(已重试 {n} 次)"
REASON_CRASH = "起草这张表时出错:{line}"
+#: 止损(连续几段拆到底仍一项没写出来,后面的段不再拆小重试)时补在原因后面
+REASON_HALTED = ";模型连续 {n} 段都没写出来,已停止拆小重试,建议更换起草模型"
# ==========================================================================
@@ -233,21 +241,25 @@ class _Reply:
@dataclass(frozen=True)
class _Why:
- """一部分没写出来的原因。base 给人看的原因;split 拆小重试过。"""
+ """一部分没写出来的原因。base 给人看的原因;split 拆小重试过;halted 本来还能拆小,因为前面连续几段都没写出来
+ (止损)才没拆。"""
base: str
split: bool = False
+ halted: bool = False
def _join(whys: Iterable[_Why]) -> str:
- """几处没写出来的原因拼成一句:同样的原因只说一次(最多说两种),拆小重试过的在最后补一次(只看说出来的那几种)。
- 不然宽表的表级那段拆不开、列分段拆过时,同一句原因会说两遍。"""
+ """几处没写出来的原因拼成一句:同样的原因只说一次(最多说两种),拆小重试过、止损的说明各在最后补一次(只看说出来
+ 的那几种)。不然宽表的表级那段拆不开、列分段拆过时,同一句原因会说两遍。"""
whys = list(whys)
shown = list(dict.fromkeys(w.base for w in whys))[:2]
told = [w for w in whys if w.base in shown]
text = ";".join(shown)
if any(w.split for w in told):
text += REASON_SPLIT
+ if any(w.halted for w in told):
+ text += REASON_HALTED.format(n=max(1, int(C.MODEL_GIVE_UP_AFTER)))
return text
@@ -586,6 +598,10 @@ class _Table:
"""这张表已经拿到了模型起草的内容(表级的项或任何一列)。"""
return self.got_table or bool(self.got_columns)
+ def wrote(self, piece: _Piece) -> bool:
+ """这一段请写的部分拿到了内容。"""
+ return (piece.table_level and self.got_table) or any(c in self.got_columns for c in piece.columns)
+
def _restrict(notes: Mapping[str, Any], piece: _Piece) -> dict[str, Any]:
"""只留这一段请模型写的部分:没请写表级的项就不要表级的项,列只要请写的那几列。"""
@@ -599,6 +615,15 @@ def _restrict(notes: Mapping[str, Any], piece: _Piece) -> dict[str, Any]:
return out
+@dataclass
+class _Work:
+ """排队起草的一段(顶层的段:窄表拼成的整表段、宽表的表级那段或一个列分段)。then:这一段写完才能起草的段(宽表
+ 的表级那段写完,才轮到它的列分段)。"""
+
+ pieces: list[_Piece]
+ then: list[_Work] = field(default_factory=list)
+
+
def _halves(pieces: list[_Piece]) -> tuple[list[_Piece], list[_Piece]] | None:
"""拆成两半:多表的段按表拆;单表的按列拆,两半都至少 MODEL_SPLIT_MIN_COLUMNS 列(表级的项跟着前一半)。拆不开返回 None。"""
if len(pieces) > 1:
@@ -630,11 +655,52 @@ class _Run:
for name in self.order
}
self.background = self._background(getattr(source, "description", None), background)
+ #: 这次起草同时在等名额或正在进行的调用最多几个(全进程的名额另算,见 model_gate)
+ self.gate = asyncio.Semaphore(max(1, int(C.MODEL_CONCURRENCY)))
+ #: 连续几段拆到底仍一项没写出来(止损用,有一段写出来就清零)
+ self.dead_streak = 0
+
+ @property
+ def halted(self) -> bool:
+ """止损:连续 MODEL_GIVE_UP_AFTER 段一项都没写出来,后面的段不再拆小重试。"""
+ return self.dead_streak >= max(1, int(C.MODEL_GIVE_UP_AFTER))
# ---------------------------------------------------------------- 总流程
async def run(self) -> dict[str, ModelOutcome]:
- await asyncio.gather(*(self._unit(unit) for unit in self._plan()))
+ """按计划的先后起草:同时只起草 MODEL_CONCURRENCY 段,一段(连同拆小、补缺)做完再取下一段;宽表的表级那段
+ 写完,它的列分段排到最前面。不一下子把所有段(包括宽表的全部列分段)都排上去:那样后开始的另一次起草要等这次
+ 排着的段全部调完;拆小重试也排在所有段的第一次调用后面,一段要等几乎所有调用都做完才结束,止损形同虚设。"""
+ queue: collections.deque[_Work] = collections.deque()
+ for unit in self._plan():
+ if isinstance(unit, list):
+ queue.append(_Work([self.tables[n].parts[0] for n in unit]))
+ else:
+ head, *rest = self.tables[unit].parts
+ # 表级那段先写:列分段拿它做背景
+ queue.append(_Work([head], [_Work([p]) for p in rest]))
+ left = {name: len(st.parts) for name, st in self.tables.items()}
+ busy = 0
+ changed = asyncio.Event()
+
+ async def worker() -> None:
+ nonlocal busy
+ while True:
+ if queue:
+ work = queue.popleft()
+ busy += 1
+ try:
+ await self._work(work, queue, left)
+ finally:
+ busy -= 1
+ changed.set()
+ continue
+ if not busy:
+ return
+ changed.clear() # 队列空着、别的段还在起草(宽表的表级那段写完会排上列分段):等它们
+ await changed.wait()
+
+ await asyncio.gather(*(worker() for _ in range(max(1, int(C.MODEL_CONCURRENCY)))))
return {name: self._outcome(t) for name, t in self.tables.items()}
def _plan(self) -> list[list[str] | str]:
@@ -661,24 +727,16 @@ class _Run:
used += n
return units
- async def _unit(self, unit: list[str] | str) -> None:
- names = unit if isinstance(unit, list) else [unit]
- if isinstance(unit, list):
- await self._guarded([self.tables[n].parts[0] for n in unit])
- for name in unit:
- await self._part_done(name)
- else:
- head, *rest = self.tables[unit].parts
- await self._guarded([head]) # 表级那段先写:列分段拿它做背景
- await self._part_done(unit)
-
- async def column_part(piece: _Piece) -> None:
- await self._guarded([piece])
- await self._part_done(unit)
-
- await asyncio.gather(*(column_part(p) for p in rest))
- for name in names:
- await self._table_done(name)
+ async def _work(self, work: _Work, queue: collections.deque[_Work], left: dict[str, int]) -> None:
+ """起草一段;它之后的段(宽表的列分段)排到最前面;一张表的段都结束了,这张表的模型部分就结束了。"""
+ await self._guarded(work.pieces)
+ for piece in work.pieces:
+ await self._part_done(piece.table)
+ queue.extendleft(reversed(work.then))
+ for piece in work.pieces:
+ left[piece.table] -= 1
+ if not left[piece.table]:
+ await self._table_done(piece.table)
async def _guarded(self, pieces: list[_Piece]) -> None:
"""起草一段;意料之外的错误只记这一段没写出来,不影响别的段和别的表。"""
@@ -691,6 +749,16 @@ class _Run:
logger.exception("起草数据目录时出错:%s", "、".join(p.table for p in pieces))
self._fail(pieces, REASON_CRASH.format(line=first_line(e) or describe_exception(e)))
+ self._tally(pieces)
+
+ def _tally(self, pieces: list[_Piece]) -> None:
+ """一段(连同拆小、补缺)结束:一项都没写出来的接着数,写出来了清零。"""
+ if any(self.tables[p.table].wrote(p) for p in pieces):
+ self.dead_streak = 0
+ return
+ self.dead_streak += 1
+ if self.dead_streak == max(1, int(C.MODEL_GIVE_UP_AFTER)):
+ logger.warning("起草数据目录:模型连续 %d 段都没写出来,这次起草后面的段不再拆小重试", self.dead_streak)
# ---------------------------------------------------------------- 一段
@@ -727,7 +795,8 @@ class _Run:
return
if not anything and not followup:
if last:
- self._fail(pieces, lambda p: self._reason("hollow", split=depth > 0, piece=p))
+ halted = self._stopped(pieces, depth)
+ self._fail(pieces, lambda p: self._reason("hollow", split=depth > 0, piece=p, halted=halted))
return
await self._split(pieces, depth, "hollow", followup)
return
@@ -753,7 +822,8 @@ class _Run:
if not empty:
return
if last:
- self._fail(empty, lambda p: self._reason("hollow", split=depth > 0, piece=p))
+ halted = self._stopped(empty, depth)
+ self._fail(empty, lambda p: self._reason("hollow", split=depth > 0, piece=p, halted=halted))
return
await self._split(empty, depth, "hollow", False)
@@ -765,9 +835,14 @@ class _Run:
logger.info("起草数据目录:结构化输出回了说明文字,后面改走纯文本")
await self._segment(pieces, depth, followup, mode="text", last=True)
+ def _stopped(self, pieces: list[_Piece], depth: int) -> bool:
+ """这几段本来还能拆小,因为前面连续几段都没写出来(止损)才没拆。"""
+ return self.halted and depth < int(C.MODEL_SPLIT_DEPTH) and _halves(pieces) is not None
+
async def _split(self, pieces: list[_Piece], depth: int, kind: str, followup: bool) -> None:
halves = _halves(pieces) if depth < int(C.MODEL_SPLIT_DEPTH) else None
- if halves is not None:
+ halted = halves is not None and self.halted
+ if halves is not None and not halted:
first, second = halves
if (len(first) == len(second) == 1 and first[0].table == second[0].table and first[0].table_level):
# 同一张表按列拆、前一半带着表级的项:先写前一半,后一半拿它做背景
@@ -783,7 +858,7 @@ class _Run:
if kind == "hollow" and self.mode == "structured":
await self._segment(pieces, depth, followup, mode="text", last=True)
return
- self._fail(pieces, lambda p: self._reason(kind, split=depth > 0, piece=p))
+ self._fail(pieces, lambda p: self._reason(kind, split=depth > 0, piece=p, halted=halted))
def _remainder(self, pieces: list[_Piece], gains: list[_Gain], *, truncated: bool) -> list[_Piece]:
"""还没写出来的部分:回复里没有这张表、或者这张表一项可用内容都没有的整段重问;有内容的只问没列出来的列
@@ -809,7 +884,8 @@ class _Run:
while True:
used = getattr(self.model, "budget", None)
try:
- async with model_gate():
+ # 先占这次起草自己的名额,再去排全进程的:一次起草排在全进程名额上的调用最多 MODEL_CONCURRENCY 个
+ async with self.gate, model_gate():
await self._hook(self.hooks.call_started, [p.table for p in pieces])
reply, raw = await asyncio.wait_for(self._call(pieces, mode), timeout=C.MODEL_TIMEOUT_S)
# 用量记在时限之外:落库慢了不能把已经回来的这次调用算成超时
@@ -950,7 +1026,7 @@ class _Run:
if piece.table_level and not st.got_table:
st.failed.setdefault(_TABLE, why)
- def _reason(self, kind: str, *, split: bool, piece: _Piece | None = None) -> _Why:
+ def _reason(self, kind: str, *, split: bool, piece: _Piece | None = None, halted: bool = False) -> _Why:
if kind == "exhausted":
base = REASON_EXHAUSTED.format(budget=self._budget())
elif kind == "truncated":
@@ -961,7 +1037,7 @@ class _Run:
base = C.MODEL_EMPTY_REASON if st.named else REASON_MISSING
else:
base = REASON_HOLLOW
- return _Why(base, split=split)
+ return _Why(base, split=split, halted=halted)
# ---------------------------------------------------------------- 收尾
@@ -1121,5 +1197,5 @@ class _Run:
__all__ = ["BUDGET_WORDS", "Background", "Hooks", "ModelOutcome", "REASON_CALL", "REASON_EXHAUSTED", "REASON_FORMAT",
- "REASON_HOLLOW", "REASON_MISSING", "REASON_RETRIED", "REASON_SPLIT", "REASON_TIMEOUT",
+ "REASON_HALTED", "REASON_HOLLOW", "REASON_MISSING", "REASON_RETRIED", "REASON_SPLIT", "REASON_TIMEOUT",
"REASON_TRUNCATED", "REASON_UNANSWERED", "classify_failure", "model_gate", "parts_for", "run_model_draft"]
diff --git a/backend/tests/test_catalog_draft_engine.py b/backend/tests/test_catalog_draft_engine.py
index b3e36962..4f17ee3f 100644
--- a/backend/tests/test_catalog_draft_engine.py
+++ b/backend/tests/test_catalog_draft_engine.py
@@ -594,7 +594,7 @@ async def test_tables_are_packed_up_to_40_columns_and_4_tables():
# --------------------------------------------------------------------------
-# 报错去重
+# 报错去重、止损、几次起草轮流用模型
# --------------------------------------------------------------------------
@@ -602,8 +602,9 @@ def _hollow(call: Call) -> Reply:
return Reply(obj={"tables": [{"table": t} for t in call.asked]})
-async def test_the_same_reason_is_said_once_even_when_some_parts_were_split():
+async def test_the_same_reason_is_said_once_even_when_some_parts_were_split(monkeypatch):
"""宽表的表级那段拆不开、列分段拆过:原因只说一次,拆过的在最后补一句。"""
+ monkeypatch.setattr(catalog, "MODEL_GIVE_UP_AFTER", 99)
model = Scripted(_hollow)
got = await D.run_model_draft(model, _source({"wide45": _table(45)}), ["wide45"])
o = got["wide45"]
@@ -618,6 +619,92 @@ def test_reasons_are_joined_by_their_base():
assert D._join([W(exhausted, split=True), W(D.REASON_HOLLOW)]) == \
f"{exhausted};{D.REASON_HOLLOW}{D.REASON_SPLIT}"
assert D._join([W("甲"), W("乙"), W("丙", split=True)]) == "甲;乙", "没说出来的原因不补后缀"
+ assert D._join([W(D.REASON_HOLLOW, halted=True), W(D.REASON_HOLLOW)]) == \
+ D.REASON_HOLLOW + D.REASON_HALTED.format(n=catalog.MODEL_GIVE_UP_AFTER)
+
+
+def _by_table(model: Scripted) -> dict[str, int]:
+ out: dict[str, int] = {}
+ for call in model.calls:
+ for name in call.asked:
+ out[name] = out.get(name, 0) + 1
+ return out
+
+
+async def test_after_two_segments_in_a_row_come_to_nothing_the_rest_are_not_split(monkeypatch):
+ """模型明显用不了(思考总是吃满额度):连续 2 段拆到底仍一项没写出,后面的段只问一次、不再拆,报错说明已停止拆小重试。
+ 有一段写出来了就恢复拆小。一次只起草一段(并发 1),先后是确定的。"""
+ monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
+ monkeypatch.setattr(catalog, "MODEL_BATCH_TABLES", 1)
+ assert catalog.MODEL_GIVE_UP_AFTER == 2
+ tables = {f"t{i}": _table(10) for i in range(1, 5)}
+ exhausted = Reply(text="", finish="length", thinking="……")
+
+ model = Scripted(lambda call: exhausted)
+ got = await D.run_model_draft(model, _source(tables), list(tables))
+ assert _by_table(model) == {"t1": 3, "t2": 3, "t3": 1, "t4": 1}
+ budget = D.REASON_EXHAUSTED.format(budget=32768)
+ assert got["t1"].error == budget + D.REASON_SPLIT
+ assert got["t3"].error == budget + ";模型连续 2 段都没写出来,已停止拆小重试,建议更换起草模型"
+
+ ok = answering(tables)
+ model = Scripted(lambda call: ok(call) if "t3" in call.asked else exhausted)
+ got = await D.run_model_draft(model, _source(tables), list(tables))
+ assert _by_table(model) == {"t1": 3, "t2": 3, "t3": 1, "t4": 3}, "t3 写出来了,t4 照常拆小"
+ assert got["t3"].status == "ok" and got["t4"].error == budget + D.REASON_SPLIT
+
+
+async def test_the_column_parts_of_a_wide_table_stop_splitting_too(monkeypatch):
+ """宽表的列分段一段一段排队起草(一段拆到底再起草下一段),止损在宽表里同样起作用。
+
+ 95 列:表级那段拆不开(结构化、纯文本各一次);第 2 段 40 列拆到底(15 次结构化 + 8 次纯文本);这时已经连续 2 段
+ 没写出来,第 3、4 段只问一次、换纯文本一次。"""
+ monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
+ model = Scripted(_hollow)
+ got = await D.run_model_draft(model, _source({"wide95": _table(95)}), ["wide95"])
+ assert len(model.calls) == 2 + 23 + 2 + 2
+ assert got["wide95"].error == (D.REASON_HOLLOW + D.REASON_SPLIT
+ + ";模型连续 2 段都没写出来,已停止拆小重试,建议更换起草模型")
+
+
+async def test_a_wide_table_that_never_comes_back_is_bounded_by_the_first_wave():
+ """默认并发(3 段同时起草):294 列一直只给空壳,最多是表级那段 2 次、头 3 个列分段拆到底(各 23 次)、其余 5 段
+ 各 2 次。以前所有列分段一下子都排上去、一层一层地拆,要 168 次。"""
+ model = Scripted(_hollow)
+ got = await D.run_model_draft(model, _source({"wide294": _table(294)}), ["wide294"])
+ assert got["wide294"].status == "failed"
+ assert len(model.calls) <= 2 + 3 * 23 + 5 * 2
+
+
+async def test_a_big_draft_does_not_hold_the_model_ahead_of_a_later_one(monkeypatch):
+ """全进程的调用名额先来先放:一次起草不把它的所有段一下子都排上去,后开始的那次起草(另一个数据源)能轮上。"""
+ monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
+ monkeypatch.setattr(catalog, "MODEL_BATCH_TABLES", 1)
+ big = {f"a{i}": _table(4) for i in range(1, 5)}
+ small = {"b1": _table(4)}
+ order: list[str] = []
+ release = asyncio.Event()
+
+ def handler(tables: dict) -> Callable[[Call], Any]:
+ ok = answering(tables)
+
+ async def handle(call: Call) -> Reply:
+ order.append(next(iter(call.asked)))
+ if len(order) == 1:
+ await release.wait()
+ return ok(call)
+ return handle
+
+ first = asyncio.create_task(D.run_model_draft(Scripted(handler(big)), _source(big), list(big)))
+ while not order:
+ await asyncio.sleep(0)
+ second = asyncio.create_task(D.run_model_draft(Scripted(handler(small)), _source(small), ["b1"]))
+ for _ in range(20):
+ await asyncio.sleep(0)
+ release.set()
+ got_big, got_small = await asyncio.gather(first, second)
+ assert set(_status(got_big).values()) == {"ok"} and _status(got_small) == {"b1": "ok"}
+ assert order == ["a1", "b1", "a2", "a3", "a4"]
# --------------------------------------------------------------------------
From 38d6f47e678517aee49c54495b5641d775930125 Mon Sep 17 00:00:00 2001
From: ThearchyHelios
Date: Tue, 13 Oct 2026 16:48:46 +0800
Subject: [PATCH 15/29] =?UTF-8?q?PUT=20/catalog/guidance=20=E7=9A=84?=
=?UTF-8?q?=E8=AF=B7=E6=B1=82=E6=B2=A1=E5=86=99=20guidance=E3=80=81?=
=?UTF-8?q?=E6=88=96=E8=80=85=E4=B8=8D=E6=98=AF=E4=B8=80=E6=AE=B5=E6=96=87?=
=?UTF-8?q?=E5=AD=97=E6=97=B6=E5=9B=9E=20422=20=E5=B8=A6=E4=B8=80=E5=8F=A5?=
=?UTF-8?q?=E4=B8=AD=E6=96=87=E8=AF=B4=E6=98=8E=EF=BC=88=E5=90=8C=E8=B6=85?=
=?UTF-8?q?=E9=95=BF=E6=97=B6=E7=9A=84=E5=86=99=E6=B3=95=EF=BC=89=EF=BC=8C?=
=?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=98=AF=20pydantic=20=E7=9A=84=E8=8B=B1?=
=?UTF-8?q?=E6=96=87=E6=A0=A1=E9=AA=8C=E7=BB=93=E6=9E=9C=EF=BC=9B=E8=A1=A8?=
=?UTF-8?q?=E5=90=8D=E6=81=B0=E5=A5=BD=E5=8F=AB=20guidance=20=E7=9A=84?=
=?UTF-8?q?=E6=95=B4=E4=BB=BD=E6=8F=90=E4=BA=A4=E7=85=A7=E6=97=A7?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/api/catalog.py | 20 +++++++++++---------
backend/tests/test_catalog_draft_jobs.py | 6 +++++-
2 files changed, 16 insertions(+), 10 deletions(-)
diff --git a/backend/app/api/catalog.py b/backend/app/api/catalog.py
index 46362b9b..a0945853 100644
--- a/backend/app/api/catalog.py
+++ b/backend/app/api/catalog.py
@@ -85,10 +85,11 @@ class DraftJobIn(BaseModel):
note: str | None = None
-class GuidanceIn(BaseModel):
- #: 数据源级的起草要求;空串(或 null)清除。最多 2000 字(超了 422,说明是中文)。必须写这一项:表名恰好叫
- #: guidance 时,整份提交那张表目录的请求也落到这个路径上,不能把它当成「清除起草要求」
- guidance: str | None
+#: PUT /catalog/guidance 的请求 {guidance: string}:数据源级的起草要求,空串(或 null)清除,最多 2000 字。必须写
+#: 这一项:表名恰好叫 guidance 时,整份提交那张表目录的请求也落到这个路径上,不能把它当成「清除起草要求」。
+#: 没写、不是文字都回 422,说明是中文的一句话(同超长)
+GUIDANCE_MISSING = "请求里要写起草要求(guidance 一项,一段文字;要清除起草要求请传空串)"
+GUIDANCE_NOT_TEXT = "起草要求要是一段文字(要清除起草要求请传空串)"
class CatalogProfileIn(BaseModel):
@@ -260,13 +261,14 @@ async def put_catalog_guidance(source_id: str, body: dict[str, Any] = 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
+ 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, payload.guidance)
+ 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}
diff --git a/backend/tests/test_catalog_draft_jobs.py b/backend/tests/test_catalog_draft_jobs.py
index d766532b..a8a961bb 100644
--- a/backend/tests/test_catalog_draft_jobs.py
+++ b/backend/tests/test_catalog_draft_jobs.py
@@ -541,7 +541,11 @@ async def test_a_table_named_guidance_can_still_be_saved(client):
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
+ 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):
From 950b88a15bd2c03dab95465cec4d2bf4ce7f738b Mon Sep 17 00:00:00 2001
From: ThearchyHelios
Date: Tue, 13 Oct 2026 16:57:02 +0800
Subject: [PATCH 16/29] =?UTF-8?q?README=EF=BC=9A=E6=95=B0=E6=8D=AE?=
=?UTF-8?q?=E7=9B=AE=E5=BD=95=E4=B8=80=E8=8A=82=E8=A1=A5=E4=B8=8A=E6=A8=A1?=
=?UTF-8?q?=E5=9E=8B=E8=B5=B7=E8=8D=89=EF=BC=88=E5=90=8E=E5=8F=B0=E4=BB=BB?=
=?UTF-8?q?=E5=8A=A1=E3=80=81=E5=8F=AA=E9=87=8D=E8=AF=95=E5=A4=B1=E8=B4=A5?=
=?UTF-8?q?=E7=9A=84=E8=A1=A8=E3=80=81=E4=B8=AD=E6=96=AD=E4=B8=8D=E8=87=AA?=
=?UTF-8?q?=E5=8A=A8=E7=BB=AD=E8=B7=91=E3=80=81=E4=BF=9D=E7=95=99=E6=80=9D?=
=?UTF-8?q?=E8=80=83=E5=8A=A0=E5=A4=A7=E9=A2=9D=E5=BA=A6=E3=80=81=E6=8C=89?=
=?UTF-8?q?=E6=AE=B5=E8=B5=B7=E8=8D=89=E5=AE=BD=E8=A1=A8=E3=80=81=E8=B5=B7?=
=?UTF-8?q?=E8=8D=89=E8=A6=81=E6=B1=82=E4=B8=8E=E6=9C=AC=E6=AC=A1=E8=A1=A5?=
=?UTF-8?q?=E5=85=85=E8=AF=B4=E6=98=8E=E3=80=81=E8=B5=B7=E8=8D=89=E6=A8=A1?=
=?UTF-8?q?=E5=9E=8B=E8=AE=BE=E7=BD=AE=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index eff62429..0f484f0e 100644
--- a/README.md
+++ b/README.md
@@ -276,7 +276,8 @@ flowchart LR
| 环节 | 做法 |
|---|---|
-| 起草 | 探查时读外键和唯一约束;按 `xxx_id`、`xxxId` 推断关联,表名带业务前缀的也认;可选让模型起草中文名、粒度、度量类型。导入表格的源已有系统按核对结果生成的说明,目录不重复、不改写它 |
+| 起草 | 探查时读外键和唯一约束;按 `xxx_id`、`xxxId` 推断关联,表名带业务前缀的也认;可选让模型起草中文名、粒度、表类型、业务主键、业务日期和每列的含义、单位、度量类型。导入表格的源已有系统按核对结果生成的说明,目录不重复、不改写它 |
+| 模型起草 | 作为后台任务逐表进行,每张表记下状态和原因,写完一张就并入目录;关掉对话框不停,可以停止、只重试失败的表,清单上标出上次起草失败或只完成一部分的表。服务重启时没跑完的任务标成中断,不会自己续跑,点重试接上。保留模型的思考,输出额度 32768(服务方不收时退回 8192);每次调用最多 40 列,宽表先起草表级的项、再按段起草列;额度用完就拆小再问,缺的表和列只补问缺的,被截断的回复先留下已经写完整的部分。数据源上存的「起草要求」和这次的补充说明一起交给模型,帮它理解业务、统一用词,不能推翻「只记数据事实」的规矩,交回的内容照样逐项校验。用哪个模型在「设置 → 偏好设置」的「起草模型」里选,空着跟随助手的模型;按配方导入表格的 AI 起草也用它 |
| 数据剖析 | 默认关闭,按数据源开启。只读、经过守卫,单条查询和整次剖析都有上限,大表用统计信息估算行数,遮罩的列不取样。抽样核实推断出的关联,补上覆盖率和基数,站得住的升为已验证;状态列给出码值候选,含义等人填写 |
| 对话里纠正 | 用户说「status=9 表示作废」,助手给出一条目录修改提案,列出改前、改后和理由,人确认后才保存;公式不收,留给口径卡 |
| 版本与提醒 | 目录随表结构快照冻结进证据,每次运行用的是哪一版查得到。发布时记下模板引用的表的目录版本,正式运行时目录有变化就提醒;agent 运行时才写 SQL 的,按「可能涉及」单独提醒。只提醒,不拦运行 |
From c5a95c9198668ce594307b3fbd698cb299520803 Mon Sep 17 00:00:00 2001
From: ThearchyHelios
Date: Tue, 13 Oct 2026 16:59:58 +0800
Subject: [PATCH 17/29] =?UTF-8?q?test=5Fmasking=5Ftool=5Fboundary=EF=BC=9A?=
=?UTF-8?q?=E6=9F=A5=E9=87=91=E9=A2=9D=E5=8E=9F=E5=80=BC=E4=B9=8B=E5=89=8D?=
=?UTF-8?q?=E5=85=88=E5=8E=BB=E6=8E=89=E5=93=88=E5=B8=8C=E2=80=94=E2=80=94?=
=?UTF-8?q?=E5=B7=A5=E4=BB=B6=20id=E3=80=81=E5=BF=AB=E7=85=A7=E9=87=8C?=
=?UTF-8?q?=E8=AE=B0=E7=9A=84=E5=93=88=E5=B8=8C=E6=98=AF=E9=9A=8F=E6=9C=BA?=
=?UTF-8?q?=E7=9A=84=E5=8D=81=E5=85=AD=E8=BF=9B=E5=88=B6=EF=BC=8C=E5=81=B6?=
=?UTF-8?q?=E5=B0=94=E7=A2=B0=E5=B7=A7=E5=90=AB=E3=80=8C1200=E3=80=8D?=
=?UTF-8?q?=EF=BC=8C=E5=85=A8=E9=87=8F=E5=B9=B6=E8=A1=8C=E6=97=B6=E5=81=B6?=
=?UTF-8?q?=E5=8F=91=E5=A4=B1=E8=B4=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/tests/test_masking_tool_boundary.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/backend/tests/test_masking_tool_boundary.py b/backend/tests/test_masking_tool_boundary.py
index 9f2797ad..2323c2af 100644
--- a/backend/tests/test_masking_tool_boundary.py
+++ b/backend/tests/test_masking_tool_boundary.py
@@ -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 的写法认不全,整份结果查询时已遮罩,不采信"
From a0b06f93c4de0958a381775476b364b87c170403 Mon Sep 17 00:00:00 2001
From: ThearchyHelios
Date: Tue, 13 Oct 2026 20:26:52 +0800
Subject: [PATCH 18/29] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95?=
=?UTF-8?q?=E8=B5=B7=E8=8D=89=EF=BC=88=E5=89=8D=E7=AB=AF=EF=BC=89=EF=BC=9A?=
=?UTF-8?q?=E8=B0=83=E7=94=A8=E6=8E=A5=E8=BF=9E=E6=B2=A1=E5=86=99=E5=87=BA?=
=?UTF-8?q?=E6=9D=A5=E6=97=B6=EF=BC=8C=E8=BF=9B=E8=A1=8C=E4=B8=AD=E9=86=92?=
=?UTF-8?q?=E7=9B=AE=E6=98=BE=E7=A4=BA=E6=9C=8D=E5=8A=A1=E7=AB=AF=E7=BB=99?=
=?UTF-8?q?=E7=9A=84=E6=8F=90=E9=86=92=EF=BC=88job.notice=EF=BC=89?=
=?UTF-8?q?=E2=80=94=E2=80=94=E6=8F=90=E9=86=92=E7=9A=84=E6=A0=B7=E5=BC=8F?=
=?UTF-8?q?=E3=80=81=E4=B8=8D=E6=98=AF=E9=94=99=E8=AF=AF=EF=BC=8C=E5=86=99?=
=?UTF-8?q?=E5=9C=A8=E8=BF=9B=E5=BA=A6=E6=9D=A1=E4=B8=8B=E9=9D=A2=EF=BC=9B?=
=?UTF-8?q?=E5=85=B3=E6=8E=89=E5=AF=B9=E8=AF=9D=E6=A1=86=E6=97=B6=E7=9B=AE?=
=?UTF-8?q?=E5=BD=95=E9=A1=B5=E4=B8=8A=E6=96=B9=E7=9A=84=E8=BF=9B=E5=BA=A6?=
=?UTF-8?q?=E4=B9=9F=E5=B8=A6=E7=9D=80=E8=BF=99=E4=B8=80=E8=A1=8C=EF=BC=88?=
=?UTF-8?q?=E5=85=A8=E6=96=87=E5=9C=A8=E6=82=AC=E5=81=9C=E9=87=8C=EF=BC=89?=
=?UTF-8?q?=EF=BC=9B=E5=A5=BD=E8=BD=AC=E5=90=8E=E6=9C=8D=E5=8A=A1=E7=AB=AF?=
=?UTF-8?q?=E6=B8=85=E6=8E=89=E5=B0=B1=E6=94=B6=E8=B5=B7=EF=BC=8C=E4=BB=BB?=
=?UTF-8?q?=E5=8A=A1=E7=BB=93=E6=9D=9F=E5=90=8E=E4=B8=8D=E5=86=8D=E6=98=BE?=
=?UTF-8?q?=E7=A4=BA=E3=80=82=E7=94=A8=E9=87=8F=E9=82=A3=E4=B8=80=E8=A1=8C?=
=?UTF-8?q?=E6=8E=A5=E7=9D=80=E5=86=99=E6=B2=A1=E5=86=99=E5=87=BA=E6=9D=A5?=
=?UTF-8?q?=E3=80=81=E8=B6=85=E6=97=B6=E7=9A=84=E6=AC=A1=E6=95=B0=EF=BC=88?=
=?UTF-8?q?=E6=B2=A1=E5=86=99=E5=87=BA=E6=9D=A5=E7=9A=84=E6=AC=A1=E6=95=B0?=
=?UTF-8?q?=E9=87=8C=E5=90=AB=E8=B6=85=E6=97=B6=EF=BC=8C=E5=86=99=E6=88=90?=
=?UTF-8?q?=E3=80=8C=E6=B2=A1=E5=86=99=E5=87=BA=E6=9D=A5=204=20=E6=AC=A1?=
=?UTF-8?q?=EF=BC=88=E5=85=B6=E4=B8=AD=E8=B6=85=E6=97=B6=203=20=E6=AC=A1?=
=?UTF-8?q?=EF=BC=89=E3=80=8D=EF=BC=8C=E4=B8=8D=E8=AF=BB=E6=88=90=E4=B8=A4?=
=?UTF-8?q?=E4=B8=AA=E6=95=B0=E7=9B=B8=E5=8A=A0=EF=BC=89=EF=BC=8C=E6=9C=89?=
=?UTF-8?q?=E8=B6=85=E6=97=B6=E6=97=B6=E5=86=99=E6=98=8E=E3=80=8C=E8=B6=85?=
=?UTF-8?q?=E6=97=B6=E7=9A=84=E8=B0=83=E7=94=A8=E4=B8=8D=E8=AE=A1=E7=94=A8?=
=?UTF-8?q?=E9=87=8F=EF=BC=8C=E5=AE=9E=E9=99=85=E7=94=A8=E9=87=8F=E5=8F=AF?=
=?UTF-8?q?=E8=83=BD=E6=9B=B4=E5=A4=9A=E3=80=8D=EF=BC=9B=E9=A2=9D=E5=BA=A6?=
=?UTF-8?q?=E7=94=A8=E5=AE=8C=E3=80=81=E8=B6=85=E6=97=B6=E5=A4=9A=E4=BA=86?=
=?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=E6=8A=8A=E4=B9=8B=E5=90=8E=E7=9A=84?=
=?UTF-8?q?=E6=AE=B5=E7=BC=A9=E5=B0=8F=E6=97=B6=E5=86=99=E6=98=8E=E3=80=8C?=
=?UTF-8?q?=E4=B9=8B=E5=90=8E=E7=9A=84=E6=AE=B5=E6=AF=8F=E6=AE=B5=E6=9C=80?=
=?UTF-8?q?=E5=A4=9A=2020=20=E5=88=97=E3=80=8D=E3=80=82=E7=B1=BB=E5=9E=8B?=
=?UTF-8?q?=E5=8A=A0=20CatalogDraftJob.part=5Fcolumns=E3=80=81notice?=
=?UTF-8?q?=EF=BC=8Cusage=20=E5=8A=A0=20timeouts=E3=80=81failed=5Fcalls?=
=?UTF-8?q?=EF=BC=88=E8=80=81=E6=9C=8D=E5=8A=A1=E7=AB=AF=E6=B2=A1=E6=9C=89?=
=?UTF-8?q?=E8=BF=99=E5=87=A0=E4=B8=AA=E9=94=AE=EF=BC=8C=E6=8C=89=200=20?=
=?UTF-8?q?=E5=92=8C=20null=20=E5=A4=84=E7=90=86=EF=BC=89=E3=80=82check-ca?=
=?UTF-8?q?talog=20=E7=9A=84=E5=81=87=E6=9C=8D=E5=8A=A1=E7=AB=AF=E8=B7=9F?=
=?UTF-8?q?=E7=9D=80=E7=BB=99=E8=BF=99=E5=87=A0=E9=A1=B9=EF=BC=8C=E6=96=B0?=
=?UTF-8?q?=E5=8A=A0=E4=B8=80=E8=8A=82=E8=A6=86=E7=9B=96=E6=8F=90=E9=86=92?=
=?UTF-8?q?=E7=9A=84=E5=87=BA=E7=8E=B0=E4=B8=8E=E6=B6=88=E5=A4=B1=E3=80=81?=
=?UTF-8?q?=E8=B6=85=E6=97=B6=E5=92=8C=E6=B2=A1=E5=86=99=E5=87=BA=E6=9D=A5?=
=?UTF-8?q?=E7=9A=84=E6=AC=A1=E6=95=B0=E3=80=81=E6=AE=B5=E7=BC=A9=E5=B0=8F?=
=?UTF-8?q?=E7=9A=84=E8=AF=B4=E6=98=8E=E3=80=81=E4=BB=BB=E5=8A=A1=E7=BB=93?=
=?UTF-8?q?=E6=9D=9F=E5=90=8E=E6=8F=90=E9=86=92=E6=94=B6=E8=B5=B7=EF=BC=8C?=
=?UTF-8?q?=E7=AA=84=E5=B1=8F=E7=9A=84=E8=B5=B7=E8=8D=89=E5=BC=B9=E7=AA=97?=
=?UTF-8?q?=E5=92=8C=E4=B8=8A=E6=96=B9=E8=BF=9B=E5=BA=A6=E5=B8=A6=E7=9D=80?=
=?UTF-8?q?=E6=8F=90=E9=86=92=E4=B8=80=E5=B9=B6=E9=87=8F=EF=BC=9B=E5=AE=BD?=
=?UTF-8?q?=E8=A1=A8=E6=B2=A1=E5=86=99=E5=87=BA=E6=9D=A5=E7=9A=84=E5=8E=9F?=
=?UTF-8?q?=E5=9B=A0=E7=85=A7=E6=96=B0=E5=86=99=E6=B3=95=E5=86=99=E6=98=8E?=
=?UTF-8?q?=E5=88=97=E7=9A=84=E8=8C=83=E5=9B=B4=EF=BC=88=E7=AC=AC=203=20?=
=?UTF-8?q?=E6=AE=B5=EF=BC=88promo=5Fattr=5F19=20=E5=88=B0=20promo=5Fattr?=
=?UTF-8?q?=5F43=EF=BC=8C=E5=85=B1=2025=20=E5=88=97=EF=BC=89=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/src/lib/terms.ts | 18 +++-
frontend/src/pages/catalog/CatalogPage.tsx | 22 ++++-
frontend/src/pages/catalog/DraftDialog.tsx | 39 ++++++---
frontend/src/pages/catalog/draftJob.ts | 3 +
frontend/src/types.ts | 25 +++++-
scripts/check-catalog.mjs | 95 ++++++++++++++++++++--
6 files changed, 177 insertions(+), 25 deletions(-)
diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts
index 6b325b3b..dfb0aa2f 100644
--- a/frontend/src/lib/terms.ts
+++ b/frontend/src/lib/terms.ts
@@ -2390,8 +2390,11 @@ export const CATALOG_TEXT = {
// - 「起草要求」:使用者补充给模型的业务背景和用词约定,存在数据源上,以后每次用模型起草都带上;
// 「本次补充说明」只用于这一次(重试时沿用)。两者都只交给模型,不改起草规则:冲突时以规则为准。
// - 「起草模型」:设置里单独的一项(偏好设置 → 起草模型),留空跟随助手的模型。
-// - 宽表的「段」:超过 40 列的表拆成表级一段和若干列分段,逐段起草;写的是「已完成 3 / 9 段」。
+// - 宽表的「段」:超过 40 列的表拆成表级一段和若干列分段,逐段起草;写的是「已完成 3 / 9 段」。模型多次用完额度或
+// 超时后,之后的段缩小(每段最多 20 列、10 列)。
// - 「只重试失败的表」:重新起草失败、部分完成和已停止的表;已经写进目录的内容不回滚、不受影响。
+// - 「没写出来」:一次调用没有写出内容(额度用完、只给空壳、超时、出错)。接连没写出来时服务端给一句「提醒」,
+// 好转后收起;提醒是提醒的样式,不是错误。超时的调用不计用量。
// ===========================================================================
/** 起草要求、本次补充说明的字数上限(服务端同一个数:超长 422) */
@@ -2441,6 +2444,19 @@ export const CATALOG_DRAFT_TEXT = {
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)} 列`,
// ---- 结束
title: {
queued: '排队中,等待开始…',
diff --git a/frontend/src/pages/catalog/CatalogPage.tsx b/frontend/src/pages/catalog/CatalogPage.tsx
index 15f8e24e..51097aea 100644
--- a/frontend/src/pages/catalog/CatalogPage.tsx
+++ b/frontend/src/pages/catalog/CatalogPage.tsx
@@ -1,7 +1,7 @@
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'
@@ -203,6 +203,8 @@ export function CatalogPage() {
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])
@@ -430,7 +432,7 @@ export function CatalogPage() {
onGoSource={() => navigate(back)} />
)}
{hasTables && usage.used > 0 && !empty && }
- {hasTables && stripJob && !draftOpen && }
+ {hasTables && stripJob && !draftOpen && }
{body}
{draftOpen && data && (
void }) {
+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 (
{text}
+ {notice && (
+
+
+ {notice}
+
+ )}
RETRYABLE.has(t.status))
const [showAll, setShowAll] = useState(false)
@@ -342,6 +344,13 @@ function JobView({ job, running, stopping, busy }: { job: CatalogDraftJob; runni
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
return (
{busy &&
{DT.busy}}
@@ -355,14 +364,22 @@ function JobView({ job, running, stopping, busy }: { job: CatalogDraftJob; runni
aria-valuemin={0} aria-valuemax={job.total} aria-valuenow={value}>
+ {notice &&
{notice}}
{summary}
- {(job.model || job.usage.calls > 0) && (
-
- {job.model && {DT.modelUsed(job.model)}}
- {job.usage.calls > 0 && (
- {DT.usage(job.usage.calls, job.usage.input_tokens, job.usage.output_tokens)}
- )}
-
+ {(job.model || usage.calls > 0 || failures) && (
+
+
+ {job.model && {DT.modelUsed(job.model)}}
+ {usage.calls > 0 && (
+ {DT.usage(usage.calls, usage.input_tokens, usage.output_tokens)}
+ )}
+ {failures && (
+ {failures}
+ )}
+
+ {timeouts > 0 &&
{DT.timeoutNote}
}
+ {partColumns != null &&
{DT.partColumns(partColumns)}
}
+
)}
{running &&
{DT.hideHint}
}
{job.status === 'failed' && job.error &&
{job.error}}
diff --git a/frontend/src/pages/catalog/draftJob.ts b/frontend/src/pages/catalog/draftJob.ts
index eaa2c5b4..1e56e56b 100644
--- a/frontend/src/pages/catalog/draftJob.ts
+++ b/frontend/src/pages/catalog/draftJob.ts
@@ -36,6 +36,9 @@ export const retryableCount = (j: CatalogDraftJob): number => j.tasks.filter((t)
/** 一张表这次为什么没写全:整张表没起草的原因在前,模型那部分的原因在后 */
export const taskReason = (t: { error: string | null; model_error: string | null }): string => t.error ?? t.model_error ?? ''
+/** 每段最多几列(服务端起草的起始上限)。任务里的当前上限比它小:额度用完、超时多了,之后的段缩小了 */
+export const DRAFT_PART_COLUMNS = 40
+
/**
* 一项都没写进去的任务该怎么说(只对写完了的任务:完成、部分完成)。null:照常写合计。
* 用模型、却没有一张表拿到模型的可用内容:目录没有更新、模型没给内容;有表没写全:目录没有更新、原因见下方;
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index ef13a6d2..baf7ed51 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -2901,6 +2901,19 @@ export interface CatalogDraftTask {
finished_at: string | null
}
+/** 起草任务的模型用量 */
+export interface CatalogDraftUsage {
+ /** 调用次数(没写出来的、超时的也算) */
+ calls: number
+ /** 超时的调用不计 token(照实记 0:服务方可能已经生成了内容) */
+ input_tokens: number
+ output_tokens: number
+ /** 超时的调用次数。老服务端没有这个键 */
+ timeouts?: number
+ /** 没写出来的调用次数:额度用完、只给空壳、超时、出错。老服务端没有这个键 */
+ failed_calls?: number
+}
+
/** 一次起草任务 */
export interface CatalogDraftJob {
id: string
@@ -2929,7 +2942,17 @@ export interface CatalogDraftJob {
updated: number
removed: number
/** 模型调用的用量;拿不到用量时照实是 0 */
- usage: { calls: number; input_tokens: number; output_tokens: number }
+ usage: CatalogDraftUsage
+ /**
+ * 当前每段最多几列:一次起草里额度用完或超时累计 2 次后,之后新开始的段减半(40 → 20 → 10,最低 10)。
+ * 不用模型时可能为 null;老服务端没有这个键
+ */
+ part_columns?: number | null
+ /**
+ * 调用接连没写出来时的提醒(一句中文:最近几次里几次没写出来、原因,建议换起草模型、缩小范围或停止);
+ * 好转后服务端清掉,为 null。老服务端没有这个键
+ */
+ notice?: string | null
/** 任务整体出错的原因 */
error: string | null
tasks: CatalogDraftTask[]
diff --git a/scripts/check-catalog.mjs b/scripts/check-catalog.mjs
index dc8dde8b..57963898 100644
--- a/scripts/check-catalog.mjs
+++ b/scripts/check-catalog.mjs
@@ -425,7 +425,12 @@ const JOB_ERR = '起草任务出错:database is locked'
/** 起草时每段最多几列(服务端的常量):超过的宽表拆成「表级一段 + 列分段」 */
const PART_COLUMNS = 40
-/** 建一个排队的任务:表结构里没有的表照样建进任务、记 skipped。重试建的记着原任务(retry_of,界面不用) */
+/**
+ * 建一个排队的任务:表结构里没有的表照样建进任务、记 skipped。重试建的记着原任务(retry_of,界面不用)。
+ * 超过 40 列的宽表均匀分段:列分段数 = ceil(列数 / 40),段进度是「表级一段 + 列分段」。
+ * 用模型的任务记着当前每段最多几列(part_columns,额度用完、超时多了服务端会减半)、调用接连没写出来时的提醒(notice),
+ * 用量里另记超时和没写出来的调用次数
+ */
function newJob(state, src, { tables, use_model, note = null, retry_of = null }) {
const id = `check-job-${++state.jobSeq}`
const tasks = tables.map((name) => {
@@ -442,7 +447,8 @@ function newJob(state, src, { tables, use_model, note = null, retry_of = null })
const job = {
id, source_id: src, status: 'queued', use_model: !!use_model, model: use_model ? 'check-draft-model' : null,
guidance: state.guidance[src] ?? null, note, created_at: isoNow(), started_at: null, finished_at: null, created_by: ACTOR,
- retry_of, usage: { calls: 0, input_tokens: 0, output_tokens: 0 }, error: null, tasks,
+ retry_of, part_columns: use_model ? PART_COLUMNS : null, notice: null,
+ usage: { calls: 0, input_tokens: 0, output_tokens: 0, timeouts: 0, failed_calls: 0 }, error: null, tasks,
}
state.jobs.push(job)
return job
@@ -677,8 +683,11 @@ async function startDraft(page, { select = null, model = false, note = '' } = {}
/** 起草用的几句话(虚构) */
const GUIDANCE2 = '景区票务库:「核销」指闸机检票入园;金额单位是元。会员相关的表统一写「会员」,不写「用户」'
const DRAFT_NOTE = '这一批是闸机和会员相关的表'
-const WIDE_ERR = '这张表 50 列,分 3 段起草,第 2 段没写出来(共 10 列):模型的思考和输出用完了额度(32768 个 token),已拆小重试仍没写出。可以只重试这张表'
+// 宽表没写出来的那一段写明列的范围(列名照表结构原样);只有真拆过才写「已拆小重试仍没写出」
+const WIDE_ERR = '这张表 50 列,分 3 段起草,第 3 段(promo_attr_19 到 promo_attr_43,共 25 列)没写出来:模型的思考和输出用完了额度(32768 个 token),已拆小重试仍没写出。可以只重试这张表'
const CALL_ERR = '调用模型出错:服务暂时不可用(已重试 2 次)'
+/** 调用接连没写出来时服务端给的提醒(虚构的一句,照约定写明次数、原因和办法) */
+const DRAFT_NOTICE = '最近 6 次调用有 4 次没写出来(3 次超时、1 次额度用完)。建议在「设置 → 偏好设置」的「起草模型」换一个模型、缩小起草范围,或者停止这次起草'
const detail = (page) => page.locator('[data-catalog-detail]')
const version = async (page) => Number(await detail(page).getAttribute('data-version'))
const panel = (page) => page.locator('[data-item-panel]')
@@ -1021,7 +1030,7 @@ await section('数据目录 · 起草:进度、结果与重试', async () => {
check('……写明哪些没写全:失败 1 张、部分完成 1 张', problems.includes('失败 1 张') && problems.includes('部分完成 1 张'), problems)
const shown = await draftBox(page).locator('[data-draft-task]').evaluateAll((els) => els.map((e) => e.getAttribute('data-draft-task')))
check('……逐表只列没写全的 2 张,各自写原因', shown.join(',') === 'store_sales,members'
- && (await draftText(page, '[data-draft-task="store_sales"] [data-draft-task-reason]')).includes('第 2 段没写出来')
+ && (await draftText(page, '[data-draft-task="store_sales"] [data-draft-task-reason]')).includes('第 3 段(promo_attr_19 到 promo_attr_43,共 25 列)没写出来')
&& (await draftText(page, '[data-draft-task="members"] [data-draft-task-reason]')).includes('已重试 2 次'), shown.join(','))
await draftBox(page).locator('[data-draft-show-all]').click()
check('……点「显示全部」列出 3 张', (await draftBox(page).locator('[data-draft-task]').count()) === 3)
@@ -1305,6 +1314,71 @@ await section('数据目录 · 起草:清单上的失败标记与单表重起
await close()
})
+await section('数据目录 · 起草:接连没写出来的提醒、超时与段缩小', async () => {
+ const state = freshState()
+ const { page, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
+ await page.locator('[data-catalog-row]').first().waitFor()
+ check('开始起草(闸机到会员 3 张,用模型)', await startDraft(page, { select: ['gates', 'members'], model: true }))
+ const job = state.jobs.at(-1)
+ const usageLine = () => draftText(page, '[data-draft-usage-line]')
+ settle(state, job, 'store_sales', { status: 'running', parts_done: 1 })
+ check('还没有超时、没写出来的调用:用量只写调用次数和 token,没有提醒、超时的说明和段大小的说明',
+ await until(async () => (await usageLine()).includes('调用模型 1 次')) && !(await usageLine()).includes('超时')
+ && !(await usageLine()).includes('没写出来')
+ && await draftBox(page).locator('[data-draft-notice], [data-draft-timeout-note], [data-draft-part-columns]').count() === 0, await usageLine())
+
+ // 宽表的段接连超时、用完额度:服务端记下超时和没写出来的次数,之后的段缩小到每段 20 列,并给出一句提醒
+ Object.assign(job.usage, { calls: 7, timeouts: 3, failed_calls: 4 })
+ Object.assign(job, { part_columns: 20, notice: DRAFT_NOTICE })
+ const notice = draftBox(page).locator('[data-draft-notice]')
+ check('接连没写出来:进行中醒目显示服务端的提醒,是提醒的样式(不是错误)',
+ await until(async () => (await draftText(page, '[data-draft-notice]')) === DRAFT_NOTICE)
+ && (await notice.getAttribute('data-notice')) === 'warn', await draftText(page, '[data-draft-notice]'))
+ const above = await page.evaluate(() => {
+ const n = document.querySelector('[data-draft-notice]')?.getBoundingClientRect()
+ const list = document.querySelector('[data-draft-tasks]')?.getBoundingClientRect()
+ return !!n && !!list && n.bottom <= list.top
+ })
+ check('……提醒在逐表进度上面,不用往下翻', above)
+ // 没写出来的次数里含超时的:写成「其中」,免得读成两个数相加
+ check('……用量写「没写出来 4 次(其中超时 3 次)」', (await usageLine()).includes('没写出来 4 次(其中超时 3 次)'), await usageLine())
+ check('……写明超时的调用不计用量', (await draftText(page, '[data-draft-timeout-note]')).includes('超时的调用不计用量'),
+ await draftText(page, '[data-draft-timeout-note]'))
+ check('……写明之后的段每段最多 20 列', (await draftText(page, '[data-draft-part-columns]')).includes('每段最多 20 列'),
+ await draftText(page, '[data-draft-part-columns]'))
+ await shot(page, 'catalog-draft-notice')
+
+ // 关掉对话框:上方的进度也带着提醒;好转后服务端清掉提醒,两处都收起
+ await page.locator('[data-draft-hide]').click()
+ const strip = page.locator('[data-catalog-draft-progress]')
+ const stripNotice = strip.locator('[data-catalog-draft-notice]')
+ check('在后台继续:上方的进度也带着提醒(全文在悬停里)', await until(async () => (await stripNotice.count()) === 1)
+ && (await stripNotice.getAttribute('title')) === DRAFT_NOTICE && (await stripNotice.innerText()).length > 0,
+ await stripNotice.getAttribute('title', { timeout: 500 }).catch(() => ''))
+ await shot(page, 'catalog-draft-strip-notice')
+ job.notice = null
+ check('……好转后服务端清掉提醒:上方的进度不再带着', await until(async () => (await stripNotice.count()) === 0))
+ await strip.locator('[data-catalog-draft-open]').click()
+ check('……点开对话框:提醒也不再显示,超时和没写出来的次数照旧', await until(async () => (await draftPhase(page)) === 'running')
+ && await notice.count() === 0 && (await usageLine()).includes('其中超时 3 次'), await usageLine())
+
+ // 又接连没写出来:提醒再出现;任务结束以后不再显示(停止、缩小范围的建议只对进行中的任务有用),用量里的次数和
+ // 超时不计用量的说明照旧,段缩小的说明收起
+ Object.assign(job.usage, { timeouts: 5, failed_calls: 6 })
+ Object.assign(job, { part_columns: 10, notice: DRAFT_NOTICE })
+ check('又接连没写出来:提醒再出现,段缩小到每段 10 列', await until(async () => (await draftText(page, '[data-draft-notice]')) === DRAFT_NOTICE)
+ && (await draftText(page, '[data-draft-part-columns]')).includes('每段最多 10 列'), await draftText(page, '[data-draft-part-columns]'))
+ for (const name of ['gates', 'store_sales', 'members']) settle(state, job, name, { added: 1, model_items: 1 })
+ finishJob(job)
+ check('结束:不再显示提醒和段缩小的说明,用量里的次数和超时不计用量的说明照旧', await until(async () => (await draftPhase(page)) === 'done')
+ && await notice.count() === 0 && await draftBox(page).locator('[data-draft-part-columns]').count() === 0
+ && (await usageLine()).includes('没写出来 6 次(其中超时 5 次)') && (await draftText(page, '[data-draft-timeout-note]')).includes('超时的调用不计用量'),
+ await usageLine())
+ check('没有原生对话框', natives.length === 0, natives.join(' | '))
+ check('没有运行时报错', errors.length === 0, errors[0] ?? '')
+ await close()
+})
+
await section('数据目录 · 表详情与单项审阅', async () => {
const state = freshState()
const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
@@ -2396,14 +2470,19 @@ await section('数据目录 · 窄屏', async () => {
const narrowJob = state.jobs.at(-1)
settle(state, narrowJob, 'visits', { added: 2 })
settle(state, narrowJob, 'store_sales', { status: 'partial', parts_done: 2, added: 4, model_error: WIDE_ERR })
- await until(async () => (await draftText(page, '[data-draft-task="store_sales"] [data-draft-task-reason]')).includes('第 2 段'))
+ // 接连没写出来的提醒、超时和没写出来的次数、段缩小的说明也都在
+ Object.assign(narrowJob.usage, { timeouts: 3, failed_calls: 4 })
+ Object.assign(narrowJob, { part_columns: 20, notice: DRAFT_NOTICE })
+ await until(async () => (await draftText(page, '[data-draft-task="store_sales"] [data-draft-task-reason]')).includes('第 3 段')
+ && (await draftText(page, '[data-draft-notice]')) === DRAFT_NOTICE)
const runFit = await draftFits()
- check('……起草进行中:段进度和长原因折行,弹窗不横向滚动', runFit.ok, JSON.stringify(runFit))
+ check('……起草进行中:段进度、长原因、提醒都折行,弹窗不横向滚动', runFit.ok, JSON.stringify(runFit))
await shot(page, 'catalog-narrow-draft')
await page.locator('[data-draft-hide]').click()
await until(async () => (await page.locator('[data-catalog-draft-progress]').count()) === 1)
- check('……上方的起草进度不撑宽页面', await overflow() <= 1 && !!(await page.locator('[data-catalog-draft-progress]').boundingBox())
- && (await page.locator('[data-catalog-draft-progress]').boundingBox()).width <= 390, String(await overflow()))
+ check('……上方的起草进度(带着提醒)不撑宽页面', await overflow() <= 1 && !!(await page.locator('[data-catalog-draft-progress]').boundingBox())
+ && (await page.locator('[data-catalog-draft-progress]').boundingBox()).width <= 390
+ && await page.locator('[data-catalog-draft-notice]').count() === 1, String(await overflow()))
stopJob(narrowJob)
await until(async () => (await page.locator('[data-catalog-draft-progress]').count()) === 0)
// 宽屏照旧是表格(表头在,列表格 860px 起,框里横向滚动)
From 645064fe47d06c707c95c277edc695c1d9f00bc1 Mon Sep 17 00:00:00 2001
From: ThearchyHelios
Date: Tue, 13 Oct 2026 20:21:54 +0800
Subject: [PATCH 19/29] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95?=
=?UTF-8?q?=E9=87=8D=E6=96=B0=E8=B5=B7=E8=8D=89=E4=B8=8D=E4=B8=A2=E5=86=85?=
=?UTF-8?q?=E5=AE=B9=EF=BC=9B=E5=BA=93=E6=B3=A8=E9=87=8A=E5=B0=B1=E6=98=AF?=
=?UTF-8?q?=E5=90=8D=E5=AD=97=E6=9C=AC=E8=BA=AB=E6=88=96=E6=A0=87=E8=AF=86?=
=?UTF-8?q?=E7=AC=A6=E5=86=99=E6=B3=95=E7=9A=84=E4=B8=8D=E5=BD=93=E4=B8=AD?=
=?UTF-8?q?=E6=96=87=E5=90=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 合并(merge_notes)只改模型起草这一个来源的「覆盖即删」:模型这次对某一项没给值(留空,或者给的值没通过校验、在
_from_model 里就丢掉了),以前模型起草的项留着,只有给了合格的新值才替换。只有这一轮完整起草过(covered 含 llm)、
而这一项说的列已经不在表结构里(列级的项看列名,业务主键、业务日期看它们指着的列)才删;write_draft 把表结构里的
列名交给合并。注释、外键、命名推断、剖析各自的规矩不变,人工确认、驳回的不动。删除数因此只在真删时才有。
以前整库重跑一次,模型这次没给的几百项全被删掉,有的表覆盖率从八成多掉到五成,表仍记「已完成」。
- 库注释(comment_role):和表名(列注释和列名)相同(忽略大小写、下划线、连字符、空白,表名带 schema 前缀的也比
不带前缀的),或者是标识符写法(只有字母、数字、下划线、点,不带空格),不当中文名、也不当说明,不记进目录;交给
模型的表结构里照旧有这条注释。以前这种注释短、不带句读就当中文名,库注释的级别又高于模型起草,模型起草的中文名
永远进不了目录。已有目录不另做迁移:起草到那张表时,旧的那条随这一轮的注释删掉,模型起草的中文名就能进来。
---
backend/app/data/catalog.py | 91 ++++++++++++++++++++++++-----
backend/tests/test_catalog_draft.py | 42 +++++++++++++
backend/tests/test_catalog_merge.py | 51 +++++++++++++++-
3 files changed, 167 insertions(+), 17 deletions(-)
diff --git a/backend/app/data/catalog.py b/backend/app/data/catalog.py
index 3d0d4275..f92f824b 100644
--- a/backend/app/data/catalog.py
+++ b/backend/app/data/catalog.py
@@ -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,11 @@ 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()
@@ -1180,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}
@@ -1371,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)。
"""
@@ -1382,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)
@@ -1767,11 +1823,14 @@ async def write_draft(session: AsyncSession, source: Any, table: str, modeled: A
没用模型时为 None)有可用内容的再并上。同步起草和后台起草任务(每张表一结束就调)共用这一处。
- 并入已有目录按 merge_notes 的规则:人工确认、驳回过的项不动。内容没变不写、不升版本。
- - 模型只写出一部分的,模型那份不算完整起草过(covered 不含 llm),已有的模型起草项不会因此被删。
+ - 模型这次没给的项(留空、没通过校验)不删以前起草的;列已经不在表结构里的,模型各段都写出来时(covered 含 llm)
+ 照旧删掉。模型只写出一部分的不算完整起草过(covered 不含 llm)。
- 写入撞上别人刚改过(乐观锁)就重读重并,最多三次。
"""
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
@@ -1783,9 +1842,10 @@ async def write_draft(session: AsyncSession, source: Any, table: str, modeled: A
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)
+ 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)
+ 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
@@ -2244,7 +2304,8 @@ __all__ = [
"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",
diff --git a/backend/tests/test_catalog_draft.py b/backend/tests/test_catalog_draft.py
index f9b54aa8..9bf50ea5 100644
--- a/backend/tests/test_catalog_draft.py
+++ b/backend/tests/test_catalog_draft.py
@@ -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),数据目录不重复它们。"""
diff --git a/backend/tests/test_catalog_merge.py b/backend/tests/test_catalog_merge.py
index f7584664..260e9bf5 100644
--- a/backend/tests/test_catalog_merge.py
+++ b/backend/tests/test_catalog_merge.py
@@ -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")}}}
+
+
# ---------------------------------------------------------------- 单项审阅
From 975dde11466d92af0235b0e29ad783fb9874f24a Mon Sep 17 00:00:00 2001
From: ThearchyHelios
Date: Tue, 13 Oct 2026 20:22:53 +0800
Subject: [PATCH 20/29] =?UTF-8?q?=E8=B5=B7=E8=8D=89=E5=BC=95=E6=93=8E?=
=?UTF-8?q?=E6=8C=89=E9=AA=8C=E6=94=B6=E4=BF=AE=E6=AD=A3=EF=BC=9A=E5=88=97?=
=?UTF-8?q?=E5=88=86=E6=AE=B5=E5=9D=87=E5=88=86=E3=80=81=E8=B6=85=E6=97=B6?=
=?UTF-8?q?=E6=8B=86=E5=B0=8F=E3=80=81=E6=AE=B5=E5=A4=A7=E5=B0=8F=E9=9A=8F?=
=?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=87=8F=E5=8D=8A=E3=80=81=E6=AF=8F=E6=AE=B5?=
=?UTF-8?q?=208=20=E6=AC=A1=E5=B0=81=E9=A1=B6=E3=80=81=E6=8F=90=E9=86=92?=
=?UTF-8?q?=E3=80=81=E8=B6=85=E6=97=B6=E4=B8=8E=E6=B2=A1=E5=86=99=E5=87=BA?=
=?UTF-8?q?=E6=9D=A5=E7=9A=84=E8=AE=A1=E6=95=B0=E3=80=81=E6=A8=A1=E5=9E=8B?=
=?UTF-8?q?=E7=95=99=E7=A9=BA=E7=9A=84=E5=88=97=E3=80=81=E4=B8=8D=E6=8F=92?=
=?UTF-8?q?=E9=98=9F=E3=80=81=E5=8E=9F=E5=9B=A0=E5=86=99=E6=B8=85=E5=93=AA?=
=?UTF-8?q?=E5=87=A0=E6=AE=B5=E5=93=AA=E5=87=A0=E5=88=97?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 均分:超过 40 列的表,列分段数是列数除以 40 向上取整,各段列数平均(相差不超过 1),不再留下 2 到 8 列的尾段(实测
会思考的模型常对这种小段只给空壳);表级那段、窄表拼段、parts_total 不变。
- 拆不开的段(不到 10 列)只给空壳:再问一次,结构化输出换纯文本,已经走纯文本的原样再问,仍是空壳才记没写出来。
- 超时当作这一段太大:不再原样重试同样大小的段,按额度用完的办法拆小再问;网络错误、429、5xx 仍原样退避重试。
- 段大小随模型调整:一次起草里额度用完或超时累计 2 次,之后新开始的段列数上限减半(40 → 20 → 10,最低 10),新开始
的段超过上限就先按上限均分开(多表的段按表重新拼);任务记当前的上限 part_columns。
- 封顶:一段(连同它拆出来的、补问的、重试的)最多调用 8 次,到了次数还没写完的记没写出来、原因写清。止损照旧。
- 提醒:最近 6 次调用有 4 次以上没写出来(额度用完、超时、空壳、出错),任务的 notice 写一句提醒(次数、原因,建议在
「设置 → 偏好设置」的「起草模型」换一个模型、减少表或停止);之后最近 6 次里少于 2 次就清掉。
- 用量:进度回调 usage 换成 call_done(CallReport:用量、没写出来的原因种类、当前的提醒和列数上限),任务的用量加
timeouts、failed_calls;超时的调用 token 照实记 0。
- 留空的列:请模型写了、模型也列出了,中文名和含义却都没给(或者给的都没通过校验)的列记在 ModelOutcome.blank,逐表
记 blank_columns 和最多 5 个列名 blank_sample,任务上是合计,目录清单的 last_draft 也带列数;表的状态不因此变。已确认
的、库注释已经说清的列不算。补问时把漏的列列出来、字段却留空的,算留空,不再记成「漏了这些列」。
- 公平排队:宽表的表级那段写完,它的列分段排到已经排着的段后面(先来先服务),不再插到队首让窄表一直等。
- 原因:几段因为同一个原因没写出来时说「这几段」,同一段拆出来的几块仍是「这一段」;只有真拆小过才补「已拆小重试仍
没写出」(截断后只补剩下的不算);每段写上列的范围,例如「第 5 段(列名 到 列名,共 33 列)」,窄表写出是哪几列。
- 库:catalog_draft_jobs 加 timeouts、failed_calls、part_columns、notice,catalog_draft_tasks 加 blank_columns、
blank_sample,旧库启动时补列(存量回填 0 和空)。假模型加 hang(头几次调用一直不回,用来模拟超时)。
---
backend/app/api/catalog.py | 3 +-
backend/app/data/catalog.py | 30 +-
backend/app/data/catalog_draft.py | 468 ++++++++++++++-----
backend/app/data/catalog_jobs.py | 54 ++-
backend/app/db/base.py | 8 +
backend/app/db/models.py | 12 +-
backend/tests/fixtures/catalog/fake_model.py | 17 +-
backend/tests/test_catalog_draft.py | 2 +-
backend/tests/test_catalog_draft_engine.py | 383 +++++++++++++--
backend/tests/test_catalog_draft_jobs.py | 131 +++++-
10 files changed, 928 insertions(+), 180 deletions(-)
diff --git a/backend/app/api/catalog.py b/backend/app/api/catalog.py
index a0945853..624a2c12 100644
--- a/backend/app/api/catalog.py
+++ b/backend/app/api/catalog.py
@@ -144,7 +144,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
+ # 这张表最近一次起草任务里的那一项:{status, job_id, finished_at, error, model_error, blank_columns},没起草过
+ # 是 null
"last_draft": last_draft,
}
diff --git a/backend/app/data/catalog.py b/backend/app/data/catalog.py
index f92f824b..cdb99192 100644
--- a/backend/app/data/catalog.py
+++ b/backend/app/data/catalog.py
@@ -1463,12 +1463,23 @@ 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 = 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
-#: 单次调用最多等多久(秒)。会思考的模型想得久,一段 40 列常常要几分钟
+#: 单次调用最多等多久(秒)。会思考的模型想得久,一段 40 列常常要几分钟;超时了拆小再问(同额度用完)
MODEL_TIMEOUT_S = 420
#: 输出额度。会思考的模型(经 OpenAI 兼容网关的推理模型等)思考也算在里面:8192 时上百列的表常常想完就没额度
#: 写正文。保留思考(不关),加大额度;服务方嫌额度太大时退回 MODEL_FALLBACK_MAX_TOKENS(同 copilot、recipe_ai)
@@ -1477,10 +1488,10 @@ MODEL_FALLBACK_MAX_TOKENS = 8192
#: 一段没写出来(额度用完、只给空壳)时拆成两半再问:最多拆几层、拆到最少几列为止
MODEL_SPLIT_DEPTH = 3
MODEL_SPLIT_MIN_COLUMNS = 5
-#: 止损:连续这么多段拆到底仍一项没写出来,这次起草后面的段不再拆小重试(一段 40 列拆到底要 15 次调用,模型明显
-#: 用不了时不该照这个倍数烧额度)
+#: 止损:连续这么多段拆到底仍一项没写出来,这次起草后面的段不再拆小重试(模型明显用不了时不该一段一段拆到底烧额度;
+#: 每段另有 MODEL_PART_MAX_CALLS 次封顶)
MODEL_GIVE_UP_AFTER = 2
-#: 网络错误、429、5xx、超时最多重试几次,每次重试前等多久(秒,依次取用)
+#: 网络错误、429、5xx 最多重试几次,每次重试前等多久(秒,依次取用)。超时不原样重试,当作这一段太大、拆小再问
MODEL_RETRIES = 2
MODEL_RETRY_DELAYS_S = (10.0, 30.0)
#: 不指定表时起草使用次数最多的前几张
@@ -1794,6 +1805,8 @@ class TableDraftResult:
status: str = "ok"
#: 模型分几段起草(不用模型时为 None)
parts_total: int | None = None
+ #: 模型拿不准、留空了的列(catalog_draft.ModelOutcome.blank,按表结构的顺序);表的状态不因此变
+ blank: list[str] = field(default_factory=list)
@dataclass
@@ -1837,6 +1850,7 @@ async def write_draft(session: AsyncSession, source: Any, table: str, modeled: A
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):
@@ -2298,8 +2312,10 @@ __all__ = [
"CatalogEntry", "CatalogInvalid", "CatalogModelUnavailable", "CatalogPathError", "DRAFT_DEFAULT_TABLES",
"DraftModel", "DraftReport", "INFERRED_MARK", "ITEM_SOURCES", "ITEM_STATUSES", "JoinEdge", "KIND_LABEL",
"MEASURE_LABEL", "MODEL_BATCH_COLUMNS", "MODEL_BATCH_TABLES", "MODEL_CONCURRENCY", "MODEL_EMPTY_REASON",
- "MODEL_FALLBACK_MAX_TOKENS", "MODEL_MAX_TOKENS", "MODEL_RETRIES", "MODEL_RETRY_DELAYS_S", "MODEL_SPLIT_DEPTH",
- "MODEL_SPLIT_MIN_COLUMNS", "MODEL_TIMEOUT_S", "MergeStats", "NEW_RELATION_PATH", "PATCH_MAX_CHANGES",
+ "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",
diff --git a/backend/app/data/catalog_draft.py b/backend/app/data/catalog_draft.py
index bbacc754..90c6b33e 100644
--- a/backend/app/data/catalog_draft.py
+++ b/backend/app/data/catalog_draft.py
@@ -9,15 +9,21 @@
**分段。** 每次调用最多 MODEL_BATCH_TABLES 张表、合计 MODEL_BATCH_COLUMNS 列:列数不超过它的表按顺序拼在一起
(整表段);超过它的宽表拆成「表级一段 + 列分段」——表级那段给全部列名和类型做背景、只要表级的项(中文名、粒度、
-表类型、业务主键、业务日期);列分段每段 MODEL_BATCH_COLUMNS 列,带着表名、表注释和表级起草结果做背景,只要这几列。
-最后合并成这张表的一份,照旧经 catalog._from_model 逐项校验(起草要求不能让模型绕过校验)。
+表类型、业务主键、业务日期);列分段数是列数除以 MODEL_BATCH_COLUMNS 向上取整、各段列数平均(相差不超过 1,以前
+按 40 列定长切,留下几列的尾段,实测会思考的模型常对这种小段只给空壳),带着表名、表注释和表级起草结果做背景,只要
+这几列。最后合并成这张表的一份,照旧经 catalog._from_model 逐项校验(起草要求不能让模型绕过校验)。
+
+**段大小随模型调整。** 一次起草里额度用完或超时累计 MODEL_SHRINK_AFTER 次,之后新开始的段列数上限减半(40 → 20
+→ 10,最低 MODEL_MIN_PART_COLUMNS):新开始的段(整表段、列分段)超过上限就先按上限均分开再问。已经开始的段照旧
+按失败原因拆小。
**按失败原因处理,不原样重发。**
-- 思考加输出用完额度(finish_reason=length、LengthFinishReasonError、正文为空而有思考)、只给空壳:把这一段拆成
- 两半再问(多表的段按表拆,单表的按列拆),最小 MODEL_SPLIT_MIN_COLUMNS 列,最多拆 MODEL_SPLIT_DEPTH 层。
- 拆不开的空壳(结构化输出给的)换纯文本问一次:有的网关的结构化输出只给空壳、纯文本却能写——写出来了,这次起草
- 后面都走纯文本。
+- 思考加输出用完额度(finish_reason=length、LengthFinishReasonError、正文为空而有思考)、只给空壳、超时(当作这一段
+ 太大,不原样重试同样大小的段):把这一段拆成两半再问(多表的段按表拆,单表的按列拆),最小
+ MODEL_SPLIT_MIN_COLUMNS 列,最多拆 MODEL_SPLIT_DEPTH 层。拆不开的空壳再问一次:结构化输出给的换纯文本(有的网关的
+ 结构化输出只给空壳、纯文本却能写——写出来了,这次起草后面都走纯文本),已经走纯文本的原样再问;仍是空壳才记没写
+ 出来。
- 结构化输出回了一段说明文字(不是 JSON、也没停在额度上):网关接受了结构化输出的参数却没照做(例如开了丢弃不认识
的参数),提示词里又没有格式说明,模型就写成了说明文字。这次起草后面改走纯文本,这一段换纯文本问一次。有思考
不等于额度用完:只有停在额度上、或者正文为空而有思考才算。
@@ -27,20 +33,30 @@
- 结构化输出这个接口不支持(400 / 422 提到 response_format、json_schema、tool……,或者模型类不支持):这次起草
后面改走纯文本、从正文取 JSON(纯文本那条路另外说清 JSON 的格式)。
- 服务方嫌额度太大:退回 MODEL_FALLBACK_MAX_TOKENS 重来,这次起草后面都用它(catalog.DraftModel.shrink)。
-- 网络错误、429、5xx、超时:等一会儿重试(最多 MODEL_RETRIES 次,退避)。单次调用的时限是 MODEL_TIMEOUT_S。SDK
+- 网络错误、429、5xx:等一会儿重试(最多 MODEL_RETRIES 次,退避)。单次调用的时限是 MODEL_TIMEOUT_S。SDK
自己不再重试(catalog.resolve_draft_model 建模型时 max_retries=0),实际重试次数和报错写的一致。
- 别的错误:这一段没写出来,原因照实写。
+- 封顶:一段(连同它拆出来的、补问的、重试的)最多调用 MODEL_PART_MAX_CALLS 次,到了次数还没写完的记没写出来。
- 止损:连续 MODEL_GIVE_UP_AFTER 段拆到底仍一项没写出来(模型明显用不了,例如思考总是吃满额度),这次起草后面的
- 段不再拆小重试、没写出来就直接记下,报错建议更换起草模型;有一段写出来了就恢复。不然一段拆到底要几十次调用,
- 起草几十张宽表会烧掉数百万 token、跑几个小时。
+ 段不再拆小重试、没写出来就直接记下,报错建议更换起草模型;有一段写出来了就恢复。
+- 提醒:最近 MODEL_NOTICE_WINDOW 次调用里有 MODEL_NOTICE_ON 次以上没写出来(额度用完、超时、空壳、出错),给一句
+ 提醒(次数和原因,建议换起草模型、减少表或停止),经进度回调交给任务;之后少于 MODEL_NOTICE_OFF 次时清掉。以前
+ 看不出调用在反复失败:一个任务 55 分钟只结束 1 张表,界面上只有段进度和用量。
+
+**原因。** 没写出来的原因只写进这张表的 model_error:几段因为同一个原因没写出来时说「这几段」,每段写上列的范围
+(「第 5 段(列名 到 列名,共 33 列)」,列名照表结构原样),真拆小重试过的才补「已拆小重试仍没写出」。
+
+**留空的列。** 请模型写了、模型也列出了,中文名和含义却都没给(或者给的都没通过校验)的列是模型拿不准,不补问,
+记在 ModelOutcome.blank 里(表的状态不因此变)。已确认的、库注释已经说清的列不算(提示词里它们以此为准)。
**并发。** 全进程最多 MODEL_CONCURRENCY 个调用同时进行(model_gate:按事件循环各一个信号量,所有起草任务和同步
起草共用,先来先放)。一次起草里同时只起草 MODEL_CONCURRENCY 段(一段拆小、补缺都做完再起草下一段),同时在等
名额的调用也最多这么多个:不把几百段一下子都排上去,几次起草(不同数据源的任务、同步起草)的调用交替排队,后开始
-的不会等前一个排着的段全部调完。
+的不会等前一个排着的段全部调完。一次起草里按表的先后排队:宽表的表级那段写完,它的列分段排到已经排着的段后面
+(先来先服务),不插到窄表前面。
-**进度。** 调用方给 Hooks:开始调用、每次调用的用量、一段结束、一张表的模型部分全部结束。后台起草任务据此落库,
-每张表一结束就并入目录(data/catalog_jobs.py)。
+**进度。** 调用方给 Hooks:开始调用、每次调用的结果(用量、没写出来的原因、当前的提醒和列数上限)、一段结束、一张表
+的模型部分全部结束。后台起草任务据此落库,每张表一结束就并入目录(data/catalog_jobs.py)。
交给模型的只有表结构(列、类型、主键、外键、唯一约束、注释)和使用者补充的背景,不发任何数据值。写给模型的文字都在
catalog.py 名字带 _SYSTEM / _PROMPT 的常量里;这里的 REASON_* 是给人看的(拼进这张表的 model_error)。
@@ -59,6 +75,7 @@ from dataclasses import dataclass, field
from typing import Any
from app.data import catalog as C
+from app.data.draft_model import DRAFT_MODEL_PLACE
from app.data.names import name_key
logger = logging.getLogger(__name__)
@@ -75,21 +92,33 @@ STRUCTURED_WORDS = ("response_format", "json_schema", "json_object", "tool", "fu
#: 一张表的表级的项在「没写出来」记录里的键(不会和列名撞上)
_TABLE = "\0table"
-# ---- 一段没写出来的原因(给人看,拼进这张表的 model_error)
-REASON_EXHAUSTED = "模型的思考和输出用完了额度({budget} 个 token),这一段没写出来"
+# ---- 一段没写出来的原因(给人看,拼进这张表的 model_error)。{seg} 在拼成一句时换成「这一段」或「这几段」:同一个
+#: 原因落在几段上(宽表的几个列分段)说「这几段」,同一段拆出来的几块仍是「这一段」
+SEG = "{seg}"
+SEG_ONE = "这一段"
+SEG_MANY = "这几段"
+REASON_EXHAUSTED = "模型的思考和输出用完了额度({budget} 个 token),{seg}没写出来"
REASON_SPLIT = ";已拆小重试仍没写出"
REASON_HOLLOW = "模型只给了空壳,没有写出可用的内容"
-REASON_TRUNCATED = "模型的输出被截断,这一段没写完"
+REASON_TRUNCATED = "模型的输出被截断,{seg}没写完"
REASON_FORMAT = "模型的回复不是约定的格式"
REASON_MISSING = "模型的回复里没有这张表"
REASON_UNANSWERED = "模型的回复里漏了这些列,补问一次仍没有给出"
-REASON_TIMEOUT = "模型 {seconds} 秒内没有写完这一段"
+REASON_TIMEOUT = "模型 {seconds} 秒内没有写完{seg}"
REASON_CALL = "调用模型出错:{line}"
REASON_RETRIED = "(已重试 {n} 次)"
REASON_CRASH = "起草这张表时出错:{line}"
+#: 一段到了调用次数的上限(MODEL_PART_MAX_CALLS)还没写完
+REASON_CAPPED = "每段最多调用 {n} 次模型,{seg}到了次数仍没写完,已停止重试"
#: 止损(连续几段拆到底仍一项没写出来,后面的段不再拆小重试)时补在原因后面
REASON_HALTED = ";模型连续 {n} 段都没写出来,已停止拆小重试,建议更换起草模型"
+# ---- 提醒(最近几次调用多数没写出来),经进度回调交给任务(CatalogDraftJob.notice)
+NOTICE = ("最近 {calls} 次调用有 {failed} 次没写出来({kinds})。可以在{place}换一个模型、减少这次起草的表,"
+ "或者停止这次起草")
+#: 一次调用没写出来的原因种类 → 提醒里的说法(按这个顺序列)
+FAILURE_LABEL = {"exhausted": "额度用完", "timeout": "超时", "hollow": "只给空壳", "error": "出错"}
+
# ==========================================================================
# 对外的形状
@@ -111,7 +140,8 @@ class ModelOutcome:
status:ok 各段都写出来了;partial 有的段写出来、有的没有(error 写清哪部分、为什么);failed 一项都没写出来
(error 写原因)。notes 是写出来的那些(已逐项校验,llm / proposed);items 是项数。parts_total 分几段起草
- (整表段是 1,宽表是 1 + 列分段数),parts_done 已经结束了几段。"""
+ (整表段是 1,宽表是 1 + 列分段数),parts_done 已经结束了几段。blank:模型拿不准、留空了的列(按表结构的顺序;
+ 表的状态不因此变)。"""
status: str
notes: dict[str, Any]
@@ -119,6 +149,26 @@ class ModelOutcome:
error: str | None
parts_total: int
parts_done: int
+ blank: list[str] = field(default_factory=list)
+
+
+@dataclass(frozen=True)
+class CallReport:
+ """一次调用的结果(进度回调 call_done 收到的)。
+
+ failure:没写出来的原因种类,写出来了是 None——exhausted 额度用完(一项都没捡回来)、timeout 超时、hollow 只给空壳
+ (回复里没有可用的内容)、error 出错(会重试的那次也算一次)。超时的调用拿不到用量,token 记 0(网关那边可能已经
+ 生成了)。notice、part_columns 是这次调用之后的:提醒(没有为 None)、新开始的段每段最多几列。"""
+
+ input_tokens: int
+ output_tokens: int
+ failure: str | None
+ notice: str | None
+ part_columns: int
+
+ @property
+ def timeout(self) -> bool:
+ return self.failure == "timeout"
@dataclass
@@ -126,22 +176,37 @@ class Hooks:
"""进度回调,都是 async,出错只记日志、不影响起草。
- call_started(表名列表):一次调用拿到并发名额、马上要发出去了;
- - usage(输入 token, 输出 token):每次调用一回(拿不到用量的是 0),条数就是调用次数;
+ - call_done(CallReport):每次调用一回(条数就是调用次数),带着用量、没写出来的原因、当前的提醒和列数上限;
- part_done(表名):这张表的一段结束了(写没写出来都算);
- table_done(表名, ModelOutcome):这张表的模型部分全部结束。"""
call_started: Callable[[list[str]], Awaitable[None]] | None = None
- usage: Callable[[int, int], Awaitable[None]] | None = None
+ call_done: Callable[[CallReport], Awaitable[None]] | None = None
part_done: Callable[[str], Awaitable[None]] | None = None
table_done: Callable[[str, ModelOutcome], Awaitable[None]] | None = None
def parts_for(columns: int) -> int:
- """一张有这么多列的表分几段起草:不超过 MODEL_BATCH_COLUMNS 列是 1 段,否则是表级一段加列分段。"""
+ """一张有这么多列的表分几段起草:不超过 MODEL_BATCH_COLUMNS 列是 1 段,否则是表级一段加列分段(列数除以
+ MODEL_BATCH_COLUMNS 向上取整)。"""
width = max(1, int(C.MODEL_BATCH_COLUMNS))
return 1 if columns <= width else 1 + -(-columns // width)
+def _even(columns: Sequence[str], parts: int) -> list[tuple[str, ...]]:
+ """按顺序切成 parts 段,各段列数平均(相差不超过 1,多出来的放在前面几段)。"""
+ columns = tuple(columns)
+ parts = max(1, min(int(parts), len(columns) or 1))
+ size, extra = divmod(len(columns), parts)
+ out: list[tuple[str, ...]] = []
+ start = 0
+ for i in range(parts):
+ n = size + (1 if i < extra else 0)
+ out.append(columns[start:start + n])
+ start += n
+ return out
+
+
async def run_model_draft(model: Any, source: Any, tables: Sequence[str], *,
existing: Mapping[str, Mapping[str, Any]] | None = None,
background: Background | None = None,
@@ -237,25 +302,36 @@ class _Reply:
error: str | None = None
#: 结构化输出回了一段说明文字(error 是「不是约定的格式」):换纯文本还可能写出来
prose: bool = False
+ #: 这次调用超时了(当作这一段太大:拆小再问,不原样重试)
+ timeout: bool = False
+ #: 这一段的调用次数用完了,这次没有调用
+ capped: bool = False
+ #: 这次调用的用量 (输入, 输出),还没报给进度回调;没有调用(capped)的是 None
+ usage: tuple[int, int] | None = None
@dataclass(frozen=True)
class _Why:
- """一部分没写出来的原因。base 给人看的原因;split 拆小重试过;halted 本来还能拆小,因为前面连续几段都没写出来
- (止损)才没拆。"""
+ """一部分没写出来的原因。base 给人看的原因(可以带 SEG,拼成一句时换成「这一段」「这几段」);split 真拆小重试过;
+ halted 本来还能拆小,因为前面连续几段都没写出来(止损)才没拆。"""
base: str
split: bool = False
halted: bool = False
-def _join(whys: Iterable[_Why]) -> str:
- """几处没写出来的原因拼成一句:同样的原因只说一次(最多说两种),拆小重试过、止损的说明各在最后补一次(只看说出来
- 的那几种)。不然宽表的表级那段拆不开、列分段拆过时,同一句原因会说两遍。"""
- whys = list(whys)
- shown = list(dict.fromkeys(w.base for w in whys))[:2]
- told = [w for w in whys if w.base in shown]
- text = ";".join(shown)
+def _join(failures: Iterable[tuple[_Why, int]]) -> str:
+ """几处没写出来的原因拼成一句。failures 是 (原因, 属于这张表的第几段)。
+
+ 同样的原因只说一次(最多说两种);同一个原因落在几段上说「这几段」,同一段拆出来的几块仍是「这一段」;拆小重试过、
+ 止损的说明各在最后补一次(只看说出来的那几种)。不然宽表的表级那段拆不开、列分段拆过时,同一句原因会说两遍。"""
+ failures = list(failures)
+ parts: dict[str, set[int]] = {}
+ for why, part in failures:
+ parts.setdefault(why.base, set()).add(part)
+ shown = list(parts)[:2]
+ told = [w for w, _ in failures if w.base in shown]
+ text = ";".join(base.replace(SEG, SEG_MANY if len(parts[base]) > 1 else SEG_ONE) for base in shown)
if any(w.split for w in told):
text += REASON_SPLIT
if any(w.halted for w in told):
@@ -263,6 +339,22 @@ def _join(whys: Iterable[_Why]) -> str:
return text
+def _columns_text(order: Sequence[str], columns: Iterable[str], *, count: bool) -> str:
+ """说清是哪几列(列名照表结构原样,按表结构的顺序):一到三列逐个写;更多的连着就写「头 到 尾」,不连着写前三个加
+ 「等」。count:后面带上列数(「,共 33 列」)。"""
+ pos = {name: i for i, name in enumerate(order)}
+ cols = sorted(dict.fromkeys(columns), key=lambda c: pos.get(c, len(pos)))
+ n = len(cols)
+ if n <= 3:
+ text = "、".join(cols)
+ elif pos.get(cols[-1], -1) - pos.get(cols[0], 0) + 1 == n:
+ text = f"{cols[0]} 到 {cols[-1]}"
+ else:
+ head = "、".join(cols[:3])
+ return f"{head} 等 {n} 列" if count else f"{head} 等"
+ return f"{text},共 {n} 列" if count else text
+
+
class _Cut:
"""被截断的容器(对象或数组):value 里只有已经完整的成员,最后一个没写完的容器成员也包成 _Cut 留着,好往里捡。"""
@@ -541,6 +633,12 @@ def _text_reply(raw: Any) -> _Reply:
return _Reply(exhausted=cut or _thought(raw))
+def _usage_of(raw: Any) -> tuple[int, int]:
+ """一次调用的用量 (输入, 输出):拿不到的是 0(测试的假模型没有原始回复)。"""
+ meta = getattr(raw, "usage_metadata", None) or {}
+ return int(meta.get("input_tokens") or 0), int(meta.get("output_tokens") or 0)
+
+
def _unpack(out: Any) -> tuple[Any, Any]:
"""with_structured_output(include_raw=True) 交回 {raw, parsed, parsing_error};测试的假模型直接交回解析好的对象。"""
if isinstance(out, dict) and "raw" in out and ("parsed" in out or "parsing_error" in out):
@@ -602,6 +700,12 @@ class _Table:
"""这一段请写的部分拿到了内容。"""
return (piece.table_level and self.got_table) or any(c in self.got_columns for c in piece.columns)
+ def part_of(self) -> dict[str, int]:
+ """每一列(表级的项是 _TABLE)属于这张表的第几段。"""
+ out = {col: piece.part for piece in self.parts for col in piece.columns}
+ out[_TABLE] = 1
+ return out
+
def _restrict(notes: Mapping[str, Any], piece: _Piece) -> dict[str, Any]:
"""只留这一段请模型写的部分:没请写表级的项就不要表级的项,列只要请写的那几列。"""
@@ -624,6 +728,27 @@ class _Work:
then: list[_Work] = field(default_factory=list)
+@dataclass
+class _Budget:
+ """一段(新开始的一段,连同它拆出来的、补问的、重试的)还能调用几次模型。"""
+
+ left: int
+
+
+def _pack(pieces: list[_Piece], cap: int) -> list[list[_Piece]]:
+ """多表的段按顺序重新拼成每段不超过 cap 列(超过 cap 的表自成一段)。"""
+ groups: list[list[_Piece]] = []
+ used = 0
+ for piece in pieces:
+ n = len(piece.columns)
+ if not groups or used + n > cap:
+ groups.append([])
+ used = 0
+ groups[-1].append(piece)
+ used += n
+ return groups
+
+
def _halves(pieces: list[_Piece]) -> tuple[list[_Piece], list[_Piece]] | None:
"""拆成两半:多表的段按表拆;单表的按列拆,两半都至少 MODEL_SPLIT_MIN_COLUMNS 列(表级的项跟着前一半)。拆不开返回 None。"""
if len(pieces) > 1:
@@ -659,6 +784,12 @@ class _Run:
self.gate = asyncio.Semaphore(max(1, int(C.MODEL_CONCURRENCY)))
#: 连续几段拆到底仍一项没写出来(止损用,有一段写出来就清零)
self.dead_streak = 0
+ #: 新开始的段每段最多几列:额度用完或超时(strikes)累计 MODEL_SHRINK_AFTER 次减半一次
+ self.part_columns = max(1, int(C.MODEL_BATCH_COLUMNS))
+ self.strikes = 0
+ #: 最近几次调用没写出来的原因种类(写出来了是 None),提醒看它
+ self.recent: collections.deque[str | None] = collections.deque(maxlen=max(1, int(C.MODEL_NOTICE_WINDOW)))
+ self.notice: str | None = None
@property
def halted(self) -> bool:
@@ -669,8 +800,9 @@ class _Run:
async def run(self) -> dict[str, ModelOutcome]:
"""按计划的先后起草:同时只起草 MODEL_CONCURRENCY 段,一段(连同拆小、补缺)做完再取下一段;宽表的表级那段
- 写完,它的列分段排到最前面。不一下子把所有段(包括宽表的全部列分段)都排上去:那样后开始的另一次起草要等这次
- 排着的段全部调完;拆小重试也排在所有段的第一次调用后面,一段要等几乎所有调用都做完才结束,止损形同虚设。"""
+ 写完,它的列分段排到队尾(先来先服务:排在已经排着的窄表段后面,不插队)。不一下子把所有段(包括宽表的全部列
+ 分段)都排上去:那样后开始的另一次起草要等这次排着的段全部调完;拆小重试也排在所有段的第一次调用后面,一段要
+ 等几乎所有调用都做完才结束,止损形同虚设。"""
queue: collections.deque[_Work] = collections.deque()
for unit in self._plan():
if isinstance(unit, list):
@@ -704,7 +836,8 @@ class _Run:
return {name: self._outcome(t) for name, t in self.tables.items()}
def _plan(self) -> list[list[str] | str]:
- """分段:窄表按顺序拼成整表段(列表),宽表一个一项(表名)。各项按第一张表在 tables 里的位置排。"""
+ """分段:窄表按顺序拼成整表段(列表),宽表一个一项(表名),列分段均分(_even)。各项按第一张表在 tables 里的
+ 位置排。"""
width = max(1, int(C.MODEL_BATCH_COLUMNS))
most = max(1, int(C.MODEL_BATCH_TABLES))
units: list[list[str] | str] = []
@@ -715,8 +848,7 @@ class _Run:
n = len(st.columns)
if n > width:
st.parts = [_Piece(name, (), True, 1)] + [
- _Piece(name, tuple(st.columns[i:i + width]), False, k + 2)
- for k, i in enumerate(range(0, n, width))]
+ _Piece(name, cols, False, k + 2) for k, cols in enumerate(_even(st.columns, -(-n // width)))]
units.append(name)
continue
st.parts = [_Piece(name, tuple(st.columns), True, 1)]
@@ -728,20 +860,40 @@ class _Run:
return units
async def _work(self, work: _Work, queue: collections.deque[_Work], left: dict[str, int]) -> None:
- """起草一段;它之后的段(宽表的列分段)排到最前面;一张表的段都结束了,这张表的模型部分就结束了。"""
- await self._guarded(work.pieces)
+ """起草一段;它之后的段(宽表的列分段)排到队尾;一张表的段都结束了,这张表的模型部分就结束了。"""
+ await self._start(work.pieces)
for piece in work.pieces:
await self._part_done(piece.table)
- queue.extendleft(reversed(work.then))
+ queue.extend(work.then)
for piece in work.pieces:
left[piece.table] -= 1
if not left[piece.table]:
await self._table_done(piece.table)
+ async def _start(self, pieces: list[_Piece]) -> None:
+ """开始起草一段。超过当前的列数上限(额度用完、超时多了会减半)的先按上限分开:多表的段按表重新拼,单张表的
+ 按列均分(表级的项跟着第一块,先写它,后面几块拿它做背景)。分开的每一块各算一段(各有调用次数的上限)。"""
+ cap = max(1, self.part_columns)
+ if sum(len(p.columns) for p in pieces) <= cap:
+ await self._guarded(pieces)
+ return
+ if len(pieces) > 1:
+ await asyncio.gather(*(self._start(group) for group in _pack(pieces, cap)))
+ return
+ piece = pieces[0]
+ chunks = _even(piece.columns, -(-len(piece.columns) // cap))
+ first = [_Piece(piece.table, chunks[0], piece.table_level, piece.part)]
+ rest = [[_Piece(piece.table, cols, False, piece.part)] for cols in chunks[1:]]
+ if piece.table_level:
+ await self._guarded(first)
+ await asyncio.gather(*(self._guarded(group) for group in rest))
+ else:
+ await asyncio.gather(*(self._guarded(group) for group in [first, *rest]))
+
async def _guarded(self, pieces: list[_Piece]) -> None:
"""起草一段;意料之外的错误只记这一段没写出来,不影响别的段和别的表。"""
try:
- await self._segment(pieces, 0, False)
+ await self._segment(pieces, 0, False, _Budget(max(1, int(C.MODEL_PART_MAX_CALLS))))
except asyncio.CancelledError:
raise
except Exception as e: # noqa: BLE001
@@ -762,111 +914,132 @@ class _Run:
# ---------------------------------------------------------------- 一段
- async def _segment(self, pieces: list[_Piece], depth: int, followup: bool, *, mode: str | None = None,
- last: bool = False) -> None:
+ async def _segment(self, pieces: list[_Piece], depth: int, followup: bool, budget: _Budget, *,
+ halved: bool = False, mode: str | None = None, last: bool = False) -> None:
"""问一段,拿到的并进各表;按失败原因拆开、补缺、记下没写出来的。
depth:已经拆了几层(截断后只补剩下的也算一层)。followup:这一段是在补缺(补过就不再补,见 _after_followup)。
+ budget:这一段还能调用几次。halved:这几块是真拆小(_split)出来的——原因里「已拆小重试」只在这时写。
mode / last:换纯文本问的那一次——拆不开的空壳、结构化输出回了说明文字(再不行就记没写出来)。"""
- reply = await self._ask(pieces, mode)
+ reply = await self._ask(pieces, budget, mode)
+ if reply.capped:
+ self._fail(pieces, self._capped(halved))
+ return
+ if reply.timeout:
+ await self._report(reply, wrote=False)
+ await self._split(pieces, depth, "timeout", followup, budget, halved)
+ return
if reply.error is not None:
+ await self._report(reply, wrote=False)
if reply.prose and not last:
- await self._prose_to_text(pieces, depth, followup)
+ await self._prose_to_text(pieces, depth, followup, budget, halved)
return
- self._fail(pieces, reply.error)
+ self._fail(pieces, _Why(reply.error, split=halved))
return
gains = self._take(pieces, reply.entries)
anything = any(g.items for g in gains)
+ await self._report(reply, wrote=anything)
if last and anything and self.mode != "text":
# 结构化输出只给空壳、纯文本写出来了:这个模型的结构化输出靠不住,这次起草后面都走纯文本
self.mode = "text"
logger.info("起草数据目录:结构化输出只给了空壳,纯文本写出来了,后面改走纯文本")
if reply.exhausted or reply.truncated:
- rest = self._remainder(pieces, gains, truncated=True)
+ rest = self._remainder(pieces, gains, truncated=True, followup=followup)
if not rest:
return
kind = "exhausted" if reply.exhausted else "truncated"
if not anything:
- await self._split(rest, depth, kind, followup)
+ await self._split(rest, depth, kind, followup, budget, halved)
elif depth < int(C.MODEL_SPLIT_DEPTH):
- await self._segment(rest, depth + 1, followup) # 捡回了一部分:只补剩下的
+ # 捡回了一部分:只补剩下的(不是拆小)
+ await self._segment(rest, depth + 1, followup, budget, halved=halved)
else:
- self._fail(rest, self._reason(kind, split=True))
+ self._fail(rest, self._reason(kind, split=halved))
return
if not anything and not followup:
if last:
halted = self._stopped(pieces, depth)
- self._fail(pieces, lambda p: self._reason("hollow", split=depth > 0, piece=p, halted=halted))
+ self._fail(pieces, lambda p: self._reason("hollow", split=halved, piece=p, halted=halted))
return
- await self._split(pieces, depth, "hollow", followup)
+ await self._split(pieces, depth, "hollow", followup, budget, halved)
return
- rest = self._remainder(pieces, gains, truncated=False)
+ rest = self._remainder(pieces, gains, truncated=False, followup=followup)
if not rest:
return
if followup:
- await self._after_followup(rest, depth, last=last)
+ await self._after_followup(rest, depth, budget, halved, last=last)
return
- await self._segment(rest, depth, True)
+ await self._segment(rest, depth, True, budget, halved=halved)
- async def _after_followup(self, rest: list[_Piece], depth: int, *, last: bool) -> None:
+ async def _after_followup(self, rest: list[_Piece], depth: int, budget: _Budget, halved: bool, *,
+ last: bool) -> None:
"""补问过仍然没给的部分(补过就不再补)。
- - 这张表已经拿到了内容:没列出来的列记没写出来(这张表是 partial,旧的模型起草项不删)。模型列出了、字段留空
- 的列是拿不准,不在这里(_remainder 只把没列出来的列交过来);
- - 这张表到现在还一项都没有(回复里没有这张表、只给了空壳):照第一次只给空壳的规矩,拆小再问,拆不开换纯文本
- 问一次。拆出来的半段是新的一段(还能补缺一次),层数照算。"""
+ - 这张表已经拿到了内容:补问时仍没列出来的列记没写出来(这张表是 partial)。补问时列出了、字段留空的列是拿不准
+ (记留空),不在这里(_remainder 只把没列出来的列交过来);
+ - 这张表到现在还一项都没有(回复里没有这张表、只给了空壳):照第一次只给空壳的规矩,拆小再问,拆不开再问一次。
+ 拆出来的半段是新的一段(还能补缺一次),层数照算。"""
given = [p for p in rest if self.tables[p.table].has_any()]
empty = [p for p in rest if not self.tables[p.table].has_any()]
if given:
- self._fail(given, _Why(REASON_UNANSWERED))
+ self._fail(given, _Why(REASON_UNANSWERED, split=halved))
if not empty:
return
if last:
halted = self._stopped(empty, depth)
- self._fail(empty, lambda p: self._reason("hollow", split=depth > 0, piece=p, halted=halted))
+ self._fail(empty, lambda p: self._reason("hollow", split=halved, piece=p, halted=halted))
return
- await self._split(empty, depth, "hollow", False)
+ await self._split(empty, depth, "hollow", False, budget, halved)
- async def _prose_to_text(self, pieces: list[_Piece], depth: int, followup: bool) -> None:
+ async def _prose_to_text(self, pieces: list[_Piece], depth: int, followup: bool, budget: _Budget,
+ halved: bool) -> None:
"""结构化输出回了一段说明文字:网关接受了结构化输出的参数却没照做。这次起草后面都走纯文本(另带格式说明),
这一段换纯文本问一次。纯文本额度用完了还能照常拆小(拆出来的半段也走纯文本,不再先问一遍结构化输出)。"""
if self.mode != "text":
self.mode = "text"
logger.info("起草数据目录:结构化输出回了说明文字,后面改走纯文本")
- await self._segment(pieces, depth, followup, mode="text", last=True)
+ await self._segment(pieces, depth, followup, budget, halved=halved, mode="text", last=True)
def _stopped(self, pieces: list[_Piece], depth: int) -> bool:
"""这几段本来还能拆小,因为前面连续几段都没写出来(止损)才没拆。"""
return self.halted and depth < int(C.MODEL_SPLIT_DEPTH) and _halves(pieces) is not None
- async def _split(self, pieces: list[_Piece], depth: int, kind: str, followup: bool) -> None:
+ async def _split(self, pieces: list[_Piece], depth: int, kind: str, followup: bool, budget: _Budget,
+ halved: bool) -> None:
+ """拆成两半再问(额度用完、超时、只给空壳、截断后一项都没捡回来)。拆不开的空壳再问一次:结构化输出给的换纯文本,
+ 已经走纯文本的原样再问;别的原因拆不开就记没写出来。"""
halves = _halves(pieces) if depth < int(C.MODEL_SPLIT_DEPTH) else None
halted = halves is not None and self.halted
if halves is not None and not halted:
first, second = halves
if (len(first) == len(second) == 1 and first[0].table == second[0].table and first[0].table_level):
# 同一张表按列拆、前一半带着表级的项:先写前一半,后一半拿它做背景
- await self._segment(first, depth + 1, followup)
- await self._segment(second, depth + 1, followup)
+ await self._segment(first, depth + 1, followup, budget, halved=True)
+ await self._segment(second, depth + 1, followup, budget, halved=True)
return
- results = await asyncio.gather(self._segment(first, depth + 1, followup),
- self._segment(second, depth + 1, followup), return_exceptions=True)
+ results = await asyncio.gather(self._segment(first, depth + 1, followup, budget, halved=True),
+ self._segment(second, depth + 1, followup, budget, halved=True),
+ return_exceptions=True)
for result in results:
if isinstance(result, BaseException):
raise result
return
- if kind == "hollow" and self.mode == "structured":
- await self._segment(pieces, depth, followup, mode="text", last=True)
+ if kind == "hollow":
+ await self._segment(pieces, depth, followup, budget, halved=halved, mode="text", last=True)
return
- self._fail(pieces, lambda p: self._reason(kind, split=depth > 0, piece=p, halted=halted))
+ self._fail(pieces, lambda p: self._reason(kind, split=halved, piece=p, halted=halted))
- def _remainder(self, pieces: list[_Piece], gains: list[_Gain], *, truncated: bool) -> list[_Piece]:
+ def _remainder(self, pieces: list[_Piece], gains: list[_Gain], *, truncated: bool,
+ followup: bool) -> list[_Piece]:
"""还没写出来的部分:回复里没有这张表、或者这张表一项可用内容都没有的整段重问;有内容的只问没列出来的列
(列出了、字段留空的算模型拿不准,不再问)。截断的回复里表级的项也没拿到的,表级的项一起重问(没截断时表级
- 空着算模型拿不准:这张表在回复里,表级的字段留空和列的字段留空一样)。"""
+ 空着算模型拿不准:这张表在回复里,表级的字段留空和列的字段留空一样)。
+
+ 补问(followup)时这张表已经有内容了:补问的列列出来了就算答过(字段留空是拿不准),只交回没列出来的列。"""
out: list[_Piece] = []
for piece, gain in zip(pieces, gains):
- if not gain.named or not gain.items:
+ answered_blank = followup and gain.named and self.tables[piece.table].has_any()
+ if not gain.named or (not gain.items and not answered_blank):
out.append(piece)
continue
cols = tuple(c for c in piece.columns if c not in gain.answered)
@@ -877,40 +1050,49 @@ class _Run:
# ---------------------------------------------------------------- 调用
- async def _ask(self, pieces: list[_Piece], mode: str | None = None) -> _Reply:
- """调一次模型(按原因重来:额度被拒、结构化输出不支持、网络错误……),交回读好的回复。"""
+ async def _ask(self, pieces: list[_Piece], budget: _Budget, mode: str | None = None) -> _Reply:
+ """调一次模型(按原因重来:额度被拒、结构化输出不支持、网络错误……),交回读好的回复。
+
+ 这一段的调用次数用完了交回 capped(不调用)。超时不原样重试:交回 timeout,由调用方当作这一段太大、拆小再问。
+ 重来之前的那次调用在这里就报给进度回调;交回的那次由调用方读完内容再报(写没写出来要看校验之后的内容)。"""
mode = mode or self.mode
retried = 0
while True:
+ if budget.left <= 0:
+ return _Reply(capped=True)
+ budget.left -= 1
used = getattr(self.model, "budget", None)
try:
# 先占这次起草自己的名额,再去排全进程的:一次起草排在全进程名额上的调用最多 MODEL_CONCURRENCY 个
async with self.gate, model_gate():
await self._hook(self.hooks.call_started, [p.table for p in pieces])
reply, raw = await asyncio.wait_for(self._call(pieces, mode), timeout=C.MODEL_TIMEOUT_S)
- # 用量记在时限之外:落库慢了不能把已经回来的这次调用算成超时
- await self._usage(raw)
+ reply.usage = _usage_of(raw)
return reply
except asyncio.CancelledError:
raise
except Exception as e: # noqa: BLE001 - 按原因分别处理
kind = classify_failure(e)
if kind == "length":
- return await self._length_reply(e)
- await self._hook(self.hooks.usage, 0, 0)
+ return self._length_reply(e)
+ if kind == "timeout":
+ return _Reply(timeout=True, usage=(0, 0))
if kind == "budget" and (getattr(self.model, "budget", None) != used or self._shrink()):
+ await self._called((0, 0), "error")
continue
if kind == "unsupported" and mode == "structured":
if self.mode == "structured":
logger.info("起草数据目录:结构化输出用不了(%s),后面改走纯文本", str(e)[:200])
self.mode = mode = "text"
+ await self._called((0, 0), "error")
continue
- if kind in ("transient", "timeout") and retried < int(C.MODEL_RETRIES):
+ if kind == "transient" and retried < int(C.MODEL_RETRIES):
+ await self._called((0, 0), "error")
delays = tuple(C.MODEL_RETRY_DELAYS_S) or (0.0,)
await _sleep(delays[min(retried, len(delays) - 1)])
retried += 1
continue
- return _Reply(error=self._call_error(e, kind, retried))
+ return _Reply(error=self._call_error(e, retried), usage=(0, 0))
async def _call(self, pieces: list[_Piece], mode: str) -> tuple[_Reply, Any]:
"""一次调用:(读好的回复, 原始回复)。原始回复给用量用(测试的假模型没有,是 None)。"""
@@ -922,12 +1104,12 @@ class _Run:
raw = await self.model.ainvoke(messages)
return _text_reply(raw), raw
- async def _length_reply(self, e: BaseException) -> _Reply:
- """LengthFinishReasonError:额度用完了。异常里带着那次的回复,能捡回来的照捡。"""
+ @staticmethod
+ def _length_reply(e: BaseException) -> _Reply:
+ """LengthFinishReasonError:额度用完了。异常里带着那次的回复(和用量),能捡回来的照捡。"""
completion = getattr(e, "completion", None)
usage = getattr(completion, "usage", None)
- await self._hook(self.hooks.usage, int(getattr(usage, "prompt_tokens", 0) or 0),
- int(getattr(usage, "completion_tokens", 0) or 0))
+ tokens = (int(getattr(usage, "prompt_tokens", 0) or 0), int(getattr(usage, "completion_tokens", 0) or 0))
try:
text = completion.choices[0].message.content or "" # type: ignore[union-attr]
except (AttributeError, IndexError, TypeError):
@@ -935,8 +1117,8 @@ class _Run:
if text.strip():
reply = _parse_text(text, True)
if reply is not None and reply.entries:
- return _Reply(entries=reply.entries, truncated=True, exhausted=True)
- return _Reply(exhausted=True)
+ return _Reply(entries=reply.entries, truncated=True, exhausted=True, usage=tokens)
+ return _Reply(exhausted=True, usage=tokens)
def _shrink(self) -> bool:
shrink = getattr(self.model, "shrink", None)
@@ -948,17 +1130,50 @@ class _Run:
def _budget(self) -> int:
return int(getattr(self.model, "budget", None) or C.MODEL_MAX_TOKENS)
- def _call_error(self, e: BaseException, kind: str, retried: int) -> str:
+ @staticmethod
+ def _call_error(e: BaseException, retried: int) -> str:
from app.core.errors import describe_exception, first_line
tail = REASON_RETRIED.format(n=retried) if retried else ""
- if kind == "timeout":
- return REASON_TIMEOUT.format(seconds=_seconds(C.MODEL_TIMEOUT_S)) + tail
return REASON_CALL.format(line=first_line(e) or describe_exception(e)) + tail
- async def _usage(self, raw: Any) -> None:
- meta = getattr(raw, "usage_metadata", None) or {}
- await self._hook(self.hooks.usage, int(meta.get("input_tokens") or 0), int(meta.get("output_tokens") or 0))
+ # ---------------------------------------------------------------- 每次调用之后:用量、段大小、提醒
+
+ async def _report(self, reply: _Reply, *, wrote: bool) -> None:
+ """交回的那次调用读完内容之后报给进度回调。"""
+ if reply.usage is None:
+ return
+ if reply.timeout:
+ failure: str | None = "timeout"
+ elif reply.error is not None:
+ failure = "error"
+ elif wrote:
+ failure = None
+ else:
+ failure = "exhausted" if reply.exhausted else "hollow"
+ await self._called(reply.usage, failure, strike=reply.timeout or reply.exhausted)
+
+ async def _called(self, usage: tuple[int, int], failure: str | None, *, strike: bool = False) -> None:
+ """记下一次调用:额度用完或超时(strike)累计 MODEL_SHRINK_AFTER 次,新开始的段列数上限减半;最近几次调用多数
+ 没写出来时提醒,少了清掉。报给进度回调。"""
+ self.recent.append(failure)
+ if strike:
+ self.strikes += 1
+ width = max(1, int(C.MODEL_BATCH_COLUMNS))
+ floor = min(width, max(1, int(C.MODEL_MIN_PART_COLUMNS)))
+ cap = max(floor, width >> (self.strikes // max(1, int(C.MODEL_SHRINK_AFTER))))
+ if cap < self.part_columns:
+ self.part_columns = cap
+ logger.info("起草数据目录:额度用完或超时已有 %d 次,之后新开始的段每段最多 %d 列", self.strikes, cap)
+ failed = [f for f in self.recent if f is not None]
+ if len(failed) >= int(C.MODEL_NOTICE_ON) or (self.notice is not None and len(failed) >= int(C.MODEL_NOTICE_OFF)):
+ counts = collections.Counter(failed)
+ kinds = "、".join(f"{label} {counts[kind]} 次" for kind, label in FAILURE_LABEL.items() if counts[kind])
+ self.notice = NOTICE.format(calls=len(self.recent), failed=len(failed), kinds=kinds, place=DRAFT_MODEL_PLACE)
+ else:
+ self.notice = None
+ await self._hook(self.hooks.call_done, CallReport(int(usage[0]), int(usage[1]), failure, self.notice,
+ self.part_columns))
# ---------------------------------------------------------------- 拿到的内容
@@ -1028,9 +1243,11 @@ class _Run:
def _reason(self, kind: str, *, split: bool, piece: _Piece | None = None, halted: bool = False) -> _Why:
if kind == "exhausted":
- base = REASON_EXHAUSTED.format(budget=self._budget())
+ base = REASON_EXHAUSTED.format(budget=self._budget(), seg=SEG)
elif kind == "truncated":
- base = REASON_TRUNCATED
+ base = REASON_TRUNCATED.format(seg=SEG)
+ elif kind == "timeout":
+ base = REASON_TIMEOUT.format(seconds=_seconds(C.MODEL_TIMEOUT_S), seg=SEG)
else:
st = self.tables[piece.table] if piece is not None else None
if st is not None and st.whole(piece):
@@ -1039,6 +1256,10 @@ class _Run:
base = REASON_HOLLOW
return _Why(base, split=split, halted=halted)
+ @staticmethod
+ def _capped(halved: bool) -> _Why:
+ return _Why(REASON_CAPPED.format(n=max(1, int(C.MODEL_PART_MAX_CALLS)), seg=SEG), split=halved)
+
# ---------------------------------------------------------------- 收尾
def _outcome(self, st: _Table) -> ModelOutcome:
@@ -1046,31 +1267,57 @@ class _Run:
failed_cols = [c for c in st.columns if c in st.failed and c not in st.got_columns]
failed_table = _TABLE in st.failed and not st.got_table
total = len(st.parts) or 1
+ part_of = st.part_of()
if not items:
- whys = list(st.failed.values())
- reason = _join(whys) if whys else (C.MODEL_EMPTY_REASON if st.named else REASON_MISSING)
+ failures = [(why, part_of.get(key, 1)) for key, why in st.failed.items()]
+ reason = _join(failures) if failures else (C.MODEL_EMPTY_REASON if st.named else REASON_MISSING)
return ModelOutcome("failed", {}, 0, reason, total, st.parts_done)
notes = copy.deepcopy(st.notes)
+ blank = self._blank(st, set(failed_cols))
if failed_cols or failed_table:
return ModelOutcome("partial", notes, items, self._partial_text(st, failed_cols, failed_table), total,
- st.parts_done)
- return ModelOutcome("ok", notes, items, None, total, st.parts_done)
+ st.parts_done, blank)
+ return ModelOutcome("ok", notes, items, None, total, st.parts_done, blank)
+
+ def _blank(self, st: _Table, failed: set[str]) -> list[str]:
+ """模型拿不准、留空了的列:请它写了(不在没写出来的里面),中文名和含义却都没给(或者给的都没通过校验)。已确认
+ 的、库注释已经说清的不算——提示词里它们以此为准,模型不写也不缺:已确认(有确证)的中文名或含义、库注释(按
+ catalog.comment_role 记进目录的那种;导入表格的源是系统说明)。"""
+ drafted = st.notes.get("columns") or {}
+ confirmed = C._confirmed_only(st.existing).get("columns") or {}
+ comments = {str(c.get("name")): (c.get("comment") or "").strip()
+ for c in st.meta.get("columns") or [] if isinstance(c, dict)}
+ out: list[str] = []
+ for col in st.columns:
+ if col in failed:
+ continue
+ if any(f in (drafted.get(col) or {}) or f in (confirmed.get(col) or {}) for f in ("label", "meaning")):
+ continue
+ text = comments.get(col)
+ if text and (self.system or C.comment_role(text, col) is not None):
+ continue
+ out.append(col)
+ return out
@staticmethod
def _partial_text(st: _Table, failed_cols: list[str], failed_table: bool) -> str:
- """只写出一部分时说清哪部分没写出来、为什么、怎么办。"""
+ """只写出一部分时说清哪部分没写出来(第几段、哪几列)、为什么、怎么办。"""
+ part_of = st.part_of()
keys = ([_TABLE] if failed_table else []) + failed_cols
- reason = _join(st.failed[k] for k in keys)
+ reason = _join((st.failed[k], part_of.get(k, 1)) for k in keys)
n = len(st.columns)
if len(st.parts) > 1:
- part_of = {c: p.part for p in st.parts for c in p.columns}
- bad = sorted({part_of[c] for c in failed_cols if c in part_of} | ({1} if failed_table else set()))
- head = f"这张表 {n} 列,分 {len(st.parts)} 段起草,第 {'、'.join(str(b) for b in bad)} 段没写出来"
- head += f"(共 {len(failed_cols)} 列)" if failed_cols else "(表级的项)"
+ bad: dict[int, list[str]] = {}
+ for col in failed_cols:
+ bad.setdefault(part_of.get(col, 1), []).append(col)
+ said = ["第 1 段(表级的项)"] if failed_table else []
+ said += [f"第 {k} 段({_columns_text(st.columns, cols, count=True)})" for k, cols in sorted(bad.items())]
+ head = f"这张表 {n} 列,分 {len(st.parts)} 段起草,{'、'.join(said)}没写出来"
elif failed_cols and failed_table:
- head = f"这张表 {n} 列,有表级的项和 {len(failed_cols)} 列没写出来"
+ head = (f"这张表 {n} 列,有表级的项和 {len(failed_cols)} 列({_columns_text(st.columns, failed_cols, count=False)})"
+ "没写出来")
elif failed_cols:
- head = f"这张表 {n} 列,有 {len(failed_cols)} 列没写出来"
+ head = f"这张表 {n} 列,有 {len(failed_cols)} 列({_columns_text(st.columns, failed_cols, count=False)})没写出来"
else:
head = f"这张表 {n} 列,表级的项没写出来"
return f"{head}:{reason}。可以只重试这张表"
@@ -1196,6 +1443,7 @@ class _Run:
return C._notes_lines(confirmed, meta=st.meta, system_notes=False)
-__all__ = ["BUDGET_WORDS", "Background", "Hooks", "ModelOutcome", "REASON_CALL", "REASON_EXHAUSTED", "REASON_FORMAT",
- "REASON_HALTED", "REASON_HOLLOW", "REASON_MISSING", "REASON_RETRIED", "REASON_SPLIT", "REASON_TIMEOUT",
- "REASON_TRUNCATED", "REASON_UNANSWERED", "classify_failure", "model_gate", "parts_for", "run_model_draft"]
+__all__ = ["BUDGET_WORDS", "Background", "CallReport", "FAILURE_LABEL", "Hooks", "ModelOutcome", "NOTICE", "REASON_CALL",
+ "REASON_CAPPED", "REASON_EXHAUSTED", "REASON_FORMAT", "REASON_HALTED", "REASON_HOLLOW", "REASON_MISSING",
+ "REASON_RETRIED", "REASON_SPLIT", "REASON_TIMEOUT", "REASON_TRUNCATED", "REASON_UNANSWERED", "SEG",
+ "classify_failure", "model_gate", "parts_for", "run_model_draft"]
diff --git a/backend/app/data/catalog_jobs.py b/backend/app/data/catalog_jobs.py
index 4c580e8b..d708ed50 100644
--- a/backend/app/data/catalog_jobs.py
+++ b/backend/app/data/catalog_jobs.py
@@ -6,8 +6,9 @@
- **建任务**(create_job):同一个数据源同时只有一个排队或进行中的任务(DraftBusy;部分唯一索引兜底)。不指定表时
按使用次数取前 20 张;表结构里没有的表照样建进任务、状态 skipped。起草要求(数据源级)和本次说明随任务记下快照。
- **在后台起草**(manager.start):进程内的 asyncio 任务,引擎是 data/catalog_draft.py(分段、按失败原因处理)。
- 每张表一结束就并入目录(catalog.write_draft,规则和同步起草一样:人工确认、驳回过的不动,乐观锁冲突重读重并);
- 逐表的状态、到第几段、计数、用量随时落库,接口读的是库里的状态。
+ 每张表一结束就并入目录(catalog.write_draft,规则和同步起草一样:人工确认、驳回过的不动,模型这次没给的项不删
+ 以前起草的,乐观锁冲突重读重并);逐表的状态、到第几段、计数、模型留空的列,任务的用量(含超时、没写出来的调用
+ 次数)、当前的列数上限、提醒随时落库,接口读的是库里的状态。
- **停止**(cancel):排队的直接 cancelled;进行中的停掉正在进行的模型调用,没开始和没写完的表记 cancelled,已经写进
目录的不回滚,任务最后是 cancelled。
- **重试**(retry_tables + create_job):新任务,只含原任务里 failed、partial、cancelled 的表;用不用模型、本次说明沿用,
@@ -55,6 +56,8 @@ GUIDANCE_MAX = 2000
NOTE_MAX = 1000
#: 目录清单上「这张表最近一次起草」只看最近这么多个任务
LAST_DRAFT_JOBS = 100
+#: 逐表最多记几个模型留空的列名
+BLANK_SAMPLE = 5
#: 停止进行中的任务时最多等它收尾多久(秒);关停服务时另给(shutdown 的 grace)
STOP_GRACE_S = 10.0
@@ -164,6 +167,9 @@ def task_dict(task: CatalogDraftTask) -> dict[str, Any]:
"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),
@@ -200,8 +206,14 @@ def job_dict(job: CatalogDraftJob, tasks: list[CatalogDraftTask]) -> dict[str, A
"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},
+ "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],
}
@@ -225,20 +237,22 @@ async def job_brief(session: AsyncSession, job: CatalogDraftJob | None) -> dict[
async def last_drafts(session: AsyncSession, source_id: str) -> dict[str, dict[str, Any]]:
- """每张表最近一次起草任务里的那一项:{表名: {status, job_id, finished_at, error, model_error}}。任务还在进行,
- 就是进行中的状态。只看最近 LAST_DRAFT_JOBS 个任务;每张表取哪一项在库里挑好(清单页起草时会反复取)。"""
+ """每张表最近一次起草任务里的那一项:{表名: {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, rank)
+ 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"]} for row in rows}
+ "model_error": row["model_error"], "blank_columns": row["blank_columns"] or 0}
+ for row in rows}
# ==========================================================================
@@ -463,7 +477,7 @@ class _JobRun:
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,
+ 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)
@@ -482,8 +496,8 @@ class _JobRun:
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 _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)
@@ -506,7 +520,8 @@ class _JobRun:
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))
+ .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:
@@ -521,11 +536,16 @@ class _JobRun:
.values(status="running", started_at=utcnow()))
await session.commit()
- async def _add_usage(self, session: AsyncSession, input_tokens: int, output_tokens: int) -> None:
+ 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(input_tokens),
- output_tokens=CatalogDraftJob.output_tokens + int(output_tokens))
+ .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()
@@ -551,6 +571,7 @@ class _JobRun:
"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
@@ -638,8 +659,9 @@ class DraftJobs:
manager = DraftJobs()
-__all__ = ["ACTIVE", "DraftBusy", "DraftFinished", "DraftJobs", "FINISHED", "GUIDANCE_MAX", "INTERRUPTED_ERROR",
- "MAX_TABLES", "NOTE_MAX", "NothingToRetry", "RETRYABLE", "TASK_DONE", "TASK_STATUSES", "TextTooLong",
+__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"]
diff --git a/backend/app/db/base.py b/backend/app/db/base.py
index 68ee478b..f0da4ac6 100644
--- a/backend/app/db/base.py
+++ b/backend/app/db/base.py
@@ -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 不碰已有的表,表上新列的索引也就不会建。
diff --git a/backend/app/db/models.py b/backend/app/db/models.py
index 932d02a7..a5438d84 100644
--- a/backend/app/db/models.py
+++ b/backend/app/db/models.py
@@ -379,10 +379,17 @@ class CatalogDraftJob(Base, TimestampMixin):
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 算)
+ # 用量:调用次数、输入和输出 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)
# 重试建的任务记下是重试哪一个
@@ -411,6 +418,9 @@ class CatalogDraftTask(Base):
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)
diff --git a/backend/tests/fixtures/catalog/fake_model.py b/backend/tests/fixtures/catalog/fake_model.py
index 1c4206f1..1574076e 100644
--- a/backend/tests/fixtures/catalog/fake_model.py
+++ b/backend/tests/fixtures/catalog/fake_model.py
@@ -10,6 +10,7 @@
不整列漏掉。list_all=False 时只给 replies 里有的列(模拟漏列的模型:漏掉的列会被补问一次,补问后仍没给的算没写出来);
- 直接 ainvoke 是纯文本那条路:同样的内容写成一段正文(AIMessage),前面带一句话;
- fail 里的表所在的那一段抛异常,用来验证「一段失败只影响这一段」;
+- hang=N:头 N 次调用一直不回(等起草引擎按时限掐掉,当作超时);
- calls 记下每次收到的提示词全文,用来核对交给模型的内容(只有结构,没有数据);modes 记下每次走的是
structured 还是 text。
@@ -22,6 +23,7 @@ structured、text 模拟实测见过的几种回复(两条路各自设):
"""
from __future__ import annotations
+import asyncio
import json
from typing import Any
@@ -105,10 +107,11 @@ def with_every_column(entry: dict, wanted: list[str]) -> dict:
class FakeModel:
def __init__(self, replies: dict[str, dict], fail: set[str] = frozenset(), *,
- structured: str = "tables", text: str = "tables", list_all: bool = True) -> 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":
@@ -118,9 +121,17 @@ 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)
@@ -147,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")
diff --git a/backend/tests/test_catalog_draft.py b/backend/tests/test_catalog_draft.py
index 9bf50ea5..81a5629b 100644
--- a/backend/tests/test_catalog_draft.py
+++ b/backend/tests/test_catalog_draft.py
@@ -324,7 +324,7 @@ async def test_hollow_structured_output_retries_with_text():
got = await catalog.draft_with_model(model, _source(_scenic_tables()), ["visits"])
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 列没写出来" in got["visits"].error
+ assert got["visits"].covered == frozenset() and "有 5 列(id 到 status)没写出来" in got["visits"].error
async def test_hollow_structured_then_single_text_object():
diff --git a/backend/tests/test_catalog_draft_engine.py b/backend/tests/test_catalog_draft_engine.py
index 4f17ee3f..59171b50 100644
--- a/backend/tests/test_catalog_draft_engine.py
+++ b/backend/tests/test_catalog_draft_engine.py
@@ -6,7 +6,11 @@
- 被截断的 JSON:已经完整的表、列对象捡回来,只补剩下的;
- 只回一部分表:只补缺的,补一次;
- 服务方嫌额度太大 → 退回 8192,后面都用它;结构化输出不支持 → 后面改走纯文本;
-- 网络错误、5xx、超时 → 退避重试,最多 2 次,报错写明重试过几次。
+- 网络错误、5xx → 退避重试,最多 2 次,报错写明重试过几次;超时当作这一段太大,拆小再问,不原样重试。
+
+验收后修正的几条(用假模型复现):宽表的列分段均分、没有很小的尾段;拆不开的空壳纯文本也再问一次;额度用完或超时
+累计 2 次后新开始的段列数减半;一段最多调用 8 次;最近 6 次调用 4 次没写出来时提醒、少于 2 次时清掉;记超时和没写
+出来的调用;模型留空的列计数;窄表不被宽表的列分段插队;原因写清是哪几段、哪几列。
表名、列名、说明文字都是虚构的。
"""
@@ -163,6 +167,34 @@ def _status(outcomes: dict) -> dict:
return {t: o.status for t, o in outcomes.items()}
+def _hollow(call: Call) -> Reply:
+ return Reply(obj={"tables": [{"table": t} for t in call.asked]})
+
+
+def _exhausted(seg: str = "这一段", budget: int = 32768) -> str:
+ return D.REASON_EXHAUSTED.format(budget=budget, seg=seg)
+
+
+EXHAUSTED = Reply(text="", finish="length", thinking="……")
+
+
+class Reports:
+ """进度回调 call_done 收到的每次调用的结果。"""
+
+ def __init__(self) -> None:
+ self.items: list[D.CallReport] = []
+
+ async def __call__(self, report: D.CallReport) -> None:
+ self.items.append(report)
+
+ def hooks(self) -> D.Hooks:
+ return D.Hooks(call_done=self)
+
+ @property
+ def failures(self) -> list[str | None]:
+ return [r.failure for r in self.items]
+
+
# --------------------------------------------------------------------------
# 思考用完额度、空壳:拆开再问
# --------------------------------------------------------------------------
@@ -220,9 +252,10 @@ async def test_hollow_shell_splits_the_pack_by_table():
assert all(c.asked[t][0] == "full" for c in model.calls[1:] for t in c.asked)
-async def test_splitting_stops_at_three_levels_and_five_columns():
+async def test_splitting_stops_at_three_levels_and_five_columns(monkeypatch):
+ monkeypatch.setattr(catalog, "MODEL_PART_MAX_CALLS", 99)
tables = {"wide40": _table(40)}
- model = Scripted(lambda call: Reply(text="", finish="length", thinking="……"))
+ model = Scripted(lambda call: EXHAUSTED)
got = await D.run_model_draft(model, _source(tables), ["wide40"])
o = got["wide40"]
assert len(model.calls) == 1 + 2 + 4 + 8, "40 → 20 → 10 → 5 列,最多拆三层"
@@ -236,6 +269,30 @@ async def test_splitting_stops_at_three_levels_and_five_columns():
assert got["t9"].error == "模型的思考和输出用完了额度(32768 个 token),这一段没写出来"
+async def test_one_part_makes_at_most_eight_calls(monkeypatch):
+ """一段(连同它拆出来的、补问的、重试的)最多调用 8 次:40 列一直用完额度,拆到第 8 次就停,剩下没问的记没写出来、
+ 原因写清。以前一段 40 列拆到底要 15 次。"""
+ monkeypatch.setattr(catalog, "MODEL_GIVE_UP_AFTER", 99)
+ assert catalog.MODEL_PART_MAX_CALLS == 8
+ model = Scripted(lambda call: EXHAUSTED)
+ got = await D.run_model_draft(model, _source({"t40": _table(40)}), ["t40"])
+ assert len(model.calls) == 8
+ o = got["t40"]
+ assert o.status == "failed"
+ assert o.error == (_exhausted() + ";每段最多调用 8 次模型,这一段到了次数仍没写完,已停止重试"
+ + D.REASON_SPLIT)
+
+ # 网络错误的重试也算在这 8 次里
+ def flaky(call: Call) -> Reply:
+ raise ConnectError("All connection attempts failed")
+
+ monkeypatch.setattr(catalog, "MODEL_PART_MAX_CALLS", 2)
+ model = Scripted(flaky)
+ got = await D.run_model_draft(model, _source({"t4": _table(4)}), ["t4"])
+ assert len(model.calls) == 2
+ assert got["t4"].error == "每段最多调用 2 次模型,这一段到了次数仍没写完,已停止重试"
+
+
async def test_an_unsplittable_hollow_shell_tries_text_once_then_stays_on_text():
"""有的网关的结构化输出只给空壳:拆不开时换纯文本问一次,写出来了,这次起草后面直接走纯文本。
@@ -255,6 +312,31 @@ async def test_an_unsplittable_hollow_shell_tries_text_once_then_stays_on_text()
assert catalog.CATALOG_DRAFT_TEXT_PROMPT in model.calls[1].text, "纯文本那条路要说清 JSON 的格式"
+def _text_only(handle_text: Callable[[Call], Reply]) -> Callable[[Call], Reply]:
+ """结构化输出不支持(这次起草一开始就改走纯文本),纯文本交给 handle_text。"""
+ def handle(call: Call) -> Reply:
+ if call.mode == "structured":
+ raise _Status(400, "This response_format type is unavailable now")
+ return handle_text(call)
+ return handle
+
+
+async def test_an_unsplittable_hollow_shell_is_asked_again_in_text_mode_too():
+ """已经走纯文本时,拆不开的段(不到 10 列)只给空壳:原样再问一次,写出来就用;仍是空壳才记没写出来。
+ 以前纯文本模式下不补问,整张表直接记没写出来。"""
+ tables = {"t8": _table(8)}
+ ok = answering(tables)
+ model = Scripted(_text_only(lambda call: _hollow(call) if call.n == 1 else ok(call)))
+ got = await D.run_model_draft(model, _source(tables), ["t8"])
+ assert [c.mode for c in model.calls] == ["structured", "text", "text"]
+ assert got["t8"].status == "ok" and len(got["t8"].notes["columns"]) == 8
+
+ model = Scripted(_text_only(_hollow))
+ got = await D.run_model_draft(model, _source(tables), ["t8"])
+ assert [c.mode for c in model.calls] == ["structured", "text", "text"], "再问一次,不多问"
+ assert got["t8"].status == "failed" and got["t8"].error == catalog.MODEL_EMPTY_REASON
+
+
# --------------------------------------------------------------------------
# 截断、缺表、缺列
# --------------------------------------------------------------------------
@@ -358,12 +440,35 @@ async def test_columns_still_missing_after_the_followup_are_not_written():
assert len(model.calls) == 2 and model.calls[1].asked == {"t8": ("columns", ["c007", "c008"])}
o = got["t8"]
assert o.status == "partial"
- assert o.error == "这张表 8 列,有 2 列没写出来:模型的回复里漏了这些列,补问一次仍没有给出。可以只重试这张表"
+ assert o.error == ("这张表 8 列,有 2 列(c007、c008)没写出来:模型的回复里漏了这些列,补问一次仍没有给出。"
+ "可以只重试这张表")
assert set(o.notes["columns"]) == {f"c{i:03d}" for i in range(1, 7)}
+ assert o.blank == [], "没写出来的列不算留空"
compat = (await catalog.draft_with_model(Scripted(handle), _source(tables), ["t8"]))["t8"]
assert compat.covered == frozenset(), "没写全的不算完整起草过"
+async def test_columns_listed_blank_in_the_followup_are_left_blank_not_failed():
+ """补问时模型把漏的列列出来了、字段却留空:是拿不准(记留空),不算「漏了这些列」。"""
+ tables = {"t8": _table(8)}
+ ok = answering(tables)
+
+ def handle(call: Call) -> Reply:
+ reply = ok(call)
+ entry = reply.obj["tables"][0]
+ if call.n == 0:
+ entry["columns"] = [c for c in entry["columns"] if c["name"] not in ("c003", "c007")]
+ else:
+ entry["columns"] = [{"name": c["name"]} for c in entry["columns"]]
+ return reply
+
+ model = Scripted(handle)
+ got = await D.run_model_draft(model, _source(tables), ["t8"])
+ assert model.calls[1].asked == {"t8": ("columns", ["c003", "c007"])}
+ o = got["t8"]
+ assert o.status == "ok" and o.error is None and o.blank == ["c003", "c007"]
+
+
async def test_a_followup_that_comes_back_as_an_empty_shell_leaves_the_table_partial():
"""40 列的表第一次只回了 20 列,补问只给一个空壳:没写出来的 20 列照实记下,表是 partial。"""
tables = {"t40": _table(40)}
@@ -382,11 +487,12 @@ async def test_a_followup_that_comes_back_as_an_empty_shell_leaves_the_table_par
assert len(model.calls) == 2
assert model.calls[1].asked == {"t40": ("columns", [f"c{i:03d}" for i in range(21, 41)])}
assert o.status == "partial" and len(o.notes["columns"]) == 20
- assert o.error == "这张表 40 列,有 20 列没写出来:模型的回复里漏了这些列,补问一次仍没有给出。可以只重试这张表"
+ assert o.error == ("这张表 40 列,有 20 列(c021 到 c040)没写出来:模型的回复里漏了这些列,补问一次仍没有给出。"
+ "可以只重试这张表")
async def test_columns_listed_with_empty_fields_are_left_to_the_model():
- """列出了、字段留空的列是模型拿不准:不补问,也不算没写出来。"""
+ """列出了、字段留空的列是模型拿不准:不补问,也不算没写出来;记成留空(表的状态不因此变)。"""
tables = {"t8": _table(8)}
ok = answering(tables)
@@ -402,6 +508,30 @@ async def test_columns_listed_with_empty_fields_are_left_to_the_model():
assert len(model.calls) == 1
assert got["t8"].status == "ok" and got["t8"].error is None
assert set(got["t8"].notes["columns"]) == {f"c{i:03d}" for i in range(1, 7)}
+ assert got["t8"].blank == ["c007", "c008"]
+
+
+async def test_blank_columns_are_the_ones_without_a_label_or_meaning():
+ """留空的列:请模型写了、中文名和含义却都没给(只给了度量类型、单位,或者给的都没通过校验)。已确认的、库注释已经说清
+ 的不算:给模型的提示词里它们以此为准,模型不写也不缺。"""
+ meta = _table(9)
+ meta["columns"][5]["comment"] = "会员等级" # c006:库注释就是中文名
+ meta["columns"][8]["comment"] = "C009" # c009:注释就是列名,说明不了什么
+ tables = {"t9": meta}
+ existing = {"t9": {"columns": {"c005": {"label": catalog.make_item("已确认的名字", "human")}}}}
+
+ def handle(call: Call) -> Reply:
+ return Reply(obj={"tables": [{"table": "t9", "label": "某表", "columns": [
+ {"name": "c001", "label": "第一列"},
+ {"name": "c002", "meaning": "第二列的含义"},
+ {"name": "c003", "measure": "attribute", "unit": "元"}, # 中文名、含义都没给
+ {"name": "c004", "label": "=c001+c002"}, # 像公式:校验丢掉
+ {"name": "c005"}, {"name": "c006"}, {"name": "c007"}, {"name": "c008"}, {"name": "c009"}]}]})
+
+ got = await D.run_model_draft(Scripted(handle), _source(tables), ["t9"], existing=existing)
+ o = got["t9"]
+ assert o.status == "ok" and o.error is None
+ assert o.blank == ["c003", "c004", "c007", "c008", "c009"]
# --------------------------------------------------------------------------
@@ -521,7 +651,29 @@ async def test_other_errors_are_not_retried():
assert len(model.calls) == 1 and got["t4"].error == "调用模型出错:网关拒绝了这次请求"
-async def test_a_call_past_the_time_limit_is_retried_then_reported(monkeypatch):
+async def test_a_call_past_the_time_limit_is_split_not_retried(monkeypatch, _no_wait):
+ """超时当作这一段太大:不原样重试同样大小的段,拆成两半再问(同额度用完)。超时的调用拿不到用量,记 0。"""
+ monkeypatch.setattr(catalog, "MODEL_TIMEOUT_S", 0.05)
+ tables = {"t24": _table(24)}
+ ok = answering(tables, usage=(100, 50))
+
+ async def handle(call: Call) -> Reply:
+ if call.n == 0:
+ await asyncio.sleep(1)
+ return ok(call)
+
+ model, reports = Scripted(handle), Reports()
+ got = await D.run_model_draft(model, _source(tables), ["t24"], hooks=reports.hooks())
+ cols = [f"c{i:03d}" for i in range(1, 25)]
+ assert got["t24"].status == "ok"
+ assert [c.asked for c in model.calls] == [{"t24": ("full", None)}, {"t24": ("table+columns", cols[:12])},
+ {"t24": ("columns", cols[12:])}]
+ assert reports.failures == ["timeout", None, None]
+ assert [(r.input_tokens, r.output_tokens) for r in reports.items] == [(0, 0), (100, 50), (100, 50)]
+ assert _no_wait == [], "不退避重试"
+
+
+async def test_a_small_part_past_the_time_limit_is_not_retried(monkeypatch):
monkeypatch.setattr(catalog, "MODEL_TIMEOUT_S", 0.05)
async def slow(call: Call) -> Reply:
@@ -530,8 +682,8 @@ async def test_a_call_past_the_time_limit_is_retried_then_reported(monkeypatch):
model = Scripted(slow)
got = await D.run_model_draft(model, _source({"t4": _table(4)}), ["t4"])
- assert len(model.calls) == 3
- assert got["t4"].error == "模型 0.05 秒内没有写完这一段(已重试 2 次)"
+ assert len(model.calls) == 1, "拆不开、也不原样重试"
+ assert got["t4"].status == "failed" and got["t4"].error == "模型 0.05 秒内没有写完这一段"
# --------------------------------------------------------------------------
@@ -554,13 +706,30 @@ async def test_a_wide_table_is_split_into_a_table_part_and_column_parts():
first, *rest = model.calls
assert first.asked == {"wide95": ("table", None)}
assert catalog.CATALOG_DRAFT_PROMPT_LABELS["brief_columns"].format(n=95) in first.text
- assert sorted(len(c.asked["wide95"][1]) for c in rest) == [15, 40, 40]
+ assert sorted(len(c.asked["wide95"][1]) for c in rest) == [31, 32, 32]
for call in rest:
assert call.asked["wide95"][0] == "columns"
assert "wide95的中文名" in call.text and "虚构的宽表" in call.text, "列分段带着表注释和表级起草结果"
assert {c for call in rest for c in call.asked["wide95"][1]} == {f"c{i:03d}" for i in range(1, 96)}
+@pytest.mark.parametrize("n, sizes", [(164, [33, 33, 33, 33, 32]), (95, [32, 32, 31]), (41, [21, 20]),
+ (80, [40, 40]), (81, [27, 27, 27])])
+async def test_column_parts_are_even_without_a_small_tail(monkeypatch, n, sizes):
+ """列分段数 = 列数 / 40 向上取整,各段列数平均(相差不超过 1):不再留下几列的尾段(实测会思考的模型常对这种小段只给
+ 空壳)。表级那段照旧在最前面。"""
+ monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
+ tables = {"wide": _table(n)}
+ model = Scripted(answering(tables))
+ got = await D.run_model_draft(model, _source(tables), ["wide"])
+ o = got["wide"]
+ assert o.status == "ok" and o.parts_total == 1 + len(sizes) == D.parts_for(n)
+ assert model.calls[0].asked == {"wide": ("table", None)}
+ parts = [c.asked["wide"][1] for c in model.calls[1:]]
+ assert [len(p) for p in parts] == sizes
+ assert [c for p in parts for c in p] == [f"c{i:03d}" for i in range(1, n + 1)], "按表结构的顺序连续切开"
+
+
async def test_a_wide_table_with_one_failed_part_is_partial_and_says_which():
tables = {"wide95": _table(95)}
ok = answering(tables)
@@ -574,12 +743,69 @@ async def test_a_wide_table_with_one_failed_part_is_partial_and_says_which():
model = Scripted(handle)
got = await D.run_model_draft(model, _source(tables), ["wide95"])
o = got["wide95"]
- assert o.status == "partial" and len(o.notes["columns"]) == 55
- assert o.error == "这张表 95 列,分 4 段起草,第 3 段没写出来(共 40 列):调用模型出错:网关拒绝。可以只重试这张表"
+ assert o.status == "partial" and len(o.notes["columns"]) == 63
+ assert o.error == ("这张表 95 列,分 4 段起草,第 3 段(c033 到 c064,共 32 列)没写出来:调用模型出错:网关拒绝。"
+ "可以只重试这张表")
compat = (await catalog.draft_with_model(Scripted(handle), _source(tables), ["wide95"]))["wide95"]
assert compat.covered == frozenset() and compat.error == o.error, "没写全的不算完整起草过,旧的模型项不删"
+async def test_reasons_name_each_part_and_say_these_parts(monkeypatch):
+ """几段因为同一个原因没写出来:原因里写「这几段」;每段写上列的范围(列名照表结构原样);拆过的才补「已拆小重试」。"""
+ monkeypatch.setattr(catalog, "MODEL_SHRINK_AFTER", 99)
+ tables = {"wide95": _table(95)}
+ ok = answering(tables)
+
+ def handle(call: Call) -> Reply:
+ part, cols = call.asked["wide95"]
+ if part == "columns" and cols[0] >= "c033": # 第 3、4 段(c033 起)一直用完额度
+ return EXHAUSTED
+ return ok(call)
+
+ got = await D.run_model_draft(Scripted(handle), _source(tables), ["wide95"])
+ o = got["wide95"]
+ assert o.status == "partial" and len(o.notes["columns"]) == 32
+ assert o.error == ("这张表 95 列,分 4 段起草,第 3 段(c033 到 c064,共 32 列)、第 4 段(c065 到 c095,共 31 列)没写出来:"
+ + _exhausted("这几段") + D.REASON_SPLIT + "。可以只重试这张表")
+
+
+async def test_a_part_that_was_never_halved_does_not_say_it_was():
+ """被截断后只补剩下的(不是拆小):补的那次也用完了额度、又拆不开,原因里不写「已拆小重试」。"""
+ tables = {"t9": _table(9)}
+ full = json.dumps({"tables": [{"table": "t9", **_reply("t9", tables["t9"])}]}, ensure_ascii=False)
+ cut = full[:full.index('"c005"') + 8]
+
+ def handle(call: Call) -> Reply:
+ return Reply(text=cut, finish="length") if call.n == 0 else EXHAUSTED
+
+ model = Scripted(handle)
+ got = await D.run_model_draft(model, _source(tables), ["t9"])
+ o = got["t9"]
+ assert len(model.calls) == 2 and model.calls[1].asked == {"t9": ("columns", [f"c{i:03d}" for i in range(5, 10)])}
+ assert o.status == "partial"
+ assert o.error == "这张表 9 列,有 5 列(c005 到 c009)没写出来:" + _exhausted() + "。可以只重试这张表"
+
+
+@pytest.mark.parametrize("missing, said", [
+ (["c004"], "有 1 列(c004)"),
+ (["c003", "c007"], "有 2 列(c003、c007)"),
+ (["c002", "c004", "c006", "c008"], "有 4 列(c002、c004、c006 等)"),
+ (["c003", "c004", "c005", "c006"], "有 4 列(c003 到 c006)"),
+])
+async def test_columns_not_written_are_named(missing, said):
+ tables = {"t8": _table(8)}
+ ok = answering(tables)
+
+ def handle(call: Call) -> Reply:
+ reply = ok(call)
+ for entry in reply.obj["tables"]:
+ entry["columns"] = [c for c in entry.get("columns", []) if c["name"] not in missing]
+ return reply
+
+ got = await D.run_model_draft(Scripted(handle), _source(tables), ["t8"])
+ assert got["t8"].error == f"这张表 8 列,{said}没写出来:{D.REASON_UNANSWERED}。可以只重试这张表"
+
+
async def test_tables_are_packed_up_to_40_columns_and_4_tables():
widths = {"ta": 15, "tb": 15, "tc": 15, "td": 5, "te": 5, "tf": 5, "tg": 5, "th": 41}
tables = {name: _table(n) for name, n in widths.items()}
@@ -598,13 +824,10 @@ async def test_tables_are_packed_up_to_40_columns_and_4_tables():
# --------------------------------------------------------------------------
-def _hollow(call: Call) -> Reply:
- return Reply(obj={"tables": [{"table": t} for t in call.asked]})
-
-
async def test_the_same_reason_is_said_once_even_when_some_parts_were_split(monkeypatch):
"""宽表的表级那段拆不开、列分段拆过:原因只说一次,拆过的在最后补一句。"""
monkeypatch.setattr(catalog, "MODEL_GIVE_UP_AFTER", 99)
+ monkeypatch.setattr(catalog, "MODEL_PART_MAX_CALLS", 99)
model = Scripted(_hollow)
got = await D.run_model_draft(model, _source({"wide45": _table(45)}), ["wide45"])
o = got["wide45"]
@@ -613,14 +836,19 @@ async def test_the_same_reason_is_said_once_even_when_some_parts_were_split(monk
def test_reasons_are_joined_by_their_base():
+ """(原因, 第几段):同样的原因只说一次;同一个原因落在几段上写「这几段」,同一段拆出来的几块仍是「这一段」。"""
W = D._Why
- exhausted = D.REASON_EXHAUSTED.format(budget=32768)
- assert D._join([W(D.REASON_HOLLOW), W(D.REASON_HOLLOW, split=True)]) == D.REASON_HOLLOW + D.REASON_SPLIT
- assert D._join([W(exhausted, split=True), W(D.REASON_HOLLOW)]) == \
- f"{exhausted};{D.REASON_HOLLOW}{D.REASON_SPLIT}"
- assert D._join([W("甲"), W("乙"), W("丙", split=True)]) == "甲;乙", "没说出来的原因不补后缀"
- assert D._join([W(D.REASON_HOLLOW, halted=True), W(D.REASON_HOLLOW)]) == \
+ ex = D.REASON_EXHAUSTED.format(budget=32768, seg=D.SEG)
+ assert D._join([(W(D.REASON_HOLLOW), 1), (W(D.REASON_HOLLOW, split=True), 2)]) == \
+ D.REASON_HOLLOW + D.REASON_SPLIT
+ assert D._join([(W(ex, split=True), 2), (W(D.REASON_HOLLOW), 1)]) == \
+ f"{_exhausted()};{D.REASON_HOLLOW}{D.REASON_SPLIT}"
+ assert D._join([(W(ex), 2), (W(ex), 3)]) == _exhausted("这几段")
+ assert D._join([(W(ex), 2), (W(ex, split=True), 2)]) == _exhausted() + D.REASON_SPLIT
+ assert D._join([(W("甲"), 1), (W("乙"), 1), (W("丙", split=True), 1)]) == "甲;乙", "没说出来的原因不补后缀"
+ assert D._join([(W(D.REASON_HOLLOW, halted=True), 1), (W(D.REASON_HOLLOW), 2)]) == \
D.REASON_HOLLOW + D.REASON_HALTED.format(n=catalog.MODEL_GIVE_UP_AFTER)
+ assert D._join([(W("调用模型出错:{坏的}"), 1)]) == "调用模型出错:{坏的}", "报错原文里的花括号原样留着"
def _by_table(model: Scripted) -> dict[str, int]:
@@ -643,7 +871,7 @@ async def test_after_two_segments_in_a_row_come_to_nothing_the_rest_are_not_spli
model = Scripted(lambda call: exhausted)
got = await D.run_model_draft(model, _source(tables), list(tables))
assert _by_table(model) == {"t1": 3, "t2": 3, "t3": 1, "t4": 1}
- budget = D.REASON_EXHAUSTED.format(budget=32768)
+ budget = _exhausted()
assert got["t1"].error == budget + D.REASON_SPLIT
assert got["t3"].error == budget + ";模型连续 2 段都没写出来,已停止拆小重试,建议更换起草模型"
@@ -657,23 +885,23 @@ async def test_after_two_segments_in_a_row_come_to_nothing_the_rest_are_not_spli
async def test_the_column_parts_of_a_wide_table_stop_splitting_too(monkeypatch):
"""宽表的列分段一段一段排队起草(一段拆到底再起草下一段),止损在宽表里同样起作用。
- 95 列:表级那段拆不开(结构化、纯文本各一次);第 2 段 40 列拆到底(15 次结构化 + 8 次纯文本);这时已经连续 2 段
- 没写出来,第 3、4 段只问一次、换纯文本一次。"""
+ 95 列:表级那段拆不开(结构化、纯文本各一次);第 2 段 32 列拆小、到 8 次封顶;这时已经连续 2 段没写出来,第 3、4 段
+ 只问一次、换纯文本一次。"""
monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
model = Scripted(_hollow)
got = await D.run_model_draft(model, _source({"wide95": _table(95)}), ["wide95"])
- assert len(model.calls) == 2 + 23 + 2 + 2
- assert got["wide95"].error == (D.REASON_HOLLOW + D.REASON_SPLIT
- + ";模型连续 2 段都没写出来,已停止拆小重试,建议更换起草模型")
+ assert len(model.calls) == 2 + 8 + 2 + 2
+ assert got["wide95"].error == (D.REASON_HOLLOW + ";每段最多调用 8 次模型,这一段到了次数仍没写完,已停止重试"
+ + D.REASON_SPLIT + ";模型连续 2 段都没写出来,已停止拆小重试,建议更换起草模型")
async def test_a_wide_table_that_never_comes_back_is_bounded_by_the_first_wave():
- """默认并发(3 段同时起草):294 列一直只给空壳,最多是表级那段 2 次、头 3 个列分段拆到底(各 23 次)、其余 5 段
- 各 2 次。以前所有列分段一下子都排上去、一层一层地拆,要 168 次。"""
+ """默认并发(3 段同时起草):294 列(8 个列分段)一直只给空壳,最多是表级那段 2 次、头 3 个列分段各 8 次(封顶)、
+ 其余 5 段各 2 次。以前所有列分段一下子都排上去、一层一层地拆,要 168 次。"""
model = Scripted(_hollow)
got = await D.run_model_draft(model, _source({"wide294": _table(294)}), ["wide294"])
- assert got["wide294"].status == "failed"
- assert len(model.calls) <= 2 + 3 * 23 + 5 * 2
+ assert got["wide294"].status == "failed" and got["wide294"].parts_total == 9
+ assert len(model.calls) <= 2 + 3 * 8 + 5 * 2
async def test_a_big_draft_does_not_hold_the_model_ahead_of_a_later_one(monkeypatch):
@@ -707,6 +935,85 @@ async def test_a_big_draft_does_not_hold_the_model_ahead_of_a_later_one(monkeypa
assert order == ["a1", "b1", "a2", "a3", "a4"]
+def _order(model: Scripted) -> list[tuple[str, str]]:
+ return [(name, part) for call in model.calls for name, (part, _) in call.asked.items()]
+
+
+async def test_narrow_tables_are_not_cut_in_by_the_column_parts_of_a_wide_one(monkeypatch):
+ """一个任务里按表的先后排队:宽表的表级那段写完,它的列分段排到已经排着的窄表段后面(先来先服务),不插队。
+ 以前插到队首,窄表要等宽表的列分段全部派完。"""
+ monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
+ monkeypatch.setattr(catalog, "MODEL_BATCH_TABLES", 1)
+ tables = {"wide": _table(45), "na": _table(4), "nb": _table(4)}
+ model = Scripted(answering(tables))
+ got = await D.run_model_draft(model, _source(tables), ["wide", "na", "nb"])
+ assert set(_status(got).values()) == {"ok"}
+ assert _order(model) == [("wide", "table"), ("na", "full"), ("nb", "full"), ("wide", "columns"),
+ ("wide", "columns")]
+
+
+async def test_after_two_exhausted_or_timed_out_calls_new_parts_are_halved(monkeypatch):
+ """额度用完或超时累计 2 次,之后新开始的段列数上限减半(40 → 20 → 10,最低 10);已经开始的段照旧(按原因拆小)。
+ 每次调用的结果里带着当前的上限(part_columns)。"""
+ monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
+ monkeypatch.setattr(catalog, "MODEL_BATCH_TABLES", 1)
+ tables = {f"t{i}": _table(40) for i in range(1, 4)}
+ ok = answering(tables)
+
+ def handle(call: Call) -> Reply:
+ (_, (part, cols)), = call.asked.items()
+ width = 40 if part == "full" else len(cols)
+ return EXHAUSTED if width > 10 else ok(call) # 这个模型一次只写得出 10 列
+
+ model, reports = Scripted(handle), Reports()
+ got = await D.run_model_draft(model, _source(tables), list(tables), hooks=reports.hooks())
+ assert set(_status(got).values()) == {"ok"}
+ asked = {name: [(part, len(cols or [])) for c in model.calls for n, (part, cols) in c.asked.items() if n == name]
+ for name in tables}
+ # t1:整张 40 列开始,用完额度拆成 20、20,又各拆成 10、10
+ assert asked["t1"][0] == ("full", 0) and len(asked["t1"]) == 7
+ # 第 2 次用完额度后上限是 20:t2 一开始就按 20 列切成两段(各自再拆一次)
+ assert asked["t2"][0] == ("table+columns", 20) and ("columns", 20) in asked["t2"]
+ # 第 4 次之后上限是 10:t3 一开始就切成 4 段 10 列,一次都没用完额度
+ assert asked["t3"] == [("table+columns", 10), ("columns", 10), ("columns", 10), ("columns", 10)]
+ caps = [r.part_columns for r in reports.items]
+ assert list(dict.fromkeys(caps)) == [40, 20, 10]
+ assert caps[:2] == [40, 20], "第 2 次用完额度的那次调用之后就是 20"
+ assert min(caps) == 10 == catalog.MODEL_MIN_PART_COLUMNS
+
+
+async def test_the_notice_comes_when_four_of_six_calls_fail_and_goes_when_fewer_than_two(monkeypatch):
+ """最近 6 次调用有 4 次以上没写出来:提醒(写明次数和原因,建议换起草模型、减少表或停止);之后最近 6 次里没写出来
+ 的少于 2 次就清掉。"""
+ monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
+ monkeypatch.setattr(catalog, "MODEL_BATCH_TABLES", 1)
+ tables = {f"t{i}": _table(4) for i in range(1, 10)}
+ ok = answering(tables)
+
+ def handle(call: Call) -> Reply:
+ name = next(iter(call.asked))
+ if name in ("t1", "t2"):
+ raise RuntimeError("网关拒绝了这次请求")
+ if name == "t3":
+ return EXHAUSTED
+ if name == "t4":
+ return _hollow(call)
+ return ok(call)
+
+ reports = Reports()
+ got = await D.run_model_draft(Scripted(handle), _source(tables), list(tables), hooks=reports.hooks())
+ assert reports.failures == ["error", "error", "exhausted", "hollow", "hollow"] + [None] * 5
+ notices = [r.notice for r in reports.items]
+ assert notices[:3] == [None] * 3
+ assert notices[3] == ("最近 4 次调用有 4 次没写出来(额度用完 1 次、只给空壳 1 次、出错 2 次)。可以在「设置 → 偏好设置」"
+ "的「起草模型」换一个模型、减少这次起草的表,或者停止这次起草")
+ assert notices[4].startswith("最近 5 次调用有 5 次没写出来")
+ assert notices[7].startswith("最近 6 次调用有 3 次没写出来"), "还有 2 次以上没写出来:留着,次数照实写"
+ assert notices[8].startswith("最近 6 次调用有 2 次没写出来")
+ assert notices[9] is None, "最近 6 次只有 1 次没写出来:清掉"
+ assert got["t9"].status == "ok"
+
+
# --------------------------------------------------------------------------
# 起草要求、用量
# --------------------------------------------------------------------------
@@ -747,13 +1054,11 @@ async def test_usage_is_added_up_through_the_hooks():
return Reply(text="", finish="length", thinking="……", usage=(100, 32768))
return ok(call)
- seen: list[tuple[int, int]] = []
-
- async def usage(i: int, o: int) -> None:
- seen.append((i, o))
-
- await D.run_model_draft(Scripted(handle), _source(tables), ["orders_x"], hooks=D.Hooks(usage=usage))
- assert seen == [(100, 32768), (120, 60), (120, 60)]
+ reports = Reports()
+ await D.run_model_draft(Scripted(handle), _source(tables), ["orders_x"], hooks=reports.hooks())
+ assert [(r.input_tokens, r.output_tokens, r.failure) for r in reports.items] == \
+ [(100, 32768, "exhausted"), (120, 60, None), (120, 60, None)]
+ assert {r.notice for r in reports.items} == {None} and {r.part_columns for r in reports.items} == {40}
def test_budget_words_match_the_other_drafts():
diff --git a/backend/tests/test_catalog_draft_jobs.py b/backend/tests/test_catalog_draft_jobs.py
index a8a961bb..c72cc9b0 100644
--- a/backend/tests/test_catalog_draft_jobs.py
+++ b/backend/tests/test_catalog_draft_jobs.py
@@ -6,7 +6,9 @@
- 停止:排队的直接 cancelled;进行中的停掉正在进行的模型调用,没写完的表记 cancelled,已经写进目录的不回滚;已结束的 409
- 服务启动时把没结束的任务标成 interrupted(没结束的表记 cancelled、写明服务重启),不自动续跑;关停时同样
- 重试:只含原任务里 failed、partial、cancelled 的表,本次说明沿用、起草要求取数据源当前的;没有可重试的 422
-- 目录清单:每行 last_draft,顶层 guidance、active_job
+- 目录清单:每行 last_draft(带模型留空的列数),顶层 guidance、active_job
+- 重新起草时模型这次留空、给的值没通过校验:以前起草好的模型项留着,删除只在列已经不在表结构里时才有
+- 用量记超时次数、没写出来的调用次数;任务记当前的列数上限、提醒(最近 6 次调用 4 次没写出来);逐表记模型留空的列
- 起草要求存在数据源 options 里(上传表格的源存在它自己那一行),数据源接口不显示、整份回写 options 时留着,拼连接串时摘掉
表名、列名、说明文字都是虚构的。
@@ -261,11 +263,136 @@ async def test_a_followup_that_comes_back_empty_keeps_the_earlier_model_items(cl
task = job["tasks"][0]
assert model.calls == 2
assert job["status"] == "partial" and task["status"] == "partial" and task["removed"] == 0
- assert "有 4 列没写出来" in task["model_error"] and "可以只重试这张表" in task["model_error"]
+ 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, 7)]}
+ first = await _draft(client, sid, monkeypatch, FakeModel({"tickets": full}), ["tickets"])
+ assert first["tasks"][0]["status"] == "ok" and first["blank_columns"] == 0
+ 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", "c06"):
+ assert after["columns"][col] == before["columns"][col], col
+ # 模型这次留空的列照实记下(表的状态不因此变)
+ assert task["blank_columns"] == 4 and task["blank_sample"] == ["c01", "c02", "c05", "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 列(带表级的项)超时 → 拆成 6、6 → 后 12 列
+ assert [len(requested(c)["tickets"][1] or []) for c in model.calls] == [0, 12, 6, 6, 12]
+
+ 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
+
+
+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()
From afc98d1318d0611f36bb647483fb90597601e9d5 Mon Sep 17 00:00:00 2001
From: ThearchyHelios
Date: Tue, 13 Oct 2026 20:23:30 +0800
Subject: [PATCH 21/29] =?UTF-8?q?=E6=97=A7=E7=9A=84=E5=90=8C=E6=AD=A5?=
=?UTF-8?q?=E8=B5=B7=E8=8D=89=20POST=20/catalog/draft=EF=BC=9A=E5=90=8C?=
=?UTF-8?q?=E4=B8=80=E4=B8=AA=E6=BA=90=E6=9C=89=E6=8E=92=E9=98=9F=E6=88=96?=
=?UTF-8?q?=E8=BF=9B=E8=A1=8C=E4=B8=AD=E7=9A=84=E5=90=8E=E5=8F=B0=E8=B5=B7?=
=?UTF-8?q?=E8=8D=89=E4=BB=BB=E5=8A=A1=E6=97=B6=E5=9B=9E=20409=20catalog?=
=?UTF-8?q?=5Fdraft=5Fbusy=EF=BC=88=E5=92=8C=E5=BB=BA=E4=BB=BB=E5=8A=A1?=
=?UTF-8?q?=E5=90=8C=E4=B8=80=E5=8F=A5=E8=AF=9D=EF=BC=89=EF=BC=8C=E4=B8=8D?=
=?UTF-8?q?=E5=86=8D=E4=B8=A4=E8=BE=B9=E5=90=8C=E6=97=B6=E5=86=99=E5=90=8C?=
=?UTF-8?q?=E4=B8=80=E4=B8=AA=E6=BA=90=E7=9A=84=E7=9B=AE=E5=BD=95=E3=80=81?=
=?UTF-8?q?=E6=8A=A2=E6=A8=A1=E5=9E=8B=E5=90=8D=E9=A2=9D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/api/catalog.py | 10 ++++++++-
backend/tests/test_catalog_draft_jobs.py | 26 ++++++++++++++++++++++++
2 files changed, 35 insertions(+), 1 deletion(-)
diff --git a/backend/app/api/catalog.py b/backend/app/api/catalog.py
index 624a2c12..0405a0f7 100644
--- a/backend/app/api/catalog.py
+++ b/backend/app/api/catalog.py
@@ -7,7 +7,8 @@
- 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)
@@ -417,8 +418,15 @@ async def draft_catalog(source_id: str, payload: CatalogDraftIn,
model_items:模型给了这张表几项可用内容(没用模型、模型这部分失败时为 null)。界面据此区分「模型给了内容、
只是和现有目录一致」(model_items > 0、三个数都是 0)和「模型没给内容」(model_error 有值)。
+
+ 同一个源有排队或进行中的后台起草任务时 409 catalog_draft_busy(和建任务同一句话):两边同时写同一个源的目录、
+ 抢模型名额。
"""
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)
diff --git a/backend/tests/test_catalog_draft_jobs.py b/backend/tests/test_catalog_draft_jobs.py
index c72cc9b0..00455492 100644
--- a/backend/tests/test_catalog_draft_jobs.py
+++ b/backend/tests/test_catalog_draft_jobs.py
@@ -9,6 +9,7 @@
- 目录清单:每行 last_draft(带模型留空的列数),顶层 guidance、active_job
- 重新起草时模型这次留空、给的值没通过校验:以前起草好的模型项留着,删除只在列已经不在表结构里时才有
- 用量记超时次数、没写出来的调用次数;任务记当前的列数上限、提醒(最近 6 次调用 4 次没写出来);逐表记模型留空的列
+- 旧的同步起草:同一个源有排队或进行中的任务时 409 catalog_draft_busy
- 起草要求存在数据源 options 里(上传表格的源存在它自己那一行),数据源接口不显示、整份回写 options 时留着,拼连接串时摘掉
表名、列名、说明文字都是虚构的。
@@ -374,6 +375,31 @@ async def test_blank_columns_reach_the_task_the_job_and_the_list(client, monkeyp
assert rows["tickets"]["last_draft"]["blank_columns"] == 7 and rows["gates"]["last_draft"]["blank_columns"] == 1
+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 a9170d872822f8aa4af521fa6e0e52c74a5ffeb4 Mon Sep 17 00:00:00 2001
From: ThearchyHelios
Date: Tue, 13 Oct 2026 20:23:30 +0800
Subject: [PATCH 22/29] =?UTF-8?q?README=EF=BC=9A=E6=95=B0=E6=8D=AE?=
=?UTF-8?q?=E7=9B=AE=E5=BD=95=E7=9A=84=E6=A8=A1=E5=9E=8B=E8=B5=B7=E8=8D=89?=
=?UTF-8?q?=E8=A1=A5=E4=B8=8A=E9=AA=8C=E6=94=B6=E5=90=8E=E7=9A=84=E4=BF=AE?=
=?UTF-8?q?=E6=AD=A3=EF=BC=88=E5=88=97=E5=88=86=E6=AE=B5=E5=9D=87=E5=88=86?=
=?UTF-8?q?=E3=80=81=E6=8C=89=E8=A1=A8=E5=85=88=E5=90=8E=E6=8E=92=E9=98=9F?=
=?UTF-8?q?=E3=80=81=E8=B6=85=E6=97=B6=E6=8B=86=E5=B0=8F=E3=80=81=E6=AE=B5?=
=?UTF-8?q?=E5=A4=A7=E5=B0=8F=E5=87=8F=E5=8D=8A=E3=80=81=E6=AF=8F=E6=AE=B5?=
=?UTF-8?q?=208=20=E6=AC=A1=E5=B0=81=E9=A1=B6=E3=80=81=E6=8F=90=E9=86=92?=
=?UTF-8?q?=E6=8D=A2=E6=A8=A1=E5=9E=8B=E3=80=81=E9=87=8D=E6=96=B0=E8=B5=B7?=
=?UTF-8?q?=E8=8D=89=E4=B8=8D=E5=88=A0=E6=A8=A1=E5=9E=8B=E8=BF=99=E6=AC=A1?=
=?UTF-8?q?=E6=B2=A1=E7=BB=99=E7=9A=84=E9=A1=B9=E3=80=81=E7=95=99=E7=A9=BA?=
=?UTF-8?q?=E7=9A=84=E5=88=97=E9=80=90=E8=A1=A8=E8=AE=B0=E4=B8=8B=E3=80=81?=
=?UTF-8?q?=E5=BA=93=E6=B3=A8=E9=87=8A=E5=B0=B1=E6=98=AF=E5=90=8D=E5=AD=97?=
=?UTF-8?q?=E6=9C=AC=E8=BA=AB=E7=9A=84=E4=B8=8D=E5=BD=93=E4=B8=AD=E6=96=87?=
=?UTF-8?q?=E5=90=8D=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 0f484f0e..d44f52e3 100644
--- a/README.md
+++ b/README.md
@@ -277,7 +277,7 @@ flowchart LR
| 环节 | 做法 |
|---|---|
| 起草 | 探查时读外键和唯一约束;按 `xxx_id`、`xxxId` 推断关联,表名带业务前缀的也认;可选让模型起草中文名、粒度、表类型、业务主键、业务日期和每列的含义、单位、度量类型。导入表格的源已有系统按核对结果生成的说明,目录不重复、不改写它 |
-| 模型起草 | 作为后台任务逐表进行,每张表记下状态和原因,写完一张就并入目录;关掉对话框不停,可以停止、只重试失败的表,清单上标出上次起草失败或只完成一部分的表。服务重启时没跑完的任务标成中断,不会自己续跑,点重试接上。保留模型的思考,输出额度 32768(服务方不收时退回 8192);每次调用最多 40 列,宽表先起草表级的项、再按段起草列;额度用完就拆小再问,缺的表和列只补问缺的,被截断的回复先留下已经写完整的部分。数据源上存的「起草要求」和这次的补充说明一起交给模型,帮它理解业务、统一用词,不能推翻「只记数据事实」的规矩,交回的内容照样逐项校验。用哪个模型在「设置 → 偏好设置」的「起草模型」里选,空着跟随助手的模型;按配方导入表格的 AI 起草也用它 |
+| 模型起草 | 作为后台任务逐表进行,每张表记下状态和原因,写完一张就并入目录;关掉对话框不停,可以停止、只重试失败的表,清单上标出上次起草失败或只完成一部分的表。服务重启时没跑完的任务标成中断,不会自己续跑,点重试接上。保留模型的思考,输出额度 32768(服务方不收时退回 8192);每次调用最多 40 列,宽表先起草表级的项、再把列均分成几段起草,按表的先后排队,不插到窄表前面;额度用完或超时就拆小再问,累计两次后新开始的段减半(最低 10 列),每段最多调用 8 次,缺的表和列只补问缺的,被截断的回复先留下已经写完整的部分;最近 6 次调用有 4 次没写出来时提醒换模型。重新起草时模型这次留空或没通过校验的项不删以前起草的;模型拿不准、留空的列逐表记下,可以补充起草要求后重起草。库注释就是表名、列名本身或者是标识符写法的,不当中文名。数据源上存的「起草要求」和这次的补充说明一起交给模型,帮它理解业务、统一用词,不能推翻「只记数据事实」的规矩,交回的内容照样逐项校验。用哪个模型在「设置 → 偏好设置」的「起草模型」里选,空着跟随助手的模型;按配方导入表格的 AI 起草也用它 |
| 数据剖析 | 默认关闭,按数据源开启。只读、经过守卫,单条查询和整次剖析都有上限,大表用统计信息估算行数,遮罩的列不取样。抽样核实推断出的关联,补上覆盖率和基数,站得住的升为已验证;状态列给出码值候选,含义等人填写 |
| 对话里纠正 | 用户说「status=9 表示作废」,助手给出一条目录修改提案,列出改前、改后和理由,人确认后才保存;公式不收,留给口径卡 |
| 版本与提醒 | 目录随表结构快照冻结进证据,每次运行用的是哪一版查得到。发布时记下模板引用的表的目录版本,正式运行时目录有变化就提醒;agent 运行时才写 SQL 的,按「可能涉及」单独提醒。只提醒,不拦运行 |
From 1e1aa3f64a3752a08d82a89fd2d75d7b19241fe3 Mon Sep 17 00:00:00 2001
From: ThearchyHelios
Date: Tue, 13 Oct 2026 20:28:58 +0800
Subject: [PATCH 23/29] =?UTF-8?q?=E8=B5=B7=E8=8D=89=E5=BC=95=E6=93=8E?=
=?UTF-8?q?=EF=BC=9A=E6=8F=90=E9=86=92=E7=AC=AC=E4=B8=80=E6=AC=A1=E5=87=BA?=
=?UTF-8?q?=E7=8E=B0=E6=97=B6=E8=AE=B0=E4=B8=80=E6=9D=A1=E6=97=A5=E5=BF=97?=
=?UTF-8?q?=EF=BC=88=E6=9C=80=E8=BF=91=E5=87=A0=E6=AC=A1=E8=B0=83=E7=94=A8?=
=?UTF-8?q?=E6=9C=89=E5=87=A0=E6=AC=A1=E6=B2=A1=E5=86=99=E5=87=BA=E6=9D=A5?=
=?UTF-8?q?=E3=80=81=E5=8E=9F=E5=9B=A0=EF=BC=89=EF=BC=9B=E6=A8=A1=E5=9D=97?=
=?UTF-8?q?=E8=AF=B4=E6=98=8E=E8=A1=A5=E4=B8=8A=E8=A1=A5=E9=97=AE=E6=97=B6?=
=?UTF-8?q?=E6=89=8D=E5=88=97=E5=87=BA=E3=80=81=E5=AD=97=E6=AE=B5=E7=95=99?=
=?UTF-8?q?=E7=A9=BA=E7=9A=84=E5=88=97=E4=B9=9F=E7=AE=97=E6=8B=BF=E4=B8=8D?=
=?UTF-8?q?=E5=87=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/data/catalog_draft.py | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/backend/app/data/catalog_draft.py b/backend/app/data/catalog_draft.py
index 90c6b33e..6d35d132 100644
--- a/backend/app/data/catalog_draft.py
+++ b/backend/app/data/catalog_draft.py
@@ -28,8 +28,8 @@
的参数),提示词里又没有格式说明,模型就写成了说明文字。这次起草后面改走纯文本,这一段换纯文本问一次。有思考
不等于额度用完:只有停在额度上、或者正文为空而有思考才算。
- 被截断的 JSON:先把已经完整的表、列对象捡回来,只补剩下的。
-- 一段里缺了的表、列只补缺的,补一次。补问之后:模型列出了、字段留空的列是拿不准,不算没写出来;整列没列出来的
- 算没写出来(这张表是 partial,旧的模型起草项不删);整张表还一项都没有的,照只给空壳处理(拆小、换纯文本)。
+- 一段里缺了的表、列只补缺的,补一次。补问之后:模型列出了、字段留空的列是拿不准(补问时才列出来的也一样),不算
+ 没写出来;整列没列出来的算没写出来(这张表是 partial);整张表还一项都没有的,照只给空壳处理(拆小、再问一次)。
- 结构化输出这个接口不支持(400 / 422 提到 response_format、json_schema、tool……,或者模型类不支持):这次起草
后面改走纯文本、从正文取 JSON(纯文本那条路另外说清 JSON 的格式)。
- 服务方嫌额度太大:退回 MODEL_FALLBACK_MAX_TOKENS 重来,这次起草后面都用它(catalog.DraftModel.shrink)。
@@ -1169,6 +1169,8 @@ class _Run:
if len(failed) >= int(C.MODEL_NOTICE_ON) or (self.notice is not None and len(failed) >= int(C.MODEL_NOTICE_OFF)):
counts = collections.Counter(failed)
kinds = "、".join(f"{label} {counts[kind]} 次" for kind, label in FAILURE_LABEL.items() if counts[kind])
+ if self.notice is None:
+ logger.warning("起草数据目录:最近 %d 次调用有 %d 次没写出来(%s)", len(self.recent), len(failed), kinds)
self.notice = NOTICE.format(calls=len(self.recent), failed=len(failed), kinds=kinds, place=DRAFT_MODEL_PLACE)
else:
self.notice = None
From b91a652efe030b9e3b4465aa5ee5a518603cd224 Mon Sep 17 00:00:00 2001
From: ThearchyHelios
Date: Tue, 13 Oct 2026 20:30:18 +0800
Subject: [PATCH 24/29] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95?=
=?UTF-8?q?=E8=B5=B7=E8=8D=89=EF=BC=88=E5=89=8D=E7=AB=AF=EF=BC=89=EF=BC=9A?=
=?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=8B=BF=E4=B8=8D=E5=87=86=E3=80=81=E7=95=99?=
=?UTF-8?q?=E7=A9=BA=E7=9A=84=E5=88=97=EF=BC=88=E4=B8=AD=E6=96=87=E5=90=8D?=
=?UTF-8?q?=E5=92=8C=E5=90=AB=E4=B9=89=E9=83=BD=E6=B2=A1=E7=BB=99=EF=BC=89?=
=?UTF-8?q?=E5=8D=95=E7=8B=AC=E5=86=99=E5=87=BA=E6=9D=A5=EF=BC=8C=E4=B8=8D?=
=?UTF-8?q?=E7=AE=97=E6=B2=A1=E5=86=99=E5=85=A8=E3=80=81=E8=A1=A8=E7=85=A7?=
=?UTF-8?q?=E6=A0=B7=E6=98=AF=E5=B7=B2=E5=AE=8C=E6=88=90=E3=80=82=E8=B5=B7?=
=?UTF-8?q?=E8=8D=89=E5=AF=B9=E8=AF=9D=E6=A1=86=E9=80=90=E8=A1=A8=E5=86=99?=
=?UTF-8?q?=E3=80=8C12=20=E5=88=97=E6=A8=A1=E5=9E=8B=E6=8B=BF=E4=B8=8D?=
=?UTF-8?q?=E5=87=86=E3=80=81=E7=95=99=E7=A9=BA=E4=BA=86=E3=80=8D=EF=BC=8C?=
=?UTF-8?q?=E5=88=97=E5=90=8D=E6=82=AC=E5=81=9C=E7=9C=8B=E5=BE=97=E5=88=B0?=
=?UTF-8?q?=E3=80=81=E4=B9=9F=E5=8F=AF=E4=BB=A5=E5=B1=95=E5=BC=80=EF=BC=88?=
=?UTF-8?q?=E6=9C=80=E5=A4=9A=205=20=E4=B8=AA=EF=BC=8C=E6=B2=A1=E5=88=97?=
=?UTF-8?q?=E5=85=A8=E5=86=99=E4=B8=80=E5=85=B1=E5=87=A0=E5=88=97=EF=BC=89?=
=?UTF-8?q?=EF=BC=9B=E7=BB=93=E6=9D=9F=E5=90=8E=E5=86=99=E5=90=88=E8=AE=A1?=
=?UTF-8?q?=E3=80=8C=E5=85=B1=2013=20=E5=88=97=E6=A8=A1=E5=9E=8B=E6=8B=BF?=
=?UTF-8?q?=E4=B8=8D=E5=87=86=E3=80=81=E7=95=99=E7=A9=BA=E4=BA=86=EF=BC=88?=
=?UTF-8?q?2=20=E5=BC=A0=E8=A1=A8=EF=BC=89=E3=80=8D=E5=92=8C=E5=8A=9E?=
=?UTF-8?q?=E6=B3=95=E2=80=94=E2=80=94=E5=9C=A8=E8=B5=B7=E8=8D=89=E8=A6=81?=
=?UTF-8?q?=E6=B1=82=E9=87=8C=E8=A1=A5=E5=85=85=E7=BC=A9=E5=86=99=E5=92=8C?=
=?UTF-8?q?=E6=9C=AF=E8=AF=AD=E7=9A=84=E5=90=AB=E4=B9=89=EF=BC=8C=E5=86=8D?=
=?UTF-8?q?=E9=87=8D=E6=96=B0=E8=B5=B7=E8=8D=89=E8=BF=99=E5=87=A0=E5=BC=A0?=
=?UTF-8?q?=E8=A1=A8=EF=BC=8C=E5=B7=B2=E6=9C=89=E7=9A=84=E5=86=85=E5=AE=B9?=
=?UTF-8?q?=E4=B8=8D=E4=BC=9A=E4=B8=A2=EF=BC=9B=E3=80=8C=E9=87=8D=E6=96=B0?=
=?UTF-8?q?=E8=B5=B7=E8=8D=89=E8=BF=99=20N=20=E5=BC=A0=E8=A1=A8=E3=80=8D?=
=?UTF-8?q?=E5=9B=9E=E5=88=B0=E8=AE=BE=E7=BD=AE=EF=BC=8C=E8=8C=83=E5=9B=B4?=
=?UTF-8?q?=E5=B0=B1=E6=98=AF=E8=BF=99=E5=87=A0=E5=BC=A0=E3=80=81=E5=8B=BE?=
=?UTF-8?q?=E7=9D=80=E7=94=A8=E6=A8=A1=E5=9E=8B=E3=80=81=E7=84=A6=E7=82=B9?=
=?UTF-8?q?=E5=9C=A8=E8=B5=B7=E8=8D=89=E8=A6=81=E6=B1=82=EF=BC=8C=E8=A1=A5?=
=?UTF-8?q?=E5=AE=8C=E5=86=8D=E5=BC=80=E5=A7=8B=EF=BC=9B=E7=BB=93=E6=9D=9F?=
=?UTF-8?q?=E5=90=8E=E9=BB=98=E8=AE=A4=E5=88=97=E5=87=BA=E7=9A=84=E8=A1=A8?=
=?UTF-8?q?=E8=BF=9E=E5=90=8C=E6=9C=89=E7=95=99=E7=A9=BA=E5=88=97=E7=9A=84?=
=?UTF-8?q?=EF=BC=88=E3=80=8C=E5=8F=AA=E7=9C=8B=E6=9C=AA=E5=86=99=E5=85=A8?=
=?UTF-8?q?=E3=80=81=E6=9C=89=E7=95=99=E7=A9=BA=E5=88=97=E7=9A=84=E8=A1=A8?=
=?UTF-8?q?=E3=80=8D=EF=BC=89=EF=BC=9B=E5=85=B3=E7=9D=80=E5=AF=B9=E8=AF=9D?=
=?UTF-8?q?=E6=A1=86=E6=97=B6=E7=BB=93=E6=9D=9F=E7=9A=84=E6=8F=90=E7=A4=BA?=
=?UTF-8?q?=E4=B9=9F=E6=8E=A5=E7=9D=80=E5=86=99=E7=95=99=E7=A9=BA=E7=9A=84?=
=?UTF-8?q?=E5=88=97=EF=BC=9B=E8=AE=BE=E7=BD=AE=E9=98=B6=E6=AE=B5=E7=9A=84?=
=?UTF-8?q?=E3=80=8C=E4=B8=8A=E4=B8=80=E6=AC=A1=E8=B5=B7=E8=8D=89=E3=80=8D?=
=?UTF-8?q?=E9=83=BD=E5=86=99=E5=AE=8C=E4=BA=86=E3=80=81=E5=8F=AA=E6=98=AF?=
=?UTF-8?q?=E6=9C=89=E7=95=99=E7=A9=BA=E5=88=97=E7=9A=84=E4=B9=9F=E6=8F=90?=
=?UTF-8?q?=EF=BC=8C=E5=86=99=E6=98=8E=E5=87=A0=E5=88=97=E3=80=82=E6=B8=85?=
=?UTF-8?q?=E5=8D=95=E4=B8=8A=E4=B8=8A=E6=AC=A1=E8=B5=B7=E8=8D=89=E6=9C=89?=
=?UTF-8?q?=E7=95=99=E7=A9=BA=E5=88=97=E7=9A=84=E8=A1=A8=E4=B8=80=E8=A1=8C?=
=?UTF-8?q?=E6=B7=A1=E8=89=B2=E8=BD=BB=E6=8F=90=E7=A4=BA=EF=BC=88=E4=B8=8D?=
=?UTF-8?q?=E6=98=AF=E5=A4=B1=E8=B4=A5=E6=A0=87=E8=AE=B0=EF=BC=8C=E5=92=8C?=
=?UTF-8?q?=E5=A4=B1=E8=B4=A5=E3=80=81=E9=83=A8=E5=88=86=E5=AE=8C=E6=88=90?=
=?UTF-8?q?=E7=9A=84=E6=A0=87=E8=AE=B0=E5=88=86=E5=BC=80=EF=BC=9B=E8=BF=98?=
=?UTF-8?q?=E5=9C=A8=E8=B5=B7=E8=8D=89=E7=9A=84=E9=82=A3=E4=B8=80=E6=AC=A1?=
=?UTF-8?q?=E4=B8=8D=E7=AE=97=E3=80=8C=E4=B8=8A=E6=AC=A1=E3=80=8D=EF=BC=89?=
=?UTF-8?q?=EF=BC=9B=E8=A1=A8=E8=AF=A6=E6=83=85=E9=A1=B6=E9=83=A8=E5=90=8C?=
=?UTF-8?q?=E6=A0=B7=E6=8F=90=E7=A4=BA=EF=BC=88=E5=8F=AA=E6=9C=89=E7=95=99?=
=?UTF-8?q?=E7=A9=BA=E5=88=97=E6=97=B6=E6=98=AF=E6=8F=90=E7=A4=BA=E3=80=81?=
=?UTF-8?q?=E4=B8=8D=E6=98=AF=E5=A4=B1=E8=B4=A5=EF=BC=9B=E9=83=A8=E5=88=86?=
=?UTF-8?q?=E5=AE=8C=E6=88=90=E5=8F=88=E6=9C=89=E7=95=99=E7=A9=BA=E5=88=97?=
=?UTF-8?q?=E7=9A=84=E5=B9=B6=E5=9C=A8=E4=B8=80=E6=9D=A1=E9=87=8C=EF=BC=89?=
=?UTF-8?q?=EF=BC=8C=E7=BB=99=E3=80=8C=E9=87=8D=E6=96=B0=E8=B5=B7=E8=8D=89?=
=?UTF-8?q?=E8=BF=99=E5=BC=A0=E8=A1=A8=E3=80=8D=E5=92=8C=E3=80=8C=E8=A1=A5?=
=?UTF-8?q?=E5=85=85=E8=B5=B7=E8=8D=89=E8=A6=81=E6=B1=82=E3=80=8D=EF=BC=88?=
=?UTF-8?q?=E6=89=93=E5=BC=80=E8=B5=B7=E8=8D=89=E8=AE=BE=E7=BD=AE=EF=BC=8C?=
=?UTF-8?q?=E8=8C=83=E5=9B=B4=E5=8F=AA=E6=9C=89=E8=BF=99=E5=BC=A0=E8=A1=A8?=
=?UTF-8?q?=EF=BC=89=E3=80=82=E7=B1=BB=E5=9E=8B=E5=8A=A0=20CatalogDraftTas?=
=?UTF-8?q?k.blank=5Fcolumns=E3=80=81blank=5Fsample=EF=BC=8CCatalogDraftJo?=
=?UTF-8?q?b.blank=5Fcolumns=EF=BC=8Clast=5Fdraft.blank=5Fcolumns=EF=BC=88?=
=?UTF-8?q?=E8=80=81=E6=9C=8D=E5=8A=A1=E7=AB=AF=E6=B2=A1=E6=9C=89=EF=BC=8C?=
=?UTF-8?q?=E6=8C=89=200=EF=BC=89=E3=80=82check-catalog=20=E6=96=B0?=
=?UTF-8?q?=E5=8A=A0=E4=B8=80=E8=8A=82=E8=A6=86=E7=9B=96=E9=80=90=E8=A1=A8?=
=?UTF-8?q?=E4=B8=8E=E6=B1=87=E6=80=BB=E3=80=81=E5=B1=95=E5=BC=80=E5=88=97?=
=?UTF-8?q?=E5=90=8D=E3=80=81=E9=BB=98=E8=AE=A4=E5=88=97=E8=A1=A8=E3=80=81?=
=?UTF-8?q?=E6=B8=85=E5=8D=95=E8=BD=BB=E6=8F=90=E7=A4=BA=EF=BC=88=E9=A2=9C?=
=?UTF-8?q?=E8=89=B2=E5=90=8C=E6=AC=A1=E8=A6=81=E4=BF=A1=E6=81=AF=E3=80=81?=
=?UTF-8?q?=E4=B8=8D=E6=98=AF=E8=AD=A6=E5=91=8A=E8=89=B2=EF=BC=89=E3=80=81?=
=?UTF-8?q?=E8=BF=9B=E8=A1=8C=E4=B8=AD=E4=B8=8D=E7=AE=97=E3=80=8C=E4=B8=8A?=
=?UTF-8?q?=E6=AC=A1=E3=80=8D=E3=80=81=E5=9B=9E=E5=88=B0=E8=AE=BE=E7=BD=AE?=
=?UTF-8?q?=E9=87=8D=E6=96=B0=E8=B5=B7=E8=8D=89=EF=BC=88=E5=85=88=E5=AD=98?=
=?UTF-8?q?=E8=B5=B7=E8=8D=89=E8=A6=81=E6=B1=82=E5=86=8D=E5=BB=BA=E4=BB=BB?=
=?UTF-8?q?=E5=8A=A1=EF=BC=89=E3=80=81=E7=BB=93=E6=9D=9F=E6=8F=90=E7=A4=BA?=
=?UTF-8?q?=E3=80=81=E8=A1=A8=E8=AF=A6=E6=83=85=E7=9A=84=E4=B8=A4=E4=B8=AA?=
=?UTF-8?q?=E6=8C=89=E9=92=AE=EF=BC=8C=E7=AA=84=E5=B1=8F=E5=B1=95=E5=BC=80?=
=?UTF-8?q?=E5=88=97=E5=90=8D=E4=B8=80=E5=B9=B6=E9=87=8F=EF=BC=9Bcheck-all?=
=?UTF-8?q?=20=E9=87=8C=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95=E7=9A=84?=
=?UTF-8?q?=E8=80=97=E6=97=B6=E6=8C=89=E5=AE=9E=E6=B5=8B=E6=94=B9=E6=88=90?=
=?UTF-8?q?=20130=20=E7=A7=92?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/src/lib/terms.ts | 26 ++-
frontend/src/pages/catalog/CatalogPage.tsx | 21 ++-
frontend/src/pages/catalog/DraftDialog.tsx | 141 ++++++++++++++---
frontend/src/pages/catalog/TableDetail.tsx | 55 +++++--
frontend/src/pages/catalog/TableIndex.tsx | 17 +-
frontend/src/pages/catalog/draftJob.ts | 23 +++
frontend/src/types.ts | 11 ++
scripts/check-all.mjs | 2 +-
scripts/check-catalog.mjs | 175 ++++++++++++++++++++-
9 files changed, 410 insertions(+), 61 deletions(-)
diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts
index dfb0aa2f..b409f578 100644
--- a/frontend/src/lib/terms.ts
+++ b/frontend/src/lib/terms.ts
@@ -2395,6 +2395,8 @@ export const CATALOG_TEXT = {
// - 「只重试失败的表」:重新起草失败、部分完成和已停止的表;已经写进目录的内容不回滚、不受影响。
// - 「没写出来」:一次调用没有写出内容(额度用完、只给空壳、超时、出错)。接连没写出来时服务端给一句「提醒」,
// 好转后收起;提醒是提醒的样式,不是错误。超时的调用不计用量。
+// - 「留空」:模型列出了一列(或这一段请它写了),中文名和含义却都没给,写「N 列模型拿不准、留空了」。表的状态不因此
+// 变,不算没写全;办法是在起草要求里补充缩写和术语的含义,再重新起草,已有的内容不会丢。
// ===========================================================================
/** 起草要求、本次补充说明的字数上限(服务端同一个数:超长 422) */
@@ -2424,9 +2426,14 @@ export const CATALOG_DRAFT_TEXT = {
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}`,
// ---- 进行中
queued: '排队中,等待开始…',
running: '正在起草…',
@@ -2457,6 +2464,15 @@ export const CATALOG_DRAFT_TEXT = {
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: '排队中,等待开始…',
@@ -2486,6 +2502,7 @@ export const CATALOG_DRAFT_TEXT = {
retryHint: '重新起草失败、部分完成和已停止的表;已写进目录的内容不受影响',
showAll: (n: number) => `显示全部 ${formatNumber(n)} 张表`,
showProblems: '只看未写全的表',
+ showProblemsBlank: '只看未写全、有留空列的表',
tasks: '逐表进度',
taskStatus: {
pending: '等待', running: '起草中', ok: '已完成', partial: '部分完成', failed: '失败', skipped: '已跳过', cancelled: '已停止',
@@ -2501,6 +2518,8 @@ export const CATALOG_DRAFT_TEXT = {
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: '服务重启,数据目录起草中断',
@@ -2510,6 +2529,11 @@ export const CATALOG_DRAFT_TEXT = {
lastFailed: '上次起草失败',
lastPartial: '上次起草部分完成',
lastWith: (label: string, reason: string) => `${label}:${reason}`,
+ /** 上次起草有留空的列:清单上是轻提示(不是失败标记),表详情顶部同一句 */
+ lastBlank: (n: number) => `上次起草有 ${formatNumber(n)} 列模型拿不准、留空了`,
+ lastBlankAdvice: '可以在起草要求里补充缩写和术语的含义,再重新起草这张表,已有的内容不会丢',
+ draftGuidance: '补充起草要求',
+ draftGuidanceHint: '打开起草设置,范围是这张表:先在起草要求里补充缩写和术语的含义,再开始起草',
redraft: '重新起草这张表',
redraftHint: '用模型重新起草这张表(不带补充说明,带上数据源的起草要求);人工确认、驳回过的项不会被改动',
redraftBusy: '已有起草任务在进行,结束后再重新起草这张表',
diff --git a/frontend/src/pages/catalog/CatalogPage.tsx b/frontend/src/pages/catalog/CatalogPage.tsx
index 51097aea..3257ec32 100644
--- a/frontend/src/pages/catalog/CatalogPage.tsx
+++ b/frontend/src/pages/catalog/CatalogPage.tsx
@@ -12,8 +12,8 @@ import {
} from '../../lib/terms'
import { StatusLegend, SystemNotesNotice } from './parts'
import { DraftDialog } from './DraftDialog'
-import { isJobActive, retryableCount, useDraftJob } from './draftJob'
-import type { DraftStartBody } from './draftJob'
+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'
@@ -105,9 +105,9 @@ export function CatalogPage() {
const [selected, setSelected] = useState>(() => new Set())
/**
* 起草对话框开着没有、打开时显示什么:setup 选范围开始;job 显示页面跟着的任务(进行中的进度、刚结束的结果)。
- * busy:是撞上已有任务(409)转过去显示的
+ * busy:是撞上已有任务(409)转过去显示的;preset:设置阶段预先选好的范围(表详情里「补充起草要求」)
*/
- const [draftOpen, setDraftOpen] = useState<{ view: 'setup' | 'job'; busy?: boolean } | null>(null)
+ const [draftOpen, setDraftOpen] = useState<{ view: 'setup' | 'job'; busy?: boolean; preset?: DraftPreset } | null>(null)
/** 起草写进了这些表:详情据此重新载入(编辑中的不动,保存时服务端会用版本号拦下) */
const [drafted, setDrafted] = useState<{ seq: number; tables: Set }>({ seq: 0, tables: new Set() })
const [profiling, setProfiling] = useState(false)
@@ -169,8 +169,10 @@ export function CatalogPage() {
key: `catalog-draft:${src}`, duration: 10000,
action: { label: DT.viewResult, onClick: () => { if (sourceRef.current === src) setDraftOpen({ view: 'job' }) } },
}
- if (job.status === 'succeeded') toast.ok(DT.toast.succeeded(CT.draftSummary(job.added, job.updated, job.removed)), opts)
- else if (job.status === 'partial') toast.warn(DT.toast.partial(retryableCount(job)), opts)
+ // 留空的列不算没写全,提示里接着写一句,点「查看结果」看是哪几张表、怎么办
+ 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)
@@ -195,6 +197,10 @@ export function CatalogPage() {
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 }))
}, [])
@@ -402,7 +408,7 @@ export function CatalogPage() {
? (
+ draftBusy={!!stripJob} onRedraft={redraftTable} onDraftGuidance={draftWithGuidance} />
)
: }
@@ -446,6 +452,7 @@ export function CatalogPage() {
job={draftJob}
initial={draftOpen.view}
initialBusy={!!draftOpen.busy}
+ preset={draftOpen.preset ?? null}
stopping={draft.stopping}
onStart={startDraft}
onCancel={draft.cancel}
diff --git a/frontend/src/pages/catalog/DraftDialog.tsx b/frontend/src/pages/catalog/DraftDialog.tsx
index c68c46d4..4dafc323 100644
--- a/frontend/src/pages/catalog/DraftDialog.tsx
+++ b/frontend/src/pages/catalog/DraftDialog.tsx
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
-import { CircleCheck, CircleDashed, CircleMinus, CircleSlash, CircleX, RotateCcw, Sparkles, TriangleAlert } from 'lucide-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 { ApiError, api } from '../../api/client'
@@ -9,26 +9,33 @@ import { errorMessage } from '../../lib/errors'
import { formatTime } from '../../lib/format'
import { CATALOG_DRAFT_TEXT as DT, CATALOG_TEXT as CT, DRAFT_GUIDANCE_MAX, DRAFT_NOTE_MAX } from '../../lib/terms'
import {
- DRAFT_MAX_TABLES, DRAFT_MODEL_UNAVAILABLE, DRAFT_PART_COLUMNS, DRAFT_TOP_N, RETRYABLE, isJobActive, nothingWritten, retryableCount,
- taskReason,
+ DRAFT_MAX_TABLES, DRAFT_MODEL_UNAVAILABLE, DRAFT_PART_COLUMNS, DRAFT_TOP_N, RETRYABLE, blankOf, isJobActive, jobBlank, needsLook,
+ nothingWritten, retryableCount, taskReason,
} from './draftJob'
-import type { DraftStartBody } from './draftJob'
+import type { DraftPreset, DraftStartBody } from './draftJob'
// ===========================================================================
// 起草数据目录。起草是服务端的后台任务:这里选范围、写起草要求和本次补充说明,建任务;之后显示页面跟着的那个任务
// (进行中的进度、结束后的结果),任务本身的状态在页面上(draftJob.ts),关掉对话框照常进行。
//
// 三段:设置(范围、用不用模型;用模型时写起草要求、补充说明,写明用哪个起草模型)→ 进行中(总进度、合计、调用接连
-// 没写出来时的提醒、逐表的状态和宽表的段进度、没写全的原因;可以停止、在后台继续)→ 结束(汇总、没写全的表和原因、
-// 只重试失败的表)。
+// 没写出来时的提醒、逐表的状态和宽表的段进度、没写全的原因、留空的列;可以停止、在后台继续)→ 结束(汇总、没写全的表
+// 和原因、留空的列合计、只重试失败的表)。
// 已经写进目录的内容不回滚——起草只增改推断项,人工确认、驳回过的项服务端不会动。
+//
+// 留空的列(模型列出了、中文名和含义都没给)不算没写全,表照样是已完成;结果里写合计和办法——在起草要求里补充缩写和
+// 术语的含义,再重新起草这几张表。「重新起草这几张表」回到设置:范围就是那几张、勾着用模型、焦点在起草要求,
+// 补完再开始。表详情里的「补充起草要求」也是这样打开(范围是那一张,见 preset)。
// ===========================================================================
-type Scope = 'selected' | 'filtered' | 'top' | 'all'
+type Scope = 'preset' | 'selected' | 'filtered' | 'top' | 'all'
/** 字数按服务端的算法:去掉首尾空白后的字符数(按码位,不按 UTF-16 的长度) */
const charCount = (s: string) => [...s.trim()].length
+/** 起草要求输入框的 id:预先选好范围回到设置时焦点放在这里 */
+const GUIDANCE_ID = 'catalog-draft-guidance'
+
/**
* 建任务(开始、只重试失败的表)没建成怎么说。起草模型用不了(409,建任务和重试都会回):服务端那句话已经写了原因和
* 去处,照原样,长句也不拆成「标题:原因」;别的错误前面写明没有开始
@@ -37,8 +44,8 @@ const startErrorText = (e: unknown) =>
(e instanceof ApiError && e.code === DRAFT_MODEL_UNAVAILABLE ? e.message : DT.startFailed(errorMessage(e)))
export function DraftDialog({
- sourceId, sourceDescription, guidance, rows, visible, selected, filtered, job, initial, initialBusy = false, stopping,
- onStart, onCancel, onRetry, onTrack, onGuidanceSaved, onClose,
+ sourceId, sourceDescription, guidance, rows, visible, selected, filtered, job, initial, initialBusy = false, preset: initialPreset = null,
+ stopping, onStart, onCancel, onRetry, onTrack, onGuidanceSaved, onClose,
}: {
sourceId: string
/** 数据源说明:用模型起草时连同起草要求一起交给模型 */
@@ -58,6 +65,11 @@ export function DraftDialog({
initial: 'setup' | 'job'
/** 打开时显示的任务是撞上已有任务(409)转过来的:写明 */
initialBusy?: boolean
+ /**
+ * 打开设置时预先选好的范围(表详情里「补充起草要求」:只有那张表)。给了就勾上用模型、焦点放在起草要求上:
+ * 打开它就是为了先补起草要求再起草
+ */
+ preset?: DraftPreset | null
/** 点了停止、服务端还没停下来 */
stopping: boolean
/** 建任务;409 已有任务时页面改取进行中的那一个(busy 为真) */
@@ -73,8 +85,12 @@ export function DraftDialog({
// 表结构里已经没有的表起草不了(服务端会记「已跳过」),不算进范围
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(initialPreset)
const scopes = useMemo(() => {
const out: { key: Scope; label: string; tables: string[] }[] = []
+ const pre = preset?.tables.filter((t) => liveSet.has(t)) ?? []
+ if (preset && pre.length) out.push({ key: 'preset', label: preset.label, tables: pre })
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)
@@ -82,11 +98,13 @@ export function DraftDialog({
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(() => scopes[0].key)
const tables = scopes.find((s) => s.key === scope)?.tables ?? scopes[0].tables
const tooMany = tables.length > DRAFT_MAX_TABLES
- const [useModel, setUseModel] = useState(false)
+ const [useModel, setUseModel] = useState(!!initialPreset)
+ /** 回到设置以后把焦点放进起草要求(预先选好范围的两种入口都是为了先补起草要求) */
+ const [focusGuidance, setFocusGuidance] = useState(!!initialPreset)
const [guidanceText, setGuidanceText] = useState(guidance)
const [note, setNote] = useState('')
const guidanceLen = charCount(guidanceText)
@@ -102,7 +120,7 @@ export function DraftDialog({
const [busy, setBusy] = useState(initialBusy)
const [acting, setActing] = useState(false)
const [actionError, setActionError] = useState(null)
- /** 上一次起草(没写全的才提),设置阶段写一行、可以点开 */
+ /** 上一次起草(没写全、有留空列的才提),设置阶段写一行、可以点开 */
const [last, setLast] = useState(null)
/** 起草模型的说法:设置里的起草模型,没配就是跟随助手的模型 */
const [modelLine, setModelLine] = useState<{ text: string; warn: string | null } | null>(null)
@@ -120,13 +138,22 @@ export function DraftDialog({
onTrackRef.current(latest)
setBusy(true)
setMode('job')
- } else if (latest.status !== 'succeeded') {
+ } else if (latest.status !== 'succeeded' || jobBlank(latest) > 0) {
setLast(latest)
}
}, () => {})
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
@@ -194,6 +221,26 @@ export function DraftDialog({
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 phase: 'setup' | 'running' | 'done' = inSetup ? 'setup' : isJobActive(job) ? 'running' : 'done'
const canStart = !!tables.length && !tooMany && !(useModel && (guidanceOver || noteOver)) && !starting
@@ -263,7 +310,7 @@ export function DraftDialog({
{modelLine?.warn && {modelLine.warn}}
@@ -319,13 +372,26 @@ function Counter({ n, max }: { n: number; max: number }) {
)
}
-/** 任务的进度或结果:标题、总进度、提醒(进行中)、合计、模型和用量(超时、没写出来的次数)、没写全的表、逐表列表 */
-function JobView({ job, running, stopping, busy }: { job: CatalogDraftJob; running: boolean; stopping: boolean; busy: boolean }) {
+/**
+ * 任务的进度或结果:标题、总进度、提醒(进行中)、合计、模型和用量(超时、没写出来的次数)、没写全的表、留空的列合计
+ * (结束后)、逐表列表
+ */
+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 && problems.length > 0 && !showAll
- const tasks = onlyProblems ? problems : job.tasks
+ 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
@@ -351,6 +417,7 @@ function JobView({ job, running, stopping, busy }: { job: CatalogDraftJob; runni
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 (
{busy &&
{DT.busy}}
@@ -388,14 +455,27 @@ function JobView({ job, running, stopping, busy }: { job: CatalogDraftJob; runni
{problemText && (
{problemText}
)}
+ {!running && blankTotal > 0 && (
+ // 留空的列不算没写全(表照样是已完成):写合计和办法,给「重新起草这几张表」回到设置先补起草要求
+
+ {DT.blankTotal(blankTotal, blankCount)}
+ {DT.blankAdvice}
+ {blankTables.length > 0 && (
+ onRedraftBlank(blankTables)}
+ data-draft-blank-redraft="">
+ {DT.blankRedraft(blankTables.length)}
+
+ )}
+
+ )}
{DT.tasks}
- {!running && problems.length > 0 && problems.length < job.tasks.length && (
+ {!running && attention.length > 0 && attention.length < job.tasks.length && (
setShowAll((v) => !v)} data-draft-show-all="">
- {showAll ? DT.showProblems : DT.showAll(job.tasks.length)}
+ {showAll ? (blankCount ? DT.showProblemsBlank : DT.showProblems) : DT.showAll(job.tasks.length)}
)}
@@ -420,13 +500,16 @@ const TASK_ICON: Record
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 (
@@ -448,6 +531,14 @@ function TaskRow({ task: t, jobError }: { task: CatalogDraftTask; jobError: stri
{reason && (
{reason}
)}
+ {blank > 0 && (sample.length
+ ? (
+
+ {DT.blank(blank)}
+ {DT.blankSample(sample, blank)}
+
+ )
+ :
{DT.blank(blank)}
)}
)
}
diff --git a/frontend/src/pages/catalog/TableDetail.tsx b/frontend/src/pages/catalog/TableDetail.tsx
index 334bc896..049ce6c3 100644
--- a/frontend/src/pages/catalog/TableDetail.tsx
+++ b/frontend/src/pages/catalog/TableDetail.tsx
@@ -21,7 +21,7 @@ import { ColumnTable, NARROW, Value, pendingCodes } from './ColumnTable'
import type { ColumnModel } from './ColumnTable'
import { CodesDialog } from './CodesDialog'
import { coverageText } from './profile'
-import { taskReason } from './draftJob'
+import { lastBlankOf, taskReason } from './draftJob'
import {
COLUMN_FIELDS, DATE_KEYS, TABLE_FIELDS, buildNotes, changeCount, columnPath, confirmProposed, countsOf, formFromNotes,
relationDraft, relationPath, relationRemoval, tablePath, unknownKeys, validateForm,
@@ -53,7 +53,7 @@ type Editing = { initial: EditForm; form: EditForm }
type Conflict = 'edit' | 'review'
export function TableDetail({
- sourceId, table, rows, drafted, fillCodes, onFillDone, prev, next, onOpen, onBack, onDetail, draftBusy = false, onRedraft,
+ sourceId, table, rows, drafted, fillCodes, onFillDone, prev, next, onOpen, onBack, onDetail, draftBusy = false, onRedraft, onDraftGuidance,
}: {
sourceId: string
table: string
@@ -77,8 +77,10 @@ export function TableDetail({
onDetail: (d: CatalogDetail) => void
/** 这个数据源有排队、进行中的起草任务:「重新起草这张表」等它结束 */
draftBusy?: boolean
- /** 上次起草失败、部分完成时的「重新起草这张表」:建只含这张表的起草任务(用模型、不带补充说明) */
+ /** 上次起草失败、部分完成或有留空列时的「重新起草这张表」:建只含这张表的起草任务(用模型、不带补充说明) */
onRedraft?: (table: string) => Promise
+ /** 上次起草有留空列时的「补充起草要求」:打开起草设置,范围只有这张表,先补起草要求再开始 */
+ onDraftGuidance?: (table: string) => void
}) {
const [detail, setDetail] = useState(null)
const [loadError, setLoadError] = useState(null)
@@ -318,6 +320,9 @@ export function TableDetail({
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
@@ -456,21 +461,39 @@ export function TableDetail({
)}
{writeError && {writeError}}
- {lastDraft && (lastDraft.status === 'failed' || lastDraft.status === 'partial') && (
- // 上次起草没写全:写明原因,给「重新起草这张表」(只含这张表、用模型)。有起草任务在进行、正在编辑这张表时
- // 先不给点:起草会写进这张表,编辑中的保存就会撞上版本冲突
-
- {lastDraft.status === 'failed' ? DT.lastFailed : DT.lastPartial}
- {taskReason(lastDraft) && {taskReason(lastDraft)}
}
+ {lastDraft && (lastBad || lastBlank > 0) && (
+ // 上次起草没写全:写明原因;有留空的列:写几列、怎么办(在起草要求里补充缩写和术语的含义)。只有留空的列时是
+ // 提示(表照样写完了),不是失败。给「重新起草这张表」(只含这张表、用模型);有留空的列再给「补充起草要求」
+ // (打开起草设置,范围是这张表)。有起草任务在进行、正在编辑这张表时先不给点:起草会写进这张表,编辑中的保存
+ // 就会撞上版本冲突
+
+ {lastBad && {lastBad.status === 'failed' ? DT.lastFailed : DT.lastPartial}
}
+ {lastBad && taskReason(lastBad) && (
+ {taskReason(lastBad)}
+ )}
+ {lastBlank > 0 && (
+ <>
+ {DT.lastBlank(lastBlank)}
+ {DT.lastBlankAdvice}
+ >
+ )}
{onRedraft && (
<>
- {
- setRedrafting(true)
- try { await onRedraft(table) } finally { setRedrafting(false) }
- }} data-catalog-redraft="">
- {redrafting ? : } {DT.redraft}
-
+
+
{
+ setRedrafting(true)
+ try { await onRedraft(table) } finally { setRedrafting(false) }
+ }} data-catalog-redraft="">
+ {redrafting ? : } {DT.redraft}
+
+ {lastBlank > 0 && onDraftGuidance && (
+
onDraftGuidance(table)} data-catalog-draft-guidance="">
+ {DT.draftGuidance}
+
+ )}
+
{edit ? DT.redraftEditing : draftBusy ? DT.redraftBusy : DT.redraftHint}
>
)}
diff --git a/frontend/src/pages/catalog/TableIndex.tsx b/frontend/src/pages/catalog/TableIndex.tsx
index 99b5d10c..4f9b3517 100644
--- a/frontend/src/pages/catalog/TableIndex.tsx
+++ b/frontend/src/pages/catalog/TableIndex.tsx
@@ -1,6 +1,6 @@
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'
@@ -10,15 +10,15 @@ import {
} from '../../lib/terms'
import { CountBadges } from './parts'
import { LIST_FILTERS, LIST_SORTS, progressOf } from './model'
-import { taskReason } from './draftJob'
+import { lastBlankOf, taskReason } from './draftJob'
import type { ListFilter, ListSort } from './model'
// ===========================================================================
// 表清单(数据目录页的左栏):搜索、按审阅进度筛选、排序、多选后批量起草。
// 一行两层:上面是中文名和各状态的项数,下面是表名、类型、关联数和使用次数;还没填中文名的表上面直接写表名,
// 下面淡淡地写「未填写中文名」——一屏几十行「未填写中文名」认不出是哪张表。上次起草失败、部分完成的表再多一行,
-// 写明原因(全文在悬停里,点开表还有「重新起草这张表」)。键盘:↑↓ 在行之间移动,Home / End 到两头,Enter 打开;
-// 复选框按住 Shift 点选可以连选一段。
+// 写明原因(全文在悬停里,点开表还有「重新起草这张表」);上次起草有留空列(模型拿不准)的表另有一行淡色的轻提示,
+// 不是失败标记。键盘:↑↓ 在行之间移动,Home / End 到两头,Enter 打开;复选框按住 Shift 点选可以连选一段。
// ===========================================================================
export function TableIndex({
@@ -213,6 +213,8 @@ const Row = memo(function Row({ row: r, index, active, checked, onToggle, onOpen
const last = r.last_draft && (r.last_draft.status === 'failed' || r.last_draft.status === 'partial') ? r.last_draft : null
const lastLabel = last ? (last.status === 'failed' ? DT.lastFailed : DT.lastPartial) : ''
const lastText = last ? (taskReason(last) ? DT.lastWith(lastLabel, taskReason(last)) : lastLabel) : ''
+ // 上次起草有留空的列:表照样写完了,只是模型对这几列拿不准。淡色的轻提示,和失败标记分开
+ const blank = lastBlankOf(r.last_draft)
return (
{lastText}
)}
+ {blank > 0 && (
+
+
+ {DT.lastBlank(blank)}
+
+ )}
)
diff --git a/frontend/src/pages/catalog/draftJob.ts b/frontend/src/pages/catalog/draftJob.ts
index 1e56e56b..529760ba 100644
--- a/frontend/src/pages/catalog/draftJob.ts
+++ b/frontend/src/pages/catalog/draftJob.ts
@@ -39,6 +39,29 @@ export const taskReason = (t: { error: string | null; model_error: string | null
/** 每段最多几列(服务端起草的起始上限)。任务里的当前上限比它小:额度用完、超时多了,之后的段缩小了 */
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:照常写合计。
* 用模型、却没有一张表拿到模型的可用内容:目录没有更新、模型没给内容;有表没写全:目录没有更新、原因见下方;
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index baf7ed51..4fd72ba3 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -2893,6 +2893,13 @@ export interface CatalogDraftTask {
* 大于 0 而新增、更新、删除都是 0,才是「模型给了内容、只是和现有目录一致」
*/
model_items: number | null
+ /**
+ * 模型拿不准、留空的列数:模型列出了(或这一段请它写了),中文名和含义却都没给(给了却都没通过校验的也算)。
+ * 表的状态不因此变(照旧可以是已完成)。老服务端没有这个键
+ */
+ blank_columns?: number
+ /** 留空的列里的前几个(最多 5 个,列名照表结构原样)。老服务端没有这个键 */
+ blank_sample?: string[]
/** 整张表没起草的原因:表结构里没有、写入一直冲突、服务重启中断 */
error: string | null
/** 模型那部分没写出来的原因和办法(第几段、哪些列、为什么) */
@@ -2941,6 +2948,8 @@ export interface CatalogDraftJob {
added: number
updated: number
removed: number
+ /** 各表模型拿不准、留空的列数合计。老服务端没有这个键 */
+ blank_columns?: number
/** 模型调用的用量;拿不到用量时照实是 0 */
usage: CatalogDraftUsage
/**
@@ -2973,6 +2982,8 @@ export interface CatalogLastDraft {
finished_at: string | null
error: string | null
model_error: string | null
+ /** 那次起草模型拿不准、留空的列数(同 CatalogDraftTask.blank_columns)。老服务端没有这个键 */
+ blank_columns?: number
}
/**
diff --git a/scripts/check-all.mjs b/scripts/check-all.mjs
index c124ed16..8e4d2f82 100644
--- a/scripts/check-all.mjs
+++ b/scripts/check-all.mjs
@@ -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: 110, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
+ evidence: 91, versions: 60, catalog: 130, '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,
}
diff --git a/scripts/check-catalog.mjs b/scripts/check-catalog.mjs
index 57963898..05b32494 100644
--- a/scripts/check-catalog.mjs
+++ b/scripts/check-catalog.mjs
@@ -429,7 +429,7 @@ const PART_COLUMNS = 40
* 建一个排队的任务:表结构里没有的表照样建进任务、记 skipped。重试建的记着原任务(retry_of,界面不用)。
* 超过 40 列的宽表均匀分段:列分段数 = ceil(列数 / 40),段进度是「表级一段 + 列分段」。
* 用模型的任务记着当前每段最多几列(part_columns,额度用完、超时多了服务端会减半)、调用接连没写出来时的提醒(notice),
- * 用量里另记超时和没写出来的调用次数
+ * 用量里另记超时和没写出来的调用次数;逐表记模型拿不准、留空的列数和前几个列名(blank_columns、blank_sample)
*/
function newJob(state, src, { tables, use_model, note = null, retry_of = null }) {
const id = `check-job-${++state.jobSeq}`
@@ -439,7 +439,7 @@ function newJob(state, src, { tables, use_model, note = null, retry_of = null })
return {
table_name: name, status: t ? 'pending' : 'skipped', columns: cols,
parts_total: t && use_model ? (cols > PART_COLUMNS ? 1 + Math.ceil(cols / PART_COLUMNS) : 1) : null, parts_done: 0,
- added: 0, updated: 0, removed: 0, version: t?.version ?? 0, model_items: null,
+ added: 0, updated: 0, removed: 0, version: t?.version ?? 0, model_items: null, blank_columns: 0, blank_sample: [],
error: t ? null : `表结构里没有 ${name},请先重新探查结构`, model_error: null,
started_at: null, finished_at: t ? null : isoNow(),
}
@@ -454,14 +454,14 @@ function newJob(state, src, { tables, use_model, note = null, retry_of = null })
return job
}
-/** 接口返回的形状:计数、合计由逐表算出来 */
+/** 接口返回的形状:计数、合计(含留空的列数)由逐表算出来 */
function jobOut(job) {
const counts = { pending: 0, running: 0, ok: 0, partial: 0, failed: 0, skipped: 0, cancelled: 0 }
for (const t of job.tasks) counts[t.status]++
const sum = (k) => job.tasks.reduce((n, t) => n + t[k], 0)
const { stopAsked, ...rest } = job
return clone({ ...rest, total: job.tasks.length, done: job.tasks.filter((t) => TASK_END.has(t.status)).length, counts,
- added: sum('added'), updated: sum('updated'), removed: sum('removed') })
+ added: sum('added'), updated: sum('updated'), removed: sum('removed'), blank_columns: sum('blank_columns') })
}
/**
@@ -527,7 +527,10 @@ function lastDraftOf(state, src, name) {
for (const job of [...state.jobs].reverse()) {
if (job.source_id !== src) continue
const t = job.tasks.find((x) => x.table_name === name)
- if (t) return { status: t.status, job_id: job.id, finished_at: t.finished_at, error: t.error, model_error: t.model_error }
+ if (t) {
+ return { status: t.status, job_id: job.id, finished_at: t.finished_at, error: t.error, model_error: t.model_error,
+ blank_columns: t.blank_columns }
+ }
}
return state.tables[src]?.find((x) => x.name === name)?.lastDraft ?? null
}
@@ -688,6 +691,10 @@ const WIDE_ERR = '这张表 50 列,分 3 段起草,第 3 段(promo_attr_19
const CALL_ERR = '调用模型出错:服务暂时不可用(已重试 2 次)'
/** 调用接连没写出来时服务端给的提醒(虚构的一句,照约定写明次数、原因和办法) */
const DRAFT_NOTICE = '最近 6 次调用有 4 次没写出来(3 次超时、1 次额度用完)。建议在「设置 → 偏好设置」的「起草模型」换一个模型、缩小起草范围,或者停止这次起草'
+/** 宽表里模型拿不准、留空的列(服务端最多给 5 个列名) */
+const BLANK_SAMPLE = ['promo_attr_31', 'promo_attr_32', 'promo_attr_33', 'promo_attr_34', 'promo_attr_35']
+/** 补上缩写含义的起草要求(虚构) */
+const GUIDANCE_ABBR = `${S1_GUIDANCE}。promo_attr 开头的列是促销活动的附加属性,按列名后面的编号区分`
const detail = (page) => page.locator('[data-catalog-detail]')
const version = async (page) => Number(await detail(page).getAttribute('data-version'))
const panel = (page) => page.locator('[data-item-panel]')
@@ -1379,6 +1386,158 @@ await section('数据目录 · 起草:接连没写出来的提醒、超时与
await close()
})
+await section('数据目录 · 起草:模型拿不准、留空的列', async () => {
+ const state = freshState()
+ // 不在这次起草里的一张表:上一次起草部分完成,又有留空的列
+ state.tables[S1].find((t) => t.name === 'channels').lastDraft = {
+ status: 'partial', job_id: 'check-job-old', finished_at: T0, error: null, model_error: CALL_ERR, blank_columns: 4,
+ }
+ const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
+ await page.locator('[data-catalog-row]').first().waitFor()
+ check('开始起草(闸机到会员 3 张,用模型)', await startDraft(page, { select: ['gates', 'members'], model: true }))
+ const job = state.jobs.at(-1)
+
+ // 留空的列:模型列出了、中文名和含义都没给。表的状态不因此变。闸机表写完了;宽表还在起草,已经有几列留空
+ settle(state, job, 'gates', { added: 2, model_items: 3, blank_columns: 1, blank_sample: ['gate_code'] })
+ settle(state, job, 'store_sales', { status: 'running', parts_done: 2, blank_columns: 5, blank_sample: BLANK_SAMPLE })
+ const blankOf = (n) => draftBox(page).locator(`[data-draft-task="${n}"] [data-draft-task-blank]`)
+ const rowBlank = (n) => page.locator(`[data-catalog-row="${n}"] [data-row-blank]`)
+ check('起草中:逐表跟着写留空的列;清单上写完的表才提示「上次起草」,还在起草的不提示',
+ await until(async () => (await blankOf('store_sales').innerText({ timeout: 500 }).catch(() => '')).includes('5 列模型拿不准、留空了')
+ && (await rowBlank('gates').count()) === 1)
+ && (await rowBlank('store_sales').count()) === 0 && (await page.locator('[data-catalog-row="store_sales"] [data-row-last-draft]').count()) === 0,
+ `${await draftText(page, '[data-draft-task="store_sales"]')} · ${await rowBlank('gates').count()} · ${await rowBlank('store_sales').count()}`)
+ settle(state, job, 'store_sales', { added: 30, model_items: 38, blank_columns: 12, blank_sample: BLANK_SAMPLE })
+ check('逐表:写完的宽表写「12 列模型拿不准、留空了」,状态照旧是「已完成」',
+ await until(async () => (await blankOf('store_sales').innerText({ timeout: 500 }).catch(() => '')).includes('12 列模型拿不准、留空了'))
+ && (await draftText(page, '[data-draft-task="store_sales"] [data-draft-task-status]')) === '已完成',
+ await draftText(page, '[data-draft-task="store_sales"]'))
+ const sample = blankOf('store_sales').locator('[data-draft-task-blank-sample]')
+ check('……悬停看得到是哪几列', ((await blankOf('store_sales').locator('summary').getAttribute('title')) ?? '').includes(BLANK_SAMPLE.join('、')),
+ await blankOf('store_sales').locator('summary').getAttribute('title').catch(() => ''))
+ check('……列名默认收起', !(await sample.isVisible()))
+ await blankOf('store_sales').locator('summary').click()
+ check('……展开写出列名,列不全时写一共几列', await until(async () => sample.isVisible())
+ && (await draftText(page, '[data-draft-task="store_sales"] [data-draft-task-blank-sample]')) === `留空的列:${BLANK_SAMPLE.join('、')} 等 12 列`,
+ await draftText(page, '[data-draft-task="store_sales"] [data-draft-task-blank-sample]'))
+ settle(state, job, 'members', { added: 3, model_items: 3 })
+ finishJob(job)
+ check('结束:标题照旧「起草完成」(留空的列不算没写全),不给「只重试失败的表」',
+ await until(async () => (await draftPhase(page)) === 'done') && (await draftText(page, '[data-draft-title]')) === '起草完成'
+ && await page.locator('[data-draft-retry]').count() === 0, await draftText(page, '[data-draft-title]'))
+ const blankSum = await draftText(page, '[data-draft-blank-summary]')
+ check('……汇总写合计:共 13 列模型拿不准、留空了(2 张表)', blankSum.includes('共 13 列模型拿不准、留空了(2 张表)'), blankSum)
+ check('……并给出办法:在起草要求里补充缩写和术语的含义,再重新起草这几张表,已有的内容不会丢',
+ blankSum.includes('在起草要求里补充缩写和术语的含义') && blankSum.includes('重新起草这几张表') && blankSum.includes('已有的内容不会丢'), blankSum)
+ const shown = await draftBox(page).locator('[data-draft-task]').evaluateAll((els) => els.map((e) => e.getAttribute('data-draft-task')))
+ check('……逐表默认只列有留空列的 2 张,没有留空列的那张不写,可以显示全部', shown.join(',') === 'gates,store_sales'
+ && await draftBox(page).locator('[data-draft-show-all]').count() === 1, shown.join(','))
+ await draftBox(page).locator('[data-draft-show-all]').click()
+ check('……显示全部:没有留空列的表不写留空', (await draftBox(page).locator('[data-draft-task]').count()) === 3 && await blankOf('members').count() === 0)
+ await shot(page, 'catalog-draft-blank')
+
+ // 清单:有留空列的表显示轻提示,不是失败标记
+ check('清单:有留空列的表显示轻提示「上次起草有 12 列模型拿不准、留空了」',
+ await until(async () => (await rowBlank('store_sales').innerText({ timeout: 500 }).catch(() => '')).includes('上次起草有 12 列模型拿不准、留空了')),
+ await rowBlank('store_sales').innerText({ timeout: 500 }).catch(() => ''))
+ const colors = await page.evaluate(() => {
+ const color = (q) => { const el = document.querySelector(q); return el ? getComputedStyle(el).color : null }
+ return { blank: color('[data-catalog-row="store_sales"] [data-row-blank]'), faint: color('[data-catalog-row="store_sales"] [data-row-usage]'),
+ partial: color('[data-catalog-row="channels"] [data-row-last-draft]') }
+ })
+ check('……不是失败标记:不写「上次起草失败 / 部分完成」,用次要信息的淡色,不用警告色', await page.locator('[data-catalog-row="store_sales"] [data-row-last-draft]').count() === 0
+ && !!colors.blank && colors.blank === colors.faint && colors.blank !== colors.partial, JSON.stringify(colors))
+ check('……没有留空列的表不提示', await rowBlank('members').count() === 0)
+ check('……部分完成又有留空列的表:失败标记和留空提示都在', (await page.locator('[data-catalog-row="channels"] [data-row-last-draft="partial"]').count()) === 1
+ && (await rowBlank('channels').innerText().catch(() => '')).includes('上次起草有 4 列模型拿不准、留空了'))
+
+ // 「重新起草这 2 张表」:回到设置,范围就是这 2 张、勾着用模型,焦点在起草要求;补上缩写的含义再开始
+ await draftBox(page).locator('[data-draft-blank-redraft]').click()
+ check('「重新起草这 2 张表」:回到设置,范围是有留空列的这 2 张,勾着用模型,焦点在起草要求',
+ await until(async () => (await draftPhase(page)) === 'setup')
+ && await draftBox(page).locator('[data-draft-scope="preset"] input').isChecked()
+ && (await draftText(page, '[data-draft-scope="preset"]')).includes('有留空列的 2 张表')
+ && await draftBox(page).locator('[data-draft-model] input').isChecked()
+ && await until(async () => page.evaluate(() => document.activeElement?.id === 'catalog-draft-guidance')),
+ `${await draftText(page, '[data-draft-scope="preset"]')} · ${await page.evaluate(() => document.activeElement?.id ?? '')}`)
+ check('……起草按钮写「起草 2 张表」', (await page.locator('[data-draft-start]').innerText()).includes('起草 2 张表'))
+ await draftBox(page).locator('[data-draft-guidance] textarea').fill(GUIDANCE_ABBR)
+ await page.locator('[data-draft-start]').click()
+ check('……先存起草要求,再建只含这 2 张表、用模型的任务', await until(async () => (await draftPhase(page)) === 'running')
+ && JSON.stringify(lastBody(sent, /catalog\/guidance$/)) === JSON.stringify({ guidance: GUIDANCE_ABBR })
+ && JSON.stringify(lastBody(sent, /draft-jobs$/)) === JSON.stringify({ tables: ['gates', 'store_sales'], use_model: true }),
+ JSON.stringify(lastBody(sent, /draft-jobs$/)))
+ // 这一回宽表仍有几列拿不准(已有的内容不丢由服务端保证)。关着对话框时结束:提示里也写留空的列
+ const again = state.jobs.at(-1)
+ await page.locator('[data-draft-hide]').click()
+ await until(async () => (await draftBox(page).count()) === 0)
+ settle(state, again, 'gates', { model_items: 3 })
+ settle(state, again, 'store_sales', { updated: 5, model_items: 40, blank_columns: 7, blank_sample: BLANK_SAMPLE })
+ finishJob(again)
+ const toastBox = page.locator('[data-toast]').filter({ hasText: '起草完成' })
+ check('关着对话框时结束:提示里写「7 列模型拿不准、留空了」', await until(async () => (await toastBox.count()) > 0)
+ && (await toastBox.first().innerText()).includes('7 列模型拿不准、留空了'),
+ await page.locator('[data-toast]').allInnerTexts().then((t) => t.join(' | ')).catch(() => ''))
+ await toastBox.getByRole('button', { name: '查看结果' }).click()
+ check('……点「查看结果」:汇总写合计', await until(async () => (await draftText(page, '[data-draft-blank-summary]')).includes('共 7 列模型拿不准、留空了(1 张表)')),
+ await draftText(page, '[data-draft-blank-summary]'))
+ await page.locator('[data-draft-close]').click()
+ // 再打开起草:设置阶段写明上一次有留空的列,可以点开
+ await page.locator('[data-catalog-draft]').first().click()
+ await draftBox(page).waitFor()
+ check('再打开起草:设置阶段写明上一次起草有 7 列模型拿不准、留空了,可以点开', await until(async () => (await draftText(page, '[data-draft-last]')).includes('7 列模型拿不准、留空了'))
+ && await draftBox(page).locator('[data-draft-last-open]').count() === 1, await draftText(page, '[data-draft-last]'))
+ await page.keyboard.press('Escape')
+ await until(async () => (await draftBox(page).count()) === 0)
+
+ // 表详情:顶部同样提示(不是失败),给「重新起草这张表」和「补充起草要求」
+ await page.locator('[data-catalog-row="store_sales"] [data-catalog-open-row]').click()
+ const tip = page.locator('[data-catalog-last-draft]')
+ check('表详情:顶部写「上次起草有 7 列模型拿不准、留空了」,是提示(不是失败)',
+ await until(async () => (await tip.innerText({ timeout: 500 }).catch(() => '')).includes('上次起草有 7 列模型拿不准、留空了'))
+ && (await tip.getAttribute('data-notice')) === 'info', await tip.innerText({ timeout: 500 }).catch(() => ''))
+ check('……写明办法:补充起草要求再重新起草,已有的内容不会丢;给「重新起草这张表」', (await tip.innerText()).includes('已有的内容不会丢')
+ && await tip.locator('[data-catalog-redraft]').count() === 1, await tip.innerText())
+ await shot(page, 'catalog-blank-detail')
+ const jobsBefore = count(sent, /^POST .*draft-jobs$/)
+ await tip.locator('[data-catalog-draft-guidance]').click()
+ check('「补充起草要求」:打开起草设置,范围只有这张表,勾着用模型,焦点在起草要求',
+ await until(async () => (await draftPhase(page)) === 'setup')
+ && await draftBox(page).locator('[data-draft-scope="preset"] input').isChecked()
+ && (await draftText(page, '[data-draft-scope="preset"]')).includes('store_sales')
+ && await draftBox(page).locator('[data-draft-model] input').isChecked()
+ && (await page.locator('[data-draft-start]').innerText()).includes('起草 1 张表')
+ && await until(async () => page.evaluate(() => document.activeElement?.id === 'catalog-draft-guidance')),
+ `${await draftText(page, '[data-draft-scope="preset"]')} · ${await page.evaluate(() => document.activeElement?.id ?? '')}`)
+ await page.keyboard.press('Escape')
+ check('……没改就关掉:不建任务', await until(async () => (await draftBox(page).count()) === 0) && count(sent, /^POST .*draft-jobs$/) === jobsBefore)
+ await tip.locator('[data-catalog-redraft]').click()
+ check('「重新起草这张表」:建只含这张表、用模型的任务', await until(async () => count(sent, /^POST .*draft-jobs$/) === jobsBefore + 1)
+ && JSON.stringify(lastBody(sent, /draft-jobs$/)) === JSON.stringify({ tables: ['store_sales'], use_model: true }),
+ JSON.stringify(lastBody(sent, /draft-jobs$/)))
+ const third = state.jobs.at(-1)
+ await until(async () => (await draftPhase(page)) === 'running')
+ settle(state, third, 'store_sales', { model_items: 40 })
+ finishJob(third)
+ await until(async () => (await draftPhase(page)) === 'done')
+ await page.locator('[data-draft-close]').click()
+ check('……这回没有留空的列:清单的轻提示和详情的提示都收起', await until(async () => (await rowBlank('store_sales').count()) === 0 && (await tip.count()) === 0))
+
+ // 部分完成又有留空列的表:一条说明里写原因和留空的列,只有一个「重新起草这张表」
+ await page.locator('[data-catalog-row="channels"] [data-catalog-open-row]').click()
+ check('部分完成又有留空列的表:详情里一条说明写原因和留空的列,只有一个「重新起草这张表」',
+ await until(async () => (await tip.innerText({ timeout: 500 }).catch(() => '')).includes('上次起草部分完成'))
+ && (await tip.innerText()).includes(CALL_ERR) && (await tip.innerText()).includes('4 列模型拿不准、留空了')
+ && (await tip.getAttribute('data-notice')) === 'warn' && await tip.locator('[data-catalog-redraft]').count() === 1,
+ await tip.innerText({ timeout: 500 }).catch(() => ''))
+ await cleanCopy(page, '留空列的提示')
+ check('只发过起草任务相关的写请求', writes(sent, /./).every((s) => /catalog\/(draft-jobs|guidance)/.test(s.key)),
+ JSON.stringify(writes(sent, /./).map((s) => s.key)))
+ check('没有原生对话框', natives.length === 0, natives.join(' | '))
+ check('没有运行时报错', errors.length === 0, errors[0] ?? '')
+ await close()
+})
+
await section('数据目录 · 表详情与单项审阅', async () => {
const state = freshState()
const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
@@ -2469,14 +2628,16 @@ await section('数据目录 · 窄屏', async () => {
await until(async () => (await draftPhase(page)) === 'running')
const narrowJob = state.jobs.at(-1)
settle(state, narrowJob, 'visits', { added: 2 })
- settle(state, narrowJob, 'store_sales', { status: 'partial', parts_done: 2, added: 4, model_error: WIDE_ERR })
+ settle(state, narrowJob, 'store_sales', { status: 'partial', parts_done: 2, added: 4, model_error: WIDE_ERR, blank_columns: 12, blank_sample: BLANK_SAMPLE })
// 接连没写出来的提醒、超时和没写出来的次数、段缩小的说明也都在
Object.assign(narrowJob.usage, { timeouts: 3, failed_calls: 4 })
Object.assign(narrowJob, { part_columns: 20, notice: DRAFT_NOTICE })
await until(async () => (await draftText(page, '[data-draft-task="store_sales"] [data-draft-task-reason]')).includes('第 3 段')
&& (await draftText(page, '[data-draft-notice]')) === DRAFT_NOTICE)
+ await draftBox(page).locator('[data-draft-task="store_sales"] [data-draft-task-blank] summary').click().catch(() => {})
const runFit = await draftFits()
- check('……起草进行中:段进度、长原因、提醒都折行,弹窗不横向滚动', runFit.ok, JSON.stringify(runFit))
+ check('……起草进行中:段进度、长原因、提醒、展开的留空列名都折行,弹窗不横向滚动', runFit.ok
+ && await draftBox(page).locator('[data-draft-task-blank-sample]').isVisible().catch(() => false), JSON.stringify(runFit))
await shot(page, 'catalog-narrow-draft')
await page.locator('[data-draft-hide]').click()
await until(async () => (await page.locator('[data-catalog-draft-progress]').count()) === 1)
From 912bc3f107a61991d71de9e293ca8b08374ac85d Mon Sep 17 00:00:00 2001
From: ThearchyHelios
Date: Tue, 13 Oct 2026 21:09:49 +0800
Subject: [PATCH 25/29] =?UTF-8?q?check-catalog=EF=BC=9A=E5=81=87=E6=9C=8D?=
=?UTF-8?q?=E5=8A=A1=E7=AB=AF=E7=BB=99=E7=9A=84=E6=8F=90=E9=86=92=E5=8E=9F?=
=?UTF-8?q?=E6=96=87=E5=92=8C=E5=AE=BD=E8=A1=A8=E6=B2=A1=E5=86=99=E5=87=BA?=
=?UTF-8?q?=E6=9D=A5=E7=9A=84=E5=8E=9F=E5=9B=A0=E7=85=A7=E6=9C=8D=E5=8A=A1?=
=?UTF-8?q?=E7=AB=AF=E7=9A=84=E5=8E=9F=E6=A0=B7=E5=86=99=E2=80=94=E2=80=94?=
=?UTF-8?q?=E6=8F=90=E9=86=92=E6=98=AF=E3=80=8C=E6=9C=80=E8=BF=91=206=20?=
=?UTF-8?q?=E6=AC=A1=E8=B0=83=E7=94=A8=E6=9C=89=204=20=E6=AC=A1=E6=B2=A1?=
=?UTF-8?q?=E5=86=99=E5=87=BA=E6=9D=A5=EF=BC=88=E9=A2=9D=E5=BA=A6=E7=94=A8?=
=?UTF-8?q?=E5=AE=8C=201=20=E6=AC=A1=E3=80=81=E8=B6=85=E6=97=B6=203=20?=
=?UTF-8?q?=E6=AC=A1=EF=BC=89=E3=80=82=E5=8F=AF=E4=BB=A5=E5=9C=A8=E3=80=8C?=
=?UTF-8?q?=E8=AE=BE=E7=BD=AE=20=E2=86=92=20=E5=81=8F=E5=A5=BD=E8=AE=BE?=
=?UTF-8?q?=E7=BD=AE=E3=80=8D=E7=9A=84=E3=80=8C=E8=B5=B7=E8=8D=89=E6=A8=A1?=
=?UTF-8?q?=E5=9E=8B=E3=80=8D=E6=8D=A2=E4=B8=80=E4=B8=AA=E6=A8=A1=E5=9E=8B?=
=?UTF-8?q?=E3=80=81=E5=87=8F=E5=B0=91=E8=BF=99=E6=AC=A1=E8=B5=B7=E8=8D=89?=
=?UTF-8?q?=E7=9A=84=E8=A1=A8=EF=BC=8C=E6=88=96=E8=80=85=E5=81=9C=E6=AD=A2?=
=?UTF-8?q?=E8=BF=99=E6=AC=A1=E8=B5=B7=E8=8D=89=E3=80=8D=EF=BC=88=E5=8E=9F?=
=?UTF-8?q?=E5=9B=A0=E6=8C=89=E9=A2=9D=E5=BA=A6=E7=94=A8=E5=AE=8C=E3=80=81?=
=?UTF-8?q?=E8=B6=85=E6=97=B6=E3=80=81=E5=8F=AA=E7=BB=99=E7=A9=BA=E5=A3=B3?=
=?UTF-8?q?=E3=80=81=E5=87=BA=E9=94=99=E7=9A=84=E9=A1=BA=E5=BA=8F=E5=88=97?=
=?UTF-8?q?=EF=BC=89=EF=BC=9B=E5=AE=BD=E8=A1=A8=E7=9A=84=E5=8E=9F=E5=9B=A0?=
=?UTF-8?q?=E5=9C=A8=E5=88=97=E8=8C=83=E5=9B=B4=E4=B9=8B=E5=90=8E=E6=8E=A5?=
=?UTF-8?q?=E7=9D=80=E5=86=99=E3=80=8C=E8=BF=99=E4=B8=80=E6=AE=B5=E6=B2=A1?=
=?UTF-8?q?=E5=86=99=E5=87=BA=E6=9D=A5=EF=BC=9B=E5=B7=B2=E6=8B=86=E5=B0=8F?=
=?UTF-8?q?=E9=87=8D=E8=AF=95=E4=BB=8D=E6=B2=A1=E5=86=99=E5=87=BA=E3=80=8D?=
=?UTF-8?q?=E3=80=82=E4=BB=A5=E5=89=8D=E6=98=AF=E7=85=A7=E7=BA=A6=E5=AE=9A?=
=?UTF-8?q?=E8=87=AA=E6=8B=9F=E7=9A=84=E4=B8=80=E5=8F=A5=EF=BC=8C=E5=92=8C?=
=?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=E5=AE=9E=E9=99=85=E6=8B=BC=E5=87=BA?=
=?UTF-8?q?=E6=9D=A5=E7=9A=84=E4=B8=8D=E4=B8=80=E6=A0=B7=EF=BC=8C=E5=8F=AA?=
=?UTF-8?q?=E5=BD=B1=E5=93=8D=E6=A3=80=E6=9F=A5=E7=9A=84=E9=80=BC=E7=9C=9F?=
=?UTF-8?q?=E5=BA=A6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
scripts/check-catalog.mjs | 14 ++++++++++----
1 file changed, 10 insertions(+), 4 deletions(-)
diff --git a/scripts/check-catalog.mjs b/scripts/check-catalog.mjs
index 05b32494..9c4691a7 100644
--- a/scripts/check-catalog.mjs
+++ b/scripts/check-catalog.mjs
@@ -686,11 +686,17 @@ async function startDraft(page, { select = null, model = false, note = '' } = {}
/** 起草用的几句话(虚构) */
const GUIDANCE2 = '景区票务库:「核销」指闸机检票入园;金额单位是元。会员相关的表统一写「会员」,不写「用户」'
const DRAFT_NOTE = '这一批是闸机和会员相关的表'
-// 宽表没写出来的那一段写明列的范围(列名照表结构原样);只有真拆过才写「已拆小重试仍没写出」
-const WIDE_ERR = '这张表 50 列,分 3 段起草,第 3 段(promo_attr_19 到 promo_attr_43,共 25 列)没写出来:模型的思考和输出用完了额度(32768 个 token),已拆小重试仍没写出。可以只重试这张表'
+// 宽表没写出来的那一段写明列的范围(列名照表结构原样,50 列均分成表级一段加两段各 25 列);只有真拆过才写「已拆小
+// 重试仍没写出」。写法照服务端 catalog_draft._partial_text 拼出来的原样
+const WIDE_ERR = '这张表 50 列,分 3 段起草,第 3 段(promo_attr_19 到 promo_attr_43,共 25 列)没写出来:'
+ + '模型的思考和输出用完了额度(32768 个 token),这一段没写出来;已拆小重试仍没写出。可以只重试这张表'
const CALL_ERR = '调用模型出错:服务暂时不可用(已重试 2 次)'
-/** 调用接连没写出来时服务端给的提醒(虚构的一句,照约定写明次数、原因和办法) */
-const DRAFT_NOTICE = '最近 6 次调用有 4 次没写出来(3 次超时、1 次额度用完)。建议在「设置 → 偏好设置」的「起草模型」换一个模型、缩小起草范围,或者停止这次起草'
+/**
+ * 调用接连没写出来时服务端给的提醒:写法照服务端 catalog_draft.NOTICE 的原样(原因按额度用完、超时、只给空壳、出错的
+ * 顺序列,去处是 draft_model.DRAFT_MODEL_PLACE)
+ */
+const DRAFT_NOTICE = '最近 6 次调用有 4 次没写出来(额度用完 1 次、超时 3 次)。'
+ + '可以在「设置 → 偏好设置」的「起草模型」换一个模型、减少这次起草的表,或者停止这次起草'
/** 宽表里模型拿不准、留空的列(服务端最多给 5 个列名) */
const BLANK_SAMPLE = ['promo_attr_31', 'promo_attr_32', 'promo_attr_33', 'promo_attr_34', 'promo_attr_35']
/** 补上缩写含义的起草要求(虚构) */
From ead8ea216bdbd041e203e24487b10e5b8806bb01 Mon Sep 17 00:00:00 2001
From: ThearchyHelios
Date: Tue, 13 Oct 2026 21:13:15 +0800
Subject: [PATCH 26/29] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95?=
=?UTF-8?q?=E8=B5=B7=E8=8D=89=EF=BC=88=E5=89=8D=E7=AB=AF=EF=BC=89=EF=BC=9A?=
=?UTF-8?q?=E8=A1=A8=E7=BB=93=E6=9E=84=E9=87=8C=E5=B7=B2=E7=BB=8F=E6=B2=A1?=
=?UTF-8?q?=E6=9C=89=E7=9A=84=E8=A1=A8=EF=BC=88=E5=8F=AA=E5=89=A9=E7=9B=AE?=
=?UTF-8?q?=E5=BD=95=EF=BC=89=E4=B8=8D=E5=86=8D=E7=BB=99=E3=80=8C=E9=87=8D?=
=?UTF-8?q?=E6=96=B0=E8=B5=B7=E8=8D=89=E8=BF=99=E5=BC=A0=E8=A1=A8=E3=80=8D?=
=?UTF-8?q?=E3=80=8C=E8=A1=A5=E5=85=85=E8=B5=B7=E8=8D=89=E8=A6=81=E6=B1=82?=
=?UTF-8?q?=E3=80=8D=E2=80=94=E2=80=94=E6=9C=8D=E5=8A=A1=E7=AB=AF=E5=8F=AA?=
=?UTF-8?q?=E4=BC=9A=E6=8A=8A=E5=AE=83=E8=AE=B0=E6=88=90=E5=B7=B2=E8=B7=B3?=
=?UTF-8?q?=E8=BF=87=E3=80=82=E8=A1=A8=E8=AF=A6=E6=83=85=E9=A1=B6=E9=83=A8?=
=?UTF-8?q?=E7=85=A7=E5=86=99=E4=B8=8A=E6=AC=A1=E8=B5=B7=E8=8D=89=E7=9A=84?=
=?UTF-8?q?=E7=BB=93=E6=9E=9C=EF=BC=88=E6=B2=A1=E5=86=99=E5=85=A8=E7=9A=84?=
=?UTF-8?q?=E5=8E=9F=E5=9B=A0=E3=80=81=E7=95=99=E7=A9=BA=E4=BA=86=E5=87=A0?=
=?UTF-8?q?=E5=88=97=EF=BC=89=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=BB=BA=E8=AE=AE?=
=?UTF-8?q?=E8=A1=A5=E5=85=85=E8=B5=B7=E8=8D=89=E8=A6=81=E6=B1=82=E5=90=8E?=
=?UTF-8?q?=E9=87=8D=E6=96=B0=E8=B5=B7=E8=8D=89=EF=BC=8C=E5=86=99=E6=98=8E?=
=?UTF-8?q?=E3=80=8C=E8=A1=A8=E7=BB=93=E6=9E=84=E4=B8=AD=E5=B7=B2=E6=B2=A1?=
=?UTF-8?q?=E6=9C=89=E8=BF=99=E5=BC=A0=E8=A1=A8=EF=BC=8C=E4=B8=8D=E8=83=BD?=
=?UTF-8?q?=E9=87=8D=E6=96=B0=E8=B5=B7=E8=8D=89=E3=80=8D=EF=BC=9B=E6=B8=85?=
=?UTF-8?q?=E5=8D=95=E4=B8=8A=E7=95=99=E7=A9=BA=E5=88=97=E7=9A=84=E8=BD=BB?=
=?UTF-8?q?=E6=8F=90=E7=A4=BA=E7=85=A7=E6=97=A7=EF=BC=8C=E6=82=AC=E5=81=9C?=
=?UTF-8?q?=E9=87=8C=E4=B8=8D=E5=86=8D=E5=BB=BA=E8=AE=AE=E9=87=8D=E6=96=B0?=
=?UTF-8?q?=E8=B5=B7=E8=8D=89=E3=80=82=E8=B5=B7=E8=8D=89=E8=AE=BE=E7=BD=AE?=
=?UTF-8?q?=E6=94=B6=E5=88=B0=E7=9A=84=E9=A2=84=E5=85=88=E9=80=89=E5=A5=BD?=
=?UTF-8?q?=E7=9A=84=E8=8C=83=E5=9B=B4=EF=BC=88=E8=A1=A8=E8=AF=A6=E6=83=85?=
=?UTF-8?q?=E7=9A=84=E3=80=8C=E8=A1=A5=E5=85=85=E8=B5=B7=E8=8D=89=E8=A6=81?=
=?UTF-8?q?=E6=B1=82=E3=80=8D=E3=80=81=E7=BB=93=E6=9E=9C=E9=87=8C=E7=9A=84?=
=?UTF-8?q?=E3=80=8C=E9=87=8D=E6=96=B0=E8=B5=B7=E8=8D=89=E8=BF=99=E5=87=A0?=
=?UTF-8?q?=E5=BC=A0=E8=A1=A8=E3=80=8D=EF=BC=89=E9=87=8C=E7=9A=84=E8=A1=A8?=
=?UTF-8?q?=E5=B7=B2=E7=BB=8F=E4=B8=8D=E5=9C=A8=E8=A1=A8=E7=BB=93=E6=9E=84?=
=?UTF-8?q?=E9=87=8C=E6=97=B6=E4=B8=8D=E5=86=8D=E9=80=80=E5=9B=9E=E5=88=AB?=
=?UTF-8?q?=E7=9A=84=E8=8C=83=E5=9B=B4=EF=BC=9A=E9=82=A3=E4=B8=80=E9=A1=B9?=
=?UTF-8?q?=E7=85=A7=E6=97=A7=E9=80=89=E7=9D=80=EF=BC=8C=E5=86=99=E6=98=8E?=
=?UTF-8?q?=E3=80=8C=E8=A1=A8=E7=BB=93=E6=9E=84=E4=B8=AD=E5=B7=B2=E6=B2=A1?=
=?UTF-8?q?=E6=9C=89=E8=BF=99=E5=BC=A0=E8=A1=A8=EF=BC=8C=E4=B8=8D=E8=83=BD?=
=?UTF-8?q?=E8=B5=B7=E8=8D=89=E3=80=8D=EF=BC=88=E5=8F=AA=E5=B0=91=E4=BA=86?=
=?UTF-8?q?=E5=87=A0=E5=BC=A0=E6=97=B6=E5=86=99=E5=85=B6=E4=B8=AD=E5=87=A0?=
=?UTF-8?q?=E5=BC=A0=E4=B8=8D=E4=BC=9A=E8=B5=B7=E8=8D=89=EF=BC=89=EF=BC=8C?=
=?UTF-8?q?=E4=B8=8D=E8=83=BD=E5=BC=80=E5=A7=8B=EF=BC=8C=E4=B9=9F=E4=B8=8D?=
=?UTF-8?q?=E9=BB=98=E8=AE=A4=E5=8B=BE=E7=94=A8=E6=A8=A1=E5=9E=8B=E3=80=81?=
=?UTF-8?q?=E4=B8=8D=E6=8A=8A=E7=84=A6=E7=82=B9=E6=94=BE=E8=BF=9B=E8=B5=B7?=
=?UTF-8?q?=E8=8D=89=E8=A6=81=E6=B1=82=E2=80=94=E2=80=94=E4=BB=A5=E5=89=8D?=
=?UTF-8?q?=E9=80=80=E5=88=B0=E3=80=8C=E4=BD=BF=E7=94=A8=E6=AC=A1=E6=95=B0?=
=?UTF-8?q?=E6=9C=80=E5=A4=9A=E7=9A=84=2020=20=E5=BC=A0=E8=A1=A8=E3=80=8D?=
=?UTF-8?q?=E8=BF=98=E5=8B=BE=E7=9D=80=E7=94=A8=E6=A8=A1=E5=9E=8B=EF=BC=8C?=
=?UTF-8?q?=E4=BB=A5=E4=B8=BA=E5=8F=AA=E8=B5=B7=E8=8D=89=E8=BF=99=E4=B8=80?=
=?UTF-8?q?=E5=BC=A0=EF=BC=8C=E5=AE=9E=E9=99=85=E6=8C=89=E5=88=AB=E7=9A=84?=
=?UTF-8?q?=E8=8C=83=E5=9B=B4=E8=B5=B7=E8=8D=89=E4=BA=86=EF=BC=9B=E9=80=89?=
=?UTF-8?q?=E7=9A=84=E9=82=A3=E4=B8=80=E9=A1=B9=E4=B8=8D=E5=9C=A8=E4=BA=86?=
=?UTF-8?q?=E6=97=B6=EF=BC=8C=E5=8D=95=E9=80=89=E6=A1=86=E7=85=A7=E5=AE=9E?=
=?UTF-8?q?=E9=99=85=E8=B5=B7=E8=8D=89=E7=9A=84=E9=82=A3=E4=B8=80=E9=A1=B9?=
=?UTF-8?q?=E5=8B=BE=E3=80=82check-catalog=20=E6=96=B0=E5=8A=A0=E4=B8=80?=
=?UTF-8?q?=E8=8A=82=EF=BC=9A=E5=8F=AA=E5=89=A9=E7=9B=AE=E5=BD=95=E7=9A=84?=
=?UTF-8?q?=E8=A1=A8=E7=9A=84=E6=B8=85=E5=8D=95=E6=8F=90=E7=A4=BA=E4=B8=8E?=
=?UTF-8?q?=E8=AF=A6=E6=83=85=EF=BC=9B=E6=89=93=E5=BC=80=E8=AF=A6=E6=83=85?=
=?UTF-8?q?=E4=BB=A5=E5=90=8E=E8=A1=A8=E6=89=8D=E6=B2=A1=E4=BA=86=EF=BC=88?=
=?UTF-8?q?=E6=B8=85=E5=8D=95=E9=87=8D=E5=8F=96=E4=BA=86=E3=80=81=E8=AF=A6?=
=?UTF-8?q?=E6=83=85=E6=B2=A1=E9=87=8D=E6=96=B0=E8=BD=BD=E5=85=A5=EF=BC=89?=
=?UTF-8?q?=E6=97=B6=E3=80=8C=E8=A1=A5=E5=85=85=E8=B5=B7=E8=8D=89=E8=A6=81?=
=?UTF-8?q?=E6=B1=82=E3=80=8D=E6=89=93=E5=BC=80=E7=9A=84=E8=AE=BE=E7=BD=AE?=
=?UTF-8?q?=EF=BC=9B=E9=87=8D=E6=96=B0=E6=89=93=E5=BC=80=E5=90=8E=E4=B8=8D?=
=?UTF-8?q?=E5=86=8D=E7=BB=99=E4=B8=A4=E4=B8=AA=E6=8C=89=E9=92=AE=E3=80=82?=
=?UTF-8?q?check-all=20=E9=87=8C=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95?=
=?UTF-8?q?=E7=9A=84=E8=80=97=E6=97=B6=E6=8C=89=E5=AE=9E=E6=B5=8B=E6=94=B9?=
=?UTF-8?q?=E6=88=90=20140=20=E7=A7=92?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/src/lib/terms.ts | 9 +++
frontend/src/pages/catalog/DraftDialog.tsx | 41 +++++++----
frontend/src/pages/catalog/TableDetail.tsx | 15 ++--
frontend/src/pages/catalog/TableIndex.tsx | 6 +-
scripts/check-all.mjs | 2 +-
scripts/check-catalog.mjs | 84 ++++++++++++++++++++++
6 files changed, 137 insertions(+), 20 deletions(-)
diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts
index b409f578..663b6329 100644
--- a/frontend/src/lib/terms.ts
+++ b/frontend/src/lib/terms.ts
@@ -2434,6 +2434,13 @@ export const CATALOG_DRAFT_TEXT = {
// ---- 从结果、表详情回到设置:范围就是那几张表
scopeBlank: (n: number) => `有留空列的 ${formatNumber(n)} 张表`,
scopeTable: (name: string) => `这张表:${name}`,
+ /**
+ * 预先选好的范围里有表已经不在表结构里(打开表详情以后别处重新探查了结构):写在那一项下面。一张都不剩时那一项
+ * 照旧选着、不能开始,不退回别的范围
+ */
+ presetMissing: (gone: number, total: number) => (gone < total
+ ? `其中 ${formatNumber(gone)} 张在表结构中已没有,不会起草`
+ : total === 1 ? '表结构中已没有这张表,不能起草' : `表结构中已没有这 ${formatNumber(total)} 张表,不能起草`),
// ---- 进行中
queued: '排队中,等待开始…',
running: '正在起草…',
@@ -2538,6 +2545,8 @@ export const CATALOG_DRAFT_TEXT = {
redraftHint: '用模型重新起草这张表(不带补充说明,带上数据源的起草要求);人工确认、驳回过的项不会被改动',
redraftBusy: '已有起草任务在进行,结束后再重新起草这张表',
redraftEditing: '正在编辑这张表,保存或取消后再重新起草',
+ /** 表结构里已经没有的表(只剩目录):上次起草的结果照写,不给重新起草(服务端只会把它记成已跳过) */
+ redraftMissing: '表结构中已没有这张表,不能重新起草',
}
/** 设置页「偏好设置」里「起草模型」一节(服务端 api/settings.py 的 catalog_draft 一组) */
diff --git a/frontend/src/pages/catalog/DraftDialog.tsx b/frontend/src/pages/catalog/DraftDialog.tsx
index 4dafc323..3a7547fc 100644
--- a/frontend/src/pages/catalog/DraftDialog.tsx
+++ b/frontend/src/pages/catalog/DraftDialog.tsx
@@ -25,7 +25,8 @@ import type { DraftPreset, DraftStartBody } from './draftJob'
//
// 留空的列(模型列出了、中文名和含义都没给)不算没写全,表照样是已完成;结果里写合计和办法——在起草要求里补充缩写和
// 术语的含义,再重新起草这几张表。「重新起草这几张表」回到设置:范围就是那几张、勾着用模型、焦点在起草要求,
-// 补完再开始。表详情里的「补充起草要求」也是这样打开(范围是那一张,见 preset)。
+// 补完再开始。表详情里的「补充起草要求」也是这样打开(范围是那一张,见 preset)。预先选好的范围里的表已经不在表结构
+// 里时不退回别的范围:那一项照旧选着、写明原因,不能开始。
// ===========================================================================
type Scope = 'preset' | 'selected' | 'filtered' | 'top' | 'all'
@@ -66,8 +67,9 @@ export function DraftDialog({
/** 打开时显示的任务是撞上已有任务(409)转过来的:写明 */
initialBusy?: boolean
/**
- * 打开设置时预先选好的范围(表详情里「补充起草要求」:只有那张表)。给了就勾上用模型、焦点放在起草要求上:
- * 打开它就是为了先补起草要求再起草
+ * 打开设置时预先选好的范围(表详情里「补充起草要求」:只有那张表)。给了就选它;里面有可起草的表时勾上用模型、
+ * 焦点放在起草要求上(打开它就是为了先补起草要求再起草)。一张可起草的都没有(已不在表结构里)时照旧选着它、
+ * 写明原因、不能开始,也不勾用模型——不退回别的范围
*/
preset?: DraftPreset | null
/** 点了停止、服务端还没停下来 */
@@ -88,9 +90,13 @@ export function DraftDialog({
/** 预先选好的范围:打开时给的,或者结果里点了「重新起草这几张表」 */
const [preset, setPreset] = useState(initialPreset)
const scopes = useMemo(() => {
- const out: { key: Scope; label: string; tables: string[] }[] = []
- const pre = preset?.tables.filter((t) => liveSet.has(t)) ?? []
- if (preset && pre.length) out.push({ key: 'preset', label: preset.label, tables: pre })
+ 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)
@@ -100,11 +106,15 @@ export function DraftDialog({
return out
}, [preset, selected, visible, filtered, live, liveSet])
const [scope, setScope] = useState(() => scopes[0].key)
- 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 [useModel, setUseModel] = useState(!!initialPreset)
+ /** 打开时给的范围里有可起草的表:勾上用模型、焦点放进起草要求(打开它就是为了先补起草要求);一张都没有就都不做 */
+ const presetLive = !!initialPreset && initialPreset.tables.some((t) => liveSet.has(t))
+ const [useModel, setUseModel] = useState(presetLive)
/** 回到设置以后把焦点放进起草要求(预先选好范围的两种入口都是为了先补起草要求) */
- const [focusGuidance, setFocusGuidance] = useState(!!initialPreset)
+ const [focusGuidance, setFocusGuidance] = useState(presetLive)
const [guidanceText, setGuidanceText] = useState(guidance)
const [note, setNote] = useState('')
const guidanceLen = charCount(guidanceText)
@@ -284,9 +294,16 @@ export function DraftDialog({
{scopes.map((s) => (
))}
diff --git a/frontend/src/pages/catalog/TableDetail.tsx b/frontend/src/pages/catalog/TableDetail.tsx
index 049ce6c3..61783e69 100644
--- a/frontend/src/pages/catalog/TableDetail.tsx
+++ b/frontend/src/pages/catalog/TableDetail.tsx
@@ -77,9 +77,12 @@ export function TableDetail({
onDetail: (d: CatalogDetail) => void
/** 这个数据源有排队、进行中的起草任务:「重新起草这张表」等它结束 */
draftBusy?: boolean
- /** 上次起草失败、部分完成或有留空列时的「重新起草这张表」:建只含这张表的起草任务(用模型、不带补充说明) */
+ /**
+ * 上次起草失败、部分完成或有留空列时的「重新起草这张表」:建只含这张表的起草任务(用模型、不带补充说明)。
+ * 表结构里已经没有的表不给
+ */
onRedraft?: (table: string) => Promise
- /** 上次起草有留空列时的「补充起草要求」:打开起草设置,范围只有这张表,先补起草要求再开始 */
+ /** 上次起草有留空列时的「补充起草要求」:打开起草设置,范围只有这张表,先补起草要求再开始。表结构里已经没有的表不给 */
onDraftGuidance?: (table: string) => void
}) {
const [detail, setDetail] = useState(null)
@@ -465,7 +468,8 @@ export function TableDetail({
// 上次起草没写全:写明原因;有留空的列:写几列、怎么办(在起草要求里补充缩写和术语的含义)。只有留空的列时是
// 提示(表照样写完了),不是失败。给「重新起草这张表」(只含这张表、用模型);有留空的列再给「补充起草要求」
// (打开起草设置,范围是这张表)。有起草任务在进行、正在编辑这张表时先不给点:起草会写进这张表,编辑中的保存
- // 就会撞上版本冲突
+ // 就会撞上版本冲突。表结构里已经没有这张表(只剩目录)时上次的结果照写,不给这两个按钮、不建议重新起草,
+ // 写明为什么:服务端只会把它记成「已跳过」
{lastBad && {lastBad.status === 'failed' ? DT.lastFailed : DT.lastPartial}
}
{lastBad && taskReason(lastBad) && (
@@ -474,10 +478,11 @@ export function TableDetail({
{lastBlank > 0 && (
<>
{DT.lastBlank(lastBlank)}
- {DT.lastBlankAdvice}
+ {detail.in_schema && {DT.lastBlankAdvice}
}
>
)}
- {onRedraft && (
+ {!detail.in_schema && {DT.redraftMissing}
}
+ {detail.in_schema && onRedraft && (
<>
)}
{blank > 0 && (
-
{DT.lastBlank(blank)}
diff --git a/scripts/check-all.mjs b/scripts/check-all.mjs
index 8e4d2f82..6113ca0b 100644
--- a/scripts/check-all.mjs
+++ b/scripts/check-all.mjs
@@ -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: 130, '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,
}
diff --git a/scripts/check-catalog.mjs b/scripts/check-catalog.mjs
index 9c4691a7..b512d5fa 100644
--- a/scripts/check-catalog.mjs
+++ b/scripts/check-catalog.mjs
@@ -1544,6 +1544,90 @@ await section('数据目录 · 起草:模型拿不准、留空的列', async (
await close()
})
+await section('数据目录 · 起草:表结构里已经没有的表', async () => {
+ const state = freshState()
+ const tOf = (n) => state.tables[S1].find((t) => t.name === n)
+ // 起草过(部分完成、留了几列空)的表后来重新探查结构时没了,只剩目录;另一张有留空列的这时还在表结构里
+ tOf('legacy_coupons').lastDraft = {
+ status: 'partial', job_id: 'check-job-old', finished_at: T0, error: null, model_error: CALL_ERR, blank_columns: 3,
+ }
+ tOf('channels').lastDraft = { status: 'ok', job_id: 'check-job-old', finished_at: T0, error: null, model_error: null, blank_columns: 4 }
+ const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
+ await page.locator('[data-catalog-row]').first().waitFor()
+ const tip = page.locator('[data-catalog-last-draft]')
+ const tipText = async () => (await tip.innerText({ timeout: 500 }).catch(() => '')).replace(/\s+/g, ' ').trim()
+ const buttons = () => tip.locator('[data-catalog-redraft], [data-catalog-draft-guidance]').count()
+
+ // 清单:只剩目录的表照样写上次起草留空了几列(是历史),悬停里不再建议重新起草(起草不了)
+ const goneBlank = page.locator('[data-catalog-row="legacy_coupons"] [data-row-blank]')
+ const goneTitle = (await goneBlank.getAttribute('title', { timeout: 1000 }).catch(() => '')) ?? ''
+ check('清单:只剩目录的表照样提示上次起草留空的列,悬停里不建议重新起草',
+ (await goneBlank.innerText({ timeout: 500 }).catch(() => '')).includes('上次起草有 3 列模型拿不准、留空了')
+ && !!goneTitle && !goneTitle.includes('重新起草'), goneTitle)
+
+ // 表详情:写明上次起草的结果,不给「重新起草这张表」「补充起草要求」(服务端只会把它记成已跳过),写明为什么
+ await page.locator('[data-catalog-row="legacy_coupons"] [data-catalog-open-row]').click()
+ check('只剩目录的表:详情顶部照样写上次起草部分完成、原因和留空的列',
+ await until(async () => (await tipText()).includes('上次起草部分完成'))
+ && (await tipText()).includes(CALL_ERR) && (await tipText()).includes('3 列模型拿不准、留空了'), await tipText())
+ check('……不给「重新起草这张表」「补充起草要求」,写明表结构中已没有这张表、不能重新起草',
+ await buttons() === 0
+ && (await tip.locator('[data-catalog-redraft-missing]').innerText({ timeout: 500 }).catch(() => '')).includes('表结构中已没有这张表,不能重新起草'),
+ await tipText())
+ check('……也不建议补充起草要求再重新起草', !(await tipText()).includes('再重新起草'), await tipText())
+ await shot(page, 'catalog-missing-last-draft')
+
+ // 打开详情以后表才没了(别处重新探查了结构):清单重取了、详情还没有重新载入。这时点「补充起草要求」,起草设置
+ // 不能退回别的范围(以前退到「使用次数最多的 20 张表」还勾着用模型,以为只起草这一张,实际按别的范围起草了)
+ await page.locator('[data-catalog-row="channels"] [data-catalog-open-row]').click()
+ check('还在表结构里的表:详情给「重新起草这张表」和「补充起草要求」',
+ await until(async () => (await tip.locator('[data-catalog-draft-guidance]').count()) === 1)
+ && await tip.locator('[data-catalog-redraft]').count() === 1, await tipText())
+ tOf('channels').missing = true
+ // 起草另一张表:每张表起草完页面就重取清单;正在看的这张不在任务里,详情不重新载入
+ check('起草另一张表(不用模型)', await startDraft(page, { select: ['gates'] }))
+ const job = state.jobs.at(-1)
+ settle(state, job, 'gates', { added: 1 })
+ finishJob(job)
+ await until(async () => (await draftPhase(page)) === 'done')
+ await page.locator('[data-draft-close]').click()
+ check('……清单重取回来:这张表标成表结构中已没有,详情还是打开时的那份',
+ await until(async () => (await page.locator('[data-catalog-row="channels"] [data-row-missing]').count()) === 1)
+ && await tip.locator('[data-catalog-draft-guidance]').count() === 1)
+ const jobsBefore = count(sent, /^POST .*draft-jobs$/)
+ await tip.locator('[data-catalog-draft-guidance]').click()
+ const presetInput = draftBox(page).locator('[data-draft-scope="preset"] input')
+ check('「补充起草要求」:范围仍是这张表、选着它,写明表结构中已没有这张表、不能起草',
+ await until(async () => (await draftPhase(page)) === 'setup')
+ && await presetInput.isChecked({ timeout: 1000 }).catch(() => false)
+ && (await draftText(page, '[data-draft-scope="preset"]')).includes('channels')
+ && (await draftText(page, '[data-draft-preset-missing]')).includes('表结构中已没有这张表,不能起草'),
+ `${await draftText(page, '[data-draft-scope="preset"]')} · 选中:${await draftBox(page).locator('[data-draft-scope]:has(input:checked)').getAttribute('data-draft-scope', { timeout: 500 }).catch(() => '')}`)
+ check('……不能开始,也不默认勾用模型,焦点不放进起草要求', await page.locator('[data-draft-start]').isDisabled()
+ && !(await draftBox(page).locator('[data-draft-model] input').isChecked())
+ && await page.evaluate(() => document.activeElement?.id !== 'catalog-draft-guidance'),
+ `${await page.locator('[data-draft-start]').innerText()} · 用模型:${await draftBox(page).locator('[data-draft-model] input').isChecked()}`)
+ // 截图等弹窗的打开动效播完
+ if (SHOTS) await sleep(400)
+ await shot(page, 'catalog-missing-preset')
+ await page.keyboard.press('Escape')
+ check('……关掉:没有建任务', await until(async () => (await draftBox(page).count()) === 0) && count(sent, /^POST .*draft-jobs$/) === jobsBefore)
+
+ // 换一张再回来:详情重新载入,也认得表结构中已没有,不再给两个按钮
+ await page.locator('[data-catalog-row="gates"] [data-catalog-open-row]').click()
+ await until(async () => (await page.locator('[data-catalog-detail="gates"][data-version]').count()) === 1)
+ await page.locator('[data-catalog-row="channels"] [data-catalog-open-row]').click()
+ check('重新打开这张表:详情也写明表结构中已没有,不再给两个按钮',
+ await until(async () => (await page.locator('[data-catalog-detail="channels"] [data-catalog-missing]').count()) === 1)
+ && await buttons() === 0 && (await tipText()).includes('不能重新起草'), await tipText())
+ await cleanCopy(page, '表结构里已经没有的表')
+ check('只发过起草任务相关的写请求', writes(sent, /./).every((s) => /catalog\/(draft-jobs|guidance)/.test(s.key)),
+ JSON.stringify(writes(sent, /./).map((s) => s.key)))
+ check('没有原生对话框', natives.length === 0, natives.join(' | '))
+ check('没有运行时报错', errors.length === 0, errors[0] ?? '')
+ await close()
+})
+
await section('数据目录 · 表详情与单项审阅', async () => {
const state = freshState()
const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
From 84405977b626881e3a90e8964a7da6cd0e2e161e Mon Sep 17 00:00:00 2001
From: ThearchyHelios
Date: Tue, 13 Oct 2026 21:25:27 +0800
Subject: [PATCH 27/29] =?UTF-8?q?=E8=B5=B7=E8=8D=89=E5=BC=95=E6=93=8E?=
=?UTF-8?q?=E6=8C=89=E5=A4=8D=E6=A0=B8=E4=BF=AE=E6=AD=A3=EF=BC=9A=E6=AF=8F?=
=?UTF-8?q?=E4=B8=AA=E5=8E=9F=E5=A7=8B=E6=AE=B5=E8=BF=9E=E5=90=8C=E5=88=86?=
=?UTF-8?q?=E5=BC=80=E7=9A=84=E5=90=84=E5=9D=97=E4=B8=80=E5=85=B1=208=20?=
=?UTF-8?q?=E6=AC=A1=EF=BC=9B=E4=B8=80=E7=9B=B4=E8=B6=85=E6=97=B6=E7=9A=84?=
=?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=87=A0=E8=BD=AE=E5=B0=B1=E5=81=9C=EF=BC=9B?=
=?UTF-8?q?=E6=AD=A2=E6=8D=9F=E5=90=8E=E7=BA=AF=E6=96=87=E6=9C=AC=E4=B8=8D?=
=?UTF-8?q?=E5=86=8D=E5=8E=9F=E6=A0=B7=E5=86=8D=E9=97=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 封顶管到原始段:段大小减半以后,新开始的段按上限分开的各块(窄表重新拼成的各组、单表均分的各块)共用这一段的
调用次数(_Budget 在 _work 里按原始段建,_start、_guarded 接过去),不再每块各有 8 次。以前上限缩到 10 列后一直
只给空壳,4 张窄表拼成的 40 列段调用 20 次,最坏是「段列数 / 上限 × 8」。止损的计数(连续几段没写出来)同样按
原始段数。
- 超时的两半同时问:带表级项的前一半不再先写完才问后一半(一次超时就是一个时限,40 → 20 → 10 串着问要好几个时限);
额度用完、只给空壳的照旧先写前一半、后一半拿它做背景。
- 超时另有一条止损:拆到每段 10 列(MODEL_MIN_PART_COLUMNS)的调用接连超时 2 次(MODEL_GIVE_UP_AFTER),超时就和
段大小无关了,后面超时的不再拆小,超时拆出来、等名额时还没问的块也不再问(次数退回);有一次调用写出来就恢复。
只管超时:额度用完、只给空壳照旧拆小。原因后面补「拆到每段 10 列仍然超时,已停止拆小重试,建议更换起草模型」。
用假模型量:每次都超时的 40 列表从 8 次调用、约 7 轮(420 秒时约 49 分钟)降到 6 次、3 轮;首轮同时起草的 3 段从
24 次、约 8 轮降到 12 次、4 轮;40、20 列超时而 10 列写得完的照旧拆到 10 列全部写出。
- 止损以后本来能拆小的段只给空壳:已经走纯文本的不再原样再问(约定只对拆不开的段再问一次);结构化输出的照旧换
纯文本问一次。
- resolve_draft_model 的说明照实写:超时不由引擎重试,当作这一段太大、拆小再问。
- 止损的说明改成文字(_Why.halted),两种止损各说一次。
---
backend/app/data/catalog.py | 5 +-
backend/app/data/catalog_draft.py | 168 ++++++++++++++-------
backend/tests/test_catalog_draft_engine.py | 119 ++++++++++++++-
backend/tests/test_catalog_draft_jobs.py | 4 +-
4 files changed, 236 insertions(+), 60 deletions(-)
diff --git a/backend/app/data/catalog.py b/backend/app/data/catalog.py
index cdb99192..db02c025 100644
--- a/backend/app/data/catalog.py
+++ b/backend/app/data/catalog.py
@@ -1640,8 +1640,9 @@ async def resolve_draft_model(session: AsyncSession) -> tuple[Any, str]:
不关思考(spec 里不传 thinking,按接入默认)、温度 0、额度 MODEL_MAX_TOKENS,服务方嫌大时退回
MODEL_FALLBACK_MAX_TOKENS(DraftModel)。单次调用的时限 MODEL_TIMEOUT_S 也交给 SDK,免得它先按全局的 5 分钟断开。
- SDK 自己不重试(max_retries=0):429、5xx、网络错误、超时由起草引擎按失败原因重试(最多 MODEL_RETRIES 次),
- 不然两层叠起来一段最多发 9 次请求,报错却写「已重试 2 次」,网关正在限流时还会放大流量。
+ SDK 自己不重试(max_retries=0):429、5xx、网络错误由起草引擎退避重试(最多 MODEL_RETRIES 次);超时当作这一段
+ 太大,拆小再问(data/catalog_draft.py),不原样重试。不然两层叠起来一段最多发 9 次请求,报错却写「已重试 2 次」,
+ 网关正在限流时还会放大流量。
只建模型对象,不发请求。
和 recipe_ai.resolve_draft_model 一样不用 get_chat_model:它在没有接入时静默退回演示模型,拿演示模型
diff --git a/backend/app/data/catalog_draft.py b/backend/app/data/catalog_draft.py
index 6d35d132..ae1a7031 100644
--- a/backend/app/data/catalog_draft.py
+++ b/backend/app/data/catalog_draft.py
@@ -21,9 +21,10 @@
- 思考加输出用完额度(finish_reason=length、LengthFinishReasonError、正文为空而有思考)、只给空壳、超时(当作这一段
太大,不原样重试同样大小的段):把这一段拆成两半再问(多表的段按表拆,单表的按列拆),最小
- MODEL_SPLIT_MIN_COLUMNS 列,最多拆 MODEL_SPLIT_DEPTH 层。拆不开的空壳再问一次:结构化输出给的换纯文本(有的网关的
- 结构化输出只给空壳、纯文本却能写——写出来了,这次起草后面都走纯文本),已经走纯文本的原样再问;仍是空壳才记没写
- 出来。
+ MODEL_SPLIT_MIN_COLUMNS 列,最多拆 MODEL_SPLIT_DEPTH 层。同一张表按列拆时带表级项的前一半先写、后一半拿它做背景;
+ 超时的两半同时问(一次超时就是一个时限,串着问 40 → 20 → 10 要好几个时限)。拆不开的空壳再问一次:结构化输出给的
+ 换纯文本(有的网关的结构化输出只给空壳、纯文本却能写——写出来了,这次起草后面都走纯文本),已经走纯文本的原样再问;
+ 仍是空壳才记没写出来。
- 结构化输出回了一段说明文字(不是 JSON、也没停在额度上):网关接受了结构化输出的参数却没照做(例如开了丢弃不认识
的参数),提示词里又没有格式说明,模型就写成了说明文字。这次起草后面改走纯文本,这一段换纯文本问一次。有思考
不等于额度用完:只有停在额度上、或者正文为空而有思考才算。
@@ -36,9 +37,13 @@
- 网络错误、429、5xx:等一会儿重试(最多 MODEL_RETRIES 次,退避)。单次调用的时限是 MODEL_TIMEOUT_S。SDK
自己不再重试(catalog.resolve_draft_model 建模型时 max_retries=0),实际重试次数和报错写的一致。
- 别的错误:这一段没写出来,原因照实写。
-- 封顶:一段(连同它拆出来的、补问的、重试的)最多调用 MODEL_PART_MAX_CALLS 次,到了次数还没写完的记没写出来。
+- 封顶:每个原始段(计划里的整表段、表级那段或一个列分段,连同按缩小后的上限分开的各块、拆出来的、补问的、重试的)
+ 一共最多调用 MODEL_PART_MAX_CALLS 次,到了次数还没写完的记没写出来。
- 止损:连续 MODEL_GIVE_UP_AFTER 段拆到底仍一项没写出来(模型明显用不了,例如思考总是吃满额度),这次起草后面的
- 段不再拆小重试、没写出来就直接记下,报错建议更换起草模型;有一段写出来了就恢复。
+ 段不再拆小重试、没写出来就直接记下,报错建议更换起草模型;有一段写出来了就恢复。超时另有一条:拆到每段
+ MODEL_MIN_PART_COLUMNS 列的调用接连超时 MODEL_GIVE_UP_AFTER 次(超时和段大小无关,再拆小也是一次一个时限),后面
+ 超时的不再拆小,超时拆出来、还没问的块也不再问;有一次调用写出来了就恢复。不然一直超时的模型首轮同时起草的几段
+ 要各自拆满次数,一段 40 列串着问约 7 个时限(420 秒时约 49 分钟)。
- 提醒:最近 MODEL_NOTICE_WINDOW 次调用里有 MODEL_NOTICE_ON 次以上没写出来(额度用完、超时、空壳、出错),给一句
提醒(次数和原因,建议换起草模型、减少表或停止),经进度回调交给任务;之后少于 MODEL_NOTICE_OFF 次时清掉。以前
看不出调用在反复失败:一个任务 55 分钟只结束 1 张表,界面上只有段进度和用量。
@@ -112,6 +117,8 @@ REASON_CRASH = "起草这张表时出错:{line}"
REASON_CAPPED = "每段最多调用 {n} 次模型,{seg}到了次数仍没写完,已停止重试"
#: 止损(连续几段拆到底仍一项没写出来,后面的段不再拆小重试)时补在原因后面
REASON_HALTED = ";模型连续 {n} 段都没写出来,已停止拆小重试,建议更换起草模型"
+#: 拆到每段 MODEL_MIN_PART_COLUMNS 列仍接连超时(超时和段大小无关,后面超时的不再拆小)时补在原因后面
+REASON_SLOW = ";拆到每段 {n} 列仍然超时,已停止拆小重试,建议更换起草模型"
# ---- 提醒(最近几次调用多数没写出来),经进度回调交给任务(CatalogDraftJob.notice)
NOTICE = ("最近 {calls} 次调用有 {failed} 次没写出来({kinds})。可以在{place}换一个模型、减少这次起草的表,"
@@ -306,6 +313,8 @@ class _Reply:
timeout: bool = False
#: 这一段的调用次数用完了,这次没有调用
capped: bool = False
+ #: 超时拆出来的块,轮到它时已经止损:这次没有调用(见 _Run._ask 的 optional)
+ skipped: bool = False
#: 这次调用的用量 (输入, 输出),还没报给进度回调;没有调用(capped)的是 None
usage: tuple[int, int] | None = None
@@ -313,18 +322,20 @@ class _Reply:
@dataclass(frozen=True)
class _Why:
"""一部分没写出来的原因。base 给人看的原因(可以带 SEG,拼成一句时换成「这一段」「这几段」);split 真拆小重试过;
- halted 本来还能拆小,因为前面连续几段都没写出来(止损)才没拆。"""
+ halted 本来还能拆小、因为止损才没拆时止损的说明(REASON_HALTED:前面连续几段都没写出来;REASON_SLOW:拆到每段
+ MODEL_MIN_PART_COLUMNS 列仍接连超时),没有是空串。"""
base: str
split: bool = False
- halted: bool = False
+ halted: str = ""
def _join(failures: Iterable[tuple[_Why, int]]) -> str:
"""几处没写出来的原因拼成一句。failures 是 (原因, 属于这张表的第几段)。
同样的原因只说一次(最多说两种);同一个原因落在几段上说「这几段」,同一段拆出来的几块仍是「这一段」;拆小重试过、
- 止损的说明各在最后补一次(只看说出来的那几种)。不然宽表的表级那段拆不开、列分段拆过时,同一句原因会说两遍。"""
+ 止损的说明各在最后补一次(只看说出来的那几种;两种止损各说一次)。不然宽表的表级那段拆不开、列分段拆过时,同一句
+ 原因会说两遍。"""
failures = list(failures)
parts: dict[str, set[int]] = {}
for why, part in failures:
@@ -334,8 +345,8 @@ def _join(failures: Iterable[tuple[_Why, int]]) -> str:
text = ";".join(base.replace(SEG, SEG_MANY if len(parts[base]) > 1 else SEG_ONE) for base in shown)
if any(w.split for w in told):
text += REASON_SPLIT
- if any(w.halted for w in told):
- text += REASON_HALTED.format(n=max(1, int(C.MODEL_GIVE_UP_AFTER)))
+ for note in dict.fromkeys(w.halted for w in told if w.halted):
+ text += note
return text
@@ -784,6 +795,9 @@ class _Run:
self.gate = asyncio.Semaphore(max(1, int(C.MODEL_CONCURRENCY)))
#: 连续几段拆到底仍一项没写出来(止损用,有一段写出来就清零)
self.dead_streak = 0
+ #: 拆到每段 MODEL_MIN_PART_COLUMNS 列(或更小)的调用接连超时了几次(有一次调用写出来就清零):超时和段大小
+ #: 无关,再拆小也是一次一个时限,后面超时的不再拆小
+ self.slow_streak = 0
#: 新开始的段每段最多几列:额度用完或超时(strikes)累计 MODEL_SHRINK_AFTER 次减半一次
self.part_columns = max(1, int(C.MODEL_BATCH_COLUMNS))
self.strikes = 0
@@ -791,10 +805,24 @@ class _Run:
self.recent: collections.deque[str | None] = collections.deque(maxlen=max(1, int(C.MODEL_NOTICE_WINDOW)))
self.notice: str | None = None
- @property
- def halted(self) -> bool:
- """止损:连续 MODEL_GIVE_UP_AFTER 段一项都没写出来,后面的段不再拆小重试。"""
- return self.dead_streak >= max(1, int(C.MODEL_GIVE_UP_AFTER))
+ def _halt_note(self, kind: str | None = None) -> str:
+ """止损的说明(拼在原因后面),没止损是空串。
+
+ - 连续 MODEL_GIVE_UP_AFTER 段一项都没写出来:后面的段不再拆小重试(不论原因);
+ - 超时的(kind 是 timeout)另看:拆到每段 MODEL_MIN_PART_COLUMNS 列的调用接连超时 MODEL_GIVE_UP_AFTER 次,
+ 超时就和段大小无关了,再拆小也是一次一个时限,后面超时的不再拆小,超时拆出来还没问的块也不再问。只管超时:
+ 额度用完、只给空壳照旧拆小(段小了思考和输出才放得下)。"""
+ give_up = max(1, int(C.MODEL_GIVE_UP_AFTER))
+ if self.dead_streak >= give_up:
+ return REASON_HALTED.format(n=give_up)
+ if kind == "timeout" and self.slow_streak >= give_up:
+ return REASON_SLOW.format(n=self._floor())
+ return ""
+
+ @staticmethod
+ def _floor() -> int:
+ """段大小减半的下限(每段最少几列)。"""
+ return min(max(1, int(C.MODEL_BATCH_COLUMNS)), max(1, int(C.MODEL_MIN_PART_COLUMNS)))
# ---------------------------------------------------------------- 总流程
@@ -860,8 +888,12 @@ class _Run:
return units
async def _work(self, work: _Work, queue: collections.deque[_Work], left: dict[str, int]) -> None:
- """起草一段;它之后的段(宽表的列分段)排到队尾;一张表的段都结束了,这张表的模型部分就结束了。"""
- await self._start(work.pieces)
+ """起草一段;它之后的段(宽表的列分段)排到队尾;一张表的段都结束了,这张表的模型部分就结束了。
+
+ 这一段(原始段:计划里的整表段、表级那段或一个列分段)连同按缩小后的上限分开的各块、拆出来的、补问的、重试的,
+ 一共最多调用 MODEL_PART_MAX_CALLS 次(各块共用一个 _Budget);止损按原始段数(_tally)。"""
+ await self._start(work.pieces, _Budget(max(1, int(C.MODEL_PART_MAX_CALLS))))
+ self._tally(work.pieces)
for piece in work.pieces:
await self._part_done(piece.table)
queue.extend(work.then)
@@ -870,30 +902,31 @@ class _Run:
if not left[piece.table]:
await self._table_done(piece.table)
- async def _start(self, pieces: list[_Piece]) -> None:
+ async def _start(self, pieces: list[_Piece], budget: _Budget) -> None:
"""开始起草一段。超过当前的列数上限(额度用完、超时多了会减半)的先按上限分开:多表的段按表重新拼,单张表的
- 按列均分(表级的项跟着第一块,先写它,后面几块拿它做背景)。分开的每一块各算一段(各有调用次数的上限)。"""
+ 按列均分(表级的项跟着第一块,先写它,后面几块拿它做背景)。分开的各块同时起草,共用这一段的调用次数(budget):
+ 以前每块各有 MODEL_PART_MAX_CALLS 次,上限缩到 10 列以后一段 40 列最多能调用 32 次。"""
cap = max(1, self.part_columns)
if sum(len(p.columns) for p in pieces) <= cap:
- await self._guarded(pieces)
+ await self._guarded(pieces, budget)
return
if len(pieces) > 1:
- await asyncio.gather(*(self._start(group) for group in _pack(pieces, cap)))
+ await asyncio.gather(*(self._start(group, budget) for group in _pack(pieces, cap)))
return
piece = pieces[0]
chunks = _even(piece.columns, -(-len(piece.columns) // cap))
first = [_Piece(piece.table, chunks[0], piece.table_level, piece.part)]
rest = [[_Piece(piece.table, cols, False, piece.part)] for cols in chunks[1:]]
if piece.table_level:
- await self._guarded(first)
- await asyncio.gather(*(self._guarded(group) for group in rest))
+ await self._guarded(first, budget)
+ await asyncio.gather(*(self._guarded(group, budget) for group in rest))
else:
- await asyncio.gather(*(self._guarded(group) for group in [first, *rest]))
+ await asyncio.gather(*(self._guarded(group, budget) for group in [first, *rest]))
- async def _guarded(self, pieces: list[_Piece]) -> None:
- """起草一段;意料之外的错误只记这一段没写出来,不影响别的段和别的表。"""
+ async def _guarded(self, pieces: list[_Piece], budget: _Budget) -> None:
+ """起草一段(或它按上限分开的一块);意料之外的错误只记这一块没写出来,不影响别的段和别的表。"""
try:
- await self._segment(pieces, 0, False, _Budget(max(1, int(C.MODEL_PART_MAX_CALLS))))
+ await self._segment(pieces, 0, False, budget)
except asyncio.CancelledError:
raise
except Exception as e: # noqa: BLE001
@@ -901,10 +934,9 @@ class _Run:
logger.exception("起草数据目录时出错:%s", "、".join(p.table for p in pieces))
self._fail(pieces, REASON_CRASH.format(line=first_line(e) or describe_exception(e)))
- self._tally(pieces)
def _tally(self, pieces: list[_Piece]) -> None:
- """一段(连同拆小、补缺)结束:一项都没写出来的接着数,写出来了清零。"""
+ """一段(原始段,连同它分开的各块、拆小、补缺)结束:一项都没写出来的接着数,写出来了清零。"""
if any(self.tables[p.table].wrote(p) for p in pieces):
self.dead_streak = 0
return
@@ -915,17 +947,29 @@ class _Run:
# ---------------------------------------------------------------- 一段
async def _segment(self, pieces: list[_Piece], depth: int, followup: bool, budget: _Budget, *,
- halved: bool = False, mode: str | None = None, last: bool = False) -> None:
+ halved: bool = False, mode: str | None = None, last: bool = False,
+ optional: bool = False) -> None:
"""问一段,拿到的并进各表;按失败原因拆开、补缺、记下没写出来的。
depth:已经拆了几层(截断后只补剩下的也算一层)。followup:这一段是在补缺(补过就不再补,见 _after_followup)。
- budget:这一段还能调用几次。halved:这几块是真拆小(_split)出来的——原因里「已拆小重试」只在这时写。
- mode / last:换纯文本问的那一次——拆不开的空壳、结构化输出回了说明文字(再不行就记没写出来)。"""
- reply = await self._ask(pieces, budget, mode)
+ budget:这一段(原始段)还能调用几次。halved:这几块是真拆小(_split)出来的——原因里「已拆小重试」只在这时写。
+ mode / last:换纯文本问的那一次——拆不开的空壳、结构化输出回了说明文字(再不行就记没写出来)。
+ optional:超时拆出来的块,轮到它时已经止损就不问了(_ask)。"""
+ reply = await self._ask(pieces, budget, mode, optional=optional)
if reply.capped:
self._fail(pieces, self._capped(halved))
return
+ if reply.skipped:
+ note = self._halt_note("timeout")
+ self._fail(pieces, lambda p: self._reason("timeout", split=True, piece=p, halted=note))
+ return
if reply.timeout:
+ # 先记下再报用量:报用量要写库、会让出事件循环,等名额的块醒来时要看到这一次
+ if sum(len(p.columns) for p in pieces) <= self._floor():
+ self.slow_streak += 1
+ if self.slow_streak == max(1, int(C.MODEL_GIVE_UP_AFTER)):
+ logger.warning("起草数据目录:拆到每段 %d 列仍接连超时 %d 次,后面超时的不再拆小",
+ self._floor(), self.slow_streak)
await self._report(reply, wrote=False)
await self._split(pieces, depth, "timeout", followup, budget, halved)
return
@@ -1000,31 +1044,40 @@ class _Run:
logger.info("起草数据目录:结构化输出回了说明文字,后面改走纯文本")
await self._segment(pieces, depth, followup, budget, halved=halved, mode="text", last=True)
- def _stopped(self, pieces: list[_Piece], depth: int) -> bool:
- """这几段本来还能拆小,因为前面连续几段都没写出来(止损)才没拆。"""
- return self.halted and depth < int(C.MODEL_SPLIT_DEPTH) and _halves(pieces) is not None
+ def _stopped(self, pieces: list[_Piece], depth: int) -> str:
+ """这几段本来还能拆小、因为止损才没拆时,止损的说明;不是的话是空串。"""
+ if depth < int(C.MODEL_SPLIT_DEPTH) and _halves(pieces) is not None:
+ return self._halt_note()
+ return ""
async def _split(self, pieces: list[_Piece], depth: int, kind: str, followup: bool, budget: _Budget,
halved: bool) -> None:
- """拆成两半再问(额度用完、超时、只给空壳、截断后一项都没捡回来)。拆不开的空壳再问一次:结构化输出给的换纯文本,
- 已经走纯文本的原样再问;别的原因拆不开就记没写出来。"""
+ """拆成两半再问(额度用完、超时、只给空壳、截断后一项都没捡回来)。
+
+ - 同一张表按列拆、前一半带着表级的项:先写前一半,后一半拿它做背景。超时的不等:一次超时就是一个时限,40 → 20
+ → 10 一层一层串着问要好几个时限,两半同时问;拆出来的块轮到它时已经止损的不再问(optional)。
+ - 拆不开的空壳再问一次:结构化输出给的换纯文本,已经走纯文本的原样再问。止损时本来能拆的不拆:结构化输出给的
+ 照旧换纯文本问一次(看看是不是结构化输出的问题),已经走纯文本的不再问。
+ - 别的原因拆不开、止损了,就记没写出来。"""
halves = _halves(pieces) if depth < int(C.MODEL_SPLIT_DEPTH) else None
- halted = halves is not None and self.halted
+ halted = self._halt_note(kind) if halves is not None else ""
if halves is not None and not halted:
first, second = halves
- if (len(first) == len(second) == 1 and first[0].table == second[0].table and first[0].table_level):
- # 同一张表按列拆、前一半带着表级的项:先写前一半,后一半拿它做背景
+ timeout = kind == "timeout"
+ if (not timeout and len(first) == len(second) == 1 and first[0].table == second[0].table
+ and first[0].table_level):
await self._segment(first, depth + 1, followup, budget, halved=True)
await self._segment(second, depth + 1, followup, budget, halved=True)
return
- results = await asyncio.gather(self._segment(first, depth + 1, followup, budget, halved=True),
- self._segment(second, depth + 1, followup, budget, halved=True),
- return_exceptions=True)
+ results = await asyncio.gather(
+ self._segment(first, depth + 1, followup, budget, halved=True, optional=timeout),
+ self._segment(second, depth + 1, followup, budget, halved=True, optional=timeout),
+ return_exceptions=True)
for result in results:
if isinstance(result, BaseException):
raise result
return
- if kind == "hollow":
+ if kind == "hollow" and (not halted or self.mode == "structured"):
await self._segment(pieces, depth, followup, budget, halved=halved, mode="text", last=True)
return
self._fail(pieces, lambda p: self._reason(kind, split=halved, piece=p, halted=halted))
@@ -1050,21 +1103,27 @@ class _Run:
# ---------------------------------------------------------------- 调用
- async def _ask(self, pieces: list[_Piece], budget: _Budget, mode: str | None = None) -> _Reply:
+ async def _ask(self, pieces: list[_Piece], budget: _Budget, mode: str | None = None, *,
+ optional: bool = False) -> _Reply:
"""调一次模型(按原因重来:额度被拒、结构化输出不支持、网络错误……),交回读好的回复。
- 这一段的调用次数用完了交回 capped(不调用)。超时不原样重试:交回 timeout,由调用方当作这一段太大、拆小再问。
- 重来之前的那次调用在这里就报给进度回调;交回的那次由调用方读完内容再报(写没写出来要看校验之后的内容)。"""
+ 这一段的调用次数用完了交回 capped(不调用)。optional(超时拆出来的块):拿到名额时已经止损,交回 skipped(不调用,
+ 次数退回)——等名额的时候前面的块接连超时,再问也是一个时限。超时不原样重试:交回 timeout,由调用方当作这一段
+ 太大、拆小再问。重来之前的那次调用在这里就报给进度回调;交回的那次由调用方读完内容再报(写没写出来要看校验之后
+ 的内容)。"""
mode = mode or self.mode
retried = 0
while True:
if budget.left <= 0:
return _Reply(capped=True)
- budget.left -= 1
+ budget.left -= 1 # 先占上:各块共用这一段的次数,等名额的时候别的块不能把它用掉
used = getattr(self.model, "budget", None)
try:
# 先占这次起草自己的名额,再去排全进程的:一次起草排在全进程名额上的调用最多 MODEL_CONCURRENCY 个
async with self.gate, model_gate():
+ if optional and self._halt_note("timeout"):
+ budget.left += 1
+ return _Reply(skipped=True)
await self._hook(self.hooks.call_started, [p.table for p in pieces])
reply, raw = await asyncio.wait_for(self._call(pieces, mode), timeout=C.MODEL_TIMEOUT_S)
reply.usage = _usage_of(raw)
@@ -1155,13 +1214,14 @@ class _Run:
async def _called(self, usage: tuple[int, int], failure: str | None, *, strike: bool = False) -> None:
"""记下一次调用:额度用完或超时(strike)累计 MODEL_SHRINK_AFTER 次,新开始的段列数上限减半;最近几次调用多数
- 没写出来时提醒,少了清掉。报给进度回调。"""
+ 没写出来时提醒,少了清掉。写出来了(failure 为 None)就清掉接连超时的计数。报给进度回调。"""
self.recent.append(failure)
+ if failure is None:
+ self.slow_streak = 0
if strike:
self.strikes += 1
width = max(1, int(C.MODEL_BATCH_COLUMNS))
- floor = min(width, max(1, int(C.MODEL_MIN_PART_COLUMNS)))
- cap = max(floor, width >> (self.strikes // max(1, int(C.MODEL_SHRINK_AFTER))))
+ cap = max(self._floor(), width >> (self.strikes // max(1, int(C.MODEL_SHRINK_AFTER))))
if cap < self.part_columns:
self.part_columns = cap
logger.info("起草数据目录:额度用完或超时已有 %d 次,之后新开始的段每段最多 %d 列", self.strikes, cap)
@@ -1243,7 +1303,7 @@ class _Run:
if piece.table_level and not st.got_table:
st.failed.setdefault(_TABLE, why)
- def _reason(self, kind: str, *, split: bool, piece: _Piece | None = None, halted: bool = False) -> _Why:
+ def _reason(self, kind: str, *, split: bool, piece: _Piece | None = None, halted: str = "") -> _Why:
if kind == "exhausted":
base = REASON_EXHAUSTED.format(budget=self._budget(), seg=SEG)
elif kind == "truncated":
@@ -1447,5 +1507,5 @@ class _Run:
__all__ = ["BUDGET_WORDS", "Background", "CallReport", "FAILURE_LABEL", "Hooks", "ModelOutcome", "NOTICE", "REASON_CALL",
"REASON_CAPPED", "REASON_EXHAUSTED", "REASON_FORMAT", "REASON_HALTED", "REASON_HOLLOW", "REASON_MISSING",
- "REASON_RETRIED", "REASON_SPLIT", "REASON_TIMEOUT", "REASON_TRUNCATED", "REASON_UNANSWERED", "SEG",
- "classify_failure", "model_gate", "parts_for", "run_model_draft"]
+ "REASON_RETRIED", "REASON_SLOW", "REASON_SPLIT", "REASON_TIMEOUT", "REASON_TRUNCATED", "REASON_UNANSWERED",
+ "SEG", "classify_failure", "model_gate", "parts_for", "run_model_draft"]
diff --git a/backend/tests/test_catalog_draft_engine.py b/backend/tests/test_catalog_draft_engine.py
index 59171b50..7d25a348 100644
--- a/backend/tests/test_catalog_draft_engine.py
+++ b/backend/tests/test_catalog_draft_engine.py
@@ -846,8 +846,11 @@ def test_reasons_are_joined_by_their_base():
assert D._join([(W(ex), 2), (W(ex), 3)]) == _exhausted("这几段")
assert D._join([(W(ex), 2), (W(ex, split=True), 2)]) == _exhausted() + D.REASON_SPLIT
assert D._join([(W("甲"), 1), (W("乙"), 1), (W("丙", split=True), 1)]) == "甲;乙", "没说出来的原因不补后缀"
- assert D._join([(W(D.REASON_HOLLOW, halted=True), 1), (W(D.REASON_HOLLOW), 2)]) == \
- D.REASON_HOLLOW + D.REASON_HALTED.format(n=catalog.MODEL_GIVE_UP_AFTER)
+ halted = D.REASON_HALTED.format(n=catalog.MODEL_GIVE_UP_AFTER)
+ assert D._join([(W(D.REASON_HOLLOW, halted=halted), 1), (W(D.REASON_HOLLOW), 2)]) == D.REASON_HOLLOW + halted
+ slow = D.REASON_SLOW.format(n=catalog.MODEL_MIN_PART_COLUMNS)
+ assert D._join([(W(D.REASON_HOLLOW, halted=halted), 1), (W(D.REASON_HOLLOW, halted=slow), 2),
+ (W(D.REASON_HOLLOW, halted=halted), 3)]) == D.REASON_HOLLOW + halted + slow, "两种止损各说一次"
assert D._join([(W("调用模型出错:{坏的}"), 1)]) == "调用模型出错:{坏的}", "报错原文里的花括号原样留着"
@@ -1014,6 +1017,118 @@ async def test_the_notice_comes_when_four_of_six_calls_fail_and_goes_when_fewer_
assert got["t9"].status == "ok"
+# --------------------------------------------------------------------------
+# 复核后的修正:每个原始段一共 8 次、一直超时的模型几轮就停、止损后纯文本不再原样再问
+# --------------------------------------------------------------------------
+
+
+def _width(call: Call, tables: dict) -> int:
+ """这次调用请模型写几列(只写表级的项是 0)。"""
+ return sum(len(tables[name]["columns"]) if part == "full" else len(cols or [])
+ for name, (part, cols) in call.asked.items())
+
+
+async def test_the_chunks_of_a_part_cut_by_the_smaller_cap_share_its_eight_calls(monkeypatch):
+ """每个原始段(连同段大小减半后按上限分开的各块、拆出来的、补问的、重试的)一共最多调用 8 次。以前分开的每一块各有
+ 8 次:上限缩到 10 列以后一直只给空壳,4 张窄表拼成的 40 列段调用了 20 次,单张 40 列的表也是 20 次。"""
+ monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
+ monkeypatch.setattr(catalog, "MODEL_GIVE_UP_AFTER", 99) # 不让止损先停下来
+ tables = {"w40": _table(40), **{f"n{i}": _table(10) for i in range(1, 5)}, "x40": _table(40)}
+
+ def handle(call: Call) -> Reply:
+ return EXHAUSTED if "w40" in call.asked else _hollow(call)
+
+ model, reports = Scripted(handle), Reports()
+ got = await D.run_model_draft(model, _source(tables), list(tables), hooks=reports.hooks())
+ first = sum(1 for c in model.calls if "w40" in c.asked)
+ assert first == 8 and reports.items[first - 1].part_columns == 10, "w40 一直用完额度,上限缩到 10 列"
+ packed = [c for c in model.calls if set(c.asked) & {"n1", "n2", "n3", "n4"}]
+ assert len(packed) == 8, "拼成的 40 列段按 10 列分开成 4 块,4 块一共 8 次"
+ assert all(len(c.asked) == 1 and _width(c, tables) <= 10 for c in packed)
+ assert {got[f"n{i}"].status for i in range(1, 5)} == {"failed"}
+ assert "每段最多调用 8 次模型,这一段到了次数仍没写完,已停止重试" in {got[f"n{i}"].error for i in range(1, 5)}, \
+ "次数用完时还没问的块记「每段最多调用 8 次」"
+ wide = [c for c in model.calls if "x40" in c.asked]
+ assert len(wide) == 8 and _width(wide[0], tables) == 10, "单张 40 列的表按 10 列分开,各块一共 8 次"
+ assert len(reports.items) == len(model.calls)
+
+
+class AlwaysSlow:
+ """每次调用都超时(立刻抛 TimeoutError,和到了单次调用的时限走同一条路),fits(列数) 为真的照常写。
+
+ 记下每次调用在第几轮:每次调用一样长(一个时限),一次调用开始时,已经结束的调用里最晚的那一轮的下一轮——
+ 轮数乘以时限就是用掉的时间。"""
+
+ def __init__(self, tables: dict, fits: Callable[[int], bool] = lambda n: False) -> None:
+ self.tables, self.fits, self.ok = tables, fits, answering(tables)
+ self.rounds: list[int] = []
+ self._ended: list[int] = []
+
+ async def __call__(self, call: Call) -> Reply:
+ r = 1 + max(self._ended, default=0)
+ self.rounds.append(r)
+ for _ in range(5):
+ await asyncio.sleep(0)
+ self._ended.append(r)
+ if self.fits(_width(call, self.tables)):
+ return self.ok(call)
+ raise TimeoutError()
+
+
+SLOW_STOP = ";拆到每段 10 列仍然超时,已停止拆小重试,建议更换起草模型"
+
+
+async def test_a_model_that_always_times_out_stops_after_three_rounds_not_seven():
+ """每次调用都超时、和段大小无关:超时拆出来的两半同时问(不再等带表级项的前一半写完才问后一半),拆到每段 10 列的
+ 调用接连超时 2 次就停止拆小,拆出来还没问的块不再问。40 列的表 3 轮、6 次调用就结束(时限 420 秒时约 21 分钟);
+ 以前 40→20→10→5 一层一层串着问,8 次调用约 7 轮(约 49 分钟)。"""
+ tables = {"t40": _table(40)}
+ slow = AlwaysSlow(tables)
+ model, reports = Scripted(slow), Reports()
+ got = await D.run_model_draft(model, _source(tables), ["t40"], hooks=reports.hooks())
+ assert [_width(c, tables) for c in model.calls] == [40, 20, 20, 10, 10, 10], "拆到每段 10 列仍超时就不再往下拆"
+ assert slow.rounds == [1, 2, 2, 3, 3, 3], "两半同时问"
+ assert reports.failures == ["timeout"] * 6 and len(reports.items) == len(model.calls)
+ o = got["t40"]
+ assert o.status == "failed" and o.error == "模型 420 秒内没有写完这一段" + D.REASON_SPLIT + SLOW_STOP
+
+
+async def test_three_parts_that_always_time_out_end_in_four_rounds():
+ """首轮同时起草的 3 段都一直超时:拆到每段 10 列仍超时就一起停下,不再各自拆满 8 次。12 次调用、4 轮;以前 24 次
+ 调用、约 8 轮(止损要等一整段结束才计数,首轮的 3 段都跑满)。"""
+ tables = {f"t{i}": _table(40) for i in range(1, 4)}
+ slow = AlwaysSlow(tables)
+ model = Scripted(slow)
+ got = await D.run_model_draft(model, _source(tables), list(tables))
+ assert len(model.calls) == 12 and max(slow.rounds) == 4
+ assert {o.status for o in got.values()} == {"failed"}
+ assert all(o.error.endswith(SLOW_STOP) for o in got.values())
+
+
+async def test_timeouts_that_go_away_at_ten_columns_are_still_split_down_and_written():
+ """超时和段大小有关(40、20 列超时,10 列写得完):照旧拆到 10 列写出来,不因为前面超时多就停下。"""
+ tables = {"t40": _table(40)}
+ slow = AlwaysSlow(tables, fits=lambda n: n <= 10)
+ model = Scripted(slow)
+ got = await D.run_model_draft(model, _source(tables), ["t40"])
+ assert got["t40"].status == "ok" and len(got["t40"].notes["columns"]) == 40
+ assert len(model.calls) == 7 and max(slow.rounds) == 4
+
+
+async def test_a_halted_part_that_could_be_split_is_not_asked_again_in_text_mode(monkeypatch):
+ """止损以后本来能拆小的段只给空壳:已经走纯文本的不再原样再问(约定只对拆不开的段再问一次);结构化输出的照旧换
+ 纯文本问一次,看看是不是结构化输出的问题(见 test_the_column_parts_of_a_wide_table_stop_splitting_too)。"""
+ monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
+ monkeypatch.setattr(catalog, "MODEL_BATCH_TABLES", 1)
+ tables = {f"t{i}": _table(10) for i in range(1, 5)}
+ model = Scripted(_text_only(_hollow))
+ got = await D.run_model_draft(model, _source(tables), list(tables))
+ # t1:结构化不支持 1 次、纯文本 1 次、拆成两半各问一次再原样再问一次;t2 同样(少了结构化那次);之后止损
+ assert _by_table(model) == {"t1": 6, "t2": 5, "t3": 1, "t4": 1}
+ assert got["t3"].error == (catalog.MODEL_EMPTY_REASON
+ + ";模型连续 2 段都没写出来,已停止拆小重试,建议更换起草模型")
+
+
# --------------------------------------------------------------------------
# 起草要求、用量
# --------------------------------------------------------------------------
diff --git a/backend/tests/test_catalog_draft_jobs.py b/backend/tests/test_catalog_draft_jobs.py
index 00455492..b07622a1 100644
--- a/backend/tests/test_catalog_draft_jobs.py
+++ b/backend/tests/test_catalog_draft_jobs.py
@@ -335,8 +335,8 @@ async def test_usage_counts_timeouts_and_failed_calls(client, monkeypatch):
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 列(带表级的项)超时 → 拆成 6、6 → 后 12 列
- assert [len(requested(c)["tickets"][1] or []) for c in model.calls] == [0, 12, 6, 6, 12]
+ # 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
From 7bc7fc685f2c6f0b9fdb3bd9cc84f76a785a3d8a Mon Sep 17 00:00:00 2001
From: ThearchyHelios
Date: Tue, 13 Oct 2026 21:25:59 +0800
Subject: [PATCH 28/29] =?UTF-8?q?=E8=B5=B7=E8=8D=89=E6=8C=89=E5=A4=8D?=
=?UTF-8?q?=E6=A0=B8=E4=BF=AE=E6=AD=A3=EF=BC=9A=E8=AF=BB=E5=9B=9E=E5=A4=8D?=
=?UTF-8?q?=E5=87=BA=E9=94=99=E4=B9=9F=E8=AE=B0=E8=BF=9B=E7=94=A8=E9=87=8F?=
=?UTF-8?q?=EF=BC=9B=E8=A1=A5=E9=97=AE=E6=97=B6=E5=88=97=E5=87=BA=E4=BA=86?=
=?UTF-8?q?=E3=80=81=E5=AD=97=E6=AE=B5=E7=95=99=E7=A9=BA=E4=B8=8D=E7=AE=97?=
=?UTF-8?q?=E6=B2=A1=E5=86=99=E5=87=BA=E6=9D=A5=EF=BC=9B=E7=95=99=E7=A9=BA?=
=?UTF-8?q?=E7=9A=84=E5=88=97=E7=9C=8B=E7=9B=AE=E5=BD=95=E9=87=8C=E5=B7=B2?=
=?UTF-8?q?=E6=9C=89=E7=9A=84=EF=BC=9B=E4=B8=80=E9=A1=B9=E9=83=BD=E6=B2=A1?=
=?UTF-8?q?=E5=86=99=E5=87=BA=E6=9D=A5=E7=9A=84=E5=AE=BD=E8=A1=A8=E9=80=90?=
=?UTF-8?q?=E6=AE=B5=E5=86=99=E8=8C=83=E5=9B=B4=EF=BC=9B=E6=8F=90=E9=86=92?=
=?UTF-8?q?=E7=85=A7=E7=BA=A6=E5=AE=9A=E7=9A=84=E8=AF=B4=E6=B3=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 读回复时出了意料之外的错:这次调用照样记进用量(算一次出错),再交给 _guarded 记这一块没写出来;以前 _take 抛异常
时这次调用不进 calls、token、failed_calls,也不进提醒的窗口。校验(catalog._from_model)里该是文字的地方写成了
数组、对象的(列名、表类型、业务日期的列、度量类型,columns 不是数组的)直接丢掉、不抛异常:实测列名写成数组时
抛 TypeError,整张表记「起草这张表时出错」。
- 补问时请写的列都列出来了、字段留空:表上算拿不准(记留空),用量和提醒里也不再算一次「只给空壳」没写出来;以前
提醒会因此劝人换模型,真正该做的是补充起草要求。
- 留空的列不算目录里已经有中文名或含义的(不论来源:以前模型起草、按「重新起草不丢内容」留下的,库注释,人工;驳回
的不算有);写入时(write_draft)再按并入目录之后的结果筛一遍,起草进行中有人填了的同样不算。以前照界面的建议补了
起草要求再起草,模型这次没再给的老列又被说成「拿不准、留空了」,提示一直消不掉。
- 一项都没写出来的宽表同样逐段写上列的范围:「这张表 95 列,分 4 段起草,第 1 段(表级的项)、第 2 段(c001 到
c032,共 32 列)……都没写出来:原因」;只有一段的表照旧只写原因(这一段就是整张表)。
- 提醒照约定写「建议在「设置 → 偏好设置」的「起草模型」换一个模型、缩小起草范围,或者停止这次起草」。
---
backend/app/data/catalog.py | 29 +++++--
backend/app/data/catalog_draft.py | 73 ++++++++++++-----
backend/tests/test_catalog_draft_engine.py | 95 ++++++++++++++++++++--
backend/tests/test_catalog_draft_jobs.py | 38 +++++++--
4 files changed, 190 insertions(+), 45 deletions(-)
diff --git a/backend/app/data/catalog.py b/backend/app/data/catalog.py
index db02c025..3dd0bb39 100644
--- a/backend/app/data/catalog.py
+++ b/backend/app/data/catalog.py
@@ -1711,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())
@@ -1720,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())
@@ -1745,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:
@@ -1806,7 +1810,8 @@ class TableDraftResult:
status: str = "ok"
#: 模型分几段起草(不用模型时为 None)
parts_total: int | None = None
- #: 模型拿不准、留空了的列(catalog_draft.ModelOutcome.blank,按表结构的顺序);表的状态不因此变
+ #: 模型拿不准、留空了的列(catalog_draft.ModelOutcome.blank,按表结构的顺序),只留并入目录之后仍然没有中文名和
+ #: 含义的;表的状态不因此变
blank: list[str] = field(default_factory=list)
@@ -1839,6 +1844,8 @@ async def write_draft(session: AsyncSession, source: Any, table: str, modeled: A
- 并入已有目录按 merge_notes 的规则:人工确认、驳回过的项不动。内容没变不写、不升版本。
- 模型这次没给的项(留空、没通过校验)不删以前起草的;列已经不在表结构里的,模型各段都写出来时(covered 含 llm)
照旧删掉。模型只写出一部分的不算完整起草过(covered 不含 llm)。
+ - 模型留空的列(blank)按并入之后的目录再筛一遍:已经有中文名或含义的(以前起草的、库注释、起草进行中有人填的)
+ 不算留空。
- 写入撞上别人刚改过(乐观锁)就重读重并,最多三次。
"""
result = TableDraftResult(table=table)
@@ -1875,6 +1882,10 @@ async def write_draft(session: AsyncSession, source: Any, table: str, modeled: A
else:
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":
diff --git a/backend/app/data/catalog_draft.py b/backend/app/data/catalog_draft.py
index ae1a7031..4183bff1 100644
--- a/backend/app/data/catalog_draft.py
+++ b/backend/app/data/catalog_draft.py
@@ -30,13 +30,16 @@
不等于额度用完:只有停在额度上、或者正文为空而有思考才算。
- 被截断的 JSON:先把已经完整的表、列对象捡回来,只补剩下的。
- 一段里缺了的表、列只补缺的,补一次。补问之后:模型列出了、字段留空的列是拿不准(补问时才列出来的也一样),不算
- 没写出来;整列没列出来的算没写出来(这张表是 partial);整张表还一项都没有的,照只给空壳处理(拆小、再问一次)。
+ 没写出来(补问的那次调用把请写的都列出来了,用量和提醒里也不算没写出来);整列没列出来的算没写出来(这张表是
+ partial);整张表还一项都没有的,照只给空壳处理(拆小、再问一次)。
- 结构化输出这个接口不支持(400 / 422 提到 response_format、json_schema、tool……,或者模型类不支持):这次起草
后面改走纯文本、从正文取 JSON(纯文本那条路另外说清 JSON 的格式)。
- 服务方嫌额度太大:退回 MODEL_FALLBACK_MAX_TOKENS 重来,这次起草后面都用它(catalog.DraftModel.shrink)。
- 网络错误、429、5xx:等一会儿重试(最多 MODEL_RETRIES 次,退避)。单次调用的时限是 MODEL_TIMEOUT_S。SDK
自己不再重试(catalog.resolve_draft_model 建模型时 max_retries=0),实际重试次数和报错写的一致。
- 别的错误:这一段没写出来,原因照实写。
+- 读回复时出了意料之外的错:这一块记没写出来(「起草这张表时出错」),这次调用照样记进用量、算一次出错。该是文字的
+ 地方写成了数组、对象的值在校验(catalog._from_model)里丢掉,不算出错。
- 封顶:每个原始段(计划里的整表段、表级那段或一个列分段,连同按缩小后的上限分开的各块、拆出来的、补问的、重试的)
一共最多调用 MODEL_PART_MAX_CALLS 次,到了次数还没写完的记没写出来。
- 止损:连续 MODEL_GIVE_UP_AFTER 段拆到底仍一项没写出来(模型明显用不了,例如思考总是吃满额度),这次起草后面的
@@ -45,14 +48,18 @@
超时的不再拆小,超时拆出来、还没问的块也不再问;有一次调用写出来了就恢复。不然一直超时的模型首轮同时起草的几段
要各自拆满次数,一段 40 列串着问约 7 个时限(420 秒时约 49 分钟)。
- 提醒:最近 MODEL_NOTICE_WINDOW 次调用里有 MODEL_NOTICE_ON 次以上没写出来(额度用完、超时、空壳、出错),给一句
- 提醒(次数和原因,建议换起草模型、减少表或停止),经进度回调交给任务;之后少于 MODEL_NOTICE_OFF 次时清掉。以前
- 看不出调用在反复失败:一个任务 55 分钟只结束 1 张表,界面上只有段进度和用量。
+ 提醒(次数和原因,建议换起草模型、缩小起草范围或停止),经进度回调交给任务;之后少于 MODEL_NOTICE_OFF 次时清掉。
+ 以前看不出调用在反复失败:一个任务 55 分钟只结束 1 张表,界面上只有段进度和用量。
-**原因。** 没写出来的原因只写进这张表的 model_error:几段因为同一个原因没写出来时说「这几段」,每段写上列的范围
-(「第 5 段(列名 到 列名,共 33 列)」,列名照表结构原样),真拆小重试过的才补「已拆小重试仍没写出」。
+**原因。** 没写出来的原因只写进这张表的 model_error:几段因为同一个原因没写出来时说「这几段」,分段起草的表(只写
+出一部分的、一项都没写出来的都一样)每段写上列的范围(「第 5 段(列名 到 列名,共 33 列)」,列名照表结构原样),
+真拆小重试过的才补「已拆小重试仍没写出」。只有一段的表,这一段就是整张表:只写出一部分的写清是哪几列,一项都没写
+出来的只写原因。
**留空的列。** 请模型写了、模型也列出了,中文名和含义却都没给(或者给的都没通过校验)的列是模型拿不准,不补问,
-记在 ModelOutcome.blank 里(表的状态不因此变)。已确认的、库注释已经说清的列不算(提示词里它们以此为准)。
+记在 ModelOutcome.blank 里(表的状态不因此变)。目录里已经有中文名或含义的列不算(不论来源,驳回的不算有:以前模型
+起草的留着,照「补充起草要求再重新起草」做一遍,模型这次没再给的老列不该又说成拿不准),库注释已经说清的不算
+(提示词里它们以此为准);写入时(catalog.write_draft)再按并入目录之后的结果筛一遍。
**并发。** 全进程最多 MODEL_CONCURRENCY 个调用同时进行(model_gate:按事件循环各一个信号量,所有起草任务和同步
起草共用,先来先放)。一次起草里同时只起草 MODEL_CONCURRENCY 段(一段拆小、补缺都做完再起草下一段),同时在等
@@ -121,7 +128,7 @@ REASON_HALTED = ";模型连续 {n} 段都没写出来,已停止拆小重试
REASON_SLOW = ";拆到每段 {n} 列仍然超时,已停止拆小重试,建议更换起草模型"
# ---- 提醒(最近几次调用多数没写出来),经进度回调交给任务(CatalogDraftJob.notice)
-NOTICE = ("最近 {calls} 次调用有 {failed} 次没写出来({kinds})。可以在{place}换一个模型、减少这次起草的表,"
+NOTICE = ("最近 {calls} 次调用有 {failed} 次没写出来({kinds})。建议在{place}换一个模型、缩小起草范围,"
"或者停止这次起草")
#: 一次调用没写出来的原因种类 → 提醒里的说法(按这个顺序列)
FAILURE_LABEL = {"exhausted": "额度用完", "timeout": "超时", "hollow": "只给空壳", "error": "出错"}
@@ -980,14 +987,22 @@ class _Run:
return
self._fail(pieces, _Why(reply.error, split=halved))
return
- gains = self._take(pieces, reply.entries)
+ try:
+ gains = self._take(pieces, reply.entries)
+ except Exception:
+ # 读回复时出了意料之外的错(_guarded 记这一块没写出来):这次调用照样记进用量,算一次出错
+ await self._called(reply.usage or (0, 0), "error")
+ raise
anything = any(g.items for g in gains)
- await self._report(reply, wrote=anything)
+ complete = not (reply.exhausted or reply.truncated)
+ rest = self._remainder(pieces, gains, truncated=False, followup=followup) if complete else []
+ # 补问时请写的都列出来了、字段留空:是模型拿不准(记留空),这次调用不算没写出来(不进提醒的窗口)
+ await self._report(reply, wrote=anything or (followup and complete and not rest))
if last and anything and self.mode != "text":
# 结构化输出只给空壳、纯文本写出来了:这个模型的结构化输出靠不住,这次起草后面都走纯文本
self.mode = "text"
logger.info("起草数据目录:结构化输出只给了空壳,纯文本写出来了,后面改走纯文本")
- if reply.exhausted or reply.truncated:
+ if not complete:
rest = self._remainder(pieces, gains, truncated=True, followup=followup)
if not rest:
return
@@ -1007,7 +1022,6 @@ class _Run:
return
await self._split(pieces, depth, "hollow", followup, budget, halved)
return
- rest = self._remainder(pieces, gains, truncated=False, followup=followup)
if not rest:
return
if followup:
@@ -1333,6 +1347,11 @@ class _Run:
if not items:
failures = [(why, part_of.get(key, 1)) for key, why in st.failed.items()]
reason = _join(failures) if failures else (C.MODEL_EMPTY_REASON if st.named else REASON_MISSING)
+ if failures and len(st.parts) > 1:
+ # 宽表一项都没写出来:同样逐段写上列的范围
+ said = self._parts_said(st, failed_cols, failed_table)
+ every = "都" if len(said) == len(st.parts) else ""
+ reason = f"这张表 {len(st.columns)} 列,分 {len(st.parts)} 段起草,{'、'.join(said)}{every}没写出来:{reason}"
return ModelOutcome("failed", {}, 0, reason, total, st.parts_done)
notes = copy.deepcopy(st.notes)
blank = self._blank(st, set(failed_cols))
@@ -1342,18 +1361,21 @@ class _Run:
return ModelOutcome("ok", notes, items, None, total, st.parts_done, blank)
def _blank(self, st: _Table, failed: set[str]) -> list[str]:
- """模型拿不准、留空了的列:请它写了(不在没写出来的里面),中文名和含义却都没给(或者给的都没通过校验)。已确认
- 的、库注释已经说清的不算——提示词里它们以此为准,模型不写也不缺:已确认(有确证)的中文名或含义、库注释(按
- catalog.comment_role 记进目录的那种;导入表格的源是系统说明)。"""
+ """模型拿不准、留空了的列:请它写了(不在没写出来的里面),中文名和含义却都没给(或者给的都没通过校验)。
+
+ 目录里已经有中文名或含义的不算(不论来源:以前模型起草的——重新起草时模型这次没给的留着、不删;人工填写、确认的;
+ 库注释;驳回的不算有):照「补充起草要求再重新起草」做一遍,模型这次没再给的老列不该又说成拿不准。库注释已经说清
+ 的(catalog.comment_role 记进目录的那种;导入表格的源是系统说明)同样不算:提示词里它们以此为准,模型不写也不缺。
+ 写入时(catalog.write_draft)再按并入目录之后的结果筛一遍。"""
drafted = st.notes.get("columns") or {}
- confirmed = C._confirmed_only(st.existing).get("columns") or {}
+ known = C.visible_notes(st.existing).get("columns") or {}
comments = {str(c.get("name")): (c.get("comment") or "").strip()
for c in st.meta.get("columns") or [] if isinstance(c, dict)}
out: list[str] = []
for col in st.columns:
if col in failed:
continue
- if any(f in (drafted.get(col) or {}) or f in (confirmed.get(col) or {}) for f in ("label", "meaning")):
+ if any(f in (drafted.get(col) or {}) or f in (known.get(col) or {}) for f in ("label", "meaning")):
continue
text = comments.get(col)
if text and (self.system or C.comment_role(text, col) is not None):
@@ -1362,18 +1384,25 @@ class _Run:
return out
@staticmethod
- def _partial_text(st: _Table, failed_cols: list[str], failed_table: bool) -> str:
+ def _parts_said(st: _Table, failed_cols: list[str], failed_table: bool) -> list[str]:
+ """分段起草的表没写出来的是哪几段,每段写上列的范围(列名照表结构原样):「第 1 段(表级的项)」「第 3 段(列名 到
+ 列名,共 32 列)」。"""
+ part_of = st.part_of()
+ bad: dict[int, list[str]] = {}
+ for col in failed_cols:
+ bad.setdefault(part_of.get(col, 1), []).append(col)
+ said = ["第 1 段(表级的项)"] if failed_table else []
+ return said + [f"第 {k} 段({_columns_text(st.columns, cols, count=True)})" for k, cols in sorted(bad.items())]
+
+ @classmethod
+ def _partial_text(cls, st: _Table, failed_cols: list[str], failed_table: bool) -> str:
"""只写出一部分时说清哪部分没写出来(第几段、哪几列)、为什么、怎么办。"""
part_of = st.part_of()
keys = ([_TABLE] if failed_table else []) + failed_cols
reason = _join((st.failed[k], part_of.get(k, 1)) for k in keys)
n = len(st.columns)
if len(st.parts) > 1:
- bad: dict[int, list[str]] = {}
- for col in failed_cols:
- bad.setdefault(part_of.get(col, 1), []).append(col)
- said = ["第 1 段(表级的项)"] if failed_table else []
- said += [f"第 {k} 段({_columns_text(st.columns, cols, count=True)})" for k, cols in sorted(bad.items())]
+ said = cls._parts_said(st, failed_cols, failed_table)
head = f"这张表 {n} 列,分 {len(st.parts)} 段起草,{'、'.join(said)}没写出来"
elif failed_cols and failed_table:
head = (f"这张表 {n} 列,有表级的项和 {len(failed_cols)} 列({_columns_text(st.columns, failed_cols, count=False)})"
diff --git a/backend/tests/test_catalog_draft_engine.py b/backend/tests/test_catalog_draft_engine.py
index 7d25a348..4a1a94aa 100644
--- a/backend/tests/test_catalog_draft_engine.py
+++ b/backend/tests/test_catalog_draft_engine.py
@@ -462,11 +462,13 @@ async def test_columns_listed_blank_in_the_followup_are_left_blank_not_failed():
entry["columns"] = [{"name": c["name"]} for c in entry["columns"]]
return reply
- model = Scripted(handle)
- got = await D.run_model_draft(model, _source(tables), ["t8"])
+ model, reports = Scripted(handle), Reports()
+ got = await D.run_model_draft(model, _source(tables), ["t8"], hooks=reports.hooks())
assert model.calls[1].asked == {"t8": ("columns", ["c003", "c007"])}
o = got["t8"]
assert o.status == "ok" and o.error is None and o.blank == ["c003", "c007"]
+ # 补问的那次把请写的列都列出来了(字段留空是拿不准):用量和提醒里也不算一次「只给空壳」
+ assert reports.failures == [None, None]
async def test_a_followup_that_comes_back_as_an_empty_shell_leaves_the_table_partial():
@@ -832,7 +834,8 @@ async def test_the_same_reason_is_said_once_even_when_some_parts_were_split(monk
got = await D.run_model_draft(model, _source({"wide45": _table(45)}), ["wide45"])
o = got["wide45"]
assert o.status == "failed"
- assert o.error == "模型只给了空壳,没有写出可用的内容;已拆小重试仍没写出"
+ assert o.error == ("这张表 45 列,分 3 段起草,第 1 段(表级的项)、第 2 段(c001 到 c023,共 23 列)、"
+ "第 3 段(c024 到 c045,共 22 列)都没写出来:模型只给了空壳,没有写出可用的内容;已拆小重试仍没写出")
def test_reasons_are_joined_by_their_base():
@@ -894,7 +897,8 @@ async def test_the_column_parts_of_a_wide_table_stop_splitting_too(monkeypatch):
model = Scripted(_hollow)
got = await D.run_model_draft(model, _source({"wide95": _table(95)}), ["wide95"])
assert len(model.calls) == 2 + 8 + 2 + 2
- assert got["wide95"].error == (D.REASON_HOLLOW + ";每段最多调用 8 次模型,这一段到了次数仍没写完,已停止重试"
+ assert got["wide95"].error == (WIDE95_PARTS + "都没写出来:" + D.REASON_HOLLOW
+ + ";每段最多调用 8 次模型,这一段到了次数仍没写完,已停止重试"
+ D.REASON_SPLIT + ";模型连续 2 段都没写出来,已停止拆小重试,建议更换起草模型")
@@ -938,6 +942,11 @@ async def test_a_big_draft_does_not_hold_the_model_ahead_of_a_later_one(monkeypa
assert order == ["a1", "b1", "a2", "a3", "a4"]
+#: 95 列的宽表分 4 段起草,逐段写上列的范围
+WIDE95_PARTS = ("这张表 95 列,分 4 段起草,第 1 段(表级的项)、第 2 段(c001 到 c032,共 32 列)、"
+ "第 3 段(c033 到 c064,共 32 列)、第 4 段(c065 到 c095,共 31 列)")
+
+
def _order(model: Scripted) -> list[tuple[str, str]]:
return [(name, part) for call in model.calls for name, (part, _) in call.asked.items()]
@@ -1008,8 +1017,8 @@ async def test_the_notice_comes_when_four_of_six_calls_fail_and_goes_when_fewer_
assert reports.failures == ["error", "error", "exhausted", "hollow", "hollow"] + [None] * 5
notices = [r.notice for r in reports.items]
assert notices[:3] == [None] * 3
- assert notices[3] == ("最近 4 次调用有 4 次没写出来(额度用完 1 次、只给空壳 1 次、出错 2 次)。可以在「设置 → 偏好设置」"
- "的「起草模型」换一个模型、减少这次起草的表,或者停止这次起草")
+ assert notices[3] == ("最近 4 次调用有 4 次没写出来(额度用完 1 次、只给空壳 1 次、出错 2 次)。建议在「设置 → 偏好设置」"
+ "的「起草模型」换一个模型、缩小起草范围,或者停止这次起草")
assert notices[4].startswith("最近 5 次调用有 5 次没写出来")
assert notices[7].startswith("最近 6 次调用有 3 次没写出来"), "还有 2 次以上没写出来:留着,次数照实写"
assert notices[8].startswith("最近 6 次调用有 2 次没写出来")
@@ -1018,7 +1027,8 @@ async def test_the_notice_comes_when_four_of_six_calls_fail_and_goes_when_fewer_
# --------------------------------------------------------------------------
-# 复核后的修正:每个原始段一共 8 次、一直超时的模型几轮就停、止损后纯文本不再原样再问
+# 复核后的修正:每个原始段一共 8 次、一直超时的模型几轮就停、读回复出错也记用量、目录里已有的不算留空、补问列出了不算
+# 没写出来、一项都没写出来的宽表逐段写范围、止损后纯文本不再原样再问
# --------------------------------------------------------------------------
@@ -1115,6 +1125,77 @@ async def test_timeouts_that_go_away_at_ten_columns_are_still_split_down_and_wri
assert len(model.calls) == 7 and max(slow.rounds) == 4
+async def test_odd_values_in_a_reply_are_dropped_not_a_crash():
+ """模型把列名、表类型、业务主键、业务日期的列、度量类型写成了数组或对象:这几项丢掉,别的照收;不当成「起草这张表
+ 时出错」。以前列名是数组时校验里抛 TypeError,整张表记出错,这次调用也没有记进用量。"""
+ tables = {"t4": _table(4)}
+
+ def handle(call: Call) -> Reply:
+ return Reply(obj={"tables": [{
+ "table": "t4", "label": "某表", "kind": ["fact"], "keys": [["c001"]],
+ "business_date": {"column": ["c002"], "rule": "按下单时间"},
+ "columns": [{"name": ["c001"], "label": "数组列名"}, {"name": {"x": 1}, "label": "对象列名"},
+ {"name": "c002", "label": "第二列", "measure": ["flow"]},
+ {"name": "c003", "label": "第三列", "measure": {"x": "flow"}},
+ {"name": "c004", "label": "第四列"}]}]})
+
+ model, reports = Scripted(handle), Reports()
+ got = await D.run_model_draft(model, _source(tables), ["t4"], hooks=reports.hooks())
+ o = got["t4"]
+ assert len(reports.items) == len(model.calls) == 2, "c001 没有按列名列出来:补问一次"
+ assert o.status == "partial" and "c001" in o.error and "出错" not in o.error
+ assert set(o.notes) == {"label", "columns"} and set(o.notes["columns"]) == {"c002", "c003", "c004"}
+ assert all(set(items) == {"label"} for items in o.notes["columns"].values())
+
+
+async def test_a_call_whose_reply_cannot_be_read_still_counts(monkeypatch):
+ """读回复时出了意料之外的错:这张表记出错,这次调用照样记进用量(算一次出错),调用次数和用量对得上。"""
+ tables = {"t4": _table(4)}
+
+ def broken(entry, meta, *, system):
+ raise RuntimeError("校验时出了意外")
+
+ monkeypatch.setattr(catalog, "_from_model", broken)
+ model, reports = Scripted(answering(tables, usage=(100, 50))), Reports()
+ got = await D.run_model_draft(model, _source(tables), ["t4"], hooks=reports.hooks())
+ assert len(model.calls) == 1 and reports.failures == ["error"]
+ assert [(r.input_tokens, r.output_tokens) for r in reports.items] == [(100, 50)]
+ assert got["t4"].status == "failed" and got["t4"].error == "起草这张表时出错:校验时出了意外"
+
+
+async def test_columns_the_catalog_already_names_are_not_counted_blank():
+ """模型这次留空、目录里已经有中文名或含义的列(不论来源:以前模型起草的、库注释、人工;驳回的不算)不算留空:照界面
+ 的建议补了起草要求再起草,以前起草好的留着(重新起草不丢内容),这一列不该再说成「模型拿不准、留空了」。"""
+ tables = {"t6": _table(6)}
+ item = catalog.make_item
+ existing = {"t6": {"columns": {
+ "c002": {"label": item("以前起草的名字", "llm")},
+ "c003": {"meaning": item("库注释说清的含义", "comment")},
+ "c004": {"label": item("驳回的名字", "llm", "rejected")},
+ "c005": {"unit": item("元", "llm")},
+ }}}
+
+ def handle(call: Call) -> Reply:
+ cols = [{"name": "c001", "label": "第一列"}] + [{"name": f"c00{i}"} for i in range(2, 7)]
+ return Reply(obj={"tables": [{"table": "t6", "label": "某表", "columns": cols}]})
+
+ got = await D.run_model_draft(Scripted(handle), _source(tables), ["t6"], existing=existing)
+ assert got["t6"].status == "ok" and got["t6"].blank == ["c004", "c005", "c006"]
+
+
+async def test_a_wide_table_that_wrote_nothing_names_every_part():
+ """一项都没写出来的宽表同样逐段写上列的范围(以前只写原因和「这一段」「这几段」,看不出是哪几段)。"""
+ def handle(call: Call) -> Reply:
+ raise RuntimeError("网关拒绝")
+
+ got = await D.run_model_draft(Scripted(handle), _source({"wide95": _table(95)}), ["wide95"])
+ o = got["wide95"]
+ assert o.status == "failed" and o.error == WIDE95_PARTS + "都没写出来:调用模型出错:网关拒绝"
+
+ narrow = await D.run_model_draft(Scripted(handle), _source({"t8": _table(8)}), ["t8"])
+ assert narrow["t8"].error == "调用模型出错:网关拒绝", "只有一段的表:这一段就是整张表,照旧只写原因"
+
+
async def test_a_halted_part_that_could_be_split_is_not_asked_again_in_text_mode(monkeypatch):
"""止损以后本来能拆小的段只给空壳:已经走纯文本的不再原样再问(约定只对拆不开的段再问一次);结构化输出的照旧换
纯文本问一次,看看是不是结构化输出的问题(见 test_the_column_parts_of_a_wide_table_stop_splitting_too)。"""
diff --git a/backend/tests/test_catalog_draft_jobs.py b/backend/tests/test_catalog_draft_jobs.py
index b07622a1..bc366876 100644
--- a/backend/tests/test_catalog_draft_jobs.py
+++ b/backend/tests/test_catalog_draft_jobs.py
@@ -287,9 +287,9 @@ async def test_a_redraft_keeps_what_the_model_left_blank_this_time(client, monke
给了合格新值的才替换。以前整库重跑一次删掉几百项,表仍记「已完成」。"""
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, 7)]}
+ "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"] == 0
+ assert first["tasks"][0]["status"] == "ok" and first["blank_columns"] == 1, "c06 第一次就留空了"
before = (await client.get(f"{_base(sid)}/tickets")).json()["notes"]
# 这一次:表的中文名换了;c03 的中文名像公式(校验丢掉)、含义换了;c04 只给了中文名;其余列列出了但都留空
@@ -303,10 +303,11 @@ async def test_a_redraft_keeps_what_the_model_left_blank_this_time(client, monke
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", "c06"):
+ for col in ("c01", "c02", "c05"):
assert after["columns"][col] == before["columns"][col], col
- # 模型这次留空的列照实记下(表的状态不因此变)
- assert task["blank_columns"] == 4 and task["blank_sample"] == ["c01", "c02", "c05", "c06"]
+ # 留空的列只记目录里还是没有中文名、含义的(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):
@@ -351,8 +352,8 @@ async def test_the_notice_is_kept_on_the_job(client, monkeypatch):
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 次)。可以在「设置 → 偏好设置」的「起草模型」换一个模型、"
- "减少这次起草的表,或者停止这次起草")
+ 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"])
@@ -375,6 +376,29 @@ async def test_blank_columns_reach_the_task_the_job_and_the_list(client, monkeyp
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()
From f74744b29e80544214579d6723c556938dd19f6f Mon Sep 17 00:00:00 2001
From: ThearchyHelios
Date: Tue, 13 Oct 2026 21:26:07 +0800
Subject: [PATCH 29/29] =?UTF-8?q?README=EF=BC=9A=E6=95=B0=E6=8D=AE?=
=?UTF-8?q?=E7=9B=AE=E5=BD=95=E7=9A=84=E6=A8=A1=E5=9E=8B=E8=B5=B7=E8=8D=89?=
=?UTF-8?q?=E7=85=A7=E5=A4=8D=E6=A0=B8=E4=BF=AE=E6=AD=A3=E8=A1=A5=E4=B8=8A?=
=?UTF-8?q?=E2=80=94=E2=80=94=E8=B6=85=E6=97=B6=E7=9A=84=E4=B8=A4=E5=8D=8A?=
=?UTF-8?q?=E5=90=8C=E6=97=B6=E9=97=AE=E3=80=81=E6=8B=86=E5=88=B0=E6=AF=8F?=
=?UTF-8?q?=E6=AE=B5=2010=20=E5=88=97=E4=BB=8D=E6=8E=A5=E8=BF=9E=E8=B6=85?=
=?UTF-8?q?=E6=97=B6=E5=B0=B1=E4=B8=8D=E5=86=8D=E6=8B=86=EF=BC=8C=E6=AF=8F?=
=?UTF-8?q?=E6=AE=B5=E8=BF=9E=E5=90=8C=E5=AE=83=E5=88=86=E5=BC=80=E3=80=81?=
=?UTF-8?q?=E6=8B=86=E5=B0=8F=E7=9A=84=E5=90=84=E5=9D=97=E6=9C=80=E5=A4=9A?=
=?UTF-8?q?=208=20=E6=AC=A1=EF=BC=8C=E6=B2=A1=E5=86=99=E5=87=BA=E6=9D=A5?=
=?UTF-8?q?=E7=9A=84=E5=8E=9F=E5=9B=A0=E5=86=99=E6=B8=85=E7=AC=AC=E5=87=A0?=
=?UTF-8?q?=E6=AE=B5=E3=80=81=E5=93=AA=E5=87=A0=E5=88=97=EF=BC=8C=E7=95=99?=
=?UTF-8?q?=E7=A9=BA=E7=9A=84=E5=88=97=E5=8F=AA=E8=AE=B0=E7=9B=AE=E5=BD=95?=
=?UTF-8?q?=E9=87=8C=E8=BF=98=E6=B2=A1=E6=9C=89=E4=B8=AD=E6=96=87=E5=90=8D?=
=?UTF-8?q?=E5=92=8C=E5=90=AB=E4=B9=89=E7=9A=84=EF=BC=8C=E5=BA=93=E6=B3=A8?=
=?UTF-8?q?=E9=87=8A=E5=B0=B1=E6=98=AF=E5=90=8D=E5=AD=97=E6=9C=AC=E8=BA=AB?=
=?UTF-8?q?=E6=88=96=E6=A0=87=E8=AF=86=E7=AC=A6=E5=86=99=E6=B3=95=E7=9A=84?=
=?UTF-8?q?=E6=95=B4=E6=9D=A1=E4=B8=8D=E8=AE=B0=E8=BF=9B=E7=9B=AE=E5=BD=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index d44f52e3..431e6e76 100644
--- a/README.md
+++ b/README.md
@@ -277,7 +277,7 @@ flowchart LR
| 环节 | 做法 |
|---|---|
| 起草 | 探查时读外键和唯一约束;按 `xxx_id`、`xxxId` 推断关联,表名带业务前缀的也认;可选让模型起草中文名、粒度、表类型、业务主键、业务日期和每列的含义、单位、度量类型。导入表格的源已有系统按核对结果生成的说明,目录不重复、不改写它 |
-| 模型起草 | 作为后台任务逐表进行,每张表记下状态和原因,写完一张就并入目录;关掉对话框不停,可以停止、只重试失败的表,清单上标出上次起草失败或只完成一部分的表。服务重启时没跑完的任务标成中断,不会自己续跑,点重试接上。保留模型的思考,输出额度 32768(服务方不收时退回 8192);每次调用最多 40 列,宽表先起草表级的项、再把列均分成几段起草,按表的先后排队,不插到窄表前面;额度用完或超时就拆小再问,累计两次后新开始的段减半(最低 10 列),每段最多调用 8 次,缺的表和列只补问缺的,被截断的回复先留下已经写完整的部分;最近 6 次调用有 4 次没写出来时提醒换模型。重新起草时模型这次留空或没通过校验的项不删以前起草的;模型拿不准、留空的列逐表记下,可以补充起草要求后重起草。库注释就是表名、列名本身或者是标识符写法的,不当中文名。数据源上存的「起草要求」和这次的补充说明一起交给模型,帮它理解业务、统一用词,不能推翻「只记数据事实」的规矩,交回的内容照样逐项校验。用哪个模型在「设置 → 偏好设置」的「起草模型」里选,空着跟随助手的模型;按配方导入表格的 AI 起草也用它 |
+| 模型起草 | 作为后台任务逐表进行,每张表记下状态和原因,写完一张就并入目录;关掉对话框不停,可以停止、只重试失败的表,清单上标出上次起草失败或只完成一部分的表。服务重启时没跑完的任务标成中断,不会自己续跑,点重试接上。保留模型的思考,输出额度 32768(服务方不收时退回 8192);每次调用最多 40 列,宽表先起草表级的项、再把列均分成几段起草,按表的先后排队,不插到窄表前面;额度用完或超时就拆小再问(超时的两半同时问,拆到每段 10 列仍接连超时就不再拆),累计两次后新开始的段减半(最低 10 列),每段连同它分开、拆小的各块最多调用 8 次,缺的表和列只补问缺的,被截断的回复先留下已经写完整的部分;最近 6 次调用有 4 次没写出来时提醒换模型。没写出来的原因写清是第几段、哪几列。重新起草时模型这次留空或没通过校验的项不删以前起草的;模型拿不准、留空的列(目录里还没有中文名和含义的)逐表记下,可以补充起草要求后重起草。库注释就是表名、列名本身或者是标识符写法的,不记进目录(不当中文名,也不当说明、含义)。数据源上存的「起草要求」和这次的补充说明一起交给模型,帮它理解业务、统一用词,不能推翻「只记数据事实」的规矩,交回的内容照样逐项校验。用哪个模型在「设置 → 偏好设置」的「起草模型」里选,空着跟随助手的模型;按配方导入表格的 AI 起草也用它 |
| 数据剖析 | 默认关闭,按数据源开启。只读、经过守卫,单条查询和整次剖析都有上限,大表用统计信息估算行数,遮罩的列不取样。抽样核实推断出的关联,补上覆盖率和基数,站得住的升为已验证;状态列给出码值候选,含义等人填写 |
| 对话里纠正 | 用户说「status=9 表示作废」,助手给出一条目录修改提案,列出改前、改后和理由,人确认后才保存;公式不收,留给口径卡 |
| 版本与提醒 | 目录随表结构快照冻结进证据,每次运行用的是哪一版查得到。发布时记下模板引用的表的目录版本,正式运行时目录有变化就提醒;agent 运行时才写 SQL 的,按「可能涉及」单独提醒。只提醒,不拦运行 |