Files
yunke/yunke-server/app/management/metric_definitions.py
T

144 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""管理域指标元数据与默认评分配置(批次B,v2 §6/§10)。
METRIC_DEFINITIONS 是 /api/management/metric-definitions 的单一数据源:
名称/单位/方向/口径说明(含与现有看板的三方口径差异标注)。
DEFAULT_SCORE_CONFIG 为 v1.0 试算起点——所有阈值标「试算」,调整走
新配置版本(employee_score_config),不静默改数。
"""
from __future__ import annotations
import copy
import sqlalchemy as sa
# 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
async def _seed_score_config(session) -> str | None:
"""种子默认评分配置;已存在同版本返回 None(幂等)。纯会话函数可测。
同 ensure_starter_words 先例:域包提供纯会话种子函数,CLI 只做接线。
config_json 用 deepcopy 冻结快照——JSON 列须整列重赋值,深拷贝防止
长驻 API 进程未来的原地修改波及模块常量。
"""
from app.db.models import EmployeeScoreConfig
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=copy.deepcopy(DEFAULT_SCORE_CONFIG),
created_by="seed-cli",
)
)
await session.flush()
return DEFAULT_SCORE_CONFIG["version"]