feat: configure audio providers and add local sample acceptance

This commit is contained in:
2026-10-07 18:15:32 +08:00
parent 70be2fc70f
commit fea33285e8
21 changed files with 1103 additions and 131 deletions
+76
View File
@@ -9,6 +9,7 @@ require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\common\service\followupaudio\FollowupAudioApply as Apply;
use app\common\service\followupaudio\FollowupAudioFields as Fields;
use app\common\service\followupaudio\FollowupAudioPolicy as Policy;
use app\common\service\followupaudio\FollowupAudioProviderConfig as ProviderConfig;
use app\common\service\followupaudio\FollowupAudioStore as Store;
use think\Container;
use think\facade\Db;
@@ -37,6 +38,17 @@ $private = $child ? (string) getenv('FOLLOWUP_AUDIO_TEST_PRIVATE') : '/private/t
$config->set(['enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen', 'openai'], 'encryption_key' => str_repeat('synthetic-test-key-', 4),
'lease_seconds' => 60, 'concurrency' => 1, 'retention_days' => 90, 'private_dir' => $private], 'followup_audio');
Container::getInstance()->instance('config', $config);
// Explicit synthetic provider identity: no live credentials, application .env or external requests.
$testProviders = [];
foreach (['qwen', 'openai'] as $profile) {
$testProviders[$profile] = ['driver' => 'dify', 'base_url' => 'https://synthetic.invalid/v1',
'api_key' => 'synthetic-test-key', 'model' => 'synthetic-audio', 'label' => 'Synthetic ' . $profile];
}
$config->set(['providers' => $testProviders], 'followup_audio');
foreach (array_keys($testProviders) as $profile) {
$testProviders[$profile]['verified_fingerprint'] = ProviderConfig::resolve($profile)['fingerprint'];
}
$config->set(['providers' => $testProviders], 'followup_audio');
$root = ['root' => 1, 'admin_id' => 1, 'id' => 1, 'name' => 'Synthetic'];
if ($mode === '--claim') { echo json_encode(['id' => Store::claim()['id'] ?? null]) . "\n"; exit(0); }
if ($mode === '--create') {
@@ -182,9 +194,16 @@ try {
$expect(!Store::verified() && !Store::verified('qwen'), 'No profile is implicitly verified');
$config->set(['verified_profiles' => ['qwen']], 'followup_audio');
$expect(Store::verified('qwen') && !Store::verified('openai'), 'Verification is profile-specific');
$unboundProviders = $testProviders; $unboundProviders['qwen']['verified_fingerprint'] = '';
$config->set(['providers' => $unboundProviders], 'followup_audio');
$expect(!Store::verified('qwen'), 'Flags alone cannot verify an unbound provider');
$config->set(['providers' => $testProviders], 'followup_audio');
$reject(fn () => Store::create($firstUpload, $recordedAt, 'openai', 1, $root), 'DISABLED_OR_UNVERIFIED');
$config->set(['verified_profiles' => ['qwen', 'openai']], 'followup_audio');
$a = Store::create($firstUpload, $recordedAt, 'qwen', 1, $root);
$binding = json_decode(Store::task($a['task_id'])['upstream_ids_json'], true, 16, JSON_THROW_ON_ERROR);
$expect($binding === ['provider_fingerprint' => ProviderConfig::resolve('qwen')['fingerprint']],
'New task persists only immutable provider fingerprint, not credentials or endpoint');
$expect(Store::create($firstUpload, $recordedAt, 'qwen', 1, $root)['task_id'] === $a['task_id'], 'Repeated create does not enqueue duplicate upstream work');
$reject(fn () => Store::create($firstUpload, $recordedAt, 'openai', 1, $root), 'UPLOAD_ALREADY_USED');
$differentUpload = $upload(1, 1, $firstUpload);
@@ -203,6 +222,18 @@ try {
$expect(Store::checkpoint((int) $running['id'], $running['lease_token'], ['stage' => 'uploading', 'upstream_started_at' => time(),
'upstream_ids_json' => ['request_id' => 'opaque-test-request']]), 'Upstream started checkpoint persisted');
$reject(fn () => Store::checkpoint((int) $running['id'], $running['lease_token'], ['api_key' => 'forbidden']), 'CHECKPOINT_INVALID');
$savedIds = json_decode(Store::task((int) $running['id'])['upstream_ids_json'], true, 16, JSON_THROW_ON_ERROR);
$expect($savedIds['provider_fingerprint'] === $binding['provider_fingerprint'] && $savedIds['request_id'] === 'opaque-test-request',
'Normal upstream checkpoints preserve immutable fingerprint');
foreach ([str_repeat('0', 64), '', null] as $replacement) {
$reject(fn () => Store::checkpoint((int) $running['id'], $running['lease_token'],
['upstream_ids_json' => ['provider_fingerprint' => $replacement]]), 'CHECKPOINT_INVALID');
}
$expect(Store::checkpoint((int) $running['id'], $running['lease_token'], ['upstream_ids_json' => '{}'])
&& json_decode(Store::task((int) $running['id'])['upstream_ids_json'], true)['provider_fingerprint'] === $binding['provider_fingerprint'],
'An empty checkpoint cannot delete task binding');
$expect(Store::checkpoint((int) $running['id'], $running['lease_token'], ['upstream_ids_json' => $binding]),
'Transport may echo exactly the original immutable fingerprint');
Db::name('followup_audio_task')->where('id', $running['id'])->update(['lease_until' => time() - 1]);
$other = Store::claim();
$expect(Store::task((int) $running['id'])['status'] === 'needs_reconciliation', 'Expired attempted request cannot be resubmitted');
@@ -223,6 +254,51 @@ try {
$parallelClaim = Store::claim();
$expect((int) $parallelClaim['id'] === $parallelCreated[0]['id'], 'Concurrent dedupe leaves exactly one queued upstream operation');
Store::fail((int) $parallelClaim['id'], $parallelClaim['lease_token'], 'LOCAL_PROBE_FAILED', '');
// Queue bindings survive model switches; changed configuration never silently reroutes old audio.
$oldTask = Store::create($upload(), $recordedAt, 'qwen', 1, $root);
Db::name('followup_audio_task')->where('id', $oldTask['id'])->update(['upstream_ids_json' => '{}']);
$expect(Store::claim() === null, 'Legacy task without fingerprint is never claimed');
$legacy = Store::task($oldTask['id']);
$expect($legacy['status'] === 'failed' && $legacy['error_code'] === 'PROVIDER_CONFIGURATION_CHANGED'
&& (int) $legacy['attempts'] === 0 && !Store::summary($legacy)['can_retry'], 'Legacy binding failure is visible and cannot retry');
$changedQueued = Store::create($upload(), $recordedAt, 'qwen', 1, $root);
$changedProviders = $testProviders; $changedProviders['qwen']['model'] = 'synthetic-changed-model';
$config->set(['providers' => $changedProviders], 'followup_audio');
$expect(!Store::verified('qwen') && Store::claim() === null, 'Changed unverified model cannot consume queued audio');
$changedRow = Store::task($changedQueued['id']);
$expect($changedRow['status'] === 'failed' && $changedRow['error_code'] === 'PROVIDER_CONFIGURATION_CHANGED'
&& (int) $changedRow['upstream_started_at'] === 0, 'Configuration drift fails before upstream intent');
$changedProviders['qwen']['verified_fingerprint'] = ProviderConfig::resolve('qwen')['fingerprint'];
$config->set(['providers' => $changedProviders], 'followup_audio');
$expect(Store::verified('qwen') && !Store::summary($changedRow)['can_retry'], 'Reverified new provider does not unlock old task retry');
$providerMismatch = false;
try { Store::retry($changedQueued['id']); }
catch (\app\common\service\followupaudio\FollowupAudioException $error) { $providerMismatch = $error->errorCode === 'PROVIDER_CONFIGURATION_CHANGED'; }
$expect($providerMismatch, 'Retry reports stable provider-binding error rather than rerouting');
$config->set(['providers' => $testProviders], 'followup_audio');
$expect(Store::retry($changedQueued['id'])['status'] === 'queued', 'Explicit restoration of exact original provider allows safe pre-request retry');
$restored = Store::claim();
Store::fail((int) $restored['id'], $restored['lease_token'], 'LOCAL_PROBE_FAILED', '');
$switchUpload = $upload(); $switchTask = Store::create($switchUpload, $recordedAt, 'qwen', 1, $root);
$switchClaim = Store::claim();
Store::checkpoint($switchTask['id'], $switchClaim['lease_token'], ['upstream_started_at' => time()]);
$config->set(['providers' => $changedProviders], 'followup_audio');
$expect(!Store::heartbeat($switchTask['id'], $switchClaim['lease_token'])
&& !Store::checkpoint($switchTask['id'], $switchClaim['lease_token'], ['stage' => 'analyzing'])
&& !Store::complete($switchTask['id'], $switchClaim['lease_token'], []),
'Heartbeat checkpoint and complete independently fence provider drift');
$config->set(['enabled' => false, 'audio_verified' => false], 'followup_audio');
$expect(Store::fail($switchTask['id'], $switchClaim['lease_token'], 'PROVIDER_CONFIGURATION_CHANGED', 'must not persist', false),
'Configuration and feature withdrawal cannot prevent fenced failure persistence');
$failedSwitch = Store::task($switchTask['id']);
$expect($failedSwitch['status'] === 'needs_reconciliation' && $failedSwitch['lease_token'] === '',
'Attempted old-provider work is held for reconciliation, not left running');
$config->set(['enabled' => true, 'audio_verified' => true], 'followup_audio');
$switchReuse = Store::create($upload(1, 1, $switchUpload), $recordedAt, 'qwen', 1, $root);
$expect($switchReuse['id'] === $switchTask['id'] && $switchReuse['reused']
&& $switchReuse['status'] === 'needs_reconciliation' && str_contains($switchReuse['reuse_message'], '配置'),
'Reverified provider switch plus reupload cannot bypass unknown-request dedupe');
$config->set(['providers' => $testProviders], 'followup_audio');
$makeReview = static function (array $extraction, int $diagnosisId = 1, int $actor = 1) use ($upload, $recordedAt, $root): array {
$created = Store::create($upload($diagnosisId, $actor), $recordedAt, 'qwen', $actor, $root);
$claim = Store::claim();