378 lines
20 KiB
PHP
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);
|
|
}
|
|
}
|