mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 01:21:59 +00:00
2b9dcc55e3
- db/models_eval.py:EvalSuite、EvalCase、EvalRun、EvalResult 一次定全(第 3 波的执行、评分直接用),另加
EvalCatalogSnapshot 登记目录快照(谁、何时、来源、影子起草的进度和用量)。EvalResult.job_id 是 eval_run_id 的
别名:后台任务运行时的收尾按 task_model.job_id 认逐项。预算缺省按评测-D9(同时 2 题、每题 900 秒、单次 2 美元、
每日 5 美元),这一波只定字段。
- app/ledger/derive.py:全站唯一的「从钉住版本派生只读图」的生成器(总方案 X3)。这一波实现口径探针:拷出口径卡读
的取数节点(id、配置、图级 defaults 原样)+ caliber_from 固定到那一版的口径卡;只认可复用的源头卡,取数节点只许
引用入口字段,别的照实拒收。对账(这次的取数 + 口径 + 对账卡)、召回(新旧两版带前缀、caliber_from.nodes 改名
映射)的扩展点写在模块说明里,assemble / caliber_block 直接可用。
- app/eval/cases.py、suites.py:题目和题集的形状与校验。数值期望不许人填(评测-D4,写了 value 一类的键一律拒收),
文字要点单列、不计入准确率;只做单轮(评测-D12,history 必须为空);变量只认定时任务那份 {{ schedule.* }};
题目指纹去掉确认人、出处运行、升版处置这些记录项。
- app/eval/gold.py:SQL 题、口径题、从运行认定三种期望。每次都以来源 eval、只读 + 遮罩的探索运行重算,期望值取封存的
查询快照(或口径卡的指标集);只读守卫拒写语句、用到遮罩列的拒收(认法同证据面板的派生遮罩)、取不到值 / 失败 /
超时 / 停在人工确认上各写各的原因。口径升版用和正式运行同一个 unresolved_caliber_upgrades 判、退役按
subgraph_pin_problems 判,待处置就跳过;recompute / dual / incomparable 三选一、没有缺省。作答前后各算一次
(bracket / settle)给第 3 波的执行调。
- app/eval/snapshots.py:目录快照存成 kind=eval_catalog_snapshot 的内容寻址工件(读法即 data/catalog_view.py 定的
那一种);影子起草是不占数据源队的后台任务,起草写进内存里的目录副本,不调 write_entry、不碰线上目录、不查业务库,
只起草题集用得到的表,开始前估算花费;停止、出错的不出快照。
- data/catalog_draft.py 只加入口 run_shadow_draft(同一个起草引擎,唯一不同是不去业务库数有效行过滤的排除比例);
data/catalog.py 把写库前的并入抽成纯函数 merge_draft、建起草模型抽成 build_draft_model(行为不变,影子起草和写库
用同一套并入规矩、同一个模型建法)。
1850 lines
100 KiB
Python
1850 lines
100 KiB
Python
"""数据目录的模型起草:分段、按失败原因处理、捡回被截断的部分、记用量。
|
||
|
||
对外的名字留在 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 向上取整、各段列数平均(相差不超过 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 层。同一张表按列拆时带表级项的前一半先写、后一半拿它做背景;
|
||
超时的两半同时问(一次超时就是一个时限,串着问 40 → 20 → 10 要好几个时限)。拆不开的空壳再问一次:结构化输出给的
|
||
换纯文本(有的网关的结构化输出只给空壳、纯文本却能写——写出来了,这次起草后面都走纯文本),已经走纯文本的原样再问;
|
||
仍是空壳才记没写出来。
|
||
- 结构化输出回了一段说明文字(不是 JSON、也没停在额度上):网关接受了结构化输出的参数却没照做(例如开了丢弃不认识
|
||
的参数),提示词里又没有格式说明,模型就写成了说明文字。这次起草后面改走纯文本,这一段换纯文本问一次。有思考
|
||
不等于额度用完:只有停在额度上、或者正文为空而有思考才算。
|
||
- 被截断的 JSON:先把已经完整的表、列对象捡回来,只补剩下的。
|
||
- 一段里缺了的表、列只补缺的,补一次。补问之后:模型列出了、字段留空的列是拿不准(补问时才列出来的也一样),不算
|
||
没写出来(补问的那次调用把请写的都列出来了,用量和提醒里也不算没写出来);整列没列出来的算没写出来(这张表是
|
||
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_HALT_WINDOW 次调用里有 MODEL_HALT_ON 次以上没写出来(额度用完、超时、空壳、出错),
|
||
之后的段不再拆小、每块只问一次(不拆小、不补问漏掉的列、截断后不再补剩下的、空壳不再换纯文本原样再问;拆出来还没问
|
||
的块也不再问),报错建议更换起草模型,直到比例降下去。以前按「连续几段拆到底」「接连超时」算,偶尔一次写出来就清零:
|
||
实测会思考的模型在宽表上单次 1 到 7 分钟、四分之一超时,90 分钟停不下来。几处只管通信的调整照旧:服务方嫌额度
|
||
太大退回小额度、结构化输出不支持或回了说明文字改走纯文本、网络错误退避重试;结构化输出只给空壳的,这次起草还没换
|
||
纯文本问过的照旧问一次(看看是不是结构化输出的问题)。止损的窗口只记每次调用的最终结局:上面这几种重来之前的那次
|
||
不花额度、也说明不了模型写不出来,照旧报给进度回调(用量、提醒照算),不进止损的窗口——以前都记成没写出来,额度上限
|
||
8192、不支持 json_schema 的 OpenAI 兼容网关上,并发的头几次调用全是这种重来,第一次额度用完就凑满 10 次里 7 次,
|
||
那一段直接记没写出来、不再拆小。重试用完仍出错的那一次是最终结局,照常算。
|
||
- 同一张表连续 MODEL_TABLE_GIVE_UP_AFTER 个原始段都一项没写出来:这张表剩下还没开始的段跳过不问(宽表一段一段问下去
|
||
只是烧额度),原因写清跳过了几段、可以换一个起草模型后只重试这张表。中间有一段写出来了就不算「接连」。
|
||
- 提醒:最近 MODEL_NOTICE_WINDOW 次调用里有 MODEL_NOTICE_ON 次以上没写出来(额度用完、超时、空壳、出错),给一句
|
||
提醒(次数和原因,建议换起草模型、缩小起草范围或停止),经进度回调交给任务;之后少于 MODEL_NOTICE_OFF 次时清掉。
|
||
以前看不出调用在反复失败:一个任务 55 分钟只结束 1 张表,界面上只有段进度和用量。
|
||
|
||
**原因。** 没写出来的原因只写进这张表的 model_error:几段因为同一个原因没写出来时说「这几段」,分段起草的表(只写
|
||
出一部分的、一项都没写出来的都一样)每段写上列的范围(「第 5 段(列名 到 列名,共 33 列)」,列名照表结构原样),
|
||
真拆小重试过的才补「已拆小重试仍没写出」,而且只跟在拆过的那几段后面说(说到的段只有一部分拆过时写「其中第 3 段」)。
|
||
只有一段的表,这一段就是整张表:只写出一部分的写清是哪几列,一项都没写出来的只写原因。
|
||
|
||
**留空的列。** 请模型写了、模型也列出了,中文名和含义却都没给(或者给的都没通过校验)的列是模型拿不准,不补问,
|
||
记在 ModelOutcome.blank 里(表的状态不因此变)。目录里已经有中文名或含义的列不算(不论来源,驳回的不算有:以前模型
|
||
起草的留着,照「补充起草要求再重新起草」做一遍,模型这次没再给的老列不该又说成拿不准),库注释已经说清的不算
|
||
(提示词里它们以此为准);写入时(catalog.write_draft)再按并入目录之后的结果筛一遍。
|
||
|
||
**并发。** 全进程最多 MODEL_CONCURRENCY 个调用同时进行(model_gate:按事件循环各一个信号量,所有起草任务和同步
|
||
起草共用,先来先放)。一次起草里同时只起草 MODEL_CONCURRENCY 段(一段拆小、补缺都做完再起草下一段),同时在等
|
||
名额的调用也最多这么多个:不把几百段一下子都排上去,几次起草(不同数据源的任务、同步起草)的调用交替排队,后开始
|
||
的不会等前一个排着的段全部调完。一次起草里按表的先后排队:宽表的表级那段写完,它的列分段排到已经排着的段后面
|
||
(先来先服务),不插到窄表前面。
|
||
|
||
**进度。** 调用方给 Hooks:开始调用、每次调用的结果(用量、没写出来的原因、当前的提醒和列数上限)、一段结束、宽表的
|
||
一段写出来了(交出这张表到这时拿到的全部内容,调用方马上并入目录:停止、服务重启时已经写出来的段不丢)、块进度变了、
|
||
一张表的模型部分全部结束。后台起草任务据此落库(data/catalog_jobs.py)。块:这张表实际分成几块来问——一开始是段数,
|
||
一段被缩小的上限分开成几块、被拆成两半,块数就多几块;一块问完(写没写出来都算)算结束一块。
|
||
|
||
**防错事实。** 表级的项里模型可以提议有效行过滤(valid_filter)和去重规则(dedup),各附一句依据(写进这一项的 note),
|
||
校验见 catalog._from_model(有效行过滤按方言解析,不过就丢掉;存的是整理过的写法:列换成表结构里的真名、按方言加引号,
|
||
剖析核对的也是这一条)。开了数据剖析的源(catalog_profile.profile_settings):目录里记下的码值交给模型做背景(遮罩的列
|
||
不给,「已确认的目录项」里遮罩列的码值同样不给),表级那段写完后经剖析的只读守卫数一数有效行过滤排除了多少行
|
||
(catalog_profile.check_valid_filter),排除 0% 或 100% 的不收,收下的 note 写上排除比例;没开剖析、连不上的照收,
|
||
note 写没有核对的原因。没收下的(没通过校验、排除 0% 或 100%)不算失败,在这张表的日志里记一句(ModelOutcome.log,
|
||
起草任务逐表的 log)。
|
||
|
||
交给模型的是表结构(列、类型、主键、外键、唯一约束、注释)和使用者补充的背景;开了数据剖析的源另给目录里记下的码值
|
||
(剖析取过的取值和含义,遮罩的列不给),别的数据值一概不发。写给模型的文字都在 catalog.py 名字带 _SYSTEM / _PROMPT
|
||
的常量里;这里的 REASON_* 是给人看的(拼进这张表的 model_error)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import collections
|
||
import copy
|
||
import json
|
||
import logging
|
||
import re
|
||
import weakref
|
||
from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
|
||
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__)
|
||
|
||
#: 退避等待用的 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)。{seg} 在拼成一句时换成「这一段」或「这几段」:同一个
|
||
#: 原因落在几段上(宽表的几个列分段)说「这几段」,同一段拆出来的几块仍是「这一段」
|
||
SEG = "{seg}"
|
||
SEG_ONE = "这一段"
|
||
SEG_MANY = "这几段"
|
||
REASON_EXHAUSTED = "模型的思考和输出用完了额度({budget} 个 token),{seg}没写出来"
|
||
REASON_SPLIT = ";已拆小重试仍没写出"
|
||
#: 说到的几段里只有一部分真拆小重试过:只说那几段
|
||
REASON_SPLIT_PARTS = ";其中第 {parts} 段已拆小重试仍没写出"
|
||
REASON_HOLLOW = "模型只给了空壳,没有写出可用的内容"
|
||
REASON_TRUNCATED = "模型的输出被截断,{seg}没写完"
|
||
REASON_FORMAT = "模型的回复不是约定的格式"
|
||
REASON_MISSING = "模型的回复里没有这张表"
|
||
REASON_UNANSWERED = "模型的回复里漏了这些列,补问一次仍没有给出"
|
||
#: 止损期间每块只问一次:漏了的列不补问
|
||
REASON_OMITTED = "模型的回复里漏了这些列"
|
||
REASON_TIMEOUT = "模型 {seconds} 秒内没有写完{seg}"
|
||
REASON_CALL = "调用模型出错:{line}"
|
||
REASON_RETRIED = "(已重试 {n} 次)"
|
||
REASON_CRASH = "起草这张表时出错:{line}"
|
||
#: 一段到了调用次数的上限(MODEL_PART_MAX_CALLS)还没写完
|
||
REASON_CAPPED = "每段最多调用 {n} 次模型,{seg}到了次数仍没写完,已停止重试"
|
||
#: 止损(最近几次调用多数没写出来,之后的段不再拆小、每块只问一次)时补在原因后面
|
||
REASON_HALTED = ";最近 {window} 次调用有 {on} 次以上没写出来,已停止拆小重试,建议更换起草模型"
|
||
#: 同一张表接连几段都没写出来,剩下的段跳过不问
|
||
REASON_ABANDONED = "这张表接连 {k} 段都没写出来,剩下的 {n} 段已跳过,可以换一个起草模型后只重试这张表"
|
||
#: 跳过不问的段在「没写出来」记录里的原因(不拼进原因,见 _Run._outcome)
|
||
_SKIPPED = "\0skipped"
|
||
|
||
# ---- 这张表起草时记下的几句话(不算失败),经 ModelOutcome.log 交给起草任务逐表的 log(界面看得到)
|
||
#: 模型提议的有效记录条件没有收下:why 是原因(没通过校验的说法,或者剖析核对的结果)
|
||
LOG_FILTER_DROPPED = "{why},模型提议的这一条没有收下:{condition}"
|
||
#: 剖析核对排除了 0% 或 100% 的行
|
||
LOG_FILTER_COUNTED = "{label}按剖析核对{what}(共 {rows} 行)"
|
||
#: 一张表最多记几句;日志里的条件最多写几个字
|
||
LOG_MAX = 10
|
||
_LOG_CONDITION_MAX = 200
|
||
|
||
# ---- 提醒(最近几次调用多数没写出来),经进度回调交给任务(CatalogDraftJob.notice)
|
||
NOTICE = ("最近 {calls} 次调用有 {failed} 次没写出来({kinds})。建议在{place}换一个模型、缩小起草范围,"
|
||
"或者停止这次起草")
|
||
#: 一次调用没写出来的原因种类 → 提醒里的说法(按这个顺序列)
|
||
FAILURE_LABEL = {"exhausted": "额度用完", "timeout": "超时", "hollow": "只给空壳", "error": "出错"}
|
||
|
||
|
||
# ==========================================================================
|
||
# 对外的形状
|
||
# ==========================================================================
|
||
|
||
|
||
@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 已经结束了几段,parts_written 其中写出了内容的段数。blank:模型拿
|
||
不准、留空了的列(按表结构的顺序;表的状态不因此变)。chunks_total、chunks_done:这张表实际分成的块数和已结束的
|
||
块数(段被缩小的上限分开、拆成两半后会变多)。log:起草时记下的几句话,不算失败(模型提议的有效记录条件没有收下的
|
||
原因),最多 LOG_MAX 句。"""
|
||
|
||
status: str
|
||
notes: dict[str, Any]
|
||
items: int
|
||
error: str | None
|
||
parts_total: int
|
||
parts_done: int
|
||
blank: list[str] = field(default_factory=list)
|
||
parts_written: int = 0
|
||
chunks_total: int = 0
|
||
chunks_done: int = 0
|
||
log: list[str] = field(default_factory=list)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PartWrite:
|
||
"""宽表写出了一段(进度回调 part_written 收到的):notes 是这张表到这时拿到的全部模型起草(已校验、已核对,深拷贝),
|
||
parts_written 是其中写出了内容的段数。调用方马上并入目录(这一次不删任何东西),整张表结束时另有 table_done 收尾。"""
|
||
|
||
notes: dict[str, Any]
|
||
parts_written: int
|
||
|
||
|
||
@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
|
||
class Hooks:
|
||
"""进度回调,都是 async,出错只记日志、不影响起草。
|
||
|
||
- call_started(表名列表):一次调用拿到并发名额、马上要发出去了;
|
||
- call_done(CallReport):每次调用一回(条数就是调用次数),带着用量、没写出来的原因、当前的提醒和列数上限;
|
||
- part_done(表名):这张表的一段结束了(写没写出来都算);
|
||
- part_written(表名, PartWrite):宽表写出了一段、后面还有段没结束(最后一段由 table_done 收尾;只有一段的表不调);
|
||
- chunks_changed(表名, 块数, 已结束的块数):块进度变了;
|
||
- table_done(表名, ModelOutcome):这张表的模型部分全部结束。"""
|
||
|
||
call_started: Callable[[list[str]], 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
|
||
part_written: Callable[[str, PartWrite], Awaitable[None]] | None = None
|
||
chunks_changed: Callable[[str, int, int], Awaitable[None]] | None = None
|
||
|
||
|
||
def parts_for(columns: int) -> int:
|
||
"""一张有这么多列的表分几段起草:不超过 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,
|
||
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()
|
||
|
||
|
||
#: 影子起草不核对有效行过滤的排除比例:那一步要经剖析的只读守卫查业务库(FILTER_UNCHECKED 的原因)
|
||
SHADOW_NOT_CHECKED = "影子起草只看表结构、不查业务库"
|
||
|
||
|
||
async def run_shadow_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]:
|
||
"""影子起草的入口(评测的目录快照,docs/design/next-round/01-效果度量与回归评测.md 做法 6):和 run_model_draft 同一个
|
||
起草引擎、同样的分段和失败处理,只有一处不同——开了数据剖析的源,表级那段写完后不经剖析去业务库数有效行过滤排除了
|
||
多少行(_vet),照收、note 写明没有核对(SHADOW_NOT_CHECKED)。影子起草要的是「只看表结构」:不查业务库、不占数据源
|
||
的队。写不写目录由调用方定(评测写进内存里的目录副本,不调 write_entry)。"""
|
||
run = _Run(model, source, tables, existing or {}, background or Background(), hooks or Hooks(), offline=True)
|
||
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
|
||
#: 结构化输出回了一段说明文字(error 是「不是约定的格式」):换纯文本还可能写出来
|
||
prose: bool = False
|
||
#: 这次调用超时了(当作这一段太大:拆小再问,不原样重试)
|
||
timeout: bool = False
|
||
#: 这一段的调用次数用完了,这次没有调用
|
||
capped: bool = False
|
||
#: 超时拆出来的块,轮到它时已经止损:这次没有调用(见 _Run._ask 的 optional)
|
||
skipped: bool = False
|
||
#: 这次调用的用量 (输入, 输出),还没报给进度回调;没有调用(capped)的是 None
|
||
usage: tuple[int, int] | None = None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _Why:
|
||
"""一部分没写出来的原因。base 给人看的原因(可以带 SEG,拼成一句时换成「这一段」「这几段」;跳过不问的段是 _SKIPPED,
|
||
不拼进原因);split 真拆小重试过;halted 本来还能拆小、补问或再问、因为止损才没问时止损的说明(REASON_HALTED:最近
|
||
几次调用多数没写出来),没有是空串。"""
|
||
|
||
base: str
|
||
split: bool = False
|
||
halted: str = ""
|
||
|
||
|
||
def _join(failures: Iterable[tuple[_Why, int]]) -> str:
|
||
"""几处没写出来的原因拼成一句。failures 是 (原因, 属于这张表的第几段)。
|
||
|
||
同样的原因只说一次(最多说两种);同一个原因落在几段上说「这几段」,同一段拆出来的几块仍是「这一段」;拆小重试过、
|
||
止损的说明各在最后补一次(只看说出来的那几种)。不然宽表的表级那段拆不开、列分段拆过时,同一句原因会说两遍。
|
||
|
||
「已拆小重试仍没写出」只跟在真拆过的那几段后面说:说到的段都拆过(只有一段的表就是这一段)时不点名,只有一部分拆过
|
||
时写「其中第 3、4 段已拆小重试仍没写出」。以前一律补在最后,读起来像每一段都拆过。"""
|
||
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, part) for w, part 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)
|
||
split = sorted({part for w, part in told if w.split})
|
||
if split:
|
||
everywhere = {part for _, part in told}
|
||
text += REASON_SPLIT if set(split) == everywhere else \
|
||
REASON_SPLIT_PARTS.format(parts="、".join(str(part) for part in split))
|
||
for note in dict.fromkeys(w.halted for w, _ in told if w.halted):
|
||
text += note
|
||
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 留着,好往里捡。"""
|
||
|
||
__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)
|
||
|
||
|
||
#: 正文里最多试几个 { 或 [ 作为 JSON 的开头(前面的说明文字里也可能有括号)
|
||
_MAX_STARTS = 16
|
||
|
||
|
||
def _starts(text: str) -> list[int]:
|
||
out: list[int] = []
|
||
for i, ch in enumerate(text):
|
||
if ch in "{[":
|
||
out.append(i)
|
||
if len(out) >= _MAX_STARTS:
|
||
break
|
||
return out
|
||
|
||
|
||
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:
|
||
"""正文里的表对象。前面可以有一句话、围栏,后面可以有别的字;JSON 没写完的(截断)只收已经写完的部分。
|
||
认不出约定的格式返回 None。"""
|
||
empty: _Reply | None = None
|
||
for start in _starts(text):
|
||
try:
|
||
root, _, complete = _value(text, start, 0)
|
||
except (_Bad, RecursionError):
|
||
continue
|
||
entries = _tables_of(root)
|
||
if entries is None:
|
||
continue
|
||
reply = _Reply(entries=entries) if complete else _Reply(entries=entries, truncated=True, exhausted=cut)
|
||
if entries:
|
||
return reply
|
||
empty = empty or reply # {} 或 {"tables": []}:后面还可能有真的那一份
|
||
return empty
|
||
|
||
|
||
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 (not texts and _thought(raw)):
|
||
return _Reply(exhausted=True) # 停在额度上,或者正文为空而有思考(同 _text_reply)
|
||
if not texts:
|
||
return _Reply() # 什么都没写:按空壳处理
|
||
# 写了字却不是约定的格式、也没停在额度上:网关接受了结构化输出的参数却没照做,模型写成了说明文字。有没有思考都
|
||
# 一样,不是额度的问题;换纯文本(另带格式说明)还能写出来
|
||
return _Reply(error=REASON_FORMAT, prose=True)
|
||
|
||
|
||
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 _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):
|
||
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, _Why] = field(default_factory=dict)
|
||
parts_done: int = 0
|
||
finished: bool = False
|
||
#: 写出了内容的段(第几段)
|
||
written_parts: set[int] = field(default_factory=set)
|
||
#: 接连几个原始段一项都没写出来(写出来就清零);到了 MODEL_TABLE_GIVE_UP_AFTER 段,剩下的段跳过不问
|
||
dead_parts: int = 0
|
||
abandoned: bool = False
|
||
#: 跳过不问的段(第几段)
|
||
skipped_parts: list[int] = field(default_factory=list)
|
||
#: 块进度:实际分成几块、结束了几块
|
||
chunks_total: int = 0
|
||
chunks_done: int = 0
|
||
#: 有效行过滤核对过了(表级那段写完核对一次)
|
||
vetted: bool = False
|
||
#: 起草时记下的几句话(不算失败):模型提议的有效记录条件没有收下的原因
|
||
log: list[str] = field(default_factory=list)
|
||
|
||
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 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 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]:
|
||
"""只留这一段请模型写的部分:没请写表级的项就不要表级的项,列只要请写的那几列。"""
|
||
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
|
||
|
||
|
||
@dataclass
|
||
class _Work:
|
||
"""排队起草的一段(顶层的段:窄表拼成的整表段、宽表的表级那段或一个列分段)。then:这一段写完才能起草的段(宽表
|
||
的表级那段写完,才轮到它的列分段)。"""
|
||
|
||
pieces: list[_Piece]
|
||
then: list[_Work] = field(default_factory=list)
|
||
|
||
|
||
@dataclass
|
||
class _Budget:
|
||
"""一段(新开始的一段,连同它拆出来的、补问的、重试的)还能调用几次模型。"""
|
||
|
||
left: int
|
||
|
||
|
||
@dataclass
|
||
class _Chunk:
|
||
"""一块:一次拿来问的几张表(多表拼成的段每张表各是一块)。补问、截断后补剩下的、换纯文本再问都还是这一块;拆成两半
|
||
(单张表按列拆,多一块;多表的段按表拆,各张表仍是一块)后,这一块里被拆的表交给新的块,split 记下它们。"""
|
||
|
||
tables: tuple[str, ...]
|
||
split: set[str] = field(default_factory=set)
|
||
|
||
|
||
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:
|
||
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, *, offline: bool = False) -> None:
|
||
self.model = model
|
||
#: 影子起草(run_shadow_draft):不查业务库
|
||
self.offline = offline
|
||
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)
|
||
#: 这次起草同时在等名额或正在进行的调用最多几个(全进程的名额另算,见 model_gate)
|
||
self.gate = asyncio.Semaphore(max(1, int(C.MODEL_CONCURRENCY)))
|
||
#: 新开始的段每段最多几列:额度用完或超时(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
|
||
#: 最近 MODEL_HALT_WINDOW 次调用没写出来没有(止损按比例看它)
|
||
self.window: collections.deque[bool] = collections.deque(maxlen=max(1, int(C.MODEL_HALT_WINDOW)))
|
||
self.was_halted = False
|
||
#: 这次起草里结构化输出只给空壳时换纯文本问过没有(止损期间只问这一次)
|
||
self.text_probed = False
|
||
#: 数据源:开了数据剖析的源把码值交给模型、核对有效行过滤的排除比例(遮罩的列都不碰)
|
||
from app.data import catalog_profile
|
||
from app.data.engine import masked_columns
|
||
|
||
self.source = source
|
||
self.kind = getattr(source, "kind", None)
|
||
options = getattr(source, "options", None)
|
||
self.profiling = catalog_profile.profile_settings(options if isinstance(options, dict) else None)
|
||
self.masked = {c.lower() for c in masked_columns(options if isinstance(options, dict) else None)}
|
||
|
||
def _halted(self) -> bool:
|
||
"""止损:最近 MODEL_HALT_WINDOW 次调用里有 MODEL_HALT_ON 次以上没写出来。之后的段不再拆小、每块只问一次,直到
|
||
比例降下去(偶尔一次写出来不会清零)。"""
|
||
return sum(self.window) >= max(1, int(C.MODEL_HALT_ON))
|
||
|
||
def _halt_note(self) -> str:
|
||
"""止损的说明(拼在原因后面),没止损是空串。"""
|
||
if not self._halted():
|
||
return ""
|
||
return REASON_HALTED.format(window=max(1, int(C.MODEL_HALT_WINDOW)), on=max(1, int(C.MODEL_HALT_ON)))
|
||
|
||
@staticmethod
|
||
def _floor() -> int:
|
||
"""段大小减半的下限(每段最少几列)。"""
|
||
return min(max(1, int(C.MODEL_BATCH_COLUMNS)), max(1, int(C.MODEL_MIN_PART_COLUMNS)))
|
||
|
||
# ---------------------------------------------------------------- 总流程
|
||
|
||
async def run(self) -> dict[str, ModelOutcome]:
|
||
"""按计划的先后起草:同时只起草 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]))
|
||
for st in self.tables.values():
|
||
st.chunks_total = len(st.parts)
|
||
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]:
|
||
"""分段:窄表按顺序拼成整表段(列表),宽表一个一项(表名),列分段均分(_even)。各项按第一张表在 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, 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)]
|
||
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 _work(self, work: _Work, queue: collections.deque[_Work], left: dict[str, int]) -> None:
|
||
"""起草一段;它之后的段(宽表的列分段)排到队尾;一张表的段都结束了,这张表的模型部分就结束了。
|
||
|
||
这一段(原始段:计划里的整表段、表级那段或一个列分段)连同按缩小后的上限分开的各块、拆出来的、补问的、重试的,
|
||
一共最多调用 MODEL_PART_MAX_CALLS 次(各块共用一个 _Budget)。表级那段写完先核对模型提议的有效行过滤(_vet),
|
||
再交出去。接连几段没写出来的表(_tally),剩下的段跳过不问。宽表写出了一段、后面还有段没结束,就把这张表到这时
|
||
拿到的内容交给调用方并入目录(part_written);最后一段结束由 table_done 收尾。"""
|
||
asked = [p for p in work.pieces if not self.tables[p.table].abandoned]
|
||
for piece in work.pieces:
|
||
if self.tables[piece.table].abandoned:
|
||
await self._skip_part(piece)
|
||
if asked:
|
||
await self._start(asked, _Budget(max(1, int(C.MODEL_PART_MAX_CALLS))))
|
||
for piece in asked:
|
||
if piece.table_level:
|
||
await self._vet(self.tables[piece.table])
|
||
self._tally(asked)
|
||
for piece in work.pieces:
|
||
await self._part_done(piece.table)
|
||
queue.extend(work.then)
|
||
for piece in work.pieces:
|
||
left[piece.table] -= 1
|
||
if not left[piece.table]:
|
||
await self._table_done(piece.table)
|
||
elif piece.part in self.tables[piece.table].written_parts:
|
||
await self._part_written(piece.table)
|
||
|
||
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, budget)
|
||
return
|
||
if len(pieces) > 1:
|
||
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))
|
||
await self._more_chunks(piece.table, len(chunks) - 1)
|
||
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, budget)
|
||
await asyncio.gather(*(self._guarded(group, budget) for group in rest))
|
||
else:
|
||
await asyncio.gather(*(self._guarded(group, budget) for group in [first, *rest]))
|
||
|
||
async def _guarded(self, pieces: list[_Piece], budget: _Budget) -> None:
|
||
"""起草一块(一段,或它按上限分开的一块);意料之外的错误只记这一块没写出来,不影响别的段和别的表。"""
|
||
chunk = _Chunk(tuple(dict.fromkeys(p.table for p in pieces)))
|
||
try:
|
||
await self._segment(pieces, 0, False, budget, chunk=chunk)
|
||
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)))
|
||
await self._chunk_done(chunk)
|
||
|
||
def _tally(self, pieces: list[_Piece]) -> None:
|
||
"""一段(原始段,连同它分开的各块、拆小、补缺)结束:记下这一段写出内容没有;同一张表接连
|
||
MODEL_TABLE_GIVE_UP_AFTER 段一项都没写出来,这张表剩下的段跳过不问(写出来了清零)。"""
|
||
give_up = max(1, int(C.MODEL_TABLE_GIVE_UP_AFTER))
|
||
for piece in pieces:
|
||
st = self.tables[piece.table]
|
||
if st.wrote(piece):
|
||
st.written_parts.add(piece.part)
|
||
st.dead_parts = 0
|
||
continue
|
||
st.dead_parts += 1
|
||
if st.dead_parts >= give_up and not st.abandoned and len(st.parts) > 1:
|
||
st.abandoned = True
|
||
logger.warning("起草数据目录:表 %s 接连 %d 段都没写出来,剩下的段跳过", st.name, st.dead_parts)
|
||
|
||
async def _skip_part(self, piece: _Piece) -> None:
|
||
"""这张表接连几段都没写出来:这一段跳过不问(算一块结束)。"""
|
||
st = self.tables[piece.table]
|
||
st.skipped_parts.append(piece.part)
|
||
self._fail([piece], _Why(_SKIPPED))
|
||
st.chunks_done += 1
|
||
await self._hook(self.hooks.chunks_changed, st.name, st.chunks_total, st.chunks_done)
|
||
|
||
# ---------------------------------------------------------------- 块进度
|
||
|
||
async def _more_chunks(self, table: str, n: int) -> None:
|
||
if n <= 0:
|
||
return
|
||
st = self.tables[table]
|
||
st.chunks_total += n
|
||
await self._hook(self.hooks.chunks_changed, table, st.chunks_total, st.chunks_done)
|
||
|
||
async def _chunk_done(self, chunk: _Chunk) -> None:
|
||
"""一块问完了:这一块里没被拆给新块的表,各结束一块。"""
|
||
for table in chunk.tables:
|
||
if table in chunk.split:
|
||
continue
|
||
st = self.tables[table]
|
||
st.chunks_done += 1
|
||
await self._hook(self.hooks.chunks_changed, table, st.chunks_total, st.chunks_done)
|
||
|
||
# ---------------------------------------------------------------- 一段
|
||
|
||
async def _segment(self, pieces: list[_Piece], depth: int, followup: bool, budget: _Budget, *,
|
||
chunk: _Chunk, halved: bool = False, mode: str | None = None, last: bool = False,
|
||
optional: str | None = None) -> None:
|
||
"""问一段,拿到的并进各表;按失败原因拆开、补缺、记下没写出来的。
|
||
|
||
depth:已经拆了几层(截断后只补剩下的也算一层)。followup:这一段是在补缺(补过就不再补,见 _after_followup)。
|
||
budget:这一段(原始段)还能调用几次。chunk:这是哪一块(块进度)。halved:这几块是真拆小(_split)出来的——
|
||
原因里「已拆小重试」只在这时写。mode / last:换纯文本问的那一次——拆不开的空壳、结构化输出回了说明文字(再不行
|
||
就记没写出来)。optional:拆出来的块(值是拆它的原因),轮到它时已经止损就不问了(_ask)。
|
||
|
||
止损期间(_halted)每块只问一次:不拆小(_split)、不补问漏掉的列、截断后不再补剩下的。"""
|
||
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()
|
||
self._fail(pieces, lambda p: self._reason(optional or "hollow", split=True, piece=p, halted=note))
|
||
return
|
||
if reply.timeout:
|
||
await self._report(reply, wrote=False)
|
||
await self._split(pieces, depth, "timeout", followup, budget, halved, chunk)
|
||
return
|
||
if reply.error is not None:
|
||
# 结构化输出回了说明文字、这一块马上改走纯文本再问:只管通信的重来,不进止损的窗口
|
||
redo = reply.prose and not last
|
||
await self._report(reply, wrote=False, halting=not redo)
|
||
if redo:
|
||
await self._prose_to_text(pieces, depth, followup, budget, halved, chunk)
|
||
return
|
||
self._fail(pieces, _Why(reply.error, split=halved))
|
||
return
|
||
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)
|
||
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 not complete:
|
||
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, budget, halved, chunk)
|
||
elif depth < int(C.MODEL_SPLIT_DEPTH) and not self._halted():
|
||
# 捡回了一部分:只补剩下的(不是拆小)
|
||
await self._segment(rest, depth + 1, followup, budget, chunk=chunk, halved=halved)
|
||
else:
|
||
note = self._halt_note() if depth < int(C.MODEL_SPLIT_DEPTH) else ""
|
||
self._fail(rest, self._reason(kind, split=halved, halted=note))
|
||
return
|
||
if not anything and not followup:
|
||
if last:
|
||
halted = self._stopped(pieces, depth)
|
||
self._fail(pieces, lambda p: self._reason("hollow", split=halved, piece=p, halted=halted))
|
||
return
|
||
await self._split(pieces, depth, "hollow", followup, budget, halved, chunk)
|
||
return
|
||
if not rest:
|
||
return
|
||
if followup:
|
||
await self._after_followup(rest, depth, budget, halved, chunk, last=last)
|
||
return
|
||
if self._halted():
|
||
# 止损期间每块只问一次:漏了的不补问
|
||
note = self._halt_note()
|
||
self._fail(rest, lambda p: _Why(REASON_OMITTED, split=halved, halted=note)
|
||
if self.tables[p.table].has_any() else self._reason("hollow", split=halved, piece=p,
|
||
halted=note))
|
||
return
|
||
await self._segment(rest, depth, True, budget, chunk=chunk, halved=halved)
|
||
|
||
async def _after_followup(self, rest: list[_Piece], depth: int, budget: _Budget, halved: bool, chunk: _Chunk, *,
|
||
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, split=halved))
|
||
if not empty:
|
||
return
|
||
if last:
|
||
halted = self._stopped(empty, depth)
|
||
self._fail(empty, lambda p: self._reason("hollow", split=halved, piece=p, halted=halted))
|
||
return
|
||
await self._split(empty, depth, "hollow", False, budget, halved, chunk)
|
||
|
||
async def _prose_to_text(self, pieces: list[_Piece], depth: int, followup: bool, budget: _Budget,
|
||
halved: bool, chunk: _Chunk) -> None:
|
||
"""结构化输出回了一段说明文字:网关接受了结构化输出的参数却没照做。这次起草后面都走纯文本(另带格式说明),
|
||
这一段换纯文本问一次(止损期间也问:这是通信方式的问题,不是模型写不出来)。纯文本额度用完了还能照常拆小
|
||
(拆出来的半段也走纯文本,不再先问一遍结构化输出)。"""
|
||
if self.mode != "text":
|
||
self.mode = "text"
|
||
logger.info("起草数据目录:结构化输出回了说明文字,后面改走纯文本")
|
||
await self._segment(pieces, depth, followup, budget, chunk=chunk, halved=halved, mode="text", last=True)
|
||
|
||
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, chunk: _Chunk) -> None:
|
||
"""拆成两半再问(额度用完、超时、只给空壳、截断后一项都没捡回来)。
|
||
|
||
- 同一张表按列拆、前一半带着表级的项:先写前一半,后一半拿它做背景。超时的不等:一次超时就是一个时限,40 → 20
|
||
→ 10 一层一层串着问要好几个时限,两半同时问。拆出来的两半是新的块(单张表多一块);轮到它时已经止损的不再问
|
||
(optional)。
|
||
- 拆不开的空壳再问一次:结构化输出给的换纯文本,已经走纯文本的原样再问。
|
||
- 止损期间(_halted)不拆小、每块只问一次:本来能拆的不拆;空壳不再原样再问,只有结构化输出给的、这次起草还没
|
||
换纯文本问过的照旧问一次(看看是不是结构化输出的问题)。
|
||
- 别的原因拆不开、止损了,就记没写出来。"""
|
||
halves = _halves(pieces) if depth < int(C.MODEL_SPLIT_DEPTH) else None
|
||
halted = self._halted()
|
||
if halves is not None and not halted:
|
||
first, second = halves
|
||
await self._split_chunk(chunk, pieces)
|
||
if (kind != "timeout" and len(first) == len(second) == 1 and first[0].table == second[0].table
|
||
and first[0].table_level):
|
||
await self._half(first, depth + 1, followup, budget, kind)
|
||
await self._half(second, depth + 1, followup, budget, kind)
|
||
return
|
||
results = await asyncio.gather(self._half(first, depth + 1, followup, budget, kind),
|
||
self._half(second, depth + 1, followup, budget, kind),
|
||
return_exceptions=True)
|
||
for result in results:
|
||
if isinstance(result, BaseException):
|
||
raise result
|
||
return
|
||
if kind == "hollow" and (not halted or (self.mode == "structured" and not self.text_probed)):
|
||
self.text_probed = True
|
||
await self._segment(pieces, depth, followup, budget, chunk=chunk, halved=halved, mode="text", last=True)
|
||
return
|
||
note = self._halt_note() if halted and (halves is not None or kind == "hollow") else ""
|
||
self._fail(pieces, lambda p: self._reason(kind, split=halved, piece=p, halted=note))
|
||
|
||
async def _half(self, pieces: list[_Piece], depth: int, followup: bool, budget: _Budget, kind: str) -> None:
|
||
"""拆出来的一半:新的一块,问完算结束一块。"""
|
||
chunk = _Chunk(tuple(dict.fromkeys(p.table for p in pieces)))
|
||
try:
|
||
await self._segment(pieces, depth, followup, budget, chunk=chunk, halved=True, optional=kind)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception:
|
||
await self._chunk_done(chunk) # 出错也算问完(_guarded 记没写出来),块进度对得上
|
||
raise
|
||
await self._chunk_done(chunk)
|
||
|
||
async def _split_chunk(self, chunk: _Chunk, pieces: list[_Piece]) -> None:
|
||
"""这一块里的这几张表拆给了新的块:单张表按列拆成两半,多一块;多表的段按表拆,各张表仍是一块。"""
|
||
chunk.split.update(dict.fromkeys(p.table for p in pieces))
|
||
if len(pieces) == 1:
|
||
await self._more_chunks(pieces[0].table, 1)
|
||
|
||
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):
|
||
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)
|
||
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], budget: _Budget, mode: str | None = None, *,
|
||
optional: str | None = None) -> _Reply:
|
||
"""调一次模型(按原因重来:额度被拒、结构化输出不支持、网络错误……),交回读好的回复。
|
||
|
||
这一段的调用次数用完了交回 capped(不调用)。optional(拆出来的块):拿到名额时已经止损,交回 skipped(不调用,
|
||
次数退回)——等名额的时候前面的调用多数没写出来,不再拆小重试。超时不原样重试:交回 timeout,由调用方当作这一段
|
||
太大、拆小再问。重来之前的那次调用在这里就报给进度回调(记进用量和提醒,不进止损的窗口:halting=False);交回的
|
||
那次由调用方读完内容再报(写没写出来要看校验之后的内容)。服务方嫌额度太大、结构化输出不支持、网络错误的重来止损
|
||
期间照旧(只管通信,不是模型写不出来)。"""
|
||
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():
|
||
if optional and self._halted():
|
||
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)
|
||
return reply
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as e: # noqa: BLE001 - 按原因分别处理
|
||
kind = classify_failure(e)
|
||
if kind == "length":
|
||
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", halting=False)
|
||
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", halting=False)
|
||
continue
|
||
if kind == "transient" and retried < int(C.MODEL_RETRIES):
|
||
await self._called((0, 0), "error", halting=False)
|
||
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, retried), usage=(0, 0))
|
||
|
||
async def _call(self, pieces: list[_Piece], mode: str) -> tuple[_Reply, Any]:
|
||
"""一次调用:(读好的回复, 原始回复)。原始回复给用量用(测试的假模型没有,是 None)。"""
|
||
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))
|
||
return _structured_reply(raw, parsed), raw
|
||
raw = await self.model.ainvoke(messages)
|
||
return _text_reply(raw), raw
|
||
|
||
@staticmethod
|
||
def _length_reply(e: BaseException) -> _Reply:
|
||
"""LengthFinishReasonError:额度用完了。异常里带着那次的回复(和用量),能捡回来的照捡。"""
|
||
completion = getattr(e, "completion", None)
|
||
usage = getattr(completion, "usage", None)
|
||
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):
|
||
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, usage=tokens)
|
||
return _Reply(exhausted=True, usage=tokens)
|
||
|
||
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)
|
||
|
||
@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 ""
|
||
return REASON_CALL.format(line=first_line(e) or describe_exception(e)) + tail
|
||
|
||
# ---------------------------------------------------------------- 每次调用之后:用量、段大小、提醒
|
||
|
||
async def _report(self, reply: _Reply, *, wrote: bool, halting: bool = True) -> None:
|
||
"""交回的那次调用读完内容之后报给进度回调。halting:这次是不是这一块这次调用的最终结局(进不进止损的窗口,见
|
||
_called)。"""
|
||
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, halting=halting)
|
||
|
||
async def _called(self, usage: tuple[int, int], failure: str | None, *, strike: bool = False,
|
||
halting: bool = True) -> None:
|
||
"""记下一次调用:额度用完或超时(strike)累计 MODEL_SHRINK_AFTER 次,新开始的段列数上限减半;最近几次调用多数
|
||
没写出来时提醒,少了清掉;止损按最近 MODEL_HALT_WINDOW 次调用的比例(_halted)。报给进度回调。
|
||
|
||
halting=False:只管通信的重来之前的那次(服务方嫌额度太大、结构化输出不支持或回了说明文字、429 / 5xx 退避重试)。
|
||
照旧记进用量和提醒,不进止损的窗口:它不花额度,也说明不了模型写不出来;止损只看每次调用的最终结局。"""
|
||
self.recent.append(failure)
|
||
if halting:
|
||
self.window.append(failure is not None)
|
||
halted = self._halted()
|
||
if halted != self.was_halted:
|
||
self.was_halted = halted
|
||
if halted:
|
||
logger.warning("起草数据目录:最近 %d 次调用有 %d 次没写出来,之后的段不再拆小、每块只问一次",
|
||
len(self.window), sum(self.window))
|
||
else:
|
||
logger.info("起草数据目录:最近的调用多数写出来了,恢复拆小重试")
|
||
if strike:
|
||
self.strikes += 1
|
||
width = max(1, int(C.MODEL_BATCH_COLUMNS))
|
||
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)
|
||
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])
|
||
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
|
||
await self._hook(self.hooks.call_done, CallReport(int(usage[0]), int(usage[1]), failure, self.notice,
|
||
self.part_columns))
|
||
|
||
# ---------------------------------------------------------------- 拿到的内容
|
||
|
||
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
|
||
dropped: list[tuple[str, str]] = []
|
||
drafted = C._from_model(entry, st.meta, system=self.system, source_kind=self.kind, table=st.name,
|
||
dropped=dropped)
|
||
if piece.table_level and "valid_filter" not in st.notes:
|
||
# 只在请它写表级的项时记:只写几列的那几段里多给的表级的项本来就不要
|
||
for why, condition in dropped:
|
||
self._log(st, why, condition)
|
||
notes = _restrict(drafted, 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: _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, halted: str = "") -> _Why:
|
||
if kind == "exhausted":
|
||
base = REASON_EXHAUSTED.format(budget=self._budget(), seg=SEG)
|
||
elif kind == "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):
|
||
base = C.MODEL_EMPTY_REASON if st.named else REASON_MISSING
|
||
else:
|
||
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:
|
||
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
|
||
part_of = st.part_of()
|
||
progress = {"parts_written": len(st.written_parts), "chunks_total": st.chunks_total,
|
||
"chunks_done": st.chunks_done, "log": list(dict.fromkeys(st.log))[:LOG_MAX]}
|
||
abandoned = self._abandoned_text(st)
|
||
if not items:
|
||
failures = [(why, part_of.get(key, 1)) for key, why in st.failed.items() if why.base != _SKIPPED]
|
||
reason = _join(failures) if failures else (C.MODEL_EMPTY_REASON if st.named else REASON_MISSING)
|
||
if abandoned:
|
||
reason = f"{reason};{abandoned}"
|
||
if st.failed 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, **progress)
|
||
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, abandoned),
|
||
total, st.parts_done, blank, **progress)
|
||
return ModelOutcome("ok", notes, items, None, total, st.parts_done, blank, **progress)
|
||
|
||
@staticmethod
|
||
def _abandoned_text(st: _Table) -> str:
|
||
"""这张表接连几段没写出来、剩下的段跳过了:说清跳过了几段、怎么办。没跳过是空串。"""
|
||
if not st.skipped_parts:
|
||
return ""
|
||
return REASON_ABANDONED.format(k=max(1, int(C.MODEL_TABLE_GIVE_UP_AFTER)), n=len(st.skipped_parts))
|
||
|
||
def _blank(self, st: _Table, failed: set[str]) -> list[str]:
|
||
"""模型拿不准、留空了的列:请它写了(不在没写出来的里面),中文名和含义却都没给(或者给的都没通过校验)。
|
||
|
||
目录里已经有中文名或含义的不算(不论来源:以前模型起草的——重新起草时模型这次没给的留着、不删;人工填写、确认的;
|
||
库注释;驳回的不算有):照「补充起草要求再重新起草」做一遍,模型这次没再给的老列不该又说成拿不准。库注释已经说清
|
||
的(catalog.comment_role 记进目录的那种;导入表格的源是系统说明)同样不算:提示词里它们以此为准,模型不写也不缺。
|
||
写入时(catalog.write_draft)再按并入目录之后的结果筛一遍。"""
|
||
drafted = st.notes.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 (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):
|
||
continue
|
||
out.append(col)
|
||
return out
|
||
|
||
@staticmethod
|
||
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, abandoned: str = "") -> 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 if st.failed[k].base != _SKIPPED)
|
||
if abandoned:
|
||
reason = f"{reason};{abandoned}" if reason else abandoned
|
||
n = len(st.columns)
|
||
if len(st.parts) > 1:
|
||
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)})"
|
||
"没写出来")
|
||
elif failed_cols:
|
||
head = f"这张表 {n} 列,有 {len(failed_cols)} 列({_columns_text(st.columns, failed_cols, count=False)})没写出来"
|
||
else:
|
||
head = f"这张表 {n} 列,表级的项没写出来"
|
||
return f"{head}:{reason}" if abandoned else 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))
|
||
|
||
async def _part_written(self, name: str) -> None:
|
||
"""宽表写出了一段、后面还有段没结束:把这张表到这时拿到的全部内容交给调用方并入目录(停止、服务重启时不丢)。"""
|
||
st = self.tables[name]
|
||
await self._hook(self.hooks.part_written, name, PartWrite(copy.deepcopy(st.notes), len(st.written_parts)))
|
||
|
||
# ---------------------------------------------------------------- 防错事实:核对有效行过滤
|
||
|
||
async def _vet(self, st: _Table) -> None:
|
||
"""表级那段写完,核对模型提议的有效行过滤(只核对一次)。
|
||
|
||
- 目录里人工确认、驳回过的有效行过滤:并入时不会采用这次的提议,不必再查库;
|
||
- 没开数据剖析:照收,note 写「没有核对排除比例(数据源没有开数据剖析)」;
|
||
- 开了:经剖析的只读守卫数排除比例(catalog_profile.check_valid_filter)。排除 0% 或 100% 的不收(这张表的日志
|
||
记一句,不算失败);收下的 note 在依据后面写上剖析核对的结果,或者没有核对的原因。"""
|
||
if st.vetted:
|
||
return
|
||
st.vetted = True
|
||
item = st.notes.get("valid_filter")
|
||
if not isinstance(item, dict):
|
||
return
|
||
held = (st.existing or {}).get("valid_filter")
|
||
if isinstance(held, Mapping) and held.get("status") in ("confirmed", "rejected"):
|
||
return
|
||
from app.data import catalog_profile
|
||
|
||
if not self.profiling.enabled or self.offline:
|
||
found = catalog_profile.FilterCheck(True, catalog_profile.FILTER_UNCHECKED.format(
|
||
why=SHADOW_NOT_CHECKED if self.offline else catalog_profile.FILTER_NOT_ENABLED))
|
||
else:
|
||
try:
|
||
found = await catalog_profile.check_valid_filter(self.source, st.name, dict(st.meta),
|
||
str(item.get("value") or ""), settings=self.profiling)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as e: # noqa: BLE001 - 核对是附加的:出了意外照收,写明没有核对
|
||
from app.core.errors import describe_exception, first_line
|
||
|
||
logger.exception("起草数据目录:核对表 %s 的有效行过滤时出错", st.name)
|
||
found = catalog_profile.FilterCheck(True, catalog_profile.FILTER_UNCHECKED.format(
|
||
why=f"核对时出错:{first_line(e) or describe_exception(e)}"))
|
||
if not found.keep:
|
||
logger.info("起草数据目录:表 %s 的有效行过滤 %s 没有收下:%s", st.name, item.get("value"), found.dropped)
|
||
st.notes.pop("valid_filter", None)
|
||
why = found.dropped or found.note
|
||
if found.rows is not None:
|
||
why = LOG_FILTER_COUNTED.format(label=C.TABLE_FIELD_LABEL["valid_filter"], what=found.dropped,
|
||
rows=f"{found.rows:,}")
|
||
self._log(st, why, str(item.get("value") or ""))
|
||
return
|
||
item["note"] = _sentences(item.get("note"), found.note)
|
||
|
||
@staticmethod
|
||
def _log(st: _Table, why: str, condition: str) -> None:
|
||
"""这张表的日志记一句:模型提议的有效记录条件没有收下(不算失败)。"""
|
||
condition = " ".join(condition.split())
|
||
if len(condition) > _LOG_CONDITION_MAX:
|
||
condition = condition[:_LOG_CONDITION_MAX] + "…"
|
||
st.log.append(LOG_FILTER_DROPPED.format(why=why, condition=condition))
|
||
|
||
@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)))
|
||
shown = self._confirmed_for(st, piece)
|
||
codes = self._codes_lines(st, piece, shown)
|
||
if codes:
|
||
lines.append(labels["codes"])
|
||
lines += codes
|
||
confirmed = C._notes_lines(shown, meta=st.meta, system_notes=False)
|
||
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 "")
|
||
|
||
def _confirmed_for(self, st: _Table, piece: _Piece) -> dict[str, Any]:
|
||
"""已确认、有确证的项(以此为准的背景):整表全给;其余给表级的项和这一段的列,关系只在要写表级的项时给。
|
||
|
||
遮罩的列上的码值不给,人工确认过的也一样:码值就是这一列的取值(同 _codes_lines,也同表助手的背景、导入目录的
|
||
规矩)。以前只有新加的码值背景挡住了,这里照给。这一列别的确认过的项(中文名、含义……)照给。"""
|
||
whole = st.whole(piece)
|
||
wanted = set(piece.columns)
|
||
kept = {key: item for key, item in C._slots(C._confirmed_only(st.existing)).items()
|
||
if not (key[0] == "c" and key[2] == "codes" and key[1].lower() in self.masked)
|
||
and (whole or key[0] == "t" or (key[0] == "c" and key[1] in wanted)
|
||
or (key[0] == "r" and piece.table_level))}
|
||
return C._assemble(kept)
|
||
|
||
def _codes_lines(self, st: _Table, piece: _Piece, shown: Mapping[str, Any]) -> list[str]:
|
||
"""开了数据剖析的源:目录里记下的码值(剖析取过的取值和已知含义)交给模型,判断有效行过滤、列的含义用。要写表级
|
||
的项的给全部列的,只写几列的给这几列的;遮罩的列、驳回的码值不给;「已确认的目录项」里已经有的不重复。"""
|
||
if not self.profiling.enabled:
|
||
return []
|
||
labels = C.CATALOG_DRAFT_PROMPT_LABELS
|
||
wanted = set(st.columns) if piece.table_level else set(piece.columns)
|
||
known = C.visible_notes(st.existing).get("columns") or {}
|
||
given = (shown.get("columns") or {}) if isinstance(shown.get("columns"), Mapping) else {}
|
||
out: list[str] = []
|
||
for col in st.columns:
|
||
item = (known.get(col) or {}).get("codes")
|
||
if col not in wanted or col.lower() in self.masked or not isinstance(item, Mapping):
|
||
continue
|
||
if "codes" in (given.get(col) or {}) or not isinstance(item.get("value"), Mapping) or not item["value"]:
|
||
continue
|
||
out.append(labels["code"].format(name=col, codes=C._codes_text(item["value"])))
|
||
return out
|
||
|
||
|
||
def _sentences(*parts: Any) -> str:
|
||
"""几句话连成一段:各句去掉句末的句号,用句号隔开(「依据:……。剖析核对:……」)。"""
|
||
texts = [str(p).strip().rstrip("。") for p in parts if isinstance(p, str) and p.strip()]
|
||
return "。".join(texts)
|
||
|
||
|
||
__all__ = ["BUDGET_WORDS", "Background", "CallReport", "FAILURE_LABEL", "Hooks", "LOG_FILTER_COUNTED",
|
||
"LOG_FILTER_DROPPED", "LOG_MAX", "ModelOutcome", "NOTICE", "PartWrite",
|
||
"REASON_ABANDONED", "REASON_CALL", "REASON_CAPPED", "REASON_EXHAUSTED", "REASON_FORMAT", "REASON_HALTED",
|
||
"REASON_HOLLOW", "REASON_MISSING", "REASON_OMITTED", "REASON_RETRIED", "REASON_SPLIT", "REASON_SPLIT_PARTS",
|
||
"REASON_TIMEOUT", "REASON_TRUNCATED", "REASON_UNANSWERED", "SEG", "classify_failure", "model_gate",
|
||
"SHADOW_NOT_CHECKED", "parts_for", "run_model_draft", "run_shadow_draft"]
|