Files
cma-management/backend/app/api/kpi_causality.py
T
Hermes CI Fix fdc42d443d feat: 因果链三层验证机制(P2) — 数据验证+人工确认+状态机
- kpi_causality 加列: source_type/verify_status/verified_at/verified_by/entity_id(回填)
- 核心服务: app/services/causality_verification.py (Pearson+滞后对齐+状态机)
- 数据验证脚本: scripts/correlation-check.py (月度cron, 输出JSON报告)
- API: create/update支持source_type, GET /verify-status, PUT /{id}/verify(人工确认)
- 全部端点按entity_id账套隔离, kpi/{id}/network/simulate补跨企业校验
- 前端: KPIDetail因果链页显示验证状态徽标(数据证实/存疑/待检)
- 测试: test_causality_verification.py 37用例 + 原因果链测试全过(59个)
- 50条因果链首轮验证: 1数据证实(#37渠补率到净利润lag1 r=-0.89), 4存疑, 45待检(数据不足)
2026-08-27 15:51:19 +08:00

429 lines
17 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.
"""KPI因果链建模 — 任务2
KPI间因果关系网络 + 模拟推演 + 三层验证机制(数据/AI/人工) (2026-08-27 P2)
"""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import text
from typing import Optional
import logging
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPICausality, KPIValue, OperationLog
from app.services.causality_verification import (
ALL_STATUSES,
ALL_SOURCE_TYPES,
STATUS_PENDING,
)
logger = logging.getLogger("kpi-causality")
router = APIRouter(prefix="/api/cma/kpi-causality", tags=["KPI因果链"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
WRITE_ROLES = Depends(require_role("ceo", "finance", "it"))
def _to_dict(obj):
return {c.name: getattr(obj, c.name) for c in obj.__table__.columns}
# ============================================================
# 注意: 静态路径必须放在动态路径之前(/{id}之前)
# ============================================================
@router.get("/full-network")
def get_full_network(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""获取全局因果网络数据(用于力导向图)— 账套隔离: 仅当前企业KPI的因果链 (2026-08-23 P1b, 2026-08-27 用entity_id列)"""
edges = db.query(KPICausality).filter(KPICausality.entity_id == entity_id).all()
node_ids = set()
edge_list = []
for e in edges:
node_ids.add(e.source_kpi_id)
node_ids.add(e.target_kpi_id)
edge_list.append({
"source": e.source_kpi_id,
"target": e.target_kpi_id,
"strength": e.strength,
"direction": e.direction,
"lag_months": e.lag_months,
})
# 获取所有节点信息
kpis = db.query(KPIDefinition).filter(KPIDefinition.id.in_(node_ids)).all() if node_ids else []
node_map = {k.id: {
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"dimension": k.dimension, "category": k.category,
} for k in kpis}
nodes = []
for nid in node_ids:
info = node_map.get(nid, {"id": nid, "kpi_code": f"KPI#{nid}", "kpi_name": f"KPI#{nid}"})
nodes.append(info)
return {"nodes": nodes, "edges": edge_list, "total_edges": len(edge_list)}
@router.get("/kpi/{kpi_id}/network")
def get_kpi_network(kpi_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""获取KPI的因果网络(上游驱动 + 下游影响)— 账套隔离: 校验KPI属于当前企业"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
if kpi.entity_id != entity_id:
raise HTTPException(404, "KPI不存在") # 跨企业不暴露存在性
# 上游(指向当前KPI的因果)
upstream = db.query(KPICausality).filter(
KPICausality.target_kpi_id == kpi_id,
KPICausality.entity_id == entity_id,
).all()
upstream_list = []
for c in upstream:
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
if src:
upstream_list.append({
"causality_id": c.id,
"kpi_id": src.id, "kpi_code": src.kpi_code, "kpi_name": src.kpi_name,
"strength": c.strength, "lag_months": c.lag_months,
"direction": c.direction, "formula": c.formula,
"source_type": c.source_type, "verify_status": c.verify_status,
"verified_at": c.verified_at.isoformat() if c.verified_at else None,
"verified_by": c.verified_by,
})
# 下游(当前KPI指向的因果)
downstream = db.query(KPICausality).filter(
KPICausality.source_kpi_id == kpi_id,
KPICausality.entity_id == entity_id,
).all()
downstream_list = []
for c in downstream:
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
if tgt:
downstream_list.append({
"causality_id": c.id,
"kpi_id": tgt.id, "kpi_code": tgt.kpi_code, "kpi_name": tgt.kpi_name,
"strength": c.strength, "lag_months": c.lag_months,
"direction": c.direction, "formula": c.formula,
"source_type": c.source_type, "verify_status": c.verify_status,
"verified_at": c.verified_at.isoformat() if c.verified_at else None,
"verified_by": c.verified_by,
})
return {
"kpi": {"id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name, "dimension": kpi.dimension},
"upstream": upstream_list,
"downstream": downstream_list,
}
@router.post("/simulate")
def simulate_causality(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""模拟推演: 修改一个KPI的值,预测对其他KPI的影响
Body: { kpi_id: int, new_value: float, period: str }
"""
kpi_id = data.get("kpi_id")
new_value = data.get("new_value")
period = data.get("period")
if not kpi_id or new_value is None:
raise HTTPException(400, "必须指定kpi_id和new_value")
source_kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not source_kpi:
raise HTTPException(404, "KPI不存在")
if source_kpi.entity_id != entity_id:
raise HTTPException(404, "KPI不存在")
# 获取当前值
current_value = None
query_values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.actual_value.isnot(None),
)
if period:
query_values = query_values.filter(KPIValue.period == period)
latest = query_values.order_by(KPIValue.period.desc()).first()
if latest:
current_value = latest.actual_value
previous_value = current_value or new_value
change_pct = ((new_value - previous_value) / previous_value * 100) if previous_value and previous_value != 0 else 0
# BFS遍历下游因果链
visited = set()
impacts = []
queue = [(kpi_id, change_pct, 0, 1.0)] # (kpi_id, change_pct, depth, cumulative_strength)
while queue:
current_kpi_id, current_change, depth, cum_strength = queue.pop(0)
if current_kpi_id in visited:
continue
visited.add(current_kpi_id)
# 查找从current_kpi_id出发的下游因果链(账套隔离: 仅本企业链)
downstream = db.query(KPICausality).filter(
KPICausality.source_kpi_id == current_kpi_id,
KPICausality.entity_id == entity_id,
).all()
for edge in downstream:
target_id = edge.target_kpi_id
if target_id in visited:
continue
target_kpi = db.query(KPIDefinition).filter(KPIDefinition.id == target_id).first()
if not target_kpi:
continue
# 计算影响: 变化率 × 强度 × 方向
edge_strength = edge.strength or 0.5
direction_factor = 1.0 if edge.direction == "positive" else -1.0
propagated_change = current_change * edge_strength * direction_factor
# 获取当前值
tgt_val = db.query(KPIValue).filter(
KPIValue.kpi_id == target_id,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.desc()).first()
predicted_value = None
if tgt_val and tgt_val.actual_value:
predicted_value = round(tgt_val.actual_value * (1 + propagated_change / 100), 2)
impacts.append({
"kpi_id": target_id,
"kpi_code": target_kpi.kpi_code,
"kpi_name": target_kpi.kpi_name,
"dimension": target_kpi.dimension,
"current_value": tgt_val.actual_value if tgt_val else None,
"predicted_value": predicted_value,
"change_pct": round(propagated_change, 2),
"strength": edge_strength,
"direction": edge.direction,
"lag_months": edge.lag_months,
"depth": depth + 1,
"path_strength": round(cum_strength * edge_strength, 3),
})
# 继续遍历下游
new_cum = cum_strength * edge_strength
if new_cum > 0.05 and depth < 5:
queue.append((target_id, propagated_change, depth + 1, new_cum))
return {
"source": {
"kpi_id": source_kpi.id,
"kpi_code": source_kpi.kpi_code,
"kpi_name": source_kpi.kpi_name,
"current_value": current_value,
"new_value": new_value,
"change_pct": round(change_pct, 2),
},
"impacts": impacts,
"total_impacted": len(impacts),
}
# ============================================================
# CRUD (动态路径)
# ============================================================
@router.get("")
def list_causalities(
source_kpi_id: Optional[int] = None,
target_kpi_id: Optional[int] = None,
verify_status: Optional[str] = None,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""获取因果链列表(账套隔离: 仅当前企业KPI, 2026-08-23 P1b, 2026-08-27 支持verify_status筛选)"""
query = db.query(KPICausality).filter(KPICausality.entity_id == entity_id)
if source_kpi_id:
query = query.filter(KPICausality.source_kpi_id == source_kpi_id)
if target_kpi_id:
query = query.filter(KPICausality.target_kpi_id == target_kpi_id)
if verify_status:
query = query.filter(KPICausality.verify_status == verify_status)
items = query.order_by(KPICausality.id).all()
result = []
for c in items:
d = _to_dict(c)
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
d["source_kpi_code"] = src.kpi_code if src else None
d["source_kpi_name"] = src.kpi_name if src else None
d["target_kpi_code"] = tgt.kpi_code if tgt else None
d["target_kpi_name"] = tgt.kpi_name if tgt else None
result.append(d)
return {"data": result, "total": len(result)}
@router.get("/verify-status")
def get_verify_status(
verify_status: Optional[str] = None,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""验证状态总览 — 按状态统计 + 链列表(2026-08-27 三层验证机制)
可选 ?verify_status=pending/data_verified/human_verified/disputed 筛选
"""
if verify_status and verify_status not in ALL_STATUSES:
raise HTTPException(400, f"verify_status 必须为 {'/'.join(ALL_STATUSES)}")
query = db.query(KPICausality).filter(KPICausality.entity_id == entity_id)
if verify_status:
query = query.filter(KPICausality.verify_status == verify_status)
items = query.order_by(KPICausality.id).all()
by_status = {s: 0 for s in ALL_STATUSES}
data = []
for c in items:
by_status[c.verify_status] = by_status.get(c.verify_status, 0) + 1
d = _to_dict(c)
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
d["source_kpi_code"] = src.kpi_code if src else None
d["source_kpi_name"] = src.kpi_name if src else None
d["target_kpi_code"] = tgt.kpi_code if tgt else None
d["target_kpi_name"] = tgt.kpi_name if tgt else None
d["verified_at"] = c.verified_at.isoformat() if c.verified_at else None
data.append(d)
return {
"summary": {"total": len(items), "by_status": by_status},
"data": data,
}
@router.get("/{causality_id}")
def get_causality(causality_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
if not c:
raise HTTPException(404, "因果链不存在")
if c.entity_id != entity_id:
raise HTTPException(404, "因果链不存在") # 账套隔离
d = _to_dict(c)
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
d["source"] = {"id": src.id, "kpi_code": src.kpi_code, "kpi_name": src.kpi_name} if src else None
d["target"] = {"id": tgt.id, "kpi_code": tgt.kpi_code, "kpi_name": tgt.kpi_name} if tgt else None
return d
@router.post("")
def create_causality(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
"""创建因果链(source_type标记来源, 2026-08-27"""
source_id = data.get("source_kpi_id")
target_id = data.get("target_kpi_id")
if not source_id or not target_id:
raise HTTPException(400, "必须指定源KPI和目标KPI")
if source_id == target_id:
raise HTTPException(400, "源和目标不能相同")
src = db.query(KPIDefinition).filter(KPIDefinition.id == source_id).first()
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == target_id).first()
if not src or not tgt:
raise HTTPException(404, "KPI不存在")
if src.entity_id != tgt.entity_id:
raise HTTPException(400, "源KPI和目标KPI必须属于同一企业")
existing = db.query(KPICausality).filter(
KPICausality.source_kpi_id == source_id,
KPICausality.target_kpi_id == target_id,
).first()
if existing:
raise HTTPException(400, f"因果链已存在: {src.kpi_code}{tgt.kpi_code}")
source_type = data.get("source_type", "manual")
if source_type not in ALL_SOURCE_TYPES:
raise HTTPException(400, f"source_type 必须为 {'/'.join(ALL_SOURCE_TYPES)}")
c = KPICausality(
entity_id=src.entity_id,
source_kpi_id=source_id,
target_kpi_id=target_id,
strength=data.get("strength", 0.5),
lag_months=data.get("lag_months", 1),
formula=data.get("formula"),
direction=data.get("direction", "positive"),
source_type=source_type,
verify_status=STATUS_PENDING,
)
db.add(c)
db.commit()
db.refresh(c)
db.add(OperationLog(action="create", target_type="kpi_causality",
detail=f"创建因果链: {src.kpi_code}{tgt.kpi_code} (source={source_type})"))
db.commit()
return _to_dict(c)
@router.put("/{causality_id}")
def update_causality(causality_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES,
entity_id: int = Depends(get_entity_id)):
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
if not c:
raise HTTPException(404, "因果链不存在")
if c.entity_id != entity_id:
raise HTTPException(404, "因果链不存在") # 账套隔离
for field in ("strength", "lag_months", "formula", "direction", "source_type"):
if field in data:
if field == "source_type" and data[field] not in ALL_SOURCE_TYPES:
raise HTTPException(400, f"source_type 必须为 {'/'.join(ALL_SOURCE_TYPES)}")
setattr(c, field, data[field])
# 修改链定义后,验证状态回到待检(定义变了旧结论失效)
if any(f in data for f in ("strength", "lag_months", "formula", "direction")):
c.verify_status = STATUS_PENDING
c.verified_at = None
c.verified_by = None
db.commit()
db.refresh(c)
return _to_dict(c)
@router.put("/{causality_id}/verify")
def verify_causality(causality_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES,
entity_id: int = Depends(get_entity_id)):
"""人工确认(战略回顾会核对打标)— 2026-08-27 三层验证机制
Body: { verify_status: "human_verified"|"disputed", verified_by?: "任富海" }
默认打标 human_verified(人工最终确认)。
"""
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
if not c:
raise HTTPException(404, "因果链不存在")
if c.entity_id != entity_id:
raise HTTPException(404, "因果链不存在") # 账套隔离
target_status = data.get("verify_status", "human_verified")
if target_status not in ("human_verified", "disputed"):
raise HTTPException(400, "verify_status 必须为 human_verified 或 disputed")
verified_by = data.get("verified_by") or user.name or user.username
c.verify_status = target_status
c.verified_at = datetime.now()
c.verified_by = str(verified_by)[:50]
db.add(OperationLog(action="verify", target_type="kpi_causality",
detail=f"因果链 #{causality_id} 人工确认: {target_status} (by {verified_by})"))
db.commit()
db.refresh(c)
d = _to_dict(c)
d["verified_at"] = c.verified_at.isoformat() if c.verified_at else None
return d
@router.delete("/{causality_id}")
def delete_causality(causality_id: int, db: Session = Depends(get_db), user=WRITE_ROLES,
entity_id: int = Depends(get_entity_id)):
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
if c:
if c.entity_id != entity_id:
raise HTTPException(404, "因果链不存在") # 账套隔离
db.delete(c)
db.commit()
return {"message": "已删除"}