2379 lines
159 KiB
PHP
2379 lines
159 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace app\adminapi\service;
|
||
|
||
/**
|
||
* Local lexical guard for source polarity and scope. No model, database or I/O.
|
||
*
|
||
* The ledger retains quotations, not inferred clinical propositions. Its three
|
||
* states describe explicit source wording only. A valid result is NOT a medical
|
||
* or complete semantic validation of arbitrary generated prose.
|
||
*/
|
||
final class TcmClinicalFactStateGuard
|
||
{
|
||
public const VERSION = 'clinical-source-state-v2';
|
||
|
||
private const UNKNOWN = '尚未(?:记录|采集|提供|询问|评估|检查|核实|测量)|未(?:曾)?(?:记录|采集|提供|填写|询问|评估|检查|核实|测量|明确)|不详|未知|不清楚|待(?:核实|补充|补采|补|采集|查)';
|
||
private const ABSENT = '否认|未见|未出现|未发现|未(?:使用|服用|应用|启用)|不存在|没有|不伴|并无|并非|非|无|不';
|
||
private const PRESENT = '存在|可见|出现|伴有|使用|服用|伴|有';
|
||
private const OBSERVED_ABSENT = '否认|未见|未出现|未发现|不存在|没有|不伴|并无|无(?!力|法|数|论|关|需|须)';
|
||
private const QUANTITY_SUBJECT = '(?:每(?:次|日|天|晚|夜|餐)(?:(?:饮水|喝水|进食|排尿|排便|用药)(?:量)?)?|一次|总量|饮水量|尿量|食量|便量|用量|剂量|量)';
|
||
private const OBSERVATION_INTRODUCER = '自述|主诉|诉称|诉说|述及|反映|报告|诊见|查见';
|
||
private const ATTACHMENT_TOPIC = '(?:其他|原始|相关|本次|任何)?(?:舌像|舌照|舌照片|图片|图像|照片|附件|文件|视频|音视频|影像资料)(?:附件|资料|记录)?(?:可供复核)?';
|
||
|
||
private const FIELDS = [
|
||
'chief_complaint', 'complaint', 'present_illness', 'present_illness_history', 'symptoms',
|
||
'appetite', 'water_intake', 'diet_condition', 'weight_change', 'body_feeling', 'sleep_condition',
|
||
'eye_condition', 'head_feeling', 'sweat_condition', 'skin_condition', 'urine_condition',
|
||
'stool_condition', 'kidney_condition', 'past_history', 'allergy_history',
|
||
'family_history', 'surgery_history', 'trauma_history', 'pregnancy_history',
|
||
'current_medications', 'local_hospital_diagnosis', 'clinical_diagnosis', 'syndrome_type',
|
||
'tongue', 'tongue_coating', 'pulse', 'pulse_condition', 'prescription', 'prescription_opinion',
|
||
'prescription_advice', 'doctor_advice', 'remark',
|
||
];
|
||
private const LEAF_FIELDS = [
|
||
'content', 'text', 'transcript_text', 'note', 'remark', 'western_medicine', 'insulin',
|
||
'prescription_name', 'clinical_diagnosis', 'usage_instruction', 'usage', 'usage_method',
|
||
];
|
||
private const EXAM_LABELS = [
|
||
'tongue' => '舌象', '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::OBSERVATION_INTRODUCER . '|' . $exam . ')/u', $questionView) ?: [$questionView]);
|
||
}
|
||
$result[] = ['text' => 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('/并且|(?<!合)并|同时|还|又|另|且|但|而|(?=患者|本例|' . self::OBSERVATION_INTRODUCER
|
||
. '|(?:本次|当前|目前)(?!(?:阴性)?(?:记录|资料|证据|结果))|今晨|今日|昨晚|昨日|每(?:次|日|天|晚|夜|餐)|饮水量|剂量|用量|(?:19|20)\d{2}|'
|
||
. $examObservation . ')/u', $text, 2) ?: [$text];
|
||
}
|
||
|
||
/** A typed modal head may govern its linked action + neutral yes/no question. */
|
||
private static function linkedReviewRequestProse(string $clause): string
|
||
{
|
||
$pieces = preg_split('/([,,])/u', $clause, -1, PREG_SPLIT_DELIM_CAPTURE) ?: [$clause];
|
||
$modalContext = false;
|
||
foreach ($pieces as $index => $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('/[,,]|并且|(?<!合)并|同时|还|又|另|且|但|而/u', $clause, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $piece) {
|
||
$piece = trim($piece, " \t#>*-");
|
||
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('/以及|及|或者|或|并且|(?<!合)并/u', $item, -1, PREG_SPLIT_NO_EMPTY) ?: []));
|
||
}
|
||
foreach ($topics as $topic) {
|
||
$topic = trim($topic, " \t\n\r\0\x0B-#>\"'");
|
||
$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('/(?<!\d)((?:19|20)\d{2})(?:-(\d{1,2})-(\d{1,2})|年(\d{1,2})月(\d{1,2})日?)(?!\d)/u',
|
||
$text, $matches, PREG_SET_ORDER);
|
||
$dates = [];
|
||
foreach ($matches as $match) {
|
||
$month = (int) (($match[2] ?? '') !== '' ? $match[2] : ($match[4] ?? 0));
|
||
$day = (int) (($match[3] ?? '') !== '' ? $match[3] : ($match[5] ?? 0));
|
||
if (checkdate($month, $day, (int) $match[1])) $dates[sprintf('%04d-%02d-%02d', (int) $match[1], $month, $day)] = true;
|
||
}
|
||
return array_keys($dates);
|
||
}
|
||
|
||
/** Separate an exam label from a following complete phenotype, longest first. */
|
||
private static function examinationFeatureText(string $text): string
|
||
{
|
||
foreach (['tongue' => '舌(?:象|质|体|色|面)?', '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('/(?<!\d)1[3-9]\d{9}(?!\d)/', '[手机号已脱敏]', $text) ?? '';
|
||
$text = preg_replace('/(?<![0-9A-Za-z])(?:\d{17}[0-9Xx]|\d{15})(?![0-9A-Za-z])/', '[身份证号已脱敏]', $text) ?? '';
|
||
$text = preg_replace('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/iu', '[邮箱已脱敏]', $text) ?? '';
|
||
return preg_replace('~(?:https?://|www\.)[^\s<>"\',;。]+~iu', '[链接已省略]', $text) ?? '';
|
||
}
|
||
}
|