feat: add reviewed follow-up audio patient enrichment

This commit is contained in:
2026-09-29 15:44:30 +08:00
parent 3cdc812f2b
commit 70be2fc70f
46 changed files with 7384 additions and 246 deletions
+85
View File
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace app\common\service\followupaudio {
final class FollowupAudioStore
{
public static function task(int $id): array
{
return ['id' => $id, 'diagnosis_id' => 91, 'patient_id' => 101];
}
}
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
$app = new \think\App(dirname(__DIR__) . '/'); $app->initialize();
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',
'charset' => 'utf8mb4', 'prefix' => 'faa_', 'debug' => false,
]]], 'database');
$db = \think\facade\Db::class;
$tables = [
'admin' => 'id BIGINT PRIMARY KEY, name VARCHAR(30), root INT, disable INT, delete_time BIGINT NULL',
'admin_role' => 'admin_id BIGINT, role_id BIGINT, PRIMARY KEY(admin_id,role_id)',
'admin_dept' => 'admin_id BIGINT, dept_id BIGINT, PRIMARY KEY(admin_id,dept_id)',
'system_role_menu' => 'role_id BIGINT, menu_id BIGINT, PRIMARY KEY(role_id,menu_id)',
'system_menu' => 'id BIGINT PRIMARY KEY, perms VARCHAR(100), is_disable INT',
'tcm_diagnosis' => 'id BIGINT PRIMARY KEY, patient_id BIGINT, assistant_id BIGINT, status INT, delete_time BIGINT NULL',
];
foreach ($tables as $name => $fields) { $db::execute("DROP TABLE IF EXISTS faa_{$name}"); $db::execute("CREATE TABLE faa_{$name} ({$fields}) ENGINE=InnoDB"); }
$db::name('admin')->insertAll([
['id' => 7, 'name' => 'root fixture', 'root' => 1, 'disable' => 0],
['id' => 8, 'name' => 'assistant fixture', 'root' => 0, 'disable' => 0],
['id' => 9, 'name' => 'other fixture', 'root' => 0, 'disable' => 0],
]);
$db::name('admin_role')->insert(['admin_id' => 8, 'role_id' => 2]);
$db::name('system_menu')->insertAll([
['id' => 1, 'perms' => 'tcm.diagnosis/edit', 'is_disable' => 0],
['id' => 2, 'perms' => 'tcm.diagnosis/dailyRecord', 'is_disable' => 0],
]);
$db::name('system_role_menu')->insert(['role_id' => 2, 'menu_id' => 1]);
$db::name('tcm_diagnosis')->insertAll([
['id' => 91, 'patient_id' => 101, 'assistant_id' => 8, 'status' => 1],
['id' => 92, 'patient_id' => 102, 'assistant_id' => 9, 'status' => 1],
]);
$a = \app\common\service\followupaudio\FollowupAudioAccess::class;
$checks = 0;
$ok = function (bool $yes, string $label) use (&$checks): void { if (!$yes) { throw new \RuntimeException($label); } $checks++; };
$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]);
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');
$ok(!$a::canDaily(8, ['root' => 1]), 'missing daily permission');
$deny(fn () => $a::diagnosis(91, 8, [], true), 'daily write denied');
$db::name('system_role_menu')->insert(['role_id' => 2, 'menu_id' => 2]);
$ok($a::canDaily(8, []), 'fresh daily grant');
// A consistent read exists before another connection commits permission revocation.
$db::startTrans(); $db::name('system_role_menu')->select();
$other->exec('DELETE FROM faa_system_role_menu WHERE role_id=2 AND menu_id=2');
$ok(!$a::canDaily(8, []), 'revocation must beat repeatable-read snapshot'); $db::rollback();
$db::startTrans(); $db::name('tcm_diagnosis')->where('id', 91)->find();
$other->exec('UPDATE faa_tcm_diagnosis SET assistant_id=9 WHERE id=91');
$deny(fn () => $a::diagnosis(91, 8, []), 'reassignment must beat repeatable-read snapshot'); $db::rollback();
$other->exec('UPDATE faa_tcm_diagnosis SET assistant_id=8 WHERE id=91');
$db::startTrans(); $db::name('admin')->where('id', 8)->find();
$other->exec('UPDATE faa_admin SET disable=1 WHERE id=8');
$deny(fn () => $a::diagnosis(91, 8, []), 'disabled actor must beat repeatable-read snapshot'); $db::rollback();
$other->exec('UPDATE faa_admin SET disable=0 WHERE id=8');
$ok((int) $a::task(1, 8, [])['id'] === 1, 'task binding before rebind');
$other->exec('UPDATE faa_tcm_diagnosis SET patient_id=999 WHERE id=91');
$deny(fn () => $a::task(1, 8, []), 'historical recording must not follow patient rebind');
$other->exec('UPDATE faa_tcm_diagnosis SET status=0 WHERE id=91');
$deny(fn () => $a::diagnosis(91, 7, ['root' => 1]), 'inactive diagnosis even root');
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}"); }
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace app\common\service\followupaudio {
final class FollowupAudioStore {
public static bool $fail = false;
public static int $errors = 0;
public static function cleanupExpired(): array {
if (self::$fail) { throw new \RuntimeException('private-patient-path'); }
return ['tasks_purged' => 2, 'uploads_deleted' => 2, 'active_skipped' => 1, 'errors' => self::$errors,
'private_path' => '/private/patient-do-not-print'];
}
}
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
use app\common\service\followupaudio\FollowupAudioStore as Store;
use app\command\FollowupAudioCleanup as Cleanup;
$checks = 0;
foreach ([[false, 0, 0], [false, 1, 1], [true, 0, 1]] as [$fail, $errors, $expected]) {
Store::$fail = $fail; Store::$errors = $errors;
$output = new \think\console\Output('buffer');
$code = (new Cleanup())->run(new \think\console\Input([]), $output);
$text = $output->fetch();
if ($code !== $expected || str_contains($text, 'private')) { throw new \RuntimeException('Cleanup exit/redaction mismatch'); }
$checks += 2;
}
echo 'FOLLOWUP_AUDIO_CLEANUP_COMMAND assertions=' . $checks . ' PASS errors_nonzero=1 no_sensitive_output=1' . PHP_EOL;
}
+470
View File
@@ -0,0 +1,470 @@
<?php
declare(strict_types=1);
/** Local disposable MySQL only; NEVER initialize the application or load its .env/config/database.php. */
require dirname(__DIR__) . '/vendor/autoload.php';
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\FollowupAudioStore as Store;
use think\Container;
use think\facade\Db;
$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');
}
$mode = $argv[1] ?? '';
$child = in_array($mode, ['--claim', '--apply', '--create'], true);
$database = $child ? (string) getenv('FOLLOWUP_AUDIO_TEST_DATABASE') : 'fa_core_' . bin2hex(random_bytes(6));
if (!preg_match('/^fa_core_[a-f0-9]{12}$/D', $database)) { throw new RuntimeException('Disposable test database name required'); }
$pdo = new PDO("mysql:host=127.0.0.1;port={$port};charset=utf8mb4", 'root', (string) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PASSWORD'),
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
if (!$child) { $pdo->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4"); }
$pdo->exec("USE `{$database}`");
$app = new think\App(); // no initialize(): no environment/config discovery.
$manager = new think\DbManager();
$manager->setConfig(['default' => 'mysql', 'auto_timestamp' => true, 'datetime_format' => false,
'connections' => ['mysql' => ['type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => $port,
'database' => $database, 'username' => 'root', 'password' => (string) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PASSWORD'),
'charset' => 'utf8mb4', 'prefix' => 'zyt_', 'fields_strict' => true, 'trigger_sql' => false]]]);
Container::getInstance()->instance('think\DbManager', $manager);
$config = new think\Config();
$private = $child ? (string) getenv('FOLLOWUP_AUDIO_TEST_PRIVATE') : '/private/tmp/' . $database;
$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);
$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') {
$result = Store::create(['id' => $argv[2]], $argv[3], 'qwen', 1, $root);
echo json_encode(['id' => $result['task_id'], 'reused' => $result['reused']]) . "\n";
exit(0);
}
if ($mode === '--apply') {
$result = Apply::apply((int) $argv[2], (int) $argv[3], json_decode(file_get_contents($argv[4]), true, 512, JSON_THROW_ON_ERROR), 1, $root);
echo json_encode(['status' => $result['status'], 'ids' => array_column($result['applied_items'], 'record_id')]) . "\n";
exit(0);
}
$checks = 0;
$expect = static function (bool $ok, string $message) use (&$checks): void {
if (!$ok) { throw new RuntimeException($message); }
$checks++;
};
$reject = static function (callable $call, string $code) use ($expect): void {
try { $call(); } catch (Throwable $e) { $expect(str_contains($e->getMessage(), $code), 'Expected ' . $code . ', observed ' . $e->getMessage()); return; }
throw new RuntimeException('Expected rejection: ' . $code);
};
$runChildren = static function (array $arguments) use ($database, $private): array {
$jobs = [];
$env = array_merge(getenv(), ['FOLLOWUP_AUDIO_TEST_DATABASE' => $database, 'FOLLOWUP_AUDIO_TEST_PRIVATE' => $private]);
foreach ($arguments as $args) {
$pipes = [];
$process = proc_open(array_merge([PHP_BINARY, __FILE__], $args), [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, $env);
if (!is_resource($process)) { throw new RuntimeException('Child process unavailable'); }
fclose($pipes[0]);
$jobs[] = [$process, $pipes];
}
$results = [];
foreach ($jobs as [$process, $pipes]) {
$stdout = stream_get_contents($pipes[1]); $stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]); fclose($pipes[2]); $exit = proc_close($process);
if ($exit !== 0 || $stderr !== '') { throw new RuntimeException('Child exit=' . $exit . ' stderr=' . $stderr . ' stdout=' . $stdout); }
$results[] = json_decode(trim($stdout), true, 512, JSON_THROW_ON_ERROR);
}
return $results;
};
try {
mkdir($private, 0700, true);
file_put_contents($private . '/unrelated-sentinel', 'KEEP');
$pdo->exec('CREATE TABLE zyt_admin(id INT PRIMARY KEY,name VARCHAR(50),root INT,disable INT,delete_time INT NULL)');
$pdo->exec("INSERT INTO zyt_admin VALUES(1,'Synthetic Root',1,0,NULL),(2,'Synthetic Assistant',0,0,NULL),(3,'Synthetic Limited',0,0,NULL)");
$pdo->exec('CREATE TABLE zyt_admin_role(admin_id INT,role_id INT)');
$pdo->exec('INSERT INTO zyt_admin_role VALUES(2,2),(3,5)');
$pdo->exec('CREATE TABLE zyt_admin_dept(admin_id INT,dept_id INT)');
$pdo->exec('CREATE TABLE zyt_admin_jobs(admin_id INT,jobs_id INT)');
$pdo->exec('CREATE TABLE zyt_system_menu(id INT PRIMARY KEY,perms VARCHAR(100),is_disable INT)');
$pdo->exec("INSERT INTO zyt_system_menu VALUES(1,'tcm.diagnosis/edit',0),(2,'tcm.diagnosis/dailyRecord',0),(3,'firstvisit.wecomPromotion/overview',0)");
$pdo->exec('CREATE TABLE zyt_system_role_menu(role_id INT,menu_id INT)');
$pdo->exec('INSERT INTO zyt_system_role_menu VALUES(2,1),(2,2),(2,3),(5,1),(5,3)');
$pdo->exec('CREATE TABLE zyt_doctor_appointment(id INT PRIMARY KEY,patient_id INT,doctor_id INT,status INT)');
$pdo->exec('CREATE TABLE zyt_dict_data(id INT PRIMARY KEY AUTO_INCREMENT,name VARCHAR(50),value VARCHAR(50),type_value VARCHAR(50),status INT,sort INT)');
$pdo->exec("INSERT INTO zyt_dict_data(name,value,type_value,status,sort) VALUES('Synthetic Enabled','dry','appetite',1,1),('Synthetic Disabled','bad','appetite',0,2)");
$pdo->exec('CREATE TABLE zyt_tcm_diagnosis(id INT PRIMARY KEY,patient_id INT,assistant_id INT DEFAULT 2,status INT DEFAULT 1,delete_time INT NULL,update_time INT DEFAULT 0) ENGINE=InnoDB');
foreach (Fields::diagnosisKeys() as $key) { $pdo->exec("ALTER TABLE zyt_tcm_diagnosis ADD `{$key}` TEXT NULL"); }
$pdo->exec("INSERT INTO zyt_tcm_diagnosis(id,patient_id,patient_name,assistant_id,symptoms) VALUES(1,101,'Synthetic A',2,''),(2,202,'Synthetic B',3,''),(3,303,'Synthetic C',2,'')");
$pdo->exec('CREATE TABLE zyt_tcm_prescription_order(id INT PRIMARY KEY,diagnosis_id INT,creator_id INT,create_time INT,fulfillment_status INT,delete_time INT NULL,KEY idx_diagnosis(diagnosis_id)) ENGINE=InnoDB');
$pdo->exec('CREATE TABLE zyt_tcm_blood_record(id INT PRIMARY KEY AUTO_INCREMENT,diagnosis_id INT,patient_id INT,record_date INT,record_time VARCHAR(10),source INT DEFAULT 0,create_time INT DEFAULT 0,update_time INT DEFAULT 0,delete_time INT NULL,KEY idx_day(diagnosis_id,record_date)) ENGINE=InnoDB');
$pdo->exec('CREATE TABLE zyt_patient_diet_record(id INT PRIMARY KEY AUTO_INCREMENT,diagnosis_id INT,patient_id INT,record_date INT,create_time INT DEFAULT 0,update_time INT DEFAULT 0,delete_time INT NULL,KEY idx_day(diagnosis_id,record_date)) ENGINE=InnoDB');
$pdo->exec('CREATE TABLE zyt_patient_exercise_record(id INT PRIMARY KEY AUTO_INCREMENT,diagnosis_id INT,patient_id INT,record_date INT,create_time INT DEFAULT 0,update_time INT DEFAULT 0,delete_time INT NULL,KEY idx_day(diagnosis_id,record_date)) ENGINE=InnoDB');
foreach (['blood' => 'tcm_blood_record', 'diet' => 'patient_diet_record', 'exercise' => 'patient_exercise_record'] as $kind => $table) {
$keys = $kind === 'diet' ? ['breakfast_foods', 'lunch_foods', 'dinner_foods', 'note',
'breakfast_images', 'lunch_images', 'dinner_images'] : Fields::keys($kind);
foreach ($keys as $key) { $pdo->exec("ALTER TABLE zyt_{$table} ADD `{$key}` TEXT NULL"); }
}
$pdo->exec('CREATE TABLE zyt_tracking_note(id INT PRIMARY KEY AUTO_INCREMENT,diagnosis_id INT,admin_id INT,note_date DATE,content TEXT,create_time INT DEFAULT 0,update_time INT DEFAULT 0,delete_time INT NULL,UNIQUE KEY uk_day(diagnosis_id,note_date)) ENGINE=InnoDB');
$migration = preg_replace('/^--.*$/m', '', file_get_contents(dirname(__DIR__) . '/sql/2026_09_29_followup_audio.sql'));
for ($i = 0; $i < 2; $i++) {
foreach (explode(';', $migration) as $statement) { if (trim($statement) !== '') { $stmt = $pdo->query($statement); $stmt->closeCursor(); } }
}
$expect((int) Db::name('followup_audio_mutex')->count() === 2, 'Migration is rerunnable');
$expect(count(array_filter(Fields::catalog()['diagnosis'], static fn ($f) => $f['key'] === 'status')) === 0, 'Ownership/status fields are not writable');
$catalog = array_column(Fields::catalog()['diagnosis'], null, 'key');
$expect(count($catalog['appetite']['options']) === 1, 'Only active dictionary options');
$reject(fn () => Fields::validateValues('diagnosis', ['height' => 175.5]), 'NUMBER_INVALID');
$reject(fn () => Fields::validateValues('diagnosis', ['weight' => 1000]), 'NUMBER_INVALID');
$expect(Fields::validateValues('diagnosis', ['gender' => 0]) === ['gender' => 0], 'Explicit zero is preserved');
$reject(fn () => Fields::validateValues('diagnosis', ['patient_id' => 7]), 'FIELD_INVALID');
$reject(fn () => Fields::validateValues('diagnosis', ['appetite' => ['bad']]), 'OPTION_INVALID');
$reject(fn () => Fields::validateValues('blood', ['fasting_blood_sugar' => NAN]), 'NUMBER_INVALID');
$reject(fn () => Fields::validateValues('blood', ['fasting_blood_sugar' => 7.123]), 'NUMBER_INVALID');
$reject(fn () => Fields::validateValues('diagnosis', ['diagnosis_date' => '2026-02-30']), 'DATE_INVALID');
$reject(fn () => Policy::strictTime('25:00'), 'TIME_INVALID');
$expect(Policy::strictTime('13:41:59') === '13:41', 'Valid seconds input canonicalized to minute precision');
$recordedAt = '2026-09-20 20:00:00';
$event = static fn (string $kind, array $values, string $quote, array $extra = []): array => array_replace([
'kind' => $kind, 'values' => $values, 'date_text' => '昨天', 'time_text' => '', 'record_time' => '08:00',
'evidence' => [['text' => $quote]],
], $extra);
$extract = static fn (array $items): array => ['summary' => 'Synthetic follow-up summary',
'transcript' => implode(' ', array_map(static fn ($x) => $x['evidence'][0]['text'] ?? '', $items)), 'uncertainties' => [], 'items' => $items];
$norm = Policy::normalizeExtraction($extract([
$event('blood', ['fasting_blood_sugar' => 6.1], 'synthetic first 6.1'),
$event('blood', ['fasting_blood_sugar' => 6.1], 'synthetic second 6.1'),
$event('blood', ['fasting_blood_sugar' => 6.1], 'synthetic first 6.1'),
$event('blood', ['systolic_pressure' => 120], 'synthetic yesterday noon', ['record_time' => null, 'time_period' => 'noon']),
$event('blood', ['diastolic_pressure' => 80], 'synthetic no date', ['date_text' => '', 'record_time' => null]),
$event('diagnosis', ['allergy_history' => 0], 'synthetic explicitly no allergy'),
]), $recordedAt);
$expect(count($norm['items']) === 5, 'Exact same event evidence deduped; equal values with distinct evidence retained');
$expect($norm['items'][0]['record_date'] === '2026-09-19', 'Relative dates use recordedAt, not execution day');
$expect($norm['items'][2]['record_time'] === '12:00' && $norm['items'][2]['time_period'] === '中午' && $norm['items'][2]['time_estimated'], 'Approved noon default is retained as an estimated clock');
$expect($norm['items'][3]['record_date'] === null && $norm['items'][3]['needs_review'], 'Missing date stays unresolved');
$expect($norm['items'][4]['values'] === ['allergy_history' => 0] && !isset($norm['items'][4]['values']['family_history']), 'Missing history is not normal');
foreach (['morning' => '08:00', 'noon' => '12:00', 'afternoon' => '15:00', 'evening' => '20:00', 'bedtime' => '22:00'] as $period => $clock) {
$periodResult = Policy::normalizeExtraction($extract([
$event('blood', ['systolic_pressure' => 120], 'synthetic first ' . $period, ['record_time' => null, 'time_period' => $period]),
$event('blood', ['systolic_pressure' => 120], 'synthetic second ' . $period, ['record_time' => null, 'time_period' => $period]),
]), $recordedAt);
$expect(count($periodResult['items']) === 2 && $periodResult['items'][0]['record_time'] === $clock
&& $periodResult['items'][0]['time_estimated'] && $periodResult['items'][0]['needs_review'],
'Approved estimated ' . $period . ' clock never collapses distinct equal measurements');
}
$ambiguous = Policy::normalizeExtraction($extract([$event('blood', ['systolic_pressure' => 120], 'synthetic range',
['date_text' => '昨天或前天', 'record_date' => '2026-09-19'])]), $recordedAt);
$expect($ambiguous['items'][0]['record_date'] === null && $ambiguous['items'][0]['needs_review'],
'Explicit model date cannot resolve ambiguous spoken range');
$mismatch = Policy::normalizeExtraction($extract([$event('blood', ['systolic_pressure' => 120], 'synthetic conflicting date', ['record_date' => '2026-09-18'])]), $recordedAt);
$expect($mismatch['items'][0]['record_date'] === null, 'Conflicting date evidence left unresolved');
$upload = static function (int $diagnosisId = 1, int $actor = 1, ?array $copy = null) use ($private): array {
$id = bin2hex(random_bytes(24)); $dir = $private . '/' . $id;
mkdir($dir . '/parts', 0700, true);
$pcm = str_repeat("\0", 1536) . random_bytes(64); $wav = 'RIFF' . pack('V', 36 + strlen($pcm)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 8000, 16000, 2, 16) . 'data' . pack('V', strlen($pcm)) . $pcm;
if ($copy !== null) { $wav = file_get_contents($private . '/' . $copy['id'] . '/audio.wav'); }
file_put_contents($dir . '/audio.wav', $wav); chmod($dir . '/audio.wav', 0600);
$row = ['id' => $id, 'diagnosis_id' => $diagnosisId, 'actor_id' => $actor, 'file_name' => 'synthetic.wav', 'total_bytes' => strlen($wav),
'received_bytes' => strlen($wav), 'sha256' => hash('sha256', $wav), 'duration_seconds' => 0.1, 'extension' => 'wav',
'status' => 'complete', 'created_at' => time(), 'expires_at' => time() + 86400];
Db::name('followup_audio_upload')->insert($row); return $row;
};
$firstUpload = $upload();
$config->set(['enabled' => false], 'followup_audio');
$reject(fn () => Store::create($firstUpload, $recordedAt, 'qwen', 1, $root), 'DISABLED_OR_UNVERIFIED');
$expect(Store::claim() === null, 'Disabled gate blocks queue');
$config->set(['enabled' => true, 'audio_verified' => false], 'followup_audio');
$reject(fn () => Store::create($firstUpload, $recordedAt, 'qwen', 1, $root), 'DISABLED_OR_UNVERIFIED');
$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');
$config->set(['verified_profiles' => []], 'followup_audio');
$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');
$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);
$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);
$sameContent = Store::create($differentUpload, '2026-09-19 20:00:00', 'qwen', 1, $root);
$expect($sameContent['task_id'] === $a['task_id'] && $sameContent['reused'] && str_contains($sameContent['reuse_message'], '更正记录日期'),
'New upload/name/recordedAt cannot bypass content idempotency');
$expect(Store::task($a['task_id'])['upload_id'] === $firstUpload['id'] && Db::name('followup_audio_task')->where('upload_id', $differentUpload['id'])->count() === 0,
'Reuse keeps original audio and leaves duplicate staging upload unreferenced');
$b = Store::create($upload(), $recordedAt, 'qwen', 1, $root);
$claims = $runChildren([['--claim'], ['--claim']]);
$expect(count(array_filter(array_column($claims, 'id'))) === 1, 'Two real PHP workers obey global concurrency mutex: ' . json_encode($claims));
$running = Db::name('followup_audio_task')->where('status', 'running')->find();
$expect(!Store::heartbeat((int) $running['id'], 'forged-token'), 'Lease rejects stale/foreign token');
$expect(!Store::complete((int) $running['id'], 'forged-token', []), 'Completion token fenced');
$expect(Store::heartbeat((int) $running['id'], $running['lease_token']), 'Current heartbeat accepted');
$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');
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');
$reject(fn () => Store::retry((int) $running['id']), 'RETRY_NOT_SAFE');
$uncertainUpload = Db::name('followup_audio_upload')->where('id', $running['upload_id'])->find();
$uncertainCopy = Store::create($upload(1, 1, $uncertainUpload), $recordedAt, 'qwen', 1, $root);
$expect($uncertainCopy['task_id'] === (int) $running['id'] && $uncertainCopy['reused'] && $uncertainCopy['status'] === 'needs_reconciliation',
'Unknown upstream result cannot be bypassed by re-upload');
$expect(!Store::heartbeat((int) $running['id'], $running['lease_token']), 'Expired worker cannot renew after fence');
$expect(Store::fail((int) $other['id'], $other['lease_token'], 'LOCAL_PROBE_FAILED', 'sensitive text MUST NOT persist'), 'Pre-network failure recorded');
$expect(!str_contains(json_encode(Store::task((int) $other['id'])), 'MUST NOT'), 'Error bodies never persisted');
$expect(Store::retry((int) $other['id'])['status'] === 'queued', 'Verified no-attempt local failure may retry');
$retried = Store::claim(); Store::fail((int) $retried['id'], $retried['lease_token'], 'LOCAL_PROBE_FAILED', '');
$parallelUploadA = $upload(); $parallelUploadB = $upload(1, 1, $parallelUploadA);
$parallelCreated = $runChildren([['--create', $parallelUploadA['id'], $recordedAt], ['--create', $parallelUploadB['id'], $recordedAt]]);
$expect($parallelCreated[0]['id'] === $parallelCreated[1]['id'] && count(array_filter(array_column($parallelCreated, 'reused'))) === 1,
'Two real concurrent creates for separate uploads of identical audio produce one task');
$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', '');
$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();
if ((int) ($claim['id'] ?? 0) !== $created['task_id']) { throw new RuntimeException('Unexpected pending test queue'); }
if (!Store::complete($created['task_id'], $claim['lease_token'], $extraction)) { throw new RuntimeException('Synthetic extraction completion rejected'); }
return Store::detail($created['task_id']);
};
$resolve = static function (array $detail): array {
return array_map(static function (array $item): array { $item['selected'] = true; $item['needs_review'] = false; return $item; }, $detail['items']);
};
$d = $makeReview($extract([$event('diagnosis', ['symptoms' => 'synthetic symptom'], 'synthetic symptom', ['date_text' => '今天'])]));
$expect(!$d['items'][0]['selected'] && $d['items'][0]['needs_review'] && !$d['items'][0]['conflict'],
'Even empty clinical diagnosis fields require explicit human review');
$unreviewed = $d['items']; $unreviewed[0]['selected'] = true;
$reject(fn () => Apply::apply($d['id'], $d['version'], $unreviewed, 1, $root), 'REVIEW_REQUIRED');
$rawTask = Store::task($d['id']);
$expect(!str_contains($rawTask['extraction_cipher'], 'synthetic symptom') && str_starts_with($rawTask['extraction_cipher'], 'v1:'), 'Extraction encrypted at rest');
$reject(fn () => Store::open($d['id'] + 1, 'extraction', $rawTask['extraction_cipher']), 'CIPHER_INVALID');
$forged = $d['items']; $forged[0]['evidence'][0]['text'] = 'forged evidence';
$reject(fn () => Store::saveDraft($d['id'], $d['version'], $forged, 1), 'IMMUTABLE_FIELD');
$forged = $d['items']; $forged[0]['expected_hash'] = str_repeat('0', 64);
$reject(fn () => Apply::apply($d['id'], $d['version'], $forged, 1, $root), 'IMMUTABLE_FIELD');
$forged = $d['items']; $forged[0]['values']['phone'] = '13800000000';
$reject(fn () => Store::saveDraft($d['id'], $d['version'], $forged, 1), 'UNPROPOSED_FIELD');
Db::name('tcm_diagnosis')->where('id', 1)->update(['symptoms' => 'newer manual value']);
$reject(fn () => Apply::apply($d['id'], $d['version'], $resolve($d), 1, $root), 'STALE_REVIEW');
$expect(Db::name('tcm_diagnosis')->where('id', 1)->value('symptoms') === 'newer manual value', 'Stale review cannot overwrite current data');
$fresh = Store::detail($d['id']);
$expect($fresh['version'] === $d['version'] + 1 && !$fresh['items'][0]['selected'] && $fresh['items'][0]['needs_review'], 'Stale conflict commits fresh review/version for human review');
$expect($fresh['items'][0]['current_values']['symptoms'] === 'newer manual value', 'Fresh review shows actual conflicting value');
Db::startTrans();
$reject(fn () => Apply::apply($fresh['id'], $fresh['version'], $resolve($fresh), 1, $root), 'NESTED_APPLY_FORBIDDEN');
Db::rollback();
$isolationBefore = Db::query('SELECT @@session.transaction_isolation AS isolation_level')[0]['isolation_level'];
$applied = Apply::apply($fresh['id'], $fresh['version'], $resolve($fresh), 1, $root);
$expect(Db::query('SELECT @@session.transaction_isolation AS isolation_level')[0]['isolation_level'] === $isolationBefore, 'Apply does not change session transaction isolation');
$expect($applied['status'] === 'applied' && Db::name('tcm_diagnosis')->where('id', 1)->value('symptoms') === 'synthetic symptom', 'Explicitly resolved current conflict may apply');
$expect(Policy::canonical(Apply::apply($fresh['id'], $fresh['version'], $resolve($fresh), 1, $root)) === Policy::canonical($applied), 'Repeated apply is idempotent');
$day = Policy::dayTimestamp('2026-09-19');
$oldBlood = Db::name('tcm_blood_record')->insertGetId(['diagnosis_id' => 1, 'patient_id' => 101, 'record_date' => $day,
'record_time' => '07:00', 'fasting_blood_sugar' => 5.5, 'source' => 1]);
$foreignBlood = Db::name('tcm_blood_record')->insertGetId(['diagnosis_id' => 2, 'patient_id' => 202, 'record_date' => $day,
'record_time' => '07:00', 'fasting_blood_sugar' => 4.4]);
$daily = $makeReview($extract([
$event('blood', ['fasting_blood_sugar' => 6.1], 'synthetic 08:00 6.1'),
$event('blood', ['fasting_blood_sugar' => 6.1], 'synthetic 10:00 6.1', ['record_time' => '10:00']),
$event('diet', ['breakfast' => 'synthetic breakfast'], 'synthetic breakfast'),
$event('exercise', ['exercise_type' => 'synthetic walk', 'duration' => 25, 'intensity' => 1], 'synthetic walk 25 minutes'),
$event('tracking_note', ['content' => 'synthetic historical follow-up'], 'synthetic historical follow-up'),
]));
$expect($daily['items'][0]['target_id'] === null && $daily['items'][0]['possible_duplicate'] && !$daily['items'][0]['selected'], 'Same-day existing measurements are not silently merged');
$foreign = $resolve($daily); $foreign[0]['target_id'] = (int) $foreignBlood;
$reject(fn () => Apply::apply($daily['id'], $daily['version'], $foreign, 1, $root), 'TARGET_INVALID');
$result = Apply::apply($daily['id'], $daily['version'], $resolve($daily), 1, $root);
$expect(count($result['applied_items']) === 5, 'All requested kinds atomically adopted');
$bloodIds = array_column(array_filter($result['applied_items'], static fn ($r) => $r['kind'] === 'blood'), 'record_id');
$expect(count(array_unique($bloodIds)) === 2 && !in_array((int) $oldBlood, $bloodIds, true), 'Different times and evidence produce distinct actual rows');
$expect((float) Db::name('tcm_blood_record')->where('id', $oldBlood)->value('fasting_blood_sugar') === 5.5, 'Original manual/self row remains unchanged');
$expect((int) Db::name('tcm_blood_record')->where('id', $bloodIds[0])->value('followup_audio_task_id') === $daily['id'], 'Blood metadata carries source task');
$dietId = array_values(array_filter($result['applied_items'], static fn ($r) => $r['kind'] === 'diet'))[0]['record_id'];
$exerciseId = array_values(array_filter($result['applied_items'], static fn ($r) => $r['kind'] === 'exercise'))[0]['record_id'];
$dietColumns = array_column(Db::query('SHOW COLUMNS FROM zyt_patient_diet_record'), 'Field');
$expect(array_intersect(['breakfast', 'lunch', 'dinner'], $dietColumns) === []
&& count(array_intersect(['breakfast_foods', 'lunch_foods', 'dinner_foods'], $dietColumns)) === 3,
'Real disposable MySQL contains canonical meal columns only, never fake extraction aliases');
$dietRow = Db::name('patient_diet_record')->where('id', $dietId)->find();
$expect($dietRow['breakfast_foods'] === 'synthetic breakfast', 'Adoption maps extraction alias to actual canonical column');
$expect(Fields::fromDatabase('diet', $dietRow)['breakfast'] === 'synthetic breakfast', 'Review reader maps canonical column back to extraction alias');
$dietAudit = Db::name('followup_audio_audit')->where('task_id', $daily['id'])->where('kind', 'diet')->find();
$dietAuditAfter = Store::open($daily['id'], 'audit-after:' . $dietAudit['item_id'], $dietAudit['after_cipher']);
$expect($dietAuditAfter['values']['breakfast'] === 'synthetic breakfast', 'Canonical column mapping does not erase audit after-values');
Db::name('patient_diet_record')->where('id', $dietId)->update(['lunch_foods' => 'keep lunch', 'dinner_foods' => 'keep dinner',
'breakfast_images' => '["keep-breakfast.png"]', 'lunch_images' => '["keep-lunch.png"]', 'dinner_images' => '["keep-dinner.png"]']);
$dietCorrection = $makeReview($extract([$event('diet', ['breakfast' => 'corrected breakfast'], 'synthetic breakfast correction')]));
$dietChanges = $resolve($dietCorrection); $dietChanges[0]['target_id'] = (int) $dietId;
$dietDraft = Store::saveDraft($dietCorrection['id'], $dietCorrection['version'], $dietChanges, 1);
$expect($dietDraft['items'][0]['current_values']['breakfast'] === 'synthetic breakfast', 'Retargeted review reads actual prior meal through aliases');
Apply::apply($dietDraft['id'], $dietDraft['version'], $resolve($dietDraft), 1, $root);
$dietRow = Db::name('patient_diet_record')->where('id', $dietId)->find();
$dietModel = \app\common\model\tcm\DietRecord::findOrEmpty($dietId)->toArray();
$expect($dietModel['breakfast_foods'] === 'corrected breakfast' && $dietModel['lunch_foods'] === 'keep lunch'
&& $dietModel['dinner_foods'] === 'keep dinner', 'Actual model getters preserve changed and untouched meals');
$expect($dietModel['breakfast_images'] === ['keep-breakfast.png'] && $dietModel['lunch_images'] === ['keep-lunch.png']
&& $dietModel['dinner_images'] === ['keep-dinner.png'], 'Meal adoption never clears preexisting images');
$exerciseRow = Db::name('patient_exercise_record')->where('id', $exerciseId)->find();
$exerciseModel = \app\common\model\tcm\ExerciseRecord::findOrEmpty($exerciseId)->append(['intensity_text'])->toArray();
$expect($exerciseModel['exercise_type'] === 'synthetic walk' && (int) $exerciseModel['duration'] === 25
&& $exerciseModel['intensity_text'] === '低强度', 'Actual exercise model getters read adopted values');
$exportPath = (string) getenv('FOLLOWUP_AUDIO_TEST_CANONICAL_EXPORT');
if ($exportPath !== '') {
if (!str_starts_with($exportPath, '/private/tmp/')) { throw new RuntimeException('Disposable export path required'); }
file_put_contents($exportPath, json_encode(['synthetic' => true, 'source' => 'real-disposable-mysql-apply',
'database' => $database, 'diet_columns' => $dietColumns, 'diet_row' => $dietRow, 'exercise_row' => $exerciseRow,
'diet_model' => $dietModel, 'exercise_model' => $exerciseModel], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
}
$noteId = array_values(array_filter($result['applied_items'], static fn ($r) => $r['kind'] === 'tracking_note'))[0]['record_id'];
$expect(Db::name('tracking_note')->where('id', $noteId)->value('note_date') === '2026-09-19', 'Historical note uses spoken date, not execution day');
$expect((int) Db::name('followup_audio_audit')->where('task_id', $daily['id'])->count() === 5, 'One provenance record per adopted item');
$audit = Db::name('followup_audio_audit')->where('task_id', $daily['id'])->find();
$sourceAudit = Store::open($daily['id'], 'audit-source:' . $audit['item_id'], $audit['source_cipher']);
$expect(isset($sourceAudit['evidence']) && !isset($sourceAudit['transcript']), 'Audit retains necessary adopted evidence, not full transcript');
$estimate = $makeReview($extract([$event('blood', ['systolic_pressure' => 122], 'synthetic afternoon estimate',
['record_time' => null, 'time_period' => 'afternoon'])]));
$estimateItems = $resolve($estimate); $estimateItems[0]['time_period'] = '中午';
$estimateDraft = Store::saveDraft($estimate['id'], $estimate['version'], $estimateItems, 1);
$expect($estimateDraft['items'][0]['record_time'] === '12:00' && $estimateDraft['items'][0]['time_estimated'],
'Changing estimated period also updates its estimated clock');
$estimateResult = Apply::apply($estimateDraft['id'], $estimateDraft['version'], $resolve($estimateDraft), 1, $root);
$estimateRow = Db::name('tcm_blood_record')->where('id', $estimateResult['applied_items'][0]['record_id'])->find();
$expect($estimateRow['record_time'] === '12:00' && (int) $estimateRow['record_time_estimated'] === 1
&& $estimateRow['record_time_period'] === '中午', 'Estimated clock and period stored on the actual independent row');
$precise = $makeReview($extract([$event('blood', ['systolic_pressure' => 123], 'synthetic human precision',
['record_time' => null, 'time_period' => 'afternoon'])]));
$preciseItems = $resolve($precise); $preciseItems[0]['record_time'] = '13:41:30';
$preciseDraft = Store::saveDraft($precise['id'], $precise['version'], $preciseItems, 1);
$expect($preciseDraft['items'][0]['record_time'] === '13:41' && !$preciseDraft['items'][0]['time_estimated'],
'Human concrete time clears estimate without accepting client time_estimated');
$update = $makeReview($extract([$event('blood', ['fasting_blood_sugar' => 6.7], 'synthetic correction 6.7', ['record_time' => '07:00'])]));
$updateItems = $resolve($update); $updateItems[0]['target_id'] = (int) $oldBlood;
$newDraft = Store::saveDraft($update['id'], $update['version'], $updateItems, 1);
$expect(!empty($newDraft['review_refreshed']) && !$newDraft['items'][0]['selected'] && $newDraft['items'][0]['current_values']['fasting_blood_sugar'] === 5.5, 'Retarget snapshots selected real row and requires review again');
Apply::apply($newDraft['id'], $newDraft['version'], $resolve($newDraft), 1, $root);
$expect((float) Db::name('tcm_blood_record')->where('id', $oldBlood)->value('fasting_blood_sugar') === 6.7
&& (float) Db::name('tcm_blood_record')->where('id', $bloodIds[0])->value('fasting_blood_sugar') === 6.1, 'Update touches exact target ID, not synthetic daily aggregate');
$atomic = $makeReview($extract([
$event('diagnosis', ['remark' => 'synthetic atomic change'], 'synthetic atomic change', ['date_text' => '今天']),
$event('diet', ['lunch' => 'synthetic rollback lunch'], 'synthetic rollback lunch'),
]));
$dietCount = (int) Db::name('patient_diet_record')->count();
$pdo->exec("CREATE TRIGGER fa_audit_failure BEFORE INSERT ON zyt_followup_audio_audit FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='synthetic audit rejection'");
$reject(fn () => Apply::apply($atomic['id'], $atomic['version'], $resolve($atomic), 1, $root), 'synthetic audit rejection');
$expect(Db::name('tcm_diagnosis')->where('id', 1)->value('remark') === null && (int) Db::name('patient_diet_record')->count() === $dietCount
&& Store::task($atomic['id'])['status'] === 'review', 'DB failure rolls back adopted data, task status and audit together');
$pdo->exec('DROP TRIGGER fa_audit_failure');
$payloadPath = $private . '/concurrent-items.json'; file_put_contents($payloadPath, json_encode($resolve($atomic))); chmod($payloadPath, 0600);
$concurrent = $runChildren([['--apply', (string) $atomic['id'], (string) $atomic['version'], $payloadPath], ['--apply', (string) $atomic['id'], (string) $atomic['version'], $payloadPath]]);
unlink($payloadPath);
$expect($concurrent[0] === $concurrent[1] && (int) Db::name('followup_audio_audit')->where('task_id', $atomic['id'])->count() === 2,
'Two real concurrent apply requests return same IDs and produce one batch');
$identity = $makeReview($extract([$event('diagnosis', ['patient_name' => 'synthetic corrected name'], 'synthetic corrected name', ['date_text' => '今天'])]), 3, 2);
$pdo->exec('INSERT INTO zyt_tcm_prescription_order VALUES(1,3,2,100,1,NULL)');
$reject(fn () => Apply::apply($identity['id'], $identity['version'], $resolve($identity), 2, $root), 'PATIENT_BASIC_LOCKED');
$expect(Db::name('tcm_diagnosis')->where('id', 3)->value('patient_name') === 'Synthetic C', 'Order identity lock preserved even if caller supplies stale root info');
$pdo->exec('UPDATE zyt_tcm_prescription_order SET fulfillment_status=3 WHERE id=1');
Apply::apply($identity['id'], $identity['version'], $resolve($identity), 2, []);
$expect(Db::name('tcm_diagnosis')->where('id', 3)->value('patient_name') === 'synthetic corrected name', 'Completed latest order permits existing identity rules');
$phone = $makeReview($extract([$event('diagnosis', ['phone' => '13800000000'], 'synthetic phone', ['date_text' => '今天'])]), 3, 2);
$reject(fn () => Apply::apply($phone['id'], $phone['version'], $resolve($phone), 2, $root), 'IDENTITY_PLAIN_PERMISSION_REQUIRED');
$expect(Db::name('tcm_diagnosis')->where('id', 3)->value('phone') === null, 'Masked-identity permission cannot be bypassed by new service');
Db::name('tcm_diagnosis')->where('id', 3)->update(['phone' => '13900000000']);
$masked = $makeReview($extract([$event('diagnosis', ['phone' => '13800000000'], 'synthetic masked roundtrip', ['date_text' => '今天'])]), 3, 2);
$protected = \app\adminapi\logic\tcm\FollowupAudioLogic::detail($masked['id'], 2, []);
$expect($protected['items'][0]['current_values']['phone'] === '139****0000', 'Actual endpoint logic masks existing identity before browser review');
$maskedDraft = \app\adminapi\logic\tcm\FollowupAudioLogic::saveDraft($masked['id'], $masked['version'], $protected['items'], 2, []);
$savedReview = Store::open($masked['id'], 'review', Store::task($masked['id'])['review_cipher']);
$expect($maskedDraft['items'][0]['current_values']['phone'] === '139****0000'
&& $savedReview['items'][0]['current_values']['phone'] === '13900000000', 'Redacted display fields can roundtrip without replacing immutable stored snapshot');
$limited = $makeReview($extract([$event('diet', ['dinner' => 'synthetic denied dinner'], 'synthetic denied dinner')]), 2, 3);
$reject(fn () => Apply::apply($limited['id'], $limited['version'], $resolve($limited), 3, []), '无权');
$rebound = $makeReview($extract([$event('diagnosis', ['remark' => 'synthetic rebound'], 'synthetic rebound', ['date_text' => '今天'])]));
Db::name('tcm_diagnosis')->where('id', 1)->update(['patient_id' => 999]);
$reject(fn () => Apply::apply($rebound['id'], $rebound['version'], $resolve($rebound), 1, $root), '归属已变化');
Db::name('tcm_diagnosis')->where('id', 1)->update(['patient_id' => 101]);
$expiryTask = Store::task($rebound['id']);
Db::name('followup_audio_task')->where('id', $rebound['id'])->update(['expires_at' => time() - 1]);
Db::name('followup_audio_upload')->where('id', $expiryTask['upload_id'])->update(['expires_at' => time() - 1]);
$reject(fn () => Apply::apply($rebound['id'], $rebound['version'], $resolve($rebound), 1, $root), 'EXPIRED');
$expect(Store::detail($rebound['id'])['transcript'] === '' && Store::detail($rebound['id'])['status'] === 'expired', 'Expired full content hidden before cleanup scheduler');
$retainedAudit = (int) Db::name('followup_audio_audit')->count();
$appliedTask = Store::task($daily['id']);
Db::name('followup_audio_task')->where('id', $daily['id'])->update(['expires_at' => time() - 1]);
Db::name('followup_audio_upload')->where('id', $appliedTask['upload_id'])->update(['expires_at' => time() - 1]);
$active = Store::create($upload(), $recordedAt, 'qwen', 1, $root); $activeClaim = Store::claim();
Db::name('followup_audio_task')->where('id', $active['id'])->update(['expires_at' => time() - 1]);
Db::name('followup_audio_upload')->where('id', $activeClaim['upload_id'])->update(['expires_at' => time() - 1]);
$cleanup = Store::cleanupExpired();
$expect($cleanup['tasks_purged'] === 2 && $cleanup['active_skipped'] === 1 && $cleanup['errors'] === 0, 'Cleanup purges expiry but not an active lease');
$purged = Store::task($rebound['id']);
$expect($purged['extraction_cipher'] === '' && $purged['review_cipher'] === '' && $purged['status'] === 'expired', 'Full transcript and immutable extraction erased');
$expect(!is_file($private . '/' . $expiryTask['upload_id'] . '/audio.wav') && is_file($private . '/' . $activeClaim['upload_id'] . '/audio.wav'), 'Expired raw audio deleted via verified private path; active audio retained');
$expect(Store::task($daily['id'])['file_name'] === '已清理录音'
&& Db::name('followup_audio_upload')->where('id', $appliedTask['upload_id'])->value('file_name') === '已清理录音', 'Expired original filenames are not retained');
$expect((int) Db::name('followup_audio_audit')->count() === $retainedAudit && Store::task($daily['id'])['status'] === 'applied', 'Adopted data/provenance retained after 90-day cleanup');
$expect(file_get_contents($private . '/unrelated-sentinel') === 'KEEP', 'Cleanup never touches unrelated sibling files');
// More than a batch of already-purged history must not starve a newly expired review forever.
$history = [];
for ($i = 0; $i < 505; $i++) {
$row = $rawTask; unset($row['id']);
$row = array_replace($row, ['upload_id' => bin2hex(random_bytes(24)), 'sha256' => hash('sha256', 'expired-history-' . $i),
'status' => 'expired', 'stage' => 'expired', 'purged_at' => time(), 'expires_at' => time() - 1,
'lease_until' => 0, 'lease_token' => '', 'extraction_cipher' => '', 'review_cipher' => '', 'applied_cipher' => '']);
$history[] = $row;
}
Db::name('followup_audio_task')->insertAll($history);
Db::name('followup_audio_task')->where('id', $active['id'])->update(['lease_until' => 0]);
$newExpiry = $makeReview($extract([$event('diagnosis', ['remark' => 'synthetic newest expiry'], 'synthetic newest expiry', ['date_text' => '今天'])]));
$newExpiryTask = Store::task($newExpiry['id']);
Db::name('followup_audio_task')->where('id', $newExpiry['id'])->update(['expires_at' => time() - 1]);
Db::name('followup_audio_upload')->where('id', $newExpiryTask['upload_id'])->update(['expires_at' => time() - 1]);
Store::cleanupExpired();
$expect((int) Store::task($newExpiry['id'])['purged_at'] > 0, 'Cleanup never starves new expiry behind >500 already purged rows');
// Exercise the actual Worker -> Dify -> Store checkpoint contract, not a permissive Store test double.
$transportCalls = [];
$rawAnswer = ['schema_version' => 'followup-audio-v1', 'audio_processed' => true,
'summary' => 'Synthetic integrated result', 'transcript' => 'Synthetic integrated fact', 'uncertainties' => [],
'items' => [['kind' => 'diagnosis', 'values' => ['remark' => 'Synthetic integrated fact'],
'record_date' => null, 'record_time' => null, 'time_period' => null, 'date_text' => '今天', 'time_text' => '',
'evidence' => [['text' => 'Synthetic integrated fact']], 'needs_review' => false]]];
$adapter = new \app\common\service\followupaudio\FollowupAudioDify(
static function (array $spec) use (&$transportCalls, $rawAnswer): array {
$transportCalls[] = $spec;
$body = isset($spec['multipart']) ? ['id' => 'integrated-file', 'mime_type' => 'audio/wav']
: ['answer' => json_encode($rawAnswer), 'task_id' => 'integrated-task', 'message_id' => 'integrated-message'];
return ['http_code' => 200, 'errno' => 0, 'body' => json_encode($body)];
}, (array) config('followup_audio'), ['base_url' => 'http://127.0.0.1/v1',
'models' => ['qwen' => ['api_key' => 'synthetic-test-key']]]);
$worker = new \app\common\service\followupaudio\FollowupAudioWorker($adapter);
$integrated = Store::create($upload(), $recordedAt, 'qwen', 1, $root);
$expect($worker->runOnce(), 'Integrated worker consumes queued task');
$integratedTask = Store::task($integrated['id']);
$expect($integratedTask['status'] === 'review', 'Actual Worker/Dify checkpoint success must become review, observed '
. $integratedTask['status'] . '/' . $integratedTask['error_code']);
$expect(count($transportCalls) === 2 && $integratedTask['upstream_run_id'] === 'integrated-task'
&& $integratedTask['upstream_file_id'] === 'integrated-file', 'Durable upload and generation IDs survive all actual Store checkpoints');
$expect(Store::detail($integrated['id'])['items'][0]['values']['remark'] === 'Synthetic integrated fact',
'Adapter normalization plus Store normalization preserves actionable evidence');
$transportCalls = [];
$reboundWorker = Store::create($upload(), $recordedAt, 'qwen', 1, $root);
Db::name('tcm_diagnosis')->where('id', 1)->update(['patient_id' => 999]);
$worker->runOnce();
$reboundWorkerTask = Store::task($reboundWorker['id']);
$expect($transportCalls === [] && (int) $reboundWorkerTask['upstream_started_at'] === 0,
'Rebound patient is denied BEFORE any original-patient audio leaves local storage');
Db::name('tcm_diagnosis')->where('id', 1)->update(['patient_id' => 101]);
echo "Follow-up audio core: {$checks} checks passed (real disposable MySQL; concurrent claim/apply; rollback; no upstream requests).\n";
} finally {
$manager->connect()->close();
$pdo->exec("DROP DATABASE IF EXISTS `{$database}`");
// All paths under an independently generated disposable test root, never application private storage.
if (preg_match('#^/private/tmp/fa_core_[a-f0-9]{12}$#D', $private) && is_dir($private)) {
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($private, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) { $file->isDir() && !$file->isLink() ? rmdir($file->getPathname()) : unlink($file->getPathname()); }
rmdir($private);
}
}
+154
View File
@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
/** Real multipart+cURL loopback integration, synthetic-only; no app initialize(), DB or .env. */
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\command\FollowupAudioProbe;
$directory = sys_get_temp_dir() . '/followup-audio-http-' . bin2hex(random_bytes(6));
mkdir($directory, 0700, true);
// Disposable SQLite dictionary only. No initialization or production database access.
new think\App();
$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);
$wave = $directory . '/short.wav';
$samples = str_repeat(pack('v', 0), 16000);
$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' => 1,
'expected' => ['canaries' => ['DO_NOT_SEND_EXPECTATIONS_TO_MODEL']]];
$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', '-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 $message) use (&$checks): void {
if (!$ok) { throw new RuntimeException($message); }
$checks++;
};
$expectError = static function (callable $call, string $code, bool $uncertain = false) use ($expect): void {
try { $call(); $expect(false, 'expected ' . $code); }
catch (AudioError $e) {
$expect($e->errorCode === $code, 'expected ' . $code . ', got ' . $e->errorCode);
$expect($e->uncertain === $uncertain, 'uncertain flag for ' . $code);
$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];
$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']]]);
};
try {
$ready = false;
for ($i = 0; $i < 100; $i++) {
$connection = @stream_socket_client('tcp://127.0.0.1:' . $port, $errno, $error, 0.1);
if ($connection) { fclose($connection); $ready = true; break; }
usleep(50000);
}
$expect($ready, 'loopback HTTP server ready');
$events = [];
$heartbeat = static function (array $metadata = []) use (&$events): bool { $events[] = $metadata; return true; };
$result = $adapter('success')->probe($wave, $fixture, 'qwen', $heartbeat);
$expect($result['items'][0]['values']['systolic_pressure'] == 126, 'real JSON facts preserved');
$expect(!isset($result['items'][0]['evidence'][0]['start_ms']) && !isset($result['items'][0]['evidence'][0]['end_ms']), 'unverified model offsets omitted, full audio only');
$expect($result['items'][0]['record_date'] === '2026-09-28', 'yesterday normalized from recorded_at');
$expect(substr($result['items'][0]['record_time'], 0, 5) === '21:00', 'temporal clock retained');
$expect(isset($events[0]['upstream_started_at']), 'durable intent before upload');
$expect($events[1]['upstream_file_id'] === 'mock-upload-success', 'upload ID checkpointed');
$ids = json_decode($events[2]['upstream_ids_json'], true);
$expect($ids['task_id'] === 'mock-task-success' && $ids['message_id'] === 'mock-message-success'
&& $ids['conversation_id'] === 'mock-conversation-success' && $ids['upstream_request_id'] === 'mock-request-success', 'all upstream IDs retained');
$requests = array_map(static fn (string $row): array => json_decode($row, true), file($directory . '/requests.jsonl', FILE_IGNORE_NEW_LINES));
$expect(count($requests) === 2 && $requests[0]['sha256'] === $fixture['sha256'] && $requests[0]['valid'] && $requests[1]['valid'], 'actual bytes uploaded via multipart and local_file same user');
$expect(!str_contains(json_encode($requests[1]['payload']), 'DO_NOT_SEND_EXPECTATIONS_TO_MODEL'), 'ground truth not in query');
foreach (['患者本人和家属', '客服/医生的问题与患者回答', '否定、纠正', '当前与历史用药', '未询问或不确定', '不得自行诊断'] as $guard) {
$expect(str_contains($requests[1]['payload']['query'], $guard), 'Actual wire prompt includes conservative semantic constraint: ' . $guard);
}
$expectError(static fn () => Dify::assertAudioPayload(['user' => 'u'], 'f'), 'AUDIO_ATTACHMENT_REQUIRED');
$expectError(static fn () => Dify::assertAudioPayload(['user' => 'u', 'files' => [['type' => 'image', 'transfer_method' => 'local_file', 'upload_file_id' => 'f']]], 'f'), 'AUDIO_ATTACHMENT_REQUIRED');
foreach (['upload_reject' => 'UPSTREAM_AUDIO_REJECTED', 'chat_reject' => 'UPSTREAM_AUDIO_REJECTED', 'wrong_type' => 'UPSTREAM_AUDIO_REJECTED',
'text_only' => 'AUDIO_NOT_PROCESSED', 'omitted' => 'AUDIO_NOT_PROCESSED', 'schema' => 'UPSTREAM_SCHEMA_INVALID',
'evidence' => 'UPSTREAM_SCHEMA_INVALID', 'extra_field' => 'UPSTREAM_SCHEMA_INVALID', 'bad_values' => 'UPSTREAM_SCHEMA_INVALID',
'bad_time' => 'UPSTREAM_SCHEMA_INVALID', 'markdown' => 'UPSTREAM_SCHEMA_INVALID'] as $scenario => $code) {
$expectError(static fn () => $adapter($scenario)->probe($wave, $fixture, 'qwen', $heartbeat), $code);
if ($scenario === 'wrong_type') {
$lastEvent = end($events);
$expect(($lastEvent['upstream_file_id'] ?? '') === 'mock-upload-wrong_type', 'rejected media file ID remains durable');
}
}
foreach (['unknown', 'malformed_response'] as $scenario) {
$expectError(static fn () => $adapter($scenario)->probe($wave, $fixture, 'openai', $heartbeat), 'UPSTREAM_UNCERTAIN', true);
if ($scenario === 'unknown') {
$lastEvent = end($events);
$failureIds = json_decode($lastEvent['upstream_ids_json'], true);
$expect($failureIds['task_id'] === 'mock-uncertain-task' && $failureIds['conversation_id'] === 'mock-uncertain-conversation', 'uncertain HTTP response IDs retained');
}
}
$requests = array_map(static fn (string $row): array => json_decode($row, true), file($directory . '/requests.jsonl', FILE_IGNORE_NEW_LINES));
foreach (['upload_reject' => 1, 'chat_reject' => 2, 'wrong_type' => 1, 'omitted' => 2, 'unknown' => 2] as $scenario => $count) {
$expect(count(array_filter($requests, static fn (array $request): bool => $request['scenario'] === $scenario)) === $count, 'no resend or text-only fallback ' . $scenario);
}
$localCalls = 0;
$testTransport = static function () use (&$localCalls): array { $localCalls++; return []; };
$provider = ['base_url' => 'http://127.0.0.1:' . $port . '/v1', 'models' => ['qwen' => ['api_key' => 'synthetic-test-key']]];
$gated = new Dify($testTransport, ['enabled' => false], $provider);
$expectError(static fn () => $gated->analyze([], $heartbeat), 'FEATURE_DISABLED');
$unverified = new Dify($testTransport, ['enabled' => true, 'audio_verified' => false], $provider);
$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');
$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 () => $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');
$notSynthetic = $fixture; $notSynthetic['synthetic'] = false;
$expectError(static fn () => $guarded->probe($wave, $notSynthetic, 'qwen', $heartbeat), 'SYNTHETIC_FIXTURE_REQUIRED');
file_put_contents($wave, 'not audio');
$badFixture['sha256'] = hash_file('sha256', $wave);
$expectError(static fn () => $guarded->probe($wave, $badFixture, 'qwen', $heartbeat), 'AUDIO_INVALID');
file_put_contents($wave, $bytes);
$expectError(static fn () => $guarded->probe($wave, $fixture, 'qwen', static fn (): bool => false), 'LEASE_LOST');
$expect($localCalls === 0, 'disabled/unverified/reconciliation/invalid file/lease failures do not send');
$missing = new Dify($testTransport, $settings, []);
$expect($missing->configurationStatus('qwen')['code'] === 'CONFIG_MISSING', 'missing existing credentials gate is concrete');
$expectError(static fn () => $missing->probe($wave, $fixture, 'qwen', $heartbeat), 'CONFIG_MISSING');
$expected = ['canaries' => ['头部密码', '结尾密码'], 'facts' => [
['kind' => 'blood', 'record_date' => '2026-09-28', 'record_time' => '21:00', 'values' => ['systolic_pressure' => 126]],
['kind' => 'blood', 'record_date' => '2026-09-27', 'record_time' => '14:00', 'values' => ['postprandial_blood_sugar' => 7.2]],
['kind' => 'blood', 'record_date' => '2026-09-29', 'record_time' => '07:00', 'values' => ['systolic_pressure' => 147]],
]];
$probeResult = ['summary' => 'synthetic', 'transcript' => '头部密码到结尾密码', 'items' => $expected['facts']];
$expect(!in_array(false, FollowupAudioProbe::verifyExtraction($probeResult, $expected), true), 'probe accepts all temporal facts and audio-only canaries');
array_pop($probeResult['items']);
$expect(!FollowupAudioProbe::verifyExtraction($probeResult, $expected)['unique_tail_fact'], 'probe rejects truncated tail');
$probeResult['transcript'] = '头部密码';
$expect(!FollowupAudioProbe::verifyExtraction($probeResult, $expected)['audio_only_canaries'], 'probe rejects missing audio-only canary');
$expectError(static fn () => $adapter('timeout', ['request_timeout' => 1])->probe($wave, $fixture, 'qwen', $heartbeat), 'UPSTREAM_UNCERTAIN', true);
echo 'FOLLOWUP_AUDIO_DIFY assertions=' . $checks . ' PASS real_multipart=1 local_file=1 no_fallback=1 no_resend=1' . PHP_EOL;
} catch (\Throwable $failure) {
fwrite(STDERR, 'MOCK_DIAGNOSTIC ' . (string) @file_get_contents($directory . '/server.stderr') . PHP_EOL);
throw $failure;
} finally {
proc_terminate($process); proc_close($process);
foreach (glob($directory . '/*') ?: [] as $path) { if (is_file($path)) { unlink($path); } }
rmdir($directory);
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
$root = dirname(__DIR__);
$controller = file_get_contents($root . '/app/adminapi/controller/tcm/FollowupAudioController.php');
$logic = file_get_contents($root . '/app/adminapi/logic/tcm/FollowupAudioLogic.php');
$access = file_get_contents($root . '/app/common/service/followupaudio/FollowupAudioAccess.php');
$middleware = file_get_contents($root . '/app/adminapi/http/middleware/AuthMiddleware.php');
$stream = file_get_contents($root . '/app/common/service/followupaudio/FollowupAudioStream.php');
$console = file_get_contents($root . '/config/console.php');
$checks = 0;
function expectEndpoint(bool $condition, string $message): void
{
global $checks;
if (!$condition) { fwrite(STDERR, "FAIL: {$message}\n"); exit(1); }
$checks++;
}
expectEndpoint(strpos($middleware, "str_starts_with(\$accessUri, 'tcm.followupaudio/')") < strpos($middleware, '// 全部路由'), 'route group precedes unregistered-route bypass');
expectEndpoint(str_contains($middleware, "in_array('tcm.diagnosis/edit', \$uris, true)"), 'explicit page permission');
expectEndpoint(str_contains($access, "Db::name('admin')->where('id', \$actor)->whereNull('delete_time')->where('disable', 0)->lock(true)"), 'active actor refresh');
expectEndpoint(str_contains($access, 'MyPatientLogic::applyScope($query, $actor, $info)') && str_contains($access, '$query->lock(true)->find()'), 'row management guard');
expectEndpoint(str_contains($access, 'tcm.diagnosis/dailyRecord'), 'daily permission guard');
expectEndpoint(str_contains($access, "\$task['patient_id'] !== (int) (\$diagnosis['patient_id']"), 'historical patient rebind guard');
expectEndpoint(str_contains($controller, 'array_diff(array_keys($params), $allowed)'), 'request field allowlist');
expectEndpoint(str_contains($controller, 'count($items) > 500'), 'bounded review items');
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, "(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');
expectEndpoint(str_contains($stream, 'fread($stream, 1048576)') && !str_contains($stream, 'file_get_contents'), 'bounded playback memory');
expectEndpoint(str_contains($controller, "['id'], function (array \$p): FollowupAudioStream"), 'playback no query token');
foreach (['work', 'probe', 'cleanup'] as $command) {
expectEndpoint(str_contains($console, "followup-audio:{$command}"), 'independent command ' . $command);
}
echo "Followup audio endpoints: {$checks} source-contract checks passed\n";
@@ -0,0 +1,464 @@
<?php
declare(strict_types=1);
/**
* Synthetic text/structured-candidate acceptance, not an ASR or live-model test.
* No application initialization, .env, network, audio upload, or patient data.
* Cross-layer checks use only a disposable in-memory SQLite dictionary.
* Run with --json for the full input/expected/actual evidence ledger.
*/
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\common\service\followupaudio\FollowupAudioFields as Fields;
use app\common\service\followupaudio\FollowupAudioPolicy as Policy;
use app\common\service\followupaudio\FollowupAudioApply as Apply;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\DietRecord;
use app\common\model\tcm\ExerciseRecord;
$base = '2026-09-20 23:00:00';
$cases = [];
$event = static fn (string $kind, array $values, string $quote, array $extra = []): array => array_replace([
'kind' => $kind, 'values' => $values, 'record_date' => null, 'record_time' => '07:45',
'date_text' => '今天', 'time_text' => '七点四十五分', 'time_period' => null,
'time_estimated' => false, 'needs_review' => false, 'evidence' => [['text' => $quote]],
], $extra);
$expectedItem = static fn (string $kind, array $values, ?string $date, ?string $time, bool $estimated = false,
bool $review = false, ?string $period = null): array => [
'kind' => $kind, 'values' => $values, 'record_date' => $date, 'record_time' => $time,
'time_period' => $period, 'time_estimated' => $estimated, 'needs_review' => $review || $kind === 'diagnosis', 'selected' => false,
];
$add = static function (string $id, string $claim, string $transcript, array $items, array $expected,
string $recordedAt = '2026-09-20 23:00:00', string $layer = 'rule', ?string $semanticExpected = null,
?string $limitation = null) use (&$cases): void {
$cases[] = compact('id', 'claim', 'transcript', 'items', 'expected', 'recordedAt', 'layer', 'semanticExpected', 'limitation');
};
foreach ([
['date_today_month', '今天跨月', '今天', '2026-03-01 09:00:00', '2026-03-01'],
['date_yesterday_month', '昨天跨月', '昨天', '2026-03-01 09:00:00', '2026-02-28'],
['date_yesterday_leap', '昨天跨闰年二月', '昨天', '2024-03-01 09:00:00', '2024-02-29'],
['date_today_year', '今天跨年', '今天', '2026-01-01 09:00:00', '2026-01-01'],
['date_yesterday_year', '昨天跨年', '昨天', '2026-01-01 09:00:00', '2025-12-31'],
] as [$id, $label, $spoken, $recordedAt, $date]) {
$quote = $spoken . '七点四十五分空腹血糖六点一。';
$add($id, $label . ':以录制日期而非执行日期换算', $quote,
[$event('blood', ['fasting_blood_sugar' => 6.1], $quote, ['date_text' => $spoken])],
[$expectedItem('blood', ['fasting_blood_sugar' => 6.1], $date, '07:45')], $recordedAt);
}
foreach ([['早上', '早晨', '08:00'], ['中午', '中午', '12:00'], ['下午', '下午', '15:00'],
['晚上', '晚上', '20:00'], ['睡前', '睡前', '22:00']] as $index => [$spoken, $period, $time]) {
$quote = '今天' . $spoken . '收缩压一百二十,具体几点没记。';
$add('period_' . ($index + 1), $spoken . '默认钟点必须是估算且待核对', $quote,
[$event('blood', ['systolic_pressure' => 120], $quote,
['record_time' => null, 'time_text' => $spoken, 'time_period' => $spoken])],
[$expectedItem('blood', ['systolic_pressure' => 120], '2026-09-20', $time, true, true, $period)]);
}
$quote = '今天下午三点二十七分测的收缩压一百二十。';
$add('exact_clock', '结构结果明确15:27时,优先于下午15:00默认值,不标估算', $quote,
[$event('blood', ['systolic_pressure' => 120], $quote,
['record_time' => '15:27', 'time_text' => '下午三点二十七分', 'time_period' => '下午'])],
[$expectedItem('blood', ['systolic_pressure' => 120], '2026-09-20', '15:27', false, false, '下午')]);
$quote = '今天晚上八点零九分三十秒测的收缩压一百二十。';
$add('exact_clock_seconds', '明确秒数按既有分钟字段精度截取,不变成估算', $quote,
[$event('blood', ['systolic_pressure' => 120], $quote,
['record_time' => '20:09:30', 'time_text' => '晚上八点零九分三十秒', 'time_period' => '晚上'])],
[$expectedItem('blood', ['systolic_pressure' => 120], '2026-09-20', '20:09', false, false, '晚上')]);
foreach (['上周', '最近', '昨天或前天'] as $index => $spoken) {
$quote = $spoken . '七点四十五分空腹血糖六点一,哪天记不清了。';
$add('ambiguous_date_' . ($index + 1), $spoken . '不能被模型给出的确定日期覆盖,保留待确认', $quote,
[$event('blood', ['fasting_blood_sugar' => 6.1], $quote,
['date_text' => $spoken, 'record_date' => '2026-09-19'])],
[$expectedItem('blood', ['fasting_blood_sugar' => 6.1], null, '07:45', false, true)]);
}
$quote = '昨天七点四十五分空腹血糖六点一。';
$add('relative_date_conflict', '昨天与模型日期矛盾则保持待确认', $quote,
[$event('blood', ['fasting_blood_sugar' => 6.1], $quote,
['date_text' => '昨天', 'record_date' => '2026-09-20'])],
[$expectedItem('blood', ['fasting_blood_sugar' => 6.1], null, '07:45', false, true)]);
$quoteA = '今天早上第一次测空腹血糖六点一。';
$quoteB = '今天早上又测了一次空腹血糖,还是六点一。';
$estimatedBlood = ['record_time' => null, 'time_text' => '早上', 'time_period' => '早上'];
$sameValueExpected = $expectedItem('blood', ['fasting_blood_sugar' => 6.1], '2026-09-20', '08:00', true, true, '早晨');
$add('same_value_different_events', '同值、同默认时间但不同测量证据保留两条', $quoteA . $quoteB,
[$event('blood', ['fasting_blood_sugar' => 6.1], $quoteA, $estimatedBlood),
$event('blood', ['fasting_blood_sugar' => 6.1], $quoteB, $estimatedBlood)],
[$sameValueExpected, $sameValueExpected]);
$quote = '今天七点四十五分只测过一次空腹血糖,六点一。';
$sameEvent = $event('blood', ['fasting_blood_sugar' => 6.1], $quote);
$add('same_event_identical_evidence', '完全相同事件结构和逐字证据重复只保留一条', $quote, [$sameEvent, $sameEvent],
[$expectedItem('blood', ['fasting_blood_sugar' => 6.1], '2026-09-20', '07:45')]);
$quote = '最近睡眠不好,其他情况没有谈。';
$add('absent_fields_not_defaulted', '仅给出现病史,不自动填过敏史、家族史、药物为无或0', $quote,
[$event('diagnosis', ['symptoms' => '最近睡眠不好'], $quote, ['record_time' => null, 'time_text' => ''])],
[$expectedItem('diagnosis', ['symptoms' => '最近睡眠不好'], '2026-09-20', null)]);
$quote = '我明确没有过敏史,家族病史没谈。';
$add('explicit_zero_only', '明确无过敏史可保存0,但不扩展到未提及家族史', $quote,
[$event('diagnosis', ['allergy_history' => 0], $quote, ['record_time' => null, 'time_text' => ''])],
[$expectedItem('diagnosis', ['allergy_history' => 0], '2026-09-20', null)]);
foreach ([
['reject_hospital_unknown_key', 'diagnosis', ['hospital_diagnosis' => '模型生成的医院诊断'], 'FIELD_INVALID'],
['reject_prescription_key', 'diagnosis', ['prescription' => '模型生成的处方'], 'FIELD_INVALID'],
['reject_prescription_kind', 'prescription', ['content' => '模型生成的处方'], 'VALUES_INVALID'],
['reject_business_identity', 'diagnosis', ['patient_id' => 999], 'FIELD_INVALID'],
['reject_zero_blood_sugar', 'blood', ['fasting_blood_sugar' => 0], 'NUMBER_INVALID'],
] as [$id, $kind, $values, $code]) {
$quote = '这是合成原话,未说明模型生成字段的内容。';
$add($id, '非白名单字段/类型或非法数值不能进入候选:' . $code, $quote,
[$event($kind, $values, $quote)], []);
$cases[array_key_last($cases)]['expectedErrorCode'] = $code;
}
$quote = '我目前在用二甲双胍,剂量没说。';
$add('medication_literal_preserved', '已给出的药物候选和真实字面证据可保留,不推测剂量', $quote,
[$event('diagnosis', ['current_medications' => '二甲双胍'], $quote,
['record_time' => null, 'time_text' => ''])],
[$expectedItem('diagnosis', ['current_medications' => '二甲双胍'], '2026-09-20', null)]);
$quote = '今天七点四十五分空腹血糖六点一。';
$add('missing_literal_evidence', '不存在于转写的证据不能成为已核对候选', $quote,
[$event('blood', ['fasting_blood_sugar' => 6.1], '转写中不存在的证据')],
[$expectedItem('blood', ['fasting_blood_sugar' => 6.1], '2026-09-20', '07:45', false, true)]);
$add('empty_literal_evidence', '没有证据的候选需要核对', $quote,
[$event('blood', ['fasting_blood_sugar' => 6.1], $quote, ['evidence' => []])],
[$expectedItem('blood', ['fasting_blood_sugar' => 6.1], '2026-09-20', '07:45', false, true)]);
$add('missing_date', '日期未提供时不使用录制日期静默补齐', $quote,
[$event('blood', ['fasting_blood_sugar' => 6.1], $quote, ['date_text' => ''])],
[$expectedItem('blood', ['fasting_blood_sugar' => 6.1], null, '07:45', false, true)]);
$add('invalid_clock', '非法时分不能进入候选', $quote,
[$event('blood', ['fasting_blood_sugar' => 6.1], $quote, ['record_time' => '25:01'])], []);
$cases[array_key_last($cases)]['expectedErrorCode'] = 'TIME_INVALID';
// These adversarial candidates intentionally contain semantic errors. Observing their
// acceptance proves a boundary, not a product PASS, and never sends answers to a model.
$diagnosisExtra = ['record_time' => null, 'time_text' => ''];
foreach ([
['semantic_family_medication', '家属药物不能当本人用药', '是我父亲在用二甲双胍,我没用。',
['current_medications' => '二甲双胍'], '不应生成本人current_medications=二甲双胍'],
['semantic_negated_medication', '否定句不能反写为正在用药', '我没有使用胰岛素。',
['current_medications' => '胰岛素'], '不应生成本人current_medications=胰岛素'],
['semantic_unsaid_drug', '真实字面引文不等于候选药名有依据', '我在用药,但这次没有说药名。',
['current_medications' => '二甲双胍'], '不应补出未说过的药名'],
['semantic_unsaid_dose', '真实药名不允许补出未说剂量', '我目前在用药甲,剂量没有说。',
['current_medications' => '药甲,每次两片'], '不应补出未说过的剂量'],
['semantic_unsaid_negative', '未提及不能让模型填写无或0', '这次只谈了睡眠,过敏史没有询问。',
['allergy_history' => 0], '不应生成allergy_history=0'],
['semantic_hospital_whitelist', '白名单字段仍须有真实事实依据', '这次没有提到去过哪家医院。',
['local_hospital_name' => '模型编造的医院'], '不应生成未说过的local_hospital_name'],
] as [$id, $claim, $quote, $values, $semanticExpected]) {
$add($id, $claim, $quote, [$event('diagnosis', $values, $quote, $diagnosisExtra)],
[$expectedItem('diagnosis', $values, '2026-09-20', null)], $base, 'model-dependent', $semanticExpected,
'现有Policy/Fields增加了保守逐项复核门禁,但仍不能证明主体、肯否、药名/剂量及事实含义识别正确。');
}
$quoteA = '今天七点四十五分测的空腹血糖六点一。';
$quoteB = '刚才说的还是那次七点四十五分的六点一,不是又测了一次。';
$repeatExpected = $expectedItem('blood', ['fasting_blood_sugar' => 6.1], '2026-09-20', '07:45', false, true);
$add('semantic_same_event_rephrased', '同一测量事件被不同话语重复,不能当两次测量', $quoteA . $quoteB,
[$event('blood', ['fasting_blood_sugar' => 6.1], $quoteA), $event('blood', ['fasting_blood_sugar' => 6.1], $quoteB)],
[$repeatExpected, $repeatExpected], $base, 'model-dependent', '语义识别同一测量,期望只保留一条',
'现有去重以结构字段加相同证据为身份,不解析“那次/不是又测”事件指代。');
$quote = '今天下午三点二十七分收缩压一百二十。';
$add('semantic_spoken_time_only', '原話中明确时分仍需上游正确结构化', $quote,
[$event('blood', ['systolic_pressure' => 120], $quote,
['record_time' => null, 'time_text' => '下午三点二十七分', 'time_period' => '下午'])],
[$expectedItem('blood', ['systolic_pressure' => 120], '2026-09-20', '15:00', true, true, '下午')],
$base, 'model-dependent', '正确识别原话,应给15:27且不估算',
'Policy不把中文time_text解析为时分;候选漏填record_time时仍走下午15:00估算并待确认。');
$add('semantic_exact_time_marked_estimate', '明确时分的估算标志也依赖结构结果准确', $quote,
[$event('blood', ['systolic_pressure' => 120], $quote,
['record_time' => '15:27', 'time_text' => '下午三点二十七分', 'time_period' => '下午', 'time_estimated' => true])],
[$expectedItem('blood', ['systolic_pressure' => 120], '2026-09-20', '15:27', true, true, '下午')],
$base, 'model-dependent', '确切时分应不估算,前提是上游标志正确',
'Policy保守保留上游time_estimated=true,不会从原话自动纠正。');
$results = [];
$failures = [];
foreach ($cases as $case) {
$input = ['summary' => '仅用于本地验收的合成摘要', 'transcript' => $case['transcript'],
'uncertainties' => [], 'items' => $case['items']];
$actual = null;
$error = null;
try { $actual = Policy::normalizeExtraction($input, $case['recordedAt']); }
catch (Throwable $failure) { $error = get_class($failure) . ': ' . $failure->getMessage(); }
$projection = $actual === null ? null : array_map(static fn (array $item): array => array_intersect_key($item,
array_flip(['kind', 'values', 'record_date', 'record_time', 'time_period', 'time_estimated', 'needs_review', 'selected'])), $actual['items']);
$observationMatches = $error === null && Policy::canonical($projection ?? []) === Policy::canonical($case['expected']);
if (isset($case['expectedErrorCode'])) {
$observationMatches = $observationMatches && count($actual['uncertainties']) === 1
&& str_contains($actual['uncertainties'][0], $case['expectedErrorCode']);
} else {
$observationMatches = $observationMatches && ($actual['uncertainties'] ?? []) === [];
}
if (!$observationMatches) { $failures[] = $case['id']; }
$results[] = [
'id' => $case['id'], 'layer' => $case['layer'], 'claim' => $case['claim'],
'recorded_at' => $case['recordedAt'], 'synthetic_transcript' => $case['transcript'],
'candidate_input' => $case['items'], 'expected_rule_observation' => $case['expected'],
'semantic_expected' => $case['semanticExpected'], 'actual' => $actual, 'exception' => $error,
'observation_matches' => $observationMatches,
'status' => !$observationMatches ? 'FAIL' : ($case['layer'] === 'rule' ? 'PASS' : 'MODEL_DEPENDENT_NOT_VERIFIED'),
'semantic_pass' => $case['layer'] === 'rule' ? null : false,
'limitation' => $case['limitation'] ?? '只证明给定合成结构结果的程序规则,不证明ASR或模型会从自然语言生成它。',
];
}
$rules = array_values(array_filter($results, static fn (array $case): bool => $case['layer'] === 'rule'));
$boundaries = array_values(array_filter($results, static fn (array $case): bool => $case['layer'] !== 'rule'));
$crossLayer = [];
$guardChecks = [];
foreach ($results as &$case) {
if ($case['layer'] !== 'model-dependent' || ($case['actual']['items'][0]['kind'] ?? '') !== 'diagnosis') { continue; }
$review = Apply::refresh(['diagnosis_id' => 1, 'patient_id' => 101], $case['actual']['items'],
['id' => 1, 'patient_id' => 101], true);
$case['actual_initial_review_with_empty_current'] = $review;
$case['review_guard'] = '临床候选即便有逐字证据且字段为空,也必须人工逐项复核;语义准确性仍未证明。';
$blocked = !$review[0]['selected'] && $review[0]['needs_review'];
$guardChecks[] = ['id' => $case['id'] . ':no_default_adoption', 'status' => $blocked ? 'PASS' : 'FAIL',
'expected' => ['selected' => false, 'needs_review' => true], 'actual' => $review];
if (!$blocked) { $failures[] = $case['id'] . ':unguarded'; }
}
unset($case);
foreach ([
['clean_numeric', '今天七点四十五分收缩压一百二十。', '今天七点四十五分收缩压一百二十。', false],
['family_context_outside_quote', '父亲刚才说了他的读数。今天七点四十五分收缩压一百二十。', '今天七点四十五分收缩压一百二十。', true],
['question_context_outside_quote', '客服问:今天七点四十五分收缩压一百二十吗?', '收缩压一百二十', true],
['negative_numeric', '今天收缩压不是一百二十。', '一百二十', true],
['corrected_numeric', '刚才说错了,一百二十要改成一百三十。', '一百二十', true],
['ambiguous_numeric', '我记不清,收缩压大概一百二十左右。', '一百二十', true],
] as [$id, $transcript, $quote, $needsReview]) {
$normalized = Policy::normalizeExtraction(['summary' => '合成数字复核门禁', 'transcript' => $transcript,
'items' => [$event('diagnosis', ['systolic_pressure' => 120], $quote)]], $base);
$review = Apply::refresh(['diagnosis_id' => 1, 'patient_id' => 101], $normalized['items'],
['id' => 1, 'patient_id' => 101], true);
$ok = $review[0]['needs_review'] === $needsReview && $review[0]['selected'] === !$needsReview;
$guardChecks[] = ['id' => $id, 'transcript' => $transcript, 'quote' => $quote,
'expected' => ['needs_review' => $needsReview, 'selected' => !$needsReview], 'actual' => $review,
'status' => $ok ? 'PASS' : 'FAIL', 'scope' => '保守字面/邻近上下文门禁,不证明自然语言理解。'];
if (!$ok) { $failures[] = $id; }
}
// Only an in-memory synthetic dictionary is accessed. App::initialize is never called.
new think\App();
$manager = new think\DbManager();
$manager->setConfig(['default' => 'sqlite', 'connections' => ['sqlite' => [
'type' => 'sqlite', 'database' => ':memory:', 'prefix' => 'zyt_',
]]]);
think\Container::getInstance()->instance('think\DbManager', $manager);
think\facade\Db::execute('CREATE TABLE zyt_dict_data (id INTEGER PRIMARY KEY, type_value TEXT, status INTEGER, sort INTEGER, name TEXT, value TEXT)');
foreach (['appetite', 'past_history', 'diet_condition', 'diabetes_type'] as $type) {
foreach (['synthetic_a', 'synthetic_b', '0'] as $value) {
think\facade\Db::name('dict_data')->insert(['type_value' => $type, 'status' => 1, 'sort' => 1,
'name' => '合成选项_' . $value, 'value' => $value]);
}
}
$dietInput = ['breakfast' => '合成早餐鸡蛋', 'lunch' => '合成午餐豆腐', 'dinner' => '合成晚餐青菜', 'note' => '合成备注'];
$dietDb = Fields::toDatabase('diet', $dietInput);
$dietRead = (new DietRecord($dietDb + ['id' => 201, 'record_date' => '2026-09-20']))->toArray();
$exerciseInput = ['exercise_type' => '散步', 'duration' => 20, 'intensity' => 2, 'note' => '合成运动'];
$exerciseDb = Fields::toDatabase('exercise', $exerciseInput);
$exerciseRead = (new ExerciseRecord($exerciseDb + ['id' => 301, 'record_date' => '2026-09-20']))
->append(['intensity_text'])->toArray();
// Execute the existing pure normalization block from DiagnosisLogic::detail, stopping
// before its first doctor-note lookup. This verifies actual reader code, not a copy.
$method = new ReflectionMethod(app\adminapi\logic\tcm\DiagnosisLogic::class, 'detail');
$methodSource = implode('', array_slice(file($method->getFileName()), $method->getStartLine() - 1,
$method->getEndLine() - $method->getStartLine() + 1));
$normalizeStart = strpos($methodSource, '// 处理既往史为数组');
$normalizeEnd = strpos($methodSource, '$noteImages = DoctorNoteLogic::');
if ($normalizeStart === false || $normalizeEnd === false) { throw new RuntimeException('DIAGNOSIS_READ_NORMALIZER_NOT_FOUND'); }
$normalizeSource = substr($methodSource, $normalizeStart, $normalizeEnd - $normalizeStart);
$normalizeDiagnosis = static function (array $diagnosis) use ($normalizeSource): array {
eval($normalizeSource);
return $diagnosis;
};
foreach (['nonzero' => ['synthetic_a', 'synthetic_b'], 'zero' => ['0']] as $variant => $selected) {
$input = array_fill_keys(['appetite', 'past_history', 'diet_condition'], $selected);
$database = Fields::toDatabase('diagnosis', $input);
$modelRead = (new Diagnosis($database))->append(['past_history_arr'])->toArray();
$detailRead = $normalizeDiagnosis($modelRead);
$comparison = array_intersect_key($detailRead, $input);
$faithful = Policy::canonical($comparison) === Policy::canonical($input);
$crossLayer[] = ['id' => 'diagnosis_multiselect_' . $variant,
'input' => $input, 'database_encoding' => $database, 'actual_model_getters' => $modelRead,
'actual_detail_read_normalization' => $detailRead, 'expected' => $input,
'roundtrip_preserved' => $faithful, 'status' => $faithful ? 'PASS' : 'RISK_CONFIRMED',
'limitation' => '只用合成有效字典选项;未读取生产字典。已验证合法字符串0读回保留。'];
if ($variant === 'nonzero' && !$faithful) { $failures[] = 'diagnosis_multiselect_nonzero'; }
if ($variant === 'zero' && !$faithful) { $failures[] = 'diagnosis_multiselect_zero'; }
}
$hospitalOptions = Fields::validateValues('diagnosis', ['local_hospital_diagnosis' => ['糖尿病', '消渴病', '糖尿病前期']]);
$editor = file_get_contents(dirname(__DIR__, 2) . '/admin/src/views/tcm/diagnosis/edit.vue');
preg_match('/<el-checkbox-group v-model="formData.local_hospital_diagnosis">([\s\S]*?)<\/el-checkbox-group>/', $editor, $hospitalGroup);
preg_match_all('/<el-checkbox-button label="([^"]+)"/', $hospitalGroup[1] ?? '', $hospitalLabels);
$hospitalMatches = $hospitalOptions['local_hospital_diagnosis'] === ($hospitalLabels[1] ?? []);
$rejectUnrelated = false;
try { Fields::validateValues('diagnosis', ['local_hospital_diagnosis' => ['synthetic_a']]); }
catch (DomainException $error) { $rejectUnrelated = $error->getMessage() === 'FOLLOWUP_AUDIO_OPTION_INVALID'; }
$crossLayer[] = ['id' => 'hospital_editor_contract', 'input' => $hospitalOptions,
'database_encoding' => Fields::toDatabase('diagnosis', $hospitalOptions),
'actual_editor_labels' => $hospitalLabels[1] ?? [], 'unrelated_dictionary_value_rejected' => $rejectUnrelated,
'status' => $hospitalMatches && $rejectUnrelated ? 'PASS' : 'FAIL',
'expected' => ['糖尿病', '消渴病', '糖尿病前期'], 'limitation' => '只证明枚举契约一致,不证明医院诊断事实真实性。'];
if (!$hospitalMatches || !$rejectUnrelated) { $failures[] = 'hospital_editor_contract'; }
foreach ([null, '', '0', 0] as $csv) {
$raw = ['appetite' => $csv, 'past_history' => $csv, 'diet_condition' => $csv];
$model = (new Diagnosis($raw))->append(['past_history_arr'])->toArray();
$read = $normalizeDiagnosis($model);
$expected = $csv === null || $csv === '' ? [] : ['0'];
$ok = $read['appetite'] === $expected && $read['past_history'] === $expected
&& $read['diet_condition'] === $expected && $model['past_history_arr'] === $expected;
$guardChecks[] = ['id' => 'csv_empty_zero_' . json_encode($csv), 'input' => $raw,
'expected' => $expected, 'actual' => $read, 'status' => $ok ? 'PASS' : 'FAIL'];
if (!$ok) { $failures[] = 'csv_empty_zero'; }
}
$mysqlFixture = null;
$exportPath = (string) getenv('FOLLOWUP_AUDIO_TEST_CANONICAL_EXPORT');
if ($exportPath !== '') {
$mysqlFixture = json_decode(file_get_contents($exportPath), true, 512, JSON_THROW_ON_ERROR);
if (($mysqlFixture['source'] ?? '') !== 'real-disposable-mysql-apply' || empty($mysqlFixture['synthetic'])) {
throw new RuntimeException('SYNTHETIC_MYSQL_EXPORT_REQUIRED');
}
$dietDb = $mysqlFixture['diet_row'];
$exerciseDb = $mysqlFixture['exercise_row'];
$dietInput = array_filter(Fields::fromDatabase('diet', $dietDb), static fn ($value): bool => $value !== null);
$exerciseInput = array_filter(Fields::fromDatabase('exercise', $exerciseDb), static fn ($value): bool => $value !== null);
$dietRead = (new DietRecord($dietDb))->toArray();
$exerciseRead = (new ExerciseRecord($exerciseDb))->append(['intensity_text'])->toArray();
$dietRead['record_date'] = date('Y-m-d', (int) $dietDb['record_date']);
$exerciseRead['record_date'] = date('Y-m-d', (int) $exerciseDb['record_date']);
}
$nodeSource = <<<'JS'
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const ts = require('typescript')
const vue = require('vue')
const { parse, compileScript } = require('@vue/compiler-sfc')
const input = JSON.parse(fs.readFileSync(0, 'utf8'))
const date = input.diet.record_date
const filename = path.resolve('src/views/tcm/diagnosis/components/DailyMatrix.vue')
const source = fs.readFileSync(filename, 'utf8')
const { descriptor, errors } = parse(source, { filename })
if (errors.length) throw new Error(JSON.stringify(errors))
const script = compileScript(descriptor, { id: 'extraction-cross-layer' })
const thresholdExports = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync('src/utils/blood-thresholds.ts', 'utf8'),
{ compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, { exports: thresholdExports })
const moduleObject = { exports: {} }
const runtime = { ...vue, onMounted() {}, onUnmounted() {} }
const api = new Proxy({}, { get() { return () => Promise.resolve({}) } })
vm.runInNewContext(ts.transpileModule(script.content,
{ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, {
exports: moduleObject.exports, module: moduleObject, console, Date, Map,
window: { addEventListener() {}, removeEventListener() {} },
require(name) {
if (name === 'vue') return runtime
if (name === '@/api/tcm') return api
if (name === '@/utils/perm') return { hasPermission: () => true }
if (name === '@/utils/blood-thresholds') return thresholdExports
if (name === '@/utils/feedback') return { default: { msgWarning() {} } }
if (name === 'vue-echarts' || name.endsWith('.vue')) return { default: {} }
throw new Error(`Unexpected import: ${name}`)
}
}, { filename })
const scope = vue.effectScope()
const state = scope.run(() => moduleObject.exports.default.setup(vue.reactive({ diagnosisId: 7, patientId: 70,
age: 50, readOnly: false, patientName: '合成验收' }), { expose() {} }))
state.dietRecords.value = [input.diet]
state.exerciseRecords.value = [input.exercise]
state.openDietRecord(input.diet, date)
state.openExerciseRecord(input.exercise, date)
const mealMatch = descriptor.template.content.match(/{{\s*(record\[`\$\{meal.key\}_foods`\][\s\S]*?)\s*}}/)
if (!mealMatch) throw new Error('ACTUAL_MEAL_TEMPLATE_EXPRESSION_NOT_FOUND')
const meals = Object.fromEntries(['breakfast', 'lunch', 'dinner'].map(key =>
[key, vm.runInNewContext(mealMatch[1], { record: input.diet, meal: { key } })]))
console.log(JSON.stringify({ actual_meal_template_expression: mealMatch[1], detail_meals: meals,
diet_cells: Object.fromEntries(['breakfast', 'lunch', 'dinner'].map(key => [key, state.getCell(key, date)])),
actual_diet_edit_form: state.dietForm.value, actual_exercise_edit_form: state.exerciseForm.value,
exercise_cell: state.getCell('exercise', date), intensity_fallback: state.intensityLabel(input.exercise.intensity),
scope: 'Actual SFC script and detail interpolation executed with synthetic records; not browser rendering or live API/DB.' }))
scope.stop()
JS;
$nodeInput = json_encode(['diet' => $dietRead, 'exercise' => $exerciseRead], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
$nodeCommand = [getenv('FOLLOWUP_AUDIO_ACCEPTANCE_NODE') ?: 'node', '-e', $nodeSource];
$nodeProcess = proc_open($nodeCommand, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$nodePipes, dirname(__DIR__, 2) . '/admin');
if (!is_resource($nodeProcess)) { throw new RuntimeException('CROSS_LAYER_NODE_START_FAILED'); }
fwrite($nodePipes[0], $nodeInput); fclose($nodePipes[0]);
$nodeStdout = stream_get_contents($nodePipes[1]); fclose($nodePipes[1]);
$nodeStderr = stream_get_contents($nodePipes[2]); fclose($nodePipes[2]);
$nodeExit = proc_close($nodeProcess);
$nodeResult = $nodeExit === 0 ? json_decode($nodeStdout, true, 512, JSON_THROW_ON_ERROR) : null;
$crossLayer[] = ['id' => 'diet_apply_model_daily_matrix', 'input' => $dietInput,
'database_encoding' => $dietDb, 'actual_model_getters' => $dietRead,
'actual_daily_matrix' => $nodeResult, 'status' => 'PASS',
'expected' => '三个餐食在详情显示且编辑表单能原样读回*_foods',
'limitation' => '已映射canonical *_foods并通过实际getter/编辑读回;MySQL证据见mysql_fixture,未连接生产。'];
$crossLayer[] = ['id' => 'exercise_apply_model_daily_matrix', 'input' => $exerciseInput,
'database_encoding' => $exerciseDb, 'actual_model_getters' => $exerciseRead,
'actual_daily_matrix' => $nodeResult === null ? null : array_intersect_key($nodeResult,
array_flip(['actual_exercise_edit_form', 'exercise_cell', 'intensity_fallback'])),
'expected' => $exerciseInput,
'status' => 'PASS', 'limitation' => '实际getter及SFC纯函数已执行,未证明真实数据库或浏览器页面。'];
if ($nodeExit !== 0 || $nodeStderr !== '') { $failures[] = 'cross_layer_node'; }
if (($nodeResult['detail_meals']['breakfast'] ?? '') !== $dietInput['breakfast']
|| ($nodeResult['actual_diet_edit_form']['breakfast_foods'] ?? null) !== $dietInput['breakfast']) { $failures[] = 'diet_roundtrip'; }
if (($exerciseRead['intensity_text'] ?? '') !== ([1 => '低强度', 2 => '中强度', 3 => '高强度'][(int) $exerciseInput['intensity']])
|| ($nodeResult['exercise_cell']['value'] ?? '') !== $exerciseInput['duration'] . 'min'
|| ($nodeResult['actual_exercise_edit_form']['exercise_type'] ?? '') !== $exerciseInput['exercise_type']) { $failures[] = 'exercise_roundtrip'; }
foreach (['breakfast', 'lunch', 'dinner'] as $meal) {
if (($nodeResult['actual_diet_edit_form'][$meal . '_foods'] ?? '') !== ($dietInput[$meal] ?? '')) { $failures[] = 'diet_edit:' . $meal; }
if (($nodeResult['actual_diet_edit_form'][$meal . '_images'] ?? []) !== ($dietRead[$meal . '_images'] ?? [])) { $failures[] = 'diet_images:' . $meal; }
}
$summary = [
'acceptance_result' => 'LOCAL_GUARDS_VERIFIED_REAL_MODEL_UNVERIFIED',
'exit_zero_means' => 'Local rules/guards/roundtrips verified; NOT live-model or release acceptance.',
'rule_cases' => count($rules),
'rule_passed' => count(array_filter($rules, static fn (array $case): bool => $case['status'] === 'PASS')),
'model_dependent_cases' => count($boundaries),
'boundary_observations_reproduced' => count(array_filter($boundaries, static fn (array $case): bool => $case['observation_matches'])),
'model_semantics_accepted' => false, 'live_dify_tested' => false, 'asr_tested' => false,
'dialect_tested' => false, 'long_audio_tested' => false, 'cross_layer_cases' => count($crossLayer),
'cross_layer_risks_confirmed' => count(array_filter($crossLayer, static fn (array $case): bool => $case['status'] === 'RISK_CONFIRMED')),
'guard_checks' => count($guardChecks), 'guard_passed' => count(array_filter($guardChecks, static fn (array $case): bool => $case['status'] === 'PASS')),
'mysql_canonical_fixture_loaded' => $mysqlFixture !== null, 'failures' => $failures,
];
$ledger = [
'suite' => 'followup-audio-extraction-acceptance-v1', 'synthetic_only' => true,
'network_access' => false, 'database_access' => 'Synthetic SQLite :memory: dictionary only; no application/production database.',
'scope' => 'Actual Policy::normalizeExtraction and Fields::validateValues on supplied synthetic transcript/candidates only.',
'source_sha256' => [
'FollowupAudioPolicy.php' => hash_file('sha256', dirname(__DIR__) . '/app/common/service/followupaudio/FollowupAudioPolicy.php'),
'FollowupAudioFields.php' => hash_file('sha256', dirname(__DIR__) . '/app/common/service/followupaudio/FollowupAudioFields.php'),
],
'whitelist_note' => [
'local_hospital_diagnosis_is_allowed' => in_array('local_hospital_diagnosis', Fields::keys('diagnosis'), true),
'local_hospital_name_is_allowed' => in_array('local_hospital_name', Fields::keys('diagnosis'), true),
'warning' => '拒绝未知hospital_diagnosis/prescription字段不等于拒绝白名单字段内的语义幻觉。',
],
'summary' => $summary, 'cases' => $results, 'cross_layer' => $crossLayer, 'guard_checks' => $guardChecks, 'mysql_fixture' => $mysqlFixture,
'node_execution' => ['command' => $nodeCommand, 'stdin' => $nodeInput, 'stdout' => $nodeStdout,
'stderr' => $nodeStderr, 'exit_status' => $nodeExit],
];
if (in_array('--json', $argv, true)) {
echo json_encode($ledger, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . PHP_EOL;
} else {
foreach ($results as $case) {
echo '[' . $case['status'] . '] ' . $case['id'] . ' ' . $case['claim'] . PHP_EOL;
echo ' 原话: ' . $case['synthetic_transcript'] . PHP_EOL;
echo ' 候选: ' . json_encode($case['candidate_input'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL;
echo ' 期望: ' . ($case['semantic_expected'] ?? json_encode($case['expected_rule_observation'], JSON_UNESCAPED_UNICODE)) . PHP_EOL;
echo ' 实际: ' . json_encode($case['actual'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL;
}
foreach ($crossLayer as $case) {
echo '[' . $case['status'] . '] ' . $case['id'] . ' ' . json_encode($case, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL;
}
echo 'FOLLOWUP_AUDIO_EXTRACTION_ACCEPTANCE ' . json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL;
}
if ($failures !== []) { fwrite(STDERR, 'OBSERVATION_MISMATCH ' . implode(',', $failures) . PHP_EOL); }
exit($failures === [] ? 0 : 1);
+171
View File
@@ -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);
}
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
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 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 {
self::$calls++;
$heartbeat(['stage' => 'uploading', 'upstream_started_at' => time(), 'upstream_ids_json' => '{"request_id":"synthetic-request"}']);
if (self::$mode === 'uncertain') { throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true); }
return ['summary' => 'synthetic', 'transcript' => 'canary-start canary-end PRIVATE_TRANSCRIPT_NEVER_PRINTED', 'items' => $fixture['expected']['facts']];
}
}
}
namespace {
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\command\FollowupAudioProbe as Probe;
use think\console\Input;
use think\console\Output;
$directory = sys_get_temp_dir() . '/followup-audio-probe-state-' . bin2hex(random_bytes(6));
mkdir($directory, 0700, true);
$directory = realpath($directory);
$manifest = ['synthetic' => true, 'generator' => 'followup-audio-synthetic-v1', 'recorded_at' => '2026-09-29 10:00:00', 'fixtures' => []];
foreach (['short' => 60, 'medium' => 900, 'long' => 3598] as $case => $duration) {
$path = $directory . '/' . $case . '.mp3';
file_put_contents($path, 'synthetic-command-state-test-double-' . $case);
$manifest['fixtures'][] = ['case' => $case, 'file' => $case . '.mp3', 'sha256' => hash_file('sha256', $path),
'duration_seconds' => $duration, 'expected' => ['canaries' => ['canary-start', 'canary-end'], 'facts' => [
['kind' => 'blood', 'record_date' => '2026-09-28', 'record_time' => '21:00', 'values' => ['systolic_pressure' => 126]],
['kind' => 'blood', 'record_date' => '2026-09-27', 'record_time' => '14:00', 'values' => ['postprandial_blood_sugar' => 7.2]],
['kind' => 'blood', 'record_date' => '2026-09-29', 'record_time' => '07:00', 'values' => ['systolic_pressure' => 147]],
]]];
}
file_put_contents($directory . '/manifest.json', json_encode($manifest));
$checks = 0;
$expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; };
$run = static function (string $profile, bool $synthetic = true) use ($directory): array {
$input = new Input(array_merge($synthetic ? ['--synthetic'] : [], ['--manifest', $directory . '/manifest.json', '--profile', $profile]));
$output = new Output('buffer');
$code = (new Probe())->run($input, $output);
return [$code, $output->fetch()];
};
try {
[$code, $stdout] = $run('qwen', false);
$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);
$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');
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');
$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';
[$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');
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');
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); } }
rmdir($directory);
}
}
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace think\facade {
// In-memory transaction/row-lock fixture: no application bootstrap or database.
class Db
{
public static int $depth = 0;
public static int $calls = 0;
public static function transaction(callable $callback)
{
self::$calls++;
$snapshot = \app\common\model\tcm\TrackingNote::$rows;
self::$depth++;
try { return $callback(); }
catch (\Throwable $error) { \app\common\model\tcm\TrackingNote::$rows = $snapshot; throw $error; }
finally { self::$depth--; }
}
}
}
namespace app\common\model\tcm {
class FixtureQuery
{
public static array $events = [];
public static bool $enforceLocks = false;
private string $table;
private array $where = [];
private bool $locked = false;
public function __construct(string $table) { $this->table = $table; }
public function where(string $key, $value): self { $this->where[$key] = $value; return $this; }
public function whereNull(string $key): self { return $this->where($key, null); }
public function lock(bool $value): self { $this->locked = $value; return $this; }
public function find()
{
self::$events[] = ['table' => $this->table, 'where' => $this->where, 'locked' => $this->locked];
if (self::$enforceLocks && (!$this->locked || \think\facade\Db::$depth < 1)) {
throw new \RuntimeException('write read must lock inside caller transaction');
}
if ($this->table === 'diagnosis') return ($this->where['id'] ?? 0) === 7 ? (object) ['id' => 7] : null;
foreach (TrackingNote::$rows as $row) {
$matches = true;
foreach ($this->where as $key => $value) { if (($row[$key] ?? null) !== $value) $matches = false; }
if ($matches) return new FixtureRow($row);
}
return null;
}
}
class FixtureRow
{
public function __construct(private array $data) {}
public function __get(string $key) { return $this->data[$key] ?? null; }
public function __isset(string $key): bool { return isset($this->data[$key]); }
public function __set(string $key, $value): void { $this->data[$key] = $value; }
public function save(): void { TrackingNote::$rows[$this->data['id']] = $this->data; }
}
class Diagnosis
{
public static function where(string $key, $value): FixtureQuery { return (new FixtureQuery('diagnosis'))->where($key, $value); }
}
class TrackingNote
{
public static array $rows = [];
public static function where(string $key, $value): FixtureQuery { return (new FixtureQuery('tracking_note'))->where($key, $value); }
public static function create(array $data): FixtureRow
{
$data['id'] = count(self::$rows) + 1;
$data['delete_time'] = null;
self::$rows[$data['id']] = $data;
return new FixtureRow($data);
}
}
}
namespace {
use app\adminapi\logic\tcm\TrackingNoteLogic;
use app\common\model\tcm\TrackingNote;
use app\common\model\tcm\FixtureQuery;
use think\facade\Db;
require dirname(__DIR__) . '/app/common/logic/BaseLogic.php';
$sourceFlag = array_search('--source', $argv, true);
require $sourceFlag === false ? dirname(__DIR__) . '/app/adminapi/logic/tcm/TrackingNoteLogic.php' : $argv[$sourceFlag + 1];
date_default_timezone_set('Asia/Shanghai');
function check(bool $condition, string $message): void { if (!$condition) throw new RuntimeException($message); }
function rejects(callable $fn, string $message): void
{
try { $fn(); } catch (DomainException $expected) { return; }
throw new RuntimeException($message);
}
$today = (new DateTimeImmutable('now', new DateTimeZone('Asia/Shanghai')))->format('Y-m-d');
check(TrackingNoteLogic::addOrAppend(['diagnosis_id' => 7, 'admin_id' => 9, 'content' => '今日合成备注', 'note_date' => '2000-01-01']), 'legacy call remains accepted');
$legacy = reset(TrackingNote::$rows);
check($legacy['note_date'] === $today, 'legacy entry point still ignores submitted historical date');
if (in_array('--observe', $argv, true)) {
echo json_encode(['legacyTodayOnly' => $legacy['note_date'] === $today, 'explicitHistoricalDateSupported' => method_exists(TrackingNoteLogic::class, 'appendForDate')], JSON_UNESCAPED_UNICODE) . PHP_EOL;
exit(0);
}
check(method_exists(TrackingNoteLogic::class, 'appendForDate'), 'explicit historical API exists');
TrackingNote::$rows = [];
FixtureQuery::$events = [];
FixtureQuery::$enforceLocks = true;
$ownTransactions = Db::$calls;
$id = Db::transaction(static fn() => TrackingNoteLogic::appendForDate(7, ' 2024-02-29 ', ' 第一条合成历史备注 ', 9));
check(Db::$calls === $ownTransactions + 1, 'new method does not open or commit a nested transaction');
check($id === 1, 'actual created id returned');
check(TrackingNote::$rows[$id]['note_date'] === '2024-02-29', 'valid leap-day date normalized explicitly');
check(preg_match('/^\[\d{2}:\d{2}\] 第一条合成历史备注$/u', TrackingNote::$rows[$id]['content']) === 1, 'trimmed content has insertion-time prefix');
$again = Db::transaction(static fn() => TrackingNoteLogic::appendForDate(7, '2024-02-29', '第二条合成历史备注', 10));
check($again === $id && count(TrackingNote::$rows) === 1, 'same day appends instead of replacing or creating second note');
check(substr_count(TrackingNote::$rows[$id]['content'], "\n") === 1, 'two original lines preserved');
check(str_contains(TrackingNote::$rows[$id]['content'], '第一条合成历史备注'), 'first content retained');
check(TrackingNote::$rows[$id]['admin_id'] === 9, 'original creator retained; caller audit owns latest actor');
check(array_column(FixtureQuery::$events, 'table') === ['diagnosis', 'tracking_note', 'diagnosis', 'tracking_note'], 'diagnosis lock precedes exact-day row lock for both insert/update');
check(FixtureQuery::$events[1]['where'] === ['diagnosis_id' => 7, 'note_date' => '2024-02-29'], 'date and diagnosis scoped lookup includes deleted rows explicitly');
$before = TrackingNote::$rows;
try {
Db::transaction(static function (): void {
TrackingNoteLogic::appendForDate(7, '2024-02-29', '必须随调用者回滚', 9);
throw new RuntimeException('synthetic audit insert failure');
});
} catch (RuntimeException $error) { check($error->getMessage() === 'synthetic audit insert failure', 'expected synthetic rollback'); }
check(TrackingNote::$rows === $before, 'caller rollback restores note alongside task/audit transaction');
$tomorrow = (new DateTimeImmutable('tomorrow', new DateTimeZone('Asia/Shanghai')))->format('Y-m-d');
foreach (['', '2025-02-29', '2024-02-30', '2024-2-01', '2024/02/01', '0000-01-01', '2024-01-01 08:00:00', $tomorrow] as $invalid) {
rejects(static fn() => TrackingNoteLogic::appendForDate(7, $invalid, '合成备注', 9), 'invalid date rejected: ' . $invalid);
}
rejects(static fn() => TrackingNoteLogic::appendForDate(0, '2024-01-01', '内容', 9), 'missing diagnosis rejected');
rejects(static fn() => TrackingNoteLogic::appendForDate(7, '2024-01-01', '内容', 0), 'missing actor rejected');
rejects(static fn() => TrackingNoteLogic::appendForDate(7, '2024-01-01', ' ', 9), 'blank content rejected');
rejects(static fn() => Db::transaction(static fn() => TrackingNoteLogic::appendForDate(999, '2024-01-01', '内容', 9)), 'missing diagnosis row rejected');
TrackingNote::$rows[$id]['delete_time'] = 123;
rejects(static fn() => Db::transaction(static fn() => TrackingNoteLogic::appendForDate(7, '2024-02-29', '不能复活已删除备注', 9)), 'deleted note cannot be resurrected');
check(TrackingNote::$rows[$id]['delete_time'] === 123, 'deleted state retained');
TrackingNote::$rows = [];
FixtureQuery::$events = [];
check(TrackingNoteLogic::addOrAppend(['diagnosis_id' => 7, 'content' => '旧接口仍可追加']), 'legacy zero-actor semantics preserved');
check(reset(TrackingNote::$rows)['note_date'] === $today, 'legacy remains today only');
check(FixtureQuery::$events[0]['table'] === 'diagnosis' && FixtureQuery::$events[0]['locked'], 'legacy shares serialization lock with new service');
check(!TrackingNoteLogic::addOrAppend(['diagnosis_id' => 0, 'content' => '无效']), 'legacy invalid ID returns false');
check(!TrackingNoteLogic::addOrAppend(['diagnosis_id' => 7, 'content' => '']), 'legacy blank content returns false');
echo "FollowupAudioTrackingNoteBehaviorTest: historical date/leap-day validation, caller transaction rollback, lock order, append-only identity, deleted guard, legacy today semantics: PASS\n";
}
+116
View File
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace app\common\service\followupaudio {
// This test exercises the real file/DB pipeline, with a deterministic synthetic row-scope boundary.
final class FollowupAudioAccess
{
public static function diagnosis(int $id, int $actor, array $info, bool $daily = false): array
{
if ($actor !== 7 || $id !== 91) {
throw new \DomainException('synthetic row scope denied');
}
return ['id' => 91, 'patient_id' => 101];
}
}
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
$app = new \think\App(dirname(__DIR__) . '/');
$app->initialize();
$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',
'charset' => 'utf8mb4', 'prefix' => 'far_', 'debug' => false,
]],
], 'database');
set_exception_handler(static function (\Throwable $e): void { fwrite(STDERR, get_class($e) . ': ' . $e->getMessage() . PHP_EOL . $e->getTraceAsString() . PHP_EOL); exit(1); });
$dir = sys_get_temp_dir() . '/followup-upload-test-' . bin2hex(random_bytes(8));
mkdir($dir, 0700, true);
$app->config->set(['private_dir' => $dir . '/private', 'max_bytes' => 524288000,
'max_seconds' => 3600, 'chunk_bytes' => 65536, 'ffprobe' => '/opt/homebrew/bin/ffprobe'], 'followup_audio');
$db = \think\facade\Db::class;
$db::execute('CREATE TABLE IF NOT EXISTS far_followup_audio_upload (
id VARCHAR(64) PRIMARY KEY, diagnosis_id BIGINT NOT NULL, actor_id BIGINT NOT NULL,
file_name VARCHAR(255) NOT NULL, extension VARCHAR(10) NOT NULL, total_bytes BIGINT NOT NULL,
received_bytes BIGINT NOT NULL DEFAULT 0, sha256 VARCHAR(64) NOT NULL DEFAULT "",
duration_seconds DECIMAL(12,3) NOT NULL DEFAULT 0, status VARCHAR(32) NOT NULL,
created_at BIGINT NOT NULL, expires_at BIGINT NOT NULL) ENGINE=InnoDB');
$checks = 0;
$ids = [];
$ok = function (bool $condition, string $message) use (&$checks): void {
if (!$condition) {
throw new \RuntimeException($message);
}
++$checks;
};
$reject = function (callable $f, string $message) use ($ok): void {
try { $f(); } catch (\DomainException $e) { $ok(true, $message); return; }
$ok(false, $message);
};
$upload = \app\common\service\followupaudio\FollowupAudioUpload::class;
try {
foreach (['../a.wav', 'a/b.wav', 'a\\b.wav', "x\0.mp3", 'x.php', 'x.MP4'] as $bad) {
$reject(fn () => $upload::fileName($bad), 'bad filename was accepted');
}
$ok($upload::fileName('回访.WAV')[1] === 'wav', 'uppercase extension normalization');
$reject(fn () => $upload::createSession(92, 'x.wav', 100, 7, []), 'wrong diagnosis');
$reject(fn () => $upload::createSession(91, 'x.wav', 0, 7, []), 'empty file');
$reject(fn () => $upload::createSession(91, 'x.wav', 524288001, 7, []), 'oversized file');
// 3 seconds of valid 16 kHz PCM audio, with actual RIFF metadata (multiple 64KiB test chunks).
$pcm = str_repeat(pack('v', 1000), 16000 * 3);
$wave = 'RIFF' . pack('V', 36 + strlen($pcm)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 16000, 32000, 2, 16)
. 'data' . pack('V', strlen($pcm)) . $pcm;
file_put_contents($dir . '/source.wav', $wave);
$meta = $upload::inspect($dir . '/source.wav', 'wav');
$ok(abs($meta['duration_seconds'] - 3) < 0.01, 'real ffprobe duration');
$reject(fn () => $upload::inspect($dir . '/source.wav', 'mp3'), 'extension/content mismatch');
file_put_contents($dir . '/fake.wav', '<?php echo "not audio";');
$reject(fn () => $upload::inspect($dir . '/fake.wav', 'wav'), 'fake audio was accepted');
$session = $upload::createSession(91, '回访.wav', strlen($wave), 7, []);
$id = $session['upload_id']; $ids[] = $id;
$ok((bool) preg_match('/^[a-f0-9]{48}$/D', $id), 'unguessable session ID');
$reject(fn () => $upload::owned($id, 8, []), 'foreign actor');
$reject(fn () => $upload::session('../outside'), 'path traversal');
$reject(fn () => $upload::complete($id, 7, []), 'incomplete merge');
$chunks = str_split($wave, $session['chunk_bytes']);
foreach ($chunks as $index => $contents) {
file_put_contents($dir . '/part', $contents);
$upload::putChunk($id, $index, $dir . '/part', 7, []);
$upload::putChunk($id, $index, $dir . '/part', 7, []); // Exact repeat is idempotent.
}
$ok((int) $upload::session($id)['received_bytes'] === strlen($wave), 'repeated chunks counted twice');
file_put_contents($dir . '/part', str_repeat('x', strlen($chunks[0])));
$reject(fn () => $upload::putChunk($id, 0, $dir . '/part', 7, []), 'conflicting chunk');
$reject(fn () => $upload::putChunk($id, 999, $dir . '/part', 7, []), 'invalid chunk index');
$result = $upload::complete($id, 7, []);
$ok($result['sha256'] === hash('sha256', $wave), 'assembled bytes differ');
$ok($upload::complete($id, 7, []) === $result, 'complete not idempotent');
$row = $upload::session($id);
$path = $upload::path($row);
$ok(file_get_contents($path) === $wave, 'private file mismatch');
$response = new \app\common\service\followupaudio\FollowupAudioStream($path, 'wav');
$ok($response->getHeader('Cache-Control') === 'private, no-store, max-age=0', 'private response cached');
$send = new \ReflectionMethod($response, 'sendData');
$send->setAccessible(true); ob_start(); $send->invoke($response, ''); $bytes = ob_get_clean();
$ok($bytes === $wave, 'streamed response differs');
$reject(fn () => $upload::cleanup($id), 'early cleanup accepted');
$db::name('followup_audio_upload')->where('id', $id)->update(['expires_at' => time() - 1]);
$reject(fn () => $upload::path($upload::session($id)), 'expired audio still playable');
$upload::cleanup($id);
$ok(!file_exists($path) && $upload::session($id)['status'] === 'deleted', 'expired original not deleted');
$upload::cleanup($id);
$ok($upload::session($id)['status'] === 'deleted', 'cleanup repeat is idempotent');
$db::name('followup_audio_upload')->where('id', $id)->update(['status' => 'complete']);
$upload::cleanup($id);
$ok($upload::session($id)['status'] === 'deleted', 'missing directory after DB rollback can be reconciled');
echo "Followup audio private upload: {$checks} checks passed\n";
} finally {
foreach ($ids as $id) { $db::name('followup_audio_upload')->where('id', $id)->delete(); }
$files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $f) { $f->isDir() && !$f->isLink() ? rmdir($f->getPathname()) : unlink($f->getPathname()); }
rmdir($dir);
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace app\common\service\prescriptionai {
final class PrescriptionAiAccess {
public static bool $active = true;
public static function actor(int $id): ?array { return self::$active ? ['id' => $id] : null; }
}
}
namespace app\common\service\followupaudio {
final class FollowupAudioStore {
public static bool $enabled = true;
public static bool $verified = true;
public static array $events = [];
public static bool $lease = 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 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; }
public static function fail(int $id, string $token, string $code, string $message, bool $uncertain): bool { self::$events[] = ['fail' => $code, 'uncertain' => $uncertain, 'message' => $message]; return true; }
}
final class FollowupAudioAccess {
public static bool $allowed = true;
public static function task(int $id, int $actor, array $info): array { if (!self::$allowed) { throw new \RuntimeException('private-patient'); } return ['id' => $id]; }
}
final class FollowupAudioDify {
public static string $mode = 'success';
public function analyze(array $task, callable $heartbeat): array {
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 === 'revoke') { \app\common\service\prescriptionai\PrescriptionAiAccess::$active = false; }
return ['summary' => 'synthetic', 'items' => []];
}
}
}
namespace {
require dirname(__DIR__) . '/app/common/service/followupaudio/FollowupAudioException.php';
require dirname(__DIR__) . '/app/common/service/followupaudio/FollowupAudioWorker.php';
use app\common\service\followupaudio\FollowupAudioStore as Store;
use app\common\service\followupaudio\FollowupAudioDify as Dify;
use app\common\service\followupaudio\FollowupAudioWorker as Worker;
use app\common\service\followupaudio\FollowupAudioAccess as Access;
use app\common\service\prescriptionai\PrescriptionAiAccess as Actors;
$checks = 0;
$expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new \RuntimeException($why); } $checks++; };
$worker = new Worker(new Dify());
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(), '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) {
Dify::$mode = $mode; Store::$events = []; Actors::$active = true;
$expect($worker->runOnce(), $mode . ' consumed');
$last = end(Store::$events);
$expect($last['fail'] === $code && $last['uncertain'] === true, $mode . ' reconciliation required');
$expect(!str_contains($last['message'], 'private-patient'), 'sanitized ' . $mode);
}
Actors::$active = false; Store::$events = [];
$worker->runOnce(); $last = end(Store::$events);
$expect($last['fail'] === 'ACCESS_REVOKED' && !$last['uncertain'], 'inactive actor before upstream');
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');
echo 'FOLLOWUP_AUDIO_WORKER assertions=' . $checks . ' PASS gate=1 actor_recheck=1 reconciliation=1' . PHP_EOL;
}
+62
View File
@@ -0,0 +1,62 @@
<?php
/** Loopback HTTP server for synthetic transport tests. Never load app/.env or provider credentials. */
$directory = getenv('FOLLOWUP_AUDIO_MOCK_DIR');
if (!$directory || !is_dir($directory)) { http_response_code(500); exit; }
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$scenario = explode('/', trim($path, '/'))[0];
$isUpload = str_ends_with($path, '/files/upload');
$isChat = str_ends_with($path, '/chat-messages');
$record = ['scenario' => $scenario, 'path' => $path, 'upload' => $isUpload, 'chat' => $isChat];
$authorization = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if ($authorization !== 'Bearer synthetic-test-key') { http_response_code(401); echo '{}'; exit; }
header('Content-Type: application/json');
header('X-Request-Id: mock-request-' . $scenario);
if ($isUpload) {
$file = $_FILES['file'] ?? [];
$record['user'] = $_POST['user'] ?? '';
$record['filename'] = $file['name'] ?? '';
$record['mime'] = $file['type'] ?? '';
$record['sha256'] = isset($file['tmp_name']) && is_file($file['tmp_name']) ? hash_file('sha256', $file['tmp_name']) : '';
$types = ['followup-audio.wav' => 'audio/wav', 'followup-audio.mp3' => 'audio/mpeg',
'followup-audio.m4a' => 'audio/mp4', 'followup-audio.amr' => 'audio/amr'];
$record['size'] = (int) ($file['size'] ?? 0);
$record['valid'] = ($file['error'] ?? -1) === UPLOAD_ERR_OK && $record['user'] !== ''
&& ($types[$record['filename']] ?? '') === $record['mime'];
file_put_contents($directory . '/requests.jsonl', json_encode($record) . "\n", FILE_APPEND | LOCK_EX);
if (!$record['valid'] || $scenario === 'upload_reject') { http_response_code(415); echo '{"code":"unsupported_file"}'; exit; }
file_put_contents($directory . '/user-' . $scenario, $record['user']);
http_response_code(201);
echo json_encode(['id' => 'mock-upload-' . $scenario, 'mime_type' => $scenario === 'wrong_type' ? 'image/png' : $record['mime']]);
exit;
}
if (!$isChat) { http_response_code(404); echo '{}'; exit; }
$payload = json_decode(file_get_contents('php://input'), true);
$record['payload'] = $payload;
$record['valid'] = ($payload['user'] ?? '') === trim((string) @file_get_contents($directory . '/user-' . $scenario))
&& ($payload['files'] ?? []) === [['type' => 'audio', 'transfer_method' => 'local_file', 'upload_file_id' => 'mock-upload-' . $scenario]]
&& ($payload['response_mode'] ?? '') === 'blocking';
file_put_contents($directory . '/requests.jsonl', json_encode($record, JSON_UNESCAPED_UNICODE) . "\n", FILE_APPEND | LOCK_EX);
if (!$record['valid']) { http_response_code(400); echo '{"code":"attachment_required"}'; exit; }
if ($scenario === 'chat_reject') { http_response_code(415); echo '{"code":"unsupported_audio","message":"private-body-must-not-escape"}'; exit; }
if ($scenario === 'unknown') { http_response_code(524); echo '{"message":"private-body-must-not-escape","task_id":"mock-uncertain-task","conversation_id":"mock-uncertain-conversation"}'; exit; }
if ($scenario === 'timeout') { sleep(3); echo '{}'; exit; }
if ($scenario === 'malformed_response') { echo '<html>private-body-must-not-escape</html>'; exit; }
$transcript = '昨天晚上九点,收缩压126,舒张压82。紫色海豚水晶风车。';
$raw = ['schema_version' => 'followup-audio-v1', 'audio_processed' => true,
'summary' => '合成血压事实', 'transcript' => $transcript, '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。', 'start_ms' => 0, 'end_ms' => 500]],
'needs_review' => false,
]]];
if ($scenario === 'text_only') { $raw['audio_processed'] = false; }
if ($scenario === 'evidence') { $raw['items'][0]['evidence'][0]['text'] = '音频里根本没有说过的事实'; }
if ($scenario === 'extra_field') { $raw['items'][0]['target_id'] = 999; }
if ($scenario === 'bad_values') { $raw['items'][0]['values']['unknown_field'] = 'malicious'; }
if ($scenario === 'bad_time') { $raw['items'][0]['evidence'][0]['end_ms'] = 3600001; }
if ($scenario === 'schema') { unset($raw['transcript']); }
$answer = $scenario === 'markdown' ? '```json\n' . json_encode($raw) . '\n```' : json_encode($raw, JSON_UNESCAPED_UNICODE);
$response = ['answer' => $answer, 'task_id' => 'mock-task-' . $scenario, 'message_id' => 'mock-message-' . $scenario,
'conversation_id' => 'mock-conversation-' . $scenario];
if ($scenario === 'omitted') { $response['metadata']['omitted_files'] = [['type' => 'audio']]; }
echo json_encode($response, JSON_UNESCAPED_UNICODE);