"""预警服务(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, )