223 lines
10 KiB
Python
223 lines
10 KiB
Python
"""移动端个人中心 - 训练统计 / 智能分析(读 fastapi 只读训练表)。
|
||
|
||
- 4.3 临床核心能力指标 GET /api/user/competency-metrics/
|
||
- 4.4 训练记录(统计信息) GET /api/user/training-records/
|
||
- 4.5 智能分析(关联评价表)GET /api/user/analysis/
|
||
|
||
数据源:training_record(managed=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,
|
||
})
|