265 lines
11 KiB
PHP
265 lines
11 KiB
PHP
<?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";
|