Files
zyt/server/tests/PatientAiReportContractTest.php
T
2026-08-22 08:51:35 +08:00

196 lines
8.4 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\validate\tcm\DiagnosisValidate;
function patientReportContractExpect(bool $condition, string $message): void
{
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}
$reflection = new ReflectionClass(PatientAiReportLogic::class);
patientReportContractExpect(
$reflection->getConstant('DISCLAIMER')
=== '仅供临床辅助参考,不可替代医生诊断。系统会把舌像、报告等附件与全部文字资料提交给已配置的模型分析,但模型识别结果仍须由执业医师核对原始资料;视频面诊以归档转写文字为准。',
'fixed medical disclaimer is exact'
);
patientReportContractExpect(
$reflection->getConstant('PERMISSION_READ') === 'tcm.diagnosis/patientaireports',
'read permission is defense-in-depth normalized endpoint permission'
);
patientReportContractExpect(
$reflection->getConstant('PERMISSION_GENERATE') === 'tcm.diagnosis/generatepatientaireport',
'generate permission is defense-in-depth normalized endpoint permission'
);
$logicSource = file_get_contents($reflection->getFileName());
patientReportContractExpect(is_string($logicSource), 'patient report logic source is readable');
// 报告生成与 compactSourceForPrompt 各有一组“单次 / 分片 / 归并”调用点,共 6 处。
patientReportContractExpect(
substr_count($logicSource, 'DifyChatService::chat(') === 6,
'single-pass, evidence-chunk, summary-reduction, and final synthesis upstream call sites are explicit'
);
patientReportContractExpect(
str_contains($logicSource, 'PatientAiReport::create(['),
'generation inserts a fresh report row'
);
foreach (['->update(', 'duplicate([', 'saveAll('] as $overwritePattern) {
patientReportContractExpect(
!str_contains($logicSource, $overwritePattern),
"patient report logic never overwrites history via {$overwritePattern}"
);
}
foreach ([
"'latest_by_model'",
"'reports'",
"'generated_report'",
"'disclaimer'",
"'source_summary'",
"'report'",
"'content'",
"'diagnosis'",
"'risk_assessment'",
"'treatment_advice'",
] as $responseField) {
patientReportContractExpect(str_contains($logicSource, $responseField), "response contains {$responseField}");
}
$sourceLines = file($reflection->getFileName());
patientReportContractExpect(is_array($sourceLines), 'logic source lines are readable');
$methodSource = static function (ReflectionMethod $method) use ($sourceLines): string {
return implode('', array_slice(
$sourceLines,
$method->getStartLine() - 1,
$method->getEndLine() - $method->getStartLine() + 1
));
};
$generateSource = $methodSource($reflection->getMethod('generate'));
patientReportContractExpect(
($readCheck = strpos($generateSource, 'self::PERMISSION_READ')) !== false
&& ($writeCheck = strpos($generateSource, 'self::PERMISSION_GENERATE')) !== false
&& $readCheck < $writeCheck,
'POST generation requires read permission before generate permission'
);
patientReportContractExpect(
str_contains($generateSource, "'source_diagnosis_ids_json' => self::encodeJson(\$diagnosisIds)"),
'new reports persist the complete source diagnosis id set'
);
$generateReturn = substr($generateSource, (int) strrpos($generateSource, 'return ['));
patientReportContractExpect(
str_contains($generateReturn, "'generated_report'")
&& !str_contains($generateReturn, "'latest_by_model'")
&& !str_contains($generateReturn, "'reports'"),
'POST returns only the newly generated report and not report history'
);
$controller = file_get_contents(dirname(__DIR__) . '/app/adminapi/controller/tcm/DiagnosisController.php');
patientReportContractExpect(is_string($controller), 'controller source is readable');
foreach ([
'public function patientAiReports()',
"goCheck('patientAiReports')",
'PatientAiReportLogic::reports(',
'public function generatePatientAiReport()',
"goCheck('generatePatientAiReport')",
'PatientAiReportLogic::generate(',
] as $contract) {
patientReportContractExpect(str_contains($controller, $contract), "controller contains {$contract}");
}
$validator = new DiagnosisValidate();
$validatorReflection = new ReflectionClass($validator);
$readPayload = $validatorReflection->getMethod('checkPatientAiReportsPayload');
$generatePayload = $validatorReflection->getMethod('checkGeneratePatientAiReportPayload');
patientReportContractExpect(
$readPayload->invoke($validator, 9, '', ['patient_id' => 9]) === true,
'GET accepts exactly patient_id'
);
patientReportContractExpect(
$readPayload->invoke($validator, 9, '', ['patient_id' => 9, 'model' => 'qwen']) !== true,
'GET rejects all extra fields'
);
foreach (['qwen', 'openai'] as $model) {
patientReportContractExpect(
$generatePayload->invoke($validator, 9, '', ['patient_id' => 9, 'model' => $model]) === true,
"POST accepts exact {$model} model key"
);
}
foreach (['provider', 'api_key', 'base_url', 'prompt', 'diagnosis_id', 'source_snapshot'] as $forbidden) {
patientReportContractExpect(
$generatePayload->invoke(
$validator,
9,
'',
['patient_id' => 9, 'model' => 'qwen', $forbidden => 'client-controlled']
) !== true,
"POST rejects forbidden {$forbidden}"
);
}
foreach (['', 'QWEN', ' qwen', 'gpt-5.6-sol'] as $invalidModel) {
patientReportContractExpect(
$generatePayload->invoke($validator, 9, '', ['patient_id' => 9, 'model' => $invalidModel]) !== true,
"POST rejects invalid model {$invalidModel}"
);
}
foreach ([null, 0, true, []] as $invalidType) {
patientReportContractExpect(
$generatePayload->invoke($validator, 9, '', ['patient_id' => 9, 'model' => $invalidType]) !== true,
'POST rejects non-string model type ' . get_debug_type($invalidType)
);
}
$readScene = (new DiagnosisValidate())->scene('patientAiReports');
patientReportContractExpect($readScene->check(['patient_id' => 9]), 'GET validation scene accepts patient_id');
$generateScene = (new DiagnosisValidate())->scene('generatePatientAiReport');
patientReportContractExpect(
$generateScene->check(['patient_id' => 9, 'model' => 'qwen']),
'POST validation scene accepts exact payload'
);
$forbiddenScene = (new DiagnosisValidate())->scene('generatePatientAiReport');
patientReportContractExpect(
!$forbiddenScene->check(['patient_id' => 9, 'model' => 'qwen', 'base_url' => 'https://invalid.test']),
'POST validation scene rejects upstream configuration'
);
$migration = file_get_contents(dirname(__DIR__) . '/database/migrations/2026_08_14_patient_ai_report.sql');
patientReportContractExpect(is_string($migration), 'migration source is readable');
foreach ([
'CREATE TABLE IF NOT EXISTS `zyt_patient_ai_report`',
'`patient_id`', '`diagnosis_id`', '`model_key`', '`model_name`', '`model_label`',
'`report_json`', '`diagnosis`', '`risk_assessment_json`', '`treatment_advice`',
'`source_snapshot`', '`source_summary_json`', '`source_diagnosis_ids_json`', '`source_hash`', '`generated_at`', '`admin_id`',
'`department_id`', '`department_name`', '`created_at`',
"'tcm.diagnosis/patientAiReports'",
"'tcm.diagnosis/generatePatientAiReport'",
] as $sqlContract) {
patientReportContractExpect(str_contains($migration, $sqlContract), "migration contains {$sqlContract}");
}
patientReportContractExpect(
!preg_match('/UNIQUE\s+(?:KEY|INDEX)[^\n]*(?:patient_id|model_key)/i', $migration),
'migration has no patient/model uniqueness that could overwrite or block history'
);
patientReportContractExpect(
str_contains($migration, '`idx_patient_model_generated`'),
'history lookup has patient/model/time index'
);
$modelSource = file_get_contents(dirname(__DIR__) . '/app/common/model/tcm/PatientAiReport.php');
patientReportContractExpect(
is_string($modelSource) && str_contains($modelSource, "protected \$name = 'patient_ai_report'"),
'independent patient report model uses the new table'
);
$legacyReflection = new ReflectionClass(DiagnosisAiLogic::class);
foreach (['getSavedReports', 'assistant', 'analysis', 'generateAll', 'editReport'] as $legacyMethod) {
patientReportContractExpect($legacyReflection->hasMethod($legacyMethod), "legacy {$legacyMethod} remains available");
}
echo "Patient AI report contract: OK\n";