Files
fastapi/backend/app/services/evaluation_service.py
T

257 lines
12 KiB
Python
Raw Normal View History

import json
from datetime import datetime
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.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 (
CreateEvaluationRequest,
DimensionScore,
EvaluationDetailResponse,
EvaluationListItem,
EvaluationListResponse,
EvaluationResponse,
)
from app.services.audit_service import AuditService
from app.services.knowledge_service import KnowledgeService
from app.services.runtime_memory import runtime_memory
class EvaluationService:
"""评价服务:基于新源库表和 training_record 完成评分、历史和学习档案更新。"""
def __init__(self, db: Session) -> None:
self.db = db
self.session_repo = SessionRepository(db)
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()
async def create_evaluation(self, ctx: UserContext, session_id: int, payload: CreateEvaluationRequest) -> EvaluationResponse:
"""评价生成:读取会话短期 memory、提交内容、评分规则和指南后写入 training_record。"""
session = self.session_repo.get_owned_session(session_id, ctx.user_id)
if not session:
raise AppError("SESSION_NOT_FOUND", "session not found or not owned by current user", 404)
if session.status not in {"evaluating", "completed"}:
raise AppError("SESSION_STATUS_INVALID", "evaluation requires treatment submission", 400)
existed = self.eval_repo.get_by_session(session.id, ctx.user_id)
if existed:
return self._to_response(existed)
case = self.case_repo.get_active_case(session.case_id)
if not case:
raise AppError("CASE_NOT_FOUND", "case not found or inactive", 404)
submission = self.session_repo.get_submission(session.id)
if not submission or not submission.treatment_submitted_at:
raise AppError("TREATMENT_REQUIRED", "treatment submission is required", 400)
session.score_type = payload.score_type
memory_messages = runtime_memory.get_messages(session.memory_key)
keyword_seed = (case.key_symptoms or []) + (case.key_exams or []) + [case.diagnosis_primary or ""]
guideline_result = self.knowledge.search_guidelines(case.department_id, session.training_type, keyword_seed)
guideline_refs = guideline_result["source_refs"]
scoring_rules = self.source_repo.get_scoring_rules(case.id)
report = await self.orchestrator.evaluate(
session=session,
case=case,
memory_messages=memory_messages,
orders=session.orders,
submission=submission,
rubric=None,
guideline_refs=guideline_refs,
scoring_rules=scoring_rules,
)
record = self._build_training_record(ctx, session, case, submission, report, scoring_rules, guideline_result)
self.eval_repo.create_record(record)
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)
def _build_training_record(
self,
ctx: UserContext,
session,
case,
submission,
report: dict,
scoring_rules: list,
guideline_result: dict,
) -> TrainingRecord:
"""训练记录写入:完整流程结束后把评分结果沉淀到 training_record。"""
end_time = datetime.utcnow()
start_time = session.started_at or session.created_at or end_time
duration_seconds = int((end_time - start_time).total_seconds()) if start_time else None
total_score = float(report.get("total_score") or 0)
structured = {
"score_type": report.get("score_type", session.score_type),
"total_score": total_score,
"dimension_scores": report.get("dimension_scores") or [],
"errors": report.get("errors") or [],
"improvement_plan": report.get("improvement_plan") or [],
"evidence_summary": report.get("evidence_summary") or [],
"guideline_refs": report.get("guideline_refs") or [],
"overall_comment": report.get("overall_comment") or "",
"llm_model": report.get("_llm_model"),
"latency_metrics": report.get("_latency_metrics") or {},
}
return TrainingRecord(
training_mode=session.mode,
case_type=session.training_type,
start_time=start_time,
end_time=end_time,
duration_seconds=duration_seconds,
total_score=total_score,
ai_score=total_score,
teacher_score=None,
evaluation_level=self._evaluation_level(total_score, report.get("score_type", session.score_type)),
status="completed",
feedback=structured["overall_comment"],
thinking_chain=json.dumps(
{
"evidence_summary": structured["evidence_summary"],
"guideline_refs": structured["guideline_refs"],
"scoring_rule_count": len(scoring_rules),
},
ensure_ascii=False,
),
diagnosis_path=json.dumps(
{
"primary_diagnosis": submission.primary_diagnosis,
"differential_diagnoses": submission.differential_diagnoses or [],
"diagnosis_basis": submission.diagnosis_basis,
"standard_diagnosis": case.diagnosis_primary,
},
ensure_ascii=False,
),
wrong_points=structured["errors"],
missed_questions=[],
recommendation_result={"improvement_plan": structured["improvement_plan"]},
ai_feedback_structured=structured,
osce_station_score={},
interruption_count=0,
emotion_analysis={},
prompt_version="v1",
rag_context_version=self._rag_context_version(guideline_result),
case_id=case.id,
teacher_id=None,
user_id=self._numeric_user_id(ctx.user_id),
external_user_id=ctx.user_id,
session_id=session.id,
evaluation_record_id=None,
score_type=structured["score_type"],
pdf_file_path=None,
)
def _evaluation_level(self, score: float, score_type: str) -> str:
"""评价等级:根据百分制或五分制总分生成训练记录等级。"""
normalized = score * 20 if score_type == "five_point" else score
if normalized >= 90:
return "excellent"
if normalized >= 80:
return "good"
if normalized >= 60:
return "pass"
return "needs_improvement"
def _rag_context_version(self, guideline_result: dict) -> str:
"""RAG 版本:记录评分时是否命中指南片段。"""
matched = guideline_result.get("matched_chunks") or []
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。"""
return int(user_id) if str(user_id).isdigit() else None
def list_history(self, user_id: str) -> EvaluationListResponse:
"""历史评价:按外部 user_id 查询完整训练后的 training_record。"""
records = self.eval_repo.list_by_user(user_id)
return EvaluationListResponse(
items=[
EvaluationListItem(
evaluation_id=record.id,
case_title=self._case_title(record.case_id),
score_type=record.score_type,
total_score=float(record.total_score or 0),
created_at=record.created_at,
pdf_exported=bool(record.pdf_file_path),
)
for record in records
]
)
def get_detail(self, evaluation_id: int, user_id: str) -> EvaluationDetailResponse:
"""评价详情:按 user_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)
base = self._to_response(record)
return EvaluationDetailResponse(
**base.model_dump(),
session_id=record.session_id or 0,
case_id=record.case_id,
case_title=self._case_title(record.case_id),
created_at=record.created_at,
pdf_file_path=record.pdf_file_path,
)
def _to_response(self, record: TrainingRecord) -> EvaluationResponse:
"""评价转换:把 training_record 转换为接口响应结构。"""
structured = record.ai_feedback_structured or {}
dimension_scores = structured.get("dimension_scores") or []
return EvaluationResponse(
evaluation_id=record.id,
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],
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 [],
guideline_refs=structured.get("guideline_refs") or [],
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 _case_title(self, case_id: int | None) -> str:
"""病例标题:历史记录只保存 case_id,展示时按新病例主表读取标题。"""
if not case_id:
return ""
case = self.case_repo.get_active_case(case_id)
return case.title if case else ""