*/ private const ALLOWED_PROFILES = ['qwen', 'openai']; private const MIN_TIMEOUT = 1; private const MAX_TIMEOUT = 300; private const DEFAULT_MAX_FILES = 3; /** * 上游明确以“这批附件我处理不了”拒绝整次请求时使用的状态码。 * 命中后会去掉附件重试一次,避免一张舌象图让整份病历分析失败。 */ private const FILE_REJECTION_CODES = [400, 413, 415, 422]; /** * @param array $inputs * @return array{ok:bool,content?:string,message_id?:string,latency_ms?:int,error_code?:string,error?:string} */ public static function chat( string $profile, array $inputs, string $query, string $user, array $files = [] ): array { $config = config('prescription_ai') ?: []; if (empty($config['enable'])) { return self::error('CONFIG_DISABLED', 'AI 报告功能未启用'); } $modelConfig = self::resolveProfileConfig($config, $profile); if ($modelConfig === null) { return self::error('INVALID_PROFILE', '不支持的 AI 模型'); } $baseUrl = trim((string) ($config['base_url'] ?? '')); $rawApiKey = (string) ($modelConfig['api_key'] ?? ''); $apiKey = trim($rawApiKey); if ($baseUrl === '' || $apiKey === '') { return self::error('CONFIG_MISSING', '该模型服务尚未完整配置'); } if (!self::isValidBaseUrl($baseUrl) || strpbrk($rawApiKey, "\r\n") !== false) { return self::error('CONFIG_INVALID', 'AI 服务配置无效'); } $timeout = (int) ($config['timeout'] ?? 0); if (!self::isValidTimeout($timeout)) { return self::error('CONFIG_INVALID', 'AI 服务超时配置无效'); } if (!function_exists('curl_init')) { return self::error('CURL_UNAVAILABLE', '服务器尚未启用 cURL 扩展'); } $model = trim((string) ($modelConfig['name'] ?? '')); if ($model === '') { return self::error('CONFIG_INVALID', 'AI 模型配置无效'); } $normalized = self::normalizeFiles($files, self::maxFiles($config)); $startedAt = microtime(true); $formatted = null; foreach (self::buildAttemptPlan($normalized['kept'], $normalized['dropped']) as $attempt) { $requestSpecs = self::buildRequestSpecs( $baseUrl, $model, $inputs, $query, $user, false, $attempt['files'], $attempt['omitted'] ); $lastResponse = null; $lastSpec = []; // 协议回退会把最初的“附件被拒”换成另一协议的状态码,因此降级判断 // 必须记住本轮出现过的附件拒绝信号,而不能只看最后一次响应。 $fileRejected = false; foreach ($requestSpecs as $index => $requestSpec) { $elapsedSeconds = (int) floor(microtime(true) - $startedAt); $remainingTimeout = $timeout - $elapsedSeconds; if ($remainingTimeout < self::MIN_TIMEOUT) { return self::error( 'UPSTREAM_TIMEOUT', '模型响应超时,请稍后重试', self::elapsedMilliseconds($startedAt) ); } $response = self::sendRequest( $requestSpec['url'], $requestSpec['payload'], $apiKey, $remainingTimeout ); $lastResponse = $response; $lastSpec = $requestSpec; $fileRejected = $fileRejected || self::isFileRejection($response, $attempt['files']); // /v1 在两种协议中都是合法基址。仅在明确表示路径不存在时尝试另一协议, // 避免因业务参数错误而重复提交同一份临床数据。 $hasFallback = isset($requestSpecs[$index + 1]); if ($hasFallback && self::shouldTryNextProtocol($response, false)) { continue; } break; } $lastResponse = $lastResponse ?? ['body' => '', 'errno' => 0, 'http_code' => 0]; $formatted = self::formatResponse($lastResponse, $startedAt); self::logUpstreamFailure($lastSpec, $lastResponse, $query, $attempt['files'], $formatted); if (!empty($formatted['ok'])) { return $formatted; } // 附件整体被拒时退回纯文本重试,附件清单已在下一轮尝试中补齐。 if (!$fileRejected) { return $formatted; } } return $formatted ?? self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', self::elapsedMilliseconds($startedAt)); } /** * 流式调用 Dify / OpenAI-compatible 接口。上游原始响应与凭据不会进入返回值。 * * @param array $inputs * @param callable(string):mixed $onDelta * @param callable():bool|null $shouldAbort * @return array{ok:bool,content?:string,message_id?:string,latency_ms:int,error_code?:string,error?:string} */ public static function streamChat( string $profile, array $inputs, string $query, string $user, callable $onDelta, ?callable $shouldAbort = null, array $files = [] ): array { $config = config('prescription_ai') ?: []; if (empty($config['enable'])) { return self::error('CONFIG_DISABLED', 'AI 报告功能未启用'); } $modelConfig = self::resolveProfileConfig($config, $profile); if ($modelConfig === null) { return self::error('INVALID_PROFILE', '不支持的 AI 模型'); } $baseUrl = trim((string) ($config['base_url'] ?? '')); $rawApiKey = (string) ($modelConfig['api_key'] ?? ''); $apiKey = trim($rawApiKey); if ($baseUrl === '' || $apiKey === '') { return self::error('CONFIG_MISSING', '该模型服务尚未完整配置'); } if (!self::isValidBaseUrl($baseUrl) || strpbrk($rawApiKey, "\r\n") !== false) { return self::error('CONFIG_INVALID', 'AI 服务配置无效'); } $timeout = (int) ($config['timeout'] ?? 0); if (!self::isValidTimeout($timeout)) { return self::error('CONFIG_INVALID', 'AI 服务超时配置无效'); } if (!function_exists('curl_init')) { return self::error('CURL_UNAVAILABLE', '服务器尚未启用 cURL 扩展'); } $model = trim((string) ($modelConfig['name'] ?? '')); if ($model === '') { return self::error('CONFIG_INVALID', 'AI 模型配置无效'); } $normalized = self::normalizeFiles($files, self::maxFiles($config)); $startedAt = microtime(true); $formatted = null; foreach (self::buildAttemptPlan($normalized['kept'], $normalized['dropped']) as $attempt) { $requestSpecs = self::buildRequestSpecs( $baseUrl, $model, $inputs, $query, $user, true, $attempt['files'], $attempt['omitted'] ); $lastResponse = null; $lastSpec = []; // 协议回退会把最初的“附件被拒”换成另一协议的状态码,因此降级判断 // 必须记住本轮出现过的附件拒绝信号,而不能只看最后一次响应。 $fileRejected = false; foreach ($requestSpecs as $index => $requestSpec) { $elapsedSeconds = (int) floor(microtime(true) - $startedAt); $remainingTimeout = $timeout - $elapsedSeconds; if ($remainingTimeout < self::MIN_TIMEOUT) { return self::error( 'UPSTREAM_TIMEOUT', '模型响应超时,请稍后重试', self::elapsedMilliseconds($startedAt) ); } $response = self::sendStreamRequest( $requestSpec['protocol'], $requestSpec['url'], $requestSpec['payload'], $apiKey, $remainingTimeout, $onDelta, $shouldAbort ); $lastResponse = $response; $lastSpec = $requestSpec; $fileRejected = $fileRejected || self::isFileRejection($response, $attempt['files']); // 只在尚未向下游发送任何文本、且明确为路径不支持时尝试另一协议。 $hasFallback = isset($requestSpecs[$index + 1]); if ( $hasFallback && self::shouldTryNextProtocol($response, true) ) { continue; } break; } $lastResponse = $lastResponse ?? self::emptyStreamResponse(0); $formatted = self::formatStreamResponse($lastResponse, $startedAt); self::logUpstreamFailure($lastSpec, $lastResponse, $query, $attempt['files'], $formatted); if (!empty($formatted['ok'])) { return $formatted; } // 已经推给医生的文本不能重复输出,因此只在一个字都没发出去时才降级重试。 if (!empty($lastResponse['emitted']) || !$fileRejected) { return $formatted; } } return $formatted ?? self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', self::elapsedMilliseconds($startedAt)); } /** * @param array $config * @return array|null */ private static function resolveProfileConfig(array $config, string $profile): ?array { if (!in_array($profile, self::ALLOWED_PROFILES, true)) { return null; } $modelConfig = $config['models'][$profile] ?? null; return is_array($modelConfig) ? $modelConfig : null; } /** * @param array $inputs * @param array> $files 随请求送达的附件 * @param array> $omitted 超出上游数量上限、只能写进清单的附件 * @return array}> */ private static function buildRequestSpecs( string $baseUrl, string $model, array $inputs, string $query, string $user, bool $streaming = false, array $files = [], array $omitted = [] ): array { $baseUrl = rtrim($baseUrl, '/'); $path = strtolower((string) (parse_url($baseUrl, PHP_URL_PATH) ?? '')); // Dify 能承载全部附件类型,只需补上被数量上限截断的清单。 // inputs 必须是 JSON 对象:空数组会被 json_encode 成 [],Dify 直接 // 以 invalid_param 拒绝整单,因此这里强制对象语义。 $difySpec = [ 'protocol' => 'dify', 'url' => self::buildEndpoint($baseUrl, 'chat-messages'), 'payload' => [ 'inputs' => (object) $inputs, 'query' => self::withAttachmentManifest($query, $omitted), 'response_mode' => $streaming ? 'streaming' : 'blocking', 'user' => $user, ], ]; if ($files !== []) { $difySpec['payload']['files'] = $files; } // Chat Completions 只能内联图片,非图片附件与被截断的附件一并进清单。 $openAiContent = self::buildOpenAiContent( self::withAttachmentManifest( $query, array_merge(self::nonImageFiles($files), $omitted) ), $files ); $openAiSpec = [ 'protocol' => 'openai', 'url' => self::buildEndpoint($baseUrl, 'chat/completions'), 'payload' => [ 'model' => $model, 'messages' => [ ['role' => 'user', 'content' => $openAiContent], ], 'stream' => $streaming, ], ]; if (!$streaming) { unset($openAiSpec['payload']['stream']); } if (str_ends_with($path, '/chat-messages')) { return [$difySpec]; } if (str_ends_with($path, '/chat/completions')) { return [$openAiSpec]; } // 保持既有 /v1 Dify 配置优先,同时让 OpenAI-compatible 服务在 404/405 后透明回退。 // 早期实现只在“无附件或全是图片”时提供回退,患者带检查报告/录像附件时 // Dify 路径 404 会直接变成“模型未能处理本次请求”,因此这里始终保留回退, // 非图片附件改为在正文中以清单形式随请求送达,绝不静默丢弃。 return [$difySpec, $openAiSpec]; } /** * 构造 OpenAI-compatible 正文。图片走多模态 image_url;非图片附件已由调用方 * 写进 $query 末尾的清单,这里只负责内联图片。 * * @param array> $files * @return string|array> */ private static function buildOpenAiContent(string $query, array $files) { $content = [['type' => 'text', 'text' => $query]]; foreach ($files as $file) { $url = (string) ($file['url'] ?? ''); if ($url === '' || ($file['type'] ?? '') !== 'image') { continue; } $content[] = [ 'type' => 'image_url', 'image_url' => ['url' => $url], ]; } return count($content) === 1 ? $query : $content; } /** * @param array> $files * @return array> */ private static function nonImageFiles(array $files): array { return array_values(array_filter( $files, static fn (array $file): bool => ($file['type'] ?? '') !== 'image' )); } /** * 清洗附件,并按上游应用允许的数量截断。 * * Dify 用 file_upload.number_limits 校验单次请求的附件总数,超出即返回 * 400 invalid_param 拒绝整单。患者纵向资料的附件数量不可控(舌象、报告、 * 录像可能几十份),因此这里必须主动截断;被截断的附件不会被悄悄丢弃, * 而是以清单形式随提示词送达,让模型知道存在哪些它读不到的资料。 * 保持调用方给定的顺序,由调用方决定哪些附件最值得送上去。 * * @param array $files * @return array{ * kept:array, * dropped:array * } */ private static function normalizeFiles(array $files, int $maxFiles): array { $maxFiles = max(0, $maxFiles); $kept = []; $dropped = []; $seen = []; foreach ($files as $file) { if (!is_array($file)) { continue; } $type = strtolower(trim((string) ($file['type'] ?? ''))); $url = trim((string) ($file['url'] ?? '')); if (!in_array($type, ['image', 'document', 'audio', 'video', 'custom'], true) || !self::isValidRemoteFileUrl($url) || isset($seen[$url])) { continue; } $seen[$url] = true; $normalized = [ 'type' => $type, 'transfer_method' => 'remote_url', 'url' => $url, ]; if (count($kept) >= $maxFiles) { $dropped[] = $normalized; continue; } $kept[] = $normalized; } return ['kept' => $kept, 'dropped' => $dropped]; } /** @param array $config */ private static function maxFiles(array $config): int { $configured = (int) ($config['max_files'] ?? self::DEFAULT_MAX_FILES); return $configured >= 0 ? $configured : self::DEFAULT_MAX_FILES; } /** * 排出两轮尝试:先带附件,附件被上游整体拒绝时再只发文本。 * 第二轮把全部附件写进清单,保证降级后模型仍知道资料缺口。 * * @param array> $files * @param array> $dropped * @return array>,omitted:array>}> */ private static function buildAttemptPlan(array $files, array $dropped): array { $attempts = [['files' => $files, 'omitted' => $dropped]]; if ($files !== []) { $attempts[] = ['files' => [], 'omitted' => array_merge($files, $dropped)]; } return $attempts; } /** * @param array> $files */ private static function shouldRetryWithoutFiles(int $httpCode, array $files): bool { return $files !== [] && in_array($httpCode, self::FILE_REJECTION_CODES, true); } /** * 判断一次上游响应是否属于“这批附件我处理不了”。 * * 除了 4xx 状态码,Dify 拉不到附件时会在 200 的 SSE 流里发 event:error, * 这两种形态都必须触发去掉附件的降级重试。 * * @param array $response * @param array> $files */ private static function isFileRejection(array $response, array $files): bool { if ($files === [] || (int) ($response['errno'] ?? 0) !== 0) { return false; } if (self::shouldRetryWithoutFiles((int) ($response['http_code'] ?? 0), $files)) { return true; } return !empty($response['upstream_error']); } /** * 把无法随请求送达的附件写成显式清单。模型必须知道这些资料存在但读不到, * 才不会把“没看到”当成“没有”。 * * @param array> $omitted */ private static function withAttachmentManifest(string $query, array $omitted): string { $lines = []; foreach ($omitted as $file) { $url = (string) ($file['url'] ?? ''); if ($url === '') { continue; } $lines[] = strtoupper((string) ($file['type'] ?? 'file')) . ' ' . $url; } if ($lines === []) { return $query; } return $query . "\n\n\n" . "以下附件无法随本次请求送达,只提供来源地址;无法读取的附件必须在结论中明确标注为信息缺口。\n" . implode("\n", $lines) . "\n"; } private static function isValidRemoteFileUrl(string $url): bool { if ($url === '' || preg_match('/[\x00-\x20\x7f]/', $url)) { return false; } $parts = parse_url($url); return is_array($parts) && in_array(strtolower((string) ($parts['scheme'] ?? '')), ['http', 'https'], true) && trim((string) ($parts['host'] ?? '')) !== '' && !isset($parts['user']) && !isset($parts['pass']); } private static function buildEndpoint(string $baseUrl, string $endpoint): string { $baseUrl = rtrim($baseUrl, '/'); $path = strtolower((string) (parse_url($baseUrl, PHP_URL_PATH) ?? '')); if (str_ends_with($path, '/chat-messages') || str_ends_with($path, '/chat/completions')) { return $baseUrl; } if (str_ends_with($path, '/v1')) { return $baseUrl . '/' . $endpoint; } return $baseUrl . '/v1/' . $endpoint; } /** * Decide whether an ambiguous base URL should be tried with the other wire * protocol. A 400/415/422 response cannot have started generation, and a * 2xx stream with no delivered delta but no valid terminal frame is also * safe to retry. Authentication, rate-limit and server failures retain * their original diagnosis instead of being hidden by a second request. * * @param array $response */ private static function shouldTryNextProtocol(array $response, bool $streaming): bool { if ((int) ($response['errno'] ?? 0) !== 0) { return false; } $httpCode = (int) ($response['http_code'] ?? 0); if (in_array($httpCode, [400, 404, 405, 415, 422, 501], true)) { return true; } if (!$streaming || $httpCode < 200 || $httpCode >= 300 || !empty($response['emitted'])) { return false; } return !empty($response['upstream_error']) || empty($response['finished']); } private static function isValidBaseUrl(string $baseUrl): bool { if (preg_match('/[\x00-\x20\x7f]/', $baseUrl)) { return false; } $parts = parse_url($baseUrl); if (!is_array($parts)) { return false; } $scheme = strtolower((string) ($parts['scheme'] ?? '')); return in_array($scheme, ['http', 'https'], true) && trim((string) ($parts['host'] ?? '')) !== '' && !isset($parts['user']) && !isset($parts['pass']) && !isset($parts['query']) && !isset($parts['fragment']); } private static function isValidTimeout(int $timeout): bool { return $timeout >= self::MIN_TIMEOUT && $timeout <= self::MAX_TIMEOUT; } /** * @param array $payload * @return array{body:string,errno:int,http_code:int} */ private static function sendRequest( string $url, array $payload, string $apiKey, int $timeout ): array { $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); if ($body === false) { return ['body' => '', 'errno' => -1, 'http_code' => 0]; } $ch = curl_init(); if ($ch === false) { return ['body' => '', 'errno' => -2, 'http_code' => 0]; } curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => min(8, max(1, (int) ceil($timeout / 4))), CURLOPT_TIMEOUT => $timeout, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Accept: application/json', 'Authorization: Bearer ' . $apiKey, ], ]); $responseBody = curl_exec($ch); $errno = curl_errno($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); return [ 'body' => is_string($responseBody) ? $responseBody : '', 'errno' => $errno, 'http_code' => $httpCode, ]; } /** * @param array $payload * @param callable(string):mixed $onDelta * @param callable():bool|null $shouldAbort * @return array{ * errno:int,http_code:int,content:string,message_id:string,emitted:bool, * upstream_error:bool,client_aborted:bool,callback_error:bool,finished:bool * } */ private static function sendStreamRequest( string $protocol, string $url, array $payload, string $apiKey, int $timeout, callable $onDelta, ?callable $shouldAbort ): array { $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); if ($body === false) { return self::emptyStreamResponse(-1); } $ch = curl_init(); if ($ch === false) { return self::emptyStreamResponse(-2); } $buffer = ''; $state = self::newStreamState(); $responseCode = 0; $header = static function ($handle, string $line) use (&$responseCode): int { if (preg_match('/^HTTP\/\S+\s+(\d{3})(?:\s|$)/i', trim($line), $matches) === 1) { $responseCode = (int) $matches[1]; } return strlen($line); }; $write = static function ($handle, string $chunk) use ( $protocol, &$buffer, &$state, &$responseCode, $onDelta, $shouldAbort ): int { if ($shouldAbort !== null && $shouldAbort()) { $state['client_aborted'] = true; return 0; } if ($responseCode < 200 || $responseCode >= 300) { // Never decode or forward an error response body. Besides preventing // leakage, this keeps 404/405 protocol fallback side-effect free. return strlen($chunk); } self::consumeStreamBytes($protocol, $buffer, $chunk, $state, $onDelta); return $state['callback_error'] ? 0 : strlen($chunk); }; $progress = static function () use (&$state, $shouldAbort): int { if ($shouldAbort !== null && $shouldAbort()) { $state['client_aborted'] = true; return 1; } return 0; }; curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => false, CURLOPT_CONNECTTIMEOUT => min(8, max(1, (int) ceil($timeout / 4))), CURLOPT_TIMEOUT => $timeout, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Accept: text/event-stream', 'Authorization: Bearer ' . $apiKey, ], CURLOPT_HEADERFUNCTION => $header, CURLOPT_WRITEFUNCTION => $write, CURLOPT_NOPROGRESS => false, CURLOPT_XFERINFOFUNCTION => $progress, ]); curl_exec($ch); $errno = curl_errno($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if (!$state['client_aborted'] && !$state['callback_error']) { self::consumeStreamBytes($protocol, $buffer, '', $state, $onDelta, true); } return [ 'errno' => $errno, 'http_code' => $httpCode, 'content' => $state['content'], 'message_id' => $state['message_id'], 'emitted' => $state['emitted'], 'upstream_error' => $state['upstream_error'], 'upstream_code' => $state['upstream_code'], 'client_aborted' => $state['client_aborted'], 'callback_error' => $state['callback_error'], 'finished' => $state['finished'], ]; } /** * @return array{ * content:string,message_id:string,emitted:bool,upstream_error:bool, * client_aborted:bool,callback_error:bool,finished:bool * } */ private static function newStreamState(): array { return [ 'content' => '', 'message_id' => '', 'emitted' => false, 'upstream_error' => false, 'upstream_code' => '', 'client_aborted' => false, 'callback_error' => false, 'finished' => false, ]; } /** * 上游错误码只保留可枚举的短标识(如 invalid_param),杜绝把上游文案或 * 患者资源地址带进日志。 * * @param mixed $code */ private static function cleanUpstreamCode($code): string { if (!is_string($code)) { return ''; } return preg_match('/^[a-z0-9_.-]{1,64}$/i', $code) === 1 ? $code : ''; } /** * 按 SSE 空行分帧;仅在完整 data frame 后 json_decode,因此可安全接收任意字节边界。 * * @param array $state * @param callable(string):mixed $onDelta */ private static function consumeStreamBytes( string $protocol, string &$buffer, string $chunk, array &$state, callable $onDelta, bool $final = false ): void { $buffer .= $chunk; while (preg_match('/(?:\r\n|\r|\n){2}/', $buffer, $match, PREG_OFFSET_CAPTURE) === 1) { $delimiter = $match[0][0]; $offset = $match[0][1]; $frame = substr($buffer, 0, $offset); $buffer = (string) substr($buffer, $offset + strlen($delimiter)); self::consumeStreamFrame($protocol, $frame, $state, $onDelta); } if ($final && trim($buffer) !== '') { self::consumeStreamFrame($protocol, $buffer, $state, $onDelta); $buffer = ''; } } /** * @param array $state * @param callable(string):mixed $onDelta */ private static function consumeStreamFrame( string $protocol, string $frame, array &$state, callable $onDelta ): void { if ($state['finished'] || $state['upstream_error'] || $state['callback_error']) { return; } $dataLines = []; foreach (preg_split('/\r\n|\r|\n/', $frame) ?: [] as $line) { if ($line === '' || str_starts_with($line, ':')) { continue; } if (str_starts_with($line, 'data:')) { $dataLines[] = ltrim(substr($line, 5), ' '); } } if ($dataLines === []) { return; } $data = implode("\n", $dataLines); if ($data === '[DONE]') { $state['finished'] = true; return; } $decoded = json_decode($data, true); if (!is_array($decoded)) { return; } $delta = ''; if ($protocol === 'dify') { $event = strtolower((string) ($decoded['event'] ?? '')); if ($event === 'message_end') { $state['message_id'] = (string) ($decoded['message_id'] ?? $state['message_id']); $state['finished'] = true; return; } if ($event === 'error') { $state['upstream_error'] = true; // 只留可枚举的错误码用于排障;message 可能含患者资源地址,不落日志。 $state['upstream_code'] = self::cleanUpstreamCode($decoded['code'] ?? ''); return; } if (!in_array($event, ['message', 'agent_message'], true)) { return; } $delta = is_string($decoded['answer'] ?? null) ? $decoded['answer'] : ''; $state['message_id'] = (string) ($decoded['message_id'] ?? $state['message_id']); } else { $delta = self::extractStreamDelta($decoded); $state['message_id'] = (string) ($decoded['id'] ?? $state['message_id']); } if ($delta === '') { return; } try { $accepted = $onDelta($delta); if ($accepted === false) { $state['callback_error'] = true; return; } } catch (\Throwable $e) { $state['callback_error'] = true; return; } $state['content'] .= $delta; $state['emitted'] = true; } /** @param array $decoded */ private static function extractStreamDelta(array $decoded): string { $content = $decoded['choices'][0]['delta']['content'] ?? ''; if (is_string($content)) { return $content; } if (!is_array($content)) { return ''; } $parts = []; foreach ($content as $part) { if (is_array($part) && ($part['type'] ?? '') === 'text' && is_string($part['text'] ?? null)) { $parts[] = $part['text']; } } return implode('', $parts); } /** * 纯解析测试入口:生产流与测试使用同一逐字节解码路径。 * * @param array $chunks * @return array{content:string,deltas:array,message_id:string,finished:bool,upstream_error:bool} */ private static function decodeStreamChunks(string $protocol, array $chunks): array { $buffer = ''; $state = self::newStreamState(); $deltas = []; $onDelta = static function (string $delta) use (&$deltas): void { $deltas[] = $delta; }; foreach ($chunks as $chunk) { self::consumeStreamBytes($protocol, $buffer, $chunk, $state, $onDelta); } self::consumeStreamBytes($protocol, $buffer, '', $state, $onDelta, true); return [ 'content' => $state['content'], 'deltas' => $deltas, 'message_id' => $state['message_id'], 'finished' => $state['finished'], 'upstream_error' => $state['upstream_error'], ]; } /** * @return array{ * errno:int,http_code:int,content:string,message_id:string,emitted:bool, * upstream_error:bool,client_aborted:bool,callback_error:bool,finished:bool * } */ private static function emptyStreamResponse(int $errno): array { return [ 'errno' => $errno, 'http_code' => 0, 'content' => '', 'message_id' => '', 'emitted' => false, 'upstream_error' => false, 'upstream_code' => '', 'client_aborted' => false, 'callback_error' => false, 'finished' => false, ]; } /** * @param array $response * @return array{ok:bool,content?:string,message_id?:string,latency_ms:int,error_code?:string,error?:string} */ private static function formatStreamResponse(array $response, float $startedAt): array { $latencyMs = self::elapsedMilliseconds($startedAt); if (!empty($response['client_aborted'])) { return self::error('CLIENT_DISCONNECTED', '客户端已断开连接', $latencyMs); } if (!empty($response['callback_error'])) { return self::error('STREAM_DELIVERY_FAILED', '流式响应已中止', $latencyMs); } $errno = (int) ($response['errno'] ?? 0); if ($errno !== 0) { if ($errno === CURLE_OPERATION_TIMEDOUT) { return self::error('UPSTREAM_TIMEOUT', '模型响应超时,请稍后重试', $latencyMs); } if ($errno === -1) { return self::error('REQUEST_BUILD_FAILED', '病例数据编码失败', $latencyMs); } if ($errno === -2) { return self::error('CURL_INIT_FAILED', '无法初始化 AI 请求', $latencyMs); } return self::error('UPSTREAM_UNAVAILABLE', '暂时无法连接 AI 服务,请稍后重试', $latencyMs); } $httpCode = (int) ($response['http_code'] ?? 0); if ($httpCode === 401 || $httpCode === 403) { return self::error('CONFIG_INVALID', 'AI 服务凭据无效或无权限', $latencyMs); } if ($httpCode === 429 || $httpCode >= 500) { return self::error('UPSTREAM_BUSY', '模型服务繁忙,请稍后重试', $latencyMs); } if ($httpCode >= 400 || $httpCode < 200 || !empty($response['upstream_error'])) { return self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', $latencyMs); } if (empty($response['finished'])) { return self::error('INCOMPLETE_RESPONSE', '模型响应不完整,请重试', $latencyMs); } $content = (string) ($response['content'] ?? ''); if (trim($content) === '') { return self::error('EMPTY_RESPONSE', '模型未返回报告内容,请重试', $latencyMs); } return [ 'ok' => true, 'content' => $content, 'message_id' => (string) ($response['message_id'] ?? ''), 'latency_ms' => $latencyMs, ]; } /** * @param array{body:string,errno:int,http_code:int} $response * @return array{ok:bool,content?:string,message_id?:string,latency_ms:int,error_code?:string,error?:string} */ private static function formatResponse(array $response, float $startedAt): array { $latencyMs = self::elapsedMilliseconds($startedAt); $errno = $response['errno']; $httpCode = $response['http_code']; if ($errno !== 0) { if ($errno === CURLE_OPERATION_TIMEDOUT) { return self::error('UPSTREAM_TIMEOUT', '模型响应超时,请稍后重试', $latencyMs); } if ($errno === -1) { return self::error('REQUEST_BUILD_FAILED', '病例数据编码失败', $latencyMs); } if ($errno === -2) { return self::error('CURL_INIT_FAILED', '无法初始化 AI 请求', $latencyMs); } return self::error('UPSTREAM_UNAVAILABLE', '暂时无法连接 AI 服务,请稍后重试', $latencyMs); } $decoded = json_decode($response['body'], true); if ($httpCode === 401 || $httpCode === 403) { return self::error('CONFIG_INVALID', 'AI 服务凭据无效或无权限', $latencyMs); } if ($httpCode === 429 || $httpCode >= 500) { return self::error('UPSTREAM_BUSY', '模型服务繁忙,请稍后重试', $latencyMs); } if ($httpCode >= 400 || $httpCode < 200) { return self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', $latencyMs); } if (!is_array($decoded)) { return self::error('INVALID_RESPONSE', '模型返回格式异常,请重试', $latencyMs); } $answer = self::extractContent($decoded); if ($answer === '') { return self::error('EMPTY_RESPONSE', '模型未返回报告内容,请重试', $latencyMs); } return [ 'ok' => true, 'content' => $answer, 'message_id' => (string) ($decoded['message_id'] ?? $decoded['id'] ?? ''), 'latency_ms' => $latencyMs, ]; } /** @param array $decoded */ private static function extractContent(array $decoded): string { $content = $decoded['answer'] ?? $decoded['choices'][0]['message']['content'] ?? ''; if (is_string($content)) { return trim($content); } if (!is_array($content)) { return ''; } $parts = []; foreach ($content as $part) { if (is_array($part) && ($part['type'] ?? '') === 'text' && is_string($part['text'] ?? null)) { $parts[] = $part['text']; } } return trim(implode('', $parts)); } private static function elapsedMilliseconds(float $startedAt): int { return (int) round((microtime(true) - $startedAt) * 1000); } /** * 记录上游失败的结构化定位信息。按项目约定,绝不写入凭据、上游主机名或 * 响应正文,只保留可用于排障的协议、路径、状态码和请求规模。 * * @param array $requestSpec * @param array $response * @param array> $files * @param array $formatted */ private static function logUpstreamFailure( array $requestSpec, array $response, string $query, array $files, array $formatted ): void { if (!empty($formatted['ok'])) { return; } $url = (string) ($requestSpec['url'] ?? ''); $upstreamCode = (string) ($response['upstream_code'] ?? ''); if ($upstreamCode === '' && isset($response['body'])) { $decoded = json_decode((string) $response['body'], true); $upstreamCode = is_array($decoded) ? self::cleanUpstreamCode($decoded['code'] ?? '') : ''; } Log::warning('prescription ai upstream request failed', [ 'protocol' => (string) ($requestSpec['protocol'] ?? ''), 'endpoint_path' => (string) (parse_url($url, PHP_URL_PATH) ?? ''), 'http_code' => (int) ($response['http_code'] ?? 0), 'curl_errno' => (int) ($response['errno'] ?? 0), // 上游自有错误码(如 invalid_param),用于区分附件超限、鉴权、模型故障。 'upstream_code' => $upstreamCode, 'query_bytes' => strlen($query), 'file_count' => count($files), 'error_code' => (string) ($formatted['error_code'] ?? 'UNKNOWN'), 'latency_ms' => (int) ($formatted['latency_ms'] ?? 0), ]); } /** * @return array{ok:false,error_code:string,error:string,latency_ms:int} */ private static function error(string $code, string $message, int $latencyMs = 0): array { return [ 'ok' => false, 'error_code' => $code, 'error' => $message, 'latency_ms' => $latencyMs, ]; } }