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

140 lines
11 KiB
PHP

<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\tcm\PatientAiReportLogic;
use app\adminapi\service\AiRequiredSource;
use app\adminapi\service\TcmEvidenceGuard;
$sourceGuardFailures = []; $sourceGuardAssertions = 0; $sourceGuardWarnings = [];
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
$GLOBALS['sourceGuardWarnings'][] = basename($file) . ':' . $line . ' ' . $message;
return true;
});
function sourceGuardExpect(bool $ok, string $message): void
{
++$GLOBALS['sourceGuardAssertions'];
if (!$ok) $GLOBALS['sourceGuardFailures'][] = $message;
}
function sourceGuardFind(array $guard, string $marker): array
{
return array_values(array_filter($guard['facts'], static fn (array $fact): bool => 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";