feat: configure audio providers and add local sample acceptance
This commit is contained in:
@@ -14,13 +14,30 @@ final class FollowupAudioStore
|
||||
public static function enabled(): bool { return (bool) config('followup_audio.enabled', false); }
|
||||
public static function verified(?string $profile = null): bool
|
||||
{
|
||||
$profiles = self::verifiedProfiles();
|
||||
return (bool) config('followup_audio.audio_verified', false) && ($profile === null ? $profiles !== [] : in_array($profile, $profiles, true));
|
||||
return $profile === null ? self::verifiedProfiles() !== [] : FollowupAudioProviderConfig::isVerified($profile);
|
||||
}
|
||||
|
||||
private static function verifiedProfiles(): array
|
||||
{
|
||||
return array_values(array_intersect(['qwen', 'openai'], (array) config('followup_audio.verified_profiles', [])));
|
||||
return array_values(array_filter(['qwen', 'openai'], [FollowupAudioProviderConfig::class, 'isVerified']));
|
||||
}
|
||||
|
||||
/** A task binds to the exact provider/endpoint/model/key identity approved when it was created. */
|
||||
public static function providerMatches(array $task): bool
|
||||
{
|
||||
$ids = json_decode((string) ($task['upstream_ids_json'] ?? '{}'), true);
|
||||
$fingerprint = is_array($ids) ? ($ids['provider_fingerprint'] ?? '') : '';
|
||||
if (!is_string($fingerprint) || !preg_match('/^[a-f0-9]{64}$/D', $fingerprint)) { return false; }
|
||||
$status = FollowupAudioProviderConfig::status((string) ($task['model_key'] ?? ''));
|
||||
return !empty($status['configured']) && is_string($status['application_fingerprint'] ?? null)
|
||||
&& hash_equals($fingerprint, $status['application_fingerprint']);
|
||||
}
|
||||
|
||||
public static function assertTaskProvider(array $task): void
|
||||
{
|
||||
if (!self::providerMatches($task)) {
|
||||
throw new FollowupAudioException('PROVIDER_CONFIGURATION_CHANGED', (int) ($task['upstream_started_at'] ?? 0) > 0);
|
||||
}
|
||||
}
|
||||
|
||||
public static function assertEnabled(?string $profile = null): void
|
||||
@@ -69,7 +86,9 @@ final class FollowupAudioStore
|
||||
'duration_seconds' => $stored['duration_seconds'], 'recorded_at' => $recordedAt, 'model_key' => $modelKey,
|
||||
'status' => 'queued', 'stage' => 'queued', 'version' => 1, 'attempts' => 0,
|
||||
'lease_token' => '', 'lease_until' => 0, 'upstream_started_at' => 0,
|
||||
'upstream_run_id' => '', 'upstream_file_id' => '', 'upstream_ids_json' => '{}',
|
||||
'upstream_run_id' => '', 'upstream_file_id' => '', 'upstream_ids_json' => FollowupAudioPolicy::canonical([
|
||||
'provider_fingerprint' => FollowupAudioProviderConfig::resolve($modelKey)['fingerprint'],
|
||||
]),
|
||||
'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,
|
||||
]);
|
||||
@@ -91,6 +110,9 @@ final class FollowupAudioStore
|
||||
private static function reuse(array $task, string $recordedAt): array
|
||||
{
|
||||
$message = '同一诊单、录音内容及模型已有任务,已复用原任务,不会再次调用模型。';
|
||||
if (!self::providerMatches($task)) {
|
||||
$message .= '原任务的服务配置已变更或缺少配置快照,不能自动改用新服务重跑。';
|
||||
}
|
||||
if ($task['recorded_at'] !== $recordedAt) {
|
||||
$message .= '沿用原任务的录音时间,请在未采用的审阅项中更正记录日期;已采用任务不会重跑。';
|
||||
}
|
||||
@@ -127,7 +149,7 @@ final class FollowupAudioStore
|
||||
$result['error_message'] = '录音及审阅已到保留期限,不能采用';
|
||||
}
|
||||
$result['can_retry'] = self::enabled() && self::verified((string) $task['model_key']) && $alive && $task['status'] === 'failed'
|
||||
&& (int) $task['upstream_started_at'] === 0 && (int) $task['attempts'] < 3;
|
||||
&& self::providerMatches($task) && (int) $task['upstream_started_at'] === 0 && (int) $task['attempts'] < 3;
|
||||
$result['audio_available'] = $alive && (string) Db::name('followup_audio_upload')->where('id', $task['upload_id'])->value('status') === 'complete';
|
||||
return $result;
|
||||
}
|
||||
@@ -192,6 +214,7 @@ final class FollowupAudioStore
|
||||
Db::transaction(static function () use ($taskId): void {
|
||||
$task = self::lockTask($taskId);
|
||||
self::assertEnabled((string) $task['model_key']);
|
||||
self::assertTaskProvider($task);
|
||||
if ($task['status'] !== 'failed' || (int) $task['upstream_started_at'] !== 0 || (int) $task['attempts'] >= 3
|
||||
|| (int) $task['expires_at'] <= time() || (int) $task['purged_at']) {
|
||||
throw new DomainException('FOLLOWUP_AUDIO_RETRY_NOT_SAFE');
|
||||
@@ -207,7 +230,7 @@ final class FollowupAudioStore
|
||||
/** A singleton DB mutex enforces concurrency across independent CLI processes. */
|
||||
public static function claim(): ?array
|
||||
{
|
||||
if (!self::enabled() || !self::verified()) { return null; }
|
||||
if (!self::enabled()) { return null; }
|
||||
return Db::transaction(static function (): ?array {
|
||||
if (!Db::name('followup_audio_mutex')->where('id', 1)->lock(true)->find()) {
|
||||
throw new DomainException('FOLLOWUP_AUDIO_MIGRATION_REQUIRED');
|
||||
@@ -227,9 +250,22 @@ final class FollowupAudioStore
|
||||
$active = Db::name('followup_audio_task')->where('status', 'running')->where('lease_until', '>', $now)
|
||||
->field('id')->lock(true)->select()->toArray();
|
||||
if (count($active) >= max(1, min(8, (int) config('followup_audio.concurrency', 1)))) { return null; }
|
||||
$task = Db::name('followup_audio_task')->where('status', 'queued')->where('upstream_started_at', 0)->whereIn('model_key', self::verifiedProfiles())
|
||||
->where('expires_at', '>', $now)->where('purged_at', 0)->order('id', 'asc')->lock(true)->find();
|
||||
if (!$task) { return null; }
|
||||
$pending = Db::name('followup_audio_task')->where('status', 'queued')->where('upstream_started_at', 0)
|
||||
->where('expires_at', '>', $now)->where('purged_at', 0)->order('id', 'asc')->limit(200)->lock(true)->select()->toArray();
|
||||
$task = null;
|
||||
foreach ($pending as $candidate) {
|
||||
if (!self::providerMatches($candidate)) {
|
||||
// Old rows without a binding and changed configurations are visible failures, never silently re-routed.
|
||||
Db::name('followup_audio_task')->where('id', $candidate['id'])->update([
|
||||
'status' => 'failed', 'stage' => 'failed', 'error_code' => 'PROVIDER_CONFIGURATION_CHANGED',
|
||||
'error_message' => '任务服务配置已变更或缺少配置快照,已停止处理;恢复原配置并核对后再操作',
|
||||
'updated_at' => $now, 'version' => (int) $candidate['version'] + 1,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
if (self::verified((string) $candidate['model_key'])) { $task = $candidate; break; }
|
||||
}
|
||||
if ($task === null) { return null; }
|
||||
$changes = ['status' => 'running', 'stage' => 'preparing', 'lease_token' => bin2hex(random_bytes(32)),
|
||||
'lease_until' => $now + self::leaseSeconds(), 'attempts' => (int) $task['attempts'] + 1,
|
||||
'updated_at' => $now, 'version' => (int) $task['version'] + 1];
|
||||
@@ -269,6 +305,13 @@ final class FollowupAudioStore
|
||||
if (!is_array($ids)) { throw new DomainException('FOLLOWUP_AUDIO_CHECKPOINT_INVALID'); }
|
||||
$previous = json_decode($task['upstream_ids_json'] ?: '{}', true, 16, JSON_THROW_ON_ERROR);
|
||||
foreach ($ids as $name => $identifier) {
|
||||
if ($name === 'provider_fingerprint') {
|
||||
if (!is_string($identifier) || !is_string($previous[$name] ?? null)
|
||||
|| !hash_equals($previous[$name], $identifier)) {
|
||||
throw new DomainException('FOLLOWUP_AUDIO_CHECKPOINT_INVALID');
|
||||
}
|
||||
continue; // The immutable task snapshot is retained, never replaced by a callback.
|
||||
}
|
||||
if (!in_array($name, ['request_id', 'task_id', 'message_id', 'conversation_id', 'upstream_request_id'], true)) {
|
||||
throw new DomainException('FOLLOWUP_AUDIO_CHECKPOINT_INVALID');
|
||||
}
|
||||
@@ -313,13 +356,17 @@ final class FollowupAudioStore
|
||||
{
|
||||
return Db::transaction(static function () use ($id, $token, $code, $uncertain): bool {
|
||||
$task = self::lockTask($id);
|
||||
if (!self::hasLease($task, $token)) { return false; }
|
||||
if (!self::hasLease($task, $token, false)) { return false; }
|
||||
// An arbitrary exception/message can contain patient text or credentials: never persist it.
|
||||
$code = preg_match('/^[A-Z][A-Z0-9_]{2,95}$/D', $code) ? $code : 'FOLLOWUP_AUDIO_PROCESS_FAILED';
|
||||
$uncertain = $uncertain || (int) $task['upstream_started_at'] > 0;
|
||||
$status = $uncertain ? 'needs_reconciliation' : 'failed';
|
||||
Db::name('followup_audio_task')->where('id', $id)->update([
|
||||
'status' => $status, 'stage' => $status, 'error_code' => $code,
|
||||
'error_message' => $uncertain ? '上游结果待核对,禁止重复提交' : '处理未完成,请查看错误代码;不会自动重复调用',
|
||||
'error_message' => $uncertain ? '上游结果待核对,禁止重复提交'
|
||||
: ($code === 'PROVIDER_CONFIGURATION_CHANGED'
|
||||
? '任务服务配置已变更或缺少配置快照,已停止处理;恢复原配置并核对后再操作'
|
||||
: '处理未完成,请查看错误代码;不会自动重复调用'),
|
||||
'lease_token' => '', 'lease_until' => 0, 'updated_at' => time(), 'version' => (int) $task['version'] + 1,
|
||||
]);
|
||||
return true;
|
||||
@@ -422,9 +469,10 @@ final class FollowupAudioStore
|
||||
return new PrescriptionAiCipher($key === '' ? null : $key);
|
||||
}
|
||||
|
||||
private static function hasLease(array $task, string $token): bool
|
||||
private static function hasLease(array $task, string $token, bool $processing = true): bool
|
||||
{
|
||||
return self::enabled() && self::verified((string) $task['model_key']) && $task['status'] === 'running' && $token !== ''
|
||||
return (!$processing || self::enabled() && self::verified((string) $task['model_key']) && self::providerMatches($task))
|
||||
&& $task['status'] === 'running' && $token !== ''
|
||||
&& hash_equals((string) $task['lease_token'], $token) && (int) $task['lease_until'] > time()
|
||||
&& (int) $task['expires_at'] > time() && !(int) $task['purged_at'];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user