Files
ThearchyHelios 9e1ff11278 交叉表支持按文字导入的指标(如「高峰时段」一行写着「18-19」),堵上占位符陷阱:配方的 measures 分段加 text_measures(标签 → text / hour_range),落成文字列(时段存规范写法),空格、公式、占位符照块的取值规则,数字、日期、解析不了的时段报 value_not_text / value_not_hour_range;规则起草遇到无标题指标块里整行是文字的行(标签不带词表单位)按文字指标起草,并出卡片和问题要人确认(「不是」就去掉,回到按数字导入、试运行拒收),确认清单加 text_measure 项,前后对照报类型和存法变化;数值区不合格的格一行合成一条起草失败(不再一行 30 条)。陷阱:以前照「请添加占位符」把「18-19」加成占位符,整格静默存成空值、干跑照样通过——静态校验拒收含数字的占位符(placeholder_digits,重放和沿用现行配方的放行),value_not_number 的下一步按格子情况写:整行文字的提示改「指标类型」并给修复按钮 declare_text_measure,含数字的不再提示加占位符;标签带单位的拒按文字导入(text_measure_unit),文字列不能写单位(unit_on_text)。配方面板每个指标多一个「指标类型」下拉(数字 / 时段 / 文字)。默认值不进规范形式,已存配方的哈希不变。测试 tests/test_recipe_text_measures.py(合成夹具,假名);check-manage 加指标类型三项
2026-10-20 11:41:28 +08:00

1165 lines
55 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""AI 兜底起草(期 2,WP-4):规则起草不完整时,经用户同意把表格**结构**发给模型,由模型写配方。
**模型只写规则、不碰数据**(用户决定 B)。发出去的是压缩表示(compress):结构文字照发,数字格只给类型、
公式先脱敏、隐藏行列只给个数、列表数据列只给画像,任何一个数据值都不出门。模型写回来的配方走和规则草稿
同一条路:静态校验(注入的 validate,origin="ai")、执行器干跑(注入的 dry_run)、交给用户逐条确认,
必须人工确认之后才能启用。每月重传按已确认的配方重放,不再调用模型。
**为什么不用 get_chat_model。** 它在没有接入时会静默退回演示模型;拿演示模型起草等于假装起草了。这里
自己解析接入(取值顺序和数据目录起草共用 data/draft_model.py:设置里的起草模型 > 助手的模型 > 默认接入),
没有、停用、只有演示接入、缺 key 都在调用模型之前判定为不可用。
**调用参数照 c837cdb 修过的问题来**:关思考(thinking="off")、额度 32768、服务方嫌额度大时退回 8192;
正文一律经 message_text 抽取(回复可能是块列表);输出被截断单独记一种失败,不当成「不是 JSON」去修订。
写给模型的文字(系统提示、提示词、压缩表示里的固定片段、回灌的问题模板)都放在名字含 _SYSTEM / _PROMPT
的模块级常量里,拼接只用 .format()。
"""
from __future__ import annotations
import asyncio
import datetime as _dt
import hashlib
import json
import logging
import re
from dataclasses import dataclass
from typing import Any
from pydantic import ValidationError
from sqlalchemy.ext.asyncio import AsyncSession
from app.data import recipe_parsers as P
from app.data import recipe_suggest as S
from app.data.names import canon, to_sql_name
from app.data.recipe_suggest import AsyncDryRunFn, DryRunFn, ValidateFn
from app.data.recipe_types import (
AiAvailability,
AiUsage,
Card,
Draft,
DraftFacts,
Grid,
GridCell,
Recipe,
)
from app.data.draft_model import COPILOT_SETTING_KEY as _DRAFT_COPILOT_KEY
from app.data.tabular import EXCEL_ERRORS
from app.data.xlsx_scan import WorkbookScan, cell_ref, col_letter
logger = logging.getLogger(__name__)
class AiUnavailable(RuntimeError):
"""没有可用的模型(没有接入、停用、只有演示接入、缺 key)。message 给人看;在调用模型之前判定。"""
class AiTooLarge(ValueError):
"""压缩表示超过字数上限。同样在调用模型之前判定(接口 413 ai_too_large)。"""
# 没有 AiFailed:调用之后的任何失败都体现在返回值里(draft=None、error、usage 照记)
#: 助手的模型设置键(与 app/api/copilot.py 的 COPILOT_SETTING_KEY 同值;数据层不导入接口层,测试断言两者相等)。
#: 取模型时由 data/draft_model.py 读,这里留着这个名字给测试和老代码用
COPILOT_SETTING_KEY = _DRAFT_COPILOT_KEY
#: 1 次起草 + 最多 2 次修订
AI_MAX_ATTEMPTS = 3
#: 单次调用的超时(秒);测试 monkeypatch 调小
AI_TIMEOUT_S = 90
#: 输出额度(与 copilot.COPILOT_MAX_TOKENS 同值):会思考的模型的思考也算在里面,8192 时常常想完就没额度写正文
AI_MAX_TOKENS = 32768
#: 服务方嫌额度太大时退回这个(同 copilot._FALLBACK_MAX_TOKENS)
AI_FALLBACK_MAX_TOKENS = 8192
COMPRESS_MAX_CHARS_PER_SHEET = 12_000
COMPRESS_MAX_CHARS = 20_000
#: 行数超过这个数的工作表,只给前 COMPRESS_HEAD_ROWS 行、后 COMPRESS_TAIL_ROWS 行,中间折成列画像
COMPRESS_FOLD_ROWS = 300
COMPRESS_HEAD_ROWS = 200
COMPRESS_TAIL_ROWS = 20
#: 结构文字超过这个字数就截断
COMPRESS_TEXT_MAX = 40
#: 同模板的连续格至少几格折成「序列」;序列不超过这个数时把每一格都列出来
SEQ_MIN, SEQ_LIST_MAX = 3, 12
#: 服务方嫌额度太大时报错里会出现的字样(同 copilot._BUDGET_WORDS)
_BUDGET_WORDS = ("max_tokens", "max_completion_tokens", "max_output_tokens", "maximum context length")
@dataclass
class Compressed:
"""压缩表示。text 就是提示词的前三段(工作表、系统发现、分段标题候选词),ai_draft 只用 text:
接口层按这四个字段存库、取回再传进来,发出去的和预览给用户看的逐字相同。"""
text: str
chars: int
truncated: bool
#: text 的 sha256:同意记录和清单里存它,事后能核对发出去的是哪一份
sha256: str
@dataclass
class AiDraftResult:
#: origin="ai";None 表示没有得到静态校验通过的配方
draft: Draft | None
usage: list[AiUsage]
attempts: int
#: 给人看的失败原因:「AI 未能起草出合法的配方…」「输出被截断…」「调用超时…」
error: str | None
compressed_chars: int
compressed_sha256: str
# ==========================================================================
# 写给模型的文字(check-copy 按常量名排除)
# ==========================================================================
RECIPE_DRAFT_SYSTEM = (
"你为 Excel 工作表起草「取数配方」:一个符合下面 JSON Schema 的 JSON 对象。系统会按配方确定性地执行、"
"用 SQL 核对,再交给用户逐条确认;你只写规则,不接触数据。\n"
"规则:\n"
"1. 只输出一个 JSON 对象:不要解释,不要代码块标记,不要在思考里草拟 JSON。\n"
"2. 配方语言是封闭的:没有正则,不写单元格坐标;只能用 Schema 里有的字段和取值。\n"
"3. measures 的键必须与 labels.expect 里的标签逐字相同、一一对应。\n"
"4. const 的 pick 只能从「分段标题候选词」里选。\n"
"5. 占位符只能表示空值(无数据或不适用),不含数字。指标行每格写的都是文字(如时段「18-19」)时,"
"在该 measures 分段的 text_measures 里声明(时段用 hour_range,其余用 text),不要当成占位符。\n"
"6. 单位只能取自「单位词表」;标签或表头末尾括号里的单位拆进 tables[].units,列名去掉单位,"
"单位必须与括号里写的一致;括号里的单位不在词表里时,列名保留括号、不写单位。\n"
"7. 合计、小计行用 role=derived 核对,并用 keep_as 另存成一张表。\n"
"8. 「系统发现」的每一条必须被 relations 恰好认领一次:成立写 sum_eq,口径不同写 not_comparable,"
"判断为巧合写 dismissed 并给理由;认领的成员必须正是该发现里的那几行,分段 id 必须与发现里写的一致。\n"
"9. 分段 id、块 id 在整份配方内不能重复。\n"
"10. 不写年份、日期和表格里的任何数值;tables[].note 留空。\n"
"11. 表名、列名用简短的中文。"
)
#: 前三段:就是压缩表示(Compressed.text),预览里展示的也是这一段
RECIPE_SECTIONS_PROMPT = "# 工作表压缩表示\n{sheets}\n\n# 系统发现(F1…)\n{facts}\n\n# 分段标题候选词\n{candidates}"
#: 后三段:单位词表、规则起草的半成品与失败原因、配方 JSON Schema
RECIPE_REQUEST_PROMPT = (
"# 单位词表\n{units}\n\n"
"# 规则起草的半成品与失败原因\n{rules_draft}\n\n"
"# 配方 JSON Schema\n{schema}\n\n"
"请输出配方 JSON。"
)
#: 完整的首次提示词(.format(sheets=, facts=, candidates=, units=, rules_draft=, schema=))。ai_draft 发的是
#: Compressed.text + 「\n\n」+ RECIPE_REQUEST_PROMPT,与它逐字相同,只是前三段直接取压缩表示、不再重算
RECIPE_DRAFT_PROMPT = RECIPE_SECTIONS_PROMPT + "\n\n" + RECIPE_REQUEST_PROMPT
RECIPE_REPAIR_PROMPT = (
"上一次的配方有以下问题,请输出修正后的完整配方 JSON(只输出一个 JSON 对象):\n{problems}"
)
#: 规则草稿那一段、回灌的问题行
RULES_DRAFT_PROMPT = {
"none": "无",
"recipe": "半成品配方:\n{recipe}",
"failures": "失败原因:\n{lines}",
"line": "- {text}",
"not_json": "上一次的回复不是合法的 JSON 对象({detail})",
}
#: 压缩表示里的固定片段
COMPRESS_PROMPT_LABELS = {
"sheet_head": "## 工作表「{name}」 已用区域 {area},非空格 {count}",
"sheet_cut": "(起草时只读取了前 {n} 行)",
"merges": "合并:{items}",
"none": "无",
"texts": "文字:",
"text_item": " {ref}「{text}」",
"long_text": "{head}…(共 {n} 字)",
"seq_all": " {ref} 序列「{tpl}」{n} 格:{items}",
"seq_ends": " {ref} 序列「{tpl}」{n} 格:{first} … {last}",
"repeat": " {ref}「{text}」×{n}",
"numeric_text": "<文本数字>",
"other_text": " {ref} 文字({n} 字)",
"profile": " {col} 列「{header}」是数据列(只给画像):{n} 格,不同取值 {distinct} 个,最长 {longest} 字,空值 {blank}%",
"masked": " {col} 列「{header}」是遮罩列(只给画像):{n} 格,不同取值 {distinct} 个,最长 {longest} 字,空值 {blank}%",
"numbers": "数字(只有类型):",
"rect": " {ref} {sig}",
"int": "整数",
"float": "小数",
"date": "日期",
"time": "时间",
"bool": "布尔",
"error": "错误值",
"text_formula": "文字",
"formula": ",公式",
"uncached": ",无保存值",
"formulas": "公式形状:",
"shape_one": " {ref} {formula}",
"shape_many": " {ref} {formula} {how}({n} 格,{cache})",
"by_col": "逐列平移",
"by_row": "逐行平移",
"by_cell": "逐格平移",
"all_cached": "均有保存值",
"some_uncached": "{n} 格无保存值",
"hidden": "隐藏行:{rows} 隐藏列:{cols}",
"hidden_rows": "第 {span} 行(文字 {t} 格、数字 {n} 格)",
"hidden_cols": "{span} 列(文字 {t} 格、数字 {n} 格)",
"fold": "第 {a}–{b} 行(共 {n} 行)折叠为列画像:",
"fold_col": " {col} 列:{kinds},空值 {blank}%,不同取值 {distinct} 个",
"kind_count": "{kind} {n}",
"hidden_sheets": "另有隐藏工作表 {n} 个(不发送内容)",
"facts_none": "无",
"fact_sum": "{id}(等式:用 sum_eq 认领,或判断为巧合写 dismissed):{text};分段 id「{segment}」,合计行「{total}」,组成行{parts}",
"fact_ne": "{id}(口径不同:用 not_comparable 认领,或判断为巧合写 dismissed):{text};明细分段 id {a},对比分段 id「{b_segment}」的「{b}」",
"quoted": "「{text}」",
"candidates_none": "无",
"candidate": "「{title}」:{words}",
"hidden_text": "<隐藏>",
}
_L = COMPRESS_PROMPT_LABELS
#: 给人看的失败原因。只说出了什么事:下一步取决于暂存区有没有工作配方(没有时配方面板里只能粘贴一份),
#: 由接口层(recipe_imports)补上
_ERR_INVALID = "AI 未能起草出合法的配方"
_ERR_TRUNCATED = "AI 的输出被截断,配方没有写完(输出被截断)"
_ERR_TIMEOUT = "调用超时:{n} 秒内没有收到 AI 的回复"
_ERR_CALL = "调用失败:{reason}"
# ==========================================================================
# 用哪个模型
# ==========================================================================
def _budget_rejected(e: BaseException) -> bool:
"""服务方一开口就嫌额度太大(400 / 422,报错里带 max_tokens 等字样)。写法同 copilot._budget_rejected。"""
status = getattr(e, "status_code", None) or getattr(getattr(e, "response", None), "status_code", None)
return status in (400, 422) and any(w in str(e).lower() for w in _BUDGET_WORDS)
class _BudgetFallback:
"""先按大额度调;服务方嫌额度太大时按小额度重来,之后的调用(修订)都用小的。同 copilot._BudgetFallback,
只是这里是一次性的 ainvoke,不是流式。"""
def __init__(self, big: Any, small: Any, model_name: str = "") -> None:
self.model, self._small = big, small
#: 模型 id:ai_draft(model=…) 拿它记用量。不给的话 getattr(model, "model") 拿到的是底层的模型对象
self.model_name = model_name
@property
def max_tokens(self) -> Any:
return getattr(self.model, "max_tokens", None)
async def ainvoke(self, messages: list[Any]) -> Any:
try:
return await self.model.ainvoke(messages)
except Exception as e:
if self.model is self._small or not _budget_rejected(e):
raise
self.model = self._small
return await self.model.ainvoke(messages)
async def resolve_draft_model(session: AsyncSession) -> tuple[Any | None, str, AiAvailability]:
"""(可调用的模型, 模型 id, 可用性);不可用时模型为 None。只建模型对象,不发请求。
用哪个接入、哪个模型和数据目录起草共用一处取值(data/draft_model.py:起草模型 > 助手的模型 > 默认接入);
额度、关思考照旧是这里自己的。"""
from app.data.draft_model import DraftModelUnavailable, draft_unavailable, resolve_draft_provider
from app.providers.factory import ModelSpec, ProviderNotConfigured, build_chat_model
try:
provider, choice = await resolve_draft_provider(
session, none_reason="未配置模型接入,无法使用 AI 起草。请到「设置 → 模型接入」添加",
mock_reason="当前只有演示用的模型接入,无法用于 AI 起草。请到「设置 → 模型接入」添加真实的模型接入")
except DraftModelUnavailable as e:
return None, "", AiAvailability(False, reason=str(e))
spec = ModelSpec(provider=choice.provider, model=choice.model, max_tokens=AI_MAX_TOKENS, temperature=0,
thinking="off")
try:
big = build_chat_model(provider, spec)
small = build_chat_model(provider, spec.model_copy(update={"max_tokens": AI_FALLBACK_MAX_TOKENS}))
except ProviderNotConfigured as e:
return None, "", AiAvailability(False, reason=draft_unavailable(e, choice.origin))
model_id = spec.model or provider.default_model or ""
return (_BudgetFallback(big, small, model_id), model_id,
AiAvailability(True, model=model_id, provider=provider.name))
async def ai_availability(session: AsyncSession) -> AiAvailability:
return (await resolve_draft_model(session))[2]
# ==========================================================================
# 压缩表示
# ==========================================================================
_DIGITS = re.compile(r"\d+")
_WS = re.compile(r"\s+")
def _tpl(text: str) -> str:
return _DIGITS.sub("{}", text)
def _rng(r1: int, c1: int, r2: int, c2: int) -> str:
a, b = cell_ref(r1, c1), cell_ref(r2, c2)
return a if a == b else f"{a}:{b}"
def _flat(text: str) -> str:
return _WS.sub(" ", text.strip())
def _clip(text: str) -> str:
flat = _flat(text)
n = len(canon(flat))
if n <= COMPRESS_TEXT_MAX:
return flat
return _L["long_text"].format(head=flat[:20], n=n)
def _num(v: Any) -> bool:
return isinstance(v, (int, float)) and not isinstance(v, bool)
def _value_sig(cell: GridCell) -> str | None:
"""数字格、日期格、布尔、公式格的类型签名;文字格 None。"""
v = cell.value
formula = cell.formula is not None
if formula and v is None:
return _L["formula"].lstrip(",") + _L["uncached"]
if isinstance(v, bool):
base = _L["bool"]
elif _num(v):
base = _L["int"] if isinstance(v, int) or float(v).is_integer() else _L["float"]
elif isinstance(v, (_dt.datetime, _dt.date)):
base = _L["date"]
elif isinstance(v, _dt.time):
base = _L["time"]
elif isinstance(v, str) and v.strip().upper() in EXCEL_ERRORS:
base = _L["error"]
elif formula:
base = _L["text_formula"]
else:
return None
return base + (_L["formula"] if formula else "")
def _rects(cells: dict[tuple[int, int], str]) -> list[tuple[int, int, int, int, str]]:
"""同签名的格合成矩形:先按行成段,再把上下相同的段接起来。"""
by_row: dict[int, list[int]] = {}
for (r, c) in cells:
by_row.setdefault(r, []).append(c)
segs: dict[int, list[tuple[int, int, str]]] = {}
for r in sorted(by_row):
cols = sorted(by_row[r])
start = prev = cols[0]
for c in cols[1:] + [None]: # type: ignore[list-item]
if c is not None and c == prev + 1 and cells[(r, c)] == cells[(r, start)]:
prev = c
continue
segs.setdefault(r, []).append((start, prev, cells[(r, start)]))
if c is not None:
start = prev = c
rects: list[list[Any]] = []
open_: dict[tuple[int, int, str], list[Any]] = {}
for r in sorted(segs):
nxt: dict[tuple[int, int, str], list[Any]] = {}
for c0, c1, sig in segs[r]:
key = (c0, c1, sig)
if key in open_ and open_[key][2] == r - 1:
open_[key][2] = r
nxt[key] = open_[key]
else:
rect = [r, c0, r, c1, sig]
rects.append(rect)
nxt[key] = rect
open_ = nxt
return sorted((tuple(x) for x in rects), key=lambda t: (t[0], t[1])) # type: ignore[misc]
def sanitize_formula(formula: str) -> str:
"""公式脱敏:数字常量 → <数>,字符串常量 → <文本>,跨工作表的引用只留表名('内部'!<格>)。
「=3000+2500」这种把数写成算式的格本身就是数据值;「=C5*1.0675」里的税率、「=IF(B5>50000,…)」里的阈值
是业务数据,字符串常量同理。同表的单元格引用、区域、函数名、运算符照留:模型要靠它们认出合计。
"""
from openpyxl.formula.tokenizer import Token, Tokenizer
try:
tok = Tokenizer(formula if formula.startswith("=") else "=" + formula)
except Exception: # noqa: BLE001 - 切不开的公式整条不发
return "=<公式>"
out: list[str] = []
for t in tok.items:
if t.type == Token.OPERAND and t.subtype == Token.NUMBER:
out.append("<数>")
elif t.type == Token.OPERAND and t.subtype == Token.TEXT:
out.append("<文本>")
elif t.type == Token.OPERAND and t.subtype == Token.RANGE and "!" in t.value:
out.append(t.value.rsplit("!", 1)[0] + "!<格>")
else:
out.append(t.value)
return "=" + "".join(out)
def _translate(formula: str, origin: str, dest: str) -> str | None:
from openpyxl.formula.translate import Translator
try:
return Translator(formula, origin=origin).translate_formula(dest)
except Exception: # noqa: BLE001
return None
def _formula_shapes(grid: Grid, cells: list[tuple[int, int]]) -> list[str]:
"""公式形状:同一行里逐列平移的折成一段,上下相同的段再接成矩形(Translator 判平移,比较的是原文)。"""
by_row: dict[int, list[int]] = {}
for (r, c) in cells:
by_row.setdefault(r, []).append(c)
runs: list[list[int]] = [] # [r, c0, c1]
for r in sorted(by_row):
cols = sorted(by_row[r])
cur = [r, cols[0], cols[0]]
for c in cols[1:]:
first = grid.get(r, cur[1])
if (c == cur[2] + 1 and first is not None and first.formula
and _translate(first.formula, cell_ref(r, cur[1]), cell_ref(r, c)) == grid.get(r, c).formula): # type: ignore[union-attr]
cur[2] = c
else:
runs.append(cur)
cur = [r, c, c]
runs.append(cur)
rects: list[list[int]] = [] # [r0, c0, r1, c1]
for r, c0, c1 in runs:
prev = next((x for x in rects if x[2] == r - 1 and x[1] == c0 and x[3] == c1), None)
top = grid.get(prev[0], c0) if prev else None
cur = grid.get(r, c0)
if (prev is not None and top is not None and cur is not None and top.formula
and _translate(top.formula, cell_ref(prev[0], c0), cell_ref(r, c0)) == cur.formula):
prev[2] = r
else:
rects.append([r, c0, r, c1])
lines: list[str] = []
for r0, c0, r1, c1 in sorted(rects):
first = grid.get(r0, c0)
formula = sanitize_formula(first.formula or "") if first is not None else "=<公式>"
n = (r1 - r0 + 1) * (c1 - c0 + 1)
if n == 1:
lines.append(_L["shape_one"].format(ref=cell_ref(r0, c0), formula=formula))
continue
how = _L["by_cell"] if r1 > r0 and c1 > c0 else (_L["by_row"] if r1 > r0 else _L["by_col"])
uncached = sum(1 for rr in range(r0, r1 + 1) for cc in range(c0, c1 + 1)
if (x := grid.get(rr, cc)) is not None and x.value is None)
cache = _L["all_cached"] if not uncached else _L["some_uncached"].format(n=uncached)
lines.append(_L["shape_many"].format(ref=_rng(r0, c0, r1, c1), formula=formula, how=how, n=n, cache=cache))
return lines
def _spans(nums: list[int]) -> list[tuple[int, int]]:
out: list[tuple[int, int]] = []
for n in sorted(set(nums)):
if out and n == out[-1][1] + 1:
out[-1] = (out[-1][0], n)
else:
out.append((n, n))
return out
def _fold_items(items: list[tuple[int, int, str]]) -> list[tuple[int, int, str]]:
"""结构文字:同值的连续格折成「×N」,同模板的连续格折成「序列」(横向优先,再纵向)。"""
texts = {(r, c): t for r, c, t in items}
used: set[tuple[int, int]] = set()
out: list[tuple[int, int, str]] = []
for horizontal in (True, False):
keys = sorted(texts, key=(lambda k: (k[0], k[1])) if horizontal else (lambda k: (k[1], k[0])))
i = 0
while i < len(keys):
k = keys[i]
if k in used:
i += 1
continue
run = [k]
j = i + 1
while j < len(keys):
nk, pk = keys[j], run[-1]
adj = (nk[0] == pk[0] and nk[1] == pk[1] + 1) if horizontal else (nk[1] == pk[1] and nk[0] == pk[0] + 1)
if not adj or nk in used or _tpl(texts[nk]) != _tpl(texts[run[0]]):
break
run.append(nk)
j += 1
vals = [texts[x] for x in run]
ref = _rng(run[0][0], run[0][1], run[-1][0], run[-1][1])
if len(run) >= 2 and len(set(vals)) == 1:
out.append((run[0][0], run[0][1], _L["repeat"].format(ref=ref, text=vals[0], n=len(run))))
elif len(run) >= SEQ_MIN and "{}" in _tpl(vals[0]):
if len(run) <= SEQ_LIST_MAX:
line = _L["seq_all"].format(ref=ref, tpl=_tpl(vals[0]), n=len(run), items="、".join(vals))
else:
line = _L["seq_ends"].format(ref=ref, tpl=_tpl(vals[0]), n=len(run), first=vals[0], last=vals[-1])
out.append((run[0][0], run[0][1], line))
else:
i += 1
continue
used.update(run)
i = j
for (r, c), t in texts.items():
if (r, c) not in used:
out.append((r, c, _L["text_item"].format(ref=cell_ref(r, c), text=t)))
return out
def _structural_parse(v: Any) -> bool:
return any(f(v) is not None for f in (P.hour_range, P.hour_range_total, P.month_day_or_date,
P.cn_date_range, P.cn_year_month))
def _profile(cells: list[tuple[int, str]], span: tuple[int, int]) -> tuple[int, int, int, int]:
"""(个数, 不同取值个数, 最长字数, 空值百分比)。"""
n = len(cells)
distinct = len({canon(t) for _, t in cells})
longest = max((len(canon(t)) for _, t in cells), default=0)
rows = span[1] - span[0] + 1
blank = round(100 * (rows - n) / rows) if rows > 0 else 0
return n, distinct, longest, max(blank, 0)
def _compress_sheet(grid: Grid, scan: WorkbookScan, mask_keys: set[str]) -> tuple[str, bool, set[str]]:
"""一张工作表的压缩表示。返回 (文本, 是否截断/折叠, 这张表上被隐藏或遮罩的文字)。"""
name = grid.sheet
sheet_scan = scan.sheet(name)
hints = S.layout_hints(grid)
rows_all = grid.row_numbers()
hidden_r, hidden_c = set(grid.hidden_rows), set(grid.hidden_cols)
secret: set[str] = set()
lines: list[str] = []
if grid.bounds is not None:
r1, c1, r2, c2 = grid.bounds
elif rows_all:
r1, r2 = rows_all[0], rows_all[-1]
c1 = min(c for (_, c) in grid.cells)
c2 = max(c for (_, c) in grid.cells)
else:
r1 = c1 = r2 = c2 = 1
count = sheet_scan.nonempty if sheet_scan is not None else len(grid.cells)
lines.append(_L["sheet_head"].format(name=name, area=_rng(r1, c1, r2, c2), count=count))
truncated = grid.truncated
if grid.truncated:
lines.append(_L["sheet_cut"].format(n=rows_all[-1] if rows_all else 0))
# 大表:中间的行折成列画像
folded: set[int] = set()
fold_range: tuple[int, int] | None = None
if rows_all and r2 - r1 + 1 > COMPRESS_FOLD_ROWS:
a, b = r1 + COMPRESS_HEAD_ROWS, r2 - COMPRESS_TAIL_ROWS
folded = {r for r in rows_all if a <= r <= b}
fold_range = (a, b)
truncated = True
merges = [m for m in grid.merges if not all(r in hidden_r for r in range(m[0], m[2] + 1))]
lines.append(_L["merges"].format(items="、".join(_rng(*m) for m in sorted(merges)) or _L["none"]))
# 遮罩列:表头格之下的整列只给画像
masked: dict[int, tuple[int, str]] = {}
if mask_keys:
for (r, c), cell in sorted(grid.cells.items()):
if isinstance(cell.value, str) and P.match_key(cell.value) in mask_keys and c not in masked:
masked[c] = (r, _flat(cell.value))
axis_row, label_col = hints.axis_row, hints.label_col
header_rows = hints.header_rows
top_header = hints.top_header_row
def positional(r: int, c: int) -> bool:
if axis_row is not None:
return c == label_col or r <= axis_row
if top_header is not None:
return r in header_rows or r < top_header
return False
numbers: dict[tuple[int, int], str] = {}
formulas: list[tuple[int, int]] = []
structural: list[tuple[int, int, str]] = []
numeric_like: list[tuple[int, int, str]] = []
others: list[tuple[int, int, str]] = []
data_candidates: dict[int, list[tuple[int, str]]] = {}
masked_cells: dict[int, list[tuple[int, str]]] = {}
hidden_rows_count: dict[int, list[int]] = {}
hidden_cols_count: dict[int, list[int]] = {}
#: 每行最左的可见非空格所在的列:列表合计行的标签(「合计」)在这一列
leftmost: dict[int, int] = {}
for (r, c) in grid.cells:
if r not in hidden_r and c not in hidden_c and (r not in leftmost or c < leftmost[r]):
leftmost[r] = c
for (r, c), cell in sorted(grid.cells.items()):
if r in hidden_r or c in hidden_c:
is_text = isinstance(cell.value, str) and cell.formula is None
bucket = hidden_rows_count if r in hidden_r else hidden_cols_count
key = r if r in hidden_r else c
pair = bucket.setdefault(key, [0, 0])
pair[0 if is_text else 1] += 1
if isinstance(cell.value, str):
secret.add(_flat(cell.value))
continue
if r in folded:
continue
sig = _value_sig(cell)
if sig is not None:
# 日期格(包括日期轴上的)只给类型签名、不给值(P2-SPEC 6.2):表格里没有统计期格时,轴上的
# 年月日就是这一期的数据;模型从「C4:AG4 日期」已经看得出这一行是日期表头
numbers[(r, c)] = sig
if cell.formula is not None:
formulas.append((r, c))
continue
text = cell.value if isinstance(cell.value, str) else str(cell.value)
if c in masked and r > masked[c][0]:
masked_cells.setdefault(c, []).append((r, text))
secret.add(_flat(text))
continue
if P.looks_numeric_text(text):
numeric_like.append((r, c, _L["text_item"].format(ref=cell_ref(r, c), text=_L["numeric_text"])))
secret.add(_flat(text))
continue
if c in masked and r == masked[c][0]:
continue # 遮罩列的表头放在画像那一行里
# 合计字样只在结构位置或一行最左的格(列表合计行的标签)里照发:数据列中间以「合计」「累计」开头的格
# (「累计消费12000元」)是数据,和同列其他格一样只进画像(SE-3)
if (positional(r, c) or (S._total_word(text) and leftmost.get(r) == c)
or S._placeholder_like(text)):
structural.append((r, c, _clip(text)))
continue
if axis_row is None:
data_candidates.setdefault(c, []).append((r, text))
continue
if _structural_parse(text):
structural.append((r, c, _clip(text)))
else:
others.append((r, c, _L["other_text"].format(ref=cell_ref(r, c), n=len(canon(text)))))
secret.add(_flat(text))
# 没有日期轴的工作表:同一列纵向连续 ≥4 个文字格(至多隔一个空行)、所在行还有别的非空格的,是数据列
profiles: list[tuple[int, int, str]] = []
row_count = {r: len(grid.row(r)) for r in rows_all}
for c, items in sorted(data_candidates.items()):
items.sort()
runs: list[list[tuple[int, str]]] = []
for r, t in items:
if runs and r - runs[-1][-1][0] <= 2:
runs[-1].append((r, t))
else:
runs.append([(r, t)])
data: list[tuple[int, str]] = []
span: list[int] = []
for run in runs:
# 冒号结尾的是表单的标签(「项目名称:」),是结构文字;它右边那一列才是要藏起来的内容
labels = sum(1 for _, t in run if canon(t).endswith(":")) * 2 >= len(run)
if len(run) >= 4 and not labels and all(row_count.get(r, 0) >= 2 for r, _ in run):
data.extend(run)
span += [run[0][0], run[-1][0]]
else:
for r, t in run:
if labels or _structural_parse(t):
structural.append((r, c, _clip(t)))
else:
others.append((r, c, _L["other_text"].format(ref=cell_ref(r, c), n=len(canon(t)))))
secret.add(_flat(t))
if data:
for _, t in data:
secret.add(_flat(t))
header = _header_above(grid, c, data[0][0], header_rows)
n, distinct, longest, blank = _profile(data, (min(span), max(span)))
profiles.append((data[0][0], c, _L["profile"].format(col=col_letter(c), header=header, n=n,
distinct=distinct, longest=longest, blank=blank)))
for c, items in sorted(masked_cells.items()):
rows = [r for r, _ in items]
n, distinct, longest, blank = _profile(items, (min(rows), max(rows)))
profiles.append((masked[c][0], c, _L["masked"].format(col=col_letter(c), header=masked[c][1], n=n,
distinct=distinct, longest=longest, blank=blank)))
text_lines = _fold_items(structural) + numeric_like + others + profiles
lines.append(_L["texts"])
lines.extend(t for _, _, t in sorted(text_lines, key=lambda x: (x[0], x[1])))
rects = _rects(numbers)
if rects:
lines.append(_L["numbers"])
lines.extend(_L["rect"].format(ref=_rng(a, b, c, d), sig=sig) for a, b, c, d, sig in rects)
shapes = _formula_shapes(grid, formulas)
if shapes:
lines.append(_L["formulas"])
lines.extend(shapes)
hr = [_L["hidden_rows"].format(span=f"{a}" if a == b else f"{a}–{b}",
t=sum(hidden_rows_count.get(x, [0, 0])[0] for x in range(a, b + 1)),
n=sum(hidden_rows_count.get(x, [0, 0])[1] for x in range(a, b + 1)))
for a, b in _spans(sorted(hidden_r))]
hc = [_L["hidden_cols"].format(span=col_letter(a) if a == b else f"{col_letter(a)}–{col_letter(b)}",
t=sum(hidden_cols_count.get(x, [0, 0])[0] for x in range(a, b + 1)),
n=sum(hidden_cols_count.get(x, [0, 0])[1] for x in range(a, b + 1)))
for a, b in _spans(sorted(hidden_c))]
lines.append(_L["hidden"].format(rows="、".join(hr) or _L["none"], cols="、".join(hc) or _L["none"]))
if fold_range is not None:
a, b = fold_range
lines.append(_L["fold"].format(a=a, b=b, n=b - a + 1))
cols: dict[int, list[Any]] = {}
for (r, c), cell in grid.cells.items():
if r in folded and r not in hidden_r and c not in hidden_c:
cols.setdefault(c, []).append(cell)
if isinstance(cell.value, str):
secret.add(_flat(cell.value))
for c in sorted(cols):
kinds: dict[str, int] = {}
for cell in cols[c]:
k = _value_sig(cell) or _L["text_formula"]
kinds[k] = kinds.get(k, 0) + 1
distinct = len({repr(cell.value) for cell in cols[c]})
blank = round(100 * (b - a + 1 - len(cols[c])) / (b - a + 1))
lines.append(_L["fold_col"].format(
col=col_letter(c), kinds="、".join(_L["kind_count"].format(kind=k, n=n) for k, n in sorted(kinds.items())),
blank=max(blank, 0), distinct=distinct))
return "\n".join(lines), truncated, secret
def _header_above(grid: Grid, c: int, r: int, header_rows: set[int]) -> str:
rows = sorted((h for h in header_rows if h < r), reverse=True) or list(range(r - 1, max(r - 4, 0), -1))
for h in rows:
cell = grid.get(h, c)
if cell is not None and isinstance(cell.value, str) and cell.value.strip():
return _clip(cell.value)
return ""
def _hide_text(text: str, secret: set[str]) -> str:
"""把隐藏行列、遮罩列、数据列里的文字从要发出去的段落里换掉(标签可能来自隐藏行)。
secret 里每个文字同时按它的派生写法比:去掉末尾括号单位(「绝密分区(人次)」→「绝密分区」,起草时列名、
单位的键、关系成员就是这么来的)、再去掉「一、」这类序号前缀,以及它们经 to_sql_name 的写法(SE-1、SE-2)。
"""
return _hide_in(text, _variants(secret))
def _hide_in(text: str, words: set[str]) -> str:
"""把 words(已含派生写法)里 ≥2 字的文字在 text 里按子串换成 <隐藏>,长的先换。"""
for s in sorted((x for x in words if len(x) >= 2), key=len, reverse=True):
if s in text:
text = text.replace(s, _L["hidden_text"])
return text
def _variants(texts: set[str]) -> set[str]:
"""文字 → 它本身和起草时可能派生出的写法(去单位后缀、去序号前缀、to_sql_name)。"""
out: set[str] = set()
for x in texts:
f = _flat(x)
stem = _flat(P.split_unit_suffix(f)[0])
for v in (f, stem, _flat(S._ENUM_PREFIX.sub("", stem))):
if not v:
continue
out.add(v)
n = to_sql_name(v, fallback="")
if n:
out.add(n)
return out
def _hidden_texts(grids: dict[str, Grid]) -> set[str]:
"""隐藏行列里的文字(只这些,不含数据列、遮罩列):规则半成品里只要有字符串是它的一部分,或包含它,就遮掉。"""
out: set[str] = set()
for g in grids.values():
hr, hc = set(g.hidden_rows), set(g.hidden_cols)
if not hr and not hc:
continue
for (r, c), cell in g.cells.items():
if (r in hr or c in hc) and isinstance(cell.value, str) and _flat(cell.value):
out.add(_flat(cell.value))
return out
class _Outbound:
"""发给模型之前的最后一道清洗:首次调用里的规则半成品、失败原因,以及修订轮回灌的问题列表都过它。
- 隐藏行列、遮罩列、数据列、认不出用途的文字(secret)及其派生写法:整串相等就换成 <隐藏>,自由文字里按
子串换掉;
- 隐藏行列的文字再多两道(规则半成品里的字符串):是隐藏文字的一部分(≥3 字,列名可能被截短、取公共后缀)
或包含隐藏文字的派生写法(≥2 字,表名、分段 id 可能拼了后缀),也换成 <隐藏>;
- 像数的文字:整串相等、或在问题行里被「」引着的,换成 <文本数字>。
修订轮的问题来自静态校验和干跑,消息里会引用系统发现的标签原文(「应为「全日客流(人次)」=「分区甲(人次)」
+「…」」),系统发现并不排除隐藏行(SE-1);首轮同意框里展示的全文不含这些,所以必须在这里清洗。
"""
def __init__(self, secret: set[str], grids: dict[str, Grid]) -> None:
self.secret = set(secret)
self.exact = _variants(self.secret)
hidden = _hidden_texts(grids)
self.hidden_canon = [canon(h) for h in hidden]
self.hidden_parts = {v for v in _variants(hidden) if len(canon(v)) >= 2}
self.numeric = {S._shown(c.value) for g in grids.values() for c in g.cells.values()
if isinstance(c.value, str) and P.looks_numeric_text(c.value)}
self.numeric = {_flat(x) for x in self.numeric if _flat(x)}
def _secret_str(self, s: str) -> bool:
f = _flat(s)
if f in self.exact or s in self.exact:
return True
c = canon(f)
if len(c) >= 3 and any(c in h for h in self.hidden_canon):
return True
return any(v in f for v in self.hidden_parts)
def text(self, s: str) -> str:
f = _flat(s)
if f in self.numeric:
return _L["numeric_text"]
if self._secret_str(s):
return _L["hidden_text"]
return s
def json(self, node: Any) -> Any:
if isinstance(node, dict):
return {self.text(k) if isinstance(k, str) else k: self.json(v) for k, v in node.items()}
if isinstance(node, list):
return [self.json(v) for v in node]
if isinstance(node, str):
return self.text(node)
return node
def line(self, text: str) -> str:
out = _hide_in(text, self.exact)
for part in sorted(self.hidden_parts, key=len, reverse=True):
if part in out:
out = out.replace(part, _L["hidden_text"])
for n in sorted(self.numeric, key=len, reverse=True):
q = _L["quoted"].format(text=n)
if q in out:
out = out.replace(q, _L["quoted"].format(text=_L["numeric_text"]))
return out
def _facts_text(facts: DraftFacts, secret: set[str]) -> str:
words = _variants(secret) if facts.facts else set()
lines: list[str] = []
for f in facts.facts:
d = f.detail
q = _L["quoted"]
if f.kind == "sum_eq":
line = _L["fact_sum"].format(id=f.id, text=f.text, segment=d.get("segment", ""),
total=d.get("total", ""),
parts="".join(q.format(text=p) for p in d.get("parts", [])))
else:
line = _L["fact_ne"].format(id=f.id, text=f.text, a="".join(q.format(text=a) for a in d.get("a", [])),
b_segment=d.get("b_segment", ""), b=d.get("b", ""))
lines.append(_hide_in(line, words))
return "\n".join(lines) or _L["facts_none"]
def _candidates_text(facts: DraftFacts, secret: set[str]) -> str:
lines = [_L["candidate"].format(title=_flat(t), words="、".join(w)) for t, w in facts.candidates.items()
if _flat(t) not in secret]
return "\n".join(lines) or _L["candidates_none"]
def compress(grids: dict[str, Grid], scan: WorkbookScan, facts: DraftFacts, *,
mask_headers: set[str] | frozenset[str] = frozenset()) -> Compressed:
"""只发结构、不发数据的压缩表示。判定规则全部是确定性的,同一份原件的压缩文本相同。超限抛 AiTooLarge。"""
mask_keys = {P.match_key(h) for h in mask_headers}
order = [s.name for s in scan.sheets if s.state == "visible"] if scan.sheets else list(grids)
names = [n for n in order if n in grids and grids[n].cells] + [
n for n, g in grids.items() if n not in order and g.cells]
parts: list[str] = []
truncated = False
secret: set[str] = set()
for n in names:
text, cut, hidden = _compress_sheet(grids[n], scan, mask_keys)
if len(text) > COMPRESS_MAX_CHARS_PER_SHEET:
raise AiTooLarge(f"工作表「{n}」的结构压缩后有 {len(text)} 字,超过上限 {COMPRESS_MAX_CHARS_PER_SHEET} 字,"
"无法交给 AI 起草")
parts.append(text)
truncated = truncated or cut
secret |= hidden
hidden_sheets = [s for s in scan.sheets if s.state != "visible"]
if hidden_sheets:
parts.append(_L["hidden_sheets"].format(n=len(hidden_sheets)))
sheets = "\n\n".join(parts)
facts_text = _facts_text(facts, secret)
cand_text = _candidates_text(facts, secret)
text = RECIPE_SECTIONS_PROMPT.format(sheets=sheets, facts=facts_text, candidates=cand_text)
if len(text) > COMPRESS_MAX_CHARS:
raise AiTooLarge(f"表格结构压缩后有 {len(text)} 字,超过上限 {COMPRESS_MAX_CHARS} 字,无法交给 AI 起草")
return Compressed(text=text, chars=len(text), truncated=truncated,
sha256=hashlib.sha256(text.encode("utf-8")).hexdigest())
def _secret_texts(grids: dict[str, Grid], scan: WorkbookScan) -> set[str]:
"""不发给模型的文字(隐藏行列、数据列、认不出用途的文字):拼规则草稿那一段时据此遮掉。
不存进 Compressed:那个对象可能被接口层留存,隐藏行里的姓名不该跟着落库。
"""
out: set[str] = set()
for g in grids.values():
if g.cells:
out |= _compress_sheet(g, scan, set())[2]
return out
# ==========================================================================
# 起草
# ==========================================================================
def _rules_section(rules: Draft | None, secret: set[str], grids: dict[str, Grid]) -> str:
"""规则起草的半成品(紧凑形式)和 failures_for_model(不用 failures:后者可以引用格子原文)。"""
if rules is None or (rules.recipe is None and not rules.failures_for_model):
return RULES_DRAFT_PROMPT["none"]
scrub = _Outbound(secret, grids)
out: list[str] = []
if rules.recipe is not None:
try:
compact = Recipe.model_validate(rules.recipe).model_dump(mode="json", exclude_defaults=True)
except ValidationError:
compact = rules.recipe
compact = scrub.json(compact)
out.append(RULES_DRAFT_PROMPT["recipe"].format(
recipe=json.dumps(compact, ensure_ascii=False, separators=(",", ":"))))
lines = [RULES_DRAFT_PROMPT["line"].format(text=scrub.line(x)) for x in rules.failures_for_model]
if lines:
out.append(RULES_DRAFT_PROMPT["failures"].format(lines="\n".join(lines)))
return "\n".join(out) or RULES_DRAFT_PROMPT["none"]
def _scrub_json(node: Any, secret: set[str], numeric: set[str]) -> Any:
"""配方里等于隐藏文字、像数的文字的字符串(值和键)换掉:配方语言写不了数,但标签可能是「2025」这种文字。
列名是表头经 to_sql_name 变来的(「137-2222-3333」→「c_137_2222_3333」),按原文比对不上,所以也按
to_sql_name 之后的写法、去掉单位后缀的写法比(见 _variants):起草万一把数据行认成了表头,列名同样不能把
数据带出去。首次调用实际用的是 _Outbound(还会按隐藏行列的子串比),这个函数留给只有文字集合的调用方。
"""
names = _variants(secret)
def fix(s: str) -> str:
f = _flat(s)
if f in numeric:
return _L["numeric_text"]
if f in secret or f in names or s in names:
return _L["hidden_text"]
return s
if isinstance(node, dict):
return {fix(k) if isinstance(k, str) else k: _scrub_json(v, secret, numeric) for k, v in node.items()}
if isinstance(node, list):
return [_scrub_json(v, secret, numeric) for v in node]
if isinstance(node, str):
return fix(node)
return node
def _schema_text() -> str:
return json.dumps(Recipe.model_json_schema(), ensure_ascii=False, separators=(",", ":"))
#: 模型回复的 JSON 最多嵌套几层。配方本身不到 10 层;更深的一律当成不合法的 JSON(深层嵌套会让 json.loads
#: 抛 RecursionError,它不是 ValueError,会逃出 ai_draft,见 SE-6)
JSON_MAX_DEPTH = 64
def _too_deep(s: str, limit: int = JSON_MAX_DEPTH) -> bool:
depth = 0
in_str = esc = False
for ch in s:
if in_str:
if esc:
esc = False
elif ch == "\\":
esc = True
elif ch == '"':
in_str = False
elif ch == '"':
in_str = True
elif ch in "[{":
depth += 1
if depth > limit:
return True
elif ch in "]}":
depth -= 1
return False
def _extract_json(text: str) -> dict[str, Any]:
"""回复 → JSON 对象。任何解析失败(含嵌套过深、RecursionError)都按 ValueError 抛,走修订分支。"""
s = text.strip()
s = re.sub(r"^```[A-Za-z]*\s*", "", s)
s = re.sub(r"\s*```\s*$", "", s)
i, j = s.find("{"), s.rfind("}")
if i < 0 or j <= i:
raise ValueError("no JSON object found")
body = s[i:j + 1]
if _too_deep(body):
raise ValueError(f"JSON nested deeper than {JSON_MAX_DEPTH} levels")
try:
data = json.loads(body)
except RecursionError:
raise ValueError("JSON nested too deeply") from None
if not isinstance(data, dict):
raise ValueError("JSON is not an object")
return data
def _cut_off(reply: Any) -> bool:
"""输出被截断:Anthropic 是 stop_reason=max_tokens,OpenAI 系是 finish_reason=length(同 copilot._cut_off)。"""
meta = getattr(reply, "response_metadata", None) or {}
reason = meta.get("finish_reason") or meta.get("stop_reason")
return reason in ("length", "max_tokens")
def _now() -> str:
return _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds")
def _usage(reply: Any, model_id: str, attempt: int, ok: bool) -> AiUsage:
from app.providers import catalog
meta = getattr(reply, "usage_metadata", None) or {}
inp, out = int(meta.get("input_tokens") or 0), int(meta.get("output_tokens") or 0)
return AiUsage(model=model_id, input_tokens=inp, output_tokens=out,
cost_usd=round(catalog.estimate_cost(model_id, inp, out), 6), ok=ok, at=_now(), attempt=attempt)
def _failed_usage(model_id: str, attempt: int) -> AiUsage:
return AiUsage(model=model_id, input_tokens=0, output_tokens=0, cost_usd=0.0, ok=False, at=_now(), attempt=attempt)
def _problems_text(lines: list[str]) -> str:
return "\n".join(RULES_DRAFT_PROMPT["line"].format(text=x) for x in lines[:S.MODEL_LINES_MAX])
def first_call_text(compressed: Compressed, *, grids: dict[str, Grid], scan: WorkbookScan,
rules_draft: Draft | None) -> str:
"""ai_draft 第一次调用模型时实际发出的全文:系统提示 + 首次提示词(两段之间空一行)。
同意框展示的必须是这份全文,而不只是压缩表示:首次提示词里还有单位词表、规则起草的半成品与失败原因、
配方 JSON Schema,这些同样会发给模型。拼法与 ai_draft 里的逐字相同(同一组常量、同一个
_rules_section、同一个 _schema_text),接口层按这份全文算 sha256,用户同意的是哪一份、发出去的就是哪一份。
"""
secret = _secret_texts(grids, scan)
prompt = "\n\n".join([compressed.text, RECIPE_REQUEST_PROMPT.format(
units="、".join(P.UNITS),
rules_draft=_rules_section(rules_draft, secret, grids),
schema=_schema_text())])
return "\n\n".join([RECIPE_DRAFT_SYSTEM, prompt])
async def ai_draft(session: AsyncSession, *, grids: dict[str, Grid], scan: WorkbookScan, facts: DraftFacts,
rules_draft: Draft | None, filename: str, validate: ValidateFn,
dry_run: AsyncDryRunFn | DryRunFn | None, model: Any | None = None,
compressed: Compressed | None = None, model_id: str | None = None) -> AiDraftResult:
"""AI 起草。只抛 AiUnavailable / AiTooLarge(都在第一次调用模型之前);之后的失败一律返回 draft=None。
这是一个跑在事件循环上的协程,CPU 活一律不在循环线程上做:静态校验(check_recipe)和卡片问题
(questions_for)放进线程池;干跑经 S.run_dry_async——dry_run 是协程函数就 await(接口层注入的那份
占一个解析名额、在线程池里执行),是普通函数也放进线程池。
model 给了就用(接口层把同意时解析出的那个模型交进来,同意记录、实际调用、用量是同一个;测试注入假模型,
需有 async ainvoke(messages) -> AIMessage),model_id 是它的模型 id;没给 model 时 resolve_draft_model。
compressed 给了就用(接口层先在解析名额内算好、预览时给用户看的就是这一份),否则现算。
filename 不发给模型(文件名里可能有人名、单位名);只为与规则起草同签名。
"""
from langchain_core.messages import HumanMessage, SystemMessage
from app.engine.state import message_text
if model is None:
model, model_id, avail = await resolve_draft_model(session)
if model is None or not avail.available:
raise AiUnavailable(avail.reason or "AI 起草不可用")
elif not model_id:
named = getattr(model, "model_name", None) or getattr(model, "model", None)
model_id = named if isinstance(named, str) else ""
if compressed is None:
compressed = compress(grids, scan, facts)
secret = _secret_texts(grids, scan)
scrub = _Outbound(secret, grids)
# 前三段整段取压缩表示的 text(不再重算系统发现、候选词):接口层存库再取回的只有 text 等四个字段,
# 发出去的必须与同意框里展示、sha256 记下的那一份逐字相同
prompt = "\n\n".join([compressed.text, RECIPE_REQUEST_PROMPT.format(
units="、".join(P.UNITS),
rules_draft=_rules_section(rules_draft, secret, grids),
schema=_schema_text())])
messages: list[Any] = [SystemMessage(content=RECIPE_DRAFT_SYSTEM), HumanMessage(content=prompt)]
usage: list[AiUsage] = []
attempts = 0
last_error = _ERR_INVALID
best: tuple[Recipe, Any, list[str], list[str]] | None = None
while attempts < AI_MAX_ATTEMPTS:
attempts += 1
try:
reply = await asyncio.wait_for(model.ainvoke(list(messages)), timeout=AI_TIMEOUT_S)
except asyncio.TimeoutError:
usage.append(_failed_usage(model_id, attempts))
last_error = _ERR_TIMEOUT.format(n=AI_TIMEOUT_S)
logger.info("recipe ai draft attempt %d timed out (model=%s)", attempts, model_id)
continue
except Exception as e: # noqa: BLE001 - 调用失败只能记一次失败,不能变成起草成功
from app.core.errors import describe_exception
usage.append(_failed_usage(model_id, attempts))
last_error = _ERR_CALL.format(reason=describe_exception(e))
logger.info("recipe ai draft attempt %d failed (model=%s): %s", attempts, model_id, type(e).__name__)
continue
truncated = _cut_off(reply)
usage.append(_usage(reply, model_id, attempts, ok=not truncated))
u = usage[-1]
logger.info("recipe ai draft attempt %d model=%s in=%d out=%d truncated=%s",
attempts, model_id, u.input_tokens, u.output_tokens, truncated)
if truncated:
# 半截文本不拿去修订:同样的消息列表重来
last_error = _ERR_TRUNCATED
continue
last_error = _ERR_INVALID
# 拿到回复之后的任何异常都只算这一次没起草成:用量已记,结果走返回值(SE-6)
try:
text = message_text(reply)
try:
data = _extract_json(text)
except (ValueError, json.JSONDecodeError) as e:
detail = e.msg if isinstance(e, json.JSONDecodeError) else str(e)
messages += [reply, HumanMessage(content=RECIPE_REPAIR_PROMPT.format(
problems=_problems_text([RULES_DRAFT_PROMPT["not_json"].format(detail=detail)])))]
continue
human, for_model, parsed = await asyncio.to_thread(S.check_recipe, data, facts, "ai", validate)
if parsed is None:
# 静态校验的消息会引用系统发现的标签原文(可能来自隐藏行),回灌前清洗(SE-1)
messages += [reply, HumanMessage(content=RECIPE_REPAIR_PROMPT.format(
problems=_problems_text([scrub.line(x) for x in for_model])))]
continue
ext, dh, dm = (None, [], [])
if dry_run is not None:
ext, dh, dm = await S.run_dry_async(parsed, dry_run, grids)
best = (parsed, ext, dh, dm)
if dh and attempts < AI_MAX_ATTEMPTS:
messages += [reply, HumanMessage(content=RECIPE_REPAIR_PROMPT.format(
problems=_problems_text([scrub.line(x) for x in dm])))]
continue
except Exception as e: # noqa: BLE001
logger.warning("recipe ai draft attempt %d: handling the reply failed: %s", attempts, type(e).__name__)
last_error = _ERR_INVALID
break
break
if best is None:
return AiDraftResult(None, usage, attempts, last_error, compressed.chars, compressed.sha256)
parsed, ext, dh, dm = best
recipe = parsed.model_dump(mode="json")
try:
cards, questions = await asyncio.to_thread(S.questions_for, recipe, facts, grids, extraction=ext)
except Exception: # noqa: BLE001 - 卡片是辅助信息
cards, questions = [], []
cards.insert(0, Card("ai_origin", "AI 起草:请逐项核对",
"这份配方由 AI 根据表格结构起草,系统已做静态校验和检查;导入前请逐项核对下面的卡片和问题,"
"提交时需要逐条确认"))
result = Draft(recipe=recipe, complete=not dh, origin="ai", cards=cards, questions=questions, facts=facts,
failures=dh, failures_for_model=dm)
return AiDraftResult(result, usage, attempts, None, compressed.chars, compressed.sha256)
__all__ = [
"AI_FALLBACK_MAX_TOKENS", "AI_MAX_ATTEMPTS", "AI_MAX_TOKENS", "AI_TIMEOUT_S", "AiDraftResult", "AiTooLarge",
"AiUnavailable", "COMPRESS_MAX_CHARS", "COMPRESS_MAX_CHARS_PER_SHEET", "COMPRESS_PROMPT_LABELS", "JSON_MAX_DEPTH",
"COPILOT_SETTING_KEY", "Compressed", "RECIPE_DRAFT_PROMPT", "RECIPE_DRAFT_SYSTEM", "RECIPE_REPAIR_PROMPT",
"ai_availability", "ai_draft", "compress", "first_call_text", "resolve_draft_model", "sanitize_formula",
]