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