diff --git a/.gitignore b/.gitignore index a9993bd..2f47aee 100644 --- a/.gitignore +++ b/.gitignore @@ -37,7 +37,8 @@ reports/ uploads/ # Demo-only or temporary files -docs/ +docs/* +!docs/03_api_design.md demo_frontend/ scripts/check_mysql_demo.ps1 scripts/init_mysql_demo.ps1 diff --git a/app/agents/orchestrator.py b/app/agents/orchestrator.py index 9918ca3..9df1e36 100644 --- a/app/agents/orchestrator.py +++ b/app/agents/orchestrator.py @@ -20,7 +20,7 @@ class MedicalConsultationOrchestrator: async def patient_reply(self, session: TrainingSession, case: CaseBase, memory_messages: list[dict], message: str) -> LLMResponse: """问诊编排:调用 Patient Agent 生成 AI 病人回复。""" - return await self.patient_agent.reply(case, memory_messages, message, session.mode) + return await self.patient_agent.reply(case, memory_messages, message, session.mode, self._patient_config(session)) async def patient_stream_reply( self, @@ -30,7 +30,7 @@ class MedicalConsultationOrchestrator: message: str, ) -> AsyncIterator[LLMStreamChunk]: """流式问诊编排:调用 Patient Agent 并返回流式片段。""" - async for chunk in self.patient_agent.stream_reply(case, memory_messages, message, session.mode): + async for chunk in self.patient_agent.stream_reply(case, memory_messages, message, session.mode, self._patient_config(session)): yield chunk async def evaluate( @@ -67,3 +67,9 @@ class MedicalConsultationOrchestrator: ) -> dict: """新手提示编排:基于当前会话上下文生成轻量训练提醒。""" return await self.hint_agent.generate(session, case, memory_messages, orders, last_user_message) + + def _patient_config(self, session: TrainingSession) -> dict | None: + """病人配置:从会话 metadata 读取训练页初始化配置,传递给 Patient Agent。""" + metadata = session.metadata_ or {} + patient_config = metadata.get("patient_config") if isinstance(metadata, dict) else None + return patient_config if isinstance(patient_config, dict) else None diff --git a/app/agents/patient_agent.py b/app/agents/patient_agent.py index 7f970de..abc1b81 100644 --- a/app/agents/patient_agent.py +++ b/app/agents/patient_agent.py @@ -11,9 +11,16 @@ class PatientAgent: def __init__(self, llm: DeepSeekClient | None = None) -> None: self.llm = llm or DeepSeekClient() - async def reply(self, case: CaseBase, memory_messages: list[dict], user_message: str, mode: str) -> LLMResponse: + async def reply( + self, + case: CaseBase, + memory_messages: list[dict], + user_message: str, + mode: str, + patient_config: dict | None = None, + ) -> LLMResponse: """问诊回复:拼接病例上下文、短期记忆和用户输入后调用 Patient Agent。""" - messages = self._build_messages(case, memory_messages, user_message, mode) + messages = self._build_messages(case, memory_messages, user_message, mode, patient_config) return await self.llm.chat( messages, settings.llm_fast_model, @@ -27,9 +34,10 @@ class PatientAgent: memory_messages: list[dict], user_message: str, mode: str, + patient_config: dict | None = None, ) -> AsyncIterator[LLMStreamChunk]: """流式问诊:以 SSE 方式返回 AI 病人增量回复。""" - messages = self._build_messages(case, memory_messages, user_message, mode) + messages = self._build_messages(case, memory_messages, user_message, mode, patient_config) async for chunk in self.llm.stream_chat( messages, settings.llm_fast_model, @@ -38,10 +46,18 @@ class PatientAgent: ): yield chunk - def _build_messages(self, case: CaseBase, memory_messages: list[dict], user_message: str, mode: str) -> list[dict]: + def _build_messages( + self, + case: CaseBase, + memory_messages: list[dict], + user_message: str, + mode: str, + patient_config: dict | None = None, + ) -> list[dict]: """提示词拼接:构造 AI 病人的系统提示词和对话历史。""" profile = case.ai_patient_profile or {} hidden_info = case.hidden_patient_info or {} + config_rule = self._build_patient_config_rule(patient_config) mode_rule = { "novice": "新手模式:回答清楚,必要时可提示医生继续追问症状、既往史或检查。", "practice": "练习模式:只回答被问到的信息,不主动给诊断建议。", @@ -52,6 +68,7 @@ class PatientAgent: 病例主诉:{case.chief_complaint} 患者人设:{profile} 隐藏信息:{hidden_info} +病人初始化配置:{config_rule} 回答规则: 1. 不主动透露未被问到的隐藏信息。 2. 不替医生做诊断,不提供治疗方案。 @@ -66,6 +83,21 @@ class PatientAgent: messages.append({"role": "user", "content": user_message}) return messages + def _build_patient_config_rule(self, patient_config: dict | None) -> str: + """配置提示:把训练页初始化配置转成 AI 病人表达约束。""" + if not patient_config: + 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", "平和") + return ( + f"就诊环境={visit_environment};年龄段={age_group};文化程度={education_level};性格={personality}。" + "回答时根据性格调整情绪和配合度,根据文化程度调整表达清晰度,但不得改变病例事实。" + ) + def _to_llm_history(self, memory_messages: list[dict]) -> list[dict]: """历史转换:把业务角色 doctor/patient 转换为 LLM role。""" role_map = {"doctor": "user", "patient": "assistant", "system": "system", "tool": "assistant"} diff --git a/app/api/router.py b/app/api/router.py index 0b95689..f9ed745 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -1,11 +1,12 @@ from fastapi import APIRouter -from app.api import agent, auth, cases, evaluations, knowledge, llm_test, sessions +from app.api import agent, auth, cases, evaluations, knowledge, llm_test, sessions, training_config api_router = APIRouter() api_router.include_router(agent.router, tags=["agent"]) 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(evaluations.router, prefix="/evaluations", tags=["evaluations"]) api_router.include_router(knowledge.router, prefix="/knowledge", tags=["knowledge"]) diff --git a/app/api/sessions.py b/app/api/sessions.py index 9605da4..9fd9e1c 100644 --- a/app/api/sessions.py +++ b/app/api/sessions.py @@ -123,6 +123,73 @@ async def generate_hints( return ok(result) +@router.post("/{session_id}/hints/stream", response_class=StreamingResponse) +async def stream_hints( + session_id: int, + payload: HintRequest, + ctx: UserContext = Depends(get_user_context), + db: Session = Depends(get_db), +): + """流式练习提示:返回一句话形式的 SSE 提示。""" + response = await SessionService(db).stream_hints(ctx, session_id, payload) + db.commit() + return StreamingResponse( + response, + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +@router.get("/{session_id}/physical-exams", response_model=ApiResponse[OrderItemsResponse]) +def list_physical_exam_items( + session_id: int, + ctx: UserContext = Depends(get_user_context), + db: Session = Depends(get_db), +): + """体格检查列表:返回当前病例可申请的体格检查项目。""" + return ok(OrderService(db).list_physical_exam_items(session_id, ctx.user_id)) + + +@router.get("/{session_id}/auxiliary-exams", response_model=ApiResponse[OrderItemsResponse]) +def list_auxiliary_exam_items( + session_id: int, + ctx: UserContext = Depends(get_user_context), + db: Session = Depends(get_db), +): + """辅助检查列表:返回当前病例可申请的辅助检查项目。""" + return ok(OrderService(db).list_auxiliary_exam_items(session_id, ctx.user_id)) + + +@router.post("/{session_id}/physical-exams/{item_code}", response_model=ApiResponse[CreateOrderResponse]) +def create_physical_exam_order( + session_id: int, + item_code: str, + ctx: UserContext = Depends(get_user_context), + db: Session = Depends(get_db), +): + """体格检查结果:按项目编码返回数据库固定结果。""" + result = OrderService(db).create_physical_exam_order(session_id, ctx.user_id, item_code) + db.commit() + return ok(result) + + +@router.post("/{session_id}/auxiliary-exams/{item_code}", response_model=ApiResponse[CreateOrderResponse]) +def create_auxiliary_exam_order( + session_id: int, + item_code: str, + ctx: UserContext = Depends(get_user_context), + db: Session = Depends(get_db), +): + """辅助检查结果:按项目编码返回数据库固定结果。""" + result = OrderService(db).create_auxiliary_exam_order(session_id, ctx.user_id, item_code) + db.commit() + return ok(result) + + @router.post("/{session_id}/diagnosis", response_model=ApiResponse[SubmitDiagnosisResponse]) def submit_diagnosis( session_id: int, diff --git a/app/api/training_config.py b/app/api/training_config.py new file mode 100644 index 0000000..64d1213 --- /dev/null +++ b/app/api/training_config.py @@ -0,0 +1,30 @@ +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.training_config import TrainingConfigOptionsResponse, TrainingConfigRecommendedResponse +from app.services.training_config_service import TrainingConfigService + +router = APIRouter() + + +@router.get("/recommended", response_model=ApiResponse[TrainingConfigRecommendedResponse]) +def get_recommended_training_config( + case_id: int = Query(..., ge=1), + _: UserContext = Depends(get_user_context), + db: Session = Depends(get_db), +): + """推荐配置信息:返回训练页默认病人初始化配置。""" + return ok(TrainingConfigService(db).get_recommended(case_id)) + + +@router.get("/options", response_model=ApiResponse[TrainingConfigOptionsResponse]) +def get_training_config_options( + case_id: int = Query(..., ge=1), + _: UserContext = Depends(get_user_context), + db: Session = Depends(get_db), +): + """训练配置信息:返回训练页自定义病人初始化配置选项。""" + return ok(TrainingConfigService(db).get_options(case_id)) diff --git a/app/schemas/session.py b/app/schemas/session.py index fbb58a7..13760ef 100644 --- a/app/schemas/session.py +++ b/app/schemas/session.py @@ -1,5 +1,7 @@ from pydantic import BaseModel, Field, field_validator +from app.schemas.training_config import PatientConfig + class CreateSessionRequest(BaseModel): """创建会话入参:选择病例、训练类别、模式和分数类型。""" @@ -8,6 +10,7 @@ class CreateSessionRequest(BaseModel): training_type: str = Field(pattern="^(case_analysis|diagnosis_treatment|consultation)$") mode: str = Field(pattern="^(novice|practice|teaching)$") score_type: str = Field(default="percentage", pattern="^(percentage|five_point)$") + patient_config: PatientConfig | None = None @field_validator("mode") @classmethod @@ -23,6 +26,7 @@ class CreateSessionResponse(BaseModel): session_code: str status: str patient_opening: str + patient_config: dict | None = None class ChatRequest(BaseModel): diff --git a/app/schemas/training_config.py b/app/schemas/training_config.py new file mode 100644 index 0000000..3dd147a --- /dev/null +++ b/app/schemas/training_config.py @@ -0,0 +1,36 @@ +from pydantic import BaseModel + + +class ConfigOption(BaseModel): + """训练配置选项:用于前端渲染单个可选项。""" + + value: str + label: str + description: str | None = None + + +class PatientConfig(BaseModel): + """病人初始化配置:控制 AI 病人的就诊场景、年龄段、文化程度和性格。""" + + visit_environment: str = "outpatient" + age_group: str = "youth" + education_level: str = "higher" + personality: str = "calm" + + +class TrainingConfigOptionsResponse(BaseModel): + """训练配置响应:返回默认配置和全部可选项。""" + + case_id: int + recommended: PatientConfig + recommended_labels: dict[str, str] + options: dict[str, list[ConfigOption]] + + +class TrainingConfigRecommendedResponse(BaseModel): + """推荐训练配置响应:用于训练页进入时初始化默认病人信息。""" + + case_id: int + recommended: PatientConfig + recommended_labels: dict[str, str] + options: dict[str, list[ConfigOption]] diff --git a/app/services/order_service.py b/app/services/order_service.py index b14e81b..84408f0 100644 --- a/app/services/order_service.py +++ b/app/services/order_service.py @@ -11,6 +11,9 @@ from app.services.runtime_memory import runtime_memory class OrderService: """检查检验服务:提供可申请项目和数据库固定结果返回。""" + PHYSICAL_TYPES = {"physical", "physical_exam", "inspection", "palpation", "percussion", "auscultation"} + PHYSICAL_KEYWORDS = ("体格", "体征", "查体", "听诊", "叩诊", "触诊") + def __init__(self, db: Session) -> None: self.db = db self.case_repo = CaseRepository(db) @@ -20,6 +23,30 @@ class OrderService: """检查项目列表:按会话病例返回可申请项目,不返回结果。""" session = self._get_session(session_id, user_id) items = self.case_repo.get_exam_items(session.case_id) + return self._items_response(items) + + def list_physical_exam_items(self, session_id: int, user_id: str) -> OrderItemsResponse: + """体格检查列表:从当前病例检查项中筛选体格检查类项目。""" + session = self._get_session(session_id, user_id) + items = [item for item in self.case_repo.get_exam_items(session.case_id) if self._is_physical_item(item)] + return self._items_response(items) + + def list_auxiliary_exam_items(self, session_id: int, user_id: str) -> OrderItemsResponse: + """辅助检查列表:从当前病例检查项中筛选非体格检查类项目。""" + session = self._get_session(session_id, user_id) + items = [item for item in self.case_repo.get_exam_items(session.case_id) if not self._is_physical_item(item)] + return self._items_response(items) + + 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) + + 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) + + def _items_response(self, items) -> OrderItemsResponse: + """检查列表响应:把 ORM 检查项转换成前端列表结构。""" return OrderItemsResponse( items=[ OrderItemResponse(item_code=item.item_code, item_name=item.item_name, item_type=item.item_type) @@ -27,6 +54,14 @@ class OrderService: ] ) + def _is_physical_item(self, item) -> bool: + """检查分类:按 item_type 和 category 识别体格检查,其他归入辅助检查。""" + item_type = (item.item_type or "").lower() + category = item.category or "" + if item_type in self.PHYSICAL_TYPES: + return True + return any(keyword in category or keyword in item.item_name for keyword in self.PHYSICAL_KEYWORDS) + def create_order(self, session_id: int, user_id: str, item_code: str) -> CreateOrderResponse: """检查申请:从数据库读取检查结果并写入当前会话记录。""" session = self._get_session(session_id, user_id) diff --git a/app/services/session_service.py b/app/services/session_service.py index d87b07a..1585cd4 100644 --- a/app/services/session_service.py +++ b/app/services/session_service.py @@ -28,6 +28,7 @@ from app.schemas.session import ( ) from app.services.audit_service import AuditService from app.services.runtime_memory import runtime_memory +from app.services.training_config_service import TrainingConfigService logger = logging.getLogger(__name__) @@ -48,6 +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) 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( @@ -64,7 +66,7 @@ class SessionService: status="inquiry", started_at=datetime.utcnow(), memory_key=memory_key, - metadata_={"source": "demo"}, + metadata_={"source": "demo", "patient_config": patient_config}, ) ) patient_opening = case.patient_opening or "家长:医生,孩子这几天不舒服,想请您看看。" @@ -75,6 +77,7 @@ class SessionService: session_code=session.session_code, status=session.status, patient_opening=patient_opening, + patient_config=patient_config, ) async def chat(self, ctx: UserContext, session_id: int, message: str) -> ChatResponse: @@ -225,6 +228,61 @@ class SessionService: self.audit.log(ctx, "session.hints", "training_session", str(session.id), session.id) return HintResponse(**result) + async def stream_hints(self, ctx: UserContext, session_id: int, payload: HintRequest) -> AsyncIterator[str]: + """流式练习提示:把结构化提示压缩成一句话并用 SSE 返回给前端。""" + started_at = time.perf_counter() + try: + hint_result = await self.generate_hints(ctx, session_id, payload) + sentence = self._build_hint_sentence(hint_result) + except AppError as exc: + error_message = exc.message + error_code = exc.code + + async def app_error_generator() -> AsyncIterator[str]: + yield self._sse_error(error_message, error_code) + + return app_error_generator() + except Exception: + logger.exception("hint_stream.failed session_id=%s", session_id) + + async def error_generator() -> AsyncIterator[str]: + yield self._sse_error("练习提示生成失败,请稍后重试", "HINT_STREAM_FAILED") + + return error_generator() + + async def event_generator() -> AsyncIterator[str]: + if not sentence: + yield self._sse_error("当前没有生成有效提示,请继续问诊后再试", "HINT_EMPTY") + return + for chunk in self._chunk_text(sentence, size=12): + payload_text = json.dumps({"delta": chunk}, ensure_ascii=False) + yield f"event: hint_delta\ndata: {payload_text}\n\n" + await asyncio.sleep(0) + done_payload = json.dumps({"latency_ms": int((time.perf_counter() - started_at) * 1000)}, ensure_ascii=False) + yield f"event: hint_done\ndata: {done_payload}\n\n" + + return event_generator() + + def _build_hint_sentence(self, hint_result: HintResponse) -> str: + """提示压缩:从结构化提示中提炼适合前端流式展示的一句话。""" + parts: list[str] = [] + if hint_result.missing_dimensions: + parts.append(f"当前可补充{ '、'.join(hint_result.missing_dimensions[:3]) }") + if hint_result.next_questions: + parts.append(f"下一步可问:{hint_result.next_questions[0]}") + elif hint_result.hints: + parts.append(hint_result.hints[0]) + if hint_result.recommended_orders: + order = hint_result.recommended_orders[0] + item_code = order.get("item_code") or order.get("item_name") or "关键检查" + reason = order.get("reason") or "用于完善病情判断" + parts.append(f"可考虑申请{item_code},{reason}") + return ";".join(parts) + ("。" if parts else "") + + def _chunk_text(self, text: str, size: int) -> list[str]: + """文本切片:把一句练习提示拆成短片段,便于前端按 SSE 渐进展示。""" + return [text[index : index + size] for index in range(0, len(text), size)] + def complete_inquiry(self, ctx: UserContext, session_id: int) -> SessionStatusResponse: """完成问诊:校验至少一轮医生问诊后进入诊断阶段。""" session = self._get_session(session_id, ctx.user_id) diff --git a/app/services/training_config_service.py b/app/services/training_config_service.py new file mode 100644 index 0000000..45c53fc --- /dev/null +++ b/app/services/training_config_service.py @@ -0,0 +1,104 @@ +from sqlalchemy.orm import Session + +from app.core.exceptions import AppError +from app.repositories.case_repository import CaseRepository +from app.schemas.training_config import ( + ConfigOption, + PatientConfig, + TrainingConfigOptionsResponse, + TrainingConfigRecommendedResponse, +) + + +class TrainingConfigService: + """训练配置服务:提供训练页病人初始化配置,不写数据库。""" + + def __init__(self, db: Session) -> None: + self.db = db + self.case_repo = CaseRepository(db) + + def get_recommended(self, case_id: int) -> TrainingConfigRecommendedResponse: + """推荐配置:根据病例返回训练页默认病人初始化配置。""" + self._ensure_case(case_id) + recommended = self.default_patient_config() + return TrainingConfigRecommendedResponse( + case_id=case_id, + recommended=recommended, + recommended_labels=self.config_labels(recommended), + options=self.config_options(), + ) + + def get_options(self, case_id: int) -> TrainingConfigOptionsResponse: + """配置选项:返回训练页自定义配置的全部可选项。""" + self._ensure_case(case_id) + recommended = self.default_patient_config() + return TrainingConfigOptionsResponse( + case_id=case_id, + recommended=recommended, + recommended_labels=self.config_labels(recommended), + options=self.config_options(), + ) + + def default_patient_config(self) -> PatientConfig: + """默认配置:按当前产品原型初始化病人信息。""" + return PatientConfig( + visit_environment="outpatient", + age_group="youth", + education_level="higher", + personality="calm", + ) + + def normalize_patient_config(self, config: PatientConfig | None) -> dict: + """配置归一:校验并补齐前端传入的病人初始化配置。""" + selected = config or self.default_patient_config() + 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(): + if value not in allowed.get(key, set()): + raise AppError("TRAINING_CONFIG_INVALID", f"invalid patient config field: {key}", 400) + return { + "values": values, + "labels": self.config_labels(selected), + } + + def config_labels(self, config: PatientConfig) -> dict[str, str]: + """配置标签:把配置值转换为前端和提示词可读的中文标签。""" + option_map = { + key: {item.value: item.label for item in items} + for key, items in self.config_options().items() + } + values = config.model_dump() + return {key: option_map.get(key, {}).get(value, value) for key, value in values.items()} + + def config_options(self) -> dict[str, list[ConfigOption]]: + """配置选项:训练页可选病人初始化配置。""" + return { + "visit_environment": [ + ConfigOption(value="outpatient", label="门诊", description="适合常规问诊训练"), + ConfigOption(value="emergency", label="急诊", description="病情紧急、沟通节奏更快"), + ConfigOption(value="ward", label="病房", description="适合住院病情追踪和处置沟通"), + ], + "age_group": [ + ConfigOption(value="child", label="儿童", description="由家属代述为主"), + ConfigOption(value="youth", label="青年", description="表达清楚,能配合问诊"), + ConfigOption(value="middle_aged", label="中年", description="关注工作、家庭和慢病背景"), + ConfigOption(value="elderly", label="老年", description="表达较慢,需关注基础病和用药史"), + ], + "education_level": [ + ConfigOption(value="primary_or_below", label="小学及以下", description="医学术语理解弱"), + ConfigOption(value="secondary", label="中等教育", description="能理解常见健康解释"), + ConfigOption(value="higher", label="高等教育", description="理解能力强,能描述细节"), + ], + "personality": [ + ConfigOption(value="calm", label="平和", description="情绪稳定,按问题回答"), + ConfigOption(value="anxious", label="焦虑", description="更担心病情和治疗风险"), + ConfigOption(value="impatient", label="急躁", description="希望快速获得结论"), + ConfigOption(value="cooperative", label="配合", description="愿意补充细节"), + ConfigOption(value="suspicious", label="多疑", description="会追问检查和用药依据"), + ], + } + + def _ensure_case(self, case_id: int) -> None: + """病例校验:确认配置请求对应已发布病例。""" + if not self.case_repo.get_active_case(case_id): + raise AppError("CASE_NOT_FOUND", "case not found or inactive", 404) diff --git a/docs/03_api_design.md b/docs/03_api_design.md new file mode 100644 index 0000000..08e20be --- /dev/null +++ b/docs/03_api_design.md @@ -0,0 +1,1094 @@ +# 医疗问诊 Agent 前端联调 API 文档 + +> 文档版本:2026-06-04 +> 对应后端:FastAPI `main` 分支当前版本 +> 本文档以当前真实代码行为为准,用于正式 Vue 前端功能联调。 + +## 1. 联调地址 + +### 1.1 当前公网测试环境 + +| 项目 | 地址 | +|---|---| +| 网关根地址 | `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 和生产配置处于就绪状态。 + +如果服务器关闭 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> +``` + +| Header | 必填 | 说明 | +|---|---:|---| +| `Authorization` | 是 | Django 用户中心 access token。 | +| `X-Entry-Scene` | 否 | 入口场景,例如 `vue_frontend`、`production_vue`。 | +| `X-Request-Id` | 否 | 前端生成的请求追踪 ID。 | + +### 2.3 统一响应 + +除 SSE 流式问诊外,接口统一返回: + +```json +{ + "code": "OK", + "message": "success", + "data": {} +} +``` + +前端必须同时判断 HTTP 状态码和业务 `code`: + +```ts +if (!response.ok || body.code !== "OK") { + throw new Error(body.message || body.code || "request failed"); +} +``` + +## 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 | 用途 | +|---|---|---| +| `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` | `/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 +``` + +会话状态流转: + +```text +inquiry -> diagnosis -> treatment -> evaluating -> completed +``` + +| 状态 | 允许操作 | +|---|---| +| `inquiry` | 问诊、提示、检查申请、完成问诊。 | +| `diagnosis` | 提交诊断、继续申请检查。 | +| `treatment` | 提交治疗、继续申请检查。 | +| `evaluating` | 生成评价。 | +| `completed` | 查看评价、导出 PDF、查看历史记录。 | + +当前没有“获取会话详情”“恢复聊天记录”接口。页面刷新或中断后,前端不能恢复短期对话,应重新创建训练会话。 + +## 5. 认证与 Agent + +### 5.1 获取当前用户 + +```http +GET /api/v1/auth/me +Authorization: Bearer +``` + +成功响应: + +```json +{ + "code": "OK", + "message": "success", + "data": { + "user_id": "37", + "source": "django_user_center", + "username": "13700000099", + "display_name": "测试用户", + "tenant_id": "1", + "role": "student", + "phone": "13700000099", + "avatar": null, + "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 + } +} +``` + +前端规则: + +- 进入 Agent 后首先调用本接口。 +- 返回 200 且 `code=OK` 才允许进入训练页面。 +- 后续所有请求继续携带相同 token。 +- `user_id` 只用于展示或前端状态标识,不能由前端覆盖。 + +### 5.2 获取 Agent 能力 + +```http +GET /api/v1/agent/hello +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"] + } +} +``` + +## 7. 创建训练会话 + +### 7.1 推荐配置信息 + +```http +GET /api/v1/training-config/recommended?case_id=2 +``` + +响应: + +```json +{ + "code": "OK", + "message": "success", + "data": { + "case_id": 2, + "recommended": { + "visit_environment": "outpatient", + "age_group": "youth", + "education_level": "higher", + "personality": "calm" + }, + "recommended_labels": { + "visit_environment": "门诊", + "age_group": "青年", + "education_level": "高等教育", + "personality": "平和" + }, + "options": { + "visit_environment": [ + {"value": "outpatient", "label": "门诊", "description": "适合常规问诊训练"} + ], + "age_group": [], + "education_level": [], + "personality": [] + } + } +} +``` + +### 7.2 训练配置信息 + +```http +GET /api/v1/training-config/options?case_id=2 +``` + +该接口返回和推荐配置信息相同的结构,前端使用 `options` 渲染自定义配置按钮,使用 `recommended` 初始化默认选中项。 + +### 7.3 新建会话 + +```http +POST /api/v1/sessions +Content-Type: application/json +``` + +请求: + +```json +{ + "case_id": 2, + "training_type": "diagnosis_treatment", + "mode": "practice", + "score_type": "percentage", + "patient_config": { + "visit_environment": "outpatient", + "age_group": "youth", + "education_level": "higher", + "personality": "calm" + } +} +``` + +字段: + +| 字段 | 允许值 | 说明 | +|---|---|---| +| `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` | 病人性格配置。 | + +后端仍接受旧值 `novice`,但会自动转换为 `practice`。正式前端不要继续使用 `novice`。 + +响应: + +```json +{ + "code": "OK", + "message": "success", + "data": { + "session_id": 10, + "session_code": "sess_20260604100000_abcd1234", + "status": "inquiry", + "patient_opening": "家长:医生,孩子发热咳嗽好几天了,昨天开始喘得厉害,精神也不太好。", + "patient_config": { + "values": { + "visit_environment": "outpatient", + "age_group": "youth", + "education_level": "higher", + "personality": "calm" + }, + "labels": { + "visit_environment": "门诊", + "age_group": "青年", + "education_level": "高等教育", + "personality": "平和" + } + } + } +} +``` + +前端创建新会话时必须清空上一轮 Chat、检查结果、诊断、治疗和评价状态。 + +## 8. 问诊接口 + +### 8.1 非流式问诊 + +```http +POST /api/v1/sessions/{session_id}/chat +Content-Type: application/json +``` + +请求: + +```json +{ + "message": "孩子发热几天了?最高体温多少?" +} +``` + +响应: + +```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_done +data: {"latency_ms":3200,"first_token_ms":800,"model":"deepseek-chat","fallback_used":false} + +event: error +data: {"code":"LLM_STREAM_TIMEOUT","message":"AI 病人首段回复超时,请重试或关闭流式模式"} +``` + +前端必须处理: + +| 事件 | 处理 | +|---|---| +| `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 练习提示 + +#### 8.3.1 JSON 兼容接口 + +```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` 状态可调用。 +- 前端默认不自动展示提示,由用户点击后调用。 + +#### 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 获取当前病例检查项 + +```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 +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 申请检查 + +```http +POST /api/v1/sessions/{session_id}/orders +``` + +请求: + +```json +{ + "item_code": "blood_routine" +} +``` + +响应: + +```json +{ + "code": "OK", + "message": "success", + "data": { + "item_code": "blood_routine", + "item_name": "血常规", + "item_type": "lab", + "result_text": "WBC 12.5×10^9/L,中性粒细胞比例72%,提示感染及炎症反应。", + "result_structured": { + "wbc": "12.5×10^9/L", + "neutrophil": "72%" + }, + "is_key": true, + "is_abnormal": true, + "context_written": true, + "already_ordered": false + } +} +``` + +规则: + +- 检查结果只来自数据库,不由 LLM 生成。 +- 检查结果会写入本次会话上下文和评分依据。 +- 同一会话重复申请相同 `item_code` 时,返回已有结果并设置 `already_ordered=true`。 +- 前端按 `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 完成问诊 + +```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": "结合发热、咳嗽、喘息、肺部体征、炎症指标、胸片和血氧结果,符合儿童支气管肺炎表现。" +} +``` + +成功响应: + +```json +{ + "code": "OK", + "message": "success", + "data": { + "status": "treatment" + } +} +``` + +### 10.3 提交治疗 + +```http +POST /api/v1/sessions/{session_id}/treatment +``` + +请求: + +```json +{ + "treatment_principle": "抗感染、止咳平喘、改善氧合并严密观察病情变化。", + "treatment_measures": "根据病情进行抗感染治疗,必要时雾化缓解喘息,监测体温、呼吸和血氧。", + "risk_plan": "关注低氧、呼吸困难加重、持续高热、精神反应差和脱水。", + "communication": "向家属说明病情、用药注意事项、危险信号和复诊指征。", + "follow_up": "治疗后复查体温、呼吸、血氧和必要的炎症指标。" +} +``` + +成功响应: + +```json +{ + "code": "OK", + "message": "success", + "data": { + "status": "evaluating" + } +} +``` + +## 11. AI 评价、历史记录与 PDF + +### 11.1 生成评价 + +```http +POST /api/v1/sessions/{session_id}/evaluation +``` + +请求: + +```json +{ + "score_type": "percentage" +} +``` + +响应: + +```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": "诊断方向正确,问诊完整性和沟通细节仍需加强。" + } +} +``` + +评价成功后: + +- 会话状态变为 `completed`。 +- 写入 `training_record` 和 `training_score_detail`。 +- 当前 Redis 短期聊天 memory 被释放。 +- 相同会话再次调用评价接口时返回已存在的评价,不重复创建。 + +### 11.2 历史评价列表 + +```http +GET /api/v1/evaluations +``` + +响应: + +```json +{ + "code": "OK", + "message": "success", + "data": { + "items": [ + { + "evaluation_id": 101, + "case_title": "支气管肺炎 - 6岁男性患儿", + "score_type": "percentage", + "total_score": 82, + "created_at": "2026-06-04T10:00:00", + "pdf_exported": true + } + ] + } +} +``` + +### 11.3 评价详情 + +```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 并返回服务器内部文件路径,没有提供浏览器文件下载流。前端功能测试阶段展示“导出成功”和文件路径即可。正式下载接口需要后续增加受鉴权保护的文件响应接口。 + +## 12. 调试接口 + +### 12.1 知识检索 + +```http +GET /api/v1/knowledge/search?department_id=2&training_type=diagnosis_treatment&q=肺炎,血氧 +``` + +### 12.2 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. 常见错误码 + +| HTTP | code | 说明 | +|---:|---|---| +| 401 | `AUTH_CREDENTIAL_REQUIRED` | 缺少 Authorization。 | +| 401 | `AUTH_USER_INVALID` | token 无效、过期或 Django 返回非 200。 | +| 403 | `AUTH_USER_DISABLED` | Django 用户状态被禁用。 | +| 503 | `AUTH_USER_CENTER_UNAVAILABLE` | Django 用户中心超时或不可达。 | +| 404 | `CASE_NOT_FOUND` | 病例不存在、未发布或已停用。 | +| 404 | `SESSION_NOT_FOUND` | 会话不存在或不属于当前用户。 | +| 400 | `SESSION_STATUS_INVALID` | 当前状态不允许执行该操作。 | +| 400 | `INQUIRY_REQUIRED` | 完成问诊前没有医生提问。 | +| 400 | `DIAGNOSIS_REQUIRED` | 提交治疗前没有提交诊断。 | +| 400 | `TREATMENT_REQUIRED` | 生成评价前没有提交治疗。 | +| 404 | `ORDER_ITEM_NOT_FOUND` | 当前病例不存在该检查项。 | +| 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 生成失败。 | + +## 15. 前端功能验收顺序 + +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,确认接口返回文件路径。 + +## 16. 当前前端必须了解的限制 + +- 无会话详情和聊天恢复接口,页面刷新后无法恢复短期问诊。 +- 问诊聊天只保存在 Redis 短期 memory 中,评价完成后释放。 +- PDF 导出当前只返回服务器文件路径,不直接下载文件。 +- 病例库为只读数据源;新增、解析、修改和删除由外部病例管理系统完成。 +- 所有检查项必须从 `/order-items` 动态读取,不能写死。 +- 正式训练模式只有 `practice` 和 `teaching`;`novice` 仅为旧接口兼容值。 diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index 4a4067d..579b070 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -89,18 +89,46 @@ def run_api_contract_tests() -> None: 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"] + assert "/api/v1/sessions/{session_id}/hints/stream" in openapi_payload["paths"] + assert "/api/v1/sessions/{session_id}/physical-exams" in openapi_payload["paths"] + 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"] cases = client.get("/api/v1/cases", headers=headers) assert cases.status_code == 200 case_id = cases.json()["data"]["items"][0]["id"] + 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"] == "门诊" + + 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"] + created = client.post( "/api/v1/sessions", headers=headers, - json={"case_id": case_id, "training_type": "diagnosis_treatment", "mode": "practice", "score_type": "percentage"}, + json={ + "case_id": case_id, + "training_type": "diagnosis_treatment", + "mode": "practice", + "score_type": "percentage", + "patient_config": { + "visit_environment": "outpatient", + "age_group": "youth", + "education_level": "higher", + "personality": "calm", + }, + }, ) assert created.status_code == 200 session_id = created.json()["data"]["session_id"] + assert created.json()["data"]["patient_config"]["labels"]["personality"] == "平和" cross_user = client.get( f"/api/v1/sessions/{session_id}/order-items", @@ -120,6 +148,18 @@ def run_api_contract_tests() -> None: assert order_two.status_code == 200 assert order_two.json()["data"]["already_ordered"] is True + 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"] + + auxiliary_list = client.get(f"/api/v1/sessions/{session_id}/auxiliary-exams", headers=headers) + assert auxiliary_list.status_code == 200 + assert any(item["item_code"] == "blood_routine" for item in auxiliary_list.json()["data"]["items"]) + + auxiliary_result = client.post(f"/api/v1/sessions/{session_id}/auxiliary-exams/blood_routine", headers=headers) + assert auxiliary_result.status_code == 200 + assert auxiliary_result.json()["data"]["already_ordered"] is True + practice_hint_session = client.post( "/api/v1/sessions", headers=headers, @@ -136,6 +176,17 @@ def run_api_contract_tests() -> None: assert hint.json()["data"]["hints"] assert "recommended_orders" in hint.json()["data"] + with client.stream( + "POST", + f"/api/v1/sessions/{practice_hint_session_id}/hints/stream", + headers=headers, + json={"scope": "current_conversation"}, + ) as hint_stream: + assert hint_stream.status_code == 200 + hint_stream_text = "".join(hint_stream.iter_text()) + assert "event: hint_delta" in hint_stream_text + assert "event: hint_done" in hint_stream_text + teaching = client.post( "/api/v1/sessions", headers=headers, diff --git a/tests/test_demo_flow.py b/tests/test_demo_flow.py index b518ec9..c3423df 100644 --- a/tests/test_demo_flow.py +++ b/tests/test_demo_flow.py @@ -28,6 +28,7 @@ from app.schemas.session import ( SubmitDiagnosisRequest, SubmitTreatmentRequest, ) +from app.schemas.training_config import PatientConfig from app.services.evaluation_service import EvaluationService from app.services.order_service import OrderService from app.services.pdf_export_service import PdfExportService @@ -56,10 +57,17 @@ async def run_demo_flow() -> None: training_type="diagnosis_treatment", mode="practice", score_type="percentage", + patient_config=PatientConfig( + visit_environment="outpatient", + age_group="youth", + education_level="higher", + personality="calm", + ), ), ) db.commit() assert created.status == "inquiry" + assert created.patient_config["labels"]["visit_environment"] == "门诊" chat = await session_service.chat(ctx, created.session_id, ChatRequest(message="孩子最高体温多少?").message) db.commit() @@ -68,6 +76,10 @@ async def run_demo_flow() -> None: order = order_service.create_order(created.session_id, ctx.user_id, CreateOrderRequest(item_code="chest_xray").item_code) db.commit() assert order.is_key is True + 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) 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")