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待检(数据不足)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""KPI因果链建模 — 任务2
|
||||
KPI间因果关系网络 + 模拟推演
|
||||
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
|
||||
@@ -11,6 +12,11 @@ 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")
|
||||
|
||||
@@ -30,8 +36,8 @@ def _to_dict(obj):
|
||||
|
||||
@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)"""
|
||||
edges = db.query(KPICausality).join(KPIDefinition, KPIDefinition.id == KPICausality.source_kpi_id).filter(KPIDefinition.entity_id == entity_id).all()
|
||||
"""获取全局因果网络数据(用于力导向图)— 账套隔离: 仅当前企业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:
|
||||
@@ -61,14 +67,19 @@ def get_full_network(db: Session = Depends(get_db), entity_id: int = Depends(get
|
||||
|
||||
|
||||
@router.get("/kpi/{kpi_id}/network")
|
||||
def get_kpi_network(kpi_id: int, db: Session = Depends(get_db)):
|
||||
"""获取KPI的因果网络(上游驱动 + 下游影响)"""
|
||||
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).all()
|
||||
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()
|
||||
@@ -78,10 +89,16 @@ def get_kpi_network(kpi_id: int, db: Session = Depends(get_db)):
|
||||
"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).all()
|
||||
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()
|
||||
@@ -91,6 +108,9 @@ def get_kpi_network(kpi_id: int, db: Session = Depends(get_db)):
|
||||
"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 {
|
||||
@@ -101,7 +121,7 @@ def get_kpi_network(kpi_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/simulate")
|
||||
def simulate_causality(data: dict, db: Session = Depends(get_db)):
|
||||
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 }
|
||||
"""
|
||||
@@ -115,6 +135,8 @@ def simulate_causality(data: dict, db: Session = Depends(get_db)):
|
||||
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
|
||||
@@ -142,9 +164,10 @@ def simulate_causality(data: dict, db: Session = Depends(get_db)):
|
||||
continue
|
||||
visited.add(current_kpi_id)
|
||||
|
||||
# 查找从current_kpi_id出发的下游因果链
|
||||
# 查找从current_kpi_id出发的下游因果链(账套隔离: 仅本企业链)
|
||||
downstream = db.query(KPICausality).filter(
|
||||
KPICausality.source_kpi_id == current_kpi_id
|
||||
KPICausality.source_kpi_id == current_kpi_id,
|
||||
KPICausality.entity_id == entity_id,
|
||||
).all()
|
||||
|
||||
for edge in downstream:
|
||||
@@ -212,15 +235,18 @@ def simulate_causality(data: dict, db: Session = Depends(get_db)):
|
||||
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)"""
|
||||
query = db.query(KPICausality).join(KPIDefinition, KPIDefinition.id == KPICausality.source_kpi_id).filter(KPIDefinition.entity_id == 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 = []
|
||||
@@ -236,11 +262,51 @@ def list_causalities(
|
||||
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)):
|
||||
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()
|
||||
@@ -251,7 +317,7 @@ def get_causality(causality_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
@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:
|
||||
@@ -262,6 +328,8 @@ def create_causality(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES
|
||||
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,
|
||||
@@ -270,40 +338,91 @@ def create_causality(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES
|
||||
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}"))
|
||||
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):
|
||||
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, "因果链不存在")
|
||||
for field in ("strength", "lag_months", "formula", "direction"):
|
||||
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):
|
||||
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": "已删除"}
|
||||
|
||||
Reference in New Issue
Block a user