精简后端功能模块并补充教学互动

This commit is contained in:
刘金宝
2026-06-08 16:49:45 +08:00
parent 11b1712b01
commit f0cdc454b3
18 changed files with 1120 additions and 1194 deletions
+19
View File
@@ -57,6 +57,25 @@ class MedicalConsultationOrchestrator:
)
return self.report_agent.build_report(scoring_result)
async def evaluate_teaching(
self,
*,
case: CaseBase,
teaching_payload: dict,
scoring_rules: list,
guideline_refs: list[dict],
score_type: str,
) -> dict:
"""教学互动评价编排:调用 Scoring Agent 后复用 Report Agent 整理报告结构。"""
scoring_result = await self.scoring_agent.score_teaching(
case=case,
teaching_payload=teaching_payload,
scoring_rules=scoring_rules,
guideline_refs=guideline_refs,
score_type=score_type,
)
return self.report_agent.build_report(scoring_result)
async def generate_hints(
self,
session: TrainingSession,
+156
View File
@@ -58,6 +58,162 @@ class ScoringAgent:
}
return data
async def score_teaching(
self,
*,
case: CaseBase,
teaching_payload: dict,
scoring_rules: list,
guideline_refs: list[dict],
score_type: str,
) -> dict:
"""教学互动评价:根据题目、标准答案、学生作答和评分规则生成结构化评价。"""
start = time.perf_counter()
messages = self._build_teaching_messages(case, teaching_payload, scoring_rules, guideline_refs, score_type)
try:
response = await self.llm.chat(
messages,
settings.llm_fast_model,
thinking_enabled=settings.llm_fast_thinking_enabled,
reasoning_effort=None,
response_format={"type": "json_object"} if settings.llm_scoring_json_response else None,
max_tokens=min(settings.llm_scoring_max_tokens, 1600),
)
data = json.loads(response.content)
data = self._normalize_score_payload(data, score_type, guideline_refs)
data["_llm_model"] = response.model
data["_latency_metrics"] = {"scoring_latency_ms": response.latency_ms, "fallback_used": False}
return data
except (AppError, json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
logger.warning("teaching_scoring_agent.fallback case_id=%s error=%s", case.id, exc.__class__.__name__)
data = self._fallback_teaching_score(score_type, guideline_refs, teaching_payload)
data["_llm_model"] = f"local-fallback-{settings.llm_fast_model}"
data["_latency_metrics"] = {
"scoring_latency_ms": int((time.perf_counter() - start) * 1000),
"fallback_used": True,
"fallback_reason": exc.__class__.__name__,
}
return data
def _build_teaching_messages(
self,
case: CaseBase,
teaching_payload: dict,
scoring_rules: list,
guideline_refs: list[dict],
score_type: str,
) -> list[dict]:
"""教学评分提示词:只传教学互动评价需要的病例、题目、答案和评分规则。"""
payload = {
"score_type": score_type,
"case": {
"case_id": case.id,
"title": case.title,
"chief_complaint": case.chief_complaint,
"description": self._truncate(case.description, 320),
"knowledge_points": case.knowledge_points or [],
"key_points": case.key_points or [],
},
"teaching": teaching_payload,
"scoring_rules": self._compact_scoring_rules(scoring_rules),
"guidelines": self._compact_guidelines(guideline_refs),
}
system = (
"你是医学教学互动评价专家,只输出合法 JSON。"
"请根据病例、教学目标、选择题、标准答案、解析文本、学生作答和评分规则生成教学评价。"
"输出字段固定为 score_type,total_score,dimension_scores,errors,improvement_plan,"
"evidence_summary,guideline_refs,overall_comment,score_details。"
"dimension_scores 包含 知识掌握、临床推理、检查理解、治疗决策、人文沟通 维度,"
"每项包含 dimension,score,max_score,comment,evidence,deductions,improvement。"
"score_details 对应 scoring_rules 或题目维度,每项包含 rule_id,dimension,score,"
"deducted_reason,evidence_message_ids,ai_confidence,comment。"
"必须指出答对题目、答错题目、错误原因、下一步学习重点。"
"本评价仅用于医学教学训练,不替代真实临床诊疗。"
)
return [{"role": "system", "content": system}, {"role": "user", "content": json.dumps(payload, ensure_ascii=False)}]
def _fallback_teaching_score(self, score_type: str, guideline_refs: list[dict], teaching_payload: dict) -> dict:
"""教学评分兜底:LLM 不可用时按选择题正确率生成稳定评价结构。"""
results = teaching_payload.get("answer_results") or []
total = len(results)
correct = sum(1 for item in results if item.get("is_correct"))
accuracy = correct / total if total else 0
total_score = round(accuracy * 100, 1) if total else 0
incorrect = [item for item in results if not item.get("is_correct")]
incorrect_titles = [f"{item.get('question_id')}: {item.get('stem', '')}" for item in incorrect[:5]]
data = {
"score_type": "percentage",
"total_score": total_score,
"dimension_scores": [
{
"dimension": "知识掌握",
"score": round(total_score * 0.35, 1),
"max_score": 35,
"comment": f"{total} 题,答对 {correct} 题。",
"evidence": [f"正确率 {round(accuracy * 100, 1)}%"],
"deductions": incorrect_titles,
"improvement": "复习错题对应知识点和病例解析。",
},
{
"dimension": "临床推理",
"score": round(total_score * 0.25, 1),
"max_score": 25,
"comment": "根据选择题表现评估临床判断链路。",
"evidence": [item.get("stem", "") for item in results[:3]],
"deductions": incorrect_titles,
"improvement": "把题目选项与病例主诉、体征和检查结果逐项对应。",
},
{
"dimension": "检查理解",
"score": round(total_score * 0.15, 1),
"max_score": 15,
"comment": "重点关注检查项目与病情严重程度判断。",
"evidence": teaching_payload.get("scoring_focus", "").split("")[:3],
"deductions": [],
"improvement": "理解血氧、胸片和炎症指标在肺炎评估中的作用。",
},
{
"dimension": "治疗决策",
"score": round(total_score * 0.15, 1),
"max_score": 15,
"comment": "根据题目表现评估治疗原则掌握情况。",
"evidence": teaching_payload.get("teaching_goal", "").split("")[:3],
"deductions": [],
"improvement": "复习抗感染、平喘、氧合监测和风险预案。",
},
{
"dimension": "人文沟通",
"score": round(total_score * 0.10, 1),
"max_score": 10,
"comment": "教学互动中需继续强化家属沟通和健康教育。",
"evidence": ["教学互动题包含沟通与健康教育相关内容。"],
"deductions": [],
"improvement": "向家属说明病情、观察指标、复诊指征和用药注意事项。",
},
],
"score_details": [],
"errors": [
{
"title": "教学题目答题错误",
"description": "".join(incorrect_titles) if incorrect_titles else "暂无明显错题。",
"severity": "medium" if incorrect_titles else "low",
"related_dimension": "知识掌握",
}
],
"improvement_plan": [
"复盘错题解析,明确每个选项与病例证据的对应关系。",
"把病例中的主诉、体征、检查和治疗原则整理成一条临床推理链。",
"针对血氧、胸片、炎症指标和医患沟通进行专项复习。",
],
"evidence_summary": [
f"教学互动共提交 {total} 题,答对 {correct} 题。",
"评分依据包括题目标准答案、解析文本、教学目标和评分规则。",
],
"guideline_refs": guideline_refs,
"overall_comment": f"本次教学互动正确率为 {round(accuracy * 100, 1)}%,请结合错题解析继续巩固病例关键知识点。",
}
return self._convert_to_five_point(data) if score_type == "five_point" else data
def _build_messages(
self,
session: TrainingSession,
-24
View File
@@ -1,24 +0,0 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.core.response import ApiResponse, ok
from app.core.user_context import UserContext, get_user_context
from app.db.session import get_db
from app.schemas.knowledge import KnowledgeSearchResponse
from app.services.knowledge_service import KnowledgeService
router = APIRouter()
@router.get("/search", response_model=ApiResponse[KnowledgeSearchResponse])
def search_knowledge(
_: UserContext = Depends(get_user_context),
db: Session = Depends(get_db),
department_id: int = Query(...),
training_type: str = Query(...),
q: str = Query(default=""),
):
"""知识检索:按科室、训练类别和关键词检索评分参考指南。"""
keywords = [item.strip() for item in q.split(",") if item.strip()]
result = KnowledgeService(db).search_guidelines(department_id, training_type, keywords)
return ok(KnowledgeSearchResponse(**result))
-108
View File
@@ -1,108 +0,0 @@
import time
from fastapi import APIRouter, Depends
from app.agents.llm_adapter import OpenAICompatibleLLMClient
from app.core.config import settings
from app.core.exceptions import AppError
from app.core.response import ApiResponse, ok
from app.core.user_context import UserContext, get_user_context
from app.schemas.llm import LLMTestRequest, LLMTestResponse
router = APIRouter()
@router.post("/deepseek-fast", response_model=ApiResponse[LLMTestResponse])
async def test_deepseek_fast(
payload: LLMTestRequest,
_: UserContext = Depends(get_user_context),
):
"""Fast 模型测试:验证快速模型的非流式响应耗时。"""
client = OpenAICompatibleLLMClient()
response = await client.chat(
[{"role": "user", "content": payload.message}],
settings.llm_fast_model,
thinking_enabled=settings.llm_fast_thinking_enabled,
max_tokens=min(settings.llm_fast_max_tokens, 256),
)
return ok(
LLMTestResponse(
model=response.model,
total_latency_ms=response.latency_ms,
stream=False,
mock_mode=client.is_mock_mode,
fallback_used=response.model.startswith("mock-fallback"),
thinking_enabled=settings.llm_fast_thinking_enabled,
)
)
@router.post("/deepseek-reason", response_model=ApiResponse[LLMTestResponse])
async def test_deepseek_reason(
payload: LLMTestRequest,
_: UserContext = Depends(get_user_context),
):
"""Reason 模型测试:优先验证流式耗时,流式不兼容时降级为真实非流式测试。"""
client = OpenAICompatibleLLMClient()
messages = [{"role": "user", "content": payload.message}]
first_token_ms = None
start = time.perf_counter()
try:
async for chunk in client.stream_chat(
messages,
settings.llm_reason_model,
thinking_enabled=settings.llm_reason_thinking_enabled,
reasoning_effort=settings.llm_reasoning_effort if settings.llm_reason_thinking_enabled else None,
max_tokens=min(settings.llm_fast_max_tokens, 256),
):
if first_token_ms is None and chunk.first_token_ms is not None:
first_token_ms = chunk.first_token_ms
if chunk.done:
return ok(
LLMTestResponse(
model=chunk.model or (settings.llm_reason_model if not client.is_mock_mode else f"mock-{settings.llm_reason_model}"),
first_token_ms=first_token_ms,
total_latency_ms=chunk.total_latency_ms or int((time.perf_counter() - start) * 1000),
stream=True,
mock_mode=client.is_mock_mode,
fallback_used=chunk.fallback_used,
thinking_enabled=settings.llm_reason_thinking_enabled,
reasoning_effort=settings.llm_reasoning_effort if settings.llm_reason_thinking_enabled else None,
)
)
except AppError as exc:
if exc.code != "LLM_STREAM_FAILED":
raise
response = await client.chat(
messages,
settings.llm_reason_model,
thinking_enabled=settings.llm_reason_thinking_enabled,
reasoning_effort=settings.llm_reasoning_effort if settings.llm_reason_thinking_enabled else None,
max_tokens=min(settings.llm_fast_max_tokens, 256),
)
return ok(
LLMTestResponse(
model=response.model,
first_token_ms=None,
total_latency_ms=response.latency_ms,
stream=False,
mock_mode=client.is_mock_mode,
fallback_used=response.model.startswith("mock-fallback"),
thinking_enabled=settings.llm_reason_thinking_enabled,
reasoning_effort=settings.llm_reasoning_effort if settings.llm_reason_thinking_enabled else None,
)
)
return ok(
LLMTestResponse(
model=settings.llm_reason_model,
first_token_ms=first_token_ms,
total_latency_ms=int((time.perf_counter() - start) * 1000),
stream=True,
mock_mode=client.is_mock_mode,
fallback_used=False,
thinking_enabled=settings.llm_reason_thinking_enabled,
reasoning_effort=settings.llm_reasoning_effort if settings.llm_reason_thinking_enabled else None,
)
)
+2 -3
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api import agent, auth, cases, evaluations, knowledge, llm_test, sessions, training_config
from app.api import agent, auth, cases, evaluations, sessions, teaching, training_config
api_router = APIRouter()
api_router.include_router(agent.router, tags=["agent"])
@@ -8,6 +8,5 @@ api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(cases.router, prefix="/cases", tags=["cases"])
api_router.include_router(training_config.router, prefix="/training-config", tags=["training-config"])
api_router.include_router(sessions.router, prefix="/sessions", tags=["sessions"])
api_router.include_router(teaching.router, prefix="/teaching", tags=["teaching"])
api_router.include_router(evaluations.router, prefix="/evaluations", tags=["evaluations"])
api_router.include_router(knowledge.router, prefix="/knowledge", tags=["knowledge"])
api_router.include_router(llm_test.router, prefix="/llm/test", tags=["llm-test"])
+32
View File
@@ -0,0 +1,32 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.response import ApiResponse, ok
from app.core.user_context import UserContext, get_user_context
from app.db.session import get_db
from app.schemas.teaching import CreateTeachingEvaluationRequest, TeachingEvaluationResponse, TeachingItemsResponse
from app.services.teaching_service import TeachingService
router = APIRouter()
@router.get("/cases/{case_id}/items", response_model=ApiResponse[TeachingItemsResponse])
def get_teaching_items(
case_id: int,
ctx: UserContext = Depends(get_user_context),
db: Session = Depends(get_db),
):
"""教学列表:返回病例、题目、选项、答案、解析文本和教学视频。"""
return ok(TeachingService(db).list_items(ctx, case_id))
@router.post("/evaluation", response_model=ApiResponse[TeachingEvaluationResponse])
async def create_teaching_evaluation(
payload: CreateTeachingEvaluationRequest,
ctx: UserContext = Depends(get_user_context),
db: Session = Depends(get_db),
):
"""教学评价:根据教学互动作答生成 AI 评价并写入训练记录。"""
result = await TeachingService(db).create_evaluation(ctx, payload)
db.commit()
return ok(result)
+1 -1
View File
@@ -146,7 +146,7 @@ class Settings(BaseModel):
"stream_chat": self.llm_stream_enabled,
"score_types": ["percentage", "five_point"],
"pdf_export": True,
"knowledge_search": True,
"scoring_guideline_lookup": True,
"llm_mock_enabled": mock_enabled,
"llm_mode": "mock" if mock_enabled else "real",
"llm_fallback_to_mock": self.llm_fallback_to_mock,
@@ -1,37 +0,0 @@
---
template_code: guideline_search_query
agent_type: knowledge
version: v1
scene: guideline_search
model_type: fast
output_format: json
---
# Role
你是评分参考指南检索 Query Agent。
# Task
根据病例、训练类别、诊断和治疗任务生成知识库检索关键词。
# Inputs
- 病例科室。
- 主诉和关键症状。
- 训练类别。
- 用户提交的诊断和治疗方案。
# Rules
- 关键词必须来自病例和任务本身。
- 不生成与病例无关的疾病关键词。
- 控制关键词数量,便于 MySQL 文本检索。
# Output Format
输出合法 JSON`{"keywords": []}`
# Safety Boundaries
检索词仅用于教学评分参考,不用于真实临床检索决策。
@@ -0,0 +1,81 @@
---
template_code: scoring_teaching_interaction
agent_type: scoring
version: v1
scene: teaching_interaction
model_type: fast
output_format: json
---
# Role
你是医学教学互动评价专家,负责根据病例、教学题目、标准答案、解析文本、学生作答和评分规则生成教学训练评价。
# Task
对教学互动模式的选择题作答结果进行评分,指出学生对病例知识点、临床推理、检查理解、治疗决策和人文沟通的掌握情况。
# Inputs
- case_base 病例基础信息。
- teaching_case 教学目标、教师引导、评分重点。
- questions 题目、选项、标准答案、解析文本、视频资源。
- student_answers 学生作答。
- answer_results 后端计算的对错结果。
- scoring_rules 病例评分规则。
- guideline_refs 评分参考指南。
# Rules
- 只输出合法 JSON,不输出 Markdown。
- 必须指出答对题目、答错题目和错因。
- 不编造数据库中不存在的题目、答案、检查结果或视频。
- 评价仅用于医学教学训练,不替代真实临床诊疗。
- 评分要尽量复用 scoring_rules 的维度和权重。
# Output Format
```json
{
"score_type": "percentage",
"total_score": 85,
"dimension_scores": [
{
"dimension": "知识掌握",
"score": 30,
"max_score": 35,
"comment": "能够识别支气管肺炎核心诊断依据。",
"evidence": ["q2 选择正确"],
"deductions": ["q1 对严重程度指标理解不足"],
"improvement": "复习血氧、胸片和炎症指标的临床意义。"
}
],
"score_details": [
{
"rule_id": 1,
"dimension": "知识掌握",
"score": 30,
"deducted_reason": "严重程度判断题答错。",
"evidence_message_ids": ["q1", "q2"],
"ai_confidence": 0.86,
"comment": "基础诊断方向正确,严重程度评估需加强。"
}
],
"errors": [
{
"title": "严重程度评估不足",
"description": "未能优先识别血氧饱和度对病情判断的意义。",
"severity": "medium",
"related_dimension": "临床推理"
}
],
"improvement_plan": ["复习儿童肺炎严重程度评估。"],
"evidence_summary": ["共完成 5 题,答对 4 题。"],
"guideline_refs": [],
"overall_comment": "教学互动表现良好,需加强严重程度评估。"
}
```
# Safety Boundaries
本评价仅用于医学教学训练和学习反馈,不提供真实诊疗结论,不替代医生临床判断。
+25
View File
@@ -0,0 +1,25 @@
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.models.source_case import CaseBase
class TeachingRepository:
"""教学互动仓储:读取 case_base + teaching_case 以及评分相关扩展数据。"""
def __init__(self, db: Session) -> None:
self.db = db
def get_active_teaching_case(self, case_id: int) -> CaseBase | None:
"""教学病例读取:校验病例已发布、已启用且存在 teaching_case 扩展。"""
stmt = (
select(CaseBase)
.options(
selectinload(CaseBase.teaching_case),
selectinload(CaseBase.traditional_case),
selectinload(CaseBase.scoring_rules),
)
.where(CaseBase.id == case_id, CaseBase.status == 1, CaseBase.publish_status == 1)
)
case = self.db.scalar(stmt)
return case if case and case.teaching_case else None
-9
View File
@@ -1,9 +0,0 @@
from pydantic import BaseModel
class KnowledgeSearchResponse(BaseModel):
"""知识检索响应:返回评分参考指南片段和来源。"""
matched_chunks: list[dict]
source_refs: list[dict]
no_match: bool
-20
View File
@@ -1,20 +0,0 @@
from pydantic import BaseModel
class LLMTestRequest(BaseModel):
"""LLM 测试入参:用于快速模型和 reason 模型耗时验证。"""
message: str = "请用一句话说明医疗问诊训练 Demo 的用途。"
class LLMTestResponse(BaseModel):
"""LLM 测试响应:返回模型名、首 token 时间和总耗时。"""
model: str
first_token_ms: int | None = None
total_latency_ms: int
stream: bool
mock_mode: bool = False
fallback_used: bool = False
thinking_enabled: bool | None = None
reasoning_effort: str | None = None
+75
View File
@@ -0,0 +1,75 @@
from pydantic import BaseModel, Field
from app.schemas.evaluation import EvaluationResponse
class TeachingVideo(BaseModel):
"""教学视频:题目解析关联的视频资源。"""
title: str = ""
url: str = ""
class TeachingOption(BaseModel):
"""教学选项:单选题或多选题的选项结构。"""
key: str
text: str
class TeachingQuestion(BaseModel):
"""教学题目:从 teaching_case 解析出的互动题目。"""
question_id: str
question_type: str = "single_choice"
stem: str
options: list[TeachingOption] = Field(default_factory=list)
answer: str | list[str]
analysis: str = ""
video: TeachingVideo | None = None
knowledge_points: list[str] = Field(default_factory=list)
class TeachingCaseSummary(BaseModel):
"""教学病例摘要:教学互动页面展示的病例基础信息。"""
case_id: int
title: str
department_id: int | None = None
difficulty: str
chief_complaint: str
description: str
patient_age: int | None = None
patient_gender: str | None = None
knowledge_points: list[str] = Field(default_factory=list)
class TeachingItemsResponse(BaseModel):
"""教学列表响应:病例、教学目标、题目、答案、解析文本和视频。"""
case: TeachingCaseSummary
teaching_goal: str
teacher_guide: str
scoring_focus: str
questions: list[TeachingQuestion]
class TeachingAnswer(BaseModel):
"""教学作答:前端提交的单题选择结果。"""
question_id: str = Field(min_length=1, max_length=64)
selected_answer: str | list[str]
class CreateTeachingEvaluationRequest(BaseModel):
"""教学评价入参:提交教学互动题目作答并生成评价。"""
case_id: int
answers: list[TeachingAnswer] = Field(min_length=1)
score_type: str = Field(default="percentage", pattern="^(percentage|five_point)$")
class TeachingEvaluationResponse(EvaluationResponse):
"""教学评价响应:复用训练评价结构,并返回教学会话 ID。"""
session_id: int
+357
View File
@@ -0,0 +1,357 @@
from __future__ import annotations
import json
import uuid
from datetime import datetime
from typing import Any
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 import TrainingSession
from app.models.training_record import TrainingRecord
from app.repositories.evaluation_repository import EvaluationRepository
from app.repositories.source_case_repository import SourceCaseRepository
from app.repositories.teaching_repository import TeachingRepository
from app.schemas.teaching import (
CreateTeachingEvaluationRequest,
TeachingCaseSummary,
TeachingEvaluationResponse,
TeachingItemsResponse,
TeachingOption,
TeachingQuestion,
TeachingVideo,
)
from app.services.audit_service import AuditService
from app.services.evaluation_service import EvaluationService
from app.services.knowledge_service import KnowledgeService
class TeachingService:
"""教学互动服务:读取教学题目、提交作答并生成教学互动评价。"""
def __init__(self, db: Session) -> None:
self.db = db
self.repo = TeachingRepository(db)
self.source_repo = SourceCaseRepository(db)
self.eval_repo = EvaluationRepository(db)
self.audit = AuditService(db)
self.knowledge = KnowledgeService(db)
self.orchestrator = MedicalConsultationOrchestrator()
self.evaluation_service = EvaluationService(db)
def list_items(self, ctx: UserContext, case_id: int) -> TeachingItemsResponse:
"""教学列表:读取 case_base + teaching_case 并返回题目、选项、答案、解析和视频。"""
case = self.repo.get_active_teaching_case(case_id)
if not case:
raise AppError("TEACHING_CASE_NOT_FOUND", "teaching case not found or inactive", 404)
teaching = case.teaching_case
questions = self._parse_questions(case)
self.audit.log(ctx, "teaching.items", "case_base", str(case.id), None)
return TeachingItemsResponse(
case=TeachingCaseSummary(
case_id=case.id,
title=case.title,
department_id=case.department_id,
difficulty=case.difficulty,
chief_complaint=case.chief_complaint,
description=case.description,
patient_age=case.patient_age,
patient_gender=case.patient_gender,
knowledge_points=case.knowledge_points or [],
),
teaching_goal=teaching.teaching_goal,
teacher_guide=teaching.teacher_guide,
scoring_focus=teaching.scoring_focus,
questions=questions,
)
async def create_evaluation(self, ctx: UserContext, payload: CreateTeachingEvaluationRequest) -> TeachingEvaluationResponse:
"""教学评价:校验作答后调用 LLM 评分,并写入 training_record 与评分明细。"""
case = self.repo.get_active_teaching_case(payload.case_id)
if not case:
raise AppError("TEACHING_CASE_NOT_FOUND", "teaching case not found or inactive", 404)
teaching = case.teaching_case
questions = self._parse_questions(case)
if not questions:
raise AppError("TEACHING_QUESTION_EMPTY", "teaching questions are empty", 400)
answer_results = self._build_answer_results(questions, payload.answers)
session = self._create_teaching_session(ctx, case.id, payload.score_type, answer_results)
guideline_result = self.knowledge.search_guidelines(
case.department_id or 0,
case.case_type,
(case.knowledge_points or []) + (case.key_points or []),
)
scoring_rules = self.source_repo.get_scoring_rules(case.id)
teaching_payload = {
"teaching_goal": teaching.teaching_goal,
"teacher_guide": teaching.teacher_guide,
"scoring_focus": teaching.scoring_focus,
"questions": [item.model_dump() for item in questions],
"student_answers": [item.model_dump() for item in payload.answers],
"answer_results": answer_results,
"correct_count": sum(1 for item in answer_results if item["is_correct"]),
"total_count": len(answer_results),
}
report = await self.orchestrator.evaluate_teaching(
case=case,
teaching_payload=teaching_payload,
scoring_rules=scoring_rules,
guideline_refs=guideline_result["source_refs"],
score_type=payload.score_type,
)
record = self._build_training_record(ctx, session, case, teaching_payload, report, scoring_rules, guideline_result)
self.eval_repo.create_record(record)
score_details = self.evaluation_service._build_score_details(record.id, report, scoring_rules)
self.eval_repo.replace_score_details(record.id, score_details)
self.audit.log(ctx, "teaching.evaluation.generate", "training_record", str(record.id), session.id)
response = self.evaluation_service._to_response(record)
return TeachingEvaluationResponse(session_id=session.id, **response.model_dump())
def _create_teaching_session(self, ctx: UserContext, case_id: int, score_type: str, answer_results: list[dict]) -> TrainingSession:
"""教学会话:创建轻量 session 以复用评价详情、历史记录和 PDF 能力。"""
now = datetime.utcnow()
session_code = f"teach_{now.strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:8]}"
session = TrainingSession(
session_code=session_code,
user_id=ctx.user_id,
tenant_id=ctx.tenant_id,
class_id=ctx.class_id,
entry_scene=ctx.entry_scene,
case_id=case_id,
training_type="teaching_interaction",
mode="teaching",
score_type=score_type,
status="completed",
started_at=now,
completed_at=now,
memory_key=None,
metadata_={"source": "teaching_interaction", "answer_results": answer_results},
)
self.db.add(session)
self.db.flush()
return session
def _build_training_record(
self,
ctx: UserContext,
session: TrainingSession,
case,
teaching_payload: dict,
report: dict,
scoring_rules: list,
guideline_result: dict,
) -> TrainingRecord:
"""教学记录:把教学互动评价沉淀为 training_record。"""
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 [],
"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 [],
"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 {},
"teaching_summary": {
"correct_count": teaching_payload.get("correct_count", 0),
"total_count": teaching_payload.get("total_count", 0),
},
}
return TrainingRecord(
training_mode="teaching",
case_type="teaching_interaction",
start_time=session.started_at or datetime.utcnow(),
end_time=session.completed_at or datetime.utcnow(),
duration_seconds=0,
total_score=total_score,
ai_score=total_score,
teacher_score=None,
evaluation_level=self.evaluation_service._evaluation_level(total_score, structured["score_type"]),
status="completed",
feedback=structured["overall_comment"],
thinking_chain=json.dumps(
{
"teaching_goal": teaching_payload.get("teaching_goal", ""),
"scoring_focus": teaching_payload.get("scoring_focus", ""),
"answer_results": teaching_payload.get("answer_results", []),
"scoring_rule_count": len(scoring_rules),
"guideline_refs": structured["guideline_refs"],
},
ensure_ascii=False,
),
diagnosis_path=json.dumps(
{
"case_title": case.title,
"question_count": teaching_payload.get("total_count", 0),
"correct_count": teaching_payload.get("correct_count", 0),
},
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="teaching_interaction_v1",
rag_context_version=self.evaluation_service._rag_context_version(guideline_result),
case_id=case.id,
teacher_id=None,
user_id=self.evaluation_service._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 _build_answer_results(self, questions: list[TeachingQuestion], answers: list) -> list[dict]:
"""作答校验:按 question_id 对比标准答案并生成评分证据。"""
question_map = {item.question_id: item for item in questions}
results: list[dict] = []
for answer in answers:
question = question_map.get(answer.question_id)
if not question:
raise AppError("TEACHING_QUESTION_NOT_FOUND", f"question {answer.question_id} not found", 404)
selected = self._normalize_answer(answer.selected_answer)
expected = self._normalize_answer(question.answer)
results.append(
{
"question_id": question.question_id,
"stem": question.stem,
"selected_answer": selected,
"correct_answer": expected,
"is_correct": selected == expected,
"analysis": question.analysis,
"knowledge_points": question.knowledge_points,
}
)
return results
def _parse_questions(self, case) -> list[TeachingQuestion]:
"""题目解析:从 teaching_case.discussion_questions 解析 JSON,失败时返回病例默认题目。"""
raw = case.teaching_case.discussion_questions if case.teaching_case else ""
payload: Any
try:
payload = json.loads(raw)
except (TypeError, json.JSONDecodeError):
payload = self._fallback_questions(case)
if isinstance(payload, dict):
payload = payload.get("questions") or []
if not isinstance(payload, list) or not payload:
payload = self._fallback_questions(case)
return [self._normalize_question(item, index) for index, item in enumerate(payload, start=1)]
def _normalize_question(self, item: dict, index: int) -> TeachingQuestion:
"""题目结构归一:补齐选项、答案、解析、视频等字段。"""
options = item.get("options") or []
normalized_options = [
TeachingOption(key=str(option.get("key") or ""), text=str(option.get("text") or ""))
for option in options
if isinstance(option, dict)
]
video = item.get("video")
return TeachingQuestion(
question_id=str(item.get("question_id") or f"q{index}"),
question_type=str(item.get("question_type") or "single_choice"),
stem=str(item.get("stem") or item.get("question") or ""),
options=normalized_options,
answer=item.get("answer") or "",
analysis=str(item.get("analysis") or ""),
video=TeachingVideo(**video) if isinstance(video, dict) else None,
knowledge_points=[str(value) for value in (item.get("knowledge_points") or [])],
)
def _normalize_answer(self, value: str | list[str]) -> list[str]:
"""答案归一:把单选和多选统一为排序后的大写字符串数组。"""
raw_values = value if isinstance(value, list) else [value]
return sorted(str(item).strip().upper() for item in raw_values if str(item).strip())
def _fallback_questions(self, case) -> list[dict]:
"""默认题目:当数据库暂未维护结构化题库时,为儿科肺炎 demo 生成可测试题目。"""
video = {"title": "儿童肺炎教学示例视频", "url": ""}
return [
{
"question_id": "q1",
"question_type": "single_choice",
"stem": "该患儿最需要优先关注的病情严重程度指标是?",
"options": [
{"key": "A", "text": "体温峰值"},
{"key": "B", "text": "血氧饱和度"},
{"key": "C", "text": "咳嗽天数"},
{"key": "D", "text": "食欲下降"},
],
"answer": "B",
"analysis": "血氧饱和度能帮助判断低氧和肺炎严重程度,是处置决策的重要依据。",
"video": video,
"knowledge_points": ["严重程度评估", "血氧判断"],
},
{
"question_id": "q2",
"question_type": "single_choice",
"stem": "结合发热、咳嗽、喘息和肺部湿啰音,最符合的诊断方向是?",
"options": [
{"key": "A", "text": "支气管肺炎"},
{"key": "B", "text": "急性胃肠炎"},
{"key": "C", "text": "泌尿系感染"},
{"key": "D", "text": "单纯过敏性鼻炎"},
],
"answer": "A",
"analysis": "呼吸道症状、肺部体征和影像/炎症指标共同支持儿童支气管肺炎。",
"video": video,
"knowledge_points": ["诊断依据", "肺部体征"],
},
{
"question_id": "q3",
"question_type": "single_choice",
"stem": "下列哪组检查最有助于完善本例肺炎诊断和严重程度评估?",
"options": [
{"key": "A", "text": "血常规、CRP、胸片、血氧饱和度"},
{"key": "B", "text": "肝功能、甲状腺功能、腹部超声"},
{"key": "C", "text": "胃镜、幽门螺杆菌、粪便常规"},
{"key": "D", "text": "骨龄片、维生素D、微量元素"},
],
"answer": "A",
"analysis": "炎症指标、胸部影像和血氧情况可共同支撑诊断和严重程度判断。",
"video": video,
"knowledge_points": ["检查选择", "辅助检查"],
},
{
"question_id": "q4",
"question_type": "single_choice",
"stem": "治疗方案中最需要覆盖的核心原则是?",
"options": [
{"key": "A", "text": "抗感染、止咳平喘、改善氧合、严密观察"},
{"key": "B", "text": "立即长期激素维持治疗"},
{"key": "C", "text": "只需补充维生素"},
{"key": "D", "text": "无需随访观察"},
],
"answer": "A",
"analysis": "儿童肺炎处置需围绕抗感染、呼吸症状缓解、氧合监测和病情变化预案展开。",
"video": video,
"knowledge_points": ["治疗原则", "风险预案"],
},
{
"question_id": "q5",
"question_type": "single_choice",
"stem": "向家属沟通时,最合适的内容是?",
"options": [
{"key": "A", "text": "说明病情、观察指标、用药注意事项和复诊/住院指征"},
{"key": "B", "text": "只告知已经开药即可"},
{"key": "C", "text": "不需要解释检查结果"},
{"key": "D", "text": "避免回答家属担心的问题"},
],
"answer": "A",
"analysis": "儿科场景需要重视家属知情、风险信号识别和家庭护理教育。",
"video": video,
"knowledge_points": ["人文沟通", "健康教育"],
},
]