'blocked', 'error_code' => 'ACCESS_REVOKED']); return true; } if (!PrescriptionAiPolicy::isManual($rx) || !hash_equals($batch['clinical_hash'], PrescriptionAiPolicy::fingerprint($rx))) { PrescriptionAiStore::invalidate((int) $batch['prescription_id'], 'prescription_changed'); return true; } $frozenRx = (new PrescriptionAiCipher())->decrypt($batch['prescription_cipher'], 'prescription'); $context = Db::transaction(static fn (): array => PrescriptionAiContext::build( $frozenRx, (int) $batch['actor_id'], $actor, (int) $batch['decision_at'])); $context['actor_scope_hash'] = self::scopeHash((int) $batch['actor_id'], $actor); // Freeze one complete identity dictionary for both branches, outside model evidence. $context['_comparison_catalog'] = Medicine::where('status', 1)->whereNull('delete_time') ->field(['id', 'name', 'unit'])->order('id')->select()->toArray(); $context['dictionary_version'] = hash('sha256', PrescriptionAiPolicy::canonical($context['_comparison_catalog'])); if (strlen(PrescriptionAiPolicy::canonical($context)) > (int) config('prescription_analysis.max_context_bytes', 8000000)) { PrescriptionAiStore::releasePreparation($batch, ['status' => 'blocked', 'error_code' => 'CONTEXT_TOO_LARGE']); return true; } if (!empty($context['wait_for_transcript']) && time() >= (int) $batch['wait_until']) { $context['missing'][] = ['code' => 'TRANSCRIPT_NOT_FINAL', 'message' => '本次问诊转写尚未完整归档']; $context['baseline_eligible'] = false; $context['baseline_exclusion_reasons'][] = 'transcript_not_final'; } PrescriptionAiStore::finishPreparation($batch, $context); } catch (\Throwable $e) { $attempt = (int) $batch['prepare_attempts'] + 1; PrescriptionAiStore::releasePreparation($batch, [ 'status' => $attempt >= 3 ? 'blocked' : 'retry_wait', 'prepare_attempts' => $attempt, 'error_code' => 'SOURCE_PREPARATION_FAILED', 'next_run_at' => time() + 60, ]); } return true; } public function runOne(string $model): bool { if (!PrescriptionAiStore::enabled() || !($task = PrescriptionAiStore::claimTask($model))) { return false; } try { $batch = Db::name('prescription_ai_batch')->where('id', $task['batch_id'])->find(); $cipher = new PrescriptionAiCipher(); $context = $cipher->decrypt((string) $batch['context_cipher'], 'context'); if (!$this->authorizedAndCurrent($batch, $context)) { PrescriptionAiStore::fail($task, 'ACCESS_REVOKED', false); return true; } if (!empty($task['progress_cipher'])) { $context['_progress'] = $cipher->decrypt($task['progress_cipher'], 'progress:' . $task['id']); } $public = []; $checkpoint = function (array $progress, bool $persistCache = true) use ($task, $batch, $context, &$public): bool { if (!PrescriptionAiStore::enabled() || !$this->authorizedAndCurrent($batch, $context) || !PrescriptionAiStore::checkpoint($task, $progress, $persistCache)) { return false; } $public = PrescriptionAiProgress::sanitize($progress['public'] ?? null); return true; }; $output = PrescriptionAiGenerator::generate($model, $context, $checkpoint); if (empty($output['ok'])) { PrescriptionAiStore::fail($task, (string) ($output['error_code'] ?? 'UPSTREAM_FAILED'), !empty($output['retryable'])); return true; } if (!$this->authorizedAndCurrent($batch, $context)) { PrescriptionAiStore::fail($task, 'SOURCE_CHANGED', false); return true; } $doctor = $cipher->decrypt($batch['prescription_cipher'], 'prescription'); $doctor['herbs'] = PrescriptionAiPolicy::decode($doctor['herbs'] ?? []); $doctor['aux_usage'] = PrescriptionAiPolicy::decode($doctor['aux_usage'] ?? []); $candidate = is_array($output['candidate'] ?? null) ? $output['candidate'] : []; $catalog = (array) ($context['_comparison_catalog'] ?? []); if (!$checkpoint(['public' => PrescriptionAiProgress::advance($public, 'comparing')], false)) { PrescriptionAiStore::fail($task, 'CHECKPOINT_REJECTED', false); return true; } $comparison = PrescriptionAiComparison::compare($doctor, $candidate, $catalog); $comparison['dictionary_version'] = (string) ($context['dictionary_version'] ?? ''); PrescriptionAiStore::complete($task, $output, $comparison); } catch (\Throwable $e) { // No PHI, URLs, SQL or upstream body in logs / error messages. PrescriptionAiStore::fail($task, 'INTERNAL_ERROR', true); } return true; } private function authorizedAndCurrent(array $batch, array $context): bool { $fresh = Db::name('prescription_ai_batch')->where('id', $batch['id'])->find(); $actor = PrescriptionAiAccess::actor((int) $batch['actor_id']); if (!$fresh || $fresh['validity'] !== 'current' || !$actor || !PrescriptionAiAccess::canGenerate((int) $batch['actor_id'], $actor) || !hash_equals((string) ($context['actor_scope_hash'] ?? ''), self::scopeHash((int) $batch['actor_id'], $actor)) || !PrescriptionAiAccess::sourceIds($context['source_diagnosis_ids'] ?? [], (int) $batch['actor_id'], $actor)) { return false; } if (!PrescriptionAiContext::assertSnapshotAccess($context, (int) $batch['actor_id'], $actor)) { return false; } $rx = PrescriptionAiAccess::prescription((int) $batch['prescription_id'], (int) $batch['actor_id'], $actor); return $rx && PrescriptionAiPolicy::isManual($rx) && hash_equals((string) $batch['clinical_hash'], PrescriptionAiPolicy::fingerprint($rx)); } public static function scopeHash(int $adminId, array $actor): string { $permissions = \app\adminapi\logic\auth\AuthLogic::getAuthByAdminId($adminId); sort($permissions); $roles = (array) ($actor['role_id'] ?? []); $departments = (array) ($actor['dept_id'] ?? []); sort($roles); sort($departments); return hash('sha256', PrescriptionAiPolicy::canonical([ 'admin_id' => $adminId, 'root' => $actor['root'] ?? 0, 'roles' => $roles, 'departments' => $departments, 'permissions' => $permissions, 'scope' => \app\common\service\DataScope\DataScopeService::getEffectiveScope($actor), ])); } /** Bounded reconciliation of missing save events, also catches old clients. */ public function reconcile(int $afterId = 0, int $limit = 50, ?int $from = null, ?int $to = null, bool $apply = true): array { $from = $from ?? (int) config('prescription_analysis.start_at', 0); if ($from <= 0) { return ['selected' => 0, 'enqueued' => 0, 'last_id' => $afterId, 'reason' => 'start_at_required']; } $query = Db::name('tcm_prescription')->where('id', '>', $afterId)->where('is_system_auto', 0) ->where('void_status', 0)->whereNull('delete_time')->where('update_time', '>=', $from); if ($to !== null) { $query->where('update_time', '<=', $to); } $rows = $query->order('id')->limit(max(1, min(200, $limit)))->select()->toArray(); $enqueued = 0; foreach ($rows as $rx) { $afterId = (int) $rx['id']; if (!PrescriptionAiPolicy::isManual($rx)) { continue; } $actor = PrescriptionAiAccess::actor((int) $rx['creator_id']); if (!$actor || !PrescriptionAiAccess::canGenerate((int) $rx['creator_id'], $actor) || !PrescriptionAiAccess::prescription($afterId, (int) $rx['creator_id'], $actor)) { continue; } $subject = Db::name('prescription_ai_subject')->where('prescription_id', $afterId)->find(); if ($subject && hash_equals($subject['clinical_hash'], PrescriptionAiPolicy::fingerprint($rx))) { continue; } if ($apply) { Db::transaction(static function () use ($rx, $actor): void { $fresh = Db::name('tcm_prescription')->where('id', $rx['id'])->lock(true)->find(); if ($fresh) { PrescriptionAiStore::recordSaved($fresh, (int) $rx['creator_id'], $actor, ['trigger' => 'reconciled']); } }); } $enqueued++; } return ['selected' => count($rows), 'enqueued' => $enqueued, 'last_id' => $afterId]; } /** Detects late notes, reports, daily records and transcripts without client-side generation. */ public function refreshSources(int $afterId = 0, int $limit = 10): array { if (!config('prescription_analysis.auto_refresh_sources', true)) { return ['selected' => 0, 'last_id' => $afterId, 'enqueued' => 0]; } $cutoff = time() - max(1, (int) config('prescription_analysis.refresh_recent_days', 7)) * 86400; $rows = Db::name('prescription_ai_batch')->where('id', '>', $afterId)->where('validity', 'current') ->whereIn('status', ['success', 'partial', 'failed'])->where('created_at', '>=', $cutoff) ->order('id')->limit(max(1, min(30, $limit)))->select()->toArray(); $enqueued = 0; foreach ($rows as $batch) { $afterId = (int) $batch['id']; try { $actor = PrescriptionAiAccess::actor((int) $batch['actor_id']); $rx = $actor ? PrescriptionAiAccess::prescription((int) $batch['prescription_id'], (int) $batch['actor_id'], $actor) : null; if (!$actor || !$rx || !PrescriptionAiAccess::canGenerate((int) $batch['actor_id'], $actor) || !PrescriptionAiPolicy::isManual($rx)) { continue; } $context = PrescriptionAiContext::build($rx, (int) $batch['actor_id'], $actor, (int) $batch['decision_at']); if (($context['source_hash'] ?? '') === $batch['source_hash']) { continue; } $todayCount = Db::name('prescription_ai_batch')->where('patient_id', $batch['patient_id']) ->where('created_at', '>=', strtotime('today'))->count(); if ($todayCount >= (int) config('prescription_analysis.daily_patient_batches', 10)) { continue; } PrescriptionAiStore::enqueue($rx, (int) $batch['actor_id'], 'source_update', hash('sha256', 'source:' . $rx['id'] . ':' . $batch['prescription_revision'] . ':' . $context['source_hash']), ['reason' => '患者资料或问诊转写已更新']); $enqueued++; } catch (\Throwable $e) { // Existing report remains available; a later bounded sweep retries the source read. } } return ['selected' => count($rows), 'last_id' => $afterId, 'enqueued' => $enqueued]; } }