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": "病例检查检验项目表"}, )