Files
zyt/server/app/adminapi/logic/tcm/PrescriptionAiLogic.php
2026-09-10 15:19:17 +08:00

378 lines
20 KiB
PHP

<?php
declare(strict_types=1);
namespace app\adminapi\logic\tcm;
use app\common\model\auth\Admin;
use app\common\service\prescriptionai\PrescriptionAiAccess as Access;
use app\common\service\prescriptionai\PrescriptionAiCipher as Cipher;
use app\common\service\prescriptionai\PrescriptionAiPolicy as Policy;
use app\common\service\prescriptionai\PrescriptionAiProgress as Progress;
use app\common\service\prescriptionai\PrescriptionAiStatistics;
use app\common\service\prescriptionai\PrescriptionAiStore as Store;
use DomainException;
use think\facade\Db;
final class PrescriptionAiLogic
{
public static function statuses(array $ids, int $actor, array $info): array
{
self::requirePermission('statuses', $actor, $info);
if (!Store::enabled()) {
return ['enabled' => false, 'items' => []];
}
$ids = array_values(array_unique(array_map('intval', $ids)));
if (count($ids) > 100 || array_filter($ids, static fn ($id): bool => $id <= 0)) {
throw new DomainException('最多查询100个有效处方');
}
if ($ids === []) {
return ['enabled' => true, 'items' => []];
}
$subjects = Db::name('prescription_ai_subject')->whereIn('prescription_id', $ids)->column('latest_batch_id', 'prescription_id');
$batches = $subjects ? Db::name('prescription_ai_batch')->whereIn('id', array_values($subjects))->select()->toArray() : [];
$batchByRx = [];
foreach ($batches as $batch) {
$batchByRx[(int) $batch['prescription_id']] = $batch;
}
$modelMap = self::models(array_column($batches, 'id'), false);
$items = [];
foreach ($ids as $id) {
$rx = Access::prescription($id, $actor, $info);
if (!$rx) {
continue;
}
$batch = $batchByRx[$id] ?? null;
if ($batch && !self::visibleBatch($batch, $actor, $info)) {
// Do not reveal that a report with a broader source scope exists.
continue;
}
$items[] = $batch ? self::formatBatch($batch, $modelMap[(int) $batch['id']] ?? [], $rx) : [
'prescription_id' => $id, 'batch_id' => null,
'status' => (int) ($rx['is_system_auto'] ?? 0) === 1 ? 'blank' : 'not_generated',
'coverage_status' => 'pending', 'validity' => 'current', 'comparison_type' => 'unavailable', 'models' => [],
];
}
return ['enabled' => true, 'items' => $items];
}
public static function reports(array $params, int $actor, array $info): array
{
self::requirePermission('reports', $actor, $info);
$rxId = (int) ($params['prescription_id'] ?? 0);
$diagnosisId = (int) ($params['diagnosis_id'] ?? 0);
if (($rxId > 0) === ($diagnosisId > 0)) {
throw new DomainException('请指定处方或诊单');
}
$query = Db::name('prescription_ai_batch');
if ($rxId > 0) {
if (!Access::prescription($rxId, $actor, $info)) {
throw new DomainException('处方不存在或无权访问');
}
$query->where('prescription_id', $rxId);
} else {
if (!Access::diagnosis($diagnosisId, $actor, $info)) {
throw new DomainException('诊单不存在或无权访问');
}
$patientId = (int) Db::name('tcm_diagnosis')->where('id', $diagnosisId)->value('patient_id');
if ($patientId > 0) {
$query->where('patient_id', $patientId);
} else {
$query->where('diagnosis_id', $diagnosisId);
}
}
$page = max(1, (int) ($params['page_no'] ?? 1));
$size = max(1, min(50, (int) ($params['page_size'] ?? 20)));
// Filter before pagination so hidden snapshots do not leak counts or create gaps.
$visible = [];
$cursor = PHP_INT_MAX;
do {
$chunk = (clone $query)->where('id', '<', $cursor)->order('id', 'desc')->limit(200)->select()->toArray();
foreach ($chunk as $batch) {
$cursor = (int) $batch['id'];
if (self::visibleBatch($batch, $actor, $info)) {
$visible[] = $batch;
}
}
} while (count($chunk) === 200);
$rows = array_slice($visible, ($page - 1) * $size, $size);
$modelMap = self::models(array_column($rows, 'id'), false);
return ['lists' => array_map(static fn ($b): array => self::formatBatch($b, $modelMap[(int) $b['id']] ?? []), $rows),
'count' => count($visible), 'page_no' => $page, 'page_size' => $size];
}
public static function detail(int $batchId, int $actor, array $info): array
{
self::requirePermission('detail', $actor, $info);
$batch = self::loadBatch($batchId, $actor, $info);
$models = self::models([$batchId], true);
return self::formatBatch($batch, $models[$batchId] ?? [], Access::prescription((int) $batch['prescription_id'], $actor, $info));
}
public static function regenerate(int $rxId, string $reason, int $actor, array $info): array
{
self::requirePermission('regenerate', $actor, $info);
self::requirePermission('detail', $actor, $info);
if (!Store::enabled()) {
throw new DomainException('处方自动分析尚未启用');
}
$rx = Access::prescription($rxId, $actor, $info);
if (!$rx || !Policy::isManual($rx)) {
throw new DomainException('处方不存在、无权访问或尚未形成有效手工处方');
}
return Db::transaction(static function () use ($rxId, $actor, $reason): array {
$rx = Db::name('tcm_prescription')->where('id', $rxId)->lock(true)->find();
$count = Db::name('prescription_ai_batch')->where('prescription_id', $rxId)
->where('created_at', '>=', strtotime('today'))->count();
if ($count >= max(1, (int) config('prescription_analysis.daily_patient_batches', 10))) {
throw new DomainException('今日分析次数已达预算,请稍后再试');
}
$batchId = Store::enqueue($rx, $actor, 'manual_refresh', hash('sha256', random_bytes(32)), ['reason' => $reason]);
return ['batch_id' => $batchId, 'status' => 'queued'];
});
}
public static function retry(int $batchId, string $model, int $actor, array $info): array
{
self::requirePermission('retry', $actor, $info);
self::requirePermission('detail', $actor, $info);
if (!Store::enabled() || !in_array($model, Policy::MODELS, true)) {
throw new DomainException('分析未启用或模型参数无效');
}
$batch = self::loadBatch($batchId, $actor, $info);
if ($batch['validity'] !== 'current') {
throw new DomainException('该报告已过期,请按最新资料重新分析');
}
return Db::transaction(static function () use ($batchId, $model, $batch): array {
$rx = Db::name('tcm_prescription')->where('id', $batch['prescription_id'])->lock(true)->find();
$freshBatch = Db::name('prescription_ai_batch')->where('id', $batchId)->lock(true)->find();
if (!$rx || !Policy::isManual($rx) || $freshBatch['validity'] !== 'current'
|| !hash_equals($freshBatch['clinical_hash'], Policy::fingerprint($rx))) {
throw new DomainException('处方已变更,请重新分析');
}
$task = Db::name('prescription_ai_task')->where('batch_id', $batchId)->where('model_key', $model)->lock(true)->find();
if (!$task) {
throw new DomainException('资料尚未准备完成,请查看分析状态');
}
if ($task['status'] === 'success' || in_array($task['status'], Policy::ACTIVE_TASKS, true)) {
return ['batch_id' => $batchId, 'status' => $task['status']];
}
if ($task['status'] !== 'failed') {
throw new DomainException('该任务不能重试');
}
if ((int) $task['manual_retries'] >= (int) config('prescription_analysis.max_manual_retries', 2)) {
throw new DomainException('该模型已达手动重试上限,请检查失败原因');
}
// Attempt history and lifetime counter remain immutable across retry rounds.
Db::name('prescription_ai_task')->where('id', $task['id'])->update([
'status' => 'retry_wait', 'attempts' => 0, 'next_run_at' => time(), 'lock_token' => '',
'manual_retries' => (int) $task['manual_retries'] + 1,
'lock_until' => 0, 'error_code' => '', 'updated_at' => time(),
]);
Store::refreshBatch($batchId);
return ['batch_id' => $batchId, 'status' => 'retry_wait'];
});
}
public static function review(int $batchId, string $model, string $status, string $comment, int $actor, array $info): array
{
self::requirePermission('review', $actor, $info);
self::requirePermission('detail', $actor, $info);
self::loadBatch($batchId, $actor, $info);
if (!in_array($status, ['viewed', 'needs_information', 'not_adopted', 'reviewed'], true)
|| !in_array($model, Policy::MODELS, true) || mb_strlen($comment) > 2000) {
throw new DomainException('复核内容无效');
}
$resultId = Db::name('prescription_ai_result')->where('batch_id', $batchId)->where('model_key', $model)->value('id');
if (!$resultId) {
throw new DomainException('报告尚未生成');
}
Db::name('prescription_ai_review')->insert([
'result_id' => $resultId, 'admin_id' => $actor, 'status' => $status,
'comment_cipher' => (new Cipher())->encrypt(['comment' => $comment], 'review:' . $resultId), 'created_at' => time(),
]);
return ['saved' => true];
}
public static function statistics(array $params, int $actor, array $info): array
{
self::requirePermission('statistics', $actor, $info);
self::requirePermission('detail', $actor, $info);
$from = self::date((string) ($params['date_from'] ?? date('Y-m-d', strtotime('-30 days'))));
$to = self::date((string) ($params['date_to'] ?? date('Y-m-d'))) + 86399;
if ($from > $to || $to - $from > 366 * 86400) {
throw new DomainException('统计范围须在一年以内');
}
$query = Db::name('prescription_ai_batch')->alias('b')->join('prescription_ai_subject s', 's.first_batch_id=b.id')
->where('b.created_at', '>=', $from)->where('b.created_at', '<=', $to);
if ((int) ($params['doctor_id'] ?? 0) > 0) {
$query->where('b.doctor_id', (int) $params['doctor_id']);
}
$groups = [];
$cursor = 0;
do {
$rows = (clone $query)->where('b.id', '>', $cursor)->field('b.*')->order('b.id')->limit(200)->select()->toArray();
$modelMap = self::models(array_column($rows, 'id'), false);
foreach ($rows as $batch) {
$cursor = (int) $batch['id'];
if (!self::visibleBatch($batch, $actor, $info)) {
continue;
}
foreach (Policy::MODELS as $model) {
$result = $modelMap[(int) $batch['id']][$model] ?? [];
$exclusions = Policy::decode($batch['baseline_exclusions_json']);
$eligible = (bool) $batch['baseline_eligible'] && ($result['coverage_status'] ?? '') === 'complete';
$groups[(int) $batch['doctor_id']][] = [
'event_id' => (int) $batch['id'], 'patient_id' => (int) $batch['patient_id'],
'doctor_id' => (int) $batch['doctor_id'], 'model_key' => $model,
'baseline_eligible' => $eligible,
'exclusion_reason' => $eligible ? '' : ($exclusions[0] ?? (($result['status'] ?? '') === 'success' ? 'incomplete_coverage' : 'missing_result')),
'comparison' => ['status' => $result['comparison_status'] ?? 'not_comparable',
'reason_code' => $result['comparison_reason_code'] ?? '',
'score' => $result['score'] ?? null, 'algorithm_version' => $result['algorithm_version'] ?? ''],
'model_version' => $result['model_name'] ?? '', 'prompt_version' => $result['prompt_version'] ?? '',
'dictionary_version' => $result['dictionary_version'] ?? '',
];
}
}
} while (count($rows) === 200);
$doctors = [];
$allRows = [];
$names = $groups ? Admin::whereIn('id', array_keys($groups))->column('name', 'id') : [];
foreach ($groups as $doctorId => $rows) {
$summary = PrescriptionAiStatistics::summarize($rows);
$models = [];
foreach (Policy::MODELS as $model) {
$m = $summary['models'][$model] ?? [];
$models[$model] = ['eligible_count' => $m['valid_count'] ?? 0, 'coverage_rate' => $m['coverage_percent'] ?? 0,
'mean' => $m['mean'] ?? null, 'median' => $m['median'] ?? null, 'excluded_reasons' => $m['exclusion_reasons'] ?? [],
'strata' => $m['strata'] ?? []];
}
$doctors[] = ['doctor_id' => $doctorId, 'doctor_name' => (string) ($names[$doctorId] ?? ''),
'total_count' => $summary['total_events'] ?? 0, 'patient_count' => $summary['patient_count'] ?? 0,
'models' => $models, 'paired_count' => $summary['paired_count'] ?? 0,
// Viewing or rejecting an AI report is not independent expert adjudication.
'review' => ['evaluated_count' => 0, 'qualified_count' => 0, 'qualified_rate' => null]];
array_push($allRows, ...$rows);
}
$total = PrescriptionAiStatistics::summarize($allRows);
return ['total_count' => $total['total_events'] ?? 0, 'patient_count' => $total['patient_count'] ?? 0,
'doctors' => $doctors, 'date_from' => date('Y-m-d', $from), 'date_to' => date('Y-m-d', $to),
'metric_label' => '药味与剂量一致度(不代表临床准确率)'];
}
private static function models(array $batchIds, bool $full): array
{
if ($batchIds === []) {
return [];
}
$taskFields = ['batch_id', 'model_key', 'status', 'error_code', 'attempts', 'total_attempts', 'result_id',
'started_at', 'finished_at', 'updated_at', 'next_run_at'];
if (Store::supportsProgress()) {
$taskFields[] = 'progress_json';
}
$tasks = Db::name('prescription_ai_task')->whereIn('batch_id', $batchIds)->field($taskFields)->select()->toArray();
$fields = ['id', 'batch_id', 'model_key', 'score', 'herb_score', 'comparison_status', 'comparison_reason_code',
'coverage_status', 'model_name', 'prompt_version', 'algorithm_version', 'dictionary_version', 'generated_at'];
if ($full) {
$fields[] = 'body_cipher';
}
$results = Db::name('prescription_ai_result')->whereIn('batch_id', $batchIds)->field($fields)->select()->toArray();
$map = [];
foreach ($tasks as $task) {
$map[(int) $task['batch_id']][$task['model_key']] = [
'status' => $task['status'], 'score' => null, 'herb_score' => null,
'error_code' => $task['error_code'], 'error_message' => $task['error_code'] !== '' ? Policy::errorMessage($task['error_code']) : '',
'reason' => $task['error_code'] !== '' ? Policy::errorMessage($task['error_code']) : '',
'report_id' => (int) $task['result_id'],
'progress' => Progress::task($task),
];
}
foreach ($results as $result) {
$body = $full ? (new Cipher())->decrypt($result['body_cipher'], 'result:' . $result['batch_id'] . ':' . $result['model_key']) : [];
unset($result['body_cipher']);
$result['score'] = $result['score'] === null ? null : (float) $result['score'];
$result['herb_score'] = $result['herb_score'] === null ? null : (float) $result['herb_score'];
$result['report_id'] = (int) $result['id'];
$result['reason'] = $body['comparison']['reason'] ?? ($result['comparison_status'] === 'comparable' ? '' : '点击查看不可比原因');
$trustedProgress = $map[(int) $result['batch_id']][$result['model_key']]['progress'] ?? Progress::task([
'status' => 'success', 'updated_at' => $result['generated_at'], 'finished_at' => $result['generated_at'],
]);
$map[(int) $result['batch_id']][$result['model_key']] = array_merge(
$map[(int) $result['batch_id']][$result['model_key']] ?? [], $result, $body, ['progress' => $trustedProgress]);
if ($full) {
$review = Db::name('prescription_ai_review')->where('result_id', $result['id'])->order('id', 'desc')->find();
if ($review) {
$comment = (new Cipher())->decrypt($review['comment_cipher'], 'review:' . $result['id']);
$map[(int) $result['batch_id']][$result['model_key']]['review'] = [
'status' => $review['status'], 'comment' => $comment['comment'] ?? '', 'created_at' => (int) $review['created_at'],
];
}
}
}
return $map;
}
private static function formatBatch(array $batch, array $models, ?array $rx = null): array
{
$validity = $batch['validity'];
if ($rx && (!Policy::isManual($rx) || !hash_equals($batch['clinical_hash'], Policy::fingerprint($rx)))) {
$validity = 'prescription_changed';
}
return [
'id' => (int) $batch['id'], 'batch_id' => (int) $batch['id'], 'prescription_id' => (int) $batch['prescription_id'],
'prescription_revision' => (int) $batch['prescription_revision'], 'patient_id' => (int) $batch['patient_id'],
'diagnosis_id' => (int) $batch['diagnosis_id'], 'status' => $batch['status'], 'validity' => $validity,
'comparison_type' => $batch['comparison_type'], 'baseline_eligible' => (bool) $batch['baseline_eligible'],
'baseline_exclusion_reasons' => Policy::decode($batch['baseline_exclusions_json']),
'source_summary' => Policy::decode($batch['source_summary_json']), 'missing' => Policy::decode($batch['missing_json']),
'coverage_status' => $batch['coverage_status'], 'cutoff_at' => (int) $batch['cutoff_at'],
'created_at' => (int) $batch['created_at'], 'updated_at' => (int) $batch['updated_at'],
'error_code' => $batch['error_code'], 'error_message' => $batch['error_code'] !== '' ? Policy::errorMessage($batch['error_code']) : '',
'models' => $models,
'progress' => Progress::batch($batch, null, $models),
];
}
private static function loadBatch(int $id, int $actor, array $info): array
{
$batch = Db::name('prescription_ai_batch')->where('id', $id)->find();
if (!$batch || !self::visibleBatch($batch, $actor, $info)) {
throw new DomainException('报告不存在或无权访问');
}
return $batch;
}
private static function visibleBatch(array $batch, int $actor, array $info): bool
{
if (!Access::prescription((int) $batch['prescription_id'], $actor, $info)) {
return false;
}
$ids = Policy::decode($batch['source_diagnosis_ids_json']);
if ($ids === []) {
return empty($batch['context_cipher'])
&& ((int) $batch['diagnosis_id'] === 0 || Access::diagnosis((int) $batch['diagnosis_id'], $actor, $info));
}
if (!Access::sourceIds($ids, $actor, $info) || empty($batch['access_cipher'])) {
return false;
}
$access = (new Cipher())->decrypt($batch['access_cipher'], 'access:' . $batch['id']);
return \app\common\service\prescriptionai\PrescriptionAiContext::assertSnapshotAccess($access, $actor, $info);
}
private static function requirePermission(string $action, int $actor, array $info): void
{
if (!Access::allowed($actor, $info, $action)) {
throw new DomainException('无权使用此处方AI功能');
}
}
private static function date(string $value): int
{
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) || date('Y-m-d', strtotime($value)) !== $value) {
throw new DomainException('日期格式无效');
}
return strtotime($value);
}
}