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; } }