Files
2026-06-15 17:39:20 +08:00

333 lines
14 KiB
Python
Raw Permalink 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.
"""病例落库 / 完整结构组装的共享逻辑。
`/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()
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:
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', [])
if require_scoring_rules and not scoring_rules_data:
raise AppError('CASE_VALIDATION_ERROR', 'scoring_rules 必填且至少 1 条', status_code=400)
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)
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)
# 与 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_itemsscoring_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(
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