This commit is contained in:
gr
2026-10-23 09:27:45 +08:00
34 changed files with 2866 additions and 225 deletions
@@ -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'])