#!/usr/bin/env python3 """因果链数据验证脚本 — 每月 cron 自动跑 (2026-08-27 P2) 对 kpi_causality 每条链: 取 source/target KPI 的 kpi_values 历史值 → Pearson 相关系数 + 方向一致性 + 滞后对齐(lag_months) → 更新 verify_status: data_verified / disputed / pending → 输出验证报告 JSON + 控制台摘要 用法: python3 scripts/correlation-check.py # 全部企业,写库 python3 scripts/correlation-check.py --entity-id 1 # 指定企业 python3 scripts/correlation-check.py --dry-run # 只算不写库 月度 cron: 0 9 1 * * cd /root/cma-management/backend && python3 scripts/correlation-check.py """ import argparse import json import logging import sys from datetime import datetime from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from sqlalchemy import text # noqa: E402 from app.database import get_engine # noqa: E402 from app.services.causality_verification import ( # noqa: E402 STATUS_DATA_VERIFIED, STATUS_DISPUTED, STATUS_HUMAN_VERIFIED, STATUS_PENDING, VERIFIER_SCRIPT, apply_state_machine, evaluate_chain, summarize, ) logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") logger = logging.getLogger("correlation-check") REPORT_DIR = Path(__file__).resolve().parent / "reports" def load_chains(engine, entity_id: int = None) -> list: """加载因果链 + 两端KPI信息。""" q = """ SELECT c.id, c.entity_id, c.source_kpi_id, c.target_kpi_id, c.strength, c.lag_months, c.direction, c.source_type, c.verify_status, s.kpi_code AS src_code, s.kpi_name AS src_name, t.kpi_code AS tgt_code, t.kpi_name AS tgt_name FROM kpi_causality c JOIN kpi_definitions s ON s.id = c.source_kpi_id JOIN kpi_definitions t ON t.id = c.target_kpi_id """ if entity_id is not None: q += " WHERE c.entity_id = :eid" with engine.connect() as conn: rows = conn.execute(text(q), {"eid": entity_id} if entity_id is not None else {}).mappings().all() return [dict(r) for r in rows] def load_values(engine, kpi_ids: list) -> dict: """加载 KPI 历史值: {kpi_id: [(period, actual_value), ...]}""" if not kpi_ids: return {} ids = list(set(int(i) for i in kpi_ids)) q = """ SELECT kpi_id, period, actual_value FROM kpi_values WHERE kpi_id IN :ids AND actual_value IS NOT NULL ORDER BY period """ with engine.connect() as conn: rows = conn.execute(text(q).bindparams(ids=ids), {"ids": ids}).mappings().all() result = {} for r in rows: result.setdefault(r["kpi_id"], []).append((r["period"], r["actual_value"])) return result def main(): ap = argparse.ArgumentParser(description="因果链数据验证") ap.add_argument("--entity-id", type=int, default=None, help="只验证指定企业(默认全部)") ap.add_argument("--dry-run", action="store_true", help="只计算不写库") args = ap.parse_args() engine = get_engine() chains = load_chains(engine, args.entity_id) if not chains: logger.info("无因果链,退出") return 0 kpi_ids = [c["source_kpi_id"] for c in chains] + [c["target_kpi_id"] for c in chains] values = load_values(engine, kpi_ids) now = datetime.now() results = [] updated = {"data_verified": 0, "disputed": 0, "unchanged": 0} notes = [] with engine.begin() as conn: for c in chains: src_vals = values.get(c["source_kpi_id"], []) tgt_vals = values.get(c["target_kpi_id"], []) ev = evaluate_chain( src_vals, tgt_vals, lag_months=c["lag_months"] or 0, direction=c["direction"] or "positive", ) new_status, note = apply_state_machine(c["verify_status"], ev["status"], respect_human=True) if note: notes.append({"causality_id": c["id"], "note": note}) changed = new_status != c["verify_status"] if changed: updated[new_status if new_status in updated else "unchanged"] = \ updated.get(new_status if new_status in updated else "unchanged", 0) + 1 else: updated["unchanged"] += 1 if not args.dry_run: conn.execute(text( "UPDATE kpi_causality SET verify_status = :st, verified_at = :va, verified_by = :vb " "WHERE id = :cid" ), { "st": new_status, "va": now, "vb": VERIFIER_SCRIPT, "cid": c["id"], }) results.append({ "causality_id": c["id"], "source": f'{c["src_code"]}({c["src_name"]})', "target": f'{c["tgt_code"]}({c["tgt_name"]})', "direction": c["direction"], "lag_months": c["lag_months"], "strength": c["strength"], "granularity": ev["granularity"], "n_points": ev["n"], "r": round(ev["r"], 4) if ev["r"] is not None else None, "direction_consistent": ev["direction_consistent"], "old_status": c["verify_status"], "new_status": new_status, "reason": ev["reason"], }) summary = summarize([{"status": r["new_status"]} for r in results]) report = { "generated_at": now.strftime("%Y-%m-%d %H:%M:%S"), "script": VERIFIER_SCRIPT, "dry_run": args.dry_run, "entity_id": args.entity_id, "summary": summary, "updated": updated, "human_verified_notes": notes, "chains": results, } REPORT_DIR.mkdir(exist_ok=True) report_path = REPORT_DIR / f"causality_verification_{now.strftime('%Y%m%d_%H%M%S')}.json" report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") # 控制台摘要(cron 输出即消息) lines = [ f"因果链数据验证{'[dry-run]' if args.dry_run else ''} {now.strftime('%Y-%m-%d %H:%M')}", f"总数: {summary['total']} | 数据证实: {summary['by_status'][STATUS_DATA_VERIFIED]} | " f"存疑: {summary['by_status'][STATUS_DISPUTED]} | 待检(数据不足): {summary['by_status'][STATUS_PENDING]} | " f"人工确认: {summary['by_status'][STATUS_HUMAN_VERIFIED]}", f"本次更新: data_verified={updated['data_verified']} disputed={updated['disputed']} unchanged={updated['unchanged']}", ] verified = [r for r in results if r["new_status"] == STATUS_DATA_VERIFIED] disputed = [r for r in results if r["new_status"] == STATUS_DISPUTED] if verified: lines.append("── 数据证实 ──") for r in verified: lines.append(f" #{r['causality_id']} {r['source']}→{r['target']} r={r['r']} n={r['n_points']}") if disputed: lines.append("── 数据存疑 ──") for r in disputed: lines.append(f" #{r['causality_id']} {r['source']}→{r['target']} r={r['r']} n={r['n_points']} ({r['reason']})") if notes: lines.append("── 人工确认链的数据警示 ──") for n in notes: lines.append(f" #{n['causality_id']}: {n['note']}") lines.append(f"报告: {report_path}") print("\n".join(lines)) logger.info("报告已写入 %s", report_path) return 0 if __name__ == "__main__": sys.exit(main())