feat(management): 指标元数据+试算评分配置v1.0+种子CLI——权重校验/幂等落库
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user