feat: preserve stereo channels and require reviewed speaker roles

This commit is contained in:
2026-10-08 16:15:41 +08:00
parent 13948f5982
commit a20d3565af
28 changed files with 1134 additions and 113 deletions
@@ -11,6 +11,7 @@ use think\facade\Db;
/** Database queue. No plaintext transcript, credentials or review data in task metadata/logs. */
final class FollowupAudioStore
{
public const CHANNEL_ROLES = ['unconfirmed', 'left_service', 'right_service'];
public static function enabled(): bool { return (bool) config('followup_audio.enabled', false); }
public static function verified(?string $profile = null): bool
{
@@ -161,7 +162,8 @@ final class FollowupAudioStore
public static function detail(int $taskId): array
{
$task = self::task($taskId);
$data = self::summary($task) + ['summary' => '', 'transcript' => '', 'uncertainties' => [], 'items' => [], 'applied_items' => []];
$data = self::summary($task) + ['summary' => '', 'transcript' => '', 'uncertainties' => [], 'items' => [], 'applied_items' => [],
'transcript_segments' => [], 'channel_roles' => 'unconfirmed'];
// Expiry is enforced at read/apply time, not only when a scheduled cleanup eventually runs.
if ((int) $task['expires_at'] > time() && !(int) $task['purged_at'] && $task['extraction_cipher'] !== '') {
$extraction = self::open($taskId, 'extraction', $task['extraction_cipher']);
@@ -170,27 +172,35 @@ final class FollowupAudioStore
$data['transcript'] = $extraction['transcript'];
$data['uncertainties'] = $extraction['uncertainties'];
$data['items'] = $review['items'];
$data['transcript_segments'] = $extraction['transcript_segments'] ?? [];
$data['channel_roles'] = self::channelRoles($extraction, $review);
}
if ((int) $task['expires_at'] > time() && !(int) $task['purged_at'] && $task['extraction_cipher'] === '') {
$pipeline = self::pipelineState($task);
if ($pipeline) { $data['transcript'] = implode("\n", array_column(FollowupAudioPipelineCheckpoint::segments($pipeline), 'text')); }
if ($pipeline) {
$data['transcript_segments'] = FollowupAudioPipelineCheckpoint::segments($pipeline);
$data['transcript'] = implode("\n", array_column($data['transcript_segments'], 'text'));
}
}
if ($task['applied_cipher'] !== '') {
$data['applied_items'] = self::open($taskId, 'applied', $task['applied_cipher'])['items'];
$applied = self::open($taskId, 'applied', $task['applied_cipher']);
$data['applied_items'] = $applied['items'];
if (isset($applied['channel_roles']) && in_array($applied['channel_roles'], self::CHANNEL_ROLES, true)) { $data['channel_roles'] = $applied['channel_roles']; }
}
return $data;
}
public static function saveDraft(int $taskId, int $version, array $items, int $actor): array
public static function saveDraft(int $taskId, int $version, array $items, int $actor, ?string $channelRoles = null): array
{
self::assertEnabled();
$stale = Db::transaction(static function () use ($taskId, $version, $items, $actor): bool {
$stale = Db::transaction(static function () use ($taskId, $version, $items, $actor, $channelRoles): bool {
$task = self::lockTask($taskId);
self::assertEnabled((string) $task['model_key']);
FollowupAudioAccess::task($taskId, $actor, []);
self::assertReview($task, $version);
$review = self::open($taskId, 'review', $task['review_cipher']);
$extraction = self::open($taskId, 'extraction', $task['extraction_cipher']);
if ($channelRoles !== null) { self::assertChannelRoles($extraction, $channelRoles); }
$merged = FollowupAudioApply::mergeItems($review['items'], $items, $extraction['items']);
$diagnosis = FollowupAudioApply::diagnosis((int) $task['diagnosis_id'], true);
FollowupAudioApply::assertPatient($task, $diagnosis);
@@ -208,7 +218,7 @@ final class FollowupAudioStore
foreach ($merged as &$item) { $item['selected'] = false; $item['needs_review'] = true; }
unset($item);
}
self::writeReview($task, $merged);
self::writeReview($task, $merged, $channelRoles, $actor);
return $changed;
});
$detail = self::detail($taskId);
@@ -368,7 +378,7 @@ final class FollowupAudioStore
$items = FollowupAudioApply::refresh($task, $normalized['items'], $diagnosis, true);
Db::name('followup_audio_task')->where('id', $id)->update([
'extraction_cipher' => self::seal($id, 'extraction', $normalized),
'review_cipher' => self::seal($id, 'review', ['items' => $items]),
'review_cipher' => self::seal($id, 'review', ['items' => $items] + (self::hasStereo($normalized) ? ['channel_roles' => 'unconfirmed'] : [])),
'status' => 'review', 'stage' => 'review', 'lease_token' => '', 'lease_until' => 0,
'updated_at' => time(), 'version' => (int) $task['version'] + 1, 'error_code' => '', 'error_message' => '',
]);
@@ -473,10 +483,48 @@ final class FollowupAudioStore
if ((int) $task['version'] !== $version) { throw new DomainException('FOLLOWUP_AUDIO_VERSION_CONFLICT'); }
}
public static function writeReview(array $task, array $items): void
public static function hasStereo(array $source): bool
{
Db::name('followup_audio_task')->where('id', $task['id'])->update([
'review_cipher' => self::seal((int) $task['id'], 'review', ['items' => $items]),
$channels = [];
foreach ($source['transcript_segments'] ?? [] as $segment) {
if (array_key_exists('channel', $segment) && in_array($segment['channel'], [0, 1], true)) { $channels[$segment['channel']] = true; }
}
return isset($channels[0], $channels[1]);
}
public static function channelRoles(array $source, array $review): string
{
$roles = $review['channel_roles'] ?? 'unconfirmed';
if (!is_string($roles) || !in_array($roles, self::CHANNEL_ROLES, true)
|| ($roles !== 'unconfirmed' && !self::hasStereo($source))) { throw new DomainException('FOLLOWUP_AUDIO_CHANNEL_ROLES_INVALID'); }
return $roles;
}
private static function assertChannelRoles(array $source, string $roles): void
{
if (!in_array($roles, self::CHANNEL_ROLES, true) || !self::hasStereo($source)) {
throw new DomainException('FOLLOWUP_AUDIO_CHANNEL_ROLES_INVALID');
}
}
public static function writeReview(array $task, array $items, ?string $channelRoles = null, ?int $actor = null): void
{
$id = (int) $task['id'];
$review = self::open($id, 'review', $task['review_cipher']);
if ($channelRoles !== null) {
$source = self::open($id, 'extraction', $task['extraction_cipher']);
self::assertChannelRoles($source, $channelRoles);
if ($channelRoles !== self::channelRoles($source, $review)) {
if ($actor === null || $actor < 1) { throw new DomainException('FOLLOWUP_AUDIO_CHANNEL_ROLES_INVALID'); }
foreach ($items as &$item) { $item['selected'] = false; $item['needs_review'] = true; } unset($item);
$review['channel_roles'] = $channelRoles;
$review['channel_roles_annotation'] = ['value' => $channelRoles, 'actor_id' => $actor,
'updated_at' => time(), 'review_version' => (int) $task['version'] + 1];
}
}
$review['items'] = $items; // All role metadata survives stale-record refreshes and ordinary edits.
Db::name('followup_audio_task')->where('id', $id)->update([
'review_cipher' => self::seal($id, 'review', $review),
'version' => (int) $task['version'] + 1, 'updated_at' => time(),
]);
}