Files
zyt/server/app/common/service/prescriptionai/PrescriptionAiComparison.php
2026-09-10 15:19:17 +08:00

514 lines
26 KiB
PHP

<?php
declare(strict_types=1);
namespace app\common\service\prescriptionai;
/**
* Pure, conservative soft-Dice comparison. No database, configuration or model calls.
*
* Catalog contract: trusted server rows {id: positive integer, name: string,
* aliases?: string[], processing?: string, dictionary_version?: string}.
* Names (including aliases) must identify exactly one catalog identity. Candidate
* medicine_id is deliberately ignored; a supplied doctor's ID must match its name.
*
* Each herb declares dosage, unit, dose_basis (per_dose/per_day), formula_type
* (主方/辅方). Explicit prescription-level unit/dose_basis/formula_type may apply
* to all rows. Only the persisted doctor's 饮片 rows may omit unit/basis (g/per_dose),
* consistent with the desktop editor's 克 and total = dosage * dose_count contract.
* The doctor's absent role defaults to 主方 under the same editor contract.
* Missing and explicit null/empty values are different: null is never a default.
*
* Unit spelling aliases are normalized, but there are NO quantity, formulation,
* per-day/per-dose or extraction-ratio conversions. Changing this requires a new
* algorithm/configuration version. usage_differences never affect the score.
*/
final class PrescriptionAiComparison
{
// v1.1.0: the doctor's prescription-level 用量单位/剂量单位 are read as declared values, and a
// processing label already carried by the medicine name no longer splits one identity.
public const ALGORITHM_VERSION = 'prescription-soft-dice-v1.1.0';
private const FORMULATIONS = ['饮片', '颗粒', '浓缩水丸', '丸剂', '散剂', '膏方', '汤剂'];
private const USAGE_FIELDS = [
'dose_count', 'usage_days', 'times_per_day', 'dosage_amount', 'dosage_unit',
'dosage_bag_count', 'usage_instruction', 'usage_time', 'usage_way', 'aux_usage',
];
private const ROW_USAGE_FIELDS = [
'usage_instruction', 'decoction_instruction', 'special_usage', 'usage_time', 'usage_way', 'instructions',
];
public static function compare(array $doctor, array $candidate, array $catalog = []): array
{
$dictionary = self::buildDictionary($catalog);
$doctorResult = self::normalize($doctor, 'doctor', $dictionary);
$candidateResult = self::normalize($candidate, 'candidate', $dictionary);
$issues = array_merge($doctorResult['issues'], $candidateResult['issues']);
$usageDifferences = self::usageDifferences($doctor, $candidate);
if ($doctorResult['formulation'] !== $candidateResult['formulation']) {
$issues[] = self::issue('both', null, 'formulation_mismatch', '剂型不同,未提供经确认的换算规则');
}
$allBases = array_unique(array_merge($doctorResult['bases'], $candidateResult['bases']));
if (count($allBases) > 1) {
$issues[] = self::issue('both', null, 'dose_basis_mismatch', '剂量基准不同,不能按每剂与每日直接比较');
}
$doctorItems = $doctorResult['items'];
$candidateItems = $candidateResult['items'];
$keys = array_values(array_unique(array_merge(array_keys($doctorItems), array_keys($candidateItems))));
sort($keys, SORT_STRING);
$rows = [];
$matchedCount = 0;
$contribution = 0.0;
foreach ($keys as $key) {
$left = $doctorItems[$key] ?? null;
$right = $candidateItems[$key] ?? null;
$identity = $left ?? $right;
$ratio = null;
if ($left !== null && $right !== null) {
$matchedCount++;
if ($left['unit'] !== $right['unit']) {
$issues[] = self::issue('both', null, 'unit_mismatch', $identity['name'] . '的剂量单位不同,未执行单位换算');
} elseif ($left['dosage'] !== null && $right['dosage'] !== null
&& $left['dose_basis'] === $right['dose_basis']) {
$ratio = min($left['dosage'], $right['dosage']) / max($left['dosage'], $right['dosage']);
$contribution += $ratio;
}
if ($left['usage'] !== $right['usage']) {
$usageDifferences[] = [
'field' => 'herb_usage', 'key' => $key, 'name' => $identity['name'],
'doctor' => $left['usage'], 'candidate' => $right['usage'],
];
}
if ($left['declared_processing'] !== $right['declared_processing']) {
$usageDifferences[] = [
'field' => 'herb_processing_label', 'key' => $key, 'name' => $identity['name'],
'doctor' => $left['declared_processing'], 'candidate' => $right['declared_processing'],
];
}
}
$rows[] = [
'key' => $key,
'medicine_id' => $identity['medicine_id'],
'name' => $identity['name'],
'processing' => $identity['processing'],
'formula_type' => $identity['formula_type'],
'administration_route' => $identity['administration_route'],
'group' => $identity['group'],
'doctor' => $left,
'candidate' => $right,
'doctor_dosage' => $left['dosage'] ?? null,
'candidate_dosage' => $right['dosage'] ?? null,
'unit' => $identity['unit'],
'dose_basis' => $identity['dose_basis'],
'match_type' => $left === null ? 'candidate_only' : ($right === null ? 'doctor_only' : 'matched'),
'contribution' => $ratio,
];
}
$denominator = count($doctorItems) + count($candidateItems);
$identityComplete = $doctorResult['identity_complete'] && $candidateResult['identity_complete'];
$herbScore = $identityComplete && $denominator > 0
&& $doctorItems !== [] && $candidateItems !== []
? 100.0 * 2.0 * $matchedCount / $denominator : null;
$comparable = $issues === [];
if (!$comparable) {
// Partial row ratios must never look like a complete, usable score.
foreach ($rows as &$row) {
$row['contribution'] = null;
}
unset($row);
}
return [
'status' => $comparable ? 'comparable' : 'not_comparable',
'score' => $comparable ? min(100.0, max(0.0, 100.0 * 2.0 * $contribution / $denominator)) : null,
'herb_score' => $herbScore,
'reason_code' => $comparable ? 'ok' : $issues[0]['code'],
'reason' => $comparable ? '药味与剂量可比;服法、疗程与风险须独立复核' : $issues[0]['reason'],
'algorithm_version' => self::ALGORITHM_VERSION,
'doctor_count' => count($doctorItems),
'candidate_count' => count($candidateItems),
'matched_count' => $matchedCount,
'rows' => $rows,
'usage_differences' => $usageDifferences,
'normalization' => [
'doctor' => $doctorResult,
'candidate' => $candidateResult,
'issues' => $issues,
'dictionary_hash' => $dictionary['hash'],
'dictionary_versions' => $dictionary['versions'],
'unit_policy' => 'spelling_aliases_only_no_quantity_conversion',
'denominator' => $denominator,
'matched_contribution_sum' => $comparable ? $contribution : null,
],
];
}
private static function normalize(array $prescription, string $side, array $dictionary): array
{
$result = [
'formulation' => self::formulation($prescription['prescription_type'] ?? null),
'items' => [], 'bases' => [], 'issues' => [], 'merges' => [],
'defaults' => [], 'identity_complete' => true, 'raw_herb_count' => 0,
];
$status = self::text($prescription['status'] ?? null);
$blockedStatuses = [
'insufficient_data' => '资料不足,未形成可比候选方案',
'withheld_for_risk' => '因风险暂缓提供用药方案',
'no_medication' => '明确建议暂不使用药物,须单列用药决策差异',
'no_medication_recommended' => '明确建议暂不使用药物,须单列用药决策差异',
'failed' => '模型生成失败', 'error' => '模型生成失败',
];
if (isset($blockedStatuses[$status])) {
$result['issues'][] = self::issue($side, null, $status, $blockedStatuses[$status]);
}
if (!in_array($result['formulation'], self::FORMULATIONS, true)) {
$result['issues'][] = self::issue($side, null, 'unknown_formulation', '剂型缺失或不受支持');
}
$herbs = $prescription['herbs'] ?? null;
if (!is_array($herbs) || $herbs === []) {
$result['issues'][] = self::issue($side, null, 'empty_prescription', '处方为空或药味结构无效');
$result['identity_complete'] = false;
return $result;
}
$result['raw_herb_count'] = count($herbs);
if ($dictionary['names'] === []) {
$result['issues'][] = self::issue($side, null, 'catalog_unavailable', '缺少可用的服务端药材字典');
}
foreach (array_values($herbs) as $index => $herb) {
if (!is_array($herb)) {
$result['issues'][] = self::issue($side, $index, 'invalid_herb', '药味必须为结构化对象');
$result['identity_complete'] = false;
continue;
}
$name = self::text($herb['name'] ?? null);
$identities = $dictionary['names'][$name] ?? [];
if ($name === '' || count($identities) !== 1) {
$code = count($identities) > 1 ? 'ambiguous_herb_name' : 'unknown_herb_name';
$result['issues'][] = self::issue($side, $index, $code, '药名“' . $name . '”无法唯一映射服务端字典');
$result['identity_complete'] = false;
continue;
}
$entry = $dictionary['entries'][array_key_first($identities)];
if ($side === 'doctor' && array_key_exists('medicine_id', $herb)
&& self::positiveId($herb['medicine_id']) !== $entry['id']) {
$result['issues'][] = self::issue($side, $index, 'doctor_identity_mismatch', '医生药材 ID 与规范药名不一致');
$result['identity_complete'] = false;
continue;
}
$processing = array_key_exists('processing', $herb) ? self::text($herb['processing']) : $entry['processing'];
if (in_array(strtolower($processing), ['无', '明确无', '无额外炮制', 'none'], true)) {
$processing = '';
}
if ((array_key_exists('processing', $herb) && !is_string($herb['processing']))
|| in_array(strtolower($processing), ['未知', '不详', 'unknown'], true)
|| ($entry['processing'] !== '' && $processing !== $entry['processing'])) {
$result['issues'][] = self::issue($side, $index, 'processing_conflict', '炮制信息与药材字典冲突或无效');
$result['identity_complete'] = false;
continue;
}
$roleValue = self::declaredValue($herb, $prescription, 'formula_type');
if (!$roleValue['present'] && $side === 'doctor') {
$roleValue['value'] = '主方';
$result['defaults'][] = ['row' => $index, 'field' => 'formula_type', 'value' => '主方'];
}
$role = self::role($roleValue['value']);
$route = self::text($herb['administration_route'] ?? '');
$group = self::text($herb['group'] ?? '');
if ($role === '' || (array_key_exists('administration_route', $herb) && !is_string($herb['administration_route']))
|| (array_key_exists('group', $herb) && !is_string($herb['group']))) {
$result['issues'][] = self::issue($side, $index, 'ambiguous_herb_role', '主辅方、给药路径或分组语义不明确');
$result['identity_complete'] = false;
continue;
}
$unitValue = self::declaredValue($herb, $prescription, 'unit');
$basisValue = self::declaredValue($herb, $prescription, 'dose_basis');
if ($side === 'doctor') {
// The workstation stores the per-herb unit once as the prescription's 用量单位
// (dosage_unit) and declares the dose basis through 剂量单位=剂/付. Both are the
// doctor's own explicit values; nothing is guessed when they are absent.
$declaredUnit = self::unit($prescription['dosage_unit'] ?? null);
$perDose = in_array(self::text($prescription['dose_unit'] ?? null), ['剂', '付'], true);
$fallbacks = [];
if ($declaredUnit !== '') {
$fallbacks['unit'] = $declaredUnit;
} elseif ($result['formulation'] === '饮片') {
$fallbacks['unit'] = 'g';
}
if ($perDose || $result['formulation'] === '饮片') {
$fallbacks['dose_basis'] = 'per_dose';
}
foreach ($fallbacks as $field => $default) {
$value = $field === 'unit' ? $unitValue : $basisValue;
if (!$value['present']) {
$result['defaults'][] = ['row' => $index, 'field' => $field, 'value' => $default];
if ($field === 'unit') {
$unitValue['value'] = $default;
} else {
$basisValue['value'] = $default;
}
}
}
}
$unit = self::unit($unitValue['value']);
$basis = self::basis($basisValue['value']);
if ($unit === '') {
$result['issues'][] = self::issue($side, $index, 'missing_or_unknown_unit', '剂量单位缺失或不受支持');
}
if ($basis === '') {
$result['issues'][] = self::issue($side, $index, 'missing_or_unknown_dose_basis', '须明确每剂或每日剂量基准');
} else {
$result['bases'][] = $basis;
}
$dosage = self::positiveNumber($herb['dosage'] ?? null);
if ($dosage === null) {
$result['issues'][] = self::issue($side, $index, 'invalid_dosage', '剂量必须为有限、明确且大于零的数值');
}
$usage = [];
foreach (self::ROW_USAGE_FIELDS as $field) {
$value = $herb[$field] ?? '';
if (!is_string($value)) {
$result['issues'][] = self::issue($side, $index, 'invalid_herb_usage', '药味煎服说明必须为文本');
}
$usage[$field] = in_array(strtolower(self::text($value)), ['无', '明确无', 'none'], true) ? '' : self::text($value);
}
// Identity processing comes from the dictionary. When the institution already encodes
// the processed form in the medicine name (醋五味子 + "醋制", 麸炒白术 + "麸炒"), the
// written processing only restates the name: it is kept for display and difference
// reporting but must not split one medicine into two rows. A processing that the name
// does not carry (黄芪 + "蜜炙") stays a distinct medication item.
$identityProcessing = $entry['processing'] !== '' || !self::restatesName($processing, $entry['name'])
? $processing : '';
$key = json_encode([$entry['id'], $identityProcessing, $role, $route, $group], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
$normalized = [
'medicine_id' => $entry['id'], 'name' => $entry['name'], 'processing' => $identityProcessing,
'declared_processing' => $processing,
'formula_type' => $role, 'administration_route' => $route, 'group' => $group,
'dosage' => $dosage, 'unit' => $unit, 'dose_basis' => $basis, 'usage' => $usage,
'source_rows' => [$index], 'source_names' => [$name],
'original_dosages' => [self::auditNumber($herb['dosage'] ?? null)],
];
if (isset($result['items'][$key])) {
$prior = $result['items'][$key];
if ($prior['unit'] !== $unit || $prior['dose_basis'] !== $basis || $prior['usage'] !== $usage
|| $prior['declared_processing'] !== $processing) {
$result['issues'][] = self::issue($side, $index, 'duplicate_semantics_conflict', '同药项重复行的单位、基准或煎服语义不一致,不能合并');
$result['identity_complete'] = false;
continue;
}
$merged = $prior['dosage'] === null || $dosage === null ? null : $prior['dosage'] + $dosage;
if ($merged !== null && !is_finite($merged)) {
$result['issues'][] = self::issue($side, $index, 'invalid_dosage', '重复药项合并后剂量不是有限数值');
$merged = null;
}
$normalized['dosage'] = $merged;
foreach (['source_rows', 'source_names', 'original_dosages'] as $field) {
$normalized[$field] = array_merge($prior[$field], $normalized[$field]);
}
$result['merges'][] = ['key' => $key, 'source_rows' => $normalized['source_rows'], 'dosage' => $merged];
}
$result['items'][$key] = $normalized;
}
$result['bases'] = array_values(array_unique($result['bases']));
return $result;
}
private static function buildDictionary(array $catalog): array
{
$result = ['entries' => [], 'names' => [], 'versions' => []];
foreach ($catalog as $row) {
if (!is_array($row)) {
continue;
}
$id = self::positiveId($row['id'] ?? null);
$name = self::text($row['name'] ?? null);
if ($id === null || $name === '') {
continue;
}
$processing = self::text($row['processing'] ?? '');
$entryKey = json_encode([$id, $name, $processing], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
$result['entries'][$entryKey] = ['id' => $id, 'name' => $name, 'processing' => $processing];
$aliases = isset($row['aliases']) && is_array($row['aliases']) ? $row['aliases'] : [];
foreach (array_merge([$name], $aliases) as $alias) {
$alias = self::text($alias);
if ($alias !== '') {
$result['names'][$alias][$entryKey] = true;
}
}
$version = self::text($row['dictionary_version'] ?? '');
if ($version !== '') {
$result['versions'][] = $version;
}
}
// A reused ID with different canonical identities is corrupt even when the
// names themselves differ: matching by the resulting ID would forge overlap.
$ids = [];
foreach ($result['entries'] as $entryKey => $entry) {
$ids[$entry['id']][$entryKey] = true;
}
foreach ($result['names'] as &$identities) {
foreach (array_keys($identities) as $entryKey) {
$identities += $ids[$result['entries'][$entryKey]['id']];
}
ksort($identities, SORT_STRING);
}
unset($identities);
ksort($result['entries'], SORT_STRING);
ksort($result['names'], SORT_STRING);
$result['versions'] = array_values(array_unique($result['versions']));
sort($result['versions'], SORT_STRING);
$result['hash'] = hash('sha256', json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
return $result;
}
private static function declaredValue(array $row, array $prescription, string $field): array
{
if (array_key_exists($field, $row)) {
return ['present' => true, 'value' => $row[$field]];
}
return ['present' => array_key_exists($field, $prescription), 'value' => $prescription[$field] ?? null];
}
private static function role($value): string
{
$roles = ['主方' => '主方', 'main' => '主方', 'primary' => '主方', '1' => '主方',
'辅方' => '辅方', 'aux' => '辅方', 'auxiliary' => '辅方', 'secondary' => '辅方', '2' => '辅方'];
return $roles[self::text($value)] ?? '';
}
/** True when every character of a processing label already appears in the medicine name. */
private static function restatesName(string $processing, string $name): bool
{
$label = str_replace(['制', '品', '法', '的'], '', self::text($processing));
if ($label === '' || $name === '') {
return false;
}
foreach (preg_split('//u', $label, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $character) {
if (mb_strpos($name, $character) === false) {
return false;
}
}
return true;
}
/**
* Spelling aliases only. Different dosage forms are never merged: 颗粒 and 浓缩水丸 stay
* distinct, and no quantity or extraction-ratio conversion is implied.
*/
private static function formulation($value): string
{
$text = self::text($value);
$aliases = ['中药饮片' => '饮片', '草药' => '饮片', '中药配方颗粒' => '颗粒', '配方颗粒' => '颗粒',
'免煎颗粒' => '颗粒', '中药颗粒' => '颗粒', '汤药' => '汤剂', '中药汤剂' => '汤剂'];
return $aliases[$text] ?? $text;
}
private static function unit($value): string
{
$units = ['g' => 'g', '克' => 'g', 'mg' => 'mg', '毫克' => 'mg', 'kg' => 'kg', '千克' => 'kg',
'ml' => 'ml', '毫升' => 'ml', '片' => '片', '粒' => '粒', '丸' => '丸', '袋' => '袋'];
return $units[strtolower(self::text($value))] ?? '';
}
private static function basis($value): string
{
$bases = ['per_dose' => 'per_dose', '每剂' => 'per_dose', 'per_day' => 'per_day', '每日' => 'per_day'];
return $bases[self::text($value)] ?? '';
}
private static function text($value): string
{
if (!is_string($value) || preg_match('//u', $value) !== 1) {
return '';
}
return preg_replace('/^[\s\x{3000}]+|[\s\x{3000}]+$/u', '', $value) ?? '';
}
private static function positiveId($value): ?int
{
if ((!is_int($value) && !is_string($value)) || !preg_match('/^[1-9][0-9]*$/D', (string) $value)) {
return null;
}
$id = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
return $id === false ? null : $id;
}
private static function positiveNumber($value): ?float
{
if ((!is_int($value) && !is_float($value) && !is_string($value)) || !is_numeric($value)) {
return null;
}
$number = (float) $value;
return is_finite($number) && $number > 0.0 ? $number : null;
}
private static function auditNumber($value)
{
if (is_float($value) && !is_finite($value)) {
return is_nan($value) ? 'NaN' : ($value > 0 ? 'Infinity' : '-Infinity');
}
return is_scalar($value) || $value === null ? $value : '[invalid non-scalar]';
}
private static function issue(string $side, ?int $row, string $code, string $reason): array
{
return ['side' => $side, 'row' => $row, 'code' => $code, 'reason' => $reason];
}
private static function usageDifferences(array $doctor, array $candidate): array
{
$doctor = self::usageView($doctor);
$candidate = self::usageView($candidate);
$differences = [];
foreach (self::USAGE_FIELDS as $field) {
$left = $doctor[$field] ?? null;
$right = $candidate[$field] ?? null;
if (self::stableValue($left) !== self::stableValue($right)) {
$differences[] = ['field' => $field, 'doctor' => $left, 'candidate' => $right];
}
}
return $differences;
}
/**
* Display-only: a side that states one unanimous per-herb unit also states it as the
* prescription's 用量单位. Never used for identity, dosage or scoring.
*/
private static function usageView(array $prescription): array
{
if (self::text($prescription['dosage_unit'] ?? null) !== '' || !is_array($prescription['herbs'] ?? null)) {
return $prescription;
}
$units = [];
foreach ($prescription['herbs'] as $herb) {
$units[] = is_array($herb) ? self::unit($herb['unit'] ?? null) : '';
}
$units = array_values(array_unique($units));
if (count($units) === 1 && $units[0] !== '') {
$prescription['dosage_unit'] = $units[0];
}
return $prescription;
}
private static function stableValue($value)
{
if (is_array($value)) {
ksort($value);
return array_map([self::class, 'stableValue'], $value);
}
if (is_string($value)) {
$value = self::text($value);
}
// Database numeric strings and the same JSON number are equivalent usage.
if ((is_string($value) || is_int($value) || is_float($value)) && is_numeric($value)) {
return self::auditNumber((float) $value);
}
return $value;
}
}