From c9f8c844d64516bfed8c5d87ea8768d17dc9d635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Wed, 9 Sep 2026 17:42:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(management):=20=E8=AF=84=E5=88=86=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E2=80=94=E2=80=94=E4=B8=89=E7=B1=BB=E6=A0=87=E5=87=86?= =?UTF-8?q?=E5=8C=96/=E7=BB=B4=E5=BA=A6=E5=BD=92=E4=B8=80/=E6=A0=B7?= =?UTF-8?q?=E6=9C=AC=E4=BF=9D=E6=8A=A4/=E7=AD=89=E7=BA=A7/=E5=9B=9B?= =?UTF-8?q?=E5=AF=B9=E6=AF=94=E6=98=8E=E7=BB=86+=E6=9C=9F=E6=9C=ABactive?= =?UTF-8?q?=5Ffriends=E6=8E=A5=E5=8F=A3=E4=BF=AE=E8=AE=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- .../app/management/employee_metrics.py | 16 +- .../app/management/metric_definitions.py | 39 ++- yunke-server/app/management/scoring.py | 227 ++++++++++++++++++ .../tests/test_management_period_metrics.py | 14 +- yunke-server/tests/test_management_scoring.py | 223 ++++++++++++++++- 5 files changed, 507 insertions(+), 12 deletions(-) create mode 100644 yunke-server/app/management/scoring.py diff --git a/yunke-server/app/management/employee_metrics.py b/yunke-server/app/management/employee_metrics.py index 01dabf0..977d255 100644 --- a/yunke-server/app/management/employee_metrics.py +++ b/yunke-server/app/management/employee_metrics.py @@ -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"], diff --git a/yunke-server/app/management/metric_definitions.py b/yunke-server/app/management/metric_definitions.py index f13a55f..f2934b1 100644 --- a/yunke-server/app/management/metric_definitions.py +++ b/yunke-server/app/management/metric_definitions.py @@ -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 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(不再加),= 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, + } diff --git a/yunke-server/tests/test_management_period_metrics.py b/yunke-server/tests/test_management_period_metrics.py index d66e107..d47bbae 100644 --- a/yunke-server/tests/test_management_period_metrics.py +++ b/yunke-server/tests/test_management_period_metrics.py @@ -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): diff --git a/yunke-server/tests/test_management_scoring.py b/yunke-server/tests/test_management_scoring.py index a148bf3..f0aafa1 100644 --- a/yunke-server/tests/test_management_scoring.py +++ b/yunke-server/tests/test_management_scoring.py @@ -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 # target 0.60 + assert any("active_share" in p and "floor floor_ms 14.4M + assert any("fr_p90_ms" in p and "target_mshigh 200 + assert any("call_band" in p and "low