finalize medical consultation agent backend

This commit is contained in:
刘金宝
2026-06-03 15:51:46 +08:00
parent 93d9e1c6a5
commit eb43573a44
33 changed files with 1063 additions and 281 deletions
+25
View File
@@ -9,6 +9,7 @@ class ReportAgent:
"score_type": scoring_result.get("score_type", "percentage"),
"total_score": total_score,
"dimension_scores": dimension_scores,
"score_details": self._normalize_score_details(scoring_result.get("score_details", []), dimension_scores),
"errors": self._ensure_list(scoring_result.get("errors")),
"improvement_plan": self._ensure_list(scoring_result.get("improvement_plan")),
"evidence_summary": self._ensure_list(scoring_result.get("evidence_summary")),
@@ -32,6 +33,30 @@ class ReportAgent:
"score": self._safe_float(item.get("score"), 0),
"max_score": self._safe_float(item.get("max_score"), 0),
"comment": str(item.get("comment", "")),
"evidence": self._ensure_list(item.get("evidence")),
"deductions": self._ensure_list(item.get("deductions")),
"improvement": str(item.get("improvement", "")),
}
)
return normalized
def _normalize_score_details(self, raw_details: object, dimension_scores: list[dict]) -> list[dict]:
"""评分明细校验:保留可写入 training_score_detail 的细粒度字段。"""
source = raw_details if isinstance(raw_details, list) and raw_details else dimension_scores
normalized: list[dict] = []
for item in source:
if not isinstance(item, dict):
continue
deductions = self._ensure_list(item.get("deductions"))
normalized.append(
{
"rule_id": item.get("rule_id"),
"dimension": str(item.get("dimension", "综合表现")),
"score": self._safe_float(item.get("score"), 0),
"deducted_reason": str(item.get("deducted_reason") or "".join(str(value) for value in deductions)),
"evidence_message_ids": self._ensure_list(item.get("evidence_message_ids") or item.get("evidence")),
"ai_confidence": self._safe_float(item.get("ai_confidence"), 0.85),
"comment": str(item.get("comment") or item.get("improvement") or ""),
}
)
return normalized
+64 -1
View File
@@ -114,8 +114,9 @@ class ScoringAgent:
"你是医学教学问诊评分专家,只输出合法 JSON。"
"请结合病例、问诊过程、检查申请、诊断和治疗提交进行教学评价。"
"输出字段固定为 score_type,total_score,dimension_scores,errors,improvement_plan,"
"evidence_summary,guideline_refs,overall_comment。"
"evidence_summary,guideline_refs,overall_comment,score_details"
"dimension_scores 为 5-6 项,每项包含 dimension,score,max_score,comment,evidence,deductions,improvement。"
"score_details 对应 scoring_rules,每项包含 rule_id,dimension,score,deducted_reason,evidence_message_ids,ai_confidence,comment。"
"evidence、deductions、improvement_plan、evidence_summary 必须是数组,每个元素一句话。"
"errors 每项包含 title,description,severity,related_dimension。"
"评价必须具体指出用户问了什么、申请了什么检查、诊断治疗哪里充分或不足。"
@@ -129,6 +130,7 @@ class ScoringAgent:
for item in scoring_rules[:12]:
compact.append(
{
"rule_id": getattr(item, "id", None),
"dimension": getattr(item, "dimension", ""),
"competency_dimension": getattr(item, "competency_dimension", ""),
"score_weight": float(getattr(item, "score_weight", 0) or 0),
@@ -161,6 +163,7 @@ class ScoringAgent:
data.setdefault("score_type", "percentage")
data.setdefault("total_score", 0)
data.setdefault("dimension_scores", [])
data.setdefault("score_details", [])
data.setdefault("errors", [])
data.setdefault("improvement_plan", [])
data.setdefault("evidence_summary", [])
@@ -183,6 +186,7 @@ class ScoringAgent:
}
)
data["dimension_scores"] = normalized_dimensions or self._fallback_score("percentage", guideline_refs)["dimension_scores"]
data["score_details"] = self._normalize_score_details(data.get("score_details"), data["dimension_scores"])
data["errors"] = self._normalize_errors(data.get("errors"))
data["improvement_plan"] = self._ensure_list(data.get("improvement_plan"))
data["evidence_summary"] = self._ensure_list(data.get("evidence_summary"))
@@ -195,6 +199,29 @@ class ScoringAgent:
data["score_type"] = "percentage"
return data
def _normalize_score_details(self, raw_details: object, dimension_scores: list[dict]) -> list[dict]:
"""评分明细归一化:生成可落库的 training_score_detail 数据。"""
source = raw_details if isinstance(raw_details, list) and raw_details else dimension_scores
details = []
for item in source:
if not isinstance(item, dict):
continue
deducted_reason = item.get("deducted_reason")
if not deducted_reason:
deducted_reason = "".join(str(value) for value in item.get("deductions", []) if value)
details.append(
{
"rule_id": item.get("rule_id"),
"dimension": str(item.get("dimension") or "综合表现"),
"score": float(item.get("score") or 0),
"deducted_reason": self._truncate(deducted_reason or "", 260),
"evidence_message_ids": self._ensure_list(item.get("evidence_message_ids") or item.get("evidence")),
"ai_confidence": float(item.get("ai_confidence") or 0.85),
"comment": self._truncate(item.get("comment") or item.get("improvement") or "", 220),
}
)
return details
def _normalize_errors(self, errors: object) -> list[dict]:
"""错误项归一化:转为报告可渲染的扣分项。"""
normalized = []
@@ -320,6 +347,35 @@ class ScoringAgent:
"improvement": "用 SOAP 结构归纳病情,把证据、判断和计划串联起来。",
},
],
"score_details": [
{
"rule_id": None,
"dimension": "信息获取",
"score": 20,
"deducted_reason": "既往喘息史、过敏史、疫苗接种史、家属照护能力等信息不够完整。",
"evidence_message_ids": ["围绕发热、咳嗽、喘息等核心症状展开问诊。"],
"ai_confidence": 0.85,
"comment": "完成主要症状追问,但儿科专科病史仍需补充。",
},
{
"rule_id": None,
"dimension": "分析推理",
"score": 16,
"deducted_reason": "鉴别诊断和严重程度判断未充分引用血氧、胸片和炎症指标。",
"evidence_message_ids": ["主要诊断指向支气管肺炎。"],
"ai_confidence": 0.84,
"comment": "诊断方向基本正确,但严重程度分层需要更清晰。",
},
{
"rule_id": None,
"dimension": "检查利用",
"score": 12,
"deducted_reason": "对 SpO2、胸片异常和炎症指标的临床意义解释不够具体。",
"evidence_message_ids": ["胸片、血氧或炎症指标可支持肺炎诊断和严重程度判断。"],
"ai_confidence": 0.84,
"comment": "关键检查申请较完整,但检查结果解释仍可细化。",
},
],
"errors": [
{
"title": "信息采集不够系统",
@@ -359,6 +415,13 @@ class ScoringAgent:
}
for item in data.get("dimension_scores", [])
]
converted["score_details"] = [
{
**item,
"score": round(float(item.get("score", 0)) / 20, 1),
}
for item in data.get("score_details", [])
]
return converted
def _truncate(self, value: Any, limit: int) -> str:
+12 -2
View File
@@ -22,9 +22,11 @@ async def auth_me(ctx: UserContext = Depends(get_user_context)):
phone=profile.get("phone"),
avatar=profile.get("avatar"),
gender=profile.get("gender"),
institution=profile.get("institution"),
institution=profile.get("institution") or profile.get("institution_id"),
institution_id=profile.get("institution_id") or profile.get("institution"),
institution_name=profile.get("institution_name"),
department=profile.get("department"),
department=profile.get("department") or profile.get("department_id"),
department_id=profile.get("department_id") or profile.get("department"),
department_name=profile.get("department_name"),
title_name=profile.get("title_name"),
major=profile.get("major"),
@@ -38,5 +40,13 @@ async def auth_me(ctx: UserContext = Depends(get_user_context)):
total_case_count=profile.get("total_case_count"),
current_level=profile.get("current_level"),
status=profile.get("status"),
last_login=profile.get("last_login"),
last_login_time=profile.get("last_login_time"),
is_superuser=profile.get("is_superuser"),
is_staff=profile.get("is_staff"),
is_active=profile.get("is_active"),
date_joined=profile.get("date_joined"),
created_at=profile.get("created_at"),
updated_at=profile.get("updated_at"),
)
)
+7 -1
View File
@@ -3,17 +3,23 @@ from dataclasses import dataclass
@dataclass(frozen=True)
class UserContext:
"""用户上下文:承载宿主系统传入的 user_id 和入口元数据。"""
"""用户上下文:承载 Django 用户中心认证后的用户 ID 和入口元数据。"""
user_id: str
tenant_id: str | None = None
role: str | None = None
class_id: str | None = None
institution_id: int | None = None
department_id: int | None = None
entry_scene: str | None = None
request_id: str | None = None
ip_address: str | None = None
user_agent: str | None = None
username: str | None = None
display_name: str | None = None
phone: str | None = None
major: str | None = None
training_stage: str | None = None
learning_target: str | None = None
auth_source: str = "django_user_center"
profile: dict | None = None
+6
View File
@@ -23,12 +23,18 @@ async def get_user_context(
user_id=user.user_id,
tenant_id=user.tenant_id,
role=user.role,
institution_id=user.institution_id,
department_id=user.department_id,
entry_scene=x_entry_scene,
request_id=x_request_id,
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("User-Agent"),
username=user.username,
display_name=user.display_name,
phone=user.phone,
major=user.major,
training_stage=user.training_stage,
learning_target=user.learning_target,
auth_source=user.source,
profile=user.profile,
)
+3 -3
View File
@@ -6,8 +6,8 @@ from app.models.knowledge import KnowledgeChunk, KnowledgeDocument, KnowledgeSou
from app.models.prompt import PromptTemplate
from app.models.source_case import CaseBase, CaseExamItem, ScoringRule, TeachingCase, TraditionalCase
from app.models.training import SessionOrder, SessionSubmission, TrainingSession
from app.models.training_record import TrainingRecord
from app.models.user import User, UserLearningProfile
from app.models.training_record import TrainingRecord, TrainingScoreDetail
from app.models.user import User
__all__ = [
"AuditLog",
@@ -25,6 +25,6 @@ __all__ = [
"SessionSubmission",
"TrainingSession",
"TrainingRecord",
"TrainingScoreDetail",
"User",
"UserLearningProfile",
]
+1 -1
View File
@@ -12,7 +12,7 @@ class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[str | None] = mapped_column(String(128), index=True)
user_id: Mapped[str | None] = mapped_column(String(128), index=True, comment="Django用户中心ID")
tenant_id: Mapped[str | None] = mapped_column(String(128))
session_id: Mapped[int | None] = mapped_column(Integer, index=True)
action: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
+11 -11
View File
@@ -1,22 +1,22 @@
from __future__ import annotations
from sqlalchemy import Boolean, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import BigInteger, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
from app.models.mixins import TimestampMixin
BIGINT_PK = BigInteger().with_variant(Integer, "sqlite")
class Department(TimestampMixin, Base):
"""科室模型:维护病例、知识库和评分规则的科室分类"""
"""科室模型:使用用户端确定的 department 表字段"""
__tablename__ = "departments"
__tablename__ = "department"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
code: Mapped[str] = mapped_column(String(50), nullable=False, unique=True, index=True)
parent_id: Mapped[int | None] = mapped_column(ForeignKey("departments.id"), nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
id: Mapped[int] = mapped_column(BIGINT_PK, primary_key=True, autoincrement=True, comment="科室ID")
name: Mapped[str] = mapped_column(String(100), nullable=False, comment="科室名称")
category: Mapped[str] = mapped_column(String(50), nullable=False, comment="科室分类")
institution_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True, comment="所属机构ID")
parent: Mapped["Department | None"] = relationship(remote_side=[id])
__table_args__ = {"comment": "科室表"}
+2 -2
View File
@@ -29,7 +29,7 @@ class KnowledgeDocument(TimestampMixin, Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
source_id: Mapped[int] = mapped_column(ForeignKey("knowledge_sources.id"), nullable=False, index=True)
department_id: Mapped[int | None] = mapped_column(ForeignKey("departments.id"), nullable=True, index=True)
department_id: Mapped[int | None] = mapped_column(ForeignKey("department.id"), nullable=True, index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
task_type: Mapped[str | None] = mapped_column(String(64), index=True)
summary: Mapped[str | None] = mapped_column(Text)
@@ -46,7 +46,7 @@ class KnowledgeChunk(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
document_id: Mapped[int] = mapped_column(ForeignKey("knowledge_documents.id"), nullable=False, index=True)
department_id: Mapped[int | None] = mapped_column(ForeignKey("departments.id"), nullable=True, index=True)
department_id: Mapped[int | None] = mapped_column(ForeignKey("department.id"), nullable=True, index=True)
task_type: Mapped[str | None] = mapped_column(String(64), index=True)
chunk_text: Mapped[str] = mapped_column(Text, nullable=False)
keywords: Mapped[list | None] = mapped_column(JSON)
+4 -4
View File
@@ -10,13 +10,13 @@ BIGINT_PK = BigInteger().with_variant(Integer, "sqlite")
class TrainingSession(TimestampMixin, Base):
"""训练会话表:保存一次训练的运行状态、用户隔离信息和短期 memory key。"""
"""训练会话表:保存一次训练的运行状态、Django 用户隔离信息和短期 memory key。"""
__tablename__ = "training_session"
id: Mapped[int] = mapped_column(BIGINT_PK, primary_key=True, autoincrement=True, comment="训练会话ID")
session_code: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True, comment="会话编码")
user_id: Mapped[str] = mapped_column("external_user_id", String(128), nullable=False, index=True, comment="宿主系统用户ID")
user_id: Mapped[str] = mapped_column("external_user_id", String(128), nullable=False, index=True, comment="Django用户中心ID")
tenant_id: Mapped[str | None] = mapped_column(String(128), comment="租户或项目ID")
class_id: Mapped[str | None] = mapped_column(String(128), comment="班级或课程ID")
entry_scene: Mapped[str | None] = mapped_column(String(64), comment="入口场景")
@@ -45,7 +45,7 @@ class SessionOrder(Base):
id: Mapped[int] = mapped_column(BIGINT_PK, primary_key=True, autoincrement=True, comment="检查申请ID")
session_id: Mapped[int] = mapped_column(ForeignKey("training_session.id"), nullable=False, index=True, comment="训练会话ID")
user_id: Mapped[str] = mapped_column("external_user_id", String(128), nullable=False, index=True, comment="宿主系统用户ID")
user_id: Mapped[str] = mapped_column("external_user_id", String(128), nullable=False, index=True, comment="Django用户中心ID")
case_id: Mapped[int] = mapped_column(ForeignKey("case_base.id"), nullable=False, index=True, comment="病例ID")
case_exam_item_id: Mapped[int] = mapped_column("exam_item_id", ForeignKey("case_exam_item.id"), nullable=False, comment="检查项目ID")
item_code: Mapped[str] = mapped_column(String(64), nullable=False, comment="项目编码")
@@ -74,7 +74,7 @@ class SessionSubmission(TimestampMixin, Base):
id: Mapped[int] = mapped_column(BIGINT_PK, primary_key=True, autoincrement=True, comment="提交记录ID")
session_id: Mapped[int] = mapped_column(ForeignKey("training_session.id"), nullable=False, unique=True, comment="训练会话ID")
user_id: Mapped[str] = mapped_column("external_user_id", String(128), nullable=False, index=True, comment="宿主系统用户ID")
user_id: Mapped[str] = mapped_column("external_user_id", String(128), nullable=False, index=True, comment="Django用户中心ID")
primary_diagnosis: Mapped[str | None] = mapped_column(Text, comment="主要诊断")
differential_diagnoses: Mapped[list | None] = mapped_column(JSON, comment="鉴别诊断")
diagnosis_basis: Mapped[str | None] = mapped_column(Text, comment="诊断依据")
+26 -4
View File
@@ -3,8 +3,8 @@ from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import BigInteger, DateTime, Integer, JSON, Numeric, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, JSON, Numeric, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.mixins import TimestampMixin
@@ -42,8 +42,8 @@ class TrainingRecord(TimestampMixin, Base):
rag_context_version: Mapped[str] = mapped_column(String(50), nullable=False, default="none", comment="RAG上下文版本")
case_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True, comment="病例ID")
teacher_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True, comment="教师ID")
user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True, comment="数字用户ID")
external_user_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="宿主系统用户ID")
user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True, comment="Django用户中心数字ID")
external_user_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="Django用户中心ID")
session_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True, comment="训练会话ID")
evaluation_record_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True, comment="兼容旧评价记录ID")
score_type: Mapped[str] = mapped_column(String(20), nullable=False, default="percentage", comment="分数类型")
@@ -53,3 +53,25 @@ class TrainingRecord(TimestampMixin, Base):
UniqueConstraint("session_id", name="uk_training_record_session"),
{"comment": "训练记录表"},
)
score_details = relationship("TrainingScoreDetail", back_populates="record", cascade="all, delete-orphan")
class TrainingScoreDetail(TimestampMixin, Base):
"""评分明细表:保存每条 scoring_rule 对应的 AI 评分、扣分原因、证据和置信度。"""
__tablename__ = "training_score_detail"
id: Mapped[int] = mapped_column(BIGINT_PK, primary_key=True, autoincrement=True, comment="评分明细ID")
record_id: Mapped[int] = mapped_column(ForeignKey("training_record.id"), nullable=False, index=True, comment="训练记录ID")
rule_id: Mapped[int | None] = mapped_column(ForeignKey("scoring_rule.id"), nullable=True, index=True, comment="评分规则ID")
dimension: Mapped[str] = mapped_column(String(50), nullable=False, index=True, comment="评分维度")
score: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0, comment="分数")
deducted_reason: Mapped[str | None] = mapped_column(Text, comment="扣分原因")
evidence_message_ids: Mapped[list] = mapped_column(JSON, nullable=False, default=list, comment="对应对话证据")
ai_confidence: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), comment="AI评分置信度")
comment: Mapped[str | None] = mapped_column(Text, comment="评语")
record = relationship("TrainingRecord", back_populates="score_details")
__table_args__ = {"comment": "评分明细表"}
+34 -21
View File
@@ -1,33 +1,46 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, JSON, Numeric, String
from sqlalchemy import BigInteger, Boolean, DateTime, Integer, JSON, SmallInteger, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
from app.models.mixins import TimestampMixin
BIGINT_PK = BigInteger().with_variant(Integer, "sqlite")
class User(TimestampMixin, Base):
"""宿主用户引用:保存外部 user_id,不承担登录注册职责。"""
"""用户端用户表:按 Django 用户中心确定字段建模,只读取不承担登录注册职责。"""
__tablename__ = "users"
__tablename__ = "user"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
external_user_id: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
display_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
id: Mapped[int] = mapped_column(BIGINT_PK, primary_key=True, autoincrement=True, comment="用户ID")
username: Mapped[str] = mapped_column(String(50), nullable=False, unique=True, index=True, comment="用户名")
password: Mapped[str] = mapped_column(String(255), nullable=False, comment="密码哈希")
real_name: Mapped[str] = mapped_column(String(50), nullable=False, comment="真实姓名")
phone: Mapped[str] = mapped_column(String(20), nullable=False, unique=True, index=True, comment="手机号")
avatar: Mapped[str] = mapped_column(String(255), nullable=False, default="", comment="头像")
gender: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0, comment="性别")
role_type: Mapped[str] = mapped_column(String(30), nullable=False, comment="角色类型")
title_name: Mapped[str] = mapped_column(String(50), nullable=False, default="", comment="职称")
major: Mapped[str] = mapped_column(String(100), nullable=False, default="", comment="专业")
training_stage: Mapped[str] = mapped_column(String(50), nullable=False, default="", comment="培训阶段")
learning_target: Mapped[str] = mapped_column(String(255), nullable=False, default="", comment="学习目标")
competency_profile: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict, comment="能力画像")
weak_dimensions: Mapped[list] = mapped_column(JSON, nullable=False, default=list, comment="薄弱维度")
strong_dimensions: Mapped[list] = mapped_column(JSON, nullable=False, default=list, comment="优势维度")
ai_preference: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict, comment="AI偏好")
total_training_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="训练次数")
total_case_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="病例数")
current_level: Mapped[str] = mapped_column(String(30), nullable=False, default="", comment="当前等级")
status: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=1, comment="状态")
last_login: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="最后登录")
last_login_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="最后登录时间")
is_superuser: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, comment="是否超级用户")
is_staff: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, comment="是否员工")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, comment="是否激活")
date_joined: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="加入时间")
department_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True, comment="科室ID")
institution_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True, comment="机构ID")
class UserLearningProfile(TimestampMixin, Base):
"""学习档案模型:聚合完整评价记录形成用户能力画像。"""
__tablename__ = "user_learning_profiles"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
tenant_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
total_evaluations: Mapped[int] = mapped_column(Integer, default=0)
avg_score_percentage: Mapped[float | None] = mapped_column(Numeric(6, 2))
avg_score_five_point: Mapped[float | None] = mapped_column(Numeric(4, 2))
weak_dimensions: Mapped[list | None] = mapped_column(JSON)
last_evaluation_id: Mapped[int | None] = mapped_column(Integer)
last_trained_at: Mapped[datetime | None] = mapped_column(DateTime, index=True)
__table_args__ = {"comment": "用户表"}
+27 -1
View File
@@ -3,7 +3,7 @@ from sqlalchemy.orm import Session, selectinload
from app.models.source_case import CaseBase, CaseExamItem, ScoringRule, TeachingCase, TraditionalCase
from app.models.training import SessionOrder, SessionSubmission, TrainingSession
from app.models.training_record import TrainingRecord
from app.models.training_record import TrainingRecord, TrainingScoreDetail
class CaseRepository:
@@ -64,6 +64,7 @@ class CaseRepository:
"training_session": len(session_ids),
"training_order": self._count_training_orders(case_id, session_ids),
"training_submission": self._count_by_sessions(SessionSubmission, SessionSubmission.session_id, session_ids),
"training_score_detail": self._count_score_details(case_id, session_ids),
"training_record": self._count_training_records(case_id, session_ids),
}
@@ -75,6 +76,7 @@ class CaseRepository:
deleted["training_submission"] = self._delete_by_sessions(
SessionSubmission, SessionSubmission.session_id, session_ids
)
deleted["training_score_detail"] = self._delete_score_details(case_id, session_ids)
deleted["training_record"] = self._delete_training_records(case_id, session_ids)
deleted["training_session"] = self._delete_where(TrainingSession, TrainingSession.case_id == case_id)
deleted["case_exam_item"] = self._delete_where(CaseExamItem, CaseExamItem.case_id == case_id)
@@ -115,6 +117,13 @@ class CaseRepository:
)
return self._count(TrainingRecord, TrainingRecord.case_id == case_id)
def _count_score_details(self, case_id: int, session_ids: list[int]) -> int:
"""病例删除预览:统计该病例评价记录下的评分明细。"""
record_ids = self._record_ids(case_id, session_ids)
if not record_ids:
return 0
return self._count(TrainingScoreDetail, TrainingScoreDetail.record_id.in_(record_ids))
def _delete_where(self, model: type, *criteria) -> int:
"""病例删除执行:按条件删除单表记录并返回影响行数。"""
result = self.db.execute(delete(model).where(*criteria))
@@ -144,6 +153,23 @@ class CaseRepository:
)
return self._delete_where(TrainingRecord, TrainingRecord.case_id == case_id)
def _delete_score_details(self, case_id: int, session_ids: list[int]) -> int:
"""病例删除执行:先删除评价明细,避免阻塞训练记录删除。"""
record_ids = self._record_ids(case_id, session_ids)
if not record_ids:
return 0
return self._delete_where(TrainingScoreDetail, TrainingScoreDetail.record_id.in_(record_ids))
def _record_ids(self, case_id: int, session_ids: list[int]) -> list[int]:
"""病例删除:读取该病例关联的训练记录 ID 集合。"""
if session_ids:
stmt = select(TrainingRecord.id).where(
or_(TrainingRecord.case_id == case_id, TrainingRecord.session_id.in_(session_ids))
)
else:
stmt = select(TrainingRecord.id).where(TrainingRecord.case_id == case_id)
return [int(item) for item in self.db.scalars(stmt).all()]
def get_exam_items(self, case_id: int) -> list[CaseExamItem]:
"""检查项目:读取当前病例下全部可申请检查检验项目。"""
stmt = select(CaseExamItem).where(CaseExamItem.case_id == case_id).order_by(CaseExamItem.display_order)
@@ -1,7 +1,7 @@
from sqlalchemy import select
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.models.training_record import TrainingRecord
from app.models.training_record import TrainingRecord, TrainingScoreDetail
class EvaluationRepository:
@@ -16,6 +16,20 @@ class EvaluationRepository:
self.db.flush()
return record
def replace_score_details(self, record_id: int, details: list[TrainingScoreDetail]) -> list[TrainingScoreDetail]:
"""评分明细保存:按训练记录覆盖写入维度评分明细。"""
self.db.execute(delete(TrainingScoreDetail).where(TrainingScoreDetail.record_id == record_id))
for detail in details:
detail.record_id = record_id
self.db.add(detail)
self.db.flush()
return details
def list_score_details(self, record_id: int) -> list[TrainingScoreDetail]:
"""评分明细读取:按训练记录查询全部维度明细。"""
stmt = select(TrainingScoreDetail).where(TrainingScoreDetail.record_id == record_id).order_by(TrainingScoreDetail.id)
return list(self.db.scalars(stmt).all())
def get_by_session(self, session_id: int, user_id: str) -> TrainingRecord | None:
"""评价读取:按会话 ID 和外部 user_id 查询训练记录。"""
stmt = select(TrainingRecord).where(
@@ -1,25 +0,0 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.user import UserLearningProfile
class UserLearningProfileRepository:
"""学习档案仓储:维护用户训练评价聚合数据。"""
def __init__(self, db: Session) -> None:
self.db = db
def get_profile(self, user_id: str, tenant_id: str | None) -> UserLearningProfile | None:
"""档案读取:按 user_id 和 tenant_id 获取学习档案。"""
stmt = select(UserLearningProfile).where(
UserLearningProfile.user_id == user_id,
UserLearningProfile.tenant_id == tenant_id,
)
return self.db.scalar(stmt)
def save(self, profile: UserLearningProfile) -> UserLearningProfile:
"""档案保存:创建或更新用户学习档案。"""
self.db.add(profile)
self.db.flush()
return profile
@@ -50,7 +50,7 @@ class SourceCaseRepository:
return self.db.scalar(stmt)
def get_department_name(self, department_id: int | None) -> str:
"""科室名称:兼容当前 demo 的 departments,源库无科室表时返回空字符串"""
"""科室名称:按用户端 department 表读取科室名称"""
if not department_id:
return ""
department = self.db.scalar(select(Department).where(Department.id == department_id))
+10
View File
@@ -16,8 +16,10 @@ class AuthMeResponse(BaseModel):
avatar: str | None = None
gender: int | None = None
institution: int | None = None
institution_id: int | None = None
institution_name: str | None = None
department: int | None = None
department_id: int | None = None
department_name: str | None = None
title_name: str | None = None
major: str | None = None
@@ -31,3 +33,11 @@ class AuthMeResponse(BaseModel):
total_case_count: int | None = None
current_level: str | None = None
status: int | None = None
last_login: str | None = None
last_login_time: str | None = None
is_superuser: bool | None = None
is_staff: bool | None = None
is_active: bool | None = None
date_joined: str | None = None
created_at: str | None = None
updated_at: str | None = None
+15
View File
@@ -21,6 +21,20 @@ class DimensionScore(BaseModel):
improvement: str = ""
class ScoreDetailItem(BaseModel):
"""评分明细:对应 training_score_detail 的单条评分细则。"""
id: int | None = None
record_id: int | None = None
rule_id: int | None = None
dimension: str
score: float | None = None
deducted_reason: str | None = None
evidence_message_ids: list = Field(default_factory=list)
ai_confidence: float | None = None
comment: str | None = None
class EvaluationResponse(BaseModel):
"""评价报告响应:返回结构化 AI 评价报告。"""
@@ -28,6 +42,7 @@ class EvaluationResponse(BaseModel):
score_type: str
total_score: float
dimension_scores: list[DimensionScore]
score_details: list[ScoreDetailItem] = Field(default_factory=list)
errors: list[dict]
improvement_plan: list[str]
evidence_summary: list[str]
+97 -28
View File
@@ -1,16 +1,15 @@
import json
from datetime import datetime
from decimal import Decimal
from sqlalchemy.orm import Session
from app.agents.orchestrator import MedicalConsultationOrchestrator
from app.core.context import UserContext
from app.core.exceptions import AppError
from app.models.training_record import TrainingRecord
from app.models.user import UserLearningProfile
from app.models.training_record import TrainingRecord, TrainingScoreDetail
from app.repositories.case_repository import CaseRepository
from app.repositories.evaluation_repository import EvaluationRepository
from app.repositories.profile_repository import UserLearningProfileRepository
from app.repositories.session_repository import SessionRepository
from app.repositories.source_case_repository import SourceCaseRepository
from app.schemas.evaluation import (
@@ -20,6 +19,7 @@ from app.schemas.evaluation import (
EvaluationListItem,
EvaluationListResponse,
EvaluationResponse,
ScoreDetailItem,
)
from app.services.audit_service import AuditService
from app.services.knowledge_service import KnowledgeService
@@ -27,7 +27,7 @@ from app.services.runtime_memory import runtime_memory
class EvaluationService:
"""评价服务:基于新源库表和 training_record 完成评分、历史和学习档案更新"""
"""评价服务:基于病例、评分规则和作答过程生成 training_record 与评分明细"""
def __init__(self, db: Session) -> None:
self.db = db
@@ -35,7 +35,6 @@ class EvaluationService:
self.case_repo = CaseRepository(db)
self.eval_repo = EvaluationRepository(db)
self.source_repo = SourceCaseRepository(db)
self.profile_repo = UserLearningProfileRepository(db)
self.knowledge = KnowledgeService(db)
self.audit = AuditService(db)
self.orchestrator = MedicalConsultationOrchestrator()
@@ -79,9 +78,9 @@ class EvaluationService:
record = self._build_training_record(ctx, session, case, submission, report, scoring_rules, guideline_result)
self.eval_repo.create_record(record)
self.eval_repo.replace_score_details(record.id, self._build_score_details(record.id, report, scoring_rules))
self.session_repo.update_status(session, "completed")
runtime_memory.release(session.memory_key)
self._update_learning_profile(ctx, record)
self.audit.log(ctx, "evaluation.generate", "training_record", str(record.id), session.id)
return self._to_response(record)
@@ -104,6 +103,7 @@ class EvaluationService:
"score_type": report.get("score_type", session.score_type),
"total_score": total_score,
"dimension_scores": report.get("dimension_scores") or [],
"score_details": report.get("score_details") or [],
"errors": report.get("errors") or [],
"improvement_plan": report.get("improvement_plan") or [],
"evidence_summary": report.get("evidence_summary") or [],
@@ -160,6 +160,61 @@ class EvaluationService:
pdf_file_path=None,
)
def _build_score_details(self, record_id: int, report: dict, scoring_rules: list) -> list[TrainingScoreDetail]:
"""评分明细写入:把 LLM 结构化评分结果映射到 training_score_detail。"""
raw_items = report.get("score_details") or report.get("dimension_scores") or []
rule_map = self._rule_map(scoring_rules)
details: list[TrainingScoreDetail] = []
for item in raw_items:
if not isinstance(item, dict):
continue
dimension = str(item.get("dimension") or "综合表现")
matched_rule = self._match_rule(item, dimension, rule_map)
deducted_reason = item.get("deducted_reason")
if not deducted_reason:
deducted_reason = "".join(str(value) for value in (item.get("deductions") or []) if value)
evidence = item.get("evidence_message_ids")
if evidence is None:
evidence = item.get("evidence") or []
details.append(
TrainingScoreDetail(
record_id=record_id,
rule_id=int(item.get("rule_id") or matched_rule.id) if matched_rule else None,
dimension=dimension[:50],
score=self._decimal_or_none(item.get("score")),
deducted_reason=deducted_reason or "",
evidence_message_ids=evidence if isinstance(evidence, list) else [evidence],
ai_confidence=self._decimal_or_none(item.get("ai_confidence") or 0.85),
comment=item.get("comment") or item.get("improvement") or "",
)
)
return details
def _rule_map(self, scoring_rules: list) -> dict[str, object]:
"""评分规则映射:按维度和能力维度建立匹配索引。"""
result = {}
for rule in scoring_rules:
for key in (getattr(rule, "dimension", ""), getattr(rule, "competency_dimension", "")):
if key:
result[str(key).strip()] = rule
return result
def _match_rule(self, item: dict, dimension: str, rule_map: dict[str, object]):
"""评分规则匹配:优先按 rule_id,其次按维度文本匹配 scoring_rule。"""
rule_id = item.get("rule_id")
if rule_id:
for rule in rule_map.values():
if getattr(rule, "id", None) == rule_id:
return rule
return rule_map.get(dimension) or rule_map.get(str(item.get("competency_dimension") or "").strip())
def _decimal_or_none(self, value: object) -> Decimal | None:
"""分数转换:将 LLM 返回值转换为 Decimal,异常时置空。"""
try:
return Decimal(str(value))
except Exception:
return None
def _evaluation_level(self, score: float, score_type: str) -> str:
"""评价等级:根据百分制或五分制总分生成训练记录等级。"""
normalized = score * 20 if score_type == "five_point" else score
@@ -177,11 +232,11 @@ class EvaluationService:
return f"knowledge_chunks:{len(matched)}" if matched else "none"
def _numeric_user_id(self, user_id: str) -> int | None:
"""用户 ID 兼容:宿主传字符串 user_id 写入 external_user_id,数字 ID 同步写入 user_id。"""
"""用户 ID 兼容:Django 返回的 id 写入 external_user_id数字同步写入源库 user_id。"""
return int(user_id) if str(user_id).isdigit() else None
def list_history(self, user_id: str) -> EvaluationListResponse:
"""历史评价:按外部 user_id 查询完整训练后的 training_record。"""
"""历史评价:按 Django 用户中心 ID 查询完整训练后的 training_record。"""
records = self.eval_repo.list_by_user(user_id)
return EvaluationListResponse(
items=[
@@ -198,7 +253,7 @@ class EvaluationService:
)
def get_detail(self, evaluation_id: int, user_id: str) -> EvaluationDetailResponse:
"""评价详情:按 user_id 校验归属并返回完整报告。"""
"""评价详情:按 Django 用户中心 ID 校验归属并返回完整报告。"""
record = self.eval_repo.get_owned_record(evaluation_id, user_id)
if not record:
raise AppError("EVALUATION_NOT_FOUND", "evaluation not found or not owned by current user", 404)
@@ -221,6 +276,7 @@ class EvaluationService:
score_type=record.score_type,
total_score=float(record.total_score or structured.get("total_score") or 0),
dimension_scores=[DimensionScore(**item) for item in dimension_scores],
score_details=self._score_detail_response(record),
errors=structured.get("errors") or record.wrong_points or [],
improvement_plan=structured.get("improvement_plan") or (record.recommendation_result or {}).get("improvement_plan") or [],
evidence_summary=structured.get("evidence_summary") or [],
@@ -228,25 +284,38 @@ class EvaluationService:
overall_comment=structured.get("overall_comment") or record.feedback or "",
)
def _update_learning_profile(self, ctx: UserContext, record: TrainingRecord) -> None:
"""学习档案:根据完整训练记录更新用户平均分和薄弱维度"""
profile = self.profile_repo.get_profile(ctx.user_id, ctx.tenant_id)
if not profile:
profile = UserLearningProfile(user_id=ctx.user_id, tenant_id=ctx.tenant_id)
records = self.eval_repo.list_by_user(ctx.user_id)
percentage_scores = [float(item.total_score or 0) for item in records if item.score_type == "percentage"]
five_point_scores = [float(item.total_score or 0) for item in records if item.score_type == "five_point"]
dimensions = (record.ai_feedback_structured or {}).get("dimension_scores") or []
weak_dimensions = sorted(dimensions, key=lambda item: float(item.get("score", 0)))[:2]
profile.total_evaluations = len(records)
profile.avg_score_percentage = round(sum(percentage_scores) / len(percentage_scores), 2) if percentage_scores else None
profile.avg_score_five_point = round(sum(five_point_scores) / len(five_point_scores), 2) if five_point_scores else None
profile.weak_dimensions = weak_dimensions
profile.last_evaluation_id = record.id
profile.last_trained_at = datetime.utcnow()
self.profile_repo.save(profile)
def _score_detail_response(self, record: TrainingRecord) -> list[ScoreDetailItem]:
"""评分明细响应:优先读取 training_score_detail,旧记录回退到结构化维度评分"""
details = self.eval_repo.list_score_details(record.id)
if details:
return [
ScoreDetailItem(
id=item.id,
record_id=item.record_id,
rule_id=item.rule_id,
dimension=item.dimension,
score=float(item.score) if item.score is not None else None,
deducted_reason=item.deducted_reason,
evidence_message_ids=item.evidence_message_ids or [],
ai_confidence=float(item.ai_confidence) if item.ai_confidence is not None else None,
comment=item.comment,
)
for item in details
]
structured = record.ai_feedback_structured or {}
return [
ScoreDetailItem(
record_id=record.id,
dimension=item.get("dimension", "综合表现"),
score=float(item.get("score") or 0),
deducted_reason="".join(str(value) for value in item.get("deductions", []) if value),
evidence_message_ids=item.get("evidence") or [],
ai_confidence=None,
comment=item.get("comment") or "",
)
for item in structured.get("dimension_scores") or []
if isinstance(item, dict)
]
def _case_title(self, case_id: int | None) -> str:
"""病例标题:历史记录只保存 case_id,展示时按新病例主表读取标题。"""
@@ -100,7 +100,7 @@ class ExternalAuthService:
username = self._first_present(data, ["username", "account", "mobile", "phone"])
display_name = self._first_present(data, ["display_name", "name", "nickname", "real_name"])
role = self._first_present(data, ["role_type", "role", "user_role"])
institution_id = self._to_int(data.get("institution"))
institution_id = self._to_int(data.get("institution_id") or data.get("institution"))
tenant_id = str(institution_id) if institution_id is not None else None
profile = self._build_profile(data)
return AuthenticatedUser(
@@ -114,7 +114,7 @@ class ExternalAuthService:
gender=self._to_int(data.get("gender")),
institution_id=institution_id,
institution_name=self._to_str(data.get("institution_name")),
department_id=self._to_int(data.get("department")),
department_id=self._to_int(data.get("department_id") or data.get("department")),
department_name=self._to_str(data.get("department_name")),
title_name=self._to_str(data.get("title_name")),
major=self._to_str(data.get("major")),
@@ -136,8 +136,10 @@ class ExternalAuthService:
"gender",
"role_type",
"institution",
"institution_id",
"institution_name",
"department",
"department_id",
"department_name",
"title_name",
"major",
@@ -151,7 +153,12 @@ class ExternalAuthService:
"total_case_count",
"current_level",
"status",
"last_login",
"last_login_time",
"is_superuser",
"is_staff",
"is_active",
"date_joined",
"created_at",
"updated_at",
]
+46 -13
View File
@@ -35,7 +35,8 @@ class PdfExportService:
timestamp = datetime.utcnow().strftime("%Y%m%d%H%M%S")
file_name = f"training_record_{record.id}_{record.score_type}_{timestamp}_{uuid.uuid4().hex[:6]}.pdf"
file_path = output_dir / file_name
self._write_pdf(file_path, record, session)
score_details = self.repo.list_score_details(record.id)
self._write_pdf(file_path, record, session, score_details)
record.pdf_file_path = str(file_path)
recommendation = dict(record.recommendation_result or {})
@@ -57,7 +58,7 @@ class PdfExportService:
)
return self.db.scalar(stmt)
def _write_pdf(self, file_path: Path, record: TrainingRecord, session: TrainingSession | None) -> None:
def _write_pdf(self, file_path: Path, record: TrainingRecord, session: TrainingSession | None, score_details: list | None = None) -> None:
"""PDF 写入:使用 reportlab 生成 Acrobat 可正常打开的标准 PDF。"""
try:
from reportlab.lib import colors
@@ -133,7 +134,7 @@ class PdfExportService:
bottomMargin=14 * mm,
title="医疗问诊 Agent 训练评价报告",
)
doc.build(self._build_story(record, session, context))
doc.build(self._build_story(record, session, context, score_details or []))
except ModuleNotFoundError:
self._write_minimal_pdf(file_path, record)
except Exception as exc:
@@ -184,7 +185,7 @@ class PdfExportService:
)
file_path.write_bytes(bytes(content))
def _build_story(self, record: TrainingRecord, session: TrainingSession | None, context: dict[str, Any]) -> list:
def _build_story(self, record: TrainingRecord, session: TrainingSession | None, context: dict[str, Any], score_details: list | None = None) -> list:
"""报告模板:按基本信息、病例、提交、检查、评分细则和改进计划组织内容。"""
Paragraph = context["Paragraph"]
Spacer = context["Spacer"]
@@ -220,7 +221,7 @@ class PdfExportService:
self._append_case_section(story, case, context)
self._append_submission_section(story, submission, context)
self._append_order_section(story, orders, context)
self._append_dimension_section(story, structured.get("dimension_scores") or [], context)
self._append_dimension_section(story, score_details or structured.get("dimension_scores") or [], context)
self._append_error_section(story, structured.get("errors") or record.wrong_points or [], context)
self._append_list_section(story, "七、改进计划", structured.get("improvement_plan") or (record.recommendation_result or {}).get("improvement_plan") or [], context)
self._append_list_section(story, "八、证据摘要", structured.get("evidence_summary") or [], context)
@@ -284,17 +285,49 @@ class PdfExportService:
if not dimensions:
story.append(context["Paragraph"]("暂无维度评分。", context["styles"]["body"]))
return
rows = [["维度", "得分", "评价"]]
rows = [["维度", "得分", "扣分原因", "置信度", "评语"]]
for item in dimensions:
rows.append([item.get("dimension", "未命名维度"), f"{item.get('score', 0)} / {item.get('max_score', '-')}", item.get("comment", "")])
self._append_table(story, rows, [88, 58, 339], context)
payload = self._score_detail_payload(item)
rows.append([
payload["dimension"],
payload["score_text"],
payload["deducted_reason"],
payload["ai_confidence"],
payload["comment"],
])
self._append_table(story, rows, [70, 45, 160, 45, 165], context)
for index, item in enumerate(dimensions, start=1):
title = f"{index}. {item.get('dimension', '未命名维度')}{item.get('score', 0)} / {item.get('max_score', '-')}"
payload = self._score_detail_payload(item)
title = f"{index}. {payload['dimension']}{payload['score_text']}"
story.append(context["Paragraph"](self._safe_text(title), context["styles"]["body"]))
self._append_list_section(story, "证据", item.get("evidence") or [], context, compact=True)
self._append_list_section(story, "扣分原因", item.get("deductions") or [], context, compact=True)
if item.get("improvement"):
story.append(context["Paragraph"](f"改进动作{self._safe_text(item.get('improvement'))}", context["styles"]["small"]))
self._append_list_section(story, "证据", payload["evidence"], context, compact=True)
self._append_list_section(story, "扣分原因", [payload["deducted_reason"]] if payload["deducted_reason"] else [], context, compact=True)
if payload["comment"]:
story.append(context["Paragraph"](f"评语{self._safe_text(payload['comment'])}", context["styles"]["small"]))
def _score_detail_payload(self, item: Any) -> dict[str, Any]:
"""评分明细展示:兼容 training_score_detail ORM 和旧 dimension_scores 字典。"""
if isinstance(item, dict):
score = item.get("score", 0)
max_score = item.get("max_score")
return {
"dimension": item.get("dimension", "未命名维度"),
"score_text": f"{score} / {max_score}" if max_score is not None else str(score),
"deducted_reason": item.get("deducted_reason") or "".join(str(value) for value in item.get("deductions", []) if value),
"ai_confidence": item.get("ai_confidence") or "未记录",
"comment": item.get("comment") or item.get("improvement") or "",
"evidence": item.get("evidence_message_ids") or item.get("evidence") or [],
}
score = getattr(item, "score", None)
confidence = getattr(item, "ai_confidence", None)
return {
"dimension": getattr(item, "dimension", "未命名维度"),
"score_text": f"{float(score):g}" if score is not None else "未记录",
"deducted_reason": getattr(item, "deducted_reason", "") or "",
"ai_confidence": f"{float(confidence):g}" if confidence is not None else "未记录",
"comment": getattr(item, "comment", "") or "",
"evidence": getattr(item, "evidence_message_ids", None) or [],
}
def _append_error_section(self, story: list, errors: list, context: dict[str, Any]) -> None:
"""扣分问题分节:展示模型识别出的关键问题和严重程度。"""