Merge branch 'master' of http://git.zhenyangtang.com.cn/ZYT/zyt
This commit is contained in:
@@ -23,6 +23,7 @@ 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\service\AssistantRequestControl;
|
||||
use app\adminapi\validate\tcm\DiagnosisValidate;
|
||||
use app\common\model\Order;
|
||||
use app\common\model\WechatChatRecord;
|
||||
@@ -936,135 +937,163 @@ class DiagnosisController extends BaseAdminController
|
||||
public function aiAssistantStream()
|
||||
{
|
||||
$timing = new AssistantSseTiming();
|
||||
// 登录由全局中间件完成;请求校验、旧助手权限与 DataScope 必须全部
|
||||
// 在任何 SSE header / start 事件之前完成,失败时仍返回标准 JSON。
|
||||
$params = (new DiagnosisValidate())->post()->goCheck('aiAssistant');
|
||||
$prepared = DiagnosisAiLogic::prepareAssistant(
|
||||
(int) $params['id'],
|
||||
(string) $params['task'],
|
||||
(string) ($params['prompt'] ?? ''),
|
||||
$this->adminId,
|
||||
$this->adminInfo,
|
||||
$timing
|
||||
);
|
||||
if ($prepared === null) {
|
||||
return $this->fail(DiagnosisAiLogic::getError());
|
||||
$params = (new DiagnosisValidate())->post()->goCheck('aiAssistantStream');
|
||||
// Old streaming clients may omit request_id; only requests with a known UUID
|
||||
// can be explicitly cancelled by a second HTTP request.
|
||||
$requestId = (string) ($params['request_id'] ?? '');
|
||||
if ($requestId === '') {
|
||||
$hex = bin2hex(random_bytes(16));
|
||||
$requestId = substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-4' . substr($hex, 13, 3)
|
||||
. '-8' . substr($hex, 17, 3) . '-' . substr($hex, 20);
|
||||
}
|
||||
$emit = null;
|
||||
$control = null;
|
||||
$profile = '';
|
||||
$sseStartedAt = hrtime(true);
|
||||
$disconnected = false;
|
||||
$shouldAbort = static function () use (&$control, &$disconnected): bool {
|
||||
if (connection_aborted() === 1 && !$disconnected) {
|
||||
$disconnected = true;
|
||||
if ($control !== null) $control->signalCancel();
|
||||
}
|
||||
return $disconnected || ($control !== null && $control->shouldAbort());
|
||||
};
|
||||
$options = [
|
||||
'should_abort' => $shouldAbort,
|
||||
'upstream_user' => 'admin-diagnosis-assistant-' . $this->adminId,
|
||||
'on_authorized' => function (array $metadata) use (&$control, &$emit, &$profile, &$sseStartedAt, $requestId, $timing): bool {
|
||||
// prepareAssistant reaches this callback only after permission, task
|
||||
// and canonical diagnosis DataScope checks. Failures before it are JSON.
|
||||
$control = new AssistantRequestControl($this->adminId, (int) $metadata['diagnosis_id'], $requestId);
|
||||
if (!$control->begin()) return false;
|
||||
$profile = (string) $metadata['model_key'];
|
||||
$emit = $this->openAssistantSse();
|
||||
$sseStartedAt = hrtime(true);
|
||||
$started = $emit('start', $metadata + ['request_id' => $requestId, 'message' => '已连接,正在整理资料']);
|
||||
$timing->log('sse_start', ['status' => $started ? 'ok' : 'disconnected']);
|
||||
return true;
|
||||
},
|
||||
'on_progress' => static function (array $progress) use (&$emit): void {
|
||||
if ($emit !== null) $emit('progress', $progress);
|
||||
},
|
||||
'on_stop' => static function (bool $confirmed, string $taskId = '') use (&$control): void {
|
||||
if ($control !== null && $taskId !== '') $control->markStop($confirmed, $taskId);
|
||||
},
|
||||
'on_transfer_end' => static function (string $taskId = '') use (&$control): void {
|
||||
if ($control !== null) $control->clearTask($taskId !== '' ? $taskId : null);
|
||||
},
|
||||
'on_cancel_task' => static function (string $taskId, callable $performStop) use (&$control): array {
|
||||
if ($control === null) return ['accepted' => false, 'pending' => false];
|
||||
$control->signalCancel();
|
||||
return $control->stopBoundTask($performStop, $taskId);
|
||||
},
|
||||
'on_terminal' => static function (string $taskId, string $status) use (&$control): void {
|
||||
if ($control !== null) $control->markTerminal($taskId, $status);
|
||||
},
|
||||
];
|
||||
$upstreamUser = $options['upstream_user'];
|
||||
$options['on_task'] = static function (string $taskId) use (&$control, &$profile, $upstreamUser): void {
|
||||
if ($control !== null) $control->bindTask($profile, $upstreamUser, $taskId);
|
||||
};
|
||||
try {
|
||||
$prepared = DiagnosisAiLogic::prepareAssistant(
|
||||
(int) $params['id'], (string) $params['task'], (string) ($params['prompt'] ?? ''),
|
||||
$this->adminId, $this->adminInfo, $timing, $options
|
||||
);
|
||||
if ($emit === null) {
|
||||
return $this->fail(DiagnosisAiLogic::getError());
|
||||
}
|
||||
if ($prepared === null) {
|
||||
$this->finishAssistantError($emit, $control, $shouldAbort());
|
||||
exit;
|
||||
}
|
||||
$this->runAssistantSse($prepared, $timing, $emit, $shouldAbort, $options, $control, $sseStartedAt);
|
||||
} catch (\Throwable $e) {
|
||||
\think\facade\Log::warning('diagnosis ai assistant sse failed ' . json_encode(['trace_id' => $timing->id()]));
|
||||
$timing->log('sse_done', ['duration_ms' => AssistantSseTiming::elapsedMs($sseStartedAt), 'status' => 'exception']);
|
||||
if ($emit === null) return $this->fail('AI 助手暂时不可用,请稍后重试');
|
||||
try { if ($control !== null) $control->finish('error'); } catch (\Throwable $ignored) {}
|
||||
$emit('error', ['code' => 'AI_ASSISTANT_FAILED', 'message' => 'AI 助手暂时不可用,请稍后重试']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$this->runAssistantSse($prepared, $timing);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $prepared */
|
||||
private function runAssistantSse(array $prepared, AssistantSseTiming $timing): void
|
||||
public function cancelAiAssistant()
|
||||
{
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
$params = (new DiagnosisValidate())->post()->goCheck('cancelAiAssistant');
|
||||
$result = DiagnosisAiLogic::cancelAssistant((int) $params['id'], (string) $params['request_id'], $this->adminId, $this->adminInfo);
|
||||
return $result === null ? $this->fail(DiagnosisAiLogic::getError()) : $this->data($result);
|
||||
}
|
||||
|
||||
private function openAssistantSse(): callable
|
||||
{
|
||||
while (ob_get_level() > 0) ob_end_clean();
|
||||
@ini_set('output_buffering', 'off');
|
||||
@ini_set('zlib.output_compression', '0');
|
||||
ignore_user_abort(true);
|
||||
if (function_exists('apache_setenv')) {
|
||||
@apache_setenv('no-gzip', '1');
|
||||
}
|
||||
|
||||
if (function_exists('apache_setenv')) @apache_setenv('no-gzip', '1');
|
||||
header('Content-Type: text/event-stream; charset=utf-8');
|
||||
header('Cache-Control: no-cache, no-transform');
|
||||
header('Connection: keep-alive');
|
||||
header('X-Accel-Buffering: no');
|
||||
header('Content-Encoding: none');
|
||||
|
||||
echo ':' . str_repeat(' ', 2048) . "\n\n";
|
||||
$this->flushSseOutput();
|
||||
|
||||
$protocol = new AssistantSseProtocol();
|
||||
$emit = function (string $event, array $payload) use ($protocol): bool {
|
||||
if ($protocol->isTerminal() || connection_aborted()) {
|
||||
return false;
|
||||
}
|
||||
return function (string $event, array $payload) use ($protocol): bool {
|
||||
if ($protocol->isTerminal() || connection_aborted()) return false;
|
||||
$encoded = $protocol->encode($event, $payload);
|
||||
if ($encoded === null) {
|
||||
return false;
|
||||
}
|
||||
if ($encoded === null) return false;
|
||||
echo $encoded;
|
||||
$this->flushSseOutput();
|
||||
return !connection_aborted();
|
||||
};
|
||||
}
|
||||
|
||||
$startEmitted = $emit('start', [
|
||||
'task' => (string) ($prepared['task'] ?? ''),
|
||||
'model_key' => (string) ($prepared['profile'] ?? ''),
|
||||
'diagnosis_id' => (int) ($prepared['diagnosis_id'] ?? 0),
|
||||
'context_scope' => (string) ($prepared['context_scope'] ?? ''),
|
||||
'context_version' => (string) ($prepared['context_version'] ?? ''),
|
||||
'source_summary' => is_array($prepared['source_summary'] ?? null)
|
||||
? $prepared['source_summary']
|
||||
: [],
|
||||
'message' => '已连接,正在生成…',
|
||||
]);
|
||||
$sseStartedAt = hrtime(true);
|
||||
private function finishAssistantError(callable $emit, ?AssistantRequestControl $control, bool $cancelled): void
|
||||
{
|
||||
if ($control !== null) $control->finish($cancelled ? 'cancelled' : 'error');
|
||||
if ($cancelled) {
|
||||
$emit('progress', ['phase' => 'cancelled', 'status' => 'cancelled', 'message' => '本次请求已取消']
|
||||
+ ($control !== null ? $control->publicState() : []));
|
||||
}
|
||||
$emit('error', ['code' => $cancelled ? 'AI_ASSISTANT_CANCELLED' : DiagnosisAiLogic::getAssistantErrorCode(),
|
||||
'message' => $cancelled ? '本次请求已取消' : DiagnosisAiLogic::getError()]);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $prepared */
|
||||
private function runAssistantSse(array $prepared, AssistantSseTiming $timing, callable $emit, callable $shouldAbort,
|
||||
array $options, AssistantRequestControl $control, int $sseStartedAt): void
|
||||
{
|
||||
$firstDeltaMs = null;
|
||||
$timing->log('sse_start', ['status' => $startEmitted ? 'ok' : 'disconnected']);
|
||||
|
||||
try {
|
||||
$result = DiagnosisAiLogic::streamPreparedAssistant(
|
||||
$prepared,
|
||||
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) {
|
||||
$emit('error', [
|
||||
'code' => DiagnosisAiLogic::getAssistantErrorCode(),
|
||||
'message' => DiagnosisAiLogic::getError(),
|
||||
]);
|
||||
$result = DiagnosisAiLogic::streamPreparedAssistant(
|
||||
$prepared,
|
||||
static function (string $delta) use ($emit, $timing, $sseStartedAt, &$firstDeltaMs, $shouldAbort): bool {
|
||||
if ($shouldAbort()) return false;
|
||||
$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;
|
||||
},
|
||||
$shouldAbort, $timing, $options
|
||||
);
|
||||
$cancelled = $shouldAbort();
|
||||
if ($result === null || $cancelled) {
|
||||
$this->finishAssistantError($emit, $control, $cancelled);
|
||||
} else {
|
||||
$control->finish('done');
|
||||
if ($control->publicState()['status'] === 'cancelled') {
|
||||
$this->finishAssistantError($emit, $control, true);
|
||||
} 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([
|
||||
'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 助手暂时不可用,请稍后重试',
|
||||
]);
|
||||
}
|
||||
|
||||
$timing->log('sse_done', ['duration_ms' => AssistantSseTiming::elapsedMs($sseStartedAt),
|
||||
'first_delta_observed' => $firstDeltaMs !== null, 'first_delta_ms' => $firstDeltaMs,
|
||||
'status' => $cancelled ? 'cancelled' : ($result === null ? 'error' : 'ok')]);
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ class AuthMiddleware
|
||||
if (!in_array($accessUri, $allUri, true)
|
||||
&& !PharmacyUploadPermissionAlias::allows($accessUri, $allUri)
|
||||
&& !$this->isCriticalPrescriptionWrite($accessUri)
|
||||
&& !($accessUri === 'tcm.diagnosis/aiassistantstream'
|
||||
&& !(in_array($accessUri, ['tcm.diagnosis/aiassistantstream', 'tcm.diagnosis/cancelaiassistant'], true)
|
||||
&& in_array('tcm.diagnosis/aiassistant', $allUri, true))) {
|
||||
return $next($request);
|
||||
}
|
||||
@@ -184,7 +184,7 @@ class AuthMiddleware
|
||||
{
|
||||
// AI 助手流式端点与旧 blocking 端点共享同一权限;别名同时用于
|
||||
// allUri 判定和当前管理员权限判定,确保在 SSE headers 前完成鉴权。
|
||||
if ($accessUri === 'tcm.diagnosis/aiassistantstream'
|
||||
if (in_array($accessUri, ['tcm.diagnosis/aiassistantstream', 'tcm.diagnosis/cancelaiassistant'], true)
|
||||
&& in_array('tcm.diagnosis/aiassistant', $adminUris, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use app\common\service\NihaixiaClinicalSkill;
|
||||
use app\common\service\TcmAncientBooksReference;
|
||||
use app\adminapi\service\AssistantSseTiming;
|
||||
use app\adminapi\service\AiContextCompactionCache;
|
||||
use app\adminapi\service\AssistantRequestControl;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
@@ -347,7 +348,8 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 SSE headers 发出前完成参数、权限、DataScope、病例和模型选择预检。
|
||||
* 参数、权限、DataScope 和模型选择完成后可通过 on_authorized 提前开启 SSE;
|
||||
* 随后的资料聚合/证据整理通过可选 progress 与取消回调协作。
|
||||
* 返回值只供同一请求内的流执行使用,绝不能直接序列化给客户端。
|
||||
*
|
||||
* @param array<string,mixed> $adminInfo
|
||||
@@ -362,7 +364,8 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
string $prompt,
|
||||
int $adminId,
|
||||
array $adminInfo,
|
||||
?AssistantSseTiming $timing = null
|
||||
?AssistantSseTiming $timing = null,
|
||||
array $options = []
|
||||
): ?array {
|
||||
$prepareStartedAt = hrtime(true);
|
||||
$aggregateMs = 0;
|
||||
@@ -402,16 +405,6 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
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'] ?? ''));
|
||||
@@ -420,14 +413,40 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
self::setError('AI 模型服务尚未完整配置');
|
||||
return null;
|
||||
}
|
||||
// This is the only early-start boundary: all identity, scope and task
|
||||
// checks have succeeded, while no aggregation or model work has begun.
|
||||
if (is_callable($options['on_authorized'] ?? null)
|
||||
&& ($options['on_authorized'])(['diagnosis_id' => $diagnosisId, 'task' => $task, 'model_key' => $profile]) === false) {
|
||||
self::setError('该请求标识已使用,请重新发起');
|
||||
return null;
|
||||
}
|
||||
if (self::assistantCancelled($options['should_abort'] ?? null)) return null;
|
||||
self::assistantProgress($options, ['phase' => 'preparing', 'status' => 'aggregating', 'message' => '正在聚合本次授权范围内的患者资料']);
|
||||
$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 (self::assistantCancelled($options['should_abort'] ?? null)) return null;
|
||||
self::assistantProgress($options, ['phase' => 'preparing', 'status' => 'aggregated',
|
||||
'message' => '资料已聚合,正在整理本次提交内容',
|
||||
'source_summary' => $context['source_summary'] ?? [], 'attachment_count' => $attachmentCount]);
|
||||
if ($context['case_lines'] === []) {
|
||||
self::setError('患者纵向资料为空或聚合失败,无法使用 AI 助手');
|
||||
return null;
|
||||
}
|
||||
|
||||
$compactionAttempted = $sourceBytes > self::MAX_PROMPT_SOURCE_BYTES;
|
||||
$compactionStartedAt = hrtime(true);
|
||||
if (!self::fitContextForPrompt($context, $profile, $timing)) {
|
||||
if (!self::fitContextForPrompt($context, $profile, $timing, $options)) {
|
||||
$compactionMs = $compactionAttempted ? AssistantSseTiming::elapsedMs($compactionStartedAt) : 0;
|
||||
return null;
|
||||
}
|
||||
$compactionMs = $compactionAttempted ? AssistantSseTiming::elapsedMs($compactionStartedAt) : 0;
|
||||
if (self::assistantCancelled($options['should_abort'] ?? null)) return null;
|
||||
self::assistantProgress($options, ['phase' => 'preparing', 'status' => 'ready',
|
||||
'message' => '资料正文已整理,准备提交给模型',
|
||||
'source_summary' => $context['source_summary'] ?? [], 'attachment_count' => $attachmentCount]);
|
||||
|
||||
$query = self::buildAssistantPrompt($context, $task, $prompt);
|
||||
$inputs = self::buildUpstreamInputs(
|
||||
@@ -455,6 +474,8 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
'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_form' => !empty($context['source_summary']['source_compacted']) ? 'evidence_summary' : 'full_context',
|
||||
'evidence_cache_hit' => !empty($context['compaction_cache_hit']),
|
||||
'source_diagnosis_ids' => is_array($context['source_diagnosis_ids'] ?? null)
|
||||
? $context['source_diagnosis_ids']
|
||||
: [],
|
||||
@@ -487,7 +508,8 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
array $prepared,
|
||||
callable $onDelta,
|
||||
?callable $shouldAbort = null,
|
||||
?AssistantSseTiming $timing = null
|
||||
?AssistantSseTiming $timing = null,
|
||||
array $options = []
|
||||
): ?array {
|
||||
$diagnosisId = (int) ($prepared['diagnosis_id'] ?? 0);
|
||||
$profile = (string) ($prepared['profile'] ?? '');
|
||||
@@ -510,6 +532,7 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
};
|
||||
|
||||
try {
|
||||
if (self::assistantCancelled($shouldAbort)) return null;
|
||||
try {
|
||||
$result = DifyChatService::streamChat(
|
||||
$profile,
|
||||
@@ -518,7 +541,10 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
(string) ($prepared['user'] ?? ''),
|
||||
$forwardDelta,
|
||||
$shouldAbort,
|
||||
is_array($prepared['files'] ?? null) ? $prepared['files'] : []
|
||||
is_array($prepared['files'] ?? null) ? $prepared['files'] : [],
|
||||
$options + ['source_summary' => $prepared['source_summary'] ?? [],
|
||||
'source_form' => $prepared['source_form'] ?? 'full_context',
|
||||
'evidence_cache_hit' => !empty($prepared['evidence_cache_hit'])]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
if ($timing === null) {
|
||||
@@ -573,7 +599,10 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
(string) ($prepared['query'] ?? ''),
|
||||
(string) ($prepared['user'] ?? ''),
|
||||
is_array($prepared['files'] ?? null) ? $prepared['files'] : [],
|
||||
['timeout' => $remainingTimeout]
|
||||
$options + ['timeout' => $remainingTimeout, 'should_abort' => $shouldAbort, 'blocking_compatibility' => true,
|
||||
'source_summary' => $prepared['source_summary'] ?? [],
|
||||
'source_form' => $prepared['source_form'] ?? 'full_context',
|
||||
'evidence_cache_hit' => !empty($prepared['evidence_cache_hit'])]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
if ($timing === null) {
|
||||
@@ -601,6 +630,7 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
}
|
||||
}
|
||||
|
||||
if (self::assistantCancelled($shouldAbort)) { $status = 'cancelled'; return null; }
|
||||
$formatted = self::formatAssistantResult($prepared, $result);
|
||||
$status = $formatted === null ? 'error' : 'ok';
|
||||
return $formatted;
|
||||
@@ -619,6 +649,35 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
}
|
||||
}
|
||||
|
||||
public static function cancelAssistant(int $diagnosisId, string $requestId, int $adminId, array $adminInfo): ?array
|
||||
{
|
||||
if (!AssistantRequestControl::validRequestId($requestId)) {
|
||||
self::setError('请求标识无效');
|
||||
return null;
|
||||
}
|
||||
if (self::loadAuthorizedDiagnosis($diagnosisId, $adminId, $adminInfo, self::PERMISSION_ASSISTANT,
|
||||
'权限不足,无法取消诊单 AI 助手') === null) return null;
|
||||
try {
|
||||
return (new AssistantRequestControl($adminId, $diagnosisId, $requestId))->requestCancel();
|
||||
} catch (\Throwable $ignored) {
|
||||
self::setError('取消请求暂未送达,请稍后重试');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static function assistantCancelled(?callable $shouldAbort): bool
|
||||
{
|
||||
if ($shouldAbort === null || !$shouldAbort()) return false;
|
||||
self::$assistantErrorCode = 'AI_ASSISTANT_CANCELLED';
|
||||
self::setError('本次请求已取消');
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function assistantProgress(array $options, array $event): void
|
||||
{
|
||||
if (is_callable($options['on_progress'] ?? null)) ($options['on_progress'])($event);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $prepared
|
||||
* @param array<string,mixed> $result
|
||||
@@ -1570,7 +1629,8 @@ PROMPT;
|
||||
private static function fitContextForPrompt(
|
||||
array &$context,
|
||||
string $profile,
|
||||
?AssistantSseTiming $timing = null
|
||||
?AssistantSseTiming $timing = null,
|
||||
array $options = []
|
||||
): bool
|
||||
{
|
||||
$caseText = (string) ($context['case_text'] ?? '');
|
||||
@@ -1581,9 +1641,11 @@ PROMPT;
|
||||
try {
|
||||
$compacted = AiContextCompactionCache::remember(
|
||||
(string) ($context['compaction_cache_scope'] ?? ''), $profile, $caseText,
|
||||
static fn (): array => PatientAiReportLogic::compactSourceForPrompt($profile, $caseText)
|
||||
static fn (): array => PatientAiReportLogic::compactSourceForPrompt($profile, $caseText, $options),
|
||||
$options['should_abort'] ?? null
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
if (self::assistantCancelled($options['should_abort'] ?? null)) return false;
|
||||
if ($timing === null) {
|
||||
Log::warning('diagnosis ai context compaction failed', [
|
||||
'source_bytes' => strlen($caseText),
|
||||
|
||||
@@ -550,8 +550,24 @@ class PatientAiReportLogic extends BaseLogic
|
||||
*
|
||||
* @return array{text:string,chunk_count:int,reduction_rounds:int,source_bytes:int,compacted:bool}
|
||||
*/
|
||||
public static function compactSourceForPrompt(string $modelKey, string $sourceJson): array
|
||||
public static function compactSourceForPrompt(string $modelKey, string $sourceJson, array $options = []): array
|
||||
{
|
||||
$abort = $options['should_abort'] ?? null;
|
||||
$check = static function () use ($abort): void {
|
||||
if (is_callable($abort) && $abort()) throw new \RuntimeException('Context operation cancelled');
|
||||
};
|
||||
$progress = static function (string $message, int $completed, int $total) use ($options): void {
|
||||
if (is_callable($options['on_progress'] ?? null)) ($options['on_progress'])([
|
||||
'phase' => 'preparing', 'status' => 'compacting', 'message' => $message,
|
||||
'completed' => $completed, 'total' => $total,
|
||||
]);
|
||||
};
|
||||
$transportOptions = array_intersect_key($options, array_flip([
|
||||
'should_abort', 'on_task', 'on_stop', 'on_transfer_end', 'on_cancel_task', 'on_terminal',
|
||||
]));
|
||||
$transportOptions['knowledge_mode'] = 'evidence';
|
||||
$upstreamUser = (string) ($options['upstream_user'] ?? 'patient-longitudinal-context');
|
||||
$check();
|
||||
$sourceBytes = strlen($sourceJson);
|
||||
if ($sourceBytes <= self::MAX_PROMPT_CHUNK_BYTES) {
|
||||
return [
|
||||
@@ -567,6 +583,8 @@ class PatientAiReportLogic extends BaseLogic
|
||||
$chunkCount = count($chunks);
|
||||
$summaries = [];
|
||||
foreach ($chunks as $index => $chunk) {
|
||||
$check();
|
||||
$progress('正在向模型提交资料分片并整理证据', $index, $chunkCount);
|
||||
$part = DifyChatService::chat(
|
||||
$modelKey,
|
||||
[
|
||||
@@ -576,8 +594,9 @@ class PatientAiReportLogic extends BaseLogic
|
||||
'prompt_version' => self::PROMPT_VERSION,
|
||||
],
|
||||
self::buildChunkPrompt($chunk, $index + 1, $chunkCount),
|
||||
'patient-longitudinal-context', [], ['knowledge_mode' => 'evidence']
|
||||
$upstreamUser, [], $transportOptions
|
||||
);
|
||||
$check();
|
||||
if (empty($part['ok']) || trim((string) ($part['content'] ?? '')) === '') {
|
||||
throw new \RuntimeException('Patient context chunk analysis failed');
|
||||
}
|
||||
@@ -586,6 +605,7 @@ class PatientAiReportLogic extends BaseLogic
|
||||
'total' => $chunkCount,
|
||||
'summary' => self::cleanSourceText($part['content'], true),
|
||||
];
|
||||
$progress('资料分片证据已返回', $index + 1, $chunkCount);
|
||||
}
|
||||
|
||||
$reductionRounds = 0;
|
||||
@@ -598,6 +618,8 @@ class PatientAiReportLogic extends BaseLogic
|
||||
$summaryChunks = self::splitUtf8ByBytes($summaryJson, self::MAX_PROMPT_CHUNK_BYTES);
|
||||
$reduced = [];
|
||||
foreach ($summaryChunks as $index => $chunk) {
|
||||
$check();
|
||||
$progress('正在归并第' . $reductionRounds . '轮资料证据', $index, count($summaryChunks));
|
||||
$part = DifyChatService::chat(
|
||||
$modelKey,
|
||||
[
|
||||
@@ -608,8 +630,9 @@ class PatientAiReportLogic extends BaseLogic
|
||||
'prompt_version' => self::PROMPT_VERSION,
|
||||
],
|
||||
self::buildReductionPrompt($chunk, $index + 1, count($summaryChunks)),
|
||||
'patient-longitudinal-context', [], ['knowledge_mode' => 'evidence']
|
||||
$upstreamUser, [], $transportOptions
|
||||
);
|
||||
$check();
|
||||
if (empty($part['ok']) || trim((string) ($part['content'] ?? '')) === '') {
|
||||
throw new \RuntimeException('Patient context reduction failed');
|
||||
}
|
||||
@@ -618,6 +641,7 @@ class PatientAiReportLogic extends BaseLogic
|
||||
'total' => count($summaryChunks),
|
||||
'summary' => self::cleanSourceText($part['content'], true),
|
||||
];
|
||||
$progress('资料证据归并已返回', $index + 1, count($summaryChunks));
|
||||
}
|
||||
$nextJson = self::encodeJson($reduced, true);
|
||||
if (strlen($nextJson) >= strlen($summaryJson) && count($reduced) >= count($summaries)) {
|
||||
@@ -627,6 +651,7 @@ class PatientAiReportLogic extends BaseLogic
|
||||
$summaryJson = $nextJson;
|
||||
}
|
||||
|
||||
$check();
|
||||
return [
|
||||
'text' => '患者纵向完整资料(服务端已逐片读取全部来源后归并的证据摘要,覆盖 '
|
||||
. $chunkCount . ' 个来源片段):' . $summaryJson,
|
||||
|
||||
@@ -11,7 +11,7 @@ use think\facade\Cache;
|
||||
/** Short-lived evidence only. The caller must re-authorize and aggregate before lookup. */
|
||||
final class AiContextCompactionCache
|
||||
{
|
||||
public static function remember(string $scope, string $profile, string $source, callable $produce): array
|
||||
public static function remember(string $scope, string $profile, string $source, callable $produce, ?callable $shouldAbort = null): array
|
||||
{
|
||||
$config = config('prescription_ai') ?: [];
|
||||
$ttl = max(0, min(600, (int) ($config['context_compaction_cache_ttl'] ?? 300)));
|
||||
@@ -28,6 +28,7 @@ final class AiContextCompactionCache
|
||||
}
|
||||
}
|
||||
$result = $produce();
|
||||
if ($shouldAbort !== null && $shouldAbort()) throw new \RuntimeException('Context operation cancelled');
|
||||
if ($scope !== '' && $ttl > 0 && self::valid($result, strlen($source))) {
|
||||
try {
|
||||
Cache::set($key, $result, $ttl);
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\service;
|
||||
|
||||
use app\common\service\DifyChatService;
|
||||
|
||||
/**
|
||||
* Shared between FPM workers on one host. Contains no credentials or clinical text.
|
||||
* The caller MUST authorize the diagnosis before constructing or reading a control.
|
||||
*/
|
||||
final class AssistantRequestControl
|
||||
{
|
||||
private const STOP_LEASE_SECONDS = 1.25;
|
||||
private const STOP_RETRY_DELAY_SECONDS = 0.25;
|
||||
private const STOP_RETENTION_SECONDS = 30;
|
||||
private const MAX_STOP_ATTEMPTS = 2;
|
||||
|
||||
private string $path;
|
||||
private int $ttl;
|
||||
private string $directory;
|
||||
|
||||
public function __construct(int $adminId, int $diagnosisId, string $requestId, ?string $directory = null, int $ttl = 1200)
|
||||
{
|
||||
if ($adminId <= 0 || $diagnosisId <= 0 || !self::validRequestId($requestId)) {
|
||||
throw new \InvalidArgumentException('Invalid assistant request identity');
|
||||
}
|
||||
$directory = $directory ?? app()->getRuntimePath() . 'assistant-requests';
|
||||
if (!is_dir($directory) && !@mkdir($directory, 0700, true) && !is_dir($directory)) {
|
||||
throw new \RuntimeException('Assistant request control unavailable');
|
||||
}
|
||||
$this->path = rtrim($directory, '/\\') . DIRECTORY_SEPARATOR
|
||||
. hash('sha256', $adminId . ':' . $diagnosisId . ':' . strtolower($requestId)) . '.json';
|
||||
$this->ttl = max(600, $ttl);
|
||||
$this->directory = $directory;
|
||||
$this->collectExpired();
|
||||
}
|
||||
|
||||
public static function validRequestId(string $id): bool
|
||||
{
|
||||
return preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $id) === 1;
|
||||
}
|
||||
|
||||
/** A cancel tombstone created before prepare is retained; a replay never restarts work. */
|
||||
public function begin(): bool
|
||||
{
|
||||
return $this->update(static function (array &$state): bool {
|
||||
if (!empty($state['begun'])) {
|
||||
return false;
|
||||
}
|
||||
$state['begun'] = true;
|
||||
$state['status'] = !empty($state['cancel_requested']) ? 'cancel_requested' : 'running';
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public function shouldAbort(): bool
|
||||
{
|
||||
return $this->update(static fn (array &$state): bool => !empty($state['cancel_requested']), false);
|
||||
}
|
||||
|
||||
public function bindTask(string $profile, string $user, string $taskId): void
|
||||
{
|
||||
if (!in_array($profile, ['qwen', 'openai'], true)
|
||||
|| preg_match('/^[a-zA-Z0-9_-]{1,128}$/', $taskId) !== 1 || strlen($user) > 128) {
|
||||
throw new \RuntimeException('Invalid upstream task identity');
|
||||
}
|
||||
$this->update(static function (array &$state) use ($profile, $user, $taskId): void {
|
||||
if (in_array($state['status'] ?? '', ['done', 'error', 'cancelled'], true)) return;
|
||||
$previous = $state['upstream'] ?? [];
|
||||
if (($previous['task_id'] ?? '') === $taskId && ($previous['user'] ?? '') === $user
|
||||
&& ($previous['profile'] ?? '') === $profile) return;
|
||||
$state['task_generation'] = (int) ($state['task_generation'] ?? 0) + 1;
|
||||
$state['upstream'] = ['profile' => $profile, 'user' => $user, 'task_id' => $taskId,
|
||||
'generation' => $state['task_generation']];
|
||||
$state['upstream_stop_confirmed'] = false;
|
||||
$state['upstream_terminal_confirmed'] = false;
|
||||
$state['upstream_terminal_status'] = '';
|
||||
$state['stop_attempts'] = 0;
|
||||
unset($state['stop_claim'], $state['stop_retry_after']);
|
||||
});
|
||||
}
|
||||
|
||||
public function clearTask(?string $taskId = null): void
|
||||
{
|
||||
$this->update(static function (array &$state) use ($taskId): void {
|
||||
if ($taskId !== null && ($state['upstream']['task_id'] ?? '') !== $taskId) return;
|
||||
self::releaseTask($state);
|
||||
});
|
||||
}
|
||||
|
||||
public function markStop(bool $confirmed, ?string $taskId = null): void
|
||||
{
|
||||
$this->update(static function (array &$state) use ($confirmed, $taskId): void {
|
||||
if ($taskId !== null && ($state['upstream']['task_id'] ?? '') !== $taskId) return;
|
||||
$state['upstream_stop_confirmed'] = !empty($state['upstream_stop_confirmed']) || $confirmed;
|
||||
});
|
||||
}
|
||||
|
||||
/** Only a real workflow terminal event confirms execution ended; stop acceptance does not. */
|
||||
public function markTerminal(string $taskId, string $status): void
|
||||
{
|
||||
if (!in_array($status, ['stopped', 'succeeded', 'failed'], true)) return;
|
||||
$this->update(static function (array &$state) use ($taskId, $status): void {
|
||||
if (($state['upstream']['task_id'] ?? '') !== $taskId || !empty($state['upstream_terminal_confirmed'])) return;
|
||||
$state['upstream_terminal_confirmed'] = true;
|
||||
$state['upstream_terminal_status'] = $status;
|
||||
});
|
||||
}
|
||||
|
||||
/** Safe inside a transport callback: writes intent and never performs network I/O. */
|
||||
public function signalCancel(): void
|
||||
{
|
||||
$this->update(static function (array &$state): void {
|
||||
if (in_array($state['status'] ?? '', ['done', 'error'], true)) return;
|
||||
$state['cancel_requested'] = true;
|
||||
if (($state['status'] ?? '') !== 'cancelled') $state['status'] = 'cancel_requested';
|
||||
$state['stop_retry_until'] = $state['stop_retry_until'] ?? microtime(true) + self::STOP_RETENTION_SECONDS;
|
||||
});
|
||||
}
|
||||
|
||||
/** Safe client view. A requested cancellation is never reported as confirmed stop. */
|
||||
public function requestCancel(?callable $stop = null): array
|
||||
{
|
||||
$this->signalCancel();
|
||||
$this->stopBoundTask($stop);
|
||||
return $this->publicState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared by the cancel endpoint and stream owner. The lease exceeds the 1s stop
|
||||
* transport budget; a crashed claimant can be replaced, while accepted stops
|
||||
* never repeat. Failed tasks remain eligible for one short-lived compensation.
|
||||
* @return array{accepted:bool,pending:bool}
|
||||
*/
|
||||
public function stopBoundTask(?callable $stop = null, ?string $expectedTaskId = null): array
|
||||
{
|
||||
$token = bin2hex(random_bytes(12));
|
||||
$claim = $this->update(static function (array &$state) use ($token, $expectedTaskId): array {
|
||||
$now = microtime(true);
|
||||
$task = $state['upstream'] ?? null;
|
||||
$accepted = !empty($state['upstream_stop_confirmed']);
|
||||
if (!is_array($task) || ($expectedTaskId !== null && $task['task_id'] !== $expectedTaskId)) {
|
||||
return ['accepted' => false, 'pending' => false];
|
||||
}
|
||||
// A cancel worker may load a task written by the previous local
|
||||
// release. Upgrade its private identity atomically before claiming.
|
||||
if (!isset($task['generation'])) {
|
||||
$state['task_generation'] = (int) ($state['task_generation'] ?? 0) + 1;
|
||||
$task['generation'] = $state['task_generation'];
|
||||
$state['upstream'] = $task;
|
||||
}
|
||||
if ($accepted || !empty($state['upstream_terminal_confirmed']) || empty($state['cancel_requested'])
|
||||
|| (float) ($state['stop_retry_until'] ?? 0) <= $now) {
|
||||
return ['accepted' => $accepted, 'pending' => false];
|
||||
}
|
||||
if ((float) ($state['stop_claim']['until'] ?? 0) > $now
|
||||
|| (float) ($state['stop_retry_after'] ?? 0) > $now) {
|
||||
return ['accepted' => false, 'pending' => true];
|
||||
}
|
||||
if ((int) ($state['stop_attempts'] ?? 0) >= self::MAX_STOP_ATTEMPTS) {
|
||||
return ['accepted' => false, 'pending' => false];
|
||||
}
|
||||
$state['stop_claim'] = ['token' => $token, 'until' => $now + self::STOP_LEASE_SECONDS];
|
||||
$state['stop_attempts'] = (int) ($state['stop_attempts'] ?? 0) + 1;
|
||||
return ['task' => $task, 'accepted' => false, 'pending' => true];
|
||||
});
|
||||
if (!isset($claim['task'])) return $claim;
|
||||
$task = $claim['task'];
|
||||
$confirmed = false;
|
||||
// Never hold either file lock while waiting for the remote stop endpoint.
|
||||
try {
|
||||
$confirmed = ($stop !== null ? $stop($task) : DifyChatService::stopTask(
|
||||
$task['profile'], $task['task_id'], $task['user']
|
||||
)) === true;
|
||||
} catch (\Throwable $ignored) {}
|
||||
return $this->update(static function (array &$state) use ($task, $token, $confirmed): array {
|
||||
if (($state['upstream']['generation'] ?? 0) !== $task['generation']
|
||||
|| ($state['upstream']['task_id'] ?? '') !== $task['task_id']
|
||||
|| ($state['stop_claim']['token'] ?? '') !== $token) {
|
||||
return ['accepted' => false, 'pending' => false];
|
||||
}
|
||||
$state['upstream_stop_confirmed'] = !empty($state['upstream_stop_confirmed']) || $confirmed;
|
||||
unset($state['stop_claim']);
|
||||
$state['stop_retry_after'] = microtime(true) + self::STOP_RETRY_DELAY_SECONDS;
|
||||
return ['accepted' => !empty($state['upstream_stop_confirmed']), 'pending' => false];
|
||||
});
|
||||
}
|
||||
|
||||
private static function releaseTask(array &$state): void
|
||||
{
|
||||
if (!empty($state['cancel_requested']) && empty($state['upstream_terminal_confirmed'])
|
||||
&& (float) ($state['stop_retry_until'] ?? 0) > microtime(true)) {
|
||||
return;
|
||||
}
|
||||
unset($state['upstream'], $state['stop_claim']);
|
||||
}
|
||||
|
||||
public function finish(string $status): void
|
||||
{
|
||||
if (!in_array($status, ['done', 'error', 'cancelled'], true)) {
|
||||
throw new \InvalidArgumentException('Invalid assistant terminal state');
|
||||
}
|
||||
$this->update(static function (array &$state) use ($status): void {
|
||||
if (in_array($state['status'] ?? '', ['done', 'error', 'cancelled'], true)) {
|
||||
return;
|
||||
}
|
||||
$state['status'] = !empty($state['cancel_requested']) ? 'cancelled' : $status;
|
||||
$state['transport_closed'] = true;
|
||||
self::releaseTask($state);
|
||||
});
|
||||
}
|
||||
|
||||
public function publicState(): array
|
||||
{
|
||||
return $this->update(static fn (array &$state): array => [
|
||||
'status' => $state['status'] ?? 'pending',
|
||||
'cancel_requested' => !empty($state['cancel_requested']),
|
||||
'transport_closed' => !empty($state['transport_closed']),
|
||||
'upstream_stop_confirmed' => !empty($state['upstream_stop_confirmed']),
|
||||
'upstream_terminal_confirmed' => !empty($state['upstream_terminal_confirmed']),
|
||||
'upstream_terminal_status' => (string) ($state['upstream_terminal_status'] ?? ''),
|
||||
], false);
|
||||
}
|
||||
|
||||
private function update(callable $apply, bool $persist = true)
|
||||
{
|
||||
$guard = @fopen($this->directory . DIRECTORY_SEPARATOR . '.gc.lock', 'c+');
|
||||
if ($guard === false || !flock($guard, LOCK_SH)) {
|
||||
if (is_resource($guard)) fclose($guard);
|
||||
throw new \RuntimeException('Assistant request control unavailable');
|
||||
}
|
||||
$handle = @fopen($this->path, 'c+');
|
||||
if ($handle === false || !flock($handle, $persist ? LOCK_EX : LOCK_SH)) {
|
||||
if (is_resource($handle)) fclose($handle);
|
||||
flock($guard, LOCK_UN);
|
||||
fclose($guard);
|
||||
throw new \RuntimeException('Assistant request control unavailable');
|
||||
}
|
||||
try {
|
||||
@chmod($this->path, 0600);
|
||||
$raw = stream_get_contents($handle);
|
||||
$state = is_string($raw) ? json_decode($raw, true) : null;
|
||||
if ($raw !== '' && !is_array($state)) throw new \RuntimeException('Assistant request control unavailable');
|
||||
if (!is_array($state) || (int) ($state['expires_at'] ?? 0) <= time()) {
|
||||
$state = ['expires_at' => time() + $this->ttl];
|
||||
}
|
||||
$result = $apply($state);
|
||||
if (!$persist) return $result;
|
||||
$state['expires_at'] = time() + $this->ttl;
|
||||
$encoded = json_encode($state, JSON_THROW_ON_ERROR);
|
||||
rewind($handle);
|
||||
if (!ftruncate($handle, 0) || fwrite($handle, $encoded) !== strlen($encoded) || !fflush($handle)) {
|
||||
throw new \RuntimeException('Assistant request control unavailable');
|
||||
}
|
||||
return $result;
|
||||
} finally {
|
||||
flock($handle, LOCK_UN);
|
||||
fclose($handle);
|
||||
flock($guard, LOCK_UN);
|
||||
fclose($guard);
|
||||
}
|
||||
}
|
||||
|
||||
/** The global guard prevents unlink/reopen races with any other worker. */
|
||||
private function collectExpired(): void
|
||||
{
|
||||
$guard = @fopen($this->directory . DIRECTORY_SEPARATOR . '.gc.lock', 'c+');
|
||||
if ($guard === false) return;
|
||||
try {
|
||||
if (!flock($guard, LOCK_EX | LOCK_NB)) return;
|
||||
$paths = glob($this->directory . DIRECTORY_SEPARATOR . '*.json') ?: [];
|
||||
// Bound each sweep; rotating the scan also reclaims older files in large directories.
|
||||
if (count($paths) > 256) shuffle($paths);
|
||||
foreach (array_slice($paths, 0, 256) as $path) {
|
||||
if ((int) @filemtime($path) > time() - 600) continue;
|
||||
$state = json_decode((string) @file_get_contents($path), true);
|
||||
if (!is_array($state) || (int) ($state['expires_at'] ?? 0) <= time()) @unlink($path);
|
||||
}
|
||||
} finally {
|
||||
flock($guard, LOCK_UN);
|
||||
fclose($guard);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
namespace app\adminapi\service;
|
||||
|
||||
/**
|
||||
* 诊单 AI 助手 SSE 事件状态机:start -> delta* -> done|error。
|
||||
* 诊单 AI 助手 SSE 事件状态机:start -> (progress|delta)* -> done|error。
|
||||
*/
|
||||
final class AssistantSseProtocol
|
||||
{
|
||||
@@ -18,7 +18,7 @@ final class AssistantSseProtocol
|
||||
/** @param array<string,mixed> $payload */
|
||||
public function encode(string $event, array $payload): ?string
|
||||
{
|
||||
if ($this->terminal || !in_array($event, ['start', 'delta', 'done', 'error'], true)) {
|
||||
if ($this->terminal || !in_array($event, ['start', 'progress', 'delta', 'done', 'error'], true)) {
|
||||
return null;
|
||||
}
|
||||
if ((!$this->started && $event !== 'start') || ($this->started && $event === 'start')) {
|
||||
|
||||
@@ -25,7 +25,7 @@ final class AssistantSseTiming
|
||||
'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',
|
||||
'UPSTREAM_BUSY', 'INVALID_RESPONSE', 'AI_ASSISTANT_CANCELLED',
|
||||
];
|
||||
|
||||
private string $traceId;
|
||||
@@ -72,7 +72,7 @@ final class AssistantSseTiming
|
||||
$event[$field] = $metrics[$field];
|
||||
}
|
||||
}
|
||||
if (isset($metrics['status']) && in_array($metrics['status'], ['ok', 'error', 'exception', 'disconnected'], true)) {
|
||||
if (isset($metrics['status']) && in_array($metrics['status'], ['ok', 'error', 'exception', 'disconnected', 'cancelled'], true)) {
|
||||
$event['status'] = $metrics['status'];
|
||||
}
|
||||
if (isset($metrics['error_code']) && in_array($metrics['error_code'], self::ERROR_CODES, true)) {
|
||||
|
||||
@@ -206,6 +206,24 @@ class DiagnosisValidate extends BaseValidate
|
||||
return $this->only(['id', 'task', 'prompt']);
|
||||
}
|
||||
|
||||
public function sceneAiAssistantStream()
|
||||
{
|
||||
return $this->only(['id', 'task', 'prompt', 'request_id'])
|
||||
->append('request_id', 'checkAssistantRequestId');
|
||||
}
|
||||
|
||||
public function sceneCancelAiAssistant()
|
||||
{
|
||||
return $this->only(['id', 'request_id'])
|
||||
->append('request_id', 'require|checkAssistantRequestId');
|
||||
}
|
||||
|
||||
protected function checkAssistantRequestId($value)
|
||||
{
|
||||
return is_string($value) && \app\adminapi\service\AssistantRequestControl::validRequestId($value)
|
||||
? true : 'AI请求标识无效';
|
||||
}
|
||||
|
||||
public function sceneAiAnalysis()
|
||||
{
|
||||
return $this->only(['id', 'model'])
|
||||
|
||||
Reference in New Issue
Block a user