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:
Hermes CI Fix
2026-08-27 15:51:19 +08:00
parent 6b479bfe7d
commit fdc42d443d
10 changed files with 1063 additions and 22 deletions
+3
View File
@@ -11,3 +11,6 @@ __pycache__/
*.tsbuildinfo *.tsbuildinfo
venv/ venv/
backend/logs/ backend/logs/
# 因果链验证报告(生成物)
backend/scripts/reports/
+137 -18
View File
@@ -1,6 +1,7 @@
"""KPI因果链建模 — 任务2 """KPI因果链建模 — 任务2
KPI间因果关系网络 + 模拟推演 KPI间因果关系网络 + 模拟推演 + 三层验证机制(数据/AI/人工) (2026-08-27 P2)
""" """
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import text from sqlalchemy import text
@@ -11,6 +12,11 @@ from app.database import get_db
from app.deps import get_entity_id from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPICausality, KPIValue, OperationLog 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") logger = logging.getLogger("kpi-causality")
@@ -30,8 +36,8 @@ def _to_dict(obj):
@router.get("/full-network") @router.get("/full-network")
def get_full_network(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): def get_full_network(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""获取全局因果网络数据(用于力导向图)— 账套隔离: 仅当前企业KPI的因果链 (2026-08-23 P1b)""" """获取全局因果网络数据(用于力导向图)— 账套隔离: 仅当前企业KPI的因果链 (2026-08-23 P1b, 2026-08-27 用entity_id列)"""
edges = db.query(KPICausality).join(KPIDefinition, KPIDefinition.id == KPICausality.source_kpi_id).filter(KPIDefinition.entity_id == entity_id).all() edges = db.query(KPICausality).filter(KPICausality.entity_id == entity_id).all()
node_ids = set() node_ids = set()
edge_list = [] edge_list = []
for e in edges: 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") @router.get("/kpi/{kpi_id}/network")
def get_kpi_network(kpi_id: int, db: Session = Depends(get_db)): def get_kpi_network(kpi_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""获取KPI的因果网络(上游驱动 + 下游影响)""" """获取KPI的因果网络(上游驱动 + 下游影响)— 账套隔离: 校验KPI属于当前企业"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi: if not kpi:
raise HTTPException(404, "KPI不存在") raise HTTPException(404, "KPI不存在")
if kpi.entity_id != entity_id:
raise HTTPException(404, "KPI不存在") # 跨企业不暴露存在性
# 上游(指向当前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 = [] upstream_list = []
for c in upstream: for c in upstream:
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first() 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, "kpi_id": src.id, "kpi_code": src.kpi_code, "kpi_name": src.kpi_name,
"strength": c.strength, "lag_months": c.lag_months, "strength": c.strength, "lag_months": c.lag_months,
"direction": c.direction, "formula": c.formula, "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指向的因果) # 下游(当前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 = [] downstream_list = []
for c in downstream: for c in downstream:
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first() 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, "kpi_id": tgt.id, "kpi_code": tgt.kpi_code, "kpi_name": tgt.kpi_name,
"strength": c.strength, "lag_months": c.lag_months, "strength": c.strength, "lag_months": c.lag_months,
"direction": c.direction, "formula": c.formula, "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 { return {
@@ -101,7 +121,7 @@ def get_kpi_network(kpi_id: int, db: Session = Depends(get_db)):
@router.post("/simulate") @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的影响 """模拟推演: 修改一个KPI的值,预测对其他KPI的影响
Body: { kpi_id: int, new_value: float, period: str } 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() source_kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not source_kpi: if not source_kpi:
raise HTTPException(404, "KPI不存在") raise HTTPException(404, "KPI不存在")
if source_kpi.entity_id != entity_id:
raise HTTPException(404, "KPI不存在")
# 获取当前值 # 获取当前值
current_value = None current_value = None
@@ -142,9 +164,10 @@ def simulate_causality(data: dict, db: Session = Depends(get_db)):
continue continue
visited.add(current_kpi_id) visited.add(current_kpi_id)
# 查找从current_kpi_id出发的下游因果链 # 查找从current_kpi_id出发的下游因果链(账套隔离: 仅本企业链)
downstream = db.query(KPICausality).filter( 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() ).all()
for edge in downstream: for edge in downstream:
@@ -212,15 +235,18 @@ def simulate_causality(data: dict, db: Session = Depends(get_db)):
def list_causalities( def list_causalities(
source_kpi_id: Optional[int] = None, source_kpi_id: Optional[int] = None,
target_kpi_id: Optional[int] = None, target_kpi_id: Optional[int] = None,
verify_status: Optional[str] = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id), entity_id: int = Depends(get_entity_id),
): ):
"""获取因果链列表(账套隔离: 仅当前企业KPI, 2026-08-23 P1b""" """获取因果链列表(账套隔离: 仅当前企业KPI, 2026-08-23 P1b, 2026-08-27 支持verify_status筛选"""
query = db.query(KPICausality).join(KPIDefinition, KPIDefinition.id == KPICausality.source_kpi_id).filter(KPIDefinition.entity_id == entity_id) query = db.query(KPICausality).filter(KPICausality.entity_id == entity_id)
if source_kpi_id: if source_kpi_id:
query = query.filter(KPICausality.source_kpi_id == source_kpi_id) query = query.filter(KPICausality.source_kpi_id == source_kpi_id)
if target_kpi_id: if target_kpi_id:
query = query.filter(KPICausality.target_kpi_id == 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() items = query.order_by(KPICausality.id).all()
result = [] result = []
@@ -236,11 +262,51 @@ def list_causalities(
return {"data": result, "total": len(result)} 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}") @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() c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
if not c: if not c:
raise HTTPException(404, "因果链不存在") raise HTTPException(404, "因果链不存在")
if c.entity_id != entity_id:
raise HTTPException(404, "因果链不存在") # 账套隔离
d = _to_dict(c) d = _to_dict(c)
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first() src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_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("") @router.post("")
def create_causality(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES): 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") source_id = data.get("source_kpi_id")
target_id = data.get("target_kpi_id") target_id = data.get("target_kpi_id")
if not source_id or not target_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() tgt = db.query(KPIDefinition).filter(KPIDefinition.id == target_id).first()
if not src or not tgt: if not src or not tgt:
raise HTTPException(404, "KPI不存在") raise HTTPException(404, "KPI不存在")
if src.entity_id != tgt.entity_id:
raise HTTPException(400, "源KPI和目标KPI必须属于同一企业")
existing = db.query(KPICausality).filter( existing = db.query(KPICausality).filter(
KPICausality.source_kpi_id == source_id, 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: if existing:
raise HTTPException(400, f"因果链已存在: {src.kpi_code}{tgt.kpi_code}") 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( c = KPICausality(
entity_id=src.entity_id,
source_kpi_id=source_id, source_kpi_id=source_id,
target_kpi_id=target_id, target_kpi_id=target_id,
strength=data.get("strength", 0.5), strength=data.get("strength", 0.5),
lag_months=data.get("lag_months", 1), lag_months=data.get("lag_months", 1),
formula=data.get("formula"), formula=data.get("formula"),
direction=data.get("direction", "positive"), direction=data.get("direction", "positive"),
source_type=source_type,
verify_status=STATUS_PENDING,
) )
db.add(c) db.add(c)
db.commit() db.commit()
db.refresh(c) db.refresh(c)
db.add(OperationLog(action="create", target_type="kpi_causality", 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() db.commit()
return _to_dict(c) return _to_dict(c)
@router.put("/{causality_id}") @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() c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
if not c: if not c:
raise HTTPException(404, "因果链不存在") 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 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]) 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.commit()
db.refresh(c) db.refresh(c)
return _to_dict(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}") @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() c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
if c: if c:
if c.entity_id != entity_id:
raise HTTPException(404, "因果链不存在") # 账套隔离
db.delete(c) db.delete(c)
db.commit() db.commit()
return {"message": "已删除"} return {"message": "已删除"}
+5
View File
@@ -292,12 +292,17 @@ class KPICausality(Base):
"""KPI因果链 — 记录KPI间的因果关系""" """KPI因果链 — 记录KPI间的因果关系"""
__tablename__ = "kpi_causality" __tablename__ = "kpi_causality"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-27)")
source_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="源KPI(因)") source_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="源KPI(因)")
target_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="目标KPI(果)") target_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="目标KPI(果)")
strength = Column(Float, default=0.5, comment="影响强度 0~1") strength = Column(Float, default=0.5, comment="影响强度 0~1")
lag_months = Column(Integer, default=1, comment="滞后期(月)") lag_months = Column(Integer, default=1, comment="滞后期(月)")
formula = Column(String(500), nullable=True, comment="影响公式描述") formula = Column(String(500), nullable=True, comment="影响公式描述")
direction = Column(String(10), default="positive", comment="positive/negative 正向/负向影响") direction = Column(String(10), default="positive", comment="positive/negative 正向/负向影响")
source_type = Column(String(20), default="manual", comment="建链来源 AI_suggested/manual/imported (2026-08-27 验证机制)")
verify_status = Column(String(20), default="pending", comment="验证状态 pending/data_verified/human_verified/disputed (2026-08-27)")
verified_at = Column(DateTime, nullable=True, comment="验证时间")
verified_by = Column(String(50), nullable=True, comment="验证人/AI/脚本")
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
@@ -0,0 +1,238 @@
"""因果链验证核心逻辑 — 数据验证(Pearson相关性)+ 状态机 (2026-08-27 P2)
三层验证:
1. 数据验证(自动化):kpi_values 历史值 → Pearson 相关系数 + 方向一致性 + 滞后对齐
2. AI/人工验证:战略回顾会人工打标 human_verifiedAPI PUT /verify
3. 状态机流转:
pending(初始)→ data_verified / disputed(数据验证 cron
pending/data_verified/disputed → human_verified(人工确认,最终)
任何矛盾 → disputed(待检)
判定规则(可解释、可测试):
- 对齐后数据点 < MIN_POINTS(4) → pending(数据不足,无法统计验证)
- |r| >= CORR_THRESHOLD(0.5) 且方向与 direction 一致 → data_verified
- 否则(点数足够但弱相关/方向矛盾)→ disputed
- human_verified 为人工最终确认,脚本默认不覆盖(respect_human=True),
但若数据矛盾会在报告中给出警示(disputed_note
脚本入口: scripts/correlation-check.py
测试入口: tests/test_causality_verification.py
"""
from __future__ import annotations
import logging
import math
import re
from typing import Dict, List, Optional, Tuple
logger = logging.getLogger("causality-verification")
# 验证状态
STATUS_PENDING = "pending"
STATUS_DATA_VERIFIED = "data_verified"
STATUS_HUMAN_VERIFIED = "human_verified"
STATUS_DISPUTED = "disputed"
ALL_STATUSES = (STATUS_PENDING, STATUS_DATA_VERIFIED, STATUS_HUMAN_VERIFIED, STATUS_DISPUTED)
# 建链来源
SOURCE_MANUAL = "manual"
SOURCE_AI = "AI_suggested"
SOURCE_IMPORTED = "imported"
ALL_SOURCE_TYPES = (SOURCE_MANUAL, SOURCE_AI, SOURCE_IMPORTED)
# 判定参数
MIN_POINTS = 4 # 最少对齐数据点(少于则无法统计验证)
CORR_THRESHOLD = 0.5 # |r| 阈值:达到且方向一致 → 数据证实
VERIFIER_SCRIPT = "correlation-check"
# period 粒度(对齐时只允许同粒度配对,避免月度/年度量纲混用)
GRANULARITY_ORDER = ("month", "half", "year")
_PERIOD_RE = {
"month": re.compile(r"^(\d{4})-(\d{2})$"),
"half": re.compile(r"^(\d{4})-H([12])$"),
"year": re.compile(r"^(\d{4})$"),
}
def parse_period(period: str) -> Optional[Tuple[str, int]]:
"""解析 period 为 (granularity, seq)。
seq = year*12 + 月序号(0-indexed),可比较/做滞后偏移。
- '2026-07' → ('month', 2026*12+6)
- '2026-H1' → ('half', 2026*12+5) (H1≈6月)
- '2026-H2' → ('half', 2026*12+11) (H2≈12月)
- '2026' → ('year', 2026*12+5) (年中)
无法解析 → None
"""
if not period:
return None
s = str(period).strip()
m = _PERIOD_RE["month"].match(s)
if m:
year, mon = int(m.group(1)), int(m.group(2))
if 1 <= mon <= 12:
return ("month", year * 12 + (mon - 1))
m = _PERIOD_RE["half"].match(s)
if m:
year, half = int(m.group(1)), int(m.group(2))
return ("half", year * 12 + (5 if half == 1 else 11))
m = _PERIOD_RE["year"].match(s)
if m:
return ("year", int(m.group(1)) * 12 + 5)
return None
def pearson(xs: List[float], ys: List[float]) -> Tuple[Optional[float], int]:
"""Pearson 相关系数。点数 < 2 返回 (None, n)。"""
n = len(xs)
if n < 2:
return None, n
mx = sum(xs) / n
my = sum(ys) / n
sxy = sum((x - mx) * (y - my) for x, y in zip(xs, ys))
sxx = sum((x - mx) ** 2 for x in xs)
syy = sum((y - my) ** 2 for y in ys)
if sxx <= 0 or syy <= 0: # 常数列 → 无相关
return None, n
r = sxy / math.sqrt(sxx * syy)
# 数值保护:浮点误差可能略超 [-1,1]
return max(-1.0, min(1.0, r)), n
def align_series(
source_values: List[Tuple[str, float]],
target_values: List[Tuple[str, float]],
lag_months: int = 0,
) -> Tuple[Optional[str], List[Tuple[float, float]]]:
"""按滞后期对齐 source/target 时间序列,返回 (granularity, pairs)。
语义:source 是因(先发生),target 是果(滞后 lag 月出现)。
pair = (source[t], target[t + lag])。
只使用同粒度(month/half/year)数据配对,避免量纲混用。
按粒度优先级 month > half > year 选取数据点最多的粒度。
"""
parsed_src: Dict[str, Dict[int, float]] = {g: {} for g in GRANULARITY_ORDER}
parsed_tgt: Dict[str, Dict[int, float]] = {g: {} for g in GRANULARITY_ORDER}
for period, val in source_values:
if val is None:
continue
r = parse_period(period)
if r:
g, seq = r
parsed_src[g][seq] = float(val)
for period, val in target_values:
if val is None:
continue
r = parse_period(period)
if r:
g, seq = r
parsed_tgt[g][seq] = float(val)
best_g, best_pairs = None, []
for g in GRANULARITY_ORDER:
src_map, tgt_map = parsed_src[g], parsed_tgt[g]
pairs = []
for seq, sv in sorted(src_map.items()):
tv = tgt_map.get(seq + lag_months)
if tv is not None:
pairs.append((sv, tv))
if len(pairs) > len(best_pairs):
best_g, best_pairs = g, pairs
return best_g, best_pairs
def evaluate_chain(
source_values: List[Tuple[str, float]],
target_values: List[Tuple[str, float]],
lag_months: int = 0,
direction: str = "positive",
min_points: int = MIN_POINTS,
corr_threshold: float = CORR_THRESHOLD,
) -> dict:
"""对单条因果链做数据验证。
返回:
{
granularity, n, r, expected_sign, actual_sign, direction_consistent,
status (pending/data_verified/disputed), reason
}
"""
granularity, pairs = align_series(source_values, target_values, lag_months)
n = len(pairs)
r = None
if n >= 2:
r, _ = pearson([p[0] for p in pairs], [p[1] for p in pairs])
expected_sign = 1 if direction == "positive" else -1
actual_sign = 1 if r is not None and r > 0 else (-1 if r is not None and r < 0 else 0)
direction_consistent = r is not None and actual_sign == expected_sign
if n < min_points or r is None:
return {
"granularity": granularity, "n": n, "r": r,
"expected_sign": expected_sign, "actual_sign": actual_sign,
"direction_consistent": direction_consistent,
"status": STATUS_PENDING,
"reason": f"数据不足(对齐后{n}点,需≥{min_points}点)" if n < min_points else "序列无方差,无法计算相关性",
}
abs_r = abs(r)
if abs_r >= corr_threshold and direction_consistent:
return {
"granularity": granularity, "n": n, "r": r,
"expected_sign": expected_sign, "actual_sign": actual_sign,
"direction_consistent": True,
"status": STATUS_DATA_VERIFIED,
"reason": f"|r|={abs_r:.3f}{corr_threshold} 且方向一致({direction}) → 数据证实",
}
if not direction_consistent:
return {
"granularity": granularity, "n": n, "r": r,
"expected_sign": expected_sign, "actual_sign": actual_sign,
"direction_consistent": False,
"status": STATUS_DISPUTED,
"reason": f"方向矛盾: 声明{direction}但实际相关方向{'' if r > 0 else ''} (r={r:.3f})",
}
return {
"granularity": granularity, "n": n, "r": r,
"expected_sign": expected_sign, "actual_sign": actual_sign,
"direction_consistent": True,
"status": STATUS_DISPUTED,
"reason": f"弱相关: |r|={abs_r:.3f}<{corr_threshold},数据暂不能证实该因果强度",
}
def apply_state_machine(
current_status: str,
eval_status: str,
respect_human: bool = True,
) -> Tuple[str, Optional[str]]:
"""状态机:根据数据验证结果流转状态。
规则:
- human_verified 是人工最终确认: respect_human=True 时不被脚本覆盖
(返回原状态 + disputed_note 警示)
- 数据不足(pending) → 保持当前状态(不降级已有结论)
- data_verified → 覆盖为非 human_verified 的当前状态
- disputed → 覆盖为非 human_verified 的当前状态
"""
if current_status == STATUS_HUMAN_VERIFIED and respect_human:
if eval_status == STATUS_DISPUTED:
return current_status, "人工已确认但数据复核矛盾,建议重新核对"
return current_status, None
if eval_status == STATUS_PENDING:
return current_status, None
return eval_status, None
def summarize(results: List[dict]) -> dict:
"""验证结果汇总统计。"""
counter = {s: 0 for s in ALL_STATUSES}
for r in results:
counter[r.get("status", STATUS_PENDING)] = counter.get(r.get("status", STATUS_PENDING), 0) + 1
return {
"total": len(results),
"by_status": counter,
"data_verified_ratio": round(counter[STATUS_DATA_VERIFIED] / len(results), 3) if results else 0,
}
+193
View File
@@ -0,0 +1,193 @@
#!/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())
@@ -0,0 +1,95 @@
"""kpi_causality 因果链验证机制迁移脚本 (2026-08-27 P2)
加列:
- source_type: varchar(20) 建链来源 AI_suggested/manual/imported
- verify_status: varchar(20) 验证状态 pending/data_verified/human_verified/disputed
- verified_at: datetime 验证时间
- verified_by: varchar(50) 验证人/AI/脚本
- entity_id: int 多租户隔离 (2026-08-27 收官补齐)
幂等: 列已存在则跳过; entity_id 回填只更新 NULL/0 行。
用法: python scripts/migrate_causality_verification.py
"""
import logging
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from sqlalchemy import text
from app.database import get_engine
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger("migrate-causality-verification")
COLUMNS = [
("source_type", "ALTER TABLE kpi_causality ADD COLUMN source_type VARCHAR(20) NOT NULL DEFAULT 'manual' COMMENT '建链来源 AI_suggested/manual/imported'"),
("verify_status", "ALTER TABLE kpi_causality ADD COLUMN verify_status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '验证状态 pending/data_verified/human_verified/disputed'"),
("verified_at", "ALTER TABLE kpi_causality ADD COLUMN verified_at DATETIME NULL COMMENT '验证时间'"),
("verified_by", "ALTER TABLE kpi_causality ADD COLUMN verified_by VARCHAR(50) NULL COMMENT '验证人/AI/脚本'"),
("entity_id", "ALTER TABLE kpi_causality ADD COLUMN entity_id INT NOT NULL DEFAULT 1 COMMENT '企业ID (多租户隔离 2026-08-27)'"),
]
def run():
engine = get_engine()
with engine.connect() as conn:
# 1. 检查表是否存在
exists = conn.execute(text(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'kpi_causality'"
)).scalar()
if not exists:
logger.error("kpi_causality 表不存在,跳过")
return 1
# 2. 现有列
existing = {r[0] for r in conn.execute(text(
"SELECT column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'kpi_causality'"
))}
logger.info("现有列: %s", sorted(existing))
# 3. 加列(幂等)
for col, ddl in COLUMNS:
if col in existing:
logger.info("%s 已存在,跳过", col)
else:
conn.execute(text(ddl))
logger.info("已添加列 %s", col)
# 4. 回填 entity_id(无条件从 source KPI 对齐,纠正默认值偏差)
# 仅当来源KPI存在才回填;无来源KPI的孤儿链保持原值
conn.execute(text(
"UPDATE kpi_causality c JOIN kpi_definitions k ON k.id = c.source_kpi_id "
"SET c.entity_id = k.entity_id"
))
orphan = conn.execute(text(
"SELECT COUNT(*) FROM kpi_causality c LEFT JOIN kpi_definitions k ON k.id = c.source_kpi_id "
"WHERE k.id IS NULL"
)).scalar()
if orphan:
logger.warning("%d 条因果链无来源KPI(孤儿链)", orphan)
else:
logger.info("entity_id 已全部按来源KPI回填")
# 5. 验证
cols = {r[0] for r in conn.execute(text(
"SELECT column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'kpi_causality'"
))}
missing = {c for c, _ in COLUMNS} - cols
if missing:
logger.error("仍有缺失列: %s", missing)
return 1
row = conn.execute(text(
"SELECT COUNT(*) FROM kpi_causality WHERE entity_id IS NULL OR entity_id = 0"
)).scalar()
if row:
logger.error("仍有 %d 行 entity_id 为空", row)
return 1
total = conn.execute(text("SELECT COUNT(*) FROM kpi_causality")).scalar()
logger.info("迁移完成: kpi_causality %d 条, 新列: source_type/verify_status/verified_at/verified_by/entity_id", total)
return 0
if __name__ == "__main__":
sys.exit(run())
@@ -0,0 +1,363 @@
"""因果链验证机制测试 — 数据验证核心 + 状态机 + API (2026-08-27 P2)
覆盖:
1. 服务层: parse_period / pearson / align_series(滞后) / evaluate_chain / apply_state_machine
2. API: create(source_type) / verify-status / verify(人工确认) / entity隔离 / 权限
"""
import hashlib
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import KPIDefinition, KPICausality, KPIValue, Entity, User
from app.services.causality_verification import (
STATUS_DATA_VERIFIED,
STATUS_DISPUTED,
STATUS_HUMAN_VERIFIED,
STATUS_PENDING,
align_series,
apply_state_machine,
evaluate_chain,
parse_period,
pearson,
summarize,
)
from tests.conftest import create_test_user, get_token_for_user, auth_header
BASE = "/api/cma/kpi-causality"
def _seed_kpi(db: Session, code: str, name: str = None, dimension: str = "finance",
entity_id: int = 1) -> KPIDefinition:
kpi = KPIDefinition(
kpi_code=code, kpi_name=name or code, dimension=dimension,
entity_id=entity_id, status="active", target_value=100.0,
)
db.add(kpi)
db.commit()
db.refresh(kpi)
return kpi
def _seed_chain(db: Session, source_type: str = "AI_suggested"):
src = _seed_kpi(db, "BH_REVENUE", "营业收入")
tgt = _seed_kpi(db, "BH_NET_PROFIT", "净利润")
c = KPICausality(entity_id=src.entity_id, source_kpi_id=src.id, target_kpi_id=tgt.id,
strength=0.5, lag_months=0, direction="positive",
source_type=source_type, verify_status=STATUS_PENDING)
db.add(c)
db.commit()
db.refresh(c)
return src, tgt, c
class TestParsePeriod:
def test_month(self):
assert parse_period("2026-07") == ("month", 2026 * 12 + 6)
def test_half(self):
assert parse_period("2026-H1") == ("half", 2026 * 12 + 5)
assert parse_period("2026-H2") == ("half", 2026 * 12 + 11)
def test_year(self):
assert parse_period("2026") == ("year", 2026 * 12 + 5)
def test_invalid(self):
assert parse_period("abc") is None
assert parse_period("") is None
assert parse_period(None) is None
class TestPearson:
def test_perfect_positive(self):
r, n = pearson([1, 2, 3, 4], [2, 4, 6, 8])
assert n == 4
assert abs(r - 1.0) < 1e-9
def test_perfect_negative(self):
r, n = pearson([1, 2, 3, 4], [8, 6, 4, 2])
assert abs(r + 1.0) < 1e-9
def test_known_value(self):
# 与 numpy 核对过的样例 (F_REVENUE / F_NET_PROFIT 7点)
xs = [180.87, 132.33, 120.15, 60.5, 90.09, 129.32, 81.08]
ys = [94.31, -85.06, -21.66, -3.74, -21.91, -45.04, -17.63]
r, n = pearson(xs, ys)
assert n == 7
assert abs(r - 0.394012) < 1e-4
def test_insufficient(self):
r, n = pearson([1], [2])
assert r is None and n == 1
def test_constant_series(self):
r, n = pearson([3, 3, 3], [1, 2, 3])
assert r is None and n == 3
class TestAlignSeries:
def test_no_lag(self):
src = [("2026-01", 1), ("2026-02", 2), ("2026-03", 3)]
tgt = [("2026-01", 10), ("2026-02", 20), ("2026-03", 30)]
g, pairs = align_series(src, tgt, lag_months=0)
assert g == "month"
assert pairs == [(1, 10), (2, 20), (3, 30)]
def test_lag_alignment(self):
"""source t 与 target t+lag 配对"""
src = [("2026-01", 1), ("2026-02", 2), ("2026-03", 3)]
tgt = [("2026-02", 10), ("2026-03", 20), ("2026-04", 30)]
g, pairs = align_series(src, tgt, lag_months=1)
assert pairs == [(1, 10), (2, 20), (3, 30)]
def test_granularity_filter(self):
"""月度/半年度混用时只取同粒度(优先月)"""
src = [("2026-01", 1), ("2026-02", 2), ("2026-H1", 3)]
tgt = [("2026-01", 10), ("2026-02", 20), ("2026-H1", 30)]
g, pairs = align_series(src, tgt, lag_months=0)
assert g == "month"
assert pairs == [(1, 10), (2, 20)]
class TestEvaluateChain:
def test_data_verified_positive(self):
src = [(f"2026-{m:02d}", m) for m in range(1, 9)]
tgt = [(f"2026-{m:02d}", m * 2) for m in range(1, 9)]
ev = evaluate_chain(src, tgt, lag_months=0, direction="positive")
assert ev["status"] == STATUS_DATA_VERIFIED
assert ev["direction_consistent"] is True
assert ev["n"] == 8
def test_data_verified_negative(self):
src = [(f"2026-{m:02d}", m) for m in range(1, 9)]
tgt = [(f"2026-{m:02d}", -m * 2) for m in range(1, 9)]
ev = evaluate_chain(src, tgt, lag_months=0, direction="negative")
assert ev["status"] == STATUS_DATA_VERIFIED
def test_direction_conflict(self):
"""声明 positive 但实际负相关 → disputed"""
src = [(f"2026-{m:02d}", m) for m in range(1, 9)]
tgt = [(f"2026-{m:02d}", -m) for m in range(1, 9)]
ev = evaluate_chain(src, tgt, lag_months=0, direction="positive")
assert ev["status"] == STATUS_DISPUTED
assert "方向矛盾" in ev["reason"]
def test_weak_correlation(self):
"""弱相关(方向一致但|r|<阈值)→ disputed"""
# numpy seed=1: x=[1..8], y=x+N(0,6) → r≈0.119 (弱正相关)
src = [(f"2026-{m:02d}", m) for m in range(1, 9)]
tgt = [(f"2026-{m:02d}", y) for m, y in enumerate(
[10.75, -1.67, -0.17, -2.44, 10.19, -7.81, 17.47, 3.43], start=1)]
ev = evaluate_chain(src, tgt, lag_months=0, direction="positive")
assert ev["status"] == STATUS_DISPUTED
assert "弱相关" in ev["reason"]
def test_insufficient_points(self):
"""数据点不足 → pending"""
src = [("2026-01", 1), ("2026-02", 2)]
tgt = [("2026-01", 10), ("2026-02", 20)]
ev = evaluate_chain(src, tgt, lag_months=0, direction="positive")
assert ev["status"] == STATUS_PENDING
def test_no_shared_periods(self):
src = [("2026-01", 1)]
tgt = [("2026-02", 10)]
ev = evaluate_chain(src, tgt, lag_months=0, direction="positive")
assert ev["status"] == STATUS_PENDING
class TestStateMachine:
def test_pending_to_verified(self):
st, note = apply_state_machine(STATUS_PENDING, STATUS_DATA_VERIFIED)
assert st == STATUS_DATA_VERIFIED and note is None
def test_pending_to_disputed(self):
st, _ = apply_state_machine(STATUS_PENDING, STATUS_DISPUTED)
assert st == STATUS_DISPUTED
def test_human_verified_not_overridden(self):
st, note = apply_state_machine(STATUS_HUMAN_VERIFIED, STATUS_DISPUTED)
assert st == STATUS_HUMAN_VERIFIED
assert note is not None # 数据矛盾警示
def test_human_verified_positive_note_none(self):
st, note = apply_state_machine(STATUS_HUMAN_VERIFIED, STATUS_DATA_VERIFIED)
assert st == STATUS_HUMAN_VERIFIED and note is None
def test_insufficient_keeps_status(self):
st, _ = apply_state_machine(STATUS_PENDING, STATUS_PENDING)
assert st == STATUS_PENDING
class TestSummarize:
def test_counts(self):
s = summarize([{"status": STATUS_DATA_VERIFIED}, {"status": STATUS_DISPUTED},
{"status": STATUS_PENDING}, {"status": STATUS_HUMAN_VERIFIED}])
assert s["total"] == 4
assert s["by_status"][STATUS_DATA_VERIFIED] == 1
assert s["by_status"][STATUS_DISPUTED] == 1
assert s["by_status"][STATUS_PENDING] == 1
assert s["by_status"][STATUS_HUMAN_VERIFIED] == 1
class TestCausalityVerificationAPI:
def test_create_with_source_type(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
src = _seed_kpi(db, "BH_REVENUE", "营业收入")
tgt = _seed_kpi(db, "BH_NET_PROFIT", "净利润")
resp = client.post(BASE, headers=auth_header(token), json={
"source_kpi_id": src.id, "target_kpi_id": tgt.id,
"source_type": "AI_suggested",
})
assert resp.status_code == 200
body = resp.json()
assert body["source_type"] == "AI_suggested"
assert body["verify_status"] == STATUS_PENDING
def test_create_invalid_source_type(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
src = _seed_kpi(db, "BH_REVENUE", "营业收入")
tgt = _seed_kpi(db, "BH_NET_PROFIT", "净利润")
resp = client.post(BASE, headers=auth_header(token), json={
"source_kpi_id": src.id, "target_kpi_id": tgt.id, "source_type": "unknown",
})
assert resp.status_code == 400
def test_update_resets_verification(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
src, tgt, c = _seed_chain(db)
# 先人工确认
resp = client.put(f"{BASE}/{c.id}/verify", headers=auth_header(token),
json={"verify_status": "human_verified", "verified_by": "任富海"})
assert resp.json()["verify_status"] == STATUS_HUMAN_VERIFIED
# 修改链定义 → 状态回到 pending
resp2 = client.put(f"{BASE}/{c.id}", headers=auth_header(token), json={"strength": 0.9})
assert resp2.json()["verify_status"] == STATUS_PENDING
assert resp2.json()["verified_by"] is None
def test_verify_status_endpoint(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
src, tgt, c = _seed_chain(db)
resp = client.get(f"{BASE}/verify-status", headers=auth_header(token))
assert resp.status_code == 200
body = resp.json()
assert body["summary"]["total"] == 1
assert body["summary"]["by_status"][STATUS_PENDING] == 1
assert body["data"][0]["id"] == c.id
assert body["data"][0]["verify_status"] == STATUS_PENDING
def test_verify_status_filter(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
src, tgt, c = _seed_chain(db)
client.put(f"{BASE}/{c.id}/verify", headers=auth_header(token),
json={"verify_status": "human_verified"})
resp = client.get(f"{BASE}/verify-status?verify_status=human_verified",
headers=auth_header(token))
assert resp.json()["summary"]["total"] == 1
resp2 = client.get(f"{BASE}/verify-status?verify_status=pending", headers=auth_header(token))
assert resp2.json()["summary"]["total"] == 0
def test_human_verify(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
src, tgt, c = _seed_chain(db)
resp = client.put(f"{BASE}/{c.id}/verify", headers=auth_header(token),
json={"verify_status": "human_verified", "verified_by": "任富海"})
assert resp.status_code == 200
body = resp.json()
assert body["verify_status"] == STATUS_HUMAN_VERIFIED
assert body["verified_by"] == "任富海"
assert body["verified_at"] is not None
def test_human_verify_default(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
src, tgt, c = _seed_chain(db)
resp = client.put(f"{BASE}/{c.id}/verify", headers=auth_header(token), json={})
assert resp.json()["verify_status"] == STATUS_HUMAN_VERIFIED
assert resp.json()["verified_by"] is not None # 默认取用户名
def test_verify_disputed(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
src, tgt, c = _seed_chain(db)
resp = client.put(f"{BASE}/{c.id}/verify", headers=auth_header(token),
json={"verify_status": "disputed"})
assert resp.json()["verify_status"] == STATUS_DISPUTED
def test_verify_invalid_status(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
src, tgt, c = _seed_chain(db)
resp = client.put(f"{BASE}/{c.id}/verify", headers=auth_header(token),
json={"verify_status": "bogus"})
assert resp.status_code == 400
def test_verify_not_found(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
resp = client.put(f"{BASE}/99999/verify", headers=auth_header(token),
json={"verify_status": "human_verified"})
assert resp.status_code == 404
def test_business_cannot_verify(self, client: TestClient, db: Session):
"""business 角色无写权限 → 403"""
business = User(
username="business_verify", password_hash=hashlib.sha256("pass123".encode()).hexdigest(),
name="业务员", role="business",
)
db.add(business)
db.commit()
token = get_token_for_user(client, username="business_verify", password="pass123")
src, tgt, c = _seed_chain(db)
resp = client.put(f"{BASE}/{c.id}/verify", headers=auth_header(token),
json={"verify_status": "human_verified"})
assert resp.status_code == 403
def test_entity_isolation(self, client: TestClient, db: Session):
"""企业B看不到企业A的链,也不能verify企业A的链"""
create_test_user(db)
token = get_token_for_user(client) # entity_id=1
_seed_entity2(db)
src2 = _seed_kpi(db, "BH2_REVENUE", "博海收入", entity_id=2)
tgt2 = _seed_kpi(db, "BH2_PROFIT", "博海利润", entity_id=2)
c2 = KPICausality(entity_id=2, source_kpi_id=src2.id, target_kpi_id=tgt2.id,
strength=0.5, lag_months=0, direction="positive",
source_type="manual", verify_status=STATUS_PENDING)
db.add(c2)
db.commit()
# entity1 的 verify-status 看不到 entity2 的链
resp = client.get(f"{BASE}/verify-status", headers=auth_header(token))
assert resp.json()["summary"]["total"] == 0
# entity1 的 token verify entity2 的链 → 404
resp2 = client.put(f"{BASE}/{c2.id}/verify", headers=auth_header(token),
json={"verify_status": "human_verified"})
assert resp2.status_code == 404
def test_network_includes_verify_status(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
src, tgt, c = _seed_chain(db)
resp = client.get(f"{BASE}/kpi/{src.id}/network", headers=auth_header(token))
assert resp.status_code == 200
downstream = resp.json()["downstream"]
assert downstream[0]["verify_status"] == STATUS_PENDING
assert downstream[0]["source_type"] == "AI_suggested"
def _seed_entity2(db: Session) -> None:
ent = db.query(Entity).filter(Entity.id == 2).first()
if not ent:
db.add(Entity(id=2, name="博海网络科技", short_name="博海", status="active"))
db.commit()
+2 -2
View File
@@ -348,6 +348,6 @@ class TestPermissions:
assert resp.status_code == 200 assert resp.status_code == 200
def test_no_token_denied(self, client: TestClient): def test_no_token_denied(self, client: TestClient):
"""无token → 403""" """无token → 401HTTPBearer 标准行为)"""
resp = client.get(BASE) resp = client.get(BASE)
assert resp.status_code == 403 assert resp.status_code == 401
+2
View File
@@ -293,6 +293,8 @@ export const kpiCausalityApi = {
getNetwork: (kpiId: number) => api.get(`/kpi-causality/kpi/${kpiId}/network`), getNetwork: (kpiId: number) => api.get(`/kpi-causality/kpi/${kpiId}/network`),
getFullNetwork: () => api.get('/kpi-causality/full-network'), getFullNetwork: () => api.get('/kpi-causality/full-network'),
simulate: (data: any) => api.post('/kpi-causality/simulate', data), simulate: (data: any) => api.post('/kpi-causality/simulate', data),
getVerifyStatus: (params?: any) => api.get('/kpi-causality/verify-status', { params }),
verify: (id: number, data: any) => api.put(`/kpi-causality/${id}/verify`, data),
} }
export const dataQualityApi = { export const dataQualityApi = {
+25 -2
View File
@@ -216,7 +216,13 @@
<el-timeline-item v-for="item in upstream" :key="item.kpi_id" <el-timeline-item v-for="item in upstream" :key="item.kpi_id"
:color="item.direction === 'positive' ? 'var(--bsc-process)' : 'var(--bsc-finance)'" :color="item.direction === 'positive' ? 'var(--bsc-process)' : 'var(--bsc-finance)'"
:timestamp="'强度:' + item.strength + ' / 滞后期:' + item.lag_months + '月'"> :timestamp="'强度:' + item.strength + ' / 滞后期:' + item.lag_months + '月'">
<div><b>{{ item.kpi_name }}</b> ({{ item.kpi_code }})</div> <div>
<b>{{ item.kpi_name }}</b> ({{ item.kpi_code }})
<span v-if="item.verify_status"
:style="{ display:'inline-block', marginLeft:'8px', padding:'0 8px', borderRadius:'10px', fontSize:'12px', lineHeight:'20px', color: verifyBadge(item.verify_status).color, background: verifyBadge(item.verify_status).bg }">
{{ verifyBadge(item.verify_status).text }}
</span>
</div>
<div style="font-size:12px;color:#999;"> <div style="font-size:12px;color:#999;">
{{ item.direction === 'positive' ? '正向驱动' : '负向抑制' }} {{ item.direction === 'positive' ? '正向驱动' : '负向抑制' }}
<span v-if="item.formula"> · {{ item.formula }}</span> <span v-if="item.formula"> · {{ item.formula }}</span>
@@ -233,7 +239,13 @@
<el-timeline-item v-for="item in downstream" :key="item.kpi_id" <el-timeline-item v-for="item in downstream" :key="item.kpi_id"
:color="item.direction === 'positive' ? 'var(--bsc-process)' : 'var(--bsc-finance)'" :color="item.direction === 'positive' ? 'var(--bsc-process)' : 'var(--bsc-finance)'"
:timestamp="'强度:' + item.strength + ' / 滞后期:' + item.lag_months + '月'"> :timestamp="'强度:' + item.strength + ' / 滞后期:' + item.lag_months + '月'">
<div><b>{{ item.kpi_name }}</b> ({{ item.kpi_code }})</div> <div>
<b>{{ item.kpi_name }}</b> ({{ item.kpi_code }})
<span v-if="item.verify_status"
:style="{ display:'inline-block', marginLeft:'8px', padding:'0 8px', borderRadius:'10px', fontSize:'12px', lineHeight:'20px', color: verifyBadge(item.verify_status).color, background: verifyBadge(item.verify_status).bg }">
{{ verifyBadge(item.verify_status).text }}
</span>
</div>
<div style="font-size:12px;color:#999;"> <div style="font-size:12px;color:#999;">
{{ item.direction === 'positive' ? '正向推动' : '负向抑制' }} {{ item.direction === 'positive' ? '正向推动' : '负向抑制' }}
<span v-if="item.formula"> · {{ item.formula }}</span> <span v-if="item.formula"> · {{ item.formula }}</span>
@@ -408,6 +420,17 @@ function statusLabel(s: string) {
} }
return map[s] || s || '—' return map[s] || s || '—'
} }
// 因果链验证状态映射(三层验证机制 2026-08-27
function verifyBadge(s: string) {
const map: Record<string, { text: string; color: string; bg: string }> = {
data_verified: { text: '✅ 数据证实', color: '#67c23a', bg: 'rgba(103,194,58,.12)' },
human_verified: { text: '✅ 人工确认', color: '#409eff', bg: 'rgba(64,158,255,.12)' },
disputed: { text: '⚠️ 存疑', color: '#e6a23c', bg: 'rgba(230,162,60,.12)' },
pending: { text: '⏳ 待检', color: '#909399', bg: 'rgba(144,147,153,.12)' },
}
return map[s] || { text: s || '—', color: '#909399', bg: 'rgba(144,147,153,.12)' }
}
const mapOptions = ref<any[]>([]) const mapOptions = ref<any[]>([])
const activeTab = ref('basic') const activeTab = ref('basic')