更新
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
use app\adminapi\logic\setting\DesktopWorkstationLogic;
|
||||
|
||||
function desktopUpdateExpect(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) {
|
||||
fwrite(STDERR, "FAIL: {$message}\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
desktopUpdateExpect(
|
||||
DesktopWorkstationLogic::normalizeVersion('0.2') === '0.2.0',
|
||||
'short versions pad to three segments'
|
||||
);
|
||||
desktopUpdateExpect(
|
||||
DesktopWorkstationLogic::compareVersion('0.1.0', '0.2.0') < 0,
|
||||
'0.1.0 is older than 0.2.0'
|
||||
);
|
||||
desktopUpdateExpect(
|
||||
DesktopWorkstationLogic::packageKey('win32', 'amd64') === 'windows_x64',
|
||||
'windows/amd64 maps to windows_x64'
|
||||
);
|
||||
desktopUpdateExpect(
|
||||
DesktopWorkstationLogic::packageKey('darwin', 'aarch64') === 'macos_arm64',
|
||||
'darwin/arm maps to macos_arm64'
|
||||
);
|
||||
|
||||
$sha = str_repeat('a', 64);
|
||||
$config = [
|
||||
'enabled' => 1,
|
||||
'latest_version' => '0.2.0',
|
||||
'min_version' => '0.1.5',
|
||||
'force_update' => 0,
|
||||
'title' => '医生工作站 0.2.0',
|
||||
'notes' => '稳定性更新',
|
||||
'packages' => [
|
||||
'windows_x64' => [
|
||||
'url' => 'https://cdn.example.com/DoctorWorkstation-Windows-x64-0.2.0.zip',
|
||||
'sha256' => $sha,
|
||||
'size' => 123,
|
||||
'filename' => 'DoctorWorkstation-Windows-x64-0.2.0.zip',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$optional = DesktopWorkstationLogic::evaluate($config, '0.1.8', 'windows', 'x64');
|
||||
desktopUpdateExpect($optional['has_update'] === true, 'newer published version is an update');
|
||||
desktopUpdateExpect($optional['force'] === false, 'force stays off when above min version');
|
||||
desktopUpdateExpect($optional['can_install'] === true, 'hashed package can be installed');
|
||||
desktopUpdateExpect(
|
||||
is_array($optional['package']) && $optional['package']['sha256'] === $sha,
|
||||
'matching windows package is returned'
|
||||
);
|
||||
|
||||
$forcedByMin = DesktopWorkstationLogic::evaluate($config, '0.1.0', 'windows', 'x64');
|
||||
desktopUpdateExpect($forcedByMin['force'] === true, 'below min version forces upgrade');
|
||||
|
||||
$config['force_update'] = 1;
|
||||
$forcedAll = DesktopWorkstationLogic::evaluate($config, '0.1.9', 'windows', 'x64');
|
||||
desktopUpdateExpect($forcedAll['force'] === true, 'force_update blocks every older client');
|
||||
|
||||
$current = DesktopWorkstationLogic::evaluate($config, '0.2.0', 'windows', 'x64');
|
||||
desktopUpdateExpect($current['has_update'] === false, 'current latest version is not an update');
|
||||
|
||||
$macosMissing = DesktopWorkstationLogic::evaluate($config, '0.1.0', 'macos', 'arm64');
|
||||
desktopUpdateExpect($macosMissing['has_update'] === true, 'other platforms still see the new version');
|
||||
desktopUpdateExpect($macosMissing['can_install'] === false, 'missing platform package cannot auto-install');
|
||||
desktopUpdateExpect($macosMissing['force'] === false, 'force requires an installable package');
|
||||
|
||||
$disabled = $config;
|
||||
$disabled['enabled'] = 0;
|
||||
$quiet = DesktopWorkstationLogic::evaluate($disabled, '0.1.0', 'windows', 'x64');
|
||||
desktopUpdateExpect($quiet['has_update'] === false, 'disabled detection never prompts');
|
||||
|
||||
$controller = file_get_contents(dirname(__DIR__) . '/app/adminapi/controller/setting/DesktopWorkstationController.php');
|
||||
desktopUpdateExpect(is_string($controller), 'controller source is readable');
|
||||
desktopUpdateExpect(
|
||||
str_contains($controller, "public array \$notNeedLogin = ['check']")
|
||||
&& str_contains($controller, 'public function check()')
|
||||
&& str_contains($controller, 'DesktopWorkstationLogic::check('),
|
||||
'check action is public and delegates to logic'
|
||||
);
|
||||
|
||||
$adminView = file_get_contents(dirname(__DIR__, 2) . '/admin/src/views/setting/desktop_workstation/index.vue');
|
||||
desktopUpdateExpect(is_string($adminView), 'admin view source is readable');
|
||||
desktopUpdateExpect(
|
||||
str_contains($adminView, 'setting.desktop_workstation/setConfig')
|
||||
&& str_contains($adminView, 'force_update'),
|
||||
'admin page can save force-update configuration'
|
||||
);
|
||||
|
||||
$migration = file_get_contents(
|
||||
dirname(__DIR__) . '/sql/1.9.20260821/add_desktop_workstation_update_menu.sql'
|
||||
);
|
||||
desktopUpdateExpect(is_string($migration), 'menu migration is readable');
|
||||
desktopUpdateExpect(
|
||||
substr_count($migration, "WHERE `perms` = 'setting.desktop_workstation/getConfig'") >= 1
|
||||
&& str_contains($migration, 'setting.desktop_workstation/setConfig')
|
||||
&& str_contains($migration, 'setting/desktop_workstation/index'),
|
||||
'menu registration is idempotent and points at the admin view'
|
||||
);
|
||||
|
||||
echo "Desktop workstation update contract: OK\n";
|
||||
@@ -19,6 +19,7 @@ $selectProfile = $reflection->getMethod('selectAssistantProfile');
|
||||
$buildPrompt = $reflection->getMethod('buildAssistantPrompt');
|
||||
$buildReportPrompt = $reflection->getMethod('buildPrompt');
|
||||
$buildInputs = $reflection->getMethod('buildUpstreamInputs');
|
||||
$parseDraft = $reflection->getMethod('parsePrescriptionDraft');
|
||||
$tasks = $reflection->getConstant('ASSISTANT_TASKS');
|
||||
$assistantPermission = $reflection->getConstant('PERMISSION_ASSISTANT');
|
||||
|
||||
@@ -32,6 +33,7 @@ assistantExpect(
|
||||
'summary',
|
||||
'tcm_pattern',
|
||||
'prescription_review',
|
||||
'prescription_generate',
|
||||
'medication_review',
|
||||
'exam_review',
|
||||
'complication_risk',
|
||||
@@ -43,10 +45,11 @@ assistantExpect(
|
||||
|
||||
assistantExpect($selectProfile->invoke(null, 'summary', '') === 'qwen', 'summary routes to qwen');
|
||||
assistantExpect($selectProfile->invoke(null, 'tcm_pattern', '') === 'qwen', 'TCM routes to qwen');
|
||||
assistantExpect($selectProfile->invoke(null, 'exam_review', '') === 'openai', 'exam routes to openai');
|
||||
assistantExpect($selectProfile->invoke(null, 'prescription_generate', '') === 'qwen', 'prescription generation routes to qwen');
|
||||
assistantExpect($selectProfile->invoke(null, 'exam_review', '') === 'qwen', 'exam routes to qwen');
|
||||
assistantExpect(
|
||||
$selectProfile->invoke(null, 'custom', '请评估并发症风险') === 'openai',
|
||||
'risk prompt routes to openai'
|
||||
$selectProfile->invoke(null, 'custom', '请评估并发症风险') === 'qwen',
|
||||
'risk prompt routes to qwen'
|
||||
);
|
||||
assistantExpect(
|
||||
$selectProfile->invoke(null, 'custom', '请分析中药处方') === 'qwen',
|
||||
@@ -80,6 +83,30 @@ assistantExpect(substr_count($prompt, '13812345678') === 0, 'question phone is a
|
||||
assistantExpect(str_contains($prompt, '</USER_QUESTION>'), 'injected boundary is neutralized');
|
||||
assistantExpect(str_contains($prompt, '密钥索取'), 'highest-priority safety boundary is present');
|
||||
|
||||
$draftPrompt = $buildPrompt->invoke(null, $context, 'prescription_generate', '请生成处方草稿');
|
||||
assistantExpect(str_contains($draftPrompt, 'PATIENT_LONGITUDINAL_SOURCE'), 'prescription prompt uses longitudinal context');
|
||||
assistantExpect(str_contains($draftPrompt, 'prescription_draft'), 'prescription prompt requires structured draft JSON');
|
||||
assistantExpect(str_contains($draftPrompt, '逐味复核'), 'prescription draft requires doctor review');
|
||||
$draft = $parseDraft->invoke(null, json_encode([
|
||||
'prescription_draft' => [
|
||||
'clinical_diagnosis' => '气阴两虚证',
|
||||
'herbs' => [
|
||||
['name' => '黄芪', 'dosage' => 15, 'formula_type' => '主方'],
|
||||
['name' => '山药', 'dosage' => 12, 'formula_type' => '主方'],
|
||||
],
|
||||
'dose_count' => 7,
|
||||
'usage_days' => 7,
|
||||
'times_per_day' => 2,
|
||||
'rationale' => '结合完整资料辨证拟方',
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
assistantExpect(is_array($draft) && count($draft['herbs']) === 2, 'valid prescription draft is parsed');
|
||||
assistantExpect($draft['requires_doctor_review'] === true && $draft['audit_status'] === 0, 'draft cannot bypass review');
|
||||
assistantExpect(
|
||||
$parseDraft->invoke(null, '{"prescription_draft":{"clinical_diagnosis":"证型","herbs":[{"name":"黄芪","dosage":10},{"name":"黄芪","dosage":12}]}}') === null,
|
||||
'duplicate herbs are rejected'
|
||||
);
|
||||
|
||||
$reportPrompt = $buildReportPrompt->invoke(null, $context);
|
||||
assistantExpect(!str_contains($reportPrompt, '13812345678'), 'saved report prompt redacts phone');
|
||||
assistantExpect(!str_contains($reportPrompt, '11010519491231002X'), 'saved report prompt redacts ID');
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
use app\adminapi\logic\tcm\DiagnosisAiLogic;
|
||||
|
||||
final class DiagnosisAiPatientOptionsAuthCacheDouble
|
||||
{
|
||||
/** @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 patientOptionsExpect(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) {
|
||||
fwrite(STDERR, "FAIL: {$message}\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
patientOptionsExpect(
|
||||
class_alias(DiagnosisAiPatientOptionsAuthCacheDouble::class, 'app\\common\\cache\\AdminAuthCache'),
|
||||
'permission cache double is installed before logic autoload'
|
||||
);
|
||||
|
||||
$reflection = new ReflectionClass(DiagnosisAiLogic::class);
|
||||
$source = file_get_contents($reflection->getFileName());
|
||||
patientOptionsExpect(is_string($source), 'logic source is readable');
|
||||
|
||||
$hasPermission = $reflection->getMethod('hasPermission');
|
||||
DiagnosisAiPatientOptionsAuthCacheDouble::$uris = ['tcm.diagnosis/aiAssistant'];
|
||||
patientOptionsExpect(
|
||||
$hasPermission->invoke(null, 7, ['root' => 0], 'tcm.diagnosis/aiassistant') === true,
|
||||
'roles with aiAssistant pass the explicit logic permission check'
|
||||
);
|
||||
DiagnosisAiPatientOptionsAuthCacheDouble::$uris = ['tcm.diagnosis/aiReports'];
|
||||
patientOptionsExpect(
|
||||
$hasPermission->invoke(null, 7, ['root' => 0], 'tcm.diagnosis/aiassistant') === false,
|
||||
'adjacent AI permissions do not grant patient options access'
|
||||
);
|
||||
patientOptionsExpect(
|
||||
$hasPermission->invoke(null, 1, ['root' => 1], 'tcm.diagnosis/aiassistant') === true,
|
||||
'root permission compatibility is preserved'
|
||||
);
|
||||
|
||||
$normalize = $reflection->getMethod('normalizePatientOptionsParams');
|
||||
$defaults = $normalize->invoke(null, []);
|
||||
patientOptionsExpect(
|
||||
$defaults === ['keyword' => '', 'page_no' => 1, 'page_size' => 20],
|
||||
'pagination defaults are stable'
|
||||
);
|
||||
$bounded = $normalize->invoke(null, ['keyword' => ' 张三 ', 'page_no' => -8, 'page_size' => 500]);
|
||||
patientOptionsExpect(
|
||||
$bounded === ['keyword' => '张三', 'page_no' => 1, 'page_size' => 50],
|
||||
'keyword is trimmed and pagination is bounded'
|
||||
);
|
||||
patientOptionsExpect(
|
||||
$normalize->invoke(null, ['keyword' => str_repeat('患', 65)]) === null,
|
||||
'keywords longer than 64 characters fail validation'
|
||||
);
|
||||
|
||||
$format = $reflection->getMethod('formatPatientOptionRow');
|
||||
$dto = $format->invoke(null, [
|
||||
'diagnosis_id' => 18,
|
||||
'source_patient_id' => 77,
|
||||
'patient_name' => '张三',
|
||||
'gender' => 1,
|
||||
'age' => 42,
|
||||
'phone_value' => '13812345678',
|
||||
'id_card' => '11010519491231002X',
|
||||
'diagnosis_date' => 1787155200,
|
||||
'diagnosis_summary' => '气阴两虚',
|
||||
'last_visit_at' => '2026-08-18 09:30:00',
|
||||
'next_appointment_at' => '2026-08-22 14:00:00',
|
||||
]);
|
||||
patientOptionsExpect(
|
||||
array_keys($dto) === [
|
||||
'diagnosis_id',
|
||||
'source_patient_id',
|
||||
'patient_name',
|
||||
'gender',
|
||||
'age',
|
||||
'phone_masked',
|
||||
'diagnosis_date',
|
||||
'diagnosis_summary',
|
||||
'last_visit_at',
|
||||
'next_appointment_at',
|
||||
],
|
||||
'diagnosis option DTO exposes only the minimal allowlist'
|
||||
);
|
||||
patientOptionsExpect($dto['phone_masked'] === '138****5678', 'phone is masked');
|
||||
patientOptionsExpect($dto['diagnosis_summary'] === '气阴两虚', 'safe diagnosis summary is retained');
|
||||
$encodedDto = json_encode($dto, JSON_UNESCAPED_UNICODE);
|
||||
patientOptionsExpect(is_string($encodedDto), 'DTO is JSON encodable');
|
||||
patientOptionsExpect(!str_contains($encodedDto, '13812345678'), 'plain phone is absent');
|
||||
patientOptionsExpect(!str_contains($encodedDto, '11010519491231002X'), 'ID card is absent');
|
||||
|
||||
$patientOptions = $reflection->getMethod('patientOptions');
|
||||
$sourceLines = file($reflection->getFileName());
|
||||
$methodSource = is_array($sourceLines) ? implode('', array_slice(
|
||||
$sourceLines,
|
||||
$patientOptions->getStartLine() - 1,
|
||||
$patientOptions->getEndLine() - $patientOptions->getStartLine() + 1
|
||||
)) : '';
|
||||
patientOptionsExpect(
|
||||
str_contains($methodSource, 'self::hasPermission($adminId, $adminInfo, self::PERMISSION_ASSISTANT)'),
|
||||
'endpoint explicitly checks the existing AI assistant permission'
|
||||
);
|
||||
patientOptionsExpect(
|
||||
str_contains($methodSource, 'MyPatientLogic::applyScope($query, $adminId, $adminInfo)'),
|
||||
'query applies the shared patient data scope'
|
||||
);
|
||||
patientOptionsExpect(
|
||||
str_contains($methodSource, "->where('d.status', 1)")
|
||||
&& str_contains($methodSource, "->whereNull('d.delete_time')"),
|
||||
'query only exposes enabled, non-deleted diagnoses'
|
||||
);
|
||||
patientOptionsExpect(
|
||||
str_contains($methodSource, 'patient_option_apt.status = 3')
|
||||
&& str_contains($methodSource, 'patient_option_apt.status = 1')
|
||||
&& str_contains($methodSource, "->order('d.id', 'desc')"),
|
||||
'appointment summaries and deterministic diagnosis ordering are present'
|
||||
);
|
||||
patientOptionsExpect(
|
||||
substr_count($methodSource, '->select()') === 1,
|
||||
'page data is fetched in one query without row-level lookups'
|
||||
);
|
||||
|
||||
$controller = file_get_contents(dirname(__DIR__) . '/app/adminapi/controller/tcm/DiagnosisController.php');
|
||||
patientOptionsExpect(is_string($controller), 'controller source is readable');
|
||||
patientOptionsExpect(
|
||||
str_contains($controller, 'public function aiPatientOptions()')
|
||||
&& str_contains($controller, 'DiagnosisAiLogic::patientOptions('),
|
||||
'GET controller action delegates to the scoped logic'
|
||||
);
|
||||
|
||||
$migration = file_get_contents(
|
||||
dirname(__DIR__) . '/database/migrations/2026_08_20_diagnosis_ai_patient_options_permission.sql'
|
||||
);
|
||||
patientOptionsExpect(is_string($migration), 'permission migration is readable');
|
||||
patientOptionsExpect(
|
||||
substr_count($migration, "WHERE `perms` = 'tcm.diagnosis/aiPatientOptions'") >= 2,
|
||||
'endpoint permission registration is idempotent and addressable'
|
||||
);
|
||||
patientOptionsExpect(
|
||||
str_contains($migration, "`menu`.`perms` = 'tcm.diagnosis/aiAssistant'")
|
||||
&& str_contains($migration, 'INSERT IGNORE INTO `zyt_system_role_menu`'),
|
||||
'roles owning aiAssistant inherit the endpoint permission idempotently'
|
||||
);
|
||||
|
||||
echo "Diagnosis AI patient options contract: OK\n";
|
||||
@@ -36,8 +36,10 @@ difyStreamExpect(
|
||||
);
|
||||
difyStreamExpect($generic[1]['protocol'] === 'openai', 'OpenAI remains the fallback protocol');
|
||||
difyStreamExpect($generic[1]['payload']['stream'] === true, 'OpenAI stream request uses stream=true');
|
||||
// inputs 以对象语义上线(空 inputs 编码成 [] 会被 Dify 判为 invalid_param),
|
||||
// 因此按线上实际编码结果断言,而不是按 PHP 数组比较。
|
||||
difyStreamExpect(
|
||||
$generic[0]['payload']['inputs'] === ['case' => 'redacted']
|
||||
json_decode((string) json_encode($generic[0]['payload']['inputs']), true) === ['case' => 'redacted']
|
||||
&& $generic[0]['payload']['query'] === 'safe query'
|
||||
&& $generic[0]['payload']['user'] === 'admin-safe',
|
||||
'Dify streaming preserves structured inputs, query and user'
|
||||
@@ -59,6 +61,31 @@ difyStreamExpect(
|
||||
'legacy OpenAI blocking request does not gain a stream field'
|
||||
);
|
||||
|
||||
$withFiles = callDifyStreamPrivate('buildRequestSpecs', [
|
||||
'https://ai.example.test/v1',
|
||||
'model-safe',
|
||||
['case' => 'full-context'],
|
||||
'analyze all supplied records',
|
||||
'admin-safe',
|
||||
false,
|
||||
[
|
||||
['type' => 'image', 'transfer_method' => 'remote_url', 'url' => 'https://files.example.test/tongue.jpg'],
|
||||
['type' => 'document', 'transfer_method' => 'remote_url', 'url' => 'https://files.example.test/report.pdf'],
|
||||
],
|
||||
]);
|
||||
// 回退协议保留(网关 404/405 时才会用到),但任何附件都不得被静默丢弃:
|
||||
// Dify 走文件通道,OpenAI-compatible 无法内联的附件必须落在提示词清单里。
|
||||
difyStreamExpect(count($withFiles) === 2, 'ambiguous /v1 keeps the OpenAI fallback reachable');
|
||||
difyStreamExpect($withFiles[0]['protocol'] === 'dify', 'Dify remains the preferred protocol');
|
||||
difyStreamExpect(count($withFiles[0]['payload']['files']) === 2, 'Dify receives every supplied clinical file');
|
||||
difyStreamExpect($withFiles[0]['payload']['files'][0]['type'] === 'image', 'tongue image keeps image type');
|
||||
difyStreamExpect($withFiles[0]['payload']['files'][1]['type'] === 'document', 'report keeps document type');
|
||||
$fallbackText = $withFiles[1]['payload']['messages'][0]['content'][0]['text'];
|
||||
difyStreamExpect(
|
||||
str_contains($fallbackText, 'https://files.example.test/report.pdf'),
|
||||
'the fallback protocol declares the report it cannot inline'
|
||||
);
|
||||
|
||||
$explicitDify = callDifyStreamPrivate('buildRequestSpecs', [
|
||||
'https://ai.example.test/v1/chat-messages', 'model-safe', [], 'query', 'user', true,
|
||||
]);
|
||||
|
||||
@@ -19,7 +19,7 @@ function patientReportContractExpect(bool $condition, string $message): void
|
||||
$reflection = new ReflectionClass(PatientAiReportLogic::class);
|
||||
patientReportContractExpect(
|
||||
$reflection->getConstant('DISCLAIMER')
|
||||
=== '仅供临床辅助参考,不可替代医生诊断,不得直接用于开方、用药调整或其他医疗决策。系统未对舌像、报告附件或视频画面进行视觉诊断;仅分析已录入、归档或转写的文字及附件元数据。',
|
||||
=== '仅供临床辅助参考,不可替代医生诊断。系统会把舌像、报告等附件与全部文字资料提交给已配置的模型分析,但模型识别结果仍须由执业医师核对原始资料;视频面诊以归档转写文字为准。',
|
||||
'fixed medical disclaimer is exact'
|
||||
);
|
||||
patientReportContractExpect(
|
||||
@@ -33,8 +33,9 @@ patientReportContractExpect(
|
||||
|
||||
$logicSource = file_get_contents($reflection->getFileName());
|
||||
patientReportContractExpect(is_string($logicSource), 'patient report logic source is readable');
|
||||
// 报告生成与 compactSourceForPrompt 各有一组“单次 / 分片 / 归并”调用点,共 6 处。
|
||||
patientReportContractExpect(
|
||||
substr_count($logicSource, 'DifyChatService::chat(') === 4,
|
||||
substr_count($logicSource, 'DifyChatService::chat(') === 6,
|
||||
'single-pass, evidence-chunk, summary-reduction, and final synthesis upstream call sites are explicit'
|
||||
);
|
||||
patientReportContractExpect(
|
||||
|
||||
@@ -71,8 +71,8 @@ patientPermissionExpect(
|
||||
patientPermissionExpect(
|
||||
str_contains($source, "->where('d.patient_id', \$patientId)")
|
||||
&& str_contains($source, "->whereNull('d.delete_time')")
|
||||
&& str_contains($source, "->where('d.status', 1)"),
|
||||
'authorization derives visible diagnosis rows from the stable patient id'
|
||||
&& !str_contains($source, "->where('d.status', 1)"),
|
||||
'authorization derives every non-deleted visible diagnosis row from the stable patient id'
|
||||
);
|
||||
patientPermissionExpect(
|
||||
str_contains($source, "->whereIn('diagnosis_id', \$diagnosisIds)"),
|
||||
@@ -103,7 +103,7 @@ $baseRow = [
|
||||
'report_json' => '{"diagnosis":"诊断","risk_assessment":[],"treatment_advice":"建议"}',
|
||||
'source_summary_json' => '{"diagnosis_count":2}',
|
||||
'source_hash' => str_repeat('a', 64),
|
||||
'prompt_version' => 'patient-longitudinal-report-v1',
|
||||
'prompt_version' => 'patient-longitudinal-report-v2',
|
||||
'generated_at' => 1786665600,
|
||||
'created_at' => 1786665600,
|
||||
];
|
||||
|
||||
@@ -89,6 +89,19 @@ $sources = [
|
||||
'duration' => 35,
|
||||
'intensity' => 2,
|
||||
]],
|
||||
'prescriptions' => [[
|
||||
'id' => 12,
|
||||
'diagnosis_id' => 101,
|
||||
'patient_id' => 88,
|
||||
'prescription_name' => '益气养阴方',
|
||||
'clinical_diagnosis' => '气阴两虚证',
|
||||
'case_record' => '{"present_illness":"口渴乏力一月"}',
|
||||
'herbs' => '[{"name":"黄芪","dosage":15,"formula_type":"主方"}]',
|
||||
'dose_count' => 7,
|
||||
'usage_days' => 7,
|
||||
'times_per_day' => 2,
|
||||
'audit_status' => 1,
|
||||
]],
|
||||
'im_messages' => [[
|
||||
'id' => 6,
|
||||
'diagnosis_id' => 101,
|
||||
@@ -153,6 +166,12 @@ patientSnapshotExpect(count($snapshot['doctor_notes'][0]['report_files']) === 1,
|
||||
patientSnapshotExpect(count($snapshot['daily_records']['blood_glucose_pressure']) === 1, 'blood daily records are aggregated');
|
||||
patientSnapshotExpect(count($snapshot['daily_records']['diet']) === 1, 'diet daily records are aggregated');
|
||||
patientSnapshotExpect(count($snapshot['daily_records']['exercise']) === 1, 'exercise daily records are aggregated');
|
||||
patientSnapshotExpect(count($snapshot['prescriptions']) === 1, 'formal prescriptions are aggregated');
|
||||
patientSnapshotExpect($snapshot['prescriptions'][0]['herbs'][0]['name'] === '黄芪', 'prescription herbs are decoded');
|
||||
patientSnapshotExpect(
|
||||
$snapshot['prescriptions'][0]['case_record']['present_illness'] === '口渴乏力一月',
|
||||
'prescription case history snapshot is decoded'
|
||||
);
|
||||
patientSnapshotExpect(count($snapshot['chat_records']['tencent_im']) === 1, 'IM chat is aggregated');
|
||||
patientSnapshotExpect(count($snapshot['chat_records']['wechat_work']) === 1, 'WeChat Work chat is aggregated');
|
||||
patientSnapshotExpect(count($snapshot['video_calls'][0]['segments']) === 2, 'every call includes transcript segments');
|
||||
@@ -171,6 +190,7 @@ foreach ([
|
||||
'blood_record_count' => 1,
|
||||
'diet_record_count' => 1,
|
||||
'exercise_record_count' => 1,
|
||||
'prescription_count' => 1,
|
||||
'im_message_count' => 1,
|
||||
'wechat_message_count' => 1,
|
||||
'call_record_count' => 1,
|
||||
@@ -198,6 +218,12 @@ patientSnapshotExpect(
|
||||
'filename-shaped fields are redacted upstream'
|
||||
);
|
||||
patientSnapshotExpect(str_contains($upstreamJson, 'attachment_count'), 'attachment presence remains available upstream');
|
||||
// 方名、外院机构名和药味名是临床内容,不是身份信息;把它们一并脱敏会让模型
|
||||
// 看不到既往用方,直接影响处方草稿与用药复核。
|
||||
patientSnapshotExpect(
|
||||
str_contains($upstreamJson, '益气养阴方'),
|
||||
'clinical prescription_name survives upstream sanitisation'
|
||||
);
|
||||
|
||||
$longText = str_repeat('超长病历段落甲乙丙。', 20000);
|
||||
$manyNotes = [];
|
||||
|
||||
@@ -38,6 +38,48 @@ expectSame(false, str_contains($serializedSpecs, 'api_key'), 'credential field i
|
||||
expectSame(false, str_contains($serializedSpecs, 'provider'), 'provider override is absent from request bodies');
|
||||
expectSame(false, str_contains($serializedSpecs, 'base_url'), 'base URL override is absent from request bodies');
|
||||
|
||||
// 附件不得让 OpenAI-compatible 回退失效。历史实现遇到报告/录像等非图片附件时只返回
|
||||
// Dify 一种协议,网关 404 会直接变成“模型未能处理本次请求”,开处方因此必失败。
|
||||
$attachments = [
|
||||
['type' => 'document', 'transfer_method' => 'remote_url', 'url' => 'https://cdn.example.test/report.pdf'],
|
||||
['type' => 'video', 'transfer_method' => 'remote_url', 'url' => 'https://cdn.example.test/call.mp4'],
|
||||
['type' => 'image', 'transfer_method' => 'remote_url', 'url' => 'https://cdn.example.test/tongue.jpg'],
|
||||
];
|
||||
$withFiles = callPrivate('buildRequestSpecs', [
|
||||
'https://ai.example.test/v1',
|
||||
'model-name',
|
||||
[],
|
||||
'clinical prompt',
|
||||
'server-user',
|
||||
false,
|
||||
$attachments,
|
||||
]);
|
||||
expectSame(2, count($withFiles), 'non-image attachments must keep the OpenAI fallback available');
|
||||
expectSame($attachments, $withFiles[0]['payload']['files'], 'Dify still receives every attachment');
|
||||
expectSame(
|
||||
'clinical prompt',
|
||||
$withFiles[0]['payload']['query'],
|
||||
'Dify carries attachments in the file channel, not as a manifest'
|
||||
);
|
||||
$openAiContent = $withFiles[1]['payload']['messages'][0]['content'];
|
||||
expectSame(true, is_array($openAiContent), 'OpenAI content becomes multimodal when attachments exist');
|
||||
expectSame('text', $openAiContent[0]['type'], 'attachment manifest travels in the text part');
|
||||
expectSame(
|
||||
true,
|
||||
str_contains($openAiContent[0]['text'], 'https://cdn.example.test/report.pdf'),
|
||||
'document attachment is listed instead of being silently dropped'
|
||||
);
|
||||
expectSame(
|
||||
true,
|
||||
str_contains($openAiContent[0]['text'], 'https://cdn.example.test/call.mp4'),
|
||||
'recording attachment is listed instead of being silently dropped'
|
||||
);
|
||||
expectSame(
|
||||
'https://cdn.example.test/tongue.jpg',
|
||||
$openAiContent[1]['image_url']['url'],
|
||||
'image attachments stay inline for multimodal reading'
|
||||
);
|
||||
|
||||
$openAi = callPrivate('buildRequestSpecs', [
|
||||
'https://ai.example.test/v1/chat/completions',
|
||||
'model-name',
|
||||
@@ -48,6 +90,30 @@ $openAi = callPrivate('buildRequestSpecs', [
|
||||
expectSame(1, count($openAi), 'explicit OpenAI endpoint should not probe Dify');
|
||||
expectSame('openai', $openAi[0]['protocol'], 'explicit OpenAI protocol');
|
||||
|
||||
$openAiWithFiles = callPrivate('buildRequestSpecs', [
|
||||
'https://ai.example.test/v1/chat/completions',
|
||||
'model-name',
|
||||
[],
|
||||
'prompt',
|
||||
'server-user',
|
||||
false,
|
||||
$attachments,
|
||||
]);
|
||||
expectSame(1, count($openAiWithFiles), 'explicit OpenAI endpoint stays OpenAI even with attachments');
|
||||
expectSame('openai', $openAiWithFiles[0]['protocol'], 'explicit OpenAI protocol with attachments');
|
||||
|
||||
$difyWithFiles = callPrivate('buildRequestSpecs', [
|
||||
'https://ai.example.test/v1/chat-messages',
|
||||
'model-name',
|
||||
[],
|
||||
'prompt',
|
||||
'server-user',
|
||||
false,
|
||||
$attachments,
|
||||
]);
|
||||
expectSame(1, count($difyWithFiles), 'explicit Dify endpoint stays Dify with attachments');
|
||||
expectSame($attachments, $difyWithFiles[0]['payload']['files'], 'explicit Dify keeps the file channel');
|
||||
|
||||
$dify = callPrivate('buildRequestSpecs', [
|
||||
'https://ai.example.test/v1/chat-messages',
|
||||
'model-name',
|
||||
@@ -73,6 +139,81 @@ expectSame(
|
||||
'OpenAI multipart response'
|
||||
);
|
||||
|
||||
// 附件数量必须按上游应用的 file_upload.number_limits 截断。Dify 超限时返回
|
||||
// 400 invalid_param 并整单拒绝,历史实现会把患者的全部舌象/报告一次性送上去,
|
||||
// 导致该患者的每一次 AI 请求都固定失败(UPSTREAM_REJECTED)。
|
||||
$manyFiles = [];
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$manyFiles[] = ['type' => 'image', 'transfer_method' => 'remote_url', 'url' => "https://cdn.example.test/tongue{$i}.jpg"];
|
||||
}
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
$manyFiles[] = ['type' => 'document', 'transfer_method' => 'remote_url', 'url' => "https://cdn.example.test/report{$i}.pdf"];
|
||||
}
|
||||
$capped = callPrivate('normalizeFiles', [$manyFiles, 3]);
|
||||
expectSame(3, count($capped['kept']), 'the total attachment count is capped, not each type');
|
||||
expectSame(6, count($capped['dropped']), 'attachments past the cap are recorded, not discarded');
|
||||
expectSame(
|
||||
'https://cdn.example.test/tongue3.jpg',
|
||||
$capped['dropped'][0]['url'],
|
||||
'the earliest attachments are the ones kept'
|
||||
);
|
||||
expectSame(
|
||||
['kept' => [], 'dropped' => []],
|
||||
callPrivate('normalizeFiles', [[['type' => 'image', 'url' => 'ftp://cdn.example.test/x.jpg']], 3]),
|
||||
'non-http attachments are still rejected outright'
|
||||
);
|
||||
|
||||
// 被截断的附件必须出现在提示词清单里,否则模型会把“没看到”当成“没有”。
|
||||
$cappedSpecs = callPrivate('buildRequestSpecs', [
|
||||
'https://ai.example.test/v1/chat-messages',
|
||||
'model-name',
|
||||
[],
|
||||
'clinical prompt',
|
||||
'server-user',
|
||||
false,
|
||||
$capped['kept'],
|
||||
$capped['dropped'],
|
||||
]);
|
||||
$cappedQuery = $cappedSpecs[0]['payload']['query'];
|
||||
expectSame(true, str_contains($cappedQuery, '<ATTACHMENTS_NOT_INLINE>'), 'dropped attachments are declared to the model');
|
||||
expectSame(true, str_contains($cappedQuery, 'https://cdn.example.test/tongue3.jpg'), 'dropped attachment URL is listed');
|
||||
expectSame(false, str_contains($cappedQuery, 'https://cdn.example.test/tongue0.jpg'), 'delivered attachments are not duplicated in the manifest');
|
||||
|
||||
// 附件被整体拒绝时必须降级为纯文本重试,而不是让整次问诊失败。
|
||||
$plan = callPrivate('buildAttemptPlan', [$capped['kept'], $capped['dropped']]);
|
||||
expectSame(2, count($plan), 'a request with attachments gets a text-only fallback attempt');
|
||||
expectSame([], $plan[1]['files'], 'the fallback attempt sends no attachments');
|
||||
expectSame(9, count($plan[1]['omitted']), 'the fallback attempt declares every attachment');
|
||||
expectSame(1, count(callPrivate('buildAttemptPlan', [[], []])), 'a request without attachments is attempted once');
|
||||
|
||||
expectSame(true, callPrivate('shouldRetryWithoutFiles', [400, $capped['kept']]), 'invalid_param retries without attachments');
|
||||
expectSame(true, callPrivate('shouldRetryWithoutFiles', [413, $capped['kept']]), 'oversized attachments retry without attachments');
|
||||
expectSame(false, callPrivate('shouldRetryWithoutFiles', [400, []]), 'a text-only rejection is not retried');
|
||||
expectSame(false, callPrivate('shouldRetryWithoutFiles', [401, $capped['kept']]), 'a credential failure is not retried');
|
||||
expectSame(false, callPrivate('shouldRetryWithoutFiles', [500, $capped['kept']]), 'an upstream outage is not retried here');
|
||||
|
||||
// Dify 的 inputs 必须是 JSON 对象。PHP 空数组会被编码成 [],上游以
|
||||
// invalid_param 拒绝整单——空 inputs 的调用方会 100% 失败。
|
||||
$emptyInputs = callPrivate('buildRequestSpecs', [
|
||||
'https://ai.example.test/v1/chat-messages', 'model-name', [], 'prompt', 'server-user',
|
||||
]);
|
||||
expectSame(
|
||||
true,
|
||||
str_contains((string) json_encode($emptyInputs[0]['payload']), '"inputs":{}'),
|
||||
'empty inputs are encoded as a JSON object, never as an array'
|
||||
);
|
||||
$filledInputs = callPrivate('buildRequestSpecs', [
|
||||
'https://ai.example.test/v1/chat-messages', 'model-name', ['prompt_version' => 'v2'], 'prompt', 'server-user',
|
||||
]);
|
||||
expectSame(
|
||||
true,
|
||||
str_contains((string) json_encode($filledInputs[0]['payload']), '"inputs":{"prompt_version":"v2"}'),
|
||||
'populated inputs keep their keys'
|
||||
);
|
||||
|
||||
expectSame('invalid_param', callPrivate('cleanUpstreamCode', ['invalid_param']), 'enumerable upstream codes are kept for logs');
|
||||
expectSame('', callPrivate('cleanUpstreamCode', ["Run failed: 404 for https://cdn.example.test/a.pdf"]), 'upstream prose never reaches the log');
|
||||
|
||||
expectSame(true, callPrivate('isValidBaseUrl', ['https://ai.example.test/v1']), 'https URL');
|
||||
expectSame(true, callPrivate('isValidBaseUrl', ['http://127.0.0.1:8080/v1']), 'internal http URL');
|
||||
expectSame(false, callPrivate('isValidBaseUrl', ['file:///tmp/socket']), 'non-http URL');
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
use app\adminapi\logic\tcm\PrescriptionLogic;
|
||||
|
||||
function prescriptionAiWriteSafetyExpect(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
function prescriptionAiWriteSafetySource(string $method): string
|
||||
{
|
||||
$reflection = (new ReflectionClass(PrescriptionLogic::class))->getMethod($method);
|
||||
$lines = file($reflection->getFileName());
|
||||
if (!is_array($lines)) {
|
||||
throw new RuntimeException('method source must be readable');
|
||||
}
|
||||
|
||||
return implode('', array_slice(
|
||||
$lines,
|
||||
$reflection->getStartLine() - 1,
|
||||
$reflection->getEndLine() - $reflection->getStartLine() + 1
|
||||
));
|
||||
}
|
||||
|
||||
$add = prescriptionAiWriteSafetySource('add');
|
||||
$edit = prescriptionAiWriteSafetySource('editLocked');
|
||||
|
||||
prescriptionAiWriteSafetyExpect(
|
||||
str_contains($add, 'DiagnosisLogic::canManageDiagnosis'),
|
||||
'prescription creation must enforce diagnosis write scope before loading patient data'
|
||||
);
|
||||
prescriptionAiWriteSafetyExpect(
|
||||
str_contains($add, "->where('patient_id', \$diagnosisIdRule)"),
|
||||
'client appointment id must be bound to the authorized diagnosis'
|
||||
);
|
||||
foreach (['patient_id', 'patient_name', 'gender', 'age', 'phone'] as $field) {
|
||||
prescriptionAiWriteSafetyExpect(
|
||||
str_contains($add, "'{$field}' => \$diagnosis !== null"),
|
||||
"{$field} must be derived from the authorized diagnosis"
|
||||
);
|
||||
}
|
||||
prescriptionAiWriteSafetyExpect(
|
||||
str_contains($add, "'case_record' => \$diagnosis !== null")
|
||||
&& str_contains($add, "'doctor_name' => \$doctorName")
|
||||
&& !str_contains($add, "'doctor_name' => \$doctorName !== ''"),
|
||||
'case record and doctor identity must come from authoritative server state'
|
||||
);
|
||||
prescriptionAiWriteSafetyExpect(
|
||||
str_contains($add, "'audit_status' => 0"),
|
||||
'new AI-assisted prescriptions must always enter pending audit'
|
||||
);
|
||||
prescriptionAiWriteSafetyExpect(
|
||||
str_contains($edit, '共享只扩大只读范围')
|
||||
&& str_contains($edit, '处方不允许改绑其他诊单'),
|
||||
'shared prescriptions must stay read-only and edits must not rebind diagnoses'
|
||||
);
|
||||
|
||||
echo "Prescription AI write safety contract: OK\n";
|
||||
Reference in New Issue
Block a user