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
+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:
"""扣分问题分节:展示模型识别出的关键问题和严重程度。"""