173 lines
10 KiB
PHP
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;
|
|
}
|
|
}
|