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