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

95 lines
5.9 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;
// Only the dictionary lookup of the pure enrichment helper is needed here.
final class SourceStateDictionary
{
public static function name(string $table): self
{
if ($table !== 'dict_data') throw new RuntimeException('Unexpected I/O dependency');
return new self();
}
public function whereIn(...$args): self { return $this; }
public function field(...$args): self { return $this; }
public function select(): self { return $this; }
public function toArray(): array { return []; }
}
class_alias(SourceStateDictionary::class, 'think\facade\Db');
$checks = 0;
function sourceStateExpect(bool $condition, string $description): void
{
global $checks;
++$checks;
if (!$condition) throw new RuntimeException($description);
}
function sourceStateSection(array $submission, string $key)
{
foreach ($submission['required_content'] as $section) if ($section['key'] === $key) return $section['value'];
throw new RuntimeException('Missing required section');
}
$empty = AiRequiredSource::submission([]);
sourceStateExpect(count($empty['required_content']) === 13, 'All thirteen empty categories remain explicit');
foreach ($empty['required_content'] as $section) sourceStateExpect($section['value'] === '无', 'Empty category is no data, not a denial');
$row = ['id' => 71, 'sleep_condition' => '睡眠一般', 'stool_condition' => '',
'current_medications' => null, 'appetite' => '口干;大便未记录;否认腹痛',
'family_history' => '无', 'allergy_history_text' => '否认药物过敏史',
'tongue' => '本次舌淡红苔少', 'tongue_text' => '', 'pulse' => '本次脉细弱',
'pulse_text' => null, 'tongue_images' => [], 'status' => 0, 'show_card' => false];
$submission = AiRequiredSource::submission(['diagnoses' => [$row]]);
$present = sourceStateSection($submission, 'present_illness')[0];
sourceStateExpect($present['sleep_condition'] === '睡眠一般', 'Actual observation is verbatim');
sourceStateExpect($present['stool_condition'] === '', 'Missing scalar does not become 无');
sourceStateExpect($present['current_medications'] === null, 'Unknown medication is not an assertion of no medication');
sourceStateExpect($present['appetite'] === $row['appetite'], 'Mixed positive, unknown and denial text stays whole');
sourceStateExpect($present['weight_change'] === null, 'Absent field remains null');
$other = sourceStateSection($submission, 'other_history')[0];
sourceStateExpect($other['family_history'] === '无', 'An actual raw source string is never rewritten');
sourceStateExpect($other['allergy_history_text'] === '否认药物过敏史', 'Explicit denial alias survives');
$exam = sourceStateSection($submission, 'diagnosis_info')[0];
sourceStateExpect($exam['tongue_images'] === [], 'Empty attachment array never becomes an invented reference');
$basic = sourceStateSection($submission, 'basic_info')[0];
sourceStateExpect($basic['status'] === 0 && $basic['show_card'] === false, 'Nonclinical zero and false retain type');
$guard = TcmEvidenceGuard::assess($row, $submission);
sourceStateExpect(!$guard['restricted'], 'Empty aliases do not erase complete raw tongue and pulse');
sourceStateExpect($guard['current_tongue'] === $row['tongue'] && $guard['current_pulse'] === $row['pulse'], 'Actual source exams are unchanged');
sourceStateExpect(str_contains(json_encode($guard['facts'], JSON_UNESCAPED_UNICODE), '口干;大便未记录;否认腹痛'), 'Source-only evidence retains mixed source text');
$enrich = (new ReflectionClass(PatientAiReportLogic::class))->getMethod('enrichDiagnosisRows');
$binary = ['trauma_history', 'surgery_history', 'allergy_history', 'family_history', 'pregnancy_history'];
$coded = $enrich->invoke(null, [array_fill_keys($binary, 0), array_fill_keys($binary, 1),
['family_history' => 0, 'family_history_text' => '本次询问明确否认家族史'],
['allergy_history' => '否认药物过敏史']]);
foreach ($binary as $field) {
sourceStateExpect($coded[0][$field] === 0 && $coded[0][$field . '_text'] === '未记录', 'Default zero is not confirmed negative');
sourceStateExpect($coded[1][$field] === 1 && $coded[1][$field . '_text'] === '有', 'Positive legacy flag is retained');
}
sourceStateExpect($coded[2]['family_history_text'] === '本次询问明确否认家族史', 'Enrichment never replaces recorded interview text');
sourceStateExpect($coded[3]['allergy_history'] === '否认药物过敏史', 'Raw textual denial remains unchanged');
foreach ([null, '', 'unknown', false, 'invalid'] as $value) {
$result = $enrich->invoke(null, [['gender' => $value]]);
sourceStateExpect($result[0]['gender_text'] === '未知', 'Blank or invalid gender is not cast to female');
}
$gender = (new ReflectionClass(DiagnosisAiLogic::class))->getMethod('genderText');
foreach ([0 => '女', 1 => '男'] as $value => $label) {
foreach ([$value, (string) $value] as $encoded) {
$result = $enrich->invoke(null, [['gender' => $encoded]]);
sourceStateExpect($result[0]['gender_text'] === $label && $gender->invoke(null, $encoded) === $label, 'Demographics agree with defined source codes');
}
}
$safe = (new ReflectionClass(PatientAiReportLogic::class))->getMethod('sanitizeSnapshotForUpstream')
->invoke(null, $submission);
$safeExam = sourceStateSection($safe, 'diagnosis_info')[0];
sourceStateExpect($safeExam['tongue_images']['attachment_count'] === 0, 'Sanitization counts empty attachments correctly');
$safePresent = sourceStateSection($safe, 'present_illness')[0];
sourceStateExpect($safePresent['current_medications'] === null && $safePresent['stool_condition'] === '', 'Transport projection keeps field absence distinct from denial');
echo "AI source state preservation: {$checks} assertions passed (pure, no network/database).\n";