100 || $roles === 'unconfirmed' || (int) ($task['extraction_source_version'] ?? 0) < 1 || (int) ($task['extraction_confirmed_by'] ?? 0) < 1 || (int) ($task['extraction_confirmed_at'] ?? 0) < 1) { throw new DomainException('FOLLOWUP_AUDIO_EXTRACTION_BINDING_INVALID'); } return ['revision' => $revision, 'source_version' => (int) $task['extraction_source_version'], 'channel_roles' => $roles, 'original_channel_roles' => FollowupAudioStore::taskChannelRoles($task), 'actor_id' => (int) $task['extraction_confirmed_by'], 'confirmed_at' => (int) $task['extraction_confirmed_at']]; } public static function roles(array $task): string { return self::binding($task)['channel_roles'] ?? FollowupAudioStore::taskChannelRoles($task); } public static function blockReason(array $task): string { if (!FollowupAudioStore::enabled() || !FollowupAudioStore::ready((string) $task['model_key'])) { return '回访录音提炼服务尚未就绪'; } if ((int) $task['expires_at'] <= time() || (int) $task['purged_at']) { return '录音已到保留期限'; } if ((int) ($task['active_slot'] ?? 0) !== 0 || $task['status'] !== 'review' || (int) $task['lease_until'] > time()) { return '仅已完成且未采用的审阅任务可重新提炼'; } if ((int) $task['applied_at'] > 0 || $task['applied_cipher'] !== '' || Db::name('followup_audio_audit')->where('task_id', $task['id'])->lock(true)->find()) { return '已有采用记录的任务须保留'; } if (!FollowupAudioStore::providerMatches($task)) { return '原提炼服务配置已变化'; } if ((int) ($task['extraction_revision'] ?? 0) >= 100) { return '已达到重新提炼次数上限'; } try { if (FollowupAudioProviderConfig::resolve((string) $task['model_key'])['driver'] !== 'asr_then_llm') { return '历史任务没有可复用的独立转写'; } $state = self::state($task); if (($state['schema_version'] ?? 0) !== 2 || ($state['source_channels'] ?? 0) !== 2 || ($state['extraction']['state'] ?? '') !== 'complete' || $state['revision'] >= 14995 || $task['extraction_cipher'] === '' || $task['review_cipher'] === '') { return '缺少已完成的双声道转写或提炼快照'; } foreach ($state['chunks'] as $chunk) { if (!in_array($chunk['state'], ['complete', 'local_silence'], true)) { return '转写尚未完成或上游结果待核对'; } } FollowupAudioStore::open((int) $task['id'], 'extraction', $task['extraction_cipher']); FollowupAudioStore::open((int) $task['id'], 'review', $task['review_cipher']); } catch (\Throwable $error) { return '历史快照校验未通过,须先核对'; } return ''; } private static function state(array $task): array { $state = FollowupAudioStore::open((int) $task['id'], 'pipeline', (string) ($task['pipeline_cipher'] ?? '')); FollowupAudioPipelineCheckpoint::validate($state, $task); return $state; } public static function queue(int $id, int $version, $roles, int $actor, array $info): array { $roles = FollowupAudioStore::validateChannelRoles($roles); if ($roles === 'unconfirmed') { throw new DomainException('FOLLOWUP_AUDIO_CHANNEL_ROLES_INVALID'); } $result = Db::transaction(static function () use ($id, $version, $roles, $actor, $info): array { $task = FollowupAudioStore::lockTask($id); FollowupAudioStore::assertEnabled((string) $task['model_key']); FollowupAudioAccess::task($id, $actor, $info); $fresh = FollowupAudioAccess::actor($actor); if ((int) $fresh['root'] !== 1 && (int) $task['actor_id'] !== $actor) { throw new DomainException('FOLLOWUP_AUDIO_REEXTRACT_ACTOR_DENIED'); } // Current authorization and scope also apply to replay; a replay never queues another call. $existing = Db::name('followup_audio_extraction_history')->where('task_id', $id)->where('source_version', $version)->lock(true)->find(); if ($existing) { if ($existing['channel_roles'] !== $roles) { throw new DomainException('FOLLOWUP_AUDIO_VERSION_CONFLICT'); } if ((int) $task['expires_at'] <= time() || (int) $task['purged_at']) { throw new DomainException('FOLLOWUP_AUDIO_EXPIRED'); } return ['reextract_reused' => true, 'reextract_requested_revision' => (int) $existing['target_revision']]; } FollowupAudioStore::assertReview($task, $version); $reason = self::blockReason($task); if ($reason !== '') { throw new DomainException('FOLLOWUP_AUDIO_REEXTRACT_BLOCKED:' . $reason); } if (self::roles($task) === $roles) { throw new DomainException('FOLLOWUP_AUDIO_REEXTRACT_ROLE_UNCHANGED'); } $state = self::state($task); $revision = (int) ($task['extraction_revision'] ?? 0) + 1; $changes = ['extraction_revision' => $revision, 'extraction_role_override' => $roles, 'extraction_source_version' => $version, 'extraction_confirmed_by' => $actor, 'extraction_confirmed_at' => time()]; // This is the sole reset path. Normal checkpoint transitions cannot reset complete results. $nextTask = array_replace($task, $changes); $next = $state; $next['revision']++; $next['extraction'] = ['state' => 'pending']; $next['extraction_channel_roles'] = $roles; $next['extraction_binding'] = self::binding($nextTask); FollowupAudioPipelineCheckpoint::validate($next, $nextTask); Db::name('followup_audio_extraction_history')->insert([ 'task_id' => $id, 'source_version' => $version, 'source_revision' => $revision - 1, 'target_revision' => $revision, 'channel_roles' => $roles, 'actor_id' => $actor, 'snapshot_cipher' => FollowupAudioStore::seal($id, 'history:' . $revision, $task), 'created_at' => time(), 'expires_at' => (int) $task['expires_at'], 'purged_at' => 0, ]); // Creation provider/roles/preview flag, source, retention and all ASR chunks are untouched. Db::name('followup_audio_task')->where('id', $id)->update($changes + [ 'pipeline_cipher' => FollowupAudioStore::seal($id, 'pipeline', $next), 'extraction_cipher' => '', 'review_cipher' => '', 'status' => 'queued', 'stage' => 'extracting', 'attempts' => 0, 'lease_token' => '', 'lease_until' => 0, 'error_code' => '', 'error_message' => '', 'version' => $version + 1, 'updated_at' => time(), ]); return ['reextract_reused' => false, 'reextract_requested_revision' => $revision]; }); return FollowupAudioStore::detail($id) + $result; } /** Advisory explicit self-introductions only. Neither dialogue nor a suggestion can alter role bindings. */ public static function roleCheck(array $segments, string $roles): array { $evidence = []; $channels = []; foreach ($segments as $segment) { $channel = $segment['channel'] ?? null; if (!in_array($channel, [0, 1], true)) { continue; } // No loose occupation keywords: quoted mentions/questions and third-person roles are not introductions. if (preg_match('/(?:^|[。!?!?;;,,\\n])\\s*(?:您好[,,]?|你好[,,]?)?\\s*我(?:这边)?是(?:您的|你的|负责[^,。!?]{0,8}的|[\p{Han}A-Za-z0-9]{1,30}(?:医院|医疗中心|健康管理中心)的)?(?:医疗助理|健康助理|健康顾问|随访客服|客服|随访医生|医生|护士)(?!的(?:患者|家属|朋友|孩子|母亲|父亲))/u', $segment['text'])) { $channels[$channel] = true; if (count($evidence) < 10) { $evidence[] = array_intersect_key($segment, array_flip(['channel', 'start_ms', 'end_ms', 'text'])); } } } $suggested = count($channels) === 1 ? (isset($channels[0]) ? 'left_service' : 'right_service') : 'unconfirmed'; $conflict = $suggested !== 'unconfirmed' && $roles !== 'unconfirmed' && $roles !== $suggested; return ['conflict' => $conflict, 'suggested_roles' => $suggested, 'evidence' => $evidence, 'message' => $conflict ? '自我介绍与提炼时的声道角色约定不一致。请回听证据并人工确认;仅修改审阅标签不会重新提炼。' : ($suggested !== 'unconfirmed' ? '检测到工作人员自我介绍,仅作回听核对提示,不会自动修改声道角色。' : '未发现唯一明确的工作人员自我介绍,请人工核对声道角色。')]; } }