feat: configure audio providers and add local sample acceptance
This commit is contained in:
@@ -12,15 +12,29 @@ namespace app\common\service\followupaudio {
|
||||
}
|
||||
namespace {
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
$app = new \think\App(dirname(__DIR__) . '/'); $app->initialize();
|
||||
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
||||
// Explicit disposable local database only; no application initialization or .env loading.
|
||||
$port = (int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT');
|
||||
if ($port <= 0 || getenv('FOLLOWUP_AUDIO_TEST_ALLOW_DISPOSABLE') !== '1') {
|
||||
throw new \RuntimeException('Explicit local disposable MySQL port and FOLLOWUP_AUDIO_TEST_ALLOW_DISPOSABLE=1 required');
|
||||
}
|
||||
$password = (string) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PASSWORD');
|
||||
$database = 'fa_access_' . bin2hex(random_bytes(6));
|
||||
$control = new \PDO("mysql:host=127.0.0.1;port={$port};charset=utf8mb4", 'root', $password,
|
||||
[\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]);
|
||||
$control->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4");
|
||||
new \think\App();
|
||||
set_exception_handler(static function (\Throwable $e): void {
|
||||
fwrite(STDERR, $e->getMessage() . PHP_EOL . $e->getTraceAsString() . PHP_EOL); exit(1);
|
||||
});
|
||||
$app->config->set(['default' => 'mysql', 'connections' => ['mysql' => [
|
||||
'type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => 23316,
|
||||
'database' => 'followup_audio_test', 'username' => 'root', 'password' => 'followup_audio_isolated_test',
|
||||
$manager = new \think\DbManager();
|
||||
$manager->setConfig(['default' => 'mysql', 'connections' => ['mysql' => [
|
||||
'type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => $port,
|
||||
'database' => $database, 'username' => 'root', 'password' => $password,
|
||||
'charset' => 'utf8mb4', 'prefix' => 'faa_', 'debug' => false,
|
||||
]]], 'database');
|
||||
]]]);
|
||||
\think\Container::getInstance()->instance('think\DbManager', $manager);
|
||||
\think\Container::getInstance()->instance('config', new \think\Config());
|
||||
$db = \think\facade\Db::class;
|
||||
$tables = [
|
||||
'admin' => 'id BIGINT PRIMARY KEY, name VARCHAR(30), root INT, disable INT, delete_time BIGINT NULL',
|
||||
@@ -52,7 +66,7 @@ namespace {
|
||||
$deny = function (callable $f, string $label) use ($ok): void {
|
||||
try { $f(); } catch (\DomainException $e) { $ok(true, $label); return; } $ok(false, $label);
|
||||
};
|
||||
$other = new \PDO('mysql:host=127.0.0.1;port=23316;dbname=followup_audio_test;charset=utf8mb4', 'root', 'followup_audio_isolated_test', [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]);
|
||||
$other = new \PDO("mysql:host=127.0.0.1;port={$port};dbname={$database};charset=utf8mb4", 'root', $password, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]);
|
||||
try {
|
||||
$ok((int) $a::diagnosis(91, 8, ['root' => 1])['id'] === 91, 'own patient');
|
||||
$deny(fn () => $a::diagnosis(92, 8, ['root' => 1]), 'forged cached root must fail');
|
||||
@@ -80,6 +94,7 @@ namespace {
|
||||
echo "Followup audio real authorization: {$checks} checks passed (isolated MySQL; current-read revocation/reassignment)\n";
|
||||
} finally {
|
||||
try { $db::rollback(); } catch (\Throwable $e) {}
|
||||
foreach (array_keys($tables) as $name) { $db::execute("DROP TABLE IF EXISTS faa_{$name}"); }
|
||||
$manager->connect()->close();
|
||||
$control->exec("DROP DATABASE IF EXISTS `{$database}`");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -51,7 +51,7 @@ $expectError = static function (callable $call, string $code, bool $uncertain =
|
||||
$expect(!str_contains($e->getMessage(), 'private-body') && !str_contains($e->getMessage(), 'synthetic-test-key'), 'redacted error');
|
||||
}
|
||||
};
|
||||
$settings = ['enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen', 'openai'], 'ffprobe' => 'ffprobe', 'request_timeout' => 5, 'max_seconds' => 3600];
|
||||
$settings = ['allow_insecure_synthetic' => true, 'enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen', 'openai'], 'ffprobe' => 'ffprobe', 'request_timeout' => 5, 'max_seconds' => 3600];
|
||||
$adapter = static function (string $scenario, array $extra = []) use ($port, $settings): Dify {
|
||||
return new Dify(null, $extra + $settings, ['base_url' => 'http://127.0.0.1:' . $port . '/' . $scenario . '/v1',
|
||||
'models' => ['qwen' => ['api_key' => 'synthetic-test-key'], 'openai' => ['api_key' => 'synthetic-test-key']]]);
|
||||
@@ -115,8 +115,12 @@ try {
|
||||
$expectError(static fn () => $unverified->analyze([], $heartbeat), 'AUDIO_NOT_VERIFIED');
|
||||
$notVerifiedProfile = new Dify($testTransport, ['verified_profiles' => ['qwen']] + $settings, $provider);
|
||||
$expectError(static fn () => $notVerifiedProfile->analyze(['model_key' => 'openai'], $heartbeat), 'AUDIO_NOT_VERIFIED');
|
||||
$safeProvider = ['base_url' => 'https://synthetic.invalid/v1', 'models' => ['qwen' => ['api_key' => 'synthetic-test-key']]];
|
||||
$verifiedSettings = $settings;
|
||||
$verifiedSettings['providers']['qwen']['verified_fingerprint'] = \app\common\service\followupaudio\FollowupAudioProviderConfig::resolve('qwen', $settings, $safeProvider)['fingerprint'];
|
||||
$verifiedGuarded = new Dify($testTransport, $verifiedSettings, $safeProvider);
|
||||
$guarded = new Dify($testTransport, $settings, $provider);
|
||||
$expectError(static fn () => $guarded->analyze(['upstream_started_at' => 1, 'model_key' => 'qwen'], $heartbeat), 'RECONCILIATION_REQUIRED', true);
|
||||
$expectError(static fn () => $verifiedGuarded->analyze(['upstream_started_at' => 1, 'model_key' => 'qwen'], $heartbeat), 'RECONCILIATION_REQUIRED', true);
|
||||
$expectError(static fn () => $guarded->probe($wave, $fixture + ['irrelevant' => 'x'], 'unsupported', $heartbeat), 'INVALID_PROFILE');
|
||||
$badFixture = $fixture; $badFixture['sha256'] = str_repeat('0', 64);
|
||||
$expectError(static fn () => $guarded->probe($wave, $badFixture, 'qwen', $heartbeat), 'AUDIO_INVALID');
|
||||
|
||||
@@ -27,7 +27,9 @@ expectEndpoint(str_contains($controller, 'count($items) > 500'), 'bounded review
|
||||
expectEndpoint(str_contains($controller, "'code' => 'FOLLOWUP_AUDIO_STALE_REVIEW'"), 'typed stale review response');
|
||||
expectEndpoint(substr_count($controller, 'Logic::requireEnabled(true)') >= 3, 'upload capability gate');
|
||||
expectEndpoint(str_contains($logic, "Store::verified(\$p['model_key'])"), 'per-model audio capability gate');
|
||||
expectEndpoint(str_contains($logic, "'models' => array_values(array_filter("), 'only verified models advertised');
|
||||
expectEndpoint(str_contains($logic, "'models' => ProviderConfig::publicModels()"), 'only fingerprint-verified server model labels advertised');
|
||||
expectEndpoint(!str_contains($logic, 'Dify 音频能力') && !str_contains($logic, 'api_key') && !str_contains($logic, 'base_url'),
|
||||
'provider-neutral public capabilities never expose credentials or addresses');
|
||||
expectEndpoint(str_contains($logic, "(int) \$upload['diagnosis_id'] !== \$p['diagnosis_id']"), 'upload/diagnosis binding');
|
||||
expectEndpoint(str_contains($logic, "new \\DateTimeZone('Asia/Shanghai')"), 'explicit local date anchor timezone');
|
||||
expectEndpoint(str_contains($stream, 'private, no-store, max-age=0'), 'private playback response');
|
||||
|
||||
@@ -64,12 +64,31 @@ $makeTask = static function (string $path, string $extension) use ($directory):
|
||||
'file_name' => 'synthetic.' . $extension, 'extension' => $extension, 'total_bytes' => filesize($target),
|
||||
'received_bytes' => filesize($target), 'sha256' => $hash, 'duration_seconds' => $duration,
|
||||
'status' => 'complete', 'created_at' => time(), 'expires_at' => time() + 86400]);
|
||||
return ['id' => 1, 'upload_id' => $id, 'diagnosis_id' => 91, 'actor_id' => 7, 'model_key' => 'qwen',
|
||||
return ['upstream_ids_json' => '{}', 'id' => 1, 'upload_id' => $id, 'diagnosis_id' => 91, 'actor_id' => 7, 'model_key' => 'qwen',
|
||||
'recorded_at' => '2026-09-29 10:00:00', 'sha256' => $hash, 'duration_seconds' => $duration, 'path' => $target];
|
||||
};
|
||||
$adapter = static function (string $scenario, array $overrides = []) use ($port, $settings): Dify {
|
||||
return new Dify(null, $overrides + $settings, ['base_url' => 'http://127.0.0.1:' . $port . '/' . $scenario . '/v1',
|
||||
'models' => ['qwen' => ['api_key' => 'synthetic-test-key']]]);
|
||||
$provider = ['base_url' => 'https://synthetic.invalid/' . $scenario . '/v1',
|
||||
'models' => ['qwen' => ['api_key' => 'synthetic-test-key']]];
|
||||
$effective = $overrides + $settings;
|
||||
// Keep a stable task identity while varying transport scenarios; the test seam owns scenario routing.
|
||||
$provider['base_url'] = 'https://synthetic.invalid/v1';
|
||||
$effective['providers']['qwen']['verified_fingerprint'] = \app\common\service\followupaudio\FollowupAudioProviderConfig::resolve('qwen', $effective, $provider)['fingerprint'];
|
||||
$adapter = null;
|
||||
$transport = static function (array $spec, callable $heartbeat) use (&$adapter, $port, $scenario): array {
|
||||
$spec['url'] = str_replace('https://synthetic.invalid', 'http://127.0.0.1:' . $port . '/' . $scenario, $spec['url']);
|
||||
// Reflection is a test seam only; production cURL still verifies HTTPS certificates.
|
||||
$curl = new ReflectionMethod(Dify::class, 'curl');
|
||||
return $curl->invoke($adapter, $spec, $heartbeat);
|
||||
};
|
||||
$adapter = new Dify($transport, $effective, $provider);
|
||||
return $adapter;
|
||||
};
|
||||
$taskFingerprint = \app\common\service\followupaudio\FollowupAudioProviderConfig::resolve('qwen', $settings,
|
||||
['base_url' => 'https://synthetic.invalid/v1', 'models' => ['qwen' => ['api_key' => 'synthetic-test-key']]])['fingerprint'];
|
||||
$bindTask = static function (array $task) use ($taskFingerprint): array {
|
||||
$task['upstream_ids_json'] = json_encode(['provider_fingerprint' => $taskFingerprint]);
|
||||
return $task;
|
||||
};
|
||||
$requests = static fn (): array => is_file($directory . '/requests.jsonl') ? array_map(static fn (string $line): array =>
|
||||
json_decode($line, true), file($directory . '/requests.jsonl', FILE_IGNORE_NEW_LINES)) : [];
|
||||
@@ -87,7 +106,7 @@ try {
|
||||
file_put_contents($directory . '/source.amr', "#!AMR\n" . str_repeat("\x3c" . str_repeat("\0", 31), 150));
|
||||
$tasks = [];
|
||||
foreach (['wav', 'm4a', 'mp3', 'amr'] as $extension) {
|
||||
$task = $makeTask($directory . '/source.' . $extension, $extension); $tasks[$extension] = $task;
|
||||
$task = $bindTask($makeTask($directory . '/source.' . $extension, $extension)); $tasks[$extension] = $task;
|
||||
$events = []; $copies = [];
|
||||
$heartbeat = static function (array $fields = []) use (&$events, &$copies, $task): bool {
|
||||
$events[] = $fields;
|
||||
@@ -140,7 +159,7 @@ try {
|
||||
$expect(count($requests()) === $before + 2 && glob(dirname($wave['path']) . '/processing.*') === [],
|
||||
'unknown HTTP result is not resent and processing copy is erased');
|
||||
$expect(hash_file('sha256', $wave['path']) === $wave['sha256'], 'unknown result leaves original intact');
|
||||
$makeWav($directory . '/long.wav', 3600); $long = $makeTask($directory . '/long.wav', 'wav');
|
||||
$makeWav($directory . '/long.wav', 3600); $long = $bindTask($makeTask($directory . '/long.wav', 'wav'));
|
||||
$copyMetadata = [];
|
||||
$adapter('media-long')->analyze($long, static function (array $fields = []) use ($long, &$copyMetadata): bool {
|
||||
if (isset($fields['upstream_started_at'])) {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Synthetic bytes, real ffmpeg/ffprobe, injected HTTP response; no network, .env or patient data. */
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
||||
use app\common\service\followupaudio\FollowupAudioDify as Adapter;
|
||||
use app\common\service\followupaudio\FollowupAudioProviderConfig as Provider;
|
||||
use app\common\service\followupaudio\FollowupAudioException as AudioError;
|
||||
new think\App();
|
||||
$directory = sys_get_temp_dir() . '/followup-audio-openai-' . bin2hex(random_bytes(6)); mkdir($directory, 0700, true);
|
||||
$pdo = new PDO('sqlite:' . $directory . '/dictionary.sqlite');
|
||||
$pdo->exec('CREATE TABLE zyt_dict_data (id INTEGER PRIMARY KEY, type_value TEXT, status INTEGER, sort INTEGER, name TEXT, value TEXT)');
|
||||
$manager = new think\DbManager();
|
||||
$manager->setConfig(['default' => 'sqlite', 'connections' => ['sqlite' => ['type' => 'sqlite', 'database' => $directory . '/dictionary.sqlite', 'prefix' => 'zyt_']]]);
|
||||
think\Container::getInstance()->instance('think\DbManager', $manager);
|
||||
$checks = 0;
|
||||
$expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; };
|
||||
$expectError = static function (callable $call, string $code, bool $uncertain = false) use ($expect): void {
|
||||
try { $call(); } catch (AudioError $error) {
|
||||
$expect($error->errorCode === $code && $error->uncertain === $uncertain, 'expected ' . $code . ', got ' . $error->errorCode); return;
|
||||
}
|
||||
throw new RuntimeException('Expected ' . $code);
|
||||
};
|
||||
$slot = ['driver' => 'openai_audio', 'base_url' => 'https://synthetic.invalid/v1', 'api_key' => 'synthetic-key', 'model' => 'audio-model-from-config'];
|
||||
$settings = ['enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen'], 'providers' => ['qwen' => $slot],
|
||||
'max_seconds' => 3600, 'ffmpeg' => 'ffmpeg', 'ffprobe' => 'ffprobe', 'request_timeout' => 5];
|
||||
$fingerprint = Provider::resolve('qwen', $settings, [])['fingerprint'];
|
||||
$settings['providers']['qwen']['verified_fingerprint'] = $fingerprint;
|
||||
$wave = $directory . '/short.wav';
|
||||
$samples = str_repeat(pack('v', 0), 48000);
|
||||
$bytes = 'RIFF' . pack('V', 36 + strlen($samples)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 16000, 32000, 2, 16)
|
||||
. 'data' . pack('V', strlen($samples)) . $samples;
|
||||
file_put_contents($wave, $bytes);
|
||||
$fixture = ['synthetic' => true, 'generator' => 'followup-audio-synthetic-v1', 'case' => 'short', 'sha256' => hash_file('sha256', $wave), 'duration_seconds' => 3];
|
||||
$raw = ['schema_version' => 'followup-audio-v1', 'audio_processed' => true, 'summary' => '合成事实',
|
||||
'transcript' => '昨天晚上九点,收缩压126,舒张压82。', 'uncertainties' => [], 'items' => [[
|
||||
'kind' => 'blood', 'values' => ['systolic_pressure' => 126, 'diastolic_pressure' => 82],
|
||||
'record_date' => null, 'record_time' => '21:00:00', 'time_period' => null, 'time_estimated' => false,
|
||||
'date_text' => '昨天', 'time_text' => '晚上九点', 'evidence' => [['text' => '昨天晚上九点,收缩压126,舒张压82。']], 'needs_review' => false,
|
||||
]]];
|
||||
$events = []; $requests = []; $mode = 'success';
|
||||
$heartbeat = static function (array $fields = []) use (&$events): bool { $events[] = $fields; return true; };
|
||||
$transport = static function (array $spec) use (&$requests, &$events, &$mode, $raw, $expect): array {
|
||||
$expect(!empty(end($events)['upstream_started_at']), 'intent is durable before a single chat request');
|
||||
$requests[] = $spec;
|
||||
$answer = $raw;
|
||||
if ($mode === 'not_read') { $answer['audio_processed'] = false; }
|
||||
$content = $mode === 'text_only' ? '没有收到可读取音频附件' : json_encode($answer, JSON_UNESCAPED_UNICODE);
|
||||
$response = ['id' => 'synthetic-completion-id', 'choices' => [['finish_reason' => $mode === 'truncated' ? 'length' : 'stop', 'message' => ['role' => 'assistant', 'content' => $content]]]];
|
||||
if ($mode === 'malformed_choices') { $response['choices'] = 'invalid'; }
|
||||
return ['http_code' => $mode === 'unknown' ? 504 : ($mode === 'rejected' ? 400 : 200),
|
||||
'errno' => 0, 'request_id' => 'synthetic-request-id', 'body' => json_encode($response)];
|
||||
};
|
||||
$adapter = new Adapter($transport, $settings, []);
|
||||
try {
|
||||
$result = $adapter->probe($wave, $fixture, 'qwen', $heartbeat);
|
||||
$spec = $requests[0]; $content = $spec['json']['messages'][0]['content'];
|
||||
$expect(count($requests) === 1 && $spec['url'] === 'https://synthetic.invalid/v1/chat/completions', 'one explicit OpenAI endpoint, no upload or Dify fallback');
|
||||
$expect($spec['json']['model'] === $slot['model'], 'selected model actually sent');
|
||||
$expect($content[1]['type'] === 'input_audio' && $content[1]['input_audio']['format'] === 'wav'
|
||||
&& base64_decode($content[1]['input_audio']['data'], true) === $bytes, 'exact original WAV bytes attached');
|
||||
$expect($result['items'][0]['record_date'] === '2026-09-28', 'same policy and temporal normalization');
|
||||
$ids = json_decode(end($events)['upstream_ids_json'], true);
|
||||
$expect($ids['provider_fingerprint'] === $fingerprint && $ids['message_id'] === 'synthetic-completion-id'
|
||||
&& $ids['upstream_request_id'] === 'synthetic-request-id', 'response ID and bound identity checkpointed');
|
||||
foreach (['not_read' => 'AUDIO_NOT_PROCESSED', 'text_only' => 'UPSTREAM_SCHEMA_INVALID', 'truncated' => 'UPSTREAM_SCHEMA_INVALID',
|
||||
'malformed_choices' => 'UPSTREAM_SCHEMA_INVALID', 'rejected' => 'UPSTREAM_AUDIO_REJECTED', 'unknown' => 'UPSTREAM_UNCERTAIN'] as $scenario => $code) {
|
||||
$mode = $scenario; $before = count($requests);
|
||||
$expectError(static fn () => $adapter->probe($wave, $fixture, 'qwen', $heartbeat), $code, $scenario === 'unknown');
|
||||
$expect(count($requests) === $before + 1, 'HTTP success/rejection/unknown never causes fallback or resend');
|
||||
$ids = json_decode(end($events)['upstream_ids_json'], true);
|
||||
$expect($ids['provider_fingerprint'] === $fingerprint && $ids['message_id'] === 'synthetic-completion-id', 'failed response retains identity and billable ID');
|
||||
}
|
||||
$mode = 'success';
|
||||
$http = $settings; $http['providers']['qwen']['base_url'] = 'http://synthetic.invalid/v1';
|
||||
$before = count($requests);
|
||||
$expectError(static fn () => (new Adapter($transport, $http, []))->probe($wave, $fixture, 'qwen', $heartbeat), 'HTTPS_REQUIRED');
|
||||
$http['allow_insecure_synthetic'] = true;
|
||||
(new Adapter($transport, $http, []))->probe($wave, $fixture, 'qwen', $heartbeat);
|
||||
$expect(count($requests) === $before + 1, 'HTTP requires explicit synthetic-only switch');
|
||||
$expectError(static fn () => (new Adapter($transport, $http, []))->analyze(['model_key' => 'qwen'], $heartbeat), 'AUDIO_NOT_VERIFIED');
|
||||
$before = count($requests);
|
||||
$expectError(static fn () => $adapter->analyze(['model_key' => 'qwen', 'upstream_started_at' => 1], $heartbeat), 'RECONCILIATION_REQUIRED', true);
|
||||
$expect(count($requests) === $before, 'started tasks never resend');
|
||||
$analyze = new ReflectionMethod(Adapter::class, 'analyzeFile');
|
||||
$task = ['id' => 'synthetic-task', 'recorded_at' => '2026-09-29 10:00:00', 'model_key' => 'qwen',
|
||||
'sha256' => $fixture['sha256'], 'duration_seconds' => 3, 'upstream_ids_json' => '{}'];
|
||||
$expectError(static fn () => $analyze->invoke($adapter, $wave, $task, $heartbeat), 'PROVIDER_CONFIGURATION_CHANGED');
|
||||
$task['upstream_ids_json'] = json_encode(['provider_fingerprint' => str_repeat('0', 64)]);
|
||||
$expectError(static fn () => $analyze->invoke($adapter, $wave, $task, $heartbeat), 'PROVIDER_CONFIGURATION_CHANGED');
|
||||
$expect(count($requests) === $before, 'missing or changed provider snapshot fails before sending');
|
||||
$task['upstream_ids_json'] = json_encode(['provider_fingerprint' => $fingerprint]);
|
||||
// M4A and AMR must fully normalize to MP3, not be mislabeled input_audio formats.
|
||||
$p = proc_open(['ffmpeg', '-nostdin', '-v', 'error', '-i', $wave, '-c:a', 'aac', $directory . '/short.m4a'], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
|
||||
fclose($pipes[0]); stream_get_contents($pipes[1]); stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]);
|
||||
$expect(proc_close($p) === 0, 'synthetic M4A generated');
|
||||
file_put_contents($directory . '/short.amr', "#!AMR\n" . str_repeat("\x3c" . str_repeat("\0", 31), 150));
|
||||
foreach (['m4a', 'amr'] as $extension) {
|
||||
$path = $directory . '/short.' . $extension; $hash = hash_file('sha256', $path); $task['sha256'] = $hash;
|
||||
$analyze->invoke($adapter, $path, $task, $heartbeat);
|
||||
$audio = end($requests)['json']['messages'][0]['content'][1]['input_audio'];
|
||||
$expect($audio['format'] === 'mp3' && base64_decode($audio['data'], true) !== false, $extension . ' complete MP3 processing payload');
|
||||
$expect(hash_file('sha256', $path) === $hash && glob($directory . '/processing.*') === [], $extension . ' original preserved and private copy cleaned');
|
||||
}
|
||||
$source = file_get_contents(dirname(__DIR__) . '/app/common/service/followupaudio/FollowupAudioDify.php');
|
||||
$expect(str_contains($source, 'CURLOPT_SSL_VERIFYPEER => true') && str_contains($source, 'CURLOPT_SSL_VERIFYHOST => 2'), 'TLS validation is never disabled');
|
||||
echo 'FOLLOWUP_AUDIO_OPENAI assertions=' . $checks . ' PASS real_input_audio=1 model_configurable=1 no_text_fallback=1 no_resend=1 https_patient_gate=1 full_normalization=1' . PHP_EOL;
|
||||
} finally {
|
||||
$manager->connect()->close(); $pdo = null;
|
||||
foreach (glob($directory . '/*') ?: [] as $path) { if (is_file($path)) { unlink($path); } }
|
||||
rmdir($directory);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ namespace app\common\service\followupaudio {
|
||||
/** Command-state test double. Actual HTTP/audio behavior is covered by FollowupAudioDifyTest. */
|
||||
final class FollowupAudioDify {
|
||||
public static string $mode = 'uncertain';
|
||||
public static string $fingerprint = 'synthetic-application-one';
|
||||
public static string $fingerprint = '1111111111111111111111111111111111111111111111111111111111111111';
|
||||
public static int $calls = 0;
|
||||
public function configurationStatus(string $profile): array { return ['configured' => true, 'profile' => $profile, 'code' => 'OK', 'application_fingerprint' => self::$fingerprint]; }
|
||||
public function probe(string $path, array $fixture, string $profile, callable $heartbeat): array {
|
||||
@@ -24,6 +24,8 @@ namespace {
|
||||
use app\command\FollowupAudioProbe as Probe;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
new think\App(); // No initialization, real config or DB.
|
||||
$safeConfig = new think\Config(); think\Container::getInstance()->instance('config', $safeConfig);
|
||||
$directory = sys_get_temp_dir() . '/followup-audio-probe-state-' . bin2hex(random_bytes(6));
|
||||
mkdir($directory, 0700, true);
|
||||
$directory = realpath($directory);
|
||||
@@ -52,26 +54,42 @@ namespace {
|
||||
$expect($code === 1 && Dify::$calls === 0 && str_contains($stdout, 'SYNTHETIC_ACK_REQUIRED'), 'explicit synthetic acknowledgement required');
|
||||
[$code, $stdout] = $run('qwen');
|
||||
$expect($code === 2 && Dify::$calls === 1 && str_contains($stdout, 'needs_reconciliation'), 'unknown result stops next cases');
|
||||
$report = json_decode(file_get_contents($directory . '/probe-qwen.json'), true);
|
||||
$report = json_decode(file_get_contents($directory . '/probe-qwen-' . Dify::$fingerprint . '.json'), true);
|
||||
$expect($report['cases']['short']['upstream_started_at'] > 0, 'intent persisted before request');
|
||||
[$code, $stdout] = $run('qwen');
|
||||
$expect($code === 2 && Dify::$calls === 1 && str_contains($stdout, 'NO_RESUBMISSION'), 'resume never recharges unknown result');
|
||||
$currentQwenPath = $directory . '/probe-qwen-' . Dify::$fingerprint . '.json';
|
||||
$legacyQwenPath = $directory . '/probe-qwen.json';
|
||||
rename($currentQwenPath, $legacyQwenPath);
|
||||
$legacyBytes = file_get_contents($legacyQwenPath);
|
||||
[$code, $stdout] = $run('qwen');
|
||||
$expect($code === 2 && Dify::$calls === 1 && str_contains($stdout, 'NO_RESUBMISSION'), 'matching legacy identity remains authoritative for unknown requests');
|
||||
$expect(file_get_contents($legacyQwenPath) === $legacyBytes, 'legacy unknown evidence retained unchanged');
|
||||
$legacyReport = json_decode($legacyBytes, true);
|
||||
$legacyReport['configuration']['application_fingerprint'] = hash('sha256', "https://synthetic.invalid/v1\0synthetic-key");
|
||||
file_put_contents($legacyQwenPath, json_encode($legacyReport));
|
||||
$safeConfig->set(['providers' => ['qwen' => ['driver' => 'dify', 'base_url' => 'https://synthetic.invalid/v1', 'api_key' => 'synthetic-key']]], 'followup_audio');
|
||||
[$code, $stdout] = $run('qwen');
|
||||
$expect($code === 2 && Dify::$calls === 1 && str_contains($stdout, 'NO_RESUBMISSION'), 'legacy Dify hash upgrade cannot resubmit same-app unknown');
|
||||
$safeConfig->set([], 'followup_audio');
|
||||
|
||||
Dify::$mode = 'success';
|
||||
[$code, $stdout] = $run('openai');
|
||||
$expect($code === 0 && Dify::$calls === 4, 'three lengths passed sequentially');
|
||||
$bytes = file_get_contents($directory . '/probe-openai.json');
|
||||
$bytes = file_get_contents($directory . '/probe-openai-' . Dify::$fingerprint . '.json');
|
||||
$report = json_decode($bytes, true);
|
||||
$expect($report['audio_verified'] && count($report['cases']) === 3, 'all three required before verification report');
|
||||
$expect(!str_contains($bytes . $stdout, 'PRIVATE_TRANSCRIPT_NEVER_PRINTED'), 'no transcript in output or report');
|
||||
[$code, $stdout] = $run('openai');
|
||||
$expect($code === 0 && Dify::$calls === 4 && substr_count($stdout, 'retained_passed') === 3, 'passed gate results reused without resending');
|
||||
Dify::$fingerprint = 'synthetic-application-two';
|
||||
$oldPath = $directory . '/probe-openai-' . Dify::$fingerprint . '.json';
|
||||
Dify::$fingerprint = '2222222222222222222222222222222222222222222222222222222222222222';
|
||||
[$code, $stdout] = $run('openai');
|
||||
$expect($code === 1 && Dify::$calls === 4 && str_contains($stdout, 'PROBE_APPLICATION_CHANGED'), 'new application cannot inherit old gate');
|
||||
$expect(file_get_contents($directory . '/probe-openai.json') === $bytes, 'application mismatch leaves original evidence unchanged');
|
||||
$expect($code === 0 && Dify::$calls === 7 && !str_contains($stdout, 'retained_passed'), 'new application executes its own gate');
|
||||
$expect(file_get_contents($oldPath) === $bytes, 'application mismatch leaves original evidence unchanged');
|
||||
file_put_contents($directory . '/short.mp3', 'tampered');
|
||||
[$code, $stdout] = $run('qwen');
|
||||
$expect($code === 1 && Dify::$calls === 4 && str_contains($stdout, 'SYNTHETIC_FIXTURE_REQUIRED'), 'tampered fixture cannot run');
|
||||
$expect($code === 1 && Dify::$calls === 7 && str_contains($stdout, 'SYNTHETIC_FIXTURE_REQUIRED'), 'tampered fixture cannot run');
|
||||
echo 'FOLLOWUP_AUDIO_PROBE_COMMAND assertions=' . $checks . ' PASS durable_no_resend=1 retained_results=1 app_identity=1' . PHP_EOL;
|
||||
} finally {
|
||||
foreach (glob($directory . '/*') ?: [] as $file) { if (is_file($file)) { unlink($file); } }
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/** No app initialization, .env, network or real credentials. */
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
||||
use app\common\service\followupaudio\FollowupAudioProviderConfig as Provider;
|
||||
use app\common\service\followupaudio\FollowupAudioException as AudioError;
|
||||
new think\App();
|
||||
$config = new think\Config();
|
||||
think\Container::getInstance()->instance('config', $config);
|
||||
$checks = 0;
|
||||
$expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; };
|
||||
$expectError = static function (callable $call, string $code) use ($expect): void {
|
||||
try { $call(); } catch (AudioError $error) { $expect($error->errorCode === $code, $code); return; }
|
||||
throw new RuntimeException('Expected ' . $code);
|
||||
};
|
||||
$legacy = ['base_url' => 'https://legacy.invalid/v1', 'models' => ['qwen' => ['api_key' => 'synthetic-legacy-key', 'name' => 'must-not-inherit']]];
|
||||
$dify = Provider::resolve('qwen', [], $legacy);
|
||||
$expect($dify['driver'] === 'dify' && $dify['base_url'] === $legacy['base_url'] && $dify['model'] === '', 'legacy Dify app identity allowed without fake internal model');
|
||||
$slot = ['driver' => 'openai_audio', 'base_url' => 'https://audio.invalid/v1/chat/completions',
|
||||
'api_key' => 'synthetic-provider-key', 'model' => 'configurable-audio-model', 'label' => 'Audio label'];
|
||||
$settings = ['enabled' => false, 'audio_verified' => true, 'verified_profiles' => ['qwen'], 'providers' => ['qwen' => $slot]];
|
||||
$resolved = Provider::resolve('qwen', $settings, $legacy);
|
||||
$expect($resolved['base_url'] === 'https://audio.invalid/v1' && $resolved['model'] === $slot['model'], 'explicit selected driver model and normalized endpoint');
|
||||
$settings['providers']['qwen']['verified_fingerprint'] = $resolved['fingerprint'];
|
||||
$config->set($settings, 'followup_audio'); $config->set($legacy, 'prescription_ai');
|
||||
$expect(Provider::isVerified('qwen'), 'verified state independent of disabled operational switch');
|
||||
$expect(Provider::publicModels() === [['value' => 'qwen', 'label' => 'Audio label']], 'only verified slots exposed');
|
||||
$status = Provider::status('qwen', $settings, $legacy);
|
||||
$expect(array_keys($status) === ['configured', 'profile', 'code', 'application_fingerprint'], 'safe exact status keys');
|
||||
$public = json_encode([$status, Provider::publicModels()]);
|
||||
$expect(!str_contains($public, 'synthetic-provider-key') && !str_contains($public, 'audio.invalid'), 'public response contains no endpoint or key');
|
||||
foreach (['driver' => 'dify', 'base_url' => 'https://changed.invalid/v1', 'model' => 'another-model', 'api_key' => 'other-synthetic-key'] as $field => $value) {
|
||||
$changed = $settings; $changed['providers']['qwen'][$field] = $value;
|
||||
$expect(!Provider::verified('qwen', $changed, $legacy), 'identity change invalidates ' . $field);
|
||||
}
|
||||
$changed = $settings; $changed['providers']['qwen']['label'] = 'Renamed display';
|
||||
$expect(Provider::verified('qwen', $changed, $legacy), 'display-only label does not change request identity');
|
||||
$changed = $settings; $changed['providers']['qwen']['label'] = '';
|
||||
$expect(Provider::resolve('qwen', $changed, $legacy)['label'] === $slot['model'], 'empty label falls back to configured model');
|
||||
$changed = $settings; $changed['providers']['qwen']['base_url'] = 'http://audio.invalid/v1';
|
||||
$changed['providers']['qwen']['verified_fingerprint'] = Provider::resolve('qwen', $changed, $legacy)['fingerprint'];
|
||||
$changed['allow_insecure_synthetic'] = true;
|
||||
$expect(!Provider::verified('qwen', $changed, $legacy), 'HTTP never becomes production-verified');
|
||||
foreach (['model', 'base_url', 'api_key'] as $field) {
|
||||
$changed = $settings; $changed['providers']['qwen'][$field] = '';
|
||||
$expectError(static fn () => Provider::resolve('qwen', $changed, $legacy), 'CONFIG_MISSING');
|
||||
}
|
||||
foreach (['ftp://audio.invalid/v1', 'https://user:password@audio.invalid/v1', 'https://audio.invalid/v1?token=x',
|
||||
"https://audio.invalid/v1\n", 'https://audio.invalid/v1#fragment'] as $url) {
|
||||
$changed = $settings; $changed['providers']['qwen']['base_url'] = $url;
|
||||
$expectError(static fn () => Provider::resolve('qwen', $changed, []), 'CONFIG_INVALID');
|
||||
}
|
||||
$changed = $settings; $changed['providers']['qwen']['driver'] = 'automatic';
|
||||
$expectError(static fn () => Provider::resolve('qwen', $changed, []), 'CONFIG_INVALID');
|
||||
$changed = $settings; $changed['providers']['qwen']['verified_fingerprint'] = '';
|
||||
$expect(!Provider::verified('qwen', $changed, $legacy), 'old unbound flags do not enable audio');
|
||||
$expect(Provider::status('invalid', $settings, $legacy)['code'] === 'INVALID_PROFILE', 'logical slot allowlist retained');
|
||||
echo 'FOLLOWUP_AUDIO_PROVIDER_CONFIG assertions=' . $checks . ' PASS explicit_driver=1 model_configurable=1 exact_fingerprint=1 https_verified=1 secrets_hidden=1' . PHP_EOL;
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/** No autoloader, app, .env, filesystem inputs, network or DB: public gate must fail closed before I/O. */
|
||||
namespace think\facade {
|
||||
final class Db
|
||||
{
|
||||
public static int $calls = 0;
|
||||
public static function __callStatic(string $name, array $args): never
|
||||
{
|
||||
self::$calls++;
|
||||
throw new \RuntimeException('DATABASE_MUST_NOT_BE_USED');
|
||||
}
|
||||
}
|
||||
}
|
||||
namespace {
|
||||
$settings = [];
|
||||
function config(string $key, $default = null)
|
||||
{
|
||||
global $settings;
|
||||
$value = $settings;
|
||||
foreach (explode('.', $key) as $part) {
|
||||
if (!is_array($value) || !array_key_exists($part, $value)) { return $default; }
|
||||
$value = $value[$part];
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
$root = dirname(__DIR__) . '/app/common/service/followupaudio/';
|
||||
foreach (['Exception', 'ProviderConfig', 'Store'] as $class) { require $root . 'FollowupAudio' . $class . '.php'; }
|
||||
require dirname(__DIR__) . '/app/adminapi/logic/tcm/FollowupAudioLogic.php';
|
||||
use app\common\service\followupaudio\FollowupAudioStore as Store;
|
||||
use app\common\service\followupaudio\FollowupAudioProviderConfig as Providers;
|
||||
use app\adminapi\logic\tcm\FollowupAudioLogic as Logic;
|
||||
$checks = 0;
|
||||
$expect = static function (bool $ok, string $why) use (&$checks): void {
|
||||
if (!$ok) { throw new RuntimeException($why); }
|
||||
$checks++;
|
||||
};
|
||||
$expect(!Store::enabled() && !Store::verified() && !Store::verified('qwen'), 'Missing app configuration stays disabled and unverified');
|
||||
$expect(Store::claim() === null, 'Missing app configuration never enters queue transaction');
|
||||
$expect(Providers::publicModels() === [], 'Missing provider configuration publishes no models');
|
||||
try { Store::assertEnabled('qwen'); throw new RuntimeException('Expected disabled gate'); }
|
||||
catch (DomainException $error) { $expect($error->getMessage() === 'FOLLOWUP_AUDIO_DISABLED_OR_UNVERIFIED', 'Stable disabled error'); }
|
||||
try { Logic::requireEnabled(true); throw new RuntimeException('Expected public disabled gate'); }
|
||||
catch (DomainException $error) { $expect($error->getMessage() === '回访录音功能尚未启用', 'Public gate avoids database'); }
|
||||
$settings['followup_audio'] = ['enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen']];
|
||||
$expect(!Store::verified(), 'Unbound flags alone never grant capability');
|
||||
try { Logic::requireEnabled(true); throw new RuntimeException('Expected public unverified gate'); }
|
||||
catch (DomainException $error) {
|
||||
$expect(str_contains($error->getMessage(), '当前服务与模型') && !str_contains($error->getMessage(), 'Dify'), 'Public unverified error is provider-neutral');
|
||||
}
|
||||
$expect(Providers::publicModels() === [], 'Unconfigured flags do not advertise models');
|
||||
$provider = ['driver' => 'openai_audio', 'base_url' => 'https://synthetic.invalid/v1',
|
||||
'api_key' => 'synthetic-test-only', 'model' => 'audio-fixture-v1', 'label' => 'Synthetic audio model'];
|
||||
$settings['followup_audio']['providers']['qwen'] = $provider;
|
||||
$fingerprint = Providers::resolve('qwen')['fingerprint'];
|
||||
$settings['followup_audio']['providers']['qwen']['verified_fingerprint'] = $fingerprint;
|
||||
$task = ['model_key' => 'qwen', 'upstream_ids_json' => json_encode(['provider_fingerprint' => $fingerprint]), 'upstream_started_at' => 0];
|
||||
$expect(Store::verified('qwen') && Store::providerMatches($task), 'Exact approved provider matches bound task');
|
||||
$expect(Providers::publicModels() === [['value' => 'qwen', 'label' => 'Synthetic audio model']], 'Public model list contains only configured value/label');
|
||||
$public = json_encode(Providers::publicModels());
|
||||
$expect(!str_contains($public, 'synthetic.invalid') && !str_contains($public, $provider['api_key']), 'Public labels never expose server URL/key');
|
||||
foreach (['{}', 'not-json', '{"provider_fingerprint":null}', '{"provider_fingerprint":123}'] as $ids) {
|
||||
$unbound = array_replace($task, ['upstream_ids_json' => $ids]);
|
||||
$expect(!Store::providerMatches($unbound), 'Missing malformed or invalid fingerprint cannot be trusted');
|
||||
}
|
||||
foreach (['driver' => 'dify', 'base_url' => 'https://changed.invalid/v1', 'api_key' => 'rotated-synthetic-key', 'model' => 'audio-fixture-v2'] as $field => $changed) {
|
||||
$settings['followup_audio']['providers']['qwen'] = $provider + ['verified_fingerprint' => $fingerprint];
|
||||
$settings['followup_audio']['providers']['qwen'][$field] = $changed;
|
||||
$expect(!Store::verified('qwen') && !Store::providerMatches($task), 'Provider identity drift invalidates capability and task binding: ' . $field);
|
||||
}
|
||||
$expect(\think\facade\Db::$calls === 0, 'All missing-config and provider-binding checks performed without database I/O');
|
||||
echo 'FOLLOWUP_AUDIO_PROVIDER_INTEGRATION assertions=' . $checks . " PASS database_calls=0 app_initialized=0 network_calls=0\n";
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Opt-in, LOCAL ONLY acceptance of user-supplied audio. No ASR, provider calls, application bootstrap or .env.
|
||||
* Usage: php tests/FollowupAudioSampleAcceptanceTest.php --manifest /absolute/private/manifest.json
|
||||
* A manifest must be a private (0600) local JSON file OUTSIDE Git, with this schema:
|
||||
* {"samples":[{"id":"sample-1","path":"/absolute/local/audio.mp3","sha256":"<64 lowercase hex>"}],
|
||||
* "recorded_at_cases":[{"id":"ordinary","recorded_at":"2026-10-07 00:00:00"},
|
||||
* {"id":"month","recorded_at":"2026-03-01 00:00:00"},
|
||||
* {"id":"year","recorded_at":"2026-01-01 00:00:00"},
|
||||
* {"id":"leap","recorded_at":"2024-03-01 00:00:00"}]}
|
||||
* Optional --ffmpeg/--ffprobe select absolute local executables; otherwise PATH is used.
|
||||
* Real paths, recordings, keys and transcripts MUST NOT be added to committed fixtures.
|
||||
* Date candidates below are explicitly SYNTHETIC, never transcripts of the supplied recordings.
|
||||
*/
|
||||
namespace app\common\service\followupaudio {
|
||||
/** Test-only access boundary: upload implementation and SQLite are real; production RBAC is NOT exercised. */
|
||||
final class FollowupAudioAccess
|
||||
{
|
||||
public static function diagnosis(int $id, int $actor, array $info, bool $daily = false): array
|
||||
{
|
||||
if ($id !== 91 || $actor !== 7) { throw new \DomainException('SYNTHETIC_SCOPE_DENIED'); }
|
||||
return ['id' => 91, 'patient_id' => 101];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
use app\common\service\followupaudio\FollowupAudioDify as Dify;
|
||||
use app\common\service\followupaudio\FollowupAudioPolicy as Policy;
|
||||
use app\common\service\followupaudio\FollowupAudioUpload as Upload;
|
||||
use think\Container;
|
||||
use think\facade\Db;
|
||||
|
||||
function fail(string $code): never { throw new RuntimeException($code); }
|
||||
function expect(bool $condition, string $code): void
|
||||
{
|
||||
if (!$condition) { fail($code); }
|
||||
$GLOBALS['sample_checks']++;
|
||||
}
|
||||
function reject(callable $call, string $code): void
|
||||
{
|
||||
try { $call(); } catch (DomainException | RuntimeException $e) {
|
||||
expect($e->getMessage() === $code, 'WRONG_REJECTION_CODE');
|
||||
return;
|
||||
}
|
||||
fail('EXPECTED_REJECTION');
|
||||
}
|
||||
function exactKeys(array $value, array $keys): bool
|
||||
{
|
||||
$actual = array_keys($value); sort($actual); sort($keys);
|
||||
return $actual === $keys;
|
||||
}
|
||||
function localFile(string $path, string $error): string
|
||||
{
|
||||
if ($path === '' || $path[0] !== '/' || preg_match('/[\x00-\x1f\x7f]/', $path)
|
||||
|| preg_match('~/(?:\.|\.\.)(?:/|$)~', $path) || is_link($path) || !is_file($path) || !is_readable($path)) {
|
||||
fail($error);
|
||||
}
|
||||
$real = realpath($path);
|
||||
if ($real === false) { fail($error); }
|
||||
// Reject symlink components too; no implicit reads through a linked private input.
|
||||
$part = '';
|
||||
foreach (explode('/', ltrim($path, '/')) as $component) {
|
||||
$part .= '/' . $component;
|
||||
if (is_link($part)) { fail($error); }
|
||||
}
|
||||
return $real;
|
||||
}
|
||||
function outsideGit(string $path): bool
|
||||
{
|
||||
for ($dir = dirname($path); ; $dir = dirname($dir)) {
|
||||
if (file_exists($dir . '/.git')) { return false; }
|
||||
if ($dir === dirname($dir)) { return true; }
|
||||
}
|
||||
}
|
||||
function parseManifest(string $json): array
|
||||
{
|
||||
try { $value = json_decode($json, true, 64, JSON_THROW_ON_ERROR); }
|
||||
catch (JsonException $e) { fail('MANIFEST_JSON_INVALID'); }
|
||||
if (!is_array($value) || !exactKeys($value, ['samples', 'recorded_at_cases'])
|
||||
|| !is_array($value['samples']) || !array_is_list($value['samples']) || count($value['samples']) < 1
|
||||
|| count($value['samples']) > 20 || !is_array($value['recorded_at_cases']) || !array_is_list($value['recorded_at_cases'])) {
|
||||
fail('MANIFEST_SCHEMA_INVALID');
|
||||
}
|
||||
$ids = []; $paths = [];
|
||||
foreach ($value['samples'] as &$sample) {
|
||||
if (!is_array($sample) || !exactKeys($sample, ['id', 'path', 'sha256'])
|
||||
|| !is_string($sample['id']) || !preg_match('/^sample-[1-9][0-9]?$/D', $sample['id'])
|
||||
|| isset($ids[$sample['id']]) || !is_string($sample['path'])
|
||||
|| !is_string($sample['sha256']) || !preg_match('/^[a-f0-9]{64}$/D', $sample['sha256'])) {
|
||||
fail('MANIFEST_SAMPLE_INVALID');
|
||||
}
|
||||
$sample['path'] = localFile($sample['path'], 'SAMPLE_PATH_INVALID');
|
||||
if (isset($paths[$sample['path']])) { fail('MANIFEST_SAMPLE_DUPLICATE'); }
|
||||
$ids[$sample['id']] = true; $paths[$sample['path']] = true;
|
||||
}
|
||||
unset($sample);
|
||||
$expected = ['ordinary' => '2026-10-07', 'month' => '2026-03-01', 'year' => '2026-01-01', 'leap' => '2024-03-01'];
|
||||
$dates = [];
|
||||
foreach ($value['recorded_at_cases'] as $case) {
|
||||
if (!is_array($case) || !exactKeys($case, ['id', 'recorded_at']) || !is_string($case['id'])
|
||||
|| !isset($expected[$case['id']]) || isset($dates[$case['id']]) || !is_string($case['recorded_at'])
|
||||
|| substr($case['recorded_at'], 0, 10) !== $expected[$case['id']]) { fail('MANIFEST_DATES_INVALID'); }
|
||||
try { Policy::strictRecordedAt($case['recorded_at']); }
|
||||
catch (DomainException $e) { fail('MANIFEST_DATES_INVALID'); }
|
||||
$dates[$case['id']] = $case['recorded_at'];
|
||||
}
|
||||
if (count($dates) !== count($expected)) { fail('MANIFEST_DATES_INVALID'); }
|
||||
return $value;
|
||||
}
|
||||
function process(array $command): array
|
||||
{
|
||||
$child = proc_open($command, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
|
||||
if (!is_resource($child)) { fail('DECODE_PROCESS_UNAVAILABLE'); }
|
||||
fclose($pipes[0]); stream_set_blocking($pipes[1], false); stream_set_blocking($pipes[2], false);
|
||||
$stdout = ''; $stderr = ''; $exit = -1; $deadline = microtime(true) + 180;
|
||||
try {
|
||||
do {
|
||||
$stdout .= stream_get_contents($pipes[1]); $stderr .= stream_get_contents($pipes[2]);
|
||||
$state = proc_get_status($child);
|
||||
if (!$state['running']) { $exit = $state['exitcode']; break; }
|
||||
if (microtime(true) > $deadline || strlen($stdout) + strlen($stderr) > 1048576) {
|
||||
proc_terminate($child, 9); fail('DECODE_PROCESS_LIMIT');
|
||||
}
|
||||
usleep(10000);
|
||||
} while (true);
|
||||
$stdout .= stream_get_contents($pipes[1]); $stderr .= stream_get_contents($pipes[2]);
|
||||
} finally {
|
||||
fclose($pipes[1]); fclose($pipes[2]); $closed = proc_close($child);
|
||||
}
|
||||
return ['command' => $command, 'stdout' => $stdout, 'stderr' => $stderr, 'exit_status' => $exit < 0 ? $closed : $exit];
|
||||
}
|
||||
function eraseOwnedTree(string $root): void
|
||||
{
|
||||
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
|
||||
foreach ($files as $file) {
|
||||
$file->isDir() && !$file->isLink() ? rmdir($file->getPathname()) : unlink($file->getPathname());
|
||||
}
|
||||
rmdir($root);
|
||||
}
|
||||
|
||||
$GLOBALS['sample_checks'] = 0; $directory = null; $originals = []; $exit = 0; $oldMask = umask(0077);
|
||||
set_error_handler(static function (int $severity): bool {
|
||||
if (!(error_reporting() & $severity)) { return false; }
|
||||
// Never print a warning that might contain a source filename or private input.
|
||||
throw new RuntimeException('LOCAL_IO_FAILED');
|
||||
});
|
||||
try {
|
||||
if ($argc === 1) {
|
||||
echo "SKIP Followup audio real samples: opt in with --manifest outside Git; no files read, no ASR.\n";
|
||||
} else {
|
||||
$options = [];
|
||||
for ($i = 1; $i < $argc; $i += 2) {
|
||||
if (!in_array($argv[$i], ['--manifest', '--ffmpeg', '--ffprobe'], true) || !isset($argv[$i + 1])
|
||||
|| isset($options[$argv[$i]])) { fail('ARGUMENTS_INVALID'); }
|
||||
$options[$argv[$i]] = $argv[$i + 1];
|
||||
}
|
||||
if (!isset($options['--manifest'])) { fail('MANIFEST_REQUIRED'); }
|
||||
$manifestPath = localFile($options['--manifest'], 'MANIFEST_PATH_INVALID');
|
||||
if (!outsideGit($manifestPath)) { fail('MANIFEST_MUST_BE_OUTSIDE_GIT'); }
|
||||
if ((fileperms($manifestPath) & 0777) !== 0600 || filesize($manifestPath) > 65536) { fail('MANIFEST_NOT_PRIVATE_OR_TOO_LARGE'); }
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
||||
$manifest = parseManifest(file_get_contents($manifestPath));
|
||||
if (!in_array('sqlite', PDO::getAvailableDrivers(), true)) { fail('SQLITE_REQUIRED_NO_PRODUCTION_FALLBACK'); }
|
||||
$tools = [];
|
||||
foreach (['ffmpeg', 'ffprobe'] as $name) {
|
||||
$tools[$name] = $options['--' . $name] ?? $name;
|
||||
if (isset($options['--' . $name]) && ($tools[$name][0] !== '/' || !is_executable($tools[$name]))) { fail('MEDIA_TOOL_INVALID'); }
|
||||
}
|
||||
$directory = realpath(sys_get_temp_dir()) . '/followup-audio-samples-' . bin2hex(random_bytes(12));
|
||||
if (!mkdir($directory . '/private', 0700, true)) { fail('PRIVATE_DIRECTORY_FAILED'); }
|
||||
new think\App(); // Intentionally NOT initialize(): never loads real app config/services/.env.
|
||||
$database = $directory . '/test.sqlite';
|
||||
$pdo = new PDO('sqlite:' . $database); chmod($database, 0600);
|
||||
$pdo->exec('CREATE TABLE sample_followup_audio_upload (id TEXT PRIMARY KEY, diagnosis_id INT, actor_id INT,
|
||||
file_name TEXT, extension TEXT, total_bytes INT, received_bytes INT, sha256 TEXT, duration_seconds REAL,
|
||||
status TEXT, created_at INT, expires_at INT)');
|
||||
$manager = new think\DbManager();
|
||||
$manager->setConfig(['default' => 'sqlite', 'connections' => ['sqlite' => [
|
||||
'type' => 'sqlite', 'database' => $database, 'prefix' => 'sample_']]]);
|
||||
Container::getInstance()->instance('think\DbManager', $manager);
|
||||
$settings = ['enabled' => false, 'audio_verified' => false, 'verified_profiles' => [],
|
||||
'private_dir' => $directory . '/private', 'ffmpeg' => $tools['ffmpeg'], 'ffprobe' => $tools['ffprobe'],
|
||||
'max_bytes' => 524288000, 'max_seconds' => 3600, 'chunk_bytes' => 65536,
|
||||
'upstream_max_bytes' => 20971520, 'normalize_timeout' => 120];
|
||||
$config = new think\Config(); $config->set($settings, 'followup_audio');
|
||||
Container::getInstance()->instance('config', $config);
|
||||
$networkAttempts = 0;
|
||||
$adapter = new Dify(static function () use (&$networkAttempts): never { $networkAttempts++; fail('NETWORK_FORBIDDEN'); }, $settings, []);
|
||||
$inspect = new ReflectionMethod(Dify::class, 'inspectAudio');
|
||||
$prepare = new ReflectionMethod(Dify::class, 'prepareAudio');
|
||||
$inspect->setAccessible(true); $prepare->setAccessible(true);
|
||||
$media = [];
|
||||
foreach ($manifest['samples'] as $sample) {
|
||||
$path = $sample['path']; $hash = (string) hash_file('sha256', $path); $originals[$path] = $hash;
|
||||
expect(hash_equals($sample['sha256'], $hash), 'INPUT_HASH_MISMATCH');
|
||||
[, $extension] = Upload::fileName('sample.' . strtolower(pathinfo($path, PATHINFO_EXTENSION)));
|
||||
$baseline = Upload::inspect($path, $extension);
|
||||
$session = Upload::createSession(91, $sample['id'] . '.' . $extension, filesize($path), 7, []);
|
||||
$source = fopen($path, 'rb'); $chunks = 0;
|
||||
try {
|
||||
while (!feof($source)) {
|
||||
$contents = fread($source, $session['chunk_bytes']);
|
||||
if ($contents === '') { break; }
|
||||
file_put_contents($directory . '/chunk', $contents); chmod($directory . '/chunk', 0600);
|
||||
Upload::putChunk($session['upload_id'], $chunks++, $directory . '/chunk', 7, []);
|
||||
}
|
||||
} finally { fclose($source); }
|
||||
$result = Upload::complete($session['upload_id'], 7, []);
|
||||
expect(hash_equals($hash, $result['sha256']), 'REASSEMBLY_HASH_MISMATCH');
|
||||
expect(Upload::complete($session['upload_id'], 7, []) === $result, 'COMPLETION_NOT_IDEMPOTENT');
|
||||
$assembled = Upload::path(Upload::session($session['upload_id']));
|
||||
expect(hash_file('sha256', $assembled) === $hash, 'ASSEMBLED_BYTES_CHANGED');
|
||||
expect(abs($baseline['duration_seconds'] - $result['duration_seconds']) < 0.001, 'ASSEMBLED_DURATION_CHANGED');
|
||||
$decode = process([$tools['ffmpeg'], '-nostdin', '-hide_banner', '-v', 'error', '-xerror',
|
||||
'-protocol_whitelist', 'file,pipe', '-threads', '1', '-i', $assembled, '-map', '0:a:0',
|
||||
'-vn', '-sn', '-dn', '-f', 'null', '-']);
|
||||
expect($decode['exit_status'] === 0, 'FULL_DECODE_FAILED');
|
||||
$local = $inspect->invoke($adapter, $assembled, $hash);
|
||||
expect(abs($local['duration'] - $baseline['duration_seconds']) <= 0.001, 'DIFY_LOCAL_INSPECT_MISMATCH');
|
||||
// Only private LOCAL preprocessing helpers: never analyze(), probe(), or a provider response.
|
||||
$prepared = $prepare->invoke($adapter, $local, static fn (): bool => true);
|
||||
$temporary = !empty($prepared['temporary']);
|
||||
if ($temporary) {
|
||||
expect(dirname($prepared['path']) === dirname($assembled), 'PROCESSING_COPY_SCOPE_INVALID');
|
||||
expect((fileperms($prepared['path']) & 0777) === 0600, 'PROCESSING_COPY_NOT_PRIVATE');
|
||||
unlink($prepared['path']);
|
||||
} else { expect($prepared['path'] === $assembled, 'UNEXPECTED_PREPARATION_PATH'); }
|
||||
expect(hash_file('sha256', $path) === $hash && hash_file('sha256', $assembled) === $hash, 'ORIGINAL_CHANGED');
|
||||
$media[] = ['id' => $sample['id'], 'bytes' => filesize($path), 'sha256' => $hash,
|
||||
'duration_seconds' => $result['duration_seconds'], 'chunks' => $chunks,
|
||||
'upload' => 'real_service_with_synthetic_access_and_disposable_sqlite',
|
||||
'decode' => $decode, 'local_prepare' => $temporary ? 'private_processing_copy' : 'original_compatible_unchanged',
|
||||
'original_sha256_unchanged' => true];
|
||||
}
|
||||
$dateResults = [];
|
||||
$yesterdays = ['ordinary' => '2026-10-06', 'month' => '2026-02-28', 'year' => '2025-12-31', 'leap' => '2024-02-29'];
|
||||
foreach ($manifest['recorded_at_cases'] as $case) {
|
||||
$today = substr($case['recorded_at'], 0, 10);
|
||||
$cases = [
|
||||
['today', '今天', $today, '11:21', null, false, false],
|
||||
['yesterday', '昨天', $yesterdays[$case['id']], '11:21', null, false, false],
|
||||
['ambiguous-last-week', '上周', null, '11:21', null, false, true],
|
||||
];
|
||||
foreach (['凌晨' => null, '早晨' => '08:00', '上午' => '08:00', '中午' => '12:00',
|
||||
'下午' => '15:00', '晚上' => '20:00', '睡前' => '22:00', '' => null] as $period => $clock) {
|
||||
$cases[] = ['estimated-' . ($period ?: 'unspecified'), '今天', $today, $clock, $period ?: null, true, true];
|
||||
}
|
||||
foreach ($cases as [$id, $dateText, $expectedDate, $clock, $period, $estimate, $review]) {
|
||||
$quote = $dateText . ($period ?? '') . '空腹血糖6.7';
|
||||
$raw = ['kind' => 'blood', 'values' => ['fasting_blood_sugar' => 6.7], 'date_text' => $dateText,
|
||||
'record_date' => $id === 'ambiguous-last-week' ? $today : null, 'record_time' => $estimate ? null : $clock,
|
||||
'time_period' => $period, 'evidence' => [['text' => $quote]]];
|
||||
$normalized = Policy::normalizeExtraction(['transcript' => $quote, 'summary' => 'Synthetic date-only candidate, NOT ASR',
|
||||
'items' => [$raw], 'uncertainties' => []], $case['recorded_at']);
|
||||
expect(count($normalized['items']) === 1, 'SYNTHETIC_CANDIDATE_MISSING');
|
||||
$item = $normalized['items'][0];
|
||||
expect($item['record_date'] === $expectedDate, 'SYNTHETIC_DATE_MISMATCH');
|
||||
expect($item['record_time'] === $clock, 'SYNTHETIC_CLOCK_MISMATCH');
|
||||
expect($item['time_estimated'] === $estimate, 'SYNTHETIC_ESTIMATION_MISMATCH');
|
||||
expect($item['needs_review'] === $review && $item['selected'] === false, 'SYNTHETIC_REVIEW_MISMATCH');
|
||||
$dateResults[] = ['case' => $case['id'] . ':' . $id, 'synthetic_only' => true,
|
||||
'record_date' => $item['record_date'], 'record_time' => $item['record_time'],
|
||||
'time_estimated' => $item['time_estimated'], 'needs_review' => $item['needs_review'], 'selected' => false];
|
||||
}
|
||||
}
|
||||
// Input rejection tests use only anonymous temporary paths; no application/environment files are read.
|
||||
$beforeNegative = $GLOBALS['sample_checks'];
|
||||
reject(static fn () => parseManifest('{'), 'MANIFEST_JSON_INVALID');
|
||||
reject(static fn () => parseManifest('{}'), 'MANIFEST_SCHEMA_INVALID');
|
||||
foreach (['https://example.invalid/audio.mp3', 'relative.mp3', $directory . '/../missing.mp3', $directory . '/missing.mp3', $directory] as $bad) {
|
||||
$invalid = $manifest; $invalid['samples'][0]['path'] = $bad;
|
||||
reject(static fn () => parseManifest(json_encode($invalid, JSON_THROW_ON_ERROR)), 'SAMPLE_PATH_INVALID');
|
||||
}
|
||||
symlink($directory . '/chunk', $directory . '/linked.mp3');
|
||||
$invalid = $manifest; $invalid['samples'][0]['path'] = $directory . '/linked.mp3';
|
||||
reject(static fn () => parseManifest(json_encode($invalid, JSON_THROW_ON_ERROR)), 'SAMPLE_PATH_INVALID');
|
||||
unlink($directory . '/linked.mp3');
|
||||
$invalid = $manifest; $invalid['samples'][] = $invalid['samples'][0];
|
||||
reject(static fn () => parseManifest(json_encode($invalid, JSON_THROW_ON_ERROR)), 'MANIFEST_SAMPLE_INVALID');
|
||||
$invalid = $manifest; $invalid['recorded_at_cases'][0]['recorded_at'] = '2026-02-30 00:00:00';
|
||||
reject(static fn () => parseManifest(json_encode($invalid, JSON_THROW_ON_ERROR)), 'MANIFEST_DATES_INVALID');
|
||||
$negativeChecks = $GLOBALS['sample_checks'] - $beforeNegative;
|
||||
expect($networkAttempts === 0, 'NETWORK_WAS_ATTEMPTED');
|
||||
expect((fileperms($directory) & 0777) === 0700, 'PRIVATE_ROOT_MODE_INVALID');
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST) as $file) {
|
||||
expect(!$file->isLink() && (($file->getPerms() & 0777) === ($file->isDir() ? 0700 : 0600)), 'PRIVATE_MODE_INVALID');
|
||||
}
|
||||
echo json_encode(['suite' => 'followup-audio-local-samples-v1', 'status' => 'PASS',
|
||||
'checks' => $GLOBALS['sample_checks'], 'media_samples' => count($media), 'synthetic_date_cases' => count($dateResults),
|
||||
'negative_input_checks' => $negativeChecks, 'asr' => 'NOT_RUN', 'network_attempts' => $networkAttempts,
|
||||
'production_database' => 'NOT_USED', 'app_env' => 'NOT_LOADED',
|
||||
'limitations' => ['Synthetic Access stub does not validate production RBAC.',
|
||||
'SQLite upload acceptance does not validate production MySQL or HTTP endpoints.',
|
||||
'Date results are synthetic policy inputs, not recognition of supplied recordings.',
|
||||
'No Dify upload, model recognition, transcript accuracy, or business writeback is claimed.'],
|
||||
'media' => $media, 'synthetic_dates' => $dateResults], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL;
|
||||
}
|
||||
} catch (Throwable $error) {
|
||||
$code = preg_match('/^[A-Z0-9_]+$/D', $error->getMessage()) ? $error->getMessage() : 'LOCAL_ACCEPTANCE_FAILED';
|
||||
fwrite(STDERR, 'FAIL ' . $code . PHP_EOL); $exit = 1;
|
||||
} finally {
|
||||
foreach ($originals as $path => $hash) {
|
||||
if (!is_file($path) || hash_file('sha256', $path) !== $hash) { fwrite(STDERR, "FAIL ORIGINAL_HASH_CHANGED\n"); $exit = 1; }
|
||||
}
|
||||
if ($directory !== null && is_dir($directory)) {
|
||||
try { eraseOwnedTree($directory); }
|
||||
catch (Throwable $error) { fwrite(STDERR, "FAIL TEMPORARY_CLEANUP_FAILED\n"); $exit = 1; }
|
||||
}
|
||||
umask($oldMask); restore_error_handler();
|
||||
}
|
||||
exit($exit);
|
||||
}
|
||||
@@ -14,9 +14,11 @@ namespace app\common\service\followupaudio {
|
||||
public static bool $verified = true;
|
||||
public static array $events = [];
|
||||
public static bool $lease = true;
|
||||
public static bool $providerMatches = true;
|
||||
public static function enabled(): bool { return self::$enabled; }
|
||||
public static function verified(?string $profile = null): bool { return self::$verified; }
|
||||
public static function claim(): ?array { self::$events[] = 'claim'; return ['id' => 1, 'actor_id' => 1, 'diagnosis_id' => 1, 'lease_token' => 'test', 'model_key' => 'qwen']; }
|
||||
public static function claim(): ?array { self::$events[] = 'claim'; return self::$verified ? ['id' => 1, 'actor_id' => 1, 'diagnosis_id' => 1, 'lease_token' => 'test', 'model_key' => 'qwen'] : null; }
|
||||
public static function assertTaskProvider(array $task): void { if (!self::$providerMatches) { throw new FollowupAudioException('PROVIDER_CONFIGURATION_CHANGED'); } }
|
||||
public static function heartbeat(int $id, string $token): bool { self::$events[] = 'heartbeat'; return self::$lease; }
|
||||
public static function checkpoint(int $id, string $token, array $fields): bool { self::$events[] = ['checkpoint' => $fields]; return self::$lease; }
|
||||
public static function complete(int $id, string $token, array $extraction): bool { self::$events[] = ['complete' => $extraction]; return self::$lease; }
|
||||
@@ -29,9 +31,11 @@ namespace app\common\service\followupaudio {
|
||||
final class FollowupAudioDify {
|
||||
public static string $mode = 'success';
|
||||
public function analyze(array $task, callable $heartbeat): array {
|
||||
if (self::$mode === 'change-before-send') { FollowupAudioStore::$providerMatches = false; }
|
||||
if ($heartbeat(['upstream_started_at' => 1, 'stage' => 'uploading']) === false) { throw new FollowupAudioException('LEASE_LOST'); }
|
||||
if (self::$mode === 'uncertain') { throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true); }
|
||||
if (self::$mode === 'internal') { throw new \RuntimeException('private-patient'); }
|
||||
if (self::$mode === 'change-after-send') { FollowupAudioStore::$providerMatches = false; }
|
||||
if (self::$mode === 'revoke') { \app\common\service\prescriptionai\PrescriptionAiAccess::$active = false; }
|
||||
return ['summary' => 'synthetic', 'items' => []];
|
||||
}
|
||||
@@ -51,8 +55,8 @@ namespace {
|
||||
Store::$enabled = false;
|
||||
$expect(!$worker->runOnce() && Store::$events === [], 'disabled before claim');
|
||||
Store::$enabled = true; Store::$verified = false;
|
||||
$expect(!$worker->runOnce() && Store::$events === [], 'unverified before claim');
|
||||
Store::$verified = true;
|
||||
$expect(!$worker->runOnce() && Store::$events === ['claim'], 'unverified task is not consumed; queue may fence stale bindings');
|
||||
Store::$events = []; Store::$verified = true;
|
||||
$expect($worker->runOnce(), 'success consumed task');
|
||||
$expect(isset(Store::$events[count(Store::$events)-1]['complete']), 'success completed after authorization');
|
||||
foreach (['uncertain' => 'UPSTREAM_UNCERTAIN', 'internal' => 'INTERNAL_ERROR', 'revoke' => 'LEASE_LOST'] as $mode => $code) {
|
||||
@@ -68,5 +72,18 @@ namespace {
|
||||
Actors::$active = true; Access::$allowed = false; Store::$events = [];
|
||||
$worker->runOnce(); $last = end(Store::$events);
|
||||
$expect($last['fail'] === 'INTERNAL_ERROR' && !$last['uncertain'], 'scope denied before upstream');
|
||||
Access::$allowed = true; Actors::$active = true;
|
||||
foreach (['change-before-send' => false, 'change-after-send' => true] as $mode => $uncertain) {
|
||||
Dify::$mode = $mode; Store::$events = []; Store::$providerMatches = true;
|
||||
$worker->runOnce(); $last = end(Store::$events);
|
||||
$expect($last['fail'] === 'PROVIDER_CONFIGURATION_CHANGED' && $last['uncertain'] === $uncertain,
|
||||
$mode . ' is fenced with correct upstream uncertainty');
|
||||
$expect(!array_filter(Store::$events, static fn ($event): bool => is_array($event) && isset($event['complete'])),
|
||||
$mode . ' never completes result under a changed provider');
|
||||
}
|
||||
Store::$providerMatches = false; Store::$events = []; Dify::$mode = 'success';
|
||||
$worker->runOnce(); $last = end(Store::$events);
|
||||
$expect($last['fail'] === 'PROVIDER_CONFIGURATION_CHANGED' && !$last['uncertain'] && count(Store::$events) === 2,
|
||||
'Initial missing or stale task fingerprint is rejected before any heartbeat or transport');
|
||||
echo 'FOLLOWUP_AUDIO_WORKER assertions=' . $checks . ' PASS gate=1 actor_recheck=1 reconciliation=1' . PHP_EOL;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user