feat: add durable ASR-to-patient extraction and guarded review

This commit is contained in:
2026-10-08 15:08:42 +08:00
parent 8bb4bd07ae
commit 13948f5982
34 changed files with 2377 additions and 56 deletions
+105
View File
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
/** Disposable fixture only. Never calls App::initialize(), discovers .env, or connects outside literal loopback. */
require_once dirname(__DIR__, 3) . '/vendor/autoload.php';
require_once dirname(__DIR__, 3) . '/vendor/topthink/framework/src/helper.php';
use app\common\service\followupaudio\FollowupAudioFields as Fields;
use app\common\service\followupaudio\FollowupAudioProviderConfig as Provider;
use app\common\service\followupaudio\FollowupAudioUpload as Upload;
use think\Container;
use think\facade\Db;
function followupAudioTestDatabase(array $settings = []): array
{
$port = (int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT');
if ($port < 1 || $port === 3306 || getenv('FOLLOWUP_AUDIO_TEST_ALLOW_DISPOSABLE') !== '1') {
throw new RuntimeException('Explicit non-production disposable MySQL port and acknowledgement required');
}
$database = 'fa_pipeline_' . bin2hex(random_bytes(6));
$password = (string) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PASSWORD');
$pdo = new PDO("mysql:host=127.0.0.1;port={$port};charset=utf8mb4", 'root', $password, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$pdo->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4"); $pdo->exec("USE `{$database}`");
new think\App();
$manager = new think\DbManager();
$manager->setConfig(['default' => 'mysql', 'connections' => ['mysql' => ['type' => 'mysql', 'hostname' => '127.0.0.1',
'hostport' => $port, 'database' => $database, 'username' => 'root', 'password' => $password,
'charset' => 'utf8mb4', 'prefix' => 'zyt_', 'fields_strict' => true, 'trigger_sql' => false]]]);
Container::getInstance()->instance('think\DbManager', $manager);
$config = new think\Config(); Container::getInstance()->instance('config', $config);
$private = '/private/tmp/' . $database; mkdir($private, 0700);
$config->set(array_replace(['enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen'],
'encryption_key' => bin2hex(random_bytes(32)), 'lease_seconds' => 60, 'concurrency' => 1,
'retention_days' => 90, 'private_dir' => $private], $settings), 'followup_audio');
$providers = $settings['providers'] ?? ['qwen' => ['driver' => 'asr_then_llm', 'label' => 'Synthetic',
'asr' => ['base_url' => 'https://asr.invalid/v1', 'api_key' => 'synthetic-asr', 'model' => 'synthetic-asr'],
'extraction' => ['base_url' => 'https://llm.invalid/v1', 'api_key' => 'synthetic-llm', 'model' => 'synthetic-llm']]];
$config->set(['providers' => $providers], 'followup_audio');
// Test-only verification flags in this isolated process/database. Never modify a deployment config.
foreach (array_keys($providers) as $profile) { $providers[$profile]['verified_fingerprint'] = Provider::resolve($profile)['fingerprint']; }
$config->set(['providers' => $providers], 'followup_audio');
$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__, 3) . '/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(); } }
}
return ['pdo' => $pdo, 'database' => $database, 'private' => $private, 'config' => $config,
'actor' => ['root' => 1, 'admin_id' => 1, 'id' => 1, 'name' => 'Synthetic'], 'diagnosis_id' => 1];
}
/** Actual private Upload API, chunk assembly, hash and ffprobe checks; caller chooses only the isolated test diagnosis. */
function followupAudioTestUpload(array $fixture, string $source, string $sampleName): array
{
$session = Upload::createSession(1, $sampleName, (int) filesize($source), 1, $fixture['actor']);
$input = fopen($source, 'rb'); $index = 0;
$part = $fixture['private'] . '/input-chunk';
try {
while (!feof($input)) {
$bytes = fread($input, $session['chunk_bytes']);
if ($bytes === '') { break; }
file_put_contents($part, $bytes); chmod($part, 0600);
Upload::putChunk($session['upload_id'], $index++, $part, 1, $fixture['actor']);
}
} finally { fclose($input); if (is_file($part)) { unlink($part); } }
Upload::complete($session['upload_id'], 1, $fixture['actor']);
return Upload::session($session['upload_id']);
}
function followupAudioTestDatabaseCleanup(array $fixture): void
{
if (!preg_match('/^fa_pipeline_[a-f0-9]{12}$/D', $fixture['database']) || $fixture['private'] !== '/private/tmp/' . $fixture['database']) {
throw new RuntimeException('Disposable cleanup identity mismatch');
}
$fixture['pdo']->exec('DROP DATABASE `' . $fixture['database'] . '`');
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($fixture['private'], FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
foreach ($iterator as $entry) { $entry->isDir() && !$entry->isLink() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); }
rmdir($fixture['private']);
}