feat(cma): cashflow NA annotation + dimension guard + threshold unit migration script
dev-level prep only: no prod write, no push, no pipeline deploy.
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""量纲守卫(Dimension Guard)—— 阈值与实测值跨量级比较时拒绝判定
|
||||
|
||||
依据:财务口径裁定 `finance-20260916-cma-caliber-ruling.md` §四(裁定③)
|
||||
· 同量纲铁律:阈值表达式须与 kpi_definitions.unit 同量纲
|
||||
· 量纲守卫:|log10(阈值)| 与 |log10(实测)| 相差 ≥1 个数量级 → **拒绝判定**,
|
||||
标记「口径错误」,**不得输出红/黄/绿**
|
||||
· 典型:流动比率实测 1.29(倍)vs 阈值 `<150`(百分数写法)→ 跨 2 个量级 → 假红
|
||||
|
||||
设计原则(**additive / opt-in**):
|
||||
· 本模块**不修改** `threshold_parser` 的任何既有行为,也不改现有调用方;
|
||||
· 判定侧改用 `calc_level_guarded()` 即得守卫(返回 dict,含 level 与守卫明细);
|
||||
· 装配到 alert_generator / kpis.py 属生产行为变更,需审批后单独执行(本单只备代码)。
|
||||
|
||||
单位维度表(裁定 §4.1):
|
||||
· 倍(无量纲)→ ratio :流动比率、速动比率、现金比率
|
||||
· % → percent :资产负债率、毛利率、净利率、保本覆盖率、返利率
|
||||
· 天 / 次 → days / times:周转天数、周转率
|
||||
· 万元 / 元 → currency_wan / currency_yuan
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Optional, Tuple, Union
|
||||
|
||||
from app.utils.threshold_parser import LEVEL_PRIORITY, eval_threshold, parse_clauses
|
||||
|
||||
#: 单位 → 维度
|
||||
UNIT_DIMENSION = {
|
||||
"倍": "ratio",
|
||||
"": "ratio",
|
||||
"%": "percent",
|
||||
"百分比": "percent",
|
||||
"万元": "currency_wan",
|
||||
"元": "currency_yuan",
|
||||
"天": "days",
|
||||
"次": "times",
|
||||
"人": "count",
|
||||
"个": "count",
|
||||
"家": "count",
|
||||
"分": "score",
|
||||
}
|
||||
|
||||
#: 默认拒绝阈值:量级差 ≥ 1.0 → 口径错误
|
||||
DEFAULT_ORDERS_GAP = 1.0
|
||||
|
||||
#: 判定返回:口径错误时的 level 常量(不出红/黄/绿)
|
||||
LEVEL_DIMENSION_ERROR = "口径错误"
|
||||
|
||||
|
||||
def dimension_of(unit) -> str:
|
||||
"""单位字符串 → 维度名(未知单位返回 'unknown')。"""
|
||||
if unit is None:
|
||||
return "unknown"
|
||||
return UNIT_DIMENSION.get(str(unit).strip(), "unknown")
|
||||
|
||||
|
||||
def _orders(v) -> Optional[float]:
|
||||
"""数值量级 = log10(|v|);0 / None / 非数值 → None(不可判)。"""
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
f = float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if f == 0:
|
||||
return None
|
||||
return math.log10(abs(f))
|
||||
|
||||
|
||||
def _clause_values(clauses) -> list:
|
||||
"""子句 → 候选比较值列表(区间取两端)。"""
|
||||
vals = []
|
||||
for _op, val in clauses:
|
||||
if isinstance(val, tuple):
|
||||
vals.extend(list(val))
|
||||
else:
|
||||
vals.append(val)
|
||||
return vals
|
||||
|
||||
|
||||
def check_dimension(actual, expr, unit=None, orders_gap: float = DEFAULT_ORDERS_GAP) -> dict:
|
||||
"""检查「实测值 vs 阈值表达式」是否同量级。
|
||||
|
||||
返回 dict:
|
||||
ok True=可判定;False=口径错误(拒绝判定)
|
||||
verdict 'ok' | 'dimension_error' | 'undecidable'
|
||||
gap 与最接近子句的量级差(None=不可判)
|
||||
nearest 最接近的子句值
|
||||
reason 人类可读说明
|
||||
"""
|
||||
if actual is None or expr is None or not str(expr).strip():
|
||||
return {"ok": True, "verdict": "undecidable", "gap": None, "nearest": None,
|
||||
"reason": "无实测值或无阈值 → 守卫不介入", "dimension": dimension_of(unit)}
|
||||
|
||||
a = _orders(actual)
|
||||
clauses = parse_clauses(expr)
|
||||
if a is None:
|
||||
return {"ok": True, "verdict": "undecidable", "gap": None, "nearest": None,
|
||||
"reason": "实测值为 0(量级不可判)→ 守卫不介入", "dimension": dimension_of(unit)}
|
||||
if not clauses:
|
||||
return {"ok": True, "verdict": "undecidable", "gap": None, "nearest": None,
|
||||
"reason": "阈值表达式无法解析 → 守卫不介入(解析器自身会返回 False)",
|
||||
"dimension": dimension_of(unit)}
|
||||
|
||||
best_gap, nearest = None, None
|
||||
for v in _clause_values(clauses):
|
||||
o = _orders(v)
|
||||
if o is None:
|
||||
continue
|
||||
g = abs(a - o)
|
||||
if best_gap is None or g < best_gap:
|
||||
best_gap, nearest = g, v
|
||||
|
||||
if best_gap is None:
|
||||
return {"ok": True, "verdict": "undecidable", "gap": None, "nearest": None,
|
||||
"reason": "阈值子句全为 0 → 守卫不介入", "dimension": dimension_of(unit)}
|
||||
|
||||
if best_gap >= orders_gap:
|
||||
return {"ok": False, "verdict": "dimension_error", "gap": round(best_gap, 3),
|
||||
"nearest": nearest,
|
||||
"reason": ("量级差 %.2f ≥ %.1f 个数量级:实测 %s(单位 %s)vs 阈值 %r(最近子句 %s)"
|
||||
" → 跨量级比较,判定作废" % (best_gap, orders_gap, actual, unit, expr, nearest)),
|
||||
"dimension": dimension_of(unit)}
|
||||
return {"ok": True, "verdict": "ok", "gap": round(best_gap, 3), "nearest": nearest,
|
||||
"reason": "量级差 %.2f < %.1f → 同量级可判" % (best_gap, orders_gap),
|
||||
"dimension": dimension_of(unit)}
|
||||
|
||||
|
||||
def calc_level_guarded(actual, kpi, orders_gap: float = DEFAULT_ORDERS_GAP) -> dict:
|
||||
"""带量纲守卫的红黄绿判定(不改 threshold_parser 的原行为)。
|
||||
|
||||
kpi 需含 threshold_green/yellow/red 与 unit 属性(ORM 对象或同名 duck type)。
|
||||
返回 {"level": 'red'|'yellow'|'green'|'info'|'口径错误', "guard": {...}, "expr": 命中的表达式}
|
||||
口径错误:level = LEVEL_DIMENSION_ERROR,不出红/黄/绿(裁定 §4.2)。
|
||||
"""
|
||||
exprs = {
|
||||
"green": getattr(kpi, "threshold_green", None),
|
||||
"yellow": getattr(kpi, "threshold_yellow", None),
|
||||
"red": getattr(kpi, "threshold_red", None),
|
||||
}
|
||||
unit = getattr(kpi, "unit", None)
|
||||
if actual is None:
|
||||
return {"level": "info", "guard": {"ok": True, "verdict": "undecidable",
|
||||
"reason": "无实测值", "dimension": dimension_of(unit)},
|
||||
"expr": None}
|
||||
|
||||
# 逐等级守卫:任一等级被守卫生效(口径错误)→ 不进红/黄/绿
|
||||
for level in LEVEL_PRIORITY:
|
||||
expr = exprs[level]
|
||||
if not expr:
|
||||
continue
|
||||
g = check_dimension(actual, expr, unit, orders_gap)
|
||||
if not g["ok"]:
|
||||
return {"level": LEVEL_DIMENSION_ERROR, "guard": g, "expr": expr,
|
||||
"level_blocked": level}
|
||||
|
||||
for level in LEVEL_PRIORITY:
|
||||
expr = exprs[level]
|
||||
if expr and eval_threshold(actual, expr):
|
||||
return {"level": level, "guard": check_dimension(actual, expr, unit, orders_gap),
|
||||
"expr": expr}
|
||||
return {"level": "info", "guard": {"ok": True, "verdict": "ok", "reason": "未命中任何阈值",
|
||||
"dimension": dimension_of(unit)}, "expr": None}
|
||||
|
||||
|
||||
class _K:
|
||||
"""self-test 用的最小 KPI 载体。"""
|
||||
|
||||
def __init__(self, unit, green=None, yellow=None, red=None):
|
||||
self.unit, self.threshold_green, self.threshold_yellow, self.threshold_red = \
|
||||
unit, green, yellow, red
|
||||
|
||||
|
||||
def _self_test() -> int:
|
||||
"""守卫自测(零库连接):假红必拦、真红必放、真黄必放。"""
|
||||
cases: Tuple[Tuple[str, float, "_K", str], ...] = (
|
||||
# (说明, 实测, KPI, 期望 level)
|
||||
("假红·流动比率 1.29倍 vs <150(跨2量级)", 1.29, _K("%", ">=200", ">=150", "<150"),
|
||||
LEVEL_DIMENSION_ERROR),
|
||||
("假红·速动比率 0.54倍 vs <80(跨2量级)", 0.54, _K("%", ">=100", ">=80", "<80"),
|
||||
LEVEL_DIMENSION_ERROR),
|
||||
("真红·返利率 48.44% vs >12(同量级)", 48.44, _K("%", "<=10", ">10", ">12"), "red"),
|
||||
("真红·保本覆盖率 60.4% vs <85(同量级)", 60.4, _K("%", ">=100", ">=85", "<85"), "red"),
|
||||
("真红·应收周转天数 610.2天 vs >91(同量级)", 610.2, _K("天", None, None, ">91"), "red"),
|
||||
("真红·收入增长率 -82.2% vs <15(同量级)", -82.2, _K("%", None, None, "<15"), "red"),
|
||||
("修正后·流动比率 1.29倍 vs 红<1.0/黄>=1.0/绿>=2.0", 1.29,
|
||||
_K("倍", ">=2.0", ">=1.0", "<1.0"), "yellow"),
|
||||
("修正后·速动比率 0.54倍 vs 红<0.5/黄>=0.5/绿>=1.0", 0.54,
|
||||
_K("倍", ">=1.0", ">=0.5", "<0.5"), "yellow"),
|
||||
("修正后·流动比率 0.8倍 → 红", 0.8, _K("倍", ">=2.0", ">=1.0", "<1.0"), "red"),
|
||||
("修正后·流动比率 2.5倍 → 绿", 2.5, _K("倍", ">=2.0", ">=1.0", "<1.0"), "green"),
|
||||
("零值不误拦·实测 0 无守卫介入", 0.0, _K("%", ">=100", ">=85", "<85"), "red"),
|
||||
("无阈值 → info", 5.0, _K("倍"), "info"),
|
||||
)
|
||||
ok = 0
|
||||
print("=== 量纲守卫 self-test ===")
|
||||
for desc, actual, kpi, expect in cases:
|
||||
r = calc_level_guarded(actual, kpi)
|
||||
flag = "PASS" if r["level"] == expect else "FAIL"
|
||||
ok += (r["level"] == expect)
|
||||
print(" [%s] %-46s → %-6s (期望 %s) gap=%s" % (
|
||||
flag, desc, r["level"], expect, r["guard"].get("gap")))
|
||||
print("=== %d/%d PASS ===" % (ok, len(cases)))
|
||||
return 0 if ok == len(cases) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(_self_test())
|
||||
Reference in New Issue
Block a user