11, 'patient_id' => 99, 'diagnosis_date' => '2026-10-23', 'chief_complaint' => '口干、乏力', 'heart_rate' => 72, 'pulse_rate' => 72]; // The real detail API already aggregates doctor_note.tongue_images for this // diagnosis into the authorized row. No database, HTTP or model call is needed. $current = $base + ['tongue_images' => [ 'https://assets.invalid/tongue.jpg?signature=private', 'https://assets.invalid/eye-and-nail.jpg?signature=private', ]]; $guard = TcmEvidenceGuard::assess($current); imageExpect($guard['current_tongue_images'] === ['reference_count' => 2, 'status' => 'references_pending_review', 'availability_verified' => false, 'content_read' => false, 'tongue_appearance_verified' => false], 'current doctor-note references have a separate inventory with no access, reading or clinical verification claim'); imageExpect($guard['restricted'] && $guard['missing'] === ['舌象', '脉象'] && $guard['current_tongue'] === '' && $guard['current_pulse'] === '', 'photo references and heart/pulse rates neither invent signs nor unlock clinical generation'); imageExpect($guard['version'] === 'current-examination-evidence-v3', 'image-source semantics invalidate the prior guard version'); $imageFacts = array_filter($guard['facts'], static fn (array $fact): bool => $fact['label'] === '本次图片附件引用(非舌象结论)'); imageExpect(count($imageFacts) === 1 && str_contains(reset($imageFacts)['value'], '2份附件引用'), 'the application owns a selectable immutable fact describing references rather than an image interpretation'); $selection = json_encode(['fact_ids' => array_keys($imageFacts)]); $parse = new ReflectionMethod(DiagnosisAiLogic::class, 'parseAnalysisResponse'); foreach ([false, true] as $structured) { $review = TcmEvidenceGuard::review($guard, $selection, $structured); imageExpect($review['selection_valid'] && $review['answer_mode'] === 'source_evidence_review' && str_contains($review['diagnosis_advice'], '本次有2份图片附件引用') && str_contains($review['diagnosis_advice'], '待核验可访问性、读取内容并由医师确认舌质、舌苔'), 'both source-only views explicitly show current references and pending reading/clinical confirmation'); imageExpect(str_contains($review['diagnosis_advice'], '图片栏目或数量不能确认附件为舌照、可访问或已读取') && !str_contains($review['diagnosis_advice'], '舌象:本次缺少可核验记录,待采集'), 'both views distinguish absent verified signs from the existing attachment record and possible other body photos'); imageExpect(str_contains($review['treatment_advice'], '请先查看本次已有图片附件') && str_contains($review['treatment_advice'], '仅在附件无法访问、非舌照或不清晰时补拍或重传') && str_contains($review['treatment_advice'], '请补采脉象') && !str_contains($review['treatment_advice'], '请补采舌质、舌苔'), 'collection instructions inspect known attachments first and require replacement only if unusable'); imageExpect(str_contains($review['answer'], '证候待定') && str_contains($review['answer'], '不新增方名、药味、加减或剂量') && $review['risk_assessment'] === [], 'having image references does not create a syndrome, prescription or new risk finding'); $clinical = array_intersect_key($review, array_flip(['diagnosis_advice', 'risk_assessment', 'treatment_advice'])); if ($structured) imageExpect($parse->invoke(null, json_encode($clinical, JSON_UNESCAPED_UNICODE)) !== null, 'the image-aware structured view satisfies the genuine production response parser and character bounds'); foreach (['assets.invalid', 'signature=private', 'eye-and-nail.jpg'] as $token) { imageExpect(!str_contains(json_encode($review, JSON_UNESCAPED_UNICODE), $token), 'review metadata and source quotations omit attachment addresses and names: ' . $token); } } imageExpect(!str_contains(json_encode($guard, JSON_UNESCAPED_UNICODE), 'assets.invalid'), 'the guard retains safe image inventory metadata, never attachment URLs'); $missing = TcmEvidenceGuard::assess($base + ['tongue_images' => ['https://assets.invalid/missing-404.jpg']]); imageExpect($missing['current_tongue_images']['reference_count'] === 1 && !$missing['current_tongue_images']['availability_verified'] && !$missing['current_tongue_images']['content_read'] && !$missing['current_tongue_images']['tongue_appearance_verified'], 'a missing/404 reference is never assumed reachable, successfully read or clinically verified'); $missingReview = TcmEvidenceGuard::review($missing, json_encode(['fact_ids' => [array_key_first($missing['facts'])]])); imageExpect(str_contains($missingReview['answer'], '待核验可访问性') && str_contains($missingReview['answer'], '尚未核验可访问性或读取内容') && !str_contains($missingReview['answer'], '读取成功'), 'a reference alone produces no successful-read claim even when the referenced file is missing'); // "已读取" appears only in the explicit warning that the label/count cannot // establish it; test successful claims independently from that fixed warning. imageExpect(!str_contains($missingReview['answer'], '附件已读取') && !str_contains($missingReview['answer'], '图片已读取'), 'missing files have no affirmative read claim'); $build = new ReflectionMethod(PatientAiReportLogic::class, 'buildSourceSnapshotFromRows'); $sanitize = new ReflectionMethod(PatientAiReportLogic::class, 'sanitizeSnapshotForUpstream'); $snapshot = $build->invoke(null, ['patient_id' => 99, 'as_of' => time(), 'diagnoses' => [ ['id' => 3, 'patient_id' => 99, 'diagnosis_date' => '2020-01-01', 'tongue' => '舌红', 'pulse' => '脉细', 'tongue_images' => ['https://assets.invalid/historical-row.jpg']], $base, ], 'doctor_notes' => [['id' => 22, 'diagnosis_id' => 3, 'note_date' => '2020-01-01', 'tongue_images' => ['https://assets.invalid/historical-note.jpg']]]]); $safe = $sanitize->invoke(null, AiRequiredSource::submission($snapshot)); $historical = TcmEvidenceGuard::assess($base, $safe); imageExpect($historical['current_tongue_images']['reference_count'] === 0 && $historical['current_tongue_images']['status'] === 'no_references_recorded' && $historical['current_tongue'] === '' && $historical['current_pulse'] === '' && $historical['restricted'], 'historical image references and historical signs in the genuine redacted longitudinal schema cannot become current evidence'); $historicalReview = TcmEvidenceGuard::review($historical, json_encode(['fact_ids' => [array_key_first($historical['facts'])]])); imageExpect(!str_contains($historicalReview['answer'], '本次有1份图片附件引用') && !str_contains($historicalReview['answer'], '请先查看本次已有图片附件'), 'historical attachments do not create a current-source display or displace current collection instructions'); foreach ([['attachment_count' => 6], [['file_name' => 'tongue-red-pulse-wiry.jpg']], ['无', '', 'javascript:alert(1)', 6], '无', '[]'] as $marker) { $unverified = TcmEvidenceGuard::assess($base + ['tongue_images' => $marker]); imageExpect($unverified['current_tongue_images']['reference_count'] === 0 && $unverified['restricted'], 'attachment counts, filenames in objects, absence sentinels and invalid schemes are not raw current references'); } $legacy = TcmEvidenceGuard::assess($base + ['tongue_images' => '["/uploads/a.jpg","/uploads/b.jpg"]', 'tongue_photo' => '/uploads/a.jpg']); imageExpect($legacy['current_tongue_images']['reference_count'] === 2, 'legacy JSON and scalar attachment fields preserve current references without duplicate counting'); $nameOnly = TcmEvidenceGuard::assess($base + ['tongue_images' => ['tongue-red-coating-yellow-pulse-wiry.jpg'], 'pulse' => '心率正常72次/分']); imageExpect($nameOnly['current_tongue_images']['reference_count'] === 1 && $nameOnly['current_tongue'] === '' && $nameOnly['current_pulse'] === '' && $nameOnly['restricted'], 'an image filename that names signs and a heart-rate string never establish tongue or TCM pulse findings'); $imageAndPulse = TcmEvidenceGuard::assess($base + ['tongue_images' => ['/uploads/tongue.jpg'], 'pulse' => '脉弦细']); imageExpect($imageAndPulse['restricted'] && $imageAndPulse['current_tongue'] === '' && $imageAndPulse['current_pulse'] === '脉弦细' && $imageAndPulse['missing'] === ['舌象'], 'a genuine pulse plus unread images still requires a confirmed current tongue description'); $nearBound = TcmEvidenceGuard::assess(['tongue' => str_repeat('淡', 160), 'pulse' => str_repeat('细', 160), 'tongue_images' => ['/uploads/tongue.jpg']], AiRequiredSource::submission(['diagnoses' => [ ['clinical_diagnosis' => str_repeat('已', 350)], ]])); $boundedReview = TcmEvidenceGuard::review($nearBound, json_encode(['fact_ids' => array_keys($nearBound['facts'])]), true); $boundedClinical = array_intersect_key($boundedReview, array_flip(['diagnosis_advice', 'risk_assessment', 'treatment_advice'])); imageExpect($parse->invoke(null, json_encode($boundedClinical, JSON_UNESCAPED_UNICODE)) !== null, 'image inventory and collection guidance fit the real structured parser even near examination and source-text limits'); $complete = TcmEvidenceGuard::assess($current + ['tongue' => '舌淡红,苔薄白', 'pulse' => '脉弦细']); imageExpect(!$complete['restricted'] && $complete['current_tongue'] === '舌淡红,苔薄白' && $complete['current_pulse'] === '脉弦细', 'image inventory does not discard genuine current tongue and pulse text or block a complete examination'); if ($imageFailures !== []) { fwrite(STDERR, 'TCM image references: ' . count($imageFailures) . ' failures / ' . $imageAssertions . " assertions\n- " . implode("\n- ", $imageFailures) . "\n"); exit(1); } echo 'TCM image references: ' . $imageAssertions . " assertions passed.\n";