Files
zyt/server/tests/TcmEvidenceGuardImageReferencesTest.php
T
2026-10-27 14:03:35 +08:00

139 lines
10 KiB
PHP

<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\tcm\DiagnosisAiLogic;
use app\adminapi\logic\tcm\PatientAiReportLogic;
use app\adminapi\service\AiRequiredSource;
use app\adminapi\service\TcmEvidenceGuard;
$imageFailures = []; $imageAssertions = 0;
function imageExpect(bool $ok, string $message): void
{
++$GLOBALS['imageAssertions'];
if (!$ok) $GLOBALS['imageFailures'][] = $message;
}
$base = ['id' => 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";