Files
zyt/server/app/common/service/followupaudio/FollowupAudioReextract.php
T

140 lines
9.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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' ? '检测到工作人员自我介绍,仅作回听核对提示,不会自动修改声道角色。' : '未发现唯一明确的工作人员自我介绍,请人工核对声道角色。')];
}
}