195 lines
8.3 KiB
PHP
195 lines
8.3 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');
|
|
patientReportContractExpect(
|
|
substr_count($logicSource, 'DifyChatService::chat(') === 4,
|
|
'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";
|