精简后端功能模块并补充教学互动
This commit is contained in:
@@ -1,34 +1,64 @@
|
||||
# 医疗问诊 Agent FastAPI 后端
|
||||
|
||||
医疗问诊 Agent 是医疗教学平台中的问诊训练服务。后端负责 Django 用户身份校验、病例读取、多轮问诊、检查申请、诊断治疗提交、AI 评价、评分明细、PDF 报告和历史训练记录。
|
||||
医疗问诊 Agent 是医疗教学平台中的训练服务。后端负责 Django 用户鉴权、病例读取、训练会话、流式问诊、练习提示、检查结果、诊断治疗提交、AI 评价、教学互动评价、训练记录和 PDF 下载。
|
||||
|
||||
病例库在本服务中为只读数据源。病例新增、解析、修改和删除由外部病例管理系统负责;本服务只读取已发布病例及其训练扩展、检查项和评分规则。
|
||||
病例新增、病例解析、病例导入和病例删除不在本服务中实现。本服务只读取数据库中已经维护好的病例、检查项、教学题和评分规则。
|
||||
|
||||
## 当前保留功能
|
||||
|
||||
训练页面:
|
||||
|
||||
- 推荐配置信息
|
||||
- 训练配置信息
|
||||
- 新建会话
|
||||
- 流式会话
|
||||
- 王主任练习提示
|
||||
- 体格检查列表获取
|
||||
- 辅助检查列表获取
|
||||
- 体格检查某项结果
|
||||
- 辅助检查某项结果
|
||||
- 完成问诊
|
||||
- 提交诊断
|
||||
- 提交治疗
|
||||
- 生成评价
|
||||
- 获取评价详情
|
||||
- 下载 PDF
|
||||
|
||||
教学互动:
|
||||
|
||||
- 获取教学列表,包含题目、选项、答案、解析文本和视频
|
||||
- 生成评价
|
||||
- 获取评价详情
|
||||
- 下载 PDF
|
||||
|
||||
个人中心:
|
||||
|
||||
- 训练记录列表
|
||||
- 训练记录详情
|
||||
|
||||
基础能力:
|
||||
|
||||
- Django access token 鉴权
|
||||
- MySQL 数据读取和训练记录写入
|
||||
- Redis 短期会话 memory
|
||||
- OpenAI-compatible LLM 调用
|
||||
- Swagger / OpenAPI
|
||||
- 健康检查
|
||||
|
||||
## 项目结构
|
||||
|
||||
仓库根目录可以直接部署为服务器的 `fastapi/` 目录:
|
||||
|
||||
```text
|
||||
fastapi/
|
||||
├── app/ # FastAPI 应用、Agent、服务、模型与提示词
|
||||
├── scripts/ # 数据库迁移、结构检查与运维脚本
|
||||
├── tests/ # 核心逻辑与接口测试
|
||||
├── app/ # FastAPI 应用、Agent、服务、模型和提示词
|
||||
├── scripts/ # 数据库初始化和检查脚本
|
||||
├── tests/ # 当前功能测试
|
||||
├── docs/03_api_design.md # 前端联调 API 文档
|
||||
├── Dockerfile
|
||||
├── requirements.txt
|
||||
├── .env.example
|
||||
└── .env.production.example
|
||||
```
|
||||
|
||||
## 核心依赖
|
||||
|
||||
- Python 3.11
|
||||
- FastAPI
|
||||
- SQLAlchemy 2.x
|
||||
- MySQL 8
|
||||
- Redis 7
|
||||
- OpenAI-compatible LLM API
|
||||
- Django 用户中心 `/api/user/users/me/`
|
||||
|
||||
## 本地启动
|
||||
|
||||
```powershell
|
||||
@@ -47,14 +77,14 @@ http://127.0.0.1:9000/docs
|
||||
|
||||
真实密码、LLM Key 和 access token 只写入本地 `.env` 或服务器环境变量,不提交到 Git。
|
||||
|
||||
## Docker Compose 部署
|
||||
## 服务器部署
|
||||
|
||||
服务器目录:
|
||||
服务器目录示例:
|
||||
|
||||
```text
|
||||
/home/code/medical-ai/
|
||||
├── django/
|
||||
├── fastapi/ # 本仓库
|
||||
├── fastapi/
|
||||
├── vueapp/
|
||||
├── vuecms/
|
||||
└── docker-compose.yml
|
||||
@@ -69,41 +99,28 @@ cp fastapi/.env.production.example fastapi/.env
|
||||
vi fastapi/.env
|
||||
```
|
||||
|
||||
必须在服务器 `.env` 中填写:
|
||||
服务器 `.env` 至少配置:
|
||||
|
||||
- MySQL 密码和数据库名
|
||||
- `LLM_API_KEY`
|
||||
- 实际前端来源 `CORS_ALLOW_ORIGINS`
|
||||
- Nginx 使用 `/fastapi/` 前缀时保留 `APP_ROOT_PATH=/fastapi`
|
||||
|
||||
父目录 `docker-compose.yml` 的 FastAPI 服务需要包含:
|
||||
|
||||
```yaml
|
||||
fastapi:
|
||||
build:
|
||||
context: ./fastapi
|
||||
container_name: fastapi
|
||||
restart: always
|
||||
ports:
|
||||
- "9000:9000"
|
||||
env_file:
|
||||
- ./fastapi/.env
|
||||
volumes:
|
||||
- ./logs/fastapi:/app/logs
|
||||
- ./data/fastapi-reports:/app/storage/reports
|
||||
depends_on:
|
||||
- mysql
|
||||
- redis
|
||||
- django
|
||||
networks:
|
||||
- medical
|
||||
```env
|
||||
APP_ENV=production
|
||||
APP_ROOT_PATH=/fastapi
|
||||
DATABASE_URL=mysql+pymysql://root:1822..@mysql:3306/medical?charset=utf8mb4
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
RUNTIME_MEMORY_BACKEND=redis
|
||||
AUTH_VALIDATE_ENABLED=true
|
||||
AUTH_USER_ME_URL=http://django:8000/api/user/users/me/
|
||||
LLM_BASE_URL=<模型服务地址>
|
||||
LLM_API_KEY=<模型密钥>
|
||||
LLM_MODEL=<模型名称>
|
||||
LLM_FAST_MODEL=<模型名称>
|
||||
LLM_REASON_MODEL=<模型名称>
|
||||
CORS_ALLOW_ORIGINS=http://8.160.178.88
|
||||
```
|
||||
|
||||
构建并启动:
|
||||
|
||||
```bash
|
||||
cd /home/code/medical-ai
|
||||
docker compose config
|
||||
docker compose build fastapi
|
||||
docker compose up -d fastapi
|
||||
docker compose logs --tail=200 fastapi
|
||||
@@ -119,35 +136,9 @@ docker compose build fastapi
|
||||
docker compose up -d fastapi
|
||||
```
|
||||
|
||||
## 数据库初始化与检查
|
||||
## 验证
|
||||
|
||||
服务启动后先进行只读结构检查:
|
||||
|
||||
```bash
|
||||
cd /home/code/medical-ai
|
||||
docker compose exec fastapi python scripts/check_final_schema.py
|
||||
docker compose exec fastapi python scripts/check_final_demo_readiness.py
|
||||
```
|
||||
|
||||
以下迁移脚本只用于独立本地开发库或旧环境升级,不得用于共享生产病例库:
|
||||
|
||||
```bash
|
||||
docker compose exec fastapi python scripts/migrate_to_new_schema.py
|
||||
docker compose exec fastapi python scripts/migrate_user_department_score_detail.py
|
||||
```
|
||||
|
||||
迁移脚本使用 `create_all` 补齐 Agent 所需表,不删除 Django 或现有业务表;`migrate_to_new_schema.py` 会写入 Demo 病例和基础数据。共享环境中的病例数据由外部病例管理系统维护。
|
||||
|
||||
## 部署验证
|
||||
|
||||
容器内部端口验证:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:9000/health/live
|
||||
curl http://127.0.0.1:9000/health/ready
|
||||
```
|
||||
|
||||
使用 Nginx `/fastapi/` 代理后的公网验证:
|
||||
公网验证:
|
||||
|
||||
```text
|
||||
http://8.160.178.88/fastapi/docs
|
||||
@@ -155,9 +146,7 @@ http://8.160.178.88/fastapi/openapi.json
|
||||
http://8.160.178.88/fastapi/health/ready
|
||||
```
|
||||
|
||||
`/health/live` 返回 200 表示 FastAPI 进程正常。`/health/ready` 返回 200 表示 MySQL、Redis 和关键配置已经就绪;返回 503 时查看响应中的检查项和容器日志。
|
||||
|
||||
验证 Django 用户中心联调:
|
||||
Django 用户鉴权验证:
|
||||
|
||||
```bash
|
||||
curl "http://8.160.178.88/fastapi/api/v1/auth/me" \
|
||||
@@ -165,7 +154,7 @@ curl "http://8.160.178.88/fastapi/api/v1/auth/me" \
|
||||
-H "X-Entry-Scene: production_vue"
|
||||
```
|
||||
|
||||
验证 PDF 文件流下载:
|
||||
PDF 下载验证:
|
||||
|
||||
```bash
|
||||
curl -L "http://8.160.178.88/fastapi/api/v1/evaluations/<evaluation_id>/download-pdf" \
|
||||
@@ -183,12 +172,22 @@ python tests\test_api_contract.py
|
||||
python tests\test_demo_flow.py
|
||||
```
|
||||
|
||||
当前测试覆盖训练页主要链路:
|
||||
测试覆盖:
|
||||
|
||||
- Django token 鉴权与 user_id 隔离。
|
||||
- 新建会话、流式问诊、练习提示。
|
||||
- 体格检查列表、辅助检查列表、单项检查结果和重复申请幂等。
|
||||
- 完成问诊、提交诊断、提交治疗、生成评价。
|
||||
- 评价详情、历史评价、PDF 路径导出、PDF 文件流下载和跨用户访问拒绝。
|
||||
- Django token 鉴权和 user_id 隔离
|
||||
- 推荐配置和训练配置
|
||||
- 新建会话、流式问诊、王主任练习提示
|
||||
- 体格检查和辅助检查列表
|
||||
- 单项检查结果和重复申请幂等
|
||||
- 完成问诊、提交诊断、提交治疗、生成评价
|
||||
- 教学互动列表和教学互动评价
|
||||
- 训练记录列表、评价详情、PDF 下载
|
||||
- 跨用户访问拦截
|
||||
|
||||
病例新增、解析、导入、删除不在本 FastAPI 服务中实现;本服务只读取数据库中已发布病例、检查项和评分规则。
|
||||
## API 文档
|
||||
|
||||
前端联调文档见:
|
||||
|
||||
```text
|
||||
docs/03_api_design.md
|
||||
```
|
||||
|
||||
@@ -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": ["人文沟通", "健康教育"],
|
||||
},
|
||||
]
|
||||
+149
-888
File diff suppressed because it is too large
Load Diff
+96
-2
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
@@ -116,19 +117,111 @@ def _seed_traditional_case(db, case_id: int) -> None:
|
||||
|
||||
def _seed_teaching_case(db, case_id: int) -> None:
|
||||
"""教学互动病例种子:教学互动模式读取 case_base + teaching_case。"""
|
||||
if db.scalar(select(TeachingCase).where(TeachingCase.case_id == case_id)):
|
||||
existing = db.scalar(select(TeachingCase).where(TeachingCase.case_id == case_id))
|
||||
if existing:
|
||||
try:
|
||||
json.loads(existing.discussion_questions)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
existing.discussion_questions = _demo_teaching_questions_json()
|
||||
return
|
||||
questions_json = _demo_teaching_questions_json()
|
||||
case = db.get(CaseBase, case_id)
|
||||
if case and not case.multimodal_assets:
|
||||
case.multimodal_assets = [{"type": "video", "title": "儿童肺炎教学示例视频", "url": ""}]
|
||||
db.add(
|
||||
TeachingCase(
|
||||
case_id=case_id,
|
||||
teaching_goal="围绕儿科肺炎问诊、检查选择、诊断依据、治疗决策和医患沟通完成互动训练。",
|
||||
discussion_questions="如何判断病情严重程度?哪些检查是关键检查?治疗方案如何兼顾抗感染、平喘和氧合监测?",
|
||||
discussion_questions=questions_json,
|
||||
teacher_guide="观察学生是否完整追问发热、咳嗽、喘息、既往史、接触史,并能解释胸片、炎症指标和血氧。",
|
||||
scoring_focus="问诊完整性、检查合理性、诊断准确性、治疗计划、风险预案、人文沟通。",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _demo_teaching_questions_json() -> str:
|
||||
"""教学题库种子:为儿科支气管肺炎病例生成可测试的选择题、答案、解析和视频字段。"""
|
||||
video = {"title": "儿童肺炎教学示例视频", "url": ""}
|
||||
questions = [
|
||||
{
|
||||
"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": ["人文沟通", "健康教育"],
|
||||
},
|
||||
]
|
||||
return json.dumps(questions, ensure_ascii=False)
|
||||
|
||||
|
||||
def _seed_exam_items(db, case_id: int) -> None:
|
||||
"""检查项目种子:写入病例可申请检查和固定返回结果。"""
|
||||
if db.scalar(select(CaseExamItem).where(CaseExamItem.case_id == case_id)):
|
||||
@@ -265,6 +358,7 @@ def _seed_prompts(db) -> None:
|
||||
("patient_teaching", "patient", "teaching", "v1", "fast", "text", "app/prompts/patient/teaching.md"),
|
||||
("novice_case_hint", "hint", "novice", "v1", "fast", "json", "app/prompts/hint/novice_case_hint.md"),
|
||||
("scoring_pediatrics_pneumonia", "scoring", "pediatrics_pneumonia", "v1", "fast", "json", "app/prompts/scoring/pediatrics_pneumonia.md"),
|
||||
("scoring_teaching_interaction", "scoring", "teaching_interaction", "v1", "fast", "json", "app/prompts/scoring/teaching_interaction_evaluation.md"),
|
||||
("report_evaluation", "report", "evaluation", "v1", "fast", "json", "app/prompts/report/evaluation_report.md"),
|
||||
]
|
||||
for code, agent_type, scene, version, model_type, output_format, file_path in templates:
|
||||
|
||||
+41
-15
@@ -86,9 +86,6 @@ def run_api_contract_tests() -> None:
|
||||
auth_me_operation = openapi_payload["paths"]["/api/v1/auth/me"]["get"]
|
||||
assert any("HTTPBearer" in item for item in auth_me_operation.get("security", []))
|
||||
assert "HTTPBearer" in openapi_payload["components"]["securitySchemes"]
|
||||
assert "/api/v1/imports/case-sql/preview" not in openapi_payload["paths"]
|
||||
assert "/api/v1/imports/case-sql/apply" not in openapi_payload["paths"]
|
||||
assert "/api/v1/cases/{case_id}/delete-preview" not in openapi_payload["paths"]
|
||||
assert "delete" not in openapi_payload["paths"]["/api/v1/cases/{case_id}"]
|
||||
assert "/api/v1/training-config/recommended" in openapi_payload["paths"]
|
||||
assert "/api/v1/training-config/options" in openapi_payload["paths"]
|
||||
@@ -98,11 +95,52 @@ def run_api_contract_tests() -> None:
|
||||
assert "/api/v1/sessions/{session_id}/physical-exams/{item_code}" in openapi_payload["paths"]
|
||||
assert "/api/v1/sessions/{session_id}/auxiliary-exams/{item_code}" in openapi_payload["paths"]
|
||||
assert "/api/v1/evaluations/{evaluation_id}/download-pdf" in openapi_payload["paths"]
|
||||
assert "/api/v1/teaching/cases/{case_id}/items" in openapi_payload["paths"]
|
||||
assert "/api/v1/teaching/evaluation" in openapi_payload["paths"]
|
||||
|
||||
cases = client.get("/api/v1/cases", headers=headers)
|
||||
assert cases.status_code == 200
|
||||
case_id = cases.json()["data"]["items"][0]["id"]
|
||||
|
||||
teaching_items = client.get(f"/api/v1/teaching/cases/{case_id}/items", headers=headers)
|
||||
assert teaching_items.status_code == 200
|
||||
teaching_data = teaching_items.json()["data"]
|
||||
assert teaching_data["case"]["case_id"] == case_id
|
||||
assert teaching_data["questions"]
|
||||
assert teaching_data["questions"][0]["options"]
|
||||
assert teaching_data["questions"][0]["answer"]
|
||||
assert "analysis" in teaching_data["questions"][0]
|
||||
|
||||
teaching_answers = [
|
||||
{"question_id": item["question_id"], "selected_answer": item["answer"]}
|
||||
for item in teaching_data["questions"]
|
||||
]
|
||||
teaching_evaluation = client.post(
|
||||
"/api/v1/teaching/evaluation",
|
||||
headers=headers,
|
||||
json={"case_id": case_id, "answers": teaching_answers, "score_type": "percentage"},
|
||||
)
|
||||
assert teaching_evaluation.status_code == 200
|
||||
teaching_evaluation_id = teaching_evaluation.json()["data"]["evaluation_id"]
|
||||
assert teaching_evaluation.json()["data"]["session_id"]
|
||||
assert teaching_evaluation.json()["data"]["score_details"]
|
||||
|
||||
teaching_detail = client.get(f"/api/v1/evaluations/{teaching_evaluation_id}", headers=headers)
|
||||
assert teaching_detail.status_code == 200
|
||||
assert teaching_detail.json()["data"]["evaluation_id"] == teaching_evaluation_id
|
||||
|
||||
teaching_pdf_download = client.get(f"/api/v1/evaluations/{teaching_evaluation_id}/download-pdf", headers=headers)
|
||||
assert teaching_pdf_download.status_code == 200
|
||||
assert teaching_pdf_download.headers["content-type"].startswith("application/pdf")
|
||||
assert teaching_pdf_download.content.startswith(b"%PDF")
|
||||
|
||||
cross_user_teaching_detail = client.get(
|
||||
f"/api/v1/evaluations/{teaching_evaluation_id}",
|
||||
headers={"Authorization": "Bearer api_user_002_token", "X-Entry-Scene": "api_test"},
|
||||
)
|
||||
assert cross_user_teaching_detail.status_code == 404
|
||||
assert cross_user_teaching_detail.json()["code"] == "EVALUATION_NOT_FOUND"
|
||||
|
||||
recommended_config = client.get(f"/api/v1/training-config/recommended?case_id={case_id}", headers=headers)
|
||||
assert recommended_config.status_code == 200
|
||||
assert recommended_config.json()["data"]["recommended"]["visit_environment"] == "outpatient"
|
||||
@@ -320,18 +358,6 @@ def run_api_contract_tests() -> None:
|
||||
assert teaching_hint.status_code == 400
|
||||
assert teaching_hint.json()["code"] == "SESSION_STATUS_INVALID"
|
||||
|
||||
llm_fast = client.post("/api/v1/llm/test/deepseek-fast", headers=headers, json={"message": "hello"})
|
||||
assert llm_fast.status_code == 200
|
||||
assert llm_fast.json()["code"] == "OK"
|
||||
assert llm_fast.json()["data"]["stream"] is False
|
||||
|
||||
llm_reason = client.post("/api/v1/llm/test/deepseek-reason", headers=headers, json={"message": "hello"})
|
||||
assert llm_reason.status_code == 200
|
||||
assert llm_reason.json()["code"] == "OK"
|
||||
assert "total_latency_ms" in llm_reason.json()["data"]
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_api_contract_tests()
|
||||
print("api contract tests passed")
|
||||
|
||||
Reference in New Issue
Block a user