Files
AgentLab/backend/app/analysis/facts.py
T

219 lines
10 KiB
Python
Raw 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.
"""目录事实:结果列给人看的叫法、度量类型,表的业务日期(细案 01 §2.1、§6、§7.3)。
算子要知道「gmv 是流量还是比率」(比率当可加是调用错误;窗口不等时流量判不可比、其余只提醒)、「region 叫地区」(判定语
写「分散在各地区」)、「orders 的业务日期是 order_date」(可比性检查按它取时间条件)。这些在数据目录里,可目录描述的是表的
列,快照里的是 SQL 的结果列,所以先按 SQL 认结果列取自哪张表的哪一列,认法同单元格的显示规则(engine/cell_format:最外层
SELECT 里按别名或列名找到那一项,看它是不是聚合、聚合的参数是不是一列、这一列在 FROM / JOIN 的哪张实表上),再查那一列
的目录项:
- SUM(x)、不聚合的 x:x 的度量类型;COUNT(*) / COUNT(x):计数,按流量算;AVG(x):平均值,按比率算(不能按段相加);
COUNT(DISTINCT x)、MIN / MAX、算式:认不出(当没标)。
- 叫法:x 的中文名(目录里的 label);没有就是没有,调用方用列名。
- 只认没被驳回的项(catalog.visible_notes)。度量类型另记有没有人确认过(confirmed / verified):只有确认过的才拿来判
调用错误,推断的只写进附带说明(同 SQL 检查「宁可漏报,不可误报」)。
**复算不读目录**:run 时查过的都记进结论的 params["catalog"](record),复算时 Facts 从那里读,同一个键得到同一个答案;
目录事后改了,封存的结论照样复算得出来。
"""
from __future__ import annotations
from collections.abc import Mapping
from functools import lru_cache
from typing import Any
from app.data.names import name_key
#: 有确证的项状态:只有它们拿来判调用错误
_STRONG = frozenset({"confirmed", "verified"})
def _notes_of(entry: Any) -> Mapping[str, Any] | None:
"""目录里一张表的 notes:CatalogEntry(有 .notes)或 notes 本身;去掉被驳回的项。"""
from app.data import catalog
notes = getattr(entry, "notes", entry)
return catalog.visible_notes(notes) if isinstance(notes, Mapping) else None
def _table_notes(catalog: Mapping[str, Any] | None, table: str | None) -> Mapping[str, Any] | None:
if not catalog or not table:
return None
wanted = {name_key(table), name_key(table.rsplit(".", 1)[-1])}
for name, entry in catalog.items():
if name_key(str(name)) in wanted or name_key(str(name).rsplit(".", 1)[-1]) in wanted:
return _notes_of(entry)
return None
def _item(notes: Mapping[str, Any] | None, column: str, key: str) -> Mapping[str, Any] | None:
columns = (notes or {}).get("columns") if isinstance(notes, Mapping) else None
if not isinstance(columns, Mapping):
return None
for name, items in columns.items():
if name_key(str(name)) == name_key(column) and isinstance(items, Mapping):
item = items.get(key)
return item if isinstance(item, Mapping) else None
return None
@lru_cache(maxsize=128)
def _outer_select(sql: str) -> Any:
"""最外层的 SELECT(engine/cell_format 的读法);同一条 SQL 只解析一次(只读,不改这棵树)。"""
from app.engine import cell_format as cf
try:
return cf._outer_select(sql)
except Exception: # noqa: BLE001
return None
def source_of(sql: str, column: str) -> tuple[str | None, str | None, str | None, bool] | None:
"""结果列 column 取自哪里:(表, 列, 聚合, 去重)。聚合是 sum / count / avg / min / max / other(别的聚合)或 None(不聚合);
表在 SELECT * 、子查询、认不准的时候为 None。SQL 解析不了、结果列在最外层 SELECT 里找不到为 None。"""
from sqlglot import exp
from app.engine import cell_format as cf
try:
select = _outer_select(sql)
if select is None:
return None
item = cf._projection(select, column)
if item is None:
return None
value = cf._unwrap(item.unalias())
agg: str | None = None
distinct = False
if isinstance(value, exp.AggFunc):
agg = {exp.Sum: "sum", exp.Count: "count", exp.Avg: "avg", exp.Min: "min", exp.Max: "max"}.get(
type(value), "other")
arg = value.this
if isinstance(arg, exp.Distinct):
distinct = True
arg = arg.expressions[0] if len(arg.expressions) == 1 else None
value = cf._unwrap(arg) if arg is not None else None
if not isinstance(value, exp.Column) or isinstance(value.this, exp.Star):
return None, None, agg, distinct
tables = cf._tables(select)
table = tables.get(name_key(value.table)) if value.table else \
(next(iter(tables.values())) if len(tables) == 1 else None)
return table, value.name, agg, distinct
except Exception: # noqa: BLE001 - 认不出就当没标
return None
def date_source(sql: str, column: str) -> tuple[str, str] | None:
"""期次结果列取自哪张表的哪个日期列:那一项选取里只引用了一列(order_date、DATE(order_date)、
strftime('%Y-%m', order_date))时就是它。认不出为 None。"""
from sqlglot import exp
from app.engine import cell_format as cf
try:
select = _outer_select(sql)
item = cf._projection(select, column) if select is not None else None
if item is None:
return None
cols = [c for c in item.unalias().find_all(exp.Column) if not isinstance(c.this, exp.Star)]
if len({(name_key(c.table), name_key(c.name)) for c in cols}) != 1:
return None
ref = cols[0]
tables = cf._tables(select)
table = tables.get(name_key(ref.table)) if ref.table else \
(next(iter(tables.values())) if len(tables) == 1 else None)
return (table, ref.name) if table else None
except Exception: # noqa: BLE001
return None
class Facts:
"""目录事实的取法:run 时 catalogs 是 context["catalogs"](数据源名 → {表名: notes 或 CatalogEntry}),复算时 recorded 是
结论里记下的 params["catalog"]。查过的都记下(record),同一个键只认一次。"""
def __init__(self, catalogs: Mapping[str, Any] | None = None, recorded: Mapping[str, Any] | None = None) -> None:
self._catalogs = catalogs or {}
self._recorded = recorded
self._labels: dict[str, str | None] = {}
self._measures: dict[str, tuple[str | None, bool]] = {}
self._dates: dict[str, str | None] = {}
def _catalog(self, source: str) -> Mapping[str, Any] | None:
found = self._catalogs.get(source)
return found if isinstance(found, Mapping) else None
def label(self, snap: Any, column: str) -> str | None:
"""结果列给人看的叫法(目录里取自的那一列的中文名);没有为 None。"""
if column in self._labels:
return self._labels[column]
if self._recorded is not None:
value = (self._recorded.get("labels") or {}).get(column)
else:
value = None
src = source_of(snap.sql, column)
if src is not None and src[1] is not None:
item = _item(_table_notes(self._catalog(snap.source), src[0]), src[1], "label")
text = item.get("value") if item else None
value = text.strip() if isinstance(text, str) and text.strip() else None
self._labels[column] = value
return value
def measure(self, snap: Any, column: str) -> tuple[str | None, bool]:
"""结果列的度量类型和有没有人确认过:(flow / stock / ratio / identifier / status / attribute 或 None, 确认过)。"""
if column in self._measures:
return self._measures[column]
if self._recorded is not None:
kind = (self._recorded.get("measures") or {}).get(column)
out = (kind, kind is not None and column not in (self._recorded.get("unconfirmed") or []))
else:
out = self._live_measure(snap, column)
self._measures[column] = out
return out
def _live_measure(self, snap: Any, column: str) -> tuple[str | None, bool]:
src = source_of(snap.sql, column)
if src is None:
return None, False
table, name, agg, distinct = src
if agg == "count" and not distinct:
return "flow", True
if agg == "avg":
return "ratio", True
if agg not in (None, "sum") or name is None:
return None, False
item = _item(_table_notes(self._catalog(snap.source), table), name, "measure")
kind = item.get("value") if item else None
if not isinstance(kind, str):
return None, False
return kind, item.get("status") in _STRONG
def business_date(self, source: str, table: str) -> str | None:
"""表的业务日期列(目录的 business_date);没写为 None。"""
key = name_key(table)
if key in self._dates:
return self._dates[key]
if self._recorded is not None:
value = (self._recorded.get("dates") or {}).get(key)
else:
notes = _table_notes(self._catalog(source), table)
item = notes.get("business_date") if isinstance(notes, Mapping) else None
raw = item.get("value") if isinstance(item, Mapping) else None
col = raw.get("column") if isinstance(raw, Mapping) else None
value = col if isinstance(col, str) and col.strip() else None
self._dates[key] = value
return value
def record(self) -> dict[str, Any] | None:
"""查过、查得到的事实(写进 params["catalog"]);一项都没有为 None。"""
out: dict[str, Any] = {}
labels = {k: v for k, v in self._labels.items() if v is not None}
measures = {k: v[0] for k, v in self._measures.items() if v[0] is not None}
unconfirmed = sorted(k for k, v in self._measures.items() if v[0] is not None and not v[1])
dates = {k: v for k, v in self._dates.items() if v is not None}
for key, value in (("labels", labels), ("measures", measures), ("unconfirmed", unconfirmed), ("dates", dates)):
if value:
out[key] = value
return out or None
__all__ = ["Facts", "date_source", "source_of"]