Files
fastapi/backend/app/models/source_case.py
T
2026-06-01 09:25:26 +08:00

229 lines
12 KiB
Python

from __future__ import annotations
from decimal import Decimal
from sqlalchemy import BigInteger, Boolean, ForeignKey, Integer, JSON, Numeric, SmallInteger, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.mixins import TimestampMixin
BIGINT_PK = BigInteger().with_variant(Integer, "sqlite")
class CaseBase(TimestampMixin, Base):
"""源库病例主表:保持 case_base 字段稳定,业务兼容字段通过属性派生。"""
__tablename__ = "case_base"
id: Mapped[int] = mapped_column(BIGINT_PK, primary_key=True, autoincrement=True, comment="病例ID")
title: Mapped[str] = mapped_column(String(255), nullable=False, comment="病例标题")
case_type: Mapped[str] = mapped_column(String(30), nullable=False, index=True, comment="病例类型")
difficulty: Mapped[str] = mapped_column(String(20), nullable=False, default="medium", index=True, comment="难度")
difficulty_score: Mapped[int | None] = mapped_column(Integer, comment="难度分")
chief_complaint: Mapped[str] = mapped_column(Text, nullable=False, comment="主诉")
description: Mapped[str] = mapped_column(Text, nullable=False, comment="病例描述")
patient_age: Mapped[int | None] = mapped_column(Integer, comment="患者年龄")
patient_gender: Mapped[str] = mapped_column(String(10), nullable=False, comment="患者性别")
tags: Mapped[str] = mapped_column(String(500), nullable=False, default="", comment="标签")
symptom_tags: Mapped[list] = mapped_column(JSON, nullable=False, default=list, comment="症状标签")
disease_tags: Mapped[list] = mapped_column(JSON, nullable=False, default=list, comment="疾病标签")
competency_tags: Mapped[list] = mapped_column(JSON, nullable=False, default=list, comment="能力标签")
guideline_tags: Mapped[list] = mapped_column(JSON, nullable=False, default=list, comment="指南标签")
knowledge_points: Mapped[list] = mapped_column(JSON, nullable=False, default=list, comment="知识点")
icd_codes: Mapped[str] = mapped_column(String(500), nullable=False, default="", comment="ICD编码")
estimated_minutes: Mapped[int | None] = mapped_column(Integer, comment="预计训练分钟数")
osce_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, comment="是否启用OSCE")
rag_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, comment="是否启用RAG")
ai_prompt_template: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="病例AI提示词片段")
multimodal_assets: Mapped[list] = mapped_column(JSON, nullable=False, default=list, comment="多模态资源")
vector_status: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0, comment="向量状态")
publish_status: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=1, index=True, comment="发布状态")
status: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=1, index=True, comment="启用状态")
created_by_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True, comment="创建人ID")
department_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True, comment="科室ID")
traditional_case = relationship("TraditionalCase", back_populates="case", uselist=False, cascade="all, delete-orphan")
teaching_case = relationship("TeachingCase", back_populates="case", uselist=False, cascade="all, delete-orphan")
scoring_rules = relationship("ScoringRule", back_populates="case", cascade="all, delete-orphan")
exam_items = relationship("CaseExamItem", back_populates="case", cascade="all, delete-orphan")
__table_args__ = {"comment": "病例主表"}
@property
def case_code(self) -> str:
"""病例编码:源表没有独立编码时用稳定派生值兼容前端。"""
return f"CASE_{self.id}"
@property
def patient_name(self) -> None:
"""患者姓名:源表不保存患者姓名。"""
return None
@property
def patient_occupation(self) -> None:
"""患者职业:源表不保存职业。"""
return None
@property
def supported_training_type(self) -> str:
"""训练类别:沿用源库 case_type,异常值按诊疗练习处理。"""
return self.case_type if self.case_type in {"case_analysis", "diagnosis_treatment", "consultation"} else "diagnosis_treatment"
@property
def supported_mode(self) -> str:
"""交互模式:存在 teaching_case 时为互动模式,否则为自由问诊。"""
return "interactive" if self.teaching_case else "free_chat"
@property
def has_teaching_video(self) -> bool:
"""教学视频标记:从多模态资源判断是否存在视频。"""
return any(isinstance(item, dict) and item.get("type") == "video" for item in (self.multimodal_assets or []))
@property
def has_knowledge_points(self) -> bool:
"""知识点标记:由源表 knowledge_points 判断。"""
return bool(self.knowledge_points)
@property
def has_quiz(self) -> bool:
"""题库标记:教学互动扩展表存在讨论题时视为有题库入口。"""
return bool(self.teaching_case and self.teaching_case.discussion_questions)
@property
def patient_opening(self) -> str:
"""AI 病人开场:根据主诉派生,不新增源表字段。"""
return f"家长:医生,孩子{self.chief_complaint},想请您看看。"
@property
def ai_patient_profile(self) -> dict:
"""AI 病人人设:由病例描述和提示词动态形成基础人设。"""
return {
"speaker": "患儿家长",
"answer_style": "简短、真实、只回答被问到的信息",
"prompt_template": self.ai_prompt_template,
}
@property
def hidden_patient_info(self) -> dict:
"""隐藏病情信息:使用病例描述和传统病例指南作为上下文。"""
return {
"case_description": self.description,
"guideline_reference": self.traditional_case.guideline_reference if self.traditional_case else "",
"teaching_goal": self.teaching_case.teaching_goal if self.teaching_case else "",
}
@property
def key_symptoms(self) -> list:
"""关键症状:使用源库 symptom_tags。"""
return self.symptom_tags or []
@property
def key_exams(self) -> list:
"""关键检查:使用源库 knowledge_points 作为演示阶段考核提示。"""
return self.knowledge_points or []
@property
def key_points(self) -> list:
"""考核要点:使用源库 competency_tags。"""
return self.competency_tags or []
@property
def diagnosis_primary(self) -> str:
"""标准诊断:练习模式来自 traditional_case.standard_diagnosis。"""
return self.traditional_case.standard_diagnosis if self.traditional_case else ""
@property
def diagnosis_basis(self) -> str:
"""诊断依据:优先使用 traditional_case.guideline_reference。"""
if self.traditional_case:
return self.traditional_case.guideline_reference
return self.description
@property
def treatment_plan(self) -> dict:
"""标准治疗:练习模式来自 traditional_case.standard_treatment。"""
return {"standard_treatment": self.traditional_case.standard_treatment} if self.traditional_case else {}
class TraditionalCase(TimestampMixin, Base):
"""源库传统病例表:练习模式读取 case_base + traditional_case。"""
__tablename__ = "traditional_case"
id: Mapped[int] = mapped_column(BIGINT_PK, primary_key=True, autoincrement=True, comment="传统病例ID")
standard_diagnosis: Mapped[str] = mapped_column(Text, nullable=False, comment="标准诊断")
standard_treatment: Mapped[str] = mapped_column(Text, nullable=False, comment="标准治疗")
guideline_reference: Mapped[str] = mapped_column(Text, nullable=False, comment="指南参考")
case_id: Mapped[int] = mapped_column(ForeignKey("case_base.id"), nullable=False, unique=True, index=True, comment="病例ID")
case = relationship("CaseBase", back_populates="traditional_case")
__table_args__ = {"comment": "传统病例扩展表"}
class TeachingCase(TimestampMixin, Base):
"""源库教学互动病例表:教学互动模式读取 case_base + teaching_case。"""
__tablename__ = "teaching_case"
id: Mapped[int] = mapped_column(BIGINT_PK, primary_key=True, autoincrement=True, comment="教学互动病例ID")
teaching_goal: Mapped[str] = mapped_column(Text, nullable=False, comment="教学目标")
discussion_questions: Mapped[str] = mapped_column(Text, nullable=False, comment="讨论问题")
teacher_guide: Mapped[str] = mapped_column(Text, nullable=False, comment="教师引导")
scoring_focus: Mapped[str] = mapped_column(Text, nullable=False, comment="评分重点")
case_id: Mapped[int] = mapped_column(ForeignKey("case_base.id"), nullable=False, unique=True, index=True, comment="病例ID")
case = relationship("CaseBase", back_populates="teaching_case")
__table_args__ = {"comment": "教学互动病例扩展表"}
class ScoringRule(TimestampMixin, Base):
"""源库评分规则表:评价时作为基础评分细则输入 Scoring Agent。"""
__tablename__ = "scoring_rule"
id: Mapped[int] = mapped_column(BIGINT_PK, primary_key=True, autoincrement=True, comment="评分规则ID")
dimension: Mapped[str] = mapped_column(String(50), nullable=False, index=True, comment="一级维度")
competency_dimension: Mapped[str] = mapped_column(String(50), nullable=False, index=True, comment="能力维度")
score_weight: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, comment="分值权重")
ai_auto_score: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, comment="是否AI自动评分")
osce_dimension: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, comment="是否OSCE维度")
scoring_standard: Mapped[str] = mapped_column(Text, nullable=False, comment="评分标准")
rubric_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict, comment="结构化评分细则")
case_id: Mapped[int] = mapped_column(ForeignKey("case_base.id"), nullable=False, index=True, comment="病例ID")
case = relationship("CaseBase", back_populates="scoring_rules")
__table_args__ = (
UniqueConstraint("case_id", "dimension", "competency_dimension", name="uk_scoring_rule_case_dimension"),
{"comment": "评分规则表"},
)
class CaseExamItem(TimestampMixin, Base):
"""病例检查检验项目表:保存固定检查结果,避免由 LLM 编造检查/检验数据。"""
__tablename__ = "case_exam_item"
id: Mapped[int] = mapped_column(BIGINT_PK, primary_key=True, autoincrement=True, comment="检查项目ID")
case_id: Mapped[int] = mapped_column(ForeignKey("case_base.id"), nullable=False, index=True, comment="病例ID")
item_code: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="检查项目编码")
item_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="检查项目名称")
item_type: Mapped[str] = mapped_column(String(32), nullable=False, index=True, comment="项目类型")
category: Mapped[str | None] = mapped_column(String(64), comment="项目分类")
result_text: Mapped[str] = mapped_column(Text, nullable=False, comment="固定返回结果文本")
result_structured: Mapped[dict | None] = mapped_column(JSON, comment="结构化检查结果")
is_key: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否关键检查")
is_abnormal: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否异常结果")
score_weight: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=0, comment="评分权重")
display_order: Mapped[int] = mapped_column(Integer, default=0, comment="展示顺序")
case = relationship("CaseBase", back_populates="exam_items")
__table_args__ = (
UniqueConstraint("case_id", "item_code", name="uk_case_exam_item_code"),
{"comment": "病例检查检验项目表"},
)