From a7733243b26ce0f475abf668e5963d573e8f4cf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E9=87=91=E5=AE=9D?= Date: Mon, 1 Jun 2026 09:25:26 +0800 Subject: [PATCH] chore: initialize medical consultation agent demo --- .env.example | 37 + .gitattributes | 16 + .gitignore | 44 + README.md | 174 ++ backend/README.md | 23 + backend/app/__init__.py | 1 + backend/app/agents/__init__.py | 1 + backend/app/agents/hint_agent.py | 158 ++ backend/app/agents/llm_adapter.py | 280 +++ backend/app/agents/orchestrator.py | 69 + backend/app/agents/patient_agent.py | 76 + backend/app/agents/report_agent.py | 48 + backend/app/agents/scoring_agent.py | 369 ++++ backend/app/api/__init__.py | 1 + backend/app/api/agent.py | 24 + backend/app/api/cases.py | 59 + backend/app/api/evaluations.py | 39 + backend/app/api/imports.py | 26 + backend/app/api/knowledge.py | 24 + backend/app/api/llm_test.py | 108 + backend/app/api/router.py | 12 + backend/app/api/sessions.py | 162 ++ backend/app/core/__init__.py | 1 + backend/app/core/config.py | 108 + backend/app/core/context.py | 15 + backend/app/core/errors.py | 64 + backend/app/core/exceptions.py | 8 + backend/app/core/response.py | 18 + backend/app/core/user_context.py | 29 + backend/app/db/__init__.py | 1 + backend/app/db/base.py | 7 + backend/app/db/session.py | 28 + backend/app/main.py | 38 + backend/app/models/__init__.py | 30 + backend/app/models/audit.py | 25 + backend/app/models/department.py | 22 + backend/app/models/knowledge.py | 56 + backend/app/models/mixins.py | 15 + backend/app/models/prompt.py | 28 + backend/app/models/source_case.py | 228 ++ backend/app/models/training.py | 91 + backend/app/models/training_record.py | 55 + backend/app/models/user.py | 33 + backend/app/prompts/hint/novice_case_hint.md | 71 + backend/app/prompts/hint/novice_hint.md | 36 + .../knowledge/guideline_search_query.md | 37 + backend/app/prompts/patient/free_chat.md | 38 + backend/app/prompts/patient/novice.md | 38 + backend/app/prompts/patient/practice.md | 40 + backend/app/prompts/patient/teaching.md | 39 + .../prompts/polish/doctor_question_polish.md | 36 + .../app/prompts/report/evaluation_report.md | 38 + .../app/prompts/scoring/default_five_point.md | 40 + .../app/prompts/scoring/default_percentage.md | 42 + .../prompts/scoring/pediatrics_pneumonia.md | 40 + backend/app/repositories/__init__.py | 1 + backend/app/repositories/audit_repository.py | 16 + backend/app/repositories/case_repository.py | 155 ++ .../app/repositories/evaluation_repository.py | 46 + .../app/repositories/knowledge_repository.py | 34 + .../app/repositories/profile_repository.py | 25 + .../app/repositories/session_repository.py | 67 + .../repositories/source_case_repository.py | 69 + .../training_record_repository.py | 24 + backend/app/schemas/__init__.py | 1 + backend/app/schemas/agent.py | 16 + backend/app/schemas/case.py | 76 + backend/app/schemas/evaluation.py | 69 + backend/app/schemas/imports.py | 36 + backend/app/schemas/knowledge.py | 9 + backend/app/schemas/llm.py | 20 + backend/app/schemas/session.py | 127 ++ backend/app/services/__init__.py | 1 + backend/app/services/audit_service.py | 38 + backend/app/services/case_service.py | 140 ++ .../app/services/case_sql_import_service.py | 101 + backend/app/services/evaluation_service.py | 256 +++ backend/app/services/knowledge_service.py | 33 + backend/app/services/order_service.py | 86 + backend/app/services/pdf_export_service.py | 466 +++++ backend/app/services/runtime_memory.py | 133 ++ backend/app/services/session_service.py | 278 +++ backend/pyproject.toml | 21 + backend/requirements.txt | 11 + backend/scripts/__init__.py | 1 + backend/scripts/debug_patient_stream.py | 53 + backend/scripts/drop_legacy_tables.py | 43 + backend/scripts/import_source_case_sql.py | 612 ++++++ backend/scripts/init_demo_db.py | 303 +++ backend/scripts/migrate_to_new_schema.py | 45 + backend/tests/test_api_contract.py | 227 ++ backend/tests/test_core_logic.py | 87 + backend/tests/test_demo_flow.py | 148 ++ backend/tests/test_import_source_case_sql.py | 75 + docs/00_development_log.md | 92 + docs/01_functional_scope.md | 71 + docs/02_database_design.md | 87 + docs/02_database_table_dictionary.md | 172 ++ docs/03_api_design.md | 672 ++++++ docs/04_data_collection.md | 97 + docs/05_agent_prompt_design.md | 169 ++ docs/06_demo_testing_guide.md | 210 ++ docs/07_demo_function_traceability.md | 63 + docs/08_pediatric_case_demo_script.md | 119 ++ docs/sql/schema.sql | 516 +++++ docs/sql/seed_pediatric_pneumonia.sql | 197 ++ frontend/.gitignore | 7 + frontend/README.md | 81 + frontend/index.html | 12 + frontend/package-lock.json | 1853 +++++++++++++++++ frontend/package.json | 24 + frontend/src/App.vue | 7 + frontend/src/components/AppShell.vue | 81 + frontend/src/components/CaseCard.vue | 37 + frontend/src/components/ChatBubble.vue | 49 + frontend/src/components/ExamOrderPanel.vue | 75 + frontend/src/components/FlowStepper.vue | 46 + frontend/src/components/ReportPanel.vue | 144 ++ frontend/src/main.ts | 8 + frontend/src/router/index.ts | 28 + frontend/src/services/apiClient.ts | 263 +++ frontend/src/stores/consultationStore.ts | 486 +++++ frontend/src/styles/main.css | 1409 +++++++++++++ frontend/src/types/api.ts | 241 +++ frontend/src/views/CasesView.vue | 217 ++ frontend/src/views/ChatView.vue | 178 ++ frontend/src/views/HistoryView.vue | 53 + frontend/src/views/HomeView.vue | 184 ++ frontend/src/views/ImportCaseView.vue | 192 ++ frontend/src/views/LlmTestView.vue | 71 + frontend/src/views/ReportView.vue | 64 + frontend/src/views/SessionView.vue | 103 + frontend/src/views/SubmitView.vue | 114 + frontend/src/vite-env.d.ts | 1 + frontend/tsconfig.json | 21 + frontend/tsconfig.node.json | 10 + frontend/vite.config.ts | 10 + scripts/check_mysql_demo.ps1 | 40 + scripts/init_mysql_demo.ps1 | 95 + 139 files changed, 15764 insertions(+) create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/README.md create mode 100644 backend/app/__init__.py create mode 100644 backend/app/agents/__init__.py create mode 100644 backend/app/agents/hint_agent.py create mode 100644 backend/app/agents/llm_adapter.py create mode 100644 backend/app/agents/orchestrator.py create mode 100644 backend/app/agents/patient_agent.py create mode 100644 backend/app/agents/report_agent.py create mode 100644 backend/app/agents/scoring_agent.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/agent.py create mode 100644 backend/app/api/cases.py create mode 100644 backend/app/api/evaluations.py create mode 100644 backend/app/api/imports.py create mode 100644 backend/app/api/knowledge.py create mode 100644 backend/app/api/llm_test.py create mode 100644 backend/app/api/router.py create mode 100644 backend/app/api/sessions.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/context.py create mode 100644 backend/app/core/errors.py create mode 100644 backend/app/core/exceptions.py create mode 100644 backend/app/core/response.py create mode 100644 backend/app/core/user_context.py create mode 100644 backend/app/db/__init__.py create mode 100644 backend/app/db/base.py create mode 100644 backend/app/db/session.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/audit.py create mode 100644 backend/app/models/department.py create mode 100644 backend/app/models/knowledge.py create mode 100644 backend/app/models/mixins.py create mode 100644 backend/app/models/prompt.py create mode 100644 backend/app/models/source_case.py create mode 100644 backend/app/models/training.py create mode 100644 backend/app/models/training_record.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/prompts/hint/novice_case_hint.md create mode 100644 backend/app/prompts/hint/novice_hint.md create mode 100644 backend/app/prompts/knowledge/guideline_search_query.md create mode 100644 backend/app/prompts/patient/free_chat.md create mode 100644 backend/app/prompts/patient/novice.md create mode 100644 backend/app/prompts/patient/practice.md create mode 100644 backend/app/prompts/patient/teaching.md create mode 100644 backend/app/prompts/polish/doctor_question_polish.md create mode 100644 backend/app/prompts/report/evaluation_report.md create mode 100644 backend/app/prompts/scoring/default_five_point.md create mode 100644 backend/app/prompts/scoring/default_percentage.md create mode 100644 backend/app/prompts/scoring/pediatrics_pneumonia.md create mode 100644 backend/app/repositories/__init__.py create mode 100644 backend/app/repositories/audit_repository.py create mode 100644 backend/app/repositories/case_repository.py create mode 100644 backend/app/repositories/evaluation_repository.py create mode 100644 backend/app/repositories/knowledge_repository.py create mode 100644 backend/app/repositories/profile_repository.py create mode 100644 backend/app/repositories/session_repository.py create mode 100644 backend/app/repositories/source_case_repository.py create mode 100644 backend/app/repositories/training_record_repository.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/agent.py create mode 100644 backend/app/schemas/case.py create mode 100644 backend/app/schemas/evaluation.py create mode 100644 backend/app/schemas/imports.py create mode 100644 backend/app/schemas/knowledge.py create mode 100644 backend/app/schemas/llm.py create mode 100644 backend/app/schemas/session.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/audit_service.py create mode 100644 backend/app/services/case_service.py create mode 100644 backend/app/services/case_sql_import_service.py create mode 100644 backend/app/services/evaluation_service.py create mode 100644 backend/app/services/knowledge_service.py create mode 100644 backend/app/services/order_service.py create mode 100644 backend/app/services/pdf_export_service.py create mode 100644 backend/app/services/runtime_memory.py create mode 100644 backend/app/services/session_service.py create mode 100644 backend/pyproject.toml create mode 100644 backend/requirements.txt create mode 100644 backend/scripts/__init__.py create mode 100644 backend/scripts/debug_patient_stream.py create mode 100644 backend/scripts/drop_legacy_tables.py create mode 100644 backend/scripts/import_source_case_sql.py create mode 100644 backend/scripts/init_demo_db.py create mode 100644 backend/scripts/migrate_to_new_schema.py create mode 100644 backend/tests/test_api_contract.py create mode 100644 backend/tests/test_core_logic.py create mode 100644 backend/tests/test_demo_flow.py create mode 100644 backend/tests/test_import_source_case_sql.py create mode 100644 docs/00_development_log.md create mode 100644 docs/01_functional_scope.md create mode 100644 docs/02_database_design.md create mode 100644 docs/02_database_table_dictionary.md create mode 100644 docs/03_api_design.md create mode 100644 docs/04_data_collection.md create mode 100644 docs/05_agent_prompt_design.md create mode 100644 docs/06_demo_testing_guide.md create mode 100644 docs/07_demo_function_traceability.md create mode 100644 docs/08_pediatric_case_demo_script.md create mode 100644 docs/sql/schema.sql create mode 100644 docs/sql/seed_pediatric_pneumonia.sql create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/components/AppShell.vue create mode 100644 frontend/src/components/CaseCard.vue create mode 100644 frontend/src/components/ChatBubble.vue create mode 100644 frontend/src/components/ExamOrderPanel.vue create mode 100644 frontend/src/components/FlowStepper.vue create mode 100644 frontend/src/components/ReportPanel.vue create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/router/index.ts create mode 100644 frontend/src/services/apiClient.ts create mode 100644 frontend/src/stores/consultationStore.ts create mode 100644 frontend/src/styles/main.css create mode 100644 frontend/src/types/api.ts create mode 100644 frontend/src/views/CasesView.vue create mode 100644 frontend/src/views/ChatView.vue create mode 100644 frontend/src/views/HistoryView.vue create mode 100644 frontend/src/views/HomeView.vue create mode 100644 frontend/src/views/ImportCaseView.vue create mode 100644 frontend/src/views/LlmTestView.vue create mode 100644 frontend/src/views/ReportView.vue create mode 100644 frontend/src/views/SessionView.vue create mode 100644 frontend/src/views/SubmitView.vue create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 scripts/check_mysql_demo.ps1 create mode 100644 scripts/init_mysql_demo.ps1 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..41d1ccf --- /dev/null +++ b/.env.example @@ -0,0 +1,37 @@ +APP_NAME=Medical Consultation Agent Demo +APP_ENV=local +APP_DEBUG=true +API_V1_PREFIX=/api/v1 + +# MySQL +# MYSQL_URL keeps the original async style URL for future async SQLAlchemy. +# DATABASE_URL is optional; the current sync backend will normalize MYSQL_URL to mysql+pymysql automatically. +MYSQL_URL=mysql+aiomysql://root:password@localhost:3306/medical_consultation_agent?charset=utf8mb4 + +# Redis +RUNTIME_MEMORY_BACKEND=redis +REDIS_URL=redis://localhost:6379/0 +RUNTIME_MEMORY_TTL_SECONDS=7200 + +# OpenAI-compatible LLM +LLM_BASE_URL=https://api.deepseek.com/chat/completions +LLM_API_KEY= +LLM_MODEL=deepseek-v4-pro +LLM_FAST_MODEL=deepseek-v4-pro +LLM_REASON_MODEL=deepseek-v4-pro +LLM_TIMEOUT_SECONDS=45 +LLM_CHAT_TIMEOUT_SECONDS=20 +LLM_STREAM_FIRST_TOKEN_TIMEOUT_SECONDS=15 +LLM_STREAM_TOTAL_TIMEOUT_SECONDS=45 +LLM_STREAM_ENABLED=true +LLM_MOCK_ENABLED=false +LLM_FALLBACK_TO_MOCK=false +LLM_FAST_THINKING_ENABLED=false +LLM_REASON_THINKING_ENABLED=false +LLM_REASONING_EFFORT=low +LLM_FAST_MAX_TOKENS=512 +LLM_HINT_MAX_TOKENS=1200 +LLM_SCORING_JSON_RESPONSE=true +LLM_SCORING_MAX_TOKENS=4096 + +REPORT_STORAGE_DIR=./storage/reports diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b7408c2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,16 @@ +* text=auto + +*.py text eol=lf +*.ts text eol=lf +*.tsx text eol=lf +*.vue text eol=lf +*.css text eol=lf +*.html text eol=lf +*.json text eol=lf +*.md text eol=lf +*.sql text eol=lf +*.toml text eol=lf +*.yml text eol=lf +*.yaml text eol=lf + +*.ps1 text eol=crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..59f240c --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.coverage +htmlcov/ +.venv/ +venv/ +backend/.venv/ + +# Node / frontend +frontend/node_modules/ +frontend/dist/ +frontend/.npm-cache/ +frontend/*.tsbuildinfo + +# Local environment +.env +.env.* +!.env.example + +# Local runtime data and generated artifacts +storage/ +backend/storage/ +*.db +*.sqlite +*.sqlite3 +*.log +*.pdf +reports/ +uploads/ + +# Demo-only or temporary files +demo_frontend/ +backend/test*.sql + +# Editor / OS +.vscode/ +.idea/ +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..b9c8345 --- /dev/null +++ b/README.md @@ -0,0 +1,174 @@ +# 医疗问诊 Agent 第一版 Demo + +这是大系统中的“医疗问诊 Agent”子功能 Demo。系统不做独立注册登录,宿主系统进入时通过请求头传入 `X-User-Id`,后端按该用户隔离会话、检查申请、诊断治疗提交、评价报告和历史记录。 + +## 当前功能 + +```text +病例列表 +-> 病例详情 +-> 创建训练会话 +-> 多轮问诊 Chat / SSE 流式 Chat +-> 提示辅助(练习模式中手动点击) +-> 检查/检验申请 +-> 完成问诊 +-> 提交诊断 +-> 提交治疗方案 +-> 生成 AI 评价报告 +-> 导出 PDF +-> 查询历史记录 +-> LLM Fast/Reason 测试 +``` + +## 技术栈 + +| 层级 | 技术 | +|---|---| +| 后端 | FastAPI、SQLAlchemy 2.x、Pydantic | +| 数据库 | MySQL,库名 `medical_consultation_agent` | +| 短期记忆 | Redis 优先,进程内 memory 兜底 | +| LLM | OpenAI-compatible Chat Completions Adapter | +| 前端 | Vue 3、Vite、TypeScript、Pinia、Vue Router | +| 报告 | 本地 PDF 文件生成 | +| 提示词 | Markdown 模板 + Agent 运行时拼接 | + +## 核心数据表 + +当前功能只依赖新表: + +```text +case_base +traditional_case +teaching_case +scoring_rule +case_exam_item +training_session +training_order +training_submission +training_record +prompt_templates +knowledge_sources +knowledge_documents +knowledge_chunks +user_learning_profiles +audit_logs +``` + +旧表 `cases`、`case_exam_items`、`training_sessions`、`session_orders`、`session_submissions`、`evaluation_records`、`evaluation_report_exports`、`rubric_templates` 已清理。 + +## 启动后端 + +```powershell +cd D:\Code\newfounder\medical-consultation-agent\backend +.\.venv\Scripts\activate +uvicorn app.main:app --reload --host 127.0.0.1 --port 8000 +``` + +接口文档: + +```text +http://127.0.0.1:8000/docs +``` + +## 初始化数据库 + +```powershell +cd D:\Code\newfounder\medical-consultation-agent\backend +.\.venv\Scripts\python.exe scripts\migrate_to_new_schema.py +``` + +清理旧表: + +```powershell +.\.venv\Scripts\python.exe scripts\drop_legacy_tables.py +``` + +## 导入接口解析后的病例 SQL + +接口提供的 SQL dump 不能直接在正式库执行。项目提供安全导入脚本,只解析病例数据并按当前新表字段映射写入,不执行源 SQL 中的 `DROP TABLE`、`CREATE TABLE`、`ALTER TABLE`。 + +先检查不写库: + +```powershell +cd D:\Code\newfounder\medical-consultation-agent\backend +.\.venv\Scripts\python.exe scripts\import_source_case_sql.py "C:\path\to\case.sql" +``` + +确认检查通过后再写入: + +```powershell +.\.venv\Scripts\python.exe scripts\import_source_case_sql.py "C:\path\to\case.sql" --apply +``` + +如果源 SQL 缺少 `case_exam_item`,脚本会根据病例描述生成基础检查项目,保障练习模式可继续申请检查。源 SQL 存在乱码、字段数量不匹配或损坏的 `INSERT` 时,脚本会拒绝导入。 + +前端也提供同一套安全导入能力: + +```text +http://127.0.0.1:5173/#/import +``` + +页面流程为“选择 SQL 文件 -> 解析检查 -> 确认导入 -> 刷新病例库”。后端接口只接受 `.sql` 文件,最大 5MB,只解析 `case_base`、`traditional_case`、`teaching_case`、`scoring_rule` 四类源表数据;`case_exam_item` 仍由后端按病例内容自动补齐。确认导入成功后,病例列表会重新请求后端,新病例可以直接进入练习模式或教学互动模式。 + +## 启动前端 + +```powershell +cd D:\Code\newfounder\medical-consultation-agent\frontend +npm.cmd install +npm.cmd run dev -- --host 127.0.0.1 --port 5173 +``` + +访问: + +```text +http://127.0.0.1:5173 +``` + +## 环境变量 + +真实 `.env` 只保留在本地,不提交 API Key。 + +| 变量 | 说明 | +|---|---| +| `MYSQL_URL` | MySQL 连接串 | +| `REDIS_URL` | Redis 地址 | +| `RUNTIME_MEMORY_BACKEND` | `redis` 或 `memory` | +| `LLM_BASE_URL` | OpenAI-compatible `chat/completions` URL | +| `LLM_API_KEY` | 模型服务 API Key | +| `LLM_MODEL` | 默认模型 | +| `LLM_FAST_MODEL` | 问诊、提示、快速测试模型 | +| `LLM_REASON_MODEL` | Reason 测试模型 | +| `LLM_MOCK_ENABLED` | 是否强制 mock | +| `LLM_FALLBACK_TO_MOCK` | 真实模型失败时是否回退 mock | + +## 验证命令 + +```powershell +cd D:\Code\newfounder\medical-consultation-agent\backend +.\.venv\Scripts\python.exe -m compileall app scripts tests +.\.venv\Scripts\python.exe tests\test_core_logic.py +.\.venv\Scripts\python.exe tests\test_api_contract.py +.\.venv\Scripts\python.exe tests\test_demo_flow.py +``` + +前端: + +```powershell +cd D:\Code\newfounder\medical-consultation-agent\frontend +npm.cmd run build +``` + +## 文档入口 + +| 文档 | 内容 | +|---|---| +| [docs/00_development_log.md](docs/00_development_log.md) | 开发过程和本轮变更记录 | +| [docs/01_functional_scope.md](docs/01_functional_scope.md) | 当前功能范围 | +| [docs/02_database_design.md](docs/02_database_design.md) | 数据库总体设计 | +| [docs/02_database_table_dictionary.md](docs/02_database_table_dictionary.md) | 表字段字典 | +| [docs/03_api_design.md](docs/03_api_design.md) | 前端对接 API 文档 | +| [docs/04_data_collection.md](docs/04_data_collection.md) | 数据采集和存储边界 | +| [docs/05_agent_prompt_design.md](docs/05_agent_prompt_design.md) | Agent 和提示词模板调用说明 | +| [docs/06_demo_testing_guide.md](docs/06_demo_testing_guide.md) | 前端测试指南 | +| [docs/07_demo_function_traceability.md](docs/07_demo_function_traceability.md) | 功能到代码和数据表追踪 | +| [docs/08_pediatric_case_demo_script.md](docs/08_pediatric_case_demo_script.md) | 儿科病例演示脚本 | diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..f378dee --- /dev/null +++ b/backend/README.md @@ -0,0 +1,23 @@ +# Backend + +医疗问诊 Agent 第一版 Demo 后端工程。 + +## 启动流程 + +```bash +cd backend +python -m venv .venv +.venv\Scripts\activate +pip install -r requirements.txt +copy ..\.env.example ..\.env +python -m scripts.init_demo_db +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +## 核心约束 + +- 所有业务接口通过 `X-User-Id` 做用户隔离。 +- 问诊消息进入短期 memory,不作为长期历史保存。 +- 检查检验结果只从数据库读取。 +- 完整训练结束后只保存评价记录、PDF 导出记录、学习档案和审计日志。 +- DeepSeek 调用统一经过 `agents/llm_adapter.py`。 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..2e1b189 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +"""医疗问诊 Agent 后端应用包。""" diff --git a/backend/app/agents/__init__.py b/backend/app/agents/__init__.py new file mode 100644 index 0000000..fce02bd --- /dev/null +++ b/backend/app/agents/__init__.py @@ -0,0 +1 @@ +"""Agent 编排层:Patient、Scoring、Report 和 LLM Adapter。""" diff --git a/backend/app/agents/hint_agent.py b/backend/app/agents/hint_agent.py new file mode 100644 index 0000000..d6082c8 --- /dev/null +++ b/backend/app/agents/hint_agent.py @@ -0,0 +1,158 @@ +import json +from pathlib import Path +from typing import Any + +from app.agents.llm_adapter import DeepSeekClient +from app.core.config import settings +from app.models.source_case import CaseBase +from app.models.training import SessionOrder, TrainingSession + + +class HintAgent: + """新手提示 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" + + async def generate( + self, + session: TrainingSession, + case: CaseBase, + memory_messages: list[dict], + orders: list[SessionOrder], + last_user_message: str | None = None, + ) -> dict: + """LLM 提示:标准化输入病例上下文并要求模型返回固定 JSON 结构。""" + payload = self._build_input(session, case, memory_messages, orders, last_user_message) + messages = [ + {"role": "system", "content": self._load_template()}, + {"role": "user", "content": json.dumps(payload, ensure_ascii=False)}, + ] + try: + response = await self.llm.chat( + messages, + settings.llm_fast_model, + thinking_enabled=settings.llm_fast_thinking_enabled, + response_format={"type": "json_object"}, + max_tokens=settings.llm_hint_max_tokens, + ) + data = json.loads(response.content) + return self._normalize_output(data, payload) + except Exception: + return self._fallback_output(payload) + + def _build_input( + self, + session: TrainingSession, + case: CaseBase, + memory_messages: list[dict], + orders: list[SessionOrder], + last_user_message: str | None, + ) -> dict: + """输入构造:把病例、会话、对话摘要和已申请检查整理为稳定 JSON。""" + return { + "case": { + "case_id": case.id, + "department": case.department.name if getattr(case, "department", None) else "", + "title": case.title, + "chief_complaint": case.chief_complaint, + "key_symptoms": case.key_symptoms or [], + "key_exams": case.key_exams or [], + "key_points": case.key_points or [], + }, + "session": { + "mode": session.mode, + "status": session.status, + }, + "conversation_summary": [ + {"role": item.get("role"), "content": str(item.get("content", ""))[:240]} + for item in memory_messages[-12:] + if item.get("content") + ], + "ordered_results": [ + { + "item_code": order.item_code, + "item_name": order.item_name, + "item_type": order.item_type, + "result_text": order.result_text, + "is_key": order.is_key, + "is_abnormal": order.is_abnormal, + } + for order in orders + ], + "last_user_message": last_user_message or "", + } + + def _load_template(self) -> str: + """提示词读取:加载新手模式病例提示模板。""" + if self.template_path.exists(): + return self.template_path.read_text(encoding="utf-8") + return "你是医疗问诊训练提示 Agent,只输出合法 JSON。" + + def _normalize_output(self, data: Any, payload: dict) -> dict: + """输出校验:确保 LLM 返回结构稳定,不把原始文本透传给前端。""" + if not isinstance(data, dict): + return self._fallback_output(payload) + normalized = { + "hints": self._clean_str_list(data.get("hints"))[:4], + "missing_dimensions": self._clean_str_list(data.get("missing_dimensions"))[:6], + "next_questions": self._clean_str_list(data.get("next_questions"))[:5], + "recommended_orders": self._clean_orders(data.get("recommended_orders"))[:5], + } + if not any(normalized.values()): + return self._fallback_output(payload) + return normalized + + def _fallback_output(self, payload: dict) -> dict: + """提示兜底:LLM 异常或 JSON 不合法时按病例关键点生成稳定提示。""" + case = payload.get("case", {}) + ordered_codes = {item.get("item_code") for item in payload.get("ordered_results", [])} + key_exams = case.get("key_exams") or [] + recommended_orders = [] + if "blood_routine" not in ordered_codes: + recommended_orders.append({"item_code": "blood_routine", "reason": "用于初步判断感染及炎症反应"}) + if "crp" not in ordered_codes: + recommended_orders.append({"item_code": "crp", "reason": "用于辅助判断炎症程度"}) + if "chest_xray" not in ordered_codes: + recommended_orders.append({"item_code": "chest_xray", "reason": "用于判断肺部感染影像学证据"}) + if "spo2" not in ordered_codes: + recommended_orders.append({"item_code": "spo2", "reason": "用于判断氧合和病情严重程度"}) + return { + "hints": [ + f"本病例主诉为{case.get('chief_complaint') or '当前症状'},问诊要围绕起病时间、症状演变和严重程度展开。", + "当前提示来自病例关键症状、关键检查和已完成对话的结构化兜底分析。", + "获得检查结果后,需要把异常结果用于诊断依据和病情严重程度判断。", + ], + "missing_dimensions": ["既往史", "严重程度评估", "人文沟通"], + "next_questions": [ + "孩子最高体温多少?退烧药后能不能降下来?", + "有没有喘息、气促、口唇发绀或呼吸困难?", + "以前有没有喘息、哮喘、湿疹或药物过敏史?", + "精神、食欲、饮水和尿量怎么样?", + "家属现在最担心什么?", + ], + "recommended_orders": recommended_orders or [ + {"item_code": str(item), "reason": "病例关键检查,需要结合结果完善诊断依据"} for item in key_exams[:3] + ], + } + + def _clean_str_list(self, value: Any) -> list[str]: + """字段清洗:把模型返回的数组字段压成字符串列表。""" + if not isinstance(value, list): + return [] + return [str(item).strip() for item in value if str(item).strip()] + + def _clean_orders(self, value: Any) -> list[dict]: + """推荐检查清洗:只保留 item_code 和 reason 两个前端需要的字段。""" + if not isinstance(value, list): + return [] + cleaned = [] + for item in value: + if not isinstance(item, dict): + continue + code = str(item.get("item_code", "")).strip() + reason = str(item.get("reason", "")).strip() + if code: + cleaned.append({"item_code": code, "reason": reason}) + return cleaned diff --git a/backend/app/agents/llm_adapter.py b/backend/app/agents/llm_adapter.py new file mode 100644 index 0000000..a9d9378 --- /dev/null +++ b/backend/app/agents/llm_adapter.py @@ -0,0 +1,280 @@ +import asyncio +import json +import time +from collections.abc import AsyncIterator +from dataclasses import dataclass + +import httpx + +from app.core.config import settings +from app.core.exceptions import AppError + + +@dataclass +class LLMResponse: + """LLM 响应:封装非流式模型输出和耗时指标。""" + + content: str + model: str + latency_ms: int + token_usage: dict | None = None + + +@dataclass +class LLMStreamChunk: + """LLM 流式片段:封装 SSE 增量内容和完成状态。""" + + delta: str + done: bool = False + first_token_ms: int | None = None + total_latency_ms: int | None = None + model: str | None = None + fallback_used: bool = False + + +class OpenAICompatibleLLMClient: + """LLM Adapter:统一封装 OpenAI-compatible 模型的可替换调用。""" + + def __init__(self) -> None: + self.base_url = settings.llm_base_url.rstrip("/") + self.api_key = settings.llm_api_key + self.timeout = settings.llm_timeout_seconds + self.chat_completions_url = self._build_chat_completions_url() + + @property + def is_mock_mode(self) -> bool: + """模型模式:没有 API Key 或开启 mock 时使用本地模拟响应。""" + return settings.llm_mock_enabled or not self.api_key + + async def chat( + self, + messages: list[dict], + model: str, + *, + thinking_enabled: bool | None = None, + reasoning_effort: str | None = None, + response_format: dict | None = None, + max_tokens: int | None = None, + ) -> LLMResponse: + """非流式调用:向 OpenAI-compatible 接口发送 messages 并返回完整文本。""" + start = time.perf_counter() + if self.is_mock_mode: + content = self._mock_response(messages) + return LLMResponse(content=content, model=f"mock-{model}", latency_ms=int((time.perf_counter() - start) * 1000)) + + try: + async with httpx.AsyncClient(timeout=self._http_timeout()) as client: + resp = await client.post( + self.chat_completions_url, + headers={"Authorization": f"Bearer {self.api_key}"}, + json=self._build_payload( + model=model, + messages=messages, + stream=False, + thinking_enabled=thinking_enabled, + reasoning_effort=reasoning_effort, + response_format=response_format, + max_tokens=max_tokens, + ), + ) + resp.raise_for_status() + data = resp.json() + content = (data["choices"][0]["message"].get("content") or "").strip() + if not content: + raise KeyError("empty llm content") + return LLMResponse( + content=content, + model=model, + latency_ms=int((time.perf_counter() - start) * 1000), + token_usage=data.get("usage"), + ) + except (httpx.TimeoutException, httpx.HTTPError, KeyError, IndexError, json.JSONDecodeError) as exc: + if settings.llm_fallback_to_mock: + content = self._mock_response(messages) + return LLMResponse( + content=content, + model=f"mock-fallback-{model}", + latency_ms=int((time.perf_counter() - start) * 1000), + token_usage={"fallback_reason": exc.__class__.__name__}, + ) + raise AppError("LLM_CALL_FAILED", "llm service call failed", 502) from exc + + async def stream_chat( + self, + messages: list[dict], + model: str, + *, + thinking_enabled: bool | None = None, + reasoning_effort: str | None = None, + max_tokens: int | None = None, + ) -> AsyncIterator[LLMStreamChunk]: + """流式调用:以统一 chunk 结构输出 OpenAI-compatible SSE 增量。""" + start = time.perf_counter() + first_token_ms: int | None = None + if self.is_mock_mode: + async for chunk in self._mock_stream(messages, model, start, model_label=f"mock-{model}"): + yield chunk + return + + try: + async with httpx.AsyncClient(timeout=self._http_timeout()) as client: + async with client.stream( + "POST", + self.chat_completions_url, + headers={"Authorization": f"Bearer {self.api_key}"}, + json=self._build_payload( + model=model, + messages=messages, + stream=True, + thinking_enabled=thinking_enabled, + reasoning_effort=reasoning_effort, + max_tokens=max_tokens, + ), + ) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if not line.startswith("data:"): + continue + payload = line.removeprefix("data:").strip() + if payload == "[DONE]": + break + data = json.loads(payload) + delta_obj = data["choices"][0].get("delta", {}) + content_delta = delta_obj.get("content") or "" + reasoning_delta = delta_obj.get("reasoning_content") or "" + if (content_delta or reasoning_delta) and first_token_ms is None: + first_token_ms = int((time.perf_counter() - start) * 1000) + if content_delta: + yield LLMStreamChunk(delta=content_delta, first_token_ms=first_token_ms) + except (httpx.TimeoutException, httpx.HTTPError, KeyError, IndexError, json.JSONDecodeError) as exc: + if settings.llm_fallback_to_mock: + async for chunk in self._mock_stream( + messages, + model, + start, + model_label=f"mock-fallback-{model}", + fallback_used=True, + ): + yield chunk + return + raise AppError("LLM_STREAM_FAILED", "llm stream call failed", 502) from exc + + yield LLMStreamChunk( + delta="", + done=True, + first_token_ms=first_token_ms, + total_latency_ms=int((time.perf_counter() - start) * 1000), + model=model, + ) + + async def _mock_stream( + self, + messages: list[dict], + model: str, + start: float, + model_label: str, + fallback_used: bool = False, + ) -> AsyncIterator[LLMStreamChunk]: + """Mock 流式输出:在模型不可用时保持 Demo 流程可验证。""" + first_token_ms: int | None = None + content = self._mock_response(messages) + for piece in self._split_mock_content(content): + await asyncio.sleep(0.02) + if first_token_ms is None: + first_token_ms = int((time.perf_counter() - start) * 1000) + yield LLMStreamChunk(delta=piece, first_token_ms=first_token_ms) + yield LLMStreamChunk( + delta="", + done=True, + first_token_ms=first_token_ms, + total_latency_ms=int((time.perf_counter() - start) * 1000), + model=model_label, + fallback_used=fallback_used, + ) + + def _mock_response(self, messages: list[dict]) -> str: + """Mock 输出:在没有 DeepSeek Key 时保证 Demo 闭环可运行。""" + latest = next((m.get("content", "") for m in reversed(messages) if m.get("role") == "user"), "") + prompt_head = " ".join(m.get("content", "").lower() for m in messages[:2]) + if "score_type" in prompt_head and "dimension_scores" in prompt_head: + return json.dumps( + { + "score_type": "percentage", + "total_score": 82, + "dimension_scores": [ + {"dimension": "信息获取", "score": 20, "max_score": 25, "comment": "覆盖了发热、咳嗽和喘息,儿科特异性病史仍需加强。"}, + {"dimension": "分析推理", "score": 21, "max_score": 25, "comment": "能够识别肺炎方向,鉴别诊断完整性中等。"}, + {"dimension": "处置决策", "score": 17, "max_score": 20, "comment": "治疗原则基本合理,风险预案需要更具体。"}, + {"dimension": "沟通人文", "score": 12, "max_score": 15, "comment": "有告知意识,家属安抚和健康教育可更系统。"}, + {"dimension": "临床整合", "score": 12, "max_score": 15, "comment": "诊疗流程完整,时间分配和整体组织较清晰。"}, + ], + "errors": [{"title": "儿科特异性病史不足", "description": "疫苗接种、过敏史、既往喘息史追问不足。"}], + "improvement_plan": ["补充儿科问诊框架:出生史、接种史、过敏史、既往喘息史。"], + "evidence_summary": ["用户完成了核心症状追问、检查申请、诊断和治疗提交。"], + "guideline_refs": [], + "overall_comment": "本次训练完成主要诊疗流程,诊断方向正确,治疗方案具备基本可执行性。", + }, + ensure_ascii=False, + ) + if "体温" in latest or "发热" in latest: + return "最高烧到39度多,已经反复四天了,退烧后会好一点,但很快又起来。" + if "喘" in latest or "呼吸" in latest: + return "昨天开始喘得明显,活动后更明显,晚上咳嗽也更重。" + if "精神" in latest or "吃" in latest: + return "精神比平时差一些,吃饭少了,但还能喝水,小便比平时略少。" + if "既往" in latest or "过敏" in latest: + return "以前没有明确哮喘诊断,也没有药物过敏史,小时候感冒时偶尔会咳得久。" + return "家长:孩子主要是发热、咳嗽,昨天开始喘,您可以继续问我具体情况。" + + def _split_mock_content(self, content: str) -> list[str]: + """Mock 分片:把本地模拟文本拆成流式输出片段。""" + return [content[i : i + 8] for i in range(0, len(content), 8)] + + def _build_chat_completions_url(self) -> str: + """接口地址:兼容 base_url 和完整 chat/completions URL 两种写法。""" + if self.base_url.endswith("/chat/completions"): + return self.base_url + return f"{self.base_url}/chat/completions" + + def _http_timeout(self) -> httpx.Timeout: + """超时策略:限制连接、写入和读取等待,避免前端长时间卡在生成中。""" + return httpx.Timeout( + timeout=self.timeout, + connect=min(8, self.timeout), + read=self.timeout, + write=min(15, self.timeout), + pool=min(8, self.timeout), + ) + + def _build_payload( + self, + *, + model: str, + messages: list[dict], + stream: bool, + thinking_enabled: bool | None = None, + reasoning_effort: str | None = None, + response_format: dict | None = None, + max_tokens: int | None = None, + ) -> dict: + """请求构造:兼容 DeepSeek V4 thinking、reasoning_effort 和 JSON 输出。""" + payload: dict = {"model": model, "messages": messages, "stream": stream} + supports_reasoning_options = self._supports_reasoning_options(model) + if thinking_enabled is not None and supports_reasoning_options: + payload["thinking"] = {"type": "enabled" if thinking_enabled else "disabled"} + if reasoning_effort and supports_reasoning_options and thinking_enabled is not False: + payload["reasoning_effort"] = reasoning_effort + if response_format: + payload["response_format"] = response_format + if max_tokens: + payload["max_tokens"] = max_tokens + return payload + + def _supports_reasoning_options(self, model: str) -> bool: + """厂商兼容:只向 DeepSeek 发送 thinking/reasoning_effort 等专有参数。""" + base = self.base_url.lower() + model_name = model.lower() + return "deepseek" in base or model_name.startswith("deepseek") + + +DeepSeekClient = OpenAICompatibleLLMClient diff --git a/backend/app/agents/orchestrator.py b/backend/app/agents/orchestrator.py new file mode 100644 index 0000000..9918ca3 --- /dev/null +++ b/backend/app/agents/orchestrator.py @@ -0,0 +1,69 @@ +from collections.abc import AsyncIterator + +from app.agents.llm_adapter import LLMResponse, LLMStreamChunk +from app.agents.hint_agent import HintAgent +from app.agents.patient_agent import PatientAgent +from app.agents.report_agent import ReportAgent +from app.agents.scoring_agent import ScoringAgent +from app.models.source_case import CaseBase +from app.models.training import SessionOrder, SessionSubmission, TrainingSession + + +class MedicalConsultationOrchestrator: + """主编排器:统一调度 Patient、Scoring、Report 等子 Agent。""" + + def __init__(self) -> None: + self.patient_agent = PatientAgent() + self.hint_agent = HintAgent() + 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) + + async def patient_stream_reply( + self, + session: TrainingSession, + case: CaseBase, + memory_messages: list[dict], + message: str, + ) -> AsyncIterator[LLMStreamChunk]: + """流式问诊编排:调用 Patient Agent 并返回流式片段。""" + async for chunk in self.patient_agent.stream_reply(case, memory_messages, message, session.mode): + yield chunk + + async def evaluate( + self, + session: TrainingSession, + case: CaseBase, + memory_messages: list[dict], + orders: list[SessionOrder], + submission: SessionSubmission, + rubric: object | None, + guideline_refs: list[dict], + scoring_rules: list | None = None, + ) -> dict: + """评价编排:调用 Scoring Agent 后交给 Report Agent 整理报告。""" + scoring_result = await self.scoring_agent.score( + session=session, + case=case, + memory_messages=memory_messages, + orders=orders, + submission=submission, + rubric=rubric, + guideline_refs=guideline_refs, + scoring_rules=scoring_rules or [], + ) + return self.report_agent.build_report(scoring_result) + + async def generate_hints( + self, + session: TrainingSession, + case: CaseBase, + memory_messages: list[dict], + orders: list[SessionOrder], + last_user_message: str | None = None, + ) -> dict: + """新手提示编排:基于当前会话上下文生成轻量训练提醒。""" + return await self.hint_agent.generate(session, case, memory_messages, orders, last_user_message) diff --git a/backend/app/agents/patient_agent.py b/backend/app/agents/patient_agent.py new file mode 100644 index 0000000..7f970de --- /dev/null +++ b/backend/app/agents/patient_agent.py @@ -0,0 +1,76 @@ +from collections.abc import AsyncIterator + +from app.agents.llm_adapter import DeepSeekClient, LLMResponse, LLMStreamChunk +from app.core.config import settings +from app.models.source_case import CaseBase + + +class PatientAgent: + """AI 病人:根据病例资料、隐藏信息和短期 memory 回复医生问诊。""" + + def __init__(self, llm: DeepSeekClient | None = None) -> None: + self.llm = llm or DeepSeekClient() + + async def reply(self, case: CaseBase, memory_messages: list[dict], user_message: str, mode: str) -> LLMResponse: + """问诊回复:拼接病例上下文、短期记忆和用户输入后调用 Patient Agent。""" + messages = self._build_messages(case, memory_messages, user_message, mode) + 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, + memory_messages: list[dict], + user_message: str, + mode: str, + ) -> AsyncIterator[LLMStreamChunk]: + """流式问诊:以 SSE 方式返回 AI 病人增量回复。""" + messages = self._build_messages(case, memory_messages, user_message, mode) + async for chunk in self.llm.stream_chat( + messages, + settings.llm_fast_model, + thinking_enabled=settings.llm_fast_thinking_enabled, + max_tokens=settings.llm_fast_max_tokens, + ): + yield chunk + + def _build_messages(self, case: CaseBase, memory_messages: list[dict], user_message: str, mode: str) -> list[dict]: + """提示词拼接:构造 AI 病人的系统提示词和对话历史。""" + profile = case.ai_patient_profile or {} + hidden_info = case.hidden_patient_info or {} + mode_rule = { + "novice": "新手模式:回答清楚,必要时可提示医生继续追问症状、既往史或检查。", + "practice": "练习模式:只回答被问到的信息,不主动给诊断建议。", + "teaching": "教学模式:保持患者身份,允许在回答后补充简短学习提示。", + }.get(mode, "只回答被问到的信息。") + system = f""" +你是一名标准化 AI 病人或患儿家属,只能基于病例资料回答。 +病例主诉:{case.chief_complaint} +患者人设:{profile} +隐藏信息:{hidden_info} +回答规则: +1. 不主动透露未被问到的隐藏信息。 +2. 不替医生做诊断,不提供治疗方案。 +3. 不编造病例外检查检验结果。 +4. 每次回答控制在1到3句话,使用患儿家属口吻,不输出分析过程。 +5. 只输出给医生看的家属回答纯文本,不输出 JSON、Markdown、标题、解释或思考过程。 +6. 如果医生一次问多个问题,按问题顺序简短回答,不扩展病例外信息。 +7. {mode_rule} +""" + messages = [{"role": "system", "content": system.strip()}] + messages.extend(self._to_llm_history(memory_messages[-12:])) + messages.append({"role": "user", "content": user_message}) + return messages + + def _to_llm_history(self, memory_messages: list[dict]) -> list[dict]: + """历史转换:把业务角色 doctor/patient 转换为 LLM role。""" + role_map = {"doctor": "user", "patient": "assistant", "system": "system", "tool": "assistant"} + return [ + {"role": role_map.get(item.get("role"), "user"), "content": item.get("content", "")} + for item in memory_messages + if item.get("content") + ] diff --git a/backend/app/agents/report_agent.py b/backend/app/agents/report_agent.py new file mode 100644 index 0000000..55f542c --- /dev/null +++ b/backend/app/agents/report_agent.py @@ -0,0 +1,48 @@ +class ReportAgent: + """报告 Agent:整理评分结果为接口和 PDF 可复用的报告结构。""" + + def build_report(self, scoring_result: dict) -> dict: + """报告整理:校验评分结果字段并补齐展示默认值,不重新评分。""" + dimension_scores = self._normalize_dimension_scores(scoring_result.get("dimension_scores", [])) + total_score = self._safe_float(scoring_result.get("total_score"), 0) + return { + "score_type": scoring_result.get("score_type", "percentage"), + "total_score": total_score, + "dimension_scores": dimension_scores, + "errors": self._ensure_list(scoring_result.get("errors")), + "improvement_plan": self._ensure_list(scoring_result.get("improvement_plan")), + "evidence_summary": self._ensure_list(scoring_result.get("evidence_summary")), + "guideline_refs": self._ensure_list(scoring_result.get("guideline_refs")), + "overall_comment": scoring_result.get("overall_comment", ""), + "_llm_model": scoring_result.get("_llm_model"), + "_latency_metrics": scoring_result.get("_latency_metrics", {}), + } + + def _normalize_dimension_scores(self, raw_scores: object) -> list[dict]: + """维度校验:把模型输出归一为前端和数据库可保存的评分列表。""" + if not isinstance(raw_scores, list): + return [] + normalized: list[dict] = [] + for item in raw_scores: + if not isinstance(item, dict): + continue + normalized.append( + { + "dimension": str(item.get("dimension", "未命名维度")), + "score": self._safe_float(item.get("score"), 0), + "max_score": self._safe_float(item.get("max_score"), 0), + "comment": str(item.get("comment", "")), + } + ) + return normalized + + def _ensure_list(self, value: object) -> list: + """列表校验:保证报告中的数组字段稳定返回 list。""" + return value if isinstance(value, list) else [] + + def _safe_float(self, value: object, default: float) -> float: + """数值校验:把模型输出中的分数安全转换为 float。""" + try: + return float(value) + except (TypeError, ValueError): + return default diff --git a/backend/app/agents/scoring_agent.py b/backend/app/agents/scoring_agent.py new file mode 100644 index 0000000..f20ea0c --- /dev/null +++ b/backend/app/agents/scoring_agent.py @@ -0,0 +1,369 @@ +import json +import logging +import re +import time +from typing import Any + +from app.agents.llm_adapter import DeepSeekClient +from app.core.config import settings +from app.core.exceptions import AppError +from app.models.source_case import CaseBase +from app.models.training import SessionOrder, SessionSubmission, TrainingSession + +logger = logging.getLogger(__name__) + + +class ScoringAgent: + """评分 Agent:结合病例、问诊过程、检查结果、提交内容和 scoring_rule 生成结构化评价。""" + + def __init__(self, llm: DeepSeekClient | None = None) -> None: + self.llm = llm or DeepSeekClient() + + async def score( + self, + session: TrainingSession, + case: CaseBase, + memory_messages: list[dict], + orders: list[SessionOrder], + submission: SessionSubmission, + rubric: object | None, + guideline_refs: list[dict], + scoring_rules: list | None = None, + ) -> dict: + """评价生成:优先调用快速模型输出 JSON,失败时返回稳定兜底评分。""" + start = time.perf_counter() + messages = self._build_messages(session, case, memory_messages, orders, submission, guideline_refs, scoring_rules or []) + try: + response = await self.llm.chat( + messages, + settings.llm_fast_model, + thinking_enabled=settings.llm_fast_thinking_enabled, + reasoning_effort=None, + response_format={"type": "json_object"} if settings.llm_scoring_json_response else None, + max_tokens=min(settings.llm_scoring_max_tokens, 1800), + ) + data = json.loads(response.content) + data = self._normalize_score_payload(data, session.score_type, guideline_refs) + data["_llm_model"] = response.model + data["_latency_metrics"] = {"scoring_latency_ms": response.latency_ms, "fallback_used": False} + return data + except (AppError, json.JSONDecodeError, KeyError, TypeError, ValueError) as exc: + logger.warning("scoring_agent.fallback session_id=%s error=%s", session.id, exc.__class__.__name__) + data = self._fallback_score(session.score_type, guideline_refs) + data["_llm_model"] = f"local-fallback-{settings.llm_fast_model}" + data["_latency_metrics"] = { + "scoring_latency_ms": int((time.perf_counter() - start) * 1000), + "fallback_used": True, + "fallback_reason": exc.__class__.__name__, + } + return data + + def _build_messages( + self, + session: TrainingSession, + case: CaseBase, + memory_messages: list[dict], + orders: list[SessionOrder], + submission: SessionSubmission, + guideline_refs: list[dict], + scoring_rules: list, + ) -> list[dict]: + """评分提示词:只传评价必需字段,避免旧表冗余字段和长病历全文拖慢生成。""" + transcript_summary = [ + {"role": item.get("role"), "content": self._truncate(item.get("content", ""), 220)} + for item in memory_messages[-14:] + if item.get("content") + ] + ordered_payload = [ + { + "item_code": order.item_code, + "item_name": order.item_name, + "result_text": self._truncate(order.result_text, 180), + "is_key": order.is_key, + "is_abnormal": order.is_abnormal, + } + for order in orders + ] + payload = { + "score_type": session.score_type, + "case": { + "title": case.title, + "chief_complaint": case.chief_complaint, + "standard_diagnosis": case.diagnosis_primary, + "diagnosis_basis": self._truncate(case.diagnosis_basis, 260), + "key_symptoms": case.key_symptoms or [], + "key_exams": case.key_exams or [], + "key_points": case.key_points or [], + }, + "conversation": transcript_summary, + "orders": ordered_payload, + "submission": { + "primary_diagnosis": submission.primary_diagnosis, + "differential_diagnoses": submission.differential_diagnoses or [], + "diagnosis_basis": self._truncate(submission.diagnosis_basis, 320), + "treatment_principle": self._truncate(submission.treatment_principle, 220), + "treatment_measures": self._truncate(submission.treatment_measures, 320), + "risk_plan": self._truncate(submission.risk_plan, 220), + "communication": self._truncate(submission.communication, 220), + "follow_up": self._truncate(submission.follow_up, 180), + }, + "scoring_rules": self._compact_scoring_rules(scoring_rules), + "guidelines": self._compact_guidelines(guideline_refs), + } + system = ( + "你是医学教学问诊评分专家,只输出合法 JSON。" + "请结合病例、问诊过程、检查申请、诊断和治疗提交进行教学评价。" + "输出字段固定为 score_type,total_score,dimension_scores,errors,improvement_plan," + "evidence_summary,guideline_refs,overall_comment。" + "dimension_scores 为 5-6 项,每项包含 dimension,score,max_score,comment,evidence,deductions,improvement。" + "evidence、deductions、improvement_plan、evidence_summary 必须是数组,每个元素一句话。" + "errors 每项包含 title,description,severity,related_dimension。" + "评价必须具体指出用户问了什么、申请了什么检查、诊断治疗哪里充分或不足。" + "报告仅用于教学训练,不替代真实临床诊疗。" + ) + return [{"role": "system", "content": system}, {"role": "user", "content": json.dumps(payload, ensure_ascii=False)}] + + def _compact_scoring_rules(self, scoring_rules: list) -> list[dict]: + """源库评分规则压缩:把 scoring_rule 转为评分 Agent 可直接使用的细则。""" + compact = [] + for item in scoring_rules[:12]: + compact.append( + { + "dimension": getattr(item, "dimension", ""), + "competency_dimension": getattr(item, "competency_dimension", ""), + "score_weight": float(getattr(item, "score_weight", 0) or 0), + "ai_auto_score": bool(getattr(item, "ai_auto_score", True)), + "osce_dimension": bool(getattr(item, "osce_dimension", False)), + "scoring_standard": self._truncate(getattr(item, "scoring_standard", ""), 240), + "rubric_json": getattr(item, "rubric_json", {}) or {}, + } + ) + return compact + + def _compact_guidelines(self, guideline_refs: list[dict]) -> list[dict]: + """参考指南压缩:保留少量命中来源,供模型引用。""" + compact = [] + for item in (guideline_refs or [])[:3]: + if isinstance(item, dict): + compact.append( + { + "title": item.get("title") or item.get("source"), + "content": self._truncate(item.get("content") or item.get("chunk"), 180), + } + ) + return compact + + def _normalize_score_payload(self, data: dict, score_type: str, guideline_refs: list[dict]) -> dict: + """评分结构校验:补齐缺失字段,并拆分模型返回的长编号列表。""" + if not isinstance(data, dict): + data = self._fallback_score(score_type, guideline_refs) + + data.setdefault("score_type", "percentage") + data.setdefault("total_score", 0) + data.setdefault("dimension_scores", []) + data.setdefault("errors", []) + data.setdefault("improvement_plan", []) + data.setdefault("evidence_summary", []) + data.setdefault("guideline_refs", guideline_refs) + data.setdefault("overall_comment", "") + + normalized_dimensions = [] + for item in data.get("dimension_scores") or []: + if not isinstance(item, dict): + continue + normalized_dimensions.append( + { + "dimension": str(item.get("dimension") or "未命名维度"), + "score": float(item.get("score") or 0), + "max_score": float(item.get("max_score") or (100 if data.get("score_type") == "percentage" else 5)), + "comment": self._truncate(item.get("comment") or "", 180), + "evidence": self._ensure_list(item.get("evidence")), + "deductions": self._ensure_list(item.get("deductions")), + "improvement": self._truncate(item.get("improvement") or "", 180), + } + ) + data["dimension_scores"] = normalized_dimensions or self._fallback_score("percentage", guideline_refs)["dimension_scores"] + data["errors"] = self._normalize_errors(data.get("errors")) + data["improvement_plan"] = self._ensure_list(data.get("improvement_plan")) + data["evidence_summary"] = self._ensure_list(data.get("evidence_summary")) + data["guideline_refs"] = self._normalize_guideline_refs(data.get("guideline_refs"), guideline_refs) + data["overall_comment"] = self._truncate(data.get("overall_comment") or "", 260) + + if score_type == "five_point" and data.get("score_type") != "five_point": + return self._convert_to_five_point(data) + if score_type == "percentage" and data.get("score_type") != "percentage": + data["score_type"] = "percentage" + return data + + def _normalize_errors(self, errors: object) -> list[dict]: + """错误项归一化:转为报告可渲染的扣分项。""" + normalized = [] + for index, item in enumerate(self._ensure_list(errors), start=1): + if isinstance(item, dict): + normalized.append( + { + "title": self._truncate(item.get("title") or f"问题 {index}", 60), + "description": self._truncate(item.get("description") or item.get("comment") or "", 180), + "severity": item.get("severity") or "medium", + "related_dimension": item.get("related_dimension") or item.get("dimension") or "综合表现", + } + ) + else: + normalized.append( + { + "title": f"问题 {index}", + "description": self._truncate(str(item), 180), + "severity": "medium", + "related_dimension": "综合表现", + } + ) + return normalized[:6] + + def _normalize_guideline_refs(self, value: object, fallback_refs: list[dict]) -> list[dict]: + """指南引用归一化:保证字符串、字典或空值都能转成字典数组。""" + raw_items = value if isinstance(value, list) else ([value] if value else fallback_refs) + normalized = [] + for index, item in enumerate(raw_items or [], start=1): + if isinstance(item, dict): + normalized.append( + { + "title": self._truncate(item.get("title") or item.get("source") or f"参考依据 {index}", 80), + "content": self._truncate( + item.get("content") or item.get("text") or item.get("chunk") or item.get("summary") or "", + 180, + ), + "source": self._truncate(item.get("source") or item.get("source_type") or "knowledge_base", 80), + } + ) + elif item: + normalized.append({"title": f"参考依据 {index}", "content": self._truncate(str(item), 180), "source": "llm_output"}) + return normalized[:6] + + def _ensure_list(self, value: object) -> list: + """列表规整:拆分模型常见的 1/2/3 编号长文本。""" + if value is None: + return [] + raw_items = value if isinstance(value, list) else [value] + items: list = [] + for raw in raw_items: + if raw is None: + continue + if isinstance(raw, dict): + items.append(raw) + continue + text = str(raw).strip() + if not text: + continue + parts = re.split(r"(?:^|\s)(?:\d+[\.\、)]|[;;]\d+[;;])\s*", text) + parts = [part.strip(";;、\n\t ") for part in parts if part and part.strip(";;、\n\t ")] + items.extend(parts if len(parts) > 1 else [text]) + return [self._truncate(item, 180) if not isinstance(item, dict) else item for item in items[:8]] + + def _fallback_score(self, score_type: str, guideline_refs: list[dict]) -> dict: + """评分兜底:LLM 失败时生成稳定结构化评分,保证报告流程可展示。""" + data = { + "score_type": "percentage", + "total_score": 80, + "dimension_scores": [ + { + "dimension": "信息获取", + "score": 20, + "max_score": 25, + "comment": "完成主要症状追问,但儿科专科病史仍需补充。", + "evidence": ["围绕发热、咳嗽、喘息等核心症状展开问诊。"], + "deductions": ["既往喘息史、过敏史、疫苗接种史、家属照护能力等信息不够完整。"], + "improvement": "下一次按主诉、现病史、既往史、个人史、家族史和家属顾虑逐项补全。", + }, + { + "dimension": "分析推理", + "score": 16, + "max_score": 20, + "comment": "诊断方向基本正确,但严重程度分层需要更清晰。", + "evidence": ["主要诊断指向支气管肺炎。"], + "deductions": ["鉴别诊断和严重程度判断未充分引用血氧、胸片和炎症指标。"], + "improvement": "把症状、体征、影像、血氧和炎症指标逐条对应到诊断依据和病情分层。", + }, + { + "dimension": "检查利用", + "score": 12, + "max_score": 15, + "comment": "关键检查申请较完整,但检查结果解释仍可细化。", + "evidence": ["胸片、血氧或炎症指标可支持肺炎诊断和严重程度判断。"], + "deductions": ["对 SpO2、胸片异常和炎症指标的临床意义解释不够具体。"], + "improvement": "在诊断依据中写明每项异常检查如何支持诊断、鉴别诊断和治疗决策。", + }, + { + "dimension": "处置决策", + "score": 16, + "max_score": 20, + "comment": "治疗原则基本完整,仍需补充监测和升级处理条件。", + "evidence": ["治疗原则覆盖抗感染、止咳平喘、改善氧合和观察病情。"], + "deductions": ["药物选择、门诊/住院判断、风险预案和随访节点不够具体。"], + "improvement": "补充治疗适应证、监测指标、病情加重预案和复诊/住院指征。", + }, + { + "dimension": "沟通人文", + "score": 8, + "max_score": 10, + "comment": "有基本沟通意识,但家属顾虑回应不够结构化。", + "evidence": ["治疗方案中包含向家属说明病情和复诊指征。"], + "deductions": ["知情同意、家属担心、用药注意事项和家庭护理教育仍需补充。"], + "improvement": "增加对家属顾虑的回应、危险信号说明和家庭护理指导。", + }, + { + "dimension": "临床整合", + "score": 8, + "max_score": 10, + "comment": "完成训练闭环,证据衔接仍需强化。", + "evidence": ["完成问诊、检查、诊断、治疗和评价流程。"], + "deductions": ["各阶段证据之间的逻辑衔接和 SOAP 化表达不够清晰。"], + "improvement": "用 SOAP 结构归纳病情,把证据、判断和计划串联起来。", + }, + ], + "errors": [ + { + "title": "信息采集不够系统", + "description": "儿科特异性病史追问不足,影响对喘息诱因、感染风险和肺炎严重程度的判断。", + "severity": "medium", + "related_dimension": "信息获取", + } + ], + "improvement_plan": [ + "补充疫苗接种史、过敏史、既往喘息史、近期接触史和家庭照护能力评估。", + "诊断依据中逐条引用发热、咳嗽、喘息、肺部体征、胸片、血氧和炎症指标。", + "治疗方案中补充监测指标、病情加重预案、复诊/住院指征和家属健康教育。", + "沟通环节增加家属担心回应、知情说明和家庭护理要点。", + ], + "evidence_summary": [ + "问诊:已完成核心症状追问,但儿科特异性病史仍需补全。", + "检查:需重点评价是否申请并利用胸片、血氧和炎症指标。", + "诊断:主要诊断方向基本正确,鉴别诊断和严重程度分层需要更完整。", + "治疗:治疗原则基本覆盖抗感染、平喘、氧合和观察。", + "沟通:已有基本告知意识,仍需加强家属顾虑回应和健康教育。", + ], + "guideline_refs": guideline_refs, + "overall_comment": "本次训练完成核心闭环,诊断和处理方向基本正确;后续需要强化问诊结构化、检查结果利用和治疗细化。", + } + return self._convert_to_five_point(data) if score_type == "five_point" else data + + def _convert_to_five_point(self, data: dict) -> dict: + """分数转换:将百分制评价转换为五分制,同时保留证据、扣分和改进细则。""" + converted = dict(data) + converted["score_type"] = "five_point" + converted["total_score"] = round(float(data.get("total_score", 0)) / 20, 1) + converted["dimension_scores"] = [ + { + **item, + "score": round(float(item.get("score", 0)) / float(item.get("max_score") or 100) * 5, 1), + "max_score": 5, + } + for item in data.get("dimension_scores", []) + ] + return converted + + def _truncate(self, value: Any, limit: int) -> str: + """文本截断:限制模型输入和报告字段长度,降低评分耗时并稳定排版。""" + if value is None: + return "" + text = str(value).strip() + return text if len(text) <= limit else f"{text[:limit]}..." diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..642af6a --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ +"""FastAPI 路由模块。""" diff --git a/backend/app/api/agent.py b/backend/app/api/agent.py new file mode 100644 index 0000000..ad92965 --- /dev/null +++ b/backend/app/api/agent.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.core.config import settings +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.agent import AgentHelloResponse, AgentHelloUser +from app.services.audit_service import AuditService + +router = APIRouter() + + +@router.get("/agent/hello", response_model=ApiResponse[AgentHelloResponse]) +def hello(ctx: UserContext = Depends(get_user_context), db: Session = Depends(get_db)): + """Agent Hello:读取宿主用户上下文并返回 Demo 功能配置。""" + AuditService(db).log(ctx, "agent.hello", "agent") + db.commit() + return ok( + AgentHelloResponse( + user=AgentHelloUser(user_id=ctx.user_id, tenant_id=ctx.tenant_id, role=ctx.role), + features=settings.as_public_dict(), + ) + ) diff --git a/backend/app/api/cases.py b/backend/app/api/cases.py new file mode 100644 index 0000000..c46cd97 --- /dev/null +++ b/backend/app/api/cases.py @@ -0,0 +1,59 @@ +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 ( + CaseDeletePreviewResponse, + CaseDeleteRequest, + CaseDeleteResponse, + 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)) + + +@router.get("/{case_id}/delete-preview", response_model=ApiResponse[CaseDeletePreviewResponse]) +def get_case_delete_preview( + case_id: int, + _: UserContext = Depends(get_user_context), + db: Session = Depends(get_db), +): + """病例删除预览:返回删除该病例会影响的训练与病例数据数量。""" + return ok(CaseService(db).get_delete_preview(case_id)) + + +@router.delete("/{case_id}", response_model=ApiResponse[CaseDeleteResponse]) +def delete_case( + case_id: int, + payload: CaseDeleteRequest, + ctx: UserContext = Depends(get_user_context), + db: Session = Depends(get_db), +): + """病例删除:确认后级联删除病例、扩展表、评分规则、检查项和关联训练数据。""" + return ok(CaseService(db).delete_case(case_id, payload, ctx)) diff --git a/backend/app/api/evaluations.py b/backend/app/api/evaluations.py new file mode 100644 index 0000000..90462ce --- /dev/null +++ b/backend/app/api/evaluations.py @@ -0,0 +1,39 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.core.response import ApiResponse, ok +from app.core.user_context import UserContext, get_user_context +from app.db.session import get_db +from app.schemas.evaluation import EvaluationDetailResponse, EvaluationListResponse, ExportPdfResponse +from app.services.evaluation_service import EvaluationService +from app.services.pdf_export_service import PdfExportService + +router = APIRouter() + + +@router.get("", response_model=ApiResponse[EvaluationListResponse]) +def list_evaluations(ctx: UserContext = Depends(get_user_context), db: Session = Depends(get_db)): + """历史评价:基于 user_id 查询完整训练后的评价记录。""" + return ok(EvaluationService(db).list_history(ctx.user_id)) + + +@router.get("/{evaluation_id}", response_model=ApiResponse[EvaluationDetailResponse]) +def get_evaluation_detail( + evaluation_id: int, + ctx: UserContext = Depends(get_user_context), + db: Session = Depends(get_db), +): + """评价详情:校验 user_id 后返回完整评价报告。""" + 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)) diff --git a/backend/app/api/imports.py b/backend/app/api/imports.py new file mode 100644 index 0000000..c5c7eae --- /dev/null +++ b/backend/app/api/imports.py @@ -0,0 +1,26 @@ +from fastapi import APIRouter, Depends, File, UploadFile + +from app.core.response import ApiResponse, ok +from app.core.user_context import UserContext, get_user_context +from app.schemas.imports import CaseSqlImportApplyResponse, CaseSqlImportPreviewResponse +from app.services.case_sql_import_service import CaseSqlImportService + +router = APIRouter() + + +@router.post("/case-sql/preview", response_model=ApiResponse[CaseSqlImportPreviewResponse]) +async def preview_case_sql( + file: UploadFile = File(...), + _: UserContext = Depends(get_user_context), +): + """病例 SQL 预检:上传接口 SQL 文件,解析可导入病例数据但不写入数据库。""" + return ok(await CaseSqlImportService().preview(file)) + + +@router.post("/case-sql/apply", response_model=ApiResponse[CaseSqlImportApplyResponse]) +async def apply_case_sql( + file: UploadFile = File(...), + _: UserContext = Depends(get_user_context), +): + """病例 SQL 导入:确认后把 SQL 中的病例表数据映射写入当前本地数据库。""" + return ok(await CaseSqlImportService().apply(file)) diff --git a/backend/app/api/knowledge.py b/backend/app/api/knowledge.py new file mode 100644 index 0000000..90771ce --- /dev/null +++ b/backend/app/api/knowledge.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.core.response import ApiResponse, ok +from app.core.user_context import UserContext, get_user_context +from app.db.session import get_db +from app.schemas.knowledge import KnowledgeSearchResponse +from app.services.knowledge_service import KnowledgeService + +router = APIRouter() + + +@router.get("/search", response_model=ApiResponse[KnowledgeSearchResponse]) +def search_knowledge( + _: UserContext = Depends(get_user_context), + db: Session = Depends(get_db), + department_id: int = Query(...), + training_type: str = Query(...), + q: str = Query(default=""), +): + """知识检索:按科室、训练类别和关键词检索评分参考指南。""" + keywords = [item.strip() for item in q.split(",") if item.strip()] + result = KnowledgeService(db).search_guidelines(department_id, training_type, keywords) + return ok(KnowledgeSearchResponse(**result)) diff --git a/backend/app/api/llm_test.py b/backend/app/api/llm_test.py new file mode 100644 index 0000000..710fd67 --- /dev/null +++ b/backend/app/api/llm_test.py @@ -0,0 +1,108 @@ +import time + +from fastapi import APIRouter, Depends + +from app.agents.llm_adapter import OpenAICompatibleLLMClient +from app.core.config import settings +from app.core.exceptions import AppError +from app.core.response import ApiResponse, ok +from app.core.user_context import UserContext, get_user_context +from app.schemas.llm import LLMTestRequest, LLMTestResponse + +router = APIRouter() + + +@router.post("/deepseek-fast", response_model=ApiResponse[LLMTestResponse]) +async def test_deepseek_fast( + payload: LLMTestRequest, + _: UserContext = Depends(get_user_context), +): + """Fast 模型测试:验证快速模型的非流式响应耗时。""" + client = OpenAICompatibleLLMClient() + response = await client.chat( + [{"role": "user", "content": payload.message}], + settings.llm_fast_model, + thinking_enabled=settings.llm_fast_thinking_enabled, + max_tokens=min(settings.llm_fast_max_tokens, 256), + ) + return ok( + LLMTestResponse( + model=response.model, + total_latency_ms=response.latency_ms, + stream=False, + mock_mode=client.is_mock_mode, + fallback_used=response.model.startswith("mock-fallback"), + thinking_enabled=settings.llm_fast_thinking_enabled, + ) + ) + + +@router.post("/deepseek-reason", response_model=ApiResponse[LLMTestResponse]) +async def test_deepseek_reason( + payload: LLMTestRequest, + _: UserContext = Depends(get_user_context), +): + """Reason 模型测试:优先验证流式耗时,流式不兼容时降级为真实非流式测试。""" + client = OpenAICompatibleLLMClient() + messages = [{"role": "user", "content": payload.message}] + first_token_ms = None + start = time.perf_counter() + + try: + async for chunk in client.stream_chat( + messages, + settings.llm_reason_model, + thinking_enabled=settings.llm_reason_thinking_enabled, + reasoning_effort=settings.llm_reasoning_effort if settings.llm_reason_thinking_enabled else None, + max_tokens=min(settings.llm_fast_max_tokens, 256), + ): + if first_token_ms is None and chunk.first_token_ms is not None: + first_token_ms = chunk.first_token_ms + if chunk.done: + return ok( + LLMTestResponse( + model=chunk.model or (settings.llm_reason_model if not client.is_mock_mode else f"mock-{settings.llm_reason_model}"), + first_token_ms=first_token_ms, + total_latency_ms=chunk.total_latency_ms or int((time.perf_counter() - start) * 1000), + stream=True, + mock_mode=client.is_mock_mode, + fallback_used=chunk.fallback_used, + thinking_enabled=settings.llm_reason_thinking_enabled, + reasoning_effort=settings.llm_reasoning_effort if settings.llm_reason_thinking_enabled else None, + ) + ) + except AppError as exc: + if exc.code != "LLM_STREAM_FAILED": + raise + response = await client.chat( + messages, + settings.llm_reason_model, + thinking_enabled=settings.llm_reason_thinking_enabled, + reasoning_effort=settings.llm_reasoning_effort if settings.llm_reason_thinking_enabled else None, + max_tokens=min(settings.llm_fast_max_tokens, 256), + ) + return ok( + LLMTestResponse( + model=response.model, + first_token_ms=None, + total_latency_ms=response.latency_ms, + stream=False, + mock_mode=client.is_mock_mode, + fallback_used=response.model.startswith("mock-fallback"), + thinking_enabled=settings.llm_reason_thinking_enabled, + reasoning_effort=settings.llm_reasoning_effort if settings.llm_reason_thinking_enabled else None, + ) + ) + + return ok( + LLMTestResponse( + model=settings.llm_reason_model, + first_token_ms=first_token_ms, + total_latency_ms=int((time.perf_counter() - start) * 1000), + stream=True, + mock_mode=client.is_mock_mode, + fallback_used=False, + thinking_enabled=settings.llm_reason_thinking_enabled, + reasoning_effort=settings.llm_reasoning_effort if settings.llm_reason_thinking_enabled else None, + ) + ) diff --git a/backend/app/api/router.py b/backend/app/api/router.py new file mode 100644 index 0000000..0226225 --- /dev/null +++ b/backend/app/api/router.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter + +from app.api import agent, cases, evaluations, imports, knowledge, llm_test, sessions + +api_router = APIRouter() +api_router.include_router(agent.router, tags=["agent"]) +api_router.include_router(cases.router, prefix="/cases", tags=["cases"]) +api_router.include_router(sessions.router, prefix="/sessions", tags=["sessions"]) +api_router.include_router(evaluations.router, prefix="/evaluations", tags=["evaluations"]) +api_router.include_router(knowledge.router, prefix="/knowledge", tags=["knowledge"]) +api_router.include_router(llm_test.router, prefix="/llm/test", tags=["llm-test"]) +api_router.include_router(imports.router, prefix="/imports", tags=["imports"]) diff --git a/backend/app/api/sessions.py b/backend/app/api/sessions.py new file mode 100644 index 0000000..9605da4 --- /dev/null +++ b/backend/app/api/sessions.py @@ -0,0 +1,162 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +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.evaluation import CreateEvaluationRequest, EvaluationResponse +from app.schemas.session import ( + ChatRequest, + ChatResponse, + CreateOrderRequest, + CreateOrderResponse, + CreateSessionRequest, + CreateSessionResponse, + OrderItemsResponse, + SessionStatusResponse, + SubmitDiagnosisRequest, + SubmitDiagnosisResponse, + SubmitTreatmentRequest, + SubmitTreatmentResponse, + HintRequest, + HintResponse, +) +from app.services.evaluation_service import EvaluationService +from app.services.order_service import OrderService +from app.services.session_service import SessionService + +router = APIRouter() + + +@router.post("", response_model=ApiResponse[CreateSessionResponse]) +def create_session( + payload: CreateSessionRequest, + ctx: UserContext = Depends(get_user_context), + db: Session = Depends(get_db), +): + """创建训练会话:初始化 user_id 隔离的训练会话和短期 memory。""" + result = SessionService(db).create_session(ctx, payload) + db.commit() + 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, + payload: ChatRequest, + ctx: UserContext = Depends(get_user_context), + db: Session = Depends(get_db), +): + """流式问诊:返回 SSE 格式的 AI 病人增量回复。""" + response = await SessionService(db).stream_chat(ctx, session_id, payload.message) + db.commit() + return StreamingResponse( + response, + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +@router.get("/{session_id}/order-items", response_model=ApiResponse[OrderItemsResponse]) +def list_order_items( + session_id: int, + ctx: UserContext = Depends(get_user_context), + db: Session = Depends(get_db), +): + """检查项目列表:返回当前病例可申请项目,不返回检查结果。""" + return ok(OrderService(db).list_order_items(session_id, ctx.user_id)) + + +@router.post("/{session_id}/orders", response_model=ApiResponse[CreateOrderResponse]) +def create_order( + session_id: int, + payload: CreateOrderRequest, + ctx: UserContext = Depends(get_user_context), + db: Session = Depends(get_db), +): + """申请检查检验:从数据库读取并返回结构化结果。""" + result = OrderService(db).create_order(session_id, ctx.user_id, payload.item_code) + db.commit() + return ok(result) + + +@router.post("/{session_id}/complete-inquiry", response_model=ApiResponse[SessionStatusResponse]) +def complete_inquiry( + session_id: int, + ctx: UserContext = Depends(get_user_context), + db: Session = Depends(get_db), +): + """完成问诊:从问诊阶段进入诊断阶段。""" + result = SessionService(db).complete_inquiry(ctx, session_id) + db.commit() + return ok(result) + + +@router.post("/{session_id}/hints", response_model=ApiResponse[HintResponse]) +async def generate_hints( + session_id: int, + payload: HintRequest, + 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) + + +@router.post("/{session_id}/diagnosis", response_model=ApiResponse[SubmitDiagnosisResponse]) +def submit_diagnosis( + session_id: int, + payload: SubmitDiagnosisRequest, + ctx: UserContext = Depends(get_user_context), + db: Session = Depends(get_db), +): + """提交诊断:保存主要诊断、鉴别诊断和诊断依据。""" + result = SessionService(db).submit_diagnosis(ctx, session_id, payload) + db.commit() + return ok(result) + + +@router.post("/{session_id}/treatment", response_model=ApiResponse[SubmitTreatmentResponse]) +def submit_treatment( + session_id: int, + payload: SubmitTreatmentRequest, + ctx: UserContext = Depends(get_user_context), + db: Session = Depends(get_db), +): + """提交治疗方案:保存治疗、风险、沟通和随访内容。""" + result = SessionService(db).submit_treatment(ctx, session_id, payload) + db.commit() + return ok(result) + + +@router.post("/{session_id}/evaluation", response_model=ApiResponse[EvaluationResponse]) +async def create_evaluation( + session_id: int, + payload: CreateEvaluationRequest, + ctx: UserContext = Depends(get_user_context), + db: Session = Depends(get_db), +): + """生成评价报告:检索指南并调用 Scoring Agent 生成结构化评价。""" + result = await EvaluationService(db).create_evaluation(ctx, session_id, payload) + db.commit() + return ok(result) diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..49cc051 --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1 @@ +"""核心配置、异常、响应和用户上下文模块。""" diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..eff2c17 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,108 @@ +import os +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, Field + + +def _load_dotenv_file() -> None: + """环境加载:轻量读取项目根目录 `.env`,避免强依赖 python-dotenv。""" + env_path = Path(__file__).resolve().parents[3] / ".env" + if not env_path.exists(): + return + for line in env_path.read_text(encoding="utf-8").splitlines(): + if not line or line.strip().startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + os.environ.setdefault(key.strip(), value.strip()) + + +_load_dotenv_file() + + +def _env_first(*keys: str, default: str = "") -> str: + """环境读取:按优先级读取多个环境变量。""" + for key in keys: + value = os.getenv(key) + if value: + return value + return default + + +def _normalize_sync_database_url(url: str) -> str: + """数据库连接:将异步 MySQL URL 转换为当前同步 ORM 可用的 URL。""" + if url.startswith("mysql+aiomysql://"): + return url.replace("mysql+aiomysql://", "mysql+pymysql://", 1) + if url.startswith("mysql://"): + return url.replace("mysql://", "mysql+pymysql://", 1) + return url + + +class Settings(BaseModel): + """系统配置:集中管理数据库、DeepSeek、报告和短期 memory 配置。""" + + app_name: str = Field(default_factory=lambda: os.getenv("APP_NAME", "Medical Consultation Agent Demo")) + app_env: str = Field(default_factory=lambda: os.getenv("APP_ENV", "local")) + app_debug: bool = Field(default_factory=lambda: os.getenv("APP_DEBUG", "true").lower() == "true") + api_v1_prefix: str = Field(default_factory=lambda: os.getenv("API_V1_PREFIX", "/api/v1")) + + mysql_url: str = Field(default_factory=lambda: os.getenv("MYSQL_URL", "")) + database_url: str = Field( + default_factory=lambda: _normalize_sync_database_url( + _env_first("DATABASE_URL", "MYSQL_URL", default="sqlite:///./storage/demo.db") + ) + ) + + llm_api_key: str = Field(default_factory=lambda: _env_first("LLM_API_KEY", "DEEPSEEK_API_KEY", default="")) + llm_base_url: str = Field( + default_factory=lambda: _env_first("LLM_BASE_URL", "DEEPSEEK_BASE_URL", default="https://api.deepseek.com") + ) + llm_model: str = Field(default_factory=lambda: _env_first("LLM_MODEL", "DEEPSEEK_FAST_MODEL", default="deepseek-chat")) + llm_fast_model: str = Field(default_factory=lambda: _env_first("LLM_FAST_MODEL", "LLM_MODEL", "DEEPSEEK_FAST_MODEL", default="deepseek-chat")) + llm_reason_model: str = Field( + default_factory=lambda: _env_first("LLM_REASON_MODEL", "LLM_MODEL", "DEEPSEEK_REASON_MODEL", default="deepseek-reasoner") + ) + llm_timeout_seconds: int = Field(default_factory=lambda: int(os.getenv("LLM_TIMEOUT_SECONDS", "45"))) + llm_chat_timeout_seconds: int = Field(default_factory=lambda: int(os.getenv("LLM_CHAT_TIMEOUT_SECONDS", "20"))) + llm_stream_first_token_timeout_seconds: int = Field( + default_factory=lambda: int(os.getenv("LLM_STREAM_FIRST_TOKEN_TIMEOUT_SECONDS", "15")) + ) + llm_stream_total_timeout_seconds: int = Field(default_factory=lambda: int(os.getenv("LLM_STREAM_TOTAL_TIMEOUT_SECONDS", "45"))) + llm_stream_enabled: bool = Field(default_factory=lambda: os.getenv("LLM_STREAM_ENABLED", "true").lower() == "true") + llm_mock_enabled: bool = Field(default_factory=lambda: os.getenv("LLM_MOCK_ENABLED", "true").lower() == "true") + llm_fallback_to_mock: bool = Field(default_factory=lambda: os.getenv("LLM_FALLBACK_TO_MOCK", "true").lower() == "true") + llm_fast_thinking_enabled: bool = Field(default_factory=lambda: os.getenv("LLM_FAST_THINKING_ENABLED", "false").lower() == "true") + llm_reason_thinking_enabled: bool = Field(default_factory=lambda: os.getenv("LLM_REASON_THINKING_ENABLED", "false").lower() == "true") + llm_reasoning_effort: str = Field(default_factory=lambda: os.getenv("LLM_REASONING_EFFORT", "low")) + llm_fast_max_tokens: int = Field(default_factory=lambda: int(os.getenv("LLM_FAST_MAX_TOKENS", "512"))) + llm_hint_max_tokens: int = Field(default_factory=lambda: int(os.getenv("LLM_HINT_MAX_TOKENS", "1200"))) + llm_scoring_json_response: bool = Field(default_factory=lambda: os.getenv("LLM_SCORING_JSON_RESPONSE", "true").lower() == "true") + llm_scoring_max_tokens: int = Field(default_factory=lambda: int(os.getenv("LLM_SCORING_MAX_TOKENS", "4096"))) + + report_storage_dir: str = Field(default_factory=lambda: os.getenv("REPORT_STORAGE_DIR", "./storage/reports")) + runtime_memory_ttl_seconds: int = Field(default_factory=lambda: int(os.getenv("RUNTIME_MEMORY_TTL_SECONDS", "7200"))) + runtime_memory_backend: str = Field(default_factory=lambda: os.getenv("RUNTIME_MEMORY_BACKEND", "memory")) + redis_url: str = Field(default_factory=lambda: os.getenv("REDIS_URL", "redis://127.0.0.1:6379/0")) + + def as_public_dict(self) -> dict[str, Any]: + """配置展示:返回允许暴露给 Demo 前端的功能开关。""" + mock_enabled = self.llm_mock_enabled or not self.llm_api_key + return { + "stream_chat": self.llm_stream_enabled, + "score_types": ["percentage", "five_point"], + "pdf_export": True, + "knowledge_search": True, + "llm_mock_enabled": mock_enabled, + "llm_mode": "mock" if mock_enabled else "real", + "llm_fallback_to_mock": self.llm_fallback_to_mock, + "llm_fast_model": self.llm_fast_model, + "llm_reason_model": self.llm_reason_model, + "llm_fast_thinking_enabled": self.llm_fast_thinking_enabled, + "llm_reason_thinking_enabled": self.llm_reason_thinking_enabled, + "llm_reasoning_effort": self.llm_reasoning_effort, + "llm_fast_max_tokens": self.llm_fast_max_tokens, + "runtime_memory_backend": self.runtime_memory_backend, + } + + +settings = Settings() diff --git a/backend/app/core/context.py b/backend/app/core/context.py new file mode 100644 index 0000000..7243267 --- /dev/null +++ b/backend/app/core/context.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class UserContext: + """用户上下文:承载宿主系统传入的 user_id 和入口元数据。""" + + user_id: str + tenant_id: str | None = None + role: str | None = None + class_id: str | None = None + entry_scene: str | None = None + request_id: str | None = None + ip_address: str | None = None + user_agent: str | None = None diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py new file mode 100644 index 0000000..572538e --- /dev/null +++ b/backend/app/core/errors.py @@ -0,0 +1,64 @@ +import logging + +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from sqlalchemy.exc import SQLAlchemyError + +from app.core.exceptions import AppError + +logger = logging.getLogger(__name__) + + +def register_exception_handlers(app: FastAPI) -> None: + """异常注册:把业务异常转换为统一响应格式。""" + + @app.exception_handler(AppError) + async def handle_app_error(request: Request, exc: AppError) -> JSONResponse: + logger.warning( + "business_error code=%s path=%s user_id=%s", + exc.code, + request.url.path, + request.headers.get("X-User-Id"), + ) + return JSONResponse( + status_code=exc.status_code, + content={"code": exc.code, "message": exc.message, "data": None}, + ) + + @app.exception_handler(RequestValidationError) + async def handle_validation_error(request: Request, exc: RequestValidationError) -> JSONResponse: + logger.warning( + "validation_error path=%s user_id=%s errors=%s", + request.url.path, + request.headers.get("X-User-Id"), + exc.errors(), + ) + return JSONResponse( + status_code=422, + content={"code": "VALIDATION_ERROR", "message": "request validation failed", "data": {"errors": exc.errors()}}, + ) + + @app.exception_handler(SQLAlchemyError) + async def handle_database_error(request: Request, exc: SQLAlchemyError) -> JSONResponse: + logger.exception( + "database_error path=%s user_id=%s", + request.url.path, + request.headers.get("X-User-Id"), + ) + return JSONResponse( + status_code=500, + content={"code": "DATABASE_ERROR", "message": "database operation failed", "data": None}, + ) + + @app.exception_handler(Exception) + async def handle_unexpected_error(request: Request, exc: Exception) -> JSONResponse: + logger.exception( + "unexpected_error path=%s user_id=%s", + request.url.path, + request.headers.get("X-User-Id"), + ) + return JSONResponse( + status_code=500, + content={"code": "INTERNAL_ERROR", "message": "internal server error", "data": None}, + ) diff --git a/backend/app/core/exceptions.py b/backend/app/core/exceptions.py new file mode 100644 index 0000000..5e2f389 --- /dev/null +++ b/backend/app/core/exceptions.py @@ -0,0 +1,8 @@ +class AppError(Exception): + """业务异常:承载业务错误码、错误信息和 HTTP 状态码。""" + + def __init__(self, code: str, message: str, status_code: int = 400) -> None: + self.code = code + self.message = message + self.status_code = status_code + super().__init__(message) diff --git a/backend/app/core/response.py b/backend/app/core/response.py new file mode 100644 index 0000000..852e9a7 --- /dev/null +++ b/backend/app/core/response.py @@ -0,0 +1,18 @@ +from typing import Generic, TypeVar + +from pydantic import BaseModel + +T = TypeVar("T") + + +class ApiResponse(BaseModel, Generic[T]): + """统一响应:所有业务接口使用相同的 `code/message/data` 结构。""" + + code: str = "OK" + message: str = "success" + data: T | None = None + + +def ok(data: T | None = None) -> ApiResponse[T]: + """响应封装:生成成功响应对象。""" + return ApiResponse(data=data) diff --git a/backend/app/core/user_context.py b/backend/app/core/user_context.py new file mode 100644 index 0000000..238ac73 --- /dev/null +++ b/backend/app/core/user_context.py @@ -0,0 +1,29 @@ +from fastapi import Header, Request + +from app.core.context import UserContext +from app.core.exceptions import AppError + + +async def get_user_context( + request: Request, + x_user_id: str | None = Header(default=None, alias="X-User-Id"), + x_tenant_id: str | None = Header(default=None, alias="X-Tenant-Id"), + x_user_role: str | None = Header(default=None, alias="X-User-Role"), + x_class_id: str | None = Header(default=None, alias="X-Class-Id"), + x_entry_scene: str | None = Header(default=None, alias="X-Entry-Scene"), + x_request_id: str | None = Header(default=None, alias="X-Request-Id"), +) -> UserContext: + """用户校验:读取请求头并强制校验 `X-User-Id`。""" + if not x_user_id or not x_user_id.strip(): + raise AppError("USER_ID_REQUIRED", "X-User-Id header is required", 401) + + return UserContext( + user_id=x_user_id.strip(), + tenant_id=x_tenant_id, + role=x_user_role, + class_id=x_class_id, + entry_scene=x_entry_scene, + request_id=x_request_id, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("User-Agent"), + ) diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..4efab81 --- /dev/null +++ b/backend/app/db/__init__.py @@ -0,0 +1 @@ +"""数据库连接、声明式基类和会话依赖。""" diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..2f3d1c7 --- /dev/null +++ b/backend/app/db/base.py @@ -0,0 +1,7 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + """SQLAlchemy 基类:所有 ORM 模型统一继承该基类。""" + + pass diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..db05de1 --- /dev/null +++ b/backend/app/db/session.py @@ -0,0 +1,28 @@ +from collections.abc import Generator +from pathlib import Path + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.core.config import settings + + +def _engine_kwargs() -> dict: + """数据库连接:根据数据库类型设置 SQLAlchemy engine 参数。""" + if settings.database_url.startswith("sqlite"): + Path("storage").mkdir(exist_ok=True) + return {"connect_args": {"check_same_thread": False}} + return {"pool_pre_ping": True, "pool_recycle": 3600} + + +engine = create_engine(settings.database_url, **_engine_kwargs()) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False) + + +def get_db() -> Generator[Session, None, None]: + """请求依赖:为每个请求创建数据库会话并在结束后关闭。""" + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..0188f04 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,38 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.router import api_router +from app.core.config import settings +from app.core.errors import register_exception_handlers + + +def create_app() -> FastAPI: + """应用工厂:创建 FastAPI 实例并挂载中间件、路由和异常处理器。""" + app = FastAPI( + title=settings.app_name, + debug=settings.app_debug, + version="0.1.0", + docs_url="/docs", + redoc_url="/redoc", + ) + + app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://127.0.0.1:5173", + "http://localhost:5173", + "http://127.0.0.1:5174", + "http://localhost:5174", + ], + allow_origin_regex=r"^http://(127\.0\.0\.1|localhost):\d+$", + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], + ) + + app.include_router(api_router, prefix=settings.api_v1_prefix) + register_exception_handlers(app) + return app + + +app = create_app() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..608de2b --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,30 @@ +"""ORM 模型导出:初始化数据库时只导入当前新表体系需要的模型。""" + +from app.models.audit import AuditLog +from app.models.department import Department +from app.models.knowledge import KnowledgeChunk, KnowledgeDocument, KnowledgeSource +from app.models.prompt import PromptTemplate +from app.models.source_case import CaseBase, CaseExamItem, ScoringRule, TeachingCase, TraditionalCase +from app.models.training import SessionOrder, SessionSubmission, TrainingSession +from app.models.training_record import TrainingRecord +from app.models.user import User, UserLearningProfile + +__all__ = [ + "AuditLog", + "Department", + "KnowledgeChunk", + "KnowledgeDocument", + "KnowledgeSource", + "PromptTemplate", + "CaseBase", + "CaseExamItem", + "TraditionalCase", + "TeachingCase", + "ScoringRule", + "SessionOrder", + "SessionSubmission", + "TrainingSession", + "TrainingRecord", + "User", + "UserLearningProfile", +] diff --git a/backend/app/models/audit.py b/backend/app/models/audit.py new file mode 100644 index 0000000..3ab433f --- /dev/null +++ b/backend/app/models/audit.py @@ -0,0 +1,25 @@ +from datetime import datetime + +from sqlalchemy import DateTime, Integer, JSON, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class AuditLog(Base): + """审计日志模型:记录关键接口和安全相关元数据。""" + + __tablename__ = "audit_logs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[str | None] = mapped_column(String(128), index=True) + tenant_id: Mapped[str | None] = mapped_column(String(128)) + session_id: Mapped[int | None] = mapped_column(Integer, index=True) + action: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + resource_type: Mapped[str] = mapped_column(String(64), nullable=False) + resource_id: Mapped[str | None] = mapped_column(String(128)) + request_id: Mapped[str | None] = mapped_column(String(128)) + ip_address: Mapped[str | None] = mapped_column(String(64)) + user_agent: Mapped[str | None] = mapped_column(String(512)) + metadata_: Mapped[dict | None] = mapped_column("metadata", JSON) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) diff --git a/backend/app/models/department.py b/backend/app/models/department.py new file mode 100644 index 0000000..20afdc0 --- /dev/null +++ b/backend/app/models/department.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from sqlalchemy import Boolean, ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base +from app.models.mixins import TimestampMixin + + +class Department(TimestampMixin, Base): + """科室模型:维护病例、知识库和评分规则的科室分类。""" + + __tablename__ = "departments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False, unique=True, index=True) + parent_id: Mapped[int | None] = mapped_column(ForeignKey("departments.id"), nullable=True) + sort_order: Mapped[int] = mapped_column(Integer, default=0) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + + parent: Mapped["Department | None"] = relationship(remote_side=[id]) diff --git a/backend/app/models/knowledge.py b/backend/app/models/knowledge.py new file mode 100644 index 0000000..921e141 --- /dev/null +++ b/backend/app/models/knowledge.py @@ -0,0 +1,56 @@ +from sqlalchemy import Boolean, Enum, ForeignKey, Integer, JSON, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base +from app.models.mixins import TimestampMixin + + +class KnowledgeSource(TimestampMixin, Base): + """知识来源模型:保存指南、专家标准和考试要求来源。""" + + __tablename__ = "knowledge_sources" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + source_code: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True) + source_name: Mapped[str] = mapped_column(String(255), nullable=False) + source_type: Mapped[str] = mapped_column( + Enum("national_standard", "department_expert", "exam_requirement", "clinical_guideline", "humanistic_care", "other"), + nullable=False, + index=True, + ) + authority_level: Mapped[int] = mapped_column(Integer, default=1) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + + +class KnowledgeDocument(TimestampMixin, Base): + """知识文档模型:保存知识来源下的具体文档元数据。""" + + __tablename__ = "knowledge_documents" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + source_id: Mapped[int] = mapped_column(ForeignKey("knowledge_sources.id"), nullable=False, index=True) + department_id: Mapped[int | None] = mapped_column(ForeignKey("departments.id"), nullable=True, index=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + task_type: Mapped[str | None] = mapped_column(String(64), index=True) + summary: Mapped[str | None] = mapped_column(Text) + file_path: Mapped[str | None] = mapped_column(String(512)) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + + source = relationship("KnowledgeSource") + + +class KnowledgeChunk(Base): + """知识片段模型:保存评分前检索和拼接使用的指南片段。""" + + __tablename__ = "knowledge_chunks" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + document_id: Mapped[int] = mapped_column(ForeignKey("knowledge_documents.id"), nullable=False, index=True) + department_id: Mapped[int | None] = mapped_column(ForeignKey("departments.id"), nullable=True, index=True) + task_type: Mapped[str | None] = mapped_column(String(64), index=True) + chunk_text: Mapped[str] = mapped_column(Text, nullable=False) + keywords: Mapped[list | None] = mapped_column(JSON) + weight: Mapped[float] = mapped_column(default=1.0) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + + document = relationship("KnowledgeDocument") diff --git a/backend/app/models/mixins.py b/backend/app/models/mixins.py new file mode 100644 index 0000000..c10a2ae --- /dev/null +++ b/backend/app/models/mixins.py @@ -0,0 +1,15 @@ +from datetime import datetime + +from sqlalchemy import DateTime +from sqlalchemy.orm import Mapped, mapped_column + + +class TimestampMixin: + """时间字段:统一提供创建时间和更新时间。""" + + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + default=datetime.utcnow, + onupdate=datetime.utcnow, + ) diff --git a/backend/app/models/prompt.py b/backend/app/models/prompt.py new file mode 100644 index 0000000..1b70619 --- /dev/null +++ b/backend/app/models/prompt.py @@ -0,0 +1,28 @@ +from sqlalchemy import Boolean, Enum, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base +from app.models.mixins import TimestampMixin + + +class PromptTemplate(TimestampMixin, Base): + """提示词模板元数据:保存 Markdown 模板路径、场景、版本和启用状态。""" + + __tablename__ = "prompt_templates" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="提示词模板ID") + template_code: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="模板编码") + agent_type: Mapped[str] = mapped_column( + Enum("patient", "scoring", "report", "polish", "hint", "knowledge"), + nullable=False, + index=True, + comment="Agent类型", + ) + scene: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="使用场景") + version_no: Mapped[str] = mapped_column(String(32), nullable=False, comment="版本号") + model_type: Mapped[str] = mapped_column(Enum("fast", "reason"), nullable=False, comment="模型类型") + output_format: Mapped[str] = mapped_column(Enum("text", "json"), nullable=False, comment="输出格式") + file_path: Mapped[str] = mapped_column(String(512), nullable=False, comment="Markdown文件路径") + is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否启用") + + __table_args__ = {"comment": "提示词模板元数据表"} diff --git a/backend/app/models/source_case.py b/backend/app/models/source_case.py new file mode 100644 index 0000000..3a347f7 --- /dev/null +++ b/backend/app/models/source_case.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +from decimal import Decimal + +from sqlalchemy import BigInteger, Boolean, ForeignKey, Integer, JSON, Numeric, SmallInteger, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base +from app.models.mixins import TimestampMixin + +BIGINT_PK = BigInteger().with_variant(Integer, "sqlite") + + +class CaseBase(TimestampMixin, Base): + """源库病例主表:保持 case_base 字段稳定,业务兼容字段通过属性派生。""" + + __tablename__ = "case_base" + + id: Mapped[int] = mapped_column(BIGINT_PK, primary_key=True, autoincrement=True, comment="病例ID") + title: Mapped[str] = mapped_column(String(255), nullable=False, comment="病例标题") + case_type: Mapped[str] = mapped_column(String(30), nullable=False, index=True, comment="病例类型") + difficulty: Mapped[str] = mapped_column(String(20), nullable=False, default="medium", index=True, comment="难度") + difficulty_score: Mapped[int | None] = mapped_column(Integer, comment="难度分") + chief_complaint: Mapped[str] = mapped_column(Text, nullable=False, comment="主诉") + description: Mapped[str] = mapped_column(Text, nullable=False, comment="病例描述") + patient_age: Mapped[int | None] = mapped_column(Integer, comment="患者年龄") + patient_gender: Mapped[str] = mapped_column(String(10), nullable=False, comment="患者性别") + tags: Mapped[str] = mapped_column(String(500), nullable=False, default="", comment="标签") + symptom_tags: Mapped[list] = mapped_column(JSON, nullable=False, default=list, comment="症状标签") + disease_tags: Mapped[list] = mapped_column(JSON, nullable=False, default=list, comment="疾病标签") + competency_tags: Mapped[list] = mapped_column(JSON, nullable=False, default=list, comment="能力标签") + guideline_tags: Mapped[list] = mapped_column(JSON, nullable=False, default=list, comment="指南标签") + knowledge_points: Mapped[list] = mapped_column(JSON, nullable=False, default=list, comment="知识点") + icd_codes: Mapped[str] = mapped_column(String(500), nullable=False, default="", comment="ICD编码") + estimated_minutes: Mapped[int | None] = mapped_column(Integer, comment="预计训练分钟数") + osce_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, comment="是否启用OSCE") + rag_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, comment="是否启用RAG") + ai_prompt_template: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="病例AI提示词片段") + multimodal_assets: Mapped[list] = mapped_column(JSON, nullable=False, default=list, comment="多模态资源") + vector_status: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0, comment="向量状态") + publish_status: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=1, index=True, comment="发布状态") + status: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=1, index=True, comment="启用状态") + created_by_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True, comment="创建人ID") + department_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True, comment="科室ID") + + traditional_case = relationship("TraditionalCase", back_populates="case", uselist=False, cascade="all, delete-orphan") + teaching_case = relationship("TeachingCase", back_populates="case", uselist=False, cascade="all, delete-orphan") + scoring_rules = relationship("ScoringRule", back_populates="case", cascade="all, delete-orphan") + exam_items = relationship("CaseExamItem", back_populates="case", cascade="all, delete-orphan") + + __table_args__ = {"comment": "病例主表"} + + @property + def case_code(self) -> str: + """病例编码:源表没有独立编码时用稳定派生值兼容前端。""" + return f"CASE_{self.id}" + + @property + def patient_name(self) -> None: + """患者姓名:源表不保存患者姓名。""" + return None + + @property + def patient_occupation(self) -> None: + """患者职业:源表不保存职业。""" + return None + + @property + def supported_training_type(self) -> str: + """训练类别:沿用源库 case_type,异常值按诊疗练习处理。""" + return self.case_type if self.case_type in {"case_analysis", "diagnosis_treatment", "consultation"} else "diagnosis_treatment" + + @property + def supported_mode(self) -> str: + """交互模式:存在 teaching_case 时为互动模式,否则为自由问诊。""" + return "interactive" if self.teaching_case else "free_chat" + + @property + def has_teaching_video(self) -> bool: + """教学视频标记:从多模态资源判断是否存在视频。""" + return any(isinstance(item, dict) and item.get("type") == "video" for item in (self.multimodal_assets or [])) + + @property + def has_knowledge_points(self) -> bool: + """知识点标记:由源表 knowledge_points 判断。""" + return bool(self.knowledge_points) + + @property + def has_quiz(self) -> bool: + """题库标记:教学互动扩展表存在讨论题时视为有题库入口。""" + return bool(self.teaching_case and self.teaching_case.discussion_questions) + + @property + def patient_opening(self) -> str: + """AI 病人开场:根据主诉派生,不新增源表字段。""" + return f"家长:医生,孩子{self.chief_complaint},想请您看看。" + + @property + def ai_patient_profile(self) -> dict: + """AI 病人人设:由病例描述和提示词动态形成基础人设。""" + return { + "speaker": "患儿家长", + "answer_style": "简短、真实、只回答被问到的信息", + "prompt_template": self.ai_prompt_template, + } + + @property + def hidden_patient_info(self) -> dict: + """隐藏病情信息:使用病例描述和传统病例指南作为上下文。""" + return { + "case_description": self.description, + "guideline_reference": self.traditional_case.guideline_reference if self.traditional_case else "", + "teaching_goal": self.teaching_case.teaching_goal if self.teaching_case else "", + } + + @property + def key_symptoms(self) -> list: + """关键症状:使用源库 symptom_tags。""" + return self.symptom_tags or [] + + @property + def key_exams(self) -> list: + """关键检查:使用源库 knowledge_points 作为演示阶段考核提示。""" + return self.knowledge_points or [] + + @property + def key_points(self) -> list: + """考核要点:使用源库 competency_tags。""" + return self.competency_tags or [] + + @property + def diagnosis_primary(self) -> str: + """标准诊断:练习模式来自 traditional_case.standard_diagnosis。""" + return self.traditional_case.standard_diagnosis if self.traditional_case else "" + + @property + def diagnosis_basis(self) -> str: + """诊断依据:优先使用 traditional_case.guideline_reference。""" + if self.traditional_case: + return self.traditional_case.guideline_reference + return self.description + + @property + def treatment_plan(self) -> dict: + """标准治疗:练习模式来自 traditional_case.standard_treatment。""" + return {"standard_treatment": self.traditional_case.standard_treatment} if self.traditional_case else {} + + +class TraditionalCase(TimestampMixin, Base): + """源库传统病例表:练习模式读取 case_base + traditional_case。""" + + __tablename__ = "traditional_case" + + id: Mapped[int] = mapped_column(BIGINT_PK, primary_key=True, autoincrement=True, comment="传统病例ID") + standard_diagnosis: Mapped[str] = mapped_column(Text, nullable=False, comment="标准诊断") + standard_treatment: Mapped[str] = mapped_column(Text, nullable=False, comment="标准治疗") + guideline_reference: Mapped[str] = mapped_column(Text, nullable=False, comment="指南参考") + case_id: Mapped[int] = mapped_column(ForeignKey("case_base.id"), nullable=False, unique=True, index=True, comment="病例ID") + + case = relationship("CaseBase", back_populates="traditional_case") + + __table_args__ = {"comment": "传统病例扩展表"} + + +class TeachingCase(TimestampMixin, Base): + """源库教学互动病例表:教学互动模式读取 case_base + teaching_case。""" + + __tablename__ = "teaching_case" + + id: Mapped[int] = mapped_column(BIGINT_PK, primary_key=True, autoincrement=True, comment="教学互动病例ID") + teaching_goal: Mapped[str] = mapped_column(Text, nullable=False, comment="教学目标") + discussion_questions: Mapped[str] = mapped_column(Text, nullable=False, comment="讨论问题") + teacher_guide: Mapped[str] = mapped_column(Text, nullable=False, comment="教师引导") + scoring_focus: Mapped[str] = mapped_column(Text, nullable=False, comment="评分重点") + case_id: Mapped[int] = mapped_column(ForeignKey("case_base.id"), nullable=False, unique=True, index=True, comment="病例ID") + + case = relationship("CaseBase", back_populates="teaching_case") + + __table_args__ = {"comment": "教学互动病例扩展表"} + + +class ScoringRule(TimestampMixin, Base): + """源库评分规则表:评价时作为基础评分细则输入 Scoring Agent。""" + + __tablename__ = "scoring_rule" + + id: Mapped[int] = mapped_column(BIGINT_PK, primary_key=True, autoincrement=True, comment="评分规则ID") + dimension: Mapped[str] = mapped_column(String(50), nullable=False, index=True, comment="一级维度") + competency_dimension: Mapped[str] = mapped_column(String(50), nullable=False, index=True, comment="能力维度") + score_weight: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, comment="分值权重") + ai_auto_score: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, comment="是否AI自动评分") + osce_dimension: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, comment="是否OSCE维度") + scoring_standard: Mapped[str] = mapped_column(Text, nullable=False, comment="评分标准") + rubric_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict, comment="结构化评分细则") + case_id: Mapped[int] = mapped_column(ForeignKey("case_base.id"), nullable=False, index=True, comment="病例ID") + + case = relationship("CaseBase", back_populates="scoring_rules") + + __table_args__ = ( + UniqueConstraint("case_id", "dimension", "competency_dimension", name="uk_scoring_rule_case_dimension"), + {"comment": "评分规则表"}, + ) + + +class CaseExamItem(TimestampMixin, Base): + """病例检查检验项目表:保存固定检查结果,避免由 LLM 编造检查/检验数据。""" + + __tablename__ = "case_exam_item" + + id: Mapped[int] = mapped_column(BIGINT_PK, primary_key=True, autoincrement=True, comment="检查项目ID") + case_id: Mapped[int] = mapped_column(ForeignKey("case_base.id"), nullable=False, index=True, comment="病例ID") + item_code: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="检查项目编码") + item_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="检查项目名称") + item_type: Mapped[str] = mapped_column(String(32), nullable=False, index=True, comment="项目类型") + category: Mapped[str | None] = mapped_column(String(64), comment="项目分类") + result_text: Mapped[str] = mapped_column(Text, nullable=False, comment="固定返回结果文本") + result_structured: Mapped[dict | None] = mapped_column(JSON, comment="结构化检查结果") + is_key: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否关键检查") + is_abnormal: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否异常结果") + score_weight: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=0, comment="评分权重") + display_order: Mapped[int] = mapped_column(Integer, default=0, comment="展示顺序") + + case = relationship("CaseBase", back_populates="exam_items") + + __table_args__ = ( + UniqueConstraint("case_id", "item_code", name="uk_case_exam_item_code"), + {"comment": "病例检查检验项目表"}, + ) diff --git a/backend/app/models/training.py b/backend/app/models/training.py new file mode 100644 index 0000000..43ee08c --- /dev/null +++ b/backend/app/models/training.py @@ -0,0 +1,91 @@ +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, JSON, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base +from app.models.mixins import TimestampMixin + +BIGINT_PK = BigInteger().with_variant(Integer, "sqlite") + + +class TrainingSession(TimestampMixin, Base): + """训练会话表:保存一次训练的运行状态、用户隔离信息和短期 memory key。""" + + __tablename__ = "training_session" + + id: Mapped[int] = mapped_column(BIGINT_PK, primary_key=True, autoincrement=True, comment="训练会话ID") + session_code: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True, comment="会话编码") + user_id: Mapped[str] = mapped_column("external_user_id", String(128), nullable=False, index=True, comment="宿主系统用户ID") + tenant_id: Mapped[str | None] = mapped_column(String(128), comment="租户或项目ID") + class_id: Mapped[str | None] = mapped_column(String(128), comment="班级或课程ID") + entry_scene: Mapped[str | None] = mapped_column(String(64), comment="入口场景") + case_id: Mapped[int] = mapped_column(ForeignKey("case_base.id"), nullable=False, index=True, comment="病例ID") + training_type: Mapped[str] = mapped_column("case_type", String(30), nullable=False, comment="病例/训练类型") + mode: Mapped[str] = mapped_column("training_mode", String(50), nullable=False, index=True, comment="训练模式") + score_type: Mapped[str] = mapped_column(String(20), default="percentage", comment="分数输出类型") + status: Mapped[str] = mapped_column(String(30), default="created", index=True, comment="会话状态") + started_at: Mapped[datetime | None] = mapped_column(DateTime, comment="开始时间") + inquiry_completed_at: Mapped[datetime | None] = mapped_column(DateTime, comment="问诊完成时间") + completed_at: Mapped[datetime | None] = mapped_column(DateTime, comment="完成时间") + memory_key: Mapped[str | None] = mapped_column(String(128), comment="短期memory key") + metadata_: Mapped[dict | None] = mapped_column("metadata", JSON, comment="扩展数据") + + case = relationship("CaseBase") + orders = relationship("SessionOrder", back_populates="session", cascade="all, delete-orphan") + submission = relationship("SessionSubmission", back_populates="session", uselist=False, cascade="all, delete-orphan") + + __table_args__ = {"comment": "训练会话表"} + + +class SessionOrder(Base): + """训练检查申请表:记录用户在一次训练中申请过的检查/检验项目和固定结果。""" + + __tablename__ = "training_order" + + id: Mapped[int] = mapped_column(BIGINT_PK, primary_key=True, autoincrement=True, comment="检查申请ID") + session_id: Mapped[int] = mapped_column(ForeignKey("training_session.id"), nullable=False, index=True, comment="训练会话ID") + user_id: Mapped[str] = mapped_column("external_user_id", String(128), nullable=False, index=True, comment="宿主系统用户ID") + case_id: Mapped[int] = mapped_column(ForeignKey("case_base.id"), nullable=False, index=True, comment="病例ID") + case_exam_item_id: Mapped[int] = mapped_column("exam_item_id", ForeignKey("case_exam_item.id"), nullable=False, comment="检查项目ID") + item_code: Mapped[str] = mapped_column(String(64), nullable=False, comment="项目编码") + item_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="项目名称") + item_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="项目类型") + result_text: Mapped[str] = mapped_column(Text, nullable=False, comment="检查结果文本") + result_structured: Mapped[dict | None] = mapped_column(JSON, comment="结构化检查结果") + is_key: Mapped[bool] = mapped_column(default=False, comment="是否关键检查") + is_abnormal: Mapped[bool] = mapped_column(default=False, comment="是否异常结果") + ordered_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, comment="申请时间") + + session = relationship("TrainingSession", back_populates="orders") + case = relationship("CaseBase") + exam_item = relationship("CaseExamItem") + + __table_args__ = ( + UniqueConstraint("session_id", "item_code", name="uk_training_order_session_item"), + {"comment": "训练检查申请表"}, + ) + + +class SessionSubmission(TimestampMixin, Base): + """训练诊断治疗提交表:保存用户最终提交的诊断、治疗、沟通和随访内容。""" + + __tablename__ = "training_submission" + + id: Mapped[int] = mapped_column(BIGINT_PK, primary_key=True, autoincrement=True, comment="提交记录ID") + session_id: Mapped[int] = mapped_column(ForeignKey("training_session.id"), nullable=False, unique=True, comment="训练会话ID") + user_id: Mapped[str] = mapped_column("external_user_id", String(128), nullable=False, index=True, comment="宿主系统用户ID") + primary_diagnosis: Mapped[str | None] = mapped_column(Text, comment="主要诊断") + differential_diagnoses: Mapped[list | None] = mapped_column(JSON, comment="鉴别诊断") + diagnosis_basis: Mapped[str | None] = mapped_column(Text, comment="诊断依据") + treatment_principle: Mapped[str | None] = mapped_column(Text, comment="治疗原则") + treatment_measures: Mapped[str | None] = mapped_column(Text, comment="治疗措施") + risk_plan: Mapped[str | None] = mapped_column(Text, comment="风险预案") + communication: Mapped[str | None] = mapped_column(Text, comment="医患沟通") + follow_up: Mapped[str | None] = mapped_column(Text, comment="随访安排") + diagnosis_submitted_at: Mapped[datetime | None] = mapped_column(DateTime, comment="诊断提交时间") + treatment_submitted_at: Mapped[datetime | None] = mapped_column(DateTime, comment="治疗提交时间") + + session = relationship("TrainingSession", back_populates="submission") + + __table_args__ = {"comment": "训练诊断治疗提交表"} diff --git a/backend/app/models/training_record.py b/backend/app/models/training_record.py new file mode 100644 index 0000000..8132ce3 --- /dev/null +++ b/backend/app/models/training_record.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import BigInteger, DateTime, Integer, JSON, Numeric, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base +from app.models.mixins import TimestampMixin + +BIGINT_PK = BigInteger().with_variant(Integer, "sqlite") + + +class TrainingRecord(TimestampMixin, Base): + """训练记录表:完整完成问诊、诊断、治疗和评价后写入长期记录。""" + + __tablename__ = "training_record" + + id: Mapped[int] = mapped_column(BIGINT_PK, primary_key=True, autoincrement=True, comment="训练记录ID") + training_mode: Mapped[str] = mapped_column(String(50), nullable=False, index=True, comment="训练模式") + case_type: Mapped[str] = mapped_column(String(30), nullable=False, index=True, comment="病例/训练类型") + start_time: Mapped[datetime] = mapped_column(DateTime, nullable=False, comment="训练开始时间") + end_time: Mapped[datetime | None] = mapped_column(DateTime, comment="训练结束时间") + duration_seconds: Mapped[int | None] = mapped_column(Integer, comment="训练持续秒数") + total_score: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), comment="总分") + ai_score: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), comment="AI评分") + teacher_score: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), comment="教师评分") + evaluation_level: Mapped[str] = mapped_column(String(20), nullable=False, default="", comment="评价等级") + status: Mapped[str] = mapped_column(String(30), nullable=False, index=True, comment="记录状态") + feedback: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="总体反馈") + thinking_chain: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="诊断循证与评分依据摘要") + diagnosis_path: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="诊断路径摘要") + wrong_points: Mapped[list] = mapped_column(JSON, nullable=False, default=list, comment="错误点/扣分点") + missed_questions: Mapped[list] = mapped_column(JSON, nullable=False, default=list, comment="遗漏问题") + recommendation_result: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict, comment="改进建议和导出结果") + ai_feedback_structured: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict, comment="AI结构化评价") + osce_station_score: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict, comment="OSCE站点评分") + interruption_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="中断次数") + emotion_analysis: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict, comment="情绪分析") + prompt_version: Mapped[str] = mapped_column(String(50), nullable=False, default="v1", comment="提示词版本") + rag_context_version: Mapped[str] = mapped_column(String(50), nullable=False, default="none", comment="RAG上下文版本") + case_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True, comment="病例ID") + teacher_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True, comment="教师ID") + user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True, comment="数字用户ID") + external_user_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="宿主系统用户ID") + session_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True, comment="训练会话ID") + evaluation_record_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True, comment="兼容旧评价记录ID") + score_type: Mapped[str] = mapped_column(String(20), nullable=False, default="percentage", comment="分数类型") + pdf_file_path: Mapped[str | None] = mapped_column(String(512), comment="PDF报告路径") + + __table_args__ = ( + UniqueConstraint("session_id", name="uk_training_record_session"), + {"comment": "训练记录表"}, + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..4ee2a63 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,33 @@ +from datetime import datetime + +from sqlalchemy import DateTime, Integer, JSON, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base +from app.models.mixins import TimestampMixin + + +class User(TimestampMixin, Base): + """宿主用户引用:保存外部 user_id,不承担登录注册职责。""" + + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + external_user_id: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True) + display_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + + +class UserLearningProfile(TimestampMixin, Base): + """学习档案模型:聚合完整评价记录形成用户能力画像。""" + + __tablename__ = "user_learning_profiles" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True) + tenant_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + total_evaluations: Mapped[int] = mapped_column(Integer, default=0) + avg_score_percentage: Mapped[float | None] = mapped_column(Numeric(6, 2)) + avg_score_five_point: Mapped[float | None] = mapped_column(Numeric(4, 2)) + weak_dimensions: Mapped[list | None] = mapped_column(JSON) + last_evaluation_id: Mapped[int | None] = mapped_column(Integer) + last_trained_at: Mapped[datetime | None] = mapped_column(DateTime, index=True) diff --git a/backend/app/prompts/hint/novice_case_hint.md b/backend/app/prompts/hint/novice_case_hint.md new file mode 100644 index 0000000..e1fe119 --- /dev/null +++ b/backend/app/prompts/hint/novice_case_hint.md @@ -0,0 +1,71 @@ +--- +template_code: novice_case_hint +agent_type: hint +version: v1 +scene: novice +model_type: fast +output_format: json +--- + +# Role + +你是医疗问诊训练系统的新手提示 Agent。你的任务是帮助医学生在当前病例训练中发现问诊缺口、下一步问题和必要检查,而不是替学生完成诊断。 + +# Task + +根据输入的病例信息、当前会话状态、短期对话摘要、已申请检查和最后一句医生问题,生成新手模式下可展示的结构化提示。 + +# Inputs + +你会收到一个 JSON 对象,包含: + +- `case`:病例标题、科室、主诉、关键症状、关键检查和考核要点。 +- `session`:训练模式和当前阶段。 +- `conversation_summary`:当前会话最近问答摘要。 +- `ordered_results`:已经申请的检查/检验及其结果。 +- `last_user_message`:用户最近一次问诊问题。 + +# Rules + +1. 只输出合法 JSON,不输出 Markdown、解释、标题或思考过程。 +2. 提示必须紧扣当前病例,不使用其他病例信息。 +3. 不直接给出最终诊断答案,不代替学生完成治疗方案。 +4. `hints` 用于简短提示当前该注意什么,每条不超过 40 个汉字。 +5. `missing_dimensions` 只写缺失的问诊或临床思维维度。 +6. `next_questions` 必须是学生下一步可以直接询问患者/家属的问题。 +7. `recommended_orders` 只推荐当前病例需要考虑的检查,必须给出 `item_code` 和 `reason`。 +8. 已经在 `ordered_results` 中出现的检查不再推荐。 +9. 检查结果只能引用 `ordered_results` 中已有内容,不能编造新的检查结果。 +10. 本系统用于医学教学训练,不输出真实医疗建议,不替代临床诊疗。 + +# Output Format + +必须输出如下 JSON 结构: + +```json +{ + "hints": [ + "可以继续追问最高体温和退热反应。" + ], + "missing_dimensions": [ + "既往史", + "严重程度评估" + ], + "next_questions": [ + "孩子有没有既往喘息或哮喘史?" + ], + "recommended_orders": [ + { + "item_code": "spo2", + "reason": "用于判断低氧和病情严重程度" + } + ] +} +``` + +# Safety Boundaries + +- 不提供真实临床诊断结论。 +- 不给患者真实用药建议。 +- 不编造病例、检查和检验结果。 +- 不泄露病例标准答案或隐藏信息。 diff --git a/backend/app/prompts/hint/novice_hint.md b/backend/app/prompts/hint/novice_hint.md new file mode 100644 index 0000000..6c01819 --- /dev/null +++ b/backend/app/prompts/hint/novice_hint.md @@ -0,0 +1,36 @@ +--- +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/backend/app/prompts/knowledge/guideline_search_query.md b/backend/app/prompts/knowledge/guideline_search_query.md new file mode 100644 index 0000000..4f8fe96 --- /dev/null +++ b/backend/app/prompts/knowledge/guideline_search_query.md @@ -0,0 +1,37 @@ +--- +template_code: guideline_search_query +agent_type: knowledge +version: v1 +scene: guideline_search +model_type: fast +output_format: json +--- + +# Role + +你是评分参考指南检索 Query Agent。 + +# Task + +根据病例、训练类别、诊断和治疗任务生成知识库检索关键词。 + +# Inputs + +- 病例科室。 +- 主诉和关键症状。 +- 训练类别。 +- 用户提交的诊断和治疗方案。 + +# Rules + +- 关键词必须来自病例和任务本身。 +- 不生成与病例无关的疾病关键词。 +- 控制关键词数量,便于 MySQL 文本检索。 + +# Output Format + +输出合法 JSON:`{"keywords": []}`。 + +# Safety Boundaries + +检索词仅用于教学评分参考,不用于真实临床检索决策。 diff --git a/backend/app/prompts/patient/free_chat.md b/backend/app/prompts/patient/free_chat.md new file mode 100644 index 0000000..e4a873f --- /dev/null +++ b/backend/app/prompts/patient/free_chat.md @@ -0,0 +1,38 @@ +--- +template_code: patient_free_chat +agent_type: patient +version: v1 +scene: free_chat +model_type: fast +output_format: text +--- + +# Role + +你是医疗问诊训练中的 AI 标准化病人或患儿家属。 + +# Task + +基于病例资料回答医生问诊问题,帮助用户完成临床问诊训练。 + +# Inputs + +- 病例主诉、现病史、既往史、个人史、家族史。 +- AI 病人人设和隐藏信息。 +- 当前会话短期 memory。 +- 医生最新问题。 + +# Rules + +- 只回答医生问到的信息。 +- 不主动透露隐藏信息。 +- 不主动给出诊断、治疗方案或评分提示。 +- 不编造病例外检查、检验、影像或生命体征结果。 + +# Output Format + +使用患者或家属口吻输出 1 到 3 句话。 + +# Safety Boundaries + +本输出仅用于医学教学训练,不构成真实医疗建议,不替代临床诊疗。 diff --git a/backend/app/prompts/patient/novice.md b/backend/app/prompts/patient/novice.md new file mode 100644 index 0000000..16a3afd --- /dev/null +++ b/backend/app/prompts/patient/novice.md @@ -0,0 +1,38 @@ +--- +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/backend/app/prompts/patient/practice.md b/backend/app/prompts/patient/practice.md new file mode 100644 index 0000000..d675109 --- /dev/null +++ b/backend/app/prompts/patient/practice.md @@ -0,0 +1,40 @@ +--- +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/backend/app/prompts/patient/teaching.md b/backend/app/prompts/patient/teaching.md new file mode 100644 index 0000000..ef8b0d4 --- /dev/null +++ b/backend/app/prompts/patient/teaching.md @@ -0,0 +1,39 @@ +--- +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/backend/app/prompts/polish/doctor_question_polish.md b/backend/app/prompts/polish/doctor_question_polish.md new file mode 100644 index 0000000..7078a6a --- /dev/null +++ b/backend/app/prompts/polish/doctor_question_polish.md @@ -0,0 +1,36 @@ +--- +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/backend/app/prompts/report/evaluation_report.md b/backend/app/prompts/report/evaluation_report.md new file mode 100644 index 0000000..7eb4d1c --- /dev/null +++ b/backend/app/prompts/report/evaluation_report.md @@ -0,0 +1,38 @@ +--- +template_code: report_evaluation +agent_type: report +version: v1 +scene: evaluation_report +model_type: fast +output_format: json +--- + +# Role + +你是评价报告整理 Agent。 + +# Task + +将 Scoring Agent 的结构化 JSON 整理成前端展示和 PDF 导出的报告结构。 + +# Inputs + +- Scoring Agent JSON。 +- 分数类型。 +- 指南引用。 +- 病例和会话基础信息。 + +# Rules + +- 不重新评分。 +- 不修改总分和维度分。 +- 保留错误分析、改进方案、证据摘要和参考指南来源。 +- 缺失字段必须补为空数组或空字符串。 + +# Output Format + +输出 JSON:`score_type,total_score,dimension_scores,errors,improvement_plan,evidence_summary,guideline_refs,overall_comment` + +# Safety Boundaries + +报告仅用于教学训练反馈,不作为真实医疗评价。 diff --git a/backend/app/prompts/scoring/default_five_point.md b/backend/app/prompts/scoring/default_five_point.md new file mode 100644 index 0000000..c7e672e --- /dev/null +++ b/backend/app/prompts/scoring/default_five_point.md @@ -0,0 +1,40 @@ +--- +template_code: scoring_default_five_point +agent_type: scoring +version: v1 +scene: default_five_point +model_type: reason +output_format: json +--- + +# Role + +你是医疗问诊训练评分专家。 + +# Task + +将医疗问诊训练表现输出为 5 分制结构化评价。 + +# Inputs + +- 用户问诊过程摘要。 +- 检查/检验申请记录。 +- 用户诊断和治疗方案。 +- 病例标准答案。 +- 五分制评分规则。 + +# Rules + +- 必须输出合法 JSON。 +- 总分满分为 5 分。 +- 维度分也使用 5 分制。 +- 不改变病例事实,不输出真实诊疗建议。 + +# Output Format + +JSON 字段: +`score_type,total_score,dimension_scores,errors,improvement_plan,evidence_summary,guideline_refs,overall_comment` + +# Safety Boundaries + +本评分仅用于教学训练,不作为真实医疗决策依据。 diff --git a/backend/app/prompts/scoring/default_percentage.md b/backend/app/prompts/scoring/default_percentage.md new file mode 100644 index 0000000..ca3e957 --- /dev/null +++ b/backend/app/prompts/scoring/default_percentage.md @@ -0,0 +1,42 @@ +--- +template_code: scoring_default_percentage +agent_type: scoring +version: v1 +scene: default_percentage +model_type: reason +output_format: json +--- + +# Role + +你是医疗问诊训练评分专家。 + +# Task + +根据短期 memory、检查申请、诊断提交、治疗提交、病例标准答案、评分规则和指南片段,生成百分制结构化评价。 + +# Inputs + +- 会话短期 memory。 +- 检查/检验申请记录。 +- 用户诊断和治疗方案。 +- 病例标准答案与关键考核点。 +- 评分 rubric。 +- 知识库检索到的评分参考指南。 + +# Rules + +- 必须输出合法 JSON。 +- 不输出 Markdown。 +- 百分制总分满分为 100 分。 +- 评分维度固定为:信息获取、分析推理、处置决策、沟通人文、临床整合。 +- 评价必须基于输入证据,不补造用户没有完成的行为。 + +# Output Format + +JSON 字段: +`score_type,total_score,dimension_scores,errors,improvement_plan,evidence_summary,guideline_refs,overall_comment` + +# Safety Boundaries + +本评分仅用于教学训练反馈,不作为真实医疗质量评价或临床结论。 diff --git a/backend/app/prompts/scoring/pediatrics_pneumonia.md b/backend/app/prompts/scoring/pediatrics_pneumonia.md new file mode 100644 index 0000000..66013c0 --- /dev/null +++ b/backend/app/prompts/scoring/pediatrics_pneumonia.md @@ -0,0 +1,40 @@ +--- +template_code: scoring_pediatrics_pneumonia +agent_type: scoring +version: v1 +scene: pediatrics_pneumonia +model_type: reason +output_format: json +--- + +# Role + +你是儿科支气管肺炎问诊训练评分专家。 + +# Task + +针对儿科支气管肺炎病例,对问诊、检查申请、诊断推理、治疗计划和家属沟通进行评分。 + +# Inputs + +- 患儿病例资料。 +- 用户问诊过程。 +- 检查/检验申请结果。 +- 用户诊断、鉴别诊断和治疗方案。 +- 儿童肺炎诊疗规范片段。 +- 儿科考试评分要求。 + +# Rules + +- 重点关注发热、咳嗽、喘息、呼吸困难、精神反应、进食饮水和尿量。 +- 重点关注既往喘息史、过敏史、疫苗接种史、接触史。 +- 检查评价只基于数据库返回的检查结果。 +- 不把未申请的检查当作用户已完成内容。 + +# Output Format + +输出合法 JSON,字段同默认评分模板。 + +# Safety Boundaries + +本评分仅用于儿科教学训练,不替代真实儿科临床诊疗。 diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py new file mode 100644 index 0000000..487070f --- /dev/null +++ b/backend/app/repositories/__init__.py @@ -0,0 +1 @@ +"""数据访问层:封装 ORM 查询和持久化。""" diff --git a/backend/app/repositories/audit_repository.py b/backend/app/repositories/audit_repository.py new file mode 100644 index 0000000..6282713 --- /dev/null +++ b/backend/app/repositories/audit_repository.py @@ -0,0 +1,16 @@ +from sqlalchemy.orm import Session + +from app.models.audit import AuditLog + + +class AuditRepository: + """审计仓储:负责写入关键业务动作的审计日志。""" + + def __init__(self, db: Session) -> None: + self.db = db + + def create(self, log: AuditLog) -> AuditLog: + """审计写入:保存一条审计日志并刷新主键。""" + self.db.add(log) + self.db.flush() + return log diff --git a/backend/app/repositories/case_repository.py b/backend/app/repositories/case_repository.py new file mode 100644 index 0000000..ca4b553 --- /dev/null +++ b/backend/app/repositories/case_repository.py @@ -0,0 +1,155 @@ +from sqlalchemy import delete, exists, func, or_, select +from sqlalchemy.orm import Session, selectinload + +from app.models.source_case import CaseBase, CaseExamItem, ScoringRule, TeachingCase, TraditionalCase +from app.models.training import SessionOrder, SessionSubmission, TrainingSession +from app.models.training_record import TrainingRecord + + +class CaseRepository: + """病例仓储:基于 case_base 新表体系读取病例、扩展表和检查项目。""" + + 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 读取已发布病例,并按模式匹配传统/教学扩展表。""" + 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 mode == "practice": + stmt = stmt.where(exists().where(TraditionalCase.case_id == CaseBase.id)) + if 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: + """病例详情:读取 case_base,并加载传统病例、教学病例、评分规则和检查项目。""" + stmt = ( + select(CaseBase) + .options( + selectinload(CaseBase.traditional_case), + selectinload(CaseBase.teaching_case), + selectinload(CaseBase.scoring_rules), + selectinload(CaseBase.exam_items), + ) + .where(CaseBase.id == case_id, CaseBase.status == 1, CaseBase.publish_status == 1) + ) + return self.db.scalar(stmt) + + def get_case_by_id(self, case_id: int) -> CaseBase | None: + """病例删除:按主键读取病例,不限制发布状态,用于删除前校验。""" + return self.db.get(CaseBase, case_id) + + def get_delete_preview_counts(self, case_id: int) -> dict[str, int]: + """病例删除预览:统计删除病例会影响的业务数据数量。""" + session_ids = self._session_ids(case_id) + return { + "case_base": self._count(CaseBase, CaseBase.id == case_id), + "traditional_case": self._count(TraditionalCase, TraditionalCase.case_id == case_id), + "teaching_case": self._count(TeachingCase, TeachingCase.case_id == case_id), + "scoring_rule": self._count(ScoringRule, ScoringRule.case_id == case_id), + "case_exam_item": self._count(CaseExamItem, CaseExamItem.case_id == case_id), + "training_session": len(session_ids), + "training_order": self._count_training_orders(case_id, session_ids), + "training_submission": self._count_by_sessions(SessionSubmission, SessionSubmission.session_id, session_ids), + "training_record": self._count_training_records(case_id, session_ids), + } + + def delete_case_cascade(self, case_id: int) -> dict[str, int]: + """病例删除执行:按外键依赖顺序清理训练数据、检查项、评分规则和病例主表。""" + session_ids = self._session_ids(case_id) + deleted: dict[str, int] = {} + deleted["training_order"] = self._delete_training_orders(case_id, session_ids) + deleted["training_submission"] = self._delete_by_sessions( + SessionSubmission, SessionSubmission.session_id, session_ids + ) + deleted["training_record"] = self._delete_training_records(case_id, session_ids) + deleted["training_session"] = self._delete_where(TrainingSession, TrainingSession.case_id == case_id) + deleted["case_exam_item"] = self._delete_where(CaseExamItem, CaseExamItem.case_id == case_id) + deleted["scoring_rule"] = self._delete_where(ScoringRule, ScoringRule.case_id == case_id) + deleted["traditional_case"] = self._delete_where(TraditionalCase, TraditionalCase.case_id == case_id) + deleted["teaching_case"] = self._delete_where(TeachingCase, TeachingCase.case_id == case_id) + deleted["case_base"] = self._delete_where(CaseBase, CaseBase.id == case_id) + return deleted + + def _session_ids(self, case_id: int) -> list[int]: + """病例删除:读取该病例关联的训练会话 ID 集合。""" + stmt = select(TrainingSession.id).where(TrainingSession.case_id == case_id) + return [int(item) for item in self.db.scalars(stmt).all()] + + def _count(self, model: type, *criteria) -> int: + """病例删除预览:按条件统计单表记录数。""" + stmt = select(func.count()).select_from(model).where(*criteria) + return int(self.db.scalar(stmt) or 0) + + def _count_by_sessions(self, model: type, session_column, session_ids: list[int]) -> int: + """病例删除预览:按训练会话集合统计从表记录数。""" + if not session_ids: + return 0 + return self._count(model, session_column.in_(session_ids)) + + def _count_training_orders(self, case_id: int, session_ids: list[int]) -> int: + """病例删除预览:统计检查申请记录,兼容按病例和按会话两种关联。""" + if session_ids: + return self._count(SessionOrder, or_(SessionOrder.case_id == case_id, SessionOrder.session_id.in_(session_ids))) + return self._count(SessionOrder, SessionOrder.case_id == case_id) + + def _count_training_records(self, case_id: int, session_ids: list[int]) -> int: + """病例删除预览:统计完整训练记录,兼容按病例和按会话两种关联。""" + if session_ids: + return self._count( + TrainingRecord, + or_(TrainingRecord.case_id == case_id, TrainingRecord.session_id.in_(session_ids)), + ) + return self._count(TrainingRecord, TrainingRecord.case_id == case_id) + + def _delete_where(self, model: type, *criteria) -> int: + """病例删除执行:按条件删除单表记录并返回影响行数。""" + result = self.db.execute(delete(model).where(*criteria)) + return int(result.rowcount or 0) + + def _delete_by_sessions(self, model: type, session_column, session_ids: list[int]) -> int: + """病例删除执行:按训练会话集合删除从表记录。""" + if not session_ids: + return 0 + return self._delete_where(model, session_column.in_(session_ids)) + + def _delete_training_orders(self, case_id: int, session_ids: list[int]) -> int: + """病例删除执行:删除该病例下所有检查申请记录,避免阻塞检查项删除。""" + if session_ids: + return self._delete_where( + SessionOrder, + or_(SessionOrder.case_id == case_id, SessionOrder.session_id.in_(session_ids)), + ) + return self._delete_where(SessionOrder, SessionOrder.case_id == case_id) + + def _delete_training_records(self, case_id: int, session_ids: list[int]) -> int: + """病例删除执行:删除该病例完整训练后沉淀的评价记录。""" + if session_ids: + return self._delete_where( + TrainingRecord, + or_(TrainingRecord.case_id == case_id, TrainingRecord.session_id.in_(session_ids)), + ) + return self._delete_where(TrainingRecord, TrainingRecord.case_id == case_id) + + def get_exam_items(self, case_id: int) -> list[CaseExamItem]: + """检查项目:读取当前病例下全部可申请检查检验项目。""" + stmt = select(CaseExamItem).where(CaseExamItem.case_id == case_id).order_by(CaseExamItem.display_order) + return list(self.db.scalars(stmt).all()) + + def get_exam_item(self, case_id: int, item_code: str) -> CaseExamItem | None: + """检查结果:按病例和项目编码读取固定检查检验结果。""" + stmt = select(CaseExamItem).where(CaseExamItem.case_id == case_id, CaseExamItem.item_code == item_code) + return self.db.scalar(stmt) diff --git a/backend/app/repositories/evaluation_repository.py b/backend/app/repositories/evaluation_repository.py new file mode 100644 index 0000000..8206002 --- /dev/null +++ b/backend/app/repositories/evaluation_repository.py @@ -0,0 +1,46 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.training_record import TrainingRecord + + +class EvaluationRepository: + """评价仓储:负责完整训练结束后的 training_record 读写。""" + + def __init__(self, db: Session) -> None: + self.db = db + + def create_record(self, record: TrainingRecord) -> TrainingRecord: + """评价保存:把 AI 评分报告保存为训练记录。""" + self.db.add(record) + self.db.flush() + return record + + def get_by_session(self, session_id: int, user_id: str) -> TrainingRecord | None: + """评价读取:按会话 ID 和外部 user_id 查询训练记录。""" + stmt = select(TrainingRecord).where( + TrainingRecord.session_id == session_id, + TrainingRecord.external_user_id == user_id, + ) + return self.db.scalar(stmt) + + def get_owned_record(self, evaluation_id: int, user_id: str) -> TrainingRecord | None: + """评价归属校验:按训练记录 ID 和外部 user_id 查询记录。""" + stmt = select(TrainingRecord).where( + TrainingRecord.id == evaluation_id, + TrainingRecord.external_user_id == user_id, + ) + return self.db.scalar(stmt) + + def list_by_user(self, user_id: str) -> list[TrainingRecord]: + """历史评价:按外部 user_id 查询完整训练后的评价记录。""" + stmt = ( + select(TrainingRecord) + .where(TrainingRecord.external_user_id == user_id) + .order_by(TrainingRecord.created_at.desc()) + ) + return list(self.db.scalars(stmt).all()) + + def flush(self) -> None: + """记录更新:刷新 PDF 路径等派生字段。""" + self.db.flush() diff --git a/backend/app/repositories/knowledge_repository.py b/backend/app/repositories/knowledge_repository.py new file mode 100644 index 0000000..94eb3af --- /dev/null +++ b/backend/app/repositories/knowledge_repository.py @@ -0,0 +1,34 @@ +from sqlalchemy import or_, select +from sqlalchemy.orm import Session, selectinload + +from app.models.knowledge import KnowledgeChunk + + +class KnowledgeRepository: + """知识库仓储:负责评分参考指南的轻量检索。""" + + def __init__(self, db: Session) -> None: + self.db = db + + def search_chunks( + self, + department_id: int, + task_type: str, + keywords: list[str], + limit: int = 5, + ) -> list[KnowledgeChunk]: + """知识检索:按科室、任务类型和关键词检索知识片段。""" + stmt = ( + select(KnowledgeChunk) + .options(selectinload(KnowledgeChunk.document)) + .where(KnowledgeChunk.is_active.is_(True)) + .where(or_(KnowledgeChunk.department_id == department_id, KnowledgeChunk.department_id.is_(None))) + .where(or_(KnowledgeChunk.task_type == task_type, KnowledgeChunk.task_type.is_(None))) + ) + + keyword_clauses = [KnowledgeChunk.chunk_text.contains(keyword) for keyword in keywords if keyword] + if keyword_clauses: + stmt = stmt.where(or_(*keyword_clauses)) + + stmt = stmt.order_by(KnowledgeChunk.weight.desc(), KnowledgeChunk.id.asc()).limit(limit) + return list(self.db.scalars(stmt).all()) diff --git a/backend/app/repositories/profile_repository.py b/backend/app/repositories/profile_repository.py new file mode 100644 index 0000000..862a7d7 --- /dev/null +++ b/backend/app/repositories/profile_repository.py @@ -0,0 +1,25 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.user import UserLearningProfile + + +class UserLearningProfileRepository: + """学习档案仓储:维护用户训练评价聚合数据。""" + + def __init__(self, db: Session) -> None: + self.db = db + + def get_profile(self, user_id: str, tenant_id: str | None) -> UserLearningProfile | None: + """档案读取:按 user_id 和 tenant_id 获取学习档案。""" + stmt = select(UserLearningProfile).where( + UserLearningProfile.user_id == user_id, + UserLearningProfile.tenant_id == tenant_id, + ) + return self.db.scalar(stmt) + + def save(self, profile: UserLearningProfile) -> UserLearningProfile: + """档案保存:创建或更新用户学习档案。""" + self.db.add(profile) + self.db.flush() + return profile diff --git a/backend/app/repositories/session_repository.py b/backend/app/repositories/session_repository.py new file mode 100644 index 0000000..5dfbe93 --- /dev/null +++ b/backend/app/repositories/session_repository.py @@ -0,0 +1,67 @@ +from datetime import datetime + +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload + +from app.models.training import SessionOrder, SessionSubmission, TrainingSession + + +class SessionRepository: + """会话仓储:负责训练会话、检查申请和诊断治疗提交数据。""" + + def __init__(self, db: Session) -> None: + self.db = db + + def create_session(self, session: TrainingSession) -> TrainingSession: + """会话创建:保存训练会话主记录。""" + self.db.add(session) + self.db.flush() + return session + + def get_owned_session(self, session_id: int, user_id: str) -> TrainingSession | None: + """会话归属校验:根据 session_id 和 user_id 查询会话。""" + stmt = ( + select(TrainingSession) + .options( + selectinload(TrainingSession.case), + selectinload(TrainingSession.orders), + selectinload(TrainingSession.submission), + ) + .where(TrainingSession.id == session_id, TrainingSession.user_id == user_id) + ) + return self.db.scalar(stmt) + + def update_status(self, session: TrainingSession, status: str) -> TrainingSession: + """状态流转:更新训练会话阶段状态。""" + session.status = status + if status == "diagnosis": + session.inquiry_completed_at = datetime.utcnow() + if status == "completed": + session.completed_at = datetime.utcnow() + self.db.flush() + return session + + def create_order(self, order: SessionOrder) -> SessionOrder: + """检查申请保存:保存用户申请过的检查检验结果。""" + self.db.add(order) + self.db.flush() + return order + + def get_order_by_item(self, session_id: int, item_code: str) -> SessionOrder | None: + """检查申请读取:按会话和检查编码获取已申请结果,用于幂等返回。""" + stmt = select(SessionOrder).where( + SessionOrder.session_id == session_id, + SessionOrder.item_code == item_code, + ) + return self.db.scalar(stmt) + + def get_submission(self, session_id: int) -> SessionSubmission | None: + """提交读取:获取当前会话的诊断治疗提交记录。""" + stmt = select(SessionSubmission).where(SessionSubmission.session_id == session_id) + return self.db.scalar(stmt) + + def upsert_submission(self, submission: SessionSubmission) -> SessionSubmission: + """诊断治疗保存:创建或更新当前会话的提交记录。""" + self.db.add(submission) + self.db.flush() + return submission diff --git a/backend/app/repositories/source_case_repository.py b/backend/app/repositories/source_case_repository.py new file mode 100644 index 0000000..f8ea80f --- /dev/null +++ b/backend/app/repositories/source_case_repository.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from sqlalchemy import exists, select +from sqlalchemy.orm import Session, selectinload + +from app.models.department import Department +from app.models.source_case import CaseBase, ScoringRule, TeachingCase, TraditionalCase + + +class SourceCaseRepository: + """源库病例仓储:读取 case_base、traditional_case、teaching_case 和 scoring_rule。""" + + def __init__(self, db: Session) -> None: + self.db = db + + def list_active_cases( + self, + department_id: int | None = None, + case_type: str | None = None, + mode: str | None = None, + ) -> list[CaseBase]: + """源库病例列表:按科室、病例分类和训练模式读取已发布病例。""" + 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 case_type: + stmt = stmt.where(CaseBase.case_type == case_type) + normalized_mode = self.normalize_mode(mode) + 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_base(self, case_id: int) -> CaseBase | None: + """源库病例详情:读取病例主表及传统/教学扩展表。""" + stmt = ( + select(CaseBase) + .options( + selectinload(CaseBase.traditional_case), + selectinload(CaseBase.teaching_case), + selectinload(CaseBase.scoring_rules), + ) + .where(CaseBase.id == case_id, CaseBase.status == 1, CaseBase.publish_status == 1) + ) + return self.db.scalar(stmt) + + def get_department_name(self, department_id: int | None) -> str: + """科室名称:兼容当前 demo 的 departments 表,源库无科室表时返回空字符串。""" + if not department_id: + return "" + department = self.db.scalar(select(Department).where(Department.id == department_id)) + return department.name if department else "" + + def get_scoring_rules(self, case_id: int) -> list[ScoringRule]: + """评分规则:读取当前病例对应的基础评分细则。""" + stmt = select(ScoringRule).where(ScoringRule.case_id == case_id).order_by(ScoringRule.id) + return list(self.db.scalars(stmt).all()) + + @staticmethod + def normalize_mode(mode: str | None) -> str | None: + """模式归一:旧 novice 请求按练习模式处理,第一版只暴露 practice/teaching。""" + if mode == "novice": + return "practice" + return mode diff --git a/backend/app/repositories/training_record_repository.py b/backend/app/repositories/training_record_repository.py new file mode 100644 index 0000000..eaac67b --- /dev/null +++ b/backend/app/repositories/training_record_repository.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.training_record import TrainingRecord + + +class TrainingRecordRepository: + """训练记录仓储:完整训练结束后写入 training_record,未完成会话不沉淀长期记录。""" + + def __init__(self, db: Session) -> None: + self.db = db + + def get_by_session(self, session_id: int) -> TrainingRecord | None: + """训练记录读取:按 session_id 保证评价接口重复调用时幂等。""" + stmt = select(TrainingRecord).where(TrainingRecord.session_id == session_id) + return self.db.scalar(stmt) + + def create_record(self, record: TrainingRecord) -> TrainingRecord: + """训练记录保存:写入源库兼容训练记录表。""" + self.db.add(record) + self.db.flush() + return record diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..51a01e5 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1 @@ +"""Pydantic 入参和出参模型。""" diff --git a/backend/app/schemas/agent.py b/backend/app/schemas/agent.py new file mode 100644 index 0000000..8a9ec57 --- /dev/null +++ b/backend/app/schemas/agent.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel + + +class AgentHelloUser(BaseModel): + """Hello 用户信息:返回宿主系统传入的上下文。""" + + user_id: str + tenant_id: str | None = None + role: str | None = None + + +class AgentHelloResponse(BaseModel): + """Hello 响应:返回用户上下文和 Demo 能力开关。""" + + user: AgentHelloUser + features: dict diff --git a/backend/app/schemas/case.py b/backend/app/schemas/case.py new file mode 100644 index 0000000..40e1780 --- /dev/null +++ b/backend/app/schemas/case.py @@ -0,0 +1,76 @@ +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] + + +class CaseDeletePreviewResponse(BaseModel): + """病例删除预览:返回删除该病例会影响的业务数据数量。""" + + case_id: int + case_title: str + can_delete: bool + affected: dict[str, int] + + +class CaseDeleteRequest(BaseModel): + """病例删除请求:前端必须显式确认,并默认同时删除该病例训练数据。""" + + confirm: bool = False + delete_training_data: bool = True + + +class CaseDeleteResponse(BaseModel): + """病例删除结果:返回已删除的各表记录数量。""" + + deleted: bool + case_id: int + deleted_counts: dict[str, int] diff --git a/backend/app/schemas/evaluation.py b/backend/app/schemas/evaluation.py new file mode 100644 index 0000000..cb61bf7 --- /dev/null +++ b/backend/app/schemas/evaluation.py @@ -0,0 +1,69 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class CreateEvaluationRequest(BaseModel): + """评价生成入参:指定输出分数类型。""" + + score_type: str = Field(default="percentage", pattern="^(percentage|five_point)$") + + +class DimensionScore(BaseModel): + """维度评分:保存单个评分维度的分数、满分和评价。""" + + dimension: str + score: float + max_score: float + comment: str + evidence: list[str] = Field(default_factory=list) + deductions: list[str] = Field(default_factory=list) + improvement: str = "" + + +class EvaluationResponse(BaseModel): + """评价报告响应:返回结构化 AI 评价报告。""" + + evaluation_id: int + score_type: str + total_score: float + dimension_scores: list[DimensionScore] + errors: list[dict] + improvement_plan: list[str] + evidence_summary: list[str] + guideline_refs: list[dict] + overall_comment: str + + +class EvaluationListItem(BaseModel): + """历史评价列表项:按 user_id 查询完整训练后的评价记录。""" + + evaluation_id: int + case_title: str + score_type: str + total_score: float + created_at: datetime + pdf_exported: bool + + +class EvaluationListResponse(BaseModel): + """历史评价列表响应。""" + + items: list[EvaluationListItem] + + +class ExportPdfResponse(BaseModel): + """PDF 导出响应:返回导出记录和本地文件路径。""" + + export_id: int + file_path: str + + +class EvaluationDetailResponse(EvaluationResponse): + """评价详情响应:在报告详情页使用。""" + + session_id: int + case_id: int + case_title: str + created_at: datetime + pdf_file_path: str | None = None diff --git a/backend/app/schemas/imports.py b/backend/app/schemas/imports.py new file mode 100644 index 0000000..ac39490 --- /dev/null +++ b/backend/app/schemas/imports.py @@ -0,0 +1,36 @@ +from pydantic import BaseModel, Field + + +class CaseSqlPreviewCase(BaseModel): + """病例 SQL 预览病例:展示导入文件中识别到的病例摘要。""" + + id: int + title: str + case_type: str + difficulty: str + + +class CaseSqlImportPreviewResponse(BaseModel): + """病例 SQL 预检响应:只展示解析结果,不写入数据库。""" + + file_name: str + encoding: str | None = None + tables: dict[str, int] = Field(default_factory=dict) + can_import: bool = False + warnings: list[str] = Field(default_factory=list) + errors: list[str] = Field(default_factory=list) + preview_cases: list[CaseSqlPreviewCase] = Field(default_factory=list) + + +class CaseSqlImportApplyResponse(BaseModel): + """病例 SQL 导入响应:展示实际写库结果。""" + + imported: bool + file_name: str + encoding: str + inserted_or_updated_cases: int + imported_traditional_cases: int + imported_teaching_cases: int + imported_scoring_rules: int + generated_exam_items: int + warnings: list[str] = Field(default_factory=list) diff --git a/backend/app/schemas/knowledge.py b/backend/app/schemas/knowledge.py new file mode 100644 index 0000000..2694761 --- /dev/null +++ b/backend/app/schemas/knowledge.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + + +class KnowledgeSearchResponse(BaseModel): + """知识检索响应:返回评分参考指南片段和来源。""" + + matched_chunks: list[dict] + source_refs: list[dict] + no_match: bool diff --git a/backend/app/schemas/llm.py b/backend/app/schemas/llm.py new file mode 100644 index 0000000..6eee003 --- /dev/null +++ b/backend/app/schemas/llm.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel + + +class LLMTestRequest(BaseModel): + """LLM 测试入参:用于快速模型和 reason 模型耗时验证。""" + + message: str = "请用一句话说明医疗问诊训练 Demo 的用途。" + + +class LLMTestResponse(BaseModel): + """LLM 测试响应:返回模型名、首 token 时间和总耗时。""" + + model: str + first_token_ms: int | None = None + total_latency_ms: int + stream: bool + mock_mode: bool = False + fallback_used: bool = False + thinking_enabled: bool | None = None + reasoning_effort: str | None = None diff --git a/backend/app/schemas/session.py b/backend/app/schemas/session.py new file mode 100644 index 0000000..fbb58a7 --- /dev/null +++ b/backend/app/schemas/session.py @@ -0,0 +1,127 @@ +from pydantic import BaseModel, Field, field_validator + + +class CreateSessionRequest(BaseModel): + """创建会话入参:选择病例、训练类别、模式和分数类型。""" + + case_id: int + training_type: str = Field(pattern="^(case_analysis|diagnosis_treatment|consultation)$") + mode: str = Field(pattern="^(novice|practice|teaching)$") + score_type: str = Field(default="percentage", pattern="^(percentage|five_point)$") + + @field_validator("mode") + @classmethod + def normalize_mode(cls, value: str) -> str: + """训练模式:兼容旧 novice 请求,实际按 practice 练习模式处理。""" + return "practice" if value == "novice" else value + + +class CreateSessionResponse(BaseModel): + """创建会话响应:返回会话标识和 AI 病人开场白。""" + + session_id: int + session_code: str + status: str + patient_opening: str + + +class ChatRequest(BaseModel): + """问诊消息入参:医生向 AI 病人发送的自然语言问题。""" + + 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): + """可申请检查项:只返回名称和类型,不返回结果。""" + + item_code: str + item_name: str + item_type: str + + +class OrderItemsResponse(BaseModel): + """可申请检查项列表响应。""" + + items: list[OrderItemResponse] + + +class CreateOrderRequest(BaseModel): + """检查申请入参:指定当前病例下的检查项目编码。""" + + item_code: str = Field(min_length=1, max_length=64) + + +class CreateOrderResponse(BaseModel): + """检查申请响应:返回数据库预设的结构化检查结果。""" + + item_code: str + item_name: str + item_type: str + result_text: str + result_structured: dict | None = None + is_key: bool + is_abnormal: bool + context_written: bool = True + already_ordered: bool = False + + +class SessionStatusResponse(BaseModel): + """会话状态响应:用于阶段流转接口。""" + + session_id: int + status: str + + +class SubmitDiagnosisRequest(BaseModel): + """诊断提交入参:保存主要诊断、鉴别诊断和诊断依据。""" + + primary_diagnosis: str = Field(min_length=1, max_length=2000) + differential_diagnoses: list[str] = Field(default_factory=list) + diagnosis_basis: str = Field(min_length=1, max_length=5000) + + +class SubmitDiagnosisResponse(BaseModel): + """诊断提交响应:进入治疗阶段。""" + + status: str + + +class SubmitTreatmentRequest(BaseModel): + """治疗方案入参:保存治疗原则、措施、风险预案、沟通和随访。""" + + treatment_principle: str = Field(min_length=1, max_length=3000) + treatment_measures: str = Field(min_length=1, max_length=5000) + risk_plan: str | None = Field(default=None, max_length=3000) + communication: str | None = Field(default=None, max_length=3000) + follow_up: str | None = Field(default=None, max_length=3000) + + +class SubmitTreatmentResponse(BaseModel): + """治疗提交响应:进入评价阶段。""" + + status: str + + +class HintRequest(BaseModel): + """会话提示入参:基于当前会话上下文生成新手模式提醒。""" + + last_user_message: str | None = Field(default=None, max_length=2000) + scope: str = Field(default="current_conversation", pattern="^current_conversation$") + + +class HintResponse(BaseModel): + """会话提示响应:返回缺失维度、下一步问题和轻量训练提示。""" + + hints: list[str] + missing_dimensions: list[str] + next_questions: list[str] + recommended_orders: list[dict] = Field(default_factory=list) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..92281b8 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +"""业务服务层:组合仓储、Agent 和外部能力。""" diff --git a/backend/app/services/audit_service.py b/backend/app/services/audit_service.py new file mode 100644 index 0000000..9a9f54f --- /dev/null +++ b/backend/app/services/audit_service.py @@ -0,0 +1,38 @@ +from sqlalchemy.orm import Session + +from app.core.context import UserContext +from app.models.audit import AuditLog +from app.repositories.audit_repository import AuditRepository + + +class AuditService: + """审计服务:统一记录关键接口调用和资源访问。""" + + def __init__(self, db: Session) -> None: + self.db = db + self.repo = AuditRepository(db) + + def log( + self, + ctx: UserContext, + action: str, + resource_type: str, + resource_id: str | None = None, + session_id: int | None = None, + metadata: dict | None = None, + ) -> None: + """审计写入:保存用户、动作、资源和请求元数据。""" + self.repo.create( + AuditLog( + user_id=ctx.user_id, + tenant_id=ctx.tenant_id, + session_id=session_id, + action=action, + resource_type=resource_type, + resource_id=resource_id, + request_id=ctx.request_id, + ip_address=ctx.ip_address, + user_agent=ctx.user_agent, + metadata_=metadata, + ) + ) diff --git a/backend/app/services/case_service.py b/backend/app/services/case_service.py new file mode 100644 index 0000000..f330d49 --- /dev/null +++ b/backend/app/services/case_service.py @@ -0,0 +1,140 @@ +from sqlalchemy.orm import Session + +from app.core.context import UserContext +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 ( + CaseDeletePreviewResponse, + CaseDeleteRequest, + CaseDeleteResponse, + CaseDetailResponse, + CaseListItem, + CaseListResponse, + CasePatientInfo, +) +from app.services.audit_service import AuditService + + +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 get_delete_preview(self, case_id: int) -> CaseDeletePreviewResponse: + """病例删除预览:返回删除病例前端需要展示的影响范围。""" + case = self.repo.get_case_by_id(case_id) + if not case: + raise AppError("CASE_NOT_FOUND", "case not found", 404) + return CaseDeletePreviewResponse( + case_id=case.id, + case_title=case.title, + can_delete=True, + affected=self.repo.get_delete_preview_counts(case.id), + ) + + def delete_case(self, case_id: int, payload: CaseDeleteRequest, ctx: UserContext) -> CaseDeleteResponse: + """病例删除:级联删除病例业务数据,并保留审计日志用于追踪操作。""" + case = self.repo.get_case_by_id(case_id) + if not case: + raise AppError("CASE_NOT_FOUND", "case not found", 404) + if not payload.confirm: + raise AppError("CASE_DELETE_CONFIRM_REQUIRED", "case delete confirmation is required", 400) + + preview = self.repo.get_delete_preview_counts(case_id) + training_rows = ( + preview.get("training_session", 0) + + preview.get("training_order", 0) + + preview.get("training_submission", 0) + + preview.get("training_record", 0) + ) + if training_rows and not payload.delete_training_data: + raise AppError("CASE_DELETE_TRAINING_DATA_EXISTS", "case has training data; delete_training_data must be true", 400) + + try: + deleted_counts = self.repo.delete_case_cascade(case_id) + AuditService(self.db).log( + ctx, + action="case.delete", + resource_type="case", + resource_id=str(case_id), + metadata={"case_title": case.title, "deleted_counts": deleted_counts}, + ) + self.db.commit() + except Exception: + self.db.rollback() + raise + return CaseDeleteResponse(deleted=True, case_id=case_id, deleted_counts=deleted_counts) + + 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/backend/app/services/case_sql_import_service.py b/backend/app/services/case_sql_import_service.py new file mode 100644 index 0000000..af22bb0 --- /dev/null +++ b/backend/app/services/case_sql_import_service.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import tempfile +from pathlib import Path + +from fastapi import UploadFile + +from app.core.exceptions import AppError +from app.schemas.imports import CaseSqlImportApplyResponse, CaseSqlImportPreviewResponse, CaseSqlPreviewCase +from scripts.import_source_case_sql import ImportValidationError, import_source_sql, parse_source_dump + + +MAX_SQL_UPLOAD_BYTES = 5 * 1024 * 1024 +ALLOWED_TABLES = {"case_base", "traditional_case", "teaching_case", "scoring_rule"} + + +class CaseSqlImportService: + """病例 SQL 导入服务:解析接口 SQL 文件并安全映射到当前病例表。""" + + async def preview(self, file: UploadFile) -> CaseSqlImportPreviewResponse: + """导入预检:上传 SQL 后只解析结构和数据,不写入数据库。""" + temp_path = await self._save_upload_to_temp(file) + try: + parsed, warnings, encoding = parse_source_dump(temp_path) + table_rows = self._allowed_table_counts(parsed) + preview_cases = [ + CaseSqlPreviewCase( + id=int(row["id"]), + title=str(row.get("title") or ""), + case_type=str(row.get("case_type") or ""), + difficulty=str(row.get("difficulty") or ""), + ) + for row in parsed.get("case_base", []) + ] + return CaseSqlImportPreviewResponse( + file_name=file.filename or "case.sql", + encoding=encoding, + tables=table_rows, + can_import=True, + warnings=warnings, + errors=[], + preview_cases=preview_cases, + ) + except ImportValidationError as exc: + return CaseSqlImportPreviewResponse( + file_name=file.filename or "case.sql", + can_import=False, + errors=[str(exc)], + ) + finally: + self._remove_temp_file(temp_path) + + async def apply(self, file: UploadFile) -> CaseSqlImportApplyResponse: + """确认导入:解析通过后以事务方式写入 case_base/traditional_case/teaching_case/scoring_rule。""" + temp_path = await self._save_upload_to_temp(file) + try: + report = import_source_sql(temp_path, apply=True) + return CaseSqlImportApplyResponse( + imported=True, + file_name=file.filename or "case.sql", + encoding=report.encoding, + inserted_or_updated_cases=report.upserted_cases, + imported_traditional_cases=report.upserted_traditional_cases, + imported_teaching_cases=report.upserted_teaching_cases, + imported_scoring_rules=report.replaced_scoring_rules, + generated_exam_items=report.generated_exam_items, + warnings=report.warnings, + ) + except ImportValidationError as exc: + raise AppError("CASE_SQL_IMPORT_INVALID", str(exc), 400) from exc + finally: + self._remove_temp_file(temp_path) + + async def _save_upload_to_temp(self, file: UploadFile) -> Path: + """上传落盘:校验 SQL 后缀和大小后保存到临时文件,供解析器读取。""" + filename = file.filename or "" + if not filename.lower().endswith(".sql"): + raise AppError("CASE_SQL_FILE_INVALID", "only .sql files are supported", 400) + content = await file.read() + if not content: + raise AppError("CASE_SQL_FILE_EMPTY", "uploaded SQL file is empty", 400) + if len(content) > MAX_SQL_UPLOAD_BYTES: + raise AppError("CASE_SQL_FILE_TOO_LARGE", "SQL file is larger than 5MB", 400) + + handle = tempfile.NamedTemporaryFile(delete=False, suffix=".sql") + try: + handle.write(content) + return Path(handle.name) + finally: + handle.close() + + def _allowed_table_counts(self, parsed: dict[str, list[dict]]) -> dict[str, int]: + """表计数:只暴露当前业务允许导入的病例表。""" + return {table: len(rows) for table, rows in parsed.items() if table in ALLOWED_TABLES} + + def _remove_temp_file(self, path: Path) -> None: + """临时文件清理:解析或导入完成后删除上传副本。""" + try: + path.unlink(missing_ok=True) + except OSError: + pass diff --git a/backend/app/services/evaluation_service.py b/backend/app/services/evaluation_service.py new file mode 100644 index 0000000..f8968c9 --- /dev/null +++ b/backend/app/services/evaluation_service.py @@ -0,0 +1,256 @@ +import json +from datetime import datetime + +from sqlalchemy.orm import Session + +from app.agents.orchestrator import MedicalConsultationOrchestrator +from app.core.context import UserContext +from app.core.exceptions import AppError +from app.models.training_record import TrainingRecord +from app.models.user import UserLearningProfile +from app.repositories.case_repository import CaseRepository +from app.repositories.evaluation_repository import EvaluationRepository +from app.repositories.profile_repository import UserLearningProfileRepository +from app.repositories.session_repository import SessionRepository +from app.repositories.source_case_repository import SourceCaseRepository +from app.schemas.evaluation import ( + CreateEvaluationRequest, + DimensionScore, + EvaluationDetailResponse, + EvaluationListItem, + EvaluationListResponse, + EvaluationResponse, +) +from app.services.audit_service import AuditService +from app.services.knowledge_service import KnowledgeService +from app.services.runtime_memory import runtime_memory + + +class EvaluationService: + """评价服务:基于新源库表和 training_record 完成评分、历史和学习档案更新。""" + + def __init__(self, db: Session) -> None: + self.db = db + self.session_repo = SessionRepository(db) + self.case_repo = CaseRepository(db) + self.eval_repo = EvaluationRepository(db) + self.source_repo = SourceCaseRepository(db) + self.profile_repo = UserLearningProfileRepository(db) + self.knowledge = KnowledgeService(db) + self.audit = AuditService(db) + self.orchestrator = MedicalConsultationOrchestrator() + + async def create_evaluation(self, ctx: UserContext, session_id: int, payload: CreateEvaluationRequest) -> EvaluationResponse: + """评价生成:读取会话短期 memory、提交内容、评分规则和指南后写入 training_record。""" + session = self.session_repo.get_owned_session(session_id, ctx.user_id) + if not session: + raise AppError("SESSION_NOT_FOUND", "session not found or not owned by current user", 404) + if session.status not in {"evaluating", "completed"}: + raise AppError("SESSION_STATUS_INVALID", "evaluation requires treatment submission", 400) + + existed = self.eval_repo.get_by_session(session.id, ctx.user_id) + if existed: + return self._to_response(existed) + + case = self.case_repo.get_active_case(session.case_id) + if not case: + raise AppError("CASE_NOT_FOUND", "case not found or inactive", 404) + submission = self.session_repo.get_submission(session.id) + if not submission or not submission.treatment_submitted_at: + raise AppError("TREATMENT_REQUIRED", "treatment submission is required", 400) + + session.score_type = payload.score_type + memory_messages = runtime_memory.get_messages(session.memory_key) + keyword_seed = (case.key_symptoms or []) + (case.key_exams or []) + [case.diagnosis_primary or ""] + guideline_result = self.knowledge.search_guidelines(case.department_id, session.training_type, keyword_seed) + guideline_refs = guideline_result["source_refs"] + scoring_rules = self.source_repo.get_scoring_rules(case.id) + + report = await self.orchestrator.evaluate( + session=session, + case=case, + memory_messages=memory_messages, + orders=session.orders, + submission=submission, + rubric=None, + guideline_refs=guideline_refs, + scoring_rules=scoring_rules, + ) + + record = self._build_training_record(ctx, session, case, submission, report, scoring_rules, guideline_result) + self.eval_repo.create_record(record) + self.session_repo.update_status(session, "completed") + runtime_memory.release(session.memory_key) + self._update_learning_profile(ctx, record) + self.audit.log(ctx, "evaluation.generate", "training_record", str(record.id), session.id) + return self._to_response(record) + + def _build_training_record( + self, + ctx: UserContext, + session, + case, + submission, + report: dict, + scoring_rules: list, + guideline_result: dict, + ) -> TrainingRecord: + """训练记录写入:完整流程结束后把评分结果沉淀到 training_record。""" + end_time = datetime.utcnow() + start_time = session.started_at or session.created_at or end_time + duration_seconds = int((end_time - start_time).total_seconds()) if start_time else None + total_score = float(report.get("total_score") or 0) + structured = { + "score_type": report.get("score_type", session.score_type), + "total_score": total_score, + "dimension_scores": report.get("dimension_scores") or [], + "errors": report.get("errors") or [], + "improvement_plan": report.get("improvement_plan") or [], + "evidence_summary": report.get("evidence_summary") or [], + "guideline_refs": report.get("guideline_refs") or [], + "overall_comment": report.get("overall_comment") or "", + "llm_model": report.get("_llm_model"), + "latency_metrics": report.get("_latency_metrics") or {}, + } + return TrainingRecord( + training_mode=session.mode, + case_type=session.training_type, + start_time=start_time, + end_time=end_time, + duration_seconds=duration_seconds, + total_score=total_score, + ai_score=total_score, + teacher_score=None, + evaluation_level=self._evaluation_level(total_score, report.get("score_type", session.score_type)), + status="completed", + feedback=structured["overall_comment"], + thinking_chain=json.dumps( + { + "evidence_summary": structured["evidence_summary"], + "guideline_refs": structured["guideline_refs"], + "scoring_rule_count": len(scoring_rules), + }, + ensure_ascii=False, + ), + diagnosis_path=json.dumps( + { + "primary_diagnosis": submission.primary_diagnosis, + "differential_diagnoses": submission.differential_diagnoses or [], + "diagnosis_basis": submission.diagnosis_basis, + "standard_diagnosis": case.diagnosis_primary, + }, + ensure_ascii=False, + ), + wrong_points=structured["errors"], + missed_questions=[], + recommendation_result={"improvement_plan": structured["improvement_plan"]}, + ai_feedback_structured=structured, + osce_station_score={}, + interruption_count=0, + emotion_analysis={}, + prompt_version="v1", + rag_context_version=self._rag_context_version(guideline_result), + case_id=case.id, + teacher_id=None, + user_id=self._numeric_user_id(ctx.user_id), + external_user_id=ctx.user_id, + session_id=session.id, + evaluation_record_id=None, + score_type=structured["score_type"], + pdf_file_path=None, + ) + + def _evaluation_level(self, score: float, score_type: str) -> str: + """评价等级:根据百分制或五分制总分生成训练记录等级。""" + normalized = score * 20 if score_type == "five_point" else score + if normalized >= 90: + return "excellent" + if normalized >= 80: + return "good" + if normalized >= 60: + return "pass" + return "needs_improvement" + + def _rag_context_version(self, guideline_result: dict) -> str: + """RAG 版本:记录评分时是否命中指南片段。""" + matched = guideline_result.get("matched_chunks") or [] + return f"knowledge_chunks:{len(matched)}" if matched else "none" + + def _numeric_user_id(self, user_id: str) -> int | None: + """用户 ID 兼容:宿主传字符串 user_id 时写入 external_user_id,数字 ID 同步写入 user_id。""" + return int(user_id) if str(user_id).isdigit() else None + + def list_history(self, user_id: str) -> EvaluationListResponse: + """历史评价:按外部 user_id 查询完整训练后的 training_record。""" + records = self.eval_repo.list_by_user(user_id) + return EvaluationListResponse( + items=[ + EvaluationListItem( + evaluation_id=record.id, + case_title=self._case_title(record.case_id), + score_type=record.score_type, + total_score=float(record.total_score or 0), + created_at=record.created_at, + pdf_exported=bool(record.pdf_file_path), + ) + for record in records + ] + ) + + def get_detail(self, evaluation_id: int, user_id: str) -> EvaluationDetailResponse: + """评价详情:按 user_id 校验归属并返回完整报告。""" + record = self.eval_repo.get_owned_record(evaluation_id, user_id) + if not record: + raise AppError("EVALUATION_NOT_FOUND", "evaluation not found or not owned by current user", 404) + base = self._to_response(record) + return EvaluationDetailResponse( + **base.model_dump(), + session_id=record.session_id or 0, + case_id=record.case_id, + case_title=self._case_title(record.case_id), + created_at=record.created_at, + pdf_file_path=record.pdf_file_path, + ) + + def _to_response(self, record: TrainingRecord) -> EvaluationResponse: + """评价转换:把 training_record 转换为接口响应结构。""" + structured = record.ai_feedback_structured or {} + dimension_scores = structured.get("dimension_scores") or [] + return EvaluationResponse( + evaluation_id=record.id, + score_type=record.score_type, + total_score=float(record.total_score or structured.get("total_score") or 0), + dimension_scores=[DimensionScore(**item) for item in dimension_scores], + errors=structured.get("errors") or record.wrong_points or [], + improvement_plan=structured.get("improvement_plan") or (record.recommendation_result or {}).get("improvement_plan") or [], + evidence_summary=structured.get("evidence_summary") or [], + guideline_refs=structured.get("guideline_refs") or [], + overall_comment=structured.get("overall_comment") or record.feedback or "", + ) + + def _update_learning_profile(self, ctx: UserContext, record: TrainingRecord) -> None: + """学习档案:根据完整训练记录更新用户平均分和薄弱维度。""" + profile = self.profile_repo.get_profile(ctx.user_id, ctx.tenant_id) + if not profile: + profile = UserLearningProfile(user_id=ctx.user_id, tenant_id=ctx.tenant_id) + + records = self.eval_repo.list_by_user(ctx.user_id) + percentage_scores = [float(item.total_score or 0) for item in records if item.score_type == "percentage"] + five_point_scores = [float(item.total_score or 0) for item in records if item.score_type == "five_point"] + dimensions = (record.ai_feedback_structured or {}).get("dimension_scores") or [] + weak_dimensions = sorted(dimensions, key=lambda item: float(item.get("score", 0)))[:2] + + profile.total_evaluations = len(records) + profile.avg_score_percentage = round(sum(percentage_scores) / len(percentage_scores), 2) if percentage_scores else None + profile.avg_score_five_point = round(sum(five_point_scores) / len(five_point_scores), 2) if five_point_scores else None + profile.weak_dimensions = weak_dimensions + profile.last_evaluation_id = record.id + profile.last_trained_at = datetime.utcnow() + self.profile_repo.save(profile) + + def _case_title(self, case_id: int | None) -> str: + """病例标题:历史记录只保存 case_id,展示时按新病例主表读取标题。""" + if not case_id: + return "" + case = self.case_repo.get_active_case(case_id) + return case.title if case else "" diff --git a/backend/app/services/knowledge_service.py b/backend/app/services/knowledge_service.py new file mode 100644 index 0000000..e4c444c --- /dev/null +++ b/backend/app/services/knowledge_service.py @@ -0,0 +1,33 @@ +from sqlalchemy.orm import Session + +from app.repositories.knowledge_repository import KnowledgeRepository + + +class KnowledgeService: + """知识库服务:检索评分参考指南并整理来源引用。""" + + def __init__(self, db: Session) -> None: + self.repo = KnowledgeRepository(db) + + def search_guidelines(self, department_id: int, training_type: str, keywords: list[str]) -> dict: + """指南检索:根据科室、任务类型和关键词返回评分参考片段。""" + chunks = self.repo.search_chunks(department_id=department_id, task_type=training_type, keywords=keywords) + matched_chunks = [ + { + "chunk_id": chunk.id, + "document_id": chunk.document_id, + "text": chunk.chunk_text, + "keywords": chunk.keywords or [], + "weight": float(chunk.weight), + } + for chunk in chunks + ] + source_refs = [ + { + "document_id": chunk.document_id, + "title": chunk.document.title if chunk.document else "", + "chunk_id": chunk.id, + } + for chunk in chunks + ] + return {"matched_chunks": matched_chunks, "source_refs": source_refs, "no_match": not matched_chunks} diff --git a/backend/app/services/order_service.py b/backend/app/services/order_service.py new file mode 100644 index 0000000..b14e81b --- /dev/null +++ b/backend/app/services/order_service.py @@ -0,0 +1,86 @@ +from sqlalchemy.orm import Session + +from app.core.exceptions import AppError +from app.models.training import SessionOrder +from app.repositories.case_repository import CaseRepository +from app.repositories.session_repository import SessionRepository +from app.schemas.session import CreateOrderResponse, OrderItemResponse, OrderItemsResponse +from app.services.runtime_memory import runtime_memory + + +class OrderService: + """检查检验服务:提供可申请项目和数据库固定结果返回。""" + + def __init__(self, db: Session) -> None: + self.db = db + self.case_repo = CaseRepository(db) + self.session_repo = SessionRepository(db) + + def list_order_items(self, session_id: int, user_id: str) -> OrderItemsResponse: + """检查项目列表:按会话病例返回可申请项目,不返回结果。""" + session = self._get_session(session_id, user_id) + items = self.case_repo.get_exam_items(session.case_id) + return OrderItemsResponse( + items=[ + OrderItemResponse(item_code=item.item_code, item_name=item.item_name, item_type=item.item_type) + for item in items + ] + ) + + def create_order(self, session_id: int, user_id: str, item_code: str) -> CreateOrderResponse: + """检查申请:从数据库读取检查结果并写入当前会话记录。""" + session = self._get_session(session_id, user_id) + if session.status not in {"inquiry", "diagnosis", "treatment"}: + raise AppError("SESSION_STATUS_INVALID", "current session does not allow ordering", 400) + + item = self.case_repo.get_exam_item(session.case_id, item_code) + if not item: + raise AppError("ORDER_ITEM_NOT_FOUND", "order item not found for current case", 404) + + existing_order = self.session_repo.get_order_by_item(session.id, item.item_code) + if existing_order: + return self._to_response(existing_order, already_ordered=True) + + order = self.session_repo.create_order( + SessionOrder( + session_id=session.id, + user_id=user_id, + case_id=session.case_id, + case_exam_item_id=item.id, + item_code=item.item_code, + item_name=item.item_name, + item_type=item.item_type, + result_text=item.result_text, + result_structured=item.result_structured, + is_key=item.is_key, + is_abnormal=item.is_abnormal, + ) + ) + runtime_memory.add_message( + session.memory_key or "", + "tool", + f"申请检查/检验:{order.item_name}。结果:{order.result_text}", + {"item_code": order.item_code, "result_structured": order.result_structured}, + ) + return self._to_response(order, already_ordered=False) + + def _to_response(self, order: SessionOrder, already_ordered: bool) -> CreateOrderResponse: + """检查响应:统一把会话检查记录转换为接口返回结构。""" + return CreateOrderResponse( + item_code=order.item_code, + item_name=order.item_name, + item_type=order.item_type, + result_text=order.result_text, + result_structured=order.result_structured, + is_key=order.is_key, + is_abnormal=order.is_abnormal, + context_written=True, + already_ordered=already_ordered, + ) + + def _get_session(self, session_id: int, user_id: str): + """会话校验:确认检查申请属于当前用户会话。""" + session = self.session_repo.get_owned_session(session_id, user_id) + if not session: + raise AppError("SESSION_NOT_FOUND", "session not found or not owned by current user", 404) + return session diff --git a/backend/app/services/pdf_export_service.py b/backend/app/services/pdf_export_service.py new file mode 100644 index 0000000..f87c5c4 --- /dev/null +++ b/backend/app/services/pdf_export_service.py @@ -0,0 +1,466 @@ +import html +import json +import uuid +from datetime import datetime +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload + +from app.core.config import settings +from app.core.exceptions import AppError +from app.models.training import TrainingSession +from app.models.training_record import TrainingRecord +from app.repositories.evaluation_repository import EvaluationRepository + + +class PdfExportService: + """PDF 导出服务:把 training_record 渲染为包含评分细则、检查证据和改进计划的报告。""" + + def __init__(self, db: Session) -> None: + self.db = db + self.repo = EvaluationRepository(db) + + def export(self, evaluation_id: int, user_id: str) -> SimpleNamespace: + """报告导出:校验 training_record 归属后生成 PDF,并回写 PDF 路径。""" + record = self.repo.get_owned_record(evaluation_id, user_id) + if not record: + raise AppError("EVALUATION_NOT_FOUND", "evaluation not found or not owned by current user", 404) + + session = self._get_session(record.session_id) if record.session_id else None + output_dir = Path(settings.report_storage_dir) + output_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.utcnow().strftime("%Y%m%d%H%M%S") + file_name = f"training_record_{record.id}_{record.score_type}_{timestamp}_{uuid.uuid4().hex[:6]}.pdf" + file_path = output_dir / file_name + self._write_pdf(file_path, record, session) + + record.pdf_file_path = str(file_path) + recommendation = dict(record.recommendation_result or {}) + recommendation["pdf_file_path"] = str(file_path) + record.recommendation_result = recommendation + self.repo.flush() + return SimpleNamespace(id=record.id, file_path=str(file_path)) + + def _get_session(self, session_id: int) -> TrainingSession | None: + """会话读取:加载报告展示需要的病例、检查申请和诊断治疗提交。""" + stmt = ( + select(TrainingSession) + .options( + selectinload(TrainingSession.case), + selectinload(TrainingSession.orders), + selectinload(TrainingSession.submission), + ) + .where(TrainingSession.id == session_id) + ) + return self.db.scalar(stmt) + + def _write_pdf(self, file_path: Path, record: TrainingRecord, session: TrainingSession | None) -> None: + """PDF 写入:使用 reportlab 生成 Acrobat 可正常打开的标准 PDF。""" + try: + from reportlab.lib import colors + from reportlab.lib.enums import TA_CENTER, TA_LEFT + from reportlab.lib.pagesizes import A4 + from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet + from reportlab.lib.units import mm + from reportlab.pdfbase import pdfmetrics + from reportlab.pdfbase.cidfonts import UnicodeCIDFont + from reportlab.pdfbase.ttfonts import TTFont + from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle + + font_name = self._register_report_font(pdfmetrics, TTFont, UnicodeCIDFont) + base_styles = getSampleStyleSheet() + styles = { + "title": ParagraphStyle( + "McaTitle", + parent=base_styles["Title"], + fontName=font_name, + fontSize=20, + leading=28, + alignment=TA_CENTER, + textColor=colors.HexColor("#17365D"), + spaceAfter=12, + ), + "h2": ParagraphStyle( + "McaHeading", + parent=base_styles["Heading2"], + fontName=font_name, + fontSize=13, + leading=18, + alignment=TA_LEFT, + textColor=colors.HexColor("#1F4E79"), + spaceBefore=8, + spaceAfter=6, + ), + "body": ParagraphStyle( + "McaBody", + parent=base_styles["BodyText"], + fontName=font_name, + fontSize=10, + leading=15, + textColor=colors.HexColor("#1F2937"), + wordWrap="CJK", + ), + "small": ParagraphStyle( + "McaSmall", + parent=base_styles["BodyText"], + fontName=font_name, + fontSize=8.8, + leading=12.5, + textColor=colors.HexColor("#4B5563"), + wordWrap="CJK", + ), + "thead": ParagraphStyle("McaThead", parent=base_styles["BodyText"], fontName=font_name, fontSize=9, leading=12, textColor=colors.white, alignment=TA_CENTER), + "td": ParagraphStyle("McaTd", parent=base_styles["BodyText"], fontName=font_name, fontSize=8.5, leading=12, textColor=colors.HexColor("#111827"), wordWrap="CJK"), + } + context = { + "Paragraph": Paragraph, + "Spacer": Spacer, + "Table": Table, + "TableStyle": TableStyle, + "colors": colors, + "styles": styles, + "mm": mm, + } + doc = SimpleDocTemplate( + str(file_path), + pagesize=A4, + leftMargin=16 * mm, + rightMargin=16 * mm, + topMargin=14 * mm, + bottomMargin=14 * mm, + title="医疗问诊 Agent 训练评价报告", + ) + doc.build(self._build_story(record, session, context)) + except ModuleNotFoundError: + self._write_minimal_pdf(file_path, record) + except Exception as exc: + if file_path.exists(): + file_path.unlink(missing_ok=True) + raise AppError("PDF_EXPORT_FAILED", "PDF report export failed", 500) from exc + + def _write_minimal_pdf(self, file_path: Path, record: TrainingRecord) -> None: + """兜底 PDF:缺少 reportlab 时生成标准可打开的极简 PDF,生产环境仍使用 reportlab 模板。""" + lines = [ + "Medical Consultation Agent Evaluation Report", + f"Record ID: {record.id}", + f"User ID: {record.external_user_id}", + f"Score: {float(record.total_score or 0):g} ({record.score_type})", + f"Level: {record.evaluation_level}", + "This fallback PDF is generated because reportlab is not installed.", + ] + text_ops = [] + y = 780 + for line in lines: + safe = line.encode("latin-1", "replace").decode("latin-1").replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") + text_ops.append(f"BT /F1 12 Tf 50 {y} Td ({safe}) Tj ET") + y -= 22 + stream = "\n".join(text_ops).encode("latin-1") + if len(stream) < 900: + stream += b"\n% " + (b"fallback-pdf-padding " * 40) + objects = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + b"<< /Length " + str(len(stream)).encode("ascii") + b" >>\nstream\n" + stream + b"\nendstream", + ] + content = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n") + offsets = [0] + for index, obj in enumerate(objects, start=1): + offsets.append(len(content)) + content.extend(f"{index} 0 obj\n".encode("ascii")) + content.extend(obj) + content.extend(b"\nendobj\n") + xref_offset = len(content) + content.extend(f"xref\n0 {len(objects) + 1}\n".encode("ascii")) + content.extend(b"0000000000 65535 f \n") + for offset in offsets[1:]: + content.extend(f"{offset:010d} 00000 n \n".encode("ascii")) + content.extend( + f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n".encode("ascii") + ) + file_path.write_bytes(bytes(content)) + + def _build_story(self, record: TrainingRecord, session: TrainingSession | None, context: dict[str, Any]) -> list: + """报告模板:按基本信息、病例、提交、检查、评分细则和改进计划组织内容。""" + Paragraph = context["Paragraph"] + Spacer = context["Spacer"] + styles = context["styles"] + mm = context["mm"] + case = session.case if session else None + submission = session.submission if session else None + orders = sorted(session.orders, key=lambda item: item.ordered_at) if session else [] + structured = record.ai_feedback_structured or {} + + story = [Paragraph("医疗问诊 Agent 训练评价报告", styles["title"])] + story.append(Paragraph("本报告仅用于医学教学训练评价,不替代真实临床诊疗。", styles["small"])) + story.append(Spacer(1, 4 * mm)) + + self._append_kv_section( + story, + "一、报告基本信息", + [ + ("训练记录", record.id), + ("用户 ID", record.external_user_id), + ("病例", case.title if case else f"case_id={record.case_id}"), + ("训练模式", self._mode_label(record.training_mode)), + ("训练类别", record.case_type), + ("评分类型", self._score_type_label(record.score_type)), + ("总分", self._score_text(record)), + ("评价等级", record.evaluation_level), + ("生成时间", self._format_datetime(record.created_at)), + ("模型", structured.get("llm_model") or "未记录"), + ("模型耗时", self._latency_text(structured.get("latency_metrics"))), + ], + context, + ) + self._append_case_section(story, case, context) + self._append_submission_section(story, submission, context) + self._append_order_section(story, orders, context) + self._append_dimension_section(story, structured.get("dimension_scores") or [], context) + self._append_error_section(story, structured.get("errors") or record.wrong_points or [], context) + self._append_list_section(story, "七、改进计划", structured.get("improvement_plan") or (record.recommendation_result or {}).get("improvement_plan") or [], context) + self._append_list_section(story, "八、证据摘要", structured.get("evidence_summary") or [], context) + self._append_guideline_section(story, structured.get("guideline_refs") or [], context) + story.append(Spacer(1, 4 * mm)) + story.append(Paragraph(f"综合评价:{self._safe_text(structured.get('overall_comment') or record.feedback)}", styles["body"])) + return story + + def _append_case_section(self, story: list, case: Any, context: dict[str, Any]) -> None: + """病例信息分节:展示病例主诉、关键症状、关键检查和标准诊断。""" + if not case: + return + patient_gender = {"male": "男", "female": "女"}.get(case.patient_gender or "", case.patient_gender or "未记录") + self._append_kv_section( + story, + "二、病例与考核要点", + [ + ("患者", f"{case.patient_age or '未记录'} 岁,{patient_gender}"), + ("主诉", case.chief_complaint), + ("关键症状", self._join(case.key_symptoms)), + ("关键检查", self._join(case.key_exams)), + ("考核要点", self._join(case.key_points)), + ("标准诊断", case.diagnosis_primary), + ("诊断依据", case.diagnosis_basis), + ], + context, + ) + + def _append_submission_section(self, story: list, submission: Any, context: dict[str, Any]) -> None: + """学生提交分节:展示学生最终诊断、治疗、沟通和随访内容。""" + self._append_kv_section( + story, + "三、学生诊断与治疗提交", + [ + ("主要诊断", getattr(submission, "primary_diagnosis", None)), + ("鉴别诊断", self._join(getattr(submission, "differential_diagnoses", None))), + ("诊断依据", getattr(submission, "diagnosis_basis", None)), + ("治疗原则", getattr(submission, "treatment_principle", None)), + ("治疗措施", getattr(submission, "treatment_measures", None)), + ("风险预案", getattr(submission, "risk_plan", None)), + ("医患沟通", getattr(submission, "communication", None)), + ("随访安排", getattr(submission, "follow_up", None)), + ], + context, + ) + + def _append_order_section(self, story: list, orders: list, context: dict[str, Any]) -> None: + """检查结果分节:展示用户申请过的检查/检验项目和数据库固定结果。""" + self._append_title(story, "四、检查/检验申请与结果", context) + if not orders: + story.append(context["Paragraph"]("本次训练未申请检查/检验。", context["styles"]["body"])) + return + rows = [["项目", "类型", "结果", "关键", "异常"]] + for order in orders: + rows.append([order.item_name, order.item_type, order.result_text, "是" if order.is_key else "否", "是" if order.is_abnormal else "否"]) + self._append_table(story, rows, [72, 52, 285, 38, 38], context) + + def _append_dimension_section(self, story: list, dimensions: list, context: dict[str, Any]) -> None: + """评分细则分节:逐项展示得分、评价、证据、扣分原因和改进动作。""" + self._append_title(story, "五、维度评分细则", context) + if not dimensions: + story.append(context["Paragraph"]("暂无维度评分。", context["styles"]["body"])) + return + rows = [["维度", "得分", "评价"]] + for item in dimensions: + rows.append([item.get("dimension", "未命名维度"), f"{item.get('score', 0)} / {item.get('max_score', '-')}", item.get("comment", "")]) + self._append_table(story, rows, [88, 58, 339], context) + for index, item in enumerate(dimensions, start=1): + title = f"{index}. {item.get('dimension', '未命名维度')}:{item.get('score', 0)} / {item.get('max_score', '-')}" + story.append(context["Paragraph"](self._safe_text(title), context["styles"]["body"])) + self._append_list_section(story, "证据", item.get("evidence") or [], context, compact=True) + self._append_list_section(story, "扣分原因", item.get("deductions") or [], context, compact=True) + if item.get("improvement"): + story.append(context["Paragraph"](f"改进动作:{self._safe_text(item.get('improvement'))}", context["styles"]["small"])) + + def _append_error_section(self, story: list, errors: list, context: dict[str, Any]) -> None: + """扣分问题分节:展示模型识别出的关键问题和严重程度。""" + self._append_title(story, "六、主要问题与扣分原因", context) + if not errors: + story.append(context["Paragraph"]("暂无明显扣分问题。", context["styles"]["body"])) + return + rows = [["问题", "维度", "严重度", "说明"]] + for index, item in enumerate(errors, start=1): + if isinstance(item, dict): + rows.append( + [ + item.get("title") or f"问题 {index}", + item.get("related_dimension") or "综合表现", + item.get("severity") or "medium", + item.get("description") or item.get("comment") or "", + ] + ) + else: + rows.append([f"问题 {index}", "综合表现", "medium", str(item)]) + self._append_table(story, rows, [78, 70, 50, 287], context) + + def _append_guideline_section(self, story: list, refs: list, context: dict[str, Any]) -> None: + """参考依据分节:展示评分时使用的指南或知识库片段。""" + self._append_title(story, "九、参考指南与资料", context) + if not refs: + story.append(context["Paragraph"]("本次未命中外部评分指南,使用病例内置标准和评分规则。", context["styles"]["body"])) + return + rows = [["来源", "标题/内容", "相关性"]] + for item in refs: + if isinstance(item, dict): + rows.append([item.get("source") or "知识库", item.get("title") or item.get("content") or "", item.get("score") or "已引用"]) + else: + rows.append(["知识库", str(item), "已引用"]) + self._append_table(story, rows, [82, 320, 83], context) + + def _append_kv_section(self, story: list, title: str, rows: list[tuple[str, Any]], context: dict[str, Any]) -> None: + """键值分节:以两列表格展示基本信息、病例信息和学生提交内容。""" + self._append_title(story, title, context) + table_rows = [["字段", "内容"]] + table_rows.extend([[key, self._safe_text(value)] for key, value in rows]) + self._append_table(story, table_rows, [95, 390], context) + + def _append_title(self, story: list, title: str, context: dict[str, Any]) -> None: + """章节标题:统一 PDF 分节标题样式。""" + story.append(context["Spacer"](1, 3 * context["mm"])) + story.append(context["Paragraph"](self._safe_text(title), context["styles"]["h2"])) + + def _append_table(self, story: list, rows: list[list[Any]], col_widths: list[int], context: dict[str, Any]) -> None: + """表格渲染:将文本转为可自动换行的 Paragraph 并应用统一表格样式。""" + Paragraph = context["Paragraph"] + Table = context["Table"] + TableStyle = context["TableStyle"] + colors = context["colors"] + styles = context["styles"] + rendered = [] + for row_index, row in enumerate(rows): + style = styles["thead"] if row_index == 0 else styles["td"] + rendered.append([Paragraph(self._safe_text(cell), style) for cell in row]) + table = Table(rendered, colWidths=col_widths, repeatRows=1, hAlign="LEFT") + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#2F75B5")), + ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#D9E2F3")), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F5F9FF")]), + ("LEFTPADDING", (0, 0), (-1, -1), 5), + ("RIGHTPADDING", (0, 0), (-1, -1), 5), + ("TOPPADDING", (0, 0), (-1, -1), 5), + ("BOTTOMPADDING", (0, 0), (-1, -1), 5), + ] + ) + ) + story.append(table) + + def _append_list_section(self, story: list, title: str, items: Any, context: dict[str, Any], compact: bool = False) -> None: + """列表分节:展示证据摘要、扣分原因和改进计划。""" + if not compact: + self._append_title(story, title, context) + elif title: + story.append(context["Paragraph"](self._safe_text(title), context["styles"]["small"])) + normalized = self._ensure_list(items) + if not normalized: + story.append(context["Paragraph"]("无", context["styles"]["small" if compact else "body"])) + return + for item in normalized: + story.append(context["Paragraph"](f"- {self._safe_text(item)}", context["styles"]["small" if compact else "body"])) + + def _register_report_font(self, pdfmetrics: Any, TTFont: Any, UnicodeCIDFont: Any) -> str: + """字体注册:优先嵌入 Windows 中文字体,提升 Adobe Acrobat DC 兼容性。""" + font_name = "MCA-SimHei" + try: + pdfmetrics.getFont(font_name) + return font_name + except KeyError: + pass + for font_path in (Path("C:/Windows/Fonts/simhei.ttf"), Path("C:/Windows/Fonts/msyh.ttc"), Path("C:/Windows/Fonts/simsun.ttc")): + if not font_path.exists(): + continue + try: + pdfmetrics.registerFont(TTFont(font_name, str(font_path))) + return font_name + except Exception: + continue + pdfmetrics.registerFont(UnicodeCIDFont("STSong-Light")) + return "STSong-Light" + + def _score_text(self, record: TrainingRecord) -> str: + """分数展示:根据百分制或五分制输出总分文本。""" + max_score = "100" if record.score_type == "percentage" else "5" + return f"{float(record.total_score or 0):g} / {max_score}" + + def _latency_text(self, latency_metrics: dict | None) -> str: + """模型耗时展示:读取评价生成阶段记录的 LLM 延迟。""" + if not latency_metrics: + return "未记录" + value = latency_metrics.get("scoring_latency_ms") + return f"{value} ms" if value is not None else self._safe_text(latency_metrics) + + def _score_type_label(self, score_type: str) -> str: + """评分类型标签:转换内部枚举为中文显示。""" + return {"percentage": "百分制", "five_point": "五分制"}.get(score_type, score_type) + + def _mode_label(self, mode: str) -> str: + """训练模式标签:转换内部枚举为中文显示。""" + return {"practice": "练习模式", "teaching": "教学互动模式", "novice": "练习模式"}.get(mode, mode) + + def _format_datetime(self, value: datetime | None) -> str: + """时间格式化:统一报告中的时间展示。""" + return value.strftime("%Y-%m-%d %H:%M:%S") if value else "未记录" + + def _join(self, value: Any) -> str: + """数组文本化:把列表、字典或空值转换为报告中的短文本。""" + if value is None: + return "未记录" + if isinstance(value, list): + return ";".join(self._safe_text(item) for item in value) if value else "未记录" + if isinstance(value, dict): + return json.dumps(value, ensure_ascii=False) + return str(value) + + def _ensure_list(self, value: Any) -> list[str]: + """列表规整:把字符串、字典或数组统一为字符串数组。""" + if value is None: + return [] + raw_items = value if isinstance(value, list) else [value] + items = [] + for raw in raw_items: + if raw is None: + continue + if isinstance(raw, dict): + text = raw.get("description") or raw.get("content") or raw.get("text") or json.dumps(raw, ensure_ascii=False) + else: + text = str(raw) + text = text.strip() + if text: + items.append(text) + return items + + def _safe_text(self, value: Any) -> str: + """安全文本:将任意对象转换为可放入 Paragraph 的转义文本。""" + if value is None: + return "未记录" + if isinstance(value, (dict, list)): + text = json.dumps(value, ensure_ascii=False) + else: + text = str(value) + return html.escape(text).replace("\n", "
") diff --git a/backend/app/services/runtime_memory.py b/backend/app/services/runtime_memory.py new file mode 100644 index 0000000..72a7d72 --- /dev/null +++ b/backend/app/services/runtime_memory.py @@ -0,0 +1,133 @@ +import json +from datetime import datetime, timedelta +from threading import Lock + +from app.core.config import settings + + +class BaseRuntimeMemoryService: + """短期 memory 基类:定义会话消息缓存的统一接口。""" + + def create(self, memory_key: str, patient_opening: str | None = None) -> None: + raise NotImplementedError + + def add_message(self, memory_key: str, role: str, content: str, structured: dict | None = None) -> None: + raise NotImplementedError + + def get_messages(self, memory_key: str | None) -> list[dict]: + raise NotImplementedError + + def has_doctor_message(self, memory_key: str | None) -> bool: + """问诊校验:判断当前会话是否存在医生问诊消息。""" + return any(item["role"] == "doctor" for item in self.get_messages(memory_key)) + + def release(self, memory_key: str | None) -> None: + raise NotImplementedError + + +class InMemoryRuntimeMemoryService(BaseRuntimeMemoryService): + """进程内短期 memory:用于无 Redis 环境下的 Demo 兜底。""" + + def __init__(self) -> None: + self._store: dict[str, dict] = {} + self._lock = Lock() + + def create(self, memory_key: str, patient_opening: str | None = None) -> None: + """memory 创建:为新会话初始化短期消息容器。""" + with self._lock: + self._store[memory_key] = { + "expires_at": datetime.utcnow() + timedelta(seconds=settings.runtime_memory_ttl_seconds), + "messages": [], + } + if patient_opening: + self._store[memory_key]["messages"].append( + {"role": "patient", "content": patient_opening, "structured": None, "created_at": datetime.utcnow().isoformat()} + ) + + def add_message(self, memory_key: str, role: str, content: str, structured: dict | None = None) -> None: + """memory 写入:追加医生、病人或工具消息。""" + with self._lock: + self._ensure(memory_key) + self._store[memory_key]["messages"].append( + {"role": role, "content": content, "structured": structured, "created_at": datetime.utcnow().isoformat()} + ) + + def get_messages(self, memory_key: str | None) -> list[dict]: + """memory 读取:返回当前会话的短期消息列表。""" + if not memory_key: + return [] + with self._lock: + self._ensure(memory_key) + return list(self._store[memory_key]["messages"]) + + def release(self, memory_key: str | None) -> None: + """memory 释放:评价完成后删除短期聊天记录。""" + if not memory_key: + return + with self._lock: + self._store.pop(memory_key, None) + + def _ensure(self, memory_key: str) -> None: + """memory 兜底:内存丢失或过期时重新创建空容器。""" + current = self._store.get(memory_key) + if not current or current["expires_at"] < datetime.utcnow(): + self._store[memory_key] = { + "expires_at": datetime.utcnow() + timedelta(seconds=settings.runtime_memory_ttl_seconds), + "messages": [], + } + + +class RedisRuntimeMemoryService(BaseRuntimeMemoryService): + """Redis 短期 memory:保存单次训练过程中的问诊消息并按 TTL 自动过期。""" + + def __init__(self) -> None: + try: + import redis + except ImportError as exc: + raise RuntimeError("redis package is required for RedisRuntimeMemoryService") from exc + self.client = redis.Redis.from_url(settings.redis_url, decode_responses=True) + + def create(self, memory_key: str, patient_opening: str | None = None) -> None: + """Redis memory 创建:初始化会话消息列表并设置过期时间。""" + self.client.delete(memory_key) + if patient_opening: + self.add_message(memory_key, "patient", patient_opening) + self.client.expire(memory_key, settings.runtime_memory_ttl_seconds) + + def add_message(self, memory_key: str, role: str, content: str, structured: dict | None = None) -> None: + """Redis memory 写入:追加一条问诊、病人或工具消息。""" + payload = { + "role": role, + "content": content, + "structured": structured, + "created_at": datetime.utcnow().isoformat(), + } + self.client.rpush(memory_key, json.dumps(payload, ensure_ascii=False)) + self.client.expire(memory_key, settings.runtime_memory_ttl_seconds) + + def get_messages(self, memory_key: str | None) -> list[dict]: + """Redis memory 读取:读取当前会话短期消息列表。""" + if not memory_key: + return [] + return [json.loads(item) for item in self.client.lrange(memory_key, 0, -1)] + + def release(self, memory_key: str | None) -> None: + """Redis memory 释放:评价完成后删除短期消息。""" + if memory_key: + self.client.delete(memory_key) + + +def create_runtime_memory_service() -> BaseRuntimeMemoryService: + """memory 选择:根据配置启用 Redis,失败时回退进程内 memory。""" + if settings.runtime_memory_backend.lower() == "redis": + try: + service = RedisRuntimeMemoryService() + service.client.ping() + return service + except Exception: + return InMemoryRuntimeMemoryService() + return InMemoryRuntimeMemoryService() + + +RuntimeMemoryService = InMemoryRuntimeMemoryService +runtime_memory = create_runtime_memory_service() diff --git a/backend/app/services/session_service.py b/backend/app/services/session_service.py new file mode 100644 index 0000000..d87b07a --- /dev/null +++ b/backend/app/services/session_service.py @@ -0,0 +1,278 @@ +import asyncio +import time +import uuid +import json +import logging +from collections.abc import AsyncIterator +from datetime import datetime + +from sqlalchemy.orm import Session +from app.agents.orchestrator import MedicalConsultationOrchestrator +from app.core.config import settings +from app.core.context import UserContext +from app.core.exceptions import AppError +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, + SubmitDiagnosisRequest, + SubmitDiagnosisResponse, + SubmitTreatmentRequest, + SubmitTreatmentResponse, + HintRequest, + HintResponse, +) +from app.services.audit_service import AuditService +from app.services.runtime_memory import runtime_memory + +logger = logging.getLogger(__name__) + + +class SessionService: + """会话服务:负责创建会话、问诊、多阶段状态流转和诊断治疗提交。""" + + def __init__(self, db: Session) -> None: + self.db = db + self.case_repo = CaseRepository(db) + self.session_repo = SessionRepository(db) + self.audit = AuditService(db) + self.orchestrator = MedicalConsultationOrchestrator() + + def create_session(self, ctx: UserContext, payload: CreateSessionRequest) -> CreateSessionResponse: + """会话创建:校验病例并初始化短期 memory。""" + case = self.case_repo.get_active_case(payload.case_id) + if not case: + raise AppError("CASE_NOT_FOUND", "case not found or inactive", 404) + + session_code = f"sess_{datetime.utcnow().strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:8]}" + memory_key = f"mem:{session_code}" + session = self.session_repo.create_session( + TrainingSession( + session_code=session_code, + user_id=ctx.user_id, + tenant_id=ctx.tenant_id, + class_id=ctx.class_id, + entry_scene=ctx.entry_scene, + case_id=case.id, + training_type=payload.training_type, + mode=payload.mode, + score_type=payload.score_type, + status="inquiry", + started_at=datetime.utcnow(), + memory_key=memory_key, + metadata_={"source": "demo"}, + ) + ) + patient_opening = case.patient_opening or "家长:医生,孩子这几天不舒服,想请您看看。" + runtime_memory.create(memory_key, patient_opening) + self.audit.log(ctx, "session.create", "training_session", str(session.id), session.id) + return CreateSessionResponse( + session_id=session.id, + session_code=session.session_code, + status=session.status, + patient_opening=patient_opening, + ) + + 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) + 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) + + memory_messages = runtime_memory.get_messages(session.memory_key) + runtime_memory.add_message(session.memory_key or "", "doctor", message) + logger.info( + "chat_stream.start session_id=%s user_id=%s message_len=%s", + session.id, + ctx.user_id, + len(message), + ) + + async def event_generator() -> AsyncIterator[str]: + full_reply = "" + started_at = time.perf_counter() + stream_iter = self.orchestrator.patient_stream_reply(session, case, memory_messages, message).__aiter__() + while True: + elapsed = time.perf_counter() - started_at + total_remaining = settings.llm_stream_total_timeout_seconds - elapsed + if total_remaining <= 0: + logger.warning("chat_stream.total_timeout session_id=%s", session.id) + yield self._sse_error("AI 病人回复总耗时超限,请重试或关闭流式模式", "LLM_STREAM_TIMEOUT") + return + timeout = min( + total_remaining, + settings.llm_stream_first_token_timeout_seconds if not full_reply else settings.llm_chat_timeout_seconds, + ) + try: + chunk = await asyncio.wait_for(stream_iter.__anext__(), timeout=timeout) + except StopAsyncIteration: + if full_reply: + runtime_memory.add_message(session.memory_key or "", "patient", full_reply) + yield self._sse_done( + latency_ms=int((time.perf_counter() - started_at) * 1000), + first_token_ms=0, + model=None, + fallback_used=False, + ) + else: + logger.warning("chat_stream.empty_stop session_id=%s", session.id) + yield self._sse_error("AI 病人没有返回有效内容,请重试", "LLM_EMPTY_RESPONSE") + return + except TimeoutError: + logger.warning("chat_stream.first_token_timeout session_id=%s", session.id) + yield self._sse_error("AI 病人首段回复超时,请重试或关闭流式模式", "LLM_STREAM_TIMEOUT") + return + except Exception as exc: + logger.exception("chat_stream.failed session_id=%s error=%s", session.id, exc.__class__.__name__) + yield self._sse_error("AI 病人回复失败,请检查模型配置或稍后重试", "LLM_STREAM_FAILED") + return + + if chunk.done: + if not full_reply.strip(): + logger.warning("chat_stream.empty_done session_id=%s", session.id) + yield self._sse_error("AI 病人没有返回有效内容,请重试", "LLM_EMPTY_RESPONSE") + return + runtime_memory.add_message(session.memory_key or "", "patient", full_reply) + logger.info( + "chat_stream.done session_id=%s chars=%s latency_ms=%s model=%s", + session.id, + len(full_reply), + chunk.total_latency_ms, + chunk.model, + ) + yield self._sse_done( + latency_ms=chunk.total_latency_ms or int((time.perf_counter() - started_at) * 1000), + first_token_ms=chunk.first_token_ms or 0, + model=chunk.model, + fallback_used=chunk.fallback_used, + ) + return + + if chunk.delta: + full_reply += chunk.delta + logger.debug("chat_stream.delta session_id=%s delta_len=%s", session.id, len(chunk.delta)) + delta_payload = json.dumps({"delta": chunk.delta}, ensure_ascii=False) + yield f"event: message_delta\ndata: {delta_payload}\n\n" + + self.audit.log(ctx, "session.chat.stream", "training_session", str(session.id), session.id) + return event_generator() + + def _sse_done(self, latency_ms: int, first_token_ms: int, model: str | None, fallback_used: bool) -> str: + """SSE 完成事件:统一返回流式问诊耗时和模型状态。""" + done_payload = json.dumps( + { + "latency_ms": latency_ms, + "first_token_ms": first_token_ms, + "model": model, + "fallback_used": fallback_used, + }, + ensure_ascii=False, + ) + return f"event: message_done\ndata: {done_payload}\n\n" + + def _sse_error(self, message: str, code: str = "LLM_STREAM_TIMEOUT") -> str: + """SSE 错误事件:让前端结束 pending 状态并展示用户可读错误。""" + payload = json.dumps({"code": code, "message": message}, ensure_ascii=False) + 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) + if session.status != "inquiry": + raise AppError("SESSION_STATUS_INVALID", "hints are only available during inquiry", 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) + memory_messages = runtime_memory.get_messages(session.memory_key) + result = await self.orchestrator.generate_hints(session, case, memory_messages, session.orders, payload.last_user_message) + self.audit.log(ctx, "session.hints", "training_session", str(session.id), session.id) + return HintResponse(**result) + + def complete_inquiry(self, ctx: UserContext, session_id: int) -> SessionStatusResponse: + """完成问诊:校验至少一轮医生问诊后进入诊断阶段。""" + session = self._get_session(session_id, ctx.user_id) + if session.status != "inquiry": + raise AppError("SESSION_STATUS_INVALID", "only inquiry status can be completed", 400) + if not runtime_memory.has_doctor_message(session.memory_key): + raise AppError("INQUIRY_REQUIRED", "at least one doctor message is required", 400) + self.session_repo.update_status(session, "diagnosis") + self.audit.log(ctx, "session.complete_inquiry", "training_session", str(session.id), session.id) + return SessionStatusResponse(session_id=session.id, status=session.status) + + def submit_diagnosis(self, ctx: UserContext, session_id: int, payload: SubmitDiagnosisRequest) -> SubmitDiagnosisResponse: + """诊断提交:保存主要诊断、鉴别诊断和诊断依据并进入治疗阶段。""" + session = self._get_session(session_id, ctx.user_id) + if session.status != "diagnosis": + raise AppError("SESSION_STATUS_INVALID", "diagnosis submit is not allowed", 400) + submission = self.session_repo.get_submission(session.id) or SessionSubmission(session_id=session.id, user_id=ctx.user_id) + submission.primary_diagnosis = payload.primary_diagnosis + submission.differential_diagnoses = payload.differential_diagnoses + submission.diagnosis_basis = payload.diagnosis_basis + submission.diagnosis_submitted_at = datetime.utcnow() + self.session_repo.upsert_submission(submission) + self.session_repo.update_status(session, "treatment") + self.audit.log(ctx, "session.submit_diagnosis", "training_session", str(session.id), session.id) + return SubmitDiagnosisResponse(status=session.status) + + def submit_treatment(self, ctx: UserContext, session_id: int, payload: SubmitTreatmentRequest) -> SubmitTreatmentResponse: + """治疗提交:保存治疗方案、风险预案、沟通和随访并进入评价阶段。""" + session = self._get_session(session_id, ctx.user_id) + if session.status != "treatment": + raise AppError("SESSION_STATUS_INVALID", "treatment submit is not allowed", 400) + submission = self.session_repo.get_submission(session.id) + if not submission: + raise AppError("DIAGNOSIS_REQUIRED", "diagnosis submission is required", 400) + submission.treatment_principle = payload.treatment_principle + submission.treatment_measures = payload.treatment_measures + submission.risk_plan = payload.risk_plan + submission.communication = payload.communication + submission.follow_up = payload.follow_up + submission.treatment_submitted_at = datetime.utcnow() + self.session_repo.upsert_submission(submission) + self.session_repo.update_status(session, "evaluating") + self.audit.log(ctx, "session.submit_treatment", "training_session", str(session.id), session.id) + return SubmitTreatmentResponse(status=session.status) + + def _get_session(self, session_id: int, user_id: str) -> TrainingSession: + """会话归属:按 session_id 和 user_id 校验会话隔离。""" + session = self.session_repo.get_owned_session(session_id, user_id) + if not session: + raise AppError("SESSION_NOT_FOUND", "session not found or not owned by current user", 404) + return session diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..22977f4 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "medical-consultation-agent" +version = "0.1.0" +description = "Medical consultation agent first demo backend" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.30.0", + "sqlalchemy>=2.0.0", + "pydantic>=2.7.0", + "pymysql>=1.1.0", + "aiomysql>=0.2.0", + "httpx>=0.27.0", + "python-dotenv>=1.0.0", + "reportlab>=4.2.0", + "redis>=5.0.0", +] + +[tool.ruff] +line-length = 100 +target-version = "py311" diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..7435c1b --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,11 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 +sqlalchemy>=2.0.0 +pydantic>=2.7.0 +pymysql>=1.1.0 +aiomysql>=0.2.0 +httpx>=0.27.0 +python-dotenv>=1.0.0 +reportlab>=4.2.0 +streamlit>=1.36.0 +redis>=5.0.0 diff --git a/backend/scripts/__init__.py b/backend/scripts/__init__.py new file mode 100644 index 0000000..1d0606b --- /dev/null +++ b/backend/scripts/__init__.py @@ -0,0 +1 @@ +"""后端脚本包。""" diff --git a/backend/scripts/debug_patient_stream.py b/backend/scripts/debug_patient_stream.py new file mode 100644 index 0000000..f951e41 --- /dev/null +++ b/backend/scripts/debug_patient_stream.py @@ -0,0 +1,53 @@ +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app.agents.llm_adapter import OpenAICompatibleLLMClient +from app.agents.patient_agent import PatientAgent +from app.core.config import settings +from app.db.session import SessionLocal +from app.repositories.case_repository import CaseRepository + + +async def main() -> None: + """本地调试:直接调用 Patient Agent 流式回复,绕过前端和 FastAPI。""" + client = OpenAICompatibleLLMClient() + print(f"mock_mode={client.is_mock_mode}") + print(f"fast_model={settings.llm_fast_model}") + print(f"fast_thinking={settings.llm_fast_thinking_enabled}") + print(f"stream_first_token_timeout={settings.llm_stream_first_token_timeout_seconds}") + print(f"stream_total_timeout={settings.llm_stream_total_timeout_seconds}") + + db = SessionLocal() + try: + case = CaseRepository(db).list_active_cases()[0] + text = "" + first_token_ms = None + done_seen = False + async for chunk in PatientAgent().stream_reply(case, [], "孩子发热几天了?最高体温多少?", "novice"): + if first_token_ms is None and chunk.first_token_ms is not None: + first_token_ms = chunk.first_token_ms + if chunk.done: + done_seen = True + print(f"done_seen={done_seen}") + print(f"first_token_ms={first_token_ms}") + print(f"total_latency_ms={chunk.total_latency_ms}") + print(f"model={chunk.model}") + print(f"fallback_used={chunk.fallback_used}") + print(f"text_len={len(text)}") + print(f"text_preview={text[:30]}") + break + text += chunk.delta + if not done_seen: + print("done_seen=False") + print(f"text_len={len(text)}") + print(f"text_preview={text[:30]}") + raise SystemExit(1) + finally: + db.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/scripts/drop_legacy_tables.py b/backend/scripts/drop_legacy_tables.py new file mode 100644 index 0000000..03705a6 --- /dev/null +++ b/backend/scripts/drop_legacy_tables.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +from sqlalchemy import inspect, text + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app.db.session import SessionLocal + +LEGACY_TABLES = [ + "evaluation_report_exports", + "evaluation_records", + "session_submissions", + "session_orders", + "session_runtime_messages", + "training_sessions", + "case_exam_items", + "rubric_templates", + "cases", +] + + +def main() -> None: + """旧表清理:在新表链路验证通过后删除不再被业务依赖的旧表。""" + with SessionLocal() as db: + existing = set(inspect(db.bind).get_table_names()) if db.bind else set() + dialect = db.bind.dialect.name if db.bind else "" + if dialect == "mysql": + db.execute(text("SET FOREIGN_KEY_CHECKS=0")) + for table_name in LEGACY_TABLES: + if table_name in existing: + db.execute(text(f"DROP TABLE `{table_name}`")) + print(f"dropped legacy table: {table_name}") + if dialect == "mysql": + db.execute(text("SET FOREIGN_KEY_CHECKS=1")) + db.commit() + print("legacy table cleanup completed") + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/import_source_case_sql.py b/backend/scripts/import_source_case_sql.py new file mode 100644 index 0000000..c25cd44 --- /dev/null +++ b/backend/scripts/import_source_case_sql.py @@ -0,0 +1,612 @@ +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal +from pathlib import Path +from typing import Any + +from sqlalchemy import delete, inspect, select + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app.db.session import SessionLocal +from app.models.source_case import CaseBase, CaseExamItem, ScoringRule, TeachingCase, TraditionalCase + + +SOURCE_TABLES = ("case_base",) +OPTIONAL_SOURCE_TABLES = ("traditional_case", "teaching_case", "scoring_rule", "case_exam_item") +DANGEROUS_PATTERNS = ( + r"\bDROP\s+DATABASE\b", + r"\bDROP\s+TABLE\b", + r"\bTRUNCATE\b", + r"\bDELETE\s+FROM\b", + r"\bCREATE\s+DATABASE\b", + r"\bALTER\s+TABLE\b", +) +JSON_COLUMNS = { + "case_base": {"symptom_tags", "disease_tags", "competency_tags", "guideline_tags", "knowledge_points", "multimodal_assets"}, + "scoring_rule": {"rubric_json"}, +} +DATETIME_COLUMNS = {"created_at", "updated_at"} +DECIMAL_COLUMNS = {"score_weight"} +INT_COLUMNS = { + "id", + "difficulty_score", + "patient_age", + "estimated_minutes", + "vector_status", + "publish_status", + "status", + "created_by_id", + "department_id", + "case_id", +} +BOOL_COLUMNS = {"osce_enabled", "rag_enabled", "ai_auto_score", "osce_dimension"} + + +class ImportValidationError(Exception): + """导入校验错误:源 SQL 不满足安全导入要求时中止。""" + + +@dataclass +class ImportReport: + """导入报告:记录检查、写入和跳过情况。""" + + source_path: str + encoding: str + table_rows: dict[str, int] + warnings: list[str] + applied: bool + upserted_cases: int = 0 + upserted_traditional_cases: int = 0 + upserted_teaching_cases: int = 0 + replaced_scoring_rules: int = 0 + generated_exam_items: int = 0 + + def as_dict(self) -> dict[str, Any]: + """报告输出:转换为前端和命令行均可读的结构。""" + return { + "source_path": self.source_path, + "encoding": self.encoding, + "table_rows": self.table_rows, + "warnings": self.warnings, + "applied": self.applied, + "upserted_cases": self.upserted_cases, + "upserted_traditional_cases": self.upserted_traditional_cases, + "upserted_teaching_cases": self.upserted_teaching_cases, + "replaced_scoring_rules": self.replaced_scoring_rules, + "generated_exam_items": self.generated_exam_items, + } + + +def detect_encoding(path: Path) -> str: + """编码识别:根据 BOM 和试读结果判断 SQL 文件编码。""" + raw = path.read_bytes() + if raw.startswith(b"\xff\xfe") or raw.startswith(b"\xfe\xff"): + return "utf-16" + if raw.startswith(b"\xef\xbb\xbf"): + return "utf-8-sig" + for encoding in ("utf-8", "utf-16"): + try: + raw.decode(encoding) + return encoding + except UnicodeDecodeError: + continue + raise ImportValidationError("SQL 文件编码无法识别,请提供 UTF-8 或 UTF-16 文件。") + + +def load_sql_text(path: Path) -> tuple[str, str]: + """文件读取:读取接口提供的 SQL dump,并返回文本与编码。""" + if not path.exists(): + raise ImportValidationError(f"SQL 文件不存在:{path}") + encoding = detect_encoding(path) + return path.read_text(encoding=encoding), encoding + + +def find_dangerous_statements(sql_text: str) -> list[str]: + """安全扫描:识别源 dump 中不能在正式库直接执行的 DDL/DML。""" + hits: list[str] = [] + for pattern in DANGEROUS_PATTERNS: + if re.search(pattern, sql_text, flags=re.IGNORECASE): + hits.append(pattern.replace(r"\b", "").replace("\\s+", " ")) + return hits + + +def extract_create_columns(sql_text: str, table_name: str) -> list[str]: + """字段提取:从 CREATE TABLE 中按源顺序读取字段名。""" + match = re.search(rf"CREATE TABLE `{re.escape(table_name)}` \((.*?)\) ENGINE=", sql_text, flags=re.S | re.I) + if not match: + return [] + columns: list[str] = [] + for line in match.group(1).splitlines(): + stripped = line.strip().rstrip(",") + if stripped.startswith("`"): + columns.append(stripped.split("`", 2)[1]) + return columns + + +def extract_insert_rows(sql_text: str, table_name: str, columns: list[str]) -> list[dict[str, Any]]: + """数据提取:只解析 INSERT VALUES 数据,不执行源 SQL。""" + rows: list[dict[str, Any]] = [] + pattern = re.compile(rf"INSERT INTO `{re.escape(table_name)}` VALUES\s*(.*?);", flags=re.S | re.I) + for match in pattern.finditer(sql_text): + tuples = parse_values_clause(match.group(1)) + for values in tuples: + if len(values) != len(columns): + raise ImportValidationError( + f"{table_name} INSERT 字段数不匹配:期望 {len(columns)},实际 {len(values)}。" + ) + row = {columns[index]: normalize_value(table_name, columns[index], value) for index, value in enumerate(values)} + rows.append(row) + return rows + + +def parse_values_clause(values_clause: str) -> list[list[Any]]: + """VALUES 解析:把 SQL values 子句解析为二维数组,损坏字符串会直接报错。""" + rows: list[list[Any]] = [] + index = 0 + length = len(values_clause) + while index < length: + while index < length and values_clause[index].isspace(): + index += 1 + if index >= length: + break + if values_clause[index] == ",": + index += 1 + continue + if values_clause[index] != "(": + raise ImportValidationError(f"VALUES 子句格式错误:第 {index} 个字符不是 '('。") + row, index = _parse_tuple(values_clause, index) + rows.append(row) + return rows + + +def _parse_tuple(text: str, start: int) -> tuple[list[Any], int]: + """元组解析:解析一组括号内的 SQL 字段值。""" + values: list[Any] = [] + token: list[str] = [] + in_string = False + was_string = False + escaped = False + index = start + 1 + while index < len(text): + char = text[index] + if in_string: + if escaped: + token.append(_unescape_char(char)) + escaped = False + elif char == "\\": + escaped = True + elif char == ")": + recovered_at_end = _recover_unclosed_string_at_tuple_end(token) + if recovered_at_end: + string_value, raw_values = recovered_at_end + values.append(string_value) + values.extend(raw_values) + return values, index + 1 + token.append(char) + elif char == "'": + recovered = _recover_misplaced_quote_separator(token, text[index + 1] if index + 1 < len(text) else "") + if recovered: + string_value, raw_values = recovered + values.append(string_value) + values.extend(raw_values) + token = [] + was_string = True + index += 1 + continue + in_string = False + else: + token.append(char) + index += 1 + continue + + if char == "'": + stripped = "".join(token).strip() + if stripped: + raise ImportValidationError(f"字符串前存在非法未引用内容:{stripped[:20]}") + in_string = True + was_string = True + index += 1 + continue + if was_string and char.isspace(): + index += 1 + continue + if was_string and char not in {",", ")"}: + raise ImportValidationError(f"字符串后存在非法未引用内容:{char}{text[index + 1:index + 20]}") + if char == ",": + values.append(_coerce_raw_token("".join(token), was_string)) + token = [] + was_string = False + index += 1 + continue + if char == ")": + values.append(_coerce_raw_token("".join(token), was_string)) + return values, index + 1 + token.append(char) + index += 1 + raise ImportValidationError("VALUES 子句存在未闭合括号或未闭合字符串。") + + +def _recover_misplaced_quote_separator(token: list[str], next_char: str) -> tuple[str, list[Any]] | None: + """兼容解析:修复接口 SQL 文本字段结尾写成 `文本,'next'` 的引号/逗号错位。""" + if not next_char or next_char in {",", ")"} or next_char.isspace(): + return None + raw = "".join(token) + if not raw.endswith(","): + return None + + body = raw[:-1] + trailing_values: list[Any] = [] + while True: + head, separator, tail = body.rpartition(",") + if not separator or not _looks_like_unquoted_scalar(tail): + break + trailing_values.insert(0, _coerce_raw_token(tail, was_string=False)) + body = head + if not body: + return None + return body, trailing_values + + +def _recover_unclosed_string_at_tuple_end(token: list[str]) -> tuple[str, list[Any]] | None: + """兼容解析:修复文本字段缺少结束引号且后接 `,数字)` 的源 SQL。""" + body = "".join(token) + trailing_values: list[Any] = [] + while True: + head, separator, tail = body.rpartition(",") + if not separator or not _looks_like_unquoted_scalar(tail): + break + trailing_values.insert(0, _coerce_raw_token(tail, was_string=False)) + body = head + if not body or not trailing_values: + return None + return body, trailing_values + + +def _looks_like_unquoted_scalar(value: str) -> bool: + """兼容解析:判断错位引号前夹带的字段是否是可安全恢复的未引用标量。""" + stripped = value.strip() + if not stripped: + return False + if stripped.upper() == "NULL": + return True + return bool(re.fullmatch(r"-?\d+(?:\.\d+)?", stripped)) + + +def _unescape_char(char: str) -> str: + """SQL 转义:处理 dump 中的常见反斜杠转义。""" + return { + "0": "\0", + "b": "\b", + "n": "\n", + "r": "\r", + "t": "\t", + "Z": "\x1a", + "\\": "\\", + "'": "'", + '"': '"', + }.get(char, char) + + +def _coerce_raw_token(raw: str, was_string: bool) -> Any: + """原始字段转换:区分 SQL NULL、数字和字符串。""" + if was_string: + return raw + value = raw.strip() + if not value: + return "" + if value.upper() == "NULL": + return None + if re.fullmatch(r"-?\d+", value): + return int(value) + if re.fullmatch(r"-?\d+\.\d+", value): + return Decimal(value) + if re.search(r"[A-Za-z\u4e00-\u9fff]", value): + raise ImportValidationError(f"发现未引用文本字段:{value[:30]}") + return value + + +def normalize_value(table_name: str, column: str, value: Any) -> Any: + """字段归一:按当前 ORM 需要转换 JSON、时间、布尔和数值。""" + if value is None: + return None + if column in JSON_COLUMNS.get(table_name, set()): + if isinstance(value, (list, dict)): + return value + try: + return json.loads(value) + except (TypeError, json.JSONDecodeError) as exc: + raise ImportValidationError(f"{table_name}.{column} 不是合法 JSON。") from exc + if column in DATETIME_COLUMNS and isinstance(value, str): + try: + return datetime.fromisoformat(value) + except ValueError as exc: + raise ImportValidationError(f"{table_name}.{column} 不是合法 datetime。") from exc + if column in BOOL_COLUMNS: + return bool(int(value)) + if column in INT_COLUMNS and value is not None: + return int(value) + if column in DECIMAL_COLUMNS and value is not None: + return Decimal(str(value)) + return value + + +def parse_source_dump(path: Path) -> tuple[dict[str, list[dict[str, Any]]], list[str], str]: + """源文件解析:提取可导入表数据并返回兼容性警告。""" + sql_text, encoding = load_sql_text(path) + warnings = [] + dangerous = find_dangerous_statements(sql_text) + if dangerous: + warnings.append("源 SQL 包含 DDL/DML 覆盖语句,导入器会忽略这些语句:" + ", ".join(sorted(set(dangerous)))) + + parsed: dict[str, list[dict[str, Any]]] = {} + for table_name in SOURCE_TABLES: + columns = extract_create_columns(sql_text, table_name) + if not columns: + raise ImportValidationError(f"源 SQL 缺少必需表结构:{table_name}") + parsed[table_name] = extract_insert_rows(sql_text, table_name, columns) + + for table_name in OPTIONAL_SOURCE_TABLES: + columns = extract_create_columns(sql_text, table_name) + if not columns: + warnings.append(f"源 SQL 未包含 {table_name},导入器会按当前业务规则处理。") + continue + parsed[table_name] = extract_insert_rows(sql_text, table_name, columns) + if not parsed.get("traditional_case") and not parsed.get("teaching_case"): + warnings.append("源 SQL 未包含 traditional_case 或 teaching_case,病例导入后暂时缺少训练模式扩展数据。") + if not parsed.get("scoring_rule"): + warnings.append("源 SQL 未包含 scoring_rule,评价时将缺少接口侧基础评分规则。") + return parsed, warnings, encoding + + +def validate_target_schema(parsed: dict[str, list[dict[str, Any]]]) -> None: + """目标结构校验:确认源字段可映射到当前数据库表。""" + with SessionLocal() as db: + inspector = inspect(db.bind) + for table_name, rows in parsed.items(): + if not inspector.has_table(table_name): + raise ImportValidationError(f"当前数据库缺少目标表:{table_name}") + target_columns = {column["name"] for column in inspector.get_columns(table_name)} + for row in rows: + extra_columns = sorted(set(row) - target_columns) + if extra_columns: + raise ImportValidationError(f"{table_name} 存在当前库不支持的字段:{extra_columns}") + + +def import_source_sql(path: Path, apply: bool = False, generate_exam_items: bool = True) -> ImportReport: + """安全导入:解析源 SQL,并在显式 apply 时写入当前新表。""" + parsed, warnings, encoding = parse_source_dump(path) + validate_target_schema(parsed) + report = ImportReport( + source_path=str(path), + encoding=encoding, + table_rows={table: len(rows) for table, rows in parsed.items()}, + warnings=warnings, + applied=apply, + ) + if not apply: + return report + + with SessionLocal() as db: + try: + case_rows = parsed.get("case_base", []) + report.upserted_cases = _upsert_cases(db, case_rows) + if "traditional_case" in parsed: + report.upserted_traditional_cases = _sync_traditional_cases(db, case_rows, parsed.get("traditional_case", [])) + if "teaching_case" in parsed: + report.upserted_teaching_cases = _sync_teaching_cases(db, case_rows, parsed.get("teaching_case", [])) + if "scoring_rule" in parsed: + report.replaced_scoring_rules = _replace_scoring_rules(db, case_rows, parsed.get("scoring_rule", [])) + if generate_exam_items: + report.generated_exam_items = _upsert_generated_exam_items(db, case_rows) + db.commit() + except Exception: + db.rollback() + raise + return report + + +def _upsert_cases(db, rows: list[dict[str, Any]]) -> int: + """病例导入:按 case_base.id 更新或插入病例主表。""" + count = 0 + for row in rows: + row = dict(row) + row["status"] = 1 + row["publish_status"] = 1 + entity = db.get(CaseBase, row["id"]) + if not entity: + db.add(CaseBase(**row)) + else: + for key, value in row.items(): + setattr(entity, key, value) + count += 1 + return count + + +def _sync_traditional_cases(db, case_rows: list[dict[str, Any]], rows: list[dict[str, Any]]) -> int: + """传统病例导入:按本次 SQL 同步练习模式扩展表,避免同 case_id 旧数据残留。""" + case_ids = _imported_case_ids(case_rows) + if case_ids: + db.execute(delete(TraditionalCase).where(TraditionalCase.case_id.in_(case_ids))) + count = 0 + for row in rows: + db.add(TraditionalCase(**row)) + count += 1 + return count + + +def _sync_teaching_cases(db, case_rows: list[dict[str, Any]], rows: list[dict[str, Any]]) -> int: + """教学互动病例导入:源 SQL 明确提供 teaching_case 时,以源数据为准同步扩展表。""" + case_ids = _imported_case_ids(case_rows) + if case_ids: + db.execute(delete(TeachingCase).where(TeachingCase.case_id.in_(case_ids))) + count = 0 + for row in rows: + db.add(TeachingCase(**row)) + count += 1 + return count + + +def _replace_scoring_rules(db, case_rows: list[dict[str, Any]], rows: list[dict[str, Any]]) -> int: + """评分规则导入:按本次导入病例替换 scoring_rule,保持评分规则与源数据一致。""" + case_ids = _imported_case_ids(case_rows) + for case_id in case_ids: + db.execute(delete(ScoringRule).where(ScoringRule.case_id == case_id)) + for row in rows: + db.add(ScoringRule(**row)) + return len(rows) + + +def _upsert_generated_exam_items(db, case_rows: list[dict[str, Any]]) -> int: + """检查项目补齐:按 item_code 更新或补齐固定检查结果,避免删除历史训练引用的检查项。""" + changed = 0 + for row in case_rows: + case_id = row["id"] + items = build_exam_items_from_case(row) + for item in items: + entity = db.scalar( + select(CaseExamItem).where(CaseExamItem.case_id == case_id, CaseExamItem.item_code == item["item_code"]) + ) + if not entity: + db.add(CaseExamItem(case_id=case_id, **item)) + else: + for key, value in item.items(): + setattr(entity, key, value) + changed += 1 + return changed + + +def _imported_case_ids(case_rows: list[dict[str, Any]]) -> list[int]: + """导入范围:提取本次 SQL 涉及的病例 ID,用于同步扩展表。""" + return sorted({int(row["id"]) for row in case_rows if row.get("id") is not None}) + + +def build_exam_items_from_case(case_row: dict[str, Any]) -> list[dict[str, Any]]: + """检查项目生成:从病例文本识别常见检查结果,保障 Demo 可继续申请检查。""" + text = " ".join(str(case_row.get(key) or "") for key in ("chief_complaint", "description", "knowledge_points")) + candidates = [ + ( + "blood_routine", + "血常规", + "lab", + "实验室检查", + _find_result(text, r"(WBC[^,。;;]*[,,]\s*[^,。;;]*(?:中性|neutrophil)[^,。;;]*)") or "血常规结果见病例资料。", + {"source": "case_description"}, + "WBC" in text or "血常规" in text, + ), + ( + "crp", + "CRP", + "lab", + "实验室检查", + _find_result(text, r"(CRP\s*[^,。;;]*)") or "CRP 结果见病例资料。", + {"source": "case_description"}, + "CRP" in text.upper(), + ), + ( + "chest_xray", + "胸片", + "imaging", + "影像检查", + _find_result(text, r"(胸片[^。;;]*)") or "胸片结果见病例资料。", + {"source": "case_description"}, + "胸片" in text or "X线" in text, + ), + ( + "spo2", + "血氧饱和度", + "vital_sign", + "生命体征", + _find_result(text, r"(SpO2\s*\d+%?)") or "血氧饱和度结果见病例资料。", + {"source": "case_description"}, + "SpO2" in text or "血氧" in text, + ), + ( + "chest_auscultation", + "肺部体格检查", + "physical_exam", + "体格检查", + _find_result(text, r"(肺[^。;;]*(?:湿啰音|哮鸣音|呼吸音)[^。;;]*)") or "肺部体格检查结果见病例资料。", + {"source": "case_description"}, + "湿啰音" in text or "哮鸣音" in text or "肺" in text, + ), + ( + "mp_igm", + "肺炎支原体抗体IgM", + "lab", + "实验室检查", + _find_result(text, r"(肺炎支原体抗体IgM[^,。;;]*)") or "肺炎支原体抗体IgM结果见病例资料。", + {"source": "case_description"}, + "肺炎支原体抗体IgM" in text or "支原体" in text, + ), + ] + items: list[dict[str, Any]] = [] + for index, (code, name, item_type, category, result_text, structured, detected) in enumerate(candidates, start=1): + if detected: + items.append( + { + "item_code": code, + "item_name": name, + "item_type": item_type, + "category": category, + "result_text": result_text, + "result_structured": structured, + "is_key": True, + "is_abnormal": True, + "score_weight": Decimal("5.00"), + "display_order": index, + } + ) + if items: + return items + return [ + { + "item_code": "basic_exam", + "item_name": "基础检查", + "item_type": "physical_exam", + "category": "体格检查", + "result_text": "基础检查结果见病例资料。", + "result_structured": {"source": "case_description"}, + "is_key": True, + "is_abnormal": False, + "score_weight": Decimal("1.00"), + "display_order": 1, + } + ] + + +def _find_result(text: str, pattern: str) -> str | None: + """文本抽取:从病例描述中抽取简短检查结果。""" + match = re.search(pattern, text, flags=re.I) + return match.group(1).strip() if match else None + + +def main() -> None: + """命令入口:执行接口 SQL 的安全检查或导入。""" + parser = argparse.ArgumentParser(description="Safely import parsed case SQL into current medical agent schema.") + parser.add_argument("sql_path", type=Path, help="接口提供的 SQL dump 文件路径") + parser.add_argument("--apply", action="store_true", help="确认写入当前数据库;默认只检查不写入") + parser.add_argument("--no-generate-exam-items", action="store_true", help="不自动补齐 case_exam_item") + args = parser.parse_args() + + try: + report = import_source_sql( + args.sql_path, + apply=args.apply, + generate_exam_items=not args.no_generate_exam_items, + ) + except ImportValidationError as exc: + print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False, indent=2)) + raise SystemExit(2) from exc + + print(json.dumps({"ok": True, "report": report.as_dict()}, ensure_ascii=False, indent=2, default=str)) + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/init_demo_db.py b/backend/scripts/init_demo_db.py new file mode 100644 index 0000000..78caa41 --- /dev/null +++ b/backend/scripts/init_demo_db.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +from sqlalchemy import select + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app.db.base import Base +from app.db.session import SessionLocal, engine +from app.models import ( + CaseBase, + CaseExamItem, + Department, + KnowledgeChunk, + KnowledgeDocument, + KnowledgeSource, + PromptTemplate, + ScoringRule, + TeachingCase, + TraditionalCase, + User, +) + + +def init_database() -> None: + """数据库初始化:创建当前新表体系并写入第一版 Demo 种子数据。""" + Base.metadata.create_all(bind=engine) + with SessionLocal() as db: + seed_demo_data(db) + db.commit() + + +def seed_demo_data(db) -> None: + """病例导入:写入儿科支气管肺炎病例、检查项目、评分规则和提示词元数据。""" + department = _get_or_create_department(db) + user = _get_or_create_seed_user(db) + case = _get_or_create_case_base(db, department.id, user.id) + _seed_traditional_case(db, case.id) + _seed_teaching_case(db, case.id) + _seed_exam_items(db, case.id) + _seed_scoring_rules(db, case.id) + _seed_knowledge(db, department.id) + _seed_prompts(db) + + +def _get_or_create_department(db) -> Department: + """科室种子:写入儿科科室。""" + department = db.scalar(select(Department).where(Department.code == "PEDIATRICS")) + if department: + return department + department = Department(name="儿科", code="PEDIATRICS", sort_order=1, is_active=True) + db.add(department) + db.flush() + return department + + +def _get_or_create_seed_user(db) -> User: + """用户占位:写入系统种子用户,不承担登录职责。""" + user = db.scalar(select(User).where(User.external_user_id == "system_seed")) + if user: + return user + user = User(external_user_id="system_seed", display_name="系统种子数据") + db.add(user) + db.flush() + return user + + +def _get_or_create_case_base(db, department_id: int, user_id: int) -> CaseBase: + """病例主表种子:以 case_base 作为病例唯一主表。""" + case = db.scalar(select(CaseBase).where(CaseBase.title == "支气管肺炎 - 6岁男性患儿")) + if case: + return case + case = CaseBase( + title="支气管肺炎 - 6岁男性患儿", + case_type="diagnosis_treatment", + difficulty="medium", + difficulty_score=2, + chief_complaint="发热、咳嗽4天,喘息1天", + description=( + "患儿4天前无明显诱因出现发热,最高体温39.2℃,伴阵发性咳嗽,后有少量白色黏痰。" + "1天前出现喘息,夜间明显,活动后加重。精神较差,食欲下降,小便略少。" + ), + patient_age=6, + patient_gender="male", + tags="pediatrics,pneumonia,demo", + symptom_tags=["发热", "咳嗽", "喘息", "精神食纳差"], + disease_tags=["支气管肺炎"], + competency_tags=["问诊完整性", "儿科查体规范", "关键症状识别", "诊断准确性", "治疗计划合理性"], + guideline_tags=["CAP_2019", "HUMANISTIC_CARE"], + knowledge_points=["血常规", "CRP", "胸片", "血氧饱和度", "肺部湿啰音"], + icd_codes="", + estimated_minutes=20, + osce_enabled=False, + rag_enabled=True, + ai_prompt_template="app/prompts/patient/practice.md", + multimodal_assets=[], + vector_status=0, + publish_status=1, + status=1, + created_by_id=user_id, + department_id=department_id, + ) + db.add(case) + db.flush() + return case + + +def _seed_traditional_case(db, case_id: int) -> None: + """传统病例种子:练习模式读取 case_base + traditional_case。""" + if db.scalar(select(TraditionalCase).where(TraditionalCase.case_id == case_id)): + return + db.add( + TraditionalCase( + case_id=case_id, + standard_diagnosis="支气管肺炎", + standard_treatment=( + "抗感染、止咳平喘、改善氧合、严密观察病情变化;必要时雾化吸入缓解喘息," + "监测体温、呼吸、血氧、精神反应和饮水尿量,出现低氧或呼吸困难加重时及时升级处理。" + ), + guideline_reference=( + "诊断依据:发热、咳嗽、喘息,肺部湿啰音,炎症指标升高,胸片提示右下肺片状模糊影," + "符合儿童社区获得性肺炎/支气管肺炎诊断思路。严重程度需结合呼吸频率、SpO2、意识、循环和进食饮水情况。" + ), + ) + ) + + +def _seed_teaching_case(db, case_id: int) -> None: + """教学互动病例种子:教学互动模式读取 case_base + teaching_case。""" + if db.scalar(select(TeachingCase).where(TeachingCase.case_id == case_id)): + return + db.add( + TeachingCase( + case_id=case_id, + teaching_goal="围绕儿科肺炎问诊、检查选择、诊断依据、治疗决策和医患沟通完成互动训练。", + discussion_questions="如何判断病情严重程度?哪些检查是关键检查?治疗方案如何兼顾抗感染、平喘和氧合监测?", + teacher_guide="观察学生是否完整追问发热、咳嗽、喘息、既往史、接触史,并能解释胸片、炎症指标和血氧。", + scoring_focus="问诊完整性、检查合理性、诊断准确性、治疗计划、风险预案、人文沟通。", + ) + ) + + +def _seed_exam_items(db, case_id: int) -> None: + """检查项目种子:写入病例可申请检查和固定返回结果。""" + if db.scalar(select(CaseExamItem).where(CaseExamItem.case_id == case_id)): + return + items = [ + ("blood_routine", "血常规", "lab", "WBC 12.5×10^9/L,中性粒细胞比例72%,提示感染及炎症反应。", {"wbc": "12.5×10^9/L", "neutrophil": "72%"}, True, True, 1), + ("crp", "CRP", "lab", "CRP 28 mg/L,提示炎症反应升高。", {"crp": "28 mg/L"}, True, True, 2), + ("chest_xray", "胸片", "imaging", "双下肺纹理增多,右下肺片状模糊影,支持肺部感染。", {"finding": "右下肺片状模糊影"}, True, True, 3), + ("spo2", "血氧饱和度", "vital_sign", "室内空气 SpO2 94%,处于临界偏低范围。", {"spo2": "94%"}, True, True, 4), + ("mp_igm", "肺炎支原体IgM", "lab", "肺炎支原体IgM阴性。", {"mp_igm": "negative"}, False, False, 5), + ] + for code, name, item_type, result_text, structured, is_key, abnormal, order in items: + db.add( + CaseExamItem( + case_id=case_id, + item_code=code, + item_name=name, + item_type=item_type, + category=item_type, + result_text=result_text, + result_structured=structured, + is_key=is_key, + is_abnormal=abnormal, + score_weight=5.0 if is_key else 1.0, + display_order=order, + ) + ) + + +def _seed_scoring_rules(db, case_id: int) -> None: + """评分规则种子:写入 scoring_rule,评价时作为基础评分细则。""" + if db.scalar(select(ScoringRule).where(ScoringRule.case_id == case_id)): + return + rules = [ + ("信息获取", "问诊完整性", 25, "覆盖现病史、既往史、个人史、家族史、儿科特异性症状与家属担忧。"), + ("分析推理", "诊断与鉴别诊断", 25, "结合症状、体征、胸片、炎症指标和血氧支持支气管肺炎诊断,并列出合理鉴别诊断。"), + ("处置决策", "检查与治疗方案", 20, "检查申请合理,治疗原则覆盖抗感染、止咳平喘、改善氧合、风险预案和随访。"), + ("沟通人文", "家属沟通", 15, "向家属说明病情、用药注意事项、危险信号、复诊或住院指征,并回应焦虑。"), + ("临床整合", "流程与整体思维", 15, "流程连贯,把问诊、检查、诊断、治疗和沟通整合成完整临床决策。"), + ] + for dimension, competency, weight, standard in rules: + db.add( + ScoringRule( + case_id=case_id, + dimension=dimension, + competency_dimension=competency, + score_weight=weight, + ai_auto_score=True, + osce_dimension=False, + scoring_standard=standard, + rubric_json={"max_score": weight, "criteria": standard}, + ) + ) + + +def _seed_knowledge(db, department_id: int) -> None: + """知识库种子:写入评分参考指南和人文沟通片段。""" + if db.scalar(select(KnowledgeSource).where(KnowledgeSource.source_code == "CAP_2019")): + return + source = KnowledgeSource( + source_code="CAP_2019", + source_name="儿童社区获得性肺炎诊疗规范(2019年版)", + source_type="clinical_guideline", + authority_level=5, + is_active=True, + ) + human = KnowledgeSource( + source_code="HUMANISTIC_CARE", + source_name="问诊沟通与人文关怀要求", + source_type="humanistic_care", + authority_level=4, + is_active=True, + ) + db.add_all([source, human]) + db.flush() + doc = KnowledgeDocument( + source_id=source.id, + department_id=department_id, + title="儿童社区获得性肺炎诊疗规范摘要", + task_type="diagnosis_treatment", + summary="用于肺炎病例诊断、严重程度评估和治疗评分参考。", + file_path="docs/knowledge/cap_2019.md", + is_active=True, + ) + human_doc = KnowledgeDocument( + source_id=human.id, + department_id=department_id, + title="儿科问诊人文关怀要点", + task_type="diagnosis_treatment", + summary="用于评价家属沟通、知情告知和健康教育。", + file_path="docs/knowledge/humanistic_care.md", + is_active=True, + ) + db.add_all([doc, human_doc]) + db.flush() + db.add_all( + [ + KnowledgeChunk( + document_id=doc.id, + department_id=department_id, + task_type="diagnosis_treatment", + chunk_text="儿童肺炎诊断需综合发热、咳嗽、喘息、肺部湿啰音、炎症指标和胸部影像学新发浸润影。", + keywords=["发热", "咳嗽", "喘息", "胸片异常"], + weight=5.0, + is_active=True, + ), + KnowledgeChunk( + document_id=doc.id, + department_id=department_id, + task_type="diagnosis_treatment", + chunk_text="严重程度评估应关注呼吸频率、血氧饱和度、意识状态、循环状态和进食饮水情况。", + keywords=["血氧饱和度下降", "呼吸", "严重程度"], + weight=4.5, + is_active=True, + ), + KnowledgeChunk( + document_id=human_doc.id, + department_id=department_id, + task_type="diagnosis_treatment", + chunk_text="儿科问诊需要向家属说明病情观察指标、用药注意事项、复诊指征,并给予情绪安抚。", + keywords=["沟通", "健康教育", "家属"], + weight=4.0, + is_active=True, + ), + ] + ) + + +def _seed_prompts(db) -> None: + """提示词种子:写入 Markdown 模板元数据,正文保存在 prompts 目录。""" + templates = [ + ("patient_practice", "patient", "practice", "v1", "fast", "text", "app/prompts/patient/practice.md"), + ("patient_teaching", "patient", "teaching", "v1", "fast", "text", "app/prompts/patient/teaching.md"), + ("novice_case_hint", "hint", "novice", "v1", "fast", "json", "app/prompts/hint/novice_case_hint.md"), + ("scoring_pediatrics_pneumonia", "scoring", "pediatrics_pneumonia", "v1", "fast", "json", "app/prompts/scoring/pediatrics_pneumonia.md"), + ("report_evaluation", "report", "evaluation", "v1", "fast", "json", "app/prompts/report/evaluation_report.md"), + ] + for code, agent_type, scene, version, model_type, output_format, file_path in templates: + template = db.scalar(select(PromptTemplate).where(PromptTemplate.template_code == code, PromptTemplate.version_no == version)) + if not template: + template = PromptTemplate(template_code=code, version_no=version) + template.agent_type = agent_type + template.scene = scene + template.model_type = model_type + template.output_format = output_format + template.file_path = file_path + template.is_active = True + db.add(template) + + +def ensure_storage_dirs() -> None: + """目录初始化:创建报告导出目录。""" + Path("storage/reports").mkdir(parents=True, exist_ok=True) + + +if __name__ == "__main__": + ensure_storage_dirs() + init_database() + print("Demo database initialized.") diff --git a/backend/scripts/migrate_to_new_schema.py b/backend/scripts/migrate_to_new_schema.py new file mode 100644 index 0000000..52d7b57 --- /dev/null +++ b/backend/scripts/migrate_to_new_schema.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +from sqlalchemy import text + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app.db.session import SessionLocal +from scripts.init_demo_db import init_database + + +def main() -> None: + """新表迁移:创建并补齐 case_base、traditional_case、teaching_case、case_exam_item、training_* 和 training_record。""" + init_database() + with SessionLocal() as db: + _apply_table_comments(db) + db.commit() + print("new schema migration completed") + + +def _apply_table_comments(db) -> None: + """表注释补齐:为当前业务表写入中文说明,便于数据库工具查看。""" + comments = { + "case_base": "病例主表", + "traditional_case": "传统病例扩展表", + "teaching_case": "教学互动病例扩展表", + "scoring_rule": "评分规则表", + "case_exam_item": "病例检查检验项目表", + "training_session": "训练会话表", + "training_order": "训练检查申请表", + "training_submission": "训练诊断治疗提交表", + "training_record": "训练记录表", + } + dialect = db.bind.dialect.name if db.bind else "" + if dialect != "mysql": + return + for table_name, comment in comments.items(): + safe_comment = comment.replace("'", "''") + db.execute(text(f"ALTER TABLE `{table_name}` COMMENT='{safe_comment}'")) + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_api_contract.py b/backend/tests/test_api_contract.py new file mode 100644 index 0000000..510cf57 --- /dev/null +++ b/backend/tests/test_api_contract.py @@ -0,0 +1,227 @@ +import os +import sys +from decimal import Decimal +from pathlib import Path + +os.environ.setdefault("DATABASE_URL", "sqlite:///./storage/test_api_contract.db") +os.environ.setdefault("RUNTIME_MEMORY_BACKEND", "memory") +os.environ.setdefault("LLM_MOCK_ENABLED", "true") + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +Path("storage").mkdir(exist_ok=True) + +try: + from fastapi.testclient import TestClient +except ImportError: + TestClient = None + + +def run_api_contract_tests() -> None: + """API 合约:验证统一响应、user_id 校验、核心接口和跨用户隔离。""" + if TestClient is None: + print("api contract tests skipped: fastapi is not installed") + return + + from app.main import app + from app.db.session import SessionLocal + from app.models.source_case import CaseBase, CaseExamItem, ScoringRule, TraditionalCase + from app.repositories.case_repository import CaseRepository + from scripts.init_demo_db import init_database + + init_database() + client = TestClient(app) + headers = {"X-User-Id": "api_user_001", "X-Entry-Scene": "api_test"} + + missing_user = client.get("/api/v1/agent/hello") + assert missing_user.status_code == 401 + assert missing_user.json()["code"] == "USER_ID_REQUIRED" + + hello = client.get("/api/v1/agent/hello", headers=headers) + assert hello.status_code == 200 + assert hello.json()["code"] == "OK" + + cases = client.get("/api/v1/cases", headers=headers) + assert cases.status_code == 200 + case_id = cases.json()["data"]["items"][0]["id"] + + created = client.post( + "/api/v1/sessions", + headers=headers, + json={"case_id": case_id, "training_type": "diagnosis_treatment", "mode": "practice", "score_type": "percentage"}, + ) + assert created.status_code == 200 + session_id = created.json()["data"]["session_id"] + + cross_user = client.get(f"/api/v1/sessions/{session_id}/order-items", headers={"X-User-Id": "api_user_002"}) + assert cross_user.status_code == 404 + assert cross_user.json()["code"] == "SESSION_NOT_FOUND" + + invalid_order = client.post(f"/api/v1/sessions/{session_id}/orders", headers=headers, json={"item_code": "bad_item"}) + assert invalid_order.status_code == 404 + assert invalid_order.json()["code"] == "ORDER_ITEM_NOT_FOUND" + + order_one = client.post(f"/api/v1/sessions/{session_id}/orders", headers=headers, json={"item_code": "blood_routine"}) + assert order_one.status_code == 200 + assert order_one.json()["data"]["already_ordered"] is False + order_two = client.post(f"/api/v1/sessions/{session_id}/orders", headers=headers, json={"item_code": "blood_routine"}) + assert order_two.status_code == 200 + assert order_two.json()["data"]["already_ordered"] is True + + practice_hint_session = client.post( + "/api/v1/sessions", + headers=headers, + json={"case_id": case_id, "training_type": "diagnosis_treatment", "mode": "practice", "score_type": "percentage"}, + ) + assert practice_hint_session.status_code == 200 + practice_hint_session_id = practice_hint_session.json()["data"]["session_id"] + hint = client.post( + f"/api/v1/sessions/{practice_hint_session_id}/hints", + headers=headers, + json={"scope": "current_conversation"}, + ) + assert hint.status_code == 200 + assert hint.json()["data"]["hints"] + assert "recommended_orders" in hint.json()["data"] + + 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" + + llm_fast = client.post("/api/v1/llm/test/deepseek-fast", headers=headers, json={"message": "hello"}) + assert llm_fast.status_code == 200 + assert llm_fast.json()["code"] == "OK" + assert llm_fast.json()["data"]["stream"] is False + + llm_reason = client.post("/api/v1/llm/test/deepseek-reason", headers=headers, json={"message": "hello"}) + assert llm_reason.status_code == 200 + assert llm_reason.json()["code"] == "OK" + assert "total_latency_ms" in llm_reason.json()["data"] + + import_preview = client.post( + "/api/v1/imports/case-sql/preview", + headers=headers, + files={"file": ("bad.sql", b"not sql", "application/sql")}, + ) + assert import_preview.status_code == 200 + assert import_preview.json()["code"] == "OK" + assert import_preview.json()["data"]["can_import"] is False + assert import_preview.json()["data"]["errors"] + + import_apply = client.post( + "/api/v1/imports/case-sql/apply", + headers=headers, + files={"file": ("bad.sql", b"not sql", "application/sql")}, + ) + assert import_apply.status_code == 400 + assert import_apply.json()["code"] == "CASE_SQL_IMPORT_INVALID" + + temp_case_id = 880001 + with SessionLocal() as db: + CaseRepository(db).delete_case_cascade(temp_case_id) + db.add( + CaseBase( + id=temp_case_id, + title="删除测试病例", + case_type="diagnosis_treatment", + difficulty="medium", + chief_complaint="发热、咳嗽", + description="用于删除接口测试的临时病例", + patient_age=6, + patient_gender="male", + tags="test", + symptom_tags=[], + disease_tags=[], + competency_tags=[], + guideline_tags=[], + knowledge_points=[], + icd_codes="", + osce_enabled=False, + rag_enabled=False, + ai_prompt_template="", + multimodal_assets=[], + vector_status=0, + publish_status=1, + status=1, + department_id=1, + ) + ) + db.add( + TraditionalCase( + id=temp_case_id, + case_id=temp_case_id, + standard_diagnosis="测试诊断", + standard_treatment="测试治疗", + guideline_reference="测试指南", + ) + ) + db.add( + ScoringRule( + id=temp_case_id, + case_id=temp_case_id, + dimension="信息采集", + competency_dimension="问诊完整性", + score_weight=Decimal("10.00"), + ai_auto_score=True, + osce_dimension=False, + scoring_standard="测试评分标准", + rubric_json={}, + ) + ) + db.add( + CaseExamItem( + id=temp_case_id, + case_id=temp_case_id, + item_code="temp_exam", + item_name="测试检查", + item_type="lab", + result_text="测试结果", + result_structured={}, + is_key=True, + is_abnormal=False, + score_weight=Decimal("1.00"), + display_order=1, + ) + ) + db.commit() + + delete_preview = client.get(f"/api/v1/cases/{temp_case_id}/delete-preview", headers=headers) + assert delete_preview.status_code == 200 + assert delete_preview.json()["data"]["affected"]["case_base"] == 1 + assert delete_preview.json()["data"]["affected"]["case_exam_item"] == 1 + + delete_without_confirm = client.request( + "DELETE", + f"/api/v1/cases/{temp_case_id}", + headers=headers, + json={"confirm": False, "delete_training_data": True}, + ) + assert delete_without_confirm.status_code == 400 + assert delete_without_confirm.json()["code"] == "CASE_DELETE_CONFIRM_REQUIRED" + + delete_case = client.request( + "DELETE", + f"/api/v1/cases/{temp_case_id}", + headers=headers, + json={"confirm": True, "delete_training_data": True}, + ) + assert delete_case.status_code == 200 + assert delete_case.json()["data"]["deleted_counts"]["case_base"] == 1 + + deleted_detail = client.get(f"/api/v1/cases/{temp_case_id}", headers=headers) + assert deleted_detail.status_code == 404 + assert deleted_detail.json()["code"] == "CASE_NOT_FOUND" + + +if __name__ == "__main__": + run_api_contract_tests() + print("api contract tests passed") diff --git a/backend/tests/test_core_logic.py b/backend/tests/test_core_logic.py new file mode 100644 index 0000000..59cb306 --- /dev/null +++ b/backend/tests/test_core_logic.py @@ -0,0 +1,87 @@ +import sys +import os +from pathlib import Path + +os.environ.setdefault("DATABASE_URL", "sqlite:///./storage/test_core.db") +os.environ.setdefault("RUNTIME_MEMORY_BACKEND", "memory") +os.environ.setdefault("LLM_MOCK_ENABLED", "true") + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app.agents.scoring_agent import ScoringAgent +from app.agents.hint_agent import HintAgent +from app.agents.llm_adapter import OpenAICompatibleLLMClient +from app.core.config import settings +from app.services.runtime_memory import InMemoryRuntimeMemoryService + + +def test_runtime_memory_lifecycle() -> None: + """短期 memory:验证创建、写入、读取和释放流程。""" + memory = InMemoryRuntimeMemoryService() + memory.create("test-memory", "开场白") + memory.add_message("test-memory", "doctor", "孩子发热几天?") + assert memory.has_doctor_message("test-memory") is True + assert len(memory.get_messages("test-memory")) == 2 + memory.release("test-memory") + assert memory.get_messages("test-memory") == [] + + +def test_score_convert_to_five_point() -> None: + """评分转换:验证百分制到五分制的结构转换。""" + agent = ScoringAgent() + result = agent._convert_to_five_point( + { + "score_type": "percentage", + "total_score": 80, + "dimension_scores": [{"dimension": "信息获取", "score": 20, "max_score": 25, "comment": "ok"}], + } + ) + assert result["score_type"] == "five_point" + assert result["total_score"] == 4.0 + assert result["dimension_scores"][0]["max_score"] == 5 + + +def test_public_settings() -> None: + """配置输出:验证 Demo 前端可读取功能开关。""" + public = settings.as_public_dict() + assert "score_types" in public + assert "percentage" in public["score_types"] + + +def test_reasoning_effort_disabled_when_thinking_off() -> None: + """LLM 参数构造:thinking 关闭时不发送 reasoning_effort,避免 reason 测试流式 400。""" + client = OpenAICompatibleLLMClient() + payload = client._build_payload( + model="deepseek-v4-pro", + messages=[{"role": "user", "content": "hello"}], + stream=True, + thinking_enabled=False, + reasoning_effort="low", + max_tokens=128, + ) + assert "reasoning_effort" not in payload + + +def test_hint_agent_invalid_json_fallback() -> None: + """新手提示:验证模型输出结构不匹配时使用稳定 fallback。""" + agent = HintAgent() + payload = { + "case": { + "chief_complaint": "发热、咳嗽4天,喘息1天", + "key_exams": ["blood_routine", "chest_xray"], + }, + "ordered_results": [], + } + result = agent._normalize_output({"score_type": "percentage"}, payload) + assert result["hints"] + assert result["next_questions"] + assert result["recommended_orders"] + + +if __name__ == "__main__": + test_runtime_memory_lifecycle() + test_score_convert_to_five_point() + test_public_settings() + test_reasoning_effort_disabled_when_thinking_off() + test_hint_agent_invalid_json_fallback() + print("core logic tests passed") diff --git a/backend/tests/test_demo_flow.py b/backend/tests/test_demo_flow.py new file mode 100644 index 0000000..f5cd438 --- /dev/null +++ b/backend/tests/test_demo_flow.py @@ -0,0 +1,148 @@ +import asyncio +import os +import sys +from pathlib import Path + +os.environ.setdefault("DATABASE_URL", "sqlite:///./storage/test_demo_flow.db") +os.environ.setdefault("RUNTIME_MEMORY_BACKEND", "memory") +os.environ.setdefault("LLM_MOCK_ENABLED", "true") + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from sqlalchemy import select + +from app.core.context import UserContext +from app.core.exceptions import AppError +from app.db.session import SessionLocal +from app.models.source_case import CaseBase +from app.models.training_record import TrainingRecord +from app.schemas.evaluation import CreateEvaluationRequest +from app.schemas.session import ( + ChatRequest, + CreateOrderRequest, + CreateSessionRequest, + SubmitDiagnosisRequest, + SubmitTreatmentRequest, +) +from app.services.evaluation_service import EvaluationService +from app.services.order_service import OrderService +from app.services.pdf_export_service import PdfExportService +from app.services.runtime_memory import runtime_memory +from app.services.session_service import SessionService +from scripts.init_demo_db import init_database + + +async def run_demo_flow() -> None: + """完整闭环:验证第一版 Demo 的核心训练链路可跑通。""" + init_database() + ctx = UserContext(user_id="demo_flow_user", tenant_id="demo_tenant", entry_scene="service_test") + with SessionLocal() as db: + case = db.scalar(select(CaseBase).where(CaseBase.title == "支气管肺炎 - 6岁男性患儿")) + assert case is not None + + session_service = SessionService(db) + order_service = OrderService(db) + evaluation_service = EvaluationService(db) + pdf_service = PdfExportService(db) + + created = session_service.create_session( + ctx, + CreateSessionRequest( + case_id=case.id, + training_type="diagnosis_treatment", + mode="practice", + score_type="percentage", + ), + ) + db.commit() + assert created.status == "inquiry" + + chat = await session_service.chat(ctx, created.session_id, ChatRequest(message="孩子最高体温多少?").message) + db.commit() + assert chat.reply + + order = order_service.create_order(created.session_id, ctx.user_id, CreateOrderRequest(item_code="chest_xray").item_code) + db.commit() + assert order.is_key is True + tool_count_before = len([item for item in runtime_memory.get_messages(f"mem:{created.session_code}") if item.get("role") == "tool"]) + + duplicate_order = order_service.create_order(created.session_id, ctx.user_id, "chest_xray") + db.commit() + tool_count_after = len([item for item in runtime_memory.get_messages(f"mem:{created.session_code}") if item.get("role") == "tool"]) + assert duplicate_order.already_ordered is True + assert tool_count_after == tool_count_before + + try: + order_service.create_order(created.session_id, ctx.user_id, "not_exists") + except AppError as exc: + assert exc.code == "ORDER_ITEM_NOT_FOUND" + else: + raise AssertionError("invalid order item should raise AppError") + + try: + order_service.list_order_items(created.session_id, "another_user") + except AppError as exc: + assert exc.code == "SESSION_NOT_FOUND" + else: + raise AssertionError("cross user access should raise AppError") + + status = session_service.complete_inquiry(ctx, created.session_id) + db.commit() + assert status.status == "diagnosis" + + diagnosis = session_service.submit_diagnosis( + ctx, + created.session_id, + SubmitDiagnosisRequest( + primary_diagnosis="支气管肺炎", + differential_diagnoses=["毛细支气管炎", "支气管哮喘急性发作"], + diagnosis_basis="发热咳嗽伴喘息,肺部湿啰音,胸片异常,炎症指标升高。", + ), + ) + db.commit() + assert diagnosis.status == "treatment" + + treatment = session_service.submit_treatment( + ctx, + created.session_id, + SubmitTreatmentRequest( + treatment_principle="抗感染、止咳平喘、改善氧合、严密观察病情变化。", + treatment_measures="根据病情选择抗感染治疗,必要时雾化吸入,监测体温、呼吸和血氧。", + risk_plan="关注低氧、呼吸困难加重、持续高热和精神反应差。", + communication="向家属说明病情、用药注意事项和复诊指征。", + follow_up="治疗后复查体温、呼吸情况和必要炎症指标。", + ), + ) + db.commit() + assert treatment.status == "evaluating" + + try: + await session_service.chat(ctx, created.session_id, "治疗后还能问诊吗?") + except AppError as exc: + assert exc.code == "SESSION_STATUS_INVALID" + else: + raise AssertionError("chat after treatment submission should raise AppError") + + evaluation = await evaluation_service.create_evaluation( + ctx, + created.session_id, + CreateEvaluationRequest(score_type="percentage"), + ) + db.commit() + assert evaluation.total_score > 0 + training_record = db.scalar(select(TrainingRecord).where(TrainingRecord.session_id == created.session_id)) + assert training_record is not None + assert training_record.external_user_id == ctx.user_id + + export = pdf_service.export(evaluation.evaluation_id, ctx.user_id) + db.commit() + assert Path(export.file_path).exists() + assert Path(export.file_path).stat().st_size > 1000 + + history = evaluation_service.list_history(ctx.user_id) + assert history.items + + +if __name__ == "__main__": + asyncio.run(run_demo_flow()) + print("demo flow test passed") diff --git a/backend/tests/test_import_source_case_sql.py b/backend/tests/test_import_source_case_sql.py new file mode 100644 index 0000000..55d6614 --- /dev/null +++ b/backend/tests/test_import_source_case_sql.py @@ -0,0 +1,75 @@ +import tempfile +from pathlib import Path + +import os +import sys + +os.environ.setdefault("DATABASE_URL", "sqlite:///./storage/test_import.db") +os.environ.setdefault("RUNTIME_MEMORY_BACKEND", "memory") +os.environ.setdefault("LLM_MOCK_ENABLED", "true") + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from scripts.import_source_case_sql import ImportValidationError, extract_create_columns, extract_insert_rows, parse_values_clause + + +def test_extract_insert_rows_maps_by_create_columns() -> None: + """导入解析:验证 INSERT VALUES 按 CREATE TABLE 字段顺序映射。""" + sql = """ + CREATE TABLE `traditional_case` ( + `created_at` datetime(6) NOT NULL, + `updated_at` datetime(6) NOT NULL, + `id` bigint NOT NULL AUTO_INCREMENT, + `standard_diagnosis` longtext NOT NULL, + `standard_treatment` longtext NOT NULL, + `guideline_reference` longtext NOT NULL, + `case_id` bigint NOT NULL + ) ENGINE=InnoDB; + INSERT INTO `traditional_case` VALUES ('2026-05-28 09:34:08','2026-05-28 09:34:09',1,'支气管肺炎','抗感染','指南',1); + """ + columns = extract_create_columns(sql, "traditional_case") + rows = extract_insert_rows(sql, "traditional_case", columns) + assert rows[0]["standard_diagnosis"] == "支气管肺炎" + assert rows[0]["case_id"] == 1 + + +def test_extract_insert_rows_rejects_broken_sql() -> None: + """导入解析:验证损坏 SQL 被拒绝,避免半导入。""" + sql = """ + CREATE TABLE `traditional_case` ( + `created_at` datetime(6) NOT NULL, + `updated_at` datetime(6) NOT NULL, + `id` bigint NOT NULL AUTO_INCREMENT, + `standard_diagnosis` longtext NOT NULL, + `standard_treatment` longtext NOT NULL, + `guideline_reference` longtext NOT NULL, + `case_id` bigint NOT NULL + ) ENGINE=InnoDB; + INSERT INTO `traditional_case` VALUES ('2026-05-28','2026-05-28',1,'支气管肺炎'bad,'抗感染','指南',1); + """ + columns = extract_create_columns(sql, "traditional_case") + try: + extract_insert_rows(sql, "traditional_case", columns) + except ImportValidationError: + return + raise AssertionError("broken SQL should be rejected") + + +def test_parse_values_clause_recovers_misplaced_quote_separator() -> None: + """导入解析:兼容接口 SQL 中文本字段写成 `文本,'next'` 的引号/逗号错位。""" + rows = parse_values_clause("(1,'标题?,'traditional','medium',NULL,'主诉?,'描述?,6,'male')") + assert rows[0] == [1, "标题?", "traditional", "medium", None, "主诉?", "描述?", 6, "male"] + + +def test_parse_values_clause_recovers_unclosed_string_before_tuple_end() -> None: + """导入解析:兼容接口 SQL 最后文本字段缺少结束引号且后接外键的情况。""" + rows = parse_values_clause("(1,'诊断?,'治疗?,'指南?,1)") + assert rows[0] == [1, "诊断?", "治疗?", "指南?", 1] + + +if __name__ == "__main__": + test_extract_insert_rows_maps_by_create_columns() + test_extract_insert_rows_rejects_broken_sql() + test_parse_values_clause_recovers_misplaced_quote_separator() + test_parse_values_clause_recovers_unclosed_string_before_tuple_end() + print("import source case sql tests passed") diff --git a/docs/00_development_log.md b/docs/00_development_log.md new file mode 100644 index 0000000..15d14f9 --- /dev/null +++ b/docs/00_development_log.md @@ -0,0 +1,92 @@ +# 开发过程记录 + +本文档只记录开发过程和阶段性变更。其他设计文档只描述当前确定状态。 + +## 第一版 Demo 已完成 + +已完成核心问诊训练闭环: + +```text +病例列表 -> 病例详情 -> 创建训练会话 -> 多轮问诊 -> 检查申请 +-> 完成问诊 -> 提交诊断 -> 提交治疗 -> AI 评价 -> PDF 导出 -> 历史记录 +``` + +## 数据库结构调整记录 + +当前数据库以源库病例结构为基础: + +- `case_base`:病例主表。 +- `traditional_case`:练习模式病例扩展。 +- `teaching_case`:教学互动模式病例扩展。 +- `scoring_rule`:病例评分规则。 + +业务运行新增表: + +- `case_exam_item` +- `training_session` +- `training_order` +- `training_submission` + +长期评价记录使用: + +- `training_record` + +旧表已删除: + +```text +cases +case_exam_items +training_sessions +session_orders +session_submissions +session_runtime_messages +evaluation_records +evaluation_report_exports +rubric_templates +``` + +旧 ORM 模型文件已删除: + +```text +backend/app/models/case.py +backend/app/models/evaluation.py +``` + +## 当前验证结果 + +已通过: + +- 后端编译:`python -m compileall app scripts tests` +- 核心逻辑测试:`tests/test_core_logic.py` +- API 合约测试:`tests/test_api_contract.py` +- Demo 闭环测试:`tests/test_demo_flow.py` +- MySQL 新表链路测试 +- 删除旧表后的 MySQL 闭环复测 +- 前端构建:`npm.cmd run build` + +最终数据库检查: + +```text +old_remaining = [] +case_base = 1 +traditional_case = 1 +teaching_case = 1 +scoring_rule = 5 +case_exam_item = 5 +training_session = 6+ +training_order = 6+ +training_submission = 6+ +training_record = 7+ +``` + +`training_*` 和 `training_record` 数量会随测试运行增加。 + +## 病例 SQL 上传导入功能 + +本轮新增前端和后端联调入口,用于接收接口解析后的病例 SQL 文件: + +- 后端新增 `POST /api/v1/imports/case-sql/preview`,只解析校验,不写数据库。 +- 后端新增 `POST /api/v1/imports/case-sql/apply`,确认后写入 `case_base`、`traditional_case`、`teaching_case`、`scoring_rule`。 +- 导入器继续沿用安全解析逻辑,不执行源 SQL 中的 DDL/DML。 +- 前端新增 `#/import` 页面,支持选择 SQL、预检、确认导入和刷新病例库。 +- 导入成功后,新增病例会出现在病例列表中,并可继续创建训练会话。 diff --git a/docs/01_functional_scope.md b/docs/01_functional_scope.md new file mode 100644 index 0000000..2cc1205 --- /dev/null +++ b/docs/01_functional_scope.md @@ -0,0 +1,71 @@ +# 第一版 Demo 功能范围 + +## 项目定位 + +医疗问诊 Agent 是大系统中的子功能。宿主系统进入时传入 `X-User-Id`,Agent 内部基于该值做会话隔离、训练记录、评价报告和学习档案沉淀。 + +## 已实现功能 + +| 功能 | 说明 | +|---|---| +| 入口连接 | 校验 `X-User-Id`,返回当前用户上下文和 Demo 能力开关 | +| 病例列表 | 从 `case_base` 读取已发布病例 | +| 病例详情 | 展示训练入口信息,不暴露标准答案、隐藏信息和评分细则 | +| 创建训练会话 | 写入 `training_session`,初始化短期 memory | +| 多轮问诊 | 医学生向 AI 病人提问,支持普通和 SSE 流式返回 | +| 查看提示 | 练习模式下用户手动点击,Hint Agent 基于病例和当前对话生成提示 | +| 检查/检验申请 | 从 `case_exam_item` 返回固定结果,写入 `training_order` 和短期 memory | +| 完成问诊 | 至少一轮医生问诊后进入诊断阶段 | +| 提交诊断 | 写入 `training_submission` | +| 提交治疗 | 更新 `training_submission`,进入评价阶段 | +| AI 评价报告 | 读取 `scoring_rule`、知识库命中结果和作答过程,写入 `training_record` | +| PDF 导出 | 基于 `training_record` 生成本地 PDF 并回写路径 | +| 历史记录 | 按 `external_user_id` 查询 `training_record` | +| LLM 测试 | 测试 Fast 和 Reason 模型响应耗时 | + +## 训练模式 + +当前只保留两个业务模式: + +| 模式 | 内部值 | 数据来源 | 说明 | +|---|---|---|---| +| 练习模式 | `practice` | `case_base + traditional_case` | 支持自由问诊、检查申请、诊断治疗提交和查看提示 | +| 教学互动模式 | `teaching` | `case_base + teaching_case` | 保留教学目标、讨论题、教师引导和评分重点 | + +历史 `novice` 请求在后端归一为 `practice`。查看提示功能不作为独立模式存在,而是练习模式中的手动辅助能力。 + +## 状态流转 + +```text +inquiry +-> diagnosis +-> treatment +-> evaluating +-> completed +``` + +| 状态 | 可执行操作 | +|---|---| +| `inquiry` | Chat、查看提示、申请检查、完成问诊 | +| `diagnosis` | 提交诊断、申请检查 | +| `treatment` | 提交治疗、申请检查 | +| `evaluating` | 生成评价 | +| `completed` | 查看报告、导出 PDF、进入历史 | + +## 存储边界 + +- 多轮聊天只作为单次会话短期 memory,不写入长期历史。 +- 检查结果写入 `training_order`,用于评分依据。 +- 诊断和治疗写入 `training_submission`。 +- 只有完整完成评价后才写入 `training_record`。 +- 历史记录和 PDF 导出均基于 `training_record`。 + +## 第一版不包含 + +- 独立注册登录。 +- 多租户权限后台。 +- HIS/LIS/PACS 对接。 +- 语音、视频、影像阅片。 +- PDF 病例自动解析。 +- 生产级知识库后台。 +- 复杂考试系统。 diff --git a/docs/02_database_design.md b/docs/02_database_design.md new file mode 100644 index 0000000..c258ee0 --- /dev/null +++ b/docs/02_database_design.md @@ -0,0 +1,87 @@ +# 数据库设计 + +## 1. 当前确定方案 + +医疗问诊 Agent 使用独立数据库 `medical_consultation_agent`。病例来源以源库结构为准,当前业务不再依赖旧的 `cases`、`case_exam_items`、`training_sessions`、`session_orders`、`session_submissions`、`evaluation_records`、`evaluation_report_exports`、`rubric_templates`。 + +旧表已清理。后续开发直接面向新表: + +| 分类 | 表 | 说明 | +|---|---|---| +| 病例来源 | `case_base` | 病例主表,来自源库字段 | +| 病例来源 | `traditional_case` | 练习模式病例扩展 | +| 病例来源 | `teaching_case` | 教学互动模式病例扩展 | +| 评分规则 | `scoring_rule` | 病例级基础评分规则 | +| 检查项目 | `case_exam_item` | 本业务新增,保存固定检查/检验结果 | +| 训练运行 | `training_session` | 本业务新增,保存一次训练会话状态 | +| 训练运行 | `training_order` | 本业务新增,保存一次训练中的检查申请 | +| 训练运行 | `training_submission` | 本业务新增,保存诊断和治疗提交 | +| 训练结果 | `training_record` | 完整训练结束后的长期评价记录 | +| 知识库 | `knowledge_sources`、`knowledge_documents`、`knowledge_chunks` | 评分指南检索和引用 | +| 提示词 | `prompt_templates` | Markdown 提示词模板元数据 | +| 用户档案 | `user_learning_profiles` | 用户历史评价聚合 | +| 审计 | `audit_logs` | 关键行为审计 | + +## 2. 病例读取逻辑 + +练习模式读取: + +```text +case_base + traditional_case + case_exam_item + scoring_rule +``` + +教学互动模式读取: + +```text +case_base + teaching_case + case_exam_item + scoring_rule +``` + +当前模式只保留 `practice` 和 `teaching`。历史的 `novice` 请求在后端归一为 `practice`,查看提示功能保留在练习模式中,由用户点击后触发。 + +## 3. 评价生成逻辑 + +评价生成时读取以下数据: + +1. `training_session`:当前训练会话、模式、分数类型。 +2. Redis/进程内短期 memory:本次对话过程。 +3. `training_order`:用户申请过的检查结果。 +4. `training_submission`:用户提交的诊断和治疗方案。 +5. `scoring_rule`:病例基础评分规则。 +6. `knowledge_chunks`:科室/任务下命中的评分指南或人文关怀参考。 +7. 提示词模板:评分 Agent 使用的结构化输出约束。 + +LLM 返回结构化评价后,只写入 `training_record`。中断、退出、未完成诊断或未完成治疗的训练不写入 `training_record`。 + +## 4. 短期记忆和长期记录 + +| 数据 | 存储位置 | 生命周期 | +|---|---|---| +| 医生和 AI 病人的多轮对话 | Redis 或进程内 memory | 单次会话有效,TTL 到期或评价完成后释放 | +| 检查申请结果 | `training_order` + runtime memory | 当前训练长期可查,参与评分 | +| 诊断/治疗提交 | `training_submission` | 当前训练长期可查,参与评分 | +| AI 评价报告 | `training_record.ai_feedback_structured` | 完整训练后长期保存 | +| PDF 路径 | `training_record.pdf_file_path` | 导出后长期保存 | + +## 5. 用户隔离 + +宿主系统进入 Agent 时传入 `X-User-Id`。后端以该值写入 `external_user_id`: + +| 表 | 用户字段 | +|---|---| +| `training_session` | `external_user_id` | +| `training_order` | `external_user_id` | +| `training_submission` | `external_user_id` | +| `training_record` | `external_user_id` | + +`training_record.user_id` 保留源库数字用户字段。宿主传入字符串 user_id 时,业务隔离以 `external_user_id` 为准。 + +## 6. 初始化和清理脚本 + +| 脚本 | 作用 | +|---|---| +| `backend/scripts/migrate_to_new_schema.py` | 创建新表、写入 Demo 种子数据、补齐表中文注释 | +| `backend/scripts/drop_legacy_tables.py` | 删除已不再使用的旧表 | +| `backend/scripts/init_demo_db.py` | 新表体系的 Demo 数据初始化入口 | +| `backend/scripts/import_source_case_sql.py` | 安全导入接口解析后的病例 SQL,只做字段映射导入,不执行源 SQL 的建表和删表语句 | + +验证结果:旧表已清理,`old_remaining=[]`;新表已完成完整 Demo 流程复测。 diff --git a/docs/02_database_table_dictionary.md b/docs/02_database_table_dictionary.md new file mode 100644 index 0000000..a36d88c --- /dev/null +++ b/docs/02_database_table_dictionary.md @@ -0,0 +1,172 @@ +# 数据库表字段说明 + +## 1. 核心表总览 + +| 表 | 中文名 | 职责 | +|---|---|---| +| `case_base` | 病例主表 | 病例基础信息、标签、知识点、科室、发布状态 | +| `traditional_case` | 传统病例扩展表 | 练习模式下的标准诊断、标准治疗、指南参考 | +| `teaching_case` | 教学互动病例扩展表 | 教学互动模式下的教学目标、讨论题、教师引导、评分重点 | +| `scoring_rule` | 评分规则表 | 病例级基础评分维度和细则 | +| `case_exam_item` | 病例检查检验项目表 | 固定检查/检验结果,禁止 LLM 编造检查结果 | +| `training_session` | 训练会话表 | 一次训练的运行状态、模式、分数类型和 memory key | +| `training_order` | 训练检查申请表 | 用户在一次训练中申请过的检查结果 | +| `training_submission` | 训练诊断治疗提交表 | 用户提交的诊断、治疗、沟通和随访 | +| `training_record` | 训练记录表 | 完整训练结束后的 AI 评价、评分细则、PDF 路径 | +| `prompt_templates` | 提示词模板元数据表 | Markdown 提示词模板路径和版本 | +| `knowledge_*` | 知识库表 | 评分参考指南和人文关怀资料检索 | +| `user_learning_profiles` | 学习档案表 | 用户历史评价聚合 | +| `audit_logs` | 审计日志表 | 关键行为日志 | + +## 2. `case_base` + +| 字段 | 含义 | +|---|---| +| `id` | 病例 ID | +| `title` | 病例标题 | +| `case_type` | 病例/训练类型 | +| `difficulty`、`difficulty_score` | 难度和难度分 | +| `chief_complaint` | 主诉 | +| `description` | 病例描述,用于 AI 病人上下文 | +| `patient_age`、`patient_gender` | 患者年龄和性别 | +| `symptom_tags` | 关键症状标签 | +| `disease_tags` | 疾病标签 | +| `competency_tags` | 能力/考核标签 | +| `guideline_tags` | 指南标签 | +| `knowledge_points` | 知识点和关键检查提示 | +| `estimated_minutes` | 预计训练时长 | +| `osce_enabled` | 是否启用 OSCE | +| `rag_enabled` | 是否启用 RAG | +| `ai_prompt_template` | 病例 AI 提示词片段 | +| `multimodal_assets` | 多模态资源 | +| `publish_status`、`status` | 发布状态和启用状态 | +| `created_by_id`、`department_id` | 创建人和科室 | + +## 3. `traditional_case` + +| 字段 | 含义 | +|---|---| +| `id` | 传统病例扩展 ID | +| `case_id` | 关联 `case_base.id` | +| `standard_diagnosis` | 标准诊断 | +| `standard_treatment` | 标准治疗 | +| `guideline_reference` | 诊断和治疗参考依据 | + +## 4. `teaching_case` + +| 字段 | 含义 | +|---|---| +| `id` | 教学互动病例扩展 ID | +| `case_id` | 关联 `case_base.id` | +| `teaching_goal` | 教学目标 | +| `discussion_questions` | 讨论问题 | +| `teacher_guide` | 教师引导 | +| `scoring_focus` | 评分重点 | + +## 5. `scoring_rule` + +| 字段 | 含义 | +|---|---| +| `id` | 评分规则 ID | +| `case_id` | 关联 `case_base.id` | +| `dimension` | 一级评分维度 | +| `competency_dimension` | 能力维度 | +| `score_weight` | 分值权重 | +| `ai_auto_score` | 是否由 AI 自动评分 | +| `osce_dimension` | 是否 OSCE 维度 | +| `scoring_standard` | 评分标准文本 | +| `rubric_json` | 结构化评分细则 | + +## 6. `case_exam_item` + +| 字段 | 含义 | +|---|---| +| `id` | 检查项目 ID | +| `case_id` | 关联 `case_base.id` | +| `item_code` | 检查项目编码,当前会话去重依据 | +| `item_name` | 检查项目名称 | +| `item_type` | 项目类型,如 lab、imaging、vital_sign | +| `category` | 项目分类 | +| `result_text` | 固定返回结果文本 | +| `result_structured` | 结构化检查结果 | +| `is_key` | 是否关键检查 | +| `is_abnormal` | 是否异常结果 | +| `score_weight` | 评分权重 | +| `display_order` | 展示顺序 | + +## 7. `training_session` + +| 字段 | 含义 | +|---|---| +| `id` | 训练会话 ID | +| `session_code` | 会话编码 | +| `external_user_id` | 宿主系统用户 ID | +| `tenant_id`、`class_id`、`entry_scene` | 宿主上下文 | +| `case_id` | 关联 `case_base.id` | +| `case_type` | 训练类型 | +| `training_mode` | 训练模式,当前为 `practice` 或 `teaching` | +| `score_type` | 评分类型,`percentage` 或 `five_point` | +| `status` | 阶段状态:`inquiry -> diagnosis -> treatment -> evaluating -> completed` | +| `started_at`、`inquiry_completed_at`、`completed_at` | 阶段时间 | +| `memory_key` | Redis/进程内短期 memory key | +| `metadata` | 扩展数据 | + +## 8. `training_order` + +| 字段 | 含义 | +|---|---| +| `id` | 检查申请 ID | +| `session_id` | 关联 `training_session.id` | +| `external_user_id` | 宿主系统用户 ID | +| `case_id` | 关联 `case_base.id` | +| `exam_item_id` | 关联 `case_exam_item.id` | +| `item_code`、`item_name`、`item_type` | 检查项目编码、名称、类型 | +| `result_text`、`result_structured` | 固定检查结果 | +| `is_key`、`is_abnormal` | 是否关键、是否异常 | +| `ordered_at` | 申请时间 | + +同一 `session_id + item_code` 唯一,重复申请返回已有记录,不重复写 memory。 + +## 9. `training_submission` + +| 字段 | 含义 | +|---|---| +| `id` | 提交记录 ID | +| `session_id` | 关联 `training_session.id` | +| `external_user_id` | 宿主系统用户 ID | +| `primary_diagnosis` | 主要诊断 | +| `differential_diagnoses` | 鉴别诊断 | +| `diagnosis_basis` | 诊断依据 | +| `treatment_principle` | 治疗原则 | +| `treatment_measures` | 治疗措施 | +| `risk_plan` | 风险预案 | +| `communication` | 医患沟通 | +| `follow_up` | 随访安排 | +| `diagnosis_submitted_at`、`treatment_submitted_at` | 提交时间 | + +## 10. `training_record` + +| 字段 | 含义 | +|---|---| +| `id` | 训练记录 ID | +| `training_mode`、`case_type` | 模式和训练类型 | +| `start_time`、`end_time`、`duration_seconds` | 训练时间 | +| `total_score`、`ai_score`、`teacher_score` | 总分、AI 分、教师分 | +| `evaluation_level` | 评价等级 | +| `status` | 记录状态 | +| `feedback` | 总体反馈 | +| `thinking_chain` | 证据摘要和评分依据 | +| `diagnosis_path` | 诊断路径摘要 | +| `wrong_points` | 错误点/扣分点 | +| `missed_questions` | 遗漏问题 | +| `recommendation_result` | 改进建议和导出信息 | +| `ai_feedback_structured` | AI 结构化评价报告 | +| `prompt_version`、`rag_context_version` | 提示词版本和 RAG 版本 | +| `case_id` | 关联病例 | +| `user_id` | 源库数字用户 ID | +| `external_user_id` | 宿主系统用户 ID | +| `session_id` | 关联训练会话 | +| `score_type` | 评分类型 | +| `pdf_file_path` | PDF 报告路径 | + +`training_record` 是历史记录、报告详情和 PDF 导出的唯一长期来源。 diff --git a/docs/03_api_design.md b/docs/03_api_design.md new file mode 100644 index 0000000..e05a508 --- /dev/null +++ b/docs/03_api_design.md @@ -0,0 +1,672 @@ +# 后端 API 对接文档 + +## 1. 通用约定 + +Base URL: + +```text +http://127.0.0.1:8000/api/v1 +``` + +必传 Header: + +| Header | 说明 | +|---|---| +| `X-User-Id` | 宿主系统传入的用户 ID,所有业务隔离依据 | +| `X-Entry-Scene` | 入口场景,前端 Demo 默认 `vue_demo` | + +可传 Header: + +| Header | 说明 | +|---|---| +| `X-Tenant-Id` | 宿主系统租户/机构 ID | +| `X-Class-Id` | 教学班级 ID | +| `X-Role` | 用户角色 | + +统一响应: + +```json +{ + "code": "OK", + "message": "success", + "data": {} +} +``` + +常见错误码: + +| code | 含义 | +|---|---| +| `USER_ID_REQUIRED` | 缺少 `X-User-Id` | +| `CASE_NOT_FOUND` | 病例不存在或未启用 | +| `SESSION_NOT_FOUND` | 会话不存在或不属于当前用户 | +| `SESSION_STATUS_INVALID` | 当前阶段不允许该操作 | +| `INQUIRY_REQUIRED` | 完成问诊前至少需要一轮医生提问 | +| `DIAGNOSIS_REQUIRED` | 提交治疗前需要先提交诊断 | +| `TREATMENT_REQUIRED` | 生成评价前需要先提交治疗 | +| `ORDER_ITEM_NOT_FOUND` | 检查项目不存在 | +| `LLM_CALL_TIMEOUT` | LLM 普通调用超时 | +| `LLM_STREAM_TIMEOUT` | LLM 流式调用超时 | +| `LLM_STREAM_FAILED` | LLM 流式调用失败 | +| `CASE_SQL_FILE_INVALID` | 上传文件不是 `.sql` | +| `CASE_SQL_FILE_EMPTY` | 上传 SQL 文件为空 | +| `CASE_SQL_FILE_TOO_LARGE` | 上传 SQL 文件超过 5MB | +| `CASE_SQL_IMPORT_INVALID` | SQL 解析或字段映射校验失败 | + +## 2. Agent Hello + +| 项 | 内容 | +|---|---| +| Method | `GET` | +| Path | `/agent/hello` | +| Router | `agent.hello` | +| 用途 | 校验后端连接,返回当前用户上下文和功能开关 | + +Response `data`: + +```json +{ + "user": { + "user_id": "demo_user_001", + "tenant_id": null, + "role": null + }, + "features": { + "stream_chat": true, + "score_types": ["percentage", "five_point"], + "pdf_export": true, + "knowledge_search": true, + "llm_mock_enabled": false, + "llm_fallback_to_mock": false + } +} +``` + +## 3. 病例列表 + +| 项 | 内容 | +|---|---| +| Method | `GET` | +| Path | `/cases` | +| Router | `cases.list_cases` | +| Service | `CaseService.list_cases` | +| 表 | `case_base` | + +Query: + +| 参数 | 说明 | +|---|---| +| `department_id` | 科室筛选 | +| `training_type` | 训练类型筛选 | +| `mode` | `practice` 或 `teaching` | + +Response `data`: + +```json +{ + "items": [ + { + "id": 1, + "title": "支气管肺炎 - 6岁男性患儿", + "department_id": 1, + "department_name": "儿科", + "difficulty": "medium", + "chief_complaint": "发热、咳嗽4天,喘息1天。", + "patient_age": 6, + "patient_gender": "male", + "training_type": "case_analysis", + "supported_modes": ["practice", "teaching"], + "has_teaching_video": false, + "has_knowledge_points": true + } + ] +} +``` + +## 4. 病例详情 + +| 项 | 内容 | +|---|---| +| Method | `GET` | +| Path | `/cases/{case_id}` | +| Router | `cases.get_case_detail` | +| Service | `CaseService.get_case_detail` | +| 表 | `case_base`、`traditional_case`、`teaching_case`、`case_exam_item` | + +Response `data`: + +```json +{ + "id": 1, + "title": "支气管肺炎 - 6岁男性患儿", + "department_name": "儿科", + "chief_complaint": "发热、咳嗽4天,喘息1天。", + "patient_age": 6, + "patient_gender": "male", + "supported_modes": ["practice", "teaching"], + "exam_item_count": 5, + "has_knowledge_points": true +} +``` + +病例详情不返回标准答案、隐藏病史和完整评分细则。 + +## 5. 创建训练会话 + +| 项 | 内容 | +|---|---| +| Method | `POST` | +| Path | `/sessions` | +| Router | `sessions.create_session` | +| Service | `SessionService.create_session` | +| 表 | `training_session` | + +Request: + +```json +{ + "case_id": 1, + "training_type": "case_analysis", + "mode": "practice", + "score_type": "percentage" +} +``` + +Response `data`: + +```json +{ + "session_id": 10, + "session_code": "sess_20260528100000_abcd1234", + "status": "inquiry", + "patient_opening": "家长:医生,孩子发烧咳嗽好几天了..." +} +``` + +校验: + +- `case_id` 必须存在并启用。 +- `mode` 当前使用 `practice` 或 `teaching`。 +- 创建会话时初始化短期 memory。 + +## 6. 普通问诊 + +| 项 | 内容 | +|---|---| +| Method | `POST` | +| Path | `/sessions/{session_id}/chat` | +| Router | `sessions.chat` | +| Service | `SessionService.chat` | +| Agent | `PatientAgent.reply` | + +Request: + +```json +{ + "message": "孩子发热几天了?最高体温多少?" +} +``` + +Response `data`: + +```json +{ + "reply": "发热有4天了,最高烧到39度多。", + "latency_ms": 2500, + "model": "deepseek-v4-pro", + "fallback_used": false +} +``` + +校验:会话必须属于当前 `X-User-Id`,且状态为 `inquiry`。 + +## 7. 流式问诊 + +| 项 | 内容 | +|---|---| +| Method | `POST` | +| Path | `/sessions/{session_id}/chat/stream` | +| Router | `sessions.chat_stream` | +| Service | `SessionService.stream_chat` | +| Agent | `PatientAgent.stream_reply` | + +Request 同普通问诊。 + +SSE 事件: + +```text +event: message_delta +data: {"delta":"发热有"} + +event: message_done +data: {"latency_ms":3200,"first_token_ms":800,"model":"deepseek-v4-pro","fallback_used":false} + +event: error +data: {"code":"LLM_STREAM_TIMEOUT","message":"AI 病人回复超时,请重试"} +``` + +前端收到 `message_done` 或 `error` 后必须结束 pending。 + +## 8. 查看提示 + +| 项 | 内容 | +|---|---| +| Method | `POST` | +| Path | `/sessions/{session_id}/hints` | +| Router | `sessions.generate_hints` | +| Service | `SessionService.generate_hints` | +| Agent | `HintAgent.generate` | + +Request: + +```json +{ + "last_user_message": "孩子发热几天了?", + "scope": "current_conversation" +} +``` + +Response `data`: + +```json +{ + "hints": ["可以继续追问最高体温、热型和退热药反应。"], + "missing_dimensions": ["既往史", "严重程度评估"], + "next_questions": ["孩子以前有没有喘息或哮喘史?"], + "recommended_orders": [ + {"item_code": "oxygen_saturation", "reason": "用于判断缺氧和病情严重程度"} + ] +} +``` + +校验:当前仅允许 `practice` 模式且会话状态为 `inquiry`。 + +## 9. 检查项目列表 + +| 项 | 内容 | +|---|---| +| Method | `GET` | +| Path | `/sessions/{session_id}/order-items` | +| Router | `sessions.list_order_items` | +| Service | `OrderService.list_order_items` | +| 表 | `case_exam_item` | + +Response `data`: + +```json +{ + "items": [ + { + "item_code": "complete_blood_count", + "item_name": "血常规", + "item_type": "lab", + "category": "实验室检查", + "ordered": false + } + ] +} +``` + +## 10. 申请检查 + +| 项 | 内容 | +|---|---| +| Method | `POST` | +| Path | `/sessions/{session_id}/orders` | +| Router | `sessions.create_order` | +| Service | `OrderService.create_order` | +| 表 | `training_order` | + +Request: + +```json +{ + "item_code": "complete_blood_count" +} +``` + +Response `data`: + +```json +{ + "order_id": 1, + "item_code": "complete_blood_count", + "item_name": "血常规", + "result_text": "WBC 12.4×10^9/L,中性粒细胞72%。", + "result_structured": {}, + "already_ordered": false, + "context_written": true +} +``` + +同一 `session_id + item_code` 幂等,重复申请返回已有记录,不重复写入 memory。 + +## 11. 完成问诊 + +| 项 | 内容 | +|---|---| +| Method | `POST` | +| Path | `/sessions/{session_id}/complete-inquiry` | +| Router | `sessions.complete_inquiry` | +| Service | `SessionService.complete_inquiry` | + +Response `data`: + +```json +{ + "session_id": 10, + "status": "diagnosis" +} +``` + +校验:至少存在一轮医生提问。 + +## 12. 提交诊断 + +| 项 | 内容 | +|---|---| +| Method | `POST` | +| Path | `/sessions/{session_id}/diagnosis` | +| Router | `sessions.submit_diagnosis` | +| Service | `SessionService.submit_diagnosis` | +| 表 | `training_submission` | + +Request: + +```json +{ + "primary_diagnosis": "支气管肺炎", + "differential_diagnoses": ["毛细支气管炎", "哮喘急性发作"], + "diagnosis_basis": "发热、咳嗽、喘息,结合肺部体征、炎症指标、胸片和血氧情况。" +} +``` + +Response `data`: + +```json +{ + "status": "treatment" +} +``` + +## 13. 提交治疗 + +| 项 | 内容 | +|---|---| +| Method | `POST` | +| Path | `/sessions/{session_id}/treatment` | +| Router | `sessions.submit_treatment` | +| Service | `SessionService.submit_treatment` | +| 表 | `training_submission` | + +Request: + +```json +{ + "treatment_principle": "抗感染、平喘、改善氧合、严密观察。", + "treatment_measures": "根据病情选择抗感染治疗,必要时雾化吸入,监测体温、呼吸和血氧。", + "risk_plan": "关注低氧、呼吸困难加重、持续高热、精神反应差。", + "communication": "向家属说明病情、用药注意事项和复诊/住院指征。", + "follow_up": "治疗后复查体温、呼吸、血氧和炎症指标。" +} +``` + +Response `data`: + +```json +{ + "status": "evaluating" +} +``` + +## 14. 生成评价 + +| 项 | 内容 | +|---|---| +| Method | `POST` | +| Path | `/sessions/{session_id}/evaluation` | +| Router | `sessions.create_evaluation` | +| Service | `EvaluationService.create_evaluation` | +| Agent | `ScoringAgent.score`、`ReportAgent` | +| 表 | `scoring_rule`、`knowledge_chunks`、`training_record` | + +Request: + +```json +{ + "score_type": "percentage" +} +``` + +Response `data`: + +```json +{ + "evaluation_id": 1, + "score_type": "percentage", + "total_score": 82, + "dimension_scores": [], + "errors": [], + "improvement_plan": [], + "evidence_summary": [], + "guideline_refs": [], + "overall_comment": "完成了主要诊断链路,但检查利用和沟通细节仍需加强。" +} +``` + +评价完成后释放短期 memory,并写入 `training_record`。 + +## 15. 历史评价列表 + +| 项 | 内容 | +|---|---| +| Method | `GET` | +| Path | `/evaluations` | +| Router | `evaluations.list_evaluations` | +| Service | `EvaluationService.list_history` | +| 表 | `training_record` | + +Response `data`: + +```json +{ + "items": [ + { + "evaluation_id": 1, + "case_title": "支气管肺炎 - 6岁男性患儿", + "score_type": "percentage", + "total_score": 82, + "created_at": "2026-05-28T10:00:00", + "pdf_exported": true + } + ] +} +``` + +## 16. 评价详情 + +| 项 | 内容 | +|---|---| +| Method | `GET` | +| Path | `/evaluations/{evaluation_id}` | +| Router | `evaluations.get_evaluation_detail` | +| Service | `EvaluationService.get_detail` | +| 表 | `training_record` | + +Response `data` 为完整评价报告,并包含 `session_id`、`case_id`、`case_title`、`created_at`、`pdf_file_path`。 + +## 17. 导出 PDF + +| 项 | 内容 | +|---|---| +| Method | `POST` | +| Path | `/evaluations/{evaluation_id}/export-pdf` | +| Router | `evaluations.export_pdf` | +| Service | `PdfExportService.export` | +| 表 | `training_record` | + +Response `data`: + +```json +{ + "export_id": 1, + "file_path": "storage/reports/training_record_1_percentage_xxx.pdf" +} +``` + +## 18. 知识检索 + +| 项 | 内容 | +|---|---| +| Method | `GET` | +| Path | `/knowledge/search` | +| Router | `knowledge.search_knowledge` | +| Service | `KnowledgeService.search_guidelines` | +| 表 | `knowledge_sources`、`knowledge_documents`、`knowledge_chunks` | + +Query: + +| 参数 | 说明 | +|---|---| +| `department_id` | 科室 ID | +| `training_type` | 训练类型 | +| `q` | 关键词,逗号分隔 | + +用途:评价生成前检索科室/类型下的评分指南。前端第一版不需要主动调用。 + +## 19. 病例 SQL 导入预检 + +| 项 | 内容 | +|---|---| +| Method | `POST` | +| Path | `/imports/case-sql/preview` | +| Router | `imports.preview_case_sql` | +| Service | `CaseSqlImportService.preview` | +| 用途 | 前端上传接口解析后的 SQL 文件,后端只解析并校验,不写入数据库 | + +Request: + +```text +Content-Type: multipart/form-data +file: case.sql +``` + +Response `data`: + +```json +{ + "file_name": "case.sql", + "encoding": "utf-8", + "tables": { + "case_base": 1, + "traditional_case": 1, + "teaching_case": 1, + "scoring_rule": 5 + }, + "can_import": true, + "warnings": [], + "errors": [], + "preview_cases": [ + { + "id": 1001, + "title": "儿童支气管肺炎", + "case_type": "diagnosis_treatment", + "difficulty": "medium" + } + ] +} +``` + +校验逻辑: +- 必须携带 `X-User-Id`。 +- 文件后缀必须为 `.sql`,大小不超过 5MB。 +- 只解析源 SQL 中的 `case_base`、`traditional_case`、`teaching_case`、`scoring_rule`。 +- 预检接口不执行源 SQL,不写入数据库。 +- 源 SQL 中出现 `DROP TABLE`、`CREATE TABLE`、`ALTER TABLE` 等语句时只作为警告展示,导入器不会执行这些语句。 +- 字段数量不匹配、非法字符串、JSON 字段非法时返回 `can_import=false` 和 `errors`。 + +## 20. 病例 SQL 确认导入 + +| 项 | 内容 | +|---|---| +| Method | `POST` | +| Path | `/imports/case-sql/apply` | +| Router | `imports.apply_case_sql` | +| Service | `CaseSqlImportService.apply` | +| 用途 | 预检通过后,将 SQL 中的病例源表数据映射写入当前数据库 | +| 表 | `case_base`、`traditional_case`、`teaching_case`、`scoring_rule`、`case_exam_item` | + +Request: + +```text +Content-Type: multipart/form-data +file: case.sql +``` + +Response `data`: + +```json +{ + "imported": true, + "file_name": "case.sql", + "encoding": "utf-8", + "inserted_or_updated_cases": 1, + "imported_traditional_cases": 1, + "imported_teaching_cases": 1, + "imported_scoring_rules": 5, + "generated_exam_items": 4, + "warnings": [] +} +``` + +校验逻辑: +- 必须携带 `X-User-Id`。 +- 导入过程使用事务,任意表映射失败则整体回滚。 +- `case_base` 按 `id` 更新或插入。 +- `traditional_case` 和 `teaching_case` 按 `case_id` 更新或插入。 +- `scoring_rule` 按 `case_id` 先删除旧规则再写入源规则。 +- 源 SQL 缺少 `case_exam_item` 时,由后端根据病例文本生成基础检查项目,保障问诊训练链路可继续使用。 +- 导入成功后前端刷新 `/cases`,新增病例即可进入训练。 + +## 21. LLM Fast 测试 + +| 项 | 内容 | +|---|---| +| Method | `POST` | +| Path | `/llm/test/deepseek-fast` | +| Router | `llm_test.test_deepseek_fast` | +| Service | `OpenAICompatibleLLMClient.chat` | + +Request: + +```json +{ + "message": "请用一句话说明医疗问诊训练 Demo 的用途。" +} +``` + +Response `data`: + +```json +{ + "model": "deepseek-v4-pro", + "first_token_ms": null, + "total_latency_ms": 3000, + "stream": false, + "mock_mode": false, + "fallback_used": false, + "thinking_enabled": false, + "reasoning_effort": null +} +``` + +## 22. LLM Reason 测试 + +| 项 | 内容 | +|---|---| +| Method | `POST` | +| Path | `/llm/test/deepseek-reason` | +| Router | `llm_test.test_deepseek_reason` | +| Service | `OpenAICompatibleLLMClient.stream_chat`,流式不兼容时降级到 `chat` | + +Request 同 Fast 测试。Response 字段同 Fast 测试。 diff --git a/docs/04_data_collection.md b/docs/04_data_collection.md new file mode 100644 index 0000000..415a8fa --- /dev/null +++ b/docs/04_data_collection.md @@ -0,0 +1,97 @@ +# 数据采集与存储边界 + +## 1. 采集原则 + +- 进入 Agent 时只接收宿主系统传入的用户上下文,不做登录注册。 +- 问诊聊天只作为本次训练的短期 memory 使用,训练中断或退出不写入长期聊天历史。 +- 检查申请、诊断提交、治疗提交属于本次训练过程数据,参与最终评分。 +- 只有生成 AI 评价报告后,系统才写入长期训练记录 `training_record`。 +- 所有会话、检查、提交、评价和历史查询都按 `X-User-Id` 隔离。 + +## 2. 进入 Agent + +| 数据 | 来源 | 存储位置 | 用途 | +|---|---|---|---| +| `user_id` | Header `X-User-Id` | `training_session.external_user_id`、`training_record.external_user_id`、`audit_logs.user_id` | 用户隔离 | +| `tenant_id` | Header `X-Tenant-Id` | `training_session.tenant_id`、`audit_logs.tenant_id` | 宿主系统组织上下文 | +| `class_id` | Header `X-Class-Id` | `training_session.class_id` | 班级/教学上下文 | +| `entry_scene` | Header `X-Entry-Scene` | `training_session.entry_scene`、`audit_logs.entry_scene` | 入口来源 | +| `role` | Header `X-Role` | `audit_logs.role` | 审计上下文 | +| `request_id` | 系统生成或 Header | `audit_logs.request_id` | 链路追踪 | + +## 3. 创建会话 + +| 数据 | 存储位置 | 说明 | +|---|---|---| +| `case_id` | `training_session.case_id` | 关联 `case_base.id` | +| `training_type` | `training_session.case_type` | 当前来自病例类型 | +| `mode` | `training_session.training_mode` | `practice` 或 `teaching` | +| `score_type` | `training_session.score_type` | `percentage` 或 `five_point` | +| `session_code` | `training_session.session_code` | 会话业务编号 | +| `memory_key` | `training_session.memory_key` | Redis/进程内 memory key | +| `patient_opening` | runtime memory | AI 病人首句,不作为长期历史保存 | + +## 4. 问诊过程 + +| 数据 | 存储位置 | 生命周期 | +|---|---|---| +| 医学生提问 | runtime memory | 本次会话有效,TTL 到期或评价完成后释放 | +| AI 病人回复 | runtime memory | 本次会话有效,TTL 到期或评价完成后释放 | +| SSE 响应耗时 | 前端状态、评价结构摘要 | 用于演示和问题排查 | +| LLM 模型名/fallback 状态 | 前端状态、评价结构摘要 | 用于确认真实模型或 fallback | + +短期 memory 默认使用 Redis,Redis 不可用时降级为进程内 memory。TTL 由 `RUNTIME_MEMORY_TTL_SECONDS` 控制;每次写入会刷新 TTL。 + +## 5. 检查/检验申请 + +| 数据 | 存储位置 | 说明 | +|---|---|---| +| `item_code` | `training_order.item_code` | 同一会话去重依据 | +| `item_name` | `training_order.item_name` | 检查名称 | +| `item_type` | `training_order.item_type` | `lab`、`imaging`、`vital_sign` 等 | +| `result_text` | `training_order.result_text` | 从 `case_exam_item` 读取的固定结果 | +| `result_structured` | `training_order.result_structured` | 结构化结果 | +| `is_key`、`is_abnormal` | `training_order` | 评分参考 | + +检查结果只来自数据库 `case_exam_item`。LLM 不生成、不改写检查结果。 + +## 6. 诊断与治疗提交 + +| 数据 | 存储位置 | 说明 | +|---|---|---| +| `primary_diagnosis` | `training_submission.primary_diagnosis` | 主要诊断 | +| `differential_diagnoses` | `training_submission.differential_diagnoses` | 鉴别诊断 | +| `diagnosis_basis` | `training_submission.diagnosis_basis` | 诊断依据 | +| `treatment_principle` | `training_submission.treatment_principle` | 治疗原则 | +| `treatment_measures` | `training_submission.treatment_measures` | 治疗措施 | +| `risk_plan` | `training_submission.risk_plan` | 风险预案 | +| `communication` | `training_submission.communication` | 医患沟通 | +| `follow_up` | `training_submission.follow_up` | 随访安排 | + +## 7. 评价报告 + +评价生成读取以下数据: + +- `training_session`:模式、分数类型、病例 ID、memory key。 +- runtime memory:本次问诊对话摘要。 +- `training_order`:已申请检查和结果。 +- `training_submission`:诊断和治疗提交。 +- `case_base + traditional_case/teaching_case`:病例基础资料和标准参考。 +- `scoring_rule`:基础评分规则。 +- `knowledge_chunks`:科室/类型下命中的评分指南片段。 + +评价完成后写入: + +| 数据 | 存储位置 | +|---|---| +| 总分、评分类型、等级 | `training_record.total_score`、`score_type`、`evaluation_level` | +| 结构化评分 | `training_record.ai_feedback_structured` | +| 证据摘要 | `training_record.thinking_chain` | +| 诊断路径摘要 | `training_record.diagnosis_path` | +| 扣分点 | `training_record.wrong_points` | +| 改进计划 | `training_record.recommendation_result` | +| PDF 路径 | `training_record.pdf_file_path` | + +## 8. 审计日志 + +`audit_logs` 记录关键动作:进入 Agent、创建会话、问诊、申请检查、完成问诊、提交诊断、提交治疗、生成评价、导出 PDF。审计日志只记录元数据和对象 ID,不保存完整聊天全文。 diff --git a/docs/05_agent_prompt_design.md b/docs/05_agent_prompt_design.md new file mode 100644 index 0000000..01f008d --- /dev/null +++ b/docs/05_agent_prompt_design.md @@ -0,0 +1,169 @@ +# Agent 编排与提示词模板 + +## 1. 当前编排结构 + +```mermaid +flowchart LR + API["FastAPI Router"] --> Service["Session / Order / Evaluation Service"] + Service --> Orchestrator["MedicalConsultationOrchestrator"] + Orchestrator --> Patient["Patient Agent"] + Orchestrator --> Hint["Hint Agent"] + Service --> Order["Order Engine"] + Service --> Knowledge["Knowledge Retrieval"] + Orchestrator --> Scoring["Scoring Agent"] + Scoring --> Report["Report Agent"] +``` + +## 2. Agent 职责 + +| 模块 | 文件 | 调用时机 | 输出 | +|---|---|---|---| +| Orchestrator | `backend/app/agents/orchestrator.py` | Service 需要调用子 Agent 时 | 统一调度结果 | +| Patient Agent | `backend/app/agents/patient_agent.py` | Chat / SSE Chat | AI 病人文本回复 | +| Hint Agent | `backend/app/agents/hint_agent.py` | 练习模式点击“查看提示” | 结构化提示 JSON | +| Order Engine | `backend/app/services/order_service.py` | 申请检查/检验 | 数据库检查结果 | +| Knowledge Retrieval | `backend/app/services/knowledge_service.py` | 生成评价前 | 评分指南命中片段 | +| Scoring Agent | `backend/app/agents/scoring_agent.py` | 生成 AI 评价 | 结构化评分 JSON | +| Report Agent | `backend/app/agents/report_agent.py` | Scoring Agent 之后 | 报告字段归一化 | + +## 3. 模板目录 + +```text +backend/app/prompts/ +├─ hint/ +├─ knowledge/ +├─ patient/ +├─ polish/ +├─ report/ +└─ scoring/ +``` + +`prompt_templates` 表保存模板元数据和 Markdown 路径。第一版 Demo 中,`HintAgent` 已直接读取 Markdown 模板;`PatientAgent` 和 `ScoringAgent` 当前由代码内置结构化提示拼接,Markdown 模板作为标准资产和后续热加载基础保留。 + +## 4. 模板清单 + +| 模板文件 | agent_type | 当前调用状态 | 调用时机 | +|---|---|---|---| +| `hint/novice_case_hint.md` | `hint` | 已调用 | `POST /sessions/{session_id}/hints` | +| `patient/practice.md` | `patient` | 标准资产 | 练习模式 AI 病人回复规则 | +| `patient/teaching.md` | `patient` | 标准资产 | 教学互动模式 AI 病人回复规则 | +| `patient/free_chat.md` | `patient` | 保留 | 早期自由问诊模板 | +| `patient/novice.md` | `patient` | 保留 | 早期新手模式模板,当前归并到 practice | +| `scoring/pediatrics_pneumonia.md` | `scoring` | 标准资产 | 儿科支气管肺炎评分规则 | +| `scoring/default_percentage.md` | `scoring` | 标准资产 | 百分制评分输出约束 | +| `scoring/default_five_point.md` | `scoring` | 标准资产 | 五分制评分输出约束 | +| `report/evaluation_report.md` | `report` | 标准资产 | 评价报告整理规则 | +| `knowledge/guideline_search_query.md` | `knowledge` | 标准资产 | 评分指南检索查询生成 | +| `polish/doctor_question_polish.md` | `polish` | 保留 | 后续医生提问润色 | +| `hint/novice_hint.md` | `hint` | 保留 | 早期提示模板 | + +## 5. Markdown 模板字段规范 + +所有新增模板使用同一结构: + +```markdown +--- +template_code: novice_case_hint +agent_type: hint +version: v1 +scene: practice +model_type: fast +output_format: json +--- + +# Role + +# Task + +# Inputs + +# Rules + +# Output Format + +# Safety Boundaries +``` + +## 6. Patient Agent 运行规则 + +输入: + +- `case_base` 基础病例。 +- `traditional_case` 或 `teaching_case` 扩展信息。 +- runtime memory 最近对话。 +- 已申请检查结果。 +- 医学生最新问题。 + +约束: + +- 只扮演患儿家属或 AI 病人。 +- 每次回答 1-3 句话。 +- 不输出 JSON、Markdown、思考过程。 +- 不主动泄露未被问到的隐藏信息。 +- 不给诊断或治疗方案。 +- 不编造检查结果。 + +## 7. Hint Agent 运行规则 + +调用入口:`POST /api/v1/sessions/{session_id}/hints`。 + +输入 JSON: + +```json +{ + "case": {}, + "session": {}, + "conversation_summary": [], + "ordered_results": [], + "last_user_message": "" +} +``` + +输出 JSON: + +```json +{ + "hints": [], + "missing_dimensions": [], + "next_questions": [], + "recommended_orders": [] +} +``` + +缺失字段由后端补空数组;LLM 返回非法 JSON 时使用稳定 fallback。提示只在练习模式中由用户手动点击触发,不自动弹出。 + +## 8. Scoring Agent 运行规则 + +评分上下文拼接顺序固定: + +1. 病例基础信息:`case_base`。 +2. 模式扩展信息:`traditional_case` 或 `teaching_case`。 +3. 基础评分规则:`scoring_rule`。 +4. 指南检索结果:`knowledge_chunks`,未命中时为空数组。 +5. 用户作答情况:runtime memory、`training_order`、`training_submission`。 +6. 输出格式约束:百分制或五分制。 + +Scoring Agent 必须输出结构化 JSON: + +```json +{ + "score_type": "percentage", + "total_score": 82, + "dimension_scores": [], + "errors": [], + "improvement_plan": [], + "evidence_summary": [], + "guideline_refs": [], + "overall_comment": "" +} +``` + +`ReportAgent` 只做字段校验和整理,不重新评分。 + +## 9. 安全边界 + +- 本系统只用于医学教学训练,不替代真实临床诊疗。 +- 检查结果只来自数据库。 +- LLM 不接收真实患者身份信息。 +- 历史记录只保存完整训练后的评价结果。 +- 未完成训练不会生成长期聊天历史。 diff --git a/docs/06_demo_testing_guide.md b/docs/06_demo_testing_guide.md new file mode 100644 index 0000000..0ee99e2 --- /dev/null +++ b/docs/06_demo_testing_guide.md @@ -0,0 +1,210 @@ +# Demo 前端测试指南 + +## 1. 启动服务 + +后端: + +```powershell +cd D:\Code\newfounder\medical-consultation-agent\backend +.\.venv\Scripts\activate +uvicorn app.main:app --reload --host 127.0.0.1 --port 8000 +``` + +前端: + +```powershell +cd D:\Code\newfounder\medical-consultation-agent\frontend +npm.cmd run dev -- --host 127.0.0.1 --port 5173 +``` + +浏览器访问: + +```text +http://127.0.0.1:5173 +``` + +## 2. 初始化数据库 + +```powershell +cd D:\Code\newfounder\medical-consultation-agent\backend +.\.venv\Scripts\python.exe scripts\migrate_to_new_schema.py +``` + +当前数据库为 `medical_consultation_agent`。核心表为 `case_base`、`traditional_case`、`teaching_case`、`scoring_rule`、`case_exam_item`、`training_session`、`training_order`、`training_submission`、`training_record`。 + +## 3. 导入接口解析后的病例 SQL + +接口提供的 SQL dump 先走安全检查,不直接执行原始 SQL: + +```powershell +cd D:\Code\newfounder\medical-consultation-agent\backend +.\.venv\Scripts\python.exe scripts\import_source_case_sql.py "C:\path\to\case.sql" +``` + +检查通过后再写入当前库: + +```powershell +.\.venv\Scripts\python.exe scripts\import_source_case_sql.py "C:\path\to\case.sql" --apply +``` + +导入脚本规则: + +- 忽略源 SQL 中的 `DROP TABLE`、`CREATE TABLE`、`ALTER TABLE`、`LOCK TABLES`。 +- 按字段名映射导入 `case_base`、`traditional_case`、`teaching_case`、`scoring_rule`。 +- 源 SQL 缺少 `case_exam_item` 时,根据病例描述生成基础检查项目。 +- 源 SQL 缺少 `teaching_case` 时,只影响教学互动模式入口,不影响练习模式。 +- 源 SQL 存在乱码、字段数量不匹配或损坏字符串时拒绝导入。 + +前端上传测试: + +1. 进入 `http://127.0.0.1:5173/#/import`。 +2. 选择接口解析后的 `.sql` 文件。 +3. 点击“解析检查”。 +4. 查看识别到的表、病例预览、警告和错误。 +5. `can_import=true` 后点击“确认导入”。 +6. 导入成功后点击“刷新病例库”,或直接进入病例页查看新增病例。 + +预期结果: +- 预检阶段不写数据库。 +- 确认导入阶段写入 `case_base`、`traditional_case`、`teaching_case`、`scoring_rule`,并自动补齐基础检查项目。 +- 新增病例出现在病例列表中,可继续创建训练会话。 +- 损坏 SQL 会显示错误,不会写入任何表。 + +## 4. 入口页测试 + +1. 打开入口页。 +2. 确认 `user_id` 为 `demo_user_001`。 +3. 确认 API Base 为 `http://127.0.0.1:8000/api/v1`。 +4. 点击连接或进入 Agent。 + +预期结果: + +- 页面显示后端地址、当前 user_id、入口场景和最近连接时间。 +- “已连接”只在 `GET /api/v1/agent/hello` 成功返回后显示。 +- 功能卡片显示流式问诊、PDF 导出、知识检索、评分类型、LLM mock/fallback 状态。 + +## 5. 病例库测试 + +1. 进入病例页。 +2. 点击“支气管肺炎 - 6岁男性患儿”病例卡片。 +3. 查看病例详情。 +4. 点击“开始训练”。 + +预期结果: + +- 点击病例卡片只展开详情,不直接创建会话。 +- 详情展示科室、年龄、性别、主诉、训练类型、检查项目数量和是否有教学知识。 + +## 6. 创建会话 + +1. 选择训练模式:`练习模式` 或 `教学互动模式`。 +2. 选择评分类型:`百分制` 或 `五分制`。 +3. 点击“创建训练会话”。 + +预期结果: + +- 后端创建 `training_session`。 +- Redis/进程内 memory 生成 `memory_key`。 +- Chat 页面显示 AI 病人首句。 + +## 7. 多轮问诊 + +1. 在 Chat 输入框提问。 +2. 保持流式开关开启。 +3. 点击“发送问诊”。 + +推荐问题: + +```text +孩子发热几天了?最高体温多少? +咳嗽有没有痰?有没有喘息或呼吸困难? +精神状态和食欲怎么样? +以前有没有喘息、哮喘或过敏史? +最近有没有接触感冒、肺炎或发热的人? +``` + +预期结果: + +- 前端显示流式增量内容。 +- 收到 `message_done` 后停止“正在生成”。 +- 出错时显示错误,不会无限 pending。 + +## 8. 查看提示 + +练习模式中点击“查看提示”。 + +预期结果: + +- 调用 `POST /api/v1/sessions/{session_id}/hints`。 +- 显示缺失问诊维度、下一步可问问题、推荐检查。 +- 提示不自动弹出,不写入长期历史。 + +## 9. 检查/检验申请 + +在检查面板依次申请: + +- 血常规 +- CRP +- 胸片 +- 血氧饱和度 +- 肺部体格检查 + +预期结果: + +- 检查结果来自 `case_exam_item`。 +- 同一检查重复点击不重复写入,只显示“已申请”。 +- 页面提示“该检查结果已写入本次会话上下文和评分依据”。 + +## 10. 诊断与治疗提交 + +1. 点击“完成问诊”。 +2. 在提交页填写诊断。 +3. 点击“提交诊断”。 +4. 填写治疗方案。 +5. 点击“提交治疗方案”。 + +演示模板可通过“填入演示模板”按钮填充,默认表单为空。 + +预期结果: + +- 完成问诊要求至少一轮医生提问。 +- 诊断提交后进入治疗阶段。 +- 治疗提交后进入评价阶段。 + +## 11. 评价、PDF 与历史 + +1. 点击“生成 AI 评价报告”。 +2. 查看维度评分、证据摘要、扣分点和改进计划。 +3. 点击“导出 PDF”。 +4. 进入历史记录页刷新。 + +预期结果: + +- 评价写入 `training_record`。 +- runtime memory 释放。 +- PDF 路径写入 `training_record.pdf_file_path`。 +- 历史记录按当前 `user_id` 查询。 + +## 12. LLM 测试 + +进入 LLM 测试页: + +- 点击“测试 Fast”。 +- 点击“测试 Reason”。 + +预期结果: + +- 页面展示模型名、总耗时、是否流式、是否 mock、是否 fallback。 +- 真实模型异常时显示错误,不静默伪装为真实回复。 + +## 13. 常见问题 + +| 问题 | 排查 | +|---|---| +| 页面显示未连接 | 检查后端是否在 `127.0.0.1:8000` 启动 | +| 缺少 `X-User-Id` | 入口页填写 user_id 后重新进入 | +| 病例为空 | 运行 `scripts\migrate_to_new_schema.py` | +| Chat 卡在生成中 | 查看浏览器控制台 SSE 是否收到 `message_done` 或 `error` | +| 检查重复出现 | 刷新页面后复测,同一 `item_code` 应只出现一次 | +| 评价无法生成 | 确认已完成问诊、提交诊断、提交治疗 | +| Redis 中有 TTL | 正常行为,短期 memory 使用 TTL 自动过期 | diff --git a/docs/07_demo_function_traceability.md b/docs/07_demo_function_traceability.md new file mode 100644 index 0000000..fb2ce1e --- /dev/null +++ b/docs/07_demo_function_traceability.md @@ -0,0 +1,63 @@ +# Demo 功能追踪表 + +## 1. 功能到代码映射 + +| 功能 | 前端页面/按钮 | API | Router 函数 | Service / Agent | 数据表 | 状态 | +|---|---|---|---|---|---|---| +| Agent 入口连接 | 入口页“连接/进入” | `GET /api/v1/agent/hello` | `agent.hello` | `AuditService.log` | `audit_logs` | 已实现 | +| 病例列表 | 病例页刷新 | `GET /api/v1/cases` | `cases.list_cases` | `CaseService.list_cases` | `case_base` | 已实现 | +| 病例详情 | 点击病例卡片 | `GET /api/v1/cases/{case_id}` | `cases.get_case_detail` | `CaseService.get_case_detail` | `case_base`、`traditional_case`、`teaching_case`、`case_exam_item` | 已实现 | +| 病例 SQL 预检 | 导入页“解析检查” | `POST /api/v1/imports/case-sql/preview` | `imports.preview_case_sql` | `CaseSqlImportService.preview` | 不写库 | 已实现 | +| 病例 SQL 导入 | 导入页“确认导入” | `POST /api/v1/imports/case-sql/apply` | `imports.apply_case_sql` | `CaseSqlImportService.apply`、`scripts.import_source_case_sql` | `case_base`、`traditional_case`、`teaching_case`、`scoring_rule`、`case_exam_item` | 已实现 | +| 创建训练会话 | “创建训练会话” | `POST /api/v1/sessions` | `sessions.create_session` | `SessionService.create_session` | `training_session`、runtime memory | 已实现 | +| 普通问诊 | Chat“发送问诊” | `POST /api/v1/sessions/{session_id}/chat` | `sessions.chat` | `SessionService.chat`、`PatientAgent.reply` | runtime memory | 已实现 | +| 流式问诊 | Chat 流式开关开启 | `POST /api/v1/sessions/{session_id}/chat/stream` | `sessions.chat_stream` | `SessionService.stream_chat`、`PatientAgent.stream_reply` | runtime memory | 已实现 | +| 查看提示 | Chat“查看提示” | `POST /api/v1/sessions/{session_id}/hints` | `sessions.generate_hints` | `SessionService.generate_hints`、`HintAgent.generate` | runtime memory、`training_order` | 已实现 | +| 检查项目列表 | 检查面板刷新 | `GET /api/v1/sessions/{session_id}/order-items` | `sessions.list_order_items` | `OrderService.list_order_items` | `case_exam_item` | 已实现 | +| 申请检查 | “申请该检查” | `POST /api/v1/sessions/{session_id}/orders` | `sessions.create_order` | `OrderService.create_order` | `case_exam_item`、`training_order` | 已实现 | +| 完成问诊 | “完成问诊” | `POST /api/v1/sessions/{session_id}/complete-inquiry` | `sessions.complete_inquiry` | `SessionService.complete_inquiry` | `training_session` | 已实现 | +| 提交诊断 | “提交诊断” | `POST /api/v1/sessions/{session_id}/diagnosis` | `sessions.submit_diagnosis` | `SessionService.submit_diagnosis` | `training_submission` | 已实现 | +| 提交治疗 | “提交治疗方案” | `POST /api/v1/sessions/{session_id}/treatment` | `sessions.submit_treatment` | `SessionService.submit_treatment` | `training_submission` | 已实现 | +| 生成评价 | “生成 AI 评价报告” | `POST /api/v1/sessions/{session_id}/evaluation` | `sessions.create_evaluation` | `EvaluationService.create_evaluation`、`ScoringAgent.score`、`ReportAgent` | `scoring_rule`、`knowledge_chunks`、`training_record` | 已实现 | +| 历史列表 | 历史页刷新 | `GET /api/v1/evaluations` | `evaluations.list_evaluations` | `EvaluationService.list_history` | `training_record` | 已实现 | +| 报告详情 | 历史详情/报告页 | `GET /api/v1/evaluations/{evaluation_id}` | `evaluations.get_evaluation_detail` | `EvaluationService.get_detail` | `training_record` | 已实现 | +| PDF 导出 | 报告页“导出 PDF” | `POST /api/v1/evaluations/{evaluation_id}/export-pdf` | `evaluations.export_pdf` | `PdfExportService.export` | `training_record` | 已实现 | +| 知识检索 | 后端预留/评价内部使用 | `GET /api/v1/knowledge/search` | `knowledge.search_knowledge` | `KnowledgeService.search_guidelines` | `knowledge_sources`、`knowledge_documents`、`knowledge_chunks` | 已实现 | +| LLM Fast 测试 | LLM 测试页“测试 Fast” | `POST /api/v1/llm/test/deepseek-fast` | `llm_test.test_deepseek_fast` | `OpenAICompatibleLLMClient.chat` | 无长期表 | 已实现 | +| LLM Reason 测试 | LLM 测试页“测试 Reason” | `POST /api/v1/llm/test/deepseek-reason` | `llm_test.test_deepseek_reason` | `OpenAICompatibleLLMClient.stream_chat/chat` | 无长期表 | 已实现 | + +## 2. 当前数据表状态 + +当前功能只依赖新表: + +```text +case_base +traditional_case +teaching_case +scoring_rule +case_exam_item +training_session +training_order +training_submission +training_record +prompt_templates +knowledge_sources +knowledge_documents +knowledge_chunks +user_learning_profiles +audit_logs +``` + +旧表已不参与运行。旧表删除后,后端测试和前端构建均已通过。 + +## 3. 核心状态流 + +```text +inquiry -> diagnosis -> treatment -> evaluating -> completed +``` + +- `inquiry`:允许 Chat、提示、申请检查。 +- `diagnosis`:允许提交诊断。 +- `treatment`:允许提交治疗方案。 +- `evaluating`:允许生成 AI 评价。 +- `completed`:允许查询历史、查看详情、导出 PDF。 diff --git a/docs/08_pediatric_case_demo_script.md b/docs/08_pediatric_case_demo_script.md new file mode 100644 index 0000000..35045b4 --- /dev/null +++ b/docs/08_pediatric_case_demo_script.md @@ -0,0 +1,119 @@ +# 儿科支气管肺炎病例演示脚本 + +## 1. 演示目标 + +展示“病例选择 -> 多轮问诊 -> 检查申请 -> 诊断治疗提交 -> AI 评价 -> PDF 导出 -> 历史记录”的完整闭环。 + +## 2. 演示前准备 + +- MySQL、Redis、后端、前端均已启动。 +- 前端入口页 user_id 使用 `demo_user_001`。 +- 数据库已运行 `scripts\migrate_to_new_schema.py`。 +- 后端 `.env` 使用真实 LLM 或明确显示 mock 状态。 + +## 3. 病例简介 + +病例:支气管肺炎 - 6岁男性患儿。 + +主诉:发热、咳嗽 4 天,喘息 1 天。 + +训练重点: + +- 发热、咳嗽、喘息等现病史采集。 +- 既往喘息史、过敏史、接触史、用药史。 +- 血常规、CRP、胸片、血氧饱和度、肺部体格检查。 +- 支气管肺炎诊断与鉴别诊断。 +- 抗感染、平喘、氧合监测、风险预案和医患沟通。 + +## 4. 前端点击路径 + +1. 入口页:确认连接成功。 +2. 病例页:点击“支气管肺炎 - 6岁男性患儿”。 +3. 病例详情:点击“开始训练”。 +4. 训练配置:选择“练习模式”和“百分制”。 +5. Chat 页:进行多轮问诊。 +6. 检查面板:申请检查/检验。 +7. Chat 页:点击“完成问诊”。 +8. 提交页:填写诊断和治疗。 +9. 报告页:生成评价、导出 PDF。 +10. 历史页:刷新并查看记录详情。 + +## 5. 可复制问诊模板 + +```text +孩子发热几天了?最高体温多少? +咳嗽有没有痰?有没有喘息或呼吸困难? +精神状态和食欲怎么样? +有没有呕吐、腹泻、抽搐或皮疹? +以前有没有类似喘息、哮喘、过敏史? +最近有没有接触感冒、肺炎或发热的人? +有没有用过退烧药、抗生素或雾化? +夜间症状是否加重? +尿量怎么样?有没有脱水表现? +家属对治疗有什么担心? +``` + +## 6. 检查/检验申请 + +演示时依次点击: + +- 血常规 +- CRP +- 胸片 +- 血氧饱和度 +- 肺部体格检查 + +展示重点: + +- 检查结果来自数据库,不由 LLM 编造。 +- 重复申请同一检查不会重复插入。 +- 检查结果会进入本次会话上下文和评分依据。 + +## 7. 诊断填写模板 + +```text +主要诊断:支气管肺炎 + +鉴别诊断: +毛细支气管炎、支气管哮喘急性发作、肺结核、上呼吸道感染 + +诊断依据: +患儿发热、咳嗽、喘息,肺部可闻及湿啰音,结合血常规和 CRP 炎症指标升高、胸片异常及血氧饱和度情况,符合儿童支气管肺炎表现。 +``` + +## 8. 治疗填写模板 + +```text +治疗原则: +抗感染、止咳平喘、改善氧合、严密观察病情变化。 + +治疗措施: +根据病情选择抗感染治疗,必要时雾化吸入缓解喘息,监测体温、呼吸、血氧和精神反应。 + +风险预案: +关注低氧、呼吸困难加重、持续高热、精神反应差、脱水等情况。 + +医患沟通: +向家属说明肺炎病情、用药注意事项、观察指标和复诊/住院指征。 + +随访安排: +治疗后复查体温、呼吸、血氧和必要炎症指标,症状加重时及时就诊。 +``` + +## 9. 报告展示重点 + +生成报告后重点展示: + +- 总分和评分类型。 +- 信息采集、分析推理、处置决策、沟通人文、临床整合等维度评分。 +- 证据摘要:系统如何结合问诊、检查、诊断和治疗给出评分。 +- 扣分点:遗漏了哪些问诊或检查。 +- 改进计划:下一次训练如何提高。 + +## 10. 可强调的产品价值 + +- 将病例库、问诊训练、检查申请、诊断治疗、AI 评价串成闭环。 +- 用户身份由宿主系统传入,本 Agent 不做重复登录。 +- 短期 memory 支持多轮训练,但不沉淀未完成聊天历史。 +- 检查结果由数据库控制,避免模型编造。 +- 评分可结合病例评分规则、科室指南和人文关怀要求继续扩展。 diff --git a/docs/sql/schema.sql b/docs/sql/schema.sql new file mode 100644 index 0000000..77a3909 --- /dev/null +++ b/docs/sql/schema.sql @@ -0,0 +1,516 @@ +CREATE DATABASE IF NOT EXISTS `medical_consultation_agent` + DEFAULT CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +USE `medical_consultation_agent`; + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +DROP TABLE IF EXISTS `audit_logs`; +DROP TABLE IF EXISTS `user_learning_profiles`; +DROP TABLE IF EXISTS `training_record`; +DROP TABLE IF EXISTS `scoring_rule`; +DROP TABLE IF EXISTS `teaching_case`; +DROP TABLE IF EXISTS `traditional_case`; +DROP TABLE IF EXISTS `case_base`; +DROP TABLE IF EXISTS `rubric_templates`; +DROP TABLE IF EXISTS `prompt_templates`; +DROP TABLE IF EXISTS `knowledge_chunks`; +DROP TABLE IF EXISTS `knowledge_documents`; +DROP TABLE IF EXISTS `knowledge_sources`; +DROP TABLE IF EXISTS `evaluation_report_exports`; +DROP TABLE IF EXISTS `evaluation_records`; +DROP TABLE IF EXISTS `session_submissions`; +DROP TABLE IF EXISTS `session_orders`; +DROP TABLE IF EXISTS `session_runtime_messages`; +DROP TABLE IF EXISTS `training_sessions`; +DROP TABLE IF EXISTS `case_exam_items`; +DROP TABLE IF EXISTS `cases`; +DROP TABLE IF EXISTS `users`; +DROP TABLE IF EXISTS `departments`; + +CREATE TABLE `departments` ( + `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '科室ID', + `name` varchar(100) NOT NULL COMMENT '科室名称', + `code` varchar(50) NOT NULL COMMENT '科室编码', + `parent_id` int(11) DEFAULT NULL COMMENT '父级科室ID', + `sort_order` int(11) DEFAULT 0 COMMENT '排序', + `is_active` tinyint(1) DEFAULT 1 COMMENT '是否激活', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_department_code` (`code`), + KEY `idx_department_parent` (`parent_id`), + CONSTRAINT `fk_department_parent` FOREIGN KEY (`parent_id`) REFERENCES `departments` (`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='科室表'; + +CREATE TABLE `users` ( + `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '内部占位用户ID', + `external_user_id` varchar(128) NOT NULL COMMENT '宿主系统用户ID', + `display_name` varchar(100) DEFAULT NULL COMMENT '展示名', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_external_user_id` (`external_user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='宿主用户引用占位表'; + +CREATE TABLE `case_base` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '源库病例主表ID', + `title` varchar(255) NOT NULL COMMENT '病例标题', + `case_type` varchar(30) NOT NULL COMMENT '病例分类/训练类别', + `difficulty` varchar(20) NOT NULL DEFAULT 'medium' COMMENT '难度', + `difficulty_score` int DEFAULT NULL COMMENT '难度分值', + `chief_complaint` longtext NOT NULL COMMENT '主诉', + `description` longtext NOT NULL COMMENT '病例描述', + `patient_age` int DEFAULT NULL COMMENT '患者年龄', + `patient_gender` varchar(10) NOT NULL COMMENT '患者性别', + `tags` varchar(500) NOT NULL DEFAULT '' COMMENT '标签', + `symptom_tags` json NOT NULL COMMENT '症状标签', + `disease_tags` json NOT NULL COMMENT '疾病标签', + `competency_tags` json NOT NULL COMMENT '能力标签', + `guideline_tags` json NOT NULL COMMENT '指南标签', + `knowledge_points` json NOT NULL COMMENT '知识点', + `icd_codes` varchar(500) NOT NULL DEFAULT '' COMMENT 'ICD编码', + `estimated_minutes` int DEFAULT NULL COMMENT '预计训练时长', + `osce_enabled` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否OSCE', + `rag_enabled` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否启用指南检索', + `ai_prompt_template` longtext NOT NULL COMMENT 'AI病人提示词模板引用', + `multimodal_assets` json NOT NULL COMMENT '多模态资源', + `vector_status` smallint NOT NULL DEFAULT 0 COMMENT '向量状态', + `publish_status` smallint NOT NULL DEFAULT 1 COMMENT '发布状态', + `status` smallint NOT NULL DEFAULT 1 COMMENT '业务状态', + `created_by_id` bigint DEFAULT NULL COMMENT '创建人ID', + `department_id` bigint DEFAULT NULL COMMENT '科室ID', + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间', + `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_case_base_department` (`department_id`), + KEY `idx_case_base_case_type` (`case_type`), + KEY `idx_case_base_status` (`status`, `publish_status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='源库病例主表'; + +CREATE TABLE `traditional_case` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '传统病例ID', + `standard_diagnosis` longtext NOT NULL COMMENT '标准诊断', + `standard_treatment` longtext NOT NULL COMMENT '标准治疗', + `guideline_reference` longtext NOT NULL COMMENT '指南参考', + `case_id` bigint NOT NULL COMMENT '病例主表ID', + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间', + `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_traditional_case_id` (`case_id`), + CONSTRAINT `fk_traditional_case_base` FOREIGN KEY (`case_id`) REFERENCES `case_base` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='传统病例扩展表'; + +CREATE TABLE `teaching_case` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '教学互动病例ID', + `teaching_goal` longtext NOT NULL COMMENT '教学目标', + `discussion_questions` longtext NOT NULL COMMENT '讨论问题', + `teacher_guide` longtext NOT NULL COMMENT '教师引导', + `scoring_focus` longtext NOT NULL COMMENT '评分重点', + `case_id` bigint NOT NULL COMMENT '病例主表ID', + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间', + `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_teaching_case_id` (`case_id`), + CONSTRAINT `fk_teaching_case_base` FOREIGN KEY (`case_id`) REFERENCES `case_base` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='教学互动病例扩展表'; + +CREATE TABLE `scoring_rule` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '评分规则ID', + `dimension` varchar(50) NOT NULL COMMENT '评分维度', + `competency_dimension` varchar(50) NOT NULL COMMENT '能力维度', + `score_weight` decimal(5,2) NOT NULL COMMENT '分值/权重', + `ai_auto_score` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否AI自动评分', + `osce_dimension` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否OSCE维度', + `scoring_standard` longtext NOT NULL COMMENT '评分标准', + `rubric_json` json NOT NULL COMMENT '结构化评分细则', + `case_id` bigint NOT NULL COMMENT '病例主表ID', + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间', + `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_scoring_rule_case_dimension` (`case_id`, `dimension`, `competency_dimension`), + KEY `idx_scoring_rule_case` (`case_id`), + CONSTRAINT `fk_scoring_rule_case_base` FOREIGN KEY (`case_id`) REFERENCES `case_base` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='源库评分规则表'; + +CREATE TABLE `cases` ( + `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '病例ID', + `source_case_id` bigint DEFAULT NULL COMMENT '源库病例ID,对应 case_base.id', + `case_code` varchar(64) NOT NULL COMMENT '病例编码', + `department_id` int(11) NOT NULL COMMENT '所属科室ID', + `title` varchar(100) NOT NULL COMMENT '病例标题', + `difficulty` enum('easy','medium','hard') DEFAULT 'medium' COMMENT '难度', + `patient_name` varchar(50) DEFAULT NULL COMMENT '患者姓名', + `patient_age` int(11) DEFAULT NULL COMMENT '患者年龄', + `patient_gender` enum('male','female') DEFAULT NULL COMMENT '患者性别', + `patient_occupation` varchar(50) DEFAULT NULL COMMENT '职业', + `chief_complaint` text COMMENT '主诉', + `present_illness` text COMMENT '现病史', + `past_history` text COMMENT '既往史', + `personal_history` text COMMENT '个人史', + `family_history` text COMMENT '家族史', + `physical_exam` json DEFAULT NULL COMMENT '体格检查数据', + `auxiliary_exam` json DEFAULT NULL COMMENT '辅助检查数据', + `diagnosis_primary` text COMMENT '主要诊断', + `diagnosis_differential` json DEFAULT NULL COMMENT '鉴别诊断列表', + `diagnosis_basis` text COMMENT '诊断依据', + `treatment_plan` json DEFAULT NULL COMMENT '治疗方案', + `consultation_config` json DEFAULT NULL COMMENT '会诊配置', + `inquiry_options` json DEFAULT NULL COMMENT '问诊选项(互动模式)', + `knowledge_videos` json DEFAULT NULL COMMENT '知识点视频(互动模式)', + `quiz_questions` json DEFAULT NULL COMMENT '教学题库(互动模式)', + `key_symptoms` json DEFAULT NULL COMMENT '关键症状', + `key_exams` json DEFAULT NULL COMMENT '关键检查', + `key_points` json DEFAULT NULL COMMENT '考核要点', + `evidence_reasoning_chain` json DEFAULT NULL COMMENT '诊断循证思维链', + `assessment_config` json DEFAULT NULL COMMENT '评分与考核配置', + `ai_patient_profile` json DEFAULT NULL COMMENT 'AI病人人设', + `patient_opening` text COMMENT 'AI病人开场白', + `hidden_patient_info` json DEFAULT NULL COMMENT '问到才回答的隐藏信息', + `has_teaching_video` tinyint(1) DEFAULT 0 COMMENT '是否有教学视频', + `has_knowledge_points` tinyint(1) DEFAULT 0 COMMENT '是否有知识点', + `has_quiz` tinyint(1) DEFAULT 0 COMMENT '是否有教学题库', + `source_pdf_name` varchar(255) DEFAULT NULL COMMENT '来源PDF名称', + `supported_training_type` enum('case_analysis','diagnosis_treatment','consultation') DEFAULT 'case_analysis' COMMENT '支持的训练类别(单选)', + `supported_mode` enum('free_chat','interactive') DEFAULT 'free_chat' COMMENT '支持的交互模式(单选)', + `is_active` tinyint(1) DEFAULT 1 COMMENT '是否激活', + `created_by` int(11) DEFAULT NULL COMMENT '创建人ID', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_case_code` (`case_code`), + KEY `idx_department_id` (`department_id`), + KEY `idx_difficulty` (`difficulty`), + KEY `idx_is_active` (`is_active`), + KEY `idx_created_by` (`created_by`), + KEY `idx_training_type` (`supported_training_type`), + KEY `idx_mode` (`supported_mode`), + KEY `idx_cases_source_case` (`source_case_id`), + CONSTRAINT `fk_case_department` FOREIGN KEY (`department_id`) REFERENCES `departments` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_case_creator` FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='病例表'; + +CREATE TABLE `case_exam_items` ( + `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '检查项目ID', + `case_id` int(11) NOT NULL COMMENT '病例ID', + `source_case_id` bigint DEFAULT NULL COMMENT '源库病例ID,用于后续从 case_base 直接读取检查项目', + `item_code` varchar(64) NOT NULL COMMENT '项目编码', + `item_name` varchar(128) NOT NULL COMMENT '项目名称', + `item_type` enum('lab','imaging','physical_exam','vital_sign','other') NOT NULL COMMENT '项目类型', + `category` varchar(64) DEFAULT NULL COMMENT '分类', + `result_text` text NOT NULL COMMENT '结果文本', + `result_structured` json DEFAULT NULL COMMENT '结构化结果', + `is_key` tinyint(1) DEFAULT 0 COMMENT '是否关键检查', + `is_abnormal` tinyint(1) DEFAULT 0 COMMENT '是否异常', + `score_weight` decimal(5,2) DEFAULT 0.00 COMMENT '评分权重', + `display_order` int(11) DEFAULT 0 COMMENT '展示顺序', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_case_item_code` (`case_id`,`item_code`), + KEY `idx_exam_case_type` (`case_id`,`item_type`), + KEY `idx_case_exam_source_case` (`source_case_id`), + CONSTRAINT `fk_exam_case` FOREIGN KEY (`case_id`) REFERENCES `cases` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='病例检查检验项目表'; + +CREATE TABLE `training_sessions` ( + `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '会话ID', + `session_code` varchar(64) NOT NULL COMMENT '会话编码', + `user_id` varchar(128) NOT NULL COMMENT '宿主系统用户ID', + `tenant_id` varchar(128) DEFAULT NULL COMMENT '租户或项目ID', + `class_id` varchar(128) DEFAULT NULL COMMENT '班级或课程ID', + `entry_scene` varchar(64) DEFAULT NULL COMMENT '入口场景', + `case_id` int(11) NOT NULL COMMENT '病例ID', + `source_case_id` bigint DEFAULT NULL COMMENT '源库病例ID,保留 case_id 兼容旧运行表', + `training_type` enum('case_analysis','diagnosis_treatment','consultation') NOT NULL COMMENT '训练类别', + `mode` enum('novice','practice','teaching') NOT NULL COMMENT '训练模式', + `score_type` enum('percentage','five_point') NOT NULL DEFAULT 'percentage' COMMENT '分数输出类型', + `status` enum('created','inquiry','diagnosis','treatment','evaluating','completed','aborted') NOT NULL DEFAULT 'created' COMMENT '会话状态', + `started_at` datetime DEFAULT NULL COMMENT '开始时间', + `inquiry_completed_at` datetime DEFAULT NULL COMMENT '问诊完成时间', + `completed_at` datetime DEFAULT NULL COMMENT '完成时间', + `memory_key` varchar(128) DEFAULT NULL COMMENT '短期memory key', + `metadata` json DEFAULT NULL COMMENT '扩展数据', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_session_code` (`session_code`), + KEY `idx_session_user_status` (`user_id`,`status`), + KEY `idx_session_case` (`case_id`), + KEY `idx_session_created` (`created_at`), + KEY `idx_training_sessions_source_case` (`source_case_id`), + CONSTRAINT `fk_session_case` FOREIGN KEY (`case_id`) REFERENCES `cases` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='训练会话表'; + +CREATE TABLE `session_runtime_messages` ( + `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '短期消息ID', + `session_id` int(11) NOT NULL COMMENT '会话ID', + `user_id` varchar(128) NOT NULL COMMENT '宿主系统用户ID', + `role` enum('doctor','patient','system','tool') NOT NULL COMMENT '角色', + `content` text NOT NULL COMMENT '消息内容', + `content_structured` json DEFAULT NULL COMMENT '结构化内容', + `sequence_no` int(11) NOT NULL COMMENT '会话内序号', + `expires_at` datetime DEFAULT NULL COMMENT '过期时间', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_runtime_session_seq` (`session_id`,`sequence_no`), + KEY `idx_runtime_expires` (`expires_at`), + CONSTRAINT `fk_runtime_session` FOREIGN KEY (`session_id`) REFERENCES `training_sessions` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='短期会话消息调试表'; + +CREATE TABLE `session_orders` ( + `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '申请ID', + `session_id` int(11) NOT NULL COMMENT '会话ID', + `user_id` varchar(128) NOT NULL COMMENT '宿主系统用户ID', + `case_exam_item_id` int(11) NOT NULL COMMENT '病例检查项目ID', + `item_code` varchar(64) NOT NULL COMMENT '项目编码', + `item_name` varchar(128) NOT NULL COMMENT '项目名称', + `item_type` varchar(32) NOT NULL COMMENT '项目类型', + `result_text` text NOT NULL COMMENT '结果文本', + `result_structured` json DEFAULT NULL COMMENT '结构化结果', + `is_key` tinyint(1) DEFAULT 0 COMMENT '是否关键检查', + `is_abnormal` tinyint(1) DEFAULT 0 COMMENT '是否异常', + `ordered_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '申请时间', + PRIMARY KEY (`id`), + KEY `idx_order_session` (`session_id`), + KEY `idx_order_user_session` (`user_id`,`session_id`), + CONSTRAINT `fk_order_session` FOREIGN KEY (`session_id`) REFERENCES `training_sessions` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_order_exam_item` FOREIGN KEY (`case_exam_item_id`) REFERENCES `case_exam_items` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会话检查检验申请表'; + +CREATE TABLE `session_submissions` ( + `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '提交ID', + `session_id` int(11) NOT NULL COMMENT '会话ID', + `user_id` varchar(128) NOT NULL COMMENT '宿主系统用户ID', + `primary_diagnosis` text COMMENT '主要诊断', + `differential_diagnoses` json DEFAULT NULL COMMENT '鉴别诊断', + `diagnosis_basis` text COMMENT '诊断依据', + `treatment_principle` text COMMENT '治疗原则', + `treatment_measures` text COMMENT '治疗措施', + `risk_plan` text COMMENT '风险预案', + `communication` text COMMENT '沟通告知与健康教育', + `follow_up` text COMMENT '随访计划', + `diagnosis_submitted_at` datetime DEFAULT NULL COMMENT '诊断提交时间', + `treatment_submitted_at` datetime DEFAULT NULL COMMENT '治疗提交时间', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_submission_session` (`session_id`), + KEY `idx_submission_user` (`user_id`), + CONSTRAINT `fk_submission_session` FOREIGN KEY (`session_id`) REFERENCES `training_sessions` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会话诊断治疗提交表'; + +CREATE TABLE `knowledge_sources` ( + `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '知识来源ID', + `source_code` varchar(64) NOT NULL COMMENT '来源编码', + `source_name` varchar(255) NOT NULL COMMENT '来源名称', + `source_type` enum('national_standard','department_expert','exam_requirement','clinical_guideline','humanistic_care','other') NOT NULL COMMENT '来源类型', + `authority_level` int(11) DEFAULT 1 COMMENT '权威等级', + `is_active` tinyint(1) DEFAULT 1 COMMENT '是否启用', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_source_code` (`source_code`), + KEY `idx_source_type` (`source_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识来源表'; + +CREATE TABLE `knowledge_documents` ( + `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '知识文档ID', + `source_id` int(11) NOT NULL COMMENT '来源ID', + `department_id` int(11) DEFAULT NULL COMMENT '科室ID', + `title` varchar(255) NOT NULL COMMENT '文档标题', + `task_type` varchar(64) DEFAULT NULL COMMENT '任务类型', + `summary` text COMMENT '摘要', + `file_path` varchar(512) DEFAULT NULL COMMENT '文件路径', + `is_active` tinyint(1) DEFAULT 1 COMMENT '是否启用', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_doc_source` (`source_id`), + KEY `idx_doc_department_task` (`department_id`,`task_type`), + CONSTRAINT `fk_doc_source` FOREIGN KEY (`source_id`) REFERENCES `knowledge_sources` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_doc_department` FOREIGN KEY (`department_id`) REFERENCES `departments` (`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识文档表'; + +CREATE TABLE `knowledge_chunks` ( + `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '知识片段ID', + `document_id` int(11) NOT NULL COMMENT '文档ID', + `department_id` int(11) DEFAULT NULL COMMENT '科室ID', + `task_type` varchar(64) DEFAULT NULL COMMENT '任务类型', + `chunk_text` text NOT NULL COMMENT '片段内容', + `keywords` json DEFAULT NULL COMMENT '关键词', + `weight` decimal(5,2) DEFAULT 1.00 COMMENT '权重', + `is_active` tinyint(1) DEFAULT 1 COMMENT '是否启用', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_chunk_doc` (`document_id`), + KEY `idx_chunk_department_task` (`department_id`,`task_type`), + FULLTEXT KEY `ft_chunk_text` (`chunk_text`), + CONSTRAINT `fk_chunk_doc` FOREIGN KEY (`document_id`) REFERENCES `knowledge_documents` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_chunk_department` FOREIGN KEY (`department_id`) REFERENCES `departments` (`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识片段表'; + +CREATE TABLE `evaluation_records` ( + `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '评价记录ID', + `evaluation_code` varchar(64) NOT NULL COMMENT '评价记录编码', + `session_id` int(11) NOT NULL COMMENT '会话ID', + `user_id` varchar(128) NOT NULL COMMENT '宿主系统用户ID', + `tenant_id` varchar(128) DEFAULT NULL COMMENT '租户或项目ID', + `case_id` int(11) NOT NULL COMMENT '病例ID', + `source_case_id` bigint DEFAULT NULL COMMENT '源库病例ID,保留 case_id 兼容旧报告表', + `training_type` varchar(64) NOT NULL COMMENT '训练类别', + `mode` varchar(64) NOT NULL COMMENT '训练模式', + `score_type` enum('percentage','five_point') NOT NULL COMMENT '分数类型', + `total_score` decimal(6,2) NOT NULL COMMENT '总分', + `dimension_scores` json NOT NULL COMMENT '维度评分', + `errors` json DEFAULT NULL COMMENT '错误分析', + `improvement_plan` json DEFAULT NULL COMMENT '改进方案', + `evidence_summary` json DEFAULT NULL COMMENT '评分依据摘要', + `guideline_refs` json DEFAULT NULL COMMENT '参考指南来源', + `overall_comment` text COMMENT '总体评价', + `llm_model` varchar(100) DEFAULT NULL COMMENT 'LLM模型', + `latency_metrics` json DEFAULT NULL COMMENT '耗时指标', + `pdf_file_path` varchar(512) DEFAULT NULL COMMENT 'PDF报告路径', + `status` enum('generated','exported','failed') DEFAULT 'generated' COMMENT '状态', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_evaluation_code` (`evaluation_code`), + UNIQUE KEY `uk_evaluation_session` (`session_id`), + KEY `idx_eval_user_created` (`user_id`,`created_at`), + KEY `idx_eval_case` (`case_id`), + KEY `idx_eval_source_case` (`source_case_id`), + CONSTRAINT `fk_eval_session` FOREIGN KEY (`session_id`) REFERENCES `training_sessions` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_eval_case` FOREIGN KEY (`case_id`) REFERENCES `cases` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI评价记录表'; + +CREATE TABLE `evaluation_report_exports` ( + `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '导出记录ID', + `evaluation_id` int(11) NOT NULL COMMENT '评价记录ID', + `user_id` varchar(128) NOT NULL COMMENT '宿主系统用户ID', + `file_path` varchar(512) NOT NULL COMMENT 'PDF文件路径', + `file_name` varchar(255) NOT NULL COMMENT 'PDF文件名', + `score_type` enum('percentage','five_point') NOT NULL COMMENT '分数类型', + `export_status` enum('success','failed') DEFAULT 'success' COMMENT '导出状态', + `exported_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '导出时间', + PRIMARY KEY (`id`), + KEY `idx_export_eval` (`evaluation_id`), + KEY `idx_export_user` (`user_id`,`exported_at`), + CONSTRAINT `fk_export_eval` FOREIGN KEY (`evaluation_id`) REFERENCES `evaluation_records` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='评价报告PDF导出记录表'; + +CREATE TABLE `prompt_templates` ( + `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '提示词模板ID', + `template_code` varchar(64) NOT NULL COMMENT '模板编码', + `agent_type` enum('patient','scoring','report','polish','hint','knowledge') NOT NULL COMMENT 'Agent类型', + `scene` varchar(64) NOT NULL COMMENT '场景', + `version_no` varchar(32) NOT NULL COMMENT '版本', + `model_type` enum('fast','reason') NOT NULL COMMENT '模型类型', + `output_format` enum('text','json') NOT NULL COMMENT '输出格式', + `file_path` varchar(512) NOT NULL COMMENT 'Markdown文件路径', + `is_active` tinyint(1) DEFAULT 1 COMMENT '是否启用', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_prompt_code_version` (`template_code`,`version_no`), + KEY `idx_prompt_agent_scene` (`agent_type`,`scene`,`is_active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='提示词模板元数据表'; + +CREATE TABLE `rubric_templates` ( + `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '评分规则ID', + `rubric_code` varchar(64) NOT NULL COMMENT '规则编码', + `rubric_name` varchar(128) NOT NULL COMMENT '规则名称', + `version_no` varchar(32) NOT NULL COMMENT '版本', + `department_id` int(11) DEFAULT NULL COMMENT '科室ID', + `training_type` varchar(64) NOT NULL COMMENT '训练类别', + `score_type` enum('percentage','five_point') NOT NULL COMMENT '分数类型', + `dimensions` json NOT NULL COMMENT '评分维度', + `is_active` tinyint(1) DEFAULT 1 COMMENT '是否启用', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_rubric_code_version` (`rubric_code`,`version_no`), + KEY `idx_rubric_department_type` (`department_id`,`training_type`,`score_type`), + CONSTRAINT `fk_rubric_department` FOREIGN KEY (`department_id`) REFERENCES `departments` (`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='评分规则表'; + +CREATE TABLE `training_record` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '训练记录ID', + `training_mode` varchar(50) NOT NULL COMMENT '训练模式:practice/teaching', + `case_type` varchar(30) NOT NULL COMMENT '病例分类', + `start_time` datetime(6) NOT NULL COMMENT '开始时间', + `end_time` datetime(6) DEFAULT NULL COMMENT '结束时间', + `duration_seconds` int DEFAULT NULL COMMENT '训练耗时秒', + `total_score` decimal(5,2) DEFAULT NULL COMMENT '总分', + `ai_score` decimal(5,2) DEFAULT NULL COMMENT 'AI评分', + `teacher_score` decimal(5,2) DEFAULT NULL COMMENT '教师评分', + `evaluation_level` varchar(20) NOT NULL DEFAULT '' COMMENT '评价等级', + `status` varchar(30) NOT NULL COMMENT '记录状态', + `feedback` longtext NOT NULL COMMENT '总体反馈', + `thinking_chain` longtext NOT NULL COMMENT '评价证据链摘要', + `diagnosis_path` longtext NOT NULL COMMENT '诊断路径摘要', + `wrong_points` json NOT NULL COMMENT '错误点', + `missed_questions` json NOT NULL COMMENT '遗漏问题', + `recommendation_result` json NOT NULL COMMENT '改进建议', + `ai_feedback_structured` json NOT NULL COMMENT 'AI结构化反馈', + `osce_station_score` json NOT NULL COMMENT 'OSCE评分', + `interruption_count` int NOT NULL DEFAULT 0 COMMENT '中断次数', + `emotion_analysis` json NOT NULL COMMENT '情绪分析', + `prompt_version` varchar(50) NOT NULL DEFAULT 'v1' COMMENT '提示词版本', + `rag_context_version` varchar(50) NOT NULL DEFAULT 'none' COMMENT '指南检索版本', + `case_id` bigint NOT NULL COMMENT '病例ID,对应 case_base.id', + `teacher_id` bigint DEFAULT NULL COMMENT '教师ID', + `user_id` bigint DEFAULT NULL COMMENT '源系统数字用户ID;宿主传字符串时为空', + `external_user_id` varchar(128) NOT NULL COMMENT '宿主系统传入的 user_id', + `session_id` bigint DEFAULT NULL COMMENT '本系统训练会话ID', + `evaluation_record_id` bigint DEFAULT NULL COMMENT '兼容评价记录ID', + `score_type` varchar(20) NOT NULL DEFAULT 'percentage' COMMENT '分数类型', + `pdf_file_path` varchar(512) DEFAULT NULL COMMENT 'PDF报告路径', + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间', + `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_training_record_session` (`session_id`), + KEY `idx_training_record_external_user` (`external_user_id`, `created_at`), + KEY `idx_training_record_case` (`case_id`), + KEY `idx_training_record_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='源库训练记录表'; + +CREATE TABLE `user_learning_profiles` ( + `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '学习档案ID', + `user_id` varchar(128) NOT NULL COMMENT '宿主系统用户ID', + `tenant_id` varchar(128) DEFAULT NULL COMMENT '租户或项目ID', + `total_evaluations` int(11) DEFAULT 0 COMMENT '评价次数', + `avg_score_percentage` decimal(6,2) DEFAULT NULL COMMENT '百分制平均分', + `avg_score_five_point` decimal(4,2) DEFAULT NULL COMMENT '五分制平均分', + `weak_dimensions` json DEFAULT NULL COMMENT '薄弱维度', + `last_evaluation_id` int(11) DEFAULT NULL COMMENT '最近评价记录ID', + `last_trained_at` datetime DEFAULT NULL COMMENT '最近训练时间', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_profile_user_tenant` (`user_id`,`tenant_id`), + KEY `idx_profile_last_trained` (`last_trained_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户学习档案表'; + +CREATE TABLE `audit_logs` ( + `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '审计日志ID', + `user_id` varchar(128) DEFAULT NULL COMMENT '宿主系统用户ID', + `tenant_id` varchar(128) DEFAULT NULL COMMENT '租户或项目ID', + `session_id` int(11) DEFAULT NULL COMMENT '会话ID', + `action` varchar(64) NOT NULL COMMENT '动作', + `resource_type` varchar(64) NOT NULL COMMENT '资源类型', + `resource_id` varchar(128) DEFAULT NULL COMMENT '资源ID', + `request_id` varchar(128) DEFAULT NULL COMMENT '请求ID', + `ip_address` varchar(64) DEFAULT NULL COMMENT 'IP地址', + `user_agent` varchar(512) DEFAULT NULL COMMENT 'User-Agent', + `metadata` json DEFAULT NULL COMMENT '元数据', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_audit_user_created` (`user_id`,`created_at`), + KEY `idx_audit_session` (`session_id`), + KEY `idx_audit_action_created` (`action`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='审计日志表'; + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/docs/sql/seed_pediatric_pneumonia.sql b/docs/sql/seed_pediatric_pneumonia.sql new file mode 100644 index 0000000..7f3d907 --- /dev/null +++ b/docs/sql/seed_pediatric_pneumonia.sql @@ -0,0 +1,197 @@ +USE `medical_consultation_agent`; + +SET NAMES utf8mb4; + +INSERT INTO `departments` (`id`, `name`, `code`, `parent_id`, `sort_order`, `is_active`) +VALUES (1, '儿科', 'PEDIATRICS', NULL, 1, 1) +ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `is_active` = VALUES(`is_active`); + +INSERT INTO `users` (`id`, `external_user_id`, `display_name`) +VALUES (1, 'system_seed', '系统种子数据') +ON DUPLICATE KEY UPDATE `display_name` = VALUES(`display_name`); + +INSERT INTO `cases` ( + `id`, + `case_code`, + `department_id`, + `title`, + `difficulty`, + `patient_name`, + `patient_age`, + `patient_gender`, + `patient_occupation`, + `chief_complaint`, + `present_illness`, + `past_history`, + `personal_history`, + `family_history`, + `physical_exam`, + `auxiliary_exam`, + `diagnosis_primary`, + `diagnosis_differential`, + `diagnosis_basis`, + `treatment_plan`, + `consultation_config`, + `inquiry_options`, + `knowledge_videos`, + `quiz_questions`, + `key_symptoms`, + `key_exams`, + `key_points`, + `evidence_reasoning_chain`, + `assessment_config`, + `ai_patient_profile`, + `patient_opening`, + `hidden_patient_info`, + `has_teaching_video`, + `has_knowledge_points`, + `has_quiz`, + `source_pdf_name`, + `supported_training_type`, + `supported_mode`, + `is_active`, + `created_by` +) VALUES ( + 1, + 'PED_PNEUMONIA_001', + 1, + '支气管肺炎 - 6岁男性患儿', + 'medium', + '王**', + 6, + 'male', + '学龄前儿童', + '发热、咳嗽4天,喘息1天。', + '患儿4天前出现发热,体温最高39.2℃,伴阵发性咳嗽,夜间明显。1天前出现喘息及精神稍差,在社区门诊口服退热药后体温反复。无惊厥,无呕吐腹泻。', + '既往体健,出生足月,疫苗接种基本完整;有湿疹病史,无明确哮喘诊断史;无药物过敏史。', + '与父母同住,近期幼儿园同班多人有上呼吸道感染;被动吸烟暴露。', + '母亲有过敏性鼻炎史,否认家族遗传代谢病史。', + JSON_OBJECT( + 'general', JSON_OBJECT('temperature','38.7℃','pulse','126次/分','respiration','30次/分','blood_pressure','96/60mmHg','mental_status','精神稍差','nasal_flaring','轻度鼻翼煽动'), + 'lung', '双肺呼吸音粗,可闻及散在湿啰音及少量哮鸣音', + 'heart', '心音有力', + 'abdomen', '腹软,无压痛,肝脾未触及肿大,肠鸣音正常', + 'neuro', '神经系统查体未见定位体征' + ), + JSON_ARRAY( + JSON_OBJECT('name','血常规','result','WBC 12.4×10^9/L,中性粒细胞72%','is_abnormal',true,'is_key',false), + JSON_OBJECT('name','CRP','result','32 mg/L','is_abnormal',true,'is_key',false), + JSON_OBJECT('name','胸片','result','双下肺纹理增多,右下肺片状模糊影','is_abnormal',true,'is_key',true), + JSON_OBJECT('name','肺炎支原体抗体IgM','result','弱阳性','is_abnormal',true,'is_key',false), + JSON_OBJECT('name','血氧饱和度','result','室内空气 SpO2 94%','is_abnormal',true,'is_key',true) + ), + '支气管肺炎', + JSON_ARRAY('毛细支气管炎','支气管哮喘急性发作','肺结核'), + '患儿发热咳嗽伴喘息,肺部听诊有湿啰音,胸片示右下肺片状模糊影,炎症指标升高,符合支气管肺炎诊断。', + JSON_OBJECT( + 'principle', JSON_ARRAY('抗感染','止咳平喘','改善氧合','严密观察病情变化'), + 'measures', JSON_ARRAY('根据病情和病原学选择抗感染治疗','必要时雾化吸入缓解喘息','监测体温、呼吸、血氧饱和度','评估是否住院观察'), + 'risk_plan', JSON_ARRAY('关注低氧','关注呼吸困难加重','关注持续高热','关注精神反应差'), + 'communication', JSON_ARRAY('向家属说明病情','说明观察指标','说明用药注意事项','说明复诊指征') + ), + JSON_OBJECT(), + JSON_ARRAY(), + JSON_ARRAY(), + JSON_ARRAY( + JSON_OBJECT('question','该患儿的首要诊断是?','type','single','answer','支气管肺炎'), + JSON_OBJECT('question','本病例关键检查包括哪些?','type','multiple','answer',JSON_ARRAY('肺部湿啰音','胸片异常','血氧饱和度下降')) + ), + JSON_ARRAY('发热','咳嗽','喘息'), + JSON_ARRAY('肺部湿啰音','胸片异常','血氧饱和度下降'), + JSON_ARRAY('问诊完整性','儿科查体规范','关键症状识别','诊断准确性','治疗计划合理性'), + JSON_OBJECT( + 'clinical_prediction_rule','根据患儿症状(发热、咳嗽、喘息)及体征(肺部湿啰音),结合实验室炎症指标升高和胸部影像学典型新发浸润影,符合儿童支气管肺炎临床诊断标准,参考《儿童社区获得性肺炎诊疗规范(2019年版)》。', + 'severity_assessment', JSON_ARRAY( + '问题识别:判断患儿是否为重症肺炎,以决定治疗地点。', + '证据检索与整合:检索儿童肺炎严重程度评估工具,结合儿童CURB-65改良标准或WHO儿童肺炎分级。', + '应用评分:本例呼吸30次/分、SpO2 94%、无意识障碍、血压正常,尚未达到重症肺炎阈值。', + '决策:判断为非重症肺炎,因血氧偏低和喘息存在,纳入住院观察和严密随访讨论。' + ) + ), + JSON_OBJECT( + 'score_dimensions', JSON_ARRAY('信息获取','分析推理','处置决策','沟通人文','临床整合'), + 'must_cover', JSON_ARRAY('发热持续时间','咳嗽特点','喘息出现时间','精神食欲睡眠','疫苗接种','过敏史','既往喘息史','肺部听诊','胸片','血氧饱和度') + ), + JSON_OBJECT( + 'role','患儿家属', + 'personality','担心、焦虑,但配合问诊', + 'speech_style','以家长口吻回答,描述孩子症状,回答简洁', + 'visible_info', JSON_ARRAY('发热咳嗽4天','喘息1天','精神稍差'), + 'rules', JSON_ARRAY('只回答病例内事实','不主动给出诊断','不一次性泄露所有隐藏信息') + ), + '家长:医生,孩子发烧咳嗽好几天了,昨天开始喘得厉害,精神也不太好。', + JSON_ARRAY('近期幼儿园同班多人上呼吸道感染','被动吸烟暴露','母亲有过敏性鼻炎史','有湿疹病史','无明确哮喘诊断史'), + 0, + 1, + 1, + '儿科 病例样例(SOAP+循证).pdf', + 'diagnosis_treatment', + 'free_chat', + 1, + 1 +) ON DUPLICATE KEY UPDATE + `title` = VALUES(`title`), + `updated_at` = CURRENT_TIMESTAMP; + +INSERT INTO `case_exam_items` (`case_id`, `item_code`, `item_name`, `item_type`, `category`, `result_text`, `result_structured`, `is_key`, `is_abnormal`, `score_weight`, `display_order`) +VALUES +(1, 'blood_routine', '血常规', 'lab', '炎症指标', 'WBC 12.4×10^9/L,中性粒细胞72%。', JSON_OBJECT('WBC','12.4×10^9/L','neutrophil_percent','72%'), 0, 1, 3.00, 1), +(1, 'crp', 'CRP', 'lab', '炎症指标', 'CRP 32 mg/L。', JSON_OBJECT('CRP','32 mg/L'), 0, 1, 2.00, 2), +(1, 'chest_xray', '胸片', 'imaging', '影像学', '双下肺纹理增多,右下肺片状模糊影。', JSON_OBJECT('finding','右下肺片状模糊影'), 1, 1, 5.00, 3), +(1, 'mp_igm', '肺炎支原体抗体IgM', 'lab', '病原学', '肺炎支原体抗体IgM 弱阳性。', JSON_OBJECT('mp_igm','弱阳性'), 0, 1, 2.00, 4), +(1, 'spo2', '血氧饱和度', 'vital_sign', '生命体征', '室内空气 SpO2 94%。', JSON_OBJECT('SpO2','94%','condition','室内空气'), 1, 1, 5.00, 5) +ON DUPLICATE KEY UPDATE + `result_text` = VALUES(`result_text`), + `result_structured` = VALUES(`result_structured`); + +INSERT INTO `knowledge_sources` (`id`, `source_code`, `source_name`, `source_type`, `authority_level`, `is_active`) +VALUES +(1, 'CAP_2019', '儿童社区获得性肺炎诊疗规范(2019年版)', 'clinical_guideline', 5, 1), +(2, 'HUMANISTIC_CARE', '卫健委人文关怀与医患沟通要求', 'humanistic_care', 4, 1), +(3, 'PED_EXAM_REQ', '儿科期末考试问诊与诊疗评分要求', 'exam_requirement', 3, 1) +ON DUPLICATE KEY UPDATE `source_name` = VALUES(`source_name`); + +INSERT INTO `knowledge_documents` (`id`, `source_id`, `department_id`, `title`, `task_type`, `summary`, `is_active`) +VALUES +(1, 1, 1, '儿童社区获得性肺炎诊疗规范(2019年版)评分参考', 'diagnosis_treatment', '儿童肺炎诊断、严重程度评估和治疗原则参考。', 1), +(2, 2, 1, '儿科场景人文关怀评分参考', 'diagnosis_treatment', '儿童患者家属沟通、病情告知和健康教育评分参考。', 1), +(3, 3, 1, '儿科问诊考试评分要求', 'diagnosis_treatment', '儿科病史采集、查体、诊断依据和治疗计划评分参考。', 1) +ON DUPLICATE KEY UPDATE `summary` = VALUES(`summary`); + +INSERT INTO `knowledge_chunks` (`document_id`, `department_id`, `task_type`, `chunk_text`, `keywords`, `weight`, `is_active`) +VALUES +(1, 1, 'diagnosis_treatment', '儿童支气管肺炎诊断需结合发热、咳嗽、喘息等症状,肺部湿啰音等体征,炎症指标升高以及胸部影像学新发浸润影。', JSON_ARRAY('支气管肺炎','发热','咳嗽','喘息','胸片'), 5.00, 1), +(1, 1, 'diagnosis_treatment', '儿童肺炎严重程度评估关注呼吸频率、血氧饱和度、意识状态、血压和全身情况。SpO2 94%属于需要密切观察的临界表现。', JSON_ARRAY('严重程度','SpO2','呼吸频率','血氧'), 4.50, 1), +(2, 1, 'diagnosis_treatment', '儿科沟通需面向家属解释病情、观察指标、用药注意事项、复诊指征,并回应家属焦虑。', JSON_ARRAY('人文关怀','家属沟通','健康教育'), 4.00, 1), +(3, 1, 'diagnosis_treatment', '儿科病例考核重点包括问诊完整性、儿科查体规范、关键症状识别、诊断准确性、治疗计划合理性。', JSON_ARRAY('考核','问诊完整性','治疗计划'), 4.00, 1); + +INSERT INTO `prompt_templates` (`template_code`, `agent_type`, `scene`, `version_no`, `model_type`, `output_format`, `file_path`, `is_active`) +VALUES +('patient_free_chat', 'patient', 'free_chat', 'v1', 'fast', 'text', 'backend/app/prompts/patient/free_chat.md', 1), +('patient_practice', 'patient', 'practice', 'v1', 'fast', 'text', 'backend/app/prompts/patient/practice.md', 1), +('patient_novice', 'patient', 'novice', 'v1', 'fast', 'text', 'backend/app/prompts/patient/novice.md', 1), +('patient_teaching', 'patient', 'teaching', 'v1', 'fast', 'text', 'backend/app/prompts/patient/teaching.md', 1), +('scoring_default_percentage', 'scoring', 'default_percentage', 'v1', 'reason', 'json', 'backend/app/prompts/scoring/default_percentage.md', 1), +('scoring_default_five_point', 'scoring', 'default_five_point', 'v1', 'reason', 'json', 'backend/app/prompts/scoring/default_five_point.md', 1), +('scoring_pediatrics_pneumonia', 'scoring', 'pediatrics_pneumonia', 'v1', 'reason', 'json', 'backend/app/prompts/scoring/pediatrics_pneumonia.md', 1), +('report_evaluation', 'report', 'evaluation_report', 'v1', 'fast', 'json', 'backend/app/prompts/report/evaluation_report.md', 1), +('novice_hint', 'hint', 'novice', 'v1', 'fast', 'text', 'backend/app/prompts/hint/novice_hint.md', 1), +('doctor_question_polish', 'polish', 'doctor_question', 'v1', 'fast', 'text', 'backend/app/prompts/polish/doctor_question_polish.md', 1), +('guideline_search_query', 'knowledge', 'guideline_search', 'v1', 'fast', 'json', 'backend/app/prompts/knowledge/guideline_search_query.md', 1) +ON DUPLICATE KEY UPDATE `file_path` = VALUES(`file_path`); + +INSERT INTO `rubric_templates` (`rubric_code`, `rubric_name`, `version_no`, `department_id`, `training_type`, `score_type`, `dimensions`, `is_active`) +VALUES +('PED_DIAG_TX_PERCENTAGE', '儿科诊疗训练百分制评分规则', 'v1', 1, 'diagnosis_treatment', 'percentage', + JSON_ARRAY( + JSON_OBJECT('dimension','信息获取','max_score',25,'items',JSON_ARRAY('问诊完整性','查体针对性','辅助检查')), + JSON_OBJECT('dimension','分析推理','max_score',25,'items',JSON_ARRAY('病例归纳','诊断准确性','鉴别诊断')), + JSON_OBJECT('dimension','处置决策','max_score',20,'items',JSON_ARRAY('检查计划','治疗方案','风险预案')), + JSON_OBJECT('dimension','沟通人文','max_score',15,'items',JSON_ARRAY('信息告知','共情回应','共识达成')), + JSON_OBJECT('dimension','临床整合','max_score',15,'items',JSON_ARRAY('时间管理','流程连贯','整体思维')) + ), 1), +('PED_DIAG_TX_FIVE_POINT', '儿科诊疗训练五分制评分规则', 'v1', 1, 'diagnosis_treatment', 'five_point', + JSON_ARRAY( + JSON_OBJECT('dimension','综合表现','max_score',5,'conversion','percentage / 20') + ), 1) +ON DUPLICATE KEY UPDATE `dimensions` = VALUES(`dimensions`); diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..cbc94a7 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.npm-cache/ +*.log +*.tsbuildinfo +vite.config.js +vite.config.d.ts diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..fe7a044 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,81 @@ +# 医疗问诊 Agent Vue Demo + +这是第一版 Demo 的 Vue 3 测试前端,用于展示和验证当前 FastAPI 后端已实现的问诊训练闭环。它不是最终生产 UI,但页面结构、接口封装和状态管理已按后续正式前端扩展保留边界。 + +## 技术栈 + +- Vue 3 + Vite + TypeScript +- Pinia +- Vue Router +- Axios + Fetch SSE +- 原生 CSS,移动端优先 + +## 启动 + +```powershell +cd D:\Code\newfounder\medical-consultation-agent\frontend +npm.cmd install +npm.cmd run dev -- --host 127.0.0.1 --port 5173 +``` + +访问: + +```text +http://127.0.0.1:5173 +``` + +构建: + +```powershell +npm.cmd run build +``` + +## 后端配置 + +默认 API: + +```text +http://127.0.0.1:8000/api/v1 +``` + +所有业务请求都会携带: + +- `X-User-Id` +- `X-Entry-Scene` + +入口页可修改 `user_id`、API Base 和入口场景。默认 `user_id` 为 `demo_user_001`。 + +## 页面与功能 + +| 页面 | 路由 | 主要功能 | +|---|---|---| +| 入口 | `#/` | 配置 user_id/API Base,调用 Agent Hello | +| 病例 | `#/cases` | 病例列表、病例详情、开始训练 | +| 病例导入 | `#/import` | 上传接口解析后的 SQL,预检并确认写入病例源表 | +| 会话配置 | `#/session` | 选择模式、评分类型并创建会话 | +| 问诊 | `#/chat` | 普通/流式 Chat、查看提示、检查申请、完成问诊 | +| 提交 | `#/submit` | 提交诊断和治疗方案 | +| 报告 | `#/report` | 生成评价、查看评分、导出 PDF | +| 历史 | `#/history` | 查询当前 user_id 的历史评价 | +| LLM 测试 | `#/llm-test` | 测试 Fast/Reason 模型耗时 | + +## 测试流程 + +1. 入口页确认连接成功。 +2. 需要新增病例时进入 `#/import`,上传 `.sql` 文件并先执行“解析检查”,确认通过后再“确认导入”。 +3. 病例页选择“支气管肺炎 - 6岁男性患儿”或刚导入的新病例。 +4. 点击“开始训练”,创建练习模式会话。 +5. Chat 中提问并查看 AI 病人流式回复。 +6. 点击“查看提示”验证 Hint Agent。 +7. 申请血常规、CRP、胸片、血氧饱和度、肺部体格检查。 +8. 完成问诊,提交诊断和治疗方案。 +9. 生成 AI 评价报告并导出 PDF。 +10. 历史页刷新并查看报告详情。 + +## 交互边界 + +- 前端不保存长期聊天历史。 +- Chat 消息只保存在 Pinia 当前状态中,刷新页面后以当前后端会话状态为准。 +- 诊断和治疗表单默认清空;演示模板需要手动点击填入。 +- 检查项目按 `item_code` 去重,同一会话不会重复申请同一检查。 +- 真实模型、mock 和 fallback 状态由后端 `.env` 决定,前端只展示后端返回结果。 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..1ea8518 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + 医疗问诊 Agent Demo + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..fa2e2ea --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1853 @@ +{ + "name": "medical-consultation-agent-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "medical-consultation-agent-frontend", + "version": "0.1.0", + "dependencies": { + "@vitejs/plugin-vue": "^5.2.4", + "axios": "^1.7.9", + "lucide-vue-next": "^0.468.0", + "pinia": "^2.3.0", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "typescript": "^5.7.2", + "vite": "^6.0.3", + "vue-tsc": "^2.1.10" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.34.tgz", + "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/shared": "3.5.34", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz", + "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz", + "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/compiler-core": "3.5.34", + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.14", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz", + "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.34.tgz", + "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.34.tgz", + "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz", + "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/runtime-core": "3.5.34", + "@vue/shared": "3.5.34", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.34.tgz", + "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "vue": "3.5.34" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.34.tgz", + "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/lucide-vue-next": { + "version": "0.468.0", + "resolved": "https://registry.npmmirror.com/lucide-vue-next/-/lucide-vue-next-0.468.0.tgz", + "integrity": "sha512-quV/6T8YB1XK0VOEnebg3Byd8Rsan5/m95cvjnuHV4vcS3qEnLAybkrSh0hk3ppavx+V7R1PjNW+mGDvcBdz4A==", + "license": "ISC", + "peerDependencies": { + "vue": ">=3.0.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.34.tgz", + "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-sfc": "3.5.34", + "@vue/runtime-dom": "3.5.34", + "@vue/server-renderer": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..78bfda6 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "medical-consultation-agent-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview --host 127.0.0.1" + }, + "dependencies": { + "@vitejs/plugin-vue": "^5.2.4", + "axios": "^1.7.9", + "lucide-vue-next": "^0.468.0", + "pinia": "^2.3.0", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "typescript": "^5.7.2", + "vite": "^6.0.3", + "vue-tsc": "^2.1.10" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..f6a759b --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue new file mode 100644 index 0000000..7c0d31b --- /dev/null +++ b/frontend/src/components/AppShell.vue @@ -0,0 +1,81 @@ + + + diff --git a/frontend/src/components/CaseCard.vue b/frontend/src/components/CaseCard.vue new file mode 100644 index 0000000..4e6159a --- /dev/null +++ b/frontend/src/components/CaseCard.vue @@ -0,0 +1,37 @@ + + + diff --git a/frontend/src/components/ChatBubble.vue b/frontend/src/components/ChatBubble.vue new file mode 100644 index 0000000..d54b11d --- /dev/null +++ b/frontend/src/components/ChatBubble.vue @@ -0,0 +1,49 @@ + + + diff --git a/frontend/src/components/ExamOrderPanel.vue b/frontend/src/components/ExamOrderPanel.vue new file mode 100644 index 0000000..7cd6276 --- /dev/null +++ b/frontend/src/components/ExamOrderPanel.vue @@ -0,0 +1,75 @@ + + + diff --git a/frontend/src/components/FlowStepper.vue b/frontend/src/components/FlowStepper.vue new file mode 100644 index 0000000..4dbfc7a --- /dev/null +++ b/frontend/src/components/FlowStepper.vue @@ -0,0 +1,46 @@ + + + diff --git a/frontend/src/components/ReportPanel.vue b/frontend/src/components/ReportPanel.vue new file mode 100644 index 0000000..9fa0076 --- /dev/null +++ b/frontend/src/components/ReportPanel.vue @@ -0,0 +1,144 @@ + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..eb942fe --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,8 @@ +import { createPinia } from "pinia"; +import { createApp } from "vue"; + +import App from "./App.vue"; +import router from "./router"; +import "./styles/main.css"; + +createApp(App).use(createPinia()).use(router).mount("#app"); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..5f3987a --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,28 @@ +import { createRouter, createWebHashHistory } from "vue-router"; + +import CasesView from "../views/CasesView.vue"; +import ChatView from "../views/ChatView.vue"; +import HistoryView from "../views/HistoryView.vue"; +import HomeView from "../views/HomeView.vue"; +import ImportCaseView from "../views/ImportCaseView.vue"; +import LlmTestView from "../views/LlmTestView.vue"; +import ReportView from "../views/ReportView.vue"; +import SessionView from "../views/SessionView.vue"; +import SubmitView from "../views/SubmitView.vue"; + +const router = createRouter({ + history: createWebHashHistory(), + routes: [ + { path: "/", name: "home", component: HomeView }, + { path: "/cases", name: "cases", component: CasesView }, + { path: "/import", name: "import", component: ImportCaseView }, + { path: "/session", name: "session", component: SessionView }, + { path: "/chat", name: "chat", component: ChatView }, + { path: "/submit", name: "submit", component: SubmitView }, + { path: "/report", name: "report", component: ReportView }, + { path: "/history", name: "history", component: HistoryView }, + { path: "/llm-test", name: "llm-test", component: LlmTestView }, + ], +}); + +export default router; diff --git a/frontend/src/services/apiClient.ts b/frontend/src/services/apiClient.ts new file mode 100644 index 0000000..c2bac62 --- /dev/null +++ b/frontend/src/services/apiClient.ts @@ -0,0 +1,263 @@ +import axios, { AxiosError } from "axios"; + +import type { + AgentHello, + ApiEnvelope, + CaseDetail, + CaseListItem, + ChatResponse, + CreateSessionPayload, + DiagnosisPayload, + EvaluationDetail, + EvaluationListItem, + EvaluationReport, + ExportPdfResponse, + LlmTestResponse, + OrderItem, + OrderResult, + RequestContext, + ScoreType, + TrainingSession, + TreatmentPayload, + HintResponse, + CaseDeletePreview, + CaseDeleteResponse, + CaseSqlImportApply, + CaseSqlImportPreview, +} from "../types/api"; + +function headers(ctx: RequestContext) { + return { + "Content-Type": "application/json", + "X-User-Id": ctx.userId, + "X-Entry-Scene": ctx.entryScene, + }; +} + +function normalizeBaseUrl(baseUrl: string) { + return baseUrl.replace(/\/$/, ""); +} + +function unwrap(envelope: ApiEnvelope): T { + if (envelope.code !== "OK") { + throw new Error(envelope.message || envelope.code); + } + return envelope.data; +} + +function toUserFacingError(error: unknown): Error { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError>; + const message = axiosError.response?.data?.message || axiosError.message || "接口请求失败"; + return new Error(message); + } + if (error instanceof Error) { + return error; + } + return new Error("未知错误"); +} + +async function get(ctx: RequestContext, path: string, params?: Record): Promise { + try { + const response = await axios.get>(`${normalizeBaseUrl(ctx.apiBaseUrl)}${path}`, { + headers: headers(ctx), + params, + timeout: 30000, + }); + return unwrap(response.data); + } catch (error) { + throw toUserFacingError(error); + } +} + +async function post(ctx: RequestContext, path: string, body?: unknown): Promise { + try { + const response = await axios.post>(`${normalizeBaseUrl(ctx.apiBaseUrl)}${path}`, body ?? {}, { + headers: headers(ctx), + timeout: 90000, + }); + return unwrap(response.data); + } catch (error) { + throw toUserFacingError(error); + } +} + +async function del(ctx: RequestContext, path: string, body?: unknown): Promise { + try { + const response = await axios.delete>(`${normalizeBaseUrl(ctx.apiBaseUrl)}${path}`, { + headers: headers(ctx), + data: body ?? {}, + timeout: 30000, + }); + return unwrap(response.data); + } catch (error) { + throw toUserFacingError(error); + } +} + +async function postFile(ctx: RequestContext, path: string, file: File): Promise { + const form = new FormData(); + form.append("file", file); + try { + const response = await axios.post>(`${normalizeBaseUrl(ctx.apiBaseUrl)}${path}`, form, { + headers: { + "X-User-Id": ctx.userId, + "X-Entry-Scene": ctx.entryScene, + }, + timeout: 90000, + }); + return unwrap(response.data); + } catch (error) { + throw toUserFacingError(error); + } +} + +export const apiClient = { + hello: (ctx: RequestContext) => get(ctx, "/agent/hello"), + listCases: (ctx: RequestContext) => get<{ items: CaseListItem[] }>(ctx, "/cases"), + getCaseDetail: (ctx: RequestContext, caseId: number) => get(ctx, `/cases/${caseId}`), + getCaseDeletePreview: (ctx: RequestContext, caseId: number) => + get(ctx, `/cases/${caseId}/delete-preview`), + deleteCase: (ctx: RequestContext, caseId: number) => + del(ctx, `/cases/${caseId}`, { confirm: true, delete_training_data: true }), + createSession: (ctx: RequestContext, payload: CreateSessionPayload) => + post(ctx, "/sessions", payload), + chat: (ctx: RequestContext, sessionId: number, message: string) => + post(ctx, `/sessions/${sessionId}/chat`, { message }), + hints: (ctx: RequestContext, sessionId: number, lastUserMessage?: string) => + post(ctx, `/sessions/${sessionId}/hints`, { + last_user_message: lastUserMessage || null, + scope: "current_conversation", + }), + listOrderItems: (ctx: RequestContext, sessionId: number) => + get<{ items: OrderItem[] }>(ctx, `/sessions/${sessionId}/order-items`), + createOrder: (ctx: RequestContext, sessionId: number, itemCode: string) => + post(ctx, `/sessions/${sessionId}/orders`, { item_code: itemCode }), + completeInquiry: (ctx: RequestContext, sessionId: number) => + post<{ session_id: number; status: string }>(ctx, `/sessions/${sessionId}/complete-inquiry`), + submitDiagnosis: (ctx: RequestContext, sessionId: number, payload: DiagnosisPayload) => + post<{ status: string }>(ctx, `/sessions/${sessionId}/diagnosis`, payload), + submitTreatment: (ctx: RequestContext, sessionId: number, payload: TreatmentPayload) => + post<{ status: string }>(ctx, `/sessions/${sessionId}/treatment`, payload), + createEvaluation: (ctx: RequestContext, sessionId: number, scoreType: ScoreType) => + post(ctx, `/sessions/${sessionId}/evaluation`, { score_type: scoreType }), + listEvaluations: (ctx: RequestContext) => get<{ items: EvaluationListItem[] }>(ctx, "/evaluations"), + getEvaluationDetail: (ctx: RequestContext, evaluationId: number) => + get(ctx, `/evaluations/${evaluationId}`), + exportPdf: (ctx: RequestContext, evaluationId: number) => + post(ctx, `/evaluations/${evaluationId}/export-pdf`), + llmFast: (ctx: RequestContext, message: string) => post(ctx, "/llm/test/deepseek-fast", { message }), + llmReason: (ctx: RequestContext, message: string) => + post(ctx, "/llm/test/deepseek-reason", { message }), + previewCaseSql: (ctx: RequestContext, file: File) => + postFile(ctx, "/imports/case-sql/preview", file), + applyCaseSql: (ctx: RequestContext, file: File) => + postFile(ctx, "/imports/case-sql/apply", file), +}; + +export interface StreamChatResult { + firstTokenMs?: number | null; + latencyMs?: number | null; + model?: string | null; + fallbackUsed?: boolean; +} + +interface ParsedSseEvent { + event: string; + data: Record; +} + +export function parseSseEvents(buffer: string): { events: ParsedSseEvent[]; rest: string } { + const normalized = buffer.replace(/\r\n/g, "\n"); + const blocks = normalized.split("\n\n"); + const rest = blocks.pop() ?? ""; + const events: ParsedSseEvent[] = []; + for (const block of blocks) { + const lines = block.split("\n").filter(Boolean); + const event = lines.find((line) => line.startsWith("event:"))?.replace("event:", "").trim() || "message"; + const dataText = lines + .filter((line) => line.startsWith("data:")) + .map((line) => line.replace("data:", "").trim()) + .join("\n"); + if (!dataText) { + continue; + } + try { + events.push({ event, data: JSON.parse(dataText) as Record }); + } catch { + events.push({ event: "error", data: { message: "流式问诊返回数据解析失败" } }); + } + } + return { events, rest }; +} + +export async function streamChat( + ctx: RequestContext, + sessionId: number, + message: string, + onDelta: (delta: string) => void, +): Promise { + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => controller.abort(), 60000); + try { + console.info("[chat] stream start", { sessionId, messageLength: message.length }); + const response = await fetch(`${normalizeBaseUrl(ctx.apiBaseUrl)}/sessions/${sessionId}/chat/stream`, { + method: "POST", + headers: headers(ctx), + body: JSON.stringify({ message }), + signal: controller.signal, + }); + if (!response.ok || !response.body) { + throw new Error(`流式问诊请求失败:${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder("utf-8"); + let buffer = ""; + const result: StreamChatResult = {}; + let sawDone = false; + let sawDelta = false; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + buffer += decoder.decode(value, { stream: true }); + const parsed = parseSseEvents(buffer); + buffer = parsed.rest; + for (const { event: eventName, data } of parsed.events) { + if (eventName === "error") { + console.error("[chat] stream error event", data); + throw new Error(typeof data.message === "string" ? data.message : "流式问诊失败"); + } + if (eventName === "message_delta" && typeof data.delta === "string") { + sawDelta = true; + console.info("[chat] stream delta", { length: data.delta.length }); + onDelta(data.delta); + } + if (eventName === "message_done") { + sawDone = true; + result.firstTokenMs = typeof data.first_token_ms === "number" ? data.first_token_ms : null; + result.latencyMs = typeof data.latency_ms === "number" ? data.latency_ms : null; + result.model = typeof data.model === "string" ? data.model : null; + result.fallbackUsed = data.fallback_used === true; + console.info("[chat] stream done", result); + } + } + } + + if (!sawDone) { + throw new Error(sawDelta ? "流式问诊连接中断,未收到完成事件" : "流式问诊连接已结束,但未收到任何回复"); + } + return result; + } catch (error) { + console.error("[chat] stream exception", error); + if (error instanceof DOMException && error.name === "AbortError") { + throw new Error("流式问诊超时,请重试或关闭流式模式"); + } + throw error; + } finally { + window.clearTimeout(timeoutId); + } +} diff --git a/frontend/src/stores/consultationStore.ts b/frontend/src/stores/consultationStore.ts new file mode 100644 index 0000000..c1b8152 --- /dev/null +++ b/frontend/src/stores/consultationStore.ts @@ -0,0 +1,486 @@ +import { defineStore } from "pinia"; + +import { apiClient, streamChat } from "../services/apiClient"; +import type { + AgentHello, + CaseDetail, + CaseListItem, + ChatMessage, + DiagnosisPayload, + EvaluationDetail, + EvaluationListItem, + EvaluationReport, + LlmTestResponse, + OrderItem, + OrderResult, + RequestContext, + ScoreType, + TrainingMode, + TrainingSession, + TrainingType, + TreatmentPayload, + HintResponse, + CaseDeletePreview, +} from "../types/api"; + +const defaultApiBaseUrl = "http://127.0.0.1:8000/api/v1"; +const defaultUserId = "demo_user_001"; + +function uid(prefix: string) { + return `${prefix}_${Date.now()}_${Math.random().toString(16).slice(2)}`; +} + +function loadString(key: string, fallback: string) { + return localStorage.getItem(key) || fallback; +} + +const emptyDiagnosis = (): DiagnosisPayload => ({ + primary_diagnosis: "", + differential_diagnoses: [], + diagnosis_basis: "", +}); + +const emptyTreatment = (): TreatmentPayload => ({ + treatment_principle: "", + treatment_measures: "", + risk_plan: "", + communication: "", + follow_up: "", +}); + +const demoDiagnosis = (): DiagnosisPayload => ({ + primary_diagnosis: "支气管肺炎", + differential_diagnoses: ["毛细支气管炎", "支气管哮喘急性发作", "肺结核", "上呼吸道感染"], + diagnosis_basis: "结合发热、咳嗽、喘息、肺部湿啰音、胸片异常、炎症指标升高和血氧情况,符合儿童支气管肺炎表现。", +}); + +const demoTreatment = (): TreatmentPayload => ({ + treatment_principle: "抗感染、止咳平喘、改善氧合、严密观察病情变化。", + treatment_measures: "根据病情选择抗感染治疗,必要时雾化吸入缓解喘息,监测体温、呼吸、血氧和精神反应。", + risk_plan: "关注低氧、呼吸困难加重、持续高热、精神反应差、脱水等情况。", + communication: "向家属说明肺炎病情、用药注意事项、观察指标和复诊/住院指征。", + follow_up: "治疗后复查体温、呼吸、血氧和必要炎症指标,症状加重时及时就诊。", +}); + +export const useConsultationStore = defineStore("consultation", { + state: () => ({ + apiBaseUrl: loadString("mca_api_base_url", defaultApiBaseUrl), + userId: loadString("mca_user_id", defaultUserId), + entryScene: loadString("mca_entry_scene", "vue_demo"), + hello: null as AgentHello | null, + connectedAt: "", + cases: [] as CaseListItem[], + selectedCase: null as CaseDetail | null, + caseDeletePreview: null as CaseDeletePreview | null, + session: null as TrainingSession | null, + trainingType: "diagnosis_treatment" as TrainingType, + mode: "practice" as TrainingMode, + scoreType: "percentage" as ScoreType, + messages: [] as ChatMessage[], + orderItems: [] as OrderItem[], + orderedResults: [] as OrderResult[], + diagnosis: emptyDiagnosis(), + treatment: emptyTreatment(), + evaluation: null as EvaluationReport | EvaluationDetail | null, + history: [] as EvaluationListItem[], + hints: null as HintResponse | null, + hintVisible: false, + llmFastResult: null as LlmTestResponse | null, + llmReasonResult: null as LlmTestResponse | null, + loading: false, + streaming: false, + orderingItemCode: "", + highlightedOrderCode: "", + pendingAction: "", + evaluationStage: "", + evaluationSlowNotice: false, + error: "", + }), + getters: { + context(state): RequestContext { + return { + apiBaseUrl: state.apiBaseUrl, + userId: state.userId, + entryScene: state.entryScene, + }; + }, + activeSessionId(state): number | null { + return state.session?.session_id ?? null; + }, + flowStatus(state): string { + return state.session?.status || "not_started"; + }, + isConnected(state): boolean { + return Boolean(state.hello?.user.user_id && state.hello.user.user_id === state.userId); + }, + isNoviceMode(state): boolean { + return state.session?.status === "inquiry" && state.mode === "practice"; + }, + canCompleteInquiry(state): boolean { + return state.session?.status === "inquiry" && state.messages.some((message) => message.role === "doctor"); + }, + canSubmitDiagnosis(state): boolean { + return state.session?.status === "diagnosis"; + }, + canSubmitTreatment(state): boolean { + return state.session?.status === "treatment"; + }, + canCreateEvaluation(state): boolean { + return state.session?.status === "evaluating" || state.session?.status === "completed"; + }, + completeInquiryDisabledReason(state): string { + if (!state.session) return "请先创建训练会话"; + if (state.session.status !== "inquiry") return "当前阶段不能继续完成问诊"; + if (!state.messages.some((message) => message.role === "doctor")) return "至少完成一轮问诊后可进入诊断"; + return ""; + }, + diagnosisDisabledReason(state): string { + if (!state.session) return "请先创建训练会话"; + if (state.session.status !== "diagnosis") return "完成问诊后才能提交诊断"; + return ""; + }, + treatmentDisabledReason(state): string { + if (!state.session) return "请先创建训练会话"; + if (state.session.status !== "treatment") return "提交诊断后才能提交治疗"; + return ""; + }, + evaluationDisabledReason(state): string { + if (!state.session) return "请先创建训练会话"; + if (state.session.status !== "evaluating" && state.session.status !== "completed") return "提交治疗后才能生成评价"; + return ""; + }, + }, + actions: { + saveConfig() { + localStorage.setItem("mca_api_base_url", this.apiBaseUrl); + localStorage.setItem("mca_user_id", this.userId); + localStorage.setItem("mca_entry_scene", this.entryScene); + }, + clearError() { + this.error = ""; + }, + async run(task: () => Promise, pendingAction = ""): Promise { + this.loading = true; + this.error = ""; + this.pendingAction = pendingAction; + try { + return await task(); + } catch (error) { + this.error = error instanceof Error ? error.message : "操作失败"; + return null; + } finally { + this.loading = false; + this.pendingAction = ""; + } + }, + async loadHello() { + return this.run(async () => { + this.saveConfig(); + this.hello = await apiClient.hello(this.context); + this.connectedAt = new Date().toLocaleString(); + return this.hello; + }, "正在连接后端"); + }, + async loadCases() { + return this.run(async () => { + this.cases = (await apiClient.listCases(this.context)).items; + return this.cases; + }, "正在加载病例"); + }, + async selectCase(caseId: number) { + return this.run(async () => { + this.selectedCase = await apiClient.getCaseDetail(this.context, caseId); + this.caseDeletePreview = null; + this.trainingType = this.selectedCase.supported_training_type; + return this.selectedCase; + }, "正在读取病例详情"); + }, + async loadCaseDeletePreview(caseId: number) { + return this.run(async () => { + this.caseDeletePreview = await apiClient.getCaseDeletePreview(this.context, caseId); + return this.caseDeletePreview; + }, "正在计算删除影响"); + }, + async deleteCase(caseId: number) { + return this.run(async () => { + const response = await apiClient.deleteCase(this.context, caseId); + this.cases = this.cases.filter((item) => item.id !== caseId); + if (this.selectedCase?.id === caseId) { + this.selectedCase = null; + this.session = null; + this.messages = []; + this.orderItems = []; + this.orderedResults = []; + this.resetTrainingDrafts(); + } + this.caseDeletePreview = null; + await this.loadCases(); + return response; + }, "正在删除病例"); + }, + resetTrainingDrafts() { + this.diagnosis = emptyDiagnosis(); + this.treatment = emptyTreatment(); + this.evaluation = null; + this.hints = null; + this.hintVisible = false; + this.evaluationStage = ""; + this.evaluationSlowNotice = false; + this.orderingItemCode = ""; + this.highlightedOrderCode = ""; + }, + fillDemoTemplate() { + this.diagnosis = demoDiagnosis(); + this.treatment = demoTreatment(); + }, + async createSession() { + if (!this.selectedCase) { + this.error = "请先选择病例"; + return null; + } + return this.run(async () => { + this.resetTrainingDrafts(); + this.orderedResults = []; + this.orderItems = []; + this.session = await apiClient.createSession(this.context, { + case_id: this.selectedCase!.id, + training_type: this.trainingType, + mode: this.mode, + score_type: this.scoreType, + }); + this.messages = [ + { + id: uid("patient"), + role: "patient", + content: this.session.patient_opening, + }, + ]; + this.orderedResults = []; + await this.loadOrderItems(); + return this.session; + }, "正在创建训练会话"); + }, + async sendChat(message: string, useStream: boolean) { + if (!this.session) { + this.error = "请先创建训练会话"; + return null; + } + const normalized = message.trim(); + if (!normalized) { + return null; + } + console.info("[chat] send", { text: normalized, useStream, sessionId: this.session.session_id }); + this.messages.push({ id: uid("doctor"), role: "doctor", content: normalized }); + if (!useStream) { + return this.run(async () => { + const response = await apiClient.chat(this.context, this.session!.session_id, normalized); + this.messages.push({ + id: uid("patient"), + role: "patient", + content: response.reply, + }); + return response; + }, "AI 病人正在回复"); + } + + this.streaming = true; + this.error = ""; + const patientMessage: ChatMessage = { id: uid("patient"), role: "patient", content: "", pending: true }; + this.messages.push(patientMessage); + const patientMessageId = patientMessage.id; + const updatePatientMessage = (patch: Partial) => { + const index = this.messages.findIndex((item) => item.id === patientMessageId); + if (index < 0) return; + this.messages[index] = { ...this.messages[index], ...patch }; + }; + const appendPatientDelta = (delta: string) => { + const index = this.messages.findIndex((item) => item.id === patientMessageId); + if (index < 0) return; + const current = this.messages[index]; + this.messages[index] = { ...current, content: `${current.content}${delta}`, pending: true }; + }; + try { + await streamChat(this.context, this.session.session_id, normalized, (delta) => { + appendPatientDelta(delta); + }); + const finalMessage = this.messages.find((item) => item.id === patientMessageId) || patientMessage; + updatePatientMessage({ pending: false }); + console.info("[chat] stream finished", { chars: finalMessage.content.length }); + return finalMessage; + } catch (error) { + const current = this.messages.find((item) => item.id === patientMessageId); + if (!current?.content.trim()) { + updatePatientMessage({ + content: "AI 病人回复超时或失败,请重试;演示时可关闭“流式”后再发送。", + pending: false, + }); + } else { + updatePatientMessage({ pending: false }); + } + console.error("[chat] stream failed", error); + this.error = error instanceof Error ? error.message : "流式问诊失败"; + return null; + } finally { + updatePatientMessage({ pending: false }); + this.streaming = false; + } + }, + async loadOrderItems() { + if (!this.session) { + return null; + } + return this.run(async () => { + this.orderItems = (await apiClient.listOrderItems(this.context, this.session!.session_id)).items; + return this.orderItems; + }, "正在加载检查项目"); + }, + async createOrder(itemCode: string) { + if (!this.session) { + this.error = "请先创建训练会话"; + return null; + } + const existing = this.orderedResults.find((item) => item.item_code === itemCode); + if (existing) { + this.highlightedOrderCode = itemCode; + window.setTimeout(() => { + if (this.highlightedOrderCode === itemCode) { + this.highlightedOrderCode = ""; + } + }, 1200); + return existing; + } + if (this.orderingItemCode) { + return null; + } + this.orderingItemCode = itemCode; + try { + return await this.run(async () => { + const result = await apiClient.createOrder(this.context, this.session!.session_id, itemCode); + const resultIndex = this.orderedResults.findIndex((item) => item.item_code === result.item_code); + if (resultIndex >= 0) { + this.orderedResults[resultIndex] = result; + } else { + this.orderedResults.unshift(result); + } + this.highlightedOrderCode = result.item_code; + window.setTimeout(() => { + if (this.highlightedOrderCode === result.item_code) { + this.highlightedOrderCode = ""; + } + }, 1200); + if (!result.already_ordered) { + this.messages.push({ + id: uid("system"), + role: "system", + content: `检查结果:${result.item_name} - ${result.result_text}。该结果已写入本次会话上下文和评分依据。`, + }); + } + return result; + }, "正在申请检查"); + } finally { + this.orderingItemCode = ""; + } + }, + async requestHints() { + if (!this.session) { + this.error = "请先创建训练会话"; + return null; + } + const lastUserMessage = [...this.messages].reverse().find((message) => message.role === "doctor")?.content; + return this.run(async () => { + this.hints = await apiClient.hints(this.context, this.session!.session_id, lastUserMessage); + this.hintVisible = true; + return this.hints; + }, "正在生成新手提示"); + }, + async completeInquiry() { + if (!this.session) { + this.error = "请先创建训练会话"; + return null; + } + return this.run(async () => { + const response = await apiClient.completeInquiry(this.context, this.session!.session_id); + this.session!.status = response.status; + return response; + }, "正在完成问诊"); + }, + async submitDiagnosis() { + if (!this.session) { + this.error = "请先创建训练会话"; + return null; + } + return this.run(async () => { + const response = await apiClient.submitDiagnosis(this.context, this.session!.session_id, this.diagnosis); + this.session!.status = response.status; + return response; + }, "正在提交诊断"); + }, + async submitTreatment() { + if (!this.session) { + this.error = "请先创建训练会话"; + return null; + } + return this.run(async () => { + const response = await apiClient.submitTreatment(this.context, this.session!.session_id, this.treatment); + this.session!.status = response.status; + return response; + }, "正在提交治疗方案"); + }, + async createEvaluation() { + if (!this.session) { + this.error = "请先创建训练会话"; + return null; + } + this.evaluationStage = "正在整理问诊记录"; + this.evaluationSlowNotice = false; + const timers = [ + window.setTimeout(() => (this.evaluationStage = "正在读取评分规则和检查申请"), 700), + window.setTimeout(() => (this.evaluationStage = "正在调用评分模型,真实 LLM 可能需要等待"), 1400), + window.setTimeout(() => (this.evaluationStage = "正在生成报告结构"), 5000), + window.setTimeout(() => (this.evaluationSlowNotice = true), 9000), + ]; + return this.run(async () => { + try { + this.evaluation = await apiClient.createEvaluation(this.context, this.session!.session_id, this.scoreType); + this.session!.status = "evaluated"; + await this.loadHistory(); + return this.evaluation; + } finally { + timers.forEach((timer) => window.clearTimeout(timer)); + this.evaluationStage = ""; + this.evaluationSlowNotice = false; + } + }, "正在生成评价报告"); + }, + async loadHistory() { + return this.run(async () => { + this.history = (await apiClient.listEvaluations(this.context)).items; + return this.history; + }); + }, + async loadEvaluationDetail(evaluationId: number) { + return this.run(async () => { + this.evaluation = await apiClient.getEvaluationDetail(this.context, evaluationId); + return this.evaluation; + }); + }, + async exportPdf(evaluationId?: number) { + const targetId = evaluationId ?? this.evaluation?.evaluation_id; + if (!targetId) { + this.error = "请先生成或选择评价报告"; + return null; + } + return this.run(async () => apiClient.exportPdf(this.context, targetId)); + }, + async testLlm(kind: "fast" | "reason", message: string) { + return this.run(async () => { + if (kind === "fast") { + this.llmFastResult = await apiClient.llmFast(this.context, message); + return this.llmFastResult; + } + this.llmReasonResult = await apiClient.llmReason(this.context, message); + return this.llmReasonResult; + }); + }, + }, +}); diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css new file mode 100644 index 0000000..0e7a633 --- /dev/null +++ b/frontend/src/styles/main.css @@ -0,0 +1,1409 @@ +:root { + --bg: #f3f7fb; + --panel: #ffffff; + --panel-soft: #f8fbff; + --text: #253047; + --muted: #6b778c; + --line: #dce6f2; + --blue: #2167c8; + --blue-soft: #e9f1ff; + --orange: #ff6a2a; + --orange-soft: #fff0e8; + --green: #19a974; + --red: #d14343; + --shadow: 0 10px 30px rgba(31, 77, 124, 0.08); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + min-height: 100%; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); +} + +button, +input, +textarea, +select { + font: inherit; +} + +button, +a { + -webkit-tap-highlight-color: transparent; +} + +a { + color: inherit; + text-decoration: none; +} + +.app-shell { + min-height: 100vh; + display: grid; + grid-template-columns: 260px 1fr; +} + +.side-nav { + position: sticky; + top: 0; + height: 100vh; + padding: 22px 18px; + background: #183966; + color: #ffffff; +} + +.brand-block { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 24px; +} + +.brand-block span { + display: block; + color: rgba(255, 255, 255, 0.72); + font-size: 13px; + margin-top: 2px; +} + +.brand-icon { + width: 44px; + height: 44px; + border-radius: 8px; + display: grid; + place-items: center; + background: linear-gradient(135deg, #2b76dc, #ff6a2a); +} + +.nav-link { + display: flex; + align-items: center; + gap: 12px; + height: 44px; + padding: 0 12px; + border-radius: 8px; + color: rgba(255, 255, 255, 0.78); + margin-bottom: 6px; +} + +.nav-link.router-link-active { + background: rgba(255, 255, 255, 0.13); + color: #ffffff; +} + +.main-panel { + min-width: 0; + padding: 24px 24px 96px; +} + +.top-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; +} + +.top-bar h1 { + margin: 0; + font-size: 24px; +} + +.eyebrow { + margin: 0 0 4px; + color: var(--blue); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; +} + +.user-chip, +.pill { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 30px; + padding: 5px 10px; + border-radius: 999px; + background: var(--blue-soft); + color: var(--blue); + font-size: 13px; + font-weight: 600; +} + +.pill.ok { + background: rgba(25, 169, 116, 0.12); + color: var(--green); +} + +.bottom-nav { + display: none; +} + +.page-grid { + display: grid; + grid-template-columns: 1fr; + gap: 16px; +} + +.page-grid.two-column, +.chat-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 420px; + gap: 16px; + align-items: start; +} + +.panel, +.mode-card, +.case-card, +.history-item, +.metric-card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); +} + +.panel { + padding: 18px; +} + +.panel h2, +.panel h3, +.mode-card h3 { + margin: 0; +} + +.panel-title-row, +.case-card-head, +.score-header, +.dimension-label { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.hero-workbench { + background: linear-gradient(135deg, #ffffff 0%, #f7fbff 62%, #fff4ee 100%); +} + +.hero-copy h2 { + font-size: 28px; + margin: 0 0 8px; +} + +.hero-copy p, +.muted, +.mode-card p, +.case-card p, +.summary-box p, +.dimension-row p { + color: var(--muted); + line-height: 1.7; +} + +.settings-grid, +.form-grid, +.info-grid, +.metric-grid, +.connection-grid { + display: grid; + gap: 12px; +} + +.settings-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin: 18px 0; +} + +.connection-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-top: 14px; +} + +.form-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin: 16px 0; +} + +.form-grid.single { + grid-template-columns: 1fr; +} + +label span { + display: block; + margin-bottom: 6px; + color: var(--muted); + font-size: 13px; + font-weight: 600; +} + +input, +textarea, +select { + width: 100%; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; + color: var(--text); + padding: 10px 12px; + outline: none; +} + +textarea { + resize: vertical; +} + +input:focus, +textarea:focus, +select:focus { + border-color: var(--blue); + box-shadow: 0 0 0 3px rgba(33, 103, 200, 0.12); +} + +.action-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 14px; +} + +.primary-button, +.secondary-button, +.ghost-button, +.primary-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border: 0; + border-radius: 8px; + min-height: 42px; + padding: 0 14px; + cursor: pointer; + font-weight: 700; +} + +.primary-button, +.primary-icon-button { + background: linear-gradient(135deg, var(--blue), #2f80ed); + color: #ffffff; +} + +.secondary-button { + background: var(--orange-soft); + color: var(--orange); +} + +.ghost-button { + background: var(--blue-soft); + color: var(--blue); +} + +.danger-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border: 0; + border-radius: 8px; + min-height: 42px; + padding: 0 14px; + cursor: pointer; + font-weight: 700; + background: #fff0e8; + color: var(--orange); +} + +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + border: 0; + border-radius: 8px; + background: var(--blue-soft); + color: var(--blue); + cursor: pointer; +} + +.danger-text { + color: var(--red); +} + +.compact { + min-height: 34px; + padding: 0 10px; + font-size: 13px; +} + +.full { + width: 100%; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.field { + display: grid; + gap: 8px; + margin-top: 14px; +} + +.field span { + color: var(--muted); + font-weight: 700; +} + +.mode-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.mode-card { + padding: 16px; +} + +.mode-card div { + width: 42px; + height: 42px; + border-radius: 8px; + display: grid; + place-items: center; + margin-bottom: 12px; +} + +.mode-card.blue div { + background: var(--blue-soft); + color: var(--blue); +} + +.mode-card.orange div { + background: var(--orange-soft); + color: var(--orange); +} + +.json-box { + white-space: pre-wrap; + word-break: break-word; + max-height: 240px; + overflow: auto; + background: #0f223a; + color: #dbeafe; + border-radius: 8px; + padding: 14px; +} + +.status-card { + border: 1px solid var(--line); + border-radius: 8px; + padding: 12px; + background: var(--panel-soft); +} + +.status-card span, +.status-card small { + display: block; + color: var(--muted); + line-height: 1.5; +} + +.status-card strong { + display: block; + margin: 6px 0; + word-break: break-word; +} + +.status-card.ok { + border-color: rgba(25, 169, 116, 0.24); + background: rgba(25, 169, 116, 0.08); +} + +.status-card.warning { + border-color: rgba(255, 106, 42, 0.28); + background: var(--orange-soft); +} + +.feature-list { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-top: 14px; +} + +.feature-list article { + border: 1px solid var(--line); + border-radius: 8px; + padding: 12px; + background: var(--panel-soft); +} + +.feature-list span { + display: block; + color: var(--blue); + font-weight: 700; + margin: 6px 0; +} + +.feature-list p { + margin: 0; + color: var(--muted); + line-height: 1.6; +} + +.flow-stepper { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + margin: 14px 0; +} + +.flow-step { + display: flex; + align-items: center; + gap: 8px; + min-height: 42px; + padding: 8px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-soft); + color: var(--muted); +} + +.flow-step span { + width: 24px; + height: 24px; + display: grid; + place-items: center; + border-radius: 50%; + background: #e6edf7; + font-weight: 700; +} + +.flow-step.active { + border-color: var(--blue); + background: var(--blue-soft); + color: var(--blue); +} + +.flow-step.done { + border-color: rgba(25, 169, 116, 0.24); + background: rgba(25, 169, 116, 0.1); + color: var(--green); +} + +.case-list, +.history-list, +.detail-stack, +.result-list, +.dimension-list { + display: grid; + gap: 12px; + margin-top: 14px; +} + +.case-card, +.history-item { + width: 100%; + text-align: left; + padding: 14px; + cursor: pointer; +} + +.case-card.selected, +.case-card:hover, +.history-item:hover { + border-color: var(--blue); +} + +.status-dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--green); + flex: 0 0 auto; + margin-top: 7px; +} + +.difficulty { + padding: 3px 8px; + border-radius: 999px; + background: var(--orange-soft); + color: var(--orange); + font-size: 12px; + white-space: nowrap; +} + +.case-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +.case-tags span { + border-radius: 999px; + background: var(--blue-soft); + color: var(--blue); + padding: 4px 8px; + font-size: 12px; + font-weight: 600; +} + +.info-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.info-grid span, +.summary-box { + background: var(--panel-soft); + border: 1px solid var(--line); + border-radius: 8px; + padding: 10px; +} + +.summary-box strong { + display: block; + margin-bottom: 4px; +} + +.selected-case { + margin: 14px 0; + padding: 14px; + border-radius: 8px; + background: var(--panel-soft); + border: 1px solid var(--line); +} + +.mode-explain, +.training-context article, +.hint-panel, +.progress-panel { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-soft); + padding: 12px; +} + +.mode-explain p, +.training-context small, +.hint-toolbar span, +.button-hint, +.progress-panel p { + color: var(--muted); + line-height: 1.6; +} + +.training-context { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin: 12px 0; +} + +.training-context span { + display: block; + color: var(--muted); + font-size: 12px; + margin-bottom: 4px; +} + +.training-context article.novice { + border-color: rgba(255, 106, 42, 0.26); + background: var(--orange-soft); +} + +.hint-toolbar { + display: flex; + align-items: center; + gap: 10px; + margin: 10px 0; +} + +.hint-panel { + display: grid; + gap: 12px; + margin: 10px 0; +} + +.hint-panel ul { + margin: 8px 0 0; + padding-left: 18px; + color: var(--muted); + line-height: 1.7; +} + +.button-hint { + margin: 8px 0 0; + font-size: 13px; +} + +.progress-panel { + margin-top: 14px; + border-color: rgba(33, 103, 200, 0.25); + background: var(--blue-soft); +} + +.chat-layout { + grid-template-columns: minmax(0, 1fr) 380px; +} + +.chat-panel { + min-height: calc(100vh - 150px); +} + +.chat-window { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 340px; + max-height: 52vh; + overflow-y: auto; + padding: 14px 4px; +} + +.chat-bubble { + display: flex; + align-items: flex-start; + gap: 10px; + max-width: 86%; +} + +.chat-bubble.doctor { + align-self: flex-end; + flex-direction: row-reverse; +} + +.chat-bubble.system { + max-width: 100%; +} + +.avatar { + width: 34px; + height: 34px; + border-radius: 8px; + background: var(--blue-soft); + color: var(--blue); + display: grid; + place-items: center; + flex: 0 0 auto; +} + +.chat-bubble.doctor .avatar { + background: var(--orange-soft); + color: var(--orange); +} + +.bubble-body { + border: 1px solid var(--line); + border-radius: 8px; + padding: 10px 12px; + background: var(--panel-soft); +} + +.chat-bubble.doctor .bubble-body { + background: var(--blue); + color: #ffffff; +} + +.chat-bubble.system .bubble-body { + background: #f7fbf8; + border-color: rgba(25, 169, 116, 0.22); +} + +.bubble-meta { + font-size: 12px; + font-weight: 700; + color: var(--muted); + margin-bottom: 4px; +} + +.chat-bubble.doctor .bubble-meta { + color: rgba(255, 255, 255, 0.78); +} + +.bubble-body p { + margin: 0; + line-height: 1.7; + white-space: pre-wrap; +} + +.quick-bar { + display: flex; + gap: 8px; + overflow-x: auto; + padding-bottom: 8px; +} + +.quick-bar button { + flex: 0 0 auto; + border: 1px solid var(--line); + border-radius: 999px; + background: #ffffff; + color: var(--blue); + padding: 7px 10px; +} + +.chat-input-bar { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; + align-items: stretch; +} + +.primary-icon-button { + min-height: 100%; + padding: 0; +} + +.segmented { + display: inline-flex; + padding: 3px; + border-radius: 8px; + background: var(--blue-soft); +} + +.segmented button { + border: 0; + background: transparent; + color: var(--blue); + padding: 6px 10px; + border-radius: 6px; + cursor: pointer; +} + +.segmented button.active { + background: #ffffff; + box-shadow: 0 2px 8px rgba(33, 103, 200, 0.16); +} + +.order-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin: 14px 0; +} + +.order-item { + display: grid; + gap: 4px; + justify-items: start; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; + padding: 10px; + cursor: pointer; +} + +.order-item svg { + color: var(--blue); +} + +.order-item.ordered { + background: #f8fbff; + border-color: #b7cff8; +} + +.order-item.active, +.result-card.active { + border-color: var(--blue); + box-shadow: 0 0 0 3px rgba(39, 111, 216, 0.12); +} + +.order-item:disabled { + opacity: 0.82; +} + +.order-item span { + font-weight: 700; +} + +.order-item small { + color: var(--muted); +} + +.order-action, +.context-note { + color: var(--green); + font-size: 12px; +} + +.result-card { + border: 1px solid var(--line); + border-radius: 8px; + padding: 12px; + background: var(--panel-soft); +} + +.result-card div { + display: flex; + justify-content: space-between; + gap: 8px; +} + +.result-flag { + font-size: 12px; + border-radius: 999px; + padding: 3px 8px; +} + +.result-flag.abnormal { + background: var(--orange-soft); + color: var(--orange); +} + +.result-flag.normal { + background: rgba(25, 169, 116, 0.12); + color: var(--green); +} + +.score-ring { + width: 118px; + height: 118px; + border-radius: 50%; + border: 10px solid var(--blue-soft); + display: grid; + place-items: center; + text-align: center; + color: var(--blue); + flex: 0 0 auto; +} + +.score-ring strong { + display: block; + font-size: 30px; + line-height: 1; +} + +.score-ring span { + display: block; + font-size: 12px; + color: var(--muted); +} + +.report-hero { + padding-bottom: 14px; + border-bottom: 1px solid var(--line); +} + +.report-summary-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin: 14px 0; +} + +.report-summary-card { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-soft); + padding: 12px; +} + +.report-summary-card span { + display: block; + color: var(--muted); + font-size: 12px; +} + +.report-summary-card strong { + display: block; + margin-top: 4px; + color: var(--blue); + font-size: 22px; +} + +.dimension-row { + border: 1px solid var(--line); + border-radius: 8px; + padding: 12px; +} + +.dimension-row.detailed { + background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%); +} + +.dimension-detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-top: 10px; +} + +.dimension-detail-grid div, +.report-issues article { + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; + padding: 10px; +} + +.dimension-detail-grid h4, +.report-issues h3 { + margin: 0 0 8px; +} + +.dimension-detail-grid ul, +.report-columns ol { + margin: 0; + padding-left: 18px; + color: var(--muted); + line-height: 1.7; +} + +.improvement-note { + margin: 10px 0 0; + padding: 8px 10px; + border-radius: 8px; + background: var(--orange-soft); + color: var(--orange) !important; +} + +.bar-track { + height: 8px; + border-radius: 999px; + background: #edf2f8; + overflow: hidden; + margin: 8px 0; +} + +.bar-track span { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--blue), var(--orange)); +} + +.report-columns { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.report-columns div { + border: 1px solid var(--line); + border-radius: 8px; + padding: 12px; + background: var(--panel-soft); +} + +.report-columns ul { + margin: 0; + padding-left: 18px; + color: var(--muted); + line-height: 1.7; +} + +.report-columns.improved { + margin-top: 14px; +} + +.report-issues { + margin-top: 14px; + display: grid; + gap: 10px; +} + +.report-issues article div { + display: flex; + justify-content: space-between; + gap: 8px; +} + +.report-issues article span { + color: var(--orange); + font-size: 12px; + white-space: nowrap; +} + +.report-issues article p { + margin: 8px 0 0; + color: var(--muted); + line-height: 1.7; +} + +.empty-panel, +.empty-inline { + display: grid; + place-items: center; + text-align: center; + gap: 8px; + color: var(--muted); +} + +.upload-box { + margin-top: 16px; + min-height: 180px; + border: 1px dashed #abc3e5; + border-radius: 8px; + background: var(--panel-soft); + display: grid; + place-items: center; + gap: 8px; + text-align: center; + padding: 18px; + cursor: pointer; +} + +.upload-box svg { + color: var(--blue); +} + +.upload-box strong, +.upload-box span { + display: block; +} + +.upload-box span { + color: var(--muted); +} + +.upload-box input { + display: none; +} + +.import-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.import-message { + margin-top: 14px; + border-radius: 8px; + padding: 10px 12px; + display: flex; + gap: 8px; + line-height: 1.6; +} + +.import-message.error, +.error-box { + border: 1px solid rgba(209, 67, 67, 0.24); + background: #fff5f5; + color: var(--red); +} + +.import-message.success { + border: 1px solid rgba(25, 169, 116, 0.24); + background: rgba(25, 169, 116, 0.08); + color: var(--green); +} + +.warning-box { + display: flex; + gap: 10px; + align-items: flex-start; + border: 1px solid rgba(255, 106, 42, 0.28); + border-radius: 8px; + padding: 12px; + background: var(--orange-soft); + color: var(--orange); +} + +.warning-box p { + margin: 0; + color: var(--text); +} + +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + padding: 18px; + background: rgba(20, 35, 55, 0.42); +} + +.modal-card { + width: min(560px, 100%); + max-height: calc(100vh - 36px); + overflow: auto; + border-radius: 8px; + border: 1px solid var(--line); + background: var(--panel); + box-shadow: var(--shadow); + padding: 18px; +} + +.delete-impact-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-top: 10px; +} + +.delete-impact-grid span { + border-radius: 8px; + background: #ffffff; + border: 1px solid var(--line); + padding: 8px 10px; + color: var(--muted); +} + +.summary-box ul { + margin: 8px 0 0; + padding-left: 18px; + color: var(--muted); + line-height: 1.7; +} + +.compact-list { + margin-top: 8px; +} + +.import-case-row { + display: flex; + align-items: flex-start; + gap: 10px; + border: 1px solid var(--line); + border-radius: 8px; + padding: 10px; + background: #ffffff; +} + +.import-case-row svg { + color: var(--blue); +} + +.import-case-row span { + display: block; + margin-top: 3px; + color: var(--muted); + font-size: 12px; +} + +.empty-panel { + min-height: 300px; +} + +.history-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; +} + +.history-item span, +.history-score small { + display: block; + color: var(--muted); + font-size: 12px; + margin-top: 4px; +} + +.history-score { + text-align: right; +} + +.history-score strong { + font-size: 24px; + color: var(--blue); +} + +.metric-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.metric-card { + padding: 14px; +} + +.metric-card span, +.metric-card small { + display: block; + color: var(--muted); +} + +.metric-card strong { + display: block; + font-size: 24px; + color: var(--blue); + margin: 8px 0; +} + +.metric-card em { + display: block; + color: var(--orange); + font-style: normal; + font-size: 12px; +} + +.error-banner { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; + padding: 10px 12px; + border: 1px solid rgba(209, 67, 67, 0.24); + border-radius: 8px; + background: #fff5f5; + color: var(--red); +} + +.error-banner button { + margin-left: auto; + border: 0; + background: transparent; + color: var(--red); + cursor: pointer; + font-weight: 700; +} + +.safe-area-bottom { + padding-bottom: env(safe-area-inset-bottom); +} + +@media (max-width: 980px) { + .app-shell { + display: block; + } + + .side-nav { + display: none; + } + + .main-panel { + padding: 16px 12px 92px; + } + + .top-bar { + align-items: flex-start; + } + + .top-bar h1 { + font-size: 20px; + } + + .user-chip { + max-width: 44%; + overflow: hidden; + } + + .page-grid.two-column, + .chat-layout, + .settings-grid, + .form-grid, + .mode-grid, + .import-grid, + .report-summary-grid, + .dimension-detail-grid, + .report-columns, + .metric-grid, + .connection-grid, + .feature-list, + .training-context { + grid-template-columns: 1fr; + } + + .mode-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .flow-stepper { + grid-template-columns: repeat(4, minmax(72px, 1fr)); + overflow-x: auto; + } + + .chat-panel { + min-height: auto; + } + + .chat-window { + min-height: 300px; + max-height: 48vh; + } + + .chat-bubble { + max-width: 94%; + } + + .bottom-nav { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 10; + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 2px; + padding: 7px 6px; + background: rgba(255, 255, 255, 0.96); + border-top: 1px solid var(--line); + backdrop-filter: blur(14px); + } + + .bottom-link { + min-height: 50px; + display: grid; + place-items: center; + gap: 2px; + color: var(--muted); + font-size: 11px; + border-radius: 8px; + } + + .bottom-link.router-link-active { + color: var(--blue); + background: var(--blue-soft); + } +} + +@media (max-width: 520px) { + .panel { + padding: 14px; + } + + .hero-copy h2 { + font-size: 23px; + } + + .mode-grid, + .order-grid, + .info-grid { + grid-template-columns: 1fr; + } + + .score-header { + display: grid; + } + + .score-ring { + width: 104px; + height: 104px; + } + + .action-row { + display: grid; + } + + .primary-button, + .secondary-button, + .ghost-button, + .danger-button { + width: 100%; + } + + .delete-impact-grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts new file mode 100644 index 0000000..edb6781 --- /dev/null +++ b/frontend/src/types/api.ts @@ -0,0 +1,241 @@ +export type ScoreType = "percentage" | "five_point"; +export type TrainingMode = "practice" | "teaching"; +export type TrainingType = "case_analysis" | "diagnosis_treatment" | "consultation"; + +export interface ApiEnvelope { + code: string; + message: string; + data: T; +} + +export interface AgentHello { + user: { + user_id: string; + tenant_id?: string | null; + role?: string | null; + }; + features: { + stream_chat?: boolean; + score_types?: ScoreType[]; + pdf_export?: boolean; + knowledge_search?: boolean; + llm_mock_enabled?: boolean; + llm_mode?: "mock" | "real"; + llm_fallback_to_mock?: boolean; + llm_fast_model?: string; + llm_reason_model?: string; + llm_fast_thinking_enabled?: boolean; + llm_reason_thinking_enabled?: boolean; + llm_reasoning_effort?: string; + llm_fast_max_tokens?: number; + runtime_memory_backend?: string; + [key: string]: unknown; + }; +} + +export interface CaseListItem { + id: number; + case_code: string; + department_id: number; + title: string; + difficulty: string; + chief_complaint?: string | null; + supported_training_type: TrainingType; + supported_mode: string; + has_teaching_video: boolean; + has_knowledge_points: boolean; + has_quiz: boolean; +} + +export interface CaseDetail { + id: number; + case_code: string; + title: string; + department: string; + difficulty: string; + patient: { + name?: string | null; + age?: number | null; + gender?: string | null; + occupation?: string | null; + }; + chief_complaint?: string | null; + supported_training_type: TrainingType; + supported_mode: string; + has_teaching_video: boolean; + has_knowledge_points: boolean; + has_quiz: boolean; + order_item_types: string[]; +} + +export interface CreateSessionPayload { + case_id: number; + training_type: TrainingType; + mode: TrainingMode; + score_type: ScoreType; +} + +export interface TrainingSession { + session_id: number; + session_code: string; + status: string; + patient_opening: string; +} + +export interface ChatMessage { + id: string; + role: "doctor" | "patient" | "system"; + content: string; + pending?: boolean; +} + +export interface ChatResponse { + reply: string; + latency_ms: number; + model: string; + fallback_used?: boolean; +} + +export interface OrderItem { + item_code: string; + item_name: string; + item_type: string; +} + +export interface OrderResult extends OrderItem { + result_text: string; + result_structured?: Record | null; + is_key: boolean; + is_abnormal: boolean; + context_written?: boolean; + already_ordered?: boolean; +} + +export interface RecommendedOrder { + item_code: string; + reason: string; +} + +export interface HintResponse { + hints: string[]; + missing_dimensions: string[]; + next_questions: string[]; + recommended_orders: RecommendedOrder[]; +} + +export interface DiagnosisPayload { + primary_diagnosis: string; + differential_diagnoses: string[]; + diagnosis_basis: string; +} + +export interface TreatmentPayload { + treatment_principle: string; + treatment_measures: string; + risk_plan?: string | null; + communication?: string | null; + follow_up?: string | null; +} + +export interface DimensionScore { + dimension: string; + score: number; + max_score: number; + comment: string; + evidence?: string[]; + deductions?: string[]; + improvement?: string; +} + +export interface EvaluationReport { + evaluation_id: number; + score_type: ScoreType; + total_score: number; + dimension_scores: DimensionScore[]; + errors: Record[]; + improvement_plan: string[]; + evidence_summary: string[]; + guideline_refs: Record[]; + overall_comment: string; +} + +export interface EvaluationDetail extends EvaluationReport { + session_id: number; + case_id: number; + case_title: string; + created_at: string; + pdf_file_path?: string | null; +} + +export interface EvaluationListItem { + evaluation_id: number; + case_title: string; + score_type: ScoreType; + total_score: number; + created_at: string; + pdf_exported: boolean; +} + +export interface ExportPdfResponse { + export_id: number; + file_path: string; +} + +export interface LlmTestResponse { + model: string; + first_token_ms?: number | null; + total_latency_ms: number; + stream: boolean; + mock_mode?: boolean; + fallback_used?: boolean; + thinking_enabled?: boolean | null; + reasoning_effort?: string | null; +} + +export interface CaseSqlPreviewCase { + id: number; + title: string; + case_type: string; + difficulty: string; +} + +export interface CaseSqlImportPreview { + file_name: string; + encoding?: string | null; + tables: Record; + can_import: boolean; + warnings: string[]; + errors: string[]; + preview_cases: CaseSqlPreviewCase[]; +} + +export interface CaseSqlImportApply { + imported: boolean; + file_name: string; + encoding: string; + inserted_or_updated_cases: number; + imported_traditional_cases: number; + imported_teaching_cases: number; + imported_scoring_rules: number; + generated_exam_items: number; + warnings: string[]; +} + +export interface CaseDeletePreview { + case_id: number; + case_title: string; + can_delete: boolean; + affected: Record; +} + +export interface CaseDeleteResponse { + deleted: boolean; + case_id: number; + deleted_counts: Record; +} + +export interface RequestContext { + apiBaseUrl: string; + userId: string; + entryScene: string; +} diff --git a/frontend/src/views/CasesView.vue b/frontend/src/views/CasesView.vue new file mode 100644 index 0000000..6400e4a --- /dev/null +++ b/frontend/src/views/CasesView.vue @@ -0,0 +1,217 @@ + + + diff --git a/frontend/src/views/ChatView.vue b/frontend/src/views/ChatView.vue new file mode 100644 index 0000000..eb8e899 --- /dev/null +++ b/frontend/src/views/ChatView.vue @@ -0,0 +1,178 @@ + + + diff --git a/frontend/src/views/HistoryView.vue b/frontend/src/views/HistoryView.vue new file mode 100644 index 0000000..dff1cbe --- /dev/null +++ b/frontend/src/views/HistoryView.vue @@ -0,0 +1,53 @@ + + + diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue new file mode 100644 index 0000000..90568c3 --- /dev/null +++ b/frontend/src/views/HomeView.vue @@ -0,0 +1,184 @@ + + + diff --git a/frontend/src/views/ImportCaseView.vue b/frontend/src/views/ImportCaseView.vue new file mode 100644 index 0000000..607f608 --- /dev/null +++ b/frontend/src/views/ImportCaseView.vue @@ -0,0 +1,192 @@ + + + diff --git a/frontend/src/views/LlmTestView.vue b/frontend/src/views/LlmTestView.vue new file mode 100644 index 0000000..16ab46a --- /dev/null +++ b/frontend/src/views/LlmTestView.vue @@ -0,0 +1,71 @@ + + + diff --git a/frontend/src/views/ReportView.vue b/frontend/src/views/ReportView.vue new file mode 100644 index 0000000..a40cd15 --- /dev/null +++ b/frontend/src/views/ReportView.vue @@ -0,0 +1,64 @@ + + + diff --git a/frontend/src/views/SessionView.vue b/frontend/src/views/SessionView.vue new file mode 100644 index 0000000..f619b23 --- /dev/null +++ b/frontend/src/views/SessionView.vue @@ -0,0 +1,103 @@ + + + diff --git a/frontend/src/views/SubmitView.vue b/frontend/src/views/SubmitView.vue new file mode 100644 index 0000000..4198a04 --- /dev/null +++ b/frontend/src/views/SubmitView.vue @@ -0,0 +1,114 @@ + + + diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..8006521 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve" + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..3adda81 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..e0566bd --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; + +export default defineConfig({ + plugins: [vue()], + server: { + host: "127.0.0.1", + port: 5173, + }, +}); diff --git a/scripts/check_mysql_demo.ps1 b/scripts/check_mysql_demo.ps1 new file mode 100644 index 0000000..41ffc83 --- /dev/null +++ b/scripts/check_mysql_demo.ps1 @@ -0,0 +1,40 @@ +param( + [string]$HostName = "127.0.0.1", + [int]$Port = 3306, + [string]$User = "root", + [string]$Password = "", + [string]$DatabaseName = "medical_consultation_agent", + [string]$MysqlExe = "mysql" +) + +$ErrorActionPreference = "Stop" + +if ([string]::IsNullOrWhiteSpace($Password)) { + $securePassword = Read-Host "MySQL password for $User@$HostName" -AsSecureString + $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($securePassword) + try { + $Password = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) + } + finally { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) + } +} + +$env:MYSQL_PWD = $Password + +try { + & $MysqlExe -h $HostName -P $Port -u $User --default-character-set=utf8mb4 -D $DatabaseName -e @" +SELECT TABLE_NAME, TABLE_COMMENT +FROM information_schema.tables +WHERE table_schema = '$DatabaseName' +ORDER BY TABLE_NAME; +SELECT COUNT(*) AS case_count FROM cases; +SELECT COUNT(*) AS exam_item_count FROM case_exam_items; +SELECT COUNT(*) AS prompt_template_count FROM prompt_templates; +SELECT COUNT(*) AS rubric_template_count FROM rubric_templates; +SELECT COUNT(*) AS knowledge_chunk_count FROM knowledge_chunks; +"@ +} +finally { + Remove-Item Env:\MYSQL_PWD -ErrorAction SilentlyContinue +} diff --git a/scripts/init_mysql_demo.ps1 b/scripts/init_mysql_demo.ps1 new file mode 100644 index 0000000..8d9460e --- /dev/null +++ b/scripts/init_mysql_demo.ps1 @@ -0,0 +1,95 @@ +param( + [string]$HostName = "127.0.0.1", + [int]$Port = 3306, + [string]$User = "root", + [string]$Password = "", + [string]$DatabaseName = "medical_consultation_agent", + [string]$MysqlExe = "mysql" +) + +$ErrorActionPreference = "Stop" + +function Resolve-ProjectPath { + $scriptDir = Split-Path -Parent $MyInvocation.ScriptName + return (Resolve-Path -Path (Join-Path $scriptDir "..")).Path +} + +function Convert-ToMysqlSourcePath { + param([string]$Path) + return $Path.Replace("\", "/") +} + +function Invoke-MysqlCommand { + param([string]$Sql) + & $MysqlExe -h $HostName -P $Port -u $User --default-character-set=utf8mb4 -e $Sql + if ($LASTEXITCODE -ne 0) { + throw "MySQL command failed: $Sql" + } +} + +function Invoke-MysqlDatabaseCommand { + param([string]$Sql) + & $MysqlExe -h $HostName -P $Port -u $User --default-character-set=utf8mb4 -D $DatabaseName -e $Sql + if ($LASTEXITCODE -ne 0) { + throw "MySQL database command failed: $Sql" + } +} + +$projectRoot = Resolve-ProjectPath +$schemaPath = Convert-ToMysqlSourcePath((Resolve-Path -Path (Join-Path $projectRoot "docs/sql/schema.sql")).Path) +$seedPath = Convert-ToMysqlSourcePath((Resolve-Path -Path (Join-Path $projectRoot "docs/sql/seed_pediatric_pneumonia.sql")).Path) +$tempDir = Join-Path $projectRoot "storage/mysql_import" +New-Item -ItemType Directory -Force -Path $tempDir | Out-Null +$tempSchemaPath = Join-Path $tempDir "schema.$DatabaseName.sql" +$tempSeedPath = Join-Path $tempDir "seed.$DatabaseName.sql" + +(Get-Content -Path $schemaPath -Raw -Encoding UTF8).Replace("medical_consultation_agent", $DatabaseName) | + Set-Content -Path $tempSchemaPath -Encoding UTF8 +(Get-Content -Path $seedPath -Raw -Encoding UTF8).Replace("medical_consultation_agent", $DatabaseName) | + Set-Content -Path $tempSeedPath -Encoding UTF8 + +$tempSchemaSource = Convert-ToMysqlSourcePath((Resolve-Path -Path $tempSchemaPath).Path) +$tempSeedSource = Convert-ToMysqlSourcePath((Resolve-Path -Path $tempSeedPath).Path) + +if ([string]::IsNullOrWhiteSpace($Password)) { + $securePassword = Read-Host "MySQL password for $User@$HostName" -AsSecureString + $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($securePassword) + try { + $Password = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) + } + finally { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) + } +} + +$env:MYSQL_PWD = $Password + +try { + Write-Host "Checking MySQL connection..." + Invoke-MysqlCommand "SELECT VERSION() AS mysql_version;" + + Write-Host "Creating schema in database $DatabaseName from docs/sql/schema.sql..." + Invoke-MysqlCommand "source $tempSchemaSource" + + Write-Host "Seeding demo data into database $DatabaseName..." + Invoke-MysqlCommand "source $tempSeedSource" + + Write-Host "Verifying tables and seed data..." + $verifySql = @" +SELECT COUNT(*) AS table_count +FROM information_schema.tables +WHERE table_schema = 'medical_consultation_agent'; +SELECT id, case_code, title, difficulty +FROM cases; +SELECT item_code, item_name, item_type, is_key +FROM case_exam_items +ORDER BY display_order; +"@ + $verifySql = $verifySql.Replace("medical_consultation_agent", $DatabaseName) + Invoke-MysqlDatabaseCommand $verifySql + + Write-Host "MySQL demo schema initialized successfully." +} +finally { + Remove-Item Env:\MYSQL_PWD -ErrorAction SilentlyContinue +}