feat(followup-audio): confirm role corrections and reextract cached transcripts

This commit is contained in:
2026-10-12 12:24:34 +08:00
parent 2dfec801e7
commit 9e3aa9fc61
15 changed files with 660 additions and 16 deletions
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace app\common\service\followupaudio;
use DomainException;
use think\facade\Db;
/** Explicit human-confirmed extraction revisions. ASR identity and creation metadata never change. */
final class FollowupAudioReextract
{
public static function binding(array $task): ?array
{
$revision = (int) ($task['extraction_revision'] ?? 0);
if ($revision === 0) {
if (!empty($task['extraction_role_override']) || !empty($task['extraction_source_version'])
|| !empty($task['extraction_confirmed_by']) || !empty($task['extraction_confirmed_at'])) {
throw new DomainException('FOLLOWUP_AUDIO_EXTRACTION_BINDING_INVALID');
}
return null;
}
$roles = FollowupAudioStore::validateChannelRoles($task['extraction_role_override'] ?? null);
if ($revision < 1 || $revision > 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' ? '检测到工作人员自我介绍,仅作回听核对提示,不会自动修改声道角色。' : '未发现唯一明确的工作人员自我介绍,请人工核对声道角色。')];
}
}