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:
Hermes CI Fix
2026-09-17 00:01:58 +08:00
parent 9b3a131070
commit 293df6df8c
3 changed files with 503 additions and 3 deletions
+209
View File
@@ -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())