feat: update cms case api
This commit is contained in:
@@ -0,0 +1,515 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
CMS 病例库 + AI 病例生成 + 病例审核 — Swagger Try-it-out 等效脚本(真实 HTTP)。
|
||||
|
||||
覆盖 CSV 行 80~98(超级管理员/内容管理员/医院管理员 各角色拆分):
|
||||
CMS-CASE-1~7、CMS-CASE-AI-1、CMS-AUDIT-1~3
|
||||
|
||||
- 详细日志:logs/test-swagger-cms-case-YYYY-MM-DD.log
|
||||
- 样例 JSON:logs/cms-case-swagger-examples.json(供回填 docx/API - Sheet1.csv)
|
||||
- PDF/AI 需本地 Django + Redis + .env 中 DEEPSEEK_API_KEY;无 Key 时加 --skip-ai 跳过
|
||||
|
||||
运行:
|
||||
.venv\\Scripts\\python.exe test/swagger_cms_case.py
|
||||
.venv\\Scripts\\python.exe test/swagger_cms_case.py --update-csv
|
||||
.venv\\Scripts\\python.exe test/swagger_cms_case.py --skip-ai --update-csv
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
sys.stderr.reconfigure(encoding='utf-8')
|
||||
|
||||
BASE = os.environ.get('SWAGGER_BASE', 'http://127.0.0.1:8000')
|
||||
PYTHON = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.venv', 'Scripts', 'python.exe')
|
||||
if not os.path.isfile(PYTHON):
|
||||
PYTHON = sys.executable
|
||||
CWD = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
LOG_DIR = os.path.join(CWD, 'logs')
|
||||
CSV_PATH = os.path.join(CWD, 'docx', 'API - Sheet1.csv')
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
LOG_FILE = os.path.join(LOG_DIR, f'test-swagger-cms-case-{datetime.now():%Y-%m-%d}.log')
|
||||
EXAMPLES_FILE = os.path.join(LOG_DIR, 'cms-case-swagger-examples.json')
|
||||
|
||||
SUPER_PHONE = '13700009001'
|
||||
CONTENT_PHONE = '13700009002'
|
||||
HOSPITAL_PHONE = '13700009004'
|
||||
|
||||
_fh = open(LOG_FILE, 'a', encoding='utf-8')
|
||||
examples: dict[str, dict] = {}
|
||||
results: list[tuple[str, str, int]] = []
|
||||
|
||||
|
||||
def w(text=''):
|
||||
line = str(text)
|
||||
_fh.write(line + '\n')
|
||||
_fh.flush()
|
||||
print(line)
|
||||
|
||||
|
||||
def django_eval(code: str) -> str:
|
||||
pre = (
|
||||
'import django,os;'
|
||||
'os.environ.setdefault("DJANGO_SETTINGS_MODULE","config.settings");'
|
||||
'django.setup();'
|
||||
)
|
||||
p = subprocess.run(
|
||||
[PYTHON, '-c', pre + code],
|
||||
capture_output=True, text=True, cwd=CWD, encoding='utf-8',
|
||||
)
|
||||
if p.returncode != 0:
|
||||
w(f'[django_eval stderr] {p.stderr}')
|
||||
return (p.stdout or '').strip()
|
||||
|
||||
|
||||
def compact_json(obj: Any, max_len: int = 2800) -> str:
|
||||
s = json.dumps(obj, ensure_ascii=False, separators=(',', ':'))
|
||||
if len(s) > max_len:
|
||||
return s[:max_len] + '...(truncated)'
|
||||
return s
|
||||
|
||||
|
||||
def case_payload(title: str, institution_id=None, with_exam=False):
|
||||
p = {
|
||||
'title': title,
|
||||
'case_type': 'traditional',
|
||||
'difficulty': 'medium',
|
||||
'chief_complaint': '发热 3 天',
|
||||
'description': '患儿,男,4 岁,因发热 3 天就诊。',
|
||||
'patient_age': 4,
|
||||
'patient_gender': 'male',
|
||||
'department_name': '儿科',
|
||||
'estimated_minutes': 30,
|
||||
'osce_enabled': False,
|
||||
'tags': '儿科,发热',
|
||||
'traditional': {
|
||||
'standard_diagnosis': '上呼吸道感染',
|
||||
'standard_treatment': '对症治疗,退热处理',
|
||||
'guideline_reference': '《儿科学》第 9 版',
|
||||
},
|
||||
'scoring_rules': [
|
||||
{'dimension': '诊断准确性', 'score_weight': 0.5, 'ai_auto_score': True, 'scoring_standard': '能准确诊断'},
|
||||
{'dimension': '医患沟通', 'score_weight': 0.5, 'ai_auto_score': False, 'scoring_standard': '沟通到位'},
|
||||
],
|
||||
}
|
||||
if institution_id is not None:
|
||||
p['institution_id'] = institution_id
|
||||
if with_exam:
|
||||
p['exam_items'] = [{
|
||||
'item_code': 'blood_routine',
|
||||
'item_name': '血常规',
|
||||
'item_type': 'lab',
|
||||
'category': '实验室检查',
|
||||
'result_text': 'WBC 10×10^9/L',
|
||||
'is_key': True,
|
||||
'is_abnormal': False,
|
||||
'score_weight': 1.0,
|
||||
'display_order': 1,
|
||||
}]
|
||||
return p
|
||||
|
||||
|
||||
def shrink_list_resp(resp: dict) -> dict:
|
||||
"""列表响应保留 1 条 results 样例,便于 CSV。"""
|
||||
if not isinstance(resp, dict):
|
||||
return resp
|
||||
out = dict(resp)
|
||||
if isinstance(out.get('results'), list) and out['results']:
|
||||
out['results'] = [out['results'][0]]
|
||||
return out
|
||||
|
||||
|
||||
def shrink_full_resp(resp: dict) -> dict:
|
||||
"""完整病例响应保留关键字段。"""
|
||||
if not isinstance(resp, dict):
|
||||
return resp
|
||||
c = resp.get('case') or {}
|
||||
return {
|
||||
'case': {k: c.get(k) for k in (
|
||||
'id', 'title', 'case_type', 'institution', 'institution_name',
|
||||
'department', 'department_name', 'publish_status', 'created_by_name', 'created_at',
|
||||
)},
|
||||
'traditional': resp.get('traditional'),
|
||||
'scoring_rules': (resp.get('scoring_rules') or [])[:2],
|
||||
'exam_items': (resp.get('exam_items') or [])[:1],
|
||||
}
|
||||
|
||||
|
||||
def shrink_ai_resp(resp: dict) -> dict:
|
||||
if not isinstance(resp, dict):
|
||||
return resp
|
||||
data = resp.get('data') or {}
|
||||
return {
|
||||
'parse_id': resp.get('parse_id'),
|
||||
'case_type': resp.get('case_type'),
|
||||
'ai_usage': resp.get('ai_usage'),
|
||||
'prompt_version': resp.get('prompt_version'),
|
||||
'parsing_seconds': resp.get('parsing_seconds'),
|
||||
'generating_seconds': resp.get('generating_seconds'),
|
||||
'data': {k: data.get(k) for k in (
|
||||
'title', 'case_type', 'chief_complaint', 'department_name', 'patient_age', 'patient_gender',
|
||||
) if k in data},
|
||||
}
|
||||
|
||||
|
||||
def call(
|
||||
csv_key: str,
|
||||
name: str,
|
||||
method: str,
|
||||
path: str,
|
||||
token: str | None = None,
|
||||
json_body=None,
|
||||
params=None,
|
||||
files=None,
|
||||
data=None,
|
||||
expect=200,
|
||||
shrink=None,
|
||||
):
|
||||
"""发起 HTTP 请求,记录日志与 CSV 样例。"""
|
||||
headers = {'Authorization': f'Bearer {token}'} if token else {}
|
||||
url = BASE + path
|
||||
r = requests.request(
|
||||
method, url, headers=headers, json=json_body, params=params,
|
||||
files=files, data=data, timeout=120,
|
||||
)
|
||||
ct = r.headers.get('content-type', '')
|
||||
is_json = ct.startswith('application/json')
|
||||
resp_raw = r.json() if is_json else r.text
|
||||
resp_store = resp_raw
|
||||
if shrink == 'list':
|
||||
resp_store = shrink_list_resp(resp_raw if isinstance(resp_raw, dict) else {})
|
||||
elif shrink == 'full':
|
||||
resp_store = shrink_full_resp(resp_raw if isinstance(resp_raw, dict) else {})
|
||||
elif shrink == 'ai':
|
||||
resp_store = shrink_ai_resp(resp_raw if isinstance(resp_raw, dict) else {})
|
||||
elif shrink == 'create':
|
||||
resp_store = shrink_full_resp(resp_raw if isinstance(resp_raw, dict) else {})
|
||||
|
||||
if json_body is not None:
|
||||
req_example = json.dumps(json_body, ensure_ascii=False)
|
||||
elif files is not None:
|
||||
fn = list(files.values())[0][0] if files else 'file.pdf'
|
||||
extra = f', case_type={data.get("case_type")}' if data else ''
|
||||
req_example = f'multipart/form-data: files={fn}{extra}'
|
||||
elif params:
|
||||
req_example = '?' + '&'.join(f'{k}={v}' for k, v in params.items())
|
||||
elif method == 'GET' and '{' in path:
|
||||
req_example = f'路径参数 + 请求头 Authorization: Bearer <token>'
|
||||
elif method in ('POST',) and json_body is None and files is None:
|
||||
req_example = '路径参数 id + 请求头 Authorization(无 Body)'
|
||||
else:
|
||||
req_example = '请求头 Authorization: Bearer <token>'
|
||||
|
||||
exp_list = expect if isinstance(expect, (list, tuple)) else [expect]
|
||||
ok = 'PASS' if r.status_code in exp_list else 'FAIL'
|
||||
results.append((csv_key, ok, r.status_code))
|
||||
examples[csv_key] = {
|
||||
'name': name,
|
||||
'method': method,
|
||||
'path': path.lstrip('/'),
|
||||
'status': r.status_code,
|
||||
'req': req_example,
|
||||
'resp': resp_store,
|
||||
}
|
||||
|
||||
w(f'\n[{ok}] {csv_key} {name} -> {method} {path} (status={r.status_code})')
|
||||
w(f' 请求: {req_example}')
|
||||
body_str = json.dumps(resp_store, ensure_ascii=False, indent=2) if is_json else str(resp_store)
|
||||
if len(body_str) > 1800:
|
||||
body_str = body_str[:1800] + f' ...(截断, 共{len(body_str)}字符)'
|
||||
w(f' 响应: {body_str}')
|
||||
return r
|
||||
|
||||
|
||||
def cleanup():
|
||||
django_eval(
|
||||
'from apps.case.models import CaseBase; from apps.user.models import User; '
|
||||
f'CaseBase.all_objects.filter(created_by__phone__in=["{SUPER_PHONE}","{CONTENT_PHONE}"]).hard_delete(); '
|
||||
f'User.objects.filter(phone__in=["{SUPER_PHONE}","{CONTENT_PHONE}","{HOSPITAL_PHONE}"]).delete(); '
|
||||
'from apps.user.models import Institution; '
|
||||
'Institution.all_objects.filter(code__in=["SWG_CASE_A","SWG_CASE_B"]).hard_delete(); '
|
||||
'print("cleaned")'
|
||||
)
|
||||
|
||||
|
||||
def setup_tokens():
|
||||
w('\n[准备] 建临时机构/用户并签发 token ...')
|
||||
setup = django_eval(
|
||||
'from apps.user.models import User, Institution, Department; '
|
||||
'from rest_framework_simplejwt.tokens import RefreshToken; '
|
||||
'iA,_=Institution.objects.get_or_create(code="SWG_CASE_A", defaults={"name":"病例联调A院","type":"hospital"}); '
|
||||
'iB,_=Institution.objects.get_or_create(code="SWG_CASE_B", defaults={"name":"病例联调B院","type":"hospital"}); '
|
||||
'd1,_=Department.objects.get_or_create(name="儿科", defaults={"category":"临床"}); '
|
||||
'd2,_=Department.objects.get_or_create(name="内科", defaults={"category":"临床"}); '
|
||||
f'[User.objects.filter(phone=p).delete() for p in ["{SUPER_PHONE}","{CONTENT_PHONE}","{HOSPITAL_PHONE}"]]; '
|
||||
f'su=User.objects.create_user(username="{SUPER_PHONE}",password=None,phone="{SUPER_PHONE}",'
|
||||
f'real_name="病例超管",role_type="super_admin",institution=iA,status=1); '
|
||||
f'cu=User.objects.create_user(username="{CONTENT_PHONE}",password=None,phone="{CONTENT_PHONE}",'
|
||||
f'real_name="病例内容员",role_type="content_admin",institution=iA,status=1); '
|
||||
f'hu=User.objects.create_user(username="{HOSPITAL_PHONE}",password=None,phone="{HOSPITAL_PHONE}",'
|
||||
f'real_name="病例院管",role_type="hospital_admin",institution=iA,status=1); '
|
||||
'print("|".join([str(RefreshToken.for_user(su).access_token),'
|
||||
'str(RefreshToken.for_user(cu).access_token),str(RefreshToken.for_user(hu).access_token),'
|
||||
'str(d1.id),str(d2.id),str(iA.id),str(iB.id)]))'
|
||||
)
|
||||
su_tok, cu_tok, hu_tok, d1_id, d2_id, iA_id, iB_id = setup.split('|')
|
||||
w(f'[准备] 完成 instA={iA_id} instB={iB_id} dept儿科={d1_id} dept内科={d2_id}')
|
||||
return su_tok, cu_tok, hu_tok, int(d1_id), int(d2_id), int(iA_id), int(iB_id)
|
||||
|
||||
|
||||
def run_tests(skip_ai: bool):
|
||||
su_tok, cu_tok, hu_tok, d1_id, d2_id, iA_id, iB_id = setup_tokens()
|
||||
|
||||
w('\n' + '=' * 90)
|
||||
w(' 超级管理员 - 病例库')
|
||||
w('=' * 90)
|
||||
|
||||
# 先建病例供后续列表/查看
|
||||
body_super = case_payload('Swagger超管病例(联调)', with_exam=True)
|
||||
r = call('82', 'CMS-CASE-3 表单新增(超管)', 'POST', '/api/cms/cases/',
|
||||
su_tok, json_body=body_super, expect=201, shrink='create')
|
||||
super_case_id = (r.json().get('case') or {}).get('id')
|
||||
r_b = call('_b', 'CMS-CASE-3 指定B院(流程用)', 'POST', '/api/cms/cases/',
|
||||
su_tok, json_body=case_payload('Swagger B院病例', institution_id=iB_id),
|
||||
expect=201, shrink='create')
|
||||
b_case_id = (r_b.json().get('case') or {}).get('id')
|
||||
|
||||
call('80', 'CMS-CASE-1 病例列表(超管)', 'GET', '/api/cms/cases/',
|
||||
su_tok, params={'page': 1}, expect=200, shrink='list')
|
||||
|
||||
pdf_bytes = (
|
||||
b'%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n'
|
||||
b'2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n'
|
||||
b'3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R/Contents 4 0 R>>endobj\n'
|
||||
b'4 0 obj<</Length 44>>stream\nBT /F1 12 Tf 100 700 Td (fever 3d) Tj ET\nendstream\nendobj\n'
|
||||
b'xref\n0 5\ntrailer<</Root 1 0 R>>\nstartxref\n0\n%%EOF'
|
||||
)
|
||||
if not skip_ai:
|
||||
w('\n[AI] PDF 导入 / AI 生成(需 DEEPSEEK_API_KEY)...')
|
||||
call('81', 'CMS-CASE-2 PDF导入(超管)', 'POST', '/api/cms/cases/import-pdf/',
|
||||
su_tok, files={'files': ('case.pdf', pdf_bytes, 'application/pdf')},
|
||||
data={'case_type': 'traditional'}, expect=[200, 429, 502, 504], shrink='ai')
|
||||
call('87', 'CMS-CASE-AI-1 AI生成(超管)', 'POST', '/api/cms/cases/ai-generate/',
|
||||
su_tok, json_body={
|
||||
'case_type': 'traditional',
|
||||
'prompt': '请生成一个儿科急性上呼吸道感染传统病例,患儿4岁男孩,发热咳嗽3天。',
|
||||
}, expect=[200, 429, 502, 504], shrink='ai')
|
||||
else:
|
||||
w('\n[跳过] PDF/AI 接口(--skip-ai)')
|
||||
|
||||
call('83', 'CMS-CASE-4 编辑关联(超管)', 'POST', f'/api/cms/cases/{super_case_id}/relations/',
|
||||
su_tok, json_body={'institution_id': iB_id, 'department_id': d2_id}, expect=200)
|
||||
|
||||
# 单独草稿用于 submit
|
||||
r = call('84', 'CMS-CASE-5 提交(超管)', 'POST', f'/api/cms/cases/{super_case_id}/submit/',
|
||||
su_tok, expect=200)
|
||||
# 再建一条用于 disable
|
||||
r2 = call('85', 'CMS-CASE-6 停用(超管)', 'POST', f'/api/cms/cases/{b_case_id}/disable/',
|
||||
su_tok, expect=200)
|
||||
|
||||
call('86', 'CMS-CASE-7 查看(超管)', 'GET', f'/api/cms/cases/{super_case_id}/full/',
|
||||
su_tok, expect=200, shrink='full')
|
||||
|
||||
w('\n' + '=' * 90)
|
||||
w(' 内容管理员 - 病例库')
|
||||
w('=' * 90)
|
||||
|
||||
body_content = case_payload('Swagger内容员病例(联调)')
|
||||
r = call('90', 'CMS-CASE-3 表单新增(内容员)', 'POST', '/api/cms/cases/',
|
||||
cu_tok, json_body=body_content, expect=201, shrink='create')
|
||||
content_case_id = (r.json().get('case') or {}).get('id')
|
||||
|
||||
call('88', 'CMS-CASE-1 病例列表(内容员)', 'GET', '/api/cms/cases/',
|
||||
cu_tok, params={'page': 1}, expect=200, shrink='list')
|
||||
|
||||
if not skip_ai:
|
||||
call('89', 'CMS-CASE-2 PDF导入(内容员)', 'POST', '/api/cms/cases/import-pdf/',
|
||||
cu_tok, files={'files': ('case.pdf', pdf_bytes, 'application/pdf')},
|
||||
data={'case_type': 'traditional'}, expect=[200, 429, 502, 504], shrink='ai')
|
||||
call('95', 'CMS-CASE-AI-1 AI生成(内容员)', 'POST', '/api/cms/cases/ai-generate/',
|
||||
cu_tok, json_body={
|
||||
'case_type': 'traditional',
|
||||
'prompt': '请生成一个儿科发热传统病例,4岁男孩,发热3天。',
|
||||
}, expect=[200, 429, 502, 504], shrink='ai')
|
||||
else:
|
||||
w('\n[跳过] 内容员 PDF/AI')
|
||||
|
||||
call('91', 'CMS-CASE-4 编辑关联(内容员)', 'POST', f'/api/cms/cases/{content_case_id}/relations/',
|
||||
cu_tok, json_body={'department_id': d2_id}, expect=200)
|
||||
|
||||
call('92', 'CMS-CASE-5 提交(内容员)', 'POST', f'/api/cms/cases/{content_case_id}/submit/',
|
||||
cu_tok, expect=200)
|
||||
|
||||
r_dis = call('_dis', '待停用(流程用)', 'POST', '/api/cms/cases/',
|
||||
cu_tok, json_body=case_payload('Swagger待停用病例'), expect=201, shrink='create')
|
||||
disable_id = (r_dis.json().get('case') or {}).get('id')
|
||||
call('93', 'CMS-CASE-6 停用(内容员)', 'POST', f'/api/cms/cases/{disable_id}/disable/',
|
||||
cu_tok, expect=200)
|
||||
|
||||
call('94', 'CMS-CASE-7 查看(内容员)', 'GET', f'/api/cms/cases/{content_case_id}/full/',
|
||||
cu_tok, expect=200, shrink='full')
|
||||
|
||||
w('\n' + '=' * 90)
|
||||
w(' 医院管理员 - 病例审核')
|
||||
w('=' * 90)
|
||||
|
||||
call('96', 'CMS-AUDIT-1 待审核列表', 'GET', '/api/cms/cases/',
|
||||
hu_tok, params={'publish_status': 1, 'page': 1}, expect=200, shrink='list')
|
||||
|
||||
call('97', 'CMS-AUDIT-2 查看病例', 'GET', f'/api/cms/cases/{content_case_id}/full/',
|
||||
hu_tok, expect=200, shrink='full')
|
||||
|
||||
call('98', 'CMS-AUDIT-3 发布', 'POST', f'/api/cms/cases/{content_case_id}/publish/',
|
||||
hu_tok, expect=200)
|
||||
|
||||
|
||||
# 各行「说明」原文(CSV 第 9 列,update 时保持不变)
|
||||
ROW_NOTES = {
|
||||
'80': '仅超级管理员。返回全平台病例(可用?institution=按机构过滤)。已软删(下架)不返回。不可发布(归医院管理员 CMS-AUDIT-3)。未登录401;非超管403',
|
||||
'81': '仅超级管理员。解析预览不落库,前端审核后调 CMS-CASE-3 入库时可传 institution_id 指定任意机构。case_type非法→400;非multipart→415;限流429;AI异常502/504;非超管403',
|
||||
'82': '仅超级管理员。落库为草稿(publish_status=0),可指定任意 institution_id。错误:CASE_TYPE_NOT_SUPPORTED/CASE_SUBTYPE_REQUIRED/CASE_SUBTYPE_CONFLICT/CASE_VALIDATION_ERROR/CASE_FIELD_NOT_ALLOWED/CASE_INSTITUTION_NOT_FOUND/CASE_DEPARTMENT_NOT_FOUND/CASE_EXAM_ITEM_DUPLICATE;非超管403',
|
||||
'83': '仅超级管理员。可改全平台任意病例的所属机构/科室,不改病例内容。三者都没传→400 CASE_VALIDATION_ERROR;机构/科室不存在→400;病例不存在/已软删→404;非超管403',
|
||||
'84': '仅超级管理员。全平台任意病例:草稿(0)→正常(1),进入对应机构医院管理员审核队列。非草稿→400 CASE_NOT_SUBMITTABLE;病例不存在/已软删→404;非超管403',
|
||||
'85': '仅超级管理员。全平台任意病例停用=下架=软删除(is_deleted=1,不物删)。下架后默认列表/查看不返回;病例不存在/已软删→404;非超管403',
|
||||
'86': '仅超级管理员。可查看全平台任意病例完整结构。病例不存在/已软删→404;非超管403',
|
||||
'87': '仅超级管理员。DeepSeek按病例模板(prompts/case_{type}_full.md)生成,不落库;前端审核后走 CMS-CASE-3 入库时可指定 institution_id。prompt空→400;限流429;AI异常500/502/504;非超管403',
|
||||
'88': '仅内容管理员。仅返回本院(institution=登录用户所属机构)病例;他院不可见。?institution 参数无效(后端强制本院)。已软删不返回。不可审核发布(归医院管理员)。未登录401;非内容管理员403',
|
||||
'89': '仅内容管理员。解析预览不落库,前端审核后调 CMS-CASE-3 入库时 institution 强制本院(忽略 institution_id)。case_type非法→400;非multipart→415;限流429;AI异常502/504;非内容管理员403',
|
||||
'90': '仅内容管理员。新建强制 institution=本院(传 institution_id 亦忽略)。落库为草稿(publish_status=0)。错误同 CMS-CASE-3;非内容管理员403',
|
||||
'91': '仅内容管理员。仅能操作本院病例;可改科室,机构锁定本院(传 institution_id 无效或不可改他院)。不改病例内容。他院病例→404;非内容管理员403',
|
||||
'92': '仅内容管理员。仅本院病例:草稿(0)→正常(1),进入本院医院管理员审核队列。非草稿→400 CASE_NOT_SUBMITTABLE;他院/不存在/已软删→404;非内容管理员403',
|
||||
'93': '仅内容管理员。仅本院病例停用=下架=软删除(is_deleted=1)。下架后默认列表/查看不返回;他院/不存在/已软删→404;非内容管理员403',
|
||||
'94': '仅内容管理员。仅可查看本院病例完整结构。他院/不存在/已软删→404;非内容管理员403',
|
||||
'95': '仅内容管理员。DeepSeek按病例模板生成,不落库;前端审核后走 CMS-CASE-3 入库时强制落本院。prompt空→400;限流429;AI异常500/502/504;非内容管理员403',
|
||||
'96': '仅医院管理员。本院 publish_status=1(正常/待审核)病例列表,即 CMS-CASE-1 加 publish_status=1 且后端强制本院。超管/内容管理员请用各自病例列表接口。未登录401;非医院管理员403',
|
||||
'97': '仅医院管理员。审核前查看本院待审核病例完整内容。他院/不存在/已软删→404;非医院管理员403',
|
||||
'98': '仅医院管理员(超管/内容管理员均403)。本院病例:正常(1)→已发布(2),发布后对本院移动端医学生可见。非正常→400 CASE_NOT_PUBLISHABLE;他院/不存在/已软删→404',
|
||||
}
|
||||
|
||||
# 各行 params 字段说明前缀(不含「实际示例」)
|
||||
ROW_PARAMS_BASE = {
|
||||
'80': '查询参数(均可选):search(标题/主诉/标签/ICD)、case_type、publish_status(0草稿/1正常/2已发布)、institution(机构ID,可按任意机构过滤)、department(科室ID)、status、osce_enabled、ordering、page;请求头:Authorization',
|
||||
'81': '请求体(multipart/form-data):files=1~5份.pdf、case_type=traditional|teaching;请求头:Authorization',
|
||||
'82': '请求体(JSON):title*、case_type*(traditional|teaching)、institution_id(可选,指定落库机构;缺省落创建者机构)、department_name、子表traditional|teaching*、scoring_rules*(≥1,dimension必填、score_weight∈(0,1])、exam_items(可选,item_code不重复)、difficulty/chief_complaint/description/patient_age/patient_gender/tags/icd_codes/estimated_minutes/osce_enabled…;不接收stages;请求头:Authorization',
|
||||
'83': '路径参数:id;请求体(JSON,至少一项):institution_id(机构ID,null清空,可改任意机构)、department_id(科室ID,null清空) 或 department_name;请求头:Authorization',
|
||||
'84': '路径参数:id(无Body);请求头:Authorization',
|
||||
'85': '路径参数:id(无Body);请求头:Authorization',
|
||||
'86': '路径参数:id;请求头:Authorization',
|
||||
'87': '请求体(JSON):prompt*(病例长描述)、case_type*(traditional|teaching);请求头:Authorization',
|
||||
'88': '查询参数(均可选):search、case_type、publish_status(0草稿/1正常/2已发布)、department(科室ID)、status、osce_enabled、ordering、page;请求头:Authorization',
|
||||
'89': '请求体(multipart/form-data):files=1~5份.pdf、case_type=traditional|teaching;请求头:Authorization',
|
||||
'90': '请求体(JSON):title*、case_type*(traditional|teaching)、department_name、子表traditional|teaching*、scoring_rules*(≥1,dimension必填、score_weight∈(0,1])、exam_items(可选,item_code不重复)、difficulty/chief_complaint/description/patient_age/patient_gender/tags/icd_codes/estimated_minutes/osce_enabled…;不接收institution_id/stages;请求头:Authorization',
|
||||
'91': '路径参数:id;请求体(JSON,至少一项):department_id(科室ID,null清空) 或 department_name;请求头:Authorization',
|
||||
'92': '路径参数:id(无Body);请求头:Authorization',
|
||||
'93': '路径参数:id(无Body);请求头:Authorization',
|
||||
'94': '路径参数:id;请求头:Authorization',
|
||||
'95': '请求体(JSON):prompt*(病例长描述)、case_type*(traditional|teaching);请求头:Authorization',
|
||||
'96': '查询参数:publish_status=1(正常=待审核)、search、department、page;请求头:Authorization',
|
||||
'97': '路径参数:id;请求头:Authorization',
|
||||
'98': '路径参数:id(无Body);请求头:Authorization',
|
||||
}
|
||||
|
||||
|
||||
def update_csv():
|
||||
"""用 examples 回填 CSV 第 80~98 行的 params / response 列。"""
|
||||
if not os.path.isfile(EXAMPLES_FILE):
|
||||
w(f'样例文件不存在: {EXAMPLES_FILE}')
|
||||
return False
|
||||
with open(EXAMPLES_FILE, encoding='utf-8') as f:
|
||||
ex = json.load(f)
|
||||
|
||||
rows = []
|
||||
with open(CSV_PATH, encoding='utf-8', newline='') as f:
|
||||
rows = list(csv.reader(f))
|
||||
|
||||
updated = 0
|
||||
for i, row in enumerate(rows):
|
||||
if not row or row[0] not in ex:
|
||||
continue
|
||||
row_num = row[0]
|
||||
e = ex[row_num]
|
||||
method = e.get('method', row[6] if len(row) > 6 else 'GET')
|
||||
base_params = ROW_PARAMS_BASE.get(row_num, row[7] if len(row) > 7 else '')
|
||||
req = e.get('req', '')
|
||||
|
||||
if method == 'GET' and req.startswith('?'):
|
||||
new_params = f'{base_params} | 实际示例:{method} /{e["path"]}{req}'
|
||||
elif req.startswith('multipart'):
|
||||
new_params = f'{base_params} | 实际示例:{req}'
|
||||
elif req.startswith('{') or req.startswith('['):
|
||||
new_params = f'{base_params} | 实际示例:{req}'
|
||||
elif '路径参数' in req or '无 Body' in req or 'Bearer' in req:
|
||||
new_params = f'{base_params} | 实际示例:{method} /{e["path"]}'
|
||||
else:
|
||||
new_params = f'{base_params} | 实际示例:{req}'
|
||||
|
||||
new_resp = f'实际返回:{compact_json(e.get("resp", ""))}'
|
||||
note = ROW_NOTES.get(row_num, row[9] if len(row) > 9 else '')
|
||||
|
||||
rows[i] = row[:7] + [new_params, new_resp, note, '1', row[11] if len(row) > 11 else '0']
|
||||
updated += 1
|
||||
w(f' CSV 行 #{row_num} 已更新 ({e.get("name")})')
|
||||
|
||||
with open(CSV_PATH, 'w', encoding='utf-8', newline='') as f:
|
||||
csv.writer(f, quoting=csv.QUOTE_MINIMAL).writerows(rows)
|
||||
w(f'\nCSV 更新完成:{updated} 行 -> {CSV_PATH}')
|
||||
return updated > 0
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--skip-ai', action='store_true', help='跳过 PDF/AI(无 DeepSeek Key 时)')
|
||||
parser.add_argument('--update-csv', action='store_true', help='测试后回填 docx/API - Sheet1.csv')
|
||||
parser.add_argument('--csv-only', action='store_true', help='仅从已有 examples JSON 更新 CSV')
|
||||
args = parser.parse_args()
|
||||
|
||||
w('=' * 90)
|
||||
w(f' CMS 病例 Swagger 测试 {datetime.now():%Y-%m-%d %H:%M:%S}')
|
||||
w(f' BASE={BASE}')
|
||||
w('=' * 90)
|
||||
|
||||
if args.csv_only:
|
||||
update_csv()
|
||||
_fh.close()
|
||||
return 0
|
||||
|
||||
try:
|
||||
run_tests(skip_ai=args.skip_ai)
|
||||
finally:
|
||||
w('\n[清理] 删除测试病例与用户 ...')
|
||||
cleanup()
|
||||
|
||||
with open(EXAMPLES_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(examples, f, ensure_ascii=False, indent=2)
|
||||
|
||||
w('\n' + '=' * 90)
|
||||
total = len(results)
|
||||
passed = sum(1 for _, ok, _ in results if ok == 'PASS')
|
||||
w(f' 总计 {total} | 通过 {passed} | 失败 {total - passed}')
|
||||
fails = [(c, s) for c, ok, s in results if ok == 'FAIL']
|
||||
if fails:
|
||||
w(' 失败: ' + ', '.join(f'{c}({s})' for c, s in fails))
|
||||
w(f' 日志: {LOG_FILE}')
|
||||
w(f' 样例: {EXAMPLES_FILE}')
|
||||
|
||||
if args.update_csv:
|
||||
update_csv()
|
||||
|
||||
_fh.close()
|
||||
return 0 if not fails else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,237 @@
|
||||
"""CMS 病例库 + AI 病例生成 + 病例审核测试(超管 / 内容管理员 / 医院管理员)。
|
||||
|
||||
覆盖 CMS-CASE-1~7 + CMS-CASE-AI-1 + CMS-AUDIT-3(发布)、权限与角色分工、机构(institution)
|
||||
范围收口、软删除(is_deleted)、状态机(草稿0→正常1→已发布2)、仅 GET/POST。AI 调用全程 mock。
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.case.models import CaseBase
|
||||
from apps.user.throttling import PdfParseUserThrottle
|
||||
from .conftest import (
|
||||
CacheTestCase, create_test_user, get_auth_client, ensure_department, ensure_institution,
|
||||
build_traditional_payload, make_fake_pdf, MOCK_C1_PARSE_RESULT,
|
||||
)
|
||||
|
||||
CASE_URL = '/api/cms/cases/'
|
||||
IMPORT_PDF_URL = '/api/cms/cases/import-pdf/'
|
||||
AI_GENERATE_URL = '/api/cms/cases/ai-generate/'
|
||||
|
||||
|
||||
def full_url(pk):
|
||||
return f'/api/cms/cases/{pk}/full/'
|
||||
|
||||
|
||||
def relations_url(pk):
|
||||
return f'/api/cms/cases/{pk}/relations/'
|
||||
|
||||
|
||||
def submit_url(pk):
|
||||
return f'/api/cms/cases/{pk}/submit/'
|
||||
|
||||
|
||||
def disable_url(pk):
|
||||
return f'/api/cms/cases/{pk}/disable/'
|
||||
|
||||
|
||||
def publish_url(pk):
|
||||
return f'/api/cms/cases/{pk}/publish/'
|
||||
|
||||
|
||||
class CmsCaseTest(CacheTestCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.instA = ensure_institution(name='测试医院A', code='CASE-HA')
|
||||
self.instB = ensure_institution(name='测试医院B', code='CASE-HB')
|
||||
self.dept = ensure_department('儿科')
|
||||
self.dept2 = ensure_department('内科')
|
||||
self.superu = create_test_user(phone='13966600001', role_type='super_admin', institution=self.instA)
|
||||
self.content = create_test_user(phone='13966600002', role_type='content_admin', institution=self.instA)
|
||||
self.contentB = create_test_user(phone='13966600004', role_type='content_admin', institution=self.instB)
|
||||
self.hospital = create_test_user(phone='13966600005', role_type='hospital_admin', institution=self.instA)
|
||||
self.student = create_test_user(phone='13966600003', role_type='student', institution=self.instA)
|
||||
self.sclient = get_auth_client(self.superu)
|
||||
self.cclient = get_auth_client(self.content)
|
||||
self.hclient = get_auth_client(self.hospital)
|
||||
|
||||
def _create_case(self, client, title='CMS测试病例', institution_id=None):
|
||||
payload = build_traditional_payload(department_name='儿科', scoring_rules_count=2, with_exam_items=True)
|
||||
payload['title'] = title
|
||||
if institution_id is not None:
|
||||
payload['institution_id'] = institution_id
|
||||
resp = client.post(CASE_URL, payload, format='json')
|
||||
self.assertEqual(resp.status_code, 201, resp.content)
|
||||
return resp.json()
|
||||
|
||||
# ── 权限 ─────────────────────────────────────────────────────────────
|
||||
def test_requires_auth(self):
|
||||
self.assertEqual(APIClient().get(CASE_URL).status_code, 401)
|
||||
|
||||
def test_student_forbidden(self):
|
||||
resp = get_auth_client(self.student).get(CASE_URL)
|
||||
self.assertEqual(resp.status_code, 403, resp.content)
|
||||
self.assertEqual(resp.json()['code'], 'CMS_PERMISSION_DENIED')
|
||||
|
||||
# ── CMS-CASE-3 表单新增 ──────────────────────────────────────────────
|
||||
def test_create_form(self):
|
||||
created = self._create_case(self.sclient)
|
||||
self.assertEqual(created['case']['case_type'], 'traditional')
|
||||
self.assertEqual(created['case']['publish_status'], 0) # 草稿
|
||||
self.assertEqual(created['case']['created_by'], self.superu.id)
|
||||
self.assertEqual(created['case']['institution'], self.instA.id) # 超管缺省落本院
|
||||
self.assertEqual(len(created['scoring_rules']), 2)
|
||||
self.assertEqual(len(created['exam_items']), 1)
|
||||
|
||||
def test_content_admin_create_forces_own_institution(self):
|
||||
# 内容管理员即便传 institution_id 也强制落本院
|
||||
created = self._create_case(self.cclient, institution_id=self.instB.id)
|
||||
self.assertEqual(created['case']['institution'], self.instA.id)
|
||||
|
||||
def test_create_missing_scoring_rules_400(self):
|
||||
payload = build_traditional_payload(department_name='儿科')
|
||||
payload.pop('scoring_rules')
|
||||
resp = self.sclient.post(CASE_URL, payload, format='json')
|
||||
self.assertEqual(resp.status_code, 400, resp.content)
|
||||
|
||||
def test_hospital_admin_cannot_create_403(self):
|
||||
payload = build_traditional_payload(department_name='儿科')
|
||||
resp = self.hclient.post(CASE_URL, payload, format='json')
|
||||
self.assertEqual(resp.status_code, 403, resp.content)
|
||||
|
||||
# ── CMS-CASE-1 列表 + 机构范围 ───────────────────────────────────────
|
||||
def test_list_scope_by_institution(self):
|
||||
self._create_case(self.sclient, title='A院病例') # 超管落 instA
|
||||
self._create_case(self.sclient, title='B院病例', institution_id=self.instB.id)
|
||||
self.assertEqual(self.sclient.get(CASE_URL).json()['count'], 2) # 超管全部
|
||||
c_list = self.cclient.get(CASE_URL).json() # instA 内容管理员
|
||||
self.assertEqual(c_list['count'], 1)
|
||||
self.assertEqual(c_list['results'][0]['title'], 'A院病例')
|
||||
cb_list = get_auth_client(self.contentB).get(CASE_URL).json() # instB 内容管理员
|
||||
self.assertEqual(cb_list['count'], 1)
|
||||
self.assertEqual(cb_list['results'][0]['title'], 'B院病例')
|
||||
|
||||
# ── CMS-CASE-7 病例查看 + 跨机构 404 ─────────────────────────────────
|
||||
def test_full_view(self):
|
||||
cid = self._create_case(self.sclient)['case']['id']
|
||||
resp = self.sclient.get(full_url(cid))
|
||||
self.assertEqual(resp.status_code, 200, resp.content)
|
||||
self.assertEqual(resp.json()['case']['id'], cid)
|
||||
self.assertEqual(resp.json()['case']['institution_name'], '测试医院A')
|
||||
|
||||
def test_content_admin_cannot_touch_other_institution_404(self):
|
||||
cid = self._create_case(self.sclient, institution_id=self.instB.id)['case']['id']
|
||||
self.assertEqual(self.cclient.get(full_url(cid)).status_code, 404)
|
||||
self.assertEqual(self.cclient.post(disable_url(cid)).status_code, 404)
|
||||
|
||||
# ── CMS-CASE-4 编辑关联(改机构 + 科室)──────────────────────────────
|
||||
def test_relations_change_institution_and_department(self):
|
||||
cid = self._create_case(self.sclient)['case']['id']
|
||||
resp = self.sclient.post(relations_url(cid),
|
||||
{'institution_id': self.instB.id, 'department_id': self.dept2.id})
|
||||
self.assertEqual(resp.status_code, 200, resp.content)
|
||||
self.assertEqual(resp.json()['institution_name'], '测试医院B')
|
||||
self.assertEqual(resp.json()['department_name'], '内科')
|
||||
case = CaseBase.objects.get(id=cid)
|
||||
self.assertEqual(case.institution_id, self.instB.id)
|
||||
self.assertEqual(case.department_id, self.dept2.id)
|
||||
|
||||
def test_relations_bad_institution_400(self):
|
||||
cid = self._create_case(self.sclient)['case']['id']
|
||||
resp = self.sclient.post(relations_url(cid), {'institution_id': 999999})
|
||||
self.assertEqual(resp.status_code, 400, resp.content)
|
||||
|
||||
# ── CMS-CASE-5 提交(草稿 → 正常)────────────────────────────────────
|
||||
def test_submit_draft_to_normal(self):
|
||||
cid = self._create_case(self.cclient)['case']['id']
|
||||
resp = self.cclient.post(submit_url(cid))
|
||||
self.assertEqual(resp.status_code, 200, resp.content)
|
||||
self.assertEqual(resp.json()['publish_status'], 1) # 正常
|
||||
self.assertEqual(self.cclient.post(submit_url(cid)).status_code, 400) # 非草稿重复提交
|
||||
|
||||
# ── CMS-CASE-6 停用(软删除)─────────────────────────────────────────
|
||||
def test_disable_soft_delete(self):
|
||||
cid = self._create_case(self.cclient)['case']['id']
|
||||
resp = self.cclient.post(disable_url(cid))
|
||||
self.assertEqual(resp.status_code, 200, resp.content)
|
||||
self.assertFalse(CaseBase.objects.filter(id=cid).exists()) # 默认管理器过滤
|
||||
self.assertTrue(CaseBase.all_objects.get(id=cid).is_deleted) # 实际软删
|
||||
self.assertEqual(self.cclient.get(CASE_URL).json()['count'], 0) # 列表不再返回
|
||||
|
||||
# ── CMS-AUDIT-3 发布(正常 → 已发布,医院管理员)─────────────────────
|
||||
def test_publish_by_hospital_admin(self):
|
||||
cid = self._create_case(self.cclient)['case']['id']
|
||||
self.cclient.post(submit_url(cid)) # 草稿→正常
|
||||
resp = self.hclient.post(publish_url(cid)) # 医院管理员发布
|
||||
self.assertEqual(resp.status_code, 200, resp.content)
|
||||
self.assertEqual(resp.json()['publish_status'], 2) # 已发布
|
||||
self.assertEqual(CaseBase.objects.get(id=cid).publish_status, 2)
|
||||
|
||||
def test_publish_requires_normal_status_400(self):
|
||||
cid = self._create_case(self.cclient)['case']['id'] # 仍是草稿(0)
|
||||
self.assertEqual(self.hclient.post(publish_url(cid)).status_code, 400)
|
||||
|
||||
def test_content_admin_cannot_publish_403(self):
|
||||
cid = self._create_case(self.cclient)['case']['id']
|
||||
self.cclient.post(submit_url(cid))
|
||||
self.assertEqual(self.cclient.post(publish_url(cid)).status_code, 403)
|
||||
|
||||
def test_super_admin_cannot_publish_403(self):
|
||||
# 超级管理员不做病例审核发布
|
||||
cid = self._create_case(self.cclient)['case']['id']
|
||||
self.cclient.post(submit_url(cid))
|
||||
self.assertEqual(self.sclient.post(publish_url(cid)).status_code, 403)
|
||||
|
||||
def test_audit_list_filter_normal(self):
|
||||
cid = self._create_case(self.cclient)['case']['id']
|
||||
self.cclient.post(submit_url(cid))
|
||||
audit = self.hclient.get(CASE_URL, {'publish_status': 1}).json() # 待审核(正常)
|
||||
self.assertEqual(audit['count'], 1)
|
||||
self.assertEqual(audit['results'][0]['id'], cid)
|
||||
|
||||
# ── 方法收敛:PATCH/DELETE → 405 ─────────────────────────────────────
|
||||
def test_patch_delete_not_allowed(self):
|
||||
cid = self._create_case(self.sclient)['case']['id']
|
||||
self.assertEqual(self.sclient.patch(f'{CASE_URL}{cid}/', {'title': 'x'}).status_code, 405)
|
||||
self.assertEqual(self.sclient.delete(f'{CASE_URL}{cid}/').status_code, 405)
|
||||
|
||||
# ── CMS-CASE-2 PDF 导入(mock AI)────────────────────────────────────
|
||||
@patch('apps.case.services.case_importer.extract_text_from_pdfs',
|
||||
return_value='患儿,男,4岁,发热3天。')
|
||||
def test_import_pdf_preview(self, _mock_pdf):
|
||||
with (
|
||||
patch('apps.case.services.deepseek_client.call_deepseek', return_value=MOCK_C1_PARSE_RESULT),
|
||||
patch.object(PdfParseUserThrottle, 'allow_request', return_value=True),
|
||||
):
|
||||
resp = self.sclient.post(IMPORT_PDF_URL,
|
||||
{'files': make_fake_pdf(), 'case_type': 'traditional'},
|
||||
format='multipart')
|
||||
self.assertEqual(resp.status_code, 200, resp.content)
|
||||
self.assertIn('parse_id', resp.json())
|
||||
self.assertEqual(CaseBase.objects.count(), 0) # 仅预览,不落库
|
||||
|
||||
def test_hospital_admin_cannot_import_or_ai_403(self):
|
||||
with patch.object(PdfParseUserThrottle, 'allow_request', return_value=True):
|
||||
self.assertEqual(
|
||||
self.hclient.post(IMPORT_PDF_URL, {'case_type': 'traditional'}, format='multipart').status_code, 403)
|
||||
self.assertEqual(
|
||||
self.hclient.post(AI_GENERATE_URL, {'prompt': 'x', 'case_type': 'traditional'}, format='json').status_code, 403)
|
||||
|
||||
# ── CMS-CASE-AI-1 AI 生成(mock AI)──────────────────────────────────
|
||||
def test_ai_generate(self):
|
||||
with (
|
||||
patch('apps.case.services.deepseek_client.call_deepseek', return_value=MOCK_C1_PARSE_RESULT),
|
||||
patch.object(PdfParseUserThrottle, 'allow_request', return_value=True),
|
||||
):
|
||||
resp = self.cclient.post(AI_GENERATE_URL,
|
||||
{'prompt': '生成一个儿科发热病例', 'case_type': 'traditional'},
|
||||
format='json')
|
||||
self.assertEqual(resp.status_code, 200, resp.content)
|
||||
self.assertIn('parse_id', resp.json())
|
||||
self.assertEqual(CaseBase.objects.count(), 0) # 不落库
|
||||
|
||||
def test_ai_generate_missing_prompt_400(self):
|
||||
with patch.object(PdfParseUserThrottle, 'allow_request', return_value=True):
|
||||
resp = self.cclient.post(AI_GENERATE_URL, {'case_type': 'traditional'}, format='json')
|
||||
self.assertEqual(resp.status_code, 400, resp.content)
|
||||
@@ -43,11 +43,11 @@ def _dim(name, score, mx):
|
||||
return {'dimension': name, 'score': score, 'max_score': mx}
|
||||
|
||||
|
||||
# 两条记录的维度评分(得分率:见注释)
|
||||
DIMS_98 = [_dim('信息获取', 20, 25), _dim('体格检查', 8, 10), _dim('检查决策', 9, 10),
|
||||
_dim('诊断推理', 18, 20), _dim('治疗决策', 8, 10), _dim('医患沟通', 7, 10)] # 80/80/90/90/80/70
|
||||
DIMS_80 = [_dim('信息获取', 20, 25), _dim('体格检查', 8, 10), _dim('检查决策', 8, 10),
|
||||
_dim('诊断推理', 16, 20), _dim('治疗决策', 8, 10), _dim('医患沟通', 8, 10)] # 80/80/80/80/80/80
|
||||
# 两条记录的维度评分(标准 5 维;检查利用→处置决策 用于验证归并)。得分率见行尾注释。
|
||||
DIMS_98 = [_dim('信息获取', 20, 25), _dim('分析推理', 18, 20), _dim('检查利用', 9, 10),
|
||||
_dim('沟通人文', 7, 10), _dim('临床整合', 8, 10)] # 信息80/分析90/处置90/沟通70/整合80
|
||||
DIMS_80 = [_dim('信息获取', 20, 25), _dim('分析推理', 16, 20), _dim('处置决策', 8, 10),
|
||||
_dim('沟通人文', 8, 10), _dim('临床整合', 8, 10)] # 信息80/分析80/处置80/沟通80/整合80
|
||||
|
||||
|
||||
class TrainingStatsTest(TransactionTestCase):
|
||||
@@ -147,13 +147,14 @@ class TrainingStatsTest(TransactionTestCase):
|
||||
d = resp.json()
|
||||
self.assertEqual(d['current_score'], 89.0)
|
||||
radar = {x['dimension']: x['score'] for x in d['radar']}
|
||||
self.assertEqual(set(radar), {'病史采集', '查体能力', '检查决策', '诊断能力', '治疗决策', '医患沟通'})
|
||||
self.assertEqual(radar['病史采集'], 80) # avg(80,80)
|
||||
self.assertEqual(radar['检查决策'], 85) # avg(90,80)
|
||||
self.assertEqual(radar['诊断能力'], 85) # 诊断推理 avg(90,80)
|
||||
self.assertEqual(radar['医患沟通'], 75) # avg(70,80) → 最低
|
||||
self.assertEqual(d['weak_dimensions'], ['医患沟通'])
|
||||
self.assertIn('医患沟通', d['comment']) # 强/弱不同 → 走对比文案
|
||||
self.assertEqual(set(radar), {'信息获取', '分析推理', '处置决策', '沟通人文', '临床整合'})
|
||||
self.assertEqual(radar['信息获取'], 80) # avg(80,80)
|
||||
self.assertEqual(radar['分析推理'], 85) # avg(90,80)
|
||||
self.assertEqual(radar['处置决策'], 85) # 检查利用90 + 处置决策80 → avg
|
||||
self.assertEqual(radar['临床整合'], 80) # avg(80,80)
|
||||
self.assertEqual(radar['沟通人文'], 75) # avg(70,80) → 最低
|
||||
self.assertEqual(d['weak_dimensions'], ['沟通人文'])
|
||||
self.assertIn('沟通人文', d['comment']) # 强/弱不同 → 走对比文案
|
||||
self.assertIn('突出', d['comment'])
|
||||
|
||||
def test_analysis_balanced_single_record(self):
|
||||
|
||||
Reference in New Issue
Block a user