c9f8c844d6
Co-Authored-By: Claude Code <noreply@anthropic.com>
280 lines
12 KiB
Python
280 lines
12 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))
|