feat: add reviewed follow-up audio patient enrichment
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Real ffprobe/ffmpeg + loopback multipart, synthetic SQLite only. No application/.env initialization. */
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
||||
|
||||
use app\common\service\followupaudio\FollowupAudioDify as Dify;
|
||||
use app\common\service\followupaudio\FollowupAudioException as AudioError;
|
||||
use app\common\service\followupaudio\FollowupAudioUpload as Upload;
|
||||
use think\Container;
|
||||
|
||||
$directory = sys_get_temp_dir() . '/followup-audio-media-' . bin2hex(random_bytes(6));
|
||||
mkdir($directory . '/private', 0700, true);
|
||||
new think\App(); // Never initialize real app services or config.
|
||||
$pdo = new PDO('sqlite:' . $directory . '/test.sqlite');
|
||||
$pdo->exec('CREATE TABLE zyt_dict_data (id INTEGER PRIMARY KEY, type_value TEXT, status INTEGER, sort INTEGER, name TEXT, value TEXT)');
|
||||
$pdo->exec('CREATE TABLE zyt_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' => $directory . '/test.sqlite', 'prefix' => 'zyt_']]]);
|
||||
Container::getInstance()->instance('think\DbManager', $manager);
|
||||
$config = new think\Config();
|
||||
$settings = ['enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen'],
|
||||
'private_dir' => $directory . '/private', 'ffprobe' => 'ffprobe', 'ffmpeg' => 'ffmpeg', 'normalize_timeout' => 120,
|
||||
'max_bytes' => 524288000, 'max_seconds' => 3600, 'upstream_max_bytes' => 20971520, 'request_timeout' => 5];
|
||||
$config->set($settings, 'followup_audio'); Container::getInstance()->instance('config', $config);
|
||||
$socket = stream_socket_server('tcp://127.0.0.1:0', $errno, $error);
|
||||
$port = (int) substr(strrchr(stream_socket_get_name($socket, false), ':'), 1); fclose($socket);
|
||||
$environment = getenv(); $environment['FOLLOWUP_AUDIO_MOCK_DIR'] = $directory;
|
||||
$process = proc_open([PHP_BINARY, '-n', '-d', 'upload_max_filesize=24M', '-d', 'post_max_size=25M', '-S', '127.0.0.1:' . $port,
|
||||
__DIR__ . '/fixtures/followup_audio/dify_router.php'], [0 => ['pipe', 'r'],
|
||||
1 => ['file', $directory . '/server.stdout', 'a'], 2 => ['file', $directory . '/server.stderr', 'a']], $pipes, __DIR__, $environment);
|
||||
if (!is_resource($process)) { throw new RuntimeException('MOCK_SERVER_START_FAILED'); } fclose($pipes[0]);
|
||||
$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 $e) { $expect($e->errorCode === $code && $e->uncertain === $uncertain,
|
||||
'expected ' . $code . ', got ' . $e->errorCode); return; }
|
||||
throw new RuntimeException('Expected ' . $code);
|
||||
};
|
||||
$command = static function (array $args): void {
|
||||
$p = proc_open($args, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
|
||||
if (!is_resource($p)) { throw new RuntimeException('TEST_PROCESS_FAILED'); }
|
||||
fclose($pipes[0]); $out = stream_get_contents($pipes[1]); $err = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]); fclose($pipes[2]); $exit = proc_close($p);
|
||||
if ($exit !== 0) { throw new RuntimeException('TEST_PROCESS_FAILED exit=' . $exit . ' stderr=' . $err . ' stdout=' . $out); }
|
||||
};
|
||||
$makeWav = static function (string $path, int $seconds): void {
|
||||
$size = 16000 * 2 * $seconds;
|
||||
$f = fopen($path, 'xb');
|
||||
fwrite($f, 'RIFF' . pack('V', 36 + $size) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 16000, 32000, 2, 16)
|
||||
. 'data' . pack('V', $size));
|
||||
// Sparse silence: a valid complete waveform, not a forged duration tag, without allocating 115 MB in memory.
|
||||
fseek($f, 44 + $size - 1); fwrite($f, "\0"); fclose($f); chmod($path, 0600);
|
||||
};
|
||||
$makeTask = static function (string $path, string $extension) use ($directory): array {
|
||||
$id = bin2hex(random_bytes(24)); $dir = $directory . '/private/' . $id; mkdir($dir . '/parts', 0700, true);
|
||||
$target = $dir . '/audio.' . $extension; rename($path, $target); chmod($target, 0600);
|
||||
$duration = Upload::inspect($target, $extension)['duration_seconds']; $hash = hash_file('sha256', $target);
|
||||
think\facade\Db::name('followup_audio_upload')->insert(['id' => $id, 'diagnosis_id' => 91, 'actor_id' => 7,
|
||||
'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',
|
||||
'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']]]);
|
||||
};
|
||||
$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)) : [];
|
||||
try {
|
||||
$ready = false;
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
$c = @stream_socket_client('tcp://127.0.0.1:' . $port, $errno, $error, 0.1);
|
||||
if ($c) { fclose($c); $ready = true; break; } usleep(50000);
|
||||
}
|
||||
$expect($ready, 'loopback server ready');
|
||||
$makeWav($directory . '/source.wav', 3);
|
||||
$command(['ffmpeg', '-nostdin', '-hide_banner', '-v', 'error', '-i', $directory . '/source.wav', '-c:a', 'aac', $directory . '/source.m4a']);
|
||||
$command(['ffmpeg', '-nostdin', '-hide_banner', '-v', 'error', '-i', $directory . '/source.wav', '-c:a', 'libmp3lame', $directory . '/source.mp3']);
|
||||
// 150 complete synthetic AMR-NB mode-7 frames: exactly three seconds. No patient recording.
|
||||
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;
|
||||
$events = []; $copies = [];
|
||||
$heartbeat = static function (array $fields = []) use (&$events, &$copies, $task): bool {
|
||||
$events[] = $fields;
|
||||
foreach (glob(dirname($task['path']) . '/processing.*.mp3') ?: [] as $copy) {
|
||||
$copies[] = ['path' => $copy, 'mode' => fileperms($copy) & 0777, 'bytes' => filesize($copy)];
|
||||
}
|
||||
return true;
|
||||
};
|
||||
$result = $adapter('media-' . $extension)->analyze($task, $heartbeat);
|
||||
$uploads = array_values(array_filter($requests(), static fn (array $r): bool => $r['scenario'] === 'media-' . $extension && $r['upload']));
|
||||
$expect(count($uploads) === 1 && count($result['items']) === 1, $extension . ' actual multipart accepted and factual review returned');
|
||||
$expect(hash_file('sha256', $task['path']) === $task['sha256'], $extension . ' pristine original retained');
|
||||
$expect(glob(dirname($task['path']) . '/processing.*') === [], $extension . ' processing copy removed after success');
|
||||
if ($extension === 'amr') {
|
||||
$expect(abs($task['duration_seconds'] - 3) < 0.001, 'AMR duration uses complete packet count, not bitrate estimate');
|
||||
$expect($uploads[0]['mime'] === 'audio/mpeg' && $uploads[0]['filename'] === 'followup-audio.mp3', 'AMR uses compatible MP3 processing copy');
|
||||
$expect($copies !== [] && array_unique(array_column($copies, 'mode')) === [0600], 'processing copy private 0600');
|
||||
} else {
|
||||
$expect($uploads[0]['sha256'] === $task['sha256'], $extension . ' small compatible original unchanged in actual request');
|
||||
}
|
||||
}
|
||||
$wave = $tasks['wav'];
|
||||
foreach (['missing-ffmpeg', 'limit', 'timeout', 'truncated', 'lease'] as $failure) {
|
||||
$before = count($requests()); $events = []; $extra = ['upstream_max_bytes' => 20000];
|
||||
$callback = static function (array $fields = []) use (&$events): bool { $events[] = $fields; return true; };
|
||||
$code = 'AUDIO_NORMALIZATION_FAILED';
|
||||
if ($failure === 'missing-ffmpeg') { $extra['ffmpeg'] = $directory . '/missing-tool'; }
|
||||
if ($failure === 'limit') { $extra['upstream_max_bytes'] = 1; $code = 'UPSTREAM_AUDIO_LIMIT'; }
|
||||
if ($failure === 'timeout') {
|
||||
$extra['ffmpeg'] = $directory . '/slow-ffmpeg'; $extra['normalize_timeout'] = 1;
|
||||
file_put_contents($extra['ffmpeg'], "#!/bin/sh\nexec sleep 5\n"); chmod($extra['ffmpeg'], 0700); $code = 'AUDIO_NORMALIZATION_TIMEOUT';
|
||||
}
|
||||
if ($failure === 'truncated') {
|
||||
$makeWav($directory . '/fragment.wav', 1);
|
||||
$command(['ffmpeg', '-nostdin', '-hide_banner', '-v', 'error', '-i', $directory . '/fragment.wav',
|
||||
'-c:a', 'libmp3lame', '-ar', '16000', '-b:a', '32k', $directory . '/fragment.mp3']);
|
||||
$extra['ffmpeg'] = $directory . '/truncated-ffmpeg';
|
||||
file_put_contents($extra['ffmpeg'], "#!/bin/sh\nfor last; do :; done\ncp " . escapeshellarg($directory . '/fragment.mp3') . ' "$last"' . "\n");
|
||||
chmod($extra['ffmpeg'], 0700);
|
||||
}
|
||||
if ($failure === 'lease') { $callback = static fn (): bool => false; $code = 'LEASE_LOST'; }
|
||||
$expectError(static fn () => $adapter('media-failure', $extra)->analyze($wave, $callback), $code);
|
||||
$expect(count($requests()) === $before && !array_filter($events, static fn (array $row): bool => isset($row['upstream_started_at'])),
|
||||
$failure . ' local failure never uploads or creates upstream intent');
|
||||
$expect(glob(dirname($wave['path']) . '/processing.*') === [] && hash_file('sha256', $wave['path']) === $wave['sha256'],
|
||||
$failure . ' cleanup preserves exact original');
|
||||
}
|
||||
$before = count($requests());
|
||||
$expectError(static fn () => $adapter('unknown', ['upstream_max_bytes' => 20000])->analyze($wave, static fn (): bool => true), 'UPSTREAM_UNCERTAIN', true);
|
||||
$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');
|
||||
$copyMetadata = [];
|
||||
$adapter('media-long')->analyze($long, static function (array $fields = []) use ($long, &$copyMetadata): bool {
|
||||
if (isset($fields['upstream_started_at'])) {
|
||||
foreach (glob(dirname($long['path']) . '/processing.*.mp3') ?: [] as $copy) {
|
||||
$copyMetadata = ['bytes' => filesize($copy), 'mode' => fileperms($copy) & 0777];
|
||||
$p = proc_open(['ffprobe', '-v', 'error', '-show_entries', 'format=duration:stream=codec_name,sample_rate,channels',
|
||||
'-of', 'json', $copy], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
|
||||
fclose($pipes[0]); $copyMetadata += json_decode(stream_get_contents($pipes[1]), true);
|
||||
stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); proc_close($p);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
$uploads = array_values(array_filter($requests(), static fn (array $r): bool => $r['scenario'] === 'media-long' && $r['upload']));
|
||||
$expect(filesize($long['path']) > 52428800 && hash_file('sha256', $long['path']) === $long['sha256'], 'one-hour >50MiB original preserved byte-for-byte');
|
||||
$expect(count($uploads) === 1 && $uploads[0]['size'] <= 20971520 && $uploads[0]['mime'] === 'audio/mpeg', 'entire hour uploaded once inside conservative 20MiB budget');
|
||||
$expect(abs((float) $copyMetadata['format']['duration'] - 3600) < 0.25 && $copyMetadata['mode'] === 0600
|
||||
&& $copyMetadata['streams'][0]['codec_name'] === 'mp3' && (int) $copyMetadata['streams'][0]['channels'] === 1
|
||||
&& (int) $copyMetadata['streams'][0]['sample_rate'] === 16000, 'full-duration mono16k processing copy independently inspected before transport');
|
||||
$expect(glob(dirname($long['path']) . '/processing.*') === [], 'hour processing copy removed');
|
||||
echo 'FOLLOWUP_AUDIO_MEDIA assertions=' . $checks . ' PASS formats=MP3,M4A,WAV,AMR original_retained=1 hour_complete=1 bounded_private_copy=1 failure_no_send=1' . PHP_EOL;
|
||||
} finally {
|
||||
proc_terminate($process); proc_close($process);
|
||||
$manager->connect()->close(); $pdo = null;
|
||||
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
|
||||
foreach ($files as $file) { $file->isDir() && !$file->isLink() ? rmdir($file->getPathname()) : unlink($file->getPathname()); }
|
||||
rmdir($directory);
|
||||
}
|
||||
Reference in New Issue
Block a user