Files
medical_training/apps/user/stats.py
T

223 lines
10 KiB
Python
Raw Normal View History

"""移动端个人中心 - 训练统计 / 智能分析(读 fastapi 只读训练表)。
- 4.3 临床核心能力指标 GET /api/user/competency-metrics/
- 4.4 训练记录(统计信息) GET /api/user/training-records/
- 4.5 智能分析(关联评价表)GET /api/user/analysis/
数据源:training_recordmanaged=False 只读,user_id = 当前用户.id)。
各能力维度得分 + 满分来自 training_record.ai_feedback_structured.dimension_scores
(与 training_score_detail 同源,但 json 里带 max_score,便于算「得分率」)。
"""
import json
from datetime import timedelta
from django.db.models import Sum, Avg, Case, When, F, DecimalField
from django.utils import timezone
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.pagination import PageNumberPagination
from rest_framework.response import Response
from drf_spectacular.utils import extend_schema
from apps.training.models import TrainingRecord
2026-06-12 17:19:23 +08:00
# 临床胜任力标准 5 维(与 training_score_detail / ai_feedback_structured 的评分维度一致)
STANDARD_DIMS = ['信息获取', '分析推理', '处置决策', '沟通人文', '临床整合']
2026-06-12 17:19:23 +08:00
# 评分维度(fastapi 实际打分维度,名称随病例评分规则而变)→ 标准 5 维 的归并映射。
# 维度名不完全统一(实测有 检查利用/临床推理/人文沟通 等同义写法),统一归并到 5 个标准维度。
# 未在映射内的维度会被忽略。
DIMENSION_MAP = {
2026-06-12 17:19:23 +08:00
# 信息获取(病史/问诊)
'信息获取': '信息获取', '病史采集': '信息获取', '问诊': '信息获取',
# 分析推理(诊断/鉴别/推理)
'分析推理': '分析推理', '临床推理': '分析推理', '诊断推理': '分析推理',
'鉴别诊断': '分析推理', '诊断能力': '分析推理',
# 处置决策(检查决策 + 治疗决策合并)
'处置决策': '处置决策', '检查利用': '处置决策', '检查理解': '处置决策',
'检查决策': '处置决策', '辅助检查': '处置决策', '检验决策': '处置决策',
'治疗决策': '处置决策', '治疗': '处置决策',
# 沟通人文
'沟通人文': '沟通人文', '人文沟通': '沟通人文', '沟通技巧': '沟通人文',
'医患沟通': '沟通人文', '沟通': '沟通人文',
# 临床整合(含知识掌握/运用)
'临床整合': '临床整合', '知识掌握': '临床整合', '知识运用': '临床整合',
}
2026-06-12 17:19:23 +08:00
# 诊断相关维度(诊断准确率口径:这些维度的平均得分率;与归并到「分析推理」的来源一致)
DIAGNOSIS_DIMS = {'诊断推理', '诊断能力', '鉴别诊断', '分析推理', '临床推理'}
def _completed_qs(user):
return TrainingRecord.objects.filter(user_id=user.id, status='completed')
# total_score 归一到百分制:五分制(0-5) ×20,其余按原值(percentage)。
# 用于 avg_score / current_score / 趋势 / 较上周,避免百分制与五分制混平均失真。
# (诊断准确率/雷达用各维度 score/max_score 比值自归一,不走这里。)
def _norm_total(score, score_type):
if score is None:
return None
return float(score) * 20 if score_type == 'five_point' else float(score)
# DB 侧等价表达式,供 aggregate(Avg(...)) 使用
_NORM_TOTAL_EXPR = Case(
When(score_type='five_point', then=F('total_score') * 20),
default=F('total_score'),
output_field=DecimalField(max_digits=7, decimal_places=2),
)
def _week_start(offset_weeks=0):
now = timezone.localtime()
monday = now - timedelta(days=now.weekday(), weeks=-offset_weeks)
return monday.replace(hour=0, minute=0, second=0, microsecond=0)
def _dimension_scores(rec):
"""从 ai_feedback_structured.dimension_scores 取 [(dimension, score, max_score)]。"""
fb = rec.ai_feedback_structured
if isinstance(fb, str):
try:
fb = json.loads(fb)
except Exception:
fb = {}
if not isinstance(fb, dict):
return []
out = []
for d in (fb.get('dimension_scores') or []):
if isinstance(d, dict) and d.get('dimension'):
out.append((d['dimension'], d.get('score'), d.get('max_score')))
return out
def _diagnosis_accuracy(records):
"""诊断相关维度的平均得分率(%)。无数据返回 None。"""
rates = []
for rec in records:
for dim, score, mx in _dimension_scores(rec):
if dim in DIAGNOSIS_DIMS and score is not None and mx and float(mx) > 0:
rates.append(float(score) / float(mx))
return round(sum(rates) / len(rates) * 100) if rates else None
def _hours(seconds):
return round((seconds or 0) / 3600, 1)
# ── 4.3 临床核心能力指标 ─────────────────────────────────────────────────────
@extend_schema(summary='临床核心能力指标', tags=['个人中心'])
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def competency_metrics(request):
qs = _completed_qs(request.user)
agg = qs.aggregate(total=Sum('duration_seconds'), avg=Avg(_NORM_TOTAL_EXPR))
avg = agg['avg']
return Response({
'completed_cases': qs.count(),
'completed_cases_week': qs.filter(end_time__gte=_week_start()).count(),
'total_hours': _hours(agg['total']),
'avg_score': round(float(avg), 1) if avg is not None else 0,
'diagnosis_accuracy': _diagnosis_accuracy(qs.only('ai_feedback_structured')),
})
# ── 4.4 训练记录(统计信息)─────────────────────────────────────────────────
@extend_schema(summary='训练记录(统计信息)', tags=['个人中心'])
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def training_records(request):
base = _completed_qs(request.user)
search = (request.query_params.get('search') or '').strip()
if search:
from django.db.models import Q
base = base.filter(Q(case__title__icontains=search) | Q(case__department__name__icontains=search))
agg = base.aggregate(total=Sum('duration_seconds'))
summary = {
'total_cases': base.count(),
'total_hours': _hours(agg['total']),
'avg_accuracy': _diagnosis_accuracy(base.only('ai_feedback_structured')),
}
page_qs = base.select_related('case', 'case__department').order_by('-end_time', '-id')
paginator = PageNumberPagination()
page = paginator.paginate_queryset(page_qs, request)
results = []
for r in page:
case = r.case if r.case_id else None # 孤立外键(本地无对应病例)时为 None
dept = case.department if case else None # 病例无科室或科室缺失时为 None
results.append({
'record_id': r.id,
'case_id': r.case_id,
'case_title': case.title if case else '',
'department': dept.name if dept else '',
'trained_at': r.end_time or r.created_at,
'score': float(r.total_score) if r.total_score is not None else None,
'score_type': r.score_type,
'evaluation_level': r.evaluation_level,
'training_mode': r.training_mode,
'case_type': r.case_type,
})
resp = paginator.get_paginated_response(results)
resp.data['summary'] = summary
return resp
# ── 4.5 智能分析(关联评价表)─────────────────────────────────────────────────
@extend_schema(summary='智能分析(关联评价表)', tags=['个人中心'])
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def analysis(request):
recs = list(_completed_qs(request.user).order_by('end_time', 'id'))
# 趋势 / 当前评分(按 total_score,按 score_type 归一到百分制)
scored = [(r.end_time or r.created_at, _norm_total(r.total_score, r.score_type))
for r in recs if r.total_score is not None]
recent = scored[-7:]
recent_trend = [{'label': (dt.strftime('%m-%d') if dt else ''), 'score': round(s)} for dt, s in recent]
current_score = round(sum(s for _, s in recent) / len(recent), 1) if recent else 0
# 较上周提升%
ws, ws_prev = _week_start(), _week_start(offset_weeks=-1)
this_week = [s for dt, s in scored if dt and dt >= ws]
last_week = [s for dt, s in scored if dt and ws_prev <= dt < ws]
score_delta_pct = None
if this_week and last_week:
a, b = sum(this_week) / len(this_week), sum(last_week) / len(last_week)
if b:
score_delta_pct = round((a - b) / b * 100)
# 雷达:维度得分率归一到 0-100,按 A 组归并求均
from collections import defaultdict
buckets = defaultdict(list)
for r in recs:
for dim, score, mx in _dimension_scores(r):
std = DIMENSION_MAP.get(dim)
if std and score is not None and mx and float(mx) > 0:
buckets[std].append(float(score) / float(mx) * 100)
radar = [{'dimension': d, 'score': (round(sum(buckets[d]) / len(buckets[d])) if buckets[d] else 0)}
for d in STANDARD_DIMS]
# 仅在「有数据」的维度里排序取强/弱,避免无数据维度(0 分)被误判为薄弱项
ordered = sorted((x for x in radar if buckets[x['dimension']]), key=lambda x: x['score'])
weak_dimensions = [ordered[0]['dimension']] if ordered else []
comment = ''
if len(ordered) >= 2 and ordered[0]['score'] != ordered[-1]['score']:
# 强弱维度不同才做对比,避免「最强==最弱」时出现自相矛盾文案
comment = f"您的{ordered[-1]['dimension']}表现突出,但{ordered[0]['dimension']}仍有提升空间,建议增加相关模拟训练。"
elif ordered:
comment = f"各维度表现较为均衡,可针对{weak_dimensions[0]}做进一步强化。"
return Response({
'current_score': current_score,
'score_delta_pct': score_delta_pct,
'recent_trend': recent_trend,
'radar': radar,
'weak_dimensions': weak_dimensions,
'comment': comment,
})