精简后端功能模块并补充教学互动
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
@@ -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
@@ -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"])
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
本评价仅用于医学教学训练和学习反馈,不提供真实诊疗结论,不替代医生临床判断。
|
||||
@@ -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
|
||||
@@ -1,9 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class KnowledgeSearchResponse(BaseModel):
|
||||
"""知识检索响应:返回评分参考指南片段和来源。"""
|
||||
|
||||
matched_chunks: list[dict]
|
||||
source_refs: list[dict]
|
||||
no_match: bool
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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": ["人文沟通", "健康教育"],
|
||||
},
|
||||
]
|
||||
Reference in New Issue
Block a user