140 lines
9.6 KiB
PHP
140 lines
9.6 KiB
PHP
<?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' ? '检测到工作人员自我介绍,仅作回听核对提示,不会自动修改声道角色。' : '未发现唯一明确的工作人员自我介绍,请人工核对声道角色。')];
|
||
}
|
||
}
|