str_contains($fact['value'], $marker))); } $now = time(); $wxTime = $now - 3600; $current = ['id' => 11, 'patient_id' => 99, 'diagnosis_date' => '2026-10-23', 'age' => 42, 'gender_text' => '女', 'chief_complaint' => '主诉来源标记', 'systolic_pressure' => 128, 'symptoms' => '症状来源标记', 'past_history' => '既往史来源标记', 'allergy_history' => 1, 'allergy_history_text' => '过敏史来源标记', 'remark' => '诊断备注来源标记', 'patient_name' => 'IDENTITY_METADATA_MUST_NOT_APPEAR', 'phone' => '13800138000']; $private = ' 13800138000 11010519491231002X fixture@example.test https://private.invalid/resource?signature=secret'; $composite = json_encode([ ['msg_type' => 'text', 'text' => '复合消息正文来源标记' . $private], ['msg_type' => 'image', 'image_url' => 'https://private.invalid/image.png?signature=secret', 'file_name' => 'PRIVATE_IMAGE_FILENAME'], ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); // This pure production normalizer performs no loading. Use its real output rather // than hand-writing a pretend required_content schema. $build = new ReflectionMethod(PatientAiReportLogic::class, 'buildSourceSnapshotFromRows'); $sanitize = new ReflectionMethod(PatientAiReportLogic::class, 'sanitizeSnapshotForUpstream'); $snapshot = $build->invoke(null, [ 'patient_id' => 99, 'as_of' => $now, 'diagnoses' => [$current], 'doctor_notes' => [ ['id' => 21, 'diagnosis_id' => 11, 'doctor_id' => 7, 'note_date' => '2026-10-20', 'content' => '医生文字备注来源标记' . $private], ['id' => 22, 'diagnosis_id' => 11, 'note_date' => '2026-10-21', 'content' => '医生图片备注正文来源标记' . $private, 'tongue_images' => ['https://private.invalid/tongue.png?signature=secret']], ['id' => 23, 'diagnosis_id' => 11, 'note_date' => '2026-10-22', 'content' => '医生文件备注正文来源标记' . $private, 'report_files' => ['https://private.invalid/report.pdf?signature=secret']], ['id' => 24, 'diagnosis_id' => 11, 'note_date' => '2026-10-22', 'tongue_images' => ['https://private.invalid/only-image.png?signature=secret']], ], 'tracking_notes' => [['id' => 31, 'diagnosis_id' => 11, 'note_date' => '2026-10-21', 'content' => '追踪备注来源标记' . $private]], 'blood_records' => [['id' => 32, 'diagnosis_id' => 11, 'record_date' => '2026-10-20', 'record_time' => '08:00', 'record_time_estimated' => 1, 'record_time_period' => '清晨', 'blood_sugar' => '5.6', 'western_medicine' => '既有西药来源标记', 'remark' => '血糖记录来源标记' . $private]], 'diet_records' => [['id' => 33, 'diagnosis_id' => 11, 'record_date' => '2026-10-20', 'breakfast_foods' => '早餐来源标记', 'breakfast_images' => ['https://private.invalid/breakfast.png?signature=secret']]], 'exercise_records' => [['id' => 34, 'diagnosis_id' => 11, 'record_date' => '2026-10-20', 'exercise_type' => '步行来源标记', 'duration' => 20, 'intensity' => 1]], 'todos' => [['id' => 35, 'diagnosis_id' => 11, 'content' => '待办正文来源标记' . $private, 'status' => 1, 'remind_time' => $now - 86400]], 'prescriptions' => [['id' => 41, 'diagnosis_id' => 11, 'prescription_date' => '2026-10-18', 'prescription_name' => '历史方名来源标记', 'herbs' => [['name' => '历史药味来源标记', 'dosage' => 10]]]], 'call_records' => [ ['id' => 51, 'diagnosis_id' => 11, 'call_type' => 2, 'status' => 2, 'end_time' => $now - 7200, 'transcript_text' => '面诊完整转写来源标记' . $private], ['id' => 52, 'diagnosis_id' => 11, 'call_type' => 2, 'status' => 2, 'end_time' => $now - 3600, 'transcription_session_id' => 'TRANSCRIPT_METADATA_MUST_NOT_APPEAR'], ['id' => 53, 'diagnosis_id' => 11, 'call_type' => 2, 'status' => 1, 'transcript_text' => 'ACTIVE_CALL_MUST_NOT_APPEAR'], ], 'transcript_segments' => [['id' => 54, 'call_record_id' => 52, 'transcription_session_id' => 'TRANSCRIPT_METADATA_MUST_NOT_APPEAR', 'speaker_role' => 'doctor', 'text' => '面诊片段来源标记' . $private]], 'im_messages' => [ ['id' => 61, 'diagnosis_id' => 11, 'msg_time' => $now - 86400, 'msg_type' => 'text', 'text' => '腾讯文字来源标记' . $private, 'from_account' => 'ACCOUNT_METADATA_MUST_NOT_APPEAR'], ['id' => 62, 'diagnosis_id' => 11, 'msg_time' => $now - 7200, 'msg_type' => 'composite', 'text' => $composite], ['id' => 63, 'diagnosis_id' => 11, 'msg_time' => $now - 3600, 'msg_type' => 'text', 'text' => '5.6'], ['id' => 65, 'diagnosis_id' => 11, 'msg_time' => $now - 3600, 'msg_type' => 'composite', 'text' => '[]'], ], 'wechat_messages' => [['id' => 64, 'diagnosis_id' => 11, 'chat_time' => $wxTime, 'msg_type' => 'text', 'content' => '企业微信正文来源标记' . $private, 'external_name' => 'EXTERNAL_NAME_MUST_NOT_APPEAR']], ]); $submission = $sanitize->invoke(null, AiRequiredSource::submission($snapshot)); $guard = TcmEvidenceGuard::assess($current, $submission); sourceGuardExpect($guard['restricted'] && $guard['current_tongue'] === '' && $guard['current_pulse'] === '', 'notes, old video and chats expand the source index without becoming a current physical examination'); sourceGuardExpect(count($submission['required_content']) === 13, 'the genuine clinical projection still has thirteen required categories'); foreach (AiRequiredSource::LABELS as $key => $label) { sourceGuardExpect(count(array_filter($guard['facts'], static fn (array $fact): bool => $fact['section'] === $key)) >= 1, 'the genuine small-source projection gives each populated category at least one selectable fact: ' . $key); } foreach (['医生文字备注来源标记', '医生图片备注正文来源标记', '医生文件备注正文来源标记', '追踪备注来源标记', '血糖记录来源标记', '早餐来源标记', '步行来源标记', '待办正文来源标记', '面诊完整转写来源标记', '面诊片段来源标记', '腾讯文字来源标记', '企业微信正文来源标记', '复合消息正文来源标记'] as $marker) { sourceGuardExpect(sourceGuardFind($guard, $marker) !== [], 'a genuine production-shaped source value has a selectable immutable reference: ' . $marker); } sourceGuardExpect(count(array_filter($guard['facts'], static fn (array $fact): bool => $fact['section'] === 'chat' && $fact['value'] === '5.6')) === 1, 'a plain numeric blood-glucose chat is source text rather than a structured JSON envelope to discard'); $wechatFacts = sourceGuardFind($guard, '企业微信正文来源标记'); sourceGuardExpect($wechatFacts !== [] && $wechatFacts[0]['date'] === date('Y-m-d', $wxTime), 'enterprise chat provenance uses its actual chat_time field'); $doctorFacts = sourceGuardFind($guard, '医生文字备注来源标记'); sourceGuardExpect($doctorFacts !== [] && $doctorFacts[0]['date'] === '2026-10-20', 'nested doctor_text_note retains its note_date'); $bloodFacts = sourceGuardFind($guard, '血糖记录来源标记'); $bloodValue = $bloodFacts !== [] ? json_decode($bloodFacts[0]['value'], true) : []; sourceGuardExpect(($bloodValue['record_time_estimated'] ?? null) === 1, 'a selected blood record retains the source qualifier that its recording time was estimated'); sourceGuardExpect(sourceGuardFind($guard, 'ACTIVE_CALL_MUST_NOT_APPEAR') === [], 'the real projection does not index a current unended video call'); $encodedFacts = json_encode($guard['facts'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); foreach (['13800138000', '11010519491231002X', 'fixture@example.test', 'private.invalid', 'signature=secret', 'IDENTITY_METADATA_MUST_NOT_APPEAR', 'TRANSCRIPT_METADATA_MUST_NOT_APPEAR', 'ACCOUNT_METADATA_MUST_NOT_APPEAR', 'EXTERNAL_NAME_MUST_NOT_APPEAR', 'PRIVATE_IMAGE_FILENAME'] as $privateToken) { sourceGuardExpect(!str_contains($encodedFacts, $privateToken), 'new source facts omit private text, metadata and asset references: ' . $privateToken); } $newFacts = array_filter($guard['facts'], static fn (array $fact): bool => in_array($fact['section'], ['doctor_image_notes', 'doctor_file_notes', 'daily_tracking', 'video_consultation_text', 'chat'], true) || str_contains($fact['value'], '医生文字备注来源标记')); foreach ($newFacts as $fact) { sourceGuardExpect(array_keys($fact) === ['id', 'section', 'date', 'label', 'value'] && is_string($fact['value']) && $fact['value'] !== '', 'new facts expose only the fixed scalar evidence shape: ' . $fact['id']); $review = TcmEvidenceGuard::review($guard, json_encode(['fact_ids' => [$fact['id']]])); sourceGuardExpect($review['selection_valid'] && str_contains($review['answer'], '非本次') && !str_contains($review['answer'], 'private.invalid'), 'each new evidence reference can be quoted only as protected historical source material: ' . $fact['id']); } $prompt = TcmEvidenceGuard::prompt(json_encode($submission, JSON_UNESCAPED_UNICODE), $guard, '复核来源'); sourceGuardExpect(str_contains($prompt, '复合消息正文来源标记') && str_contains($prompt, '面诊片段来源标记'), 'original full submission still contains relevant composite and transcript text independent of source selection'); sourceGuardExpect(strlen($encodedFacts) < 24000 && count($guard['facts']) <= 96, 'the expanded small-source evidence index remains bounded'); restore_error_handler(); sourceGuardExpect($sourceGuardWarnings === [], 'genuine empty array sentinels produce no PHP warnings: ' . implode('; ', $sourceGuardWarnings)); if ($sourceGuardFailures !== []) { fwrite(STDERR, 'TCM evidence source coverage: ' . count($sourceGuardFailures) . ' failures / ' . $sourceGuardAssertions . " assertions\n- " . implode("\n- ", $sourceGuardFailures) . "\n"); exit(1); } echo 'TCM evidence source coverage: ' . $sourceGuardAssertions . " assertions passed.\n";