更新
This commit is contained in:
@@ -64,6 +64,7 @@ DEMO_PERMISSIONS: tuple[str, ...] = (
|
|||||||
"tcm.diagnosis/editAiReport",
|
"tcm.diagnosis/editAiReport",
|
||||||
"tcm.diagnosis/aiAnalysis",
|
"tcm.diagnosis/aiAnalysis",
|
||||||
"tcm.diagnosis/aiAssistant",
|
"tcm.diagnosis/aiAssistant",
|
||||||
|
"tcm.diagnosis/aiGeneratePrescription",
|
||||||
"tcm.diagnosis/patientAiReports",
|
"tcm.diagnosis/patientAiReports",
|
||||||
"tcm.diagnosis/generatePatientAiReport",
|
"tcm.diagnosis/generatePatientAiReport",
|
||||||
"tcm.diagnosis/dailyRecord",
|
"tcm.diagnosis/dailyRecord",
|
||||||
|
|||||||
@@ -854,6 +854,11 @@ QUICK_PROMPTS: tuple[tuple[str, str], ...] = (
|
|||||||
("并发症风险评估", "请评估并发症风险并给出随访要点。"),
|
("并发症风险评估", "请评估并发症风险并给出随访要点。"),
|
||||||
("生成问诊问题", "请生成接下来应向患者确认的关键问诊问题。"),
|
("生成问诊问题", "请生成接下来应向患者确认的关键问诊问题。"),
|
||||||
)
|
)
|
||||||
|
AI_PRESCRIPTION_PERMISSION = "tcm.diagnosis/aiGeneratePrescription"
|
||||||
|
PRESCRIPTION_CREATE_PERMISSIONS = (
|
||||||
|
"tcm.diagnosis/chufang",
|
||||||
|
"tcm.diagnosis/kaifang",
|
||||||
|
)
|
||||||
TOOL_ITEMS: tuple[tuple[str, str, str], ...] = (
|
TOOL_ITEMS: tuple[tuple[str, str, str], ...] = (
|
||||||
("chart", "分析病历", "请根据当前病历给出辨证与病情摘要。"),
|
("chart", "分析病历", "请根据当前病历给出辨证与病情摘要。"),
|
||||||
("trend", "血糖趋势", "请评估该患者的血糖控制情况与趋势。"),
|
("trend", "血糖趋势", "请评估该患者的血糖控制情况与趋势。"),
|
||||||
@@ -3662,6 +3667,8 @@ class AiConsultDialog(QDialog):
|
|||||||
hint.setObjectName("AiConsultComposerLabel")
|
hint.setObjectName("AiConsultComposerLabel")
|
||||||
chips.addWidget(hint)
|
chips.addWidget(hint)
|
||||||
for label, prompt in QUICK_PROMPTS[:3]:
|
for label, prompt in QUICK_PROMPTS[:3]:
|
||||||
|
if label == "AI 生成处方" and not self._can_generate_ai_prescription():
|
||||||
|
continue
|
||||||
button = QPushButton(label)
|
button = QPushButton(label)
|
||||||
button.setObjectName("AiConsultChipButton")
|
button.setObjectName("AiConsultChipButton")
|
||||||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
@@ -5464,6 +5471,13 @@ class AiConsultDialog(QDialog):
|
|||||||
def _submit(self) -> None:
|
def _submit(self) -> None:
|
||||||
self._ask(self.input.text())
|
self._ask(self.input.text())
|
||||||
|
|
||||||
|
def _can_generate_ai_prescription(self) -> bool:
|
||||||
|
return has_permission(
|
||||||
|
self.permissions, AI_PRESCRIPTION_PERMISSION, default=False
|
||||||
|
) and has_permission(
|
||||||
|
self.permissions, PRESCRIPTION_CREATE_PERMISSIONS, default=False
|
||||||
|
)
|
||||||
|
|
||||||
def _handle_local_action(self, text: str) -> bool:
|
def _handle_local_action(self, text: str) -> bool:
|
||||||
if not _is_open_prescription_intent(text):
|
if not _is_open_prescription_intent(text):
|
||||||
return False
|
return False
|
||||||
@@ -5474,9 +5488,8 @@ class AiConsultDialog(QDialog):
|
|||||||
text,
|
text,
|
||||||
time_text=datetime.now().strftime("%H:%M"),
|
time_text=datetime.now().strftime("%H:%M"),
|
||||||
)
|
)
|
||||||
can_prescribe = any(
|
can_prescribe = has_permission(
|
||||||
has_permission(self.permissions, code, default=False)
|
self.permissions, PRESCRIPTION_CREATE_PERMISSIONS, default=False
|
||||||
for code in ("tcm.diagnosis/chufang", "tcm.diagnosis/kaifang")
|
|
||||||
)
|
)
|
||||||
if not can_prescribe:
|
if not can_prescribe:
|
||||||
self._append_bubble(
|
self._append_bubble(
|
||||||
@@ -5485,6 +5498,15 @@ class AiConsultDialog(QDialog):
|
|||||||
time_text="系统",
|
time_text="系统",
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
if not has_permission(
|
||||||
|
self.permissions, AI_PRESCRIPTION_PERMISSION, default=False
|
||||||
|
):
|
||||||
|
self._append_bubble(
|
||||||
|
"ai",
|
||||||
|
"当前账号没有 AI 生成处方权限,无法生成处方草稿。",
|
||||||
|
time_text="系统",
|
||||||
|
)
|
||||||
|
return True
|
||||||
if not callable(getattr(self.repository, "create_prescription", None)):
|
if not callable(getattr(self.repository, "create_prescription", None)):
|
||||||
self._append_bubble(
|
self._append_bubble(
|
||||||
"ai",
|
"ai",
|
||||||
|
|||||||
@@ -197,6 +197,52 @@ def test_prescription_action_without_permission_does_not_call_ai_or_repository(
|
|||||||
dialog.close()
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("grants", "visible"),
|
||||||
|
[
|
||||||
|
(["tcm.diagnosis/aiAssistant"], False),
|
||||||
|
(["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang"], False),
|
||||||
|
(["tcm.diagnosis/aiAssistant", "tcm.diagnosis/aiGeneratePrescription"], False),
|
||||||
|
(["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang", "tcm.diagnosis/aiGeneratePrescription"], True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_ai_prescription_quick_prompt_requires_both_permissions(
|
||||||
|
application: QApplication,
|
||||||
|
grants: list[str],
|
||||||
|
visible: bool,
|
||||||
|
) -> None:
|
||||||
|
dialog = AiConsultDialog(DemoDoctorRepository(), PermissionSet(grants))
|
||||||
|
buttons = dialog.findChildren(QPushButton, "AiConsultChipButton")
|
||||||
|
assert ("AI 生成处方" in [button.text() for button in buttons]) is visible
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_prescription_action_with_write_permission_but_without_ai_grant_is_blocked(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
class Repository:
|
||||||
|
def create_prescription(self, _payload: Any) -> None:
|
||||||
|
raise AssertionError("must not create without AI prescription permission")
|
||||||
|
|
||||||
|
def generate_ai_prescription(self, _diagnosis_id: int) -> None:
|
||||||
|
raise AssertionError("must not generate without AI prescription permission")
|
||||||
|
|
||||||
|
dialog = AiConsultDialog(
|
||||||
|
Repository(),
|
||||||
|
PermissionSet(["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang"]),
|
||||||
|
)
|
||||||
|
dialog.diagnosis_id = 501
|
||||||
|
dialog._ask("开个处方")
|
||||||
|
application.processEvents()
|
||||||
|
bodies = [
|
||||||
|
browser.toPlainText()
|
||||||
|
for browser in dialog.findChildren(QTextBrowser, "AiConsultBubbleText")
|
||||||
|
]
|
||||||
|
assert any("没有 AI 生成处方权限" in body for body in bodies)
|
||||||
|
assert dialog._stream_worker is None
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
def test_prescription_action_opens_editor_and_forces_current_diagnosis_ownership(
|
def test_prescription_action_opens_editor_and_forces_current_diagnosis_ownership(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
immediate_async: None,
|
immediate_async: None,
|
||||||
@@ -298,7 +344,7 @@ def test_prescription_action_opens_editor_and_forces_current_diagnosis_ownership
|
|||||||
dialog = AiConsultDialog(
|
dialog = AiConsultDialog(
|
||||||
Repository(),
|
Repository(),
|
||||||
PermissionSet(
|
PermissionSet(
|
||||||
["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang"]
|
["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang", "tcm.diagnosis/aiGeneratePrescription"]
|
||||||
),
|
),
|
||||||
parent=host,
|
parent=host,
|
||||||
)
|
)
|
||||||
@@ -366,7 +412,7 @@ def test_prescription_action_rejects_mismatched_patient_detail(
|
|||||||
dialog = AiConsultDialog(
|
dialog = AiConsultDialog(
|
||||||
Repository(),
|
Repository(),
|
||||||
PermissionSet(
|
PermissionSet(
|
||||||
["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang"]
|
["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang", "tcm.diagnosis/aiGeneratePrescription"]
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
dialog.diagnosis_id = 501
|
dialog.diagnosis_id = 501
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ class DiagnosisAiLogic extends BaseLogic
|
|||||||
|
|
||||||
private const PERMISSION_ASSISTANT = 'tcm.diagnosis/aiassistant';
|
private const PERMISSION_ASSISTANT = 'tcm.diagnosis/aiassistant';
|
||||||
|
|
||||||
|
private const PERMISSION_GENERATE_PRESCRIPTION = 'tcm.diagnosis/aigenerateprescription';
|
||||||
|
|
||||||
private const PATIENT_OPTIONS_DEFAULT_PAGE_SIZE = 20;
|
private const PATIENT_OPTIONS_DEFAULT_PAGE_SIZE = 20;
|
||||||
|
|
||||||
private const PATIENT_OPTIONS_MAX_PAGE_SIZE = 50;
|
private const PATIENT_OPTIONS_MAX_PAGE_SIZE = 50;
|
||||||
@@ -358,6 +360,17 @@ class DiagnosisAiLogic extends BaseLogic
|
|||||||
array $adminInfo
|
array $adminInfo
|
||||||
): ?array {
|
): ?array {
|
||||||
self::$assistantErrorCode = 'AI_ASSISTANT_FAILED';
|
self::$assistantErrorCode = 'AI_ASSISTANT_FAILED';
|
||||||
|
$task = strtolower(trim($task));
|
||||||
|
if (!isset(self::ASSISTANT_TASKS[$task])) {
|
||||||
|
self::setError('不支持的 AI 助手任务');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($task === 'prescription_generate'
|
||||||
|
&& !self::hasPermission($adminId, $adminInfo, self::PERMISSION_GENERATE_PRESCRIPTION)) {
|
||||||
|
self::setError('权限不足,无法使用 AI 生成处方');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$diagnosis = self::loadAuthorizedDiagnosis(
|
$diagnosis = self::loadAuthorizedDiagnosis(
|
||||||
$diagnosisId,
|
$diagnosisId,
|
||||||
$adminId,
|
$adminId,
|
||||||
@@ -369,11 +382,6 @@ class DiagnosisAiLogic extends BaseLogic
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$task = strtolower(trim($task));
|
|
||||||
if (!isset(self::ASSISTANT_TASKS[$task])) {
|
|
||||||
self::setError('不支持的 AI 助手任务');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
$prompt = self::cleanText($prompt, self::MAX_ASSISTANT_PROMPT_LENGTH, true);
|
$prompt = self::cleanText($prompt, self::MAX_ASSISTANT_PROMPT_LENGTH, true);
|
||||||
if ($task === 'custom' && $prompt === '') {
|
if ($task === 'custom' && $prompt === '') {
|
||||||
self::setError('请输入要咨询的问题');
|
self::setError('请输入要咨询的问题');
|
||||||
|
|||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
-- AI 生成处方单独授权。默认不给现有角色,须在管理端角色权限中明确勾选。
|
||||||
|
-- 默认表前缀为 zyt_;与 AI 助手同级显示。
|
||||||
|
START TRANSACTION;
|
||||||
|
|
||||||
|
SET @diagnosis_ai_assistant_parent_id := (
|
||||||
|
SELECT `pid`
|
||||||
|
FROM `zyt_system_menu`
|
||||||
|
WHERE `perms` = 'tcm.diagnosis/aiAssistant'
|
||||||
|
ORDER BY `id`
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @diagnosis_menu_id := (
|
||||||
|
SELECT `id`
|
||||||
|
FROM `zyt_system_menu`
|
||||||
|
WHERE `perms` = 'tcm.diagnosis/lists' AND `type` = 'C'
|
||||||
|
ORDER BY `id`
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO `zyt_system_menu` (
|
||||||
|
`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`,
|
||||||
|
`selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COALESCE(@diagnosis_ai_assistant_parent_id, @diagnosis_menu_id, 0),
|
||||||
|
'A', 'AI生成处方', '', 70, 'tcm.diagnosis/aiGeneratePrescription', '', '',
|
||||||
|
'', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM `zyt_system_menu`
|
||||||
|
WHERE `perms` = 'tcm.diagnosis/aiGeneratePrescription'
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
use app\adminapi\logic\tcm\DiagnosisAiLogic;
|
||||||
|
|
||||||
|
final class PrescriptionGenerateAuthCacheDouble
|
||||||
|
{
|
||||||
|
/** @var array<int,string> */
|
||||||
|
public static array $uris = [];
|
||||||
|
|
||||||
|
public function __construct(int $adminId = 0)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int,string> */
|
||||||
|
public function getAdminUri(): array
|
||||||
|
{
|
||||||
|
return self::$uris;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prescriptionPermissionExpect(bool $condition, string $message): void
|
||||||
|
{
|
||||||
|
if (!$condition) {
|
||||||
|
fwrite(STDERR, "FAIL: {$message}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prescriptionPermissionExpect(
|
||||||
|
class_alias(PrescriptionGenerateAuthCacheDouble::class, 'app\\common\\cache\\AdminAuthCache'),
|
||||||
|
'permission cache double is installed before logic autoload'
|
||||||
|
);
|
||||||
|
|
||||||
|
$reflection = new ReflectionClass(DiagnosisAiLogic::class);
|
||||||
|
prescriptionPermissionExpect(
|
||||||
|
$reflection->getConstant('PERMISSION_GENERATE_PRESCRIPTION') === 'tcm.diagnosis/aigenerateprescription',
|
||||||
|
'prescription generation has a dedicated permission'
|
||||||
|
);
|
||||||
|
|
||||||
|
PrescriptionGenerateAuthCacheDouble::$uris = ['tcm.diagnosis/aiAssistant'];
|
||||||
|
prescriptionPermissionExpect(
|
||||||
|
DiagnosisAiLogic::prepareAssistant(1, 'prescription_generate', '', 7, ['root' => 0]) === null,
|
||||||
|
'general assistant permission alone cannot generate a prescription'
|
||||||
|
);
|
||||||
|
prescriptionPermissionExpect(
|
||||||
|
DiagnosisAiLogic::getError() === '权限不足,无法使用 AI 生成处方',
|
||||||
|
'denial names the missing prescription permission'
|
||||||
|
);
|
||||||
|
prescriptionPermissionExpect(
|
||||||
|
DiagnosisAiLogic::assistant(1, 'prescription_generate', '', 7, ['root' => 0]) === null,
|
||||||
|
'blocking endpoint rejects the task before any upstream call'
|
||||||
|
);
|
||||||
|
prescriptionPermissionExpect(
|
||||||
|
DiagnosisAiLogic::prepareAssistant(1, 'PRESCRIPTION_GENERATE', '', 7, ['root' => 0]) === null
|
||||||
|
&& DiagnosisAiLogic::getError() === '权限不足,无法使用 AI 生成处方',
|
||||||
|
'task name normalization cannot bypass the dedicated permission'
|
||||||
|
);
|
||||||
|
prescriptionPermissionExpect(
|
||||||
|
DiagnosisAiLogic::prepareAssistant(0, 'summary', '', 7, ['root' => 0]) === null
|
||||||
|
&& DiagnosisAiLogic::getError() === '诊单ID必须大于0',
|
||||||
|
'ordinary assistant tasks continue past the prescription permission check'
|
||||||
|
);
|
||||||
|
|
||||||
|
PrescriptionGenerateAuthCacheDouble::$uris = ['tcm.diagnosis/aiGeneratePrescription'];
|
||||||
|
prescriptionPermissionExpect(
|
||||||
|
DiagnosisAiLogic::prepareAssistant(1, 'prescription_generate', '', 7, ['root' => 0]) === null,
|
||||||
|
'prescription permission alone does not bypass the general assistant permission'
|
||||||
|
);
|
||||||
|
prescriptionPermissionExpect(
|
||||||
|
DiagnosisAiLogic::getError() === '权限不足,无法使用诊单 AI 助手',
|
||||||
|
'general assistant permission remains required'
|
||||||
|
);
|
||||||
|
|
||||||
|
$hasPermission = $reflection->getMethod('hasPermission');
|
||||||
|
prescriptionPermissionExpect(
|
||||||
|
$hasPermission->invoke(null, 7, ['root' => 0], 'tcm.diagnosis/aigenerateprescription') === true,
|
||||||
|
'new permission is read from role permissions'
|
||||||
|
);
|
||||||
|
prescriptionPermissionExpect(
|
||||||
|
$hasPermission->invoke(null, 1, ['root' => 1], 'tcm.diagnosis/aigenerateprescription') === true,
|
||||||
|
'root keeps its existing permission bypass'
|
||||||
|
);
|
||||||
|
|
||||||
|
$migration = file_get_contents(
|
||||||
|
dirname(__DIR__) . '/database/migrations/2026_10_08_diagnosis_ai_generate_prescription_permission.sql'
|
||||||
|
);
|
||||||
|
prescriptionPermissionExpect(
|
||||||
|
is_string($migration)
|
||||||
|
&& str_contains($migration, "'tcm.diagnosis/aiGeneratePrescription'")
|
||||||
|
&& str_contains($migration, "'AI生成处方'"),
|
||||||
|
'management role tree registers the dedicated checkbox'
|
||||||
|
);
|
||||||
|
prescriptionPermissionExpect(
|
||||||
|
!str_contains($migration, 'zyt_system_role_menu'),
|
||||||
|
'migration does not silently grant the new permission to existing roles'
|
||||||
|
);
|
||||||
|
|
||||||
|
echo "Diagnosis AI prescription generation permission: OK\n";
|
||||||
Reference in New Issue
Block a user