diff --git a/apps/case/cms.py b/apps/case/cms.py index f1e9920..ef3c946 100644 --- a/apps/case/cms.py +++ b/apps/case/cms.py @@ -12,6 +12,7 @@ """ import logging +from django.db import transaction from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiResponse, inline_serializer from rest_framework import viewsets, filters, status, serializers as drf_serializers @@ -26,10 +27,13 @@ from apps.cms.permissions import ( ) from apps.user.models import Institution, Department from apps.user.throttling import PdfParseUserThrottle -from .models import CaseBase +from .models import CaseBase, ScoringRule from .serializers import CaseBaseListSerializer -from .services import case_importer -from .services.case_writer import create_case_full, build_full_response +from .services import case_importer, scoring_rule_generator +from .services.case_writer import ( + create_case_full, update_case_full, build_full_response, build_scoring_rule_input, + validate_scoring_rules, SCORING_RULE_FIELDS, +) audit = logging.getLogger('audit') @@ -84,14 +88,50 @@ class CmsCaseViewSet(viewsets.ModelViewSet): # ── CMS-CASE-3 表单新增(草稿)/ CMS-CASE-1 列表 ────────────────────── def create(self, request, *args, **kwargs): + """新增病例(草稿)。评分规则非必填——评分规则在「发布」时由 AI 生成并落库。""" self._require_editor() - case, n_rules, n_items = create_case_full(request.data, request.user, institution=self._resolve_institution()) + case, n_rules, n_items = create_case_full( + request.data, request.user, + institution=self._resolve_institution(), + require_scoring_rules=False, + ) audit.info('CMS_CASE_CREATE case_id=%s by=%s inst=%s scoring_rules=%d exam_items=%d', case.id, request.user.id, case.institution_id, n_rules, n_items) return Response(build_full_response(case), status=status.HTTP_201_CREATED) # list 用默认实现(默认管理器已排除软删;医院管理员审核用 ?publish_status=1 取待审核) + # ── CMS-CASE-8 编辑草稿(再次保存)────────────────────────────────── + @extend_schema( + summary='CMS-CASE-8 编辑草稿(再次保存)', + description='对已保存的草稿病例,编辑录入后的表单(表单/PDF/AI 三种录入数据均在表单中)' + '再次保存,整体更新草稿内容。仅草稿(publish_status=0)可编辑。', + request=inline_serializer('CmsCaseSaveDraftRequest', fields={ + 'title': drf_serializers.CharField(required=False), + 'case_type': drf_serializers.ChoiceField(choices=['traditional', 'teaching'], required=False, + help_text='只读校验,不支持修改;传入须与原病例一致'), + 'department_name': drf_serializers.CharField(required=False, help_text='科室名称(解析为 department_id)'), + 'traditional': drf_serializers.DictField(required=False), + 'teaching': drf_serializers.DictField(required=False), + 'exam_items': drf_serializers.ListField( + child=drf_serializers.DictField(), required=False, + help_text='检查项(传入即整表替换;同一病例 item_code 不可重复)'), + 'scoring_rules': drf_serializers.ListField( + child=drf_serializers.DictField(), required=False, + help_text='评分规则(一般发布时自动生成;如传入则整表替换)'), + }), + responses={200: OpenApiResponse(description='完整病例结构(同 CMS-CASE-7)')}, + tags=['CMS-病例'], + ) + @action(detail=True, methods=['post'], url_path='save-draft') + def save_draft(self, request, pk=None): + """编辑录入后的表单再次保存:更新草稿内容(主表+子表+检查项,评分规则可选)。""" + self._require_editor() + case = self.get_object() + case = update_case_full(case, request.data, request.user) + audit.info('CMS_CASE_SAVE_DRAFT case_id=%s by=%s', case.id, request.user.id) + return Response(build_full_response(case)) + # ── CMS-CASE-7 / CMS-AUDIT-2 病例查看(完整结构)───────────────────── @extend_schema(summary='CMS-CASE-7 病例查看(完整结构)', tags=['CMS-病例']) @action(detail=True, methods=['get']) @@ -218,12 +258,37 @@ class CmsCaseViewSet(viewsets.ModelViewSet): @extend_schema(summary='CMS-AUDIT-3 发布(正常 → 已发布)', tags=['CMS-病例审核']) @action(detail=True, methods=['post']) def publish(self, request, pk=None): - """医院管理员审核发布:正常(1) → 已发布(2)。发布后对本院移动端医学生可见。超管不做审核。""" + """审核发布:正常(1) → 已发布(2)。 + + 发布前先由 AI 生成该病例评分规则并落库;评分规则生成或入库失败则不发布 + (评分规则落库与发布状态变更在同一事务内,保证「无评分规则不发布」)。 + 发布后对本院移动端医学生可见。 + """ self._require_publisher() case = self.get_object() if case.publish_status != 1: raise AppError('CASE_NOT_PUBLISHABLE', '仅「正常」状态病例可发布', status_code=400) - case.publish_status = 2 # 已发布 - case.save(update_fields=['publish_status', 'updated_at']) - audit.info('CMS_CASE_PUBLISH case_id=%s by=%s', case.id, request.user.id) - return Response({'message': '已发布', 'id': case.id, 'publish_status': case.publish_status}) + + # 1) AI 生成评分规则(慢 / 可能失败,放在事务外,失败则不触碰数据库) + gen = scoring_rule_generator.generate(build_scoring_rule_input(case)) + rules = gen['scoring_rules'] + validate_scoring_rules(rules) + + # 2) 评分规则落库 + 发布状态变更,同一事务原子完成 + with transaction.atomic(): + case.scoring_rules.all().delete() # 幂等:清掉历史规则后重新写入 + ScoringRule.objects.bulk_create([ + ScoringRule(case=case, **{k: v for k, v in r.items() if k in SCORING_RULE_FIELDS}) + for r in rules + ]) + case.publish_status = 2 # 已发布 + case.save(update_fields=['publish_status', 'updated_at']) + + audit.info('CMS_CASE_PUBLISH case_id=%s by=%s scoring_rules=%d prompt_version=%s', + case.id, request.user.id, len(rules), gen.get('prompt_version')) + return Response({ + 'message': '已发布', + 'id': case.id, + 'publish_status': case.publish_status, + 'scoring_rules_generated': len(rules), + }) diff --git a/apps/case/services/case_writer.py b/apps/case/services/case_writer.py index 30777f5..d3b6761 100644 --- a/apps/case/services/case_writer.py +++ b/apps/case/services/case_writer.py @@ -51,10 +51,12 @@ def validate_scoring_rules(rules): _UNSET = object() -def create_case_full(data, user, institution=_UNSET): - """主表 + 子表 + scoring_rules(≥1) + exam_items 同一事务入库。 +def create_case_full(data, user, institution=_UNSET, require_scoring_rules=True): + """主表 + 子表 + scoring_rules + exam_items 同一事务入库。 institution:病例所属机构;默认取创建者所属机构(`user.institution`)。 + require_scoring_rules:是否强制评分规则(≥1 条)。CMS 新增病例时为 False + (评分规则延迟到「发布」时由 AI 生成并落库);业务域 full-create 仍为 True。 返回 (case, scoring_rule_count, exam_item_count)。校验失败抛 AppError。 """ if institution is _UNSET: @@ -74,9 +76,10 @@ def create_case_full(data, user, institution=_UNSET): raise AppError('CASE_SUBTYPE_CONFLICT', '不允许同时传入两种子表数据', status_code=400) scoring_rules_data = data.get('scoring_rules', []) - if not scoring_rules_data: + if require_scoring_rules and not scoring_rules_data: raise AppError('CASE_VALIDATION_ERROR', 'scoring_rules 必填且至少 1 条', status_code=400) - validate_scoring_rules(scoring_rules_data) + if scoring_rules_data: + 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) @@ -113,6 +116,119 @@ def create_case_full(data, user, institution=_UNSET): return case, len(rule_objs), len(exam_items_data) +# 与 scoring_rule_generator.CONTEXT_FIELDS 对应的主表字段(发布时据此构建生成入参) +_SCORING_CONTEXT_FIELDS = ( + 'title', 'chief_complaint', 'description', 'patient_age', 'patient_gender', + 'icd_codes', 'symptom_tags', 'disease_tags', 'competency_tags', + 'guideline_tags', 'knowledge_points', +) + + +def build_scoring_rule_input(case): + """由已落库的病例对象构建评分规则生成入参(供发布时调用 AI 生成评分规则)。""" + data = {'case_type': case.case_type} + for field in _SCORING_CONTEXT_FIELDS: + data[field] = getattr(case, field, None) + + if case.case_type == 'traditional': + try: + tc = case.traditionalcase + data['traditional'] = { + 'standard_diagnosis': tc.standard_diagnosis, + 'standard_treatment': tc.standard_treatment, + 'guideline_reference': tc.guideline_reference, + } + except TraditionalCase.DoesNotExist: + data['traditional'] = {} + else: + try: + tc = case.teachingcase + data['teaching'] = { + 'teaching_goal': tc.teaching_goal, + 'discussion_questions': tc.discussion_questions, + 'teacher_guide': tc.teacher_guide, + 'scoring_focus': tc.scoring_focus, + } + except TeachingCase.DoesNotExist: + data['teaching'] = {} + return data + + +def update_case_full(case, data, user): + """更新草稿病例内容(主表 + 子表 + exam_items,scoring_rules 可选)。 + + 供 CMS「编辑录入后的表单再次保存」复用:三种录入方式(表单 / PDF / AI)的病例数据 + 都汇总到前端表单,再次保存时整体更新草稿。仅草稿(publish_status=0)可更新。 + - 主表字段:传入即覆盖(CASE_BASE_FIELDS 白名单)。 + - 子表:按 case_type 局部更新;不允许传入另一种子表。 + - exam_items:传入即整表替换(去重校验);不传则保持不变。 + - scoring_rules:通常发布时才生成;如传入则整表替换(校验)。 + 返回更新后的 case。校验失败抛 AppError。 + """ + if case.publish_status != 0: + raise AppError('CASE_NOT_EDITABLE', '仅草稿可编辑,请先下架', status_code=400) + + if 'stages' in data: + raise AppError('CASE_FIELD_NOT_ALLOWED', '本接口不接收 stages 字段', status_code=400) + + case_type = case.case_type + new_type = data.get('case_type') + if new_type and new_type != case_type: + raise AppError('CASE_TYPE_NOT_SUPPORTED', '不支持修改 case_type', status_code=400) + + other_type = 'teaching' if case_type == 'traditional' else 'traditional' + if data.get(other_type): + raise AppError('CASE_SUBTYPE_CONFLICT', '不允许传入另一种子表数据', status_code=400) + + scoring_rules_data = data.get('scoring_rules') + if scoring_rules_data is not None: + validate_scoring_rules(scoring_rules_data) + + exam_items_data = None + if 'exam_items' in data: + exam_items_data = normalize_exam_items(data.get('exam_items') or []) + assert_no_duplicate_exam_items(exam_items_data) + + with transaction.atomic(): + changed = [] + for field in CASE_BASE_FIELDS: + if field in data: + setattr(case, field, data[field]) + changed.append(field) + if 'department_name' in data: + case.department = resolve_department(data['department_name']) + changed.append('department') + if changed: + case.save() + + 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() + + if scoring_rules_data is not None: + case.scoring_rules.all().delete() + ScoringRule.objects.bulk_create([ + ScoringRule(case=case, **{k: v for k, v in rule.items() if k in SCORING_RULE_FIELDS}) + for rule in scoring_rules_data + ]) + + if exam_items_data is not None: + case.exam_items.all().delete() + CaseExamItem.objects.bulk_create([ + CaseExamItem(case=case, **{k: v for k, v in item.items() if k in EXAM_ITEM_FIELDS}) + for item in exam_items_data + ]) + + case.refresh_from_db() + return case + + def build_full_response(case): """组装病例完整结构(主表 + 子表 + scoring_rules + exam_items)。""" if hasattr(case, '_prefetched_objects_cache') and 'exam_items' in getattr(