diff --git a/README.md b/README.md index 4932358..f4931ba 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,15 @@ curl "http://8.160.178.88/fastapi/api/v1/auth/me" \ -H "X-Entry-Scene: production_vue" ``` +验证 PDF 文件流下载: + +```bash +curl -L "http://8.160.178.88/fastapi/api/v1/evaluations//download-pdf" \ + -H "Authorization: Bearer " \ + -H "X-Entry-Scene: production_vue" \ + -o evaluation_report.pdf +``` + ## 测试 ```powershell @@ -173,3 +182,13 @@ python tests\test_core_logic.py python tests\test_api_contract.py python tests\test_demo_flow.py ``` + +当前测试覆盖训练页主要链路: + +- Django token 鉴权与 user_id 隔离。 +- 新建会话、流式问诊、练习提示。 +- 体格检查列表、辅助检查列表、单项检查结果和重复申请幂等。 +- 完成问诊、提交诊断、提交治疗、生成评价。 +- 评价详情、历史评价、PDF 路径导出、PDF 文件流下载和跨用户访问拒绝。 + +病例新增、解析、导入、删除不在本 FastAPI 服务中实现;本服务只读取数据库中已发布病例、检查项和评分规则。 diff --git a/app/agents/patient_agent.py b/app/agents/patient_agent.py index abc1b81..6f82f8c 100644 --- a/app/agents/patient_agent.py +++ b/app/agents/patient_agent.py @@ -73,8 +73,8 @@ class PatientAgent: 1. 不主动透露未被问到的隐藏信息。 2. 不替医生做诊断,不提供治疗方案。 3. 不编造病例外检查检验结果。 -4. 每次回答控制在1到3句话,使用患儿家属口吻,不输出分析过程。 -5. 只输出给医生看的家属回答纯文本,不输出 JSON、Markdown、标题、解释或思考过程。 +4. 每次回答控制在1到3句话,使用患者或家属口吻,不输出分析过程。 +5. 只输出给医生看的患者或家属回复纯文本,不输出 JSON、Markdown、标题、解释或思考过程。 6. 如果医生一次问多个问题,按问题顺序简短回答,不扩展病例外信息。 7. {mode_rule} """ @@ -86,16 +86,48 @@ class PatientAgent: def _build_patient_config_rule(self, patient_config: dict | None) -> str: """配置提示:把训练页初始化配置转成 AI 病人表达约束。""" if not patient_config: - return "使用默认门诊、青年、高等教育、平和性格的表达方式。" + return ( + "使用默认门诊、青年、高等教育、平和性格的表达方式。" + "配置只影响表达风格,不能改变病例事实、不能泄露隐藏信息、不能编造检查检验结果。" + ) + labels = patient_config.get("labels") if isinstance(patient_config, dict) else None values = labels or (patient_config.get("values") if isinstance(patient_config, dict) else {}) or {} visit_environment = values.get("visit_environment", "门诊") age_group = values.get("age_group", "青年") education_level = values.get("education_level", "高等教育") personality = values.get("personality", "平和") + + visit_rules = { + "门诊": "按门诊沟通节奏回答,病情描述相对稳定。", + "急诊": "语气更急迫,优先表达担忧和症状变化,但每次仍控制在1到3句话。", + "病房": "体现住院或病房随访语境,回答更偏病程观察和治疗反应。", + } + age_rules = { + "儿童": "以家属代述为主,避免让患儿直接使用成人化表达。", + "青年": "表达相对清楚,能主动配合基础问诊。", + "中年": "可体现工作、家庭负担和慢病背景对就诊的影响。", + "老年": "表达稍慢,关注基础病、用药史和照护者补充信息。", + } + education_rules = { + "小学及以下": "少用医学术语,症状描述更口语化,需要医生解释才理解专业概念。", + "中等教育": "能理解常见健康解释,但不主动使用专业诊断结论。", + "高等教育": "能清楚描述症状细节,但仍不能替医生做诊断。", + } + personality_rules = { + "平和": "情绪稳定,按问题回答。", + "焦虑": "回答中带担忧,可能追问孩子是否严重、是否需要住院。", + "急躁": "回答更短,更希望尽快得到处理结果。", + "配合": "愿意补充相关细节,但不主动泄露未被问到的隐藏信息。", + "多疑": "会追问检查、用药和治疗依据。", + } return ( f"就诊环境={visit_environment};年龄段={age_group};文化程度={education_level};性格={personality}。" - "回答时根据性格调整情绪和配合度,根据文化程度调整表达清晰度,但不得改变病例事实。" + f"就诊环境规则:{visit_rules.get(visit_environment, '按常规问诊节奏回答。')}" + f"年龄段规则:{age_rules.get(age_group, '按普通成年患者表达。')}" + f"文化程度规则:{education_rules.get(education_level, '按普通健康素养表达。')}" + f"性格规则:{personality_rules.get(personality, '按问题简短回答。')}" + "上述配置只影响语气、配合度、理解能力和表达细节;不能改变病例事实,不能主动泄露隐藏信息,不能编造检查检验结果。" ) def _to_llm_history(self, memory_messages: list[dict]) -> list[dict]: diff --git a/app/api/evaluations.py b/app/api/evaluations.py index 90462ce..13ade26 100644 --- a/app/api/evaluations.py +++ b/app/api/evaluations.py @@ -1,4 +1,7 @@ +from pathlib import Path + from fastapi import APIRouter, Depends +from fastapi.responses import FileResponse from sqlalchemy.orm import Session from app.core.response import ApiResponse, ok @@ -37,3 +40,20 @@ def export_pdf( export = PdfExportService(db).export(evaluation_id, ctx.user_id) db.commit() return ok(ExportPdfResponse(export_id=export.id, file_path=export.file_path)) + + +@router.get("/{evaluation_id}/download-pdf", response_class=FileResponse) +def download_pdf( + evaluation_id: int, + ctx: UserContext = Depends(get_user_context), + db: Session = Depends(get_db), +): + """PDF 下载:校验评价归属后生成报告,并以文件流方式触发浏览器下载。""" + export = PdfExportService(db).export(evaluation_id, ctx.user_id) + db.commit() + file_path = Path(export.file_path) + return FileResponse( + path=file_path, + media_type="application/pdf", + filename=file_path.name, + ) diff --git a/app/schemas/training_config.py b/app/schemas/training_config.py index 3dd147a..4c42b0f 100644 --- a/app/schemas/training_config.py +++ b/app/schemas/training_config.py @@ -1,5 +1,12 @@ +from typing import Literal + from pydantic import BaseModel +VisitEnvironment = Literal["outpatient", "emergency", "ward"] +AgeGroup = Literal["child", "youth", "middle_aged", "elderly"] +EducationLevel = Literal["primary_or_below", "secondary", "higher"] +Personality = Literal["calm", "anxious", "impatient", "cooperative", "suspicious"] + class ConfigOption(BaseModel): """训练配置选项:用于前端渲染单个可选项。""" @@ -12,14 +19,14 @@ class ConfigOption(BaseModel): class PatientConfig(BaseModel): """病人初始化配置:控制 AI 病人的就诊场景、年龄段、文化程度和性格。""" - visit_environment: str = "outpatient" - age_group: str = "youth" - education_level: str = "higher" - personality: str = "calm" + visit_environment: VisitEnvironment = "outpatient" + age_group: AgeGroup = "youth" + education_level: EducationLevel = "higher" + personality: Personality = "calm" class TrainingConfigOptionsResponse(BaseModel): - """训练配置响应:返回默认配置和全部可选项。""" + """训练配置响应:返回推荐配置和全部可选项。""" case_id: int recommended: PatientConfig diff --git a/app/services/order_service.py b/app/services/order_service.py index 84408f0..4718e35 100644 --- a/app/services/order_service.py +++ b/app/services/order_service.py @@ -39,11 +39,11 @@ class OrderService: def create_physical_exam_order(self, session_id: int, user_id: str, item_code: str) -> CreateOrderResponse: """体格检查结果:复用检查申请逻辑,结果仍只来自数据库。""" - return self.create_order(session_id, user_id, item_code) + return self._create_order(session_id, user_id, item_code, require_physical=True) def create_auxiliary_exam_order(self, session_id: int, user_id: str, item_code: str) -> CreateOrderResponse: """辅助检查结果:复用检查申请逻辑,结果仍只来自数据库。""" - return self.create_order(session_id, user_id, item_code) + return self._create_order(session_id, user_id, item_code, require_physical=False) def _items_response(self, items) -> OrderItemsResponse: """检查列表响应:把 ORM 检查项转换成前端列表结构。""" @@ -64,6 +64,10 @@ class OrderService: def create_order(self, session_id: int, user_id: str, item_code: str) -> CreateOrderResponse: """检查申请:从数据库读取检查结果并写入当前会话记录。""" + return self._create_order(session_id, user_id, item_code) + + def _create_order(self, session_id: int, user_id: str, item_code: str, require_physical: bool | None = None) -> CreateOrderResponse: + """检查申请:统一校验会话、项目类型和幂等逻辑,检查结果只来自数据库。""" session = self._get_session(session_id, user_id) if session.status not in {"inquiry", "diagnosis", "treatment"}: raise AppError("SESSION_STATUS_INVALID", "current session does not allow ordering", 400) @@ -71,6 +75,8 @@ class OrderService: item = self.case_repo.get_exam_item(session.case_id, item_code) if not item: raise AppError("ORDER_ITEM_NOT_FOUND", "order item not found for current case", 404) + if require_physical is not None and self._is_physical_item(item) != require_physical: + raise AppError("ORDER_ITEM_TYPE_MISMATCH", "order item type does not match current exam endpoint", 400) existing_order = self.session_repo.get_order_by_item(session.id, item.item_code) if existing_order: diff --git a/app/services/session_service.py b/app/services/session_service.py index 1585cd4..12365b1 100644 --- a/app/services/session_service.py +++ b/app/services/session_service.py @@ -49,7 +49,7 @@ class SessionService: if not case: raise AppError("CASE_NOT_FOUND", "case not found or inactive", 404) - patient_config = TrainingConfigService(self.db).normalize_patient_config(payload.patient_config) + patient_config = TrainingConfigService(self.db).normalize_patient_config(payload.patient_config, case) session_code = f"sess_{datetime.utcnow().strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:8]}" memory_key = f"mem:{session_code}" session = self.session_repo.create_session( diff --git a/app/services/training_config_service.py b/app/services/training_config_service.py index 45c53fc..8363a4d 100644 --- a/app/services/training_config_service.py +++ b/app/services/training_config_service.py @@ -1,6 +1,9 @@ +from typing import Any + from sqlalchemy.orm import Session from app.core.exceptions import AppError +from app.models.source_case import CaseBase from app.repositories.case_repository import CaseRepository from app.schemas.training_config import ( ConfigOption, @@ -18,9 +21,9 @@ class TrainingConfigService: self.case_repo = CaseRepository(db) def get_recommended(self, case_id: int) -> TrainingConfigRecommendedResponse: - """推荐配置:根据病例返回训练页默认病人初始化配置。""" - self._ensure_case(case_id) - recommended = self.default_patient_config() + """推荐配置:根据病例内容返回训练页默认病人初始化配置。""" + case = self._get_case(case_id) + recommended = self.default_patient_config(case) return TrainingConfigRecommendedResponse( case_id=case_id, recommended=recommended, @@ -29,9 +32,9 @@ class TrainingConfigService: ) def get_options(self, case_id: int) -> TrainingConfigOptionsResponse: - """配置选项:返回训练页自定义配置的全部可选项。""" - self._ensure_case(case_id) - recommended = self.default_patient_config() + """配置选项:返回训练页自定义病人初始化配置的全部可选项和病例推荐值。""" + case = self._get_case(case_id) + recommended = self.default_patient_config(case) return TrainingConfigOptionsResponse( case_id=case_id, recommended=recommended, @@ -39,18 +42,51 @@ class TrainingConfigService: options=self.config_options(), ) - def default_patient_config(self) -> PatientConfig: - """默认配置:按当前产品原型初始化病人信息。""" + def default_patient_config(self, case: CaseBase | None = None) -> PatientConfig: + """默认配置:按病例年龄、科室语义和病情关键词初始化 AI 病人沟通风格。""" + if case is None: + return PatientConfig( + visit_environment="outpatient", + age_group="youth", + education_level="higher", + personality="calm", + ) + + text = self._case_text(case) + patient_age = case.patient_age + + visit_environment = "outpatient" + personality = "calm" + if self._contains_any( + text, + ["急诊", "危重", "休克", "昏迷", "意识障碍", "呼吸困难", "低氧", "抢救", "SpO2 90", "血氧明显下降"], + ): + visit_environment = "emergency" + personality = "anxious" + elif self._contains_any(text, ["住院", "病房", "入院", "住院治疗"]): + visit_environment = "ward" + + age_group = "youth" + if (patient_age is not None and patient_age < 14) or self._contains_any( + text, + ["儿童", "患儿", "儿科", "小儿", "幼儿", "婴儿"], + ): + age_group = "child" + elif patient_age is not None and patient_age >= 65: + age_group = "elderly" + elif patient_age is not None and patient_age >= 45: + age_group = "middle_aged" + return PatientConfig( - visit_environment="outpatient", - age_group="youth", + visit_environment=visit_environment, + age_group=age_group, education_level="higher", - personality="calm", + personality=personality, ) - def normalize_patient_config(self, config: PatientConfig | None) -> dict: + def normalize_patient_config(self, config: PatientConfig | None, case: CaseBase | None = None) -> dict: """配置归一:校验并补齐前端传入的病人初始化配置。""" - selected = config or self.default_patient_config() + selected = config or self.default_patient_config(case) values = selected.model_dump() allowed = {key: {item.value for item in items} for key, items in self.config_options().items()} for key, value in values.items(): @@ -75,7 +111,7 @@ class TrainingConfigService: return { "visit_environment": [ ConfigOption(value="outpatient", label="门诊", description="适合常规问诊训练"), - ConfigOption(value="emergency", label="急诊", description="病情紧急、沟通节奏更快"), + ConfigOption(value="emergency", label="急诊", description="病情紧急,沟通节奏更快"), ConfigOption(value="ward", label="病房", description="适合住院病情追踪和处置沟通"), ], "age_group": [ @@ -98,7 +134,49 @@ class TrainingConfigService: ], } - def _ensure_case(self, case_id: int) -> None: - """病例校验:确认配置请求对应已发布病例。""" - if not self.case_repo.get_active_case(case_id): + def _get_case(self, case_id: int) -> CaseBase: + """病例校验:读取配置请求对应的已发布病例。""" + case = self.case_repo.get_active_case(case_id) + if not case: raise AppError("CASE_NOT_FOUND", "case not found or inactive", 404) + return case + + def _case_text(self, case: CaseBase) -> str: + """病例文本:拼接用于推荐配置推断的病例字段。""" + parts: list[str] = [ + case.title or "", + case.chief_complaint or "", + case.description or "", + case.tags or "", + case.patient_gender or "", + ] + for field_name in ( + "symptom_tags", + "disease_tags", + "competency_tags", + "guideline_tags", + "knowledge_points", + "icd_codes", + ): + parts.extend(self._stringify(getattr(case, field_name, None))) + if case.traditional_case: + parts.extend(self._stringify(case.traditional_case.guideline_reference)) + if case.teaching_case: + parts.extend(self._stringify(case.teaching_case.teaching_goal)) + return " ".join(item for item in parts if item) + + def _stringify(self, value: Any) -> list[str]: + """字段展开:把 JSON、列表和普通文本统一转换为可检索字符串。""" + if value is None: + return [] + if isinstance(value, str): + return [value] + if isinstance(value, dict): + return [str(item) for pair in value.items() for item in pair if item is not None] + if isinstance(value, list | tuple | set): + return [str(item) for item in value if item is not None] + return [str(value)] + + def _contains_any(self, text: str, keywords: list[str]) -> bool: + """关键词判断:判断病例文本是否命中任一推荐配置关键词。""" + return any(keyword in text for keyword in keywords) diff --git a/docs/03_api_design.md b/docs/03_api_design.md index 08e20be..b5c15b7 100644 --- a/docs/03_api_design.md +++ b/docs/03_api_design.md @@ -1,7 +1,7 @@ -# 医疗问诊 Agent 前端联调 API 文档 +# 医疗问诊 Agent 前端联调 API 文档 -> 文档版本:2026-06-04 -> 对应后端:FastAPI `main` 分支当前版本 +> 文档版本:2026-06-04 +> 对应后端:FastAPI `main` 分支,提交 `b46e43a` 之后版本 > 本文档以当前真实代码行为为准,用于正式 Vue 前端功能联调。 ## 1. 联调地址 @@ -81,31 +81,28 @@ if (!response.ok || body.code !== "OK") { } ``` +### 2.4 CORS 配置 + +正式前端通过 `http://8.160.178.88/app/` 访问 API 时,与 `/fastapi/` 同源,不会触发跨域限制。 + +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.0 训练页面交付接口表 - -以下表格为前端训练页面当前交付接口。`url` 使用公网网关前缀,前端实际调用时统一在 `api` 前拼接 `http://8.160.178.88/fastapi`。 - -| 模块 | 接口名称 | url | api | methods | params | response | 说明 | -|---|---|---|---|---|---|---|---| -| 训练页面 | 推荐配置信息 | `http://8.160.178.88/fastapi/api/v1/training-config/recommended` | `/api/v1/training-config/recommended` | `GET` | Query:`case_id` | `recommended`、`recommended_labels`、`options` | 初始化训练页病人配置,默认选中门诊、青年、高等教育、平和。 | -| 训练页面 | 训练配置信息 | `http://8.160.178.88/fastapi/api/v1/training-config/options` | `/api/v1/training-config/options` | `GET` | Query:`case_id` | `recommended`、`recommended_labels`、`options` | 返回自定义配置全部可选项,用于前端渲染就诊环境、年龄段、文化程度、性格。 | -| 训练页面 | 新建会话 | `http://8.160.178.88/fastapi/api/v1/sessions` | `/api/v1/sessions` | `POST` | Body:`case_id`、`training_type`、`mode`、`score_type`、`patient_config` | `session_id`、`session_code`、`status`、`patient_opening`、`patient_config` | 创建训练会话并把病人初始化配置写入会话 metadata。 | -| 训练页面 | 流式会话 | `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` | 医学生问诊,AI 病人按病例和配置流式回复。 | -| 训练页面 | 练习提示 | `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` | SSE:`hint_delta`、`hint_done`、`error` | 返回一句话形式的练习提示,前端点击后按需展示。旧 JSON 接口 `/hints` 保留兼容。 | -| 训练页面 | 体格检查列表获取 | `http://8.160.178.88/fastapi/api/v1/sessions/{session_id}/physical-exams` | `/api/v1/sessions/{session_id}/physical-exams` | `GET` | Path:`session_id` | `items[]` | 从当前病例检查项中筛选体格检查类项目。暂无独立表,复用 `case_exam_item`。 | -| 训练页面 | 辅助检查列表获取 | `http://8.160.178.88/fastapi/api/v1/sessions/{session_id}/auxiliary-exams` | `/api/v1/sessions/{session_id}/auxiliary-exams` | `GET` | Path:`session_id` | `items[]` | 从当前病例检查项中筛选辅助检查类项目。暂无独立表,复用 `case_exam_item`。 | -| 训练页面 | 体格检查某项结果 | `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` | 检查结果结构 | 返回数据库固定检查结果,并写入本次会话上下文。 | -| 训练页面 | 辅助检查某项结果 | `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` | 检查结果结构 | 返回数据库固定检查结果,并写入本次会话上下文。 | -| 训练页面 | 完成问诊 | `http://8.160.178.88/fastapi/api/v1/sessions/{session_id}/complete-inquiry` | `/api/v1/sessions/{session_id}/complete-inquiry` | `POST` | Path:`session_id` | `session_id`、`status` | 从问诊阶段进入诊断阶段。 | -| 训练页面 | 提交诊断 | `http://8.160.178.88/fastapi/api/v1/sessions/{session_id}/diagnosis` | `/api/v1/sessions/{session_id}/diagnosis` | `POST` | Path:`session_id`;Body:诊断内容 | `status` | 保存主要诊断、鉴别诊断和诊断依据。 | -| 训练页面 | 提交治疗 | `http://8.160.178.88/fastapi/api/v1/sessions/{session_id}/treatment` | `/api/v1/sessions/{session_id}/treatment` | `POST` | Path:`session_id`;Body:治疗内容 | `status` | 保存治疗原则、措施、风险预案、沟通和随访。 | -| 训练页面 | 生成评价 | `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` | 评价报告结构 | 调用评分 Agent,写入 `training_record` 和 `training_score_detail`。 | -| 训练页面 | 获取评价(详情) | `http://8.160.178.88/fastapi/api/v1/evaluations/{evaluation_id}` | `/api/v1/evaluations/{evaluation_id}` | `GET` | Path:`evaluation_id` | 评价详情和评分明细 | 按当前 token 对应用户隔离查询。 | -| 训练页面 | 下载 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` | `export_id`、`file_path` | 生成本地 PDF 报告路径。 | -| 训练页面 | 历史评价列表 | `http://8.160.178.88/fastapi/api/v1/evaluations` | `/api/v1/evaluations` | `GET` | 无 | `items[]` | 查询当前 token 对应用户的完整训练评价历史。 | - ### 3.1 无需认证 | Method | Path | 用途 | @@ -123,13 +120,13 @@ if (!response.ok || body.code !== "OK") { | `GET` | `/agent/hello` | 获取 Agent 能力开关。 | 必须 | | `GET` | `/cases` | 获取病例列表。 | 必须 | | `GET` | `/cases/{case_id}` | 获取病例入口详情。 | 必须 | -| `GET` | `/training-config/recommended` | 获取推荐配置信息。 | 必须 | -| `GET` | `/training-config/options` | 获取训练配置选项。 | 必须 | +| `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 一句话练习提示。 | 必须 | +| `POST` | `/sessions/{session_id}/hints/stream` | SSE 流式练习提示。 | 必须 | | `GET` | `/sessions/{session_id}/order-items` | 获取病例可申请检查项。 | 必须 | | `POST` | `/sessions/{session_id}/orders` | 申请检查并返回结果。 | 必须 | | `GET` | `/sessions/{session_id}/physical-exams` | 获取体格检查列表。 | 必须 | @@ -143,12 +140,11 @@ if (!response.ok || body.code !== "OK") { | `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 @@ -164,6 +160,7 @@ GET /auth/me -> POST /sessions/{session_id}/evaluation -> GET /evaluations/{evaluation_id} -> POST /evaluations/{evaluation_id}/export-pdf + -> GET /evaluations/{evaluation_id}/download-pdf ``` 会话状态流转: @@ -182,6 +179,41 @@ inquiry -> diagnosis -> treatment -> evaluating -> completed 当前没有“获取会话详情”“恢复聊天记录”接口。页面刷新或中断后,前端不能恢复短期对话,应重新创建训练会话。 +### 4.1 训练页面当前使用接口明细 + +下表为正式前端训练页面当前需要直接调用的接口。`url` 为公网联调完整地址,`api` 为前端代码中拼接的相对路径。除健康检查外,以下接口都需要携带 `Authorization: Bearer `;`X-Entry-Scene` 建议固定传 `vue_frontend` 或实际入口场景。 + +| 接口名称 | 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。 | + +常见错误码: + +| 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 获取当前用户 @@ -352,15 +384,23 @@ GET /api/v1/cases/{case_id} } ``` -## 7. 创建训练会话 +### 6.3 推荐配置信息 -### 7.1 推荐配置信息 +推荐配置信息用于训练页初始化病人沟通风格。后端会读取病例主表内容,根据病例年龄、标题、主诉、描述和标签推断默认值。例如儿科患儿病例默认推荐 `age_group=child`;急诊、危重、低氧等关键词会推荐 `visit_environment=emergency` 和 `personality=anxious`。 ```http GET /api/v1/training-config/recommended?case_id=2 ``` -响应: +入参: + +```json +{ + "case_id": 2 +} +``` + +返回: ```json { @@ -370,37 +410,39 @@ GET /api/v1/training-config/recommended?case_id=2 "case_id": 2, "recommended": { "visit_environment": "outpatient", - "age_group": "youth", + "age_group": "child", "education_level": "higher", "personality": "calm" }, "recommended_labels": { "visit_environment": "门诊", - "age_group": "青年", + "age_group": "儿童", "education_level": "高等教育", "personality": "平和" }, - "options": { - "visit_environment": [ - {"value": "outpatient", "label": "门诊", "description": "适合常规问诊训练"} - ], - "age_group": [], - "education_level": [], - "personality": [] - } + "options": {} } } ``` -### 7.2 训练配置信息 +### 6.4 训练配置信息 + +训练配置信息用于前端渲染自定义配置页面,返回推荐值和全部可选项。前端可以直接使用 `recommended` 作为默认选中项,用户修改后把 `patient_config` 传给创建会话接口。 ```http GET /api/v1/training-config/options?case_id=2 ``` -该接口返回和推荐配置信息相同的结构,前端使用 `options` 渲染自定义配置按钮,使用 `recommended` 初始化默认选中项。 +返回字段与推荐配置信息一致,`options` 包含以下可选值: -### 7.3 新建会话 +| 配置字段 | 可选值 | 说明 | +|---|---|---| +| `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 @@ -417,7 +459,7 @@ Content-Type: application/json "score_type": "percentage", "patient_config": { "visit_environment": "outpatient", - "age_group": "youth", + "age_group": "child", "education_level": "higher", "personality": "calm" } @@ -431,12 +473,13 @@ 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` | 就诊环境。 | -| `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` | 病人性格配置。 | +| `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` 为可选字段;未传时后端会根据当前病例自动使用推荐配置。前端训练页已调用推荐配置接口时,应把用户最终选中的配置传入本接口。 响应: @@ -452,13 +495,13 @@ Content-Type: application/json "patient_config": { "values": { "visit_environment": "outpatient", - "age_group": "youth", + "age_group": "child", "education_level": "higher", "personality": "calm" }, "labels": { "visit_environment": "门诊", - "age_group": "青年", + "age_group": "儿童", "education_level": "高等教育", "personality": "平和" } @@ -586,8 +629,6 @@ async function streamChat(baseUrl: string, token: string, sessionId: number, mes ### 8.3 练习提示 -#### 8.3.1 JSON 兼容接口 - ```http POST /api/v1/sessions/{session_id}/hints ``` @@ -630,43 +671,6 @@ POST /api/v1/sessions/{session_id}/hints - 仅 `inquiry` 状态可调用。 - 前端默认不自动展示提示,由用户点击后调用。 -#### 8.3.2 SSE 一句话提示 - -```http -POST /api/v1/sessions/{session_id}/hints/stream -Content-Type: application/json -Accept: text/event-stream -``` - -请求: - -```json -{ - "last_user_message": "孩子发热几天了?最高体温多少?", - "scope": "current_conversation" -} -``` - -事件格式: - -```text -event: hint_delta -data: {"delta":"当前可补充既往史、严重程度评估;"} - -event: hint_done -data: {"latency_ms":1200} - -event: error -data: {"code":"HINT_STREAM_FAILED","message":"练习提示生成失败,请稍后重试"} -``` - -前端规则: - -- 该接口用于训练页面“查看提示”的新交互。 -- 返回内容为一句话形式,适合在提示区域流式展示。 -- 如果收到 `error`,前端结束 loading 并展示错误。 -- 旧 JSON 接口 `/hints` 保留,用于需要结构化缺失维度和推荐检查的页面。 - ## 9. 检查与检验 ### 9.1 获取当前病例检查项 @@ -695,23 +699,7 @@ GET /api/v1/sessions/{session_id}/order-items 前端不得写死检查项编码。不同病例拥有不同检查项,必须使用本接口返回的 `item_code`。 -### 9.2 获取体格检查列表 - -```http -GET /api/v1/sessions/{session_id}/physical-exams -``` - -响应结构与 `/order-items` 相同。当前没有独立体格检查表,后端按 `item_type`、`category`、`item_name` 从 `case_exam_item` 中识别体格检查类项目。 - -### 9.3 获取辅助检查列表 - -```http -GET /api/v1/sessions/{session_id}/auxiliary-exams -``` - -响应结构与 `/order-items` 相同。当前没有独立辅助检查表,非体格检查类项目均归入辅助检查。 - -### 9.4 申请检查 +### 9.2 申请检查 ```http POST /api/v1/sessions/{session_id}/orders @@ -756,22 +744,6 @@ POST /api/v1/sessions/{session_id}/orders - 前端按 `item_code` 去重;点击后立即禁用,避免重复请求。 - 检查申请允许在 `inquiry`、`diagnosis`、`treatment` 状态执行。 -### 9.5 获取体格检查某项结果 - -```http -POST /api/v1/sessions/{session_id}/physical-exams/{item_code} -``` - -响应结构与 `/orders` 相同。该接口用于前端按“体格检查”分区调用,内部仍复用检查申请逻辑。 - -### 9.6 获取辅助检查某项结果 - -```http -POST /api/v1/sessions/{session_id}/auxiliary-exams/{item_code} -``` - -响应结构与 `/orders` 相同。该接口用于前端按“辅助检查”分区调用,内部仍复用检查申请逻辑。 - ## 10. 阶段提交 ### 10.1 完成问诊 @@ -988,17 +960,123 @@ POST /api/v1/evaluations/{evaluation_id}/export-pdf } ``` -当前接口只生成 PDF 并返回服务器内部文件路径,没有提供浏览器文件下载流。前端功能测试阶段展示“导出成功”和文件路径即可。正式下载接口需要后续增加受鉴权保护的文件响应接口。 +该接口只生成 PDF 并返回服务器内部文件路径,适合前端展示“导出成功”和保存导出记录。 -## 12. 调试接口 +### 11.5 下载 PDF 文件流 -### 12.1 知识检索 +```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 请求,然后创建临时下载链接: + +```ts +async function downloadEvaluationPdf(baseUrl: string, token: string, evaluationId: number) { + const response = await fetch(`${baseUrl}/evaluations/${evaluationId}/download-pdf`, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + "X-Entry-Scene": "vue_frontend", + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => null); + throw new Error(error?.message || `PDF 下载失败:${response.status}`); + } + + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `evaluation_${evaluationId}.pdf`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} +``` + +## 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.2 LLM 测试 +### 12.5 LLM 测试 ```http POST /api/v1/llm/test/deepseek-fast @@ -1068,6 +1146,7 @@ apiClient.interceptors.response.use( | SSE error | `LLM_STREAM_FAILED` | 流式模型调用失败。 | | SSE error | `LLM_EMPTY_RESPONSE` | 模型未返回有效文本。 | | 500 | `PDF_EXPORT_FAILED` | PDF 生成失败。 | +| 400 | `CASE_SQL_IMPORT_INVALID` | 病例 SQL 预检或导入失败。 | ## 15. 前端功能验收顺序 @@ -1082,13 +1161,14 @@ apiClient.interceptors.response.use( 9. 完成问诊、提交诊断、提交治疗。 10. 生成评价,确认包含 `dimension_scores` 和 `score_details`。 11. 查询历史列表和评价详情。 -12. 导出 PDF,确认接口返回文件路径。 +12. 生成 PDF,确认接口返回文件路径。 +13. 下载 PDF 文件流,确认浏览器触发文件下载。 ## 16. 当前前端必须了解的限制 - 无会话详情和聊天恢复接口,页面刷新后无法恢复短期问诊。 - 问诊聊天只保存在 Redis 短期 memory 中,评价完成后释放。 -- PDF 导出当前只返回服务器文件路径,不直接下载文件。 -- 病例库为只读数据源;新增、解析、修改和删除由外部病例管理系统完成。 +- PDF 支持两种调用:`export-pdf` 返回服务器文件路径,`download-pdf` 返回 `application/pdf` 文件流并触发浏览器下载。 +- 病例导入和删除接口尚未增加管理员角色授权,学生前端必须隐藏。 - 所有检查项必须从 `/order-items` 动态读取,不能写死。 - 正式训练模式只有 `practice` 和 `teaching`;`novice` 仅为旧接口兼容值。 diff --git a/scripts/init_demo_db.py b/scripts/init_demo_db.py index ee063f2..1ffc351 100644 --- a/scripts/init_demo_db.py +++ b/scripts/init_demo_db.py @@ -134,6 +134,7 @@ def _seed_exam_items(db, case_id: int) -> None: if db.scalar(select(CaseExamItem).where(CaseExamItem.case_id == case_id)): return items = [ + ("lung_auscultation", "肺部听诊", "physical_exam", "双肺呼吸音粗,可闻及散在湿啰音,右下肺较明显。", {"finding": "散在湿啰音", "location": "右下肺明显"}, True, True, 0), ("blood_routine", "血常规", "lab", "WBC 12.5×10^9/L,中性粒细胞比例72%,提示感染及炎症反应。", {"wbc": "12.5×10^9/L", "neutrophil": "72%"}, True, True, 1), ("crp", "CRP", "lab", "CRP 28 mg/L,提示炎症反应升高。", {"crp": "28 mg/L"}, True, True, 2), ("chest_xray", "胸片", "imaging", "双下肺纹理增多,右下肺片状模糊影,支持肺部感染。", {"finding": "右下肺片状模糊影"}, True, True, 3), diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index 579b070..fc4dfb0 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -6,6 +6,7 @@ from pathlib import Path TEST_DB_PATH = Path(tempfile.gettempdir()) / "medical_agent_test_api_contract.db" TEST_DB_PATH.unlink(missing_ok=True) os.environ["DATABASE_URL"] = f"sqlite:///{TEST_DB_PATH.as_posix()}" +os.environ["REPORT_STORAGE_DIR"] = str(Path(tempfile.gettempdir()) / "medical_agent_test_api_reports") os.environ.setdefault("RUNTIME_MEMORY_BACKEND", "memory") os.environ.setdefault("LLM_MOCK_ENABLED", "true") os.environ.setdefault("AUTH_USER_ME_URL", "http://django-user-center.test/api/user/users/me/") @@ -96,6 +97,7 @@ def run_api_contract_tests() -> None: assert "/api/v1/sessions/{session_id}/auxiliary-exams" in openapi_payload["paths"] 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"] cases = client.get("/api/v1/cases", headers=headers) assert cases.status_code == 200 @@ -104,12 +106,21 @@ def run_api_contract_tests() -> None: 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" - assert recommended_config.json()["data"]["recommended_labels"]["visit_environment"] == "门诊" + assert recommended_config.json()["data"]["recommended"]["age_group"] == "child" + assert recommended_config.json()["data"]["recommended_labels"]["age_group"] == "儿童" config_options = client.get(f"/api/v1/training-config/options?case_id={case_id}", headers=headers) assert config_options.status_code == 200 assert config_options.json()["data"]["options"]["personality"] + recommended_session = client.post( + "/api/v1/sessions", + headers=headers, + json={"case_id": case_id, "training_type": "diagnosis_treatment", "mode": "practice", "score_type": "percentage"}, + ) + assert recommended_session.status_code == 200 + assert recommended_session.json()["data"]["patient_config"]["values"]["age_group"] == "child" + created = client.post( "/api/v1/sessions", headers=headers, @@ -130,6 +141,35 @@ def run_api_contract_tests() -> None: session_id = created.json()["data"]["session_id"] assert created.json()["data"]["patient_config"]["labels"]["personality"] == "平和" + with client.stream( + "POST", + f"/api/v1/sessions/{session_id}/chat/stream", + headers=headers, + json={"message": "孩子发热几天了?最高体温多少?"}, + ) as chat_stream: + assert chat_stream.status_code == 200 + chat_stream_text = "".join(chat_stream.iter_text()) + assert "event: message_delta" in chat_stream_text + assert "event: message_done" in chat_stream_text + + invalid_config = client.post( + "/api/v1/sessions", + headers=headers, + json={ + "case_id": case_id, + "training_type": "diagnosis_treatment", + "mode": "practice", + "score_type": "percentage", + "patient_config": { + "visit_environment": "invalid_scene", + "age_group": "youth", + "education_level": "higher", + "personality": "calm", + }, + }, + ) + assert invalid_config.status_code == 422 + cross_user = client.get( f"/api/v1/sessions/{session_id}/order-items", headers={"Authorization": "Bearer api_user_002_token", "X-Entry-Scene": "api_test"}, @@ -151,6 +191,15 @@ def run_api_contract_tests() -> None: physical_list = client.get(f"/api/v1/sessions/{session_id}/physical-exams", headers=headers) assert physical_list.status_code == 200 assert "items" in physical_list.json()["data"] + assert any(item["item_code"] == "lung_auscultation" for item in physical_list.json()["data"]["items"]) + + physical_result = client.post(f"/api/v1/sessions/{session_id}/physical-exams/lung_auscultation", headers=headers) + assert physical_result.status_code == 200 + assert physical_result.json()["data"]["item_type"] == "physical_exam" + + physical_mismatch = client.post(f"/api/v1/sessions/{session_id}/physical-exams/blood_routine", headers=headers) + assert physical_mismatch.status_code == 400 + assert physical_mismatch.json()["code"] == "ORDER_ITEM_TYPE_MISMATCH" auxiliary_list = client.get(f"/api/v1/sessions/{session_id}/auxiliary-exams", headers=headers) assert auxiliary_list.status_code == 200 @@ -160,6 +209,76 @@ def run_api_contract_tests() -> None: assert auxiliary_result.status_code == 200 assert auxiliary_result.json()["data"]["already_ordered"] is True + completed = client.post(f"/api/v1/sessions/{session_id}/complete-inquiry", headers=headers) + assert completed.status_code == 200 + assert completed.json()["data"]["status"] == "diagnosis" + + submitted_diagnosis = client.post( + f"/api/v1/sessions/{session_id}/diagnosis", + headers=headers, + json={ + "primary_diagnosis": "支气管肺炎", + "differential_diagnoses": ["毛细支气管炎", "支气管哮喘急性发作"], + "diagnosis_basis": "发热、咳嗽、喘息,肺部湿啰音,胸片和炎症指标支持肺部感染。", + }, + ) + assert submitted_diagnosis.status_code == 200 + assert submitted_diagnosis.json()["data"]["status"] == "treatment" + + submitted_treatment = client.post( + f"/api/v1/sessions/{session_id}/treatment", + headers=headers, + json={ + "treatment_principle": "抗感染、止咳平喘、改善氧合并观察病情变化。", + "treatment_measures": "根据病情选择抗感染治疗,必要时雾化吸入,监测体温、呼吸和血氧。", + "risk_plan": "关注低氧、呼吸困难加重、持续高热和精神反应差。", + "communication": "向家属说明病情、用药注意事项和复诊指征。", + "follow_up": "治疗后复查体温、呼吸、血氧和必要炎症指标。", + }, + ) + assert submitted_treatment.status_code == 200 + assert submitted_treatment.json()["data"]["status"] == "evaluating" + + evaluation = client.post(f"/api/v1/sessions/{session_id}/evaluation", headers=headers, json={"score_type": "percentage"}) + assert evaluation.status_code == 200 + evaluation_id = evaluation.json()["data"]["evaluation_id"] + assert evaluation.json()["data"]["score_details"] + + detail = client.get(f"/api/v1/evaluations/{evaluation_id}", headers=headers) + assert detail.status_code == 200 + assert detail.json()["data"]["evaluation_id"] == evaluation_id + + cross_user_detail = client.get( + f"/api/v1/evaluations/{evaluation_id}", + headers={"Authorization": "Bearer api_user_002_token", "X-Entry-Scene": "api_test"}, + ) + assert cross_user_detail.status_code == 404 + assert cross_user_detail.json()["code"] == "EVALUATION_NOT_FOUND" + + pdf = client.post(f"/api/v1/evaluations/{evaluation_id}/export-pdf", headers=headers) + assert pdf.status_code == 200 + assert pdf.json()["data"]["file_path"] + + pdf_download = client.get(f"/api/v1/evaluations/{evaluation_id}/download-pdf", headers=headers) + assert pdf_download.status_code == 200 + assert pdf_download.headers["content-type"].startswith("application/pdf") + assert "attachment" in pdf_download.headers.get("content-disposition", "") + assert pdf_download.content.startswith(b"%PDF") + + cross_user_pdf = client.post( + f"/api/v1/evaluations/{evaluation_id}/export-pdf", + headers={"Authorization": "Bearer api_user_002_token", "X-Entry-Scene": "api_test"}, + ) + assert cross_user_pdf.status_code == 404 + assert cross_user_pdf.json()["code"] == "EVALUATION_NOT_FOUND" + + cross_user_pdf_download = client.get( + f"/api/v1/evaluations/{evaluation_id}/download-pdf", + headers={"Authorization": "Bearer api_user_002_token", "X-Entry-Scene": "api_test"}, + ) + assert cross_user_pdf_download.status_code == 404 + assert cross_user_pdf_download.json()["code"] == "EVALUATION_NOT_FOUND" + practice_hint_session = client.post( "/api/v1/sessions", headers=headers, diff --git a/tests/test_core_logic.py b/tests/test_core_logic.py index 59cb306..be5957b 100644 --- a/tests/test_core_logic.py +++ b/tests/test_core_logic.py @@ -11,6 +11,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from app.agents.scoring_agent import ScoringAgent from app.agents.hint_agent import HintAgent from app.agents.llm_adapter import OpenAICompatibleLLMClient +from app.agents.patient_agent import PatientAgent from app.core.config import settings from app.services.runtime_memory import InMemoryRuntimeMemoryService @@ -78,10 +79,30 @@ def test_hint_agent_invalid_json_fallback() -> None: assert result["recommended_orders"] +def test_patient_agent_config_rule_boundary() -> None: + """病人配置提示:验证训练页初始化配置进入 Patient Agent 且不改变病例事实边界。""" + agent = PatientAgent() + rule = agent._build_patient_config_rule( + { + "labels": { + "visit_environment": "急诊", + "age_group": "儿童", + "education_level": "小学及以下", + "personality": "焦虑", + } + } + ) + assert "就诊环境=急诊" in rule + assert "以家属代述为主" in rule + assert "不能改变病例事实" in rule + assert "不能编造检查检验结果" in rule + + if __name__ == "__main__": test_runtime_memory_lifecycle() test_score_convert_to_five_point() test_public_settings() test_reasoning_effort_disabled_when_thinking_off() test_hint_agent_invalid_json_fallback() + test_patient_agent_config_rule_boundary() print("core logic tests passed") diff --git a/tests/test_demo_flow.py b/tests/test_demo_flow.py index c3423df..aee612a 100644 --- a/tests/test_demo_flow.py +++ b/tests/test_demo_flow.py @@ -79,7 +79,10 @@ async def run_demo_flow() -> None: auxiliary_items = order_service.list_auxiliary_exam_items(created.session_id, ctx.user_id) assert any(item.item_code == "chest_xray" for item in auxiliary_items.items) physical_items = order_service.list_physical_exam_items(created.session_id, ctx.user_id) - assert physical_items.items == [] or all(item.item_code != "chest_xray" for item in physical_items.items) + assert any(item.item_code == "lung_auscultation" for item in physical_items.items) + physical_order = order_service.create_physical_exam_order(created.session_id, ctx.user_id, "lung_auscultation") + db.commit() + assert physical_order.item_type == "physical_exam" tool_count_before = len([item for item in runtime_memory.get_messages(f"mem:{created.session_code}") if item.get("role") == "tool"]) duplicate_order = order_service.create_order(created.session_id, ctx.user_id, "chest_xray")