'准备资料', 'waiting_sources' => '等待问诊转写', 'queued' => '等待模型处理', 'text' => '整理文字资料', 'files' => '处理附件', 'reduce' => '汇总本轮证据', 'final' => '生成分析报告', 'validating' => '校验报告', 'comparing' => '对比处方', 'completed' => '处理完成', 'retry_wait' => '等待重试', 'failed' => '处理失败', 'cancelled' => '已取消', 'unknown' => '处理中', ]; public static function sanitize($value): array { // A corrupt/legacy field cannot make list polling parse an unbounded document. if (is_string($value)) { $value = strlen($value) <= 2048 ? json_decode($value, true) : []; } $value = is_array($value) ? $value : []; $stage = is_string($value['stage'] ?? null) && isset(self::LABELS[$value['stage']]) ? $value['stage'] : 'unknown'; $phase = in_array($value['phase'] ?? null, ['waiting', 'running', 'completed', 'failed'], true) ? $value['phase'] : 'running'; $grouped = in_array($stage, ['text', 'files', 'reduce'], true); $total = $grouped ? self::number($value['total_units'] ?? null, 1000000) : null; $completed = $grouped ? self::number($value['completed_units'] ?? null, 1000000) : null; if ($completed !== null && $total !== null) { $completed = min($completed, $total); } return ['stage' => $stage, 'phase' => $phase, 'completed_units' => $completed, 'total_units' => $total, 'stage_started_at' => self::number($value['stage_started_at'] ?? null) ?? 0, 'updated_at' => self::number($value['updated_at'] ?? null) ?? 0]; } public static function advance(array $previous, string $stage, string $phase = 'running', ?int $completed = null, ?int $total = null, ?int $now = null, bool $restart = false): array { $now = $now ?? time(); $previous = self::sanitize($previous); return self::sanitize(['stage' => $stage, 'phase' => $phase, 'completed_units' => $completed, 'total_units' => $total, 'stage_started_at' => !$restart && $previous['stage'] === $stage && $previous['stage_started_at'] > 0 ? $previous['stage_started_at'] : $now, 'updated_at' => $now]); } public static function task(array $task, ?int $now = null): array { $now = $now ?? time(); $meta = self::sanitize($task['progress_json'] ?? null); $lastStage = $meta['stage']; $status = $task['status'] ?? ''; $terminal = in_array($status, ['success', 'failed', 'cancelled'], true); $updated = self::number($task['updated_at'] ?? null) ?? 0; $overrides = ['success' => ['completed', 'completed'], 'failed' => ['failed', 'failed'], 'cancelled' => ['cancelled', 'failed'], 'retry_wait' => ['retry_wait', 'waiting'], 'queued' => ['queued', 'waiting']]; if (isset($overrides[$status])) { [$stage, $phase] = $overrides[$status]; // The task transaction is authoritative, including old cached "completed" checkpoints. $at = $terminal ? (self::number($task['finished_at'] ?? null) ?: $updated) : $updated; $meta = self::advance([], $stage, $phase, null, null, $at); } elseif ($status === 'running' && !in_array($meta['stage'], ['preparing', 'text', 'files', 'reduce', 'final', 'validating', 'comparing'], true)) { $meta = self::advance([], 'unknown', 'running', null, null, 0); } if ($status === 'running' && in_array($meta['phase'], ['completed', 'failed'], true)) { $meta['phase'] = 'running'; } $end = $terminal || $status === 'retry_wait' ? (self::number($task['finished_at'] ?? null) ?: $updated) : $now; $result = self::present($meta, $now, self::number($task['started_at'] ?? null), $end); $result['attempt'] = self::number($task['total_attempts'] ?? $task['attempts'] ?? null, 1000000) ?? 0; if ($status === 'retry_wait') { $result['wait_remaining_seconds'] = self::remaining($task['next_run_at'] ?? null, $now); $result['notice'] = ($task['error_code'] ?? '') === 'BUDGET_PAUSED' ? '今日模型任务额度已用完,到时间后自动继续。' : '本次未完成,已安排自动重试。'; } if (in_array($status, ['failed', 'retry_wait'], true) && in_array($lastStage, ['text', 'files', 'reduce', 'final', 'validating', 'comparing'], true)) { $result['notice'] .= ' 上次进度:' . self::LABELS[$lastStage] . '。'; } if ($status === 'running' && $meta['updated_at'] > 0 && $now - $meta['updated_at'] >= 90) { $result['notice'] .= ' 暂无新的进度更新。'; } if ($result['elapsed_seconds'] !== null) { $result['notice'] .= ' 耗时按本次尝试计算。'; } return $result; } public static function batch(array $batch, ?int $now = null, array $models = []): array { $now = $now ?? time(); [$stage, $phase] = match ($batch['status'] ?? '') { 'preparing' => ['preparing', 'running'], 'waiting_sources' => ['waiting_sources', 'waiting'], 'queued' => ['queued', 'waiting'], 'retry_wait' => ['retry_wait', 'waiting'], 'success', 'partial' => ['completed', 'completed'], 'blocked', 'failed' => ['failed', 'failed'], 'cancelled' => ['cancelled', 'failed'], default => ['unknown', 'running'], }; $updated = self::number($batch['updated_at'] ?? null) ?? 0; $meta = self::advance([], $stage, $phase, null, null, $updated); $meta['stage_started_at'] = 0; // Batch updated_at includes source polling, not a measured stage start. $end = in_array($phase, ['completed', 'failed'], true) ? $updated : $now; if (in_array($phase, ['completed', 'failed'], true) && $models !== []) { // Validity/source refreshes may touch the historical batch later than its result. $finished = []; foreach ($models as $model) { $progress = $model['progress'] ?? []; if (in_array($progress['phase'] ?? '', ['completed', 'failed'], true) && ($at = self::number($progress['updated_at'] ?? null)) && $at > 0) { $finished[] = $at; } } if (count($finished) === count($models)) { $end = max($finished); } } $result = self::present($meta, $now, self::number($batch['created_at'] ?? null), $end); if ($stage === 'waiting_sources') { $result['wait_remaining_seconds'] = self::remaining($batch['wait_until'] ?? null, $now); $result['notice'] = $result['wait_remaining_seconds'] === 0 ? '转写等待期限已到,待资料准备程序继续,将使用已归档资料分析。' : '等待问诊转写归档;等待到期后会自动使用已归档资料继续分析。'; } elseif ($stage === 'retry_wait') { $result['wait_remaining_seconds'] = self::remaining($batch['next_run_at'] ?? null, $now); $result['notice'] = '资料准备暂未完成,已安排自动重试。'; } elseif ($stage === 'unknown') { $result['notice'] = '模型正在分别处理,具体进度见各模型。'; } elseif (($batch['status'] ?? '') === 'partial') { $result['notice'] = '部分模型已完成,请查看各模型结果。'; } return $result; } private static function present(array $meta, int $now, ?int $started, int $end): array { $stage = $meta['stage']; $notice = match ($stage) { 'text' => '组数表示已校验的文字资料分组。', 'files' => '组数包含已处理及已明确无法读取的附件组;不代表附件全部读懂。', 'reduce' => '组数仅表示本轮证据汇总,后续轮数取决于资料长度。', 'final' => '正在生成报告,完成后还需校验和处方对比。', 'validating' => '正在校验报告,结果尚未保存。', 'comparing' => '正在对比处方并保存结果。', 'completed' => '结果已保存,可查看报告。', 'failed' => '处理未完成,请查看失败原因。', 'cancelled' => '任务已取消。', 'queued' => '等待模型处理程序接手。', 'preparing' => '正在整理本次分析所需资料。', default => '暂无分段进度记录,等待后续更新。', }; if ($meta['phase'] === 'waiting' && in_array($stage, ['text', 'files', 'reduce', 'final'], true)) { $notice = '等待模型返回。' . $notice; } return ['stage' => $stage, 'stage_label' => self::LABELS[$stage], 'phase' => $meta['phase'], 'completed_units' => $meta['completed_units'], 'total_units' => $meta['total_units'], 'unit_label' => in_array($stage, ['text', 'files', 'reduce'], true) ? '组' : '', 'elapsed_seconds' => $started !== null && $started > 0 ? max(0, min($end, $now) - $started) : null, 'stage_elapsed_seconds' => $meta['stage_started_at'] > 0 ? max(0, min($end, $now) - $meta['stage_started_at']) : null, 'wait_remaining_seconds' => null, 'updated_at' => min($now, $meta['updated_at']), 'server_time' => $now, 'notice' => $notice]; } private static function remaining($deadline, int $now): ?int { $deadline = self::number($deadline); return $deadline !== null && $deadline > 0 ? max(0, $deadline - $now) : null; } private static function number($value, int $maximum = 4294967295): ?int { if (!is_int($value) && !(is_string($value) && preg_match('/^[0-9]{1,10}$/D', $value))) { return null; } return (int) $value >= 0 && (int) $value <= $maximum ? (int) $value : null; } }