Files
medical_training/apps/user/stats.py
T
2026-06-12 17:19:23 +08:00

223 lines
10 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.
"""移动端个人中心 - 训练统计 / 智能分析(读 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
# 临床胜任力标准 5 维(与 training_score_detail / ai_feedback_structured 的评分维度一致)
STANDARD_DIMS = ['信息获取', '分析推理', '处置决策', '沟通人文', '临床整合']
# 评分维度(fastapi 实际打分维度,名称随病例评分规则而变)→ 标准 5 维 的归并映射。
# 维度名不完全统一(实测有 检查利用/临床推理/人文沟通 等同义写法),统一归并到 5 个标准维度。
# 未在映射内的维度会被忽略。
DIMENSION_MAP = {
# 信息获取(病史/问诊)
'信息获取': '信息获取', '病史采集': '信息获取', '问诊': '信息获取',
# 分析推理(诊断/鉴别/推理)
'分析推理': '分析推理', '临床推理': '分析推理', '诊断推理': '分析推理',
'鉴别诊断': '分析推理', '诊断能力': '分析推理',
# 处置决策(检查决策 + 治疗决策合并)
'处置决策': '处置决策', '检查利用': '处置决策', '检查理解': '处置决策',
'检查决策': '处置决策', '辅助检查': '处置决策', '检验决策': '处置决策',
'治疗决策': '处置决策', '治疗': '处置决策',
# 沟通人文
'沟通人文': '沟通人文', '人文沟通': '沟通人文', '沟通技巧': '沟通人文',
'医患沟通': '沟通人文', '沟通': '沟通人文',
# 临床整合(含知识掌握/运用)
'临床整合': '临床整合', '知识掌握': '临床整合', '知识运用': '临床整合',
}
# 诊断相关维度(诊断准确率口径:这些维度的平均得分率;与归并到「分析推理」的来源一致)
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,
})