feat(data-ana): 完整实现 data-ana 数据分析服务

包含 CDC consumer、analytics/mastery/warning service、grpc server、repository、ClickHouse DDL 等
This commit is contained in:
SpecialX
2026-07-10 19:09:27 +08:00
parent 033057a302
commit ca3780aa24
29 changed files with 5401 additions and 383 deletions

View File

@@ -0,0 +1,256 @@
"""掌握度计算服务(加权滑动平均 + 遗忘曲线).
算法对齐 02-architecture-design.md §9
- WEIGHTED_MOVING_AVGw_i = 0.6^i归一化权重从最近到最远递减
- FORGETTING_CURVE基于遗忘曲线的 max 叠加P5+ 启用),
half_life = 30 days越久未练习掌握度越衰减.
输入:学生指定知识点的历史成绩序列(按时间倒序)
输出mastery_level (0.0-1.0)
副作用:
- 写 mastery_snapshot 表upsert_mastery_snapshot
- 发布 mastery.updated 事件Outbox 豁免)
降级策略:
- ClickHouse 不可达:跳过计算(返回 None
- Kafka 不可达:计算继续,事件发布静默失败
"""
from datetime import UTC, datetime
from typing import Any
import structlog
from .config import settings
from .repository import clickhouse_repository, kafka_producer
logger = structlog.get_logger(__name__)
def _weighted_moving_avg(scores: list[dict[str, Any]]) -> float | None:
"""加权滑动平均算法.
输入:按时间倒序的成绩列表(最近在前),元素含 score 字段
输出:归一化的 mastery_level (0.0-1.0),样本不足返回 None.
权重w_i = decay_base ^ ii 从 0 开始,最近 attempt 权重最大)
"""
if not scores:
return None
# 截取最近 N 次window_size
window = scores[: settings.mastery_window_size]
if len(window) < settings.mastery_min_samples:
return None
decay = settings.mastery_decay_base
total_weight = 0.0
weighted_sum = 0.0
for i, record in enumerate(window):
# 成绩归一化到 0.0-1.0(假设原始分 0-100
raw_score = record.get("score", 0.0)
normalized = min(max(raw_score / 100.0, 0.0), 1.0)
weight = decay**i
weighted_sum += normalized * weight
total_weight += weight
if total_weight <= 0:
return None
mastery = weighted_sum / total_weight
return round(max(0.0, min(1.0, mastery)), 4)
def _forgetting_curve_decay(
mastery: float,
last_attempt_at: datetime | None,
now: datetime | None = None,
) -> float:
"""遗忘曲线衰减P5+ 启用,对齐 02 §9.
距离上次练习越久mastery 越衰减:
decayed = mastery * exp(-ln(2) * days_since / half_life)
half_life = 30 dayssettings.mastery_forgetting_half_life_days.
"""
if last_attempt_at is None:
return mastery
now = now or datetime.now(UTC)
if last_attempt_at.tzinfo is None:
last_attempt_at = last_attempt_at.replace(tzinfo=UTC)
days_since = (now - last_attempt_at).total_seconds() / 86400
if days_since <= 0:
return mastery
half_life = settings.mastery_forgetting_half_life_days
if half_life <= 0:
return mastery
import math
decay_factor = math.exp(-math.log(2) * days_since / half_life)
decayed = mastery * decay_factor
return round(max(0.0, min(1.0, decayed)), 4)
def classify_mastery_label(level: float) -> str:
"""掌握度三档分类."""
if level >= 0.8:
return "mastered"
if level >= 0.4:
return "progressing"
return "weak"
async def calculate_mastery(
student_id: str,
knowledge_point_id: str,
subject_id: str = "",
) -> dict[str, Any] | None:
"""计算学生指定知识点的掌握度.
流程:
1. 查询学生该知识点的历史成绩(按时间倒序)
2. 加权滑动平均 → mastery_level
3. 遗忘曲线衰减 → 最终 mastery_level
4. 写 mastery_snapshot 表
5. 发布 mastery.updated 事件Outbox 豁免)
返回:
{
"student_id": ...,
"knowledge_point_id": ...,
"subject_id": ...,
"mastery_level": 0.0-1.0,
"previous_level": 0.0-1.0 or None,
"mastery_label": "mastered" / "progressing" / "weak",
"degraded": bool,
}
ClickHouse 不可达时返回降级骨架degraded=true.
"""
# 1. 查询历史成绩
scores = await clickhouse_repository.query_student_scores_by_kp(
student_id=student_id,
knowledge_point_id=knowledge_point_id,
limit=settings.mastery_window_size * 2, # 多取一倍容错
)
if scores is None:
# ClickHouse 不可达降级
logger.warning(
"mastery_calc_clickhouse_unavailable_degraded",
student_id=student_id,
knowledge_point_id=knowledge_point_id,
)
return {
"student_id": student_id,
"knowledge_point_id": knowledge_point_id,
"subject_id": subject_id,
"mastery_level": 0.0,
"previous_level": None,
"mastery_label": "weak",
"degraded": True,
"degraded_reason": "clickhouse_unavailable",
}
# 2. 加权滑动平均
mastery = _weighted_moving_avg(scores)
if mastery is None:
# 样本不足,返回默认值
return {
"student_id": student_id,
"knowledge_point_id": knowledge_point_id,
"subject_id": subject_id,
"mastery_level": 0.0,
"previous_level": None,
"mastery_label": "weak",
"degraded": False,
"degraded_reason": "insufficient_samples",
}
# 3. 遗忘曲线衰减(基于最近一次成绩的时间)
last_attempt_at = None
if scores:
last_attempt_at = scores[0].get("timestamp")
mastery_final = _forgetting_curve_decay(mastery, last_attempt_at)
# 4. 查询上一次 mastery用于事件对比
previous_snapshot = await clickhouse_repository.query_mastery_snapshot(
student_id=student_id,
subject_id=subject_id,
)
previous_level: float | None = None
if previous_snapshot is not None:
for kp in previous_snapshot.get("knowledgePoints", []):
if kp.get("knowledge_point_id") == knowledge_point_id:
previous_level = kp.get("mastery_level")
break
# 5. 写 mastery_snapshot 表
calculated_at = datetime.now(UTC)
write_ok = await clickhouse_repository.upsert_mastery_snapshot(
student_id=student_id,
knowledge_point_id=knowledge_point_id,
subject_id=subject_id,
mastery_level=mastery_final,
calculated_at=calculated_at,
calculation_method="weighted_moving_avg",
)
if not write_ok:
logger.warning(
"mastery_snapshot_write_failed_degraded",
student_id=student_id,
knowledge_point_id=knowledge_point_id,
)
# 6. 发布 mastery.updated 事件Outbox 豁免)
published = await kafka_producer.publish_mastery_updated(
student_id=student_id,
knowledge_point_id=knowledge_point_id,
mastery_level=mastery_final,
previous_level=previous_level if previous_level is not None else 0.0,
)
result = {
"student_id": student_id,
"knowledge_point_id": knowledge_point_id,
"subject_id": subject_id,
"mastery_level": mastery_final,
"previous_level": previous_level,
"mastery_label": classify_mastery_label(mastery_final),
"degraded": not write_ok,
"degraded_reason": "snapshot_write_failed" if not write_ok else "",
"event_published": published,
}
logger.info(
"mastery_calculated",
student_id=student_id,
knowledge_point_id=knowledge_point_id,
mastery_level=mastery_final,
previous_level=previous_level,
label=result["mastery_label"],
published=published,
)
return result
async def batch_calculate_mastery(
student_id: str,
knowledge_point_ids: list[str],
subject_id: str = "",
) -> list[dict[str, Any]]:
"""批量计算学生多个知识点的掌握度."""
results: list[dict[str, Any]] = []
for kp_id in knowledge_point_ids:
result = await calculate_mastery(
student_id=student_id,
knowledge_point_id=kp_id,
subject_id=subject_id,
)
if result is not None:
results.append(result)
return results