Files
ThearchyHelios 49f00c589a 目录修改提案的依据改存成目录项单独的字段 reason(界面上叫「确认时的依据」,最多 500 字,超了截成 499 字加「…」),不再拼进备注:拼进 note 之后,带依据保存的关联关系再剖析时测量的说法换不了新、和覆盖率那一栏对不上;改了值的关联关系旧依据叠在新依据后面、越存越长;值不变、保存即确认的项撤销审阅回到推断后「依据:使用者说……」还挂着,看起来像使用者确认过一条推断项。去留:值改了(关联关系也一样)换成这次的依据,没写就去掉旧的;值不变的确认换成这次的,不叠;单项确认、驳回留着,撤销审阅去掉;整份提交改了值的、状态改回推断的、双盲复算改写的去掉;剖析刷新关联关系的测量只动备注。校验认 reason(文字、最多 500 字),起草、剖析的合并不碰人工确认的项,导出导入时已确认、已驳回的项连依据一起带,推断、有确证的项不带。表助手背景里已有的依据写成「以前确认时的依据:……」,系统提示词和 propose_changes 的说明写清那不是这次对话里使用者说的话、引用时写「以前确认时的依据写着:……」,遮罩列的项不给。表详情里依据和说明分开写:表级的项在值下面另起一行,列的一格、关联关系另一个悬停图标(读屏念「确认时的依据:……」),状态弹层里也写,驳回的项只在弹层里写;check-catalog-loop 的假服务端照服务端记下依据,补依据和说明分开、弹层、驳回项的检查;README 表助手一行写上依据另存
2026-10-14 15:38:15 +08:00

1075 lines
67 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.
"""双盲复算的选择写进数据目录:审核人在不一致的指标上选了原流程或盲算,而两边只差一个方面、又正好对得上数据目录
里的一项事实时,把被采纳那一边的写法记成人工确认过的目录项。
对得上的事实只有三种(data/catalog.py 的表级项):
- 两边按不同的时间列筛时间 → 业务日期(business_date):被采纳的一边按哪一列筛,业务日期就记哪一列。只有一边按时间
筛的不算:审核人判断的是要不要限定时间,没在两列之间选过。两边的列都得在表结构里声明为带日期的类型(DATE、
DATETIME、TIMESTAMP、TIMESTAMPTZ,带精度、带时区的写法也算):差异说明认时间列只看列名和常量像不像日期,
文本列(SQLite 常把日期存成文字)、整数列(Unix 秒、20260901 这类日期键)、名字带 time 的时长列都可能被说成「按它筛
时间」,认不准存的是不是日期,不记。只有时刻的 TIME 也不记:一天里的几点几分(MySQL 还常拿它存时长)不是日期,和
catalog.is_date_column(剖析和 SQL 检查共用的日期列判断)同一个口径——记成业务日期,SQL 检查会对按真实日期列统计的
查询一直报错误级。上传表格建的数据源,导入时日期一律按文字保存,表结构里总是 TEXT:这样的列看导入时配方里记的逻辑
类型(upload_dates,导入确认时界面上标的「日期」就是它),是日期的算日期列;配方读不出来(简单导入没有配方、配方
记录坏了)或者配方里不是日期的,照旧不记,原因照实说是上传的表格按文字保存,不说成用户声明了 TEXT
- 一边多一个排除无效记录的条件 → 有效记录条件(valid_filter):被采纳的一边有这个条件才记,记的就是这个条件
- 聚合只差去不去重、被采纳的一边去重 → 去重规则(dedup)。采纳了不去重的一边不记:那是这个指标自己的算法(数行),
写成表的「不去重」会把别的指标带偏
**为什么要这么严。** 目录是给助手、db_schema 工具、SQL 检查和下一次盲算看的数据事实,人工确认过的项还会让 SQL 检查
报错误级、以后的推断也不再覆盖它,写错了就一直错下去。只差一个方面时,人选的那一边就是对这一处的判断;差了好几处、
或者差异说明里有兜底(未能归纳的差异),人选的是「整体上哪边对」,拆不出是哪一处对,写进去就是替人编了一条事实。
两条 SQL 规范化后一致却算出不同的值(两次取数之间数据变了、结果被截断),差在数据不在写法,更没有可记的事实。
**有效记录条件和指标自己的筛选范围分不开。** 「华东销售额」的 region = '华东'、「线上订单数」的 channel = 'online'
在写法上和 status <> 'refunded' 一样是一个筛选条件,审核人选原流程只说明这个指标该这么筛,不说明整张表的有效记录
都得这么筛。写成表的有效记录条件,所有查这张表的工作流(包括下一次盲算)都会被带偏。所以条件要同时满足三条才写:
条件里的取值没有出现在指标名称、口径说明、这一格所在的分组、本次运行的输入里,用到的列也不是分组的列;每一段都是在
排除无效记录(排除的取值是退款、作废、测试这类,或者码值表里写着这类意思;标记列只认留下有效记录的那一边:
is_test = 0、is_valid = 1、deleted_at IS NULL);只涉及一张表。拿不准就不写,原因照实交回。
**怎么写。** 照对话提案的规矩核对(plan_patch:列在不在表结构里、取值格式),记为人工填写、已确认,备注写明来自哪次
运行、哪个节点、哪个指标。目录里这一项已经是人工确认过的另一种说法时不覆盖,照实交回原因;已经是同样的说法就什么都
不写(再写一遍也不升版本);这一项被人工驳回过、要写的又带回了驳回的说法(有效记录条件按段比:驳回的那句每一段都在
要写的条件里),两次人工判断对不上,不写。有效记录条件是一整句条件:目录里已有的条件被采纳的一边(几个指标并成一项
时是每一个)也按它筛了的,和新条件合起来写,已有的段只写一次(已确认的不改,照实说多出了哪几段);有一个没按它筛
的,两者的关系拿不准,不动。推断的去重规则说的未必是同一件事,也不替换。整个写入由节点放在 ask() 之后的一个 @task
里:审批恢复重放、接着运行时不会再写一遍。
**同一次裁决里几个指标落到同一项上。** 说法一样的写一次,备注里列出每个指标。有效记录条件是能用 AND 叠加的排除条件
(这里只收排除无效记录的),几个指标各自采纳的条件都有人的选择撑着,合成一句写——「排除退款」和「排除测试单」并不
矛盾。业务日期一张表只有一个,去重规则说的未必是同一件事,说法不同就一个都不写。合起来之前先按每个指标自己的说法
过一遍驳回检查:自己采纳的说法曾被人工驳回的指标拿出来不写、照实说原因,其余的照写;合起来以后又包含了驳回的整句的,
也不写。
**预告。** 审批卡上要让审核人看得出选了之后会往目录里写什么(preview):按发起审批时的目录,选原流程、选盲算各会
写什么、不写的为什么。认出了事实、只是被目录挡下(已确认、驳回过、和已有条件合不起来)的那一边也带上写到哪张表的
哪一项、本来要写什么,卡上才认得出它和别的指标是不是落到同一项上。同一次裁决里几个指标落到同一项上时,写进去的是
合起来的说法(上一段),单个指标的预告说不准:preview_joint 把这几个指标各自选哪一边的每种组合都按真写的办法算一遍
(_merged 归拢、_screen 拿掉驳回的、_plan 对目录),卡上按各指标当前的选择取对应的那一种。和真写是同一套判断、同一句
原因,只有审批停下期间目录被人改了、数据源换了版本时才会和最后写的不一样:真写按写入时的目录、表结构和导入时认作
日期的列判断。组合太多的(JOINT_LIMIT)那一项不预告,卡上有条件地说。
catalog.save_patch 把提案的依据另存成这一项确认时的依据(reason),这里要写的是备注里的出处(哪次运行、哪个节点),
所以用它的同一套原料(read_entry、apply_patch、write_entry 的乐观锁)写,再把这一项整个换成带出处备注的人工确认项:
以前确认时记下的依据说的是旧值,不留。
"""
from __future__ import annotations
import logging
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, replace
from typing import Any
from sqlalchemy import select
from app.data import catalog
from app.data.names import name_key
from app.db.base import SessionLocal
from app.db.models import DataSource
logger = logging.getLogger(__name__)
#: 能写的三项(表级)
FACT_ITEMS = ("business_date", "valid_filter", "dedup")
#: 审核人采纳的一边在差异说明里的位置:a 是原流程、b 是盲算(节点调用 explain_sql_diff 时按这个顺序给两边)
SIDE_LABEL = {"a": "原流程", "b": "盲算"}
#: 差异条目的方面(sql_semantics.FacetDiff.aspect)在说明里的叫法
ASPECT_LABEL = {
"time": "时间列", "time_range": "时间范围", "filter": "筛选条件", "aggregate": "聚合方式", "expression": "选出的列",
"join": "关联", "tables": "查询的表", "set_op": "几支查询的合并方式", "group_by": "分组", "having": "分组后的筛选",
"limit": "取哪几行", "order": "排序", "parse": "能否解析", "other": "未能归纳的地方",
}
#: 聚合函数的说法(去重规则那一句用)
_VERB = {"count": "计数", "sum": "求和", "avg": "求平均"}
#: 写入时版本对不上(别人刚改过这张表)最多重试几次
_RETRIES = 2
#: 同一次裁决里几个指标落到同一项上时,这一项最多预告多少种组合(preview_joint,每一项各算各的,不共用额度)。
#: 每个指标两边都落在这一项上时近于 3 的指标数次方:四个指标 72 种还在里面,再多的那一项不预告,审批卡回到按其余指标
#: 当前的选择有条件地说。组合数先算出来再比(_combos),不把组合列出来比:二十个指标就是几十亿种
JOINT_LIMIT = 128
#: 无效记录的取值:被排除的取值(或码值表里它的意思)里有这些词,才算在排除无效记录
_INVALID_WORDS = frozenset({
"refund", "refunded", "refunding", "cancel", "canceled", "cancelled", "void", "voided", "delete", "deleted",
"invalid", "test", "testing", "fraud", "spam", "dummy", "fake", "sandbox", "fail", "failed", "duplicate",
"duplicated", "obsolete",
})
_INVALID_CJK = ("退款", "作废", "取消", "测试", "无效", "删除", "撤销", "失败", "重复", "虚假", "欺诈", "废弃")
#: 标记列:列名里有这些词的,标记的是无效记录(is_test、is_deleted、del_flag、deleted_at……),留下的是没标记的
_MARKS_INVALID = _INVALID_WORDS | {"del", "removed", "hidden", "archived", "internal", "disabled", "inactive"}
#: 标记列:列名里有这些词的,标记的是有效记录(is_valid、enabled、is_active……),留下的是标记了的
_MARKS_VALID = frozenset({"valid", "enabled", "active", "effective"})
#: 取值和指标名称、口径说明比对时,数字至少这么长才比(0、1 这种标记值到处都是)
_SCOPE_DIGITS = 2
#: 表结构里声明的带日期的类型(业务日期只记这样的列):DATE、DATETIME、TIMESTAMP、TIMESTAMPTZ,可以带精度
#: (TIMESTAMP(3)、DATETIME(6))、带时区(TIMESTAMP WITH TIME ZONE、Oracle 的 WITH LOCAL TIME ZONE),大小写不论。整个
#: 类型名都得是这个样子:DATE_KEY、INTERVAL 这类名字里带着 date、time 的不算
_DATED_TYPE = re.compile(r"(?:date|datetime|timestamp|timestamptz)(?:\s*\(\s*\d+\s*\))?"
r"(?:\s+with(?:out)?\s+(?:local\s+)?time\s+zone)?", re.IGNORECASE)
#: 只有时刻的类型(TIME、TIMETZ、TIME(3)、TIME WITH TIME ZONE):不记,原因另说「只有时刻」
_TIME_ONLY_TYPE = re.compile(r"(?:time|timetz)(?:\s*\(\s*\d+\s*\))?(?:\s+with(?:out)?\s+time\s+zone)?",
re.IGNORECASE)
@dataclass(frozen=True)
class Fact:
"""要写进目录的一项:哪个数据源的哪张表、哪一项、写什么。metric、choice 只用来写备注和说明;sql、dialect 是被采纳
那一边的写法和方言,有效记录条件和目录里已有的条件合并时要看它还按哪些条件筛了。同一次裁决里几个指标落到同一项上
时并成一项:members 是并进来的每个指标各自的那一项(各带自己的说法,审批卡上各自的预告就是按它算的),value 是
合起来之后的说法;单个指标的 members 为空。metric_id 是指标 ID:几个指标并成一项时,预告按它说清每个指标的结果。"""
source: str
table: str
item: str
value: Any
metric: str
choice: str
sql: str = ""
dialect: str | None = None
members: tuple[Fact, ...] = ()
metric_id: str = ""
def _members(fact: Fact) -> tuple[Fact, ...]:
"""这一项背后的每个指标的选择:并成一项的是并进来的每一个(各带自己的说法),单个指标的是它自己。"""
return fact.members or (fact,)
def _names(members: Sequence[Fact]) -> str:
"""说明里的指标叫法:「销售额」,几个指标时「销售额」、「订单金额」。"""
names = list(dict.fromkeys(m.metric for m in members))
return "、".join(f"「{n}」" for n in names[:4]) + (f"等 {len(names)} 个" if len(names) > 4 else "")
def derive(candidate: Mapping[str, Any], schema_cache: Mapping[str, Any] | None,
notes: Mapping[str, Mapping[str, Any]] | None = None) -> tuple[Fact | None, str | None]:
"""一个候选(审核人在某个指标上选了 a 或 b)→ (要写的目录项, None) 或 (None, 为什么不写)。
candidate:{metric 指标名, choice "a" | "b", source 数据源名, dialect, sql: {a, b}(比较的两条 SQL), diff:
{equivalent, fallback, items: [FacetDiff.as_dict()]}, scope: {texts, columns}, uploaded, upload_dates}。scope 是
这个指标自己的范围:texts 是口径说明、所在分组的取值、本次运行的输入(指标名称总算在内),columns 是分组的列;有效
记录条件用它排除指标自己的筛选范围。uploaded 为真是上传表格建的数据源,upload_dates 是它导入时认作日期的列
({表名: [列名]},由 upload_dates 从配方读出):按文字保存的列在这里面的算日期列,不在的业务日期不记、原因照实说
是上传的表格按文字保存日期。两项都由节点发起审批时备好、放进候选,审批卡上的预告按它;真写时 record_choices 按
写入时的版本重读 upload_dates,和它现读的表结构出自同一个版本。schema_cache 是数据源的表结构:认表,业务日期还要
看两边的列声明的类型。notes 是这个数据源的目录({表名: notes}),认码值的意思用。只看差异说明,不重新比 SQL:
审核人看到的就是这份说明。
"""
name = str(candidate.get("metric") or "")
side = candidate.get("choice")
diff = candidate.get("diff") if isinstance(candidate.get("diff"), Mapping) else {}
if side not in SIDE_LABEL:
return None, f"指标「{name}」没有采用任何一边的结果,不写入数据目录"
if diff.get("equivalent"):
return None, f"指标「{name}」两边的 SQL 规范化后一致,差异来自数据而不是写法,不写入数据目录"
items = [i for i in diff.get("items") or [] if isinstance(i, Mapping)]
if diff.get("fallback") or len(items) != 1:
return None, (f"指标「{name}」两边的 SQL 不止差一个方面,或有未能归纳的差异,"
"无法确定审核人认可的是哪一处,不写入数据目录")
item = items[0]
aspect = str(item.get("aspect") or "")
if aspect == "time":
dates = candidate.get("upload_dates")
fact, why = _business_date(item, side, schema_cache, uploaded=candidate.get("uploaded") is True,
dates=dates if isinstance(dates, Mapping) else None)
elif aspect == "filter":
fact, why = _valid_filter(item, side, candidate, schema_cache, notes or {})
elif aspect == "aggregate":
fact, why = _dedup(item, side, candidate, schema_cache)
else:
return None, (f"指标「{name}」两边差在{ASPECT_LABEL.get(aspect, '其他地方')},"
"对不上数据目录里的业务日期、有效记录条件或去重规则,不写入数据目录")
if why is not None:
return None, f"指标「{name}」{why},不写入数据目录"
table, key, value = fact
resolved = catalog.resolve_table_name(schema_cache, table)
if resolved is None:
return None, f"指标「{name}」涉及的表「{table}」不在数据源的表结构里,不写入数据目录"
sql = candidate.get("sql") if isinstance(candidate.get("sql"), Mapping) else {}
dialect = candidate.get("dialect")
return Fact(source=str(candidate.get("source") or ""), table=resolved, item=key, value=value, metric=name,
choice=str(side), sql=str(sql.get(side) or ""),
dialect=dialect if isinstance(dialect, str) else None,
metric_id=str(candidate.get("metric_id") or "")), None
def _business_date(item: Mapping[str, Any], side: str, schema_cache: Mapping[str, Any] | None, *,
uploaded: bool = False, dates: Mapping[str, Any] | None = None
) -> tuple[tuple[str, str, Any] | None, str | None]:
"""两边按不同的列筛时间、被采纳的一边按哪一列:(表, business_date, {column}) 或 (None, 为什么不记)。两边的列都要在
表结构里声明为带日期的类型、或者是上传表格导入时认作日期的列(_dated_problem),被采纳的一边先看:它有问题说它;没
问题再看未被采纳的一边,另说清为什么也要看它(审核人看到的是自己选的那一列,不说就看不懂另一列为什么要紧)。数据源
还没有可用的表结构时(没探查、探查失败)只说表结构不可用:那时并不知道有没有这一列,说「不在表结构里」是在误导。"""
other = "b" if side == "a" else "a"
detail = item.get("detail") if isinstance(item.get("detail"), Mapping) else {}
def columns(key: str) -> list[Mapping[str, Any]]:
return [c for c in detail.get(key) or [] if isinstance(c, Mapping) and c.get("table") and c.get("column")]
mine, theirs = columns(side), columns(other)
if not mine:
return None, f"被采纳的{SIDE_LABEL[side]}没有按时间筛选,没有可记的业务日期"
if not theirs:
return None, (f"只有被采纳的{SIDE_LABEL[side]}按时间筛选,{SIDE_LABEL[other]}没有:审核人判断的是要不要限定"
"时间,不是按哪一列归属,没有可记的业务日期")
if len(mine) > 1 or len(theirs) > 1:
return None, "有一边按好几列筛时间,无法确定业务日期按哪一列"
if (mine[0]["table"], mine[0]["column"]) == (theirs[0]["table"], theirs[0]["column"]):
return None, "两边按同一列筛时间,没有可记的业务日期"
tables = schema_cache.get("tables") if isinstance(schema_cache, Mapping) else None
if not isinstance(tables, Mapping) or not tables:
return None, "所在数据源还没有可用的表结构(尚未探查或探查失败),无法确认两边按时间筛的列存的是日期"
for adopted, ref in ((True, mine[0]), (False, theirs[0])):
if (problem := _dated_problem(tables, ref, uploaded, dates)) is None:
continue
said = f"按时间筛的列「{ref['table']}.{ref['column']}」{problem}"
if adopted:
return None, f"被采纳的{SIDE_LABEL[side]}{said}"
return None, f"未被采纳的{SIDE_LABEL[other]}{said}:两边都是日期列时,审核人选的才是业务日期按哪一列"
return (str(mine[0]["table"]), "business_date", {"column": str(mine[0]["column"])}), None
def _dated_problem(tables: Mapping[str, Any], ref: Mapping[str, Any], uploaded: bool,
dates: Mapping[str, Any] | None = None) -> str | None:
"""按时间筛的这一列在表结构里是不是声明成了带日期的类型(_DATED_TYPE):是的为 None,不是的交回它怎么了(查不到、没
声明类型、只有时刻、声明成了别的类型)连同结论,接在「按时间筛的列「表.列」」后面读。
表按 catalog.resolve_table_name 认,列先按原样、再按 name_key 认(差异说明里的名字是规范化过的,SQLite 不加引号的
名字一律小写)。按 name_key 认出好几列的说对应好几列:哪一列是它说不准。类型照表结构里记的说(「类型是」,不说
「声明为」):introspect 记的是 SQLAlchemy 反射出来的类型,SQLite 里写成 TIMESTAMPTZ 的列反射出来是 NUMERIC;没写
类型的列反射出来是 NullType,记成「NULL」,按没有声明类型说。
上传表格建的数据源(uploaded)日期一律存成文字,表结构改不了,类型只能看导入时配方里记的(dates,见 upload_dates):
按文字保存的列在里面的就是日期列(按表结构里认出的表名、列名去对,大小写不论)。不在里面的(配方读不出来、配方里
不是日期)不说「类型是 TEXT」:那不是用户声明的,说清是上传的表格按文字保存。声明成别的类型的列照常说类型,只认
文字类型:配方里的日期列只会存成文字,别的类型的列存的就不是配方里那一列。"""
found_table = catalog.resolve_table_name({"tables": tables}, str(ref["table"]))
meta = tables.get(found_table) if found_table is not None else None
columns = [c for c in meta.get("columns") or [] if isinstance(c, Mapping) and c.get("name")] \
if isinstance(meta, Mapping) else []
column = str(ref["column"])
found = [c for c in columns if str(c["name"]) == column] \
or [c for c in columns if name_key(str(c["name"])) == name_key(column)]
if not found:
return "不在数据源的表结构里,无法确认存的是日期"
if len(found) > 1:
return "在表结构里对应好几列,无法确认存的是日期"
declared = " ".join(str(found[0].get("type") or "").split())
if not declared or declared.upper() == "NULL":
return "在表结构里没有声明类型,无法确认存的是日期"
if _DATED_TYPE.fullmatch(declared):
return None
if _TIME_ONLY_TYPE.fullmatch(declared):
return f"在表结构里的类型是 {declared},只有时刻,不含日期"
if uploaded and any(word in declared.upper() for word in catalog._TEXT_TYPES):
if _imported_as_date(dates, str(found_table), str(found[0]["name"])):
return None
return "来自上传的表格,表格里的日期一律按文字保存,无法确认存的是日期"
return f"在表结构里的类型是 {declared},无法确认存的是日期"
def _imported_as_date(dates: Mapping[str, Any] | None, table: str, column: str) -> bool:
"""表结构里的这一列是不是上传表格导入时认作日期的列(dates:{表名: [列名]})。表、列都先按原样、再按 name_key 对:
名字出自同一份配方,本来一字不差;按 name_key 对上好几个的不算,说不准是哪一个。"""
if not isinstance(dates, Mapping):
return False
def one(names: Any, wanted: str) -> str | None:
names = [str(n) for n in names if isinstance(n, str)]
if wanted in names:
return wanted
loose = [n for n in names if name_key(n) == name_key(wanted)]
return loose[0] if len(loose) == 1 else None
key = one(list(dates), table)
columns = dates.get(key) if key is not None else None
return isinstance(columns, (list, tuple)) and one(columns, column) is not None
# ---- 上传表格导入时认作日期的列:配方里记的逻辑类型
def recipe_dates(recipe: Any) -> dict[str, list[str]]:
"""一份配方(recipe_types.Recipe)里逻辑类型是日期的列:{表名: [列名]},没有日期列的表不列。
就是导入确认时界面上标「日期」的那些(recipe_notes 给列写日期格式说明认的也是这两种):交叉表的日期轴(Axis.type 恒为
日期),列表块里类型记为 DATE 的列。执行器把它们一律存成 YYYY-MM-DD 的文字,表结构里只剩 TEXT。哪张表有哪几列按
recipe_types.derive_tables 推(建表用的同一份):日期轴写进它各分段的表,列表合计行另存的表只有合计项和数字列。
列表的类型按 _DATED_TYPE 认,配方以后添了日期时间也算;只有时刻的不算。几个列表块写同一张表的,这一列每一块都
记为日期才算:日期和文字都存成 TEXT,建出的表形状一样,配方校验不拦,可按文字导入的那一块存的是单元格原文。"""
from app.data.recipe_types import ListBlock, derive_tables
tables, _ = derive_tables(recipe)
listed: dict[tuple[str, str], bool] = {}
for sheet in recipe.sheets:
for block in sheet.blocks:
if isinstance(block, ListBlock):
for column in block.columns:
key = (block.table, column.name)
listed[key] = listed.get(key, True) and _DATED_TYPE.fullmatch(column.type) is not None
out: dict[str, list[str]] = {}
for table, columns in tables.items():
names = [c.name for c in columns if c.role == "axis" or listed.get((table, c.name), False)]
if names:
out[table] = list(dict.fromkeys(names))
return out
async def upload_dates(session: Any, row: DataSource | None) -> dict[str, list[str]]:
"""上传表格建的数据源当前版本里,导入时认作日期的列:{表名: [列名]}(recipe_dates)。只读,不碰导入流程。
配方取现行的那一版(current_recipe_id):发布、启用旧版本时它和当前快照、表结构(schema_cache)一起换
(table_versions.activate_snapshot),配方和表结构出自同一个版本。读不出来的一律给空——手工登记的源、简单导入
(没有配方,构建回执里只有存储类型)、配方记录不在了、配方解析不了:业务日期照旧不记,原因说按文字保存。"""
from app.data.recipe_types import Recipe
from app.db.models import TableRecipe
if row is None or (row.origin or "manual") != "upload" or not row.current_recipe_id:
return {}
record = await session.get(TableRecipe, row.current_recipe_id)
if record is None or record.source_id != row.id:
return {}
try:
return recipe_dates(Recipe.model_validate(record.recipe))
except ValueError:
logger.warning("上传表格的配方记录解析不了,业务日期按文字保存处理:source=%s recipe=%s", row.id, record.id)
return {}
def _valid_filter(item: Mapping[str, Any], side: str, candidate: Mapping[str, Any],
schema_cache: Mapping[str, Any] | None,
notes: Mapping[str, Mapping[str, Any]]) -> tuple[tuple[str, str, Any] | None, str | None]:
other = "b" if side == "a" else "a"
condition, rival = item.get(side), item.get(other)
if rival is not None:
if condition is None:
return None, f"多出筛选条件的是{SIDE_LABEL[other]},被采纳的{SIDE_LABEL[side]}没有这个条件"
return None, "两边各有不同的筛选条件,不是一边多出一个条件"
if not isinstance(condition, str) or not condition.strip():
return None, "差异说明里没有被采纳一边的筛选条件"
condition = condition.strip()
detail = item.get("detail") if isinstance(item.get("detail"), Mapping) else {}
refs = [c for c in detail.get("columns") or [] if isinstance(c, Mapping) and c.get("table") and c.get("column")]
tables = {str(c["table"]) for c in refs}
if len(tables) != 1:
return None, "筛选条件不是只涉及一张表的列"
(table,) = tables
read = _read(candidate.get("dialect"))
tree = _parse(condition, read)
if tree is None:
return None, f"多出的条件「{condition}」无法解析,无法确定是不是有效记录条件"
scope = candidate.get("scope") if isinstance(candidate.get("scope"), Mapping) else {}
grouped = {str(c).lower() for c in scope.get("columns") or [] if isinstance(c, str)}
if hit := sorted({str(c["column"]) for c in refs if str(c["column"]).lower() in grouped}):
return None, (f"多出的条件「{condition}」用到的「{'、'.join(hit)}」是原流程分组的列,可能是指标自己的筛选范围,"
"不是整张表的有效记录条件")
texts = [str(candidate.get("metric") or ""), *(str(t) for t in scope.get("texts") or [] if isinstance(t, str))]
if (word := _scope_hit(tree, texts)) is not None:
return None, (f"多出的条件「{condition}」里的「{word}」出现在指标名称、口径说明、所在分组或本次运行的输入里,"
"可能是指标自己的筛选范围,不是整张表的有效记录条件")
resolved = catalog.resolve_table_name(schema_cache, table) or table
if not _drops_invalid(tree, notes.get(resolved) or {}):
return None, (f"多出的条件「{condition}」不是在排除退款、作废、测试这类无效记录,可能是指标自己的筛选范围"
"(例如只看某个地区、某个渠道),不是整张表的有效记录条件")
return (table, "valid_filter", _unqualified(condition, candidate.get("dialect"))), None
# ---- 有效记录条件:每一段都在排除无效记录
def _read(dialect: Any) -> str | None:
from app.data.sqlcheck import dialect_of
return dialect_of(dialect) if isinstance(dialect, str) else None
def _parse(condition: str, read: str | None) -> Any:
import sqlglot
try:
return sqlglot.parse_one(condition, read=read)
except Exception: # noqa: BLE001 - 写法特殊、解析不了:调用方当作认不出
return None
def _unparen(node: Any) -> Any:
from sqlglot import exp
while isinstance(node, exp.Paren):
node = node.this
return node
def _and_parts(node: Any) -> list[Any]:
"""条件按 AND 拆成几段(括号拆开)。"""
from sqlglot import exp
node = _unparen(node)
if isinstance(node, exp.And):
return _and_parts(node.this) + _and_parts(node.expression)
return [node]
def _literal(node: Any) -> tuple[bool, Any]:
"""常量的值:(是不是常量, 值)。字符串原样,数字转成 int / float,TRUE / FALSE 是 bool。"""
from sqlglot import exp
node = _unparen(node)
if isinstance(node, exp.Boolean):
return True, bool(node.this)
negative = isinstance(node, exp.Neg)
if negative:
node = _unparen(node.this)
if not isinstance(node, exp.Literal):
return False, None
if node.is_string:
return (False, None) if negative else (True, str(node.this))
try:
number = float(node.this) if any(ch in str(node.this) for ch in ".eE") else int(node.this)
except ValueError:
return False, None
return True, -number if negative else number
def _scope_hit(tree: Any, texts: list[str]) -> str | None:
"""条件里的取值出现在指标自己的范围里(指标名称、口径说明、分组、运行输入):返回那个取值,没有返回 None。"""
from sqlglot import exp
lowered = [t.lower() for t in texts if t.strip()]
for node in tree.find_all(exp.Literal):
word = str(node.this).strip()
if not word:
continue
if node.is_string:
if any(word.lower() in t for t in lowered):
return word
elif len(word) >= _SCOPE_DIGITS:
pattern = re.compile(rf"(?<![0-9]){re.escape(word)}(?![0-9])")
if any(pattern.search(t) for t in lowered):
return word
return None
def _invalid_text(text: str) -> bool:
lowered = text.lower()
return any(w in _INVALID_WORDS for w in re.split(r"[^a-z0-9]+", lowered) if w) or \
any(mark in text for mark in _INVALID_CJK)
def _invalid_value(value: Any, column: str, notes: Mapping[str, Any]) -> bool:
"""被排除的取值是不是无效记录:取值本身('refunded'、'已作废'),或者码值表里它的意思(9 → 作废)。"""
if isinstance(value, bool):
return False
if isinstance(value, str) and _invalid_text(value):
return True
columns = notes.get("columns") if isinstance(notes.get("columns"), Mapping) else {}
entry = next((v for k, v in columns.items() if str(k).lower() == column.lower()), None)
codes = entry.get("codes") if isinstance(entry, Mapping) else None
if not isinstance(codes, Mapping) or codes.get("status") == "rejected" \
or not isinstance(codes.get("value"), Mapping):
return False
key = str(int(value)) if isinstance(value, float) and value.is_integer() else str(value)
meaning = codes["value"].get(key)
return isinstance(meaning, str) and _invalid_text(meaning)
def _polarity(column: str) -> str | None:
"""标记列标记的是什么:invalid(is_test、deleted_at)、valid(is_valid、enabled),不是标记列为 None。"""
words = catalog.name_words(column)
if any(w in _MARKS_INVALID for w in words):
return "invalid"
if any(w in _MARKS_VALID for w in words):
return "valid"
return None
def _flag_value(value: Any) -> bool | None:
"""标记值:0 / 1 / TRUE / FALSE → bool,别的为 None。"""
if isinstance(value, bool):
return value
if isinstance(value, (int, float)) and value in (0, 1):
return bool(value)
return None
def _drops_invalid(tree: Any, notes: Mapping[str, Any]) -> bool:
"""条件的每一段都在排除无效记录。"""
return all(_drops_invalid_part(part, notes) for part in _and_parts(tree))
def _drops_invalid_part(node: Any, notes: Mapping[str, Any]) -> bool:
from sqlglot import exp
node = _unparen(node)
negated = False
if isinstance(node, exp.Not):
negated, node = True, _unparen(node.this)
if isinstance(node, exp.NEQ):
negated, node = not negated, exp.EQ(this=node.this.copy(), expression=node.expression.copy())
if isinstance(node, exp.EQ):
left, right = _unparen(node.this), _unparen(node.expression)
column, other = (left, right) if isinstance(left, exp.Column) else (right, left)
ok, value = _literal(other)
return isinstance(column, exp.Column) and ok and _keeps_valid(column.name, [value], negated, notes)
if isinstance(node, exp.In) and isinstance(_unparen(node.this), exp.Column) and not node.args.get("query"):
values = [_literal(e) for e in node.expressions]
return bool(values) and all(ok for ok, _ in values) and \
_keeps_valid(_unparen(node.this).name, [v for _, v in values], negated, notes)
if isinstance(node, exp.Is) and isinstance(node.expression, exp.Null) \
and isinstance(_unparen(node.this), exp.Column):
# 列 IS NULL 留下的是没标记的行:标记无效记录的列(deleted_at IS NULL)才是有效记录;IS NOT NULL 反过来
polarity = _polarity(_unparen(node.this).name)
return polarity == ("valid" if negated else "invalid")
if isinstance(node, exp.Column):
# 布尔标记列直接当条件写:is_valid、NOT is_test
return _polarity(node.name) == ("invalid" if negated else "valid")
if isinstance(node, (exp.Like, exp.ILike)) and negated:
ok, pattern = _literal(node.expression)
return ok and isinstance(pattern, str) and _invalid_text(pattern)
return False
def _keeps_valid(column: str, values: list[Any], negated: bool, notes: Mapping[str, Any]) -> bool:
"""列 = 取值(negated 时是 <>、NOT IN)留下的是不是有效记录。"""
polarity = _polarity(column)
flags = [_flag_value(v) for v in values]
if polarity is not None and all(f is not None for f in flags):
if len(set(flags)) != 1:
return False
# is_test = 1 留下的是标记了的行,is_test <> 1 留下的是没标记的;标记有效记录的列反过来
keeps_marked = flags[0] != negated
return keeps_marked if polarity == "valid" else not keeps_marked
# 排除几个取值:被排除的都得是无效记录。只保留几个取值(status = 'paid')就是指标自己的范围
return negated and all(_invalid_value(v, column, notes) for v in values)
def _unqualified(condition: str, dialect: Any) -> str:
"""条件里的列去掉表名:有效记录条件记在这张表自己名下,写成 status <> 'refunded' 而不是 orders.status <> …。
解析不了就原样。"""
from sqlglot import exp
read = _read(dialect)
tree = _parse(condition, read)
if tree is None or not any(col.table for col in tree.find_all(exp.Column)):
return condition
for col in tree.find_all(exp.Column):
col.set("table", None)
try:
return tree.sql(dialect=read)
except Exception: # noqa: BLE001
return condition
def _normalized(node: Any, read: str | None) -> str:
"""一段条件的比较用写法:列去掉表名、列名小写,按方言重新写一遍(<> 和 !=、NOT IN 的两种写法认成一样)。"""
from sqlglot import exp
copy = node.copy()
for col in list(copy.find_all(exp.Column)):
col.set("table", None)
col.set("this", exp.to_identifier(col.name.lower()))
return copy.sql(dialect=read)
def _conditions_of(sql: str, read: str | None) -> set[str] | None:
"""一条 SQL 里所有 WHERE 的每一段(比较用写法);解析不了返回 None。"""
import sqlglot
from sqlglot import exp
try:
tree = sqlglot.parse_one(sql, read=read)
except Exception: # noqa: BLE001
return None
return {_normalized(part, read) for where in tree.find_all(exp.Where) for part in _and_parts(where.this)}
def _keys(condition: Any, read: str | None) -> set[str] | None:
"""一句条件按 AND 拆开后每一段的比较用写法;不是文字、解析不了返回 None。"""
tree = _parse(condition, read) if isinstance(condition, str) and condition.strip() else None
return {_normalized(part, read) for part in _and_parts(tree)} if tree is not None else None
def _and_join(base: str | None, values: list[str], dialect: str | None, *, keep_base: bool = True) -> str | None:
"""几句条件用 AND 接成一句:已经有的段(base 里的、前面接过的,按 _normalized 比)不再接。一句里每一段都是新的,
原样接上(保留原来的写法);只有几段是新的,只接那几段(按方言重新写)——整句接上会把已有的段再写一遍。
keep_base 为假时 base 只用来认哪些段已经有了、不写进结果(说「另外多出哪些条件」用),什么都没多出时为空串。
不止一段时,最外层是 OR 的加括号,不然后面接的条件只并到 OR 的后一半上。有一句解析不了返回 None。"""
from sqlglot import exp
read = _read(dialect)
pieces: list[tuple[str, bool]] = [] # (写法, 最外层是不是 OR)
seen: set[str] = set()
for i, text in enumerate([*([base] if base else []), *values]):
tree = _parse(str(text), read)
if tree is None:
return None
parts = _and_parts(tree)
fresh = []
for part in parts:
key = _normalized(part, read)
if key not in seen:
seen.add(key)
fresh.append(part)
if not fresh or (base and i == 0 and not keep_base):
continue
if len(fresh) == len(parts):
pieces.append((str(text).strip(), isinstance(tree, exp.Or)))
else:
pieces += [(part.sql(dialect=read), isinstance(part, exp.Or)) for part in fresh]
if len(pieces) == 1:
return pieces[0][0]
return " AND ".join(f"({text})" if is_or else text for text, is_or in pieces)
def _combined_filter(existing: str, fact: Fact) -> tuple[str | None, str | None]:
"""目录里已有的有效记录条件和这次的新条件合起来:(合起来的整句条件, None) 或 (None, 为什么不动)。
有效记录条件是一整句(copilot 提案也要求「原有条件也要带上」)。只写新条件会把两边都按着筛的原有条件静默替换掉;
原有条件有一个被采纳的一边没按它筛,审核人的选择说明不了它对不对(合起来写就等于替人确认了它),也不能替人删掉。
接的是每个指标各自的条件(不是并好的整句):哪个指标的条件和已有的重了,那一段都只写一次。"""
label = catalog.TABLE_FIELD_LABEL["valid_filter"]
read = _read(fact.dialect)
have = _keys(existing, read)
combined = _and_join(existing, [str(m.value) for m in _members(fact)], fact.dialect) \
if have is not None else None
if have is None or combined is None:
return None, f"「{fact.table}」已有的{label}「{existing}」无法解析,与这次的条件合不起来,未改动"
for member in _members(fact):
if not have <= (_conditions_of(member.sql, read) or set()):
return None, (f"「{fact.table}」已有的{label}「{existing}」,指标「{member.metric}」被采纳的"
f"{SIDE_LABEL.get(member.choice, member.choice)}没有按它筛选,两者的关系无法确定,未改动。"
"如需更改,请到数据目录中修改")
return combined, None
def _dedup(item: Mapping[str, Any], side: str, candidate: Mapping[str, Any],
schema_cache: Mapping[str, Any] | None) -> tuple[tuple[str, str, Any] | None, str | None]:
"""聚合只差去不去重(同一个函数、同一个参数,一边写了 DISTINCT),被采纳的一边去重。表从被采纳那一边的 SQL 里认:
参数列属于哪张实表。"""
from app.data.sql_semantics import facets
pair = _aggregate_pair(item.get("a"), item.get("b"), candidate.get("dialect"))
if pair is None:
return None, "两边的聚合不只是去不去重的差别"
func, distinct = pair
adopted = distinct[0] if side == "a" else distinct[1]
if not adopted:
return None, (f"被采纳的{SIDE_LABEL[side]}没有去重:不去重是这个指标自己的算法,"
"不是表的去重规则")
sql = (candidate.get("sql") or {}).get(side) if isinstance(candidate.get("sql"), Mapping) else None
found = facets(str(sql or ""), candidate.get("dialect"), schema=schema_cache)
hits = [a for a in found.aggregates if a.func == func and a.distinct == adopted and not a.scope]
columns = {ref for a in hits for ref in a.arg.columns}
if len(columns) != 1:
return None, "无法确定去重的是哪一张表的哪一列"
((table, column),) = columns
if table is None:
# 参数列没认出属于哪张表(没给表结构、列没写表名):整条语句只查了一张表时就是它
names = list(dict.fromkeys(t.text for t in found.tables))
if len(names) != 1:
return None, "无法确定去重的列属于哪一张表"
table = names[0]
return (str(table), "dedup", f"对 {column} {_VERB.get(func, func)}时去重"), None
def _aggregate_pair(a: Any, b: Any, dialect: Any) -> tuple[str, tuple[bool, bool]] | None:
"""两边的聚合写法 → (函数名, (A 去重吗, B 去重吗));不是「同一个函数、同一个参数、只差 DISTINCT」返回 None。"""
from sqlglot import exp
read = _read(dialect)
def shape(text: Any) -> tuple[str, str, bool] | None:
if not isinstance(text, str) or not text.strip():
return None
node = _parse(text, read)
if not isinstance(node, exp.AggFunc):
return None
arg = node.this
distinct = isinstance(arg, exp.Distinct)
if distinct:
inner = arg.expressions
if len(inner) != 1:
return None
arg = inner[0]
if arg is None:
return None
return node.key, arg.sql(dialect=read), distinct
left, right = shape(a), shape(b)
if left is None or right is None or left[:2] != right[:2] or left[2] == right[2]:
return None
return left[0], (left[2], right[2])
# --------------------------------------------------------------------------
# 写入
# --------------------------------------------------------------------------
def note_of(fact: Fact, *, run_id: str, node_title: str) -> str:
"""目录项上的备注:来自哪次运行、哪个节点、哪些指标、各采纳了哪一边。"""
parts = []
for side, label in SIDE_LABEL.items():
names = list(dict.fromkeys(m.metric for m in _members(fact) if m.choice == side))
if names:
parts.append(f"在指标{'、'.join(f'「{n}」' for n in names)}上采用了{label}的写法")
return f"双盲复算(运行 {run_id},节点「{node_title}」):审核人" + ",".join(parts)
def _value_text(item: str, value: Any) -> str:
if item == "business_date" and isinstance(value, Mapping):
return str(value.get("column") or "")
return str(value)
def _same_fact(item: str, a: Any, b: Any) -> bool:
"""目录里的一项和要写的是不是同一个说法:业务日期只看哪一列(规则、时区是补充),文字压掉多余的空白比。"""
if item == "business_date":
return isinstance(a, Mapping) and isinstance(b, Mapping) and a.get("column") == b.get("column")
return isinstance(a, str) and isinstance(b, str) and " ".join(a.split()) == " ".join(b.split())
def _differing(facts: list[Fact]) -> str:
"""同一次裁决里对同一项给出的不同说法,说清是谁、各说了什么。"""
first = facts[0]
label = catalog.TABLE_FIELD_LABEL.get(first.item, first.item)
said = ";".join(f"指标「{f.metric}」采纳的是「{_value_text(f.item, f.value)}」" for f in facts[:4])
return (f"同一次裁决里对「{first.table}」的{label}给出了不同的说法({said}),无法确定哪一个是数据事实,"
"都未写入数据目录")
def _merged(members: list[Fact]) -> tuple[Fact | None, str | None]:
"""同一次裁决里落到同一项上的几个说法合成一项:(合起来的那一项, None) 或 (None, 为什么都不写)。只有一个时就是它。
说法都一样的写一次。有效记录条件用 AND 合成一句(derive 只收排除无效记录的条件,几句叠起来不会互相打架)。
业务日期一张表只有一个、去重规则说的未必是同一件事,说法不同就都不写。每个指标各自的那一项原样留在 members 里:
驳回检查(_screen)、和目录里已有条件的合并(_combined_filter)都要按各自的说法算。"""
first = members[0]
if len(members) == 1:
return first, None
if first.item == "valid_filter":
value = _and_join(None, [str(m.value) for m in members], first.dialect)
return (replace(first, value=value, members=tuple(members)), None) if value is not None \
else (None, _differing(members))
if all(_same_fact(first.item, m.value, first.value) for m in members[1:]):
return replace(first, members=tuple(members)), None
return None, _differing(members)
def _rejected(notes: Mapping[str, Any], item: str) -> tuple[bool, Any]:
"""目录里这一项是不是被人工驳回的:(是不是, 驳回的说法)。"""
stored = notes.get(item)
if isinstance(stored, Mapping) and stored.get("status") == "rejected":
return True, stored.get("value")
return False, None
def _revives(fact: Fact, rejected: Any) -> bool:
"""要写的说法是不是又带回了曾被人工驳回的说法。业务日期、去重规则:同一个说法。有效记录条件:驳回的那句的每一段
都在要写的条件里(按 _normalized 比,!= 和 <>、列名大小写、先后不同都认得出;有一边解析不了按原文比)——驳回的
那句只是要写的一部分也算,写进去就等于替人把驳回过的条件又确认了一遍。"""
if fact.item != "valid_filter":
return _same_fact(fact.item, rejected, fact.value)
read = _read(fact.dialect)
mine, theirs = _keys(fact.value, read), _keys(rejected, read)
if mine is None or theirs is None:
return _same_fact(fact.item, rejected, fact.value)
return bool(theirs) and theirs <= mine
def _rejected_text(fact: Fact, rejected: Any, members: Sequence[Fact]) -> str:
"""驳回检查拦下时的说法:驳回的是哪一句(照目录里记的原文),哪几个指标的选择又带回了它。审批卡上的预告和真写
拦下时用的是同一句话。"""
label = catalog.TABLE_FIELD_LABEL.get(fact.item, fact.item)
return (f"「{fact.table}」的{label}「{_value_text(fact.item, rejected)}」曾被人工驳回,与这次指标{_names(members)}"
"的选择相反,未写入。如需更改,请到数据目录中修改")
def _screen(notes: Mapping[str, Any], fact: Fact) -> tuple[Fact | None, list[Fact], list[str]]:
"""几个指标并成的一项,先拿掉自己采纳的说法曾被人工驳回的指标:(剩下的重新并成的一项, 拿掉的指标, 没写的原因),
一个都不剩、或者剩下的又合不起来时前者为 None;原因先是拿掉的那几个的(一条),剩下的合不起来时再跟一条。单个指标
的、这一项没被驳回过的原样返回,由 _plan 照常判断。
审批卡上每个指标的预告是按它自己的说法单独算的(preview → _plan),在那里被驳回检查拦下、卡上写着「不写」的,
并进来以后也得拦下。只拿合起来的整句去比,「status <> 'refunded'」和别的条件 AND 在一起就不再等于驳回的那句,
会被写成人工确认,和卡上说的正相反。拿掉的指标照实说原因(和它的预告同一句话),其余的照写,也和各自的预告一致。"""
rejected, said = _rejected(notes, fact.item)
if not rejected or not fact.members:
return fact, [], []
out: list[Fact] = []
kept: list[Fact] = []
for member in fact.members:
(out if _revives(member, said) else kept).append(member)
if not out:
return fact, [], []
reasons = [_rejected_text(fact, said, out)]
if not kept:
return None, out, reasons
narrowed, why = _merged(kept)
return narrowed, out, reasons if why is None else [*reasons, why]
async def record_choices(candidates: list[Mapping[str, Any]], *, run_id: str, node_title: str,
actor: str | None) -> dict[str, list[dict[str, Any]]]:
"""把审核人的选择写进数据目录:{written: [{source, table, item, value}], skipped: [{reason}]}。value 是真写进去的
说法(和已有条件合起来的就是合起来的整句),审批卡上的预告说「会记为」的就是它。永远不抛:数据源没了、目录写不
进去,都落在 skipped 里照实说。
先把每个候选认成目录项,再按(数据源、表、项)归拢成一项(_merged:说法一样的、有效记录条件合起来写,说法
不同的整组不写),逐项写(_write:按读到的目录先拿掉各自带回驳回说法的指标,其余的照写)。认目录项用的表结构、
目录、上传表格导入时认作日期的列(upload_dates)都按写入时数据源的现状读。"""
skipped: list[dict[str, Any]] = []
facts: list[Fact] = []
for candidate in candidates:
name = str(candidate.get("source") or "")
try:
async with SessionLocal() as session:
row = (await session.execute(select(DataSource).where(DataSource.name == name))).scalar_one_or_none()
if row is None:
skipped.append({"reason": f"数据源「{name}」已不存在,指标「{candidate.get('metric')}」的选择未写入数据目录"})
continue
notes = {table: entry.notes for table, entry in (await catalog.read_catalog(session, row.id)).items()}
if "upload_dates" in candidate:
# 表结构是这里现读的,导入时认作日期的列也按写入时的当前版本重读:审批停着时换了版本(日期列改成了
# 文字),拿发起审批时读的那份去配新的表结构,会写出一条哪个版本单独看都不会给出的人工确认。候选里
# 没有这一项的是升级前发起的审批,卡上预告的是不写,照旧不认
candidate = {**candidate, "upload_dates": await upload_dates(session, row)}
fact, why = derive(candidate, row.schema_cache, notes)
except Exception: # noqa: BLE001 - 目录读不出来不能毁掉审批的结果:缺口照样按选择记
logger.exception("双盲复算的选择核对数据目录失败(数据源 %s)", name)
skipped.append({"reason": f"指标「{candidate.get('metric')}」的选择核对数据目录时出错,未写入"})
continue
if fact is None:
skipped.append({"reason": why})
else:
facts.append(fact)
groups: dict[tuple[str, str, str], list[Fact]] = {}
for fact in facts:
groups.setdefault((fact.source, fact.table, fact.item), []).append(fact)
written: list[dict[str, Any]] = []
for members in groups.values():
fact, why = _merged(members)
if fact is None:
skipped.append({"reason": why})
continue
value, reasons = await _record(fact, run_id=run_id, node_title=node_title, actor=actor)
if value is not None:
written.append({"source": fact.source, "table": fact.table, "item": fact.item, "value": value})
skipped += [{"reason": r} for r in reasons]
return {"written": written, "skipped": skipped}
async def _record(fact: Fact, *, run_id: str, node_title: str, actor: str | None) -> tuple[Any, list[str]]:
"""写一项:(写进去的说法,没写为 None, 没写的原因)。几个指标并成的一项可能写了、同时有几个指标被拿出来没写,原因
照样交回。"""
try:
async with SessionLocal() as session:
row = (await session.execute(select(DataSource).where(DataSource.name == fact.source))).scalar_one_or_none()
if row is None:
return None, [f"数据源「{fact.source}」已不存在,指标{_names(_members(fact))}的选择未写入数据目录"]
return await _write(session, row, fact, run_id=run_id, node_title=node_title, actor=actor)
except Exception: # noqa: BLE001 - 目录写不进去不能毁掉审批的结果:缺口照样按选择记
logger.exception("双盲复算的选择写入数据目录失败(数据源 %s)", fact.source)
return None, [f"指标{_names(_members(fact))}的选择写入数据目录时出错,未写入"]
def _plan(notes: Mapping[str, Any], fact: Fact, tables: Mapping[str, Any] | None) -> tuple[Any, str | None]:
"""按目录里这张表现在的说法,算出这一项要写成什么:(要写的取值, None) 或 (None, 为什么不写)。不读库、不写库:
真写(_write)和审批卡上的预告(preview)用的是这同一套判断。几个指标并成的一项,先由 _screen 拿掉各自带回驳回
说法的指标再交到这里。"""
label = catalog.TABLE_FIELD_LABEL.get(fact.item, fact.item)
names = _names(_members(fact))
stored = notes.get(fact.item) if isinstance(notes.get(fact.item), dict) else None
rejected, said = _rejected(notes, fact.item)
if rejected and _revives(fact, said):
# 同一个说法以前被人明确驳回过:两次人工判断对不上,不替人拿主意。并成的一项各自的说法都不是驳回的那句、
# 合起来却又包含了它,也在这里拦下
return None, _rejected_text(fact, said, _members(fact))
live = stored if stored is not None and not rejected else None
value = fact.value
if fact.item == "business_date" and live is not None and isinstance(live.get("value"), dict) \
and live["value"].get("column") == value.get("column"):
# 业务日期按哪一列是这次选出来的事实;目录里同一列另写了规则、时区的,原样留着
value = live["value"]
elif fact.item == "valid_filter" and isinstance(live, dict) and isinstance(live.get("value"), str) \
and live["value"].strip() and not _same_fact(fact.item, live["value"], value):
combined, why = _combined_filter(live["value"].strip(), fact)
if combined is None:
return None, why
if live.get("status") == "confirmed" and not _same_fact(fact.item, combined, live["value"]):
# 只说已确认的说法里没有的那几段:已有的段算成「多出」,审核人会以为连它也被改了
extra = _and_join(live["value"].strip(), [str(m.value) for m in _members(fact)], fact.dialect,
keep_base=False)
adopted = f"被采纳的{SIDE_LABEL.get(fact.choice, fact.choice)}" if not fact.members \
else "这次几个指标被采纳的写法"
return None, (f"「{fact.table}」的{label}已有人工确认过的说法「{live['value'].strip()}」,{adopted}"
f"另外多出条件「{extra or fact.value}」,未改动。如需补上,请到数据目录中修改")
value = combined
elif fact.item == "dedup" and isinstance(live, dict) and live.get("status") != "confirmed" \
and not _same_fact(fact.item, live.get("value"), value):
# 推断的去重规则说的未必是同一件事(按哪一列去重、哪种汇总要去重),替换掉就把它丢了
return None, (f"「{fact.table}」的{label}已有推断的说法「{_value_text(fact.item, live.get('value'))}」,"
f"与这次指标{names}采纳的「{value}」不同,无法确定两者的关系,未覆盖。"
"如需更改,请到数据目录中修改")
plan = catalog.plan_patch(notes, [{"path": fact.item, "value": value}], table=fact.table, tables=tables)
if plan.problems:
return None, f"「{fact.table}」的{label}未写入数据目录:{plan.problems[0]}"
change = plan.changes[0]
if change.state == "same":
return None, f"「{fact.table}」的{label}已是人工确认过的「{_value_text(fact.item, value)}」,无需重复写入"
if change.before_status == "confirmed":
return None, (f"「{fact.table}」的{label}已有人工确认过的说法「{_value_text(fact.item, change.before)}」,"
f"与这次指标{names}采纳的「{_value_text(fact.item, value)}」不同,未覆盖。"
"如需更改,请到数据目录中修改")
return change.after, None
def preview(candidate: Mapping[str, Any], schema_cache: Mapping[str, Any] | None,
notes: Mapping[str, Mapping[str, Any]] | None = None) -> dict[str, dict[str, Any]]:
"""审批卡上的预告:审核人在这个指标上选原流程(a)、选盲算(b),按目前的数据目录各会往目录里写什么。
candidate 同 derive(choice 不用给);notes 是这个数据源的目录({表名: notes})。返回 {a: …, b: …},每边是
{write: true, source, table, item, value}(value 是真要写进去的说法:和目录里已有的有效记录条件合起来的,是合起来
之后的整句)或 {write: false, reason};认出了事实、只是被目录挡下的不写,照样带上 source、table、item 和本来要写
的 value(这个指标自己的说法)——卡上据此认出几个指标落到同一项上。这个指标单独落在这一项上时,reason 和真写时
跳过的原因一字不差;和别的指标落到同一项上时看 preview_joint。永远不抛。"""
notes = notes or {}
tables = schema_cache.get("tables") if isinstance(schema_cache, Mapping) else None
out: dict[str, dict[str, Any]] = {}
for side in SIDE_LABEL:
try:
fact, why = derive({**candidate, "choice": side}, schema_cache, notes)
value = None
if fact is not None:
value, why = _plan(notes.get(fact.table) or {}, fact, tables)
except Exception: # noqa: BLE001 - 预告算不出来不能拦住审批:照实说,以写入后的结果为准
logger.exception("双盲复算预告数据目录的写入失败(指标 %s)", candidate.get("metric"))
fact, why = None, f"指标「{candidate.get('metric')}」的选择核对数据目录时出错,无法预告是否写入"
if fact is None:
out[side] = {"write": False, "reason": why}
continue
where = {"source": fact.source, "table": fact.table, "item": fact.item}
out[side] = {"write": True, **where, "value": value} if why is None else \
{"write": False, **where, "value": fact.value, "reason": why}
return out
def _joint_outcome(notes: Mapping[str, Any], members: list[Fact], tables: Mapping[str, Any] | None
) -> dict[str, dict[str, Any]]:
"""几个指标落到同一项上、各按自己的选择并成一项时,每个指标的结果:{指标 ID: {write: true, value} 或 {write: false,
reason}}。和 record_choices 逐步一样:_merged 归拢(说法不同的整组不写),_screen 拿掉带回驳回说法的(它们说驳回
那一句,剩下的合不起来时说合不起来的那一句),剩下的按 _plan 对目录(写的是同一个值,不写的是同一句原因)。"""
fact, why = _merged(members)
if fact is None:
return {m.metric_id: {"write": False, "reason": why} for m in members}
kept, out, reasons = _screen(notes, fact)
result = {m.metric_id: {"write": False, "reason": reasons[0]} for m in out}
rest = [m for m in members if m.metric_id not in result]
if kept is None:
result.update({m.metric_id: {"write": False, "reason": reasons[-1]} for m in rest})
return result
value, why = _plan(notes, kept, tables)
result.update({m.metric_id: {"write": False, "reason": why} if why is not None else {"write": True, "value": value}
for m in rest})
return result
def _combos(sides: Sequence[int], limit: int) -> int:
"""几个指标落到同一项上时有几种组合(至少两个指标落上来的):每个指标要么没落上来、要么落上来的那几边之一,
∏(1 + k) − 1 − Σk(k 是这个指标落到这一项上的边数,减去一个都没有、只有一个的)。连乘到超过 limit 能确定的地方
就停,返回 limit + 1:只用来和上限比,不需要真算出几十亿。"""
total, cap = 1, limit + 1 + sum(sides)
for k in sides:
total *= 1 + k
if total > cap:
return limit + 1
return total - 1 - sum(sides)
def preview_joint(candidates: Sequence[Mapping[str, Any]],
catalogs: Mapping[str, tuple[Mapping[str, Any] | None, Mapping[str, Mapping[str, Any]]]]
) -> list[dict[str, Any]]:
"""同一次裁决里几个指标落到同一项上时的预告:[{source, table, item, members: [{metric_id, choice}], outcomes:
{指标 ID: {write: true, value} 或 {write: false, reason}}}],每条是一种组合(members 是按各自的选择落到这一项上的
指标,照候选的先后,至少两个)。
candidates 照节点交给真写的先后(不一致的指标的先后),带 metric_id,不带 choice;catalogs 是 {数据源名: (表结构,
{表名: notes})},同 preview 的两个参数。每个指标的两边先各按 derive 认出落到哪一项(同 preview),落到同一项上的
几个指标,每种「谁选了哪一边」的组合按 _joint_outcome 算一遍:真写时这几个指标就是这样被归拢、写或不写的。
上限按每一项算:一项的组合数(_combos,先算个数,不先列出来)超过 JOINT_LIMIT 的,这一项整项不预告,别的项照常
预告,几项之间不共用额度。一项不超过 JOINT_LIMIT 种、每个指标最多落到两项上,总的计算量和指标数成正比。
永远不抛:算不出来的那一项不预告。"""
from itertools import product
landed: dict[tuple[str, str, str], dict[int, list[tuple[str, Fact]]]] = {}
for index, candidate in enumerate(candidates):
schema, notes = catalogs.get(str(candidate.get("source") or ""), (None, {}))
for side in SIDE_LABEL:
try:
fact, _ = derive({**candidate, "choice": side}, schema, notes)
except Exception: # noqa: BLE001 - 认不出就当这一边落不到任何一项上,同 preview 照实不说
fact = None
if fact is not None:
landed.setdefault((fact.source, fact.table, fact.item), {}).setdefault(index, []).append((side, fact))
out: list[dict[str, Any]] = []
for (source, table, item), by_metric in landed.items():
if len(by_metric) < 2 or _combos([len(sides) for sides in by_metric.values()], JOINT_LIMIT) > JOINT_LIMIT:
continue
options = [[None, *sides] for _, sides in sorted(by_metric.items())]
combos = [[c for c in combo if c is not None] for combo in product(*options)]
combos = [c for c in combos if len(c) >= 2]
schema, notes = catalogs.get(source, (None, {}))
tables = schema.get("tables") if isinstance(schema, Mapping) else None
try:
entries = [{"source": source, "table": table, "item": item,
"members": [{"metric_id": fact.metric_id, "choice": side} for side, fact in combo],
"outcomes": _joint_outcome((notes or {}).get(table) or {}, [fact for _, fact in combo], tables)}
for combo in combos]
except Exception: # noqa: BLE001 - 预告算不出来不能拦住审批:这一项不预告,卡上有条件地说
logger.exception("双盲复算预告几个指标落到同一项上的写入失败(%s 的 %s)", table, item)
continue
out.extend(entries)
return out
async def _write(session: Any, row: DataSource, fact: Fact, *, run_id: str, node_title: str,
actor: str | None) -> tuple[Any, list[str]]:
"""写一项:(写进去的说法,没写为 None, 没写的原因)。几个指标并成的一项先按读到的目录拿掉各自带回驳回说法的指标
(_screen),写什么、写不写再由 _plan 定;备注只列真写进去的指标。版本对不上(别人刚改过)重读再来。"""
label = catalog.TABLE_FIELD_LABEL.get(fact.item, fact.item)
tables = (row.schema_cache or {}).get("tables") if isinstance(row.schema_cache, dict) else None
reasons: list[str] = []
for _ in range(_RETRIES):
entry = await catalog.read_entry(session, row.id, fact.table)
notes = entry.notes if entry else {}
version = entry.version if entry else 0
kept, _, reasons = _screen(notes, fact)
if kept is None:
return None, reasons
value, why = _plan(notes, kept, tables)
if why is not None:
return None, [*reasons, why]
note = note_of(kept, run_id=run_id, node_title=node_title)
at = catalog.now_iso()
try:
merged = catalog.apply_patch(notes, [{"path": kept.item, "value": value}],
table=kept.table, tables=tables, at=at)
except catalog.CatalogInvalid as e:
return None, [*reasons, f"「{kept.table}」的{label}未写入数据目录:{e}"]
# 不论原来是谁提的:这一项现在出自人的选择,记为人工填写、已确认,备注写明出处(整项换掉:以前确认时的依据不留)
merged[kept.item] = catalog.make_item(value, "human", "confirmed", note=note, at=at)
try:
await catalog.write_entry(session, row.id, kept.table, merged, if_version=version, actor=actor)
except catalog.CatalogConflict:
await session.rollback()
continue
except catalog.CatalogInvalid as e:
return None, [*reasons, f"「{kept.table}」的{label}未写入数据目录:{e}"]
return value, reasons
return None, [*reasons, f"「{fact.table}」的数据目录正被修改,{label}未能写入,请稍后在数据目录中确认"]
__all__ = ["FACT_ITEMS", "JOINT_LIMIT", "Fact", "derive", "note_of", "preview", "preview_joint", "recipe_dates",
"record_choices", "upload_dates"]