feat(followup-audio): review full dialogue and bind roles before extraction

This commit is contained in:
2026-10-10 14:25:03 +08:00
parent f015b26ae1
commit b4acad8510
14 changed files with 360 additions and 43 deletions
@@ -48,12 +48,13 @@ final class FollowupAudioStore
if (!self::enabled() || !self::ready($profile)) { throw new DomainException('FOLLOWUP_AUDIO_DISABLED_OR_UNVERIFIED'); }
}
public static function create(array $upload, string $recordedAt, string $modelKey, int $actor, array $info): array
public static function create(array $upload, string $recordedAt, string $modelKey, int $actor, array $info, string $channelRoles = 'unconfirmed'): array
{
self::validateChannelRoles($channelRoles);
self::assertEnabled($modelKey);
FollowupAudioPolicy::strictRecordedAt($recordedAt);
if (!in_array($modelKey, ['qwen', 'openai'], true)) { throw new DomainException('FOLLOWUP_AUDIO_MODEL_INVALID'); }
$created = Db::transaction(static function () use ($upload, $recordedAt, $modelKey, $actor, $info): array {
$created = Db::transaction(static function () use ($upload, $recordedAt, $modelKey, $actor, $info, $channelRoles): array {
$stored = Db::name('followup_audio_upload')->where('id', (string) ($upload['id'] ?? ''))->lock(true)->find();
if (!$stored || (int) $stored['actor_id'] !== $actor || $stored['status'] !== 'complete' || (int) $stored['expires_at'] <= time()) {
throw new DomainException('FOLLOWUP_AUDIO_UPLOAD_UNAVAILABLE');
@@ -72,7 +73,7 @@ final class FollowupAudioStore
// Diagnose+content+profile is immutable even if a retry changes upload ID, filename or recordedAt.
$existing = $existing ?: Db::name('followup_audio_task')->where('diagnosis_id', $diagnosisId)
->where('sha256', $stored['sha256'])->where('model_key', $modelKey)->lock(true)->find();
if ($existing) { return self::reuse($existing, $recordedAt); }
if ($existing) { return self::reuse($existing, $recordedAt, $channelRoles); }
$path = FollowupAudioUpload::path($stored);
if (!is_file($path) || (int) filesize($path) !== (int) $stored['total_bytes']
|| !hash_equals((string) $stored['sha256'], (string) hash_file('sha256', $path))) {
@@ -91,6 +92,7 @@ final class FollowupAudioStore
'lease_token' => '', 'lease_until' => 0, 'upstream_started_at' => 0,
'upstream_run_id' => '', 'upstream_file_id' => '', 'upstream_ids_json' => FollowupAudioPolicy::canonical([
'provider_fingerprint' => FollowupAudioProviderConfig::resolve($modelKey)['fingerprint'],
'channel_roles' => $channelRoles,
] + (FollowupAudioGate::previewOnly() ? ['preview_only' => true] : [])),
'error_code' => '', 'error_message' => '', 'extraction_cipher' => '', 'review_cipher' => '', 'applied_cipher' => '',
'created_at' => $now, 'updated_at' => $now, 'expires_at' => $expiresAt, 'applied_at' => 0, 'purged_at' => 0,
@@ -103,15 +105,18 @@ final class FollowupAudioStore
->where('sha256', $stored['sha256'])->where('model_key', $modelKey)->lock(true)->find();
if (!$winner) { throw $exception; }
Db::name('followup_audio_upload')->where('id', $stored['id'])->update(['expires_at' => $stored['expires_at']]);
return self::reuse($winner, $recordedAt);
return self::reuse($winner, $recordedAt, $channelRoles);
}
});
return ['task_id' => $created['id'], 'reused' => $created['reused'], 'reuse_message' => $created['reuse_message']]
+ self::summary(self::task($created['id']));
}
private static function reuse(array $task, string $recordedAt): array
private static function reuse(array $task, string $recordedAt, string $channelRoles): array
{
if (self::taskChannelRoles($task) !== $channelRoles) {
throw new DomainException('FOLLOWUP_AUDIO_CHANNEL_ROLES_CONFLICT:同一录音已有任务,但提炼时的声道角色不同;原结果保持不变,本次未重新提炼。请查看原任务,审阅角色修改不会重跑模型。');
}
$message = '同一诊单、录音内容及模型已有任务,已复用原任务,不会再次调用模型。';
if (!FollowupAudioGate::previewOnly() && FollowupAudioGate::taskPreview($task)) {
$message .= '这是历史测试预览任务,保持测试用途;本次未创建业务任务,原任务不提供确认入单。';
@@ -352,6 +357,10 @@ final class FollowupAudioStore
if (($previous[$name] ?? null) !== true || $identifier !== true) { throw new DomainException('FOLLOWUP_AUDIO_CHECKPOINT_INVALID'); }
continue;
}
if ($name === 'channel_roles') {
if ($identifier !== self::taskChannelRoles($task)) { throw new DomainException('FOLLOWUP_AUDIO_CHECKPOINT_INVALID'); }
continue;
}
if ($name === 'provider_fingerprint') {
if (!is_string($identifier) || !is_string($previous[$name] ?? null)
|| !hash_equals($previous[$name], $identifier)) {
@@ -389,9 +398,17 @@ final class FollowupAudioStore
$diagnosis = FollowupAudioApply::diagnosis((int) $task['diagnosis_id'], true);
FollowupAudioApply::assertPatient($task, $diagnosis);
$items = FollowupAudioApply::refresh($task, $normalized['items'], $diagnosis, true);
$review = ['items' => $items];
if (self::hasStereo($normalized)) {
$review['channel_roles'] = self::taskChannelRoles($task);
if ($review['channel_roles'] !== 'unconfirmed') {
$review['channel_roles_annotation'] = ['value' => $review['channel_roles'], 'actor_id' => (int) $task['actor_id'],
'updated_at' => (int) $task['created_at'], 'review_version' => (int) $task['version'] + 1, 'source' => 'task_create'];
}
}
Db::name('followup_audio_task')->where('id', $id)->update([
'extraction_cipher' => self::seal($id, 'extraction', $normalized),
'review_cipher' => self::seal($id, 'review', ['items' => $items] + (self::hasStereo($normalized) ? ['channel_roles' => 'unconfirmed'] : [])),
'review_cipher' => self::seal($id, 'review', $review),
'status' => 'review', 'stage' => 'review', 'lease_token' => '', 'lease_until' => 0,
'updated_at' => time(), 'version' => (int) $task['version'] + 1, 'error_code' => '', 'error_message' => '',
]);
@@ -505,6 +522,23 @@ final class FollowupAudioStore
return isset($channels[0], $channels[1]);
}
/** Exact API enum; JSON booleans, numbers, null and whitespace are not role choices. */
public static function validateChannelRoles($roles): string
{
if (!is_string($roles) || !in_array($roles, self::CHANNEL_ROLES, true)) {
throw new DomainException('FOLLOWUP_AUDIO_CHANNEL_ROLES_INVALID');
}
return $roles;
}
/** Immutable creation-time extraction binding. Legacy rows stay unconfirmed. */
public static function taskChannelRoles(array $task): string
{
$ids = json_decode((string) (($task['upstream_ids_json'] ?? '') ?: '{}'), true);
if (!is_array($ids)) { throw new DomainException('FOLLOWUP_AUDIO_CHANNEL_ROLES_INVALID'); }
return self::validateChannelRoles(array_key_exists('channel_roles', $ids) ? $ids['channel_roles'] : 'unconfirmed');
}
public static function channelRoles(array $source, array $review): string
{
$roles = $review['channel_roles'] ?? 'unconfirmed';