From eb43573a44f0a5cd03978514928c7dbb99f0cea6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E9=87=91=E5=AE=9D?= Date: Wed, 3 Jun 2026 15:51:46 +0800 Subject: [PATCH] finalize medical consultation agent backend --- .env.example | 8 +- .gitignore | 2 +- README.md | 234 ++++++++--------- backend/README.md | 17 +- backend/app/agents/report_agent.py | 25 ++ backend/app/agents/scoring_agent.py | 65 ++++- backend/app/api/auth.py | 14 +- backend/app/core/context.py | 8 +- backend/app/core/user_context.py | 6 + backend/app/models/__init__.py | 6 +- backend/app/models/audit.py | 2 +- backend/app/models/department.py | 22 +- backend/app/models/knowledge.py | 4 +- backend/app/models/training.py | 8 +- backend/app/models/training_record.py | 30 ++- backend/app/models/user.py | 55 ++-- backend/app/repositories/case_repository.py | 28 +- .../app/repositories/evaluation_repository.py | 18 +- .../app/repositories/profile_repository.py | 25 -- .../repositories/source_case_repository.py | 2 +- backend/app/schemas/auth.py | 10 + backend/app/schemas/evaluation.py | 15 ++ backend/app/services/evaluation_service.py | 125 +++++++-- backend/app/services/external_auth_service.py | 11 +- backend/app/services/pdf_export_service.py | 59 ++++- backend/scripts/check_final_demo_readiness.py | 130 ++++++++++ backend/scripts/check_final_schema.py | 240 ++++++++++++++++++ .../scripts/clear_training_runtime_data.py | 75 ++++++ backend/scripts/init_demo_db.py | 23 +- backend/scripts/migrate_to_new_schema.py | 3 + .../migrate_user_department_score_detail.py | 65 +++++ backend/tests/test_api_contract.py | 1 + backend/tests/test_demo_flow.py | 8 +- 33 files changed, 1063 insertions(+), 281 deletions(-) delete mode 100644 backend/app/repositories/profile_repository.py create mode 100644 backend/scripts/check_final_demo_readiness.py create mode 100644 backend/scripts/check_final_schema.py create mode 100644 backend/scripts/clear_training_runtime_data.py create mode 100644 backend/scripts/migrate_user_department_score_detail.py diff --git a/.env.example b/.env.example index 20fdc46..f905da1 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -APP_NAME=Medical Consultation Agent Demo +APP_NAME=Medical Consultation Agent APP_ENV=local APP_DEBUG=true API_V1_PREFIX=/api/v1 @@ -8,8 +8,8 @@ APP_PORT=9000 # 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:@mysql:3306/medical?charset=utf8mb4 -DATABASE_URL=mysql+pymysql://root:@mysql:3306/medical?charset=utf8mb4 +MYSQL_URL=mysql+aiomysql://root:@mysql:3306/medical_platform?charset=utf8mb4 +DATABASE_URL=mysql+pymysql://root:@mysql:3306/medical_platform?charset=utf8mb4 # Redis RUNTIME_MEMORY_BACKEND=redis @@ -17,7 +17,7 @@ REDIS_URL=redis://redis:6379/0 RUNTIME_MEMORY_TTL_SECONDS=7200 # Django user center auth for frontend integration -AUTH_USER_ME_URL=http://192.168.2.76:8000/api/user/users/me/ +AUTH_USER_ME_URL=http://django:8000/api/user/users/me/ AUTH_TIMEOUT_SECONDS=5 AUTH_CACHE_TTL_SECONDS=300 diff --git a/.gitignore b/.gitignore index 3875b39..a2c1eaa 100644 --- a/.gitignore +++ b/.gitignore @@ -37,7 +37,7 @@ uploads/ # Demo-only or temporary files docs/ demo_frontend/ -scripts/ +/scripts/ backend/test*.sql # Editor / OS diff --git a/README.md b/README.md index 53c1132..d829001 100644 --- a/README.md +++ b/README.md @@ -1,115 +1,20 @@ -# 医疗问诊 Agent FastAPI 后端 +# 医疗问诊 Agent 后端 -本仓库提交内容只包含医疗问诊 Agent 的 FastAPI 后端工程。前端 Demo、开发文档、运行产物和本地环境文件不进入 Git。 +医疗问诊 Agent 是宿主医疗教学平台中的问诊训练子功能。后端基于 FastAPI 构建,负责病例读取、训练会话、多轮问诊、检查申请、诊断治疗提交、AI 评价、PDF 报告导出和历史记录查询。 -## 运行地址 +## 技术栈 -后端服务固定按以下地址启动: +- Python 3.11 +- FastAPI +- SQLAlchemy 2.x +- MySQL +- Redis +- OpenAI-compatible LLM Adapter +- ReportLab PDF -```text -http://127.0.0.1:9000 -``` - -Swagger: - -```text -http://127.0.0.1:9000/docs -``` - -启动命令: - -```powershell -cd backend -.\.venv\Scripts\activate -uvicorn app.main:app --host 127.0.0.1 --port 9000 -``` - -Docker 构建与运行: - -```powershell -docker build -t medical-consultation-agent-backend . -docker run --env-file .env -p 9000:9000 medical-consultation-agent-backend -``` - -## 服务依赖 - -MySQL 使用容器或内网服务名 `mysql`: - -```text -host=mysql -port=3306 -database=medical -user=root -``` - -Redis 使用容器或内网服务名 `redis`: - -```text -host=redis -port=6379 -``` - -后端通过 `.env` 读取连接串。真实 `.env` 不提交到 Git,仓库只提供 `.env.example`。 - -## 环境变量 - -复制示例文件: - -```powershell -copy .env.example .env -``` - -关键配置: - -```env -APP_HOST=127.0.0.1 -APP_PORT=9000 -MYSQL_URL=mysql+aiomysql://root:@mysql:3306/medical?charset=utf8mb4 -DATABASE_URL=mysql+pymysql://root:@mysql:3306/medical?charset=utf8mb4 -RUNTIME_MEMORY_BACKEND=redis -REDIS_URL=redis://redis:6379/0 -``` - -`` 由部署环境注入。不要把真实数据库密码、LLM Key 或 access token 提交到 Git。 - -## 用户认证 - -医疗问诊 Agent 不做登录注册。正式联调时,前端携带宿主系统 access token: - -```http -Authorization: Bearer -X-Entry-Scene: mac_vue_dev -``` - -后端调用 Django 用户中心: - -```text -GET /api/user/users/me/ -``` - -Django 返回 200 后,后端使用返回的 `id` 作为内部 `user_id`,用于会话、训练记录、评价报告和历史记录隔离。 - -快速验证: - -```bash -curl -X GET "http://127.0.0.1:9000/api/v1/auth/me" \ - -H "Authorization: Bearer " \ - -H "X-Entry-Scene: mac_vue_dev" -``` - -成功条件: - -```json -{ - "code": "OK", - "data": { - "source": "django_user_center" - } -} -``` - -## 后端功能 +## 核心功能 +- Django 用户中心 token 校验 - 病例列表与病例详情 - 病例 SQL 安全导入与删除 - 训练会话创建 @@ -118,15 +23,104 @@ curl -X GET "http://127.0.0.1:9000/api/v1/auth/me" \ - 检查/检验申请 - 诊断与治疗提交 - AI 评价报告生成 +- 评分明细落库 - PDF 报告导出 - 历史评价查询 - LLM Fast/Reason 测试 -## 初始化数据库 +## 本地启动 + +```powershell +cd backend +python -m venv .venv +.\.venv\Scripts\activate +pip install -r requirements.txt +cd .. +copy .env.example .env +``` + +编辑 `.env`,填写 MySQL、Redis、Django 用户中心和 LLM 配置。 + +启动服务: + +```powershell +cd backend +uvicorn app.main:app --host 127.0.0.1 --port 9000 +``` + +访问: + +```text +http://127.0.0.1:9000/docs +``` + +## Docker 启动 + +```powershell +copy .env.example .env +docker build -t medical-consultation-agent-backend . +docker run --env-file .env -p 9000:9000 medical-consultation-agent-backend +``` + +## 环境变量 + +关键配置见 `.env.example`: + +```env +DATABASE_URL=mysql+pymysql://root:@mysql:3306/medical_platform?charset=utf8mb4 +MYSQL_URL=mysql+aiomysql://root:@mysql:3306/medical_platform?charset=utf8mb4 +REDIS_URL=redis://redis:6379/0 +AUTH_USER_ME_URL=http://django:8000/api/user/users/me/ +LLM_BASE_URL=https://api.deepseek.com/chat/completions +LLM_API_KEY= +``` + +真实数据库密码、LLM Key 和 access token 只写入本地 `.env` 或部署环境变量。 + +## 数据库初始化与检查 + +数据库需先创建好,表结构由后端脚本创建和校验。 ```powershell cd backend .\.venv\Scripts\python.exe scripts\migrate_to_new_schema.py +.\.venv\Scripts\python.exe scripts\migrate_user_department_score_detail.py +.\.venv\Scripts\python.exe scripts\check_final_schema.py +.\.venv\Scripts\python.exe scripts\check_final_demo_readiness.py +``` + +清空训练运行数据和本地报告文件: + +```powershell +cd backend +.\.venv\Scripts\python.exe scripts\clear_training_runtime_data.py --confirm CLEAR_TRAINING_DATA --reports +``` + +该脚本只清理训练会话、检查申请、提交、评价记录、评分明细、审计日志和本地 PDF 报告,不删除病例、用户、科室、检查项、评分规则、提示词和知识库。 + +## 用户认证 + +前端请求医疗问诊 Agent 时携带宿主系统 access token: + +```http +Authorization: Bearer +X-Entry-Scene: mac_vue_dev +``` + +后端会转发 token 到 Django 用户中心: + +```text +GET /api/user/users/me/ +``` + +Django 返回 200 后,后端使用返回的 `id` 作为本系统内部用户隔离 ID。前端不需要传 `X-User-Id`。 + +验证接口: + +```bash +curl -X GET "http://127.0.0.1:9000/api/v1/auth/me" \ + -H "Authorization: Bearer " \ + -H "X-Entry-Scene: mac_vue_dev" ``` ## 测试 @@ -139,27 +133,11 @@ cd backend .\.venv\Scripts\python.exe tests\test_demo_flow.py ``` -## Git 提交范围 +## API 文档 -Git 仓库只跟踪: +启动后访问: ```text -backend/ -README.md -.env.example -.gitignore -.gitattributes -``` - -不提交: - -```text -frontend/ -docs/ -demo_frontend/ -scripts/ -.env -storage/ -backend/.venv/ -本地报告、日志、数据库文件和临时 SQL 文件 +http://127.0.0.1:9000/docs +http://127.0.0.1:9000/openapi.json ``` diff --git a/backend/README.md b/backend/README.md index 5f1bc9a..3da9d12 100644 --- a/backend/README.md +++ b/backend/README.md @@ -5,11 +5,12 @@ ## 启动 ```powershell -cd backend python -m venv .venv .\.venv\Scripts\activate pip install -r requirements.txt -copy ..\.env.example ..\.env +cd .. +copy .env.example .env +cd backend uvicorn app.main:app --host 127.0.0.1 --port 9000 ``` @@ -21,15 +22,17 @@ http://127.0.0.1:9000/docs ## 配置 -后端读取项目根目录 `.env`。 +后端读取项目根目录 `.env`。核心配置: ```env -DATABASE_URL=mysql+pymysql://root:@mysql:3306/medical?charset=utf8mb4 -MYSQL_URL=mysql+aiomysql://root:@mysql:3306/medical?charset=utf8mb4 +DATABASE_URL=mysql+pymysql://root:@mysql:3306/medical_platform?charset=utf8mb4 +MYSQL_URL=mysql+aiomysql://root:@mysql:3306/medical_platform?charset=utf8mb4 REDIS_URL=redis://redis:6379/0 +AUTH_USER_ME_URL=http://django:8000/api/user/users/me/ +LLM_API_KEY= ``` -真实密码和 API Key 只写入部署环境或本地 `.env`,不提交 Git。 +真实密码、API Key 和 access token 只写入部署环境或本地 `.env`。 ## 核心约束 @@ -38,5 +41,5 @@ REDIS_URL=redis://redis:6379/0 - Django 返回的 `id` 是本系统内部用户隔离字段。 - 问诊消息进入短期 memory,不作为长期历史保存。 - 检查检验结果只从数据库读取。 -- 完整训练结束后只保存评价记录、PDF 路径、学习档案和审计日志。 +- 完整训练结束后保存 `training_record` 和 `training_score_detail`。 - LLM 调用统一经过 `app/agents/llm_adapter.py`。 diff --git a/backend/app/agents/report_agent.py b/backend/app/agents/report_agent.py index 55f542c..113a25f 100644 --- a/backend/app/agents/report_agent.py +++ b/backend/app/agents/report_agent.py @@ -9,6 +9,7 @@ class ReportAgent: "score_type": scoring_result.get("score_type", "percentage"), "total_score": total_score, "dimension_scores": dimension_scores, + "score_details": self._normalize_score_details(scoring_result.get("score_details", []), 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")), @@ -32,6 +33,30 @@ class ReportAgent: "score": self._safe_float(item.get("score"), 0), "max_score": self._safe_float(item.get("max_score"), 0), "comment": str(item.get("comment", "")), + "evidence": self._ensure_list(item.get("evidence")), + "deductions": self._ensure_list(item.get("deductions")), + "improvement": str(item.get("improvement", "")), + } + ) + return normalized + + def _normalize_score_details(self, raw_details: object, dimension_scores: list[dict]) -> list[dict]: + """评分明细校验:保留可写入 training_score_detail 的细粒度字段。""" + source = raw_details if isinstance(raw_details, list) and raw_details else dimension_scores + normalized: list[dict] = [] + for item in source: + if not isinstance(item, dict): + continue + deductions = self._ensure_list(item.get("deductions")) + normalized.append( + { + "rule_id": item.get("rule_id"), + "dimension": str(item.get("dimension", "综合表现")), + "score": self._safe_float(item.get("score"), 0), + "deducted_reason": str(item.get("deducted_reason") or ";".join(str(value) for value in deductions)), + "evidence_message_ids": self._ensure_list(item.get("evidence_message_ids") or item.get("evidence")), + "ai_confidence": self._safe_float(item.get("ai_confidence"), 0.85), + "comment": str(item.get("comment") or item.get("improvement") or ""), } ) return normalized diff --git a/backend/app/agents/scoring_agent.py b/backend/app/agents/scoring_agent.py index f20ea0c..e9d1335 100644 --- a/backend/app/agents/scoring_agent.py +++ b/backend/app/agents/scoring_agent.py @@ -114,8 +114,9 @@ class ScoringAgent: "你是医学教学问诊评分专家,只输出合法 JSON。" "请结合病例、问诊过程、检查申请、诊断和治疗提交进行教学评价。" "输出字段固定为 score_type,total_score,dimension_scores,errors,improvement_plan," - "evidence_summary,guideline_refs,overall_comment。" + "evidence_summary,guideline_refs,overall_comment,score_details。" "dimension_scores 为 5-6 项,每项包含 dimension,score,max_score,comment,evidence,deductions,improvement。" + "score_details 对应 scoring_rules,每项包含 rule_id,dimension,score,deducted_reason,evidence_message_ids,ai_confidence,comment。" "evidence、deductions、improvement_plan、evidence_summary 必须是数组,每个元素一句话。" "errors 每项包含 title,description,severity,related_dimension。" "评价必须具体指出用户问了什么、申请了什么检查、诊断治疗哪里充分或不足。" @@ -129,6 +130,7 @@ class ScoringAgent: for item in scoring_rules[:12]: compact.append( { + "rule_id": getattr(item, "id", None), "dimension": getattr(item, "dimension", ""), "competency_dimension": getattr(item, "competency_dimension", ""), "score_weight": float(getattr(item, "score_weight", 0) or 0), @@ -161,6 +163,7 @@ class ScoringAgent: data.setdefault("score_type", "percentage") data.setdefault("total_score", 0) data.setdefault("dimension_scores", []) + data.setdefault("score_details", []) data.setdefault("errors", []) data.setdefault("improvement_plan", []) data.setdefault("evidence_summary", []) @@ -183,6 +186,7 @@ class ScoringAgent: } ) data["dimension_scores"] = normalized_dimensions or self._fallback_score("percentage", guideline_refs)["dimension_scores"] + data["score_details"] = self._normalize_score_details(data.get("score_details"), data["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")) @@ -195,6 +199,29 @@ class ScoringAgent: data["score_type"] = "percentage" return data + def _normalize_score_details(self, raw_details: object, dimension_scores: list[dict]) -> list[dict]: + """评分明细归一化:生成可落库的 training_score_detail 数据。""" + source = raw_details if isinstance(raw_details, list) and raw_details else dimension_scores + details = [] + for item in source: + if not isinstance(item, dict): + continue + deducted_reason = item.get("deducted_reason") + if not deducted_reason: + deducted_reason = ";".join(str(value) for value in item.get("deductions", []) if value) + details.append( + { + "rule_id": item.get("rule_id"), + "dimension": str(item.get("dimension") or "综合表现"), + "score": float(item.get("score") or 0), + "deducted_reason": self._truncate(deducted_reason or "", 260), + "evidence_message_ids": self._ensure_list(item.get("evidence_message_ids") or item.get("evidence")), + "ai_confidence": float(item.get("ai_confidence") or 0.85), + "comment": self._truncate(item.get("comment") or item.get("improvement") or "", 220), + } + ) + return details + def _normalize_errors(self, errors: object) -> list[dict]: """错误项归一化:转为报告可渲染的扣分项。""" normalized = [] @@ -320,6 +347,35 @@ class ScoringAgent: "improvement": "用 SOAP 结构归纳病情,把证据、判断和计划串联起来。", }, ], + "score_details": [ + { + "rule_id": None, + "dimension": "信息获取", + "score": 20, + "deducted_reason": "既往喘息史、过敏史、疫苗接种史、家属照护能力等信息不够完整。", + "evidence_message_ids": ["围绕发热、咳嗽、喘息等核心症状展开问诊。"], + "ai_confidence": 0.85, + "comment": "完成主要症状追问,但儿科专科病史仍需补充。", + }, + { + "rule_id": None, + "dimension": "分析推理", + "score": 16, + "deducted_reason": "鉴别诊断和严重程度判断未充分引用血氧、胸片和炎症指标。", + "evidence_message_ids": ["主要诊断指向支气管肺炎。"], + "ai_confidence": 0.84, + "comment": "诊断方向基本正确,但严重程度分层需要更清晰。", + }, + { + "rule_id": None, + "dimension": "检查利用", + "score": 12, + "deducted_reason": "对 SpO2、胸片异常和炎症指标的临床意义解释不够具体。", + "evidence_message_ids": ["胸片、血氧或炎症指标可支持肺炎诊断和严重程度判断。"], + "ai_confidence": 0.84, + "comment": "关键检查申请较完整,但检查结果解释仍可细化。", + }, + ], "errors": [ { "title": "信息采集不够系统", @@ -359,6 +415,13 @@ class ScoringAgent: } for item in data.get("dimension_scores", []) ] + converted["score_details"] = [ + { + **item, + "score": round(float(item.get("score", 0)) / 20, 1), + } + for item in data.get("score_details", []) + ] return converted def _truncate(self, value: Any, limit: int) -> str: diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 7d868fe..5e5a6e5 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -22,9 +22,11 @@ async def auth_me(ctx: UserContext = Depends(get_user_context)): phone=profile.get("phone"), avatar=profile.get("avatar"), gender=profile.get("gender"), - institution=profile.get("institution"), + institution=profile.get("institution") or profile.get("institution_id"), + institution_id=profile.get("institution_id") or profile.get("institution"), institution_name=profile.get("institution_name"), - department=profile.get("department"), + department=profile.get("department") or profile.get("department_id"), + department_id=profile.get("department_id") or profile.get("department"), department_name=profile.get("department_name"), title_name=profile.get("title_name"), major=profile.get("major"), @@ -38,5 +40,13 @@ async def auth_me(ctx: UserContext = Depends(get_user_context)): total_case_count=profile.get("total_case_count"), current_level=profile.get("current_level"), status=profile.get("status"), + last_login=profile.get("last_login"), + last_login_time=profile.get("last_login_time"), + is_superuser=profile.get("is_superuser"), + is_staff=profile.get("is_staff"), + is_active=profile.get("is_active"), + date_joined=profile.get("date_joined"), + created_at=profile.get("created_at"), + updated_at=profile.get("updated_at"), ) ) diff --git a/backend/app/core/context.py b/backend/app/core/context.py index 09ebb80..5117368 100644 --- a/backend/app/core/context.py +++ b/backend/app/core/context.py @@ -3,17 +3,23 @@ from dataclasses import dataclass @dataclass(frozen=True) class UserContext: - """用户上下文:承载宿主系统传入的 user_id 和入口元数据。""" + """用户上下文:承载 Django 用户中心认证后的用户 ID 和入口元数据。""" user_id: str tenant_id: str | None = None role: str | None = None class_id: str | None = None + institution_id: int | None = None + department_id: int | None = None entry_scene: str | None = None request_id: str | None = None ip_address: str | None = None user_agent: str | None = None username: str | None = None display_name: str | None = None + phone: str | None = None + major: str | None = None + training_stage: str | None = None + learning_target: str | None = None auth_source: str = "django_user_center" profile: dict | None = None diff --git a/backend/app/core/user_context.py b/backend/app/core/user_context.py index aa4683a..a8f1a07 100644 --- a/backend/app/core/user_context.py +++ b/backend/app/core/user_context.py @@ -23,12 +23,18 @@ async def get_user_context( user_id=user.user_id, tenant_id=user.tenant_id, role=user.role, + institution_id=user.institution_id, + department_id=user.department_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"), username=user.username, display_name=user.display_name, + phone=user.phone, + major=user.major, + training_stage=user.training_stage, + learning_target=user.learning_target, auth_source=user.source, profile=user.profile, ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 608de2b..a8ed056 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -6,8 +6,8 @@ from app.models.knowledge import KnowledgeChunk, KnowledgeDocument, KnowledgeSou 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 +from app.models.training_record import TrainingRecord, TrainingScoreDetail +from app.models.user import User __all__ = [ "AuditLog", @@ -25,6 +25,6 @@ __all__ = [ "SessionSubmission", "TrainingSession", "TrainingRecord", + "TrainingScoreDetail", "User", - "UserLearningProfile", ] diff --git a/backend/app/models/audit.py b/backend/app/models/audit.py index 3ab433f..9736143 100644 --- a/backend/app/models/audit.py +++ b/backend/app/models/audit.py @@ -12,7 +12,7 @@ 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) + user_id: Mapped[str | None] = mapped_column(String(128), index=True, comment="Django用户中心ID") 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) diff --git a/backend/app/models/department.py b/backend/app/models/department.py index 20afdc0..7df4c39 100644 --- a/backend/app/models/department.py +++ b/backend/app/models/department.py @@ -1,22 +1,22 @@ from __future__ import annotations -from sqlalchemy import Boolean, ForeignKey, Integer, String -from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import BigInteger, Integer, String +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 Department(TimestampMixin, Base): - """科室模型:维护病例、知识库和评分规则的科室分类。""" + """科室模型:使用用户端确定的 department 表字段。""" - __tablename__ = "departments" + __tablename__ = "department" - 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) + id: Mapped[int] = mapped_column(BIGINT_PK, primary_key=True, autoincrement=True, comment="科室ID") + name: Mapped[str] = mapped_column(String(100), nullable=False, comment="科室名称") + category: Mapped[str] = mapped_column(String(50), nullable=False, comment="科室分类") + institution_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True, comment="所属机构ID") - parent: Mapped["Department | None"] = relationship(remote_side=[id]) + __table_args__ = {"comment": "科室表"} diff --git a/backend/app/models/knowledge.py b/backend/app/models/knowledge.py index 921e141..b304a45 100644 --- a/backend/app/models/knowledge.py +++ b/backend/app/models/knowledge.py @@ -29,7 +29,7 @@ class KnowledgeDocument(TimestampMixin, Base): 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) + department_id: Mapped[int | None] = mapped_column(ForeignKey("department.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) @@ -46,7 +46,7 @@ class KnowledgeChunk(Base): 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) + department_id: Mapped[int | None] = mapped_column(ForeignKey("department.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) diff --git a/backend/app/models/training.py b/backend/app/models/training.py index 43ee08c..9162785 100644 --- a/backend/app/models/training.py +++ b/backend/app/models/training.py @@ -10,13 +10,13 @@ BIGINT_PK = BigInteger().with_variant(Integer, "sqlite") class TrainingSession(TimestampMixin, Base): - """训练会话表:保存一次训练的运行状态、用户隔离信息和短期 memory key。""" + """训练会话表:保存一次训练的运行状态、Django 用户隔离信息和短期 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") + user_id: Mapped[str] = mapped_column("external_user_id", String(128), nullable=False, index=True, comment="Django用户中心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="入口场景") @@ -45,7 +45,7 @@ class SessionOrder(Base): 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") + user_id: Mapped[str] = mapped_column("external_user_id", String(128), nullable=False, index=True, comment="Django用户中心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="项目编码") @@ -74,7 +74,7 @@ class SessionSubmission(TimestampMixin, Base): 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") + user_id: Mapped[str] = mapped_column("external_user_id", String(128), nullable=False, index=True, comment="Django用户中心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="诊断依据") diff --git a/backend/app/models/training_record.py b/backend/app/models/training_record.py index 8132ce3..49d512d 100644 --- a/backend/app/models/training_record.py +++ b/backend/app/models/training_record.py @@ -3,8 +3,8 @@ 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 sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, JSON, Numeric, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.base import Base from app.models.mixins import TimestampMixin @@ -42,8 +42,8 @@ class TrainingRecord(TimestampMixin, Base): 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") + user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True, comment="Django用户中心数字ID") + external_user_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="Django用户中心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="分数类型") @@ -53,3 +53,25 @@ class TrainingRecord(TimestampMixin, Base): UniqueConstraint("session_id", name="uk_training_record_session"), {"comment": "训练记录表"}, ) + + score_details = relationship("TrainingScoreDetail", back_populates="record", cascade="all, delete-orphan") + + +class TrainingScoreDetail(TimestampMixin, Base): + """评分明细表:保存每条 scoring_rule 对应的 AI 评分、扣分原因、证据和置信度。""" + + __tablename__ = "training_score_detail" + + id: Mapped[int] = mapped_column(BIGINT_PK, primary_key=True, autoincrement=True, comment="评分明细ID") + record_id: Mapped[int] = mapped_column(ForeignKey("training_record.id"), nullable=False, index=True, comment="训练记录ID") + rule_id: Mapped[int | None] = mapped_column(ForeignKey("scoring_rule.id"), nullable=True, index=True, comment="评分规则ID") + dimension: Mapped[str] = mapped_column(String(50), nullable=False, index=True, comment="评分维度") + score: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0, comment="分数") + deducted_reason: Mapped[str | None] = mapped_column(Text, comment="扣分原因") + evidence_message_ids: Mapped[list] = mapped_column(JSON, nullable=False, default=list, comment="对应对话证据") + ai_confidence: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), comment="AI评分置信度") + comment: Mapped[str | None] = mapped_column(Text, comment="评语") + + record = relationship("TrainingRecord", back_populates="score_details") + + __table_args__ = {"comment": "评分明细表"} diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 4ee2a63..9564885 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,33 +1,46 @@ from datetime import datetime -from sqlalchemy import DateTime, Integer, JSON, Numeric, String +from sqlalchemy import BigInteger, Boolean, DateTime, Integer, JSON, SmallInteger, String 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 User(TimestampMixin, Base): - """宿主用户引用:保存外部 user_id,不承担登录注册职责。""" + """用户端用户表:按 Django 用户中心确定字段建模,只读取不承担登录注册职责。""" - __tablename__ = "users" + __tablename__ = "user" - 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) + id: Mapped[int] = mapped_column(BIGINT_PK, primary_key=True, autoincrement=True, comment="用户ID") + username: Mapped[str] = mapped_column(String(50), nullable=False, unique=True, index=True, comment="用户名") + password: Mapped[str] = mapped_column(String(255), nullable=False, comment="密码哈希") + real_name: Mapped[str] = mapped_column(String(50), nullable=False, comment="真实姓名") + phone: Mapped[str] = mapped_column(String(20), nullable=False, unique=True, index=True, comment="手机号") + avatar: Mapped[str] = mapped_column(String(255), nullable=False, default="", comment="头像") + gender: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0, comment="性别") + role_type: Mapped[str] = mapped_column(String(30), nullable=False, comment="角色类型") + title_name: Mapped[str] = mapped_column(String(50), nullable=False, default="", comment="职称") + major: Mapped[str] = mapped_column(String(100), nullable=False, default="", comment="专业") + training_stage: Mapped[str] = mapped_column(String(50), nullable=False, default="", comment="培训阶段") + learning_target: Mapped[str] = mapped_column(String(255), nullable=False, default="", comment="学习目标") + competency_profile: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict, comment="能力画像") + weak_dimensions: Mapped[list] = mapped_column(JSON, nullable=False, default=list, comment="薄弱维度") + strong_dimensions: Mapped[list] = mapped_column(JSON, nullable=False, default=list, comment="优势维度") + ai_preference: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict, comment="AI偏好") + total_training_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="训练次数") + total_case_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="病例数") + current_level: Mapped[str] = mapped_column(String(30), nullable=False, default="", comment="当前等级") + status: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=1, comment="状态") + last_login: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="最后登录") + last_login_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="最后登录时间") + is_superuser: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, comment="是否超级用户") + is_staff: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, comment="是否员工") + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, comment="是否激活") + date_joined: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="加入时间") + department_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True, comment="科室ID") + institution_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True, comment="机构ID") - -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) + __table_args__ = {"comment": "用户表"} diff --git a/backend/app/repositories/case_repository.py b/backend/app/repositories/case_repository.py index ca4b553..5e9be83 100644 --- a/backend/app/repositories/case_repository.py +++ b/backend/app/repositories/case_repository.py @@ -3,7 +3,7 @@ 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 +from app.models.training_record import TrainingRecord, TrainingScoreDetail class CaseRepository: @@ -64,6 +64,7 @@ class CaseRepository: "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_score_detail": self._count_score_details(case_id, session_ids), "training_record": self._count_training_records(case_id, session_ids), } @@ -75,6 +76,7 @@ class CaseRepository: deleted["training_submission"] = self._delete_by_sessions( SessionSubmission, SessionSubmission.session_id, session_ids ) + deleted["training_score_detail"] = self._delete_score_details(case_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) @@ -115,6 +117,13 @@ class CaseRepository: ) return self._count(TrainingRecord, TrainingRecord.case_id == case_id) + def _count_score_details(self, case_id: int, session_ids: list[int]) -> int: + """病例删除预览:统计该病例评价记录下的评分明细。""" + record_ids = self._record_ids(case_id, session_ids) + if not record_ids: + return 0 + return self._count(TrainingScoreDetail, TrainingScoreDetail.record_id.in_(record_ids)) + def _delete_where(self, model: type, *criteria) -> int: """病例删除执行:按条件删除单表记录并返回影响行数。""" result = self.db.execute(delete(model).where(*criteria)) @@ -144,6 +153,23 @@ class CaseRepository: ) return self._delete_where(TrainingRecord, TrainingRecord.case_id == case_id) + def _delete_score_details(self, case_id: int, session_ids: list[int]) -> int: + """病例删除执行:先删除评价明细,避免阻塞训练记录删除。""" + record_ids = self._record_ids(case_id, session_ids) + if not record_ids: + return 0 + return self._delete_where(TrainingScoreDetail, TrainingScoreDetail.record_id.in_(record_ids)) + + def _record_ids(self, case_id: int, session_ids: list[int]) -> list[int]: + """病例删除:读取该病例关联的训练记录 ID 集合。""" + if session_ids: + stmt = select(TrainingRecord.id).where( + or_(TrainingRecord.case_id == case_id, TrainingRecord.session_id.in_(session_ids)) + ) + else: + stmt = select(TrainingRecord.id).where(TrainingRecord.case_id == case_id) + return [int(item) for item in self.db.scalars(stmt).all()] + def get_exam_items(self, case_id: int) -> list[CaseExamItem]: """检查项目:读取当前病例下全部可申请检查检验项目。""" stmt = select(CaseExamItem).where(CaseExamItem.case_id == case_id).order_by(CaseExamItem.display_order) diff --git a/backend/app/repositories/evaluation_repository.py b/backend/app/repositories/evaluation_repository.py index 8206002..18eda20 100644 --- a/backend/app/repositories/evaluation_repository.py +++ b/backend/app/repositories/evaluation_repository.py @@ -1,7 +1,7 @@ -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.orm import Session -from app.models.training_record import TrainingRecord +from app.models.training_record import TrainingRecord, TrainingScoreDetail class EvaluationRepository: @@ -16,6 +16,20 @@ class EvaluationRepository: self.db.flush() return record + def replace_score_details(self, record_id: int, details: list[TrainingScoreDetail]) -> list[TrainingScoreDetail]: + """评分明细保存:按训练记录覆盖写入维度评分明细。""" + self.db.execute(delete(TrainingScoreDetail).where(TrainingScoreDetail.record_id == record_id)) + for detail in details: + detail.record_id = record_id + self.db.add(detail) + self.db.flush() + return details + + def list_score_details(self, record_id: int) -> list[TrainingScoreDetail]: + """评分明细读取:按训练记录查询全部维度明细。""" + stmt = select(TrainingScoreDetail).where(TrainingScoreDetail.record_id == record_id).order_by(TrainingScoreDetail.id) + return list(self.db.scalars(stmt).all()) + def get_by_session(self, session_id: int, user_id: str) -> TrainingRecord | None: """评价读取:按会话 ID 和外部 user_id 查询训练记录。""" stmt = select(TrainingRecord).where( diff --git a/backend/app/repositories/profile_repository.py b/backend/app/repositories/profile_repository.py deleted file mode 100644 index 862a7d7..0000000 --- a/backend/app/repositories/profile_repository.py +++ /dev/null @@ -1,25 +0,0 @@ -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/source_case_repository.py b/backend/app/repositories/source_case_repository.py index f8ea80f..428fa3a 100644 --- a/backend/app/repositories/source_case_repository.py +++ b/backend/app/repositories/source_case_repository.py @@ -50,7 +50,7 @@ class SourceCaseRepository: return self.db.scalar(stmt) def get_department_name(self, department_id: int | None) -> str: - """科室名称:兼容当前 demo 的 departments 表,源库无科室表时返回空字符串。""" + """科室名称:按用户端 department 表读取科室名称。""" if not department_id: return "" department = self.db.scalar(select(Department).where(Department.id == department_id)) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 5962818..51a3347 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -16,8 +16,10 @@ class AuthMeResponse(BaseModel): avatar: str | None = None gender: int | None = None institution: int | None = None + institution_id: int | None = None institution_name: str | None = None department: int | None = None + department_id: int | None = None department_name: str | None = None title_name: str | None = None major: str | None = None @@ -31,3 +33,11 @@ class AuthMeResponse(BaseModel): total_case_count: int | None = None current_level: str | None = None status: int | None = None + last_login: str | None = None + last_login_time: str | None = None + is_superuser: bool | None = None + is_staff: bool | None = None + is_active: bool | None = None + date_joined: str | None = None + created_at: str | None = None + updated_at: str | None = None diff --git a/backend/app/schemas/evaluation.py b/backend/app/schemas/evaluation.py index cb61bf7..8779328 100644 --- a/backend/app/schemas/evaluation.py +++ b/backend/app/schemas/evaluation.py @@ -21,6 +21,20 @@ class DimensionScore(BaseModel): improvement: str = "" +class ScoreDetailItem(BaseModel): + """评分明细:对应 training_score_detail 的单条评分细则。""" + + id: int | None = None + record_id: int | None = None + rule_id: int | None = None + dimension: str + score: float | None = None + deducted_reason: str | None = None + evidence_message_ids: list = Field(default_factory=list) + ai_confidence: float | None = None + comment: str | None = None + + class EvaluationResponse(BaseModel): """评价报告响应:返回结构化 AI 评价报告。""" @@ -28,6 +42,7 @@ class EvaluationResponse(BaseModel): score_type: str total_score: float dimension_scores: list[DimensionScore] + score_details: list[ScoreDetailItem] = Field(default_factory=list) errors: list[dict] improvement_plan: list[str] evidence_summary: list[str] diff --git a/backend/app/services/evaluation_service.py b/backend/app/services/evaluation_service.py index f8968c9..a19b763 100644 --- a/backend/app/services/evaluation_service.py +++ b/backend/app/services/evaluation_service.py @@ -1,16 +1,15 @@ import json from datetime import datetime +from decimal import Decimal 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.models.training_record import TrainingRecord, TrainingScoreDetail 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 ( @@ -20,6 +19,7 @@ from app.schemas.evaluation import ( EvaluationListItem, EvaluationListResponse, EvaluationResponse, + ScoreDetailItem, ) from app.services.audit_service import AuditService from app.services.knowledge_service import KnowledgeService @@ -27,7 +27,7 @@ from app.services.runtime_memory import runtime_memory class EvaluationService: - """评价服务:基于新源库表和 training_record 完成评分、历史和学习档案更新。""" + """评价服务:基于病例、评分规则和作答过程生成 training_record 与评分明细。""" def __init__(self, db: Session) -> None: self.db = db @@ -35,7 +35,6 @@ class EvaluationService: 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() @@ -79,9 +78,9 @@ class EvaluationService: record = self._build_training_record(ctx, session, case, submission, report, scoring_rules, guideline_result) self.eval_repo.create_record(record) + self.eval_repo.replace_score_details(record.id, self._build_score_details(record.id, report, scoring_rules)) 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) @@ -104,6 +103,7 @@ class EvaluationService: "score_type": report.get("score_type", session.score_type), "total_score": total_score, "dimension_scores": report.get("dimension_scores") or [], + "score_details": report.get("score_details") or [], "errors": report.get("errors") or [], "improvement_plan": report.get("improvement_plan") or [], "evidence_summary": report.get("evidence_summary") or [], @@ -160,6 +160,61 @@ class EvaluationService: pdf_file_path=None, ) + def _build_score_details(self, record_id: int, report: dict, scoring_rules: list) -> list[TrainingScoreDetail]: + """评分明细写入:把 LLM 结构化评分结果映射到 training_score_detail。""" + raw_items = report.get("score_details") or report.get("dimension_scores") or [] + rule_map = self._rule_map(scoring_rules) + details: list[TrainingScoreDetail] = [] + for item in raw_items: + if not isinstance(item, dict): + continue + dimension = str(item.get("dimension") or "综合表现") + matched_rule = self._match_rule(item, dimension, rule_map) + deducted_reason = item.get("deducted_reason") + if not deducted_reason: + deducted_reason = ";".join(str(value) for value in (item.get("deductions") or []) if value) + evidence = item.get("evidence_message_ids") + if evidence is None: + evidence = item.get("evidence") or [] + details.append( + TrainingScoreDetail( + record_id=record_id, + rule_id=int(item.get("rule_id") or matched_rule.id) if matched_rule else None, + dimension=dimension[:50], + score=self._decimal_or_none(item.get("score")), + deducted_reason=deducted_reason or "", + evidence_message_ids=evidence if isinstance(evidence, list) else [evidence], + ai_confidence=self._decimal_or_none(item.get("ai_confidence") or 0.85), + comment=item.get("comment") or item.get("improvement") or "", + ) + ) + return details + + def _rule_map(self, scoring_rules: list) -> dict[str, object]: + """评分规则映射:按维度和能力维度建立匹配索引。""" + result = {} + for rule in scoring_rules: + for key in (getattr(rule, "dimension", ""), getattr(rule, "competency_dimension", "")): + if key: + result[str(key).strip()] = rule + return result + + def _match_rule(self, item: dict, dimension: str, rule_map: dict[str, object]): + """评分规则匹配:优先按 rule_id,其次按维度文本匹配 scoring_rule。""" + rule_id = item.get("rule_id") + if rule_id: + for rule in rule_map.values(): + if getattr(rule, "id", None) == rule_id: + return rule + return rule_map.get(dimension) or rule_map.get(str(item.get("competency_dimension") or "").strip()) + + def _decimal_or_none(self, value: object) -> Decimal | None: + """分数转换:将 LLM 返回值转换为 Decimal,异常时置空。""" + try: + return Decimal(str(value)) + except Exception: + return None + def _evaluation_level(self, score: float, score_type: str) -> str: """评价等级:根据百分制或五分制总分生成训练记录等级。""" normalized = score * 20 if score_type == "five_point" else score @@ -177,11 +232,11 @@ class EvaluationService: 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。""" + """用户 ID 兼容:Django 返回的 id 写入 external_user_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。""" + """历史评价:按 Django 用户中心 ID 查询完整训练后的 training_record。""" records = self.eval_repo.list_by_user(user_id) return EvaluationListResponse( items=[ @@ -198,7 +253,7 @@ class EvaluationService: ) def get_detail(self, evaluation_id: int, user_id: str) -> EvaluationDetailResponse: - """评价详情:按 user_id 校验归属并返回完整报告。""" + """评价详情:按 Django 用户中心 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) @@ -221,6 +276,7 @@ class EvaluationService: 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], + score_details=self._score_detail_response(record), 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 [], @@ -228,25 +284,38 @@ class EvaluationService: 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 _score_detail_response(self, record: TrainingRecord) -> list[ScoreDetailItem]: + """评分明细响应:优先读取 training_score_detail,旧记录回退到结构化维度评分。""" + details = self.eval_repo.list_score_details(record.id) + if details: + return [ + ScoreDetailItem( + id=item.id, + record_id=item.record_id, + rule_id=item.rule_id, + dimension=item.dimension, + score=float(item.score) if item.score is not None else None, + deducted_reason=item.deducted_reason, + evidence_message_ids=item.evidence_message_ids or [], + ai_confidence=float(item.ai_confidence) if item.ai_confidence is not None else None, + comment=item.comment, + ) + for item in details + ] + structured = record.ai_feedback_structured or {} + return [ + ScoreDetailItem( + record_id=record.id, + dimension=item.get("dimension", "综合表现"), + score=float(item.get("score") or 0), + deducted_reason=";".join(str(value) for value in item.get("deductions", []) if value), + evidence_message_ids=item.get("evidence") or [], + ai_confidence=None, + comment=item.get("comment") or "", + ) + for item in structured.get("dimension_scores") or [] + if isinstance(item, dict) + ] def _case_title(self, case_id: int | None) -> str: """病例标题:历史记录只保存 case_id,展示时按新病例主表读取标题。""" diff --git a/backend/app/services/external_auth_service.py b/backend/app/services/external_auth_service.py index 4807c2a..8c4124c 100644 --- a/backend/app/services/external_auth_service.py +++ b/backend/app/services/external_auth_service.py @@ -100,7 +100,7 @@ class ExternalAuthService: username = self._first_present(data, ["username", "account", "mobile", "phone"]) display_name = self._first_present(data, ["display_name", "name", "nickname", "real_name"]) role = self._first_present(data, ["role_type", "role", "user_role"]) - institution_id = self._to_int(data.get("institution")) + institution_id = self._to_int(data.get("institution_id") or data.get("institution")) tenant_id = str(institution_id) if institution_id is not None else None profile = self._build_profile(data) return AuthenticatedUser( @@ -114,7 +114,7 @@ class ExternalAuthService: gender=self._to_int(data.get("gender")), institution_id=institution_id, institution_name=self._to_str(data.get("institution_name")), - department_id=self._to_int(data.get("department")), + department_id=self._to_int(data.get("department_id") or data.get("department")), department_name=self._to_str(data.get("department_name")), title_name=self._to_str(data.get("title_name")), major=self._to_str(data.get("major")), @@ -136,8 +136,10 @@ class ExternalAuthService: "gender", "role_type", "institution", + "institution_id", "institution_name", "department", + "department_id", "department_name", "title_name", "major", @@ -151,7 +153,12 @@ class ExternalAuthService: "total_case_count", "current_level", "status", + "last_login", "last_login_time", + "is_superuser", + "is_staff", + "is_active", + "date_joined", "created_at", "updated_at", ] diff --git a/backend/app/services/pdf_export_service.py b/backend/app/services/pdf_export_service.py index f87c5c4..ba65c2c 100644 --- a/backend/app/services/pdf_export_service.py +++ b/backend/app/services/pdf_export_service.py @@ -35,7 +35,8 @@ class PdfExportService: 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) + score_details = self.repo.list_score_details(record.id) + self._write_pdf(file_path, record, session, score_details) record.pdf_file_path = str(file_path) recommendation = dict(record.recommendation_result or {}) @@ -57,7 +58,7 @@ class PdfExportService: ) return self.db.scalar(stmt) - def _write_pdf(self, file_path: Path, record: TrainingRecord, session: TrainingSession | None) -> None: + def _write_pdf(self, file_path: Path, record: TrainingRecord, session: TrainingSession | None, score_details: list | None = None) -> None: """PDF 写入:使用 reportlab 生成 Acrobat 可正常打开的标准 PDF。""" try: from reportlab.lib import colors @@ -133,7 +134,7 @@ class PdfExportService: bottomMargin=14 * mm, title="医疗问诊 Agent 训练评价报告", ) - doc.build(self._build_story(record, session, context)) + doc.build(self._build_story(record, session, context, score_details or [])) except ModuleNotFoundError: self._write_minimal_pdf(file_path, record) except Exception as exc: @@ -184,7 +185,7 @@ class PdfExportService: ) file_path.write_bytes(bytes(content)) - def _build_story(self, record: TrainingRecord, session: TrainingSession | None, context: dict[str, Any]) -> list: + def _build_story(self, record: TrainingRecord, session: TrainingSession | None, context: dict[str, Any], score_details: list | None = None) -> list: """报告模板:按基本信息、病例、提交、检查、评分细则和改进计划组织内容。""" Paragraph = context["Paragraph"] Spacer = context["Spacer"] @@ -220,7 +221,7 @@ class PdfExportService: 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_dimension_section(story, score_details or 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) @@ -284,17 +285,49 @@ class PdfExportService: if not dimensions: story.append(context["Paragraph"]("暂无维度评分。", context["styles"]["body"])) return - rows = [["维度", "得分", "评价"]] + 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) + payload = self._score_detail_payload(item) + rows.append([ + payload["dimension"], + payload["score_text"], + payload["deducted_reason"], + payload["ai_confidence"], + payload["comment"], + ]) + self._append_table(story, rows, [70, 45, 160, 45, 165], context) for index, item in enumerate(dimensions, start=1): - title = f"{index}. {item.get('dimension', '未命名维度')}:{item.get('score', 0)} / {item.get('max_score', '-')}" + payload = self._score_detail_payload(item) + title = f"{index}. {payload['dimension']}:{payload['score_text']}" 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"])) + self._append_list_section(story, "证据", payload["evidence"], context, compact=True) + self._append_list_section(story, "扣分原因", [payload["deducted_reason"]] if payload["deducted_reason"] else [], context, compact=True) + if payload["comment"]: + story.append(context["Paragraph"](f"评语:{self._safe_text(payload['comment'])}", context["styles"]["small"])) + + def _score_detail_payload(self, item: Any) -> dict[str, Any]: + """评分明细展示:兼容 training_score_detail ORM 和旧 dimension_scores 字典。""" + if isinstance(item, dict): + score = item.get("score", 0) + max_score = item.get("max_score") + return { + "dimension": item.get("dimension", "未命名维度"), + "score_text": f"{score} / {max_score}" if max_score is not None else str(score), + "deducted_reason": item.get("deducted_reason") or ";".join(str(value) for value in item.get("deductions", []) if value), + "ai_confidence": item.get("ai_confidence") or "未记录", + "comment": item.get("comment") or item.get("improvement") or "", + "evidence": item.get("evidence_message_ids") or item.get("evidence") or [], + } + score = getattr(item, "score", None) + confidence = getattr(item, "ai_confidence", None) + return { + "dimension": getattr(item, "dimension", "未命名维度"), + "score_text": f"{float(score):g}" if score is not None else "未记录", + "deducted_reason": getattr(item, "deducted_reason", "") or "", + "ai_confidence": f"{float(confidence):g}" if confidence is not None else "未记录", + "comment": getattr(item, "comment", "") or "", + "evidence": getattr(item, "evidence_message_ids", None) or [], + } def _append_error_section(self, story: list, errors: list, context: dict[str, Any]) -> None: """扣分问题分节:展示模型识别出的关键问题和严重程度。""" diff --git a/backend/scripts/check_final_demo_readiness.py b/backend/scripts/check_final_demo_readiness.py new file mode 100644 index 0000000..71bfee3 --- /dev/null +++ b/backend/scripts/check_final_demo_readiness.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +from sqlalchemy import text +from sqlalchemy.exc import SQLAlchemyError + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app.core.config import settings +from app.db.session import SessionLocal +from scripts.check_final_schema import build_schema_report + + +COUNT_SQL = { + "departments": "SELECT COUNT(*) FROM department", + "active_cases": "SELECT COUNT(*) FROM case_base WHERE status = 1 AND publish_status = 1", + "traditional_cases": "SELECT COUNT(*) FROM traditional_case", + "teaching_cases": "SELECT COUNT(*) FROM teaching_case", + "exam_items": "SELECT COUNT(*) FROM case_exam_item", + "scoring_rules": "SELECT COUNT(*) FROM scoring_rule", + "active_prompt_templates": "SELECT COUNT(*) FROM prompt_templates WHERE is_active = 1", + "knowledge_sources": "SELECT COUNT(*) FROM knowledge_sources", + "knowledge_documents": "SELECT COUNT(*) FROM knowledge_documents", + "knowledge_chunks": "SELECT COUNT(*) FROM knowledge_chunks", +} + + +def main() -> None: + """最终就绪检查:校验当前 Demo 功能运行所需的结构、基础数据、提示词和配置。""" + try: + report = build_readiness_report() + except SQLAlchemyError as exc: + print( + json.dumps( + { + "ready": False, + "error": "database operation failed", + "detail": str(exc).splitlines()[0], + }, + ensure_ascii=False, + indent=2, + ) + ) + raise SystemExit(2) from exc + + print(json.dumps(report, ensure_ascii=False, indent=2)) + if not report["summary"]["ready"]: + raise SystemExit(1) + + +def build_readiness_report() -> dict[str, Any]: + """就绪报告:聚合数据库结构、基础业务数据、提示词文件和关键环境配置。""" + schema_report = build_schema_report() + counts = _collect_counts() + prompt_files = _prompt_files() + config = _public_config() + + critical_checks = { + "schema_complete": schema_report["summary"]["can_run_demo"], + "has_active_cases": counts["active_cases"] > 0, + "has_department": counts["departments"] > 0, + "has_case_detail": counts["traditional_cases"] + counts["teaching_cases"] > 0, + "has_exam_items": counts["exam_items"] > 0, + "has_scoring_rules": counts["scoring_rules"] > 0, + "has_prompt_templates": counts["active_prompt_templates"] > 0, + "has_prompt_files": len(prompt_files) > 0, + "auth_user_center_configured": bool(settings.auth_user_me_url), + } + warnings = [] + if not settings.llm_api_key: + warnings.append("LLM_API_KEY is not configured; real LLM calls will not work unless mock is enabled.") + if counts["knowledge_chunks"] == 0: + warnings.append("knowledge_chunks is empty; scoring can run but guideline reference retrieval has no data.") + missing_indexes = schema_report["summary"].get("missing_indexes") or [] + if missing_indexes: + warnings.append("Some recommended indexes are missing; functions can run, but high-concurrency query performance may be affected.") + + return { + "summary": { + "ready": all(critical_checks.values()), + "critical_checks": critical_checks, + "warnings": warnings, + }, + "database": { + "dialect": schema_report["database_dialect"], + "counts": counts, + "schema_summary": schema_report["summary"], + }, + "prompts": { + "markdown_count": len(prompt_files), + "files": prompt_files, + }, + "config": config, + } + + +def _collect_counts() -> dict[str, int]: + """数据计数:统计 Demo 闭环运行依赖的基础数据。""" + with SessionLocal() as db: + return {name: int(db.execute(text(sql)).scalar() or 0) for name, sql in COUNT_SQL.items()} + + +def _prompt_files() -> list[str]: + """提示词检查:读取 prompts 目录下所有 Markdown 模板。""" + prompt_root = Path(__file__).resolve().parents[1] / "app" / "prompts" + return sorted(str(path.relative_to(prompt_root)).replace("\\", "/") for path in prompt_root.rglob("*.md")) + + +def _public_config() -> dict[str, Any]: + """配置摘要:只输出可公开的配置状态,不暴露密钥。""" + return { + "auth_validate_enabled": settings.auth_validate_enabled, + "auth_user_me_url_configured": bool(settings.auth_user_me_url), + "llm_base_url_configured": bool(settings.llm_base_url), + "llm_model": settings.llm_model, + "llm_fast_model": settings.llm_fast_model, + "llm_reason_model": settings.llm_reason_model, + "llm_api_key_configured": bool(settings.llm_api_key), + "llm_mock_enabled": settings.llm_mock_enabled, + "runtime_memory_backend": settings.runtime_memory_backend, + "redis_url_configured": bool(settings.redis_url), + } + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/check_final_schema.py b/backend/scripts/check_final_schema.py new file mode 100644 index 0000000..2d650c9 --- /dev/null +++ b/backend/scripts/check_final_schema.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import json +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from sqlalchemy import inspect +from sqlalchemy.exc import SQLAlchemyError + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app.db.session import engine + + +@dataclass(frozen=True) +class TableSpec: + """最终表结构规格:定义当前医疗问诊 Agent 完整功能所需的表、字段和关键索引。""" + + columns: tuple[str, ...] + indexed_columns: tuple[str, ...] = () + + +REQUIRED_SCHEMA: dict[str, TableSpec] = { + "user": TableSpec( + columns=( + "id", + "username", + "real_name", + "phone", + "role_type", + "department_id", + "institution_id", + "competency_profile", + "weak_dimensions", + "strong_dimensions", + "ai_preference", + "total_training_count", + "total_case_count", + "current_level", + "status", + ), + indexed_columns=("id", "username", "phone", "department_id", "institution_id"), + ), + "department": TableSpec( + columns=("id", "name", "category", "institution_id", "created_at", "updated_at"), + indexed_columns=("id", "institution_id"), + ), + "case_base": TableSpec( + columns=( + "id", + "title", + "case_type", + "difficulty", + "chief_complaint", + "description", + "patient_age", + "patient_gender", + "publish_status", + "status", + "department_id", + ), + indexed_columns=("id", "case_type", "difficulty", "publish_status", "status", "department_id"), + ), + "traditional_case": TableSpec( + columns=("id", "case_id", "standard_diagnosis", "standard_treatment", "guideline_reference"), + indexed_columns=("id", "case_id"), + ), + "teaching_case": TableSpec( + columns=("id", "case_id", "teaching_goal", "discussion_questions", "teacher_guide", "scoring_focus"), + indexed_columns=("id", "case_id"), + ), + "scoring_rule": TableSpec( + columns=("id", "case_id", "dimension", "competency_dimension", "score_weight", "scoring_standard", "rubric_json"), + indexed_columns=("id", "case_id", "dimension", "competency_dimension"), + ), + "case_exam_item": TableSpec( + columns=("id", "case_id", "item_code", "item_name", "item_type", "result_text", "is_key", "is_abnormal"), + indexed_columns=("id", "case_id", "item_code", "item_type"), + ), + "training_session": TableSpec( + columns=( + "id", + "session_code", + "external_user_id", + "case_id", + "case_type", + "training_mode", + "score_type", + "status", + "memory_key", + ), + indexed_columns=("id", "session_code", "external_user_id", "case_id", "training_mode", "status"), + ), + "training_order": TableSpec( + columns=("id", "session_id", "external_user_id", "case_id", "exam_item_id", "item_code", "result_text", "ordered_at"), + indexed_columns=("id", "session_id", "external_user_id", "case_id"), + ), + "training_submission": TableSpec( + columns=("id", "session_id", "external_user_id", "primary_diagnosis", "treatment_measures"), + indexed_columns=("id", "external_user_id"), + ), + "training_record": TableSpec( + columns=( + "id", + "user_id", + "external_user_id", + "session_id", + "case_id", + "total_score", + "ai_feedback_structured", + "pdf_file_path", + ), + indexed_columns=("id", "user_id", "external_user_id", "session_id", "case_id"), + ), + "training_score_detail": TableSpec( + columns=( + "id", + "record_id", + "rule_id", + "dimension", + "score", + "deducted_reason", + "evidence_message_ids", + "ai_confidence", + "comment", + "created_at", + "updated_at", + ), + indexed_columns=("id", "record_id", "rule_id", "dimension"), + ), + "prompt_templates": TableSpec( + columns=("id", "template_code", "agent_type", "scene", "version_no", "model_type", "output_format", "file_path", "is_active"), + indexed_columns=("id", "template_code", "agent_type", "scene"), + ), + "knowledge_sources": TableSpec( + columns=("id", "source_code", "source_name", "source_type", "authority_level", "is_active"), + indexed_columns=("id", "source_code", "source_type"), + ), + "knowledge_documents": TableSpec( + columns=("id", "source_id", "department_id", "title", "task_type", "file_path", "is_active"), + indexed_columns=("id", "source_id", "department_id", "task_type"), + ), + "knowledge_chunks": TableSpec( + columns=("id", "document_id", "department_id", "task_type", "chunk_text", "keywords", "weight", "is_active"), + indexed_columns=("id", "document_id", "department_id", "task_type"), + ), + "audit_logs": TableSpec( + columns=("id", "user_id", "tenant_id", "session_id", "action", "resource_type", "request_id", "created_at"), + indexed_columns=("id", "user_id", "session_id", "action", "created_at"), + ), +} + + +def main() -> None: + """最终结构检查:只读校验当前 Agent 完整功能所需数据库结构。""" + try: + report = build_schema_report() + except SQLAlchemyError as exc: + print( + json.dumps( + { + "database_dialect": engine.dialect.name, + "summary": { + "can_run_demo": False, + "database_available": False, + "error": "database connection failed", + "detail": str(exc).splitlines()[0], + }, + }, + ensure_ascii=False, + indent=2, + ) + ) + raise SystemExit(2) from exc + print(json.dumps(report, ensure_ascii=False, indent=2)) + if not report["summary"]["can_run_demo"]: + raise SystemExit(1) + + +def build_schema_report() -> dict[str, Any]: + """结构报告:检查必需表、字段和关键索引,不修改数据库。""" + inspector = inspect(engine) + existing_tables = set(inspector.get_table_names()) + tables: dict[str, Any] = {} + missing_tables: list[str] = [] + missing_columns: list[str] = [] + missing_indexes: list[str] = [] + + for table_name, spec in REQUIRED_SCHEMA.items(): + if table_name not in existing_tables: + missing_tables.append(table_name) + tables[table_name] = {"exists": False} + continue + + actual_columns = {column["name"] for column in inspector.get_columns(table_name)} + actual_indexes = _indexed_columns(inspector, table_name) + table_missing_columns = [name for name in spec.columns if name not in actual_columns] + table_missing_indexes = [name for name in spec.indexed_columns if name not in actual_indexes] + + missing_columns.extend(f"{table_name}.{column_name}" for column_name in table_missing_columns) + missing_indexes.extend(f"{table_name}.{column_name}" for column_name in table_missing_indexes) + + tables[table_name] = { + "exists": True, + "missing_columns": table_missing_columns, + "missing_indexes": table_missing_indexes, + } + + can_run_demo = not missing_tables and not missing_columns + return { + "database_dialect": engine.dialect.name, + "identity_rule": "Authorization -> Django /api/user/users/me/ -> data.id -> user isolation fields", + "tables": tables, + "summary": { + "checked_tables": len(REQUIRED_SCHEMA), + "missing_tables": missing_tables, + "missing_columns": missing_columns, + "missing_indexes": missing_indexes, + "index_warning": bool(missing_indexes), + "can_run_demo": can_run_demo, + }, + } + + +def _indexed_columns(inspector, table_name: str) -> set[str]: + """索引读取:汇总普通索引、唯一索引和主键覆盖的字段。""" + indexed: set[str] = set() + primary_key = inspector.get_pk_constraint(table_name) or {} + indexed.update(primary_key.get("constrained_columns") or []) + for index in inspector.get_indexes(table_name): + indexed.update(index.get("column_names") or []) + for unique in inspector.get_unique_constraints(table_name): + indexed.update(unique.get("column_names") or []) + return indexed + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/clear_training_runtime_data.py b/backend/scripts/clear_training_runtime_data.py new file mode 100644 index 0000000..5fb7f3c --- /dev/null +++ b/backend/scripts/clear_training_runtime_data.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +from sqlalchemy import text + +from app.core.config import settings +from app.db.session import SessionLocal + + +TRAINING_TABLES = ( + "training_score_detail", + "training_record", + "training_submission", + "training_order", + "training_session", + "audit_logs", +) + + +def clear_training_runtime_data(clear_reports: bool = False) -> dict: + """训练数据清理:只清空训练运行表和本地报告文件,不删除病例、用户、评分规则和知识库。""" + with SessionLocal() as db: + before = {table: _count(db, table) for table in TRAINING_TABLES} + for table in TRAINING_TABLES: + db.execute(text(f"DELETE FROM {table}")) + for table in TRAINING_TABLES: + db.execute(text(f"ALTER TABLE {table} AUTO_INCREMENT = 1")) + db.commit() + after = {table: _count(db, table) for table in TRAINING_TABLES} + + deleted_reports = _clear_reports() if clear_reports else 0 + return { + "database": settings.database_url.split("@")[-1] if "@" in settings.database_url else settings.database_url, + "tables_before": before, + "tables_after": after, + "deleted_report_files": deleted_reports, + } + + +def _count(db, table: str) -> int: + """数据计数:读取目标表当前行数,用于清理前后核对。""" + return int(db.execute(text(f"SELECT COUNT(*) FROM {table}")).scalar() or 0) + + +def _clear_reports() -> int: + """报告清理:只删除 backend/storage/reports 下的文件,保留目录本身。""" + report_dir = Path(settings.report_storage_dir) + if not report_dir.is_absolute(): + report_dir = Path(__file__).resolve().parents[1] / report_dir + expected_root = Path(__file__).resolve().parents[1] / "storage" / "reports" + report_dir = report_dir.resolve() + if report_dir != expected_root.resolve(): + raise RuntimeError(f"refuse to clear unexpected report directory: {report_dir}") + report_dir.mkdir(parents=True, exist_ok=True) + files = [path for path in report_dir.iterdir() if path.is_file()] + for path in files: + path.unlink() + return len(files) + + +def main() -> None: + """命令入口:要求显式确认后才执行训练数据清理。""" + parser = argparse.ArgumentParser(description="Clear training runtime data only.") + parser.add_argument("--confirm", required=True, help="Must be CLEAR_TRAINING_DATA") + parser.add_argument("--reports", action="store_true", help="Also clear local generated PDF reports") + args = parser.parse_args() + if args.confirm != "CLEAR_TRAINING_DATA": + raise SystemExit("confirmation mismatch; use --confirm CLEAR_TRAINING_DATA") + print(clear_training_runtime_data(clear_reports=args.reports)) + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/init_demo_db.py b/backend/scripts/init_demo_db.py index 78caa41..63872d7 100644 --- a/backend/scripts/init_demo_db.py +++ b/backend/scripts/init_demo_db.py @@ -20,7 +20,6 @@ from app.models import ( ScoringRule, TeachingCase, TraditionalCase, - User, ) @@ -35,8 +34,7 @@ def init_database() -> None: 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) + case = _get_or_create_case_base(db, department.id) _seed_traditional_case(db, case.id) _seed_teaching_case(db, case.id) _seed_exam_items(db, case.id) @@ -47,27 +45,16 @@ def seed_demo_data(db) -> None: def _get_or_create_department(db) -> Department: """科室种子:写入儿科科室。""" - department = db.scalar(select(Department).where(Department.code == "PEDIATRICS")) + department = db.scalar(select(Department).where(Department.name == "儿科")) if department: return department - department = Department(name="儿科", code="PEDIATRICS", sort_order=1, is_active=True) + department = Department(name="儿科", category="clinical", institution_id=1) 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: +def _get_or_create_case_base(db, department_id: int) -> CaseBase: """病例主表种子:以 case_base 作为病例唯一主表。""" case = db.scalar(select(CaseBase).where(CaseBase.title == "支气管肺炎 - 6岁男性患儿")) if case: @@ -99,7 +86,7 @@ def _get_or_create_case_base(db, department_id: int, user_id: int) -> CaseBase: vector_status=0, publish_status=1, status=1, - created_by_id=user_id, + created_by_id=None, department_id=department_id, ) db.add(case) diff --git a/backend/scripts/migrate_to_new_schema.py b/backend/scripts/migrate_to_new_schema.py index 52d7b57..deb26cb 100644 --- a/backend/scripts/migrate_to_new_schema.py +++ b/backend/scripts/migrate_to_new_schema.py @@ -32,6 +32,9 @@ def _apply_table_comments(db) -> None: "training_order": "训练检查申请表", "training_submission": "训练诊断治疗提交表", "training_record": "训练记录表", + "training_score_detail": "评分明细表", + "department": "科室表", + "user": "用户表", } dialect = db.bind.dialect.name if db.bind else "" if dialect != "mysql": diff --git a/backend/scripts/migrate_user_department_score_detail.py b/backend/scripts/migrate_user_department_score_detail.py new file mode 100644 index 0000000..2e20f67 --- /dev/null +++ b/backend/scripts/migrate_user_department_score_detail.py @@ -0,0 +1,65 @@ +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.base import Base +from app.db.session import SessionLocal, engine +from app.models import Department, TrainingScoreDetail, User # noqa: F401 + + +def main() -> None: + """结构迁移:创建用户端 user/department 表模型和 training_score_detail,不删除旧表。""" + Base.metadata.create_all(bind=engine) + with SessionLocal() as db: + _copy_old_departments(db) + _apply_table_comments(db) + db.commit() + print("user/department/score detail migration completed") + + +def _copy_old_departments(db) -> None: + """科室迁移:如果旧 departments 表存在,则复制到新 department 表并保留原 id。""" + inspector = inspect(engine) + tables = set(inspector.get_table_names()) + if "departments" not in tables or "department" not in tables: + return + count = int(db.execute(text("SELECT COUNT(*) FROM `department`")).scalar() or 0) + if count > 0: + return + db.execute( + text( + """ + INSERT INTO `department` (`id`, `name`, `category`, `institution_id`, `created_at`, `updated_at`) + SELECT + `id`, + `name`, + 'clinical', + 1, + `created_at`, + `updated_at` + FROM `departments` + """ + ) + ) + + +def _apply_table_comments(db) -> None: + """表注释补齐:为新增或调整后的表写入中文说明。""" + if db.bind.dialect.name != "mysql": + return + comments = { + "user": "用户表", + "department": "科室表", + "training_score_detail": "评分明细表", + } + for table_name, comment in comments.items(): + db.execute(text(f"ALTER TABLE `{table_name}` COMMENT='{comment}'")) + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_api_contract.py b/backend/tests/test_api_contract.py index f3390a1..ba5c443 100644 --- a/backend/tests/test_api_contract.py +++ b/backend/tests/test_api_contract.py @@ -75,6 +75,7 @@ def run_api_contract_tests() -> None: assert auth_me.json()["data"]["user_id"] == "api_user_001" assert auth_me.json()["data"]["source"] == "django_user_center" assert auth_me.json()["data"]["display_name"] == "Swagger测试" + assert "department_id" in auth_me.json()["data"] openapi = client.get("/openapi.json") assert openapi.status_code == 200 diff --git a/backend/tests/test_demo_flow.py b/backend/tests/test_demo_flow.py index f5cd438..204ef4f 100644 --- a/backend/tests/test_demo_flow.py +++ b/backend/tests/test_demo_flow.py @@ -8,6 +8,8 @@ os.environ.setdefault("RUNTIME_MEMORY_BACKEND", "memory") os.environ.setdefault("LLM_MOCK_ENABLED", "true") sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +if os.getenv("DATABASE_URL") == "sqlite:///./storage/test_demo_flow.db": + Path("storage/test_demo_flow.db").unlink(missing_ok=True) from sqlalchemy import select @@ -15,7 +17,7 @@ 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.models.training_record import TrainingRecord, TrainingScoreDetail from app.schemas.evaluation import CreateEvaluationRequest from app.schemas.session import ( ChatRequest, @@ -130,9 +132,13 @@ async def run_demo_flow() -> None: ) db.commit() assert evaluation.total_score > 0 + assert evaluation.score_details 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 + score_details = list(db.scalars(select(TrainingScoreDetail).where(TrainingScoreDetail.record_id == training_record.id)).all()) + assert score_details + assert score_details[0].dimension export = pdf_service.export(evaluation.evaluation_id, ctx.user_id) db.commit()