= $GLOBALS['analysisDisconnectAt'] ? 1 : 0; } class DiagnosisLogic { public static function detail($params, $adminInfo): array { return $GLOBALS['analysisTimeoutCurrent']; } } class PatientAiReportLogic { public static function contextForAuthorizedDiagnosis($diagnosis, $adminId, $adminInfo): array { return $GLOBALS['analysisTimeoutLongitudinal']; } public static function compactSourceForPrompt($profile, $source, $options = []): array { throw new \RuntimeException('Below-budget test data must never be compacted.'); } } } namespace app\common\service { function config(string $name): array { return $GLOBALS['analysisTimeoutConfig']; } // Observe bytes only after the production write callback consumes them. All // connections, deadlines, payloads, stop calls and parsing remain real. function curl_setopt_array($handle, array $options): bool { if (isset($options[CURLOPT_WRITEFUNCTION])) { $write = $options[CURLOPT_WRITEFUNCTION]; $options[CURLOPT_WRITEFUNCTION] = static function ($handle, string $chunk) use ($write): int { $consumed = $write($handle, $chunk); if ($consumed === strlen($chunk)) $GLOBALS['analysisConsumedStream'] .= $chunk; return $consumed; }; } return \curl_setopt_array($handle, $options); } class ClinicalKnowledgeReference { public static function augmentCaseQuery(string $query): string { ++$GLOBALS['analysisCaseReferenceCalls']; return $query; } public static function ancientBookSources(string $query): array { throw new \RuntimeException('Analysis must retain reference_scope=case.'); } } } namespace { require dirname(__DIR__) . '/vendor/autoload.php'; use app\adminapi\logic\tcm\DiagnosisAiLogic; use app\adminapi\service\AiRequiredSource; function analysisTimeoutExpect(bool $ok, string $message): void { ++$GLOBALS['analysisTimeoutAssertions']; if (!$ok) throw new RuntimeException($message); } function analysisTimeoutFrame(array $event): string { return 'data: ' . json_encode($event, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n\n"; } $validAnswer = json_encode(['diagnosis_advice' => '合成病例:待医师复核。', 'risk_assessment' => [], 'treatment_advice' => '合成病例:继续核对本次资料。'], JSON_UNESCAPED_UNICODE); if (($argv[1] ?? '') === 'server') { $scenario = $argv[2]; $profile = $argv[3]; $expectedRequests = str_starts_with($scenario, 'repair_') ? 2 : 1; $listener = stream_socket_server('tcp://127.0.0.1:0', $errno, $error); if ($listener === false) exit(2); stream_set_blocking($listener, false); echo json_encode(['address' => stream_socket_get_name($listener, false)]) . "\n"; flush(); $clients = []; $generation = null; $task = ''; $started = 0.0; $terminalAt = null; $metrics = ['requests' => [], 'stops' => [], 'open_on_stop' => true, 'terminal_sent' => false]; $done = false; $deadline = microtime(true) + 7; while (!$done && microtime(true) < $deadline) { while (($socket = @stream_socket_accept($listener, 0)) !== false) { stream_set_blocking($socket, false); $clients[(int) $socket] = ['socket' => $socket, 'buffer' => '', 'handled' => false]; } foreach (array_keys($clients) as $key) { $socket = $clients[$key]['socket']; $chunk = @fread($socket, 65536); if ($chunk === '' && feof($socket)) { fclose($socket); unset($clients[$key]); if ($key === $generation && count($metrics['requests']) >= $expectedRequests) $done = true; continue; } if ($clients[$key]['handled']) continue; $clients[$key]['buffer'] .= is_string($chunk) ? $chunk : ''; $parts = explode("\r\n\r\n", $clients[$key]['buffer'], 2); if (count($parts) !== 2) continue; preg_match('/Content-Length:\s*(\d+)/i', $parts[0], $length); if (strlen($parts[1]) < (int) ($length[1] ?? 0)) continue; $clients[$key]['handled'] = true; $payload = json_decode($parts[1], true); if (preg_match('#^POST /v1/chat-messages/([^/ ]+)/stop #', $parts[0], $match)) { $metrics['stops'][] = ['task' => $match[1], 'user' => $payload['user'] ?? '', 'after_ms' => (microtime(true) - $started) * 1000]; $metrics['open_on_stop'] = $metrics['open_on_stop'] && isset($clients[$generation]); $body = '{"result":"success"}'; fwrite($socket, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " . strlen($body) . "\r\nConnection: close\r\n\r\n" . $body); fclose($socket); unset($clients[$key]); $terminalAt = microtime(true) + 0.06; continue; } $index = count($metrics['requests']) + 1; $metrics['requests'][] = ['request_path' => strtok($parts[0], "\r\n"), 'payload' => $payload, 'profile_key_matches' => str_contains($parts[0], 'Authorization: Bearer loopback-only-' . $profile)]; $generation = $key; $started = microtime(true); $task = 'analysis-' . $profile . '-task-' . $index; fwrite($socket, "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n" . analysisTimeoutFrame(['event' => 'workflow_started', 'task_id' => $task])); $repairFirst = str_starts_with($scenario, 'repair_') && $index === 1; if ($scenario === 'success' || $scenario === 'repair_success' || $repairFirst) { fwrite($socket, analysisTimeoutFrame(['event' => 'message', 'task_id' => $task, 'answer' => $repairFirst ? 'deliberately malformed JSON' : $validAnswer]) . analysisTimeoutFrame(['event' => 'workflow_finished', 'task_id' => $task, 'data' => ['status' => 'succeeded']]) . analysisTimeoutFrame(['event' => 'message_end', 'task_id' => $task])); fclose($socket); unset($clients[$key]); if ($index >= $expectedRequests) $done = true; } elseif ($scenario === 'timeout_partial') { // Even valid three-field bytes without a terminal are not a successful analysis. fwrite($socket, analysisTimeoutFrame(['event' => 'message', 'task_id' => $task, 'answer' => $validAnswer])); } } if ($terminalAt !== null && microtime(true) >= $terminalAt && isset($clients[$generation])) { fwrite($clients[$generation]['socket'], analysisTimeoutFrame(['event' => 'message', 'task_id' => $task, 'answer' => $validAnswer]) . analysisTimeoutFrame(['event' => 'workflow_finished', 'task_id' => $task, 'data' => ['status' => 'stopped']]) . analysisTimeoutFrame(['event' => 'message_end', 'task_id' => $task])); $metrics['terminal_sent'] = true; fclose($clients[$generation]['socket']); unset($clients[$generation]); $done = true; } usleep(1000); } foreach ($clients as $client) fclose($client['socket']); fclose($listener); echo json_encode($metrics, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; exit($done ? 0 : 3); } $analysisTimeoutAssertions = 0; $rows = []; $sections = []; $metadata = []; foreach (AiRequiredSource::LABELS as $key => $label) { $sections[] = ['key' => $key, 'label' => $label, 'value' => [['source_marker' => 'SOURCE_' . $key]]]; $metadata[] = ['key' => $key, 'label' => $label, 'availability' => 'present', 'count' => 1, 'status' => 'prepared']; } foreach (['qwen', 'openai'] as $profile) { foreach (['success', 'repair_success', 'timeout_empty', 'timeout_partial', 'repair_timeout', 'restricted_timeout', 'cancel_known'] as $scenario) { $process = proc_open([PHP_BINARY, __FILE__, 'server', $scenario, $profile], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, null, ['bypass_shell' => true]); analysisTimeoutExpect(is_resource($process), 'local peer starts'); fclose($pipes[0]); stream_set_timeout($pipes[1], 8); $ready = json_decode((string) fgets($pipes[1]), true); analysisTimeoutExpect(isset($ready['address']), 'local peer returns an ephemeral loopback port'); $analysisTimeoutConfig = ['enable' => true, 'timeout' => 1, 'max_files' => 3, 'base_url' => 'http://' . $ready['address'] . '/v1/chat-messages', 'models' => [ 'qwen' => ['name' => 'synthetic-qwen', 'label' => '合成千问', 'api_key' => 'loopback-only-qwen'], 'openai' => ['name' => 'synthetic-openai', 'label' => '合成OpenAI', 'api_key' => 'loopback-only-openai'], ]]; $analysisTimeoutCurrent = ['id' => 11, 'patient_id' => 99, 'age' => 42, 'gender' => '女', 'chief_complaint' => '合成病例,乏力', 'tongue' => '本次舌淡红,苔薄白', 'pulse' => '本次脉弦细']; if ($scenario === 'restricted_timeout') unset($analysisTimeoutCurrent['pulse']); $image = 'http://' . $ready['address'] . '/synthetic-not-fetched.png'; $analysisTimeoutLongitudinal = ['snapshot' => ['required_content' => $sections], 'source_summary' => ['required_sections' => $metadata, 'snapshot_complete' => true], 'source_diagnosis_ids' => [3, 11], 'files' => [['type' => 'image', 'url' => $image]]]; $analysisAbortPolls = 0; $analysisConsumedStream = ''; $analysisTimeoutLogs = []; $analysisCaseReferenceCalls = 0; $started = microtime(true); $analysisDisconnectAt = $scenario === 'cancel_known' ? $started + 0.18 : null; $result = DiagnosisAiLogic::analysis(11, 7, ['root' => 1], $profile); $elapsed = (microtime(true) - $started) * 1000; $peer = json_decode((string) fgets($pipes[1]), true); $stderr = stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); analysisTimeoutExpect(proc_close($process) === 0 && $stderr === '', $scenario . ': local peer exits cleanly'); $requestCount = str_starts_with($scenario, 'repair_') ? 2 : 1; analysisTimeoutExpect(count($peer['requests']) === $requestCount, $scenario . ': only the existing bounded format repair may create a second generation'); analysisTimeoutExpect($analysisAbortPolls > 0 && $elapsed < 3750, $scenario . ': cancellable transport polls and remains bounded'); foreach ($peer['requests'] as $index => $request) { $payload = $request['payload']; analysisTimeoutExpect($request['request_path'] === 'POST /v1/chat-messages HTTP/1.1' && $payload['response_mode'] === 'streaming' && $request['profile_key_matches'], $scenario . ': the selected profile stays on its Dify channel with a real internal stream'); analysisTimeoutExpect($payload['user'] === 'admin-diagnosis-analysis-7' && $payload['files'][0]['url'] === $image && count($payload['files']) === 1, $scenario . ': original analysis user and image reference reach the actual HTTP payload'); analysisTimeoutExpect($payload['inputs']['prompt_version'] === 'patient-context-analysis-tcm-four-diagnoses-v43', $scenario . ': current v43 prompt provenance reaches the actual payload'); foreach (array_keys(AiRequiredSource::LABELS) as $key) { analysisTimeoutExpect(str_contains($payload['query'], 'SOURCE_' . $key), $scenario . ': full source survives: ' . $key); } analysisTimeoutExpect(str_contains($payload['query'], '格式校验提醒') === ($index === 1), $scenario . ': only the established malformed-output repair prompt is used'); } analysisTimeoutExpect($analysisCaseReferenceCalls === ($scenario === 'restricted_timeout' ? 0 : $requestCount), $scenario . ': ordinary and repaired clinical requests retain case scope, restricted requests retain evidence mode'); if (in_array($scenario, ['success', 'repair_success'], true)) { analysisTimeoutExpect(is_array($result) && $result['model_key'] === $profile && $result['model_name'] === 'synthetic-' . $profile && $result['diagnosis_advice'] === '合成病例:待医师复核。' && $result['risk_assessment'] === [] && $result['treatment_advice'] === '合成病例:继续核对本次资料。', $scenario . ': private stream preserves the public three-field JSON and profile'); analysisTimeoutExpect(count($result['source_summary']['required_sections']) === 13 && $peer['stops'] === [], $scenario . ': successful completion keeps all source receipts without a stop'); } else { analysisTimeoutExpect($result === null && DiagnosisAiLogic::getError() === 'AI智能分析暂时不可用,请稍后重试', $scenario . ': timeout/cancellation cannot become late or local success'); $expectedTask = 'analysis-' . $profile . '-task-' . $requestCount; analysisTimeoutExpect(count($peer['stops']) === 1 && $peer['stops'][0]['task'] === $expectedTask && $peer['stops'][0]['user'] === 'admin-diagnosis-analysis-7' && $peer['open_on_stop'], $scenario . ': stop is bound to the exact timed-out task and original user before transport closes'); analysisTimeoutExpect($peer['terminal_sent'] && str_contains($analysisConsumedStream, '"task_id":"' . $expectedTask . '","data":{"status":"stopped"}'), $scenario . ': production stream consumes the explicit stopped workflow terminal'); if ($scenario !== 'cancel_known') { analysisTimeoutExpect($peer['stops'][0]['after_ms'] >= 900 && $peer['stops'][0]['after_ms'] < 1250, $scenario . ': ordinary generation deadline itself initiates the stop'); analysisTimeoutExpect(str_contains(implode('', $analysisTimeoutLogs), '"error_code":"UPSTREAM_TIMEOUT"'), $scenario . ': underlying failure remains an ordinary timeout'); } } $rows[] = ['profile' => $profile, 'scenario' => $scenario, 'requests' => $requestCount, 'stops' => count($peer['stops']), 'elapsed_ms' => round($elapsed, 1)]; } } echo 'Diagnosis AI analysis internal stream / timeout stop integration: ' . $analysisTimeoutAssertions . ' assertions passed ' . json_encode($rows) . "\n"; }