"""管理域指标元数据与默认评分配置(批次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 : 区间内 100,超上限 80(不再加分),低于下限 None(负载 # 不足→不评分)——得分取值范围 80..100 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": { # 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_distinct"}, "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 有元数据/阈值形状/ grade_bounds 形状/指标 code 跨维度唯一。 阈值形状(随评分引擎落地的 B0 评审预埋要求): - type 须为 target/reverse/band 三者之一 - target : floor < target - reverse: target_ms < floor_ms(100 分线在 0 分线之前) - band : low < high B4 预埋(配置将自 DB 加载,写入侧不再重跑种子校验): - grade_bounds 须含 A/B/C/D 且严格降序 - 指标 code 跨维度不得重复:metrics_detail 为扁平 dict, 同名 code 会相互覆盖(后写胜出) - 源指标名跨维度不得重复:employees 行 comparisons 按源名建键 (api_assembly._code_to_source),两个 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}") gb = config.get("grade_bounds") if not isinstance(gb, dict) or not all( isinstance(gb.get(k), int) for k in ("A", "B", "C", "D") ): problems.append("grade_bounds 须含 A/B/C/D 且严格降序") elif not (gb["A"] > gb["B"] > gb["C"] > gb["D"]): problems.append( f"grade_bounds 须严格降序 A>B>C>D" f"(当前 A={gb['A']} B={gb['B']} C={gb['C']} D={gb['D']})" ) seen_codes: dict[str, str] = {} seen_sources: dict[str, str] = {} for key, dim in dims.items(): if dim.get("status") == "pending_data": continue for code, spec in dim.get("metrics", {}).items(): if code in seen_codes: problems.append(f"指标 code 重复定义于多维度: {code}") seen_codes[code] = key src = spec.get("metric", code) if src in seen_sources: problems.append( f"{key}.{code} 与 {seen_sources[src]} 指向同一源指标 {src}" f"(comparisons 键域将相互覆盖)" ) else: seen_sources[src] = f"{key}.{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 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"]