feat: configure audio providers and add local sample acceptance
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user