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