From 75e214dc08fc0c7cdb54f1568b2ced422294ab78 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 27 Aug 2026 15:41:09 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/tcm/DiagnosisController.php | 7 + .../adminapi/logic/tcm/DiagnosisAiLogic.php | 11 +- server/app/common/service/DifyChatService.php | 305 ++++++++++++------ ...DiagnosisAiAssistantStreamContractTest.php | 8 + server/tests/DifyChatStreamContractTest.php | 9 +- .../PrescriptionAiUpstreamContractTest.php | 74 +++++ 6 files changed, 303 insertions(+), 111 deletions(-) diff --git a/server/app/adminapi/controller/tcm/DiagnosisController.php b/server/app/adminapi/controller/tcm/DiagnosisController.php index 26de406fd..ef00870c7 100755 --- a/server/app/adminapi/controller/tcm/DiagnosisController.php +++ b/server/app/adminapi/controller/tcm/DiagnosisController.php @@ -1017,6 +1017,13 @@ class DiagnosisController extends BaseAdminController $emit('done', $result); } } catch (\Throwable $e) { + \think\facade\Log::warning('diagnosis ai assistant sse failed ' . json_encode([ + 'diagnosis_id' => (int) ($prepared['diagnosis_id'] ?? 0), + 'profile' => (string) ($prepared['profile'] ?? ''), + 'task' => (string) ($prepared['task'] ?? ''), + 'admin_id' => (int) ($prepared['admin_id'] ?? 0), + 'exception_class' => get_class($e), + ], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE)); $emit('error', [ 'code' => 'AI_ASSISTANT_FAILED', 'message' => 'AI 助手暂时不可用,请稍后重试', diff --git a/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php b/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php index 20c4b47e5..c4dff599d 100644 --- a/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php +++ b/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php @@ -691,14 +691,13 @@ class DiagnosisAiLogic extends BaseLogic \Throwable $exception, string $task = '' ): void { - Log::warning('diagnosis ai assistant upstream call failed', [ + Log::warning('diagnosis ai assistant upstream call failed ' . json_encode([ 'diagnosis_id' => $diagnosisId, 'profile' => $profile, 'task' => $task, 'admin_id' => $adminId, 'exception_class' => get_class($exception), - 'exception_message' => $exception->getMessage(), - ]); + ], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE)); } /** @@ -717,15 +716,15 @@ class DiagnosisAiLogic extends BaseLogic $errorCode = strtoupper(trim((string) ($result['error_code'] ?? ''))); $errorMessage = trim((string) ($result['error'] ?? '')); $latencyMs = (int) ($result['latency_ms'] ?? 0); - Log::warning('diagnosis ai assistant upstream rejected', [ + Log::warning('diagnosis ai assistant upstream rejected ' . json_encode([ 'diagnosis_id' => $diagnosisId, 'profile' => $profile, 'task' => $task, 'admin_id' => $adminId, 'upstream_error_code' => $errorCode !== '' ? $errorCode : 'UNKNOWN', - 'upstream_error' => $errorMessage, + 'has_upstream_error' => $errorMessage !== '', 'latency_ms' => $latencyMs, - ]); + ], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE)); } /** diff --git a/server/app/common/service/DifyChatService.php b/server/app/common/service/DifyChatService.php index 60915482e..745e7b5b7 100644 --- a/server/app/common/service/DifyChatService.php +++ b/server/app/common/service/DifyChatService.php @@ -79,58 +79,78 @@ class DifyChatService $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; + $inputRejected = 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) + foreach (self::buildInputAttemptPlan($inputs) as $inputIndex => $attemptInputs) { + if ($inputIndex > 0 && !$inputRejected) { + break; + } + $requestSpecs = self::buildRequestSpecs( + $baseUrl, + $model, + $attemptInputs, + $query, + $user, + false, + $attempt['files'], + $attempt['omitted'] + ); + $lastResponse = null; + $lastSpec = []; + + 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']); + $inputRejected = $inputRejected || self::isInputRejection( + $response, + $requestSpec, + $attemptInputs + ); + + // /v1 在两种协议中都是合法基址。仅在明确表示路径不存在时尝试另一协议, + // 避免因业务参数错误而重复提交同一份临床数据。 + $hasFallback = isset($requestSpecs[$index + 1]); + if ( + $hasFallback + && !$inputRejected + && self::shouldTryNextProtocol($response, false) + ) { + continue; + } + break; } - $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)) { + $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; + } + // Dify 只接受应用中已声明且满足长度约束的 inputs。病例正文已经完整 + // 放在 query 中,因此 invalid_param 时可安全地用空 inputs 重试一次。 + if ($inputIndex === 0 && $inputRejected) { 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; @@ -195,63 +215,82 @@ class DifyChatService $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; + $inputRejected = 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) + foreach (self::buildInputAttemptPlan($inputs) as $inputIndex => $attemptInputs) { + if ($inputIndex > 0 && !$inputRejected) { + break; + } + $requestSpecs = self::buildRequestSpecs( + $baseUrl, + $model, + $attemptInputs, + $query, + $user, + true, + $attempt['files'], + $attempt['omitted'] + ); + $lastResponse = null; + $lastSpec = []; + + 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']); + $inputRejected = $inputRejected || self::isInputRejection( + $response, + $requestSpec, + $attemptInputs + ); + + // 只在尚未向下游发送任何文本、且明确为路径不支持时尝试另一协议。 + $hasFallback = isset($requestSpecs[$index + 1]); + if ( + $hasFallback + && !$inputRejected + && self::shouldTryNextProtocol($response, true) + ) { + continue; + } + break; } - $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]); + $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 ( - $hasFallback - && self::shouldTryNextProtocol($response, true) + $inputIndex === 0 + && $inputRejected + && empty($lastResponse['emitted']) ) { 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; @@ -454,6 +493,66 @@ class DifyChatService return $attempts; } + /** + * Dify 应用输入变量由发布时的表单定义决定。先保留结构化输入;若上游明确 + * 拒绝输入,再使用空对象兼容旧应用。病例正文始终在 query 中,不会丢失。 + * + * @param array $inputs + * @return array> + */ + private static function buildInputAttemptPlan(array $inputs): array + { + return $inputs === [] ? [[]] : [$inputs, []]; + } + + /** + * @param array $response + * @param array $requestSpec + * @param array $inputs + */ + private static function isInputRejection(array $response, array $requestSpec, array $inputs): bool + { + if ( + $inputs === [] + || ($requestSpec['protocol'] ?? '') !== 'dify' + || (int) ($response['errno'] ?? 0) !== 0 + || !empty($response['emitted']) + ) { + return false; + } + + $httpCode = (int) ($response['http_code'] ?? 0); + if (in_array($httpCode, [413, 422], true)) { + return true; + } + + $upstreamCode = strtolower(self::responseUpstreamCode($response)); + if ($httpCode === 400) { + // 标准 Dify 会给出 invalid_param;部分兼容网关只保留 400,因此空码 + // 也允许一次无 inputs 重试。额度、模型或应用状态错误不能重复提交。 + return $upstreamCode === '' + || in_array($upstreamCode, ['invalid_param', 'payload_too_large', 'request_too_large'], true); + } + + return !empty($response['upstream_error']) + && in_array( + $upstreamCode, + ['invalid_param', 'payload_too_large', 'request_too_large'], + true + ); + } + + /** @param array $response */ + private static function responseUpstreamCode(array $response): string + { + $upstreamCode = self::cleanUpstreamCode($response['upstream_code'] ?? ''); + if ($upstreamCode !== '' || !isset($response['body'])) { + return $upstreamCode; + } + $decoded = json_decode((string) $response['body'], true); + return is_array($decoded) ? self::cleanUpstreamCode($decoded['code'] ?? '') : ''; + } + /** * @param array> $files */ @@ -535,10 +634,10 @@ class DifyChatService /** * 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. + * protocol. Only a missing/unsupported endpoint is a blocking-mode protocol + * signal. A 2xx stream with no delivered delta but no valid terminal frame + * is also safe to retry. Business validation, authentication, rate-limit and + * server failures retain their original diagnosis instead of being hidden. * * @param array $response */ @@ -548,7 +647,7 @@ class DifyChatService return false; } $httpCode = (int) ($response['http_code'] ?? 0); - if (in_array($httpCode, [400, 404, 405, 415, 422, 501], true)) { + if (in_array($httpCode, [404, 405, 501], true)) { return true; } if (!$streaming || $httpCode < 200 || $httpCode >= 300 || !empty($response['emitted'])) { @@ -1092,25 +1191,23 @@ class DifyChatService 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', [ + $context = [ '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, + 'upstream_code' => self::responseUpstreamCode($response), 'query_bytes' => strlen($query), 'file_count' => count($files), 'error_code' => (string) ($formatted['error_code'] ?? 'UNKNOWN'), 'latency_ms' => (int) ($formatted['latency_ms'] ?? 0), - ]); + ]; + // ThinkPHP 文件日志不会自动输出未参与占位符替换的 context;显式序列化 + // 这组不含凭据、主机名、患者正文的诊断字段,确保线上日志真正可用。 + Log::warning( + 'prescription ai upstream request failed ' . json_encode($context, JSON_UNESCAPED_SLASHES) + ); } /** diff --git a/server/tests/DiagnosisAiAssistantStreamContractTest.php b/server/tests/DiagnosisAiAssistantStreamContractTest.php index ab69b83bc..2ab7755c0 100644 --- a/server/tests/DiagnosisAiAssistantStreamContractTest.php +++ b/server/tests/DiagnosisAiAssistantStreamContractTest.php @@ -59,6 +59,14 @@ $logic = file_get_contents(dirname(__DIR__) . '/app/adminapi/logic/tcm/Diagnosis $validate = file_get_contents(dirname(__DIR__) . '/app/adminapi/validate/tcm/DiagnosisValidate.php'); $auth = file_get_contents(dirname(__DIR__) . '/app/adminapi/http/middleware/AuthMiddleware.php'); assistantStreamExpect(is_string($controller) && is_string($logic) && is_string($validate) && is_string($auth), 'stream implementation sources are readable'); +assistantStreamExpect( + str_contains($controller, 'diagnosis ai assistant sse failed'), + 'unexpected SSE failures retain a privacy-safe server log entry' +); +assistantStreamExpect( + !str_contains($controller, "'exception_message' => \$e->getMessage()"), + 'unexpected SSE failures never log raw exception messages' +); $actionStart = strpos($controller, 'public function aiAssistantStream()'); $checkAt = strpos($controller, "goCheck('aiAssistant')", $actionStart); diff --git a/server/tests/DifyChatStreamContractTest.php b/server/tests/DifyChatStreamContractTest.php index 46330c901..22257344a 100644 --- a/server/tests/DifyChatStreamContractTest.php +++ b/server/tests/DifyChatStreamContractTest.php @@ -115,8 +115,15 @@ difyStreamExpect( callDifyStreamPrivate('shouldTryNextProtocol', [[ 'errno' => 0, 'http_code' => 400, + ], false]) === false, + 'business input rejection is not hidden by an alternate protocol attempt' +); +difyStreamExpect( + callDifyStreamPrivate('shouldTryNextProtocol', [[ + 'errno' => 0, + 'http_code' => 404, ], false]) === true, - 'an ambiguous endpoint rejected before generation tries the alternate protocol' + 'an unavailable path tries the alternate protocol' ); difyStreamExpect( callDifyStreamPrivate('shouldTryNextProtocol', [[ diff --git a/server/tests/PrescriptionAiUpstreamContractTest.php b/server/tests/PrescriptionAiUpstreamContractTest.php index ff98e68ce..c5a9b48a3 100644 --- a/server/tests/PrescriptionAiUpstreamContractTest.php +++ b/server/tests/PrescriptionAiUpstreamContractTest.php @@ -186,6 +186,80 @@ expectSame([], $plan[1]['files'], 'the fallback attempt sends no attachments'); expectSame(9, count($plan[1]['omitted']), 'the fallback attempt declares every attachment'); expectSame(1, count(callPrivate('buildAttemptPlan', [[], []])), 'a request without attachments is attempted once'); +$inputPlan = callPrivate('buildInputAttemptPlan', [['prompt_version' => 'v2']]); +expectSame(2, count($inputPlan), 'structured Dify inputs get one compatibility fallback'); +expectSame([], $inputPlan[1], 'the compatibility fallback uses an empty inputs object'); +expectSame([[]], callPrivate('buildInputAttemptPlan', [[]]), 'empty inputs are not retried twice'); + +$difyInputSpec = ['protocol' => 'dify']; +$openAiInputSpec = ['protocol' => 'openai']; +expectSame( + true, + callPrivate('isInputRejection', [ + ['errno' => 0, 'http_code' => 400, 'body' => '{"code":"invalid_param"}'], + $difyInputSpec, + ['prompt_version' => 'v2'], + ]), + 'Dify invalid_param retries with query-only input' +); +expectSame( + false, + callPrivate('isInputRejection', [ + ['errno' => 0, 'http_code' => 400, 'body' => '{"code":"invalid_param"}'], + $difyInputSpec, + [], + ]), + 'an already empty inputs object is never retried' +); +expectSame( + false, + callPrivate('isInputRejection', [ + ['errno' => 0, 'http_code' => 400, 'body' => '{"code":"invalid_param"}'], + $openAiInputSpec, + ['prompt_version' => 'v2'], + ]), + 'OpenAI protocol does not use the Dify input fallback' +); +expectSame( + false, + callPrivate('isInputRejection', [ + ['errno' => 0, 'http_code' => 400, 'body' => '{"code":"provider_quota_exceeded"}'], + $difyInputSpec, + ['prompt_version' => 'v2'], + ]), + 'quota and provider failures are not submitted twice' +); +expectSame( + true, + callPrivate('isInputRejection', [ + [ + 'errno' => 0, + 'http_code' => 200, + 'upstream_error' => true, + 'upstream_code' => 'invalid_param', + 'emitted' => false, + ], + $difyInputSpec, + ['prompt_version' => 'v2'], + ]), + 'a streaming invalid_param before any delta also retries without inputs' +); +expectSame( + false, + callPrivate('isInputRejection', [ + [ + 'errno' => 0, + 'http_code' => 200, + 'upstream_error' => true, + 'upstream_code' => 'invalid_param', + 'emitted' => true, + ], + $difyInputSpec, + ['prompt_version' => 'v2'], + ]), + 'a stream that already emitted content is never replayed' +); + expectSame(true, callPrivate('shouldRetryWithoutFiles', [400, $capped['kept']]), 'invalid_param retries without attachments'); expectSame(true, callPrivate('shouldRetryWithoutFiles', [413, $capped['kept']]), 'oversized attachments retry without attachments'); expectSame(false, callPrivate('shouldRetryWithoutFiles', [400, []]), 'a text-only rejection is not retried');