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
@@ -22,6 +22,7 @@ use app\adminapi\logic\tcm\DiagnosisLogic;
use app\adminapi\logic\tcm\PatientAiReportLogic;
use app\adminapi\logic\tcm\TrackingNoteLogic;
use app\adminapi\service\AssistantSseProtocol;
use app\adminapi\service\AssistantSseTiming;
use app\adminapi\validate\tcm\DiagnosisValidate;
use app\common\model\Order;
use app\common\model\WechatChatRecord;
@@ -930,6 +931,7 @@ class DiagnosisController extends BaseAdminController
*/
public function aiAssistantStream()
{
$timing = new AssistantSseTiming();
// 登录由全局中间件完成;请求校验、旧助手权限与 DataScope 必须全部
// 在任何 SSE header / start 事件之前完成,失败时仍返回标准 JSON。
$params = (new DiagnosisValidate())->post()->goCheck('aiAssistant');
@@ -938,17 +940,18 @@ class DiagnosisController extends BaseAdminController
(string) $params['task'],
(string) ($params['prompt'] ?? ''),
$this->adminId,
$this->adminInfo
$this->adminInfo,
$timing
);
if ($prepared === null) {
return $this->fail(DiagnosisAiLogic::getError());
}
$this->runAssistantSse($prepared);
$this->runAssistantSse($prepared, $timing);
}
/** @param array<string,mixed> $prepared */
private function runAssistantSse(array $prepared): void
private function runAssistantSse(array $prepared, AssistantSseTiming $timing): void
{
while (ob_get_level() > 0) {
ob_end_clean();
@@ -984,7 +987,7 @@ class DiagnosisController extends BaseAdminController
return !connection_aborted();
};
$emit('start', [
$startEmitted = $emit('start', [
'task' => (string) ($prepared['task'] ?? ''),
'model_key' => (string) ($prepared['profile'] ?? ''),
'diagnosis_id' => (int) ($prepared['diagnosis_id'] ?? 0),
@@ -995,14 +998,37 @@ class DiagnosisController extends BaseAdminController
: [],
'message' => '已连接,正在生成…',
]);
$sseStartedAt = hrtime(true);
$firstDeltaMs = null;
$timing->log('sse_start', ['status' => $startEmitted ? 'ok' : 'disconnected']);
try {
$result = DiagnosisAiLogic::streamPreparedAssistant(
$prepared,
static fn (string $delta): bool => $emit('delta', ['text' => $delta]),
static fn (): bool => connection_aborted() === 1
static function (string $delta) use ($emit, $timing, $sseStartedAt, &$firstDeltaMs): bool {
$observedAtMs = $firstDeltaMs === null && $delta !== ''
? AssistantSseTiming::elapsedMs($sseStartedAt)
: null;
$accepted = $emit('delta', ['text' => $delta]);
if ($accepted && $observedAtMs !== null) {
$firstDeltaMs = $observedAtMs;
$timing->log('first_delta', [
'duration_ms' => $firstDeltaMs,
'delta_bytes' => strlen($delta),
]);
}
return $accepted;
},
static fn (): bool => connection_aborted() === 1,
$timing
);
if (connection_aborted()) {
$timing->log('sse_done', [
'duration_ms' => AssistantSseTiming::elapsedMs($sseStartedAt),
'first_delta_observed' => $firstDeltaMs !== null,
'first_delta_ms' => $firstDeltaMs,
'status' => 'disconnected',
]);
exit;
}
if ($result === null) {
@@ -1013,14 +1039,22 @@ class DiagnosisController extends BaseAdminController
} else {
$emit('done', $result);
}
$timing->log('sse_done', [
'duration_ms' => AssistantSseTiming::elapsedMs($sseStartedAt),
'first_delta_observed' => $firstDeltaMs !== null,
'first_delta_ms' => $firstDeltaMs,
'status' => $result === null ? 'error' : 'ok',
]);
} 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),
'trace_id' => $timing->id(),
], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE));
$timing->log('sse_done', [
'duration_ms' => AssistantSseTiming::elapsedMs($sseStartedAt),
'first_delta_observed' => $firstDeltaMs !== null,
'first_delta_ms' => $firstDeltaMs,
'status' => 'exception',
]);
$emit('error', [
'code' => 'AI_ASSISTANT_FAILED',
'message' => 'AI 助手暂时不可用,请稍后重试',
+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;
}
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace app\adminapi\service;
use think\facade\Log;
/** A request-local, patient-free timing record for the diagnosis assistant SSE path. */
final class AssistantSseTiming
{
private const PHASES = ['prepare', 'sse_start', 'first_delta', 'upstream_done', 'sse_done'];
private const NUMBER_FIELDS = [
'duration_ms', 'aggregate_ms', 'compaction_ms', 'retry_ms',
'source_bytes', 'query_bytes', 'attachment_count',
'delta_bytes', 'delta_count', 'first_delta_ms',
];
private const BOOLEAN_FIELDS = ['compaction_attempted', 'retry_attempted', 'first_delta_observed'];
private const ERROR_CODES = [
'AI_ASSISTANT_FAILED', 'UPSTREAM_UNAVAILABLE', 'UPSTREAM_REJECTED',
'INCOMPLETE_RESPONSE', 'EMPTY_RESPONSE', 'UPSTREAM_TIMEOUT',
'CONFIG_DISABLED', 'INVALID_PROFILE', 'CONFIG_MISSING', 'CONFIG_INVALID',
'CURL_UNAVAILABLE', 'SKILL_UNAVAILABLE', 'CLIENT_DISCONNECTED',
'STREAM_DELIVERY_FAILED', 'REQUEST_BUILD_FAILED', 'CURL_INIT_FAILED',
'UPSTREAM_BUSY', 'INVALID_RESPONSE',
];
private string $traceId;
public function __construct()
{
$this->traceId = bin2hex(random_bytes(16));
}
public function id(): string
{
return $this->traceId;
}
public static function elapsedMs(int $startedAt): int
{
return max(0, (int) floor((hrtime(true) - $startedAt) / 1000000));
}
/**
* Only a fixed set of numeric and boolean metadata reaches the log. In particular,
* arbitrary prompt text, patient identifiers, URLs and credentials are discarded.
*
* @param array<string,mixed> $metrics
* @return array<string,int|bool|string>
*/
public function event(string $phase, array $metrics = []): array
{
$event = [
'trace_id' => $this->traceId,
'phase' => in_array($phase, self::PHASES, true) ? $phase : 'upstream_done',
];
foreach (self::NUMBER_FIELDS as $field) {
if (isset($metrics[$field]) && is_int($metrics[$field])) {
$event[$field] = max(0, $metrics[$field]);
}
}
foreach (self::BOOLEAN_FIELDS as $field) {
if (isset($metrics[$field]) && is_bool($metrics[$field])) {
$event[$field] = $metrics[$field];
}
}
if (isset($metrics['status']) && in_array($metrics['status'], ['ok', 'error', 'exception', 'disconnected'], true)) {
$event['status'] = $metrics['status'];
}
if (isset($metrics['error_code']) && in_array($metrics['error_code'], self::ERROR_CODES, true)) {
$event['error_code'] = $metrics['error_code'];
}
return $event;
}
/** @param array<string,mixed> $metrics */
public function log(string $phase, array $metrics = []): void
{
try {
Log::info('diagnosis ai assistant timing ' . json_encode($this->event($phase, $metrics)));
} catch (\Throwable $ignored) {
// Telemetry must never interrupt the clinical request.
}
}
}
@@ -12,6 +12,15 @@ final class ClinicalKnowledgeReference
return TcmAncientBooksReference::augmentQuery(NihaixiaClinicalSkill::augmentQuery($query));
}
/** @param list<array<string,mixed>> $ancientBookSources */
public static function augmentQueryWithAncientBookSources(string $query, array $ancientBookSources): string
{
return TcmAncientBooksReference::augmentQueryWithSources(
NihaixiaClinicalSkill::augmentQuery($query),
$ancientBookSources
);
}
/** @param array<int,array<string,mixed>> $messages
* @return array<int,array<string,mixed>>
*/
@@ -79,7 +79,7 @@ class DifyChatService
$ancientBookSources = ClinicalKnowledgeReference::ancientBookSources($query);
try {
$query = ClinicalKnowledgeReference::augmentQuery($query);
$query = ClinicalKnowledgeReference::augmentQueryWithAncientBookSources($query, $ancientBookSources);
} catch (\RuntimeException $error) {
return self::error('SKILL_UNAVAILABLE', '临床 AI 参考资料不可用');
}
@@ -239,7 +239,7 @@ class DifyChatService
$ancientBookSources = ClinicalKnowledgeReference::ancientBookSources($query);
try {
$query = ClinicalKnowledgeReference::augmentQuery($query);
$query = ClinicalKnowledgeReference::augmentQueryWithAncientBookSources($query, $ancientBookSources);
} catch (\RuntimeException $error) {
return self::error('SKILL_UNAVAILABLE', '临床 AI 参考资料不可用');
}
@@ -163,7 +163,16 @@ final class TcmAncientBooksReference
if (str_contains($query, self::MARKER)) {
return $query;
}
$reference = self::referenceForText($query);
return self::augmentQueryWithSources($query, self::sourcesForText($query));
}
/** @param list<array<string,mixed>> $sources */
public static function augmentQueryWithSources(string $query, array $sources): string
{
if (str_contains($query, self::MARKER)) {
return $query;
}
$reference = self::referenceForSources($sources);
return $reference === '' ? $query : $reference . "\n\n" . $query;
}
@@ -193,7 +202,12 @@ final class TcmAncientBooksReference
public static function referenceForText(string $text): string
{
$sources = self::sourcesForText($text);
return self::referenceForSources(self::sourcesForText($text));
}
/** @param list<array<string,mixed>> $sources */
public static function referenceForSources(array $sources): string
{
if ($sources === []) {
return '';
}