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

257 lines
12 KiB
Python

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 ""