2026-06-12 17:19:23 +08:00
|
|
|
|
"""病例落库 / 完整结构组装的共享逻辑。
|
|
|
|
|
|
|
|
|
|
|
|
`/api/case/cases/full-create`(业务域)与 `/api/cms/cases/`(CMS 内容管理)共用同一套
|
|
|
|
|
|
创建与序列化逻辑,避免两处实现漂移。
|
|
|
|
|
|
"""
|
|
|
|
|
|
from django.db import transaction
|
|
|
|
|
|
|
|
|
|
|
|
from config.exceptions import AppError
|
|
|
|
|
|
from ..models import (
|
|
|
|
|
|
CaseBase, TraditionalCase, TeachingCase, ScoringRule, CaseExamItem,
|
|
|
|
|
|
)
|
|
|
|
|
|
from .department_resolver import resolve_department
|
|
|
|
|
|
from .exam_items import (
|
|
|
|
|
|
normalize_exam_items, assert_no_duplicate_exam_items, EXAM_ITEM_FIELDS,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
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',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_UNSET = object()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-15 17:39:20 +08:00
|
|
|
|
def create_case_full(data, user, institution=_UNSET, require_scoring_rules=True):
|
|
|
|
|
|
"""主表 + 子表 + scoring_rules + exam_items 同一事务入库。
|
2026-06-12 17:19:23 +08:00
|
|
|
|
|
|
|
|
|
|
institution:病例所属机构;默认取创建者所属机构(`user.institution`)。
|
2026-06-15 17:39:20 +08:00
|
|
|
|
require_scoring_rules:是否强制评分规则(≥1 条)。CMS 新增病例时为 False
|
|
|
|
|
|
(评分规则延迟到「发布」时由 AI 生成并落库);业务域 full-create 仍为 True。
|
2026-06-12 17:19:23 +08:00
|
|
|
|
返回 (case, scoring_rule_count, exam_item_count)。校验失败抛 AppError。
|
|
|
|
|
|
"""
|
|
|
|
|
|
if institution is _UNSET:
|
|
|
|
|
|
institution = getattr(user, 'institution', None)
|
|
|
|
|
|
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', [])
|
2026-06-15 17:39:20 +08:00
|
|
|
|
if require_scoring_rules and not scoring_rules_data:
|
2026-06-12 17:19:23 +08:00
|
|
|
|
raise AppError('CASE_VALIDATION_ERROR', 'scoring_rules 必填且至少 1 条', status_code=400)
|
2026-06-15 17:39:20 +08:00
|
|
|
|
if scoring_rules_data:
|
|
|
|
|
|
validate_scoring_rules(scoring_rules_data)
|
2026-06-12 17:19:23 +08:00
|
|
|
|
|
|
|
|
|
|
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['institution'] = institution
|
|
|
|
|
|
case_kwargs['created_by'] = 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)
|
|
|
|
|
|
|
|
|
|
|
|
return case, len(rule_objs), len(exam_items_data)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-15 17:39:20 +08:00
|
|
|
|
# 与 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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-12 17:19:23 +08:00
|
|
|
|
def build_full_response(case):
|
|
|
|
|
|
"""组装病例完整结构(主表 + 子表 + scoring_rules + exam_items)。"""
|
|
|
|
|
|
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,
|
|
|
|
|
|
'institution': case.institution_id,
|
|
|
|
|
|
'institution_name': case.institution.name if case.institution else None,
|
|
|
|
|
|
'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
|