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, ]], '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['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, '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'); $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";