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";