From ec515d54534bfc301e4333429044b3b651e0c5b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E9=87=91=E5=AE=9D?= Date: Thu, 11 Jun 2026 16:19:07 +0800 Subject: [PATCH] chore: finalize backend feature scope --- .env.example | 2 + .env.production.example | 2 + README.md | 23 ++- app/agents/hint_agent.py | 6 +- app/agents/learning_assistant_agent.py | 67 ++++--- app/agents/orchestrator.py | 8 +- app/agents/patient_agent.py | 25 +-- app/api/cases.py | 32 ---- app/api/evaluations.py | 14 +- app/api/learning_assistant.py | 34 ++-- app/api/router.py | 3 +- app/api/sessions.py | 16 +- app/core/config.py | 4 + app/integrations/milvus_adapter.py | 2 +- app/prompts/hint/novice_hint.md | 36 ---- ...ice_case_hint.md => practice_case_hint.md} | 8 +- app/prompts/patient/novice.md | 38 ---- app/prompts/patient/practice.md | 40 ----- app/prompts/patient/teaching.md | 39 ----- app/prompts/polish/doctor_question_polish.md | 36 ---- app/repositories/case_repository.py | 29 +-- app/schemas/case.py | 52 ------ app/schemas/evaluation.py | 7 - app/schemas/learning_assistant.py | 40 +++-- app/schemas/session.py | 21 +-- app/services/case_service.py | 85 --------- app/services/learning_assistant_service.py | 165 +++++++++++++----- .../learning_assistant_session_store.py | 141 +++++++++++++++ app/services/pdf_export_service.py | 2 +- app/services/session_service.py | 31 +--- docs/00_project_overview.md | 14 +- docs/01_architecture.md | 24 +-- docs/02_database.md | 4 +- docs/03_api_design.md | 68 ++++++-- docs/04_deployment.md | 15 +- docs/05_modules.md | 16 +- docs/07_troubleshooting.md | 2 +- docs/08_feature_code_map.md | 21 ++- docs/09_prompt_template_catalog.md | 26 +-- docs/10_function_workflow.md | 96 ++++++++++ tests/test_api_contract.py | 74 ++++---- tests/test_core_logic.py | 2 +- tests/test_demo_flow.py | 22 ++- 43 files changed, 680 insertions(+), 712 deletions(-) delete mode 100644 app/api/cases.py delete mode 100644 app/prompts/hint/novice_hint.md rename app/prompts/hint/{novice_case_hint.md => practice_case_hint.md} (90%) delete mode 100644 app/prompts/patient/novice.md delete mode 100644 app/prompts/patient/practice.md delete mode 100644 app/prompts/patient/teaching.md delete mode 100644 app/prompts/polish/doctor_question_polish.md delete mode 100644 app/schemas/case.py delete mode 100644 app/services/case_service.py create mode 100644 app/services/learning_assistant_session_store.py create mode 100644 docs/10_function_workflow.md diff --git a/.env.example b/.env.example index 8e84798..52ece8c 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,8 @@ DATABASE_URL=mysql+pymysql://root:CHANGE_ME@127.0.0.1:3306/medical?charset=utf8m RUNTIME_MEMORY_BACKEND=redis RUNTIME_MEMORY_FALLBACK_ENABLED=true RUNTIME_MEMORY_TTL_SECONDS=7200 +LEARNING_ASSISTANT_SESSION_TTL_SECONDS=7200 +LEARNING_ASSISTANT_HISTORY_LIMIT=6 REDIS_URL=redis://127.0.0.1:6379/0 # Django user center diff --git a/.env.production.example b/.env.production.example index 106d389..829ac39 100644 --- a/.env.production.example +++ b/.env.production.example @@ -9,6 +9,8 @@ DATABASE_URL=mysql+pymysql://root:CHANGE_ME@mysql:3306/medical?charset=utf8mb4 RUNTIME_MEMORY_BACKEND=redis RUNTIME_MEMORY_FALLBACK_ENABLED=false RUNTIME_MEMORY_TTL_SECONDS=7200 +LEARNING_ASSISTANT_SESSION_TTL_SECONDS=7200 +LEARNING_ASSISTANT_HISTORY_LIMIT=6 REDIS_URL=redis://redis:6379/0 # Django service in the same Docker network diff --git a/README.md b/README.md index 3637120..1574c2b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 本项目是医疗教学平台中的 FastAPI 后端服务,负责医疗问诊训练、教学互动、AI 评价、PDF 报告、AI 学习助手,以及后台预留的机构知识库能力。 -本服务不负责登录注册、用户管理、病例 PDF 解析入库、病例增删改、多租户后台、HIS/LIS/PACS 对接。用户身份由 Django 用户中心验证,病例、检查项、教学题和评分规则由平台数据库维护。 +本服务不负责登录注册、用户管理、病例 PDF 解析入库、病例增删改、多租户后台、HIS/LIS/PACS 连接。用户身份由 Django 用户中心验证,病例、检查项、教学题和评分规则由平台数据库维护。 ## 1. 当前功能 @@ -36,9 +36,10 @@ AI 学习助手: -- 普通用户通过 SSE 流式接口提问 +- 普通用户先新建 AI 学习助手短期会话,再通过 SSE 流式接口提问 - 后端优先检索本机构知识库 - 知识库未初始化或检索失败时,自动降级为大模型通用学习回答 +- 学习助手会话使用 Redis 短期缓存保存最近问答上下文,不写入训练记录 后台预留能力: @@ -182,10 +183,20 @@ curl "http://8.160.178.88/fastapi/api/v1/auth/me" \ -H "X-Entry-Scene: production_vue" ``` -AI 学习助手流式问答: +AI 学习助手新建会话: ```bash -curl -N -X POST "http://8.160.178.88/fastapi/api/v1/learning-assistant/chat/stream" \ +curl -X POST "http://8.160.178.88/fastapi/api/v1/learning-assistant/sessions" \ + -H "Authorization: Bearer " \ + -H "X-Entry-Scene: production_vue" \ + -H "Content-Type: application/json" \ + -d '{"title":"儿科肺炎知识复习"}' +``` + +AI 学习助手会话式流式问答: + +```bash +curl -N -X POST "http://8.160.178.88/fastapi/api/v1/learning-assistant/sessions//chat/stream" \ -H "Authorization: Bearer " \ -H "X-Entry-Scene: production_vue" \ -H "Content-Type: application/json" \ @@ -195,6 +206,7 @@ curl -N -X POST "http://8.160.178.88/fastapi/api/v1/learning-assistant/chat/stre 正常应看到: ```text +event: session_ready event: retrieval_done event: answer_delta event: answer_done @@ -216,13 +228,14 @@ event: answer_done | `docs/00_project_overview.md` | 项目总览和功能讲解顺序 | | `docs/01_architecture.md` | 系统架构、调用链路和模块边界 | | `docs/02_database.md` | 核心数据库表和读写边界 | -| `docs/03_api_design.md` | 前端联调 API 文档 | +| `docs/03_api_design.md` | 前端 API 文档 | | `docs/04_deployment.md` | 云服务器部署、更新和回滚 | | `docs/05_modules.md` | 功能模块说明 | | `docs/06_maintenance_guide.md` | 开发维护说明和风险清单 | | `docs/07_troubleshooting.md` | 常见故障排查 | | `docs/08_feature_code_map.md` | 功能到接口、代码和数据表的映射表 | | `docs/09_prompt_template_catalog.md` | 提示词模板目录和调用说明 | +| `docs/10_function_workflow.md` | 功能工作流、提示词和数据流说明 | ## 10. 重要约定 diff --git a/app/agents/hint_agent.py b/app/agents/hint_agent.py index d6082c8..753ffb4 100644 --- a/app/agents/hint_agent.py +++ b/app/agents/hint_agent.py @@ -9,11 +9,11 @@ from app.models.training import SessionOrder, TrainingSession class HintAgent: - """新手提示 Agent:基于病例、对话和检查结果调用快速模型生成结构化提示。""" + """练习提示 Agent:基于病例、对话和检查结果调用快速模型生成结构化提示。""" def __init__(self, llm: DeepSeekClient | None = None) -> None: self.llm = llm or DeepSeekClient() - self.template_path = Path(__file__).resolve().parents[1] / "prompts" / "hint" / "novice_case_hint.md" + self.template_path = Path(__file__).resolve().parents[1] / "prompts" / "hint" / "practice_case_hint.md" async def generate( self, @@ -85,7 +85,7 @@ class HintAgent: } def _load_template(self) -> str: - """提示词读取:加载新手模式病例提示模板。""" + """提示词读取:加载练习模式病例提示模板。""" if self.template_path.exists(): return self.template_path.read_text(encoding="utf-8") return "你是医疗问诊训练提示 Agent,只输出合法 JSON。" diff --git a/app/agents/learning_assistant_agent.py b/app/agents/learning_assistant_agent.py index 289e91d..257a547 100644 --- a/app/agents/learning_assistant_agent.py +++ b/app/agents/learning_assistant_agent.py @@ -1,37 +1,34 @@ from collections.abc import AsyncIterator -from app.agents.llm_adapter import LLMResponse, LLMStreamChunk, OpenAICompatibleLLMClient +from app.agents.llm_adapter import LLMStreamChunk, OpenAICompatibleLLMClient from app.core.config import settings from app.schemas.learning_assistant import LearningAssistantSource class LearningAssistantAgent: - """AI学习助手 Agent:根据 RAG 来源生成带循证出处的医学学习回答。""" + """AI 学习助手 Agent:根据 RAG 来源和短期上下文生成带循证出处的医学学习回答。""" def __init__(self, llm_client: OpenAICompatibleLLMClient | None = None) -> None: self.llm_client = llm_client or OpenAICompatibleLLMClient() - async def answer(self, question: str, sources: list[LearningAssistantSource]) -> LLMResponse: - """非流式回答:把问题和检索来源拼接后调用快速模型生成标准回答。""" - return await self.llm_client.chat( - self._messages(question, sources), - model=settings.llm_fast_model, - thinking_enabled=settings.llm_fast_thinking_enabled, - max_tokens=1200, - ) - - async def stream_answer(self, question: str, sources: list[LearningAssistantSource]) -> AsyncIterator[LLMStreamChunk]: + async def stream_answer( + self, + question: str, + sources: list[LearningAssistantSource], + history: list[dict] | None = None, + ) -> AsyncIterator[LLMStreamChunk]: """流式回答:输出 AI 学习助手增量文本,前端可直接渲染。""" async for chunk in self.llm_client.stream_chat( - self._messages(question, sources), + self._messages(question, sources, history or []), model=settings.llm_fast_model, thinking_enabled=settings.llm_fast_thinking_enabled, max_tokens=1200, ): yield chunk - def _messages(self, question: str, sources: list[LearningAssistantSource]) -> list[dict]: - """提示词拼接:命中知识库时必须引用来源,未命中时必须声明未找到参考。""" + def _messages(self, question: str, sources: list[LearningAssistantSource], history: list[dict]) -> list[dict]: + """提示词拼接:命中知识库时强制引用来源,未命中时必须声明未找到机构参考。""" + history_text = self._history_text(history) if sources: context = "\n\n".join( ( @@ -42,17 +39,41 @@ class LearningAssistantAgent: for index, source in enumerate(sources, start=1) ) system = ( - "你是医学学习助手,只用于医学教育学习,不替代临床诊疗。" - "请优先依据给定知识库片段回答,回答要清晰、准确、分点。" + "你是医学学习助手,用于医学教育、课程学习和临床思维训练,不替代临床诊疗。" + "优先依据给定知识库片段回答,回答要清晰、准确、分点。" "每个关键结论后标注对应来源编号,例如【来源1】。" - "不得编造不存在的PDF、页码或指南来源。" + "不得编造不存在的 PDF、页码或指南来源。" + ) + user = ( + f"{history_text}" + f"用户当前问题:{question}\n\n" + f"可用知识库片段:\n{context}\n\n" + "请给出带来源的学习回答。" ) - user = f"用户问题:{question}\n\n可用知识库片段:\n{context}\n\n请给出带来源的学习回答。" else: system = ( - "你是医学学习助手,只用于医学教育学习,不替代临床诊疗。" - "当前没有检索到机构知识库参考,回答开头必须写:未检索到本机构知识库参考,以下为大模型通用学习回答。" - "不得伪造PDF来源、页码或指南名称。" + "你是医学学习助手,用于医学教育、课程学习和临床思维训练,不替代临床诊疗。" + "当前没有检索到机构知识库参考,回答开头必须写:" + "未检索到本机构知识库参考,以下为大模型通用学习回答。" + "不得伪造 PDF 来源、页码或指南名称。" + ) + user = ( + f"{history_text}" + f"用户当前问题:{question}\n\n" + "请给出通用学习回答,并提醒用户以课程教材、指南和临床医生判断为准。" ) - user = f"用户问题:{question}\n\n请给出通用学习回答,并提醒用户以课程教材和临床规范为准。" return [{"role": "system", "content": system}, {"role": "user", "content": user}] + + def _history_text(self, history: list[dict]) -> str: + """上下文摘要:把当前学习助手会话最近几轮问答压缩为提示词上下文。""" + if not history: + return "" + lines: list[str] = [] + for item in history[-settings.learning_assistant_history_limit :]: + role = "用户" if item.get("role") == "user" else "助手" + content = str(item.get("content") or "").strip() + if content: + lines.append(f"{role}:{content[:500]}") + if not lines: + return "" + return "当前会话最近上下文:\n" + "\n".join(lines) + "\n\n" diff --git a/app/agents/orchestrator.py b/app/agents/orchestrator.py index 4a61cbf..6439047 100644 --- a/app/agents/orchestrator.py +++ b/app/agents/orchestrator.py @@ -1,6 +1,6 @@ from collections.abc import AsyncIterator -from app.agents.llm_adapter import LLMResponse, LLMStreamChunk +from app.agents.llm_adapter import LLMStreamChunk from app.agents.hint_agent import HintAgent from app.agents.patient_agent import PatientAgent from app.agents.report_agent import ReportAgent @@ -18,10 +18,6 @@ class MedicalConsultationOrchestrator: self.scoring_agent = ScoringAgent() self.report_agent = ReportAgent() - 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, self._patient_config(session)) - async def patient_stream_reply( self, session: TrainingSession, @@ -84,7 +80,7 @@ class MedicalConsultationOrchestrator: orders: list[SessionOrder], last_user_message: str | None = None, ) -> dict: - """新手提示编排:基于当前会话上下文生成轻量训练提醒。""" + """练习提示编排:基于当前会话上下文生成轻量训练提醒。""" return await self.hint_agent.generate(session, case, memory_messages, orders, last_user_message) def _patient_config(self, session: TrainingSession) -> dict | None: diff --git a/app/agents/patient_agent.py b/app/agents/patient_agent.py index 6f82f8c..b9f1ff5 100644 --- a/app/agents/patient_agent.py +++ b/app/agents/patient_agent.py @@ -1,6 +1,6 @@ from collections.abc import AsyncIterator -from app.agents.llm_adapter import DeepSeekClient, LLMResponse, LLMStreamChunk +from app.agents.llm_adapter import DeepSeekClient, LLMStreamChunk from app.core.config import settings from app.models.source_case import CaseBase @@ -11,23 +11,6 @@ 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, - patient_config: dict | None = None, - ) -> LLMResponse: - """问诊回复:拼接病例上下文、短期记忆和用户输入后调用 Patient Agent。""" - messages = self._build_messages(case, memory_messages, user_message, mode, patient_config) - return await self.llm.chat( - messages, - settings.llm_fast_model, - thinking_enabled=settings.llm_fast_thinking_enabled, - max_tokens=settings.llm_fast_max_tokens, - ) - async def stream_reply( self, case: CaseBase, @@ -58,11 +41,7 @@ class PatientAgent: 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": "练习模式:只回答被问到的信息,不主动给诊断建议。", - "teaching": "教学模式:保持患者身份,允许在回答后补充简短学习提示。", - }.get(mode, "只回答被问到的信息。") + mode_rule = "练习模式:只回答被问到的信息,不主动给诊断建议。" system = f""" 你是一名标准化 AI 病人或患儿家属,只能基于病例资料回答。 病例主诉:{case.chief_complaint} diff --git a/app/api/cases.py b/app/api/cases.py deleted file mode 100644 index 58d638c..0000000 --- a/app/api/cases.py +++ /dev/null @@ -1,32 +0,0 @@ -from fastapi import APIRouter, Depends, Query -from sqlalchemy.orm import Session - -from app.core.response import ApiResponse, ok -from app.core.user_context import UserContext, get_user_context -from app.db.session import get_db -from app.schemas.case import CaseDetailResponse, CaseListResponse -from app.services.case_service import CaseService - -router = APIRouter() - - -@router.get("", response_model=ApiResponse[CaseListResponse]) -def list_cases( - _: UserContext = Depends(get_user_context), - db: Session = Depends(get_db), - department_id: int | None = Query(default=None), - training_type: str | None = Query(default=None), - mode: str | None = Query(default=None), -): - """病例列表:返回当前可用的激活病例,不暴露标准答案。""" - return ok(CaseService(db).list_cases(department_id=department_id, training_type=training_type, mode=mode)) - - -@router.get("/{case_id}", response_model=ApiResponse[CaseDetailResponse]) -def get_case_detail( - case_id: int, - _: UserContext = Depends(get_user_context), - db: Session = Depends(get_db), -): - """病例详情:返回训练入口信息和可申请检查类型。""" - return ok(CaseService(db).get_case_detail(case_id)) diff --git a/app/api/evaluations.py b/app/api/evaluations.py index 24e1a6c..969f6a3 100644 --- a/app/api/evaluations.py +++ b/app/api/evaluations.py @@ -7,7 +7,7 @@ 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.evaluation import EvaluationDetailResponse, EvaluationListResponse, ExportPdfResponse +from app.schemas.evaluation import EvaluationDetailResponse, EvaluationListResponse from app.services.evaluation_service import EvaluationService from app.services.pdf_export_service import PdfExportService @@ -35,18 +35,6 @@ def get_evaluation_detail( return ok(EvaluationService(db).get_detail(evaluation_id, ctx.user_id)) -@router.post("/{evaluation_id}/export-pdf", response_model=ApiResponse[ExportPdfResponse]) -def export_pdf( - evaluation_id: int, - ctx: UserContext = Depends(get_user_context), - db: Session = Depends(get_db), -): - """PDF 导出:生成评价报告 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, diff --git a/app/api/learning_assistant.py b/app/api/learning_assistant.py index 599ad93..c1a286b 100644 --- a/app/api/learning_assistant.py +++ b/app/api/learning_assistant.py @@ -5,33 +5,43 @@ from starlette.responses import StreamingResponse 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.learning_assistant import LearningAssistantChatRequest, LearningAssistantChatResponse +from app.schemas.learning_assistant import ( + LearningAssistantChatRequest, + LearningAssistantSessionCreateRequest, + LearningAssistantSessionResponse, +) from app.services.learning_assistant_service import LearningAssistantService router = APIRouter() -@router.post("/chat", response_model=ApiResponse[LearningAssistantChatResponse], include_in_schema=False) -async def learning_assistant_chat( - payload: LearningAssistantChatRequest, +@router.post("/sessions", response_model=ApiResponse[LearningAssistantSessionResponse]) +async def create_learning_assistant_session( + payload: LearningAssistantSessionCreateRequest, ctx: UserContext = Depends(get_user_context), db: Session = Depends(get_db), ): - """AI 学习助手调试接口:非流式返回回答,正式前端联调使用流式接口。""" - result = await LearningAssistantService(db).chat(ctx, payload) - db.commit() + """学习助手会话创建:进入 AI 学习助手页面时生成短期会话 ID。""" + result = LearningAssistantService(db).create_session(ctx, payload) return ok(result) -@router.post("/chat/stream", response_class=StreamingResponse) -async def learning_assistant_stream_chat( +@router.post("/sessions/{assistant_session_id}/chat/stream", response_class=StreamingResponse) +async def stream_learning_assistant_session_chat( + assistant_session_id: str, payload: LearningAssistantChatRequest, ctx: UserContext = Depends(get_user_context), db: Session = Depends(get_db), ): - """AI 学习助手流式问答:返回 retrieval_done、answer_delta、answer_done 事件。""" - stream = LearningAssistantService(db).stream_chat(ctx, payload) - db.commit() + """学习助手会话式流式问答:绑定短期会话上下文,返回 SSE 增量回答。""" + service = LearningAssistantService(db) + assistant_session = service.validate_session(ctx, assistant_session_id) + stream = service.stream_session_chat(ctx, payload, assistant_session) + return _sse_response(stream) + + +def _sse_response(stream) -> StreamingResponse: + """SSE 响应封装:关闭代理缓冲,避免前端长时间看不到增量内容。""" return StreamingResponse( stream, media_type="text/event-stream", diff --git a/app/api/router.py b/app/api/router.py index f03dad0..31cf690 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -1,11 +1,10 @@ from fastapi import APIRouter -from app.api import agent, auth, cases, evaluations, knowledge_admin, learning_assistant, sessions, teaching, training_config +from app.api import agent, auth, evaluations, knowledge_admin, learning_assistant, sessions, teaching, 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(teaching.router, prefix="/teaching", tags=["teaching"]) diff --git a/app/api/sessions.py b/app/api/sessions.py index 9fd9e1c..dadb732 100644 --- a/app/api/sessions.py +++ b/app/api/sessions.py @@ -8,7 +8,6 @@ from app.db.session import get_db from app.schemas.evaluation import CreateEvaluationRequest, EvaluationResponse from app.schemas.session import ( ChatRequest, - ChatResponse, CreateOrderRequest, CreateOrderResponse, CreateSessionRequest, @@ -41,19 +40,6 @@ def create_session( return ok(result) -@router.post("/{session_id}/chat", response_model=ApiResponse[ChatResponse]) -async def chat( - session_id: int, - payload: ChatRequest, - ctx: UserContext = Depends(get_user_context), - db: Session = Depends(get_db), -): - """非流式问诊:发送医生问题并返回 AI 病人回复。""" - result = await SessionService(db).chat(ctx, session_id, payload.message) - db.commit() - return ok(result) - - @router.post("/{session_id}/chat/stream", response_class=StreamingResponse) async def chat_stream( session_id: int, @@ -117,7 +103,7 @@ async def generate_hints( ctx: UserContext = Depends(get_user_context), db: Session = Depends(get_db), ): - """新手模式提示:根据当前问诊上下文生成缺失维度和下一步问题。""" + """练习提示:根据当前问诊上下文生成缺失维度和下一步问题。""" result = await SessionService(db).generate_hints(ctx, session_id, payload) db.commit() return ok(result) diff --git a/app/core/config.py b/app/core/config.py index 53997de..2d3feb7 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -111,6 +111,10 @@ class Settings(BaseModel): runtime_memory_fallback_enabled: bool = Field( default_factory=lambda: _env_bool("RUNTIME_MEMORY_FALLBACK_ENABLED", True) ) + learning_assistant_session_ttl_seconds: int = Field( + default_factory=lambda: int(os.getenv("LEARNING_ASSISTANT_SESSION_TTL_SECONDS", os.getenv("RUNTIME_MEMORY_TTL_SECONDS", "7200"))) + ) + learning_assistant_history_limit: int = Field(default_factory=lambda: int(os.getenv("LEARNING_ASSISTANT_HISTORY_LIMIT", "6"))) redis_url: str = Field(default_factory=lambda: os.getenv("REDIS_URL", "redis://redis:6379/0")) auth_validate_enabled: bool = Field(default_factory=lambda: _env_bool("AUTH_VALIDATE_ENABLED", True)) auth_user_me_url: str = Field(default_factory=lambda: os.getenv("AUTH_USER_ME_URL", "")) diff --git a/app/integrations/milvus_adapter.py b/app/integrations/milvus_adapter.py index 678bcd9..224ae03 100644 --- a/app/integrations/milvus_adapter.py +++ b/app/integrations/milvus_adapter.py @@ -43,7 +43,7 @@ class MilvusVectorStore: index_params=index_params, consistency_level="Strong", ) - except Exception as exc: # pragma: no cover - 真实 Milvus 由联调环境验证 + except Exception as exc: # pragma: no cover - 真实 Milvus 由集成环境验证 raise AppError("MILVUS_COLLECTION_INIT_FAILED", "milvus collection init failed", 502) from exc def upsert_vectors(self, collection_name: str, vectors: list[tuple[str, list[float]]]) -> None: diff --git a/app/prompts/hint/novice_hint.md b/app/prompts/hint/novice_hint.md deleted file mode 100644 index 6c01819..0000000 --- a/app/prompts/hint/novice_hint.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -template_code: novice_hint -agent_type: hint -version: v1 -scene: novice -model_type: fast -output_format: text ---- - -# Role - -你是临床问诊教学提示 Agent。 - -# Task - -在新手模式下生成下一步问诊提示,帮助用户补齐问诊框架。 - -# Inputs - -- 当前病例基础信息。 -- 已完成问诊内容。 -- 缺失的关键症状、病史或风险点。 - -# Rules - -- 只提示问诊方向。 -- 不直接给出诊断结论。 -- 不替用户完成问诊。 - -# Output Format - -输出 1 条简短提示。 - -# Safety Boundaries - -提示仅用于教学训练,不构成真实医疗建议。 diff --git a/app/prompts/hint/novice_case_hint.md b/app/prompts/hint/practice_case_hint.md similarity index 90% rename from app/prompts/hint/novice_case_hint.md rename to app/prompts/hint/practice_case_hint.md index e1fe119..f134e21 100644 --- a/app/prompts/hint/novice_case_hint.md +++ b/app/prompts/hint/practice_case_hint.md @@ -1,19 +1,19 @@ --- -template_code: novice_case_hint +template_code: practice_case_hint agent_type: hint version: v1 -scene: novice +scene: practice model_type: fast output_format: json --- # Role -你是医疗问诊训练系统的新手提示 Agent。你的任务是帮助医学生在当前病例训练中发现问诊缺口、下一步问题和必要检查,而不是替学生完成诊断。 +你是医疗问诊训练系统的练习提示 Agent。你的任务是帮助医学生在当前病例训练中发现问诊缺口、下一步问题和必要检查,而不是替学生完成诊断。 # Task -根据输入的病例信息、当前会话状态、短期对话摘要、已申请检查和最后一句医生问题,生成新手模式下可展示的结构化提示。 +根据输入的病例信息、当前会话状态、短期对话摘要、已申请检查和最后一句医生问题,生成练习模式下可展示的结构化提示。 # Inputs diff --git a/app/prompts/patient/novice.md b/app/prompts/patient/novice.md deleted file mode 100644 index 16a3afd..0000000 --- a/app/prompts/patient/novice.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -template_code: patient_novice -agent_type: patient -version: v1 -scene: novice -model_type: fast -output_format: text ---- - -# Role - -你是医疗问诊训练中的 AI 标准化病人或患儿家属。 - -# Task - -在新手模式下回答医生问题,并用更清晰的表达帮助用户建立问诊框架。 - -# Inputs - -- 病例资料和 AI 病人人设。 -- 当前短期 memory。 -- 医生最新问题。 -- 新手模式问诊引导规则。 - -# Rules - -- 只基于病例内信息回答。 -- 不直接给出诊断或治疗方案。 -- 医生问题过宽时,允许用家属口吻提示一个继续追问方向。 -- 不输出检查结果,除非医生明确申请并由系统工具返回。 - -# Output Format - -先回答问题,再补充一句温和引导,例如“医生,您还想了解哪方面?”。 - -# Safety Boundaries - -本输出仅用于教学训练,不构成真实医疗建议,不替代临床医生判断。 diff --git a/app/prompts/patient/practice.md b/app/prompts/patient/practice.md deleted file mode 100644 index d675109..0000000 --- a/app/prompts/patient/practice.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -template_code: patient_practice -agent_type: patient -version: v1 -scene: practice -model_type: fast -output_format: text ---- - -# Role - -你是医疗问诊训练中的 AI 标准化病人或患儿家属。 - -# Task - -在练习模式下根据病例资料回答医生问题,保持真实患者沟通风格。 - -# Inputs - -- 病例基础信息。 -- AI 病人人设。 -- 隐藏信息。 -- 当前会话短期 memory。 -- 医生最新问题。 - -# Rules - -- 只回答被问到的内容。 -- 不主动给出未被追问的隐藏信息。 -- 不评价医生表现。 -- 不输出诊断指导。 -- 不编造病例外检查结果。 - -# Output Format - -使用自然、简短的患者或家属口吻回答。 - -# Safety Boundaries - -本输出仅用于医学模拟训练,不构成真实医疗建议。 diff --git a/app/prompts/patient/teaching.md b/app/prompts/patient/teaching.md deleted file mode 100644 index ef8b0d4..0000000 --- a/app/prompts/patient/teaching.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -template_code: patient_teaching -agent_type: patient -version: v1 -scene: teaching -model_type: fast -output_format: text ---- - -# Role - -你是医疗问诊训练中的 AI 标准化病人或患儿家属,同时支持教学互动。 - -# Task - -回答医生问题,并在不泄露标准答案的前提下给出简短学习提示。 - -# Inputs - -- 病例资料。 -- 教学互动配置。 -- 当前短期 memory。 -- 医生最新问题。 - -# Rules - -- 患者回答和教学提示必须分开。 -- 教学提示只能提示问诊方向,不直接给出诊断结论。 -- 不编造病例外检查结果。 - -# Output Format - -输出格式: -患者回答:... -学习提示:... - -# Safety Boundaries - -本输出仅用于教学训练,不替代真实临床诊疗。 diff --git a/app/prompts/polish/doctor_question_polish.md b/app/prompts/polish/doctor_question_polish.md deleted file mode 100644 index 7078a6a..0000000 --- a/app/prompts/polish/doctor_question_polish.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -template_code: doctor_question_polish -agent_type: polish -version: v1 -scene: doctor_question -model_type: fast -output_format: text ---- - -# Role - -你是临床问诊表达润色 Agent。 - -# Task - -将用户问题改写为更规范、清晰、符合临床问诊习惯的表达。 - -# Inputs - -- 用户原始问题。 -- 当前病例场景。 -- 当前训练模式。 - -# Rules - -- 保留原始意图。 -- 不改变医学含义。 -- 不加入用户没有表达的新问题。 - -# Output Format - -输出润色后的单句问诊问题。 - -# Safety Boundaries - -润色结果仅用于教学训练,不作为真实医疗建议。 diff --git a/app/repositories/case_repository.py b/app/repositories/case_repository.py index 482012a..4be873e 100644 --- a/app/repositories/case_repository.py +++ b/app/repositories/case_repository.py @@ -1,7 +1,7 @@ -from sqlalchemy import exists, select +from sqlalchemy import select from sqlalchemy.orm import Session, selectinload -from app.models.source_case import CaseBase, CaseExamItem, TeachingCase, TraditionalCase +from app.models.source_case import CaseBase, CaseExamItem class CaseRepository: @@ -10,31 +10,8 @@ class CaseRepository: def __init__(self, db: Session) -> None: self.db = db - def list_active_cases( - self, - department_id: int | None = None, - training_type: str | None = None, - mode: str | None = None, - ) -> list[CaseBase]: - """病例列表:从 case_base 读取已发布病例,并按模式匹配扩展表。""" - normalized_mode = "practice" if mode == "novice" else mode - stmt = ( - select(CaseBase) - .options(selectinload(CaseBase.traditional_case), selectinload(CaseBase.teaching_case)) - .where(CaseBase.status == 1, CaseBase.publish_status == 1) - ) - if department_id: - stmt = stmt.where(CaseBase.department_id == department_id) - if training_type: - stmt = stmt.where(CaseBase.case_type == training_type) - if normalized_mode == "practice": - stmt = stmt.where(exists().where(TraditionalCase.case_id == CaseBase.id)) - if normalized_mode == "teaching": - stmt = stmt.where(exists().where(TeachingCase.case_id == CaseBase.id)) - return list(self.db.scalars(stmt.order_by(CaseBase.id.desc())).all()) - def get_active_case(self, case_id: int) -> CaseBase | None: - """病例详情:读取病例主表及训练所需的扩展表、评分规则和检查项目。""" + """病例读取:读取病例主表及训练所需的扩展表、评分规则和检查项目。""" stmt = ( select(CaseBase) .options( diff --git a/app/schemas/case.py b/app/schemas/case.py deleted file mode 100644 index 2f79eef..0000000 --- a/app/schemas/case.py +++ /dev/null @@ -1,52 +0,0 @@ -from pydantic import BaseModel, ConfigDict - - -class CaseListItem(BaseModel): - """病例列表项:不暴露标准答案和隐藏信息。""" - - id: int - case_code: str - department_id: int - title: str - difficulty: str - chief_complaint: str | None = None - supported_training_type: str - supported_mode: str - has_teaching_video: bool - has_knowledge_points: bool - has_quiz: bool - - model_config = ConfigDict(from_attributes=True) - - -class CaseListResponse(BaseModel): - """病例列表响应:返回激活病例集合。""" - - items: list[CaseListItem] - - -class CasePatientInfo(BaseModel): - """患者展示信息:用于病例详情页。""" - - name: str | None = None - age: int | None = None - gender: str | None = None - occupation: str | None = None - - -class CaseDetailResponse(BaseModel): - """病例详情响应:展示训练入口需要的信息。""" - - id: int - case_code: str - title: str - department: str - difficulty: str - patient: CasePatientInfo - chief_complaint: str | None = None - supported_training_type: str - supported_mode: str - has_teaching_video: bool - has_knowledge_points: bool - has_quiz: bool - order_item_types: list[str] diff --git a/app/schemas/evaluation.py b/app/schemas/evaluation.py index 43454b3..0d3425d 100644 --- a/app/schemas/evaluation.py +++ b/app/schemas/evaluation.py @@ -79,13 +79,6 @@ class EvaluationListResponse(BaseModel): pagination: PaginationMeta -class ExportPdfResponse(BaseModel): - """PDF 导出响应:返回导出记录和本地文件路径。""" - - export_id: int - file_path: str - - class EvaluationDetailResponse(EvaluationResponse): """评价详情响应:在报告详情页使用。""" diff --git a/app/schemas/learning_assistant.py b/app/schemas/learning_assistant.py index 44ff73d..d8cccc8 100644 --- a/app/schemas/learning_assistant.py +++ b/app/schemas/learning_assistant.py @@ -1,10 +1,30 @@ from pydantic import BaseModel, Field -class LearningAssistantChatRequest(BaseModel): - """学习助手请求:普通用户面向机构知识库提出医学学习问题。""" +class LearningAssistantSessionCreateRequest(BaseModel): + """学习助手会话创建请求:进入 AI 学习助手页面时初始化短期问答会话。""" - question: str = Field(..., min_length=2, max_length=1000, description="用户问题") + title: str | None = Field(default=None, max_length=100, description="会话标题,前端可不传") + + +class LearningAssistantSessionResponse(BaseModel): + """学习助手会话响应:返回前端后续流式问答需要使用的会话 ID。""" + + assistant_session_id: str + user_id: str + institution_id: int | None = None + institution_name: str | None = None + title: str + status: str + created_at: str + updated_at: str + expires_in_seconds: int + + +class LearningAssistantChatRequest(BaseModel): + """学习助手问答请求:普通用户面向机构知识库提出医学学习问题。""" + + question: str = Field(..., min_length=1, max_length=1000, description="用户问题") top_k: int | None = Field(default=None, ge=1, le=10, description="最终返回给 LLM 的来源片段数") score_threshold: float | None = Field(default=None, ge=0, le=1, description="向量相似度过滤阈值") @@ -20,17 +40,3 @@ class LearningAssistantSource(BaseModel): chunk_uid: str score: float quote: str - - -class LearningAssistantChatResponse(BaseModel): - """学习助手回答:返回答案、知识库命中状态、循证来源和耗时。""" - - answer: str - retrieval_hit: bool - sources: list[LearningAssistantSource] = Field(default_factory=list) - retrieval_error: str | None = None - model: str | None = None - embedding_latency_ms: int | None = None - search_latency_ms: int | None = None - llm_latency_ms: int | None = None - total_latency_ms: int | None = None diff --git a/app/schemas/session.py b/app/schemas/session.py index 13760ef..7c0e4b6 100644 --- a/app/schemas/session.py +++ b/app/schemas/session.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field from app.schemas.training_config import PatientConfig @@ -8,16 +8,10 @@ class CreateSessionRequest(BaseModel): case_id: int training_type: str = Field(pattern="^(case_analysis|diagnosis_treatment|consultation)$") - mode: str = Field(pattern="^(novice|practice|teaching)$") + mode: str = Field(pattern="^practice$") score_type: str = Field(default="percentage", pattern="^(percentage|five_point)$") patient_config: PatientConfig | None = None - @field_validator("mode") - @classmethod - def normalize_mode(cls, value: str) -> str: - """训练模式:兼容旧 novice 请求,实际按 practice 练习模式处理。""" - return "practice" if value == "novice" else value - class CreateSessionResponse(BaseModel): """创建会话响应:返回会话标识和 AI 病人开场白。""" @@ -35,15 +29,6 @@ class ChatRequest(BaseModel): message: str = Field(min_length=1, max_length=2000) -class ChatResponse(BaseModel): - """问诊消息响应:返回 AI 病人的非流式回复。""" - - reply: str - latency_ms: int - model: str - fallback_used: bool = False - - class OrderItemResponse(BaseModel): """可申请检查项:只返回名称和类型,不返回结果。""" @@ -116,7 +101,7 @@ class SubmitTreatmentResponse(BaseModel): class HintRequest(BaseModel): - """会话提示入参:基于当前会话上下文生成新手模式提醒。""" + """会话提示入参:基于当前会话上下文生成练习提示。""" last_user_message: str | None = Field(default=None, max_length=2000) scope: str = Field(default="current_conversation", pattern="^current_conversation$") diff --git a/app/services/case_service.py b/app/services/case_service.py deleted file mode 100644 index 2d0ef64..0000000 --- a/app/services/case_service.py +++ /dev/null @@ -1,85 +0,0 @@ -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.repositories.source_case_repository import SourceCaseRepository -from app.schemas.case import CaseDetailResponse, CaseListItem, CaseListResponse, CasePatientInfo - - -class CaseService: - """病例服务:基于 case_base 新表体系提供病例列表和训练入口详情。""" - - def __init__(self, db: Session) -> None: - self.db = db - self.repo = CaseRepository(db) - self.source_repo = SourceCaseRepository(db) - - def list_cases( - self, - department_id: int | None = None, - training_type: str | None = None, - mode: str | None = None, - ) -> CaseListResponse: - """病例列表:从 case_base 读取已发布病例,并按模式匹配传统/教学互动扩展表。""" - cases = self.repo.list_active_cases(department_id=department_id, training_type=training_type, mode=mode) - return CaseListResponse(items=[self._to_list_item(case) for case in cases]) - - def get_case_detail(self, case_id: int) -> CaseDetailResponse: - """病例详情:展示训练入口信息,不返回标准答案、隐藏病情和评分细则。""" - case = self.repo.get_active_case(case_id) - if not case: - raise AppError("CASE_NOT_FOUND", "case not found or inactive", 404) - order_items = self.repo.get_exam_items(case.id) - return CaseDetailResponse( - id=case.id, - case_code=f"SRC_{case.id}", - title=case.title, - department=self.source_repo.get_department_name(case.department_id), - difficulty=case.difficulty, - patient=CasePatientInfo( - name=None, - age=case.patient_age, - gender=case.patient_gender, - occupation=None, - ), - chief_complaint=case.chief_complaint, - supported_training_type=self._training_type(case.case_type), - supported_mode=self._supported_mode(case), - has_teaching_video=self._has_video(case), - has_knowledge_points=bool(case.knowledge_points), - has_quiz=bool(case.teaching_case and case.teaching_case.discussion_questions), - order_item_types=sorted({item.item_type for item in order_items}), - ) - - def _to_list_item(self, case: CaseBase) -> CaseListItem: - """病例卡片转换:把 case_base 映射为当前前端病例列表结构。""" - return CaseListItem( - id=case.id, - case_code=f"SRC_{case.id}", - department_id=case.department_id or 0, - title=case.title, - difficulty=case.difficulty, - chief_complaint=case.chief_complaint, - supported_training_type=self._training_type(case.case_type), - supported_mode=self._supported_mode(case), - has_teaching_video=self._has_video(case), - has_knowledge_points=bool(case.knowledge_points), - has_quiz=bool(case.teaching_case and case.teaching_case.discussion_questions), - ) - - @staticmethod - def _supported_mode(case: CaseBase) -> str: - """模式标识:教学互动病例显示 interactive,其余显示 free_chat。""" - return "interactive" if case.teaching_case else "free_chat" - - @staticmethod - def _has_video(case: CaseBase) -> bool: - """资源标识:根据 source 表 multimodal_assets 判断是否存在视频资源。""" - assets = case.multimodal_assets or [] - return any(isinstance(item, dict) and item.get("type") == "video" for item in assets) - - @staticmethod - def _training_type(case_type: str) -> str: - """训练类别兼容:源库 case_type 不在当前枚举内时按诊断治疗训练处理。""" - return case_type if case_type in {"case_analysis", "diagnosis_treatment", "consultation"} else "diagnosis_treatment" diff --git a/app/services/learning_assistant_service.py b/app/services/learning_assistant_service.py index 9ab687d..5168f5e 100644 --- a/app/services/learning_assistant_service.py +++ b/app/services/learning_assistant_service.py @@ -2,6 +2,7 @@ import json import time from collections.abc import AsyncIterator from dataclasses import dataclass +from typing import Any from sqlalchemy.orm import Session @@ -10,8 +11,14 @@ from app.core.config import settings from app.core.context import UserContext from app.core.exceptions import AppError from app.repositories.knowledge_base_repository import KnowledgeBaseRepository -from app.schemas.learning_assistant import LearningAssistantChatRequest, LearningAssistantChatResponse, LearningAssistantSource +from app.schemas.learning_assistant import ( + LearningAssistantChatRequest, + LearningAssistantSessionCreateRequest, + LearningAssistantSessionResponse, + LearningAssistantSource, +) from app.services.knowledge_space_service import KnowledgeSpaceService +from app.services.learning_assistant_session_store import LearningAssistantSessionStore, learning_assistant_session_store from app.services.vector_search_service import RetrievedChunk, VectorSearchService @@ -28,7 +35,7 @@ class LearningAssistantRetrieval: class LearningAssistantService: - """AI 学习助手服务:优先 RAG 检索,知识库不可用时降级为通用流式问答。""" + """AI 学习助手服务:管理短期会话,并优先通过 RAG 检索生成流式学习回答。""" def __init__( self, @@ -36,78 +43,115 @@ class LearningAssistantService: *, vector_search_service: VectorSearchService | None = None, agent: LearningAssistantAgent | None = None, + session_store: LearningAssistantSessionStore | None = None, ) -> None: self.db = db self.repo = KnowledgeBaseRepository(db) self.space_service = KnowledgeSpaceService(self.repo) self.vector_search = vector_search_service or VectorSearchService(db) self.agent = agent or LearningAssistantAgent() + self.session_store = session_store or learning_assistant_session_store - async def chat(self, ctx: UserContext, payload: LearningAssistantChatRequest) -> LearningAssistantChatResponse: - """知识问答调试:检索失败不阻断回答,返回完整文本和检索降级信息。""" - start = time.perf_counter() - retrieval = await self._retrieve_sources(ctx, payload) - llm_started = time.perf_counter() - response = await self.agent.answer(payload.question, retrieval.sources) - total_latency_ms = int((time.perf_counter() - start) * 1000) - llm_latency_ms = response.latency_ms or int((time.perf_counter() - llm_started) * 1000) - self._write_query_log( - ctx=ctx, - payload=payload, - retrieval=retrieval, - answer=response.content, - model=response.model, - llm_latency_ms=llm_latency_ms, - total_latency_ms=total_latency_ms, - ) - return LearningAssistantChatResponse( - answer=response.content, - retrieval_hit=bool(retrieval.sources), - sources=retrieval.sources, - retrieval_error=retrieval.retrieval_error, - model=response.model, - embedding_latency_ms=retrieval.embedding_latency_ms, - search_latency_ms=retrieval.search_latency_ms, - llm_latency_ms=llm_latency_ms, - total_latency_ms=total_latency_ms, - ) + def create_session(self, ctx: UserContext, payload: LearningAssistantSessionCreateRequest) -> LearningAssistantSessionResponse: + """学习助手会话创建:进入 AI 学习助手页面时初始化短期上下文容器。""" + state = self.session_store.create(ctx, title=payload.title) + return self._session_response(state) - async def stream_chat(self, ctx: UserContext, payload: LearningAssistantChatRequest) -> AsyncIterator[str]: - """流式知识问答:先返回检索状态,再流式输出 LLM 回答。""" + def validate_session(self, ctx: UserContext, assistant_session_id: str) -> dict[str, Any]: + """学习助手会话校验:确保会话存在、未过期且属于当前用户。""" + state = self.session_store.get(assistant_session_id, ctx.user_id) + if not state: + raise AppError("LEARNING_ASSISTANT_SESSION_NOT_FOUND", "learning assistant session not found", 404) + if state.get("status") != "active": + raise AppError("LEARNING_ASSISTANT_SESSION_INVALID", "learning assistant session is not active", 400) + return state + + async def stream_session_chat( + self, + ctx: UserContext, + payload: LearningAssistantChatRequest, + assistant_session: dict[str, Any], + ) -> AsyncIterator[str]: + """会话式流式问答:绑定学习助手会话,记录最近问答并参与后续提示词拼接。""" + yield self._sse( + "session_ready", + { + "assistant_session_id": assistant_session["assistant_session_id"], + "status": assistant_session["status"], + "history_count": len(assistant_session.get("messages") or []), + }, + ) + async for event in self._stream_answer(ctx, payload, assistant_session=assistant_session): + yield event + + async def _stream_answer( + self, + ctx: UserContext, + payload: LearningAssistantChatRequest, + *, + assistant_session: dict[str, Any] | None, + ) -> AsyncIterator[str]: + """学习助手流式核心流程:检索知识库、调用 LLM、写入查询日志和短期会话上下文。""" start = time.perf_counter() + assistant_session_id = assistant_session.get("assistant_session_id") if assistant_session else None + history = ( + self.session_store.get_messages(assistant_session_id, ctx.user_id, settings.learning_assistant_history_limit) + if assistant_session_id + else [] + ) + if assistant_session_id: + self.session_store.append_message(assistant_session_id, ctx.user_id, "user", payload.question) + retrieval = await self._retrieve_sources(ctx, payload) yield self._sse( "retrieval_done", - { - "retrieval_hit": bool(retrieval.sources), - "sources": [source.model_dump() for source in retrieval.sources], - "retrieval_error": retrieval.retrieval_error, - "embedding_latency_ms": retrieval.embedding_latency_ms, - "search_latency_ms": retrieval.search_latency_ms, - }, + self._with_session( + assistant_session_id, + { + "retrieval_hit": bool(retrieval.sources), + "sources": [source.model_dump() for source in retrieval.sources], + "retrieval_error": retrieval.retrieval_error, + "embedding_latency_ms": retrieval.embedding_latency_ms, + "search_latency_ms": retrieval.search_latency_ms, + }, + ), ) answer_parts: list[str] = [] llm_latency_ms: int | None = None model: str | None = None try: - async for chunk in self.agent.stream_answer(payload.question, retrieval.sources): + async for chunk in self.agent.stream_answer(payload.question, retrieval.sources, history=history): if chunk.done: llm_latency_ms = chunk.total_latency_ms model = chunk.model break if chunk.delta: answer_parts.append(chunk.delta) - yield self._sse("answer_delta", {"delta": chunk.delta}) + yield self._sse("answer_delta", self._with_session(assistant_session_id, {"delta": chunk.delta})) except AppError as exc: - yield self._sse("error", {"code": exc.code, "message": exc.message}) + yield self._sse("error", self._with_session(assistant_session_id, {"code": exc.code, "message": exc.message})) return except Exception: - yield self._sse("error", {"code": "LEARNING_ASSISTANT_LLM_FAILED", "message": "AI 学习助手回答生成失败,请稍后重试"}) + yield self._sse( + "error", + self._with_session( + assistant_session_id, + {"code": "LEARNING_ASSISTANT_LLM_FAILED", "message": "AI 学习助手回答生成失败,请稍后重试"}, + ), + ) return answer = "".join(answer_parts) total_latency_ms = int((time.perf_counter() - start) * 1000) + if assistant_session_id: + self.session_store.append_message( + assistant_session_id, + ctx.user_id, + "assistant", + answer, + metadata={"retrieval_hit": bool(retrieval.sources), "source_count": len(retrieval.sources), "model": model}, + ) self._write_query_log( ctx=ctx, payload=payload, @@ -118,7 +162,17 @@ class LearningAssistantService: total_latency_ms=total_latency_ms, commit=True, ) - yield self._sse("answer_done", {"model": model, "total_latency_ms": total_latency_ms}) + yield self._sse( + "answer_done", + self._with_session( + assistant_session_id, + { + "model": model, + "total_latency_ms": total_latency_ms, + "llm_latency_ms": llm_latency_ms, + }, + ), + ) async def _retrieve_sources(self, ctx: UserContext, payload: LearningAssistantChatRequest) -> LearningAssistantRetrieval: """知识检索:按机构读取知识空间;无空间、Milvus 或 embedding 异常时降级为空来源。""" @@ -204,7 +258,6 @@ class LearningAssistantService: sources: list[LearningAssistantSource] = [] for item in chunks: document = self.repo.get_document(item.chunk.document_id, item.chunk.institution_id) - quote = item.chunk.chunk_text[:500] sources.append( LearningAssistantSource( document_id=item.chunk.document_id, @@ -214,11 +267,31 @@ class LearningAssistantService: page_end=item.chunk.page_end, chunk_uid=item.chunk.chunk_uid, score=round(item.score, 4), - quote=quote, + quote=item.chunk.chunk_text[:500], ) ) return sources + def _session_response(self, state: dict[str, Any]) -> LearningAssistantSessionResponse: + """会话响应转换:只返回前端需要展示和后续调用的字段。""" + return LearningAssistantSessionResponse( + assistant_session_id=state["assistant_session_id"], + user_id=state["user_id"], + institution_id=state.get("institution_id"), + institution_name=state.get("institution_name"), + title=state["title"], + status=state["status"], + created_at=state["created_at"], + updated_at=state["updated_at"], + expires_in_seconds=state["expires_in_seconds"], + ) + def _sse(self, event: str, data: dict) -> str: """SSE 封装:统一输出 event + data 格式。""" return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + + def _with_session(self, assistant_session_id: str | None, data: dict) -> dict: + """SSE 数据增强:会话式接口返回 assistant_session_id,旧接口保持兼容。""" + if assistant_session_id: + return {"assistant_session_id": assistant_session_id, **data} + return data diff --git a/app/services/learning_assistant_session_store.py b/app/services/learning_assistant_session_store.py new file mode 100644 index 0000000..2bb8350 --- /dev/null +++ b/app/services/learning_assistant_session_store.py @@ -0,0 +1,141 @@ +import json +import time +import uuid +from datetime import datetime +from threading import Lock +from typing import Any + +from app.core.config import settings +from app.core.context import UserContext + + +class LearningAssistantSessionStore: + """AI 学习助手短期会话存储:使用 Redis 保存会话状态,测试或降级时使用进程内存。""" + + key_prefix = "learning_assistant:session:" + + def __init__(self) -> None: + self._lock = Lock() + self._memory_store: dict[str, dict[str, Any]] = {} + self._redis_client = self._create_redis_client() + + def create(self, ctx: UserContext, title: str | None = None) -> dict[str, Any]: + """学习助手会话创建:按当前用户和机构初始化一个短期问答会话。""" + now = self._now() + session_id = f"las_{uuid.uuid4().hex}" + state: dict[str, Any] = { + "assistant_session_id": session_id, + "user_id": ctx.user_id, + "institution_id": ctx.institution_id, + "institution_name": self._profile_value(ctx, "institution_name"), + "title": title or "AI 学习助手", + "status": "active", + "messages": [], + "created_at": now, + "updated_at": now, + "expires_in_seconds": settings.learning_assistant_session_ttl_seconds, + } + self._save(state) + return state + + def get(self, assistant_session_id: str, user_id: str) -> dict[str, Any] | None: + """学习助手会话读取:只返回属于当前用户且未过期的会话。""" + state = self._load(assistant_session_id) + if not state or state.get("user_id") != user_id: + return None + return state + + def get_messages(self, assistant_session_id: str, user_id: str, limit: int | None = None) -> list[dict[str, Any]]: + """学习助手上下文读取:返回当前会话最近若干轮问答,用于提示词拼接。""" + state = self.get(assistant_session_id, user_id) + if not state: + return [] + messages = list(state.get("messages") or []) + if limit is None: + limit = settings.learning_assistant_history_limit + return messages[-limit:] + + def append_message( + self, + assistant_session_id: str, + user_id: str, + role: str, + content: str, + metadata: dict | None = None, + ) -> dict[str, Any] | None: + """学习助手上下文写入:记录用户问题和 AI 回答,按 TTL 自动过期。""" + state = self.get(assistant_session_id, user_id) + if not state: + return None + messages = list(state.get("messages") or []) + messages.append( + { + "role": role, + "content": content, + "metadata": metadata or {}, + "created_at": self._now(), + } + ) + max_messages = max(settings.learning_assistant_history_limit * 2, 2) + state["messages"] = messages[-max_messages:] + state["updated_at"] = self._now() + self._save(state) + return state + + def _create_redis_client(self): + """Redis 客户端创建:遵循 runtime memory 配置,失败时按配置降级。""" + if settings.runtime_memory_backend.lower() != "redis": + return None + try: + import redis + + client = redis.Redis.from_url(settings.redis_url, decode_responses=True) + client.ping() + return client + except Exception: + if settings.is_production and not settings.runtime_memory_fallback_enabled: + raise RuntimeError("Redis is required for learning assistant sessions") + return None + + def _save(self, state: dict[str, Any]) -> None: + """会话保存:Redis 使用 setex,内存 fallback 使用过期时间戳。""" + session_id = state["assistant_session_id"] + ttl = settings.learning_assistant_session_ttl_seconds + if self._redis_client is not None: + self._redis_client.setex(self._key(session_id), ttl, json.dumps(state, ensure_ascii=False)) + return + with self._lock: + self._memory_store[session_id] = {"expires_at": time.time() + ttl, "state": state} + + def _load(self, assistant_session_id: str) -> dict[str, Any] | None: + """会话加载:读取并校验短期会话是否仍然有效。""" + if self._redis_client is not None: + raw = self._redis_client.get(self._key(assistant_session_id)) + if not raw: + return None + return json.loads(raw) + with self._lock: + item = self._memory_store.get(assistant_session_id) + if not item: + return None + if item["expires_at"] < time.time(): + self._memory_store.pop(assistant_session_id, None) + return None + return dict(item["state"]) + + def _key(self, assistant_session_id: str) -> str: + """Redis key 生成:与训练短期 memory 隔离。""" + return f"{self.key_prefix}{assistant_session_id}" + + def _profile_value(self, ctx: UserContext, key: str) -> Any: + """用户资料读取:从 Django `/me` 标准化 profile 中提取扩展字段。""" + if not ctx.profile: + return None + return ctx.profile.get(key) + + def _now(self) -> str: + """时间格式化:返回 ISO 字符串,便于前端展示和日志排查。""" + return datetime.utcnow().isoformat(timespec="seconds") + "Z" + + +learning_assistant_session_store = LearningAssistantSessionStore() diff --git a/app/services/pdf_export_service.py b/app/services/pdf_export_service.py index ba65c2c..3da48bc 100644 --- a/app/services/pdf_export_service.py +++ b/app/services/pdf_export_service.py @@ -454,7 +454,7 @@ class PdfExportService: def _mode_label(self, mode: str) -> str: """训练模式标签:转换内部枚举为中文显示。""" - return {"practice": "练习模式", "teaching": "教学互动模式", "novice": "练习模式"}.get(mode, mode) + return {"practice": "练习模式", "teaching": "教学互动模式"}.get(mode, mode) def _format_datetime(self, value: datetime | None) -> str: """时间格式化:统一报告中的时间展示。""" diff --git a/app/services/session_service.py b/app/services/session_service.py index 12365b1..b61cf18 100644 --- a/app/services/session_service.py +++ b/app/services/session_service.py @@ -15,7 +15,6 @@ from app.models.training import SessionSubmission, TrainingSession from app.repositories.case_repository import CaseRepository from app.repositories.session_repository import SessionRepository from app.schemas.session import ( - ChatResponse, CreateSessionRequest, CreateSessionResponse, SessionStatusResponse, @@ -80,34 +79,6 @@ class SessionService: patient_config=patient_config, ) - async def chat(self, ctx: UserContext, session_id: int, message: str) -> ChatResponse: - """问诊对话:拼接病例上下文、短期记忆和用户输入后调用 Patient Agent。""" - session = self._get_session(session_id, ctx.user_id) - if session.status != "inquiry": - raise AppError("SESSION_STATUS_INVALID", "chat is only allowed in inquiry status", 400) - case = self.case_repo.get_active_case(session.case_id) - if not case: - raise AppError("CASE_NOT_FOUND", "case not found or inactive", 404) - - start = time.perf_counter() - memory_messages = runtime_memory.get_messages(session.memory_key) - runtime_memory.add_message(session.memory_key or "", "doctor", message) - try: - response = await asyncio.wait_for( - self.orchestrator.patient_reply(session, case, memory_messages, message), - timeout=settings.llm_chat_timeout_seconds, - ) - except TimeoutError as exc: - raise AppError("LLM_CALL_TIMEOUT", "AI 病人回复超时,请稍后重试或切换为普通问诊", 504) from exc - runtime_memory.add_message(session.memory_key or "", "patient", response.content) - self.audit.log(ctx, "session.chat", "training_session", str(session.id), session.id) - return ChatResponse( - reply=response.content, - latency_ms=response.latency_ms or int((time.perf_counter() - start) * 1000), - model=response.model, - fallback_used=response.model.startswith("mock-fallback"), - ) - async def stream_chat(self, ctx: UserContext, session_id: int, message: str) -> AsyncIterator[str]: """流式问诊:返回 SSE 格式的 AI 病人回复。""" session = self._get_session(session_id, ctx.user_id) @@ -214,7 +185,7 @@ class SessionService: return f"event: error\ndata: {payload}\n\n" async def generate_hints(self, ctx: UserContext, session_id: int, payload: HintRequest) -> HintResponse: - """新手提示:基于当前会话上下文、已申请检查和病例信息生成提醒。""" + """练习提示:基于当前会话上下文、已申请检查和病例信息生成提醒。""" session = self._get_session(session_id, ctx.user_id) if session.mode != "practice": raise AppError("SESSION_STATUS_INVALID", "hints are only available in practice mode", 400) diff --git a/docs/00_project_overview.md b/docs/00_project_overview.md index a5db5f2..c4f65b8 100644 --- a/docs/00_project_overview.md +++ b/docs/00_project_overview.md @@ -13,7 +13,7 @@ - 病例 PDF 解析入库 - 病例增删改后台 - 多租户权限后台 -- HIS/LIS/PACS 对接 +- HIS/LIS/PACS 连接 - 前端最终 UI 用户身份来自 Django 用户中心。前端携带 `Authorization: Bearer ` 调用 FastAPI,FastAPI 转发 token 到 Django `/api/user/users/me/`,以 Django 返回的 `id` 作为本服务统一 `user_id`。 @@ -23,7 +23,6 @@ | 模块 | 状态 | 主要入口 | |---|---|---| | 用户鉴权 | 已实现 | `GET /api/v1/auth/me` | -| 病例读取 | 已实现 | `GET /api/v1/cases`、`GET /api/v1/cases/{case_id}` | | 训练配置 | 已实现 | `GET /api/v1/training-config/recommended`、`GET /api/v1/training-config/options` | | 训练会话 | 已实现 | `POST /api/v1/sessions` | | 流式问诊 | 已实现 | `POST /api/v1/sessions/{session_id}/chat/stream` | @@ -34,15 +33,15 @@ | 个人中心训练记录 | 已实现,支持分页 | `GET /api/v1/evaluations?page=1&page_size=10` | | PDF 下载 | 已实现 | `GET /api/v1/evaluations/{evaluation_id}/download-pdf` | | 教学互动 | 已实现 | `GET /api/v1/teaching/cases/{case_id}/items`、`POST /api/v1/teaching/evaluation` | -| AI 学习助手 | 已实现流式问答 | `POST /api/v1/learning-assistant/chat/stream` | -| 内容管理员知识库上传 | 基础链路已实现 | `POST /api/v1/knowledge-admin/documents/upload` | +| AI 学习助手 | 已实现短期会话和流式问答 | `POST /api/v1/learning-assistant/sessions`、`POST /api/v1/learning-assistant/sessions/{assistant_session_id}/chat/stream` | +| 内容管理员知识库上传 | 后台预留能力,当前前端不展示 | `POST /api/v1/knowledge-admin/documents/upload` | | 健康检查 | 已实现 | `/health/live`、`/health/ready` | ## 3. 逻辑顺序 1. 项目边界:FastAPI 是后端子服务,不做登录、病例管理和最终 UI。 2. 认证链路:前端 token -> FastAPI -> Django `/me` -> 统一 `user_id`。 -3. 训练链路:病例 -> 配置 -> 会话 -> 问诊 -> 检查 -> 诊断 -> 治疗 -> 评价 -> PDF -> 历史记录。 +3. 训练链路:病例 ID -> 配置 -> 会话 -> 问诊 -> 检查 -> 诊断 -> 治疗 -> 评价 -> PDF -> 历史记录。 4. 教学互动链路:题目列表 -> 答题 -> 评价 -> PDF。 5. AI 学习助手链路:机构知识库检索 -> LLM 流式回答;无知识库时降级通用回答。 6. 数据库边界:平台基础数据由 Django/平台维护,FastAPI 主要写训练过程和训练结果。 @@ -76,13 +75,14 @@ |---|---| | [01_architecture.md](01_architecture.md) | 系统架构、核心链路、模块边界 | | [02_database.md](02_database.md) | 数据库表、读写边界、表含义 | -| [03_api_design.md](03_api_design.md) | 前端联调用 API 文档 | +| [03_api_design.md](03_api_design.md) | 前端 API 文档 | | [04_deployment.md](04_deployment.md) | 云服务器部署、更新、回滚 | | [05_modules.md](05_modules.md) | 模块职责、接口、代码入口 | | [06_maintenance_guide.md](06_maintenance_guide.md) | 开发维护、风险、发布检查清单 | | [07_troubleshooting.md](07_troubleshooting.md) | 常见故障排查 | | [08_feature_code_map.md](08_feature_code_map.md) | 功能到接口、代码、数据表的映射表 | | [09_prompt_template_catalog.md](09_prompt_template_catalog.md) | 提示词模板目录和调用说明 | +| [10_function_workflow.md](10_function_workflow.md) | 功能工作流程、接口、提示词、数据库和结果去向 | ## 6. 本地测试命令 @@ -127,7 +127,7 @@ http://8.160.178.88/fastapi/docs - `git status --short` 无未确认改动,或已明确哪些改动尚未提交。 - `.env` 不提交 Git。 -- `docs/03_api_design.md` 是前端联调依据。 +- `docs/03_api_design.md` 是前端开发调用依据。 - `docs/02_database.md` 与当前 ORM 表名一致。 - 自动化测试全部通过。 - 云端 `/fastapi/docs` 可访问。 diff --git a/docs/01_architecture.md b/docs/01_architecture.md index c563620..9d66033 100644 --- a/docs/01_architecture.md +++ b/docs/01_architecture.md @@ -11,7 +11,7 @@ - 训练页面:训练配置、新建会话、流式问诊、练习提示、检查申请、诊断治疗提交、AI 评价、PDF 下载 - 教学互动:题目列表、答题评价、评价详情、PDF 下载 - 个人中心:训练记录列表、训练记录详情 -- AI 学习助手:流式知识问答,优先 RAG,知识库不可用时降级为通用 LLM 回答 +- AI 学习助手:短期会话和流式知识问答,优先 RAG,知识库不可用时降级为通用 LLM 回答 - 后台预留:内容管理员知识库上传、PDF 解析、分片、Embedding、Milvus、Celery 异步任务 ## 2. 总体架构 @@ -103,21 +103,23 @@ flowchart TD ```mermaid flowchart TD - A["用户提问"] --> B["按 institution_id 定位知识空间"] - B --> C{"知识库可用?"} - C -- 是 --> D["Embedding 用户问题"] - D --> E["Milvus 检索 chunks"] - E --> F["拼接来源和问题"] - C -- 否 --> G["空来源降级"] - F --> H["LLM 流式回答"] - G --> H - H --> I["SSE: retrieval_done / answer_delta / answer_done"] + A["新建学习助手短期会话"] --> B["用户提问"] + B --> C["按 institution_id 定位知识空间"] + C --> D{"知识库可用?"} + D -- 是 --> E["Embedding 用户问题"] + E --> F["Milvus 检索 chunks"] + F --> G["拼接来源、问题和短期上下文"] + D -- 否 --> H["空来源降级"] + G --> I["LLM 流式回答"] + H --> I + I --> J["SSE: session_ready / retrieval_done / answer_delta / answer_done"] ``` 当前正式前端接口只使用: ```text -POST /api/v1/learning-assistant/chat/stream +POST /api/v1/learning-assistant/sessions +POST /api/v1/learning-assistant/sessions/{assistant_session_id}/chat/stream ``` 知识库不可用时不会阻断回答,接口会返回 `retrieval_hit=false` 和 `retrieval_error`,随后继续输出通用 LLM 回答。 diff --git a/docs/02_database.md b/docs/02_database.md index 89416e0..f2240dc 100644 --- a/docs/02_database.md +++ b/docs/02_database.md @@ -55,7 +55,7 @@ FastAPI 使用 Django 返回的 `id` 作为业务 `user_id`。 | 项 | 说明 | |---|---| | 用途 | 病例主表,训练和教学互动共用 | -| 读取模块 | 病例列表、病例详情、训练会话、教学互动 | +| 读取模块 | 训练配置、训练会话、教学互动、评价 | | 关键字段 | `id`、`department_id`、`title`、`difficulty`、`case_type`、`status` | | 写入方 | 平台病例解析 / 后台维护 | | FastAPI 是否写入 | 否 | @@ -64,7 +64,7 @@ FastAPI 使用 Django 返回的 `id` 作为业务 `user_id`。 | 项 | 说明 | |---|---| -| 用途 | 练习模式传统病例详情 | +| 用途 | 练习模式传统病例资料 | | 读取模块 | 训练会话、Patient Agent、Scoring Agent | | 关键字段 | `case_id`、主诉、现病史、既往史、查体、辅助检查、诊断、治疗 | | 写入方 | 平台病例解析 / 后台维护 | diff --git a/docs/03_api_design.md b/docs/03_api_design.md index 231a77d..4f42aff 100644 --- a/docs/03_api_design.md +++ b/docs/03_api_design.md @@ -1,6 +1,6 @@ # 医疗问诊 Agent API 文档 -本文档面向前端联调,描述当前 FastAPI 后端可调用接口。训练模式和教学互动模式按页面模块分开书写;即使底层复用同一路由,文档中也在对应模块下分别列出,便于前端按页面开发。 +本文档面向前端开发调用,描述当前 FastAPI 后端可调用接口。训练模式和教学互动模式按页面模块分开书写;即使底层复用同一路由,文档中也在对应模块下分别列出,便于前端按页面开发。 公网基础地址: @@ -91,14 +91,11 @@ curl -X GET "http://8.160.178.88/fastapi/api/v1/auth/me" \ } ``` -## 3. 病例基础接口 +## 3. 病例 ID 说明 -| 接口名称 | url | api | methods | params(入参) | response(返回参数) | -|---|---|---|---|---|---| -| 病例列表 | `http://8.160.178.88/fastapi/api/v1/cases` | `/api/v1/cases` | `GET` | Query:`department_id` 选填,科室 ID;`training_type` 选填,训练类型;`mode` 选填,交互模式 | `data.items[]` 病例列表,包含 `case_id`、`title`、`department_name`、`difficulty`、`chief_complaint` 等 | -| 病例详情 | `http://8.160.178.88/fastapi/api/v1/cases/{case_id}` | `/api/v1/cases/{case_id}` | `GET` | Path:`case_id` 必填,病例 ID | `data.case_id` 病例 ID;`data.title` 病例标题;`data.chief_complaint` 主诉;`data.supported_modes` 支持模式;`data.exam_item_types` 可用检查类型 | +当前 FastAPI 后端不提供病例公开查询接口。训练页面和教学互动页面需要使用的 `case_id` 由页面上下文传入,后端在各业务接口内部读取 `case_base`、`traditional_case`、`teaching_case` 和 `case_exam_item` 完成校验和业务处理。 -说明:病例接口只读平台数据库中的已发布病例,不提供病例新增、删除、PDF 解析接口。 +病例、教学题、检查项和评分规则由平台数据库维护。FastAPI 不提供病例新增、删除、PDF 解析入库接口。 ## 4. 训练模式 API @@ -124,7 +121,7 @@ curl -X GET "http://8.160.178.88/fastapi/api/v1/auth/me" \ | 接口名称 | url | api | methods | params(入参) | response(返回参数) | |---|---|---|---|---|---| -| 新建会话 | `http://8.160.178.88/fastapi/api/v1/sessions` | `/api/v1/sessions` | `POST` | Body:`case_id` 必填,病例 ID;`training_type` 必填,当前使用 `diagnosis_treatment`;`mode` 必填,当前训练模式使用 `practice`,兼容旧值 `novice`;`score_type` 必填,`percentage` 或 `five_point`;`patient_config` 选填,病人初始化配置 | `data.session_id` 会话 ID;`data.session_code` 会话编码;`data.status` 当前阶段;`data.patient_opening` AI 病人开场白;`data.patient_config` 实际使用配置 | +| 新建会话 | `http://8.160.178.88/fastapi/api/v1/sessions` | `/api/v1/sessions` | `POST` | Body:`case_id` 必填,病例 ID;`training_type` 必填,当前使用 `diagnosis_treatment`;`mode` 必填,当前训练模式使用 `practice`;`score_type` 必填,`percentage` 或 `five_point`;`patient_config` 选填,病人初始化配置 | `data.session_id` 会话 ID;`data.session_code` 会话编码;`data.status` 当前阶段;`data.patient_opening` AI 病人开场白;`data.patient_config` 实际使用配置 | 请求示例: @@ -367,13 +364,44 @@ async function downloadTeachingPdf(baseUrl: string, token: string, evaluationId: ## 7. AI 学习助手 API -该接口用于普通用户医学知识问答。后端优先检索本机构知识库;如果机构知识库未初始化、Milvus / embedding 暂不可用或未命中来源,接口仍会继续调用 LLM,回答开头会声明“未检索到本机构知识库参考,以下为大模型通用学习回答”。 +该模块用于普通用户医学知识问答。前端点击 AI 学习助手入口后,先创建短期会话,再使用会话式 SSE 接口问答。后端优先检索本机构知识库;如果机构知识库未初始化、Milvus / embedding 暂不可用或未命中来源,接口仍会继续调用 LLM,回答开头会声明“未检索到本机构知识库参考,以下为大模型通用学习回答”。 + +学习助手会话保存在 Redis 短期缓存中,默认 TTL 为 `7200` 秒;该会话只用于当前学习问答上下文,不写入训练记录。 | 接口名称 | url | api | methods | params(入参) | response(返回参数) | |---|---|---|---|---|---| -| AI 学习助手流式问答 | `http://8.160.178.88/fastapi/api/v1/learning-assistant/chat/stream` | `/api/v1/learning-assistant/chat/stream` | `POST` | Body:`question` 必填,1-1000 字;`top_k` 选填,1-10;`score_threshold` 选填,0-1 | SSE:`retrieval_done` 检索状态;`answer_delta` 回答增量;`answer_done` 完成事件;`error` 错误事件 | +| AI 学习助手新建会话 | `http://8.160.178.88/fastapi/api/v1/learning-assistant/sessions` | `/api/v1/learning-assistant/sessions` | `POST` | Header:`Authorization` 必填,`Bearer `;`X-Entry-Scene` 建议填。Body:`title` 选填,会话标题,最大 100 字 | `data.assistant_session_id` 学习助手会话 ID;`data.user_id` 当前用户 ID;`data.institution_id` 当前机构 ID;`data.institution_name` 当前机构名称;`data.title` 会话标题;`data.status` 会话状态;`data.expires_in_seconds` 会话过期时间 | +| AI 学习助手会话式流式问答 | `http://8.160.178.88/fastapi/api/v1/learning-assistant/sessions/{assistant_session_id}/chat/stream` | `/api/v1/learning-assistant/sessions/{assistant_session_id}/chat/stream` | `POST` | Path:`assistant_session_id` 必填,新建会话接口返回。Body:`question` 必填,1-1000 字;`top_k` 选填,1-10,默认使用后端配置;`score_threshold` 选填,0-1,默认使用后端配置 | SSE:`session_ready` 会话就绪;`retrieval_done` 检索状态和来源;`answer_delta` 回答增量;`answer_done` 完成事件;`error` 错误事件 | -请求示例: +新建会话请求示例: + +```json +{ + "title": "儿科肺炎知识复习" +} +``` + +新建会话返回示例: + +```json +{ + "code": "OK", + "message": "success", + "data": { + "assistant_session_id": "las_2e2f4a6b8d4b4f0ba31731a814c1c5b8", + "user_id": "37", + "institution_id": 1, + "institution_name": "某医院", + "title": "儿科肺炎知识复习", + "status": "active", + "created_at": "2026-06-11T12:00:00Z", + "updated_at": "2026-06-11T12:00:00Z", + "expires_in_seconds": 7200 + } +} +``` + +会话式流式问答请求示例: ```json { @@ -383,6 +411,22 @@ async function downloadTeachingPdf(baseUrl: string, token: string, evaluationId: } ``` +会话式 SSE 返回示例: + +```text +event: session_ready +data: {"assistant_session_id":"las_xxx","status":"active","history_count":0} + +event: retrieval_done +data: {"assistant_session_id":"las_xxx","retrieval_hit":true,"sources":[{"document_id":1,"document_title":"诊断学","file_name":"诊断学.pdf","page_start":12,"page_end":12,"chunk_uid":"chunk_001","score":0.82,"quote":"..."}],"retrieval_error":null} + +event: answer_delta +data: {"assistant_session_id":"las_xxx","delta":"支气管肺炎常见表现包括..."} + +event: answer_done +data: {"assistant_session_id":"las_xxx","model":"deepseek-chat","total_latency_ms":1800,"llm_latency_ms":1600} +``` + ## 8. 内容管理员知识库 API 该组接口是后台内容管理员能力,学生端不展示上传入口。当前阶段保留接口和数据结构,后续生产环境接入完整 PDF 解析、分片、embedding、Milvus 构建和异步任务。 @@ -414,5 +458,7 @@ async function downloadTeachingPdf(baseUrl: string, token: string, evaluationId: | 404 | `ORDER_ITEM_NOT_FOUND` | 当前病例不存在该检查项 | | 400 | `ORDER_ITEM_TYPE_MISMATCH` | 体格检查 / 辅助检查接口与检查项类型不匹配 | | 404 | `EVALUATION_NOT_FOUND` | 评价不存在或不属于当前用户 | +| 404 | `LEARNING_ASSISTANT_SESSION_NOT_FOUND` | AI 学习助手会话不存在、已过期或不属于当前用户 | +| 400 | `LEARNING_ASSISTANT_SESSION_INVALID` | AI 学习助手会话状态不可用 | | 502 | `LLM_STREAM_FAILED` | LLM 流式调用失败 | | 503 | `AUTH_USER_CENTER_UNAVAILABLE` | Django 用户中心超时或不可达 | diff --git a/docs/04_deployment.md b/docs/04_deployment.md index 3b4a5d0..6ec3c01 100644 --- a/docs/04_deployment.md +++ b/docs/04_deployment.md @@ -118,10 +118,20 @@ curl "http://8.160.178.88/fastapi/api/v1/auth/me" \ -H "X-Entry-Scene: production_vue" ``` -AI 学习助手流式问答: +AI 学习助手新建会话: ```bash -curl -N -X POST "http://8.160.178.88/fastapi/api/v1/learning-assistant/chat/stream" \ +curl -X POST "http://8.160.178.88/fastapi/api/v1/learning-assistant/sessions" \ + -H "Authorization: Bearer " \ + -H "X-Entry-Scene: production_vue" \ + -H "Content-Type: application/json" \ + -d '{"title":"医学知识学习"}' +``` + +AI 学习助手会话式流式问答: + +```bash +curl -N -X POST "http://8.160.178.88/fastapi/api/v1/learning-assistant/sessions//chat/stream" \ -H "Authorization: Bearer " \ -H "X-Entry-Scene: production_vue" \ -H "Content-Type: application/json" \ @@ -131,6 +141,7 @@ curl -N -X POST "http://8.160.178.88/fastapi/api/v1/learning-assistant/chat/stre 正常应看到: ```text +event: session_ready event: retrieval_done event: answer_delta event: answer_done diff --git a/docs/05_modules.md b/docs/05_modules.md index 5fec579..56f06db 100644 --- a/docs/05_modules.md +++ b/docs/05_modules.md @@ -45,7 +45,7 @@ | 当前状态 | 已实现,正式接口为 SSE | | 相关接口 | `POST /api/v1/sessions/{session_id}/hints/stream` | | 相关代码 | `app/agents/hint_agent.py`、`app/services/session_service.py` | -| 相关提示词 | `app/prompts/hint/novice_case_hint.md` | +| 相关提示词 | `app/prompts/hint/practice_case_hint.md` | | 后续优化 | 增加科室主任风格模板、提示质量评估和可配置提示强度 | ## 5. 检查/检验模块 @@ -88,7 +88,7 @@ |---|---| | 主要作用 | 生成和下载 AI 评价 PDF | | 当前状态 | 已实现 | -| 相关接口 | `POST /api/v1/evaluations/{evaluation_id}/export-pdf`、`GET /api/v1/evaluations/{evaluation_id}/download-pdf` | +| 相关接口 | `GET /api/v1/evaluations/{evaluation_id}/download-pdf` | | 相关代码 | `app/services/pdf_export_service.py` | | 相关表 | `training_record` | | 文件目录 | `storage/reports` | @@ -122,19 +122,19 @@ | 项 | 内容 | |---|---| | 主要作用 | 普通用户提出医学学习问题,后端优先检索机构知识库并流式回答 | -| 当前状态 | 已实现正式流式接口;无知识库时自动降级为通用 LLM 回答 | -| 相关接口 | `POST /api/v1/learning-assistant/chat/stream` | -| 相关代码 | `app/api/learning_assistant.py`、`app/services/learning_assistant_service.py`、`app/agents/learning_assistant_agent.py` | -| 相关表 | `kb_spaces`、`kb_chunks`、`kb_query_logs` | +| 当前状态 | 已实现新建短期会话和会话式流式接口;无知识库时自动降级为通用 LLM 回答 | +| 相关接口 | `POST /api/v1/learning-assistant/sessions`、`POST /api/v1/learning-assistant/sessions/{assistant_session_id}/chat/stream` | +| 相关代码 | `app/api/learning_assistant.py`、`app/services/learning_assistant_service.py`、`app/services/learning_assistant_session_store.py`、`app/agents/learning_assistant_agent.py` | +| 相关表 | `kb_spaces`、`kb_chunks`、`kb_query_logs`;学习助手短期会话保存在 Redis | | 外部依赖 | LLM、Embedding、Milvus | -| 后续优化 | 查询改写、rerank、多轮记忆、来源引用格式优化、成本统计 | +| 后续优化 | 查询改写、rerank、长期学习记录、来源引用格式优化、成本统计 | ## 12. 后台知识库预留模块 | 项 | 内容 | |---|---| | 主要作用 | 内容管理员上传 PDF,构建机构知识库 | -| 当前状态 | 接口和数据结构已预留,生产级大规模入库仍需压测 | +| 当前状态 | 接口和数据结构保留,当前前端不展示入口,生产级大规模入库仍需压测 | | 相关接口 | `POST /api/v1/knowledge-admin/documents/upload`、文档列表、文档详情 | | 相关代码 | `app/api/knowledge_admin.py`、`app/services/document_ingestion_service.py`、`app/integrations/*` | | 相关表 | `kb_spaces`、`kb_documents`、`kb_chunks`、`kb_ingestion_tasks` | diff --git a/docs/07_troubleshooting.md b/docs/07_troubleshooting.md index c5b8573..57764ed 100644 --- a/docs/07_troubleshooting.md +++ b/docs/07_troubleshooting.md @@ -135,7 +135,7 @@ REDIS_URL=redis://redis:6379/0 建议: -- 先用 `/api/v1/learning-assistant/chat/stream` 测试最简单问题 +- 先调用 `/api/v1/learning-assistant/sessions` 创建会话,再用 `/api/v1/learning-assistant/sessions/{assistant_session_id}/chat/stream` 测试最简单问题 - 查看 FastAPI 日志 - 确认模型服务账号额度 diff --git a/docs/08_feature_code_map.md b/docs/08_feature_code_map.md index 99f1050..34f9aa3 100644 --- a/docs/08_feature_code_map.md +++ b/docs/08_feature_code_map.md @@ -30,8 +30,6 @@ SSE 流式接口返回 `event + data`,不包裹上述 JSON 结构。 |---|---|---|---|---|---|---|---| | 用户鉴权 | 当前用户信息 | `GET /api/v1/auth/me` | `app/api/auth.py::auth_me` | `app/services/external_auth_service.py::authenticate` | 无本地仓储 | Django `user`、`institution`、`department` | FastAPI 转发 token 到 Django `/api/user/users/me/`,使用 Django `id` 作为统一 `user_id` | | Agent 状态 | Hello / 功能开关 | `GET /api/v1/agent/hello` | `app/api/agent.py::agent_hello` | `app/core/config.py::as_public_dict` | `app/services/audit_service.py` | `audit_logs` | 给前端展示当前模型、PDF、知识库等能力开关 | -| 病例读取 | 病例列表 | `GET /api/v1/cases` | `app/api/cases.py::list_cases` | `app/services/case_service.py::list_cases` | `app/repositories/case_repository.py` | `case_base`、`traditional_case`、`teaching_case` | 只读已发布启用病例,不做病例新增/删除 | -| 病例读取 | 病例详情 | `GET /api/v1/cases/{case_id}` | `app/api/cases.py::get_case_detail` | `app/services/case_service.py::get_case_detail` | `app/repositories/case_repository.py` | `case_base`、`traditional_case`、`teaching_case` | 前端展示病例基础信息,不作为病例管理后台 | | 训练配置 | 推荐配置信息 | `GET /api/v1/training-config/recommended?case_id=1` | `app/api/training_config.py::get_recommended_training_config` | `app/services/training_config_service.py::get_recommended` | `app/repositories/case_repository.py` | `case_base`、`traditional_case` | 返回默认就诊环境、年龄段、文化程度、性格等病人初始化信息 | | 训练配置 | 可选配置信息 | `GET /api/v1/training-config/options?case_id=1` | `app/api/training_config.py::get_training_config_options` | `app/services/training_config_service.py::get_options` | `app/repositories/case_repository.py` | `case_base` | 返回前端可选配置项,用于自定义病人信息 | | 训练页面 | 新建会话 | `POST /api/v1/sessions` | `app/api/sessions.py::create_session` | `app/services/session_service.py::create_session` | `app/repositories/session_repository.py`、`case_repository.py` | `training_session`、Redis memory | 创建会话、写入 user_id、初始化短期 memory 和病人开场白 | @@ -49,12 +47,11 @@ SSE 流式接口返回 `event + data`,不包裹上述 JSON 结构。 | 训练页面 | 生成评价 | `POST /api/v1/sessions/{session_id}/evaluation` | `app/api/sessions.py::create_evaluation` | `app/services/evaluation_service.py::create_evaluation` | `evaluation_repository.py`、`source_case_repository.py`、`session_repository.py` | `training_record`、`training_score_detail`、`scoring_rule` | 读取评分规则、短期 memory、诊断治疗提交内容,调用 Scoring Agent | | 个人中心 | 训练记录列表 | `GET /api/v1/evaluations?page=1&page_size=10` | `app/api/evaluations.py::list_evaluations` | `app/services/evaluation_service.py::list_history` | `app/repositories/evaluation_repository.py` | `training_record` | 按 Django user_id 隔离,支持分页 | | 个人中心 | 评价详情 | `GET /api/v1/evaluations/{evaluation_id}` | `app/api/evaluations.py::get_evaluation_detail` | `app/services/evaluation_service.py::get_detail` | `evaluation_repository.py` | `training_record`、`training_score_detail` | 训练评价和教学互动评价共用详情接口 | -| 个人中心 | 导出 PDF | `POST /api/v1/evaluations/{evaluation_id}/export-pdf` | `app/api/evaluations.py::export_pdf` | `app/services/pdf_export_service.py::export` | `evaluation_repository.py` | `training_record` | 生成本地 PDF 并写入 `pdf_file_path` | | 个人中心 | 下载 PDF | `GET /api/v1/evaluations/{evaluation_id}/download-pdf` | `app/api/evaluations.py::download_pdf` | `app/services/pdf_export_service.py::export` | `evaluation_repository.py` | `training_record` | 校验 user_id 后返回 `application/pdf` 文件流 | | 教学互动 | 获取教学列表 | `GET /api/v1/teaching/cases/{case_id}/items` | `app/api/teaching.py::get_teaching_items` | `app/services/teaching_service.py::list_items` | `teaching_repository.py`、`case_repository.py` | `case_base`、`teaching_case` | 返回题目、选项、答案、解析文本、视频 | | 教学互动 | 生成评价 | `POST /api/v1/teaching/evaluation` | `app/api/teaching.py::create_teaching_evaluation` | `app/services/teaching_service.py::create_evaluation` | `evaluation_repository.py`、`teaching_repository.py` | `training_record`、`training_score_detail`、`teaching_case` | 根据作答结果生成教学互动评价 | -| AI 学习助手 | 流式问答 | `POST /api/v1/learning-assistant/chat/stream` | `app/api/learning_assistant.py::learning_assistant_stream_chat` | `app/services/learning_assistant_service.py::stream_chat` | `knowledge_base_repository.py` | `kb_spaces`、`kb_chunks`、`kb_query_logs` | 优先 RAG 检索;无知识库时返回 `retrieval_hit=false` 后继续 LLM 回答 | -| AI 学习助手 | 非流式调试问答 | `POST /api/v1/learning-assistant/chat` | `app/api/learning_assistant.py::learning_assistant_chat` | `app/services/learning_assistant_service.py::chat` | `knowledge_base_repository.py` | `kb_spaces`、`kb_chunks`、`kb_query_logs` | `include_in_schema=False`,正式前端不使用 | +| AI 学习助手 | 新建会话 | `POST /api/v1/learning-assistant/sessions` | `app/api/learning_assistant.py::create_learning_assistant_session` | `app/services/learning_assistant_service.py::create_session` | `learning_assistant_session_store.py` | Redis 短期缓存 | 进入 AI 学习助手页面时创建短期会话,返回 `assistant_session_id` | +| AI 学习助手 | 会话式流式问答 | `POST /api/v1/learning-assistant/sessions/{assistant_session_id}/chat/stream` | `app/api/learning_assistant.py::stream_learning_assistant_session_chat` | `app/services/learning_assistant_service.py::stream_session_chat` | `knowledge_base_repository.py`、`learning_assistant_session_store.py` | `kb_spaces`、`kb_chunks`、`kb_query_logs`、Redis 短期缓存 | 优先 RAG 检索;同时保存最近问答上下文;无知识库时继续 LLM 通用学习回答 | | 知识库管理 | 上传 PDF | `POST /api/v1/knowledge-admin/documents/upload` | `app/api/knowledge_admin.py::upload_knowledge_document` | `app/services/document_ingestion_service.py::upload_pdf` | `knowledge_base_repository.py` | `kb_spaces`、`kb_documents`、`kb_ingestion_tasks`、`kb_chunks` | 仅内容管理员使用;学生端不展示入口 | | 健康检查 | 存活检查 | `GET /health/live` | `app/api/health.py::live` | 无 | 无 | 无 | 检查 FastAPI 进程可响应 | | 健康检查 | 就绪检查 | `GET /health/ready` | `app/api/health.py::ready` | `settings.deployment_config_errors` | 数据库连接 | MySQL、Redis 配置 | 用于部署验证 | @@ -64,7 +61,7 @@ SSE 流式接口返回 `event + data`,不包裹上述 JSON 结构。 ### 3.1 训练链路 1. 前端调用 `GET /api/v1/auth/me` 验证用户。 -2. 前端读取病例和训练配置。 +2. 前端从页面上下文获得 `case_id`,再读取训练配置。 3. 前端调用 `POST /api/v1/sessions` 创建 `training_session`,后端初始化 Redis 短期 memory。 4. 前端调用 `POST /api/v1/sessions/{session_id}/chat/stream` 进行流式问诊。 5. 用户申请体格检查或辅助检查,后端从 `case_exam_item` 返回固定结果并写入 `training_order` 和 Redis memory。 @@ -82,11 +79,13 @@ SSE 流式接口返回 `event + data`,不包裹上述 JSON 结构。 ### 3.3 AI 学习助手链路 -1. 前端调用 `POST /api/v1/learning-assistant/chat/stream`。 -2. 后端根据当前用户 `institution_id` 查找知识空间。 -3. 有知识库时:问题 embedding -> Milvus 检索 -> MySQL 读取 chunk 元数据 -> 拼接来源给 LLM。 -4. 无知识库或检索失败时:返回 `retrieval_hit=false`,继续给出通用 LLM 学习回答。 -5. 后端写入 `kb_query_logs`,记录命中、来源和耗时。 +1. 前端进入页面后调用 `POST /api/v1/learning-assistant/sessions` 创建短期会话。 +2. 前端携带 `assistant_session_id` 调用 `POST /api/v1/learning-assistant/sessions/{assistant_session_id}/chat/stream`。 +3. 后端校验该学习助手会话属于当前 Django 用户,并读取最近问答上下文。 +4. 后端根据当前用户 `institution_id` 查找知识空间。 +5. 有知识库时:问题 embedding -> Milvus 检索 -> MySQL 读取 chunk 元数据 -> 拼接来源和会话上下文给 LLM。 +6. 无知识库或检索失败时:返回 `retrieval_hit=false`,继续给出通用 LLM 学习回答。 +7. 后端写入 `kb_query_logs`,记录命中、来源和耗时,同时把本轮问答写回 Redis 短期会话。 ## 4. 重要边界 diff --git a/docs/09_prompt_template_catalog.md b/docs/09_prompt_template_catalog.md index c466aec..417a455 100644 --- a/docs/09_prompt_template_catalog.md +++ b/docs/09_prompt_template_catalog.md @@ -21,20 +21,15 @@ | 提示词模板 | 文件路径 | 使用场景 | 调用模块 | 触发接口 | 输入数据 | 输出格式 | 说明 | |---|---|---|---|---|---|---|---| -| 新手病例提示 | `app/prompts/hint/novice_case_hint.md` | 生成结构化练习提示 | `app/agents/hint_agent.py::HintAgent.generate` | `POST /api/v1/sessions/{session_id}/hints` | 病例标题、主诉、关键症状、关键检查、当前会话 memory、已申请检查、最后一句用户问题 | JSON | 返回 `hints`、`missing_dimensions`、`next_questions`、`recommended_orders`;LLM 失败时使用兜底提示 | -| 新手提示旧模板 | `app/prompts/hint/novice_hint.md` | 早期新手提示模板保留 | 当前主流程不直接调用 | 无主入口 | 无 | 文本 | 保留用于兼容和后续提示词对比,当前推荐使用 `novice_case_hint.md` | +| 练习病例提示 | `app/prompts/hint/practice_case_hint.md` | 生成结构化练习提示 | `app/agents/hint_agent.py::HintAgent.generate` | `POST /api/v1/sessions/{session_id}/hints` | 病例标题、主诉、关键症状、关键检查、当前会话 memory、已申请检查、最后一句用户问题 | JSON | 返回 `hints`、`missing_dimensions`、`next_questions`、`recommended_orders`;LLM 失败时使用兜底提示 | | AI 病人通用模板 | `app/prompts/patient/free_chat.md` | AI 病人通用问诊风格参考 | 当前 `PatientAgent` 主要在代码内拼接系统提示 | `POST /api/v1/sessions/{session_id}/chat/stream` | 病例、隐藏信息、初始化配置、短期 memory、医生问题 | 纯文本 | 文件作为模板资产保留;实际回复规则见 `PatientAgent._build_messages` | -| AI 病人新手模板 | `app/prompts/patient/novice.md` | 新手/练习模式风格参考 | 当前 `PatientAgent` 主要在代码内拼接系统提示 | `POST /api/v1/sessions/{session_id}/chat/stream` | 同上 | 纯文本 | 新手与练习模式已合并为训练模式,提示功能由王主任练习提示承担 | -| AI 病人练习模板 | `app/prompts/patient/practice.md` | 练习模式风格参考 | 当前 `PatientAgent` 主要在代码内拼接系统提示 | `POST /api/v1/sessions/{session_id}/chat/stream` | 同上 | 纯文本 | 练习时只回答被问到的信息,不主动给诊断建议 | -| AI 病人教学模板 | `app/prompts/patient/teaching.md` | 教学风格参考 | 当前 `PatientAgent` 主要在代码内拼接系统提示 | 训练流式问诊相关接口 | 同上 | 纯文本 | 用于后续教学模式对话扩展 | -| 医生问题润色 | `app/prompts/polish/doctor_question_polish.md` | 医学生提问润色能力预留 | 当前主流程不直接调用 | 无主入口 | 医生原始问题、病例场景 | JSON 或文本 | 后续用于前端输入优化时启用 | | 百分制评分 | `app/prompts/scoring/default_percentage.md` | 训练评价百分制评分模板 | 当前 `ScoringAgent` 在代码内拼接评分系统提示,文件作为模板资产 | `POST /api/v1/sessions/{session_id}/evaluation` | 病例、问诊 memory、检查、诊断治疗、评分规则、指南引用 | JSON | 输出 `total_score`、`dimension_scores`、`score_details`、`errors`、`improvement_plan` 等 | | 五分制评分 | `app/prompts/scoring/default_five_point.md` | 训练评价五分制评分模板 | 当前 `ScoringAgent` 支持五分制转换,文件作为模板资产 | `POST /api/v1/sessions/{session_id}/evaluation` | 同百分制 | JSON | `score_type=five_point` 时将百分制结构转换为五分制 | | 儿科肺炎评分 | `app/prompts/scoring/pediatrics_pneumonia.md` | 儿科支气管肺炎病例评分参考 | 当前评分依赖 `scoring_rule` 和病例数据,文件作为病例专科提示资产 | `POST /api/v1/sessions/{session_id}/evaluation` | 儿科病例、检查、诊断、治疗、评分规则 | JSON | 用于病例专科评分要求维护 | | 教学互动评分 | `app/prompts/scoring/teaching_interaction_evaluation.md` | 教学互动答题评价 | `app/agents/scoring_agent.py::ScoringAgent.score_teaching` | `POST /api/v1/teaching/evaluation` | 病例、教学题、标准答案、解析、学生作答、评分规则 | JSON | 评价答题正确率、错误原因、学习建议和教学维度表现 | | 报告整理 | `app/prompts/report/evaluation_report.md` | 评价报告整理模板资产 | `app/agents/report_agent.py::ReportAgent.build_report` 使用代码内校验整理 | 评价生成、PDF 下载 | Scoring Agent 输出 | JSON | Report Agent 不重新评分,只补齐报告字段 | -| 学习助手命中知识库 | `app/prompts/learning_assistant/rag_answer.md` | RAG 命中时的回答模板资产 | 当前 `LearningAssistantAgent._messages` 在代码内拼接同等规则 | `POST /api/v1/learning-assistant/chat/stream` | 用户问题、知识库片段、PDF 名称、页码、chunk_uid | 流式纯文本 | 回答中必须引用来源编号,不得编造 PDF 和页码 | -| 学习助手未命中知识库 | `app/prompts/learning_assistant/no_reference_answer.md` | 未命中知识库时的回答模板资产 | 当前 `LearningAssistantAgent._messages` 在代码内拼接同等规则 | `POST /api/v1/learning-assistant/chat/stream` | 用户问题 | 流式纯文本 | 开头必须说明“未检索到本机构知识库参考” | +| 学习助手命中知识库 | `app/prompts/learning_assistant/rag_answer.md` | RAG 命中时的回答模板资产 | 当前 `LearningAssistantAgent._messages` 在代码内拼接同等规则 | `POST /api/v1/learning-assistant/sessions/{assistant_session_id}/chat/stream` | 用户问题、学习助手短期上下文、知识库片段、PDF 名称、页码、chunk_uid | 流式纯文本 | 回答中必须引用来源编号,不得编造 PDF 和页码 | +| 学习助手未命中知识库 | `app/prompts/learning_assistant/no_reference_answer.md` | 未命中知识库时的回答模板资产 | 当前 `LearningAssistantAgent._messages` 在代码内拼接同等规则 | `POST /api/v1/learning-assistant/sessions/{assistant_session_id}/chat/stream` | 用户问题、学习助手短期上下文 | 流式纯文本 | 开头必须说明“未检索到本机构知识库参考” | ## 3. 训练问诊提示词调用链 @@ -81,7 +76,7 @@ POST /api/v1/sessions/{session_id}/hints -> app/api/sessions.py::generate_hints -> app/services/session_service.py::generate_hints -> app/agents/hint_agent.py::HintAgent.generate - -> app/prompts/hint/novice_case_hint.md + -> app/prompts/hint/practice_case_hint.md ``` 输出字段: @@ -130,15 +125,20 @@ POST /api/v1/teaching/evaluation ## 7. AI 学习助手提示词调用链 ```text -POST /api/v1/learning-assistant/chat/stream - -> app/api/learning_assistant.py::learning_assistant_stream_chat - -> app/services/learning_assistant_service.py::stream_chat +POST /api/v1/learning-assistant/sessions + -> app/api/learning_assistant.py::create_learning_assistant_session + -> app/services/learning_assistant_service.py::create_session + -> app/services/learning_assistant_session_store.py::LearningAssistantSessionStore.create + +POST /api/v1/learning-assistant/sessions/{assistant_session_id}/chat/stream + -> app/api/learning_assistant.py::stream_learning_assistant_session_chat + -> app/services/learning_assistant_service.py::stream_session_chat -> app/agents/learning_assistant_agent.py::LearningAssistantAgent.stream_answer ``` 命中知识库时: -- 输入用户问题和检索到的知识片段。 +- 输入用户问题、学习助手短期上下文和检索到的知识片段。 - 每个来源包含 PDF 名称、页码、chunk_uid、引用文本。 - 回答必须标注来源编号,例如 `【来源1】`。 diff --git a/docs/10_function_workflow.md b/docs/10_function_workflow.md new file mode 100644 index 0000000..67ebdfa --- /dev/null +++ b/docs/10_function_workflow.md @@ -0,0 +1,96 @@ +# 功能工作流说明 + +本文档说明当前保留功能的运行方式,重点描述接口、代码入口、提示词、数据库读写和结果去向。本文档与 `docs/03_api_design.md` 配合使用:API 文档面向前端调用,本文档面向后端维护和功能排查。 + +## 1. 通用链路 + +所有业务接口先执行用户鉴权: + +```text +Authorization: Bearer + -> app/core/user_context.py::get_user_context + -> app/services/external_auth_service.py::ExternalAuthService.get_current_user + -> Django /api/user/users/me/ + -> UserContext(user_id, role, institution_id, department_id) +``` + +FastAPI 使用 Django 返回的 `id` 作为业务 `user_id`。训练会话、评价详情、PDF 下载、学习助手会话都按 `user_id` 做访问隔离。 + +## 2. 功能工作流表 + +| 功能 | 接口 | Router | Service / Agent | 提示词 | 数据库读取 | 数据库写入 | 结果去向 | +|---|---|---|---|---|---|---|---| +| 用户鉴权 | `GET /api/v1/auth/me` | `app/api/auth.py::auth_me` | `ExternalAuthService.get_current_user` | 无 | Django `user`、`institution`、`department` | 无 | 返回前端标准化用户信息 | +| 推荐配置信息 | `GET /api/v1/training-config/recommended` | `training_config.py::get_recommended_training_config` | `TrainingConfigService.get_recommended` | 无 | `case_base`、`traditional_case` | 无 | 返回前端默认病人配置 | +| 训练配置信息 | `GET /api/v1/training-config/options` | `training_config.py::get_training_config_options` | `TrainingConfigService.get_options` | 无 | `case_base` | 无 | 返回前端可选配置 | +| 新建训练会话 | `POST /api/v1/sessions` | `sessions.py::create_session` | `SessionService.create_session` | 无 | `case_base`、`traditional_case` | `training_sessions`;Redis `mem:*` | 返回 `session_id`、开场白;初始化短期 memory | +| 流式会话 | `POST /api/v1/sessions/{session_id}/chat/stream` | `sessions.py::chat_stream` | `SessionService.stream_chat` -> `PatientAgent.stream_reply` | `PatientAgent._build_messages` 代码内提示;`app/prompts/patient/free_chat.md` 为模板资产 | `training_sessions`、`case_base`、Redis memory | Redis memory 追加 doctor / patient 消息 | SSE 返回 `message_delta`、`message_done` | +| 王主任练习提示 | `POST /api/v1/sessions/{session_id}/hints/stream` | `sessions.py::stream_hints` | `SessionService.stream_hints` -> `HintAgent.generate` | `app/prompts/hint/practice_case_hint.md` | `training_sessions`、`case_base`、`session_orders`、Redis memory | 无 | SSE 返回一句话练习提示 | +| 结构化练习提示 | `POST /api/v1/sessions/{session_id}/hints` | `sessions.py::generate_hints` | `SessionService.generate_hints` -> `HintAgent.generate` | `app/prompts/hint/practice_case_hint.md` | `training_sessions`、`case_base`、`session_orders`、Redis memory | 无 | 返回缺失维度、下一步问题、推荐检查 | +| 体格检查列表 | `GET /api/v1/sessions/{session_id}/physical-exams` | `sessions.py::list_physical_exam_items` | `OrderService.list_physical_exam_items` | 无 | `training_sessions`、`case_exam_item` | 无 | 返回当前病例 `item_type=physical` 的检查项 | +| 辅助检查列表 | `GET /api/v1/sessions/{session_id}/auxiliary-exams` | `sessions.py::list_auxiliary_exam_items` | `OrderService.list_auxiliary_exam_items` | 无 | `training_sessions`、`case_exam_item` | 无 | 返回当前病例 `item_type=auxiliary` 的检查项 | +| 体格检查结果 | `POST /api/v1/sessions/{session_id}/physical-exams/{item_code}` | `sessions.py::create_physical_exam_order` | `OrderService.create_physical_exam_order` | 无 | `training_sessions`、`case_exam_item` | `session_orders`;Redis memory 追加 tool 消息 | 返回前端检查结果;结果写入评分依据 | +| 辅助检查结果 | `POST /api/v1/sessions/{session_id}/auxiliary-exams/{item_code}` | `sessions.py::create_auxiliary_exam_order` | `OrderService.create_auxiliary_exam_order` | 无 | `training_sessions`、`case_exam_item` | `session_orders`;Redis memory 追加 tool 消息 | 返回前端检查结果;结果写入评分依据 | +| 完成问诊 | `POST /api/v1/sessions/{session_id}/complete-inquiry` | `sessions.py::complete_inquiry` | `SessionService.complete_inquiry` | 无 | `training_sessions`、Redis memory | `training_sessions.status=diagnosis` | 返回前端新阶段 | +| 提交诊断 | `POST /api/v1/sessions/{session_id}/diagnosis` | `sessions.py::submit_diagnosis` | `SessionService.submit_diagnosis` | 无 | `training_sessions` | `session_submissions`、`training_sessions.status=treatment` | 返回前端新阶段 | +| 提交治疗 | `POST /api/v1/sessions/{session_id}/treatment` | `sessions.py::submit_treatment` | `SessionService.submit_treatment` | 无 | `training_sessions`、`session_submissions` | `session_submissions`、`training_sessions.status=evaluating` | 返回前端新阶段 | +| 训练生成评价 | `POST /api/v1/sessions/{session_id}/evaluation` | `sessions.py::create_evaluation` | `EvaluationService.create_evaluation` -> `ScoringAgent.score` -> `ReportAgent.build_report` | `app/prompts/scoring/default_percentage.md`、`default_five_point.md`、`pediatrics_pneumonia.md` 为模板资产;评分系统提示在 `ScoringAgent` 中拼接 | `training_sessions`、`case_base`、`session_orders`、`session_submissions`、`scoring_rule`、Redis memory | `training_record`、`training_score_detail`;释放 Redis 训练 memory | 返回前端结构化评价;长期保存评价记录 | +| 获取评价详情 | `GET /api/v1/evaluations/{evaluation_id}` | `evaluations.py::get_evaluation_detail` | `EvaluationService.get_evaluation_detail` | 无 | `training_record`、`training_score_detail` | 无 | 返回前端评价详情 | +| 下载 PDF | `GET /api/v1/evaluations/{evaluation_id}/download-pdf` | `evaluations.py::download_pdf` | `PdfExportService.export` | 无 | `training_record`、`training_score_detail` | 更新 `training_record.pdf_file_path` | 浏览器直接下载 PDF 文件流 | +| 教学互动获取教学列表 | `GET /api/v1/teaching/cases/{case_id}/items` | `teaching.py::get_teaching_items` | `TeachingService.get_items` | 无 | `case_base`、`teaching_case` | 无 | 返回题目、选项、答案、解析文本、视频 | +| 教学互动生成评价 | `POST /api/v1/teaching/evaluation` | `teaching.py::create_teaching_evaluation` | `TeachingService.create_evaluation` -> `ScoringAgent.score_teaching` | `app/prompts/scoring/teaching_interaction_evaluation.md` | `case_base`、`teaching_case`、`scoring_rule` | `training_sessions`、`training_record`、`training_score_detail` | 返回前端教学评价;长期保存评价记录 | +| 个人中心训练记录列表 | `GET /api/v1/evaluations?page=1&page_size=10` | `evaluations.py::list_evaluations` | `EvaluationService.list_evaluations` | 无 | `training_record` | 无 | 返回前端分页记录 | +| AI 学习助手新建会话 | `POST /api/v1/learning-assistant/sessions` | `learning_assistant.py::create_learning_assistant_session` | `LearningAssistantService.create_session` -> `LearningAssistantSessionStore.create` | 无 | 用户上下文 | Redis `learning_assistant:session:*` | 返回 `assistant_session_id` | +| AI 学习助手流式会话 | `POST /api/v1/learning-assistant/sessions/{assistant_session_id}/chat/stream` | `learning_assistant.py::stream_learning_assistant_session_chat` | `LearningAssistantService.stream_session_chat` -> `VectorSearchService` -> `LearningAssistantAgent.stream_answer` | `app/prompts/learning_assistant/rag_answer.md`、`no_reference_answer.md` 为模板资产;实际提示在 `LearningAssistantAgent._messages` 中拼接 | `kb_spaces`、`kb_chunks`、Milvus、Redis 学习助手会话 | `kb_query_logs`;Redis 追加学习助手问答 | SSE 返回检索状态、来源和回答;保存查询日志 | +| 内容管理员上传知识库 | `POST /api/v1/knowledge-admin/documents/upload` | `knowledge_admin.py::upload_knowledge_document` | `DocumentIngestionService.upload_and_ingest` | 无 | 用户角色、机构知识库空间 | `kb_documents`、`kb_chunks`、`kb_ingestion_tasks`;Milvus 向量 | 后台接口保留;当前学生端不展示入口 | + +## 3. 检查项处理规则 + +当前体格检查和辅助检查都读取 `case_exam_item`: + +- `item_type=physical`:体格检查。 +- `item_type=auxiliary`:辅助检查。 +- 列表接口只返回项目,不返回结果。 +- 结果接口按 `item_code` 返回固定结果。 +- 同一会话同一 `item_code` 幂等,重复申请不会重复写入 `session_orders` 和 Redis memory。 +- 检查结果只来自数据库,不由 LLM 编造。 + +## 4. 训练评价数据去向 + +训练评价只在用户完成完整流程后写入长期记录: + +```text +新建会话 -> 流式问诊 -> 申请检查 -> 完成问诊 -> 提交诊断 -> 提交治疗 -> 生成评价 + -> training_record + -> training_score_detail + -> PDF 下载时生成 storage/reports 文件 +``` + +中途退出、未生成评价的会话不会写入 `training_record`。问诊内容保存在 Redis 短期 memory 中,生成评价后释放。 + +## 5. AI 学习助手数据去向 + +AI 学习助手不写入训练记录: + +```text +新建学习助手会话 -> Redis 短期会话 +流式提问 -> RAG 检索 -> LLM 流式回答 + -> kb_query_logs 保存查询、命中来源和耗时 + -> Redis 保存最近问答上下文 +``` + +命中知识库时,回答必须带来源。未命中知识库时,回答开头说明未检索到本机构知识库参考,不伪造 PDF、页码或指南名称。 + +## 6. 内容管理员知识库能力 + +内容管理员知识库接口当前保留在后端,用于后续后台页面接入。学生端不展示上传入口。 + +当前已保留: + +- PDF 上传接口。 +- 文档表、分片表、任务表。 +- Embedding 适配器。 +- Milvus 适配器。 +- 同步构建和 Celery 异步任务扩展点。 + +后续生产级扩展需要重点补齐任务队列监控、失败重试、分片质量评估、文件去重和权限后台。 diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index fa987cf..ef8bf4c 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -103,7 +103,6 @@ def run_api_contract_tests() -> None: auth_me_operation = openapi_payload["paths"]["/api/v1/auth/me"]["get"] assert any("HTTPBearer" in item for item in auth_me_operation.get("security", [])) assert "HTTPBearer" in openapi_payload["components"]["securitySchemes"] - assert "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"] @@ -116,16 +115,33 @@ def run_api_contract_tests() -> None: assert "/api/v1/teaching/evaluation" in openapi_payload["paths"] assert "/api/v1/knowledge-admin/documents/upload" in openapi_payload["paths"] assert "/api/v1/learning-assistant/chat" not in openapi_payload["paths"] - assert "/api/v1/learning-assistant/chat/stream" in openapi_payload["paths"] + assert "/api/v1/learning-assistant/sessions" in openapi_payload["paths"] + assert "/api/v1/learning-assistant/sessions/{assistant_session_id}/chat/stream" in openapi_payload["paths"] + assistant_session = client.post("/api/v1/learning-assistant/sessions", headers=headers, json={"title": "医学知识学习"}) + assert assistant_session.status_code == 200 + assistant_session_id = assistant_session.json()["data"]["assistant_session_id"] + assert assistant_session_id.startswith("las_") + assert assistant_session.json()["data"]["user_id"] == "api_user_001" + assert assistant_session.json()["data"]["institution_id"] == 1 + + cross_user_assistant = client.post( + f"/api/v1/learning-assistant/sessions/{assistant_session_id}/chat/stream", + headers={"Authorization": "Bearer api_user_002_token", "X-Entry-Scene": "api_test"}, + json={"question": "这个会话是否属于我?", "top_k": 1}, + ) + assert cross_user_assistant.status_code == 404 + assert cross_user_assistant.json()["code"] == "LEARNING_ASSISTANT_SESSION_NOT_FOUND" with client.stream( "POST", - "/api/v1/learning-assistant/chat/stream", + f"/api/v1/learning-assistant/sessions/{assistant_session_id}/chat/stream", headers=headers, json={"question": "支气管肺炎有哪些常见表现?", "top_k": 1}, ) as no_kb_stream: assert no_kb_stream.status_code == 200 no_kb_stream_text = "".join(no_kb_stream.iter_text()) + assert "event: session_ready" in no_kb_stream_text + assert assistant_session_id in no_kb_stream_text assert "event: retrieval_done" in no_kb_stream_text assert '"retrieval_hit": false' in no_kb_stream_text assert "event: answer_delta" in no_kb_stream_text @@ -162,7 +178,7 @@ def run_api_contract_tests() -> None: with client.stream( "POST", - "/api/v1/learning-assistant/chat/stream", + f"/api/v1/learning-assistant/sessions/{assistant_session_id}/chat/stream", headers=headers, json={"question": "血氧下降说明什么?", "top_k": 1}, ) as rag_stream: @@ -174,9 +190,21 @@ def run_api_contract_tests() -> None: assert "event: answer_delta" in rag_stream_text assert "event: answer_done" in rag_stream_text - cases = client.get("/api/v1/cases", headers=headers) - assert cases.status_code == 200 - case_id = cases.json()["data"]["items"][0]["id"] + with client.stream( + "POST", + f"/api/v1/learning-assistant/sessions/{assistant_session_id}/chat/stream", + headers=headers, + json={"question": "结合刚才的问题再解释一下严重程度判断。", "top_k": 1}, + ) as session_rag_stream: + assert session_rag_stream.status_code == 200 + session_rag_stream_text = "".join(session_rag_stream.iter_text()) + assert "event: session_ready" in session_rag_stream_text + assert "event: retrieval_done" in session_rag_stream_text + assert "event: answer_delta" in session_rag_stream_text + assert "event: answer_done" in session_rag_stream_text + assert assistant_session_id in session_rag_stream_text + + case_id = 1 teaching_items = client.get(f"/api/v1/teaching/cases/{case_id}/items", headers=headers) assert teaching_items.status_code == 200 @@ -284,6 +312,13 @@ def run_api_contract_tests() -> None: ) assert invalid_config.status_code == 422 + invalid_mode = client.post( + "/api/v1/sessions", + headers=headers, + json={"case_id": case_id, "training_type": "diagnosis_treatment", "mode": "invalid", "score_type": "percentage"}, + ) + assert invalid_mode.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"}, @@ -388,23 +423,12 @@ def run_api_contract_tests() -> None: 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"}, @@ -439,20 +463,6 @@ def run_api_contract_tests() -> None: assert "event: hint_delta" in hint_stream_text assert "event: hint_done" in hint_stream_text - teaching = client.post( - "/api/v1/sessions", - headers=headers, - json={"case_id": case_id, "training_type": "diagnosis_treatment", "mode": "teaching", "score_type": "percentage"}, - ) - assert teaching.status_code == 200 - teaching_hint = client.post( - f"/api/v1/sessions/{teaching.json()['data']['session_id']}/hints", - headers=headers, - json={"scope": "current_conversation"}, - ) - assert teaching_hint.status_code == 400 - assert teaching_hint.json()["code"] == "SESSION_STATUS_INVALID" - if __name__ == "__main__": run_api_contract_tests() print("api contract tests passed") diff --git a/tests/test_core_logic.py b/tests/test_core_logic.py index be5957b..05e306d 100644 --- a/tests/test_core_logic.py +++ b/tests/test_core_logic.py @@ -64,7 +64,7 @@ def test_reasoning_effort_disabled_when_thinking_off() -> None: def test_hint_agent_invalid_json_fallback() -> None: - """新手提示:验证模型输出结构不匹配时使用稳定 fallback。""" + """练习提示:验证模型输出结构不匹配时使用稳定 fallback。""" agent = HintAgent() payload = { "case": { diff --git a/tests/test_demo_flow.py b/tests/test_demo_flow.py index aee612a..31cc2bb 100644 --- a/tests/test_demo_flow.py +++ b/tests/test_demo_flow.py @@ -1,4 +1,5 @@ import asyncio +import json import os import sys import tempfile @@ -37,6 +38,21 @@ from app.services.session_service import SessionService from scripts.init_demo_db import init_database +async def collect_stream_reply(session_service: SessionService, ctx: UserContext, session_id: int, message: str) -> str: + """测试辅助:消费流式问诊 SSE,提取 AI 病人增量文本。""" + stream = await session_service.stream_chat(ctx, session_id, message) + reply = "" + done = False + async for event in stream: + if event.startswith("event: message_delta"): + payload_text = event.split("data:", 1)[1].strip() + reply += json.loads(payload_text)["delta"] + if event.startswith("event: message_done"): + done = True + assert done is True + return reply + + async def run_demo_flow() -> None: """完整闭环:验证第一版 Demo 的核心训练链路可跑通。""" init_database() @@ -69,9 +85,9 @@ async def run_demo_flow() -> None: assert created.status == "inquiry" assert created.patient_config["labels"]["visit_environment"] == "门诊" - chat = await session_service.chat(ctx, created.session_id, ChatRequest(message="孩子最高体温多少?").message) + chat_reply = await collect_stream_reply(session_service, ctx, created.session_id, ChatRequest(message="孩子最高体温多少?").message) db.commit() - assert chat.reply + assert chat_reply order = order_service.create_order(created.session_id, ctx.user_id, CreateOrderRequest(item_code="chest_xray").item_code) db.commit() @@ -136,7 +152,7 @@ async def run_demo_flow() -> None: assert treatment.status == "evaluating" try: - await session_service.chat(ctx, created.session_id, "治疗后还能问诊吗?") + await session_service.stream_chat(ctx, created.session_id, "治疗后还能问诊吗?") except AppError as exc: assert exc.code == "SESSION_STATUS_INVALID" else: