feat(management): 评分引擎——三类标准化/维度归一/样本保护/等级/四对比明细+期末active_friends接口修订
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -120,26 +120,32 @@ async def test_period_sums_vs_daily_rows(make_session):
|
||||
|
||||
async def test_end_of_period_stock(make_session):
|
||||
"""期末存量=每渠道最新 ≤end 行:窗 [0901,0903] 取 0901 行、
|
||||
[0901,0905] 取 0905 行;channel=None 跨渠道相加(4+2=6)。"""
|
||||
[0901,0905] 取 0905 行;channel=None 跨渠道相加(4+2=6)。
|
||||
end_active_friends 同源同行:wechat 7 + whatsapp 3 → 10(评分
|
||||
active_share 分子,与周期 distinct 的 active_friends 字段不同口径)。"""
|
||||
s = make_session
|
||||
await _seed_org(s)
|
||||
s.add(_mday(20260901, new_customer_count=1, cooling_customer_count=2,
|
||||
dormant_customer_count=3))
|
||||
s.add(_mday(20260905, dormant_customer_count=4))
|
||||
s.add(_mday(20260905, channel="whatsapp", dormant_customer_count=2))
|
||||
dormant_customer_count=3, active_friends=7))
|
||||
s.add(_mday(20260905, dormant_customer_count=4, active_friends=7))
|
||||
s.add(_mday(20260905, channel="whatsapp", dormant_customer_count=2,
|
||||
active_friends=3))
|
||||
await s.commit()
|
||||
|
||||
m1 = await period_metrics(s, 20260901, 20260903, "wechat")
|
||||
assert m1["u1"].new_customer == 1
|
||||
assert m1["u1"].cooling_customer == 2
|
||||
assert m1["u1"].dormant_customer == 3
|
||||
assert m1["u1"].end_active_friends == 7 # 渠道内最新行(0901)的值
|
||||
|
||||
m2 = await period_metrics(s, 20260901, 20260905, "wechat")
|
||||
assert m2["u1"].dormant_customer == 4 # wechat 渠道内最新行
|
||||
assert m2["u1"].new_customer == 0
|
||||
assert m2["u1"].end_active_friends == 7
|
||||
|
||||
m3 = await period_metrics(s, 20260901, 20260905, None)
|
||||
assert m3["u1"].dormant_customer == 6 # wechat 4 + whatsapp 2
|
||||
assert m3["u1"].end_active_friends == 10 # wechat 7 + whatsapp 3
|
||||
|
||||
|
||||
async def test_distinct_active_cross_day(make_session):
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"""管理域评分配置/引擎判别测试(批次B)。"""
|
||||
"""管理域评分配置/引擎判别测试(批次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():
|
||||
@@ -56,3 +58,222 @@ async def test_seed_and_idempotent(make_session):
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user