Files
yunke/yunke-server/tests/test_management_scoring.py
T

306 lines
13 KiB
Python

"""管理域评分配置/引擎判别测试(批次B Task B2)。"""
import json
import sqlalchemy as sa
from app.db.models import EmployeeScoreConfig
from app.management.employee_metrics import PeriodMetrics
from app.management.metric_definitions import (
DEFAULT_SCORE_CONFIG,
METRIC_DEFINITIONS,
validate_config,
)
from app.management.scoring import normalize, score_employee
def test_config_weights_sum_and_structure():
dims = DEFAULT_SCORE_CONFIG["dimensions"]
assert sum(d["weight"] for d in dims.values()) == 100
# 一期 pending_data 的维度不参与试算
available = [d for d in dims.values() if d.get("status") != "pending_data"]
assert sum(d["weight"] for d in available) == 70 # 30+25+15
assert dims["action"]["status"] == "pending_data"
assert dims["risk"]["status"] == "pending_data"
def test_config_normalization_declared():
assert DEFAULT_SCORE_CONFIG["normalization"] == "available_only_rescale"
def test_validate_config_rejects_bad_weight_sum():
bad = json.loads(json.dumps(DEFAULT_SCORE_CONFIG)) # deep copy
bad["dimensions"]["service"]["weight"] = 26 # 总和 101
problems = validate_config(bad)
assert any("权重" in p for p in problems)
def test_metric_definitions_cover_scored_metrics():
# 每个参与评分的 metric_code 都有元数据(名称/单位/方向)
for dim in DEFAULT_SCORE_CONFIG["dimensions"].values():
for code in dim.get("metrics", {}):
# band 型指标的 key 带 _band 后缀,spec 里有 metric 字段:
spec = dim["metrics"][code]
src = spec.get("metric", code)
assert src in METRIC_DEFINITIONS
m = METRIC_DEFINITIONS[src]
assert m["name"] and m["unit"] and m["direction"] in (1, -1)
async def test_seed_and_idempotent(make_session):
from app.management.metric_definitions import _seed_score_config # 纯会话函数
s = make_session
v1 = await _seed_score_config(s)
await s.commit()
assert v1 == "v1.0"
rows = (await s.execute(sa.select(EmployeeScoreConfig))).scalars().all()
assert len(rows) == 1 and rows[0].status == "active"
# 幂等:已存在同版本返回 None 不新建
assert await _seed_score_config(s) is None
await s.commit()
assert len((await s.execute(sa.select(EmployeeScoreConfig))).scalars().all()) == 1
# ---- 评分引擎(Task B2) ----
def _pm(**overrides):
"""全零/None 默认的 PeriodMetrics 构造器(22 字段,覆盖式改)。"""
base = {
"user_id": "u1", "days_with_data": 0, "msg_sent": 0, "msg_recv": 0,
"cust_threads": 0, "threads_answered": 0, "fr_sum_ms": 0,
"fr_p50_ms": None, "fr_p90_ms": None,
"reply_2h_count": 0, "reply_24h_count": 0,
"active_friends": 0, "active_rooms_avg": 0.0,
"new_customer": 0, "cooling_customer": 0, "dormant_customer": 0,
"end_active_friends": 0, "reactivated_customer": 0,
"call_count": 0, "call_seconds": 0, "answered_call": 0,
"call_followup": 0,
}
base.update(overrides)
return PeriodMetrics(**base)
def _full_marks(**overrides):
"""三可用维度全满分:率 1.0/fr 10min/占比 0.6/两 band 区间内,样本全 ok。"""
full = dict(
cust_threads=50, reply_2h_count=50, reply_24h_count=50,
fr_p90_ms=600_000, # 10min ≤ 30min 满分线
end_active_friends=9, cooling_customer=3, dormant_customer=3, # 9/15=0.6
active_friends=50, # band [5,80] 内且样本 ok
call_count=20, # band [1,200] 内
)
full.update(overrides)
return _pm(**full)
def test_normalize_target_three_segments():
spec = {"type": "target", "floor": 0.40, "target": 0.85}
assert normalize(spec, 0.20) == 0.0 # ≤floor
assert normalize(spec, 0.40) == 0.0 # floor 处 0
assert normalize(spec, 0.625) == 50.0 # 中点 50
assert normalize(spec, 0.85) == 100.0 # target 处 100
assert normalize(spec, 0.99) == 100.0 # ≥target 截断
assert normalize(spec, None) is None # 无值≠0 分
def test_normalize_reverse_ends_and_midpoint():
spec = {"type": "reverse", "target_ms": 1_800_000, "floor_ms": 14_400_000}
assert normalize(spec, 1_800_000) == 100.0 # 30min 满分
assert normalize(spec, 600_000) == 100.0 # 更快也封顶 100
assert normalize(spec, 8_100_000) == 50.0 # (30min+4h) 中点 50
assert normalize(spec, 14_400_000) == 0.0 # 4h 0 分
assert normalize(spec, 28_800_000) == 0.0 # 更慢截断 0
assert normalize(spec, None) is None
def test_normalize_band_three_segments():
spec = {"type": "band", "metric": "active_friends", "low": 5, "high": 80,
"basis": "period_distinct"}
assert normalize(spec, 3) is None # <low 负载不足→不评分
assert normalize(spec, 4.9) is None
assert normalize(spec, 5) == 100.0 # 含下限
assert normalize(spec, 80) == 100.0 # 含上限
assert normalize(spec, 81) == 80.0 # 超上限 80 不再加
assert normalize(spec, 500) == 80.0
assert normalize(spec, None) is None
def test_zero_threads_rates_none_not_zero():
"""cust_threads=0 → 回复率无值(None),不是 0%;service 维度 pending。"""
res = score_employee(DEFAULT_SCORE_CONFIG, _pm(cust_threads=0))
for code in ("reply_2h_rate", "reply_24h_rate"):
md = res["metrics_detail"][code]
assert md["value"] is None
assert md["score"] is None
assert res["dimensions"]["service"]["status"] == "pending"
def test_insufficient_sample_employee():
"""cust_threads=5(<soft 10):service 维度 insufficient(率有值 0.0 但
不评分);customer 期末无存量 pending;workload 样本 af=0 也不足——
可用维度=0 → 整员工不评分。"""
res = score_employee(DEFAULT_SCORE_CONFIG, _pm(cust_threads=5))
assert res["dimensions"]["service"]["status"] == "insufficient"
assert res["dimensions"]["service"]["score"] is None
assert res["dimensions"]["customer"]["status"] == "pending"
assert res["dimensions"]["workload"]["status"] == "insufficient"
assert res["total_score"] is None
assert res["grade"] is None
assert res["score_status"] == "insufficient_sample"
md = res["metrics_detail"]["reply_2h_rate"]
assert md["value"] == 0.0 and md["score"] is None
assert md["sample_size"] == 5 and md["sample_status"] == "insufficient"
def test_low_confidence_still_scored():
"""cust_threads=15 ∈ [soft10, min30):照评但逐指标 low_confidence。"""
cur = _pm(cust_threads=15, reply_2h_count=15, reply_24h_count=15,
fr_p90_ms=600_000)
res = score_employee(DEFAULT_SCORE_CONFIG, cur)
svc = res["dimensions"]["service"]
assert svc["status"] == "scored" and svc["score"] == 100.0
for code in ("reply_2h_rate", "reply_24h_rate", "fr_p90_ms"):
assert svc["metrics"][code]["sample_status"] == "low_confidence"
assert svc["metrics"][code]["score"] == 100.0
# 仅 service 可用(25/25)→ 重归一后仍满分
assert res["score_status"] == "scored"
assert res["total_score"] == 100
def test_full_marks_rescale_100_of_70():
"""三可用维度(权重 30+25+15=70)全满分 → 100/70 重归一 = 100,A。"""
res = score_employee(DEFAULT_SCORE_CONFIG, _full_marks())
assert res["dimensions"]["customer"]["score"] == 100.0
assert res["dimensions"]["service"]["score"] == 100.0
assert res["dimensions"]["workload"]["score"] == 100.0
assert res["total_score"] == 100
assert res["grade"] == "A"
assert res["score_status"] == "scored"
# 部分:service 满分但 active_share=0(floor 下)→ (0+25+15)/70=57 → E
res2 = score_employee(
DEFAULT_SCORE_CONFIG,
_full_marks(end_active_friends=1, cooling_customer=10,
dormant_customer=10), # 1/21≈0.05 ≤ floor 0.20 → 0 分
)
assert res2["total_score"] == 57
assert res2["grade"] == "E"
def test_pending_dimensions_not_scored():
"""action/risk 一期 pending_data:status=pending、无 metrics、不参与归一。"""
res = score_employee(DEFAULT_SCORE_CONFIG, _full_marks())
for key in ("action", "risk"):
d = res["dimensions"][key]
assert d["status"] == "pending"
assert d["score"] is None
assert "metrics" not in d
scored_w = sum(d["weight"] for d in res["dimensions"].values()
if d["status"] == "scored")
assert scored_w == 70 # pending 的 30 权重不进分母
def test_active_share_derivation():
"""end_active 6/(6+cooling3+dormant1)=0.6 → 达 target 线 100 分。"""
cur = _pm(cust_threads=50, end_active_friends=6,
cooling_customer=3, dormant_customer=1)
res = score_employee(DEFAULT_SCORE_CONFIG, cur)
md = res["metrics_detail"]["active_share"]
assert md["value"] == 0.6
assert md["score"] == 100.0
assert res["dimensions"]["customer"]["score"] == 100.0
assert md["sample_status"] == "ok"
def test_metrics_detail_four_compare_slots():
"""value/prev/baseline/median 四对比位透出;band 明细的 median 按
源指标名(active_friends)取 team;单位/名称来自元数据。"""
cur = _pm(cust_threads=40, reply_2h_count=30, reply_24h_count=38,
fr_p90_ms=600_000, end_active_friends=6, cooling_customer=3,
dormant_customer=1, active_friends=40, call_count=10)
prev = _pm(cust_threads=40, reply_2h_count=20, reply_24h_count=30,
fr_p90_ms=3_600_000, end_active_friends=3, cooling_customer=6,
dormant_customer=1, active_friends=20, call_count=5)
base = _pm(cust_threads=40, reply_2h_count=10)
team = {"reply_2h_rate": 0.7, "active_friends": 33.0}
res = score_employee(DEFAULT_SCORE_CONFIG, cur, baseline=base,
team=team, prev=prev)
md = res["metrics_detail"]["reply_2h_rate"]
assert md["value"] == 0.75
assert md["prev"] == 0.5
assert md["baseline"] == 0.25
assert md["median"] == 0.7
assert md["unit"] == "0-1"
assert md["name"] == "2h回复率"
# band 型明细键=配置 code,median 按源指标名查 team
band = res["metrics_detail"]["active_friends_band"]
assert band["median"] == 33.0
assert band["unit"] == "人"
assert res["metrics_detail"]["fr_p90_ms"]["prev"] == 3_600_000
assert res["metrics_detail"]["active_share"]["prev"] == 0.3 # 3/(3+6+1)
def test_trial_flag_from_config():
"""试算标记透传:默认配置恒 True(v2 §0 全默认+试算)。"""
res = score_employee(DEFAULT_SCORE_CONFIG, _pm())
assert res["trial"] is True
# 标记来自 config 而非写死——正式版翻 False 只需改配置
no_trial = json.loads(json.dumps(DEFAULT_SCORE_CONFIG))
no_trial["trial"] = False
assert score_employee(no_trial, _pm())["trial"] is False
def test_validate_config_threshold_shapes():
"""阈值形状四查:type 合法/target floor<target/reverse 先后/band 区间。
默认配置自身必须通过。"""
assert validate_config(DEFAULT_SCORE_CONFIG) == []
def _bad(mutate):
cfg = json.loads(json.dumps(DEFAULT_SCORE_CONFIG))
mutate(cfg["dimensions"])
return cfg
bad_type = _bad(lambda d: d["workload"]["metrics"]["call_band"]
.update(type="linear"))
assert any("call_band" in p and "类型非法" in p
for p in validate_config(bad_type))
bad_target = _bad(lambda d: d["customer"]["metrics"]["active_share"]
.update(floor=0.70)) # floor>target 0.60
assert any("active_share" in p and "floor<target" in p
for p in validate_config(bad_target))
bad_reverse = _bad(lambda d: d["service"]["metrics"]["fr_p90_ms"]
.update(target_ms=20_000_000)) # > floor_ms 14.4M
assert any("fr_p90_ms" in p and "target_ms<floor_ms" in p
for p in validate_config(bad_reverse))
bad_band = _bad(lambda d: d["workload"]["metrics"]["call_band"]
.update(low=300)) # low>high 200
assert any("call_band" in p and "low<high" in p
for p in validate_config(bad_band))
def test_validate_config_grade_bounds_shape():
"""grade_bounds 须含 A/B/C/D 且严格降序(B4 DB 配置加载预埋)。"""
missing = json.loads(json.dumps(DEFAULT_SCORE_CONFIG)) # deep copy
del missing["grade_bounds"]["A"]
assert any("grade_bounds" in p for p in validate_config(missing))
non_desc = json.loads(json.dumps(DEFAULT_SCORE_CONFIG))
non_desc["grade_bounds"].update(A=80, B=90) # A<B 非降序
assert any("grade_bounds" in p and "严格降序" in p
for p in validate_config(non_desc))
def test_validate_config_cross_dimension_code_unique():
"""同指标 code 出现于两维度:metrics_detail 扁平 dict 会碰撞,须报问题。"""
dup = json.loads(json.dumps(DEFAULT_SCORE_CONFIG)) # deep copy
# 把 customer 维的 active_share 整个抄进 service 维 → code 跨维度重复
dup["dimensions"]["service"]["metrics"]["active_share"] = \
dup["dimensions"]["customer"]["metrics"]["active_share"]
assert any("active_share" in p and "重复" in p for p in validate_config(dup))
def test_validate_config_default_config_clean():
"""默认配置自身必须全绿(空问题列表)。"""
assert validate_config(DEFAULT_SCORE_CONFIG) == []