Files
cma-management/backend/scripts/kpi_alert_attribution_backfill.py
T

157 lines
6.9 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.
#!/usr/bin/env python3
"""预算偏差告警归因回填 —— yanxue-20260914-budget-p13p14-activation(P1-③ 激活)
两类动作:
1) attribution 为空的历史 open 告警 → 走现成接口 GET /budget/deviation-alerts/{id}/attribution
(端点在响应前现场组装归因并落库,见 app/api/budget.py:1004-1012)
2) 已有旧口径归因的告警 → 用修正后的科目口径重算覆盖(旧值见 /root/WB_WS/_backups/p13p14-20260914/)
另: scenario_id 为空且 alert_type 命中 scenario_suggestions → 补 scenario_id(让告警"能解释、能关")
用法:
/usr/bin/python3 scripts/kpi_alert_attribution_backfill.py # dry-run
/usr/bin/python3 scripts/kpi_alert_attribution_backfill.py --apply
"""
import json
import os
import sys
import urllib.error
import urllib.request
if __name__ == "__main__":
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.database import get_session_local
from app.models import BudgetDeviationAlert, KPIDefinition, ScenarioSuggestion
from app.utils.alert_attribution import build_attribution, match_scenario
API = "http://127.0.0.1:8010"
SECRET = "/root/.hermes/profiles/wecom-fullstack/.secrets/cma-admin.env"
OUT = "/tmp/budget-p13p14/attribution-conclusions.json"
def load_env(path):
vals = {}
with open(path) as fh:
for line in fh:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
vals[k.strip()] = v.strip()
return vals
def api(method, path, body=None, token=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(API + path, data=data, method=method)
req.add_header("Content-Type", "application/json")
if token:
req.add_header("Authorization", f"Bearer {token}")
try:
with urllib.request.urlopen(req, timeout=90) as r:
return r.status, json.loads(r.read().decode())
except urllib.error.HTTPError as e:
return e.code, {"_error": e.read().decode()[:200]}
def conclusion(alert, kpi, attribution, scenario):
dims = attribution.get("dimensions") or []
subs = attribution.get("subjects") or []
trend = attribution.get("trend") or {}
vtype = attribution.get("variance_type")
hot = [s for s in subs if abs(s.get("amount_diff") or 0) > 0.01][:3]
sub_txt = "; ".join(
f"{s['subject_code']} {s.get('subject_name','')} {s['amount_diff']:+.2f}元(权重{s.get('weight')})"
for s in hot) or "(科目明细为空:该KPI无可用取数映射,见报告§未接通清单)"
dim_txt = "; ".join(
f"{d.get('kpi_name')} 偏差{d.get('deviation_value')}" for d in dims
if d.get("deviation_value") is not None and abs(d["deviation_value"]) > 0.01) or "无下级KPI层级"
trend_txt = (f"{trend.get('type')}({trend.get('message')})" if trend.get("anomaly")
else "无连续3期异常")
return (
f"[{alert.id}] {kpi.kpi_code if kpi else alert.kpi_id} {alert.period}: "
f"实际{alert.actual_value} vs 预算{alert.budget_value}(偏差{alert.deviation_rate}%, {alert.alert_level})| "
f"场景={alert.alert_type or '未分类'}{'(建议:' + scenario['title'] + ')' if scenario else ''} | "
f"量价={vtype} | 主科目={sub_txt} | 子KPI={dim_txt} | 趋势={trend_txt}"
)
def main():
apply = "--apply" in sys.argv
sec = load_env(SECRET)
st, login = api("POST", "/api/cma/auth/login",
{"username": sec["CMA_ADMIN_USER"], "password": sec["CMA_ADMIN_PASS"], "entity_id": 1})
token = login.get("token")
print(f"login: HTTP {st} entity={login.get('entity_short_name')}")
db = get_session_local()()
try:
alerts = db.query(BudgetDeviationAlert).filter(
BudgetDeviationAlert.entity_id == 1).order_by(BudgetDeviationAlert.id).all()
print(f"== 告警: {len(alerts)} 条(open {sum(1 for a in alerts if a.status == 'open')})")
api_filled, recomputed, scenario_filled = 0, 0, 0
lines = []
for a in alerts:
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == a.kpi_id).first()
had = a.attribution is not None
if not had and apply:
# 1) 现成接口路径:现场组装并落库
st_a, resp = api("GET", f"/api/cma/budget/deviation-alerts/{a.id}/attribution", token=token)
if st_a == 200:
api_filled += 1
db.expire(a) # 端点已 commit,重读
a = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.id == a.id).first()
elif had and apply:
# 2) 旧口径 → 按修正科目口径重算
att, inferred = build_attribution(db, a.kpi_id, a.period, a.alert_type)
a.attribution = att
if a.alert_type is None:
a.alert_type = inferred
recomputed += 1
scenario = match_scenario(db, a.alert_type)
if scenario and a.scenario_id is None and apply:
a.scenario_id = scenario["scenario_id"]
scenario_filled += 1
att_now = a.attribution or {}
lines.append({
"id": a.id, "kpi_id": a.kpi_id, "kpi_code": kpi.kpi_code if kpi else None,
"period": a.period, "status": a.status, "alert_level": a.alert_level,
"budget_value": a.budget_value, "actual_value": a.actual_value,
"deviation_rate": a.deviation_rate, "alert_type": a.alert_type,
"scenario_id": a.scenario_id, "has_attribution": bool(a.attribution),
"attribution_keys": sorted(att_now.keys()),
"view": conclusion(a, kpi, att_now, scenario),
})
if apply:
db.commit()
for row in lines:
print(row["view"])
with open(OUT, "w") as fh:
json.dump(lines, fh, ensure_ascii=False, indent=1)
print(f"\n== 输出结论: {OUT} ({len(lines)} 条)")
n_attr = db.query(BudgetDeviationAlert).filter(
BudgetDeviationAlert.entity_id == 1,
BudgetDeviationAlert.attribution.isnot(None)).count()
total = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.entity_id == 1).count()
n_open = db.query(BudgetDeviationAlert).filter(
BudgetDeviationAlert.entity_id == 1, BudgetDeviationAlert.status == "open").count()
n_scen = db.query(BudgetDeviationAlert).filter(
BudgetDeviationAlert.entity_id == 1,
BudgetDeviationAlert.scenario_id.isnot(None)).count()
print(f"== 归因覆盖: {n_attr}/{total}(open {n_open})| scenario_id 已补: {n_scen} | "
f"接口落库 {api_filled} / 重算 {recomputed} / 场景补全 {scenario_filled}"
f"{'' if apply else ' [dry-run 未写库]'}")
finally:
db.close()
if __name__ == "__main__":
main()