Files
2026-09-10 15:19:17 +08:00

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 => '分析暂未完成,可查看资料缺口或重试',
};
}
}