finalize medical consultation agent backend
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": "科室表"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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="诊断依据")
|
||||
|
||||
@@ -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
@@ -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": "用户表"}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
"""扣分问题分节:展示模型识别出的关键问题和严重程度。"""
|
||||
|
||||
Reference in New Issue
Block a user