'舌象', 'tongue_coating' => '舌苔', 'pulse' => '脉象', 'pulse_condition' => '脉象', ]; private const FIELD_GROUPS = [ 'allergy_history' => ['fields' => ['allergy_history'], 'aliases' => ['药物过敏史', '药物过敏', '过敏史', '过敏情况']], 'past_history' => ['fields' => ['past_history'], 'aliases' => ['既往病史', '既往史']], 'family_history' => ['fields' => ['family_history'], 'aliases' => ['家族史', '家族病史']], 'surgery_history' => ['fields' => ['surgery_history'], 'aliases' => ['手术史']], 'trauma_history' => ['fields' => ['trauma_history'], 'aliases' => ['外伤史']], 'pregnancy_history' => ['fields' => ['pregnancy_history'], 'aliases' => ['孕产史', '妊娠史']], 'tongue' => ['fields' => ['tongue'], 'aliases' => ['舌象', '舌质', '舌体', '舌色', '舌面', '舌']], 'tongue_coating' => ['fields' => ['tongue_coating'], 'aliases' => ['舌苔', '苔']], 'pulse' => ['fields' => ['pulse', 'pulse_condition'], 'aliases' => ['脉象', '脉诊', '脉']], 'stool_condition' => ['fields' => ['stool_condition'], 'aliases' => ['大便']], 'urine_condition' => ['fields' => ['urine_condition'], 'aliases' => ['小便']], 'sleep_condition' => ['fields' => ['sleep_condition'], 'aliases' => ['睡眠']], 'skin_condition' => ['fields' => ['skin_condition'], 'aliases' => ['皮肤']], 'weight_change' => ['fields' => ['weight_change'], 'aliases' => ['体重变化', '近期体重变化']], ]; // Validation-only field references; do not alter the source ledger/schema. private const MEDICATION_FIELD_REFERENCES = [ 'current' => ['fields' => ['current_medications'], 'aliases' => ['用药', '现用药', '在用药', '当前用药', '目前用药', '本次用药', '现用药物', '在用药物', '用药情况', '用药记录', '用药信息', '现用药情况', '当前用药情况', '目前用药情况']], 'history' => ['fields' => ['current_medications', 'western_medicine', 'insulin', 'prescription_name'], 'aliases' => ['用药史', '药物使用史']], ]; // Surface terms only; this does not infer a diagnosis, verify examination // reliability, or treat one feature as a medical synonym for another. private const EXAM_FEATURES = [ 'tongue' => '淡红|淡白|鲜红|红绛|绛红|暗红|紫暗|青紫|胖大|齿痕|裂纹|瘀点|瘀斑|少津|无津|厚腻|黄腻|白腻|薄白|薄黄|苔少|少苔|无苔|舌红|舌淡(?!红|白)|舌紫|舌绛|舌燥|舌润', 'pulse' => '无力|有力|浮|沉|迟|数|弦|滑|涩|细|弱|洪|紧|濡|微|缓|促|结|代', ]; /** Inputs must already be authorized for the current visit. Never expands patient scope. */ public static function build(array $diagnosis, array $submission = []): array { $ledger = ['version' => self::VERSION, 'sources' => [], 'assertions' => [], 'exam_features' => [], 'field_states' => []]; $sourceKeys = []; foreach (self::FIELDS as $field) { $value = $diagnosis[$field . '_text'] ?? null; if ($value === null || (is_string($value) && trim($value) === '')) $value = $diagnosis[$field] ?? null; self::source($ledger, $sourceKeys, $value, 'current.' . $field, 'current', self::date($diagnosis['diagnosis_date'] ?? null), $field); } $currentId = (string) ($diagnosis['id'] ?? $diagnosis['diagnosis_id'] ?? ''); $currentDate = self::date($diagnosis['diagnosis_date'] ?? null); foreach ($submission['required_content'] ?? [] as $section) { if (!is_array($section) || !is_array($section['value'] ?? null)) continue; $key = (string) ($section['key'] ?? ''); if (!in_array($key, ['chief_complaint', 'present_illness', 'past_history', 'other_history', 'diagnosis_info', 'doctor_image_notes', 'doctor_file_notes', 'daily_tracking', 'prescriptions', 'video_consultation_text', 'chat'], true)) continue; self::walk($ledger, $sourceKeys, $section['value'], $key, $currentId, $currentDate, in_array($key, ['prescriptions', 'video_consultation_text', 'chat', 'daily_tracking'], true) ? 'historical' : 'unspecified'); } foreach (['tongue' => ['tongue', 'tongue_coating'], 'pulse' => ['pulse', 'pulse_condition']] as $kind => $fields) { $ledger['exam_features'][$kind] = []; foreach ($ledger['sources'] as $source) { if (!str_starts_with($source['path'], 'current.') || !in_array($source['field'], $fields, true)) continue; foreach (self::clauses($source['quote']) as $clause) { if (self::scope($clause, 'current') !== 'current') continue; foreach (self::parts($clause) as $part) { if ($part['state'] === 'unknown') continue; foreach (self::features($kind, $part['topic']) as $feature) { $ledger['exam_features'][$kind][$feature][$part['state']] = $source['id']; } } } } } foreach (self::FIELD_GROUPS as $group => $definition) { $state = ['state' => 'unknown', 'source_id' => 'report', 'date' => $currentDate]; foreach ($ledger['assertions'] as $assertion) { $source = $ledger['sources'][$assertion['source_id']]; if (!str_starts_with($source['path'], 'current.')) continue; $inField = in_array($source['field'], $definition['fields'], true); if ($assertion['state'] === 'unknown') { if ($inField) $state['source_id'] = $source['id']; continue; } if (self::evidenceOnly($assertion['topic']) || self::conditional($assertion['quote']) || ($assertion['state'] === 'positive' && preg_match('/^(?:本次|此次)?(?:询问|已告知|告知|待询|请|建议|必要时)/u', $assertion['topic']))) continue; $aliasRecord = !in_array($group, ['tongue', 'pulse'], true) && self::containsAlias($assertion['topic'], $definition['aliases']); if ($inField || $aliasRecord) { $state = ['state' => 'recorded', 'source_id' => $source['id'], 'date' => $currentDate]; break; } } $ledger['field_states'][$group] = $state; } return $ledger; } private static function walk(array &$ledger, array &$sourceKeys, array $node, string $path, string $currentId, string $currentDate, string $scope): void { $rowId = (string) ($node['diagnosis_id'] ?? $node['id'] ?? ''); $rowDate = self::date($node['diagnosis_date'] ?? $node['note_date'] ?? $node['record_date'] ?? $node['prescription_date'] ?? $node['msg_time'] ?? $node['end_time'] ?? null); // Only a matching visit identity establishes a current row. A historical // chat/note date alone never establishes a current physical examination. if ($scope === 'unspecified' && $currentId !== '' && $rowId === $currentId) $scope = 'current'; elseif ($scope === 'unspecified' && $rowId !== '') $scope = 'historical'; foreach ($node as $field => $value) { if (is_array($value)) { self::walk($ledger, $sourceKeys, $value, $path . '.' . $field, $currentId, $currentDate, $scope); continue; } $baseField = in_array((string) $field, self::FIELDS, true) ? (string) $field : (preg_replace('/_text$/D', '', (string) $field) ?? (string) $field); if (!in_array($baseField, self::FIELDS, true) && !in_array((string) $field, self::LEAF_FIELDS, true)) continue; // Legacy projections used “无” for missing aliases. Current nulls // stay missing; explicit “未记录” aliases remain unknown sources. // Neither representation supplies an observed field-level denial. if (is_string($value) && trim($value) === '无') continue; self::source($ledger, $sourceKeys, $value, $path . '.' . $field, $scope, $rowDate, $baseField); } } private static function source(array &$ledger, array &$sourceKeys, $value, string $path, string $scope, string $date, string $field): void { if (!is_string($value) || trim($value) === '') return; $quote = self::redact(trim($value)); // Projection repeats the current row in several columns. Deduplicate only // identical field/date/scope text; never let a historical state overwrite it. // Keep this exact, unambiguous tuple index local to one build. Serialized // strings retain every byte (including separators/NULs) without hash loss. $sourceKey = serialize([$quote, $field, $scope, $date]); if (isset($sourceKeys[$sourceKey])) return; $sourceKeys[$sourceKey] = true; $id = 's' . (count($ledger['sources']) + 1); $ledger['sources'][$id] = ['id' => $id, 'path' => $path, 'field' => $field, 'scope' => $scope, 'date' => $date, 'quote' => $quote]; // An explicit denial in a named history field is different from the // projection's ambiguous “无” marker. Do not discard its field context. if (in_array($field, ['allergy_history', 'past_history', 'family_history', 'surgery_history', 'trauma_history', 'pregnancy_history'], true) && preg_match('/^(?:明确)?否认$/uD', $quote)) { $ledger['assertions'][] = ['source_id' => $id, 'state' => 'negative', 'topic' => self::fieldLabel($field), 'quote' => $quote, 'scope' => $scope, 'date' => $date]; return; } if (preg_match('/^(?:无|暂无|未填|未填写|未记录|未采集|未提供|未知|不详|待查|待补|待采集|不清楚|没有|none|null|n\/?a|unknown|0|[-—–]+)$/iuD', $quote)) { $ledger['assertions'][] = ['source_id' => $id, 'state' => 'unknown', 'topic' => self::fieldLabel($field), 'quote' => $quote, 'scope' => $scope, 'date' => $date]; return; } foreach (self::clauses($quote) as $clause) { // Inference restrictions remain source text, not clinical polarity. if (preg_match('/^\s*(?:不(?:从|由).{0,100}(?:推断|推测|判断|定性)|不(?:移作|单凭))/u', $clause)) continue; $clauseScope = self::scope($clause, $scope); foreach (self::parts($clause) as $part) { foreach (self::topics($part['topic']) as $topic) { if ($topic === '') continue; $ledger['assertions'][] = ['source_id' => $id, 'state' => $part['state'], 'topic' => $topic, 'quote' => $part['quote'], 'scope' => $clauseScope, 'date' => $date]; } } } } /** Compact contract; quotations remain in the already supplied full case. */ public static function prompt(array $ledger, array $excludedFields = []): string { $unknown = []; foreach ($ledger['assertions'] ?? [] as $assertion) { if (($assertion['state'] ?? '') !== 'unknown' || ($assertion['scope'] ?? '') === 'historical') continue; $source = $ledger['sources'][$assertion['source_id']] ?? []; if (in_array($source['field'] ?? '', $excludedFields, true)) continue; $topic = (string) ($assertion['topic'] ?? ''); if (in_array($topic, self::FIELDS, true)) continue; $unknown[$topic] = true; } $topics = implode('、', array_slice(array_keys($unknown), 0, 32)); return "【本地事实状态约束】未记录/未采集/不详是未知,不是阴性;相同状态必须贯穿四诊、辨证、鉴别及方药反证全文。" . "患者阴性和四诊体征须引用同一时点的病历短原文,保留原修饰语与否定范围,不缩减复合词或扩大排除。" . "旧记录的肯定、否定及停药状态不能移作本次事实;条件、待核实及医学常识不能写成患者已发生的事实。" . ($topics !== '' ? '本次资料显式未知项(这里只是索引,仍以完整原文为准):' . $topics . '。' : ''); } /** * May validate one complete paragraph or a final report. Issues contain no * patient text, output snippets, source paths, dates or medication details. */ public static function validate(array $ledger, string $report): array { $issues = []; if (($ledger['version'] ?? '') !== self::VERSION || !is_array($ledger['assertions'] ?? null) || !is_array($ledger['sources'] ?? null) || !is_array($ledger['exam_features'] ?? null) || !is_array($ledger['field_states'] ?? null)) { return ['valid' => false, 'version' => self::VERSION, 'issues' => [['code' => 'FACT_STATE_LEDGER_UNAVAILABLE', 'source_id' => '']]]; } if (preg_match('//u', $report) !== 1) { return ['valid' => false, 'version' => self::VERSION, 'issues' => [['code' => 'FACT_STATE_INVALID_UTF8', 'source_id' => '']]]; } $pendingFields = []; $pendingMedicationFields = []; $previousNegation = []; foreach (self::reportClauses($ledger, $report) as $entry) { $clause = $entry['text']; $antecedent = $previousNegation; $previousNegation = []; $timeClause = $clause . ' ' . implode(' ', $entry['dates']); if ($entry['annotation_invalid']) { $issues['SOURCE_ANNOTATION_INVALID'] = ['code' => 'SOURCE_ANNOTATION_INVALID', 'source_id' => 'report']; } $hearingTarget = preg_match('/^该记录支持气短尚未表现为闻诊可见的喘促$/uD', trim($clause)) ? '喘促' : ''; if ($hearingTarget !== '' || preg_match('/^此处仅表示闻诊当时未见上述表现$/uD', trim($clause))) { $supported = $antecedent !== []; if ($entry['source_ids'] !== [] && array_diff($antecedent, $entry['source_ids']) !== []) $supported = false; $targetSupported = $hearingTarget === ''; $dyspneaSupported = $hearingTarget === ''; foreach ($antecedent as $sourceId) { if (!self::sameTime($ledger['sources'][$sourceId], 'current', $timeClause)) $supported = false; foreach ($ledger['assertions'] as $source) { if ($source['source_id'] === $sourceId && $source['state'] === 'negative' && $source['topic'] === $hearingTarget) $targetSupported = true; } } if ($hearingTarget !== '') foreach ($ledger['assertions'] as $source) { if ($source['state'] === 'positive' && self::contained('气短', $source['topic']) && self::sameTime($source, 'current', $timeClause)) $dyspneaSupported = true; } if (!$supported || !$targetSupported || !$dyspneaSupported) { $issues['UNSUPPORTED_NEGATIVE_REFERENCE'] = ['code' => 'UNSUPPORTED_NEGATIVE_REFERENCE', 'source_id' => 'report']; } continue; } if (!$entry['strict_role_tail'] && self::conditional($clause)) continue; foreach (self::medicationFieldIssues($ledger, $clause, $timeClause, $pendingMedicationFields) as $issue) { $issues[$issue['code'] . ':' . $issue['source_id']] = $issue; } if (preg_match('/^(?:因)?(?:无|未提供)(?:' . self::ATTACHMENT_TOPIC . ')(?:或(?:' . self::ATTACHMENT_TOPIC . '))*$/uD', trim($clause))) continue; // An inability to recheck this bounded list does not erase existing // observations or assert that the listed features are absent. if (preg_match('/^不能复核(?:舌体胖瘦|齿痕|裂纹|舌下络脉)(?:[、及和或]+(?:舌体胖瘦|齿痕|裂纹|舌下络脉))*$/uD', trim($clause))) continue; if (preg_match('/^(?:不能|不得|不应)据(?:图片|图像)补充(?:面色|形体|局部体征)(?:[、及和或]+(?:面色|形体|局部体征))*$/uD', trim($clause))) continue; // A bounded prohibition on inventing absent data is not an exam. // Do not consume commas or a following patient assertion. if (preg_match('/^(?:不能|不得|不应)(?:补充|补写|编造)[^,,。;]{0,60}(?:未记录|未采集|不详)(?:的)?(?:内容|体征|舌象|脉象)$/uD', trim($clause))) continue; foreach (self::fieldIssues($ledger, $clause, $pendingFields) as $issue) { $issues[$issue['code'] . ':' . $issue['source_id']] = $issue; } // A pure field heading names the following value, not an observed // positive finding. fieldIssues keeps its state across paragraphs. if ($pendingFields !== []) continue; $scope = self::scope($clause, 'current'); $parts = $entry['exam_conjunction_parts'] ?? self::parts($clause); if ($scope === 'current') { foreach (self::examinationIssues($ledger, $clause, $parts) as $issue) { $issues[$issue['code'] . ':' . $issue['source_id']] = $issue; } } foreach ($parts as $part) { if ($part['state'] === 'unknown') { foreach (self::knownToUnknownIssues($ledger, $part['topic'], $scope, $timeClause) as $issue) { $issues[$issue['code'] . ':' . $issue['source_id']] = $issue; } continue; } if (self::evidenceOnly($part['topic'])) continue; $quotedSources = self::quotedPartSources($ledger, $entry['quotes'], $part, $scope, $timeClause); if ($part['state'] === 'negative' && self::historyAttribution($clause) !== '' && $quotedSources === []) { $issues['ATTRIBUTED_SOURCE_QUOTE_INVALID'] = ['code' => 'ATTRIBUTED_SOURCE_QUOTE_INVALID', 'source_id' => 'report']; } foreach (self::topics($part['topic']) as $topic) { if ($topic === '' || self::evidenceOnly($topic)) continue; $negativeSource = false; foreach ($ledger['assertions'] as $source) { if ($quotedSources !== [] && !in_array($source['source_id'], $quotedSources, true)) continue; // Bound quotations have already passed source/time checks. if (($quotedSources === [] && !self::sameTime($source, $scope, $timeClause)) || self::medicationHeadingOnly($source) || !self::related($topic, $source['topic'])) continue; if ($source['state'] === 'negative') $negativeSource = true; $code = ''; if ($source['state'] === 'unknown') { $code = $part['state'] === 'negative' ? 'UNKNOWN_TO_NEGATIVE' : 'UNKNOWN_TO_POSITIVE'; } elseif ($part['state'] === 'negative' && $source['state'] === 'positive' && self::contained($topic, $source['topic'])) { $code = 'POSITIVE_TO_NEGATIVE'; } elseif ($part['state'] === 'positive' && $source['state'] === 'negative' && ($part['explicit'] ?? false) && self::positiveConflictsWithNegative($topic, $source['topic'])) { $code = 'NEGATIVE_TO_POSITIVE'; } elseif ($part['state'] === 'negative' && $source['state'] === 'negative' && self::narrower($topic, $source['topic'])) { $code = 'NEGATION_SCOPE_WIDENED'; } if ($code !== '') $issues[$code . ':' . $source['source_id']] = [ 'code' => $code, 'source_id' => $source['source_id']]; } if ($part['state'] === 'negative' && ($part['observed_absence'] ?? false) && !$negativeSource && $scope === 'current' && !preg_match('/\b(?:19|20)\d{2}\b/u', $clause) && mb_strlen($topic, 'UTF-8') >= 2 // Missing files are transfer metadata, not clinical absences. && !preg_match('/^' . self::ATTACHMENT_TOPIC . '$/uD', $topic) && preg_match('/患者|本例|本次|当前|皮肤|肢体|口气|气息|听声音|嗅气味|形体|体型|望诊|闻诊/u', $clause) && !in_array($topic, ['舌', '脉', '舌象', '脉象', '舌苔'], true)) { $issues['NEGATIVE_NOT_IN_SOURCE'] = ['code' => 'NEGATIVE_NOT_IN_SOURCE', 'source_id' => 'report']; } } } if ($issues === [] && $scope === 'current') { $previousNegation = self::verifiedHearingNegation($ledger, $entry, $parts, $timeClause); } } return ['valid' => $issues === [], 'version' => self::VERSION, 'issues' => array_values($issues)]; } private static function clauses(string $text): array { $text = self::cleanText($text); return preg_split('/[。;;!?!?\r\n]+|[,,](?=\s*(?:本次|此次|当前|目前|既往|历史|当年|上次|不从|不由|不移作|不单凭|(?:19|20)\d{2}))/u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: []; } private static function cleanText(string $text, bool $removeQuotes = true): string { $text = preg_replace('/\[[^\]\r\n]{0,160}\]|【[^】\r\n]{0,160}】|〔[^〕\r\n]{0,160}〕/u', '', $text) ?? $text; $text = str_replace(['**', '__', '`'], '', $text); return $removeQuotes ? str_replace(['“', '”', '「', '」', '‘', '’', '"'], '', $text) : $text; } /** Unlike source normalization, report formatting never discards bracket bodies. */ private static function cleanReportText(string $text, bool $removeQuotes = true): string { return self::cleanText(str_replace(['[', ']', '【', '】', '〔', '〕'], '', $text), $removeQuotes); } /** Keep quotation boundaries even when a quoted field contains several sentences. */ private static function reportClauses(array $ledger, string $text): array { $text = self::reportBracketProse($text); $text = self::datedNegativeLists($ledger, $text); $text = self::withoutSourceAnnotations($ledger, $text); $text = self::cleanReportText($text, false); [$text, $sourceCommentaries] = self::sourceQuoteBoundaries($ledger, $text); preg_match_all('/“([^”\r\n]+)”|「([^」\r\n]+)」|‘([^’\r\n]+)’|"([^"\r\n]+)"/u', $text, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); $quotes = []; foreach ($matches as $match) { foreach (array_slice($match, 1) as $capture) { if ($capture[1] < 0 || $capture[0] === '') continue; $canonical = self::canonicalQuote($capture[0]); $sourceIds = []; foreach ($ledger['sources'] as $source) { if ($canonical !== '' && str_contains(self::canonicalQuote($source['quote']), $canonical)) $sourceIds[] = $source['id']; } $annotationInvalid = false; $afterQuote = substr($text, $match[0][1] + strlen($match[0][0])); if (preg_match('/^\s*(?:(([^()\r\n]{1,150}))|\(([^()\r\n]{1,150})\))/u', $afterQuote, $citation)) { $metadata = self::sourceAnnotationTokens($ledger, ($citation[1] ?? '') !== '' ? $citation[1] : ($citation[2] ?? '')); if ($sourceIds !== [] && $metadata !== null && $metadata['ids'] !== []) { $annotationInvalid = !$metadata['valid'] || array_intersect($metadata['ids'], $sourceIds) === []; } } if ($sourceIds !== [] || $annotationInvalid) $quotes[] = ['start' => $capture[1], 'end' => $capture[1] + strlen($capture[0]), 'source_ids' => $sourceIds, 'annotation_invalid' => $annotationInvalid]; } } $result = []; $strictContinuation = false; $pendingQuestion = false; $pendingContingency = false; $examReferenceContext = []; $examReferenceComma = false; foreach (preg_split('/[。;;!?!?\r\n]+|(?=但(?:是)?|然而|不过)|[,,](?=\s*(?:本次|此次|当前|目前|既往|历史|当年|上次|舌质|舌象|不能复核|中药不能替代|(?:不能|不得|不应)据(?:图片|图像)补充|(?:19|20)\d{2}|若|如果|假如|倘若|一旦|仅在|只有|如出现|复诊时|后续出现|(?:后续|今后|此后)(?:若|如果|如)|适用前(?:需|须|应)|需|须|应|建议|请|仍需|还需|可作为|可作|作为|因此|故|提示|说明|支持|依据为|(?:主要|具体|直接)(?:依据|根据)|(?:访谈|聊天(?:内容|记录)?)(?:另记|另载|记载|记录为|记录中|中记载|为)|运动(?:记录(?:为|另载|另记|记载|中)|时)|(?:但)?(?:不能|不得|不应)(?:补充|补写|编造|替代)|不移作|不单凭|不从|不由))/u', $text, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_OFFSET_CAPTURE) ?: [] as [$clause, $offset]) { $terminator = substr($text, $offset + strlen($clause)); $question = preg_match('/^[??]/u', $terminator) === 1; $fragments = []; $ids = []; $dates = []; $invalid = false; foreach ($quotes as $quote) { $start = max($offset, $quote['start']); $end = min($offset + strlen($clause), $quote['end']); if ($start < $end) { $fragments[] = ['text' => substr($text, $start, $end - $start), 'source_ids' => $quote['source_ids']]; if ($quote['annotation_invalid']) $invalid = true; } } $clause = self::historyDenialReferenceProse($ledger, $clause, $invalid); // The polarity/quote parser keeps its existing temporal split. // Only this checker may retain an exam antecedent across that // comma into a finite time-prefixed continuation of the same kind. if (!$examReferenceComma || !preg_match('/^\s*(?:本次|此次|当前|目前|既往|历史|当年|上次)/u', $clause)) { $examReferenceContext = []; } $examConjunctionParts = null; if (!self::explicitExamReferencesSupported($ledger, $clause, $examReferenceContext, $examConjunctionParts)) $invalid = true; $examReferenceComma = preg_match('/^[,,]/u', $terminator) === 1; $clinical = preg_replace_callback('/(([^()\r\n]{1,150}))|\(([^()\r\n]{1,150})\)/u', static function (array $match) use ($ledger, &$ids, &$dates, &$invalid): string { $body = ($match[1] ?? '') !== '' ? $match[1] : ($match[2] ?? ''); // Mixed clinical parentheses remain prose, but cannot hide // a malformed/nonexistent source-shaped token in their list. foreach (preg_split('/[,,、]/u', $body) ?: [] as $token) { $item = self::sourceAnnotationTokens($ledger, trim($token)); if ($item !== null && !$item['valid']) $invalid = true; } $metadata = self::sourceAnnotationTokens($ledger, $body); if ($metadata === null) return $match[0]; if (!$metadata['valid']) { $invalid = true; return $match[0]; } $ids = array_merge($ids, $metadata['ids']); $dates = array_merge($dates, $metadata['dates']); return ''; }, $clause) ?? $clause; if ($ids !== []) { foreach ($fragments as &$fragment) $fragment['source_ids'] = array_values(array_intersect($fragment['source_ids'], $ids)); unset($fragment); } if ($dates !== [] && $ids === []) { foreach ($dates as $date) { $matched = false; foreach ($fragments as $fragment) foreach ($fragment['source_ids'] as $id) { if ($ledger['sources'][$id]['date'] === $date) $matched = true; } if (!$matched) $invalid = true; } } $plain = self::cleanReportText($clinical); // An affirmative evidence introducer opens its own finding list; // it must not become the subject of a postfix missing-record claim. $assertionView = preg_replace('/^\s*(?:但(?:是)?|然而|不过)\s*/u', '', $plain) ?? $plain; if (preg_match('/^(?:但(?:是)?|然而|不过)/u', $terminator)) { $assertionView = preg_replace('/[,,]\s*$/u', '', $assertionView) ?? $assertionView; } // A separately introduced evidence list supplies actual findings; // its 依据 label must not hide those findings as an evidence noun. $evidenceView = preg_replace('/^\s*(?:(?:主要|具体|直接)(?:依据|根据)\s*[::]?|依据为)\s*/u', '', $assertionView) ?? $assertionView; $referenceView = self::sourceReferenceProse($ledger, self::negatedInferenceProse(self::inferenceHeadProse($evidenceView)), $invalid); $patientReviewView = self::patientReviewQuestionProse($referenceView); $pendingHead = preg_match('/^\s*适用前(?:需|须|应)(?:核实|复核|确认)[::]/u', $referenceView) === 1; $questionView = ($question || $pendingHead || $pendingQuestion) ? self::terminatedQuestionProse($patientReviewView) : $patientReviewView; preg_match('/^[。;;!?!?\r\n]+/u', $terminator, $separator); // Only the immediately preceding fully neutral question can // supply the antecedent of this closed contingency operator. $contingencyTail = $pendingContingency && preg_match('/^\s*(?:如果|如|若)有(?=$|[,,]|且|但|而|并|同时|还|又|另|患者|本例)/u', $referenceView) === 1; $pendingContingency = $questionView !== $referenceView && trim($questionView) === '' && preg_match('/^[;;??]+$/uD', $separator[0] ?? '') === 1; // Only a consecutive neutral whether-question may inherit this // pending-review operator across a semicolon in the same line. $pendingQuestion = $questionView !== $referenceView && trim($questionView) === '' && preg_match('/^[;;]+$/uD', $separator[0] ?? '') === 1; $questionView = self::reportTypedAvailabilityProse($questionView); $questionView = self::reportRecordPredicateProse($questionView); $questionView = self::reportEvidenceGapProse($questionView); $triggerInput = $questionView; $questionView = self::prospectiveTriggerProse($questionView, $terminator); // A typed trigger's refusal cannot fall back to the generic // 如出现 exemption when it contains its own actual patient claim. $refusedActualTrigger = $questionView === $triggerInput && preg_match('/^\s*如出现(.+)$/uD', $triggerInput, $triggerObject) && (self::hasAssertionMarker($triggerObject[1]) || preg_match('/患者|本例|本次|此次|当前|目前|现在|正在|已经|已|完成|今晨|今日|昨晚|昨日|' . self::OBSERVATION_INTRODUCER . '/u', $triggerObject[1])); $questionView = self::futureCausalCriterionProse($questionView); $questionView = self::futureApplicabilityCriterionProse($questionView); $questionView = self::futureSymptomRecheckProse($questionView); $questionView = self::futurePreconditionProse($questionView); $questionView = self::prospectiveReviewProse($questionView); $labelledMedication = preg_match('/^\s*([^::]+)[::]/u', $plain, $fieldLabel) && self::medicationHeadingOnly(['quote' => trim($fieldLabel[1]) . ':']); // A removed question or a source-bound quote cannot lend a modal // exemption to an adjoining assertion in its remaining prose. $strictTail = $strictContinuation || $contingencyTail || $labelledMedication || $refusedActualTrigger || in_array($offset, $sourceCommentaries, true) || ($questionView !== $assertionView && trim($questionView) !== ''); // Comma/adversative splits are still the same sentence's prose. // Only a real sentence/question boundary ends this local guard. $strictContinuation = preg_match('/^(?:[,,]|但(?:是)?|然而|不过)/u', $terminator) === 1 && ($strictTail || $questionView !== $assertionView); if ($strictTail) { // Keep both sides of each adjoining assertion. In particular // a commentary prefix must not become the subject of a later // postfix unknown or carry its modality into that assertion. $exam = '(?:舌(?:象|质|体|色|面|苔)?|苔)(?=(?:明显|显著)?(?:' . self::EXAM_FEATURES['tongue'] . '))' . '|脉(?:象|诊)?(?=(?:' . self::EXAM_FEATURES['pulse'] . '))'; $questionView = implode(',', preg_split('/并且|(? self::normalizeUnknownCopula(self::reportProse($questionView)), 'quotes' => $fragments, 'strict_role_tail' => $strictTail, 'dates' => array_values(array_unique($dates)), 'source_ids' => array_values(array_unique($ids)), 'annotation_invalid' => $invalid]; if ($examConjunctionParts !== null) $result[array_key_last($result)]['exam_conjunction_parts'] = $examConjunctionParts; } return $result; } /** Report formatting must retain clinical bracket bodies; source cleanup stays unchanged. */ private static function reportBracketProse(string $text): string { // Link labels remain report prose. Only an explicit local source anchor // contributes source metadata; an external target is not a patient fact. $text = preg_replace_callback('/\[([^\]\r\n]{1,160})\]\(([^()\s\r\n]{1,500})\)/u', static function (array $link): string { if (preg_match('/^#(s[0-9]+)$/D', $link[2], $source)) { return ($link[1] === $source[1] ? '' : $link[1]) . '(' . $source[1] . ')'; } return '[' . $link[1] . ']'; }, $text) ?? $text; return preg_replace_callback('/\[([^\]\r\n]{0,160})\]|【([^】\r\n]{0,160})】|〔([^〕\r\n]{0,160})〕/u', static function (array $match) use ($text): string { $body = ''; foreach (array_slice($match, 1) as $capture) if ($capture[0] !== '') { $body = $capture[0]; break; } $body = str_replace(['[', ']', '【', '】', '〔', '〕'], '', $body); if (preg_match('/^(?:来源[::]|s[0-9]+(?:$|[,,、]))/u', trim($body))) { $prefix = substr($text, 0, $match[0][1]); $depth = substr_count($prefix, '(') + substr_count($prefix, '(') - substr_count($prefix, ')') - substr_count($prefix, ')'); // Preserve a nested [sN] inside its original annotation. return $depth > 0 ? $body : '(' . $body . ')'; } return $body; }, $text, -1, $replacements, PREG_OFFSET_CAPTURE) ?? $text; } /** A complete source quote ends at its own verified citation, before commentary. */ private static function sourceQuoteBoundaries(array $ledger, string $text): array { preg_match_all('/(“([^”\r\n]+)”|「([^」\r\n]+)」|‘([^’\r\n]+)’|"([^"\r\n]+)")\s*' . '((([^()\r\n]{1,150}))|\(([^()\r\n]{1,150})\))[ \t]*(?=[^\s。;;!?!?])/u', $text, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); $normalized = ''; $cursor = 0; $commentaries = []; foreach ($matches as $entry) { $normalized .= substr($text, $cursor, $entry[0][1] - $cursor) . $entry[0][0]; $cursor = $entry[0][1] + strlen($entry[0][0]); $match = array_map(static fn (array $capture): string => $capture[0], $entry); $body = ($match[7] ?? '') !== '' ? $match[7] : ($match[8] ?? ''); $metadata = self::sourceAnnotationTokens($ledger, $body); if ($metadata === null || !$metadata['valid'] || $metadata['ids'] === []) continue; $quote = ''; foreach (array_slice($match, 2, 4) as $capture) if ($capture !== '') { $quote = $capture; break; } $canonical = self::canonicalQuote($quote); $before = substr($text, 0, $entry[0][1]); $opening = preg_replace('/^.*[。;;!?!?\r\n,,]/us', '', $before) ?? $before; $combined = self::canonicalQuote($opening . $quote); $supported = $canonical !== ''; foreach ($metadata['ids'] as $id) { // A source substring or a different source cannot lend a // new boundary to a truncated or widened clinical claim. $sourceQuote = self::canonicalQuote($ledger['sources'][$id]['quote']); if ($canonical !== $sourceQuote && $combined !== $sourceQuote) $supported = false; } if (!$supported) continue; // Validation view only: build quote offsets after this step. // The stream continues forwarding the untouched model bytes. $normalized .= ';'; $commentaries[] = strlen($normalized); } return [$normalized . substr($text, $cursor), $commentaries]; } /** A date-only heading binds only its consecutive, source-verified negative list. */ private static function datedNegativeLists(array $ledger, string $text): string { $paragraphs = preg_split('/((?:\r?\n){2,})/', $text, -1, PREG_SPLIT_DELIM_CAPTURE) ?: [$text]; $calendar = '(?:19|20)\d{2}(?:-\d{1,2}-\d{1,2}|年\d{1,2}月\d{1,2}日?)'; $id = '(?:s[1-9][0-9]*|\[s[1-9][0-9]*\])'; $ids = $id . '(?:\s*[,,、]\s*' . $id . ')*'; $quote = '(?:“[^”\r\n]+”|「[^」\r\n]+」|‘[^’\r\n]+’|"[^"\r\n]+")'; foreach ($paragraphs as &$paragraph) { $lines = preg_split('/\r?\n/', $paragraph) ?: []; $header = str_replace(['**', '__', '`'], '', trim($lines[0] ?? '')); if (!preg_match('/^已记录阴性(?:((' . $calendar . '))|\((' . $calendar . ')\))[::]$/uD', $header, $heading)) continue; $dates = self::calendarDates(($heading[1] ?? '') !== '' ? $heading[1] : ($heading[2] ?? '')); if (count($dates) !== 1) continue; $rewritten = $lines; $count = 0; $valid = true; for ($line = 1; $line < count($lines); ++$line) { if (!preg_match('/^\s*[1-9]\d?[..、]/u', $lines[$line])) break; if (++$count > 32 || !preg_match('/^\s*([1-9]\d?)[..、]\s*(' . $quote . ')\s*(?:((' . $ids . '))|\((' . $ids . ')\))\s*。?\s*$/uD', $lines[$line], $item) || (int) $item[1] !== $count) { $valid = false; break; } $body = ($item[3] ?? '') !== '' ? $item[3] : ($item[4] ?? ''); $metadata = self::sourceAnnotationTokens($ledger, $dates[0] . '、' . $body); if ($metadata === null || !$metadata['valid']) { $valid = false; break; } $supported = false; foreach ($ledger['assertions'] as $source) { if ($source['state'] === 'negative' && in_array($source['source_id'], $metadata['ids'], true) && $source['date'] === $dates[0] && self::canonicalQuote($source['quote']) === self::canonicalQuote($item[2])) $supported = true; } if (!$supported) { $valid = false; break; } // Plain clause dates do not seed the separate “同日” annotation // state. No date can escape the bounded list to later prose. $rewritten[$line] = $dates[0] . ' ' . $item[2] . '(' . $body . ')'; } if ($valid && $count > 0) { $rewritten[0] = '已记录阴性:'; $paragraph = implode("\n", $rewritten); } } unset($paragraph); return implode('', $paragraphs); } /** Remove bounded report syntax and references, preserving every adjacent assertion. */ private static function reportProse(string $clause): string { // These headings and list numbers are structure, not unknown clinical // subjects. In particular “待补:1.” must not match eGFR “…/1.73m²”. $clause = preg_replace('/^\s*(?:(?:显式|明确)?未知[\//]待补|未知项)[::]\s*/u', '', $clause) ?? $clause; $clause = preg_replace('/^\s*[1-9]\d{0,2}[..、]\s*(?=[\p{L}“「‘"]|$)/u', '', $clause) ?? $clause; $clause = self::reviewRequestProse($clause); $clause = self::linkedReviewRequestProse($clause); $clause = self::collectionRequestProse($clause); $clause = self::pendingMedicationReviewProse($clause); $clause = self::formulaFunctionProse($clause); // A prohibition on deriving clinical normality from empty data does // not assert the inner normality proposition about this patient. $clause = preg_replace('/(^|[,,])\s*(?:不能|不得|不应)(?:仅|只|单)?(?:据|凭)' . '(?:空值|空白|缺项|默认值|缺失资料)(?:直接)?(?:判为|推断为|认定为)' . '(?:正常|无异常|无症状|明确阴性)(?=$|[,,]|且|但|而|患者|本例)/u', '$1', $clause) ?? $clause; $clause = self::exclusionLimitProse($clause); // A complete cross-reference names the location of the pulse result; // “结果” is not a pulse 结 finding. No clinical value fits this grammar. $reference = '(?:切诊[::]\s*)?脉(?:诊|象)结果见(?:上(?:列|文|述)|下(?:列|文|方))(?:独立)?脉象(?:行)?'; $files = '(?:图像|图片|照片|报告)(?:[或及、]+(?:图像|图片|照片|报告))*'; $limitation = '(?:并非|不代表)(?:患者|本例|舌体)(?:无|没有|不存在)(?:其他|任何)?异常'; // Outer logical negation in a file-availability explanation does not // assert its inner “患者无异常”. Require the entire metadata role, // including the missing files and the closed recheck limitation. $attachment = '(?:(?:无|未提供)(?:' . self::ATTACHMENT_TOPIC . ')[,,](?:只|仅)?表示(?:没有|无)可复核' . $files . '|(?:' . self::ATTACHMENT_TOPIC . ')缺失(?:只|仅)表示不能(?:图像|图片)复核)[,,]' . $limitation; $availability = '(?:但|亦|也)?(?:本次|此次|当前|目前)?(?:无|未提供)(?:' . self::ATTACHMENT_TOPIC . ')' . '(?:可供(?:二次)?(?:图像|图片)?(?:复核|核验))?'; $clause = preg_replace('/(^|[,,])\s*(?:' . $reference . '|' . $attachment . ')(?=$|[,,])/u', '$1', $clause) ?? $clause; return preg_replace('/(^|[,,])\s*' . $availability . '(?=$|[,,]|且|但|而|患者|本例)/u', '$1', $clause) ?? $clause; } /** An explicit question/pending operator owns only its leading neutral object. */ private static function terminatedQuestionProse(string $clause): string { $request = preg_replace('/^\s*适用前(?:需|须|应)(?:核实|复核|确认)[::]\s*/u', '', $clause) ?? $clause; $questionSubject = ''; if (preg_match('/^\s*([\p{L}\p{N}、与及和或的]{1,60}?)是否(.+)$/uD', $request, $subjectQuestion)) { if (!self::neutralRecordSubject($subjectQuestion[1]) || preg_match('/患者|本例|本次|此次|当前|目前|现在|今晨|今日|昨晚|昨日|已|完成|明确|' . self::OBSERVATION_INTRODUCER . '/u', $subjectQuestion[1])) { // An actual/completed subject remains an independent claim; // the review heading must not hide it from the usual checks. return $subjectQuestion[1] . ',是否' . $subjectQuestion[2]; } if (preg_match('/^(?:存在|出现|可见|有)$/uD', $subjectQuestion[2])) return ''; $questionSubject = $subjectQuestion[1]; $request = '是否' . $subjectQuestion[2]; } if (!preg_match('/^\s*(?:是否(?:(?:另|还)?(?:存在|可见|出现|伴有|伴|使用|服用|应用|启用|用|服|有))?|有无)(.+)$/uD', $request, $question)) return $clause; $pieces = preg_split('/[,,]/u', $question[1]) ?: []; $joined = self::roleAssertionPieces($pieces[0] ?? ''); $object = trim($joined[0]); $assertionView = $questionSubject !== '' ? $questionSubject . ',' . $question[1] : $clause; // 不宜 modifies applicability inside the question; it is not a // patient denial. All other polarity/record markers remain checked. $neutral = str_replace('不宜', '', $object); // A prospective prepositional target can precede the question's // presence predicate. Remove only that operator/predicate pair; // target and queried property still undergo every nominal/state check. if (preg_match('/^(?:对于|针对|对)(?:拟(?:选用|采用|使用|用|选)|待(?:选用|选|用)|候选|' . '(?:计划|准备)(?:选用|使用|采用))([\p{L}\p{N}、与及或的]{1,60}?)(?:存在|出现|可见|有)' . '([\p{L}\p{N}、与及或的]{1,60})$/uD', $neutral, $prospective)) { // A non-neutral target/property remains an independent assertion, // rather than hiding its record-state predicate behind 是否. $assertionPieces = $pieces; $assertionPieces[0] = $prospective[1] . ',' . $prospective[2] . (isset($joined[1]) ? ',' . $joined[1] : ''); $assertionView = implode(',', $assertionPieces); if (!self::neutralRecordSubject($prospective[1])) return $assertionView; $neutral = $prospective[1] . '的' . $prospective[2]; } // A record-state adjective belongs before a noun at a nominal item // boundary, optionally after a determiner. A postfix gap has no such // boundary and cannot borrow this question's non-assertive role. $neutral = preg_replace('/(^|、|以及|或者|与|及|和|或)((?:其他|其它|其余|额外|新增|另外|新的|后续)?)' . '(?:' . self::UNKNOWN . ')的(?=[\p{L}\p{N}])/u', '$1$2', $neutral, -1, $recordAdjectives) ?? $neutral; // In an attributed-gap noun phrase, a final action noun names what // is being queried, rather than asserting that the action occurred. if ($recordAdjectives > 0) { if (preg_match('/患者|本例|本次|此次|当前|目前|现在|正在|已经|已|完成|' . self::OBSERVATION_INTRODUCER . '/u', $neutral)) { // The adjective cannot turn an embedded actual/completed // predicate back into missing data. Keep its positive scope. $assertionPieces = $pieces; $assertionPieces[0] = '存在' . $neutral . (isset($joined[1]) ? ',' . $joined[1] : ''); $assertionView = implode(',', $assertionPieces); } else { $nominalAction = preg_replace('/(?:使用|服用|应用|启用)(?:情况|记录|史)?$/u', '', $neutral) ?? $neutral; if ($nominalAction !== '' && self::neutralRecordSubject($nominalAction)) $neutral = $nominalAction; } } if (preg_match('/' . self::UNKNOWN . '/u', $neutral)) { // A closed postfix record-state claim stays a record-state claim // even when placed after a linked 是否 operator. $recordParts = self::parts($object); if ($questionSubject === '' && trim($request) === '是否' . $question[1] && count($recordParts) === 1 && $recordParts[0]['state'] === 'unknown' && self::neutralRecordSubject($recordParts[0]['topic'])) { $pieces[0] = $object . (isset($joined[1]) ? ',' . $joined[1] : ''); return implode(',', $pieces); } return $assertionView; } // Inside a whether-object, word-internal 不 can qualify a state // (e.g. intolerance), rather than deny a patient finding. Compound // absence/presence verbs still leave their own marker after this step. $neutral = preg_replace('/(?<=\p{Han})不(?=\p{Han})/u', '', $neutral) ?? $neutral; // 是否 governs the occurrence predicate after a proven neutral // subject. It does not turn an intransitive question into a finding. // Keep polarity/record/current markers in the unmodified view below. $nominal = $questionSubject !== '' ? preg_replace('/(?:发作|复发|发生|发病|加重|缓解|减轻|持续)(?:了)?' . '(?:[0-9一二三四五六七八九十半]+(?:分钟|小时|天|日|周|月|年))?$/u', '', $neutral) ?? $neutral : $neutral; if (!preg_match('/^[\p{L}\p{N}、与及或的]{1,120}$/uD', $object) || $neutral === '' || self::hasAssertionMarker($neutral) || !self::neutralRecordSubject($nominal) || preg_match('/患者|本例|本次|当前|目前|今晨|今日|昨晚|昨日|已|明确|' . self::OBSERVATION_INTRODUCER . '/u', $neutral)) return $assertionView; // Commas and assertion joiners end the question's own object. A // following observation never borrows its heading or question mark. $pieces[0] = $joined[1] ?? ''; foreach ($pieces as $index => $piece) { if ($index > 0 && preg_match('/^\s*(?:是否|有无)/u', $piece)) { // Each linked question owns its own bounded object. A plain // assertion or a non-neutral question remains checked. $pieces[$index] = self::terminatedQuestionProse($piece); } } return implode(',', $pieces); } /** A planned target with a postfix review obligation is not an active exposure. */ private static function prospectiveReviewProse(string $clause): string { $pieces = preg_split('/([,,])/u', $clause, -1, PREG_SPLIT_DELIM_CAPTURE) ?: []; foreach ($pieces as $index => &$piece) { if ($index % 2 === 1) continue; $joined = self::roleAssertionPieces(trim($piece)); if (!preg_match('/^(?:拟(?:选用|采用|使用|用|选)|待(?:选用|选|用)|候选|' . '(?:计划|准备)(?:选用|使用|采用))([\p{L}\p{N}、与及或的]{1,80}?)(?:仍|还|尚)?(?:需|须|应)' . '(?:进一步|逐项|逐味)?(?:核实|复核|核查|核对|审核|审查|确认)$/uD', $joined[0], $review) || !self::neutralRecordSubject($review[1]) || preg_match('/患者|本例|本次|此次|当前|目前|现在|正在|已经|已|完成|' . self::OBSERVATION_INTRODUCER . '/u', $review[1])) continue; $piece = $joined[1] ?? ''; } unset($piece); return implode('', $pieces); } /** A negated inference label does not negate the observations supplied as its clues. */ private static function inferenceHeadProse(string $clause): string { return preg_replace_callback('/(^|[,,])\s*([\p{L}]{1,40}?)(线索|依据|证据|支持点)(为|是|来自|包括)[::]?/u', static function (array $match): string { $head = preg_replace('/(?<=\p{Han})不(?=\p{Han})/u', '', $match[2]) ?? $match[2]; if ($head === $match[2] || !self::neutralRecordSubject($head) || preg_match('/患者|本例|本次|此次|当前|目前|现在|已|今晨|今日|昨晚|昨日|' . self::OBSERVATION_INTRODUCER . '/u', $head)) return $match[0]; foreach (['tongue' => '/舌|苔/u', 'pulse' => '/脉/u'] as $kind => $label) { if (preg_match($label, $head) && self::features($kind, $head) !== []) return $match[0]; } // Keep the subject visible, and retain every following clue // as its own assertion, including negatives and record gaps. return $match[1] . $head . $match[3] . $match[4] . ','; }, $clause) ?? $clause; } /** A closed abstract inference cannot lend its polarity to an adjoining patient claim. */ private static function negatedInferenceProse(string $clause): string { $category = '(?:表|里|寒|热|虚|实|阴|阳)(?:证候|证型|证)?'; $boundary = '(?=$|[,,]|并且|并|同时|还|又|另|且|但|而|患者|本例|' . self::OBSERVATION_INTRODUCER . ')'; $object = '(?:(?:纯|单纯)?' . $category . '(?:而)?无' . $category . '|绝对无' . $category . ')'; $clause = preg_replace('/(^|[,,])\s*(?:不能|不得|不应)仅据相关阴性(?:记录)?反推为' . $object . $boundary . '/u', '$1', $clause) ?? $clause; $clause = preg_replace('/(^|[,,])\s*该限制不等同于证明(?:纯|单纯)' . $category . '无夹杂' . $boundary . '/u', '$1', $clause) ?? $clause; // A closed 表里 classification is a diagnostic proposition, not a // new physical absence. Keep all symptom premises and adjoining claims. return preg_replace('/(^|[,,])\s*病位不在表[,,]\s*属里证' . $boundary . '/u', '$1', $clause) ?? $clause; } /** A closed availability predicate is about evidence, not a clinical finding. */ private static function reportTypedAvailabilityProse(string $clause): string { $pieces = preg_split('/([,,])/u', $clause, -1, PREG_SPLIT_DELIM_CAPTURE) ?: []; $aliases = []; foreach (self::FIELD_GROUPS as $definition) $aliases = array_merge($aliases, $definition['aliases']); foreach (self::MEDICATION_FIELD_REFERENCES as $definition) $aliases = array_merge($aliases, $definition['aliases']); foreach ($pieces as $index => &$piece) { if ($index % 2 === 1 || !preg_match('/^\s*(?:(?:本次|此次|当前|目前)\s*)*' . '(?:尚|仍)?(?:缺(?:少|乏)?|没有|未提供)(?:可(?:核验|比较|复核)|可供(?:核验|比较|复核))的' . '([\p{Han}、及与和]{1,100})\s*$/uD', $piece, $match)) continue; $subjects = preg_split('/[、及与和]/u', $match[1], -1, PREG_SPLIT_NO_EMPTY) ?: []; $normalized = []; foreach ($subjects as $subject) { $subject = preg_replace('/^(?:本次|此次|当前|目前)/u', '', $subject) ?? $subject; $typed = preg_match('/(?:记录|资料|描述|信息|数值)$/uD', $subject) === 1; if ($typed) $subject = preg_replace('/(?:记录|资料|描述|信息|数值)$/uD', '', $subject) ?? $subject; // Only named fields or explicitly typed data objects qualify. // An observed feature or an adjoining assertion cannot borrow UNKNOWN. if ($subject === '' || !self::neutralRecordSubject($subject) || (!$typed && !in_array($subject, $aliases, true) && $subject !== '症状病史')) { $normalized = []; break; } $normalized[] = $subject . '未记录'; } // Keep normal known-to-unknown checks; never erase an existing finding. if ($normalized !== []) $piece = implode(',', $normalized); } unset($piece); return implode('', $pieces); } /** Report-only abbreviated record predicates retain UNKNOWN, never clinical absence. */ private static function reportRecordPredicateProse(string $clause): string { // Keep the source ledger and source quotations unchanged. The lexical // recollection verbs 记得/记忆/记住/记清 are not record predicates. return preg_replace('/未记(?!录|得|忆|住|清)(?:载)?(?=[\p{Han}、,,::。;;!?!?\s]|$)/u', '未记录', $clause) ?? $clause; } /** Evidence availability is epistemic; retain its subject as UNKNOWN, never a finding. */ private static function reportEvidenceGapProse(string $clause): string { $modifier = '(?:完整|独立|充分|足够|明确|直接|可靠|相应|本次|现有|阳性|的)'; $pieces = preg_split('/([,,])/u', $clause, -1, PREG_SPLIT_DELIM_CAPTURE) ?: []; foreach ($pieces as $index => &$piece) { if ($index % 2 === 1) continue; if (!preg_match('/^\s*(?:([^,,::]{1,24})[::]\s*)?([^,,。;;::]{1,80}?)' . '(?:亦|也|尚|仍|均|并){0,2}(?:没有|未见|无)' . $modifier . '{0,8}(?:证据|依据)' . '(?=$|且|但|而|并|同时|还|又|另|患者|本例|' . self::OBSERVATION_INTRODUCER . ')(.*)$/uD', $piece, $evidence)) continue; $label = trim($evidence[1] ?? ''); $subject = trim($evidence[2]); if ($subject === '' || preg_match('/^(?:亦|也|尚|仍|均|并)+$/uD', $subject) || !self::neutralRecordSubject($subject) || !self::neutralRecordSubject($label . $subject)) continue; // The closed evidence noun cannot consume any clinical value or // adjoining assertion. Usual known-to-unknown checks still apply. $piece = ($label !== '' ? $label . ':' : '') . $subject . '未记录' . ($evidence[3] !== '' ? ',' . $evidence[3] : ''); } unset($piece); return implode('', $pieces); } /** A closed event trigger + mandatory assessment does not assert the event occurred. */ private static function prospectiveTriggerProse(string $clause, string $following): string { if (!preg_match('/^[,,]\s*(?:应|须|需)(?:及时|立即|先行|优先|尽快)?' . '(?:按(?:[\p{L}\p{N}]{1,24}路径|急症))?(?:医学)?(?:评估|就医|就诊|检查|处理)' . '(?=$|[,,。;;!?!?\r\n]|且|但|而|并|同时|还|又|另|患者|本例|' . self::OBSERVATION_INTRODUCER . ')/u', $following)) return $clause; if (!preg_match('/^\s*(如)?(?:([\p{L}\p{N}、及和或]{1,40}?)(?:加重|恶化|进展)(?:或者|或))?' . '(?:出现|发生)([\p{L}\p{N}、与及和或的]{1,100}?)(时|等情况)?\s*$/uD', $clause, $trigger) || (($trigger[1] ?? '') === '' && ($trigger[4] ?? '') === '')) return $clause; $subject = $trigger[2] ?? ''; $object = $trigger[3]; if (!self::neutralRecordSubject($subject) || !self::neutralRecordSubject($object) || preg_match('/患者|本例|本次|此次|当前|目前|现在|正在|已经|已|完成|今晨|今日|昨晚|昨日|' . '既往|历史|曾经|' . self::OBSERVATION_INTRODUCER . '|' . self::UNKNOWN . '|(?:19|20)\d{2}/u', $subject . $object)) return $clause; // Only the complete trigger is removed. The action and every following // assertion retain the strict local boundary in reportClauses(). return ''; } /** A causal criterion after explicit reassessment is not a present exclusion. */ private static function futureCausalCriterionProse(string $clause): string { if (!preg_match('/^\s*适用条件[::]\s*(?:(?:医师|医生)(?:在)?)?' . '(?:复核|复查|复诊(?:确认|复核|核实)|重新(?:确认|复核|核实))后' . '((?:仍)?以[^,,]{1,100}为(?:主|兼))(?=[,,])/u', $clause, $frame) || !self::neutralRecordSubject($frame[1])) return $clause; $actual = '/患者|本例|本次|此次|当前|目前|现在|正在|已经|已|完成|今晨|今日|昨晚|昨日|' . self::OBSERVATION_INTRODUCER . '|' . self::UNKNOWN . '|(?:19|20)\d{2}/u'; if (preg_match($actual, $frame[1])) return $clause; foreach (['tongue' => '/舌|苔/u', 'pulse' => '/脉/u'] as $kind => $label) { if (preg_match($label, $frame[1]) && self::features($kind, $frame[1]) !== []) return $clause; } $pieces = preg_split('/([,,])/u', $clause, -1, PREG_SPLIT_DELIM_CAPTURE) ?: []; // The temporal operator reaches only its immediate linked criterion; // it cannot cross an intervening observation or record-gap clause. if (!preg_match('/^\s*(?:且|并且|并)?([^,,]{1,80}?)并非([^,,]{1,120}?)' . '(?:所致|所引起|造成)(?=$|且|但|而|并|同时|还|又|另|患者|本例|' . self::OBSERVATION_INTRODUCER . '|当前|目前|已经|已)(.*)$/uD', $pieces[2] ?? '', $cause) || !self::neutralRecordSubject($cause[1]) || !self::neutralRecordSubject($cause[2]) || preg_match($actual, $cause[1] . $cause[2])) return $clause; foreach (['tongue' => '/舌|苔/u', 'pulse' => '/脉/u'] as $kind => $label) { if (preg_match($label, $cause[1] . $cause[2]) && self::features($kind, $cause[1] . $cause[2]) !== []) return $clause; } // Remove only this closed future relation. Every later criterion and // every assertion after its causal complement stays visible. $pieces[2] = preg_replace('/^\s*(?:并且|并|同时|还|又|另|且|但|而)\s*/u', '', $cause[3]) ?? $cause[3]; // The closed nominal reassessment target must not become the subject // of an adjacent assertion left after removing its future relation. $pieces[0] = ''; return implode('', $pieces); } /** A reassessment-dependent applicability criterion is not a current exclusion. */ private static function futureApplicabilityCriterionProse(string $clause): string { if (!preg_match('/^\s*适用条件(?:为|[::])\s*(?:复核|复诊|核实|再次评估|重新评估|后续评估)后' . '((?:仍)?以[^,,]{1,100}为(?:核心|主|主要矛盾))(?=[,,])/u', $clause, $frame)) return $clause; $pieces = preg_split('/([,,])/u', $clause, -1, PREG_SPLIT_DELIM_CAPTURE) ?: []; if (!preg_match('/^\s*且(.+)$/uD', $pieces[2] ?? '', $criterionHead)) return $clause; $criterion = $criterionHead[1]; $joined = self::roleAssertionPieces($criterion); if (!preg_match('/^不存在([\p{L}\p{N}、与及和或的]{1,100})$/uD', $joined[0], $absence)) return $clause; $actual = '/患者|本例|本次|此次|当前|目前|现在|正在|已经|已|完成|今晨|今日|昨晚|昨日|' . self::OBSERVATION_INTRODUCER . '|' . self::UNKNOWN . '|(?:19|20)\d{2}/u'; if (!self::neutralRecordSubject($frame[1]) || !self::neutralRecordSubject($absence[1]) || preg_match($actual, $frame[1] . $absence[1])) return $clause; // Only the reassessment target and its immediate neutral criterion // belong to this future frame. Retain every adjoining actual claim. $pieces[0] = ''; $pieces[2] = $joined[1] ?? ''; return implode('', $pieces); } /** A symptom recheck frame governs only its bounded acute-cause prerequisite. */ private static function futureSymptomRecheckProse(string $clause): string { if (!preg_match('/^\s*(适用条件[::]\s*复核症状仍以([\p{L}\p{N}、及和或的]{1,100})为主[,,]' . '(?:([\p{L}\p{N}、及和或的]{1,100})持续存在[,,])?)\s*且无需要优先处理的急性病因' . '(?=$|[,,]|并且|并|同时|还|又|另|且|但|而|患者|本例|' . self::OBSERVATION_INTRODUCER . ')(.*)$/uD', $clause, $frame)) return $clause; $targets = $frame[2] . ($frame[3] ?? ''); if (!self::neutralRecordSubject($frame[2]) || (($frame[3] ?? '') !== '' && !self::neutralRecordSubject($frame[3])) || preg_match('/本次|此次|当前|目前|现在|正在|已经|已|完成|今晨|今日|昨晚|昨日|' . self::OBSERVATION_INTRODUCER . '|' . self::UNKNOWN . '|(?:19|20)\d{2}/u', $targets)) return $clause; // Retain the symptom/exam targets for ordinary source-state checks. // This frame grants no exemption to a present/completed acute exclusion. return $frame[1] . $frame[4]; } /** A closed future reassessment + only-then decision asserts no current finding. */ private static function futurePreconditionProse(string $clause): string { if (!preg_match('/^\s*适用条件[::]\s*(?:(?:医师|医生)(?:在)?)?复诊(?:时)?(?:确认|复核|核实)' . '([\p{L}\p{N}、,,\s]{1,240}?)时[,,]\s*(?:方|才)可(?:考虑|评估|讨论|采用|选用|使用|启用)(.+)$/uD', $clause, $condition)) return $clause; // 当前仍以… names the future reassessment target in this frame. // Other present/completed observations cannot borrow that operator. $body = preg_replace('/^当前(?=(?:仍)?以)/u', '', $condition[1]) ?? $condition[1]; $unmasked = $condition[1] . ',' . $condition[2]; if (preg_match('/患者|本例|本次|此次|目前|当前|现在|已经|已|今晨|今日|昨晚|昨日|' . self::OBSERVATION_INTRODUCER . '|' . self::UNKNOWN . '/u', $body)) return $unmasked; $pieces = preg_split('/[,,]/u', $condition[2]) ?: []; $joined = self::roleAssertionPieces($pieces[0] ?? ''); $object = trim($joined[0]); if (!preg_match('/^[\p{L}\p{N}、与及和或的]{1,60}$/uD', $object) || !self::neutralRecordSubject($object) || preg_match('/患者|本例|本次|此次|目前|当前|现在|已|今晨|今日|昨晚|昨日|' . self::OBSERVATION_INTRODUCER . '/u', $object)) return $unmasked; foreach (['tongue' => '/舌|苔/u', 'pulse' => '/脉/u'] as $kind => $label) { if (preg_match($label, $object) && self::features($kind, $object) !== []) return $unmasked; } // Keep every assertion after the only-then decision's neutral object. $pieces[0] = $joined[1] ?? ''; return implode(',', $pieces); } /** A neutral record locator names a source, without asserting its subject's state. */ private static function sourceReferenceProse(array $ledger, string $clause, bool &$invalid): string { // A malformed source-shaped token in this finite locator role must // not become ordinary unverified prose merely by missing the grammar. if (preg_match_all('/(?:^|[,,])\s*腰肾(?:相关)?资料(?:参见|见)([^,,。;;::()()\r\n]*)/u', $clause, $locators)) { foreach ($locators[1] as $reference) { if (preg_match('/^[sS]/D', $reference) && !preg_match('/^s[1-9][0-9]*(?=\s*(?:$|且|但|而|并|同时|还|又|另|患者|本例|' . self::OBSERVATION_INTRODUCER . '))/u', $reference)) $invalid = true; } } return preg_replace_callback('/(^|[,,])\s*([^,,。;;::()()]{1,80}?)(?:的)?(?:相关)?' . '(?:(?:资料|记录|信息)(?:的)?状态|情况|资料|记录|结果)' . '(?:参见|见)(s[0-9]+)(?=\s*(?:$|[,,]|且|但|而|并|同时|还|又|另|患者|本例|' . self::OBSERVATION_INTRODUCER . '))/u', static function (array $match) use ($ledger, $clause, &$invalid): string { $offset = $match[0][1]; $match = array_map(static fn (array $capture): string => $capture[0], $match); $subject = trim($match[2]); if (!self::neutralRecordSubject($subject)) return $match[0]; $source = $ledger['sources'][$match[3]] ?? null; if ($subject === '相关') { if (!self::verifiedMedicationAntecedent($clause, $offset, $source)) { $invalid = true; return $match[0]; } return $match[1]; } // This anatomical-family locator is not a kidney diagnosis or // absence. Bind it to the actual current lumbar/tinnitus item; // neither a generic kidney label nor an unrelated source fits. if (preg_match('/^腰肾(?:相关)?$/uD', $subject)) { $supported = $source !== null && ($source['scope'] ?? '') === 'current' && ($source['field'] ?? '') === 'present_illness' && self::sameTime($source, self::scope($clause, 'current'), $clause); $found = false; foreach ($ledger['assertions'] as $assertion) { if ($assertion['source_id'] === $match[3] && $assertion['topic'] === '腰酸耳鸣') $found = true; } if (!$supported || !$found) { $invalid = true; return $match[0]; } return $match[1]; } $topics = self::topics($subject, true); $supported = $source !== null && $topics !== [] && self::sameTime($source, self::scope($clause, 'current'), $clause); foreach ($topics as $topic) { $found = false; foreach ($ledger['assertions'] as $assertion) { if ($assertion['source_id'] === $match[3] && self::related($topic, $assertion['topic'])) $found = true; } foreach (self::FIELD_GROUPS as $definition) { if (in_array($source['field'] ?? '', $definition['fields'], true) && in_array($topic, $definition['aliases'], true)) $found = true; } foreach (self::MEDICATION_FIELD_REFERENCES as $definition) { if (in_array($source['field'] ?? '', $definition['fields'], true) && in_array($topic, $definition['aliases'], true)) $found = true; } if (!$found) $supported = false; } if (!$supported) { $invalid = true; return $match[0]; } return $match[1]; }, $clause, -1, $replacements, PREG_OFFSET_CAPTURE) ?? $clause; } /** This deictic locator needs its own immediately preceding exact medication record. */ private static function verifiedMedicationAntecedent(string $clause, int $offset, ?array $source): bool { if ($source === null || $source['field'] !== 'current_medications' || $source['scope'] !== 'current' || !self::sameTime($source, self::scope($clause, 'current'), $clause) || !preg_match('/^\s*现用([^。;;\r\n]{1,120})$/uD', substr($clause, 0, $offset), $antecedent)) return false; // Comma/list typography can vary; medication, quantity, frequency and // qualifiers must still match the complete first source assertion. $canonical = static fn (string $text): string => preg_replace('/\s+/u', '', str_replace([',', '、'], ',', $text)) ?? ''; $sourceHead = (preg_split('/[。;;\r\n]/u', $source['quote'], 2) ?: [''])[0]; return $canonical(trim($antecedent[1])) !== '' && $canonical(trim($antecedent[1])) === $canonical(trim($sourceHead)); } /** An anchored review request can contain questions and separate actual assertions. */ private static function reviewRequestProse(string $clause): string { if (!preg_match('/^\s*适用前须核实[::](.*)$/uD', $clause, $request)) return $clause; $pieces = preg_split('/[,,]/u', $request[1]) ?: []; $isQuestion = static function (string $piece): bool { $piece = preg_replace('/^(?:复核|核实|询问|补问)/u', '', trim($piece)) ?? $piece; if (!preg_match('/^[\p{L}\p{N}、与及或]{1,120}$/uD', $piece) || preg_match_all('/有无|是否/u', $piece) !== 1) return false; $payload = str_replace(['有无', '是否'], '', $piece); // Explicit observations or a new assertion joined inside the same // fragment cannot borrow the question's role. Fail closed instead. return !preg_match('/(?:' . self::ABSENT . '|' . self::PRESENT . '|且|并|但|而|患者|本例|本次|当前|目前|已|明确)/u', $payload); }; if ($pieces === [] || !$isQuestion($pieces[0])) return $clause; foreach ($pieces as &$piece) if ($isQuestion($piece)) $piece = ''; unset($piece); return implode(',', $pieces); } /** Retain an adjoining assertion outside a closed conceptual role. */ private static function roleAssertionPieces(string $text): array { $examObservation = '(?:舌(?:象|质|体|色|面)?|脉(?:象|诊)?)(?:呈|见|为|显示|表现为)'; return preg_split('/并且|(? $piece) { if ($index % 2 === 1) continue; $candidate = trim($piece); if (preg_match('/^适用前(?:需|须|应)(?:再次|进一步)?(?:确认|核实)([\p{Han}、与及和或]{1,100})$/uD', $candidate, $head) && !self::hasAssertionMarker($head[1]) && !preg_match('/患者|本例|本次|当前|目前|已|并|同时|还|又|另|' . self::OBSERVATION_INTRODUCER . '/u', $head[1])) { $modalContext = true; continue; } if (!$modalContext) continue; $candidate = preg_replace('/^(?:并|再|亦|也|且)(?=复核|核实|核查)/u', '', $candidate) ?? $candidate; $joined = self::roleAssertionPieces($candidate); if (!preg_match('/^(?:复核|核实|核查)([\p{Han}、与及和或]{1,100})$/uD', $joined[0], $question) || preg_match_all('/有无|是否/u', $question[1]) !== 1) { $modalContext = false; continue; } $questionParts = preg_split('/有无|是否/u', $question[1], 2) ?: []; $payload = implode('', $questionParts); if (trim($questionParts[1] ?? '') === '' || self::hasAssertionMarker($payload) || preg_match('/患者|本例|本次|当前|目前|今晨|今日|昨晚|昨日|已|明确|并|同时|还|又|另|且|但|而|' . self::OBSERVATION_INTRODUCER . '/u', $payload)) { $modalContext = false; continue; } $pieces[$index] = $joined[1] ?? ''; if (count($joined) > 1) $modalContext = false; } return implode('', $pieces); } /** A requested patient review owns its bounded whether-object, not its tail. */ private static function patientReviewQuestionProse(string $clause): string { if (!preg_match('/^\s*(?:(?:仍|还)?(?:需|须|应|请)(?:进一步|再次|逐项)?)?' . '(?:补问|询问|核实|复核|核对|追问|确认|查明)[::\s]*(?:患者|本例)[::\s]*' . '((?:是否|有无).+)$/uD', $clause, $request)) return $clause; // Reuse the existing neutral-object grammar and assertion boundaries. // A refused object keeps its original action/current/record markers; // only a proved question may remove this collection instruction. $view = self::terminatedQuestionProse($request[1]); return $view !== $request[1] ? $view : $clause; } /** A modal + collection action governs only its own neutral object list. */ private static function collectionRequestProse(string $clause): string { $pieces = preg_split('/[,,]/u', $clause) ?: []; foreach ($pieces as &$piece) { // The action's neutral list ends before an assertion conjunction. // Removing only that prefix also prevents “既往” in its object list // from changing the time scope of a later current assertion. $joined = preg_split('/且|但|而|(?=患者|本例|本次|当前|目前)/u', $piece, 2) ?: [$piece]; if (!preg_match('/^\s*([^,,::]{0,24}?)(?:仍|还)?(?:需|须|应)(?:进一步|逐项|逐味)?' . '(?:补问|询问|补采|采集|核实|复核|核对|追问|确认|查明)([\p{L}\p{N}、与及或的]{1,120})$/uD', $joined[0], $request)) continue; // A new observation, conjunction or patient subject cannot borrow // a review action elsewhere in this comma-delimited fragment. if (self::hasAssertionMarker($request[1]) || self::hasAssertionMarker($request[2]) || preg_match('/患者|本例|且|但|而|并非|已经|已见|已知/u', $request[1] . $request[2])) continue; $piece = $joined[1] ?? ''; } unset($piece); return implode(',', $pieces); } /** A closed medication-review list is an instruction, not patient absence. */ private static function pendingMedicationReviewProse(string $clause): string { $medicine = '(?:新方|新药|新用药|药物|方剂|处方|各味药|[一二三四五六七八九十0-9]{1,3}味药)(?:各成分)?'; $object = '(?:(?:' . $medicine . ')?(?:既往)?耐受|保健品|(?:其他未记录|其他|合并)?药物|合并用药' . '|(?:肝肾|肝|肾)功能|过敏反应|(?:制剂)?含糖辅料|药物相互作用)'; $pending = '(?:(?:仍|尚|还)?待(?:核实|复核|核)|(?:仍|还)?需(?:核查|核实|复核))'; // Only the complete typed object list and its review predicate are // removed. Keep any following clinical, quantity or time assertion. return preg_replace('/(^|[,,])\s*(?:但|亦|也)?' . $object . '(?:[、及和或]+' . $object . ')*' . $pending . '(?=$|[,,]|且|但|而|患者|本例|自述|主诉|本次|当前|目前|今晨|今日|昨晚|昨日|每(?:次|日|餐)|(?:19|20)\d{2})/u', '$1', $clause) ?? $clause; } /** An explicit formula-function heading scopes only closed method/aim fragments. */ private static function formulaFunctionProse(string $clause): string { $method = '(?:补气|益气|养津|生津|滋阴|养阴|固表|收敛|温阳|清热|化痰|活血|养血|健脾)'; $examObservation = '(?:舌(?:象|质|体|色|面)?|脉(?:象|诊)?)(?:呈|见|为|显示|表现为)'; $pieces = preg_split('/([,,])/u', $clause, -1, PREG_SPLIT_DELIM_CAPTURE) ?: [$clause]; $functionScope = false; foreach ($pieces as $index => $piece) { if ($index % 2 === 1) continue; $candidate = trim($piece); if (preg_match('/^方义(?:上|层面)?[::\s]*(.*)$/uD', $candidate, $heading)) { $functionScope = true; $candidate = $heading[1]; } elseif ($functionScope) { $candidate = preg_replace('/^(?:并|亦|也)(?=以)/u', '', $candidate) ?? $candidate; } if (!$functionScope) continue; $joined = preg_split('/并且|并|同时|还|又|另|且|但|而|(?=患者|本例|' . self::OBSERVATION_INTRODUCER . '|本次|当前|目前|今晨|今日|昨晚|昨日|' . $examObservation . ')/u', $candidate, 2) ?: [$candidate]; if (!preg_match('/^以' . $method . '(?:配合)?(?:恢复|改善|缓解|控制|促进)([\p{Han}、及和与或]{1,60})$/uD', $joined[0], $aim) || !self::neutralRecordSubject($aim[1]) || preg_match('/并|同时|还|又|另|本次|当前|目前|今晨|今日|昨晚|昨日|每(?:次|日|天|晚|餐)|' . self::OBSERVATION_INTRODUCER . '|已(?:见|发生|确认|记录)|' . $examObservation . '/u', $aim[1])) { // A new assertion ends this local scope; no later exam or // unscoped purpose fragment can borrow the heading's role. $functionScope = false; continue; } $pieces[$index] = $joined[1] ?? ''; if (count($joined) > 1) $functionScope = false; } return implode('', $pieces); } /** A modal prohibition scopes only a neutral exclusion predicate and object. */ private static function exclusionLimitProse(string $clause): string { $pieces = preg_split('/[,,]/u', $clause) ?: []; foreach ($pieces as &$piece) { // Keep every adjoining actual assertion for the usual state and // examination checks, including a tail without a comma. $candidate = preg_replace('/^\s*(?:但|亦|也|仍)\s*/u', '', $piece) ?? $piece; $joined = self::roleAssertionPieces($candidate); if (!preg_match('/^\s*(?:不能|不得|不应)(?:(?:仅|单|只)?(?:凭|据|依据|依赖)' . '(?:否认|证候|(?:现有|上述|本次|当前)?(?:阴性)?(?:记录|资料|证据|结果)))?' . '(?:彻底|完全|直接)?排除([\p{L}\p{N}、与及或的]{1,80})\s*$/uD', $joined[0], $limit) && !preg_match('/^\s*(?:(?:不能|不得|不应)(?:据此)?(?:宣称|断言|认为)' . '|不(?:据此)?(?:宣称|代表|说明|意味))([\p{L}\p{N}、与及或的]{1,80}?)' . '(?:已经|已)?被(?:彻底|完全)?排除\s*$/uD', $joined[0], $limit)) continue; // “所有” is a quantifier here, not the positive marker “有”. The // remaining target must contain no embedded patient assertion. $target = preg_replace('/^(?:所有|全部|任何|一切)(?:的)?/u', '', $limit[1]) ?? $limit[1]; if ($target === '' || self::hasAssertionMarker($target) || preg_match('/患者|本例|本次|当前|目前|今晨|今日|昨晚|昨日|且|并|但|而|同时|还|又|另|' . self::OBSERVATION_INTRODUCER . '|已(?:确认|记录|发生|见|知)/u', $target)) continue; // This discourse role does not reinterpret examination phenotypes. // Keep every named tongue/pulse feature under the existing checks. if ((preg_match('/舌|苔/u', $target) && self::features('tongue', $target) !== []) || (preg_match('/脉/u', $target) && self::features('pulse', $target) !== [])) continue; $piece = $joined[1] ?? ''; } unset($piece); return implode(',', $pieces); } /** Neutral history-denial locators require a current negative in their cited field. */ private static function historyDenialReferenceProse(array $ledger, string $clause, bool &$invalid): string { $labels = ['既往疾病' => ['past_history']]; foreach (self::FIELD_GROUPS as $group => $definition) { if (!str_ends_with($group, '_history')) continue; foreach ($definition['aliases'] as $alias) $labels[$alias] = $definition['fields']; } $aliases = implode('|', array_map(static fn (string $label): string => preg_quote($label, '/'), array_keys($labels))); return preg_replace_callback('/(^|[,,])\s*(' . $aliases . ')否认见第(?:[1-9]\d{0,2}|[①-⑳])项\s*' . '(?:(([^()\r\n]{1,150}))|\(([^()\r\n]{1,150})\))?(?=$|[,,]|且|但|而|患者|本例)/u', static function (array $match) use ($ledger, $labels, &$invalid): string { $body = ($match[3] ?? '') !== '' ? $match[3] : ($match[4] ?? ''); $metadata = self::sourceAnnotationTokens($ledger, $body); $supported = $metadata !== null && $metadata['valid'] && $metadata['ids'] !== []; foreach ($metadata['ids'] ?? [] as $id) { $source = $ledger['sources'][$id] ?? []; $denied = false; if (str_starts_with($source['path'] ?? '', 'current.') && in_array($source['field'] ?? '', $labels[$match[2]], true) && self::sameTime($source, 'current', $body)) { foreach ($ledger['assertions'] as $assertion) { if ($assertion['source_id'] === $id && $assertion['state'] === 'negative' && $assertion['scope'] === 'current' && !self::evidenceOnly($assertion['topic']) && !self::conditional($assertion['quote']) && !preg_match('/^' . self::ATTACHMENT_TOPIC . '$/uD', $assertion['topic'])) { $denied = true; break; } } } if (!$denied) $supported = false; } if (!$supported) { $invalid = true; return $match[0]; } return $match[1]; }, $clause) ?? $clause; } /** Only a complete metadata annotation naming an existing source is removed. */ private static function withoutSourceAnnotations(array $ledger, string $text): string { // Keep bare metadata until clauses are built. Normalize nested [sN] // before the existing citation cleanup, without losing IDs or dates. $citationDate = ''; $text = preg_replace_callback('/(?:\r?\n){2,}|(([^()\r\n]{1,150}))|\(([^()\r\n]{1,150})\)/u', static function (array $match) use ($ledger, &$citationDate): string { // A relative date binds only inside the same paragraph to the // preceding valid calendar annotation, never to a guessed day. if (preg_match('/^(?:\r?\n){2,}$/D', $match[0])) { $citationDate = ''; return $match[0]; } $body = ($match[1] ?? '') !== '' ? $match[1] : ($match[2] ?? ''); $metadata = self::sourceAnnotationTokens($ledger, $body, $citationDate); if ($metadata !== null && $metadata['valid'] && count($metadata['dates']) === 1) { $citationDate = $metadata['dates'][0]; } // Mixed clinical parentheses must survive generic citation // cleanup even if the patient assertion is inside [brackets]. return $metadata === null ? '(' . str_replace(['[', ']'], '', $body) . ')' : '(' . implode('、', array_merge($metadata['dates'], $metadata['ids'])) . ')'; }, $text) ?? $text; $labels = []; $groups = [ 'chief_complaint' => ['主诉', ['chief_complaint', 'complaint']], 'present_illness' => ['现病史', ['present_illness', 'present_illness_history', 'symptoms']], 'past_history' => ['既往史', ['past_history']], 'diagnosis_info' => ['诊断信息', ['tongue', 'tongue_coating', 'pulse', 'pulse_condition', 'clinical_diagnosis']], 'daily_tracking' => ['日常记录', []], 'chat' => ['聊天记录', []], 'prescriptions' => ['处方记录', []], ]; foreach ($ledger['sources'] as $source) { foreach (['id', 'path', 'field'] as $key) $labels[$source[$key]] = true; foreach (self::FIELD_GROUPS as $definition) { if (in_array($source['field'], $definition['fields'], true)) { foreach ($definition['aliases'] as $alias) $labels[$alias] = true; } } foreach ($groups as $key => [$label, $fields]) { if (str_starts_with($source['path'], $key . '.') || (str_starts_with($source['path'], 'current.') && in_array($source['field'], $fields, true))) { $labels[$key] = $labels[$label] = true; } } } return preg_replace_callback('/(来源[::]\s*([^()\r\n]{1,100}))|\(来源[::]\s*([^()\r\n]{1,100})\)/u', static function (array $match) use ($labels): string { $label = trim(($match[1] ?? '') !== '' ? $match[1] : ($match[2] ?? '')); return isset($labels[$label]) ? '' : $match[0]; }, $text) ?? $text; } /** Null means clinical/other text, not a metadata-only list. */ private static function sourceAnnotationTokens(array $ledger, string $body, string $sameDayDate = ''): ?array { $body = preg_replace('/^\s*来源[::]\s*/u', '', $body) ?? $body; $ids = []; $dates = []; $valid = true; foreach (preg_split('/[,,、]/u', $body) ?: [] as $token) { $token = trim($token); if (preg_match('/^(?:s[0-9]+|\[s[0-9]+\])$/D', $token)) { $id = trim($token, '[]'); $ids[] = $id; if (!preg_match('/^s[1-9][0-9]*$/D', $id) || !isset($ledger['sources'][$id])) $valid = false; } elseif ($token === '同日') { $dates[] = $sameDayDate !== '' ? $sameDayDate : '同日'; if ($sameDayDate === '') $valid = false; } elseif (preg_match('/^(?:19|20)\d{2}(?:-\d{1,2}-\d{1,2}|年\d{1,2}月\d{1,2}日?)$/uD', $token)) { $calendar = self::calendarDates($token); if ($calendar === []) { $valid = false; $dates[] = $token; } else $dates = array_merge($dates, $calendar); } else return null; } $ids = array_values(array_unique($ids)); $dates = array_values(array_unique($dates)); if (count($dates) > 1) $valid = false; foreach ($ids as $id) foreach ($dates as $date) { if (($ledger['sources'][$id]['date'] ?? '') !== $date) $valid = false; } return ['ids' => $ids, 'dates' => $dates, 'valid' => $valid]; } /** Bind a redundant copular subject to the same named unknown payload. */ private static function normalizeUnknownCopula(string $clause): string { // A subject's record state can explicitly contrast with a denial. // Normalize only this complete role; adjoining assertions stay intact. $clause = preg_replace_callback('/(^|[,,])\s*([^,,。;::]{1,80}?)(?:属于|属|为)(' . self::UNKNOWN . ')' . '(?:而非|不是)(?:明确)?(?:否认|阴性)(?:记录)?(?=$|[,,])/u', static function (array $contrast): string { if (!self::neutralRecordSubject($contrast[2])) return $contrast[0]; return $contrast[1] . $contrast[2] . $contrast[3]; }, $clause) ?? $clause; if (!preg_match('/^(.+?)(?:属于|属|为)(' . self::UNKNOWN . ')([^,,]+)(.*)$/uD', $clause, $match) || !self::neutralRecordSubject($match[1])) return $clause; $subject = array_values(array_unique(self::topics($match[1]))); $payload = array_values(array_unique(self::topics($match[3]))); sort($subject); sort($payload); if ($subject === [] || $subject !== $payload) return $clause; return $match[3] . $match[2] . $match[4]; } /** Only the immediately preceding exact, same-time hearing negative can bind. */ private static function verifiedHearingNegation(array $ledger, array $entry, array $parts, string $timeClause): array { $ids = []; foreach ($parts as $part) { if ($part['state'] !== 'negative') continue; $canonical = self::canonicalQuote($part['quote']); foreach ($entry['quotes'] as $quote) { if (self::canonicalQuote($quote['text']) !== $canonical) continue; foreach ($ledger['assertions'] as $source) { if ($source['state'] !== 'negative' || !in_array($source['source_id'], $quote['source_ids'], true) || self::canonicalQuote($source['quote']) !== $canonical || !self::sameTime($source, 'current', $timeClause)) continue; if (str_contains($ledger['sources'][$source['source_id']]['quote'], '闻诊')) $ids[$source['source_id']] = true; } } } return array_keys($ids); } /** A stated data gap must not overwrite a known, same-time source finding. */ private static function knownToUnknownIssues(array $ledger, string $text, string $scope, string $clause): array { // General “other sounds/smells” does not erase the specific findings // already described. A named symptom elsewhere in the list is checked. if (preg_match('/^其他(?:声音|气味)(?:[、及和或]+(?:声音|气味))*$/uD', trim($text))) return []; $issues = []; foreach (self::topics($text, true) as $topic) { $topic = preg_replace('/等(?:其他)?(?:常见)?(?:相关)?(?:表现|症状|体征)$/u', '', $topic) ?? $topic; $medicationSourceIds = null; foreach (self::MEDICATION_FIELD_REFERENCES as $kind => $definition) { if (!in_array($topic, $definition['aliases'], true)) continue; $medicationSourceIds = array_column(self::medicationFieldAssertions($ledger, $kind, $scope, $clause), 'source_id'); } foreach ($ledger['assertions'] as $source) { if ($source['state'] === 'unknown' || !self::sameTime($source, $scope, $clause) || ($medicationSourceIds !== null && !in_array($source['source_id'], $medicationSourceIds, true)) || self::medicationHeadingOnly($source) || self::evidenceOnly($source['topic']) || !self::contained($topic, $source['topic'])) continue; $issues[] = ['code' => $source['state'] === 'negative' ? 'NEGATIVE_TO_UNKNOWN' : 'POSITIVE_TO_UNKNOWN', 'source_id' => $source['source_id']]; } foreach (self::FIELD_GROUPS as $group => $definition) { $state = $ledger['field_states'][$group] ?? []; if (($state['state'] ?? '') !== 'recorded' || !in_array($topic, $definition['aliases'], true)) continue; $source = $ledger['sources'][$state['source_id']] ?? []; if ($source !== [] && self::sameTime($source, $scope, $clause)) { $issues[] = ['code' => 'RECORDED_TO_UNKNOWN', 'source_id' => $state['source_id']]; } } foreach (self::MEDICATION_FIELD_REFERENCES as $kind => $definition) { if (!in_array($topic, $definition['aliases'], true)) continue; foreach (self::medicationFieldAssertions($ledger, $kind, $scope, $clause) as $source) { $issues[] = ['code' => 'RECORDED_TO_UNKNOWN', 'source_id' => $source['source_id']]; $issues[] = ['code' => $source['state'] === 'negative' ? 'NEGATIVE_TO_UNKNOWN' : 'POSITIVE_TO_UNKNOWN', 'source_id' => $source['source_id']]; } } } return $issues; } /** Whole-field references use explicit schema provenance, not drug names. */ private static function medicationFieldAssertions(array $ledger, string $kind, string $scope, string $clause): array { $definition = self::MEDICATION_FIELD_REFERENCES[$kind]; $allHistory = $kind === 'history' && $scope !== 'historical' && !preg_match('/\b(?:19|20)\d{2}\b/u', $clause); $currentReference = $kind === 'current' && $scope === 'current' && preg_match('/现用|在用|当前|目前|本次|此次|现在/u', $clause); $result = []; foreach ($ledger['assertions'] as $assertion) { $source = $ledger['sources'][$assertion['source_id']] ?? []; if (!in_array($source['field'] ?? '', $definition['fields'], true) || $assertion['state'] === 'unknown' || self::medicationHeadingOnly($assertion) || self::evidenceOnly($assertion['topic']) || self::conditional($assertion['quote']) || ($currentReference ? $assertion['scope'] !== 'current' : (!$allHistory && !self::sameTime($assertion, $scope, $clause)))) continue; $result[] = $assertion; } // Other submitted fields can explicitly carry the same field record. // A label alone, an unknown value or an unrelated later clause cannot. foreach ($ledger['sources'] as $source) { if (in_array($source['field'] ?? '', $definition['fields'], true)) continue; foreach (self::labelledMedicationAssertions($source, $kind, $ledger) as $assertion) { if ($currentReference ? $assertion['scope'] === 'historical' : (!$allHistory && !self::sameTime($assertion, $scope, $clause))) continue; $result[] = $assertion; } } return $result; } /** Read only an explicit field label and its own first bounded actual value. */ private static function labelledMedicationAssertions(array $source, string $kind, array $ledger): array { $labels = self::MEDICATION_FIELD_REFERENCES[$kind]['aliases']; if ($kind === 'history') $labels = array_merge($labels, self::MEDICATION_FIELD_REFERENCES['current']['aliases']); $aliases = implode('|', array_map(static fn (string $alias): string => preg_quote($alias, '/'), $labels)); $currentDate = ''; foreach ($ledger['field_states'] as $state) if (($state['date'] ?? '') !== '') { $currentDate = $state['date']; break; } $result = []; foreach (self::clauses($source['quote']) as $clause) { if (!preg_match('/^\s*(' . $aliases . ')[::]\s*(.+)$/uD', $clause, $label)) continue; $value = trim((preg_split('/[,,]/u', $label[2], 2) ?: [''])[0]); $value = preg_replace('/^(?:患者|本例)(?=(?:正在|目前|当前|已)?(?:未使用|未服用|使用|服用))/u', '', $value) ?? $value; $value = trim(self::roleAssertionPieces($value)[0]); if ($value === '' || preg_match('/' . self::UNKNOWN . '|是否|有无|拟(?:用|选|采用|使用)|计划|准备/u', $value) || preg_match('/^(?:(?:医师|医生|药师)(?:已)?)?(?:需|须|应|请|建议|考虑|待|必要时|开方前|用药前|可(?:考虑|评估|使用|服用|选用))/u', $value) || preg_match('/^(?:暂无|无资料|暂无资料|无记录|暂无记录|未填|待补|none|null|unknown|0|[-—–]+)$/iuD', $value) || self::evidenceOnly($value)) continue; if ($kind === 'current') { if (($source['scope'] ?? '') === 'historical' || self::scope($value, 'current') === 'historical' || ($currentDate !== '' && ($source['date'] ?? '') !== $currentDate)) continue; foreach (self::calendarDates($value) as $date) if ($date !== ($source['date'] ?? '')) continue 2; } $usageValue = preg_match('/^(?:(?:正在|目前|当前|已经|已))?(?:未使用|未服用|使用|服用|应用|启用)/u', $value) === 1; foreach (self::parts($value) as $part) { if ($part['state'] === 'unknown' || (!$usageValue && self::evidenceOnly($part['topic']))) continue; $topic = $part['topic']; if ($topic === '' && $part['state'] === 'negative') $topic = $label[1]; if ($topic === '') continue; $result[] = ['source_id' => $source['id'], 'state' => $part['state'], 'topic' => $topic, 'quote' => $source['quote'], 'scope' => $source['scope'], 'date' => $source['date']]; } } return $result; } /** The label before a value marker is metadata, not a separate positive. */ private static function medicationHeadingOnly(array $assertion): bool { $labels = array_merge(self::MEDICATION_FIELD_REFERENCES['current']['aliases'], self::MEDICATION_FIELD_REFERENCES['history']['aliases']); $aliases = implode('|', array_map(static fn (string $alias): string => preg_quote($alias, '/'), $labels)); return preg_match('/^\s*(?:' . $aliases . ')[::]\s*$/uD', $assertion['quote']) === 1; } /** A whole medication field cannot become missing/absent through an alias. */ private static function medicationFieldIssues(array $ledger, string $clause, string $timeClause, array &$pending): array { $carried = $pending; $pending = []; $issues = []; foreach (preg_split('/[,,]|并且|(?*-"); foreach (self::MEDICATION_FIELD_REFERENCES as $kind => $definition) { $aliases = implode('|', array_map(static fn (string $alias): string => preg_quote($alias, '/'), $definition['aliases'])); if (preg_match('/^(?:' . $aliases . ')[::]?$/uD', $piece)) { $pending[] = $kind; continue; } $value = null; if (preg_match('/^(?:' . $aliases . ')[::]\s*(.+)$/uD', $piece, $label)) $value = $label[1]; elseif (preg_match('/^(?:' . $aliases . ')(?:为|属)?(' . self::UNKNOWN . '|无|没有|否认|不存在)$/uD', $piece, $label)) $value = $label[1]; elseif (preg_match('/^(' . self::UNKNOWN . ')(?:' . $aliases . ')$/uD', $piece, $label)) $value = $label[1]; elseif (in_array($kind, $carried, true)) $value = $piece; elseif (preg_match('/^(?:患者|本例)?(?:本次|此次|当前|目前|现在)?' . '(无|没有|否认|不存在)(?:' . $aliases . ')$/uD', $piece, $absence)) $value = $absence[1]; if ($value === null) continue; $sources = self::medicationFieldAssertions($ledger, $kind, self::scope($timeClause, 'current'), $timeClause); if (preg_match('/^(?:' . self::UNKNOWN . '|未填|待补)$/uD', $value)) { foreach ($sources as $source) $issues[] = ['code' => 'RECORDED_TO_UNKNOWN', 'source_id' => $source['source_id']]; continue; } if (self::conditional($value) || self::dataGapPart($value) !== null || self::evidenceOnly($value)) continue; $absent = preg_match('/^(?:无|没有|否认|不存在|未使用|未服用)(?:药物|用药)?$/uD', $value) === 1; if ($sources === []) { $issues[] = ['code' => $absent ? 'UNKNOWN_TO_NEGATIVE' : 'UNKNOWN_TO_POSITIVE', 'source_id' => 'report']; } elseif ($absent) { $wholeNegative = false; foreach ($sources as $source) { if ($source['state'] === 'positive') $issues[] = ['code' => 'POSITIVE_TO_NEGATIVE', 'source_id' => $source['source_id']]; $sourceTopic = preg_replace('/^(?:任何|全部|一切)/u', '', $source['topic']) ?? $source['topic']; if ($source['state'] === 'negative' && (in_array($sourceTopic, $definition['aliases'], true) || ($kind === 'current' && $sourceTopic === '药物'))) $wholeNegative = true; } // Non-use of one named item is not absence of the whole // field. Only an explicit whole-field denial proves it. if (!$wholeNegative && !in_array('positive', array_column($sources, 'state'), true)) { $issues[] = ['code' => 'NEGATION_SCOPE_WIDENED', 'source_id' => $sources[0]['source_id']]; } } } } return $issues; } /** Only explicit record attribution is eligible; current assertions cannot borrow it. */ private static function historyAttribution(string $clause): string { if (preg_match('/本次|此次|目前|当前|现在|现用|正在/u', $clause)) return ''; $label = preg_replace('/^\s*(?:19|20)\d{2}(?:-\d{1,2}-\d{1,2}|年\d{1,2}月\d{1,2}日?)\s*/u', '', $clause) ?? $clause; if (preg_match('/^\s*(?:访谈|聊天(?:内容|记录)?)(?:另记|另载|记载|记录为|记录中|中记载|为)[::]?/u', $label)) return 'chat.'; if (preg_match('/^\s*运动(?:记录(?:为|另载|另记|记载|中)|时)[::]?/u', $label)) return 'daily_tracking.exercise.'; return ''; } /** A source-bound quotation preserves the entire negative scope, not just a substring. */ private static function quotedPartSources(array $ledger, array $quotes, array $part, string $scope, string $clause): array { $attribution = self::historyAttribution($clause); if ($part['state'] !== 'negative' || (self::calendarDates($clause) === [] && $attribution === '')) return []; $canonical = self::canonicalQuote($part['quote']); $ids = []; foreach ($quotes as $quote) { if ($canonical === '' || !str_contains(self::canonicalQuote($quote['text']), $canonical)) continue; foreach ($ledger['assertions'] as $source) { $sourcePath = (string) ($ledger['sources'][$source['source_id']]['path'] ?? ''); $matchingSource = $attribution === '' ? self::sameTime($source, $scope, $clause) : str_starts_with($sourcePath, $attribution) && ($source['scope'] ?? '') === 'historical' && self::sameTime($source, 'historical', $clause); if ($source['state'] === 'negative' && in_array($source['source_id'], $quote['source_ids'], true) && $matchingSource && self::canonicalQuote($source['quote']) === $canonical) $ids[$source['source_id']] = true; } } return array_keys($ids); } private static function canonicalQuote(string $text): string { $text = preg_replace('/\s+/u', '', self::cleanText($text)) ?? ''; return preg_replace('/^[。;;]+|[。;;]+$/u', '', $text) ?? ''; } private static function fieldLabel(string $field): string { if (isset(self::EXAM_LABELS[$field])) return self::EXAM_LABELS[$field]; foreach (self::FIELD_GROUPS as $definition) { if (in_array($field, $definition['fields'], true)) return $definition['aliases'][0]; } return $field; } private static function containsAlias(string $text, array $aliases): bool { foreach ($aliases as $alias) if (str_contains($text, $alias)) return true; return false; } /** Field metadata is explicitly separate from quotations: missing is not a fabricated source sentence. */ private static function fieldIssues(array $ledger, string $clause, array &$pendingFields): array { $pieces = preg_split('/[,,]/u', $clause, -1, PREG_SPLIT_NO_EMPTY) ?: []; if (count($pieces) > 1 && array_filter($pieces, static fn (string $piece): bool => self::dataGapPart($piece) !== null)) { $issues = []; foreach ($pieces as $piece) array_push($issues, ...self::fieldIssues($ledger, $piece, $pendingFields)); return $issues; } $text = self::unwrappedFieldUnknown(trim($clause, " \t#>*-")); $carried = $pendingFields; $pendingFields = []; foreach (self::FIELD_GROUPS as $group => $definition) { $aliases = implode('|', array_map(static fn (string $alias): string => preg_quote($alias, '/'), $definition['aliases'])); if (preg_match('/^(?:' . $aliases . ')[::]?\s*$/uD', $text)) $pendingFields[] = $group; } if ($pendingFields !== []) return []; $issues = []; $dataGap = self::dataGapPart($text); foreach (self::FIELD_GROUPS as $group => $definition) { $state = $ledger['field_states'][$group] ?? ['state' => 'unknown', 'source_id' => 'report', 'date' => '']; $aliases = implode('|', array_map(static fn (string $alias): string => preg_quote($alias, '/'), $definition['aliases'])); $value = null; if (preg_match('/(?:' . $aliases . ')[::]\s*(.+)$/uD', $text, $match)) $value = $match[1]; elseif (in_array($group, $carried, true)) $value = $text; $unwrapped = $value !== null ? self::unwrappedFieldUnknown($value) : null; $wrappedUnknown = $unwrapped !== $value; $value = $unwrapped; if ($state['state'] !== 'unknown') { // A missing named examination is different from a missing // examination image; it cannot erase an existing result. if (in_array($group, ['tongue', 'pulse'], true) && preg_match('/^(?:本次|此次|当前|目前)?(?:无|没有|未提供)' . preg_quote(self::fieldLabel($group), '/') . '$/uD', $text)) { $issues[] = ['code' => 'RECORDED_TO_UNKNOWN', 'source_id' => $state['source_id']]; } if (in_array($group, $carried, true) && (preg_match('/^(?:' . self::UNKNOWN . ')$/uD', $text) || ($dataGap !== null && $dataGap['topic'] === ''))) { $issues[] = ['code' => 'RECORDED_TO_UNKNOWN', 'source_id' => $state['source_id']]; } if ($wrappedUnknown && self::sameTime($ledger['sources'][$state['source_id']] ?? [], self::scope($text, 'current'), $text)) { $issues[] = ['code' => 'RECORDED_TO_UNKNOWN', 'source_id' => $state['source_id']]; } continue; } if ($dataGap !== null) continue; $dates = self::calendarDates($text); if ($dates !== [] && $state['date'] !== '' && !in_array($state['date'], $dates, true)) continue; if ($value !== null && (preg_match('/^(?:' . self::UNKNOWN . '|未填|待补)/u', $value) || self::conditional($value) || self::evidenceOnly($value))) continue; $absent = preg_match('/(?:' . self::OBSERVED_ABSENT . ')[^,,::]{0,30}(?:' . $aliases . ')/u', $text) || preg_match('/(?:' . $aliases . ')[::\s]*(?:无异常|无|没有|否认|未见)/u', $text) || ($value !== null && preg_match('/^(?:无异常|无|没有|否认|未见)/u', $value)); $normal = preg_match('/(?:' . $aliases . ')[::\s]*(?:为|呈|示|基本)?正常/u', $text) || ($value !== null && preg_match('/^(?:为|呈|示|基本)?正常/u', $value)); if ($absent || $normal || ($value !== null && trim($value) !== '')) { $issues[] = ['code' => $absent ? 'UNKNOWN_TO_NEGATIVE' : 'UNKNOWN_TO_POSITIVE', 'source_id' => $state['source_id']]; } } return $issues; } /** Paired punctuation around one unknown value is formatting, never a finding. */ private static function unwrappedFieldUnknown(string $value): string { if (preg_match('/^\s*(?:(\s*(' . self::UNKNOWN . ')\s*)|\(\s*(' . self::UNKNOWN . ')\s*\))\s*$/uD', $value, $wrapped)) return ($wrapped[1] ?? '') !== '' ? $wrapped[1] : $wrapped[2]; return $value; } /** Explicit lexical polarity with inherited scope across enumerated items. */ private static function parts(string $clause): array { // A closed quantity predicate ends before an adjoining assertion, // even when the writer uses a conjunction instead of a comma. $quantityBoundary = '/(' . self::QUANTITY_SUBJECT . '不(?:多|少))(?=且|但|而)/u'; $quantitySplit = preg_replace($quantityBoundary, '$1,', $clause) ?? $clause; if ($quantitySplit !== $clause) return self::parts($quantitySplit); // A closed record-state copula also ends before an adjoining // assertion. Do not carry UNKNOWN into a later examination value. if (preg_match('/^(.+?(?:属于|属|为)(?:' . self::UNKNOWN . '))(?:且|但|而)(.+)$/uD', $clause, $recordTail) && self::dataGapPart($recordTail[1]) !== null) { return array_merge(self::parts($recordTail[1]), self::parts($recordTail[2])); } // A complete postfix record gap also ends before the next assertion; // its UNKNOWN state cannot be inherited by a new symptom/exam value. if (preg_match('/^([^,,。;::]{1,100}?(?:' . self::UNKNOWN . '))(?:并且|并|同时|还|又|另|且|但|而(?!待定(?:$|[,,])))(.+)$/uD', $clause, $recordTail) && preg_match('/^(.+?)(?:' . self::UNKNOWN . ')$/uD', $recordTail[1], $recordSubject) && self::neutralRecordSubject($recordSubject[1]) && !preg_match('/' . self::OBSERVATION_INTRODUCER . '/u', $recordSubject[1])) { return array_merge(self::parts($recordTail[1]), self::parts($recordTail[2])); } // Data nouns determine the object of a missing-record statement. Keep // it as unknown so the usual known-to-unknown checks still apply. $gap = self::dataGapPart($clause); if ($gap !== null) return [$gap]; // Split only around a complete gradual occurrence. Earlier commas in // an inherited negative list must keep their existing polarity. preg_match_all('/[,,]\s*((?:(?:且|但|而)\s*)?([^,,。;::]{1,40}?)(?:逐渐|渐次|陆续)出现)(?=$|[,,])/u', $clause, $occurrences, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); foreach ($occurrences as $occurrence) { if (!self::neutralRecordSubject($occurrence[2][0])) continue; $offset = $occurrence[0][1]; $after = substr($clause, $offset + strlen($occurrence[0][0])); $after = preg_replace('/^[,,]\s*/u', '', $after) ?? $after; return array_merge(self::parts(substr($clause, 0, $offset)), self::parts($occurrence[1][0]), self::parts($after)); } $commaParts = preg_split('/[,,]/u', $clause, -1, PREG_SPLIT_NO_EMPTY) ?: []; if (count($commaParts) > 1 && array_filter($commaParts, static fn (string $piece): bool => self::dataGapPart($piece) !== null || self::examinationAbsenceParts($piece) !== null)) { $parts = []; foreach ($commaParts as $piece) array_push($parts, ...self::parts($piece)); return $parts; } // An explicitly named new examination subject ends the previous // assertion's polarity; a comma inside an ordinary list does not. $examBoundary = '/[,,](?=\s*(?:(?:且|但|而|亦|也)\s*)?(?:舌|脉))/u'; if (preg_match($examBoundary, $clause)) { $parts = []; foreach (preg_split($examBoundary, $clause, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $piece) { array_push($parts, ...self::parts($piece)); } return $parts; } $examAbsence = self::examinationAbsenceParts($clause); if ($examAbsence !== null) return $examAbsence; // A postfix unknown may follow a separately observed clause. Do not // accidentally assign its subject to the preceding positive statement. if (preg_match('/(?:^|[,,])[^,,]+(?:' . self::UNKNOWN . ')(?=[,,]|$)/u', $clause) && preg_match('/[,,]/u', $clause)) { $parts = []; foreach (preg_split('/[,,]/u', $clause, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $piece) { array_push($parts, ...self::parts($piece)); } return $parts; } $pattern = '/(' . self::UNKNOWN . ')|(' . self::ABSENT . ')|(' . self::PRESENT . ')/u'; preg_match_all($pattern, $clause, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER); $parts = []; $cursor = 0; foreach ($matches as $i => $match) { $marker = $match[0][0]; $offset = $match[0][1]; if (!self::isMarker($clause, $marker, $offset)) continue; $end = strlen($clause); for ($j = $i + 1; $j < count($matches); ++$j) { if (self::isMarker($clause, $matches[$j][0][0], $matches[$j][0][1])) { $end = $matches[$j][0][1]; break; } } $state = ($match[1][1] ?? -1) >= 0 ? 'unknown' : (($match[2][1] ?? -1) >= 0 ? 'negative' : 'positive'); $prefix = substr($clause, $cursor, $offset - $cursor); $subject = ''; if (preg_match('/(?:^|[,,::])\s*((?:舌象|脉象|舌质|舌苔|舌|脉))$/u', $prefix, $subjectMatch)) $subject = $subjectMatch[1]; if ($cursor === 0 && trim($prefix) !== '') { // Postfix unknown: “胸痛尚未记录” is one unknown statement. if ($state === 'unknown' && trim(substr($clause, $offset + strlen($marker))) === '') { $parts[] = ['state' => 'unknown', 'topic' => $prefix, 'quote' => trim($clause), 'explicit' => true]; return $parts; } if ($subject === '') $parts[] = ['state' => 'positive', 'topic' => $prefix, 'quote' => trim($prefix), 'explicit' => false]; } $tail = substr($clause, $offset + strlen($marker), $end - $offset - strlen($marker)); $parts[] = ['state' => $state, 'topic' => $subject . $tail, 'quote' => trim(substr($clause, $offset, $end - $offset)), 'explicit' => true, 'observed_absence' => preg_match('/^(?:' . self::OBSERVED_ABSENT . ')$/uD', $marker) === 1]; $cursor = $end; } if ($parts === [] && trim($clause) !== '') $parts[] = ['state' => 'positive', 'topic' => $clause, 'quote' => trim($clause), 'explicit' => false]; return $parts; } /** Null unless a complete, assertion-free record object is explicitly missing. */ private static function dataGapPart(string $clause): ?array { $frame = '/^\s*(?:([^,,::]{1,24})[::]\s*)?(?:(?:但|亦|也|仍|本次|此次|当前|目前)\s*)*'; // A copular record-role noun refers back to its own subject. It is // still UNKNOWN, never an exemption from known-to-unknown checking. if (preg_match($frame . '([^,,。;::]{1,80}?)(?:属于|属|为)(' . self::UNKNOWN . ')' . '(?:的)?(?:依据|证据|资料|记录|信息|描述|状态)\s*(?:[::]\s*)?$/uD', $clause, $copula)) { $subject = trim($copula[2]); if (self::neutralRecordSubject($subject) && self::neutralRecordSubject(($copula[1] ?? '') . $subject)) { return ['state' => 'unknown', 'topic' => $subject, 'quote' => trim($clause), 'explicit' => true]; } } // Bind only the missing symptom record within a closed neutral causal // conclusion, not all prose preceding an arbitrary unknown marker. if (preg_match($frame . '(?:([^,,。;::]{1,40}(?:定位|判断|归属|结论|辨证|证型|证名))因)?' . '([^,,。;::]{1,80}?)(' . self::UNKNOWN . ')而待定\s*$/uD', $clause, $causal)) { $subject = trim($causal[3]); $context = ($causal[1] ?? '') . ($causal[2] ?? '') . $subject; if (self::neutralRecordSubject($subject) && self::neutralRecordSubject($context)) { return ['state' => 'unknown', 'topic' => $subject, 'quote' => trim($clause), 'explicit' => true]; } } // Closed evidential-knowledge gap, not a negative clinical finding. // Preserve it as UNKNOWN so a known observation cannot be erased. if (preg_match($frame . '(?:未见|没有|无)足以独立(?:确定|确认|支持|证实)' . '([^,,。;::]{1,60}?)的阳性(?:热象|寒象)\s*$/uD', $clause, $threshold)) { $subject = trim($threshold[2]); if (self::neutralRecordSubject($subject) && self::neutralRecordSubject(($threshold[1] ?? '') . $subject) && !preg_match('/并|同时|还|又|另|' . self::OBSERVATION_INTRODUCER . '/u', $subject)) { return ['state' => 'unknown', 'topic' => $subject, 'quote' => trim($clause), 'explicit' => true]; } } if (preg_match($frame . '(?:未见|没有|无)(?:足以|能够)(?:确认|支持|证实)' . '([^,,。;::]{1,80}?)(?:的)?(?:本次|当前)?(?:依据|证据)\s*$/uD', $clause, $evidence)) { $subject = preg_replace('/(?:的)?(?:本次|当前)?$/u', '', trim($evidence[2])) ?? ''; if ($subject !== '' && !self::hasAssertionMarker(($evidence[1] ?? '') . $subject) && !preg_match('/患者|本例|且|但|而|已确认|已记录/u', ($evidence[1] ?? '') . $subject)) { return ['state' => 'unknown', 'topic' => $subject, 'quote' => trim($clause), 'explicit' => true]; } } $record = '(?:记录|资料|描述|信息)'; // A postfix gap belongs only to the closed record object immediately // before it. “缺失” alone is not a clinical unknown-state marker, and // neither form may consume a comma or an adjacent assertion clause. if (!preg_match($frame . '(?:缺乏|缺少|没有|未见|无)([^,,。;::]{0,100}?)' . $record . '(?:(?:属于|属|为)(?:' . self::UNKNOWN . '))?\s*$/uD', $clause, $gap) && !preg_match($frame . '([^,,。;::]{0,100}?)' . $record . '(?:仍|尚)?缺失\s*$/uD', $clause, $gap)) return null; $label = trim($gap[1] ?? ''); $topic = preg_replace('/(?:的|等|相关|专门|文字)+$/u', '', trim($gap[2] ?? '')) ?? ''; if (!self::neutralRecordSubject($topic) || !self::neutralRecordSubject($label . $topic)) return null; if ($topic === '') { foreach (self::FIELD_GROUPS as $definition) { if (in_array($label, $definition['aliases'], true)) { $topic = $label; break; } } } return ['state' => 'unknown', 'topic' => $topic, 'quote' => trim($clause), 'explicit' => true]; } /** Only a neutral record subject may be rewritten as missing information. */ private static function neutralRecordSubject(string $text): bool { if (self::hasAssertionMarker($text) || preg_match('/患者|本例|且|但|而|已确认|已记录/u', $text)) return false; // Self-report/chief-complaint verbs assert their object even without // 有/出现. A named record noun such as 主诉内容 remains neutral. if (preg_match('/(?:^|[,,、::])\s*(?:(?:本次|此次|当前|目前|今晨|今早|今日|昨晚|昨日|近日)\s*)*' . '(?:' . self::OBSERVATION_INTRODUCER . ')(?!(?:的)?(?:记录|资料|内容|信息|描述)(?:$|的|[、及或和]))/u', trim($text))) return false; if (preg_match('/(?:并且|并|同时|还|又|另)(?:' . self::OBSERVATION_INTRODUCER . ')/u', $text)) return false; // A terminal occurrence predicate says the symptom happened. Do not // ban the same word inside neutral nouns such as 发作时段/持续时间. return preg_match('/[^,,、::]{1,80}(?:发作|复发|发生|发病|加重|缓解|减轻|持续)(?:了)?' . '(?:[0-9一二三四五六七八九十半]+(?:分钟|小时|天|日|周|月|年))?$/u', trim($text)) !== 1; } private static function hasAssertionMarker(string $text): bool { preg_match_all('/' . self::UNKNOWN . '|' . self::ABSENT . '|' . self::PRESENT . '/u', $text, $markers, PREG_OFFSET_CAPTURE); foreach ($markers[0] as [$marker, $offset]) if (self::isMarker($text, $marker, $offset)) return true; return false; } private static function isMarker(string $clause, string $marker, int $offset): bool { $tail = substr($clause, $offset + strlen($marker)); // A quantity predicate belongs to its adjacent measure/iteration // subject. Its closed complement cannot negate a later list item. if ($marker === '不' && preg_match('/' . self::QUANTITY_SUBJECT . '$/u', substr($clause, 0, $offset)) && preg_match('/^(?:多|少)(?=$|[、,,]|且|但|而)/u', $tail)) return false; // 不适 is the discomfort lexeme, not an absence of “适”. Keep an // outer 无/没有/否认 intact; applicability predicates stay separate. if ($marker === '不' && preg_match('/^适(?!用|于|合|应|宜|当)/u', $tail)) return false; // “不良反应/事件” is a noun phrase, not “no 良反应”. if ($marker === '不' && preg_match('/^良(?:反应|事件)/u', $tail)) return false; if ($marker === '无' && preg_match('/^(?:力|法|数|论|关|需|须|依据|证据|支持|充分|足够|适用|从|论)/u', $tail)) return false; // Qualitative reasoning such as “寒热属性不显” is not an observed // patient absence. Otherwise its trailing treatment list inherits an // unrelated negative marker and becomes a fabricated physical finding. if ($marker === '不' && preg_match('/^(?:足|确定|明|详|显|清楚|代表|能|可|宜|作|等|应|建议|意味|支持|构成|替代|作为|提供|将|把|予|擅|沿用|直接|单独|自动|同|仅|属于|用于|移作|单凭|对应|由|从)/u', $tail)) return false; if ($marker === '非' && preg_match('/^(?:本次|当前|患者|真实|中医|临床|因|药物|处方|此)/u', $tail)) return false; if ($marker === '有' && preg_match('/^(?:关|所|限|效|助|时|待|无|力)/u', $tail)) return false; if ($marker === '伴' && preg_match('/^随/u', $tail)) return false; return true; } private static function topics(string $text, bool $bindMethodSubjects = false): array { $result = []; $topics = []; foreach (preg_split('/[,,、::()()]+/u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $item) { // “汗出及四肢温度的触诊” names a method record, not a list // declaring those already described symptoms to be unknown. if ($bindMethodSubjects && preg_match('/^[^,,、::]{1,60}的(?:触诊|腹诊|按诊)$/uD', trim($item))) $topics[] = $item; else array_push($topics, ...(preg_split('/以及|及|或者|或|并且|(?\"'"); $topic = preg_replace('/^(?:本次|目前|当前|患者|本例|此次|本病历|记录|病历|面诊|医师切诊|医师|切诊|自述|既往|历史|当年|上次|曾经|曾|所见|明确为|明确|表现为|提示|为|是|且|而|但|又|仍|亦|也|有|的|等|见|记载|合成接诊|合成病史|神疲乏力:|\d{4}[-年]\d{1,2}(?:[-月]\d{1,2}日?)?)+/u', '', $topic) ?? $topic; $topic = preg_replace('/(?:的)?(?:记录|资料|描述|情况|信息|表现)?(?:尚未|未)(?:记录|采集|提供|评估|核实|明确).*$/u', '', $topic) ?? $topic; $topic = preg_replace('/(?:等|的|者)$/u', '', $topic) ?? $topic; $topic = self::examinationFeatureText($topic); $topic = trim($topic); if ($topic !== '' && mb_strlen($topic, 'UTF-8') <= 100) $result[] = $topic; } return $result; } private static function evidenceOnly(string $topic): bool { // A metadata noun cannot turn the following clinical list items into // general “other information”. Inspect those items separately. if (preg_match('/^' . self::ATTACHMENT_TOPIC . '(?:[、,,]|及|或)/u', trim($topic))) return false; // A closed evidence noun + support relation is not a patient absence. // Clinical lists and assertion tails cannot fit this complete role. if (preg_match('/^(?:本次|当前|现有)?阳性(?:资料|证据)支持血瘀$/uD', trim($topic))) return true; if (preg_match('/^[^,,]{0,120}专门[^,,]{0,12}描述$/uD', trim($topic))) return true; return preg_match('/^(?:足够|充分|(?:可|足以)?支持|依据|证据|形成|达到|明确.*证据|资料|记录|数据|检查|血色|保证|必要|理由|意义|定量|推断|判断|排除|替代|改变|医嘱|适用|处方|剂量|新增|合方|加味|提供|擅自|直接|自动|简单|机械|长期|所有|其他|从|急性气脱|属于|作为|等同|构成|代表|意味|考虑|假设|建议|推荐|应当|需要|仅|单独|自行|移作|单凭|不(?:移作|单凭|从|由))/u', trim($topic)) === 1; } private static function conditional(string $clause): bool { $clause = trim($clause, " \t#>*-"); // reportProse has already removed verified questions in this frame. // Remaining actual assertions must not borrow its generic “须核实”. if (preg_match('/^适用前须核实[::]/u', $clause)) return false; // Qualified neutral action lists were removed by reportProse. An // actual patient subject left in an action fragment fails closed. if (preg_match('/(?:需|须|应)(?:进一步|逐项|逐味)?(?:补问|询问|补采|采集|核实|复核|核对|追问|确认|查明)/u', $clause) && preg_match('/患者|本例/u', $clause)) return false; if (preg_match('/^(?:[\p{Han}A-Za-z0-9、()() ]{1,24}[::])?\s*(?:若|如果|假如|倘若|如出现|一旦|仅在|只有|复诊时|复诊确认|复诊后|后续出现|(?:后续|今后|此后)(?:若|如果|如)|需|须|应|建议|请|待核|开方前|开始前|用药前)/u', $clause)) return true; // A collection request is not an observed finding. Its own subject may // precede “须补问”, but an earlier observed negative cannot use it as a // blanket exemption. return !preg_match('/' . self::OBSERVED_ABSENT . '/u', $clause) && preg_match('/(?:需|须|应|有待|仍待|待)(?:进一步)?(?:补问|询问|补采|采集|核实|复核|追问|确认|查明)/u', $clause) === 1; } private static function scope(string $clause, string $default): string { if (preg_match('/本次|此次|目前|当前|现在|现用|正在/u', $clause)) return 'current'; if (preg_match('/既往|历史|当年|上次|曾经|早已停|旧记录|旧方|非本次/u', $clause)) return 'historical'; return $default; } private static function sameTime(array $source, string $scope, string $clause): bool { $sourceScope = $source['scope'] ?? 'unspecified'; $dates = self::calendarDates($clause); $sourceDate = (string) ($source['date'] ?? ''); if ($dates !== [] && $sourceDate !== '' && !in_array($sourceDate, $dates, true)) return false; preg_match_all('/\b(?:19|20)\d{2}\b/u', $clause, $years); if ($years[0] !== [] && ($source['date'] ?? '') !== '' && !in_array(substr((string) $source['date'], 0, 4), $years[0], true)) return false; if ($scope === 'historical') { if ($sourceScope === 'current') return false; if ($years[0] !== [] && !in_array(substr((string) ($source['date'] ?? ''), 0, 4), $years[0], true)) return false; return true; } return $sourceScope !== 'historical' || ($dates !== [] && $sourceDate !== '' && in_array($sourceDate, $dates, true) && !preg_match('/本次|此次|目前|当前|现在|现用|正在/u', $clause)); } private static function calendarDates(string $text): array { preg_match_all('/(? '舌(?:象|质|体|色|面)?', 'pulse' => '脉(?:象|诊)?'] as $kind => $label) { $text = preg_replace('/(?:' . $label . ')(?=(?:明显|显著)?(?:' . self::EXAM_FEATURES[$kind] . '))/u', '', $text) ?? $text; } return $text; } /** A closed 未呈 examination list, with each feature bound to its named exam. */ private static function examinationAbsenceParts(string $clause): ?array { if (!preg_match('/^\s*(?:(?:本次|此次|当前|目前)\s*)*(舌脉|舌(?:象|质|苔)?|脉(?:象|诊)?)未呈' . '([^,,。;::]{1,100}?)(?:等(?:所见|表现|体征))?\s*$/uD', $clause, $match)) return null; $items = preg_split('/[、和及或]+/u', trim($match[2]), -1, PREG_SPLIT_NO_EMPTY) ?: []; if ($items === []) return null; $bound = []; foreach ($items as $item) { $kind = ''; foreach (['tongue', 'pulse'] as $candidate) { if (($match[1] === '舌脉' || ($candidate === 'tongue' ? str_starts_with($match[1], '舌') : str_starts_with($match[1], '脉'))) && preg_match('/^(?:明显|显著)?(?:' . self::EXAM_FEATURES[$candidate] . ')' . ($candidate === 'tongue' ? '(?:苔)?' : '') . '$/uD', trim($item))) { $kind = $candidate; break; } } // A wrong-field/unknown phenotype is not an accepted list item. // Actual assertions instead fall through to ordinary parsing so // their own symptom polarity and unknown-state checks still run. if ($kind === '') { if (self::hasAssertionMarker($item) || preg_match('/患者|本例|且|但|而/u', $item)) return null; return [['state' => 'negative', 'topic' => $match[1] . $match[2], 'quote' => trim($clause), 'explicit' => true, 'observed_absence' => true, 'unbound_exam' => str_starts_with($match[1], '舌') ? 'tongue' : 'pulse']]; } $bound[$kind][] = trim($item); } $parts = []; foreach ($bound as $kind => $features) $parts[] = ['state' => 'negative', 'topic' => ($kind === 'tongue' ? '舌' : '脉') . implode('、', $features), 'quote' => trim($clause), 'explicit' => true, 'observed_absence' => true]; return $parts; } private static function features(string $kind, string $text): array { if ($kind === 'tongue') $text = self::examinationFeatureText($text); if ($kind === 'pulse') { // Bind single-character pulse features to an actual pulse phrase. // A sentence can contain “结合语声低微、脉细弱”; its 结/微 are // ordinary prose/voice findings, not additional pulse observations. $vocabulary = self::EXAM_FEATURES['pulse']; preg_match_all('/(?:脉(?:象|诊)?[::\s的为呈现之]*|按之)((?:(?:' . $vocabulary . ')|[不、,,和及或\s]){1,35})/u', $text, $spans); if ($spans[1] !== []) $text = implode('、', $spans[1]); elseif (!preg_match('/^(?:(?:' . $vocabulary . ')|[不、,,和及或\s])+$/uD', $text)) return []; } preg_match_all('/' . self::EXAM_FEATURES[$kind] . '/u', $text, $features); return array_values(array_unique($features[0])); } /** Normalize only a complete conjunction of existing same-exam feature items. */ private static function finiteExamConjunctionProse(string $kind, string $text, bool $labelled = false): ?string { $join = '并且|而且|且|并|而|亦|也'; $item = ($kind === 'pulse' ? '(?:按之)?' : '') . '(?:(?:' . self::ABSENT . ')?(?:' . self::EXAM_FEATURES[$kind] . '))'; $list = $item . '(?:[、和及或\s]*' . $item . ')*'; $conjunction = $list . '(?:\s*(?:' . $join . ')\s*' . $list . ')+'; $label = $kind === 'pulse' ? '脉' : '舌'; if ($labelled) { $subject = $kind === 'pulse' ? '脉(?:象|诊)?' : '(?:舌(?:象|质|体|色|面|苔)?|苔)'; $normalized = preg_replace_callback('/(' . $subject . '[::\s的为呈现之]*)(' . $conjunction . ')' . '(?=$|[、和及或]\s*(?:舌|苔|脉))/u', static fn (array $match): string => $match[1] . (self::finiteExamConjunctionProse($kind, $match[2]) ?? $match[2]), $text, -1, $count); return $count > 0 ? $normalized : null; } if (!preg_match('/^' . $conjunction . '$/uD', $text)) return null; // A named boundary lets the existing polarity parser reset for each // conjunct and lets features() see every operand. Source/model bytes // and ordinary conjunctive prose never pass through this conversion. if ($kind === 'pulse') { $items = []; foreach (preg_split('/\s*(?:' . $join . ')\s*/u', $text) ?: [] as $operand) { foreach (self::parts('脉' . $operand) as $part) { // This operand has already matched the entire closed // grammar. Single-character features here cannot be voice // prose, and a long operand must not hit a span truncation. preg_match_all('/' . self::EXAM_FEATURES['pulse'] . '/u', $part['topic'], $features); preg_match('/^(?:' . self::ABSENT . ')/u', $part['quote'], $polarity); if ($part['state'] === 'negative') { // Keep a compound negative operand whole. Its scope // must not become separate stronger feature denials. $items[] = '脉' . ($polarity[0] ?? '不') . self::examinationFeatureText($part['topic']); } else foreach ($features[0] as $feature) $items[] = '脉' . $feature; } } return substr(implode(',', $items), strlen('脉')); } return preg_replace('/\s*(?:' . $join . ')\s*/u', ',' . $label, $text); } /** Only an already closed conjunction may bypass the pulse span length. */ private static function referenceFeatures(string $kind, string $text, bool $closedConjunction): array { if ($kind === 'pulse' && $closedConjunction) { $bare = preg_replace('/^(?:(?:本次|此次|当前|目前|既往|历史|当年|上次)\s*)*' . '(?:(?:脉(?:象|诊)?|按之)[::\s的为呈现之]*)?/u', '', trim($text)) ?? $text; if (preg_match('/^(?:(?:' . self::EXAM_FEATURES['pulse'] . ')|[不、,,和及或\s])+$/uD', $bare)) { preg_match_all('/' . self::EXAM_FEATURES['pulse'] . '/u', $bare, $features); return array_values(array_unique($features[0])); } } return self::features($kind, $text); } /** A postfix exam citation must support its own local features, not another list's. */ private static function explicitExamReferencesSupported(array $ledger, string $clause, ?array &$continuation = null, ?array &$conjunctionParts = null): bool { preg_match_all('/(([^()\r\n]{1,150}))|\(([^()\r\n]{1,150})\)/u', $clause, $citations, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); $previousEnd = 0; $previousKind = $continuation['kind'] ?? ''; $claimScope = $continuation['scope'] ?? 'current'; $claimDates = $continuation['dates'] ?? []; $continuation = []; $conjunctionParts = null; $pendingConjunctionParts = null; $establishedKind = ''; // The same closed comma-piece grammar handles a cited local list // and its uncited trailing piece. No report/source text is copied // across a split, and an ordinary new subject clears this state. $readLocal = static function (string $local) use (&$previousKind, &$claimScope, &$claimDates): array { $local = self::cleanReportText($local); $local = preg_replace('/^\s*(?:(?:主要|具体|直接)(?:依据|根据)\s*[::]?|依据为)\s*/u', '', $local) ?? $local; $pieces = preg_split('/[,,]/u', $local, -1, PREG_SPLIT_NO_EMPTY) ?: []; $active = ''; $activeKind = $previousKind; $kinds = []; $closedConjunction = false; $normalizedConjunction = false; foreach ($pieces as $piece) { $piece = trim($piece); $nextScope = self::scope($piece, $claimScope); $dates = self::calendarDates($piece); if ($dates !== []) $claimDates = $dates; elseif ($nextScope !== $claimScope) $claimDates = []; $claimScope = $nextScope; preg_match_all('/舌|苔|脉/u', $piece, $labels, PREG_OFFSET_CAPTURE); if ($labels[0] !== []) { // A new explicit examination subject replaces an earlier // comma subject; a joined multi-exam list keeps both roles. $kinds = []; foreach ($labels[0] as [$label]) { $activeKind = $label === '脉' ? 'pulse' : 'tongue'; $kinds[$activeKind] = $activeKind; } if (count($pieces) === 1 && count($kinds) === 1) { $subject = $activeKind === 'pulse' ? '脉(?:象|诊)?' : '(?:舌(?:象|质|体|色|面|苔)?|苔)'; if (preg_match('/^(?:(?:本次|此次|当前|目前|既往|历史|当年|上次)\s*)*' . $subject . '[::\s的为呈现之]*(.+)$/uD', $piece, $wholeExam)) { $closedConjunction = self::finiteExamConjunctionProse($activeKind, $wholeExam[1]) !== null; } } foreach ($kinds as $kind) { $joined = self::finiteExamConjunctionProse($kind, $piece, true); if ($joined !== null) { $piece = $joined; $normalizedConjunction = true; } } $active = $piece; continue; } $bare = preg_replace('/^(?:(?:本次|此次|当前|目前|既往|历史|当年|上次)\s*)*(?:并且|而且|且|但|而|并|亦|也)?\s*/u', '', $piece) ?? $piece; if ($activeKind === 'pulse') $bare = preg_replace('/^按之/u', '', $bare) ?? $bare; $feature = $activeKind !== '' ? self::EXAM_FEATURES[$activeKind] : '(?!)'; $joined = $activeKind !== '' ? self::finiteExamConjunctionProse($activeKind, $bare) : null; if ($activeKind !== '' && ($joined !== null || preg_match('/^(?:(?:(?:' . self::ABSENT . ')?(?:' . $feature . '))|[、和及或\s])+$/uD', $bare))) { $kinds[$activeKind] = $activeKind; $closedConjunction = count($pieces) === 1 && $joined !== null; if ($joined !== null) { $bare = $joined; $normalizedConjunction = true; } // The inherited label is a validation-only antecedent; // preserve the ordinary polarity parser for the full list. $active = $active === '' ? ($activeKind === 'pulse' ? '脉' : '舌') . $bare : $active . ',' . $bare; } else { // A symptom or other palpation subject ends the exam list. // In particular 腹部按之… and 口干 cannot inherit 脉. $active = ''; $activeKind = ''; $kinds = []; $claimDates = []; } } $previousKind = $activeKind; return [$active, $kinds, $closedConjunction, $normalizedConjunction]; }; foreach ($citations as $citation) { $body = ($citation[1][0] ?? '') !== '' ? $citation[1][0] : ($citation[2][0] ?? ''); $metadata = self::sourceAnnotationTokens($ledger, $body); if ($metadata === null || !$metadata['valid'] || $metadata['ids'] === []) continue; [$active, $kinds, $closedConjunction, $normalizedConjunction] = $readLocal(substr($clause, $previousEnd, $citation[0][1] - $previousEnd)); $previousEnd = $citation[0][1] + strlen($citation[0][0]); $establishedKind = ''; if ($kinds !== []) { $dates = array_values(array_unique(array_merge($claimDates, $metadata['dates']))); if (count($dates) > 1) return false; $claimDates = $dates; } else $claimDates = []; foreach ($kinds as $kind) { $features = []; foreach (self::parts($active) as $part) { if ($part['state'] === 'unknown' || self::evidenceOnly($part['topic'])) continue; foreach (self::referenceFeatures($kind, $part['topic'], $normalizedConjunction) as $feature) { $features[$feature . ':' . $part['state']] = [$feature, $part['state']]; } } foreach ($features as [$feature, $state]) { $supported = false; foreach ($metadata['ids'] as $id) { $source = $ledger['sources'][$id]; if (($source['scope'] ?? '') !== $claimScope || ($claimScope === 'current' && !str_starts_with($source['path'], 'current.')) || !in_array($source['field'], $kind === 'pulse' ? ['pulse', 'pulse_condition'] : ['tongue', 'tongue_coating'], true) || !self::sameTime($source, $claimScope, $active . ' ' . implode(' ', $claimDates))) continue; // build() derives this per-kind date directly from the // visit date, independently of individual source rows. // Missing/invalid metadata grants no guessed date rule. $visitDate = $ledger['field_states'][$kind]['date'] ?? ''; if ($claimScope === 'current' && is_string($visitDate) && preg_match('/^(\d{4})-(\d{2})-(\d{2})$/D', $visitDate, $calendar) && checkdate((int) $calendar[2], (int) $calendar[3], (int) $calendar[1]) && $source['date'] !== $visitDate) continue; foreach (self::clauses($source['quote']) as $sourceClause) { if (($claimScope === 'current' && self::scope($sourceClause, 'current') !== 'current') || !self::sameTime($source, $claimScope, $sourceClause)) continue; foreach (self::parts($sourceClause) as $sourcePart) { if ($sourcePart['state'] === $state && in_array($feature, self::features($kind, $sourcePart['topic']), true)) $supported = true; } } } if (!$supported) return false; } if ($features !== [] && $kind === $previousKind) $establishedKind = $kind; } $pendingConjunctionParts = $closedConjunction ? self::parts($active) : null; } $tail = substr($clause, $previousEnd); if (trim($tail) !== '') { [$active, $kinds] = $readLocal($tail); $establishedKind = ''; if (isset($kinds[$previousKind])) { foreach (self::parts($active) as $part) { if ($part['state'] !== 'unknown' && !self::evidenceOnly($part['topic']) && self::features($previousKind, $part['topic']) !== []) $establishedKind = $previousKind; } } } if ($establishedKind !== '') { $continuation = ['kind' => $establishedKind, 'scope' => $claimScope, 'dates' => $claimDates]; } // Only an entire unquoted one-citation exam conjunction supplies a // parts view. Keep original prose, quote offsets, roles and dates; // no ordinary/quoted tail can be dropped by this finite conversion. if (count($citations) === 1 && trim($tail) === '' && !preg_match('/[“”「」‘’"]/u', $clause)) { $conjunctionParts = $pendingConjunctionParts; } return true; } private static function examinationIssues(array $ledger, string $clause, array $parts): array { $issues = []; foreach (['tongue' => '/舌|苔/u', 'pulse' => '/脉/u'] as $kind => $context) { if (!preg_match($context, $clause)) continue; foreach ($parts as $part) { if ($part['state'] === 'unknown' || self::evidenceOnly($part['topic'])) continue; if (($part['unbound_exam'] ?? '') === $kind) { $issues[] = ['code' => $kind === 'tongue' ? 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE' : 'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE', 'source_id' => (string) ($ledger['field_states'][$kind]['source_id'] ?? 'report')]; continue; } // Do not read ordinary words such as “结合” as pulse feature // “结” merely because another part of the sentence says 脉. if ($kind === 'pulse' && !preg_match('/脉|按之/u', $part['topic']) && !preg_match('/^(?:(?:' . self::EXAM_FEATURES['pulse'] . ')|[、,,和及或])+$/uD', trim($part['topic']))) continue; $featureTopic = $part['topic']; if ($kind === 'tongue' && $part['state'] === 'positive') { // Only the explicitly named comparison target is conceptual. // Keep the left observation and every later feature visible. $featureTopic = preg_replace_callback('/(不等同于\s*)(舌(?:质|象)?(?:' . self::EXAM_FEATURES['tongue'] . '))(?=$|[,,。;;!?!?、::()()\s])/u', static fn (array $match): string => $match[1] . str_repeat(' ', strlen($match[2])), $featureTopic) ?? $featureTopic; } foreach (self::features($kind, $featureTopic) as $feature) { if (isset($ledger['exam_features'][$kind][$feature][$part['state']]) && ($part['state'] !== 'negative' || self::negativeExamFeaturesSupported($ledger, $kind, $featureTopic, $clause))) continue; $sourceId = ''; foreach ($ledger['sources'] as $source) { if (str_starts_with($source['path'], 'current.') && in_array($source['field'], $kind === 'tongue' ? ['tongue', 'tongue_coating'] : ['pulse', 'pulse_condition'], true)) { $sourceId = $source['id']; break; } } $issues[] = ['code' => $kind === 'tongue' ? 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE' : 'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE', 'source_id' => $sourceId]; } } } return $issues; } /** Feature composition cannot discard its source's qualifiers, field or date. */ private static function negativeExamFeaturesSupported(array $ledger, string $kind, string $output, string $clause): bool { $features = self::features($kind, $output); $output = self::examinationFeatureText($output); $supported = []; foreach ($ledger['assertions'] as $source) { $origin = $ledger['sources'][$source['source_id']]; if ($source['state'] !== 'negative' || !str_starts_with($origin['path'], 'current.') || !in_array($origin['field'], $kind === 'tongue' ? ['tongue', 'tongue_coating'] : ['pulse', 'pulse_condition'], true) || !self::sameTime($source, 'current', $clause)) continue; $topic = self::examinationFeatureText($source['topic']); $withoutObject = preg_replace('/[苔脉舌]$/u', '', $topic) ?? $topic; foreach (array_intersect($features, self::features($kind, $source['topic'])) as $feature) { if ($topic === $feature || $withoutObject === $feature || str_contains($output, $topic) || ($withoutObject !== '' && str_contains($output, $withoutObject))) $supported[$source['source_id']][$feature] = true; } // A composed phenotype list keeps one current examination source; // source fragments from other fields cannot fill a missing item. if (count($supported[$source['source_id']] ?? []) === count($features)) return true; } return false; } private static function related(string $output, string $source): bool { if (self::evidenceOnly($source)) return false; if ($output === $source) return true; if (mb_strlen($output, 'UTF-8') >= 2 && str_contains($source, $output)) return true; if (mb_strlen($source, 'UTF-8') >= 2 && str_contains($output, $source)) return true; // A one-character reduction is checked only for a source compound whose // last character names the examined object (苔/脉/舌). This keeps “非腻” // from silently broadening “无黄腻苔”, without matching arbitrary hanzi. return mb_strlen($output, 'UTF-8') === 1 && preg_match('/[苔脉舌]$/u', $source) && str_contains($source, $output); } /** Qualified absence cannot deny every degree of the broader finding. */ private static function positiveConflictsWithNegative(string $output, string $source): bool { if (str_contains($output, $source)) return true; if (!self::contained($output, $source)) return false; $degree = '(?:明显|显著|严重|剧烈|强烈|轻微|大量|少量|频繁|持续|淋漓)'; return preg_match('/^' . $degree . '*' . preg_quote($output, '/') . $degree . '*$/uD', $source) !== 1; } private static function contained(string $output, string $source): bool { return mb_strlen($output, 'UTF-8') >= 2 && str_contains($source, $output); } private static function narrower(string $output, string $source): bool { if ($output === $source || !str_contains($source, $output)) return false; // Head nouns may be omitted without removing a qualifier: 黄腻苔 → 黄腻. $withoutObject = preg_replace('/[苔脉舌]$/u', '', $source) ?? $source; return $output !== $withoutObject; } private static function date($raw): string { if (is_numeric($raw) && (int) $raw > 100000000) return date('Y-m-d', (int) $raw); return is_string($raw) && preg_match('/^\d{4}-\d{2}-\d{2}/D', $raw) ? substr($raw, 0, 10) : ''; } private static function redact(string $text): string { $text = preg_replace('/(?"\',;。]+~iu', '[链接已省略]', $text) ?? ''; } }