包含 CDC consumer、analytics/mastery/warning service、grpc server、repository、ClickHouse DDL 等
384 lines
12 KiB
Python
384 lines
12 KiB
Python
"""预警服务(5 类预警阈值评估 + 去重位图 + 事件发布).
|
||
|
||
对齐 02-architecture-design.md §11 预警体系:
|
||
- LOW_MASTERY:掌握度 < 0.4
|
||
- CRITICAL_LOW:掌握度 < 0.2
|
||
- SCORE_DROP:成绩环比下降 ≥ 20%
|
||
- ABSENT_FREQUENT:缺勤 ≥ 3 次/周
|
||
- TREND_DECLINE:连续 3 次成绩下降
|
||
|
||
去重策略:
|
||
Redis bitmap key = data_ana:warning:{target_id}:{type}:{date}
|
||
TTL = 25h(跨天容忍,避免边界重复触发)
|
||
|
||
事件发布:
|
||
warning.triggered 复用 edu.insight.mastery.updated topic(总裁裁决 §2.11)
|
||
Outbox 豁免(总裁裁决 §2.10)
|
||
"""
|
||
|
||
from datetime import UTC, datetime
|
||
from typing import Any
|
||
|
||
import structlog
|
||
|
||
from .config import settings
|
||
from .repository import clickhouse_repository, kafka_producer, redis_client
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
|
||
|
||
# 预警类型常量
|
||
class WarningType:
|
||
"""预警类型."""
|
||
|
||
LOW_MASTERY = "LOW_MASTERY"
|
||
CRITICAL_LOW = "CRITICAL_LOW"
|
||
SCORE_DROP = "SCORE_DROP"
|
||
ABSENT_FREQUENT = "ABSENT_FREQUENT"
|
||
TREND_DECLINE = "TREND_DECLINE"
|
||
|
||
|
||
class Severity:
|
||
"""预警严重程度."""
|
||
|
||
INFO = "INFO"
|
||
WARN = "WARN"
|
||
CRITICAL = "CRITICAL"
|
||
|
||
|
||
# 预警类型 → 严重程度映射
|
||
_SEVERITY_MAP: dict[str, str] = {
|
||
WarningType.LOW_MASTERY: Severity.WARN,
|
||
WarningType.CRITICAL_LOW: Severity.CRITICAL,
|
||
WarningType.SCORE_DROP: Severity.WARN,
|
||
WarningType.ABSENT_FREQUENT: Severity.WARN,
|
||
WarningType.TREND_DECLINE: Severity.WARN,
|
||
}
|
||
|
||
|
||
def _warning_dedup_key(target_id: str, warning_type: str) -> str:
|
||
"""预警去重位图 key(25h TTL,避免边界重复触发)."""
|
||
today = datetime.now(UTC).strftime("%Y%m%d")
|
||
return f"data_ana:warning:{target_id}:{warning_type}:{today}"
|
||
|
||
|
||
async def _check_and_dedup(target_id: str, warning_type: str) -> bool:
|
||
"""检查去重(首次返回 True,已触发过返回 False)."""
|
||
key = _warning_dedup_key(target_id, warning_type)
|
||
is_first = await redis_client.setnx_dedup(key, ttl_s=25 * 3600)
|
||
if not is_first:
|
||
logger.info(
|
||
"warning_dedup_skipped",
|
||
target_id=target_id,
|
||
warning_type=warning_type,
|
||
)
|
||
return is_first
|
||
|
||
|
||
async def _trigger_warning(
|
||
target_id: str,
|
||
warning_type: str,
|
||
threshold: float,
|
||
current_value: float,
|
||
student_id: str = "",
|
||
knowledge_point_id: str = "",
|
||
metadata: dict[str, str] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""触发预警(去重 + 事件发布)."""
|
||
severity = _SEVERITY_MAP.get(warning_type, Severity.WARN)
|
||
|
||
# 1. 去重检查
|
||
should_trigger = await _check_and_dedup(target_id, warning_type)
|
||
if not should_trigger:
|
||
return {
|
||
"target_id": target_id,
|
||
"warning_type": warning_type,
|
||
"triggered": False,
|
||
"deduplicated": True,
|
||
"threshold": threshold,
|
||
"current_value": current_value,
|
||
}
|
||
|
||
# 2. 发布 warning.triggered 事件(Outbox 豁免,复用 mastery topic)
|
||
published = await kafka_producer.publish_warning_triggered(
|
||
target_id=target_id,
|
||
warning_type=warning_type,
|
||
threshold=threshold,
|
||
current_value=current_value,
|
||
severity=severity,
|
||
student_id=student_id,
|
||
knowledge_point_id=knowledge_point_id,
|
||
metadata=metadata,
|
||
)
|
||
|
||
logger.warning(
|
||
"warning_triggered",
|
||
target_id=target_id,
|
||
warning_type=warning_type,
|
||
severity=severity,
|
||
threshold=threshold,
|
||
current_value=current_value,
|
||
published=published,
|
||
)
|
||
return {
|
||
"target_id": target_id,
|
||
"warning_type": warning_type,
|
||
"severity": severity,
|
||
"triggered": True,
|
||
"deduplicated": False,
|
||
"threshold": threshold,
|
||
"current_value": current_value,
|
||
"event_published": published,
|
||
}
|
||
|
||
|
||
async def evaluate_mastery_warning(
|
||
student_id: str,
|
||
knowledge_point_id: str,
|
||
mastery_level: float,
|
||
) -> list[dict[str, Any]]:
|
||
"""评估掌握度预警(LOW_MASTERY / CRITICAL_LOW).
|
||
|
||
- mastery < 0.2 → CRITICAL_LOW(最严重,先评估)
|
||
- 0.2 ≤ mastery < 0.4 → LOW_MASTERY
|
||
"""
|
||
triggered: list[dict[str, Any]] = []
|
||
|
||
if mastery_level < settings.warning_critical_mastery_threshold:
|
||
result = await _trigger_warning(
|
||
target_id=student_id,
|
||
warning_type=WarningType.CRITICAL_LOW,
|
||
threshold=settings.warning_critical_mastery_threshold,
|
||
current_value=mastery_level,
|
||
student_id=student_id,
|
||
knowledge_point_id=knowledge_point_id,
|
||
)
|
||
if result["triggered"]:
|
||
triggered.append(result)
|
||
return triggered # CRITICAL_LOW 已触发,不再触发 LOW_MASTERY
|
||
|
||
if mastery_level < settings.warning_low_mastery_threshold:
|
||
result = await _trigger_warning(
|
||
target_id=student_id,
|
||
warning_type=WarningType.LOW_MASTERY,
|
||
threshold=settings.warning_low_mastery_threshold,
|
||
current_value=mastery_level,
|
||
student_id=student_id,
|
||
knowledge_point_id=knowledge_point_id,
|
||
)
|
||
if result["triggered"]:
|
||
triggered.append(result)
|
||
|
||
return triggered
|
||
|
||
|
||
async def evaluate_score_drop(
|
||
student_id: str,
|
||
current_score: float,
|
||
previous_score: float,
|
||
exam_id: str = "",
|
||
) -> dict[str, Any] | None:
|
||
"""评估成绩下降预警(SCORE_DROP).
|
||
|
||
成绩环比下降 ≥ warning_score_drop_percent(默认 20%)触发.
|
||
"""
|
||
if previous_score <= 0:
|
||
return None
|
||
|
||
drop_ratio = (previous_score - current_score) / previous_score
|
||
if drop_ratio < settings.warning_score_drop_percent:
|
||
return None
|
||
|
||
return await _trigger_warning(
|
||
target_id=student_id,
|
||
warning_type=WarningType.SCORE_DROP,
|
||
threshold=settings.warning_score_drop_percent,
|
||
current_value=round(drop_ratio, 4),
|
||
student_id=student_id,
|
||
metadata={
|
||
"exam_id": exam_id,
|
||
"previous_score": str(previous_score),
|
||
"current_score": str(current_score),
|
||
},
|
||
)
|
||
|
||
|
||
async def evaluate_absent_frequent(
|
||
student_id: str,
|
||
absent_count_week: int,
|
||
) -> dict[str, Any] | None:
|
||
"""评估缺勤频繁预警(ABSENT_FREQUENT).
|
||
|
||
一周内缺勤 ≥ warning_absent_per_week(默认 3 次)触发.
|
||
"""
|
||
if absent_count_week < settings.warning_absent_per_week:
|
||
return None
|
||
|
||
return await _trigger_warning(
|
||
target_id=student_id,
|
||
warning_type=WarningType.ABSENT_FREQUENT,
|
||
threshold=float(settings.warning_absent_per_week),
|
||
current_value=float(absent_count_week),
|
||
student_id=student_id,
|
||
)
|
||
|
||
|
||
async def evaluate_trend_decline(
|
||
student_id: str,
|
||
recent_scores: list[float],
|
||
) -> dict[str, Any] | None:
|
||
"""评估趋势下降预警(TREND_DECLINE).
|
||
|
||
连续 3 次成绩下降触发(recent_scores 按时间正序,最早在前).
|
||
"""
|
||
if len(recent_scores) < 3:
|
||
return None
|
||
|
||
# 检查最近 3 次是否连续下降
|
||
last_three = recent_scores[-3:]
|
||
is_declining = all(last_three[i] > last_three[i + 1] for i in range(len(last_three) - 1))
|
||
if not is_declining:
|
||
return None
|
||
|
||
drop = last_three[-2] - last_three[-1]
|
||
return await _trigger_warning(
|
||
target_id=student_id,
|
||
warning_type=WarningType.TREND_DECLINE,
|
||
threshold=0.0, # 下降幅度阈值(0 表示只要下降就触发)
|
||
current_value=round(drop, 4),
|
||
student_id=student_id,
|
||
)
|
||
|
||
|
||
async def evaluate_all_warnings(
|
||
student_id: str,
|
||
knowledge_point_id: str = "",
|
||
mastery_level: float | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
"""综合评估学生所有预警类型(GetWarnings RPC 调用).
|
||
|
||
参数:
|
||
- student_id:学生 ID
|
||
- knowledge_point_id:知识点 ID(可选,指定则只评估该知识点)
|
||
- mastery_level:预计算的掌握度(可选,未提供则查询)
|
||
|
||
返回:触发的预警列表
|
||
"""
|
||
triggered: list[dict[str, Any]] = []
|
||
|
||
# 1. 掌握度预警
|
||
if mastery_level is None:
|
||
snapshot = await clickhouse_repository.query_mastery_snapshot(
|
||
student_id=student_id,
|
||
)
|
||
if snapshot is not None:
|
||
knowledge_points = snapshot.get("knowledgePoints", [])
|
||
if knowledge_point_id:
|
||
# 仅评估指定知识点
|
||
knowledge_points = [
|
||
kp
|
||
for kp in knowledge_points
|
||
if kp.get("knowledge_point_id") == knowledge_point_id
|
||
]
|
||
for kp in knowledge_points:
|
||
level = kp.get("mastery_level", 0.0)
|
||
warnings = await evaluate_mastery_warning(
|
||
student_id=student_id,
|
||
knowledge_point_id=kp.get("knowledge_point_id", ""),
|
||
mastery_level=level,
|
||
)
|
||
triggered.extend(warnings)
|
||
else:
|
||
warnings = await evaluate_mastery_warning(
|
||
student_id=student_id,
|
||
knowledge_point_id=knowledge_point_id,
|
||
mastery_level=mastery_level,
|
||
)
|
||
triggered.extend(warnings)
|
||
|
||
# 2. 缺勤预警
|
||
attendance = await clickhouse_repository.query_attendance(student_id=student_id)
|
||
if attendance is not None:
|
||
# 简化:统计总缺勤数(P6 改为按周统计)
|
||
absent_count = attendance.get("absentCount", 0)
|
||
if absent_count >= settings.warning_absent_per_week:
|
||
warning = await evaluate_absent_frequent(
|
||
student_id=student_id,
|
||
absent_count_week=absent_count,
|
||
)
|
||
if warning is not None and warning.get("triggered"):
|
||
triggered.append(warning)
|
||
|
||
# 3. 成绩下降预警(需要查询最近两次成绩)
|
||
trend = await clickhouse_repository.query_learning_trend(student_id=student_id)
|
||
if trend is not None:
|
||
points = trend.get("points", [])
|
||
if len(points) >= 2:
|
||
current = points[-1].get("score", 0.0)
|
||
previous = points[-2].get("score", 0.0)
|
||
drop_warning = await evaluate_score_drop(
|
||
student_id=student_id,
|
||
current_score=current,
|
||
previous_score=previous,
|
||
)
|
||
if drop_warning is not None and drop_warning.get("triggered"):
|
||
triggered.append(drop_warning)
|
||
|
||
# 4. 趋势下降预警
|
||
scores = [p.get("score", 0.0) for p in points]
|
||
trend_warning = await evaluate_trend_decline(
|
||
student_id=student_id,
|
||
recent_scores=scores,
|
||
)
|
||
if trend_warning is not None and trend_warning.get("triggered"):
|
||
triggered.append(trend_warning)
|
||
|
||
return triggered
|
||
|
||
|
||
async def get_warnings(
|
||
student_id: str,
|
||
warning_type: str = "",
|
||
) -> list[dict[str, Any]]:
|
||
"""查询预警列表(GetWarnings RPC 实现).
|
||
|
||
参数:
|
||
- student_id:学生 ID
|
||
- warning_type:预警类型过滤(空表示全部)
|
||
|
||
返回:预警列表(每条含 target_id / warning_type / severity / threshold /
|
||
current_value / triggered_at)
|
||
"""
|
||
# 当前实现:实时评估返回触发结果
|
||
# P6 演进:将预警历史持久化到 ClickHouse 供查询
|
||
triggered = await evaluate_all_warnings(student_id=student_id)
|
||
if warning_type:
|
||
triggered = [w for w in triggered if w.get("warning_type") == warning_type]
|
||
return triggered
|
||
|
||
|
||
async def trigger_warning_manual(
|
||
target_id: str,
|
||
warning_type: str,
|
||
threshold: float,
|
||
current_value: float,
|
||
student_id: str = "",
|
||
knowledge_point_id: str = "",
|
||
severity: str = "",
|
||
metadata: dict[str, str] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""手动触发预警(TriggerWarning RPC 实现,管理员/API 用)."""
|
||
# 覆盖默认 severity
|
||
if severity:
|
||
_SEVERITY_MAP[warning_type] = severity
|
||
|
||
return await _trigger_warning(
|
||
target_id=target_id,
|
||
warning_type=warning_type,
|
||
threshold=threshold,
|
||
current_value=current_value,
|
||
student_id=student_id,
|
||
knowledge_point_id=knowledge_point_id,
|
||
metadata=metadata,
|
||
)
|