135 lines
5.6 KiB
PHP
135 lines
5.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace app\common\service\prescriptionai;
|
|
|
|
final class PrescriptionAiPolicy
|
|
{
|
|
public const MODELS = ['qwen', 'openai'];
|
|
public const ACTIVE_TASKS = ['queued', 'running', 'retry_wait'];
|
|
public const CLINICAL_FIELDS = [
|
|
'diagnosis_id', 'patient_id', 'gender', 'age', 'prescription_type', 'herbs',
|
|
'clinical_diagnosis', 'tongue', 'tongue_image', 'pulse', 'pulse_condition',
|
|
'dose_count', 'dose_unit', 'dosage_amount', 'dosage_unit', 'dosage_bag_count',
|
|
'need_decoction', 'bags_per_dose', 'usage_days', 'times_per_day', 'aux_usage',
|
|
'usage_instruction', 'usage_time', 'usage_way', 'dietary_taboo', 'usage_notes',
|
|
];
|
|
|
|
public static function decode($value): array
|
|
{
|
|
if (is_array($value)) {
|
|
return $value;
|
|
}
|
|
$decoded = is_string($value) ? json_decode($value, true) : null;
|
|
return is_array($decoded) ? $decoded : [];
|
|
}
|
|
|
|
public static function canonical($value): string
|
|
{
|
|
$sort = static function ($item) use (&$sort) {
|
|
if (!is_array($item)) {
|
|
return $item;
|
|
}
|
|
if (!array_is_list($item)) {
|
|
ksort($item, SORT_STRING);
|
|
}
|
|
return array_map($sort, $item);
|
|
};
|
|
return json_encode($sort($value), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
|
}
|
|
|
|
public static function clinical(array $row): array
|
|
{
|
|
$value = array_intersect_key($row, array_flip(self::CLINICAL_FIELDS));
|
|
foreach (['herbs', 'aux_usage'] as $field) {
|
|
if (array_key_exists($field, $value)) {
|
|
$value[$field] = self::decode($value[$field]);
|
|
}
|
|
}
|
|
foreach (['diagnosis_id', 'patient_id', 'gender', 'age', 'dose_count', 'dosage_bag_count',
|
|
'need_decoction', 'bags_per_dose', 'usage_days', 'times_per_day'] as $field) {
|
|
if (array_key_exists($field, $value)) {
|
|
$value[$field] = (int) $value[$field];
|
|
}
|
|
}
|
|
if (isset($value['dosage_amount']) && is_numeric($value['dosage_amount'])) {
|
|
$value['dosage_amount'] = (float) $value['dosage_amount'];
|
|
}
|
|
if (isset($value['herbs'])) {
|
|
foreach ($value['herbs'] as &$herb) {
|
|
if (!is_array($herb)) {
|
|
continue;
|
|
}
|
|
$herb = array_intersect_key($herb, array_flip([
|
|
'name', 'medicine_id', 'id', 'dosage', 'unit', 'dose_basis',
|
|
'processing', 'formula_type', 'special_usage', 'instructions', 'usage', 'remark',
|
|
]));
|
|
foreach (['dosage', 'medicine_id', 'id'] as $field) {
|
|
if (isset($herb[$field]) && is_numeric($herb[$field])) {
|
|
$herb[$field] = (float) $herb[$field];
|
|
}
|
|
}
|
|
}
|
|
unset($herb);
|
|
// Row order has no clinical meaning; do not deduplicate or drop invalid rows.
|
|
usort($value['herbs'], static fn ($a, $b): int => strcmp(self::canonical($a), self::canonical($b)));
|
|
}
|
|
return $value;
|
|
}
|
|
|
|
public static function fingerprint(array $row): string
|
|
{
|
|
return hash('sha256', self::canonical(self::clinical($row)));
|
|
}
|
|
|
|
public static function isManual(array $row): bool
|
|
{
|
|
return (int) ($row['id'] ?? 0) > 0
|
|
&& empty($row['delete_time']) && (int) ($row['void_status'] ?? 0) === 0
|
|
&& (int) ($row['is_system_auto'] ?? 0) === 0
|
|
&& self::decode($row['herbs'] ?? []) !== [];
|
|
}
|
|
|
|
public static function aggregate(array $states): string
|
|
{
|
|
if ($states === []) {
|
|
return 'preparing';
|
|
}
|
|
if (array_intersect($states, self::ACTIVE_TASKS) !== []) {
|
|
return 'running';
|
|
}
|
|
$success = count(array_filter($states, static fn ($v): bool => $v === 'success'));
|
|
if ($success === 2) {
|
|
return 'success';
|
|
}
|
|
if ($success > 0) {
|
|
return 'partial';
|
|
}
|
|
return count(array_filter($states, static fn ($v): bool => $v === 'cancelled')) === count($states)
|
|
? 'cancelled' : 'failed';
|
|
}
|
|
|
|
public static function retryAt(int $attempts, int $now, bool $retryable, int $maxAttempts): ?int
|
|
{
|
|
return $retryable && $attempts < $maxAttempts ? $now + ($attempts <= 1 ? 30 : 120) : null;
|
|
}
|
|
|
|
public static function errorMessage(string $code): string
|
|
{
|
|
return match ($code) {
|
|
'PATIENT_BINDING_REQUIRED' => '需完善患者与诊单关联',
|
|
'ACCESS_REVOKED' => '资料访问权限已变更',
|
|
'SOURCE_CHANGED' => '资料或处方已更新,请查看新版本',
|
|
'BUDGET_PAUSED' => '已达到分析预算,等待额度恢复',
|
|
'CONFIG_INVALID', 'CONFIG_DISABLED', 'UPSTREAM_AUTH_FAILED' => '模型配置不可用,请联系管理员',
|
|
'UPSTREAM_TIMEOUT' => '模型响应超时,可稍后重试',
|
|
'INVALID_RESPONSE', 'RESPONSE_INVALID', 'INVALID_EVIDENCE_OUTPUT', 'INVALID_FILE_EVIDENCE_OUTPUT', 'INVALID_REPORT_OUTPUT' => '模型返回内容未通过校验',
|
|
'CONTEXT_TOO_LARGE', 'INPUT_TOKEN_BUDGET_EXCEEDED', 'SYNTHESIS_BUDGET_EXCEEDED' => '资料超过本次处理预算',
|
|
'FINAL_CONTEXT_EXCEEDS_BUDGET' => '资料来源与缺口说明超过汇总预算,请联系管理员调整处理配置',
|
|
'LEASE_EXPIRED' => '工作进程中断,任务等待恢复',
|
|
default => '分析暂未完成,可查看资料缺口或重试',
|
|
};
|
|
}
|
|
}
|