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:
@@ -11,3 +11,6 @@ __pycache__/
|
||||
*.tsbuildinfo
|
||||
venv/
|
||||
backend/logs/
|
||||
|
||||
# 因果链验证报告(生成物)
|
||||
backend/scripts/reports/
|
||||
|
||||
@@ -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": "已删除"}
|
||||
|
||||
@@ -292,12 +292,17 @@ class KPICausality(Base):
|
||||
"""KPI因果链 — 记录KPI间的因果关系"""
|
||||
__tablename__ = "kpi_causality"
|
||||
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(因)")
|
||||
target_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="目标KPI(果)")
|
||||
strength = Column(Float, default=0.5, comment="影响强度 0~1")
|
||||
lag_months = Column(Integer, default=1, comment="滞后期(月)")
|
||||
formula = Column(String(500), nullable=True, comment="影响公式描述")
|
||||
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())
|
||||
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_verified(API 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,
|
||||
}
|
||||
@@ -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()
|
||||
@@ -348,6 +348,6 @@ class TestPermissions:
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_no_token_denied(self, client: TestClient):
|
||||
"""无token → 403"""
|
||||
"""无token → 401(HTTPBearer 标准行为)"""
|
||||
resp = client.get(BASE)
|
||||
assert resp.status_code == 403
|
||||
assert resp.status_code == 401
|
||||
|
||||
@@ -293,6 +293,8 @@ export const kpiCausalityApi = {
|
||||
getNetwork: (kpiId: number) => api.get(`/kpi-causality/kpi/${kpiId}/network`),
|
||||
getFullNetwork: () => api.get('/kpi-causality/full-network'),
|
||||
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 = {
|
||||
|
||||
@@ -216,7 +216,13 @@
|
||||
<el-timeline-item v-for="item in upstream" :key="item.kpi_id"
|
||||
:color="item.direction === 'positive' ? 'var(--bsc-process)' : 'var(--bsc-finance)'"
|
||||
: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;">
|
||||
{{ item.direction === 'positive' ? '正向驱动' : '负向抑制' }}
|
||||
<span v-if="item.formula"> · {{ item.formula }}</span>
|
||||
@@ -233,7 +239,13 @@
|
||||
<el-timeline-item v-for="item in downstream" :key="item.kpi_id"
|
||||
:color="item.direction === 'positive' ? 'var(--bsc-process)' : 'var(--bsc-finance)'"
|
||||
: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;">
|
||||
{{ item.direction === 'positive' ? '正向推动' : '负向抑制' }}
|
||||
<span v-if="item.formula"> · {{ item.formula }}</span>
|
||||
@@ -408,6 +420,17 @@ function statusLabel(s: string) {
|
||||
}
|
||||
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 activeTab = ref('basic')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user