feat(followup-audio): review full dialogue and bind roles before extraction
This commit is contained in:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user