"""管理域确定性异常规则判别测试(批次B Task B3,v1 §5.4 异常卡片)。""" import json import pytest from app.management.anomalies import detect_anomalies from app.management.employee_metrics import PeriodMetrics from app.management.metric_definitions import DEFAULT_SCORE_CONFIG def _pm(**overrides): """全零/None 默认的 PeriodMetrics 构造器(22 字段,覆盖式改)。 与 test_management_scoring._pm 同式(测试间不共享 helper,复制保持独立)。 """ 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 _detect(cur, base, *, n_days=7, user_id="u1", config=None): return detect_anomalies( user_id, cur, base, n_days=n_days, config=config if config is not None else DEFAULT_SCORE_CONFIG) # ---- 1. reply_p90_worsened ---- def test_p90_worsened_warning_with_pct(): """×1.6 → warning;标题百分比=(160-100)/100=60%;affected=未回串数。""" cur = _pm(cust_threads=20, threads_answered=12, fr_p90_ms=9_600_000) # 160min base = _pm(cust_threads=40, threads_answered=40, fr_p90_ms=6_000_000) # 100min cards = _detect(cur, base) assert [c["rule_code"] for c in cards] == ["reply_p90_worsened"] c = cards[0] assert c["severity"] == "warning" assert c["title"] == "回复 P90 较个人基线上升 60%" assert c["current_value"] == 9_600_000 assert c["baseline_value"] == 6_000_000 assert c["unit"] == "ms" assert c["sample_size"] == 20 assert c["affected_count"] == 8 # 20 串 - 12 已回 = 期内未回串数 assert c["drilldown"] == {"type": "metric", "query": {"metric_code": "first_reply_p90", "user_id": "u1"}} def test_p90_worsened_critical_at_3x(): cur = _pm(cust_threads=20, threads_answered=20, fr_p90_ms=18_000_000) base = _pm(cust_threads=40, threads_answered=40, fr_p90_ms=6_000_000) cards = _detect(cur, base) assert len(cards) == 1 assert cards[0]["severity"] == "critical" assert "上升 200%" in cards[0]["title"] def test_p90_boundaries_strict_compare(): """×1.5 恰好 → 严格大于不成立不触发;×2.5 恰好 → warning 非 critical; threads 恰好 = soft(10) → ≥soft 触发。""" base = _pm(cust_threads=40, fr_p90_ms=6_000_000) assert _detect(_pm(cust_threads=20, fr_p90_ms=9_000_000), base) == [] cards = _detect(_pm(cust_threads=20, fr_p90_ms=15_000_000), base) assert [c["severity"] for c in cards] == ["warning"] cards = _detect(_pm(cust_threads=10, fr_p90_ms=9_600_000), base) assert [c["rule_code"] for c in cards] == ["reply_p90_worsened"] def test_p90_below_soft_threads_not_fired(): cur = _pm(cust_threads=5, fr_p90_ms=9_600_000) # 80 同时触发,按规则表序输出; 两侧回复率同为 0(0<0 不成立)→ rate 规则不触发。""" cur = _pm(cust_threads=30, threads_answered=10, fr_p90_ms=30_000_000, active_friends=90, msg_sent=10, msg_recv=10) base = _pm(cust_threads=60, threads_answered=60, fr_p90_ms=6_000_000, msg_sent=600, msg_recv=240, days_with_data=20) cards = _detect(cur, base) assert [c["rule_code"] for c in cards] == [ "reply_p90_worsened", "activity_drop", "workload_overload"] assert cards[0]["severity"] == "critical" def test_card_structure_all_fields(): """单卡 12 字段齐全且类型正确(v1 §5.4)。""" cur = _pm(cust_threads=20, reply_2h_count=20, threads_answered=12, fr_p90_ms=9_600_000, active_friends=30, msg_sent=80, msg_recv=30) # 日均 110/7≈15.7 ≥ 15 → activity 不触发 base = _pm(cust_threads=40, reply_2h_count=40, threads_answered=40, fr_p90_ms=6_000_000, active_friends=30, msg_sent=600, msg_recv=240, days_with_data=20) cards = _detect(cur, base, user_id="u9") assert len(cards) == 1 c = cards[0] assert set(c) == { "rule_code", "severity", "employee_user_id", "title", "current_value", "baseline_value", "unit", "sample_size", "affected_count", "reason", "drilldown", "suggested_action", } assert isinstance(c["rule_code"], str) assert c["severity"] in ("warning", "critical") assert c["employee_user_id"] == "u9" assert isinstance(c["title"], str) and c["title"] assert isinstance(c["current_value"], (int, float)) assert c["baseline_value"] is None or isinstance( c["baseline_value"], (int, float)) assert isinstance(c["unit"], str) assert isinstance(c["sample_size"], int) assert isinstance(c["affected_count"], int) assert isinstance(c["reason"], str) and c["reason"] assert set(c["drilldown"]) == {"type", "query"} assert isinstance(c["drilldown"]["query"], dict) assert isinstance(c["suggested_action"], str) and c["suggested_action"]