feat(followup-audio): confirm role corrections and reextract cached transcripts
This commit is contained in:
@@ -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' ? '检测到工作人员自我介绍,仅作回听核对提示,不会自动修改声道角色。' : '未发现唯一明确的工作人员自我介绍,请人工核对声道角色。')];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user