From afc71e61efbd63b77e8b05dd8ca9ba716a6d7fe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Wed, 9 Sep 2026 16:51:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(management):=20=E6=8C=87=E6=A0=87=E5=85=83?= =?UTF-8?q?=E6=95=B0=E6=8D=AE+=E8=AF=95=E7=AE=97=E8=AF=84=E5=88=86?= =?UTF-8?q?=E9=85=8D=E7=BD=AEv1.0+=E7=A7=8D=E5=AD=90CLI=E2=80=94=E2=80=94?= =?UTF-8?q?=E6=9D=83=E9=87=8D=E6=A0=A1=E9=AA=8C/=E5=B9=82=E7=AD=89?= =?UTF-8?q?=E8=90=BD=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- yunke-server/app/management/__init__.py | 0 .../app/management/metric_definitions.py | 105 ++++++++++++++++++ yunke-server/app/sync/cli.py | 63 ++++++++++- yunke-server/tests/test_management_scoring.py | 59 ++++++++++ 4 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 yunke-server/app/management/__init__.py create mode 100644 yunke-server/app/management/metric_definitions.py create mode 100644 yunke-server/tests/test_management_scoring.py diff --git a/yunke-server/app/management/__init__.py b/yunke-server/app/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/yunke-server/app/management/metric_definitions.py b/yunke-server/app/management/metric_definitions.py new file mode 100644 index 0000000..f0bb4a6 --- /dev/null +++ b/yunke-server/app/management/metric_definitions.py @@ -0,0 +1,105 @@ +"""管理域指标元数据与默认评分配置(批次B,v2 §6/§10)。 + +METRIC_DEFINITIONS 是 /api/management/metric-definitions 的单一数据源: +名称/单位/方向/口径说明(含与现有看板的三方口径差异标注)。 +DEFAULT_SCORE_CONFIG 为 v1.0 试算起点——所有阈值标「试算」,调整走 +新配置版本(employee_score_config),不静默改数。 +""" +from __future__ import annotations + +# direction: +1 越大越好 / -1 越小越好;caliber 注明与看板差异 +METRIC_DEFINITIONS: dict[str, dict] = { + "msg_sent": {"name": "发出消息(1:1)", "unit": "条", "direction": 1, + "caliber": "仅私聊;现有看板为私聊+群聊混合"}, + "msg_recv": {"name": "收到消息(1:1)", "unit": "条", "direction": 1, + "caliber": "仅私聊"}, + "cust_threads": {"name": "客户消息串", "unit": "串", "direction": 1, + "caliber": "跨日精确,按串末条归属日;看板为日切口径(多日串重复计)"}, + "threads_answered": {"name": "被回串数", "unit": "串", "direction": 1, + "caliber": "≡first_reply_fact行数(同源)"}, + "fr_avg_ms": {"name": "平均首响", "unit": "ms", "direction": -1, + "caliber": "fr_sum_ms/threads_answered"}, + "fr_p50_ms": {"name": "首响中位数", "unit": "ms", "direction": -1, + "caliber": "周期内 fact 精确分位(窗口函数)"}, + "fr_p90_ms": {"name": "首响P90", "unit": "ms", "direction": -1, + "caliber": "周期内 fact 精确分位;日切口径会丢跨夜长尾"}, + "reply_2h_rate": {"name": "2h回复率", "unit": "0-1", "direction": 1, + "formula": "count(fr_ms<=2h)/cust_threads", + "caliber": "分子分母同源(fact/宽表同一算法)"}, + "reply_24h_rate": {"name": "24h回复率", "unit": "0-1", "direction": 1, + "formula": "count(fr_ms<=24h)/cust_threads"}, + "active_friends": {"name": "活跃客户(周期)", "unit": "人", "direction": 1, + "caliber": "期内distinct客户(msg_daily_stats直查),非宽表日值求和"}, + "active_rooms": {"name": "活跃群(日均)", "unit": "个", "direction": 1}, + "new_customer_count": {"name": "新客(期末)", "unit": "人", "direction": 1, + "caliber": "期末单日持有状态,非期内求和"}, + "cooling_customer_count": {"name": "降温客户(期末)", "unit": "人", "direction": -1, + "caliber": "距今天数口径(7-29天);频次基线口径二期"}, + "dormant_customer_count": {"name": "沉睡客户(期末)", "unit": "人", "direction": -1, + "caliber": "≥30天且历史≥5条"}, + "reactivated_customer_count": {"name": "回流客户(期内)", "unit": "人", "direction": 1, + "caliber": "当日回流且前次间隔>30天,期内求和"}, + "call_count": {"name": "通话数", "unit": "通", "direction": 1, + "caliber": "语音通话(微信+WA),无消息渠道维度"}, + "answered_rate": {"name": "接通率", "unit": "0-1", "direction": 1, + "formula": "answered_call/call_count", "caliber": "接通=duration>0"}, + "call_followup_rate": {"name": "通话后跟进率", "unit": "0-1", "direction": 1, + "formula": "call_followup_count/answered_call", + "caliber": "接通后24h内任一账号有1:1消息(含客户发来)"}, + "active_share": {"name": "活跃客户占比", "unit": "0-1", "direction": 1, + "formula": "期末active_friends/(期末active+cooling+dormant)", + "caliber": "试算口径:期末单日存量;宽表无生命周期active存量列,active以active_friends列充当"}, +} + +# 阈值全部为试算起点(v2 §0 决议:全默认+试算标记);type 三种: +# target : floor..target 线性 0..100(方向已含在 target>floor) +# reverse : ≤target_ms 100 分,≥floor_ms 0 分,中间线性降 +# band : 区间内 80..100,低于下限不评分(None),超上限不再加分 +DEFAULT_SCORE_CONFIG: dict = { + "version": "v1.0", + "name": "一期试算(3维可用)", + "normalization": "available_only_rescale", # 可用维度 100/Σweight 再 ×100 + "trial": True, + "sample_guards": {"min_threads": 30, "soft_threads": 10, "min_days": 5}, + "grade_bounds": {"A": 90, "B": 80, "C": 70, "D": 60}, # 低于 D 线为 E + "dimensions": { + "customer": {"weight": 30, "name": "客户经营", "metrics": { + "active_share": {"type": "target", "floor": 0.20, "target": 0.60}, + }}, + "service": {"weight": 25, "name": "服务质量", "metrics": { + "reply_2h_rate": {"type": "target", "floor": 0.40, "target": 0.85}, + "reply_24h_rate": {"type": "target", "floor": 0.75, "target": 0.97}, + "fr_p90_ms": {"type": "reverse", "target_ms": 1_800_000, + "floor_ms": 14_400_000}, # 30min..4h + }}, + "action": {"weight": 20, "name": "行动执行", + "status": "pending_data"}, # 批次E 接 followups/任务 + "workload": {"weight": 15, "name": "有效负载", "metrics": { + "active_friends_band": {"type": "band", "metric": "active_friends", + "low": 5, "high": 80, + "basis": "period_daily_avg"}, + "call_band": {"type": "band", "metric": "call_count", + "low": 1, "high": 200, "basis": "period_total", + "note": "通话低不扣分仅提示;band 得分下限 80"}, + }}, + "risk": {"weight": 10, "name": "合规风险", + "status": "pending_data"}, # 批次E/三期接风险事件 + }, +} + + +def validate_config(config: dict) -> list[str]: + """配置校验:权重总和/结构/引用的 metric_code 有元数据。返回问题清单。""" + problems: list[str] = [] + dims = config.get("dimensions", {}) + total = sum(d.get("weight", 0) for d in dims.values()) + if total != 100: + problems.append(f"维度权重总和须=100,当前 {total}") + for key, dim in dims.items(): + if dim.get("status") == "pending_data": + continue + for code, spec in dim.get("metrics", {}).items(): + src = spec.get("metric", code) + if src not in METRIC_DEFINITIONS: + problems.append(f"{key}.{code} 引用未定义指标 {src}") + return problems diff --git a/yunke-server/app/sync/cli.py b/yunke-server/app/sync/cli.py index 7aa0df4..d7a23e9 100644 --- a/yunke-server/app/sync/cli.py +++ b/yunke-server/app/sync/cli.py @@ -51,6 +51,8 @@ import signal import sys from typing import Any +import sqlalchemy as sa + from app.config import get_settings from app.db import models # noqa: F401 (register tables on Base.metadata) from app.db.base import Base @@ -164,6 +166,17 @@ def _build_parser() -> argparse.ArgumentParser: ), ) + # ``score-config`` ---------------------------------------------------- + p_score = sub.add_parser( + "score-config", + help="Seed the default score config (idempotent).", + description=( + "Insert DEFAULT_SCORE_CONFIG (v1.0, trial) into " + "employee_score_config as active. Skips if the version " + "already exists. Batch B." + ), + ) + # ``scheduler`` ----------------------------------------------------------- p_sched = sub.add_parser( "scheduler", @@ -423,8 +436,6 @@ async def _cmd_employee_metrics(args: argparse.Namespace) -> int: settings = get_settings() engine, session_maker = await _init_db(settings.db_url) try: - import sqlalchemy as sa - from app.db.models import MsgDailyStats, PhoneCall from app.storage.employee_metrics import ( recompute_call_days, @@ -460,6 +471,52 @@ async def _cmd_employee_metrics(args: argparse.Namespace) -> int: await engine.dispose() +async def _seed_score_config(session) -> str | None: + """种子默认评分配置;已存在同版本返回 None(幂等)。纯会话函数可测。""" + from app.db.models import EmployeeScoreConfig + from app.management.metric_definitions import ( + DEFAULT_SCORE_CONFIG, + validate_config, + ) + + problems = validate_config(DEFAULT_SCORE_CONFIG) + if problems: + raise ValueError(f"score config invalid: {problems}") + exists = ( + await session.execute( + sa.select(EmployeeScoreConfig.id).where( + EmployeeScoreConfig.version == DEFAULT_SCORE_CONFIG["version"] + ) + ) + ).scalar_one_or_none() + if exists is not None: + return None + session.add( + EmployeeScoreConfig( + version=DEFAULT_SCORE_CONFIG["version"], + name=DEFAULT_SCORE_CONFIG["name"], + status="active", + config_json=DEFAULT_SCORE_CONFIG, + created_by="seed-cli", + ) + ) + await session.flush() + return DEFAULT_SCORE_CONFIG["version"] + + +async def _cmd_score_config(args: argparse.Namespace) -> int: + settings = get_settings() + engine, session_maker = await _init_db(settings.db_url) + try: + async with session_maker() as session: + version = await _seed_score_config(session) + await session.commit() + print(f"[score-config] {'seeded ' + version if version else 'already present, skipped'}") + return 0 + finally: + await engine.dispose() + + def main(argv: list[str] | None = None) -> int: """CLI entry point. Returns a process exit code.""" # Configure logging so the scheduler's JSON line + the orchestrator's @@ -488,6 +545,8 @@ def main(argv: list[str] | None = None) -> int: return asyncio.run(_cmd_first_reply_facts(args)) if args.command == "employee-metrics": return asyncio.run(_cmd_employee_metrics(args)) + if args.command == "score-config": + return asyncio.run(_cmd_score_config(args)) except KeyboardInterrupt: # So `python -m app.sync.cli scheduler` exits cleanly on Ctrl-C. return 0 diff --git a/yunke-server/tests/test_management_scoring.py b/yunke-server/tests/test_management_scoring.py new file mode 100644 index 0000000..dfd4f8f --- /dev/null +++ b/yunke-server/tests/test_management_scoring.py @@ -0,0 +1,59 @@ +"""管理域评分配置/引擎判别测试(批次B)。""" +import json + +import sqlalchemy as sa + +from app.db.models import EmployeeScoreConfig +from app.management.metric_definitions import ( + DEFAULT_SCORE_CONFIG, + METRIC_DEFINITIONS, + validate_config, +) + + +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", {}): + md = METRIC_DEFINITIONS[code if code in METRIC_DEFINITIONS else "active_share"] + # 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.sync.cli 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