Files
2026-09-10 15:19:17 +08:00

173 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace app\common\service\prescriptionai;
/** Small public metadata only. Never project encrypted responses, source identifiers or free text. */
final class PrescriptionAiProgress
{
private const LABELS = [
'preparing' => '准备资料', '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;
}
}