Files
medical_training/apps/case/views.py
T

505 lines
22 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.
import logging
from django.db import transaction
from drf_spectacular.utils import extend_schema, OpenApiResponse, inline_serializer
from rest_framework import viewsets, filters, status, serializers as drf_serializers
from rest_framework.decorators import action
from rest_framework.parsers import MultiPartParser
from rest_framework.response import Response
from django_filters.rest_framework import DjangoFilterBackend
from config.exceptions import AppError
from apps.user.permissions import IsCaseOperationPermitted
from apps.user.throttling import PdfParseUserThrottle, ScoringRuleGenerateUserThrottle
from .models import (
CaseBase, TraditionalCase, ScriptCase,
TeachingCase, CaseStage, ScoringRule, CaseExamItem,
)
from .serializers import (
CaseBaseListSerializer, CaseBaseDetailSerializer,
CaseBaseCreateSerializer, TraditionalCaseSerializer,
ScriptCaseSerializer, TeachingCaseSerializer,
CaseStageSerializer, ScoringRuleSerializer
)
from .services import case_importer, scoring_rule_generator
from .services.department_resolver import resolve_department
from .services.exam_items import (
normalize_exam_items, assert_no_duplicate_exam_items, EXAM_ITEM_FIELDS,
)
audit = logging.getLogger('audit')
TRADITIONAL_FIELDS = {'standard_diagnosis', 'standard_treatment', 'guideline_reference'}
TEACHING_FIELDS = {'teaching_goal', 'discussion_questions', 'teacher_guide', 'scoring_focus'}
SCORING_RULE_FIELDS = {
'dimension', 'competency_dimension', 'score_weight',
'ai_auto_score', 'osce_dimension', 'scoring_standard', 'rubric_json',
}
CASE_BASE_FIELDS = {
'title', 'case_type', 'difficulty', 'difficulty_score',
'chief_complaint', 'description', 'patient_age', 'patient_gender',
'tags', 'symptom_tags', 'disease_tags', 'competency_tags',
'guideline_tags', 'knowledge_points', 'icd_codes',
'estimated_minutes', 'osce_enabled', 'rag_enabled',
'ai_prompt_template', 'multimodal_assets',
}
class CaseBaseViewSet(viewsets.ModelViewSet):
"""病例管理"""
queryset = CaseBase.objects.all()
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = [
'case_type', 'difficulty', 'department',
'publish_status', 'status', 'osce_enabled'
]
search_fields = ['title', 'chief_complaint', 'tags', 'icd_codes']
ordering_fields = ['created_at', 'difficulty_score', 'estimated_minutes']
def get_serializer_class(self):
if self.action == 'list':
return CaseBaseListSerializer
elif self.action == 'create':
return CaseBaseCreateSerializer
return CaseBaseDetailSerializer
# ── C1: parse-pdf ────────────────────────────────────────────────────
@extend_schema(
summary='C1: PDF 解析',
description='上传 1~5 份 PDF,调用 DeepSeek 提取结构化病例数据。不落库,不含评分规则。',
request={'multipart/form-data': {'type': 'object', 'properties': {
'files': {'type': 'array', 'items': {'type': 'string', 'format': 'binary'}},
'case_type': {'type': 'string', 'enum': ['traditional', 'teaching']},
}, 'required': ['files', 'case_type']}},
responses={200: OpenApiResponse(description='解析结果(含 parse_id、data')},
tags=['病例'],
)
@action(
detail=False, methods=['post'], url_path='parse-pdf',
parser_classes=[MultiPartParser],
permission_classes=[IsCaseOperationPermitted],
throttle_classes=[PdfParseUserThrottle],
)
def parse_pdf(self, request):
"""C1: PDF 解析 → 结构化数据(不落库,不含评分规则)"""
files = request.FILES.getlist('files')
case_type = request.data.get('case_type', '')
result = case_importer.parse_pdf(files, case_type, request.user)
return Response(result)
# ── C2: generate-scoring-rules ───────────────────────────────────────
@extend_schema(
summary='C2: AI 生成评分规则预览',
description='传入病例数据 JSON,调用 DeepSeek 生成评分规则。不落库,返回规则列表供前端审核。',
request=inline_serializer('GenerateScoringRulesRequest', fields={
'case_type': drf_serializers.ChoiceField(choices=['traditional', 'teaching'], help_text='病例类型'),
'title': drf_serializers.CharField(help_text='病例标题'),
'chief_complaint': drf_serializers.CharField(required=False, help_text='主诉'),
'traditional': drf_serializers.DictField(required=False, help_text='传统病例子表(case_type=traditional 时传)'),
'teaching': drf_serializers.DictField(required=False, help_text='教学病例子表(case_type=teaching 时传)'),
}),
responses={200: OpenApiResponse(description='评分规则列表 + AI 用量信息')},
tags=['病例'],
)
@action(
detail=False, methods=['post'], url_path='generate-scoring-rules',
permission_classes=[IsCaseOperationPermitted],
throttle_classes=[ScoringRuleGenerateUserThrottle],
)
def generate_scoring_rules(self, request):
"""C2: AI 生成评分规则预览(不落库)"""
result = scoring_rule_generator.generate(request.data)
audit.info(
'CASE_SCORING_RULE_PREVIEW user=%s case_type=%s rules=%d prompt_version=%s',
request.user.id, request.data.get('case_type', ''),
len(result['scoring_rules']), result['prompt_version'],
)
return Response({
'generated': len(result['scoring_rules']),
'ai_usage': result['usage'],
'prompt_version': result['prompt_version'],
'scoring_rules': result['scoring_rules'],
})
# ── C3: full-create ──────────────────────────────────────────────────
@extend_schema(
summary='C3: 创建病例(统一落库入口)',
description='病例主表 + 子表 + scoring_rules(≥1 条)同一事务入库。scoring_rules 必填。',
request=inline_serializer('FullCreateRequest', fields={
'title': drf_serializers.CharField(help_text='病例标题'),
'case_type': drf_serializers.ChoiceField(choices=['traditional', 'teaching']),
'department_name': drf_serializers.CharField(required=False, help_text='科室名称(后端解析为 department_id'),
'traditional': drf_serializers.DictField(required=False),
'teaching': drf_serializers.DictField(required=False),
'scoring_rules': drf_serializers.ListField(child=drf_serializers.DictField(), help_text='评分规则(≥1 条,必填)'),
'exam_items': drf_serializers.ListField(
child=drf_serializers.DictField(), required=False,
help_text='检查项(可选;同一病例 item_code 不可重复)',
),
'parse_id': drf_serializers.CharField(required=False, help_text='来自 parse-pdf 的 parse_id(审计用)'),
'auto_publish': drf_serializers.BooleanField(required=False, default=False),
}),
responses={201: OpenApiResponse(description='完整病例结构(同 GET full')},
tags=['病例'],
)
@action(
detail=False, methods=['post'], url_path='full-create',
permission_classes=[IsCaseOperationPermitted],
)
def full_create(self, request):
"""C3: 创建病例(主表+子表+评分规则同一事务)"""
data = request.data
case_type = data.get('case_type', '')
if case_type not in ('traditional', 'teaching'):
raise AppError('CASE_TYPE_NOT_SUPPORTED', f'case_type 不支持: {case_type}', status_code=400)
if 'stages' in data:
raise AppError('CASE_FIELD_NOT_ALLOWED', '本接口不接收 stages 字段', status_code=400)
sub_data = data.get(case_type)
other_type = 'teaching' if case_type == 'traditional' else 'traditional'
if not sub_data:
raise AppError('CASE_SUBTYPE_REQUIRED', f'{case_type} 子表数据缺失', status_code=400)
if data.get(other_type):
raise AppError('CASE_SUBTYPE_CONFLICT', '不允许同时传入两种子表数据', status_code=400)
scoring_rules_data = data.get('scoring_rules', [])
if not scoring_rules_data:
raise AppError('CASE_VALIDATION_ERROR', 'scoring_rules 必填且至少 1 条', status_code=400)
_validate_scoring_rules(scoring_rules_data)
exam_items_data = normalize_exam_items(data.get('exam_items') or [])
assert_no_duplicate_exam_items(exam_items_data)
department = resolve_department(data.get('department_name', ''))
with transaction.atomic():
case_kwargs = {k: data[k] for k in CASE_BASE_FIELDS if k in data}
case_kwargs['department'] = department
case_kwargs['created_by'] = request.user
case_kwargs['status'] = 1
case_kwargs['vector_status'] = 0
case_kwargs['publish_status'] = 1 if data.get('auto_publish') else 0
case = CaseBase.objects.create(**case_kwargs)
sub_model = TraditionalCase if case_type == 'traditional' else TeachingCase
allowed_sub = TRADITIONAL_FIELDS if case_type == 'traditional' else TEACHING_FIELDS
sub_model.objects.create(case=case, **{k: v for k, v in sub_data.items() if k in allowed_sub})
rule_objs = [
ScoringRule(case=case, **{k: v for k, v in rule.items() if k in SCORING_RULE_FIELDS})
for rule in scoring_rules_data
]
ScoringRule.objects.bulk_create(rule_objs)
if exam_items_data:
exam_objs = [
CaseExamItem(
case=case,
**{k: v for k, v in item.items() if k in EXAM_ITEM_FIELDS},
)
for item in exam_items_data
]
CaseExamItem.objects.bulk_create(exam_objs)
audit.info(
'CASE_CREATE case_id=%s from=%s by=%s scoring_rules=%d exam_items=%d',
case.id, data.get('parse_id', 'form'), request.user.id,
len(rule_objs), len(exam_items_data),
)
return Response(_build_full_response(case), status=status.HTTP_201_CREATED)
# ── C4 / C5: GET + PATCH full ───────────────────────────────────────
@extend_schema(
summary='C4: GET 完整查看 / C5: PATCH 局部编辑草稿',
description='GET: 返回主表+子表+scoring_rules。PATCH: 仅草稿可编辑,scoring_rules 传入时整体替换。',
responses={200: OpenApiResponse(description='完整病例结构')},
tags=['病例'],
)
@action(detail=True, methods=['get', 'patch'], url_path='full')
def full(self, request, pk=None):
"""C4: GET 完整查看 / C5: PATCH 局部编辑草稿"""
if request.method == 'GET':
return self._full_detail(request, pk)
return self._full_update(request, pk)
def _full_detail(self, request, pk):
case = CaseBase.objects.select_related(
'department', 'created_by'
).prefetch_related('scoring_rules', 'exam_items').filter(pk=pk).first()
if not case:
raise AppError('NOT_FOUND', '病例不存在', status_code=404)
if case.publish_status != 1:
if not request.user.is_authenticated:
raise AppError('AUTH_UNAUTHORIZED', '请先登录', status_code=401)
if not (request.user.id == case.created_by_id
or request.user.role_type in ('super_admin', 'content_admin')
or request.user.is_staff):
raise AppError('CASE_PERMISSION_DENIED', '无权查看该草稿', status_code=403)
return Response(_build_full_response(case))
def _full_update(self, request, pk):
case = CaseBase.objects.select_related('department', 'created_by').filter(pk=pk).first()
if not case:
raise AppError('NOT_FOUND', '病例不存在', status_code=404)
if not (request.user.id == case.created_by_id
or request.user.role_type in ('super_admin', 'content_admin')
or request.user.is_staff):
raise AppError('CASE_PERMISSION_DENIED', '无权编辑该病例', status_code=403)
if case.publish_status != 0:
raise AppError('CASE_NOT_EDITABLE', '仅草稿可编辑,请先下架', status_code=400)
data = request.data
with transaction.atomic():
changed = False
for field in CASE_BASE_FIELDS:
if field in data:
setattr(case, field, data[field])
changed = True
if 'department_name' in data:
case.department = resolve_department(data['department_name'])
changed = True
if changed:
case.save()
case_type = case.case_type
sub_data = data.get(case_type)
if sub_data:
sub_model = TraditionalCase if case_type == 'traditional' else TeachingCase
allowed_sub = TRADITIONAL_FIELDS if case_type == 'traditional' else TEACHING_FIELDS
sub_obj, _ = sub_model.objects.get_or_create(case=case)
for k, v in sub_data.items():
if k in allowed_sub:
setattr(sub_obj, k, v)
sub_obj.save()
scoring_rules_data = data.get('scoring_rules')
if scoring_rules_data is not None:
_validate_scoring_rules(scoring_rules_data)
case.scoring_rules.all().delete()
rule_objs = [
ScoringRule(case=case, **{k: v for k, v in rule.items() if k in SCORING_RULE_FIELDS})
for rule in scoring_rules_data
]
ScoringRule.objects.bulk_create(rule_objs)
audit.info('CASE_UPDATE case_id=%s by=%s', case.id, request.user.id)
case.refresh_from_db()
return Response(_build_full_response(case))
# ── existing actions ─────────────────────────────────────────────────
@action(detail=True, methods=['get'])
def stages(self, request, pk=None):
case = self.get_object()
serializer = CaseStageSerializer(case.stages.all(), many=True)
return Response(serializer.data)
@action(detail=True, methods=['post'])
def add_stage(self, request, pk=None):
case = self.get_object()
serializer = CaseStageSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save(case=case)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['get'])
def scoring_rules_list(self, request, pk=None):
case = self.get_object()
serializer = ScoringRuleSerializer(case.scoring_rules.all(), many=True)
return Response(serializer.data)
@action(detail=True, methods=['post'])
def add_scoring_rule(self, request, pk=None):
case = self.get_object()
serializer = ScoringRuleSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save(case=case)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['post'])
def publish(self, request, pk=None):
case = self.get_object()
case.publish_status = 1
case.save()
return Response({'message': '病例已发布'})
@action(detail=True, methods=['post'])
def unpublish(self, request, pk=None):
case = self.get_object()
case.publish_status = 2
case.save()
return Response({'message': '病例已下架'})
class TraditionalCaseViewSet(viewsets.ModelViewSet):
queryset = TraditionalCase.objects.all()
serializer_class = TraditionalCaseSerializer
filter_backends = [DjangoFilterBackend]
filterset_fields = ['case']
class ScriptCaseViewSet(viewsets.ModelViewSet):
queryset = ScriptCase.objects.all()
serializer_class = ScriptCaseSerializer
filter_backends = [DjangoFilterBackend]
filterset_fields = ['case']
class TeachingCaseViewSet(viewsets.ModelViewSet):
queryset = TeachingCase.objects.all()
serializer_class = TeachingCaseSerializer
filter_backends = [DjangoFilterBackend]
filterset_fields = ['case']
class CaseStageViewSet(viewsets.ModelViewSet):
queryset = CaseStage.objects.all()
serializer_class = CaseStageSerializer
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
filterset_fields = ['case', 'stage_type', 'stage_mode']
ordering_fields = ['sort_order', 'created_at']
class ScoringRuleViewSet(viewsets.ModelViewSet):
queryset = ScoringRule.objects.all()
serializer_class = ScoringRuleSerializer
filter_backends = [DjangoFilterBackend]
filterset_fields = ['case', 'dimension', 'ai_auto_score', 'osce_dimension']
# ── helpers ──────────────────────────────────────────────────────────────
def _validate_scoring_rules(rules):
if not isinstance(rules, list):
raise AppError('CASE_VALIDATION_ERROR', 'scoring_rules 必须为数组', status_code=400)
for i, rule in enumerate(rules):
if not isinstance(rule, dict):
raise AppError('CASE_VALIDATION_ERROR', f'scoring_rules[{i}] 必须为对象', status_code=400)
if not rule.get('dimension'):
raise AppError('CASE_VALIDATION_ERROR', f'scoring_rules[{i}].dimension 必填', status_code=400)
weight = rule.get('score_weight')
if weight is not None:
try:
weight = float(weight)
except (TypeError, ValueError):
raise AppError('CASE_VALIDATION_ERROR', f'scoring_rules[{i}].score_weight 须为数字', status_code=400)
if weight <= 0 or weight > 1:
raise AppError('CASE_VALIDATION_ERROR', f'scoring_rules[{i}].score_weight 须在 (0, 1]', status_code=400)
def _build_full_response(case):
if hasattr(case, '_prefetched_objects_cache') and 'exam_items' in getattr(
case, '_prefetched_objects_cache', {}
):
exam_qs = case.exam_items.all()
else:
exam_qs = CaseExamItem.objects.filter(case_id=case.id).order_by('display_order', 'id')
result = {
'case': {
'id': case.id,
'title': case.title,
'case_type': case.case_type,
'difficulty': case.difficulty,
'difficulty_score': case.difficulty_score,
'department': case.department_id,
'department_name': case.department.name if case.department else None,
'chief_complaint': case.chief_complaint,
'description': case.description,
'patient_age': case.patient_age,
'patient_gender': case.patient_gender,
'tags': case.tags,
'symptom_tags': case.symptom_tags,
'disease_tags': case.disease_tags,
'competency_tags': case.competency_tags,
'guideline_tags': case.guideline_tags,
'knowledge_points': case.knowledge_points,
'icd_codes': case.icd_codes,
'estimated_minutes': case.estimated_minutes,
'osce_enabled': case.osce_enabled,
'rag_enabled': case.rag_enabled,
'ai_prompt_template': case.ai_prompt_template,
'multimodal_assets': case.multimodal_assets,
'vector_status': case.vector_status,
'publish_status': case.publish_status,
'status': case.status,
'created_by': case.created_by_id,
'created_by_name': case.created_by.real_name if case.created_by else None,
'created_at': case.created_at.isoformat() if case.created_at else None,
'updated_at': case.updated_at.isoformat() if case.updated_at else None,
},
}
if case.case_type == 'traditional':
try:
tc = case.traditionalcase
result['traditional'] = {
'standard_diagnosis': tc.standard_diagnosis,
'standard_treatment': tc.standard_treatment,
'guideline_reference': tc.guideline_reference,
}
except TraditionalCase.DoesNotExist:
result['traditional'] = None
elif case.case_type == 'teaching':
try:
tc = case.teachingcase
result['teaching'] = {
'teaching_goal': tc.teaching_goal,
'discussion_questions': tc.discussion_questions,
'teacher_guide': tc.teacher_guide,
'scoring_focus': tc.scoring_focus,
}
except TeachingCase.DoesNotExist:
result['teaching'] = None
rules = case.scoring_rules.all().order_by('id')
result['scoring_rules'] = [
{
'id': r.id,
'dimension': r.dimension,
'competency_dimension': r.competency_dimension,
'score_weight': float(r.score_weight),
'ai_auto_score': r.ai_auto_score,
'osce_dimension': r.osce_dimension,
'scoring_standard': r.scoring_standard,
'rubric_json': r.rubric_json,
}
for r in rules
]
result['exam_items'] = [
{
'id': e.id,
'item_code': e.item_code,
'item_name': e.item_name,
'item_type': e.item_type,
'category': e.category,
'result_text': e.result_text,
'result_structured': e.result_structured,
'is_key': e.is_key,
'is_abnormal': e.is_abnormal,
'score_weight': float(e.score_weight),
'display_order': e.display_order,
}
for e in exam_qs
]
return result