更新
This commit is contained in:
@@ -1017,6 +1017,13 @@ class DiagnosisController extends BaseAdminController
|
|||||||
$emit('done', $result);
|
$emit('done', $result);
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} 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', [
|
$emit('error', [
|
||||||
'code' => 'AI_ASSISTANT_FAILED',
|
'code' => 'AI_ASSISTANT_FAILED',
|
||||||
'message' => 'AI 助手暂时不可用,请稍后重试',
|
'message' => 'AI 助手暂时不可用,请稍后重试',
|
||||||
|
|||||||
@@ -691,14 +691,13 @@ class DiagnosisAiLogic extends BaseLogic
|
|||||||
\Throwable $exception,
|
\Throwable $exception,
|
||||||
string $task = ''
|
string $task = ''
|
||||||
): void {
|
): void {
|
||||||
Log::warning('diagnosis ai assistant upstream call failed', [
|
Log::warning('diagnosis ai assistant upstream call failed ' . json_encode([
|
||||||
'diagnosis_id' => $diagnosisId,
|
'diagnosis_id' => $diagnosisId,
|
||||||
'profile' => $profile,
|
'profile' => $profile,
|
||||||
'task' => $task,
|
'task' => $task,
|
||||||
'admin_id' => $adminId,
|
'admin_id' => $adminId,
|
||||||
'exception_class' => get_class($exception),
|
'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'] ?? '')));
|
$errorCode = strtoupper(trim((string) ($result['error_code'] ?? '')));
|
||||||
$errorMessage = trim((string) ($result['error'] ?? ''));
|
$errorMessage = trim((string) ($result['error'] ?? ''));
|
||||||
$latencyMs = (int) ($result['latency_ms'] ?? 0);
|
$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,
|
'diagnosis_id' => $diagnosisId,
|
||||||
'profile' => $profile,
|
'profile' => $profile,
|
||||||
'task' => $task,
|
'task' => $task,
|
||||||
'admin_id' => $adminId,
|
'admin_id' => $adminId,
|
||||||
'upstream_error_code' => $errorCode !== '' ? $errorCode : 'UNKNOWN',
|
'upstream_error_code' => $errorCode !== '' ? $errorCode : 'UNKNOWN',
|
||||||
'upstream_error' => $errorMessage,
|
'has_upstream_error' => $errorMessage !== '',
|
||||||
'latency_ms' => $latencyMs,
|
'latency_ms' => $latencyMs,
|
||||||
]);
|
], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -79,58 +79,78 @@ class DifyChatService
|
|||||||
$formatted = null;
|
$formatted = null;
|
||||||
|
|
||||||
foreach (self::buildAttemptPlan($normalized['kept'], $normalized['dropped']) as $attempt) {
|
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;
|
$fileRejected = false;
|
||||||
|
$inputRejected = false;
|
||||||
|
|
||||||
foreach ($requestSpecs as $index => $requestSpec) {
|
foreach (self::buildInputAttemptPlan($inputs) as $inputIndex => $attemptInputs) {
|
||||||
$elapsedSeconds = (int) floor(microtime(true) - $startedAt);
|
if ($inputIndex > 0 && !$inputRejected) {
|
||||||
$remainingTimeout = $timeout - $elapsedSeconds;
|
break;
|
||||||
if ($remainingTimeout < self::MIN_TIMEOUT) {
|
}
|
||||||
return self::error(
|
$requestSpecs = self::buildRequestSpecs(
|
||||||
'UPSTREAM_TIMEOUT',
|
$baseUrl,
|
||||||
'模型响应超时,请稍后重试',
|
$model,
|
||||||
self::elapsedMilliseconds($startedAt)
|
$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(
|
$lastResponse = $lastResponse ?? ['body' => '', 'errno' => 0, 'http_code' => 0];
|
||||||
$requestSpec['url'],
|
$formatted = self::formatResponse($lastResponse, $startedAt);
|
||||||
$requestSpec['payload'],
|
self::logUpstreamFailure($lastSpec, $lastResponse, $query, $attempt['files'], $formatted);
|
||||||
$apiKey,
|
if (!empty($formatted['ok'])) {
|
||||||
$remainingTimeout
|
return $formatted;
|
||||||
);
|
}
|
||||||
$lastResponse = $response;
|
// Dify 只接受应用中已声明且满足长度约束的 inputs。病例正文已经完整
|
||||||
$lastSpec = $requestSpec;
|
// 放在 query 中,因此 invalid_param 时可安全地用空 inputs 重试一次。
|
||||||
$fileRejected = $fileRejected || self::isFileRejection($response, $attempt['files']);
|
if ($inputIndex === 0 && $inputRejected) {
|
||||||
|
|
||||||
// /v1 在两种协议中都是合法基址。仅在明确表示路径不存在时尝试另一协议,
|
|
||||||
// 避免因业务参数错误而重复提交同一份临床数据。
|
|
||||||
$hasFallback = isset($requestSpecs[$index + 1]);
|
|
||||||
if ($hasFallback && self::shouldTryNextProtocol($response, false)) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
break;
|
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) {
|
if (!$fileRejected) {
|
||||||
return $formatted;
|
return $formatted;
|
||||||
@@ -195,63 +215,82 @@ class DifyChatService
|
|||||||
$formatted = null;
|
$formatted = null;
|
||||||
|
|
||||||
foreach (self::buildAttemptPlan($normalized['kept'], $normalized['dropped']) as $attempt) {
|
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;
|
$fileRejected = false;
|
||||||
|
$inputRejected = false;
|
||||||
|
|
||||||
foreach ($requestSpecs as $index => $requestSpec) {
|
foreach (self::buildInputAttemptPlan($inputs) as $inputIndex => $attemptInputs) {
|
||||||
$elapsedSeconds = (int) floor(microtime(true) - $startedAt);
|
if ($inputIndex > 0 && !$inputRejected) {
|
||||||
$remainingTimeout = $timeout - $elapsedSeconds;
|
break;
|
||||||
if ($remainingTimeout < self::MIN_TIMEOUT) {
|
}
|
||||||
return self::error(
|
$requestSpecs = self::buildRequestSpecs(
|
||||||
'UPSTREAM_TIMEOUT',
|
$baseUrl,
|
||||||
'模型响应超时,请稍后重试',
|
$model,
|
||||||
self::elapsedMilliseconds($startedAt)
|
$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(
|
$lastResponse = $lastResponse ?? self::emptyStreamResponse(0);
|
||||||
$requestSpec['protocol'],
|
$formatted = self::formatStreamResponse($lastResponse, $startedAt);
|
||||||
$requestSpec['url'],
|
self::logUpstreamFailure($lastSpec, $lastResponse, $query, $attempt['files'], $formatted);
|
||||||
$requestSpec['payload'],
|
if (!empty($formatted['ok'])) {
|
||||||
$apiKey,
|
return $formatted;
|
||||||
$remainingTimeout,
|
}
|
||||||
$onDelta,
|
|
||||||
$shouldAbort
|
|
||||||
);
|
|
||||||
$lastResponse = $response;
|
|
||||||
$lastSpec = $requestSpec;
|
|
||||||
$fileRejected = $fileRejected || self::isFileRejection($response, $attempt['files']);
|
|
||||||
|
|
||||||
// 只在尚未向下游发送任何文本、且明确为路径不支持时尝试另一协议。
|
|
||||||
$hasFallback = isset($requestSpecs[$index + 1]);
|
|
||||||
if (
|
if (
|
||||||
$hasFallback
|
$inputIndex === 0
|
||||||
&& self::shouldTryNextProtocol($response, true)
|
&& $inputRejected
|
||||||
|
&& empty($lastResponse['emitted'])
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
break;
|
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) {
|
if (!empty($lastResponse['emitted']) || !$fileRejected) {
|
||||||
return $formatted;
|
return $formatted;
|
||||||
@@ -454,6 +493,66 @@ class DifyChatService
|
|||||||
return $attempts;
|
return $attempts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dify 应用输入变量由发布时的表单定义决定。先保留结构化输入;若上游明确
|
||||||
|
* 拒绝输入,再使用空对象兼容旧应用。病例正文始终在 query 中,不会丢失。
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $inputs
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private static function buildInputAttemptPlan(array $inputs): array
|
||||||
|
{
|
||||||
|
return $inputs === [] ? [[]] : [$inputs, []];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $response
|
||||||
|
* @param array<string,mixed> $requestSpec
|
||||||
|
* @param array<string,mixed> $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<string,mixed> $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<int,array<string,string>> $files
|
* @param array<int,array<string,string>> $files
|
||||||
*/
|
*/
|
||||||
@@ -535,10 +634,10 @@ class DifyChatService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Decide whether an ambiguous base URL should be tried with the other wire
|
* 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
|
* protocol. Only a missing/unsupported endpoint is a blocking-mode protocol
|
||||||
* 2xx stream with no delivered delta but no valid terminal frame is also
|
* signal. A 2xx stream with no delivered delta but no valid terminal frame
|
||||||
* safe to retry. Authentication, rate-limit and server failures retain
|
* is also safe to retry. Business validation, authentication, rate-limit and
|
||||||
* their original diagnosis instead of being hidden by a second request.
|
* server failures retain their original diagnosis instead of being hidden.
|
||||||
*
|
*
|
||||||
* @param array<string,mixed> $response
|
* @param array<string,mixed> $response
|
||||||
*/
|
*/
|
||||||
@@ -548,7 +647,7 @@ class DifyChatService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$httpCode = (int) ($response['http_code'] ?? 0);
|
$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;
|
return true;
|
||||||
}
|
}
|
||||||
if (!$streaming || $httpCode < 200 || $httpCode >= 300 || !empty($response['emitted'])) {
|
if (!$streaming || $httpCode < 200 || $httpCode >= 300 || !empty($response['emitted'])) {
|
||||||
@@ -1092,25 +1191,23 @@ class DifyChatService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$url = (string) ($requestSpec['url'] ?? '');
|
$url = (string) ($requestSpec['url'] ?? '');
|
||||||
$upstreamCode = (string) ($response['upstream_code'] ?? '');
|
$context = [
|
||||||
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'] ?? ''),
|
'protocol' => (string) ($requestSpec['protocol'] ?? ''),
|
||||||
'endpoint_path' => (string) (parse_url($url, PHP_URL_PATH) ?? ''),
|
'endpoint_path' => (string) (parse_url($url, PHP_URL_PATH) ?? ''),
|
||||||
'http_code' => (int) ($response['http_code'] ?? 0),
|
'http_code' => (int) ($response['http_code'] ?? 0),
|
||||||
'curl_errno' => (int) ($response['errno'] ?? 0),
|
'curl_errno' => (int) ($response['errno'] ?? 0),
|
||||||
// 上游自有错误码(如 invalid_param),用于区分附件超限、鉴权、模型故障。
|
// 上游自有错误码(如 invalid_param),用于区分附件超限、鉴权、模型故障。
|
||||||
'upstream_code' => $upstreamCode,
|
'upstream_code' => self::responseUpstreamCode($response),
|
||||||
'query_bytes' => strlen($query),
|
'query_bytes' => strlen($query),
|
||||||
'file_count' => count($files),
|
'file_count' => count($files),
|
||||||
'error_code' => (string) ($formatted['error_code'] ?? 'UNKNOWN'),
|
'error_code' => (string) ($formatted['error_code'] ?? 'UNKNOWN'),
|
||||||
'latency_ms' => (int) ($formatted['latency_ms'] ?? 0),
|
'latency_ms' => (int) ($formatted['latency_ms'] ?? 0),
|
||||||
]);
|
];
|
||||||
|
// ThinkPHP 文件日志不会自动输出未参与占位符替换的 context;显式序列化
|
||||||
|
// 这组不含凭据、主机名、患者正文的诊断字段,确保线上日志真正可用。
|
||||||
|
Log::warning(
|
||||||
|
'prescription ai upstream request failed ' . json_encode($context, JSON_UNESCAPED_SLASHES)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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');
|
$validate = file_get_contents(dirname(__DIR__) . '/app/adminapi/validate/tcm/DiagnosisValidate.php');
|
||||||
$auth = file_get_contents(dirname(__DIR__) . '/app/adminapi/http/middleware/AuthMiddleware.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(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()');
|
$actionStart = strpos($controller, 'public function aiAssistantStream()');
|
||||||
$checkAt = strpos($controller, "goCheck('aiAssistant')", $actionStart);
|
$checkAt = strpos($controller, "goCheck('aiAssistant')", $actionStart);
|
||||||
|
|||||||
@@ -115,8 +115,15 @@ difyStreamExpect(
|
|||||||
callDifyStreamPrivate('shouldTryNextProtocol', [[
|
callDifyStreamPrivate('shouldTryNextProtocol', [[
|
||||||
'errno' => 0,
|
'errno' => 0,
|
||||||
'http_code' => 400,
|
'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,
|
], false]) === true,
|
||||||
'an ambiguous endpoint rejected before generation tries the alternate protocol'
|
'an unavailable path tries the alternate protocol'
|
||||||
);
|
);
|
||||||
difyStreamExpect(
|
difyStreamExpect(
|
||||||
callDifyStreamPrivate('shouldTryNextProtocol', [[
|
callDifyStreamPrivate('shouldTryNextProtocol', [[
|
||||||
|
|||||||
@@ -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(9, count($plan[1]['omitted']), 'the fallback attempt declares every attachment');
|
||||||
expectSame(1, count(callPrivate('buildAttemptPlan', [[], []])), 'a request without attachments is attempted once');
|
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', [400, $capped['kept']]), 'invalid_param retries without attachments');
|
||||||
expectSame(true, callPrivate('shouldRetryWithoutFiles', [413, $capped['kept']]), 'oversized attachments retry 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');
|
expectSame(false, callPrivate('shouldRetryWithoutFiles', [400, []]), 'a text-only rejection is not retried');
|
||||||
|
|||||||
Reference in New Issue
Block a user