5, 'models' => ['qwen' => ['name' => 'synthetic', 'label' => 'synthetic']]]; } final class DiagnosisLogic { public static int $detailCalls = 0; public static bool $rejectDetail = false; public static function detail($params, $adminInfo): array { self::$detailCalls++; if (self::$rejectDetail) throw new \RuntimeException('In-call hints must not fetch chart details/images'); return ['id' => $params['id'], 'patient_id' => 9, 'age' => 52, 'gender' => '女']; } } final class PatientAiReportLogic { public static array $calls = []; public static $onAggregate = null; public static array $files = []; public static function contextForAuthorizedDiagnosis(...$args): array { self::$calls[] = $args; if (is_callable(self::$onAggregate)) (self::$onAggregate)(); return ['snapshot' => ['clinical_source' => '2020年授权病历:合成既往资料,当前状态未核实。'], 'source_summary' => ['diagnosis_count' => 1, 'snapshot_complete' => true, 'may_be_truncated' => false], 'source_diagnosis_ids' => [7], 'files' => self::$files]; } } } namespace app\common\service { final class DifyChatService { public static int $calls = 0; public static $streamHandler = null; public static ?string $handlerFailure = null; public static function chat(...$args): never { self::$calls++; throw new \RuntimeException('No outbound calls allowed in synthetic tests'); } public static function streamChat(...$args): array { self::$calls++; if (!is_callable(self::$streamHandler)) throw new \RuntimeException('No outbound calls allowed in synthetic tests'); self::$handlerFailure = null; try { return (self::$streamHandler)(...$args); } catch (\Throwable $e) { self::$handlerFailure = $e->getMessage(); throw $e; } } } } namespace { // Reuse the fully in-memory database fixture and pure source/citation checks. require __DIR__ . '/LiveConsultationContextTest.php'; use app\adminapi\logic\tcm\DiagnosisAiLogic as Logic; use app\adminapi\logic\firstvisit\MyPatientLogic; use app\common\cache\AdminAuthCache; use app\common\model\tcm\CallRecord; use app\common\service\DifyChatService; use app\adminapi\validate\tcm\DiagnosisValidate; final class SyntheticLiveValidate extends DiagnosisValidate { protected function checkDiagnosis($value) { return true; } } $authorizedEvents = 0; $options = ['call_record_id' => 91, 'transcription_session_id' => 'session-a', 'on_authorized' => static function () use (&$authorizedEvents): bool { $authorizedEvents++; return true; }]; CallRecord::$records = [$record]; AdminAuthCache::$permissions = []; $before = CallRecord::$reads; liveExpect(Logic::prepareAssistant(7, 'live_consultation', '', 8, [], null, $options) === null && CallRecord::$reads === $before && $authorizedEvents === 0, 'AI function denial precedes call data and SSE'); AdminAuthCache::$permissions = ['tcm.diagnosis/aiassistant']; MyPatientLogic::$allowed = false; liveExpect(Logic::prepareAssistant(7, 'live_consultation', '', 8, [], null, $options) === null && CallRecord::$reads === $before && $authorizedEvents === 0, 'diagnosis scope denial precedes call data and SSE'); MyPatientLogic::$allowed = true; foreach ([array_replace($options, ['call_record_id' => 92]), array_replace($options, ['transcription_session_id' => 'session-b']), array_replace($options, ['transcription_session_id' => null])] as $invalidOptions) { liveExpect(Logic::prepareAssistant(7, 'live_consultation', '', 8, [], null, $invalidOptions) === null && Logic::getAssistantErrorCode() === 'LIVE_CONTEXT_UNAVAILABLE' && $authorizedEvents === 0, 'invalid or cross-call identity fails before on_authorized'); } liveExpect(Logic::prepareAssistant(7, 'live_consultation', '', 9, ['root' => 1], null, $options) === null && $authorizedEvents === 0, 'root cannot analyze another doctor call'); liveExpect(Logic::prepareAssistant(7, 'live_review', '', 8, [], null, $options) === null && Logic::getAssistantErrorCode() === 'LIVE_CONTEXT_UNAVAILABLE', 'review waits for persisted final status'); liveExpect(\app\adminapi\logic\tcm\PatientAiReportLogic::$calls === [], 'all denied calls precede case aggregation'); $beforeDetails = \app\adminapi\logic\tcm\DiagnosisLogic::$detailCalls; $livePrepared = Logic::prepareAssistant(7, 'live_consultation', '', 8, [], null, $options); liveExpect(is_array($livePrepared) && $authorizedEvents === 1 && DifyChatService::$calls === 0, 'authorized call prepares full current snapshot without a model call'); liveExpect(\app\adminapi\logic\tcm\PatientAiReportLogic::$calls === [] && \app\adminapi\logic\tcm\DiagnosisLogic::$detailCalls === $beforeDetails, 'dialogue-only hints skip chart details, image collection and longitudinal aggregation'); CallRecord::$records = [array_replace($record, ['transcription_status' => 'completed'])]; $reviewPrepared = Logic::prepareAssistant(7, 'live_review', '', 8, [], null, $options); $caseRead = \app\adminapi\logic\tcm\PatientAiReportLogic::$calls[0]; liveExpect(is_array($reviewPrepared) && $caseRead[0]['id'] === 7 && $caseRead[1] === 8 && $caseRead[2] === [], 'review history builder still receives canonical diagnosis and trusted actor'); CallRecord::$records = [$record]; liveExpect(Logic::assistant(7, 'live_consultation', '', 8, []) === null && Logic::getAssistantErrorCode() === 'LIVE_STREAM_REQUIRED', 'non-stream endpoint cannot bypass current-call binding'); $deltas = []; liveExpect(Logic::streamPreparedAssistant(['task' => 'live_consultation'], static function ($delta) use (&$deltas) { $deltas[] = $delta; }) === null && $deltas === [] && DifyChatService::$calls === 0, 'even a forged prepared live task cannot invoke upstream or expose JSON'); $payload = ['id' => 7, 'task' => 'live_consultation', 'call_record_id' => 91, 'transcription_session_id' => 'session-a']; foreach (['live_consultation', 'live_review'] as $task) { liveExpect((new SyntheticLiveValidate())->scene('aiAssistantStream')->check(array_replace($payload, ['task' => $task])), 'stream scene accepts bound live task'); } foreach (['call_record_id', 'transcription_session_id'] as $key) { $invalid = $payload; unset($invalid[$key]); liveExpect(!(new SyntheticLiveValidate())->scene('aiAssistantStream')->check($invalid), 'scene rejects absent live identity'); } foreach ([['call_record_id' => '91suffix'], ['call_record_id' => 1.5], ['transcription_session_id' => []], ['transcription_session_id' => ''], ['transcription_session_id' => str_repeat('x', 129)]] as $invalid) liveExpect(!(new SyntheticLiveValidate())->scene('aiAssistantStream')->check(array_replace($payload, $invalid)), 'scene rejects malformed live identity'); liveExpect((new SyntheticLiveValidate())->scene('aiAssistantStream')->check(['id' => 7, 'task' => 'summary']), 'existing generic stream remains usable without call fields'); echo "Live consultation authorization / preparation / request validation: {$count} total assertions passed (no DB/network)\n"; }