feat(management): 评分引擎——三类标准化/维度归一/样本保护/等级/四对比明细+期末active_friends接口修订
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -11,9 +11,12 @@
|
||||
owner:一员工多账号(wx1+wx2→u1,channel=None 时人人如此)的样本必须
|
||||
跨账号归并,逐 owner 取分位再映射会互相覆盖。fact 全库全期 ≤6.8万行,
|
||||
Python 侧聚合可行。
|
||||
- 期末存量(new/cooling/dormant):状态快照取「每 (user, channel) 最新
|
||||
≤end 行」——channel=None 时两渠道各取各的最新行再相加(直接取全渠道
|
||||
最大 day 的那一行会丢另一渠道的存量)。
|
||||
- 期末存量(new/cooling/dormant/end_active_friends):状态快照取
|
||||
「每 (user, channel) 最新 ≤end 行」——channel=None 时两渠道各取各的
|
||||
最新行再相加(直接取全渠道最大 day 的那一行会丢另一渠道的存量)。
|
||||
end_active_friends 与 new/cooling/dormant 同源同行(该渠道最新行的
|
||||
active_friends 列),供评分 active_share 用——区别于 active_friends
|
||||
字段的周期 distinct 口径。
|
||||
- active_rooms_avg = SUM(active_rooms)/全窗天数(含无数据日摊薄),
|
||||
非有数据天数——「日均负载」语义。
|
||||
- days_with_data = COUNT(DISTINCT day):单渠道时与 COUNT(*) 等价
|
||||
@@ -47,6 +50,7 @@ PeriodMetrics = namedtuple("PeriodMetrics", [
|
||||
"active_friends", # 期内 distinct(直查,非日值求和)
|
||||
"active_rooms_avg", # 日均(全窗天数摊薄)
|
||||
"new_customer", "cooling_customer", "dormant_customer", # 期末单日存量
|
||||
"end_active_friends", # 期末行 active_friends 跨渠道 SUM(active_share 分子)
|
||||
"reactivated_customer", # 期内求和
|
||||
"call_count", "call_seconds", "answered_call", "call_followup",
|
||||
])
|
||||
@@ -108,6 +112,7 @@ _ZERO: dict[str, object] = {
|
||||
"reply_2h_count": 0, "reply_24h_count": 0,
|
||||
"active_friends": 0, "active_rooms_sum": 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,
|
||||
}
|
||||
@@ -207,6 +212,7 @@ async def period_metrics(
|
||||
sa.func.sum(
|
||||
EmployeeMetricDaily.dormant_customer_count
|
||||
).label("dorm"),
|
||||
sa.func.sum(EmployeeMetricDaily.active_friends).label("af"),
|
||||
).where(sa.or_(*(
|
||||
sa.and_(
|
||||
EmployeeMetricDaily.user_id == uid,
|
||||
@@ -217,11 +223,12 @@ async def period_metrics(
|
||||
for uid, ch, d in latest
|
||||
))).group_by(EmployeeMetricDaily.user_id)
|
||||
)).all()
|
||||
for uid, nw, cool, dorm in stocks:
|
||||
for uid, nw, cool, dorm, af in stocks:
|
||||
a = acc[uid]
|
||||
a["new_customer"] = int(nw or 0)
|
||||
a["cooling_customer"] = int(cool or 0)
|
||||
a["dormant_customer"] = int(dorm or 0)
|
||||
a["end_active_friends"] = int(af or 0)
|
||||
|
||||
# ---- fact 分位 + 2h/24h 回复计数(同一过滤,Python 侧一次聚合) ----
|
||||
fr_conds = [FirstReplyFact.day.between(start_day, end_day)]
|
||||
@@ -305,6 +312,7 @@ async def period_metrics(
|
||||
new_customer=a["new_customer"],
|
||||
cooling_customer=a["cooling_customer"],
|
||||
dormant_customer=a["dormant_customer"],
|
||||
end_active_friends=a["end_active_friends"],
|
||||
reactivated_customer=a["reactivated_customer"],
|
||||
call_count=a["call_count"],
|
||||
call_seconds=a["call_seconds"],
|
||||
|
||||
@@ -58,7 +58,8 @@ METRIC_DEFINITIONS: dict[str, dict] = {
|
||||
# 阈值全部为试算起点(v2 §0 决议:全默认+试算标记);type 三种:
|
||||
# target : floor..target 线性 0..100(方向已含在 target>floor)
|
||||
# reverse : ≤target_ms 100 分,≥floor_ms 0 分,中间线性降
|
||||
# band : 区间内 80..100,低于下限不评分(None),超上限不再加分
|
||||
# band : 区间内 100,超上限 80(不再加分),低于下限 None(负载
|
||||
# 不足→不评分)——得分取值范围 80..100
|
||||
DEFAULT_SCORE_CONFIG: dict = {
|
||||
"version": "v1.0",
|
||||
"name": "一期试算(3维可用)",
|
||||
@@ -79,9 +80,11 @@ DEFAULT_SCORE_CONFIG: dict = {
|
||||
"action": {"weight": 20, "name": "行动执行",
|
||||
"status": "pending_data"}, # 批次E 接 followups/任务
|
||||
"workload": {"weight": 15, "name": "有效负载", "metrics": {
|
||||
# basis=period_distinct:用周期 distinct active_friends(B1 口径),
|
||||
# 比单日期末值更抗单日噪声(2026-09-09 B2 决议)
|
||||
"active_friends_band": {"type": "band", "metric": "active_friends",
|
||||
"low": 5, "high": 80,
|
||||
"basis": "period_daily_avg"},
|
||||
"basis": "period_distinct"},
|
||||
"call_band": {"type": "band", "metric": "call_count",
|
||||
"low": 1, "high": 200, "basis": "period_total",
|
||||
"note": "通话低不扣分仅提示;band 得分下限 80"},
|
||||
@@ -93,7 +96,14 @@ DEFAULT_SCORE_CONFIG: dict = {
|
||||
|
||||
|
||||
def validate_config(config: dict) -> list[str]:
|
||||
"""配置校验:权重总和/结构/引用的 metric_code 有元数据。返回问题清单。"""
|
||||
"""配置校验:权重总和/结构/引用的 metric_code 有元数据/阈值形状。
|
||||
|
||||
阈值形状(随评分引擎落地的 B0 评审预埋要求):
|
||||
- type 须为 target/reverse/band 三者之一
|
||||
- target : floor < target
|
||||
- reverse: target_ms < floor_ms(100 分线在 0 分线之前)
|
||||
- band : low < high
|
||||
"""
|
||||
problems: list[str] = []
|
||||
dims = config.get("dimensions", {})
|
||||
total = sum(d.get("weight", 0) for d in dims.values())
|
||||
@@ -106,6 +116,29 @@ def validate_config(config: dict) -> list[str]:
|
||||
src = spec.get("metric", code)
|
||||
if src not in METRIC_DEFINITIONS:
|
||||
problems.append(f"{key}.{code} 引用未定义指标 {src}")
|
||||
mtype = spec.get("type")
|
||||
if mtype not in ("target", "reverse", "band"):
|
||||
problems.append(
|
||||
f"{key}.{code} 类型非法:{mtype!r}(须 target/reverse/band)"
|
||||
)
|
||||
elif mtype == "target" and not spec.get("floor", 0) < spec.get("target", 0):
|
||||
problems.append(
|
||||
f"{key}.{code} target 型须 floor<target"
|
||||
f"(当前 floor={spec.get('floor')} target={spec.get('target')})"
|
||||
)
|
||||
elif mtype == "reverse" and not (
|
||||
spec.get("target_ms", 0) < spec.get("floor_ms", 0)
|
||||
):
|
||||
problems.append(
|
||||
f"{key}.{code} reverse 型须 target_ms<floor_ms"
|
||||
f"(当前 target_ms={spec.get('target_ms')}"
|
||||
f" floor_ms={spec.get('floor_ms')})"
|
||||
)
|
||||
elif mtype == "band" and not spec.get("low", 0) < spec.get("high", 0):
|
||||
problems.append(
|
||||
f"{key}.{code} band 型须 low<high"
|
||||
f"(当前 low={spec.get('low')} high={spec.get('high')})"
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""管理域评分引擎(批次B)——试算版(v2 §0 决议:全默认+试算标记)。
|
||||
|
||||
三类标准化(v1 §10.2):
|
||||
- target : floor..target 线性 0..100(≤floor 得 0,≥target 得 100)
|
||||
- reverse : ≤target_ms 100 分,≥floor_ms 0 分,中间线性降
|
||||
- band : 区间内 100,超上限 80(不再加),低于下限 None(负载不足→不评分)
|
||||
样本保护(v1 §10.3):指标样本量=串类 cust_threads/band 类 active_friends;
|
||||
<soft_threads(10) insufficient 不评分;soft(10)~min_threads(30) 低置信照评。
|
||||
维度分=指标均分(等权,None 剔除按剩余归一);总分=可用维度加权重归一
|
||||
(满分 Σweight×100/Σweight,一期可用 70 → 100/70 放大回百分制)——
|
||||
action/risk pending_data 不参与,不默认 0/100。
|
||||
|
||||
band 基数=周期 distinct active_friends(basis=period_distinct,B2 决议:
|
||||
比单日期末值更抗噪声且与 B1 口径一致)。
|
||||
|
||||
team 中位数键=派生指标名(spec 的 metric 字段,无则 code 本身):
|
||||
fr_p90_ms/active_friends/call_count 可直接用 team_medians 输出,
|
||||
reply_*_rate/active_share 需调用方对派生值取中位后传入。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from app.management.employee_metrics import PeriodMetrics
|
||||
from app.management.metric_definitions import METRIC_DEFINITIONS
|
||||
|
||||
_GRADE_ORDER = ("A", "B", "C", "D") # 低于 D 线为 E
|
||||
|
||||
|
||||
def normalize(spec: dict, value: float | None) -> float | None:
|
||||
"""三类标准化(v1 §10.2):value None→None;band 低于下限→None。
|
||||
|
||||
- target : (v-floor)/(target-floor) 线性 0..100,两端截断
|
||||
- reverse : (floor_ms-v)/(floor_ms-target_ms) 线性降 100..0,两端截断
|
||||
- band : [low,high] 内 100,>high 80(不再加),<low None(不评分)
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
mtype = spec["type"]
|
||||
if mtype == "target":
|
||||
floor, target = spec["floor"], spec["target"]
|
||||
if value <= floor:
|
||||
return 0.0
|
||||
if value >= target:
|
||||
return 100.0
|
||||
return (value - floor) / (target - floor) * 100.0
|
||||
if mtype == "reverse":
|
||||
target_ms, floor_ms = spec["target_ms"], spec["floor_ms"]
|
||||
if value <= target_ms:
|
||||
return 100.0
|
||||
if value >= floor_ms:
|
||||
return 0.0
|
||||
return (floor_ms - value) / (floor_ms - target_ms) * 100.0
|
||||
if mtype == "band":
|
||||
low, high = spec["low"], spec["high"]
|
||||
if value < low:
|
||||
return None
|
||||
if value > high:
|
||||
return 80.0
|
||||
return 100.0
|
||||
raise ValueError(f"未知指标类型 {mtype!r}(须 target/reverse/band)")
|
||||
|
||||
|
||||
def _derive_metrics(pm: PeriodMetrics) -> dict[str, float | None]:
|
||||
"""从 PeriodMetrics 派生可评分指标值(键=指标名,None=无值≠0)。"""
|
||||
out: dict[str, float | None] = {
|
||||
"fr_p90_ms": pm.fr_p90_ms,
|
||||
"active_friends": pm.active_friends, # band 用,周期 distinct
|
||||
"call_count": pm.call_count, # band 用,period_total
|
||||
}
|
||||
if pm.cust_threads:
|
||||
out["reply_2h_rate"] = pm.reply_2h_count / pm.cust_threads
|
||||
out["reply_24h_rate"] = pm.reply_24h_count / pm.cust_threads
|
||||
else:
|
||||
# 无串样本→无率(严格区分 0%),分子分母同源
|
||||
out["reply_2h_rate"] = None
|
||||
out["reply_24h_rate"] = None
|
||||
denom = pm.end_active_friends + pm.cooling_customer + pm.dormant_customer
|
||||
out["active_share"] = (
|
||||
pm.end_active_friends / denom if denom > 0 else None
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _fmt_ms(ms: float) -> str:
|
||||
minutes = ms / 60_000
|
||||
if minutes >= 60 and minutes % 60 == 0:
|
||||
return f"{minutes // 60:g}h"
|
||||
return f"{minutes:g}min"
|
||||
|
||||
|
||||
def _formula_note(spec: dict) -> str:
|
||||
"""指标计分公式的中文说明(B4 透出,阈值全部来自 config)。"""
|
||||
mtype = spec["type"]
|
||||
if mtype == "target":
|
||||
return (
|
||||
f"floor {spec['floor']}..target {spec['target']} 线性 0..100 分"
|
||||
"(≤floor 0,≥target 100)"
|
||||
)
|
||||
if mtype == "reverse":
|
||||
return (
|
||||
f"≤{_fmt_ms(spec['target_ms'])} 100 分,"
|
||||
f"≥{_fmt_ms(spec['floor_ms'])} 0 分,中间线性降"
|
||||
)
|
||||
return (
|
||||
f"{spec['low']}..{spec['high']} 区间内 100 分,超上限 80 分,"
|
||||
f"低于 {spec['low']} 不评分(负载不足);basis={spec.get('basis', '-')}"
|
||||
)
|
||||
|
||||
|
||||
def score_employee(
|
||||
config: dict,
|
||||
current: PeriodMetrics,
|
||||
baseline: PeriodMetrics | None = None,
|
||||
team: dict[str, float] | None = None,
|
||||
prev: PeriodMetrics | None = None,
|
||||
) -> dict:
|
||||
"""单员工试算评分(纯函数,B4 端点直接透出)。
|
||||
|
||||
prev/baseline/team 为四对比位槽位:prev/baseline 按同口径派生后对比,
|
||||
team 为指标级中位数(键=派生指标名,可 None)。返回结构见模块 docstring
|
||||
——metrics_detail 键=配置里的 metric_code(band 型如 active_friends_band)。
|
||||
"""
|
||||
cur_vals = _derive_metrics(current)
|
||||
prev_vals = _derive_metrics(prev) if prev is not None else None
|
||||
base_vals = _derive_metrics(baseline) if baseline is not None else None
|
||||
guards = config.get("sample_guards", {})
|
||||
soft = guards.get("soft_threads", 10)
|
||||
minimum = guards.get("min_threads", 30)
|
||||
|
||||
dimensions: dict[str, dict] = {}
|
||||
metrics_detail: dict[str, dict] = {}
|
||||
|
||||
for dim_key, dim in config.get("dimensions", {}).items():
|
||||
entry: dict = {
|
||||
"name": dim.get("name", dim_key),
|
||||
"weight": dim.get("weight", 0),
|
||||
"score": None,
|
||||
"status": "pending",
|
||||
}
|
||||
if dim.get("status") == "pending_data" or not dim.get("metrics"):
|
||||
# 一期 action/risk:数据未接入——不参与归一,不带 metrics
|
||||
dimensions[dim_key] = entry
|
||||
continue
|
||||
|
||||
dim_scores: list[float] = []
|
||||
any_insufficient = False # 有值但样本不足→维度 insufficient
|
||||
dim_metrics: dict[str, dict] = {}
|
||||
for code, spec in dim["metrics"].items():
|
||||
src = spec.get("metric", code)
|
||||
value = cur_vals.get(src)
|
||||
# 样本量:串类=cust_threads,band 类=active_friends
|
||||
sample_size = (
|
||||
current.active_friends
|
||||
if spec.get("type") == "band" else current.cust_threads
|
||||
)
|
||||
if sample_size >= minimum:
|
||||
sample_status = "ok"
|
||||
elif sample_size >= soft:
|
||||
sample_status = "low_confidence"
|
||||
else:
|
||||
sample_status = "insufficient"
|
||||
score: float | None = None
|
||||
if value is not None:
|
||||
if sample_status == "insufficient":
|
||||
any_insufficient = True # 有可评值但不评分
|
||||
else:
|
||||
score = normalize(spec, value)
|
||||
if score is not None:
|
||||
dim_scores.append(score)
|
||||
dim_metrics[code] = {
|
||||
"value": value,
|
||||
"score": score,
|
||||
"sample_size": sample_size,
|
||||
"sample_status": sample_status,
|
||||
"formula_note": _formula_note(spec),
|
||||
}
|
||||
metrics_detail[code] = {
|
||||
"value": value,
|
||||
"prev": prev_vals.get(src) if prev_vals else None,
|
||||
"baseline": base_vals.get(src) if base_vals else None,
|
||||
"median": team.get(src) if team else None,
|
||||
"sample_size": sample_size,
|
||||
"sample_status": sample_status,
|
||||
"score": score,
|
||||
"unit": METRIC_DEFINITIONS.get(src, {}).get("unit", ""),
|
||||
"name": METRIC_DEFINITIONS.get(src, {}).get("name", code),
|
||||
}
|
||||
|
||||
if dim_scores:
|
||||
entry["score"] = sum(dim_scores) / len(dim_scores)
|
||||
entry["status"] = "scored"
|
||||
elif any_insufficient:
|
||||
entry["status"] = "insufficient"
|
||||
else:
|
||||
entry["status"] = "pending" # 全部无值(期末无存量/无串样本等)
|
||||
entry["metrics"] = dim_metrics
|
||||
dimensions[dim_key] = entry
|
||||
|
||||
# 可用维度加权重归一:Σ(w×s)/Σw(满分=Σw×100/Σw=100),四舍五入取整
|
||||
available = [
|
||||
(d["weight"], d["score"]) for d in dimensions.values()
|
||||
if d["score"] is not None
|
||||
]
|
||||
total: int | None = None
|
||||
if available:
|
||||
wsum = sum(w for w, _ in available)
|
||||
total = int(math.floor(
|
||||
sum(w * s for w, s in available) / wsum + 0.5
|
||||
))
|
||||
grade: str | None = None
|
||||
if total is not None:
|
||||
grade = "E"
|
||||
bounds = config.get("grade_bounds", {})
|
||||
for g in _GRADE_ORDER: # A..D 阈值递减,首中即停
|
||||
if total >= bounds[g]:
|
||||
grade = g
|
||||
break
|
||||
|
||||
return {
|
||||
"total_score": total,
|
||||
"grade": grade,
|
||||
"score_status": "scored" if total is not None else "insufficient_sample",
|
||||
"trial": bool(config.get("trial")),
|
||||
"dimensions": dimensions,
|
||||
"metrics_detail": metrics_detail,
|
||||
}
|
||||
@@ -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