Files
zyt/server/tests/AssistantServiceTeamCoverageTest.php
T
2026-11-12 09:07:07 +08:00

171 lines
16 KiB
PHP

<?php
declare(strict_types=1);
// 真实 ORM 与权限/业务代码,临时 SQLite;不初始化应用、不读 .env、不联网。
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\adminapi\logic\tcm\AssistantServiceLogic as Logic;
use app\adminapi\validate\tcm\AssistantServiceValidate as Validate;
use app\common\service\assistantservice\AssistantServiceAccess as Access;
use app\common\service\assistantservice\AssistantServiceContext as Context;
use think\Container;
use think\facade\Db;
if (!in_array('sqlite', PDO::getAvailableDrivers(), true)) {
throw new RuntimeException('Run: php -d extension=php_pdo_sqlite.dll tests/AssistantServiceApiTest.php');
}
new think\App();
set_exception_handler(static function (Throwable $e): void {
fwrite(STDERR, $e->getMessage() . PHP_EOL . $e->getTraceAsString() . PHP_EOL); exit(1);
});
$manager = new think\DbManager();
$manager->setConfig(['default' => 'isolated', 'connections' => ['isolated' => [
'type' => 'sqlite', 'database' => ':memory:', 'prefix' => 'test_', 'fields_strict' => true,
]]]);
Container::getInstance()->instance('think\DbManager', $manager);
think\Model::setDb($manager);
$config = new think\Config();
$config->set(['enabled' => true, 'profile' => 'qwen', 'timeout' => 120, 'daily_job_limit' => 100,
'max_source_rows' => 200, 'max_source_chars' => 30000, 'max_manual_chars' => 10000, 'max_knowledge_rows' => 40], 'assistant_service');
$config->set(['enable' => true, 'base_url' => 'https://example.invalid', 'models' => [
'qwen' => ['name' => 'synthetic', 'api_key' => 'synthetic-no-network']]], 'prescription_ai');
$config->set(['data_scope' => ['enabled' => true, 'no_dept_fallback_self' => true]], 'project');
Container::getInstance()->instance('config', $config);
$tables = [
'admin' => 'id INTEGER PRIMARY KEY,name TEXT,root INTEGER DEFAULT 0,disable INTEGER DEFAULT 0,delete_time INTEGER NULL',
'admin_role' => 'admin_id INTEGER,role_id INTEGER',
'admin_dept' => 'admin_id INTEGER,dept_id INTEGER',
'dept' => 'id INTEGER PRIMARY KEY,pid INTEGER,status INTEGER DEFAULT 1,delete_time INTEGER NULL',
'system_role' => 'id INTEGER PRIMARY KEY,name TEXT,data_scope INTEGER,delete_time INTEGER NULL',
'system_role_menu' => 'role_id INTEGER,menu_id INTEGER',
'system_menu' => 'id INTEGER PRIMARY KEY,perms TEXT,is_disable INTEGER DEFAULT 0',
'doctor_appointment' => 'id INTEGER PRIMARY KEY,patient_id INTEGER,doctor_id INTEGER,status INTEGER',
'tcm_diagnosis' => 'id INTEGER PRIMARY KEY,patient_id INTEGER,assistant_id INTEGER,patient_name TEXT,phone TEXT,status INTEGER DEFAULT 1,delete_time INTEGER NULL',
'tracking_note' => 'id INTEGER PRIMARY KEY,diagnosis_id INTEGER,admin_id INTEGER,note_date TEXT,content TEXT,create_time INTEGER,update_time INTEGER,delete_time INTEGER NULL',
'tcm_im_chat_message' => 'id INTEGER PRIMARY KEY,patient_id INTEGER,diagnosis_id INTEGER,from_account TEXT,to_account TEXT,doctor_peer_account TEXT,msg_type TEXT,text TEXT,msg_time INTEGER',
'tcm_diagnosis_todo' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,diagnosis_id INTEGER,patient_id INTEGER,content TEXT,remind_time INTEGER,status INTEGER,creator_id INTEGER,creator_name TEXT,create_time INTEGER,update_time INTEGER,notified_at INTEGER DEFAULT 0,cancelled_at INTEGER DEFAULT 0,cancelled_by INTEGER DEFAULT 0,delete_time INTEGER NULL',
'assistant_service_record' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,diagnosis_id INTEGER,patient_id INTEGER,actor_id INTEGER,content TEXT,record_type TEXT,speaker_confirmed INTEGER,occurred_at INTEGER,created_at INTEGER,delete_time INTEGER NULL',
'assistant_service_job' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,diagnosis_id INTEGER,patient_id INTEGER,actor_id INTEGER,type TEXT,scenario TEXT,days INTEGER,status TEXT,version INTEGER,dedupe_key TEXT UNIQUE,source_hash TEXT,scope_hash TEXT,payload_json TEXT,result_json TEXT,claim_token TEXT DEFAULT "",lease_until INTEGER DEFAULT 0,error_code TEXT DEFAULT "",error_message TEXT DEFAULT "",upstream_id TEXT DEFAULT "",created_at INTEGER,updated_at INTEGER,finished_at INTEGER DEFAULT 0',
'assistant_service_review' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,job_id INTEGER,item_key TEXT,reviewer_id INTEGER,decision TEXT,note TEXT,created_at INTEGER',
'assistant_service_action' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,diagnosis_id INTEGER,job_id INTEGER,action_key TEXT,todo_id INTEGER,assignee_id INTEGER,content TEXT,due_at INTEGER,business_state TEXT,completion_note TEXT DEFAULT "",completed_at INTEGER DEFAULT 0,created_at INTEGER,updated_at INTEGER,UNIQUE(job_id,action_key)',
'assistant_service_knowledge' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,question TEXT,answer TEXT,category TEXT,source_note TEXT,status TEXT,version INTEGER,created_by INTEGER,published_by INTEGER,created_at INTEGER,updated_at INTEGER',
];
foreach ($tables as $name => $schema) { Db::execute('CREATE TABLE test_' . $name . ' (' . $schema . ')'); }
Db::name('admin')->insertAll([
['id' => 1, 'name' => 'root', 'root' => 1], ['id' => 11, 'name' => 'assistant', 'root' => 0],
['id' => 12, 'name' => 'other', 'root' => 0], ['id' => 13, 'name' => 'team', 'root' => 0], ['id' => 14, 'name' => 'doctor', 'root' => 0],
]);
Db::name('system_role')->insertAll([
['id' => 1, 'name' => '医生', 'data_scope' => 1], ['id' => 2, 'name' => '医助', 'data_scope' => 4], ['id' => 3, 'name' => '经理', 'data_scope' => 3],
]);
Db::name('admin_role')->insertAll([['admin_id' => 11, 'role_id' => 2], ['admin_id' => 12, 'role_id' => 2], ['admin_id' => 13, 'role_id' => 3], ['admin_id' => 14, 'role_id' => 1]]);
Db::name('dept')->insertAll([['id' => 10, 'pid' => 0], ['id' => 20, 'pid' => 0]]);
Db::name('admin_dept')->insertAll([['admin_id' => 11, 'dept_id' => 10], ['admin_id' => 12, 'dept_id' => 20], ['admin_id' => 13, 'dept_id' => 10]]);
foreach (Access::PERMISSIONS as $i => $permission) {
Db::name('system_menu')->insert(['id' => $i + 1, 'perms' => 'tcm.assistantService/' . $permission]);
foreach ([1, 2, 3] as $role) { Db::name('system_role_menu')->insert(['role_id' => $role, 'menu_id' => $i + 1]); }
}
Db::name('tcm_diagnosis')->insertAll([
['id' => 101, 'patient_id' => 201, 'assistant_id' => 11, 'patient_name' => '合成甲', 'phone' => '13800138000'],
['id' => 102, 'patient_id' => 202, 'assistant_id' => 12, 'patient_name' => '合成乙', 'phone' => '13900139000'],
['id' => 103, 'patient_id' => 0, 'assistant_id' => 11, 'patient_name' => '未映射', 'phone' => ''],
]);
Db::name('doctor_appointment')->insert(['id' => 1, 'patient_id' => 101, 'doctor_id' => 14, 'status' => 1]);
$now = time();
Db::name('tracking_note')->insert(['id' => 1, 'diagnosis_id' => 101, 'admin_id' => 11,
'note_date' => date('Y-m-d'), 'content' => '患者要求周六确认预约', 'create_time' => $now, 'update_time' => $now]);
foreach ([
[1, 201, 'patient_201', 'doctor_11', 'text', '请帮我核对预约,电话13800138000'],
[2, 201, 'doctor_11', 'patient_201', 'text', '我将在明天回复可预约时段'],
[3, 201, 'doctor_12', 'patient_201', 'text', '其他部门私有沟通'],
[4, 201, 'doctor_11', 'patient_999', 'text', '错误患者映射'],
[5, 201, 'doctor_11', 'patient_201', 'image', '不可读图片'],
[6, 201, 'doctor_999', 'patient_201', 'text', '不存在的员工'],
] as [$id, $patient, $from, $to, $type, $text]) {
Db::name('tcm_im_chat_message')->insert(['id' => $id, 'patient_id' => $patient, 'diagnosis_id' => 999,
'from_account' => $from, 'to_account' => $to, 'doctor_peer_account' => 'doctor_11', 'msg_type' => $type, 'text' => $text, 'msg_time' => $now - 100]);
}
// 与 API 测试使用相同合成表;新增目标链路必须走真实 Access/Context/Worker,不能用上下文桩替代。
$checks = 0;
$ok = static function (bool $condition, string $label) use (&$checks): void { if (!$condition) { throw new RuntimeException($label); } $checks++; };
$deny = static function (callable $call, string $label) use ($ok): void {
try { $call(); } catch (DomainException $e) { $ok(true, $label); return; } $ok(false, $label);
};
$call = static fn (string $action, array $p = [], int $actor = 13): array => Logic::$action(Validate::parameters($action, $p), $actor);
// 仅反射调用调度器的只读候选/去重方法;不运行 tick,不写 runtime,不访问模型。
$automation = new \app\common\service\assistantservice\AssistantServiceAutomation(__DIR__ . '/unused-coverage-runtime');
$candidates = new ReflectionMethod($automation, 'candidates');
$decision = new ReflectionMethod($automation, 'analysisDecision');
$candidateIds = static function (string $channel) use ($automation, $candidates, $now): array {
$rows = $candidates->invoke($automation, $channel, 13, Access::actor(13), [11], [0, 0], 20, $now);
$ids = array_values(array_unique(array_map('intval', array_column($rows, 'diagnosis_id')))); sort($ids); return $ids;
};
$nextDecision = static fn (int $diagnosis): string => $decision->invoke($automation, Context::build($diagnosis, 13, 30, 11), 11);
$team = static fn (): array => $call('teamOverview')['lists'][0];
$complete = static function (int $diagnosis) use ($now): array {
$job = Logic::analyzeAutomatic(['assistant_id' => 11, 'diagnosis_id' => $diagnosis, 'days' => 30, 'scenario' => 'followup'], 13);
$result = ['summary' => [], 'needs' => [], 'unresolved' => [], 'tags' => [], 'suggested_actions' => [], 'quality_checks' => []];
Db::name('assistant_service_job')->where('id', $job['id'])->update(['status' => 'ready', 'result_json' => json_encode($result), 'finished_at' => $now, 'updated_at' => $now]);
return Db::name('assistant_service_job')->where('id', $job['id'])->find();
};
Db::name('tracking_note')->delete(true);
Db::name('tcm_im_chat_message')->delete(true);
Db::name('tcm_im_chat_message')->insertAll([
['id' => 1, 'patient_id' => 201, 'diagnosis_id' => 101, 'from_account' => 'patient_201', 'to_account' => 'doctor_11', 'msg_type' => 'text', 'text' => '请确认预约', 'msg_time' => $now - 1000],
['id' => 2, 'patient_id' => 201, 'diagnosis_id' => 101, 'from_account' => 'doctor_11', 'to_account' => 'patient_201', 'msg_type' => 'text', 'text' => '我会核实可用时段', 'msg_time' => $now - 900],
]);
Db::name('tcm_diagnosis')->insert(['id' => 104, 'patient_id' => 201, 'assistant_id' => 11, 'patient_name' => '同患者第二诊单', 'phone' => '']);
$ok($candidateIds('im') === [104] && $nextDecision(104) === 'new', 'IM candidates select one latest visible diagnosis for the patient');
$ok($team()['analysis_state'] === 'pending' && $team()['metrics']['im_messages'] === 1, 'first multiple diagnoses represent one pending conversation');
$first = $complete(104);
$ok($nextDecision(104) === 'existing', 'completed snapshot is not generated again');
$ok($team()['analysis_state'] === 'ready' && $team()['metrics']['analyzed_cases'] === 1, 'one completed IM analysis covers all duplicate patient diagnoses');
$ok((int) Db::name('assistant_service_job')->count() === 1, 'no task was created for the older diagnosis');
Db::name('tcm_diagnosis')->insert(['id' => 106, 'patient_id' => 201, 'assistant_id' => 11, 'patient_name' => '同患者新建诊单', 'phone' => '']);
$ok($candidateIds('im') === [106] && $nextDecision(106) === 'existing', 'new diagnosis remaps candidate but patient-level snapshot prevents duplicate generation');
$ok($team()['analysis_state'] === 'ready' && $team()['metrics']['analyzed_cases'] === 1, 'new diagnosis alone does not manufacture pending analysis');
$ok((int) Db::name('assistant_service_job')->count() === 1, 'new diagnosis overview stays read-only');
Db::name('tcm_im_chat_message')->insert(['id' => 3, 'patient_id' => 201, 'diagnosis_id' => 101, 'from_account' => 'doctor_11', 'to_account' => 'patient_201',
'msg_type' => 'text', 'text' => '周六下午可以预约', 'msg_time' => $now - 500]);
$ok($candidateIds('im') === [106] && $nextDecision(106) === 'new', 'new conversation goes to latest diagnosis regardless of archival diagnosis hint');
$pending = $team();
$ok($pending['analysis_state'] === 'pending' && $pending['metrics']['analyzed_cases'] === 0 && $pending['attention_jobs'] === 0, 'new text remains pending and cannot be hidden by an obsolete successful snapshot');
$fresh = $complete(106); $freshSources = json_decode($fresh['payload_json'], true)['context']['sources'];
$ok(array_column($freshSources, 'id') === ['im:1', 'im:2', 'im:3'] && (int) $fresh['diagnosis_id'] === 106 && (int) $fresh['patient_id'] === 201,
'new analysis snapshot binds all current conversation evidence to correct patient and current diagnosis');
$ok($team()['analysis_state'] === 'ready' && $team()['metrics']['analyzed_cases'] === 1 && $nextDecision(106) === 'existing', 'new conversation completion returns ready without regenerating old diagnoses');
$manualId = (int) Db::name('assistant_service_record')->insertGetId(['diagnosis_id' => 101, 'patient_id' => 201, 'actor_id' => 11, 'content' => '旧诊单独立服务登记',
'record_type' => 'service_note', 'speaker_confirmed' => 0, 'occurred_at' => $now - 400, 'created_at' => $now - 400]);
$ok($candidateIds('manual') === [101] && $nextDecision(101) === 'new', 'independent manual source keeps its original diagnosis candidate');
$ok($team()['analysis_state'] === 'pending' && $team()['metrics']['manual_records'] === 1, 'patient IM coverage never hides unprocessed manual evidence on another diagnosis');
$manualJob = $complete(101); $manualSources = json_decode($manualJob['payload_json'], true)['context']['sources'];
$ok(in_array('manual:' . $manualId, array_column($manualSources, 'id'), true) && (int) $manualJob['diagnosis_id'] === 101, 'manual analysis preserves exact record and diagnosis binding');
$ok($team()['analysis_state'] === 'ready' && $team()['metrics']['analyzed_cases'] === 2, 'manual completion resolves its own coverage independently');
Db::name('tracking_note')->insert(['id' => 9, 'diagnosis_id' => 104, 'admin_id' => 11, 'note_date' => date('Y-m-d'),
'content' => '另一旧诊单独立跟踪备注', 'create_time' => $now - 400, 'update_time' => $now - 400]);
$ok($candidateIds('tracking') === [104] && $nextDecision(104) === 'new', 'independent tracking note keeps its original diagnosis candidate');
$ok($team()['analysis_state'] === 'pending', 'fresh IM and manual results cannot cover a different diagnosis tracking note');
$trackingJob = $complete(104); $trackingSources = json_decode($trackingJob['payload_json'], true)['context']['sources'];
$ok(in_array('tracking:9', array_column($trackingSources, 'id'), true) && !in_array('manual:' . $manualId, array_column($trackingSources, 'id'), true),
'tracking snapshot includes its exact bound note and excludes other diagnosis manual record');
$ok($team()['analysis_state'] === 'ready' && $team()['metrics']['analyzed_cases'] === 3, 'all actual eligible source groups complete without phantom pending');
$ok((int) Db::name('assistant_service_job')->count() === 4 && $nextDecision(106) === 'existing', 'only new IM or independent diagnosis sources produced the four explicit test tasks');
Db::name('assistant_service_record')->insert(['diagnosis_id' => 101, 'patient_id' => 999, 'actor_id' => 11, 'content' => '换绑患者记录不可使用',
'record_type' => 'service_note', 'speaker_confirmed' => 0, 'occurred_at' => $now - 300, 'created_at' => $now - 300]);
$ok($team()['analysis_state'] === 'ready' && $team()['metrics']['manual_records'] === 1, 'mismatched patient binding cannot manufacture pending sources');
Db::name('system_role_menu')->where('role_id', 3)->where('menu_id', 3)->delete();
$staleScope = $team();
$ok($staleScope['analysis_state'] === 'attention' && $staleScope['attention_jobs'] === 3, 'unchanged source with changed authorization still requires attention rather than promising automatic generation');
$ok((int) Db::name('assistant_service_job')->count() === 4, 'all team queries and scheduler checks stayed read-only');
echo 'ASSISTANT_SERVICE_TEAM_COVERAGE assertions=' . $checks . ' PASS sqlite=memory real_access_context_logic_scheduler=1 network=0' . PHP_EOL;