This commit is contained in:
Your Name
2026-10-09 14:13:57 +08:00
parent 51d8fd409e
commit 5706e9a6a5
15 changed files with 732 additions and 162 deletions
+208 -128
View File
@@ -12,6 +12,7 @@ use app\common\model\tcm\DiagnosisAiReport;
use app\common\service\DifyChatService;
use app\common\service\NihaixiaClinicalSkill;
use app\common\service\TcmAncientBooksReference;
use app\adminapi\service\AssistantSseTiming;
use think\facade\Db;
use think\facade\Log;
@@ -359,79 +360,117 @@ class DiagnosisAiLogic extends BaseLogic
string $task,
string $prompt,
int $adminId,
array $adminInfo
array $adminInfo,
?AssistantSseTiming $timing = null
): ?array {
self::$assistantErrorCode = 'AI_ASSISTANT_FAILED';
$task = strtolower(trim($task));
if (!isset(self::ASSISTANT_TASKS[$task])) {
self::setError('不支持的 AI 助手任务');
return null;
}
if ($task === 'prescription_generate'
&& !self::hasPermission($adminId, $adminInfo, self::PERMISSION_GENERATE_PRESCRIPTION)) {
self::setError('权限不足,无法使用 AI 生成处方');
return null;
}
$prepareStartedAt = hrtime(true);
$aggregateMs = 0;
$compactionMs = 0;
$sourceBytes = 0;
$queryBytes = 0;
$attachmentCount = 0;
$compactionAttempted = false;
$status = 'error';
try {
self::$assistantErrorCode = 'AI_ASSISTANT_FAILED';
$task = strtolower(trim($task));
if (!isset(self::ASSISTANT_TASKS[$task])) {
self::setError('不支持的 AI 助手任务');
return null;
}
if ($task === 'prescription_generate'
&& !self::hasPermission($adminId, $adminInfo, self::PERMISSION_GENERATE_PRESCRIPTION)) {
self::setError('权限不足,无法使用 AI 生成处方');
return null;
}
$diagnosis = self::loadAuthorizedDiagnosis(
$diagnosisId,
$adminId,
$adminInfo,
self::PERMISSION_ASSISTANT,
'权限不足,无法使用诊单 AI 助手'
);
if ($diagnosis === null) {
return null;
}
$diagnosis = self::loadAuthorizedDiagnosis(
$diagnosisId,
$adminId,
$adminInfo,
self::PERMISSION_ASSISTANT,
'权限不足,无法使用诊单 AI 助手'
);
if ($diagnosis === null) {
return null;
}
$prompt = self::cleanText($prompt, self::MAX_ASSISTANT_PROMPT_LENGTH, true);
if ($task === 'custom' && $prompt === '') {
self::setError('请输入要咨询的问题');
return null;
}
$prompt = self::cleanText($prompt, self::MAX_ASSISTANT_PROMPT_LENGTH, true);
if ($task === 'custom' && $prompt === '') {
self::setError('请输入要咨询的问题');
return null;
}
$context = self::buildCaseContext($diagnosis, $adminId, $adminInfo);
if ($context['case_lines'] === []) {
self::setError('患者纵向资料为空或聚合失败,无法使用 AI 助手');
return null;
}
$aggregateStartedAt = hrtime(true);
$context = self::buildCaseContext($diagnosis, $adminId, $adminInfo);
$aggregateMs = AssistantSseTiming::elapsedMs($aggregateStartedAt);
$sourceBytes = strlen((string) ($context['case_text'] ?? ''));
$attachmentCount = count(is_array($context['files'] ?? null) ? $context['files'] : []);
if ($context['case_lines'] === []) {
self::setError('患者纵向资料为空或聚合失败,无法使用 AI 助手');
return null;
}
$profile = self::selectAssistantProfile($task, $prompt);
$modelConfig = self::modelConfigs()[$profile] ?? [];
$model = trim((string) ($modelConfig['name'] ?? ''));
$modelLabel = trim((string) ($modelConfig['label'] ?? $profile));
if ($model === '') {
self::setError('AI 模型服务尚未完整配置');
return null;
}
$profile = self::selectAssistantProfile($task, $prompt);
$modelConfig = self::modelConfigs()[$profile] ?? [];
$model = trim((string) ($modelConfig['name'] ?? ''));
$modelLabel = trim((string) ($modelConfig['label'] ?? $profile));
if ($model === '') {
self::setError('AI 模型服务尚未完整配置');
return null;
}
if (!self::fitContextForPrompt($context, $profile)) {
return null;
}
$compactionAttempted = $sourceBytes > self::MAX_PROMPT_SOURCE_BYTES;
$compactionStartedAt = hrtime(true);
if (!self::fitContextForPrompt($context, $profile, $timing)) {
$compactionMs = $compactionAttempted ? AssistantSseTiming::elapsedMs($compactionStartedAt) : 0;
return null;
}
$compactionMs = $compactionAttempted ? AssistantSseTiming::elapsedMs($compactionStartedAt) : 0;
return [
'diagnosis_id' => $diagnosisId,
'profile' => $profile,
'model_name' => $model,
'model_label' => $modelLabel,
'task' => $task,
'inputs' => self::buildUpstreamInputs(
$query = self::buildAssistantPrompt($context, $task, $prompt);
$inputs = self::buildUpstreamInputs(
$context,
'病例问诊助手',
self::ASSISTANT_PROMPT_VERSION
),
'query' => self::buildAssistantPrompt($context, $task, $prompt),
'knowledge_source' => NihaixiaClinicalSkill::knowledgeSource(),
'user' => 'admin-diagnosis-assistant-' . $adminId,
'admin_id' => $adminId,
'files' => is_array($context['files'] ?? null) ? $context['files'] : [],
'context_scope' => (string) ($context['context_scope'] ?? 'patient_longitudinal'),
'context_version' => (string) ($context['context_version'] ?? self::ASSISTANT_PROMPT_VERSION),
'source_summary' => is_array($context['source_summary'] ?? null) ? $context['source_summary'] : [],
'source_diagnosis_ids' => is_array($context['source_diagnosis_ids'] ?? null)
? $context['source_diagnosis_ids']
: [],
];
);
$knowledgeSource = NihaixiaClinicalSkill::knowledgeSource();
$queryBytes = strlen($query);
$status = 'ok';
return [
'diagnosis_id' => $diagnosisId,
'profile' => $profile,
'model_name' => $model,
'model_label' => $modelLabel,
'task' => $task,
'inputs' => $inputs,
'query' => $query,
'knowledge_source' => $knowledgeSource,
'user' => 'admin-diagnosis-assistant-' . $adminId,
'admin_id' => $adminId,
'files' => is_array($context['files'] ?? null) ? $context['files'] : [],
'context_scope' => (string) ($context['context_scope'] ?? 'patient_longitudinal'),
'context_version' => (string) ($context['context_version'] ?? self::ASSISTANT_PROMPT_VERSION),
'source_summary' => is_array($context['source_summary'] ?? null) ? $context['source_summary'] : [],
'source_diagnosis_ids' => is_array($context['source_diagnosis_ids'] ?? null)
? $context['source_diagnosis_ids']
: [],
];
} finally {
if ($timing !== null) {
$timing->log('prepare', [
'duration_ms' => AssistantSseTiming::elapsedMs($prepareStartedAt),
'aggregate_ms' => $aggregateMs,
'compaction_ms' => $compactionMs,
'source_bytes' => $sourceBytes,
'query_bytes' => $queryBytes,
'attachment_count' => $attachmentCount,
'compaction_attempted' => $compactionAttempted,
'status' => $status,
]);
}
}
}
/**
@@ -443,74 +482,42 @@ class DiagnosisAiLogic extends BaseLogic
public static function streamPreparedAssistant(
array $prepared,
callable $onDelta,
?callable $shouldAbort = null
?callable $shouldAbort = null,
?AssistantSseTiming $timing = null
): ?array {
$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) {
$deltaCount = 0;
$deltaBytes = 0;
$retryAttempted = false;
$retryMs = 0;
$status = 'error';
$upstreamStartedAt = hrtime(true);
$forwardDelta = static function (string $delta) use (&$deliveredDelta, &$deltaCount, &$deltaBytes, $onDelta) {
$accepted = $onDelta($delta);
if ($accepted !== false) {
$deliveredDelta = true;
++$deltaCount;
$deltaBytes += strlen($delta);
}
return $accepted;
};
try {
$result = DifyChatService::streamChat(
$profile,
is_array($prepared['inputs'] ?? null) ? $prepared['inputs'] : [],
(string) ($prepared['query'] ?? ''),
(string) ($prepared['user'] ?? ''),
$forwardDelta,
$shouldAbort,
is_array($prepared['files'] ?? null) ? $prepared['files'] : []
);
} catch (\Throwable $e) {
self::logAssistantFailure(
$diagnosisId,
$profile,
$adminId,
$e,
(string) ($prepared['task'] ?? '')
);
self::$assistantErrorCode = 'UPSTREAM_UNAVAILABLE';
self::setError('AI 助手暂时不可用,请稍后重试');
return null;
}
if (empty($result['ok'])) {
self::logAssistantUpstreamError(
$diagnosisId,
$profile,
$adminId,
(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) {
try {
$result = DifyChatService::streamChat(
$profile,
is_array($prepared['inputs'] ?? null) ? $prepared['inputs'] : [],
(string) ($prepared['query'] ?? ''),
(string) ($prepared['user'] ?? ''),
$forwardDelta,
$shouldAbort,
is_array($prepared['files'] ?? null) ? $prepared['files'] : []
);
} catch (\Throwable $e) {
if ($timing === null) {
self::logAssistantFailure(
$diagnosisId,
$profile,
@@ -519,7 +526,14 @@ class DiagnosisAiLogic extends BaseLogic
(string) ($prepared['task'] ?? '')
);
}
if (empty($result['ok'])) {
$status = 'exception';
self::$assistantErrorCode = 'UPSTREAM_UNAVAILABLE';
self::setError('AI 助手暂时不可用,请稍后重试');
return null;
}
if (empty($result['ok'])) {
if ($timing === null) {
self::logAssistantUpstreamError(
$diagnosisId,
$profile,
@@ -528,10 +542,72 @@ class DiagnosisAiLogic extends BaseLogic
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
)
) {
$retryAttempted = true;
$retryStartedAt = hrtime(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) {
if ($timing === null) {
self::logAssistantFailure(
$diagnosisId,
$profile,
$adminId,
$e,
(string) ($prepared['task'] ?? '')
);
}
}
$retryMs = AssistantSseTiming::elapsedMs($retryStartedAt);
if (empty($result['ok'])) {
if ($timing === null) {
self::logAssistantUpstreamError(
$diagnosisId,
$profile,
$adminId,
(string) ($prepared['task'] ?? ''),
is_array($result) ? $result : []
);
}
}
}
}
$formatted = self::formatAssistantResult($prepared, $result);
$status = $formatted === null ? 'error' : 'ok';
return $formatted;
} finally {
if ($timing !== null) {
$timing->log('upstream_done', [
'duration_ms' => AssistantSseTiming::elapsedMs($upstreamStartedAt),
'retry_ms' => $retryMs,
'retry_attempted' => $retryAttempted,
'delta_count' => $deltaCount,
'delta_bytes' => $deltaBytes,
'status' => $status,
'error_code' => $status !== 'ok' ? self::getAssistantErrorCode() : '',
]);
}
}
return self::formatAssistantResult($prepared, $result);
}
/**
@@ -1477,7 +1553,11 @@ PROMPT;
*
* @param array<string,mixed> $context
*/
private static function fitContextForPrompt(array &$context, string $profile): bool
private static function fitContextForPrompt(
array &$context,
string $profile,
?AssistantSseTiming $timing = null
): bool
{
$caseText = (string) ($context['case_text'] ?? '');
if ($caseText === '' || strlen($caseText) <= self::MAX_PROMPT_SOURCE_BYTES) {
@@ -1487,12 +1567,12 @@ PROMPT;
try {
$compacted = PatientAiReportLogic::compactSourceForPrompt($profile, $caseText);
} catch (\Throwable $e) {
Log::warning('diagnosis ai context compaction failed', [
'diagnosis_id' => (int) ($context['diagnosis_id'] ?? 0),
'profile' => $profile,
'source_bytes' => strlen($caseText),
'exception_class' => get_class($e),
]);
if ($timing === null) {
Log::warning('diagnosis ai context compaction failed', [
'source_bytes' => strlen($caseText),
'exception_class' => get_class($e),
]);
}
self::setError('患者纵向资料过大,AI 分片读取失败,请稍后重试');
return false;
}