Files
zyt/server/tests/PatientAiReportSnapshotTest.php
T
2026-08-22 08:51:35 +08:00

265 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\tcm\PatientAiReportLogic;
function patientSnapshotExpect(bool $condition, string $message): void
{
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}
$reflection = new ReflectionClass(PatientAiReportLogic::class);
$build = $reflection->getMethod('buildSourceSnapshotFromRows');
$canonicalJson = $reflection->getMethod('canonicalJson');
$sanitize = $reflection->getMethod('sanitizeSnapshotForUpstream');
$decodeAttachments = $reflection->getMethod('decodeAttachmentArray');
patientSnapshotExpect(
$decodeAttachments->invoke(null, 'https://legacy.test/only.pdf') === ['https://legacy.test/only.pdf'],
'legacy single-URL attachment is retained as one item'
);
patientSnapshotExpect(
$decodeAttachments->invoke(null, '/a.pdf, /b.jpg/c.png') === ['/a.pdf', '/b.jpg', '/c.png'],
'legacy ASCII and Chinese comma-delimited attachments are all retained'
);
patientSnapshotExpect(
$decodeAttachments->invoke(null, '"/quoted-single.pdf"') === ['/quoted-single.pdf'],
'legacy JSON string attachment is retained as one item'
);
$sources = [
'patient_id' => 88,
'diagnoses' => [[
'id' => 101,
'patient_id' => 88,
'patient_name' => '张某',
'gender' => 1,
'age' => 52,
'diagnosis_date' => 1722384000,
'symptoms' => '口渴、乏力',
'tongue_coating' => '舌红,苔薄黄',
'pulse' => '弦数',
'doctor_advice' => '复查空腹血糖',
'report_files' => '["https://private.test/report-a.pdf?token=secret"]',
]],
'doctor_notes' => [[
'id' => 1,
'diagnosis_id' => 101,
'doctor_id' => 7,
'note_date' => '2026-08-01',
'content' => '舌苔较前转薄,检验报告待复核',
'tongue_images' => '["/uploads/tongue.jpg"]',
'report_files' => '["/uploads/lab.pdf"]',
]],
'tracking_notes' => [[
'id' => 2,
'diagnosis_id' => 101,
'admin_id' => 8,
'note_date' => '2026-08-02',
'content' => '患者自述夜间口渴减轻',
]],
'blood_records' => [[
'id' => 3,
'diagnosis_id' => 101,
'patient_id' => 88,
'record_date' => 1785600000,
'fasting_blood_sugar' => '7.1',
'systolic_pressure' => 128,
'diastolic_pressure' => 82,
]],
'diet_records' => [[
'id' => 4,
'diagnosis_id' => 101,
'patient_id' => 88,
'record_date' => 1785600000,
'breakfast_foods' => '鸡蛋、燕麦',
]],
'exercise_records' => [[
'id' => 5,
'diagnosis_id' => 101,
'patient_id' => 88,
'record_date' => 1785600000,
'exercise_type' => '步行',
'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,
'patient_id' => 88,
'msg_time' => 1785600100,
'is_from_doctor' => 0,
'msg_type' => 'text',
'text' => '今天空腹血糖7.1',
'file_name' => 'patient-zhang-lab-result.pdf',
'from_staff_name' => '王医生',
]],
'wechat_messages' => [[
'id' => 7,
'diagnosis_id' => 101,
'patient_id' => 88,
'chat_time' => 1785600200,
'direction' => 0,
'msg_type' => 'text',
'content' => '请按时复诊',
]],
'call_records' => [[
'id' => 9,
'diagnosis_id' => 101,
'call_type' => 2,
'status' => 2,
'start_time' => 1785600300,
'duration' => 600,
'recording_urls' => '["https://private.test/playback.m3u8?sign=sensitive"]',
'recording_status' => 2,
]],
'transcript_segments' => [
[
'id' => 10,
'call_record_id' => 9,
'transcription_session_id' => 'session-secret',
'segment_id' => 'segment-1',
'speaker_role' => 'doctor',
'timestamp_ms' => 1000,
'text' => '最近口渴是否减轻?',
],
[
'id' => 11,
'call_record_id' => 9,
'transcription_session_id' => 'session-secret',
'segment_id' => 'segment-2',
'speaker_role' => 'patient',
'timestamp_ms' => 2000,
'text' => '减轻了,联系电话13812345678。',
],
],
];
$snapshot = $build->invoke(null, $sources);
patientSnapshotExpect(is_array($snapshot), 'snapshot is structured');
patientSnapshotExpect($snapshot['patient']['patient_id'] === 88, 'stable patient id is retained');
patientSnapshotExpect($snapshot['patient']['patient_name'] === '张某', 'server snapshot retains audited patient identity');
patientSnapshotExpect($snapshot['diagnoses'][0]['tongue_coating'] === '舌红,苔薄黄', 'tongue coating is aggregated');
patientSnapshotExpect($snapshot['diagnoses'][0]['pulse'] === '弦数', 'pulse is aggregated');
patientSnapshotExpect($snapshot['diagnoses'][0]['doctor_advice'] === '复查空腹血糖', 'diagnosis doctor advice is aggregated');
patientSnapshotExpect($snapshot['doctor_notes'][0]['content'] === '舌苔较前转薄,检验报告待复核', 'doctor notes are aggregated');
patientSnapshotExpect(count($snapshot['doctor_notes'][0]['report_files']) === 1, 'doctor report attachment records are aggregated');
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');
patientSnapshotExpect(
str_contains($snapshot['video_calls'][0]['transcript_text'], '医生:最近口渴是否减轻?')
&& str_contains($snapshot['video_calls'][0]['transcript_text'], '患者:减轻了'),
'transcript_text is rebuilt from segments when live call columns are absent'
);
patientSnapshotExpect(count($snapshot['video_calls'][0]['recording_urls']) === 1, 'playback records remain in server snapshot');
$summary = $snapshot['source_summary'];
foreach ([
'diagnosis_count' => 1,
'doctor_note_count' => 1,
'tracking_note_count' => 1,
'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,
'transcript_segment_count' => 2,
'recording_asset_count' => 1,
] as $field => $count) {
patientSnapshotExpect($summary[$field] === $count, "summary {$field} is correct");
}
$canonicalOne = $canonicalJson->invoke(null, $snapshot);
$reordered = array_reverse($snapshot, true);
$canonicalTwo = $canonicalJson->invoke(null, $reordered);
patientSnapshotExpect(hash('sha256', $canonicalOne) === hash('sha256', $canonicalTwo), 'source hash is key-order stable');
$upstream = $sanitize->invoke(null, $snapshot);
$upstreamJson = json_encode($upstream, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '';
patientSnapshotExpect(!str_contains($upstreamJson, '张某'), 'patient name is removed upstream');
patientSnapshotExpect(!str_contains($upstreamJson, '13812345678'), 'phone embedded in transcript is redacted upstream');
patientSnapshotExpect(!str_contains($upstreamJson, 'private.test'), 'private attachment and playback URLs are removed upstream');
patientSnapshotExpect(!str_contains($upstreamJson, 'patient-zhang-lab-result.pdf'), 'attachment filename is removed upstream');
patientSnapshotExpect(!str_contains($upstreamJson, '王医生'), 'staff name is removed upstream');
patientSnapshotExpect($upstream['patient']['patient_id'] === '[已脱敏]', 'patient id is removed upstream');
patientSnapshotExpect(
$upstream['chat_records']['tencent_im'][0]['file_name'] === '[已脱敏]',
'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 = [];
for ($index = 1; $index <= 240; $index++) {
$manyNotes[] = [
'id' => $index,
'diagnosis_id' => 501,
'content' => "随访记录-{$index}",
];
}
$completeSnapshot = $build->invoke(null, [
'patient_id' => 500,
'diagnoses' => [[
'id' => 501,
'patient_id' => 500,
'patient_name' => '完整性测试患者',
'symptoms' => $longText,
]],
'doctor_notes' => $manyNotes,
]);
patientSnapshotExpect(
$completeSnapshot['diagnoses'][0]['symptoms'] === $longText,
'long source text is not truncated in the persisted snapshot'
);
patientSnapshotExpect(
count($completeSnapshot['doctor_notes']) === 240
&& $completeSnapshot['doctor_notes'][0]['content'] === '随访记录-1'
&& $completeSnapshot['doctor_notes'][239]['content'] === '随访记录-240',
'large multi-record source sets retain every record in order'
);
patientSnapshotExpect(
$completeSnapshot['source_summary']['doctor_note_count'] === 240
&& $completeSnapshot['source_summary']['snapshot_complete'] === true
&& $completeSnapshot['source_summary']['may_be_truncated'] === false,
'source summary declares the complete untruncated multi-record snapshot'
);
echo "Patient AI report snapshot aggregation: OK\n";