init medical training project
This commit is contained in:
@@ -0,0 +1,459 @@
|
||||
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
|
||||
)
|
||||
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
|
||||
|
||||
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 条,必填)'),
|
||||
'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)
|
||||
|
||||
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)
|
||||
|
||||
audit.info(
|
||||
'CASE_CREATE case_id=%s from=%s by=%s scoring_rules=%d',
|
||||
case.id, data.get('parse_id', 'form'), request.user.id, len(rule_objs),
|
||||
)
|
||||
|
||||
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').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):
|
||||
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
|
||||
]
|
||||
|
||||
return result
|
||||
Reference in New Issue
Block a user