Files
Edu/services/data-ana/src/data_ana/warning_service.py
SpecialX ca3780aa24 feat(data-ana): 完整实现 data-ana 数据分析服务
包含 CDC consumer、analytics/mastery/warning service、grpc server、repository、ClickHouse DDL 等
2026-07-10 19:09:27 +08:00

384 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""预警服务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:
"""预警去重位图 key25h 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,
)