From f0cdc454b3497c018d48f1235f2653b723ada5bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E9=87=91=E5=AE=9D?= Date: Mon, 8 Jun 2026 16:49:45 +0800 Subject: [PATCH] =?UTF-8?q?=E7=B2=BE=E7=AE=80=E5=90=8E=E7=AB=AF=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E6=A8=A1=E5=9D=97=E5=B9=B6=E8=A1=A5=E5=85=85=E6=95=99?= =?UTF-8?q?=E5=AD=A6=E4=BA=92=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 173 ++- app/agents/orchestrator.py | 19 + app/agents/scoring_agent.py | 156 +++ app/api/knowledge.py | 24 - app/api/llm_test.py | 108 -- app/api/router.py | 5 +- app/api/teaching.py | 32 + app/core/config.py | 2 +- .../knowledge/guideline_search_query.md | 37 - .../teaching_interaction_evaluation.md | 81 ++ app/repositories/teaching_repository.py | 25 + app/schemas/knowledge.py | 9 - app/schemas/llm.py | 20 - app/schemas/teaching.py | 75 ++ app/services/teaching_service.py | 357 ++++++ docs/03_api_design.md | 1037 +++-------------- scripts/init_demo_db.py | 98 +- tests/test_api_contract.py | 56 +- 18 files changed, 1120 insertions(+), 1194 deletions(-) delete mode 100644 app/api/knowledge.py delete mode 100644 app/api/llm_test.py create mode 100644 app/api/teaching.py delete mode 100644 app/prompts/knowledge/guideline_search_query.md create mode 100644 app/prompts/scoring/teaching_interaction_evaluation.md create mode 100644 app/repositories/teaching_repository.py delete mode 100644 app/schemas/knowledge.py delete mode 100644 app/schemas/llm.py create mode 100644 app/schemas/teaching.py create mode 100644 app/services/teaching_service.py diff --git a/README.md b/README.md index f4931ba..fb46a24 100644 --- a/README.md +++ b/README.md @@ -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//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 +``` diff --git a/app/agents/orchestrator.py b/app/agents/orchestrator.py index 9df1e36..4a61cbf 100644 --- a/app/agents/orchestrator.py +++ b/app/agents/orchestrator.py @@ -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, diff --git a/app/agents/scoring_agent.py b/app/agents/scoring_agent.py index e9d1335..a336e6c 100644 --- a/app/agents/scoring_agent.py +++ b/app/agents/scoring_agent.py @@ -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, diff --git a/app/api/knowledge.py b/app/api/knowledge.py deleted file mode 100644 index 90771ce..0000000 --- a/app/api/knowledge.py +++ /dev/null @@ -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)) diff --git a/app/api/llm_test.py b/app/api/llm_test.py deleted file mode 100644 index 710fd67..0000000 --- a/app/api/llm_test.py +++ /dev/null @@ -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, - ) - ) diff --git a/app/api/router.py b/app/api/router.py index f9ed745..e3ce21c 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -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"]) diff --git a/app/api/teaching.py b/app/api/teaching.py new file mode 100644 index 0000000..18b3874 --- /dev/null +++ b/app/api/teaching.py @@ -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) diff --git a/app/core/config.py b/app/core/config.py index cb8d21e..174dbd9 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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, diff --git a/app/prompts/knowledge/guideline_search_query.md b/app/prompts/knowledge/guideline_search_query.md deleted file mode 100644 index 4f8fe96..0000000 --- a/app/prompts/knowledge/guideline_search_query.md +++ /dev/null @@ -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 - -检索词仅用于教学评分参考,不用于真实临床检索决策。 diff --git a/app/prompts/scoring/teaching_interaction_evaluation.md b/app/prompts/scoring/teaching_interaction_evaluation.md new file mode 100644 index 0000000..e1b2132 --- /dev/null +++ b/app/prompts/scoring/teaching_interaction_evaluation.md @@ -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 + +本评价仅用于医学教学训练和学习反馈,不提供真实诊疗结论,不替代医生临床判断。 diff --git a/app/repositories/teaching_repository.py b/app/repositories/teaching_repository.py new file mode 100644 index 0000000..1706b42 --- /dev/null +++ b/app/repositories/teaching_repository.py @@ -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 diff --git a/app/schemas/knowledge.py b/app/schemas/knowledge.py deleted file mode 100644 index 2694761..0000000 --- a/app/schemas/knowledge.py +++ /dev/null @@ -1,9 +0,0 @@ -from pydantic import BaseModel - - -class KnowledgeSearchResponse(BaseModel): - """知识检索响应:返回评分参考指南片段和来源。""" - - matched_chunks: list[dict] - source_refs: list[dict] - no_match: bool diff --git a/app/schemas/llm.py b/app/schemas/llm.py deleted file mode 100644 index 6eee003..0000000 --- a/app/schemas/llm.py +++ /dev/null @@ -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 diff --git a/app/schemas/teaching.py b/app/schemas/teaching.py new file mode 100644 index 0000000..44047f7 --- /dev/null +++ b/app/schemas/teaching.py @@ -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 diff --git a/app/services/teaching_service.py b/app/services/teaching_service.py new file mode 100644 index 0000000..87603ab --- /dev/null +++ b/app/services/teaching_service.py @@ -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": ["人文沟通", "健康教育"], + }, + ] diff --git a/docs/03_api_design.md b/docs/03_api_design.md index b5c15b7..8f8e224 100644 --- a/docs/03_api_design.md +++ b/docs/03_api_design.md @@ -1,69 +1,29 @@ -# 医疗问诊 Agent 前端联调 API 文档 +# 医疗问诊 Agent API 文档 -> 文档版本:2026-06-04 -> 对应后端:FastAPI `main` 分支,提交 `b46e43a` 之后版本 -> 本文档以当前真实代码行为为准,用于正式 Vue 前端功能联调。 +> 当前文档只描述前端联调需要的后端能力。 ## 1. 联调地址 -### 1.1 当前公网测试环境 - | 项目 | 地址 | |---|---| -| 网关根地址 | `http://8.160.178.88/fastapi` | +| 公网网关 | `http://8.160.178.88/fastapi` | | API Base URL | `http://8.160.178.88/fastapi/api/v1` | | Swagger | `http://8.160.178.88/fastapi/docs` | | OpenAPI JSON | `http://8.160.178.88/fastapi/openapi.json` | | 存活检查 | `http://8.160.178.88/fastapi/health/live` | | 就绪检查 | `http://8.160.178.88/fastapi/health/ready` | -当前 `/health/ready` 已验证 MySQL、Redis 和生产配置处于就绪状态。 +## 2. 通用规则 -如果服务器关闭 Nginx 的 `/fastapi/` 公网代理,公网地址将不可用,前端需要改用局域网、VPN、SSH 隧道或新的测试网关。 - -### 1.2 Docker 主机内部地址 - -```text -http://127.0.0.1:9000 -``` - -该地址只用于服务器本机检查,不提供给远程前端。 - -## 2. 前端接入规则 - -### 2.1 认证链路 - -医疗问诊 Agent 不实现登录注册,也不接受前端传入 `user_id`。 - -```text -前端从宿主系统获得 access token - -> 请求 FastAPI 时携带 Authorization: Bearer - -> FastAPI 将 token 转发给 Django /api/user/users/me/ - -> Django 返回 200 和用户资料 - -> FastAPI 使用 Django 返回的 id 隔离会话、检查、提交和评价记录 -``` - -前端禁止传递或信任 `X-User-Id`。 - -### 2.2 通用请求头 - -除健康检查外,所有业务接口必须携带: +除健康检查外,所有业务接口都需要携带: ```http Authorization: Bearer X-Entry-Scene: vue_frontend -X-Request-Id: <可选的请求追踪ID> +X-Request-Id: <可选> ``` -| Header | 必填 | 说明 | -|---|---:|---| -| `Authorization` | 是 | Django 用户中心 access token。 | -| `X-Entry-Scene` | 否 | 入口场景,例如 `vue_frontend`、`production_vue`。 | -| `X-Request-Id` | 否 | 前端生成的请求追踪 ID。 | - -### 2.3 统一响应 - -除 SSE 流式问诊外,接口统一返回: +普通 JSON 接口统一返回: ```json { @@ -73,157 +33,31 @@ X-Request-Id: <可选的请求追踪ID> } ``` -前端必须同时判断 HTTP 状态码和业务 `code`: +前端判断规则: -```ts -if (!response.ok || body.code !== "OK") { - throw new Error(body.message || body.code || "request failed"); -} -``` +- HTTP 状态码为 `2xx` +- `code` 等于 `OK` +- 业务数据从 `data` 读取 -### 2.4 CORS 配置 +SSE 流式接口不返回上述 JSON 包装,而是返回 `event + data` 事件流。 -正式前端通过 `http://8.160.178.88/app/` 访问 API 时,与 `/fastapi/` 同源,不会触发跨域限制。 +## 3. 前置接口 -Mac 上使用 Vite 开发服务器直接调用公网 API 时,服务器 `.env` 必须允许前端 Origin。例如: - -```env -CORS_ALLOW_ORIGINS=http://localhost:5173,http://192.168.2.100:5173 -CORS_ALLOW_ORIGIN_REGEX= -``` - -修改服务器 `.env` 后重新创建 FastAPI 容器: - -```bash -cd /home/code/medical-ai -docker compose up -d --force-recreate fastapi -``` - -不需要重新构建镜像。 - -## 3. 当前接口总览 - -### 3.1 无需认证 - -| Method | Path | 用途 | -|---|---|---| -| `GET` | `/health/live` | FastAPI 进程存活检查。 | -| `GET` | `/health/ready` | MySQL、Redis 和关键配置就绪检查。 | - -### 3.2 需要 Bearer Token - -以下路径均以 `/api/v1` 为前缀。 - -| Method | Path | 用途 | 前端范围 | -|---|---|---|---| -| `GET` | `/auth/me` | 校验 token 并获取当前用户。 | 必须 | -| `GET` | `/agent/hello` | 获取 Agent 能力开关。 | 必须 | -| `GET` | `/cases` | 获取病例列表。 | 必须 | -| `GET` | `/cases/{case_id}` | 获取病例入口详情。 | 必须 | -| `GET` | `/training-config/recommended` | 获取训练页推荐病人初始化配置。 | 必须 | -| `GET` | `/training-config/options` | 获取训练页自定义配置选项。 | 必须 | -| `POST` | `/sessions` | 创建训练会话。 | 必须 | -| `POST` | `/sessions/{session_id}/chat` | 非流式问诊。 | 必须 | -| `POST` | `/sessions/{session_id}/chat/stream` | SSE 流式问诊。 | 必须 | -| `POST` | `/sessions/{session_id}/hints` | 练习模式提示。 | 必须 | -| `POST` | `/sessions/{session_id}/hints/stream` | SSE 流式练习提示。 | 必须 | -| `GET` | `/sessions/{session_id}/order-items` | 获取病例可申请检查项。 | 必须 | -| `POST` | `/sessions/{session_id}/orders` | 申请检查并返回结果。 | 必须 | -| `GET` | `/sessions/{session_id}/physical-exams` | 获取体格检查列表。 | 必须 | -| `GET` | `/sessions/{session_id}/auxiliary-exams` | 获取辅助检查列表。 | 必须 | -| `POST` | `/sessions/{session_id}/physical-exams/{item_code}` | 获取体格检查某项结果。 | 必须 | -| `POST` | `/sessions/{session_id}/auxiliary-exams/{item_code}` | 获取辅助检查某项结果。 | 必须 | -| `POST` | `/sessions/{session_id}/complete-inquiry` | 完成问诊。 | 必须 | -| `POST` | `/sessions/{session_id}/diagnosis` | 提交诊断。 | 必须 | -| `POST` | `/sessions/{session_id}/treatment` | 提交治疗方案。 | 必须 | -| `POST` | `/sessions/{session_id}/evaluation` | 生成 AI 评价。 | 必须 | -| `GET` | `/evaluations` | 查询当前用户历史评价。 | 必须 | -| `GET` | `/evaluations/{evaluation_id}` | 查询评价详情和评分明细。 | 必须 | -| `POST` | `/evaluations/{evaluation_id}/export-pdf` | 生成 PDF 报告。 | 必须 | -| `GET` | `/evaluations/{evaluation_id}/download-pdf` | 生成并下载 PDF 报告文件流。 | 必须 | -| `GET` | `/knowledge/search` | 调试知识检索。 | 调试 | -| `POST` | `/llm/test/deepseek-fast` | 测试快速模型。 | 调试 | -| `POST` | `/llm/test/deepseek-reason` | 测试 Reason 模型。 | 调试 | - -## 4. 前端完整业务流程 - -```text -GET /auth/me - -> GET /agent/hello - -> GET /cases - -> GET /cases/{case_id} - -> POST /sessions - -> 多轮问诊、提示、检查申请 - -> POST /sessions/{session_id}/complete-inquiry - -> POST /sessions/{session_id}/diagnosis - -> POST /sessions/{session_id}/treatment - -> POST /sessions/{session_id}/evaluation - -> GET /evaluations/{evaluation_id} - -> POST /evaluations/{evaluation_id}/export-pdf - -> GET /evaluations/{evaluation_id}/download-pdf -``` - -会话状态流转: - -```text -inquiry -> diagnosis -> treatment -> evaluating -> completed -``` - -| 状态 | 允许操作 | -|---|---| -| `inquiry` | 问诊、提示、检查申请、完成问诊。 | -| `diagnosis` | 提交诊断、继续申请检查。 | -| `treatment` | 提交治疗、继续申请检查。 | -| `evaluating` | 生成评价。 | -| `completed` | 查看评价、导出 PDF、查看历史记录。 | - -当前没有“获取会话详情”“恢复聊天记录”接口。页面刷新或中断后,前端不能恢复短期对话,应重新创建训练会话。 - -### 4.1 训练页面当前使用接口明细 - -下表为正式前端训练页面当前需要直接调用的接口。`url` 为公网联调完整地址,`api` 为前端代码中拼接的相对路径。除健康检查外,以下接口都需要携带 `Authorization: Bearer `;`X-Entry-Scene` 建议固定传 `vue_frontend` 或实际入口场景。 +### 3.1 当前用户 | 接口名称 | url | api | methods | params(入参) | response(返回参数) | |---|---|---|---|---|---| -| 新建会话 | `http://8.160.178.88/fastapi/api/v1/sessions` | `/api/v1/sessions` | `POST` | Header:`Authorization` 必填,`X-Entry-Scene` 建议传;Body:`case_id` 必填,`training_type` 必填,`mode` 必填,`score_type` 可选,`patient_config` 可选。 | `code`、`message`、`data.session_id`、`data.session_code`、`data.status`、`data.patient_opening`、`data.patient_config`。 | -| 流式会话 | `http://8.160.178.88/fastapi/api/v1/sessions/{session_id}/chat/stream` | `/api/v1/sessions/{session_id}/chat/stream` | `POST` | Path:`session_id` 必填;Body:`message` 必填。 | SSE:`message_delta` 返回 `delta`;`message_done` 返回 `latency_ms`、`first_token_ms`、`model`、`fallback_used`;异常返回 `error`。 | -| 练习提示 | `http://8.160.178.88/fastapi/api/v1/sessions/{session_id}/hints/stream` | `/api/v1/sessions/{session_id}/hints/stream` | `POST` | Path:`session_id` 必填;Body:`last_user_message` 可选,`scope=current_conversation`。仅 `practice` 且 `inquiry` 状态可调用。 | SSE:`hint_delta` 返回一句话提示增量;`hint_done` 返回耗时;异常返回 `error`。 | -| 体格检查列表获取 | `http://8.160.178.88/fastapi/api/v1/sessions/{session_id}/physical-exams` | `/api/v1/sessions/{session_id}/physical-exams` | `GET` | Path:`session_id` 必填。 | `data.items[]`,包含 `item_code`、`item_name`、`item_type`。不返回检查结果。 | -| 辅助检查列表获取 | `http://8.160.178.88/fastapi/api/v1/sessions/{session_id}/auxiliary-exams` | `/api/v1/sessions/{session_id}/auxiliary-exams` | `GET` | Path:`session_id` 必填。 | `data.items[]`,包含 `item_code`、`item_name`、`item_type`。不返回检查结果。 | -| 体格检查某项结果 | `http://8.160.178.88/fastapi/api/v1/sessions/{session_id}/physical-exams/{item_code}` | `/api/v1/sessions/{session_id}/physical-exams/{item_code}` | `POST` | Path:`session_id` 必填,`item_code` 必填。`item_code` 必须属于体格检查项。 | `data.item_code`、`item_name`、`item_type`、`result_text`、`result_structured`、`is_key`、`is_abnormal`、`context_written`、`already_ordered`。 | -| 辅助检查某项结果 | `http://8.160.178.88/fastapi/api/v1/sessions/{session_id}/auxiliary-exams/{item_code}` | `/api/v1/sessions/{session_id}/auxiliary-exams/{item_code}` | `POST` | Path:`session_id` 必填,`item_code` 必填。`item_code` 必须属于辅助检查项。 | 同体格检查结果。检查结果来自数据库,并写入本次会话短期 memory。重复申请返回已有结果,`already_ordered=true`。 | -| 完成问诊 | `http://8.160.178.88/fastapi/api/v1/sessions/{session_id}/complete-inquiry` | `/api/v1/sessions/{session_id}/complete-inquiry` | `POST` | Path:`session_id` 必填。至少完成一轮医生问诊后可调用。 | `data.session_id`、`data.status=diagnosis`。 | -| 提交诊断 | `http://8.160.178.88/fastapi/api/v1/sessions/{session_id}/diagnosis` | `/api/v1/sessions/{session_id}/diagnosis` | `POST` | Path:`session_id` 必填;Body:`primary_diagnosis` 必填,`diagnosis_basis` 必填,`differential_diagnoses` 可选数组。 | `data.status=treatment`。 | -| 提交治疗 | `http://8.160.178.88/fastapi/api/v1/sessions/{session_id}/treatment` | `/api/v1/sessions/{session_id}/treatment` | `POST` | Path:`session_id` 必填;Body:`treatment_principle` 必填,`treatment_measures` 必填,`risk_plan`、`communication`、`follow_up` 可选。 | `data.status=evaluating`。 | -| 生成评价 | `http://8.160.178.88/fastapi/api/v1/sessions/{session_id}/evaluation` | `/api/v1/sessions/{session_id}/evaluation` | `POST` | Path:`session_id` 必填;Body:`score_type` 可选,允许 `percentage`、`five_point`。必须已提交治疗。 | `data.evaluation_id`、`score_type`、`total_score`、`dimension_scores`、`score_details`、`errors`、`improvement_plan`、`evidence_summary`、`guideline_refs`、`overall_comment`。 | -| 获取评价(详情) | `http://8.160.178.88/fastapi/api/v1/evaluations/{evaluation_id}` | `/api/v1/evaluations/{evaluation_id}` | `GET` | Path:`evaluation_id` 必填。只允许当前 token 对应用户访问自己的评价。 | 评价完整详情,包含 `session_id`、`case_id`、`case_title`、`created_at`、`pdf_file_path`。 | -| 生成 PDF | `http://8.160.178.88/fastapi/api/v1/evaluations/{evaluation_id}/export-pdf` | `/api/v1/evaluations/{evaluation_id}/export-pdf` | `POST` | Path:`evaluation_id` 必填。只允许当前 token 对应用户导出自己的评价。 | `data.export_id`、`data.file_path`。 | -| 下载 PDF 文件流 | `http://8.160.178.88/fastapi/api/v1/evaluations/{evaluation_id}/download-pdf` | `/api/v1/evaluations/{evaluation_id}/download-pdf` | `GET` | Path:`evaluation_id` 必填。Header:`Authorization` 必填,`X-Entry-Scene` 建议传。只允许当前 token 对应用户下载自己的评价报告。 | 成功时返回 `application/pdf` 文件流,响应头包含 `Content-Disposition: attachment`;失败时返回统一错误 JSON。 | +| 当前用户 | `http://8.160.178.88/fastapi/api/v1/auth/me` | `/api/v1/auth/me` | `GET` | Header:`Authorization` 必填,格式 `Bearer `;`X-Entry-Scene` 建议传。 | `data.user_id`、`data.username`、`data.display_name`、`data.role`、`data.phone`、`data.institution_id`、`data.department_id`、`data.status` 等 Django 用户中心字段。 | -常见错误码: - -| code | 场景 | -|---|---| -| `AUTH_CREDENTIAL_REQUIRED` | 缺少 `Authorization`。 | -| `SESSION_NOT_FOUND` | 会话不存在或不属于当前用户。 | -| `SESSION_STATUS_INVALID` | 当前会话状态不允许该操作。 | -| `INQUIRY_REQUIRED` | 未完成至少一轮医生问诊就完成问诊。 | -| `ORDER_ITEM_NOT_FOUND` | 当前病例下不存在该检查项。 | -| `ORDER_ITEM_TYPE_MISMATCH` | 用体格检查接口申请辅助检查,或用辅助检查接口申请体格检查。 | -| `TREATMENT_REQUIRED` | 未提交治疗就生成评价。 | -| `EVALUATION_NOT_FOUND` | 评价不存在或不属于当前用户。 | -| `PDF_EXPORT_FAILED` | PDF 生成失败。 | - -## 5. 认证与 Agent - -### 5.1 获取当前用户 +请求示例: ```http GET /api/v1/auth/me Authorization: Bearer +X-Entry-Scene: vue_frontend ``` -成功响应: +成功返回示例: ```json { @@ -237,167 +71,52 @@ Authorization: Bearer "tenant_id": "1", "role": "student", "phone": "13700000099", - "avatar": null, + "avatar": "", "gender": 0, - "institution": 1, "institution_id": 1, "institution_name": "测试机构", - "department": 2, "department_id": 2, "department_name": "儿科", - "title_name": null, - "major": null, - "training_stage": null, - "learning_target": null, - "competency_profile": {}, - "weak_dimensions": [], - "strong_dimensions": [], - "ai_preference": {}, - "total_training_count": 0, - "total_case_count": 0, - "current_level": null, "status": 1 } } ``` -前端规则: +### 3.2 病例读取 -- 进入 Agent 后首先调用本接口。 -- 返回 200 且 `code=OK` 才允许进入训练页面。 -- 后续所有请求继续携带相同 token。 -- `user_id` 只用于展示或前端状态标识,不能由前端覆盖。 +| 接口名称 | url | api | methods | params(入参) | response(返回参数) | +|---|---|---|---|---|---| +| 病例列表 | `http://8.160.178.88/fastapi/api/v1/cases` | `/api/v1/cases` | `GET` | Query:`department_id` 选填,科室 ID;`training_type` 选填;`mode` 选填,允许 `practice`、`teaching`。 | `data.items[]`,包含 `id`、`title`、`department_id`、`difficulty`、`chief_complaint`、`has_teaching_video`、`has_quiz`。 | +| 病例详情 | `http://8.160.178.88/fastapi/api/v1/cases/{case_id}` | `/api/v1/cases/{case_id}` | `GET` | Path:`case_id` 必填,病例 ID。 | 病例入口展示信息,不返回标准答案、隐藏病史、检查结果和评分规则。 | -### 5.2 获取 Agent 能力 +## 4. 训练页面接口 + +| 接口名称 | url | api | methods | params(入参) | response(返回参数) | +|---|---|---|---|---|---| +| 推荐配置信息 | `http://8.160.178.88/fastapi/api/v1/training-config/recommended?case_id={case_id}` | `/api/v1/training-config/recommended` | `GET` | Query:`case_id` 必填,病例 ID。 | `data.recommended` 默认配置;`data.recommended_labels` 中文标签。 | +| 训练配置信息 | `http://8.160.178.88/fastapi/api/v1/training-config/options?case_id={case_id}` | `/api/v1/training-config/options` | `GET` | Query:`case_id` 必填,病例 ID。 | `data.options` 全部可选项;`data.recommended` 推荐默认值。 | +| 新建会话 | `http://8.160.178.88/fastapi/api/v1/sessions` | `/api/v1/sessions` | `POST` | Body:`case_id` 必填;`training_type` 必填;`mode` 必填,当前训练填 `practice`;`score_type` 选填,允许 `percentage`、`five_point`;`patient_config` 选填。 | `data.session_id`、`data.session_code`、`data.status`、`data.patient_opening`、`data.patient_config`。 | +| 流式会话 | `http://8.160.178.88/fastapi/api/v1/sessions/{session_id}/chat/stream` | `/api/v1/sessions/{session_id}/chat/stream` | `POST` | Path:`session_id` 必填;Body:`message` 必填,医学生问句。 | SSE:`message_delta` 返回患者回复增量;`message_done` 返回耗时;`error` 返回错误。 | +| 王主任练习提示 | `http://8.160.178.88/fastapi/api/v1/sessions/{session_id}/hints/stream` | `/api/v1/sessions/{session_id}/hints/stream` | `POST` | Path:`session_id` 必填;Body:`last_user_message` 选填;`scope` 选填,默认 `current_conversation`。 | SSE:`hint_delta` 返回一句话提示增量;`hint_done` 返回结束事件。 | +| 体格检查列表获取 | `http://8.160.178.88/fastapi/api/v1/sessions/{session_id}/physical-exams` | `/api/v1/sessions/{session_id}/physical-exams` | `GET` | Path:`session_id` 必填。 | `data.items[]`,包含体格检查项 `item_code`、`item_name`、`item_type`。不返回结果。 | +| 辅助检查列表获取 | `http://8.160.178.88/fastapi/api/v1/sessions/{session_id}/auxiliary-exams` | `/api/v1/sessions/{session_id}/auxiliary-exams` | `GET` | Path:`session_id` 必填。 | `data.items[]`,包含辅助检查项 `item_code`、`item_name`、`item_type`。不返回结果。 | +| 体格检查某项结果 | `http://8.160.178.88/fastapi/api/v1/sessions/{session_id}/physical-exams/{item_code}` | `/api/v1/sessions/{session_id}/physical-exams/{item_code}` | `POST` | Path:`session_id` 必填;`item_code` 必填,必须属于体格检查。 | `data.result_text`、`data.result_structured`、`data.context_written`、`data.already_ordered`。 | +| 辅助检查某项结果 | `http://8.160.178.88/fastapi/api/v1/sessions/{session_id}/auxiliary-exams/{item_code}` | `/api/v1/sessions/{session_id}/auxiliary-exams/{item_code}` | `POST` | Path:`session_id` 必填;`item_code` 必填,必须属于辅助检查。 | `data.result_text`、`data.result_structured`、`data.context_written`、`data.already_ordered`。 | +| 完成问诊 | `http://8.160.178.88/fastapi/api/v1/sessions/{session_id}/complete-inquiry` | `/api/v1/sessions/{session_id}/complete-inquiry` | `POST` | Path:`session_id` 必填。至少完成一轮医生问诊。 | `data.session_id`、`data.status=diagnosis`。 | +| 提交诊断 | `http://8.160.178.88/fastapi/api/v1/sessions/{session_id}/diagnosis` | `/api/v1/sessions/{session_id}/diagnosis` | `POST` | Path:`session_id` 必填;Body:`primary_diagnosis` 必填;`diagnosis_basis` 必填;`differential_diagnoses` 选填数组。 | `data.status=treatment`。 | +| 提交治疗 | `http://8.160.178.88/fastapi/api/v1/sessions/{session_id}/treatment` | `/api/v1/sessions/{session_id}/treatment` | `POST` | Path:`session_id` 必填;Body:`treatment_principle`、`treatment_measures` 必填;`risk_plan`、`communication`、`follow_up` 选填。 | `data.status=evaluating`。 | +| 生成评价 | `http://8.160.178.88/fastapi/api/v1/sessions/{session_id}/evaluation` | `/api/v1/sessions/{session_id}/evaluation` | `POST` | Path:`session_id` 必填;Body:`score_type` 选填,允许 `percentage`、`five_point`。必须已提交治疗。 | `data.evaluation_id`、`data.total_score`、`data.dimension_scores[]`、`data.score_details[]`、`data.overall_comment`。 | +| 获取评价(详情) | `http://8.160.178.88/fastapi/api/v1/evaluations/{evaluation_id}` | `/api/v1/evaluations/{evaluation_id}` | `GET` | Path:`evaluation_id` 必填。只允许当前用户访问自己的评价。 | 评价详情,包含病例、总分、维度评分、评分明细、改进建议、PDF 路径。 | +| 下载 PDF | `http://8.160.178.88/fastapi/api/v1/evaluations/{evaluation_id}/download-pdf` | `/api/v1/evaluations/{evaluation_id}/download-pdf` | `GET` | Path:`evaluation_id` 必填。Header:`Authorization` 必填。 | 成功时返回 `application/pdf` 文件流,浏览器可下载;失败时返回统一错误 JSON。 | + +### 4.1 训练配置入参和返回示例 + +请求: ```http -GET /api/v1/agent/hello +GET /api/v1/training-config/options?case_id=2 Authorization: Bearer -``` - -响应核心结构: - -```json -{ - "code": "OK", - "message": "success", - "data": { - "user": { - "user_id": "37", - "role": "student", - "source": "django_user_center", - "username": "13700000099", - "display_name": "测试用户" - }, - "features": { - "stream_chat": true, - "score_types": ["percentage", "five_point"], - "pdf_export": true, - "knowledge_search": true, - "llm_mock_enabled": false, - "llm_mode": "real", - "llm_fallback_to_mock": false, - "llm_fast_model": "deepseek-chat", - "llm_reason_model": "deepseek-reasoner", - "runtime_memory_backend": "redis", - "auth_validate_enabled": true, - "auth_source": "django_user_center" - } - } -} -``` - -## 6. 病例接口 - -### 6.1 病例列表 - -```http -GET /api/v1/cases?department_id=2&mode=practice -``` - -Query 参数: - -| 参数 | 类型 | 必填 | 说明 | -|---|---|---:|---| -| `department_id` | integer | 否 | 科室 ID。 | -| `training_type` | string | 否 | `case_analysis`、`diagnosis_treatment`、`consultation`。 | -| `mode` | string | 否 | `practice` 或 `teaching`。 | - -响应: - -```json -{ - "code": "OK", - "message": "success", - "data": { - "items": [ - { - "id": 2, - "case_code": "SRC_2", - "department_id": 2, - "title": "支气管肺炎 - 6岁男性患儿", - "difficulty": "medium", - "chief_complaint": "发热、咳嗽4天,喘息1天", - "supported_training_type": "diagnosis_treatment", - "supported_mode": "free_chat", - "has_teaching_video": false, - "has_knowledge_points": true, - "has_quiz": false - } - ] - } -} -``` - -### 6.2 病例详情 - -```http -GET /api/v1/cases/{case_id} -``` - -响应不会返回标准诊断、标准治疗、隐藏病史、评分规则和检查结果: - -```json -{ - "code": "OK", - "message": "success", - "data": { - "id": 2, - "case_code": "SRC_2", - "title": "支气管肺炎 - 6岁男性患儿", - "department": "儿科", - "difficulty": "medium", - "patient": { - "name": null, - "age": 6, - "gender": "male", - "occupation": null - }, - "chief_complaint": "发热、咳嗽4天,喘息1天", - "supported_training_type": "diagnosis_treatment", - "supported_mode": "free_chat", - "has_teaching_video": false, - "has_knowledge_points": true, - "has_quiz": false, - "order_item_types": ["imaging", "lab", "vital_sign"] - } -} -``` - -### 6.3 推荐配置信息 - -推荐配置信息用于训练页初始化病人沟通风格。后端会读取病例主表内容,根据病例年龄、标题、主诉、描述和标签推断默认值。例如儿科患儿病例默认推荐 `age_group=child`;急诊、危重、低氧等关键词会推荐 `visit_environment=emergency` 和 `personality=anxious`。 - -```http -GET /api/v1/training-config/recommended?case_id=2 -``` - -入参: - -```json -{ - "case_id": 2 -} +X-Entry-Scene: vue_frontend ``` 返回: @@ -420,34 +139,36 @@ GET /api/v1/training-config/recommended?case_id=2 "education_level": "高等教育", "personality": "平和" }, - "options": {} + "options": { + "visit_environment": [ + {"value": "outpatient", "label": "门诊"}, + {"value": "emergency", "label": "急诊"}, + {"value": "ward", "label": "病房"} + ], + "age_group": [ + {"value": "child", "label": "儿童"}, + {"value": "youth", "label": "青年"}, + {"value": "middle_aged", "label": "中年"}, + {"value": "elderly", "label": "老年"} + ], + "education_level": [ + {"value": "primary_or_below", "label": "小学及以下"}, + {"value": "secondary", "label": "中等教育"}, + {"value": "higher", "label": "高等教育"} + ], + "personality": [ + {"value": "calm", "label": "平和"}, + {"value": "anxious", "label": "焦虑"}, + {"value": "impatient", "label": "急躁"}, + {"value": "cooperative", "label": "配合"}, + {"value": "suspicious", "label": "多疑"} + ] + } } } ``` -### 6.4 训练配置信息 - -训练配置信息用于前端渲染自定义配置页面,返回推荐值和全部可选项。前端可以直接使用 `recommended` 作为默认选中项,用户修改后把 `patient_config` 传给创建会话接口。 - -```http -GET /api/v1/training-config/options?case_id=2 -``` - -返回字段与推荐配置信息一致,`options` 包含以下可选值: - -| 配置字段 | 可选值 | 说明 | -|---|---|---| -| `visit_environment` | `outpatient`、`emergency`、`ward` | 门诊、急诊、病房。 | -| `age_group` | `child`、`youth`、`middle_aged`、`elderly` | 儿童、青年、中年、老年。 | -| `education_level` | `primary_or_below`、`secondary`、`higher` | 小学及以下、中等教育、高等教育。 | -| `personality` | `calm`、`anxious`、`impatient`、`cooperative`、`suspicious` | 平和、焦虑、急躁、配合、多疑。 | - -## 7. 创建训练会话 - -```http -POST /api/v1/sessions -Content-Type: application/json -``` +### 4.2 新建会话入参和返回示例 请求: @@ -466,30 +187,15 @@ Content-Type: application/json } ``` -字段: - -| 字段 | 允许值 | 说明 | -|---|---|---| -| `training_type` | `case_analysis`、`diagnosis_treatment`、`consultation` | 训练类别。 | -| `mode` | `practice`、`teaching` | 正式前端使用的两种模式。 | -| `score_type` | `percentage`、`five_point` | 百分制或五分制。 | -| `patient_config.visit_environment` | `outpatient`、`emergency`、`ward` | 就诊环境,影响 AI 病人沟通节奏。 | -| `patient_config.age_group` | `child`、`youth`、`middle_aged`、`elderly` | 年龄段,影响病人或家属表达方式。 | -| `patient_config.education_level` | `primary_or_below`、`secondary`、`higher` | 文化程度,影响医学术语理解和表达清晰度。 | -| `patient_config.personality` | `calm`、`anxious`、`impatient`、`cooperative`、`suspicious` | 性格,影响情绪和配合度。 | - -后端仍接受旧值 `novice`,但会自动转换为 `practice`。正式前端不要继续使用 `novice`。 -`patient_config` 为可选字段;未传时后端会根据当前病例自动使用推荐配置。前端训练页已调用推荐配置接口时,应把用户最终选中的配置传入本接口。 - -响应: +返回: ```json { "code": "OK", "message": "success", "data": { - "session_id": 10, - "session_code": "sess_20260604100000_abcd1234", + "session_id": 12, + "session_code": "sess_20260608120000_xxxx", "status": "inquiry", "patient_opening": "家长:医生,孩子发热咳嗽好几天了,昨天开始喘得厉害,精神也不太好。", "patient_config": { @@ -510,16 +216,7 @@ Content-Type: application/json } ``` -前端创建新会话时必须清空上一轮 Chat、检查结果、诊断、治疗和评价状态。 - -## 8. 问诊接口 - -### 8.1 非流式问诊 - -```http -POST /api/v1/sessions/{session_id}/chat -Content-Type: application/json -``` +### 4.3 SSE 流式会话返回格式 请求: @@ -529,191 +226,20 @@ Content-Type: application/json } ``` -响应: - -```json -{ - "code": "OK", - "message": "success", - "data": { - "reply": "发热有4天了,最高烧到39度多,吃了退烧药能降下来,但过几个小时又会烧。", - "latency_ms": 2500, - "model": "deepseek-chat", - "fallback_used": false - } -} -``` - -### 8.2 SSE 流式问诊 - -```http -POST /api/v1/sessions/{session_id}/chat/stream -Content-Type: application/json -Accept: text/event-stream -``` - -请求体与非流式接口相同。 - -事件格式: +返回: ```text event: message_delta data: {"delta":"发热有4天了,"} +event: message_delta +data: {"delta":"最高烧到39度多。"} + event: message_done -data: {"latency_ms":3200,"first_token_ms":800,"model":"deepseek-chat","fallback_used":false} - -event: error -data: {"code":"LLM_STREAM_TIMEOUT","message":"AI 病人首段回复超时,请重试或关闭流式模式"} +data: {"latency_ms":1800,"first_token_ms":500,"model":"deepseek-chat","fallback_used":false} ``` -前端必须处理: - -| 事件 | 处理 | -|---|---| -| `message_delta` | 将 `delta` 追加到当前 AI 气泡。 | -| `message_done` | 结束 pending,启用发送按钮。 | -| `error` | 结束 pending,显示错误信息。 | -| 请求中断或流结束但没有 `message_done` | 结束 pending,提示重试。 | - -流式请求使用 `fetch`,不要使用原生 `EventSource`,因为该接口是 `POST` 且需要请求体和 Authorization。 - -```ts -async function streamChat(baseUrl: string, token: string, sessionId: number, message: string) { - const response = await fetch(`${baseUrl}/sessions/${sessionId}/chat/stream`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "text/event-stream", - Authorization: `Bearer ${token}`, - "X-Entry-Scene": "vue_frontend", - }, - body: JSON.stringify({ message }), - }); - - if (!response.ok || !response.body) { - throw new Error(`stream request failed: ${response.status}`); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - let completed = false; - - while (true) { - const { value, done } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const blocks = buffer.split("\n\n"); - buffer = blocks.pop() || ""; - - for (const block of blocks) { - const event = block.match(/^event:\s*(.+)$/m)?.[1]; - const rawData = block.match(/^data:\s*(.+)$/m)?.[1]; - if (!event || !rawData) continue; - const data = JSON.parse(rawData); - - if (event === "message_delta") { - // appendAiText(data.delta) - } else if (event === "message_done") { - completed = true; - } else if (event === "error") { - throw new Error(data.message); - } - } - } - - if (!completed) throw new Error("AI 流式回复未正常结束"); -} -``` - -### 8.3 练习提示 - -```http -POST /api/v1/sessions/{session_id}/hints -``` - -请求: - -```json -{ - "last_user_message": "孩子发热几天了?最高体温多少?", - "scope": "current_conversation" -} -``` - -响应: - -```json -{ - "code": "OK", - "message": "success", - "data": { - "hints": ["可以继续追问热型、退热药反应和呼吸困难表现。"], - "missing_dimensions": ["既往史", "严重程度评估"], - "next_questions": [ - "孩子以前有没有喘息、哮喘或过敏史?", - "孩子现在有没有呼吸困难或口唇发紫?" - ], - "recommended_orders": [ - { - "item_code": "spo2", - "reason": "用于评估低氧和病情严重程度" - } - ] - } -} -``` - -约束: - -- 仅 `practice` 模式可调用。 -- 仅 `inquiry` 状态可调用。 -- 前端默认不自动展示提示,由用户点击后调用。 - -## 9. 检查与检验 - -### 9.1 获取当前病例检查项 - -```http -GET /api/v1/sessions/{session_id}/order-items -``` - -响应: - -```json -{ - "code": "OK", - "message": "success", - "data": { - "items": [ - { - "item_code": "blood_routine", - "item_name": "血常规", - "item_type": "lab" - } - ] - } -} -``` - -前端不得写死检查项编码。不同病例拥有不同检查项,必须使用本接口返回的 `item_code`。 - -### 9.2 申请检查 - -```http -POST /api/v1/sessions/{session_id}/orders -``` - -请求: - -```json -{ - "item_code": "blood_routine" -} -``` - -响应: +### 4.4 检查结果返回示例 ```json { @@ -736,173 +262,98 @@ POST /api/v1/sessions/{session_id}/orders } ``` -规则: +检查规则: - 检查结果只来自数据库,不由 LLM 生成。 -- 检查结果会写入本次会话上下文和评分依据。 -- 同一会话重复申请相同 `item_code` 时,返回已有结果并设置 `already_ordered=true`。 -- 前端按 `item_code` 去重;点击后立即禁用,避免重复请求。 -- 检查申请允许在 `inquiry`、`diagnosis`、`treatment` 状态执行。 +- 检查结果会写入本次会话短期 memory 和评分依据。 +- 同一会话重复申请相同 `item_code` 时返回已有结果,`already_ordered=true`。 -## 10. 阶段提交 +### 4.5 诊断和治疗提交示例 -### 10.1 完成问诊 - -```http -POST /api/v1/sessions/{session_id}/complete-inquiry -``` - -请求体:无。 - -响应: - -```json -{ - "code": "OK", - "message": "success", - "data": { - "session_id": 10, - "status": "diagnosis" - } -} -``` - -至少完成一轮医生提问,否则返回 `INQUIRY_REQUIRED`。 - -### 10.2 提交诊断 - -```http -POST /api/v1/sessions/{session_id}/diagnosis -``` - -请求: +提交诊断: ```json { "primary_diagnosis": "支气管肺炎", - "differential_diagnoses": [ - "支气管哮喘急性发作", - "上呼吸道感染" - ], - "diagnosis_basis": "结合发热、咳嗽、喘息、肺部体征、炎症指标、胸片和血氧结果,符合儿童支气管肺炎表现。" + "differential_diagnoses": ["支气管哮喘急性发作", "上呼吸道感染"], + "diagnosis_basis": "结合发热、咳嗽、喘息、肺部湿啰音、胸片异常、炎症指标升高和血氧情况,符合儿童支气管肺炎表现。" } ``` -成功响应: - -```json -{ - "code": "OK", - "message": "success", - "data": { - "status": "treatment" - } -} -``` - -### 10.3 提交治疗 - -```http -POST /api/v1/sessions/{session_id}/treatment -``` - -请求: +提交治疗: ```json { "treatment_principle": "抗感染、止咳平喘、改善氧合并严密观察病情变化。", - "treatment_measures": "根据病情进行抗感染治疗,必要时雾化缓解喘息,监测体温、呼吸和血氧。", + "treatment_measures": "根据病情进行抗感染治疗,必要时雾化吸入缓解喘息,监测体温、呼吸和血氧。", "risk_plan": "关注低氧、呼吸困难加重、持续高热、精神反应差和脱水。", "communication": "向家属说明病情、用药注意事项、危险信号和复诊指征。", - "follow_up": "治疗后复查体温、呼吸、血氧和必要的炎症指标。" + "follow_up": "治疗后复查体温、呼吸、血氧和必要炎症指标。" } ``` -成功响应: +## 5. 教学互动接口 + +| 接口名称 | url | api | methods | params(入参) | response(返回参数) | +|---|---|---|---|---|---| +| 获取教学列表(题目 选项 答案 解析文本 视频) | `http://8.160.178.88/fastapi/api/v1/teaching/cases/{case_id}/items` | `/api/v1/teaching/cases/{case_id}/items` | `GET` | Path:`case_id` 必填,必须存在 `teaching_case` 数据。 | `data.case` 病例摘要;`data.questions[]` 题目列表,包含 `stem`、`options[]`、`answer`、`analysis`、`video`。 | +| 生成评价 | `http://8.160.178.88/fastapi/api/v1/teaching/evaluation` | `/api/v1/teaching/evaluation` | `POST` | Body:`case_id` 必填;`answers[]` 必填;`answers[].question_id` 必填;`answers[].selected_answer` 必填;`score_type` 选填。 | `data.session_id`、`data.evaluation_id`、`data.total_score`、`data.dimension_scores[]`、`data.score_details[]`、`data.overall_comment`。 | +| 获取评价(详情) | `http://8.160.178.88/fastapi/api/v1/evaluations/{evaluation_id}` | `/api/v1/evaluations/{evaluation_id}` | `GET` | Path:`evaluation_id` 必填。 | 教学互动评价详情,结构与训练评价一致。 | +| 下载 PDF | `http://8.160.178.88/fastapi/api/v1/evaluations/{evaluation_id}/download-pdf` | `/api/v1/evaluations/{evaluation_id}/download-pdf` | `GET` | Path:`evaluation_id` 必填。 | 返回 `application/pdf` 文件流。 | + +获取教学列表返回示例: ```json { "code": "OK", "message": "success", "data": { - "status": "evaluating" + "case": { + "case_id": 2, + "title": "支气管肺炎 - 6岁男性患儿", + "chief_complaint": "发热、咳嗽4天,喘息1天" + }, + "teaching_goal": "围绕儿科肺炎问诊、检查选择、诊断依据、治疗决策和医患沟通完成互动训练。", + "questions": [ + { + "question_id": "q1", + "question_type": "single_choice", + "stem": "该患儿最需要优先关注的病情严重程度指标是?", + "options": [ + {"key": "A", "text": "体温峰值"}, + {"key": "B", "text": "血氧饱和度"} + ], + "answer": "B", + "analysis": "血氧饱和度能帮助判断低氧和肺炎严重程度。", + "video": {"title": "儿童肺炎教学示例视频", "url": ""}, + "knowledge_points": ["严重程度评估", "血氧判断"] + } + ] } } ``` -## 11. AI 评价、历史记录与 PDF - -### 11.1 生成评价 - -```http -POST /api/v1/sessions/{session_id}/evaluation -``` - -请求: +生成教学评价请求示例: ```json { - "score_type": "percentage" + "case_id": 2, + "score_type": "percentage", + "answers": [ + {"question_id": "q1", "selected_answer": "B"}, + {"question_id": "q2", "selected_answer": "A"} + ] } ``` -响应: +## 6. 个人中心接口 -```json -{ - "code": "OK", - "message": "success", - "data": { - "evaluation_id": 101, - "score_type": "percentage", - "total_score": 82, - "dimension_scores": [ - { - "dimension": "信息获取", - "score": 18, - "max_score": 25, - "comment": "已覆盖主要症状,但既往喘息史追问不足。", - "evidence": ["询问发热天数和最高体温"], - "deductions": ["未充分询问既往喘息史"], - "improvement": "补充既往史、过敏史和严重程度评估。" - } - ], - "score_details": [ - { - "id": 501, - "record_id": 101, - "rule_id": 1, - "dimension": "信息获取", - "score": 18, - "deducted_reason": "未充分询问既往喘息史", - "evidence_message_ids": ["询问发热天数和最高体温"], - "ai_confidence": 0.85, - "comment": "补充既往史和过敏史。" - } - ], - "errors": [], - "improvement_plan": ["加强儿童肺炎严重程度评估训练。"], - "evidence_summary": ["检查结果已写入评分依据。"], - "guideline_refs": [], - "overall_comment": "诊断方向正确,问诊完整性和沟通细节仍需加强。" - } -} -``` +| 接口名称 | url | api | methods | params(入参) | response(返回参数) | +|---|---|---|---|---|---| +| 训练记录列表 | `http://8.160.178.88/fastapi/api/v1/evaluations` | `/api/v1/evaluations` | `GET` | Header:`Authorization` 必填。 | `data.items[]` 当前用户完整训练后的评价记录,包含 `evaluation_id`、`case_title`、`score_type`、`total_score`、`created_at`、`pdf_exported`。 | +| 训练记录详情(评价详情) | `http://8.160.178.88/fastapi/api/v1/evaluations/{evaluation_id}` | `/api/v1/evaluations/{evaluation_id}` | `GET` | Path:`evaluation_id` 必填。 | 完整评价详情,训练和教学互动评价共用。 | -评价成功后: - -- 会话状态变为 `completed`。 -- 写入 `training_record` 和 `training_score_detail`。 -- 当前 Redis 短期聊天 memory 被释放。 -- 相同会话再次调用评价接口时返回已存在的评价,不重复创建。 - -### 11.2 历史评价列表 - -```http -GET /api/v1/evaluations -``` - -响应: +训练记录列表返回示例: ```json { @@ -915,7 +366,7 @@ GET /api/v1/evaluations "case_title": "支气管肺炎 - 6岁男性患儿", "score_type": "percentage", "total_score": 82, - "created_at": "2026-06-04T10:00:00", + "created_at": "2026-06-08T12:00:00", "pdf_exported": true } ] @@ -923,72 +374,9 @@ GET /api/v1/evaluations } ``` -### 11.3 评价详情 +## 7. PDF 下载前端写法 -```http -GET /api/v1/evaluations/{evaluation_id} -``` - -返回字段包含完整评价结构,并额外包含: - -```json -{ - "session_id": 10, - "case_id": 2, - "case_title": "支气管肺炎 - 6岁男性患儿", - "created_at": "2026-06-04T10:00:00", - "pdf_file_path": "/app/storage/reports/training_record_101_percentage_xxx.pdf" -} -``` - -### 11.4 导出 PDF - -```http -POST /api/v1/evaluations/{evaluation_id}/export-pdf -``` - -响应: - -```json -{ - "code": "OK", - "message": "success", - "data": { - "export_id": 101, - "file_path": "/app/storage/reports/training_record_101_percentage_xxx.pdf" - } -} -``` - -该接口只生成 PDF 并返回服务器内部文件路径,适合前端展示“导出成功”和保存导出记录。 - -### 11.5 下载 PDF 文件流 - -```http -GET /api/v1/evaluations/{evaluation_id}/download-pdf -Authorization: Bearer -X-Entry-Scene: vue_frontend -``` - -成功响应不是统一 JSON 包装,而是 PDF 文件流: - -```http -HTTP/1.1 200 OK -Content-Type: application/pdf -Content-Disposition: attachment; filename="training_record_101_percentage_xxx.pdf" -``` - -错误响应仍然使用统一 JSON: - -```json -{ - "code": "EVALUATION_NOT_FOUND", - "message": "evaluation not found", - "data": null -} -``` - -前端通过浏览器直接下载时不能使用普通 `` 携带 Bearer token,应使用 `fetch` 或 `axios` 的 blob 请求,然后创建临时下载链接: +因为下载接口需要 Bearer token,前端不能直接使用普通 ``。使用 `fetch` 获取 blob 后触发下载: ```ts async function downloadEvaluationPdf(baseUrl: string, token: string, evaluationId: number) { @@ -1017,115 +405,7 @@ async function downloadEvaluationPdf(baseUrl: string, token: string, evaluationI } ``` -## 12. 管理与调试接口 - -### 12.1 病例 SQL 预检 - -```http -POST /api/v1/imports/case-sql/preview -Content-Type: multipart/form-data -``` - -FormData: - -```text -file=<病例SQL文件> -``` - -只读取以下四张源表: - -```text -case_base -traditional_case -teaching_case -scoring_rule -``` - -预检不会写入数据库。 - -### 12.2 确认导入 - -```http -POST /api/v1/imports/case-sql/apply -Content-Type: multipart/form-data -``` - -后端根据导入病例生成或更新 `case_exam_item`,导入后病例列表可立即查询。 - -### 12.3 病例删除 - -```http -GET /api/v1/cases/{case_id}/delete-preview -DELETE /api/v1/cases/{case_id} -``` - -删除请求: - -```json -{ - "confirm": true, - "delete_training_data": true -} -``` - -删除会级联清理病例、病例扩展数据、检查项、评分规则以及相关训练数据。学生前端不得开放该功能。 - -### 12.4 知识检索 - -```http -GET /api/v1/knowledge/search?department_id=2&training_type=diagnosis_treatment&q=肺炎,血氧 -``` - -### 12.5 LLM 测试 - -```http -POST /api/v1/llm/test/deepseek-fast -POST /api/v1/llm/test/deepseek-reason -``` - -请求: - -```json -{ - "message": "请用一句话说明医疗问诊训练的用途。" -} -``` - -## 13. 前端 Axios 封装示例 - -```ts -import axios from "axios"; - -export const apiClient = axios.create({ - baseURL: "http://8.160.178.88/fastapi/api/v1", - timeout: 60000, -}); - -apiClient.interceptors.request.use((config) => { - const token = sessionStorage.getItem("access_token"); - if (token) config.headers.Authorization = `Bearer ${token}`; - config.headers["X-Entry-Scene"] = "vue_frontend"; - config.headers["X-Request-Id"] = crypto.randomUUID(); - return config; -}); - -apiClient.interceptors.response.use( - (response) => { - if (response.data?.code !== "OK") { - return Promise.reject(new Error(response.data?.message || response.data?.code)); - } - return response; - }, - (error) => { - if (error.response?.status === 401) { - // 返回宿主系统登录页或触发 token 刷新 - } - return Promise.reject(error); - }, -); -``` - -## 14. 常见错误码 +## 8. 常见错误码 | HTTP | code | 说明 | |---:|---|---| @@ -1140,35 +420,16 @@ apiClient.interceptors.response.use( | 400 | `DIAGNOSIS_REQUIRED` | 提交治疗前没有提交诊断。 | | 400 | `TREATMENT_REQUIRED` | 生成评价前没有提交治疗。 | | 404 | `ORDER_ITEM_NOT_FOUND` | 当前病例不存在该检查项。 | +| 400 | `ORDER_ITEM_TYPE_MISMATCH` | 检查接口类型和检查项类型不匹配。 | | 404 | `EVALUATION_NOT_FOUND` | 评价不存在或不属于当前用户。 | -| 504 | `LLM_CALL_TIMEOUT` | 非流式问诊超时。 | | SSE error | `LLM_STREAM_TIMEOUT` | 流式问诊首段或总耗时超时。 | | SSE error | `LLM_STREAM_FAILED` | 流式模型调用失败。 | -| SSE error | `LLM_EMPTY_RESPONSE` | 模型未返回有效文本。 | | 500 | `PDF_EXPORT_FAILED` | PDF 生成失败。 | -| 400 | `CASE_SQL_IMPORT_INVALID` | 病例 SQL 预检或导入失败。 | -## 15. 前端功能验收顺序 +## 9. 当前保留接口清单 -1. 打开 `/health/ready`,确认返回 `status=ready`。 -2. 使用真实 token 调用 `/auth/me`,确认返回 Django 用户 `id`。 -3. 调用 `/agent/hello`,确认 `llm_mode=real`、`runtime_memory_backend=redis`。 -4. 获取病例列表和病例详情。 -5. 创建 `practice` 会话。 -6. 测试普通问诊和 SSE 流式问诊。 -7. 点击查看提示,确认返回动态提示。 -8. 动态读取检查项并申请检查,重复申请时确认 `already_ordered=true`。 -9. 完成问诊、提交诊断、提交治疗。 -10. 生成评价,确认包含 `dimension_scores` 和 `score_details`。 -11. 查询历史列表和评价详情。 -12. 生成 PDF,确认接口返回文件路径。 -13. 下载 PDF 文件流,确认浏览器触发文件下载。 +当前后端业务模块: -## 16. 当前前端必须了解的限制 - -- 无会话详情和聊天恢复接口,页面刷新后无法恢复短期问诊。 -- 问诊聊天只保存在 Redis 短期 memory 中,评价完成后释放。 -- PDF 支持两种调用:`export-pdf` 返回服务器文件路径,`download-pdf` 返回 `application/pdf` 文件流并触发浏览器下载。 -- 病例导入和删除接口尚未增加管理员角色授权,学生前端必须隐藏。 -- 所有检查项必须从 `/order-items` 动态读取,不能写死。 -- 正式训练模式只有 `practice` 和 `teaching`;`novice` 仅为旧接口兼容值。 +- 训练页面:推荐配置、训练配置、新建会话、流式会话、王主任练习提示、检查列表、检查结果、完成问诊、诊断、治疗、评价、评价详情、PDF 下载。 +- 教学互动:教学列表、教学评价、评价详情、PDF 下载。 +- 个人中心:训练记录列表、训练记录详情。 diff --git a/scripts/init_demo_db.py b/scripts/init_demo_db.py index 1ffc351..0f12521 100644 --- a/scripts/init_demo_db.py +++ b/scripts/init_demo_db.py @@ -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: diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index fc4dfb0..d45f4ee 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -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")