feat: configure audio providers and add local sample acceptance

This commit is contained in:
2026-10-07 18:15:32 +08:00
parent 70be2fc70f
commit fea33285e8
21 changed files with 1103 additions and 131 deletions
@@ -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);
}