mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 19:12:00 +00:00
1640 lines
83 KiB
Python
1640 lines
83 KiB
Python
"""数据目录模型起草的引擎(data/catalog_draft.py):分段、按失败原因处理、捡回被截断的部分、起草要求、用量。
|
||
|
||
用假模型复刻实测查明的几种失败(不调用真实模型):
|
||
- 思考加输出用完额度:finish_reason=length 且正文为空(只有思考)、LengthFinishReasonError → 拆成两半再问;
|
||
- 只给空壳 → 拆开再问;拆不开的换纯文本问一次,写出来了这次起草后面都走纯文本;
|
||
- 被截断的 JSON:已经完整的表、列对象捡回来,只补剩下的;
|
||
- 只回一部分表:只补缺的,补一次;
|
||
- 服务方嫌额度太大 → 退回 8192,后面都用它;结构化输出不支持 → 后面改走纯文本;
|
||
- 网络错误、5xx → 退避重试,最多 2 次,报错写明重试过几次;超时当作这一段太大,拆小再问,不原样重试。
|
||
|
||
验收后修正的几条(用假模型复现):宽表的列分段均分、没有很小的尾段;拆不开的空壳纯文本也再问一次;额度用完或超时
|
||
累计 2 次后新开始的段列数减半;一段最多调用 8 次;最近 6 次调用 4 次没写出来时提醒、少于 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()}
|
||
|
||
|
||
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]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 思考用完额度、空壳:拆开再问
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
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(monkeypatch):
|
||
monkeypatch.setattr(catalog, "MODEL_PART_MAX_CALLS", 99)
|
||
monkeypatch.setattr(catalog, "MODEL_HALT_ON", 99) # 不让按比例止损先停下来
|
||
tables = {"wide40": _table(40)}
|
||
model = Scripted(lambda call: EXHAUSTED)
|
||
got = await D.run_model_draft(model, _source(tables), ["wide40"])
|
||
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_one_part_makes_at_most_eight_calls(monkeypatch):
|
||
"""一段(连同它拆出来的、补问的、重试的)最多调用 8 次:40 列一直用完额度,拆到第 8 次就停,剩下没问的记没写出来、
|
||
原因写清。以前一段 40 列拆到底要 15 次。"""
|
||
monkeypatch.setattr(catalog, "MODEL_HALT_ON", 99)
|
||
assert catalog.MODEL_PART_MAX_CALLS == 8
|
||
model = Scripted(lambda call: EXHAUSTED)
|
||
got = await D.run_model_draft(model, _source({"t40": _table(40)}), ["t40"])
|
||
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():
|
||
"""有的网关的结构化输出只给空壳:拆不开时换纯文本问一次,写出来了,这次起草后面直接走纯文本。
|
||
|
||
用宽表看先后:表级那段(拆不开)写完之后才问列分段。"""
|
||
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 的格式"
|
||
|
||
|
||
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
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 截断、缺表、缺列
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
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) == 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_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)
|
||
|
||
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 == "partial"
|
||
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, 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():
|
||
"""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 列(c021 到 c040)没写出来:模型的回复里漏了这些列,补问一次仍没有给出。"
|
||
"可以只重试这张表")
|
||
|
||
|
||
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)}
|
||
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"]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 额度被拒、结构化输出不支持、网络错误、超时
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
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"]
|
||
|
||
|
||
@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, **_):
|
||
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_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:
|
||
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) == 1, "拆不开、也不原样重试"
|
||
assert got["t4"].status == "failed" and got["t4"].error == "模型 0.05 秒内没有写完这一段"
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 分段:宽表拆成表级一段 + 列分段,窄表拼段
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
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) == [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)
|
||
|
||
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"]) == 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)
|
||
monkeypatch.setattr(catalog, "MODEL_HALT_ON", 99)
|
||
tables = {"wide95": _table(95)}
|
||
ok = answering(tables)
|
||
|
||
def handle(call: Call) -> Reply:
|
||
part, cols = call.asked["wide95"]
|
||
if part == "columns" and cols[0] >= "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()}
|
||
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_the_same_reason_is_said_once_even_when_some_parts_were_split(monkeypatch):
|
||
"""宽表的表级那段拆不开、列分段拆过:原因只说一次,「已拆小重试」只跟在真拆过的那几段后面说。"""
|
||
monkeypatch.setattr(catalog, "MODEL_HALT_ON", 99)
|
||
monkeypatch.setattr(catalog, "MODEL_PART_MAX_CALLS", 99)
|
||
model = Scripted(_hollow)
|
||
got = await D.run_model_draft(model, _source({"wide45": _table(45)}), ["wide45"])
|
||
o = got["wide45"]
|
||
assert o.status == "failed"
|
||
assert o.error == ("这张表 45 列,分 3 段起草,第 1 段(表级的项)、第 2 段(c001 到 c023,共 23 列)、"
|
||
"第 3 段(c024 到 c045,共 22 列)都没写出来:模型只给了空壳,没有写出可用的内容;"
|
||
"其中第 2、3 段已拆小重试仍没写出")
|
||
|
||
|
||
def test_reasons_are_joined_by_their_base():
|
||
"""(原因, 第几段):同样的原因只说一次;同一个原因落在几段上写「这几段」,同一段拆出来的几块仍是「这一段」。
|
||
「已拆小重试仍没写出」只跟在真拆过的那几段后面说:说到的段都拆过时不点名,只有一部分拆过时写「其中第几段」。"""
|
||
W = D._Why
|
||
ex = D.REASON_EXHAUSTED.format(budget=32768, seg=D.SEG)
|
||
assert D._join([(W(D.REASON_HOLLOW), 1), (W(D.REASON_HOLLOW, split=True), 2)]) == \
|
||
D.REASON_HOLLOW + ";其中第 2 段已拆小重试仍没写出"
|
||
assert D._join([(W(ex, split=True), 2), (W(D.REASON_HOLLOW), 1)]) == \
|
||
f"{_exhausted()};{D.REASON_HOLLOW};其中第 2 段已拆小重试仍没写出"
|
||
assert D._join([(W(ex, split=True), 2), (W(ex, split=True), 3)]) == _exhausted("这几段") + D.REASON_SPLIT
|
||
assert D._join([(W(ex, split=True), 3), (W(ex), 4), (W(ex, split=True), 2)]) == \
|
||
_exhausted("这几段") + ";其中第 2、3 段已拆小重试仍没写出"
|
||
assert D._join([(W(ex), 2), (W(ex), 3)]) == _exhausted("这几段")
|
||
assert D._join([(W(ex), 2), (W(ex, split=True), 2)]) == _exhausted() + D.REASON_SPLIT, "同一段里有一块拆过就算拆过"
|
||
assert D._join([(W("甲"), 1), (W("乙"), 1), (W("丙", split=True), 1)]) == "甲;乙", "没说出来的原因不补后缀"
|
||
halted = D.REASON_HALTED.format(window=catalog.MODEL_HALT_WINDOW, on=catalog.MODEL_HALT_ON)
|
||
assert halted == ";最近 10 次调用有 7 次以上没写出来,已停止拆小重试,建议更换起草模型"
|
||
assert D._join([(W(D.REASON_HOLLOW, halted=halted), 1), (W(D.REASON_HOLLOW), 2)]) == D.REASON_HOLLOW + halted
|
||
assert D._join([(W(D.REASON_HOLLOW, halted=halted), 1), (W(D.REASON_HOLLOW, halted=halted), 3)]) == \
|
||
D.REASON_HOLLOW + halted, "止损只说一次"
|
||
assert D._join([(W("调用模型出错:{坏的}"), 1)]) == "调用模型出错:{坏的}", "报错原文里的花括号原样留着"
|
||
|
||
|
||
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
|
||
|
||
|
||
HALTED = ";最近 10 次调用有 7 次以上没写出来,已停止拆小重试,建议更换起草模型"
|
||
|
||
|
||
async def test_when_most_recent_calls_fail_the_rest_are_asked_once_until_the_ratio_drops(monkeypatch):
|
||
"""按比例止损:这次起草最近 10 次调用有 7 次以上没写出来,之后的段不再拆小、每块只问一次,报错说明已停止拆小重试;
|
||
比例降下去(最近 10 次里少于 7 次)就恢复拆小。偶尔一次写出来不会清零:t4 到 t6 写出来了,最近 10 次里仍有 7 次没写
|
||
出来,照旧不拆;t7 写出来以后才降下去,t8 照常拆小。一次只起草一段(并发 1),先后是确定的。"""
|
||
monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
|
||
monkeypatch.setattr(catalog, "MODEL_BATCH_TABLES", 1)
|
||
assert (catalog.MODEL_HALT_WINDOW, catalog.MODEL_HALT_ON) == (10, 7)
|
||
tables = {f"t{i}": _table(10) for i in range(1, 9)}
|
||
ok = answering(tables)
|
||
bad = {"t1", "t2", "t3", "t8"}
|
||
|
||
model, reports = Scripted(lambda call: EXHAUSTED if set(call.asked) & bad else ok(call)), Reports()
|
||
got = await D.run_model_draft(model, _source(tables), list(tables), hooks=reports.hooks())
|
||
assert _by_table(model) == {"t1": 3, "t2": 3, "t3": 1, "t4": 1, "t5": 1, "t6": 1, "t7": 1, "t8": 3}
|
||
assert got["t1"].error == _exhausted() + D.REASON_SPLIT
|
||
assert got["t3"].error == _exhausted() + HALTED
|
||
assert {got[f"t{i}"].status for i in range(4, 8)} == {"ok"}
|
||
assert got["t8"].error == _exhausted() + D.REASON_SPLIT, "比例降下去以后照常拆小"
|
||
assert len(reports.items) == len(model.calls)
|
||
|
||
|
||
async def test_while_halted_a_chunk_is_not_asked_again_for_missing_columns(monkeypatch):
|
||
"""止损期间每块只问一次:回复里漏了几列不补问,漏的列记没写出来、说明已停止拆小重试。"""
|
||
monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
|
||
monkeypatch.setattr(catalog, "MODEL_BATCH_TABLES", 1)
|
||
tables = {**{f"x{i}": _table(4) for i in range(1, 8)}, "t8": _table(8)}
|
||
ok = answering(tables)
|
||
|
||
def handle(call: Call) -> Reply:
|
||
if "t8" not in call.asked:
|
||
raise RuntimeError("网关拒绝了这次请求")
|
||
reply = ok(call)
|
||
reply.obj["tables"][0]["columns"] = reply.obj["tables"][0]["columns"][:6]
|
||
return reply
|
||
|
||
model = Scripted(handle)
|
||
got = await D.run_model_draft(model, _source(tables), list(tables))
|
||
assert _by_table(model)["t8"] == 1, "漏了 c007、c008 也不补问"
|
||
o = got["t8"]
|
||
assert o.status == "partial"
|
||
assert o.error == ("这张表 8 列,有 2 列(c007、c008)没写出来:模型的回复里漏了这些列" + HALTED
|
||
+ "。可以只重试这张表")
|
||
|
||
|
||
#: 200 列的宽表分 6 段起草(表级一段、列分段 5 段),逐段写上列的范围
|
||
WIDE200_PARTS = ("这张表 200 列,分 6 段起草,第 1 段(表级的项)、第 2 段(c001 到 c040,共 40 列)、"
|
||
"第 3 段(c041 到 c080,共 40 列)、第 4 段(c081 到 c120,共 40 列)、第 5 段(c121 到 c160,共 40 列)、"
|
||
"第 6 段(c161 到 c200,共 40 列)")
|
||
|
||
|
||
async def test_a_wide_table_skips_its_remaining_parts_after_three_dead_parts_in_a_row(monkeypatch):
|
||
"""同一张表连续 3 个原始段都一项没写出来:这张表剩下的段跳过不问,原因写清跳过了几段、可以换一个起草模型后只重试
|
||
这张表。200 列分 6 段(表级一段、列分段 5 段)。"""
|
||
monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
|
||
assert catalog.MODEL_TABLE_GIVE_UP_AFTER == 3
|
||
tables = {"wide200": _table(200)}
|
||
|
||
def refuse(call: Call) -> Reply:
|
||
raise RuntimeError("网关拒绝")
|
||
|
||
model = Scripted(refuse)
|
||
got = await D.run_model_draft(model, _source(tables), ["wide200"])
|
||
o = got["wide200"]
|
||
assert len(model.calls) == 3, "第 4 到 6 段跳过不问"
|
||
assert (o.status, o.parts_total, o.parts_done) == ("failed", 6, 6)
|
||
assert o.error == (WIDE200_PARTS + "都没写出来:调用模型出错:网关拒绝;"
|
||
"这张表接连 3 段都没写出来,剩下的 3 段已跳过,可以换一个起草模型后只重试这张表")
|
||
|
||
# 表级那段写出来了、后面接连 3 段没写出来:只写出一部分,剩下的 2 段跳过;不再另说「可以只重试这张表」
|
||
ok = answering(tables)
|
||
model = Scripted(lambda call: ok(call) if call.n == 0 else refuse(call))
|
||
got = await D.run_model_draft(model, _source(tables), ["wide200"])
|
||
o = got["wide200"]
|
||
assert len(model.calls) == 4 and o.status == "partial"
|
||
assert o.error == ("这张表 200 列,分 6 段起草,第 2 段(c001 到 c040,共 40 列)、第 3 段(c041 到 c080,共 40 列)、"
|
||
"第 4 段(c081 到 c120,共 40 列)、第 5 段(c121 到 c160,共 40 列)、第 6 段(c161 到 c200,共 40 列)"
|
||
"没写出来:调用模型出错:网关拒绝;这张表接连 3 段都没写出来,剩下的 2 段已跳过,"
|
||
"可以换一个起草模型后只重试这张表")
|
||
|
||
|
||
async def test_a_part_that_writes_resets_the_dead_parts_of_its_table(monkeypatch):
|
||
"""中间有一段写出来了,就不算「接连」:200 列的第 2、3、5、6 段没写出来、第 4 段写出来了,每段都照常问。"""
|
||
monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
|
||
tables = {"wide200": _table(200)}
|
||
ok = answering(tables)
|
||
|
||
def handle(call: Call) -> Reply:
|
||
part, cols = call.asked["wide200"]
|
||
if part == "table" or "c081" in (cols or []):
|
||
return ok(call)
|
||
raise RuntimeError("网关拒绝")
|
||
|
||
model = Scripted(handle)
|
||
got = await D.run_model_draft(model, _source(tables), ["wide200"])
|
||
assert len(model.calls) == 6 and got["wide200"].status == "partial"
|
||
assert "已跳过" not in got["wide200"].error
|
||
|
||
|
||
async def test_the_column_parts_of_a_wide_table_stop_splitting_too(monkeypatch):
|
||
"""按比例止损在宽表里同样起作用,接连没写出来的宽表剩下的段跳过:95 列一直只给空壳(已经走纯文本),第 2 段拆小到
|
||
止损;之后的第 3 段只问一次;接连 3 段没写出来,第 4 段跳过。"""
|
||
monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
|
||
model = Scripted(_text_only(_hollow))
|
||
got = await D.run_model_draft(model, _source({"wide95": _table(95)}), ["wide95"])
|
||
o = got["wide95"]
|
||
asked = [c for c in model.calls if c.mode == "text"]
|
||
assert len(asked) <= 2 + 8 + 1
|
||
assert not any("c065" in (c.asked["wide95"][1] or []) for c in asked), "第 4 段没问"
|
||
assert o.status == "failed" and o.error.startswith(WIDE95_PARTS + "都没写出来:")
|
||
assert HALTED in o.error
|
||
assert o.error.endswith("这张表接连 3 段都没写出来,剩下的 1 段已跳过,可以换一个起草模型后只重试这张表")
|
||
|
||
|
||
async def test_a_wide_table_that_never_comes_back_is_bounded_by_the_first_wave():
|
||
"""默认并发(3 段同时起草):294 列(8 个列分段)一直只给空壳:表级那段 2 次,头一批同时起草的列分段拆小到止损或
|
||
封顶;接连 3 段没写出来以后,没开始的段跳过不问。以前所有列分段一下子都排上去、一层一层地拆,要 168 次。"""
|
||
model = Scripted(_hollow)
|
||
got = await D.run_model_draft(model, _source({"wide294": _table(294)}), ["wide294"])
|
||
o = got["wide294"]
|
||
assert o.status == "failed" and o.parts_total == 9 and o.parts_done == 9
|
||
assert len(model.calls) <= 2 + 4 * 8
|
||
assert "已跳过" in o.error
|
||
asked = {c for call in model.calls for c in call.asked["wide294"][1] or []}
|
||
assert "c294" not in asked, "最后一段没问"
|
||
|
||
|
||
async def test_a_big_draft_does_not_hold_the_model_ahead_of_a_later_one(monkeypatch):
|
||
"""全进程的调用名额先来先放:一次起草不把它的所有段一下子都排上去,后开始的那次起草(另一个数据源)能轮上。"""
|
||
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"]
|
||
|
||
|
||
#: 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()]
|
||
|
||
|
||
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"
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 复核后的修正:每个原始段一共 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_HALT_ON", 99) # 不让止损先停下来
|
||
tables = {"w40": _table(40), **{f"n{i}": _table(10) for i in range(1, 5)}, "x40": _table(40)}
|
||
|
||
def handle(call: Call) -> Reply:
|
||
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()
|
||
|
||
|
||
async def test_a_model_that_always_times_out_is_bounded_by_the_part_cap():
|
||
"""每次调用都超时:超时拆出来的两半同时问,最多拆到这一段的调用次数用完(8 次)或止损。以前 40→20→10→5 一层一层串着问,
|
||
8 次调用约 7 轮;现在两半同时问,至多 4 轮。"""
|
||
tables = {"t40": _table(40)}
|
||
slow = AlwaysSlow(tables)
|
||
model, reports = Scripted(slow), Reports()
|
||
got = await D.run_model_draft(model, _source(tables), ["t40"], hooks=reports.hooks())
|
||
assert len(model.calls) <= catalog.MODEL_PART_MAX_CALLS and max(slow.rounds) <= 4
|
||
assert [_width(c, tables) for c in model.calls[:3]] == [40, 20, 20], "两半同时问"
|
||
assert set(reports.failures) == {"timeout"} and len(reports.items) == len(model.calls)
|
||
o = got["t40"]
|
||
assert o.status == "failed" and "模型 420 秒内没有写完这一段" + D.REASON_SPLIT in o.error
|
||
assert o.chunks_done == o.chunks_total > 1, "拆出来的块都结束了"
|
||
|
||
|
||
async def test_three_parts_that_always_time_out_stop_together():
|
||
"""首轮同时起草的 3 段都一直超时:最近 10 次调用 7 次以上没写出来以后,拆出来还没问的块不再问,不再各自拆满 8 次。
|
||
以前(接连超时的止损)12 次调用;更早要 24 次。"""
|
||
tables = {f"t{i}": _table(40) for i in range(1, 4)}
|
||
slow = AlwaysSlow(tables)
|
||
model = Scripted(slow)
|
||
got = await D.run_model_draft(model, _source(tables), list(tables))
|
||
assert len(model.calls) <= 9 and max(slow.rounds) <= 4
|
||
assert {o.status for o in got.values()} == {"failed"}
|
||
assert any(HALTED in o.error for o in got.values())
|
||
|
||
|
||
async def test_timeouts_that_go_away_at_ten_columns_are_still_split_down_and_written():
|
||
"""超时和段大小有关(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_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 + "都没写出来:调用模型出错:网关拒绝;"
|
||
"这张表接连 3 段都没写出来,剩下的 1 段已跳过,可以换一个起草模型后只重试这张表")
|
||
|
||
narrow = await D.run_model_draft(Scripted(handle), _source({"t8": _table(8)}), ["t8"])
|
||
assert narrow["t8"].error == "调用模型出错:网关拒绝", "只有一段的表:这一段就是整张表,照旧只写原因"
|
||
|
||
|
||
async def test_a_halted_part_that_could_be_split_is_not_asked_again_in_text_mode(monkeypatch):
|
||
"""止损以后每块只问一次:已经走纯文本的空壳不再原样再问、也不拆小。t1:结构化不支持 1 次(只管通信的重来,不进
|
||
止损的窗口)、纯文本 1 次、拆成两半各问一次再原样再问一次(5 次没写出来);t2 的第一次是第 6 次,照常拆小,前一半
|
||
是第 7 次没写出来,之后止损:后一半不再问;t3 只问一次、不拆小。"""
|
||
monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
|
||
monkeypatch.setattr(catalog, "MODEL_BATCH_TABLES", 1)
|
||
tables = {f"t{i}": _table(10) for i in range(1, 5)}
|
||
model = Scripted(_text_only(_hollow))
|
||
got = await D.run_model_draft(model, _source(tables), list(tables))
|
||
assert _by_table(model) == {"t1": 6, "t2": 2, "t3": 1, "t4": 1}
|
||
assert got["t2"].error == D.REASON_HOLLOW + D.REASON_SPLIT + HALTED
|
||
assert got["t3"].error == catalog.MODEL_EMPTY_REASON + HALTED
|
||
|
||
|
||
async def test_while_halted_structured_output_still_gets_one_probe_in_text(monkeypatch):
|
||
"""止损期间结构化输出只给空壳:这次起草还没换纯文本问过的,照旧换纯文本问一次(看看是不是结构化输出的问题);纯文本
|
||
写出来了,后面都走纯文本。问过一次(纯文本也只给空壳)就不再问。"""
|
||
monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
|
||
monkeypatch.setattr(catalog, "MODEL_BATCH_TABLES", 1)
|
||
tables = {**{f"x{i}": _table(4) for i in range(1, 8)}, "t8": _table(8), "t9": _table(8)}
|
||
ok = answering(tables)
|
||
|
||
def handle(text_works: bool) -> Callable[[Call], Reply]:
|
||
def go(call: Call) -> Reply:
|
||
if next(iter(call.asked)).startswith("x"):
|
||
raise RuntimeError("网关拒绝了这次请求")
|
||
if call.mode == "structured":
|
||
return _hollow(call)
|
||
return ok(call) if text_works else _hollow(call)
|
||
return go
|
||
|
||
model = Scripted(handle(True))
|
||
got = await D.run_model_draft(model, _source(tables), list(tables))
|
||
assert [c.mode for c in model.calls if "x1" not in c.asked and not next(iter(c.asked)).startswith("x")] == \
|
||
["structured", "text", "text"]
|
||
assert got["t8"].status == got["t9"].status == "ok"
|
||
|
||
model = Scripted(handle(False))
|
||
got = await D.run_model_draft(model, _source(tables), list(tables))
|
||
assert _by_table(model)["t8"] == 2 and _by_table(model)["t9"] == 1, "纯文本问过一次,不再问"
|
||
assert got["t9"].error == catalog.MODEL_EMPTY_REASON + HALTED
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 起草要求、用量
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
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)
|
||
|
||
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():
|
||
from app.data import recipe_ai
|
||
|
||
assert D.BUDGET_WORDS == recipe_ai._BUDGET_WORDS
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 从正文里取 JSON
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.parametrize("text, tables, truncated", [
|
||
# 前面有一句话、带括号的说明、围栏
|
||
('按 {table} 的格式写好了:\n```json\n{"tables": [{"table": "a", "label": "甲"}]}\n```', ["a"], False),
|
||
('[说明] 草稿:{"tables": [{"table": "a"}]} 以上', ["a"], False),
|
||
# 外面多包了一层
|
||
('{"result": {"tables": [{"table": "a"}, {"table": "b"}]}}', ["a", "b"], False),
|
||
# 截断在字符串、数字、字面量、键名中间:只收写完了的
|
||
('{"tables": [{"table": "a", "label": "甲"}, {"table": "b", "label": "乙', ["a", "b"], True),
|
||
('{"tables": [{"table": "a", "columns": [{"name": "x", "label": "某"}, {"name": "y", "len": 12', ["a"], True),
|
||
('{"tables": [{"table": "a", "keys": ["x", "y"', ["a"], True),
|
||
('{"tables": [{"table": "a"}, {"tab', ["a"], True),
|
||
('{"tables": [{"table": "a", "flag": tr', ["a"], True),
|
||
])
|
||
def test_json_is_taken_from_the_text(text, tables, truncated):
|
||
reply = D._parse_text(text, cut=False)
|
||
assert reply is not None and [e["table"] for e in reply.entries] == tables
|
||
assert reply.truncated is truncated
|
||
|
||
|
||
def test_cut_values_are_dropped_and_complete_ones_kept():
|
||
text = '{"tables": [{"table": "a", "label": "甲", "keys": ["x", "y"], "columns": [{"name": "x", "label": "某"}, ' \
|
||
'{"name": "y", "label": "半'
|
||
entry = D._parse_text(text, cut=True).entries[0]
|
||
assert entry == {"table": "a", "label": "甲", "keys": ["x", "y"], "columns": [{"name": "x", "label": "某"}]}
|
||
cut_keys = D._parse_text('{"tables": [{"table": "a", "label": "甲", "keys": ["x", "y"', cut=True).entries[0]
|
||
assert cut_keys == {"table": "a", "label": "甲"}, "没写完的业务主键整项不要,不能只留前半"
|
||
|
||
|
||
@pytest.mark.parametrize("text", ["好的,我来写", "{坏的 json}", ""])
|
||
def test_not_json_is_not_a_reply(text):
|
||
assert D._parse_text(text, cut=False) is None
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 闭环第一步的起草遗留:逐段交出去落盘、块进度、「已拆小重试」只说拆过的段
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
class Written:
|
||
"""进度回调 part_written 收到的:(表名, 已写出内容的段数, 这时这张表拿到的列);chunks_changed 收到的块进度。"""
|
||
|
||
def __init__(self) -> None:
|
||
self.parts: list[tuple[str, int, set[str], bool]] = []
|
||
self.chunks: dict[str, list[tuple[int, int]]] = {}
|
||
|
||
async def part_written(self, table: str, written: D.PartWrite) -> None:
|
||
cols = set((written.notes.get("columns") or {}))
|
||
self.parts.append((table, written.parts_written, cols, "label" in written.notes))
|
||
|
||
async def chunks_changed(self, table: str, total: int, done: int) -> None:
|
||
self.chunks.setdefault(table, []).append((total, done))
|
||
|
||
def hooks(self) -> D.Hooks:
|
||
return D.Hooks(part_written=self.part_written, chunks_changed=self.chunks_changed)
|
||
|
||
|
||
async def test_each_part_of_a_wide_table_is_handed_over_as_soon_as_it_is_written(monkeypatch):
|
||
"""宽表每写出一段就交给调用方并入目录(最后一段由 table_done 收尾):交出去的是这张表到这时拿到的全部内容。"""
|
||
monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
|
||
tables = {"wide95": _table(95)}
|
||
seen = Written()
|
||
got = await D.run_model_draft(Scripted(answering(tables)), _source(tables), ["wide95"], hooks=seen.hooks())
|
||
o = got["wide95"]
|
||
assert o.status == "ok" and o.parts_written == 4
|
||
assert [(t, n, len(cols), label) for t, n, cols, label in seen.parts] == [
|
||
("wide95", 1, 0, True), ("wide95", 2, 32, True), ("wide95", 3, 64, True)]
|
||
|
||
def handle(call: Call) -> Reply:
|
||
part, cols = call.asked["wide95"]
|
||
if part == "columns" and "c033" in cols:
|
||
raise RuntimeError("网关拒绝")
|
||
return answering(tables)(call)
|
||
|
||
seen = Written()
|
||
got = await D.run_model_draft(Scripted(handle), _source(tables), ["wide95"], hooks=seen.hooks())
|
||
assert got["wide95"].status == "partial" and got["wide95"].parts_written == 3
|
||
assert [n for _, n, _, _ in seen.parts] == [1, 2], "没写出来的那段不交"
|
||
|
||
|
||
async def test_a_narrow_table_is_handed_over_only_once_at_the_end():
|
||
tables = {"t8": _table(8)}
|
||
seen = Written()
|
||
got = await D.run_model_draft(Scripted(answering(tables)), _source(tables), ["t8"], hooks=seen.hooks())
|
||
assert seen.parts == [] and got["t8"].parts_written == 1
|
||
failed = await D.run_model_draft(Scripted(lambda call: EXHAUSTED), _source({"t4": _table(4)}), ["t4"])
|
||
assert failed["t4"].parts_written == 0
|
||
|
||
|
||
async def test_chunks_count_what_the_table_is_actually_asked_in(monkeypatch):
|
||
"""块进度:一开始是这张表分的段数;一段被拆成两半多一块、被缩小的上限分开成几块就多几块;一块结束算一块。
|
||
多表拼成的段按表拆开时,每张表还是一块。"""
|
||
monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
|
||
monkeypatch.setattr(catalog, "MODEL_BATCH_TABLES", 1)
|
||
monkeypatch.setattr(catalog, "MODEL_SHRINK_AFTER", 1)
|
||
tables = {"a12": _table(12), "b40": _table(40)}
|
||
ok = answering(tables)
|
||
seen = Written()
|
||
got = await D.run_model_draft(Scripted(lambda call: EXHAUSTED if call.n == 0 else ok(call)), _source(tables),
|
||
["a12", "b40"], hooks=seen.hooks())
|
||
a, b = got["a12"], got["b40"]
|
||
assert a.status == b.status == "ok"
|
||
assert (a.chunks_total, a.chunks_done) == (2, 2), "额度用完拆成两半"
|
||
assert (b.chunks_total, b.chunks_done) == (2, 2), "上限缩到 20 列,40 列的段分开成两块"
|
||
assert seen.chunks["a12"][-1] == (2, 2) and seen.chunks["b40"][-1] == (2, 2)
|
||
assert all(done <= total for pairs in seen.chunks.values() for total, done in pairs)
|
||
|
||
wide = {"wide95": _table(95)}
|
||
got = await D.run_model_draft(Scripted(answering(wide)), _source(wide), ["wide95"])
|
||
assert (got["wide95"].chunks_total, got["wide95"].chunks_done) == (4, 4)
|
||
|
||
monkeypatch.setattr(catalog, "MODEL_BATCH_TABLES", 4)
|
||
packed = {"p6": _table(6), "q6": _table(6)}
|
||
okp = answering(packed)
|
||
model = Scripted(lambda call: _hollow(call) if call.n == 0 else okp(call))
|
||
got = await D.run_model_draft(model, _source(packed), ["p6", "q6"])
|
||
assert [set(c.asked) for c in model.calls] == [{"p6", "q6"}, {"p6"}, {"q6"}], "按表拆开"
|
||
assert {(o.chunks_total, o.chunks_done) for o in got.values()} == {(1, 1)}
|
||
|
||
|
||
async def test_only_the_parts_that_were_split_say_so(monkeypatch):
|
||
"""一张表几段没写出来、只有一部分真拆小重试过:「已拆小重试仍没写出」只跟在拆过的那几段后面说。95 列:第 3 段一直用完
|
||
额度(拆小重试过),第 4 段调用出错(没拆过)。"""
|
||
monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
|
||
monkeypatch.setattr(catalog, "MODEL_SHRINK_AFTER", 99)
|
||
monkeypatch.setattr(catalog, "MODEL_HALT_ON", 99)
|
||
tables = {"wide95": _table(95)}
|
||
ok = answering(tables)
|
||
|
||
def handle(call: Call) -> Reply:
|
||
part, cols = call.asked["wide95"]
|
||
if part == "columns" and cols[0] >= "c065":
|
||
raise RuntimeError("网关拒绝")
|
||
if part == "columns" and cols[0] >= "c033":
|
||
return EXHAUSTED
|
||
return ok(call)
|
||
|
||
got = await D.run_model_draft(Scripted(handle), _source(tables), ["wide95"])
|
||
o = got["wide95"]
|
||
assert o.status == "partial"
|
||
assert o.error == ("这张表 95 列,分 4 段起草,第 3 段(c033 到 c064,共 32 列)、第 4 段(c065 到 c095,共 31 列)没写出来:"
|
||
+ _exhausted() + ";调用模型出错:网关拒绝;其中第 3 段已拆小重试仍没写出。可以只重试这张表")
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 复核后的修正:止损的窗口只记每次调用的最终结局。通信层的重来(服务方嫌额度太大退回 8192、结构化输出不支持或回了
|
||
# 说明文字改走纯文本、429 / 5xx 退避重试)不花额度,也说明不了模型写不出来:照旧报给进度回调(用量、提醒照算),
|
||
# 不进止损的窗口。以前都记成没写出来,OpenAI 兼容网关(额度上限 8192、不支持 json_schema)上并发的头几次调用全是
|
||
# 重来,第一次额度用完就凑满 10 次里 7 次,那一段直接记没写出来、不再拆小。
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def _six_tables() -> dict:
|
||
"""6 张 40 列的表:每张自成一段,默认并发 3 段同时起草。"""
|
||
return {f"t{i}": _table(40, prefix=f"t{i}_") for i in range(6)}
|
||
|
||
|
||
def _halved(model: Scripted, table: str) -> bool:
|
||
"""这张表被拆成两半问过(表级的项加前 20 列那一半)。"""
|
||
return any(part == "table+columns" and len(cols or []) == 20
|
||
for call in model.calls for name, (part, cols) in call.asked.items() if name == table)
|
||
|
||
|
||
async def test_budget_and_structured_output_fallbacks_do_not_halt_a_concurrent_draft():
|
||
tables = _six_tables()
|
||
ok = answering(tables)
|
||
spent: set[str] = set()
|
||
|
||
async def refuse(call: Call) -> Reply:
|
||
await asyncio.sleep(0.01)
|
||
raise _Status(400, "max_tokens: 32768 > 8192, which is the maximum allowed number of output tokens")
|
||
|
||
async def small(call: Call) -> Reply:
|
||
await asyncio.sleep(0.01)
|
||
if call.mode == "structured":
|
||
raise _Status(400, "This response_format type is unavailable now")
|
||
if "t0" in call.asked and "t0" not in spent:
|
||
spent.add("t0")
|
||
return EXHAUSTED
|
||
return ok(call)
|
||
|
||
big, sm, reports = Scripted(refuse), Scripted(small), Reports()
|
||
got = await D.run_model_draft(catalog.DraftModel(big, sm, "m"), _source(tables), list(tables),
|
||
hooks=reports.hooks())
|
||
assert _status(got) == dict.fromkeys(tables, "ok"), {t: o.error for t, o in got.items()}
|
||
assert _halved(sm, "t0"), "额度用完的那一段照常拆小重试"
|
||
assert len(big.calls) == 3 and reports.failures.count("error") == 6, "退回小额度、改走纯文本的重来照旧报给进度回调"
|
||
assert reports.failures.count("exhausted") == 1
|
||
|
||
|
||
async def test_rate_limit_retries_do_not_halt_the_draft():
|
||
tables = _six_tables()
|
||
ok = answering(tables)
|
||
limited: dict[str, int] = {}
|
||
spent: set[str] = set()
|
||
|
||
async def handle(call: Call) -> Reply:
|
||
await asyncio.sleep(0.01)
|
||
name = next(iter(call.asked))
|
||
if name in ("t0", "t1", "t2") and limited.get(name, 0) < 2:
|
||
limited[name] = limited.get(name, 0) + 1
|
||
raise _Status(429, "Too Many Requests")
|
||
if name == "t0" and not spent:
|
||
spent.add(name)
|
||
return EXHAUSTED
|
||
return ok(call)
|
||
|
||
model, reports = Scripted(handle), Reports()
|
||
got = await D.run_model_draft(model, _source(tables), list(tables), hooks=reports.hooks())
|
||
assert _status(got) == dict.fromkeys(tables, "ok"), {t: o.error for t, o in got.items()}
|
||
assert _halved(model, "t0"), "额度用完的那一段照常拆小重试"
|
||
assert reports.failures.count("error") == 6, "429 之后重试成功的那几次照旧报给进度回调"
|
||
|
||
|
||
async def test_structured_output_that_comes_back_as_prose_does_not_count_toward_halting(monkeypatch):
|
||
"""结构化输出回了说明文字、这一块改走纯文本再问:也是通信方式的问题,那一次不进止损的窗口(止损门槛调低到 3 次,
|
||
并发的 3 块都回说明文字也不止损);纯文本写出来的、没写出来的照常算。"""
|
||
monkeypatch.setattr(catalog, "MODEL_HALT_ON", 3)
|
||
tables = _six_tables()
|
||
ok = answering(tables)
|
||
spent: set[str] = set()
|
||
|
||
async def handle(call: Call) -> Reply:
|
||
await asyncio.sleep(0.01)
|
||
if call.mode == "structured":
|
||
return Reply(text="好的。这张表记录的是一笔笔业务,各列的含义如下:第一列是编号……")
|
||
if "t0" in call.asked and "t0" not in spent:
|
||
spent.add("t0")
|
||
return EXHAUSTED
|
||
return ok(call)
|
||
|
||
model, reports = Scripted(handle), Reports()
|
||
got = await D.run_model_draft(model, _source(tables), list(tables), hooks=reports.hooks())
|
||
assert _status(got) == dict.fromkeys(tables, "ok"), {t: o.error for t, o in got.items()}
|
||
assert _halved(model, "t0")
|
||
assert reports.failures.count("error") == 3, "回了说明文字的那几次照旧报给进度回调"
|
||
|
||
|
||
async def test_a_call_that_still_fails_after_its_retries_counts_toward_halting(monkeypatch):
|
||
"""重试用完仍然出错的那一次是这次调用的最终结局,照常进止损的窗口:一直 503 的网关,止损照样起作用。"""
|
||
monkeypatch.setattr(catalog, "MODEL_CONCURRENCY", 1)
|
||
monkeypatch.setattr(catalog, "MODEL_BATCH_TABLES", 1)
|
||
tables = {f"t{i}": _table(10) for i in range(1, 9)}
|
||
ok = answering(tables)
|
||
|
||
def handle(call: Call) -> Reply:
|
||
if set(call.asked) & {"t1", "t2", "t3", "t4", "t5", "t6", "t7"}:
|
||
raise _Status(503, "Service Unavailable")
|
||
return EXHAUSTED if "t8" in call.asked else ok(call)
|
||
|
||
got = await D.run_model_draft(Scripted(handle), _source(tables), list(tables))
|
||
assert got["t7"].error == "调用模型出错:Service Unavailable(已重试 2 次)"
|
||
assert got["t8"].error == _exhausted() + HALTED, "最近 10 次的最终结局有 7 次以上没写出来:t8 不再拆小"
|