Files
zyt/server/tests/PrescriptionAiQueueTest.php
2026-09-10 15:19:17 +08:00

329 lines
26 KiB
PHP

<?php
declare(strict_types=1);
/** Disposable local MySQL only. Never initializes the app or loads production config. */
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\common\service\prescriptionai\PrescriptionAiStore as Store;
use app\common\service\prescriptionai\PrescriptionAiPolicy as Policy;
use app\common\service\prescriptionai\PrescriptionAiCipher as Cipher;
use app\common\service\prescriptionai\PrescriptionAiProgress as Progress;
use app\common\service\prescriptionai\PrescriptionAiRequest as SaveRequest;
use app\adminapi\logic\tcm\PrescriptionAiLogic as Api;
use think\Container;
use think\facade\Db;
$port = (int) getenv('ZYT_AI_TEST_MYSQL_PORT');
if ($port <= 0) {
throw new RuntimeException('Set ZYT_AI_TEST_MYSQL_PORT to an isolated local empty-password MySQL instance');
}
$child = ($argv[1] ?? '') === '--claim';
$legacyProgressSchema = in_array('--legacy-progress-schema', $argv, true);
$database = $child ? (string) getenv('ZYT_AI_TEST_DATABASE') : 'prescription_ai_test_' . bin2hex(random_bytes(6));
if (!preg_match('/^prescription_ai_test_[a-f0-9]{12}$/D', $database)) {
throw new RuntimeException('Only disposable test database names are allowed');
}
$pdo = new PDO("mysql:host=127.0.0.1;port={$port};charset=utf8mb4", 'root', '', [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()
$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' => '', 'charset' => 'utf8mb4',
'prefix' => 'zyt_', 'fields_strict' => true]]]);
Container::getInstance()->instance('think\DbManager', $manager);
$config = new think\Config();
$config->set(['enabled' => true, 'encryption_key' => str_repeat('isolated-test-', 4), 'debounce_seconds' => 0,
'lease_seconds' => 600, 'max_attempts' => 3, 'max_manual_retries' => 2, 'max_parallel_per_model' => 1,
'daily_model_tasks' => 200, 'transcript_wait_seconds' => 300], 'prescription_analysis');
Container::getInstance()->instance('config', $config);
if ($child) {
$claim = Store::claimTask($argv[2] ?? 'qwen');
echo json_encode(['id' => $claim['id'] ?? null]) . "\n";
exit(0);
}
$checks = 0;
$expect = static function (bool $ok, string $why) use (&$checks): void {
if (!$ok) { throw new RuntimeException($why); }
$checks++;
};
$root = ['root' => 1, 'admin_id' => 1, 'id' => 1, 'role_id' => [], 'dept_id' => [], 'name' => 'Test'];
try {
$pdo->exec('CREATE TABLE zyt_system_menu (id INT PRIMARY KEY AUTO_INCREMENT,pid INT,type VARCHAR(5),name VARCHAR(100),icon VARCHAR(50),sort INT,perms VARCHAR(100),paths VARCHAR(100),component VARCHAR(100),selected VARCHAR(100),params VARCHAR(100),is_cache INT,is_show INT,is_disable INT,create_time INT,update_time INT)');
$pdo->exec('CREATE TABLE zyt_system_role_menu (role_id INT,menu_id INT,UNIQUE KEY(role_id,menu_id))');
$pdo->exec('CREATE TABLE zyt_admin (id INT PRIMARY KEY,name VARCHAR(50),root INT,disable INT,delete_time INT NULL)');
$pdo->exec('CREATE TABLE zyt_admin_role (admin_id INT,role_id INT)');
$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("INSERT INTO zyt_admin VALUES(1,'Test',1,0,NULL)");
$pdo->exec('CREATE TABLE zyt_tcm_diagnosis (id INT PRIMARY KEY,patient_id INT,assistant_id INT DEFAULT 1,delete_time INT NULL)');
$pdo->exec('INSERT INTO zyt_tcm_diagnosis(id,patient_id) VALUES(1,100),(2,200)');
$pdo->exec('CREATE TABLE zyt_tcm_prescription (id INT PRIMARY KEY AUTO_INCREMENT,diagnosis_id INT DEFAULT 1,patient_id INT DEFAULT 100,creator_id INT DEFAULT 1,is_system_auto INT DEFAULT 0,void_status INT DEFAULT 0,delete_time INT NULL,herbs TEXT,prescription_type VARCHAR(30) DEFAULT "饮片",update_time INT DEFAULT 0) ENGINE=InnoDB');
foreach (['sn','prescription_name','dosage_unit','patient_name','phone','visit_no','prescription_date','pulse','pulse_condition',
'tongue','tongue_image','clinical_diagnosis','case_record','dose_unit','aux_usage','usage_instruction','usage_time','usage_way',
'dietary_taboo','usage_notes','doctor_name','doctor_signature','visible_role_ids','audit_by_name','audit_remark','void_by_name'] as $field) {
$pdo->exec("ALTER TABLE zyt_tcm_prescription ADD `$field` TEXT NULL");
}
foreach (['appointment_id','assistant_id','gender','age','dosage_bag_count','need_decoction','bags_per_dose','dose_count',
'usage_days','times_per_day','template_id','is_shared','audit_status','audit_time','audit_by','void_time','void_by','create_time'] as $field) {
$pdo->exec("ALTER TABLE zyt_tcm_prescription ADD `$field` INT DEFAULT 0");
}
$pdo->exec('ALTER TABLE zyt_tcm_prescription ADD dosage_amount DECIMAL(10,2) NULL, ADD amount DECIMAL(10,2) DEFAULT 0');
$pdo->exec('CREATE TABLE zyt_tcm_prescription_order (id INT PRIMARY KEY,prescription_id INT,source_prescription_id INT,prescription_audit_status INT,fulfillment_status INT,delete_time INT NULL)');
$pdo->exec('CREATE TABLE zyt_doctor_medicine (id INT PRIMARY KEY,name VARCHAR(100),unit VARCHAR(20),status INT,delete_time INT NULL)');
$pdo->exec("INSERT INTO zyt_doctor_medicine VALUES(1,'测试药材','g',1,NULL)");
$migration = file_get_contents(dirname(__DIR__) . '/database/migrations/2026_09_09_prescription_ai_analysis.sql');
foreach (explode(';', preg_replace('/^--.*$/m', '', $migration)) as $statement) {
if (trim($statement) !== '') { $pdo->exec($statement); }
}
// Migrations are rerunnable including role grants.
foreach (explode(';', preg_replace('/^--.*$/m', '', $migration)) as $statement) {
if (trim($statement) !== '') { $pdo->exec($statement); }
}
$expect((int) Db::name('system_menu')->count() === 7, 'idempotent permission migration');
if (!$legacyProgressSchema) {
$progressMigration = file_get_contents(dirname(__DIR__) . '/database/migrations/2026_09_10_prescription_ai_progress.sql');
for ($i = 0; $i < 2; $i++) {
foreach (explode(';', preg_replace('/^--.*$/m', '', $progressMigration)) as $statement) {
if (trim($statement) !== '') { $pdo->exec($statement); }
}
}
$expect((int) $pdo->query("SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'zyt_prescription_ai_task' AND COLUMN_NAME = 'progress_json'")->fetchColumn() === 1,
'additive progress migration is rerunnable');
}
$expect(Store::supportsProgress() === !$legacyProgressSchema, 'old and migrated schema detected without reading model caches');
$fixture = static function (array $extra = []) use ($root): array {
$id = (int) Db::name('tcm_prescription')->insertGetId($extra + [
'herbs' => json_encode([['medicine_id' => 1, 'name' => '测试药材', 'dosage' => 10]], JSON_UNESCAPED_UNICODE),
'update_time' => time(),
]);
return Db::name('tcm_prescription')->where('id', $id)->find();
};
$save = static function (array $rx, array $options = []) use ($root): ?int {
return Db::transaction(static function () use ($rx, $options, $root): ?int {
$fresh = Db::name('tcm_prescription')->where('id', $rx['id'])->lock(true)->find();
return Store::recordSaved($fresh, 1, $root, ['ai_assisted' => false] + $options);
});
};
$context = ['source' => ['clinical' => 'synthetic record'], 'source_hash' => hash('sha256', 'fixed'),
'source_diagnosis_ids' => [1], 'source_summary' => ['diagnosis_count' => 1], 'missing' => [],
'baseline_eligible' => false, 'baseline_exclusion_reasons' => ['SOURCE_HISTORY_VERSIONS_UNAVAILABLE'],
'comparison_type' => 'latest_context', 'cutoff_at' => time(), 'wait_for_transcript' => false];
$rx = $fixture();
$context['source_access_manifest'] = ['schema_version' => 'prescription-source-access-v1', 'patient_id' => 100,
'target' => ['prescription_id' => (int) $rx['id'], 'diagnosis_id' => 1],
'records' => [['source_kind' => 'diagnoses', 'id' => 1, 'source_id' => 'diagnoses:1', 'diagnosis_id' => 1, 'patient_id' => 100, 'staff' => []]]];
$batchId = $save($rx);
$expect($batchId > 0 && $save($rx) === $batchId, 'same clinical content enqueues once');
$blank = $fixture(['is_system_auto' => 1, 'herbs' => '[]']);
$expect($save($blank) === null, 'blank prescriptions do not enqueue');
$expect((int) Db::name('prescription_ai_task')->count() === 0, 'no model call or task before snapshot');
$claim = Store::claimBatch();
$expect((int) $claim['id'] === $batchId && Store::claimBatch() === null, 'preparation lease prevents duplicate claim');
$expect(Store::finishPreparation($claim, $context), 'snapshot prepares');
$expect(!Store::finishPreparation($claim, $context), 'preparation cannot run twice');
$expect((int) Db::name('prescription_ai_task')->count() === 2, 'exactly two model tasks');
$stored = Db::name('prescription_ai_batch')->find($batchId);
$expect(!str_contains($stored['context_cipher'], 'synthetic record'), 'context encrypted');
$expect((new Cipher())->decrypt($stored['context_cipher'], 'context')['source_hash'] === $context['source_hash'], 'context decrypts exactly');
$expect((int) $stored['baseline_eligible'] === 0, 'uncertain historical provenance excluded');
$q = Store::claimTask('qwen');
$o = Store::claimTask('openai');
$expect($q !== null && $o !== null && Store::claimTask('qwen') === null, 'models run independently, claim exclusive');
$expect(Store::checkpoint($q, ['stage' => 'first', 'steps' => ['private fixture']]), 'checkpoint persists');
$expect(!str_contains(Db::name('prescription_ai_task')->where('id', $q['id'])->value('progress_cipher'), 'private fixture'), 'checkpoint encrypted');
$beforeProgressCipher = Db::name('prescription_ai_task')->where('id', $q['id'])->value('progress_cipher');
$progressPayload = ['public' => Progress::advance([], 'text', 'waiting', 1, 3), 'steps' => ['must-not-replace-cache'],
'prompt' => 'never-public'];
$expect(Store::checkpoint($q, $progressPayload, false) && Store::checkpoint($q, $progressPayload, false),
'same-second metadata no-op still recognizes a valid lease');
$expect(Db::name('prescription_ai_task')->where('id', $q['id'])->value('progress_cipher') === $beforeProgressCipher,
'metadata-only checkpoint leaves encrypted model cache unchanged');
if (!$legacyProgressSchema) {
$publicJson = Db::name('prescription_ai_task')->where('id', $q['id'])->value('progress_json');
$expect(!str_contains($publicJson, 'never-public') && !str_contains($publicJson, 'must-not-replace-cache')
&& json_decode($publicJson, true)['completed_units'] === 1, 'database stores only sanitized scalar progress');
}
$modelQueries = [];
$captureModels = true;
Db::listen(static function (string $sql) use (&$modelQueries, &$captureModels): void {
if ($captureModels && str_contains($sql, 'prescription_ai_task') && preg_match('/^SELECT/i', $sql)) { $modelQueries[] = $sql; }
});
$liveStatus = Api::statuses([$rx['id']], 1, $root)['items'][0];
$liveDetail = Api::detail($batchId, 1, $root);
$liveReports = Api::reports(['prescription_id' => $rx['id']], 1, $root);
$captureModels = false;
$expect($modelQueries !== [], 'list/detail/history task select queries observed');
foreach ($modelQueries as $sql) {
$expect(!str_contains($sql, 'progress_cipher') && !preg_match('/SELECT\s+\*/i', $sql),
'polling selects bounded task fields, not encrypted model cache');
}
$expect($liveStatus['models']['qwen']['progress']['stage'] === ($legacyProgressSchema ? 'unknown' : 'text')
&& $liveDetail['models']['qwen']['progress']['stage'] === ($legacyProgressSchema ? 'unknown' : 'text'),
'status and detail present measured progress, with honest old-schema fallback');
$expect(isset($liveStatus['progress']) && isset($liveReports['lists'][0]['models']['qwen']['progress']),
'batch and history include public progress');
$output = ['report' => ['summary' => 'synthetic report'], 'candidate' => ['status' => 'available_for_review'],
'coverage' => ['status' => 'complete'], 'model_name' => 'test', 'prompt_version' => 'test-v1'];
$comparison = ['status' => 'comparable', 'score' => 80, 'herb_score' => 100, 'algorithm_version' => 'test-v1'];
$expect(Store::complete($q, $output, $comparison), 'first model completes');
$expect(!Store::complete($q, $output, $comparison), 'duplicate result callback fenced');
Store::fail($o, 'UPSTREAM_TIMEOUT', false);
$expect(Db::name('prescription_ai_batch')->where('id', $batchId)->value('status') === 'partial', 'one failure preserves other result');
$statuses = Api::statuses([$rx['id']], 1, $root);
$expect($statuses['items'][0]['models']['qwen']['score'] === 80.0, 'list returns persisted numeric score');
$expect($statuses['items'][0]['models']['openai']['score'] === null, 'failed score is null rather than zero');
$detail = Api::detail($batchId, 1, $root);
$expect($detail['models']['qwen']['report']['summary'] === 'synthetic report', 'authorized detail decrypts');
$expect($detail['models']['qwen']['progress']['stage'] === 'completed'
&& $statuses['items'][0]['models']['openai']['progress']['stage'] === 'failed', 'persisted task terminal state overrides stale stage');
$resultRow = Db::name('prescription_ai_result')->where('batch_id', $batchId)->where('model_key', 'qwen')->find();
$resultBody = (new Cipher())->decrypt($resultRow['body_cipher'], 'result:' . $batchId . ':qwen');
$resultBody['progress'] = ['stage' => 'poisoned', 'notice' => 'model supplied text'];
Db::name('prescription_ai_result')->where('id', $resultRow['id'])->update([
'body_cipher' => (new Cipher())->encrypt($resultBody, 'result:' . $batchId . ':qwen'),
]);
$expect(Api::detail($batchId, 1, $root)['models']['qwen']['progress']['stage'] === 'completed',
'report body cannot override trusted task progress');
Api::review($batchId, 'qwen', 'not_adopted', 'test comment', 1, $root);
$expect(Api::detail($batchId, 1, $root)['models']['qwen']['review']['status'] === 'not_adopted', 'review independent of prescription');
Api::retry($batchId, 'openai', 1, $root);
$o2 = Store::claimTask('openai');
$expect((int) $o2['total_attempts'] === 2 && (int) $o2['attempts'] === 1, 'manual retry preserves lifetime attempts');
$expect(Store::complete($o2, $output, $comparison), 'failed model retries without repeating successful model');
$expect(Db::name('prescription_ai_batch')->where('id', $batchId)->value('status') === 'success', 'both success aggregate');
$stats = Api::statistics([], 1, $root);
$expect($stats['doctors'][0]['models']['qwen']['mean'] === null, 'nonbaseline scores never become doctor accuracy');
Db::name('prescription_ai_batch')->where('id', $batchId)->update(['baseline_eligible' => 1, 'baseline_exclusions_json' => '[]']);
$eligibleStats = Api::statistics([], 1, $root);
$expect($eligibleStats['doctors'][0]['models']['qwen']['mean'] === 80.0, 'synthetic qualified baseline has empty exclusion reason');
Db::name('prescription_ai_batch')->where('id', $batchId)->update(['baseline_eligible' => 0, 'baseline_exclusions_json' => '["SOURCE_HISTORY_VERSIONS_UNAVAILABLE"]']);
$expect((int) Db::name('prescription_ai_attempt')->count() === 3, 'attempt history retained');
$payload = ['request_key' => '12345678-abcd-1234-abcd-123456789012', 'herbs' => [['name' => 'fixture']]];
Db::transaction(static function () use ($payload, $rx, $expect): void {
$expect(SaveRequest::replay($payload, 1, true) === null, 'first request reserved');
SaveRequest::complete($payload, 1, (int) $rx['id']);
});
$expect(SaveRequest::replay($payload, 1) === (int) $rx['id'], 'lost save response replays same prescription');
try { SaveRequest::replay($payload + ['extra' => 'changed'], 1); $expect(false, 'changed content cannot reuse key'); }
catch (DomainException $e) { $checks++; }
$countBefore = (int) Db::name('prescription_ai_batch')->count();
try {
Db::transaction(static function () use ($fixture, $save): void {
$save($fixture());
throw new RuntimeException('rollback fixture');
});
} catch (RuntimeException $e) {}
$expect((int) Db::name('prescription_ai_batch')->count() === $countBefore, 'prescription and outbox roll back together');
Db::name('tcm_prescription')->where('id', $rx['id'])->update(['herbs' => '[{"name":"changed","dosage":20}]']);
$newBatch = $save($rx);
$expect($newBatch !== $batchId, 'clinical change creates immutable new batch');
$expect(Db::name('prescription_ai_batch')->where('id', $batchId)->value('validity') === 'prescription_changed', 'previous version invalidated');
$expect((int) Db::name('prescription_ai_result')->count() === 2, 'historic model results immutable');
$newClaim = Store::claimBatch();
Store::finishPreparation($newClaim, $context);
$lease = Store::claimTask('qwen');
Db::name('prescription_ai_task')->where('id', $lease['id'])->update(['lock_until' => time() - 1]);
$replacement = Store::claimTask('qwen');
$expect($replacement !== null && $replacement['lock_token'] !== $lease['lock_token'], 'expired lease recovered');
$expect(!Store::checkpoint($lease, []) && !Store::complete($lease, $output, $comparison), 'old worker cannot write after lease steal');
$expect(!Store::checkpoint($lease, $progressPayload, false), 'metadata-only progress is fenced after lease steal');
$expect(Db::name('prescription_ai_attempt')->where('task_id', $lease['id'])->where('attempt_no', 1)->value('status') === 'expired', 'expired attempt audited');
putenv('ZYT_AI_TEST_DATABASE=' . $database);
$pipes = [];
$process = proc_open([PHP_BINARY, __FILE__, '--claim', 'qwen'], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
$childOutput = stream_get_contents($pipes[1]);
$childError = stream_get_contents($pipes[2]);
fclose($pipes[1]); fclose($pipes[2]);
$exit = proc_close($process);
$expect($exit === 0 && $childError === '' && json_decode($childOutput, true)['id'] === null, 'second PHP process cannot duplicate live lease');
Db::transaction(static function () use ($rx): void {
Db::name('tcm_prescription')->where('id', $rx['id'])->lock(true)->find();
Db::name('tcm_prescription')->where('id', $rx['id'])->update(['void_status' => 1]);
Store::invalidate((int) $rx['id'], 'voided');
});
$expect(!Store::complete($replacement, $output, $comparison), 'void during generation cannot publish');
$expect(Db::name('prescription_ai_task')->where('id', $replacement['id'])->value('status') === 'cancelled', 'void cancels tasks');
$editParams = ['id' => $blank['id'], 'herbs' => [['medicine_id' => 1, 'name' => '测试药材', 'dosage' => 10]],
'prescription_date' => '2026-09-09', 'clinical_diagnosis' => 'fixture', 'usage_instruction' => 'fixture'];
$expect(\app\adminapi\logic\tcm\PrescriptionLogic::edit($editParams, 1), 'real blank-to-manual hook: ' . \app\adminapi\logic\tcm\PrescriptionLogic::getError());
$blankBatch = Db::name('prescription_ai_batch')->where('prescription_id', $blank['id'])->find();
$expect($blankBatch['trigger_type'] === 'blank_to_manual' && (int) $blankBatch['patient_id'] === 100, 'blank hook carries authoritative binding');
$expect(\app\adminapi\logic\tcm\PrescriptionLogic::edit($editParams, 1), 'same edit can replay');
$expect((int) Db::name('prescription_ai_batch')->where('prescription_id', $blank['id'])->count() === 1, 'same edit does not generate twice');
$expect(\app\adminapi\logic\tcm\PrescriptionLogic::void((int) $blank['id'], 1, 'Test'), 'real void hook succeeds');
$expect(Db::name('prescription_ai_batch')->where('id', $blankBatch['id'])->value('validity') === 'voided', 'real void hook invalidates');
$addParams = ['request_key' => 'add-request-1234567890123456', 'diagnosis_id' => 0, 'patient_id' => 100,
'patient_name' => 'fixture', 'gender' => 1, 'age' => 50, 'clinical_diagnosis' => 'fixture',
'herbs' => [['medicine_id' => 1, 'name' => '测试药材', 'dosage' => 10]], 'doctor_signature' => 'fixture'];
$added = \app\adminapi\logic\tcm\PrescriptionLogic::add($addParams, 1, $root);
$expect($added !== null, 'real direct-manual save hook: ' . \app\adminapi\logic\tcm\PrescriptionLogic::getError());
$expect(\app\adminapi\logic\tcm\PrescriptionLogic::add($addParams, 1, $root) === $added, 'real direct save request replay returns same id');
$expect(Db::name('prescription_ai_batch')->where('prescription_id', $added)->value('error_code') === 'PATIENT_BINDING_REQUIRED', 'unbound direct prescription has explicit blocked analysis');
$pdo->exec("CREATE TRIGGER test_outbox_failure BEFORE INSERT ON zyt_prescription_ai_batch FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='test fixture failure'");
$prescriptionsBefore = (int) Db::name('tcm_prescription')->count();
$addParams['request_key'] = 'rollback-request-123456789012';
$expect(\app\adminapi\logic\tcm\PrescriptionLogic::add($addParams, 1, $root) === null, 'outbox storage failure aborts real save');
$expect((int) Db::name('tcm_prescription')->count() === $prescriptionsBefore, 'real save and request reservation roll back with outbox');
$expect(!str_contains(\app\adminapi\logic\tcm\PrescriptionLogic::getError(), 'SQLSTATE'), 'outbox error text cannot expose SQL');
$pdo->exec('DROP TRIGGER test_outbox_failure');
if (defined('PRESCRIPTION_AI_PIPELINE_FIXTURE')) {
$pipelineRx = $fixture();
$pipelineBatch = $save($pipelineRx);
$worker = new \app\common\service\prescriptionai\PrescriptionAiWorker();
$expect($worker->prepareOne(), 'real coordinator freezes fixture evidence');
$comparingObservations = [];
$captureComparing = !$legacyProgressSchema;
Db::listen(static function (string $sql) use (&$comparingObservations, &$captureComparing, $pipelineBatch): void {
if (!$captureComparing || !str_starts_with($sql, 'UPDATE') || !str_contains($sql, 'progress_json') || !str_contains($sql, 'comparing')) {
return;
}
foreach (Db::name('prescription_ai_task')->where('batch_id', $pipelineBatch)->where('status', 'running')->select()->toArray() as $row) {
if ((json_decode($row['progress_json'] ?? '{}', true)['stage'] ?? '') !== 'comparing') { continue; }
$cache = (new Cipher())->decrypt($row['progress_cipher'], 'progress:' . $row['id']);
$comparingObservations[] = ['status' => $row['status'], 'cache_stage' => $cache['stage'] ?? '',
'has_result' => (bool) Db::name('prescription_ai_result')->where('batch_id', $pipelineBatch)->where('model_key', $row['model_key'])->count()];
}
});
$expect($worker->runOne('qwen'), 'first real model worker');
Db::name('doctor_medicine')->insert(['id' => 2, 'name' => 'new catalog fixture', 'unit' => 'g', 'status' => 1]);
$expect($worker->runOne('openai'), 'second real model worker');
$captureComparing = false;
if (!$legacyProgressSchema) {
$expect(count($comparingObservations) === 2, 'each worker publishes comparing before it persists a result');
foreach ($comparingObservations as $observation) {
$expect($observation === ['status' => 'running', 'cache_stage' => 'fixture', 'has_result' => false],
'comparison retains the encrypted checkpoint and never announces task completion before the result transaction');
}
}
$pipelineStatus = Api::statuses([$pipelineRx['id']], 1, $root)['items'][0];
$expect($pipelineStatus['models']['qwen']['score'] === 100.0, 'raw DB JSON prescription reaches real comparator');
$expect($pipelineStatus['models']['openai']['score'] === 100.0, 'second model score persisted');
$inputs = \app\common\service\prescriptionai\PrescriptionAiGenerator::$inputs;
$expect($inputs['qwen']['source_hash'] === $inputs['openai']['source_hash'] && $inputs['qwen']['source'] === $inputs['openai']['source'], 'same frozen evidence supplied to both');
$expect($inputs['qwen']['dictionary_version'] === $inputs['openai']['dictionary_version']
&& count($inputs['openai']['_comparison_catalog']) === 1, 'catalog changes between branches do not alter frozen dictionary');
$expect(\app\common\service\prescriptionai\PrescriptionAiContext::$builds === 1, 'evidence read once per batch');
$revokedRx = $fixture();
$revokedBatch = $save($revokedRx);
$worker->prepareOne();
\app\common\service\prescriptionai\PrescriptionAiGenerator::$revokeDuringCall = true;
$worker->runOne('qwen');
$expect((int) Db::name('prescription_ai_result')->where('batch_id', $revokedBatch)->count() === 0, 'source permission revoked during call prevents publication');
\app\common\service\prescriptionai\PrescriptionAiContext::$allowed = true;
}
$before = (int) Db::name('prescription_ai_batch')->count();
$config->set(['enabled' => false], 'prescription_analysis');
$expect($save($fixture()) === null && (int) Db::name('prescription_ai_batch')->count() === $before, 'feature disabled makes no outbox writes');
$expect(Api::statuses([1], 1, $root) === ['enabled' => false, 'items' => []], 'disabled list has graceful compatibility');
echo "Prescription AI queue: {$checks} checks passed\n";
} finally {
$pdo->exec("DROP DATABASE `{$database}`");
}