This commit is contained in:
Your Name
2026-08-27 14:23:23 +08:00
parent b5b14516a1
commit 2fa8492c56
27 changed files with 3832 additions and 1435 deletions
@@ -1010,8 +1010,8 @@ class DiagnosisController extends BaseAdminController
}
if ($result === null) {
$emit('error', [
'code' => 'AI_ASSISTANT_FAILED',
'message' => 'AI 助手暂时不可用,请稍后重试',
'code' => DiagnosisAiLogic::getAssistantErrorCode(),
'message' => DiagnosisAiLogic::getError(),
]);
} else {
$emit('done', $result);
@@ -18,6 +18,9 @@ use think\facade\Log;
*/
class DiagnosisAiLogic extends BaseLogic
{
/** @var string Safe machine-readable code for the current assistant request. */
private static $assistantErrorCode = 'AI_ASSISTANT_FAILED';
private const PROMPT_VERSION = 'patient-context-case-explain-v2';
private const ASSISTANT_PROMPT_VERSION = 'patient-context-assistant-v2';
@@ -354,6 +357,7 @@ class DiagnosisAiLogic extends BaseLogic
int $adminId,
array $adminInfo
): ?array {
self::$assistantErrorCode = 'AI_ASSISTANT_FAILED';
$diagnosis = self::loadAuthorizedDiagnosis(
$diagnosisId,
$adminId,
@@ -433,6 +437,14 @@ class DiagnosisAiLogic extends BaseLogic
$diagnosisId = (int) ($prepared['diagnosis_id'] ?? 0);
$profile = (string) ($prepared['profile'] ?? '');
$adminId = (int) ($prepared['admin_id'] ?? 0);
$deliveredDelta = false;
$forwardDelta = static function (string $delta) use (&$deliveredDelta, $onDelta) {
$accepted = $onDelta($delta);
if ($accepted !== false) {
$deliveredDelta = true;
}
return $accepted;
};
try {
$result = DifyChatService::streamChat(
@@ -440,7 +452,7 @@ class DiagnosisAiLogic extends BaseLogic
is_array($prepared['inputs'] ?? null) ? $prepared['inputs'] : [],
(string) ($prepared['query'] ?? ''),
(string) ($prepared['user'] ?? ''),
$onDelta,
$forwardDelta,
$shouldAbort,
is_array($prepared['files'] ?? null) ? $prepared['files'] : []
);
@@ -452,6 +464,7 @@ class DiagnosisAiLogic extends BaseLogic
$e,
(string) ($prepared['task'] ?? '')
);
self::$assistantErrorCode = 'UPSTREAM_UNAVAILABLE';
self::setError('AI 助手暂时不可用,请稍后重试');
return null;
}
@@ -464,6 +477,47 @@ class DiagnosisAiLogic extends BaseLogic
(string) ($prepared['task'] ?? ''),
is_array($result) ? $result : []
);
// Some Dify-compatible gateways accept blocking chat but reject or
// incompletely terminate streaming responses. Before any delta has
// reached the doctor it is safe to make one blocking compatibility
// attempt; after a delta, retrying could duplicate clinical text.
$streamErrorCode = strtoupper(trim((string) ($result['error_code'] ?? '')));
if (
!$deliveredDelta
&& in_array(
$streamErrorCode,
['UPSTREAM_REJECTED', 'INCOMPLETE_RESPONSE', 'EMPTY_RESPONSE'],
true
)
) {
try {
$result = DifyChatService::chat(
$profile,
is_array($prepared['inputs'] ?? null) ? $prepared['inputs'] : [],
(string) ($prepared['query'] ?? ''),
(string) ($prepared['user'] ?? ''),
is_array($prepared['files'] ?? null) ? $prepared['files'] : []
);
} catch (\Throwable $e) {
self::logAssistantFailure(
$diagnosisId,
$profile,
$adminId,
$e,
(string) ($prepared['task'] ?? '')
);
}
if (empty($result['ok'])) {
self::logAssistantUpstreamError(
$diagnosisId,
$profile,
$adminId,
(string) ($prepared['task'] ?? ''),
is_array($result) ? $result : []
);
}
}
}
return self::formatAssistantResult($prepared, $result);
@@ -480,6 +534,7 @@ class DiagnosisAiLogic extends BaseLogic
// 附带上游错误码,让医生反馈时管理员能直接定位是配置、体积还是上游拒绝。
$message = (string) ($result['error'] ?? 'AI 助手暂时不可用,请稍后重试');
$errorCode = trim((string) ($result['error_code'] ?? ''));
self::$assistantErrorCode = self::normaliseAssistantErrorCode($errorCode);
if ($errorCode !== '') {
$message .= '' . $errorCode . '';
}
@@ -488,6 +543,7 @@ class DiagnosisAiLogic extends BaseLogic
}
$content = self::cleanText($result['content'] ?? '', self::MAX_REPORT_LENGTH, true);
if ($content === '') {
self::$assistantErrorCode = 'EMPTY_RESPONSE';
self::setError('AI 助手未返回内容,请重试');
return null;
}
@@ -520,6 +576,20 @@ class DiagnosisAiLogic extends BaseLogic
return $payload;
}
/** Return a safe code for the current SSE terminal error event. */
public static function getAssistantErrorCode(): string
{
return self::normaliseAssistantErrorCode(self::$assistantErrorCode);
}
private static function normaliseAssistantErrorCode(string $code): string
{
$code = strtoupper(trim($code));
return preg_match('/^[A-Z][A-Z0-9_]{2,63}$/', $code) === 1
? $code
: 'AI_ASSISTANT_FAILED';
}
/** @return array<string,mixed>|null */
private static function parsePrescriptionDraft(string $content): ?array
{
+55 -7
View File
@@ -91,6 +91,9 @@ class DifyChatService
);
$lastResponse = null;
$lastSpec = [];
// 协议回退会把最初的“附件被拒”换成另一协议的状态码,因此降级判断
// 必须记住本轮出现过的附件拒绝信号,而不能只看最后一次响应。
$fileRejected = false;
foreach ($requestSpecs as $index => $requestSpec) {
$elapsedSeconds = (int) floor(microtime(true) - $startedAt);
@@ -111,11 +114,12 @@ class DifyChatService
);
$lastResponse = $response;
$lastSpec = $requestSpec;
$fileRejected = $fileRejected || self::isFileRejection($response, $attempt['files']);
// /v1 在两种协议中都是合法基址。仅在明确表示路径不存在时尝试另一协议,
// 避免因业务参数错误而重复提交同一份临床数据。
$hasFallback = isset($requestSpecs[$index + 1]);
if ($hasFallback && in_array($response['http_code'], [404, 405, 501], true)) {
if ($hasFallback && self::shouldTryNextProtocol($response, false)) {
continue;
}
break;
@@ -128,7 +132,7 @@ class DifyChatService
return $formatted;
}
// 附件整体被拒时退回纯文本重试,附件清单已在下一轮尝试中补齐。
if (!self::shouldRetryWithoutFiles((int) $lastResponse['http_code'], $attempt['files'])) {
if (!$fileRejected) {
return $formatted;
}
}
@@ -203,6 +207,9 @@ class DifyChatService
);
$lastResponse = null;
$lastSpec = [];
// 协议回退会把最初的“附件被拒”换成另一协议的状态码,因此降级判断
// 必须记住本轮出现过的附件拒绝信号,而不能只看最后一次响应。
$fileRejected = false;
foreach ($requestSpecs as $index => $requestSpec) {
$elapsedSeconds = (int) floor(microtime(true) - $startedAt);
@@ -226,13 +233,13 @@ class DifyChatService
);
$lastResponse = $response;
$lastSpec = $requestSpec;
$fileRejected = $fileRejected || self::isFileRejection($response, $attempt['files']);
// 只在尚未向下游发送任何文本、且明确为路径不支持时尝试另一协议。
$hasFallback = isset($requestSpecs[$index + 1]);
if (
$hasFallback
&& empty($response['emitted'])
&& in_array($response['http_code'], [404, 405, 501], true)
&& self::shouldTryNextProtocol($response, true)
) {
continue;
}
@@ -246,9 +253,6 @@ class DifyChatService
return $formatted;
}
// 已经推给医生的文本不能重复输出,因此只在一个字都没发出去时才降级重试。
// 附件不可达时 Dify 会在 200 流里发 event:error,同样按附件问题降级。
$fileRejected = self::shouldRetryWithoutFiles((int) $lastResponse['http_code'], $attempt['files'])
|| (!empty($lastResponse['upstream_error']) && $attempt['files'] !== []);
if (!empty($lastResponse['emitted']) || !$fileRejected) {
return $formatted;
}
@@ -458,6 +462,26 @@ class DifyChatService
return $files !== [] && in_array($httpCode, self::FILE_REJECTION_CODES, true);
}
/**
* 判断一次上游响应是否属于“这批附件我处理不了”。
*
* 除了 4xx 状态码,Dify 拉不到附件时会在 200 的 SSE 流里发 event:error
* 这两种形态都必须触发去掉附件的降级重试。
*
* @param array<string,mixed> $response
* @param array<int,array<string,string>> $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']);
}
/**
* 把无法随请求送达的附件写成显式清单。模型必须知道这些资料存在但读不到,
* 才不会把“没看到”当成“没有”。
@@ -509,6 +533,30 @@ class DifyChatService
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<string,mixed> $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)) {