false, 'error_code' => 'UPSTREAM_TIMEOUT', 'error' => '合成上传超时']; } $uploaded = []; $indices = []; $skipped = []; foreach ($files as $index => $file) { $missing = $GLOBALS['timeoutReceiptScenario'] === 'all_missing' || (in_array($GLOBALS['timeoutReceiptScenario'], ['missing', 'deadline_after_upload', 'deadline_during_upload'], true) && $index === 0); if ($missing) { $skipped[] = ['index' => $index + 1, 'kind' => $file['type'], 'reason' => 'not_found']; if (is_callable($options['on_upload'] ?? null)) ($options['on_upload'])([ 'index' => $index + 1, 'total' => count($files), 'status' => 'skipped', 'reason' => 'not_found']); } else { $uploaded[] = ['type' => $file['type'], 'transfer_method' => 'local_file', 'upload_file_id' => 'private-upload-' . $index]; $indices[] = $index + 1; if (is_callable($options['on_upload'] ?? null)) ($options['on_upload'])([ 'index' => $index + 1, 'total' => count($files), 'status' => 'uploaded']); } } if (str_starts_with($GLOBALS['timeoutReceiptScenario'], 'deadline_')) { $GLOBALS['timeoutReceiptContext']->deadline = microtime(true) - 1; if ($GLOBALS['timeoutReceiptScenario'] === 'deadline_during_upload') { return ['ok' => false, 'error_code' => 'AI_ASSISTANT_CANCELLED', 'error' => '合成共享期限中止']; } } return ['ok' => true, 'files' => $uploaded, 'uploaded_indices' => $indices, 'skipped' => $skipped]; } } function curl_init(): \stdClass { return (object) ['errno' => 0, 'code' => 0]; } function curl_setopt_array(\stdClass $handle, array $options): bool { $handle->options = $options; return true; } function curl_exec(\stdClass $handle): string|bool { $payload = json_decode($handle->options[CURLOPT_POSTFIELDS], true); $GLOBALS['timeoutReceiptRequests'][] = $payload; $scenario = $GLOBALS['timeoutReceiptScenario']; $stream = ($payload['response_mode'] ?? '') === 'streaming'; $extract = str_starts_with($payload['query'] ?? '', '【附件分批证据提取】'); if ($scenario === 'unsent') { $handle->errno = CURLE_OPERATION_TIMEDOUT; return false; } $handle->code = 200; ($handle->options[CURLOPT_HEADERFUNCTION])($handle, "HTTP/1.1 200 OK\r\n"); if ($extract && $scenario !== 'batch_extract_timeout') { preg_match_all('/原始附件#(\d+)(/u', $payload['query'], $matches); $answer = json_encode(['files' => array_map(static fn (string $index): array => ['index' => (int) $index, 'status' => 'read', 'findings' => '合成来源观察'], $matches[1])], JSON_UNESCAPED_UNICODE); if (!$stream) return json_encode(['answer' => $answer]); ($handle->options[CURLOPT_WRITEFUNCTION])($handle, 'data: ' . json_encode(['event' => 'message', 'answer' => $answer]) . "\n\n"); ($handle->options[CURLOPT_WRITEFUNCTION])($handle, "data: {\"event\":\"message_end\"}\n\n"); return true; } if ($stream && $scenario === 'partial') { ($handle->options[CURLOPT_WRITEFUNCTION])($handle, "data: {\"event\":\"message\",\"answer\":\"截止前合成片段\"}\n\n"); } $handle->errno = CURLE_OPERATION_TIMEDOUT; return false; } function curl_errno(\stdClass $handle): int { return $handle->errno; } function curl_getinfo(\stdClass $handle, int $option): int { return $handle->code; } function curl_close(\stdClass $handle): void {} } namespace { require dirname(__DIR__) . '/vendor/autoload.php'; use app\adminapi\service\AiRequiredSource; use app\common\service\DifyChatService; function timeoutReceiptExpect(bool $ok, string $message): void { if (!$ok) throw new RuntimeException($message); } $cases = 0; foreach ([false, true] as $stream) foreach (['submitted', 'unsent', 'missing', 'all_missing', 'partial', 'batch_final', 'batch_extract_timeout', 'upload_timeout', 'deadline_after_upload', 'deadline_during_upload'] as $timeoutReceiptScenario) { $upload = in_array($timeoutReceiptScenario, ['missing', 'all_missing', 'upload_timeout', 'deadline_after_upload', 'deadline_during_upload'], true); $timeoutReceiptConfig = ['enable' => true, 'base_url' => 'https://synthetic.invalid/v1/chat-messages', 'timeout' => 5, 'max_files' => 2, 'required_attachment_transport' => $upload ? 'upload' : 'remote_url', 'models' => ['qwen' => ['name' => 'synthetic', 'api_key' => 'fixture-only']]]; $files = array_map(static fn (int $index): array => ['type' => 'image', 'url' => 'https://private.invalid/' . $index . '.png'], range(1, str_starts_with($timeoutReceiptScenario, 'batch_') ? 3 : ($upload ? 2 : 1))); $events = []; $deltas = []; $timeoutReceiptRequests = []; $options = ['required_files' => true, 'knowledge_mode' => 'evidence', 'required_sections' => AiRequiredSource::manifest([]), 'source_summary' => ['diagnosis_count' => 3], 'on_progress' => static function (array $event) use (&$events): void { $events[] = $event; }]; if (str_starts_with($timeoutReceiptScenario, 'deadline_')) { $timeoutReceiptContext = (object) ['deadline' => microtime(true) + 5, 'uploads' => []]; $options['_required_batch_internal'] = true; $options['_required_batch_context'] = $timeoutReceiptContext; } $result = $stream ? DifyChatService::streamChat('qwen', [], '合成超时资料回执', 'same-original-user', static function (string $delta) use (&$deltas): bool { $deltas[] = $delta; return true; }, null, $files, $options) : DifyChatService::chat('qwen', [], '合成超时资料回执', 'same-original-user', $files, $options); $context = ($stream ? 'stream/' : 'blocking/') . $timeoutReceiptScenario . ': '; timeoutReceiptExpect(($result['error_code'] ?? '') === 'UPSTREAM_TIMEOUT' && empty($result['ok']), $context . 'model failure remains timeout'); timeoutReceiptExpect(count($result['required_sections'] ?? []) === 13 && count($result['items'] ?? []) === count($files) && !empty($result['transfer_manifest_final']), $context . 'timeout carries complete authoritative source receipt'); timeoutReceiptExpect(($result['source_summary']['diagnosis_count'] ?? 0) === 3 && $result['source_summary']['required_sections'] === $result['required_sections'], $context . 'summary mirrors the final source receipt'); $sectionStatuses = array_values(array_unique(array_column($result['required_sections'], 'status'))); $attachments = array_values(array_filter($events, static fn (array $event): bool => ($event['phase'] ?? '') === 'attachments')); $upstreamFailures = array_filter($events, static fn (array $event): bool => ($event['phase'] ?? '') === 'upstream' && ($event['status'] ?? '') === 'failed'); if (str_starts_with($timeoutReceiptScenario, 'deadline_')) { timeoutReceiptExpect($sectionStatuses === ['prepared'] && $result['submitted_attachment_count'] === 0 && $result['items'][0]['status'] === 'skipped' && $result['skipped_attachment_count'] === 1 && $timeoutReceiptRequests === [], $context . 'shared deadline before generation preserves missing-source facts without claiming submission'); } elseif (in_array($timeoutReceiptScenario, ['unsent', 'upload_timeout'], true)) { timeoutReceiptExpect($sectionStatuses === ['failed'] && ($result['submitted_attachment_count'] ?? -1) === 0, $context . 'unsent sources are never promoted to submitted'); timeoutReceiptExpect(in_array('failed', array_column($attachments, 'status'), true), $context . 'actual transmission failure remains explicit'); if ($timeoutReceiptScenario === 'upload_timeout') timeoutReceiptExpect($timeoutReceiptRequests === [], 'upload failure starts no generation'); } elseif ($timeoutReceiptScenario === 'batch_extract_timeout') { timeoutReceiptExpect($sectionStatuses === ['prepared'] && $result['submitted_attachment_count'] === 2 && $result['items'][2]['status'] !== 'submitted' && count($timeoutReceiptRequests) === 1, $context . 'only the sent extraction batch is submitted; unsent final clinical body stays prepared'); } else { $skipped = $timeoutReceiptScenario === 'all_missing' ? 2 : ($timeoutReceiptScenario === 'missing' ? 1 : 0); timeoutReceiptExpect($sectionStatuses === ['submitted'] && $result['submitted_attachment_count'] === count($files) - $skipped && $result['skipped_attachment_count'] === $skipped, $context . 'submitted and missing source facts survive model timeout'); timeoutReceiptExpect(!in_array('failed', array_column($attachments, 'status'), true) && count($upstreamFailures) === 1, $context . 'model timeout is distinct from successful transmission'); if (str_starts_with($timeoutReceiptScenario, 'batch_')) timeoutReceiptExpect(count($timeoutReceiptRequests) === 3 && end($attachments)['status'] === 'submitted', 'batch synthesis timeout retains final submitted source status'); } timeoutReceiptExpect(!str_contains(json_encode($result), 'private.invalid') && !str_contains(json_encode($result), 'private-upload-'), $context . 'receipt discloses no source URL or upload ID'); timeoutReceiptExpect($deltas === ($stream && $timeoutReceiptScenario === 'partial' ? ['截止前合成片段'] : []), $context . 'receipt handling neither manufactures nor repeats clinical text'); ++$cases; } echo 'Diagnosis AI timeout source receipt / submitted versus generation failure: ' . $cases . " offline scenarios OK\n"; }