false, 'issues' => [['code' => 'EVIDENCE_INDEX_UNAVAILABLE', 'source_id' => '']]]; } if (!mb_check_encoding($report, 'UTF-8')) { return ['valid' => false, 'issues' => [['code' => 'EVIDENCE_INVALID_UTF8', 'source_id' => '']]]; } $pulsePolicy = TcmConsultationPulsePolicy::validate(['sources' => $index['sources']], $report); if (!$pulsePolicy['valid']) return $pulsePolicy; $first = true; $activeSection = 1; $examSectionStarted = false; $role = ''; $negativeHeading = false; $seen = []; foreach (preg_split('/\r\n|\r|\n/', $report) ?: [] as $line) { $line = self::plainLine($line); if ($line === '') continue; $section = self::section($line); if ($section !== null) { if ($first && $section !== 1) { // A later heading must not bypass the initial role frame. // Source-valid fragments alone do not establish section 1. if (!$examSectionStarted) self::issue($issues, 'EXAM_ROLE_MISSING'); foreach (TcmClinicalEvidenceIndex::CONSULTATION_ROLES as $required) { if (!isset($seen[$required])) self::issue($issues, 'EXAM_ROLE_MISSING'); } } $first = $section === 1; $activeSection = $section; if ($section === 1) $examSectionStarted = true; $role = ''; $negativeHeading = false; continue; } if ($first) { $heading = self::roleHeading($line); if ($heading !== null) { $role = $heading['role']; $negativeHeading = $heading['negative']; $line = $heading['body']; if (!$heading['valid']) self::issue($issues, 'EXAM_HEADING_METADATA_UNSUPPORTED'); } elseif (preg_match('/^(?:历史(?:记录|资料|聊天|访谈|问诊)?|追踪(?:记录|资料)?|日常(?:追踪)?记录|聊天记录)[::]\s*(.*)$/uD', $line, $history)) { $role = 'longitudinal'; $negativeHeading = false; $line = trim($history[1]); } if ($line === '') continue; if ($role === '') { self::issue($issues, 'EXAM_FACT_PROTOCOL_INVALID'); continue; } $seen[$role] = true; self::factLine($index, $line, $role, $issues, $negativeHeading); } else { self::denials($index, $line, $issues, $activeSection); } } if ($complete) { if (!$examSectionStarted) self::issue($issues, 'EXAM_ROLE_MISSING'); foreach (TcmClinicalEvidenceIndex::CONSULTATION_ROLES as $required) { if (!isset($seen[$required])) self::issue($issues, 'EXAM_ROLE_MISSING'); } } return ['valid' => $issues === [], 'issues' => array_values($issues)]; } private static function factLine(array $index, string $line, string $role, array &$issues, bool $negativeHeading): void { $plain = trim($line, " \t。;;"); if ($plain === '未记录') { if ($negativeHeading) self::issue($issues, 'EXAM_NEGATIVE_HEADING_UNSUPPORTED'); if ($role === 'longitudinal') { self::issue($issues, 'LONGITUDINAL_FACT_PROTOCOL_INVALID'); } elseif (self::recordedRole($index, $role)) { self::issue($issues, 'EXAM_KNOWN_ROLE_TO_UNKNOWN'); } return; } if ($role === 'palpation' && $plain === '其他触诊未记录') { if ($negativeHeading) self::issue($issues, 'EXAM_NEGATIVE_HEADING_UNSUPPORTED'); if (self::recordedRole($index, 'palpation')) self::issue($issues, 'EXAM_KNOWN_ROLE_TO_UNKNOWN'); return; } $entries = self::quotes($line); if ($entries === []) { self::issue($issues, $role === 'longitudinal' ? 'LONGITUDINAL_FACT_PROTOCOL_INVALID' : 'EXAM_FACT_PROTOCOL_INVALID'); return; } $masked = $line; $lastEnd = 0; $date = ''; foreach ($entries as $entry) { $prefix = substr($line, $lastEnd, $entry['start'] - $lastEnd); $dates = self::dates($prefix); if ($dates !== []) $date = end($dates); if (str_contains($prefix, '日期未记录')) $date = 'unrecorded'; $ids = $entry['ids']; if ($ids === []) { self::issue($issues, 'SOURCE_ANNOTATION_INVALID'); } foreach ($ids as $id) { $source = $index['sources'][$id] ?? null; if (!is_array($source)) { self::issue($issues, 'SOURCE_ANNOTATION_INVALID'); continue; } if ($date === '' || ($date === 'unrecorded' ? $source['date'] !== '' : $source['date'] !== $date) || count($dates) > 1 || preg_match_all('/' . self::DATE . '/u', $prefix) !== count($dates) || ($dates !== [] && str_contains($prefix, '日期未记录'))) { self::issue($issues, 'EVIDENCE_SOURCE_DATE_MISMATCH', $id); } if ($negativeHeading && !self::negativeAssertionQuoted($source, $entry['quote'])) { self::issue($issues, 'EXAM_NEGATIVE_HEADING_UNSUPPORTED', $id); } if ($role === 'longitudinal') { if ($source['scope'] === 'current' && !preg_match('/^(?:chat|daily_tracking|video_consultation_text|prescriptions)\./D', $source['path']) && !in_array($source['field'], ['past_history', 'family_history', 'surgery_history', 'trauma_history'], true)) { self::issue($issues, 'LONGITUDINAL_SOURCE_NOT_HISTORICAL', $id); } if (($source['record_state'] ?? '') === 'unknown' || !self::sourceQuote($source['quote'], $entry['quote'])) { self::issue($issues, 'EXAM_SOURCE_QUOTE_INVALID', $id); } } else { if ($source['scope'] !== 'current') self::issue($issues, 'EXAM_SOURCE_NOT_CURRENT', $id); $matchingRole = false; $matchingQuote = false; foreach ($source['spans'] as $span) { if ($span['role'] !== $role || !$span['recorded']) continue; $matchingRole = true; if (self::sourceQuote($span['quote'], $entry['quote'])) $matchingQuote = true; } if (!$matchingRole) self::issue($issues, 'EXAM_ROLE_MISMATCH', $id); elseif (!$matchingQuote) self::issue($issues, 'EXAM_SOURCE_QUOTE_INVALID', $id); } } $masked = substr_replace($masked, str_repeat(' ', $entry['end'] - $entry['start']), $entry['start'], $entry['end'] - $entry['start']); $lastEnd = $entry['end']; } $masked = preg_replace('/' . self::DATE . '/u', '', $masked) ?? $masked; $masked = str_replace('日期未记录', '', $masked); if (preg_match('/[^\s,,。;;、::]/u', $masked)) { self::issue($issues, $role === 'longitudinal' ? 'LONGITUDINAL_FACT_PROTOCOL_INVALID' : 'EXAM_FACT_PROTOCOL_INVALID'); } } /** Acquisition role and the bounded heading annotation are separate. */ private static function roleHeading(string $line): ?array { if (!preg_match('/^(望诊|闻诊|问诊|切诊|舌象|脉象)([^::]*)(?:[::]\s*(.*))?$/uD', $line, $match)) return null; $metadata = preg_replace('/\s+/u', '', $match[2]) ?? $match[2]; $token = '(?:已记录|补充|阴性|阳性|正常)'; $valid = $metadata === '' || preg_match('/^(?:' . $token . '|(' . $token . '{1,3})|\(' . $token . '{1,3}\)){1,3}$/uD', $metadata) === 1; preg_match_all('/已记录|补充|阴性|阳性|正常/u', $metadata, $tokens); if (count($tokens[0]) > 3 || count(array_unique($tokens[0])) !== count($tokens[0]) || in_array('正常', $tokens[0], true) || in_array('阳性', $tokens[0], true)) $valid = false; return ['role' => TcmClinicalEvidenceIndex::ROLES[$match[1]], 'negative' => in_array('阴性', $tokens[0], true), 'valid' => $valid, 'body' => trim($match[3] ?? '')]; } private static function negativeAssertionQuoted(array $source, string $quote): bool { if (($source['record_state'] ?? '') === 'unknown') return false; foreach ($source['negative_assertions'] ?? [] as $assertion) { if (!is_string($assertion['quote'] ?? null) || $assertion['quote'] === '') continue; // The complete report quote may preserve positive history as well; // its own bytes must also contain a complete original denial. if (substr($source['quote'], $assertion['start'], $assertion['end'] - $assertion['start']) === $assertion['quote'] && str_contains($quote, $assertion['quote'])) return true; } return false; } /** Quotes are source data only when their own immediately following IDs verify them. */ private static function denials(array $index, string $line, array &$issues, int $section): void { $masked = $line; $lastEnd = 0; $date = ''; foreach (self::quotes($line) as $entry) { $prefix = substr($line, $lastEnd, $entry['start'] - $lastEnd); $dates = self::dates($prefix); if ($dates !== []) $date = end($dates); if (str_contains($prefix, '日期未记录')) $date = 'unrecorded'; $lastEnd = $entry['end']; if (!preg_match(self::DENIAL, $entry['quote'])) continue; if (self::negatedIllustration($line, $entry)) { // A negated writing/classification operator mentions this one // adjacent uncited quote; it does not assert a patient fact. $masked = substr_replace($masked, str_repeat(' ', $entry['end'] - $entry['start']), $entry['start'], $entry['end'] - $entry['start']); continue; } $valid = $entry['quote'] !== '' && $entry['ids'] !== []; foreach ($entry['ids'] as $id) { $source = $index['sources'][$id] ?? null; if (!is_array($source) || ($source['record_state'] ?? '') === 'unknown' || !self::sourceQuote($source['quote'], $entry['quote'])) $valid = false; if (is_array($source) && $date !== '' && (($date === 'unrecorded' ? $source['date'] !== '' : $source['date'] !== $date) || count($dates) > 1 || preg_match_all('/' . self::DATE . '/u', $prefix) !== count($dates) || ($dates !== [] && str_contains($prefix, '日期未记录')))) { self::issue($issues, 'EVIDENCE_SOURCE_DATE_MISMATCH', $id); } if (is_array($source) && $source['scope'] !== 'current' && preg_match('/本次|当前|此次/u', $prefix)) { self::issue($issues, 'EXAM_SOURCE_NOT_CURRENT', $id); } } if (!$valid) self::issue($issues, 'DENIAL_SOURCE_QUOTE_INVALID'); $masked = substr_replace($masked, str_repeat(' ', $entry['end'] - $entry['start']), $entry['start'], $entry['end'] - $entry['start']); } // These complete logical-risk phrases do not assert absence. Adjacent claims survive. $masked = preg_replace('/(?:不等于|不代表)(?:绝对)?无风险(?=$|[,,。;;!?!?])/u', '', $masked) ?? $masked; // A review question can contain 有无. Do not mask another absence in its object/tail. $masked = preg_replace('/((?:核实|复核|补问|询问|确认)[^,,。;;!?!?]{0,60})有无/u', '$1是否', $masked) ?? $masked; // Absence of a bounded evidence noun is an epistemic limitation, // not an absent patient sign. Only this local noun phrase is masked; // every adjacent assertion and every clinical noun remains checked. $masked = preg_replace('/无(?:(?:完整|独立|充分|足够|明确|直接|可靠|相应|本次|现有|阳性|的)){0,8}(?:证据|依据)(?=$|[,,。;;!?!?]|支持|证明|证实|表明|说明|确认|确定|确立|且|但|而)/u', '', $masked) ?? $masked; $masked = self::diagnosticEvidenceAbsence($masked); $masked = self::withheldMethodEvidenceAbsence($masked); $masked = self::diagnosticEvidenceCapability($masked); if ($section === 2) $masked = self::categoricalEvidenceAbsence($masked); $masked = self::negatedCategoricalInference($masked, $index); $masked = self::prospectiveApplicabilityAbsence($masked); if (preg_match(self::DENIAL, $masked)) self::issue($issues, 'UNQUOTED_PATIENT_DENIAL'); } /** A future applicability frame governs only its local absence operator. */ private static function prospectiveApplicabilityAbsence(string $text): string { // The recheck target and one acute-cause prerequisite have a closed // future role. Mask only its absence operator; every target and tail // remains available to the composed source/observation checks. $text = preg_replace_callback('/(?^|[。;;])\s*(?适用条件[::]\s*复核症状仍以' . '(?[\p{L}\p{N}、及和或的]{1,100})为主[,,]' . '(?:(?[\p{L}\p{N}、及和或的]{1,100})持续存在[,,])?)\s*且(?无)需要优先处理的急性病因' . '(?=$|[,,。;;!?!?]|并且|并|同时|还|又|另|且|但|而|患者|本例|自述|主诉|诉称|诉说|述及|反映|报告|诊见|查见)/u', static function (array $match): string { $targets = $match['target'] . ($match['persist'] ?? ''); if (preg_match('/患者|本例|本次|此次|当前|目前|现在|正在|已经|已|完成|今晨|今日|昨晚|昨日|' . '自述|主诉|诉称|诉说|述及|反映|报告|诊见|查见|未记录|未采集|未提供|不详|未知|待核实|' . '没有|未见|否认|不伴|无(?!力|法|论|须)|存在|出现|伴有|且|但|而|(?:19|20)\d{2}/u', $targets)) return $match[0]; return $match['boundary'] . $match['frame'] . '且' . str_repeat(' ', strlen($match['operator'])) . '需要优先处理的急性病因'; }, $text) ?? $text; $frame = '(?:^|[。;;])\s*(?:适用条件|采用条件|用药条件|适用前提)[::]\s*' . '(?:复核|复诊|核实|再次评估|重新评估|后续评估)后\s*'; $lead = '(?:(?:[,,]\s*)?(?:若|如)\s*|[^。;;\r\n!?!?]{0,64}?(?:且|并且|同时|并))?'; $object = '[^。;;\r\n,,!?!?“”「」"()()]{1,64}?'; return preg_replace_callback('/(?' . $frame . ')(?' . $lead . ')(?没有|无)' . '(?' . $object . ')(?时\s*[,,]?\s*方可)/u', static function (array $match): string { // Completed/current observations and nested patient claims // cannot borrow a later temporal closure's prospective role. $scope = $match['lead'] . $match['object']; if (preg_match('/患者|本例|本次|此次|当前|目前|现在|现已|已经|已|既往|此前|昨日|当时|过去|原先|自述|诉称|查见|诊见|观察到|发现|证实|确认|认定|判定|确诊|未记录|未提供|不详|否认|不伴|未见|没有|无(?!力|法|论|须)|s[1-9][0-9]*|[()()]/u', $scope)) return $match[0]; // Retain every object and following assertion. Only this one // operator is non-assertive under the verified future frame. return $match['frame'] . $match['lead'] . str_repeat(' ', strlen($match['operator'])) . $match['object'] . $match['close']; }, $text) ?? $text; } /** An eight-principles head and evidence modifiers type this as evidential absence. */ private static function categoricalEvidenceAbsence(string $text): string { $modifier = '(?:明确|充分|足够|可靠|相应|现有){0,3}(?:独立(?:的)?阳性|阳性(?:的)?独立)(?:的)?'; $head = '(?:(?:表里|寒热|虚实|阴阳)[::]\s*)?'; $text = preg_replace_callback('/(?^|[,,。;;])\s*' . $head . '(?表|里|寒|热|虚|实|阴|阳)(?:证候|证型|证)' . '(?:亦|也|尚|仍|均|并){0,2}(?:没有|无)' . $modifier . '\k象(?:的?(?:证据|依据))?' . '(?=$|[,,。;;!?!?]|且|但|而)/u', static fn (array $match): string => $match['boundary'] . str_repeat(' ', strlen($match[0]) - strlen($match['boundary'])), $text) ?? $text; // A supporting combination is evidential only with an abstract // diagnostic target and explicit independent-positive qualification. return preg_replace_callback('/(?^|[,,。;;])\s*(?:亦|也|尚|仍|均|并){0,2}(?:没有|无)' . '(?:支持|佐证|证明|证实|确立|确认)(?:表|里|寒|热|虚|实|阴|阳)(?:证候|证型|证)的' . $modifier . '(?:组合|证据|依据)(?=$|[,,。;;!?!?]|且|但|而)/u', static fn (array $match): string => $match['boundary'] . str_repeat(' ', strlen($match[0]) - strlen($match['boundary'])), $text) ?? $text; } /** Negated inference governs one abstract category proposition, never patient features. */ private static function negatedCategoricalInference(string $text, array $index = []): string { $context = '(?:因此|所以|故|但|仍|尚|亦|也|且|均|现有资料|本次资料|现有证据|本次证据)'; $category = '(?:表|里|寒|热|虚|实|阴|阳)(?:证候|证型|证)?'; $operator = '(?:不能|不可(?:以)?|不应(?:当)?|不得|不宜)'; // Both an explicit premise and the complement stay inside the same // closed abstract category grammar; no patient/observed noun can fit. $premise = '(?:据此|仅据此|由此|因此|(?:仅据|仅凭|只据|只凭|单凭|根据|依据)(?:' . $category . '|相关阴性(?:记录)?))?'; $inference = '(?:反推(?:出)?|推断|推出|推定|判定|认定|判断)(?:为)?'; $copula = '(?:(?:全|均|皆|完全|全部)?(?:属(?:于)?|为))?'; $object = '(?:(?:纯|单纯)?' . $category . '(?:而)?(?:没有|无)' . $category . '|(?:绝对)?(?:没有|无)' . $category . ')'; $head = '(?:(?:表里|寒热|虚实|阴阳)[::]\s*)?'; $annotation = '(?:((?s[1-9][0-9]*(?:[、,,]s[1-9][0-9]*)*)))?'; $text = preg_replace_callback('/(?^|[,,。;;])\s*' . $context . '{0,3}' . '该限制不等同于证明(?:纯|单纯)' . $category . '无夹杂' . $annotation . '(?=$|[,,。;;!?!?]|并且|并|同时|还|又|另|且|但|而|患者|本例|自述|主诉|诉称|诉说|述及|反映|报告|诊见|查见)/u', static function (array $match) use ($index): string { foreach (preg_split('/[、,,]/u', $match['ids'] ?? '', -1, PREG_SPLIT_NO_EMPTY) ?: [] as $id) { if (!isset($index['sources'][$id])) return $match[0]; } return $match['boundary'] . str_repeat(' ', strlen($match[0]) - strlen($match['boundary'])); }, $text) ?? $text; return preg_replace_callback('/(?^|[,,。;;])\s*' . $head . $context . '{0,3}' . $operator . $premise . '(?:直接)?' . $inference . $copula . $object . '(?:的?(?:结论|判断))?(?=$|[,,。;;!?!?]|且|但|而)/u', static fn (array $match): string => $match['boundary'] . str_repeat(' ', strlen($match[0]) - strlen($match['boundary'])), $text) ?? $text; } /** Only a clause-local negated operator can govern one uncited illustration. */ private static function negatedIllustration(string $line, array $entry): bool { if ($entry['ids'] !== [] || mb_strlen($entry['quote'], 'UTF-8') > 80) return false; // Parenthetical citations (including malformed ones) and claim tails // must not acquire metalinguistic status from an earlier operator. if (!preg_match('/^(?:\s*$|\s*[,,。;;!?!?]|\s*(?:但|且|而))/u', substr($line, $entry['end']))) return false; $context = '(?:因此|所以|故|但|仍|尚|亦|也|且|均|现有资料|本次资料|现有证据|本次证据)'; $operator = '(?:不能|不可(?:以)?|不应(?:当)?|不得|不宜)(?:据此|仅据此|因此|直接)?'; $writing = '(?:写成|写为|写作|表述为|描述为|判断为|判定为|归为|归类为|认定为|定为)'; // Use real clause boundaries in the original line, not the artificial // prefix boundary created while iterating neighbouring quotes. $prefix = substr($line, 0, $entry['start']); // Reuse the same bounded abstract inference for its adjacent quoted // complement. Earlier matches cannot confer this role on another quote. $probe = $prefix . $entry['quote']; $candidate = self::negatedCategoricalInference($probe); $bytes = strlen($entry['quote']); // Keep direct conjunction tails under their existing quote-proof // requirement; they are not a completed inference-mention sentence. if ($bytes > 0 && $candidate !== $probe && preg_match('/^(?:\s*$|\s*[,,。;;!?!?])/u', substr($line, $entry['end'])) && substr($candidate, -$bytes) === str_repeat(' ', $bytes)) return true; return preg_match('/(?:^|[,,。;;!?!?])\s*' . $context . '{0,3}' . $operator . $writing . '\s*[::]?\s*$/u', $prefix) === 1; } /** Bounded diagnostic noun lists identify evidence absence, never patient-feature absence. */ private static function diagnosticEvidenceAbsence(string $text): string { // Prefer the shortest complete diagnostic noun before list/attribute // syntax, rather than swallowing its following evidential modifiers. $domain = '(?U:' . self::diagnosticDomainPattern() . ')'; $separator = '[ \t]*(?:、|以及|及|与|和|或)[ \t]*'; $list = $domain . '(?:' . $separator . $domain . '){0,7}(?:等(?:证候|证型|病机))?'; // Ambiguous bare factors require an explicit diagnostic class. This // grammar recognizes the noun role, not clinical support for a factor. $factor = '(?:痰|湿|瘀|食积)'; $classified = $factor . '(?:' . $separator . $factor . '){0,7}等(?:实邪|病邪)'; $target = '(' . $list . '|' . $classified . ')'; $adjective = '(?:完整|独立|充分|足够|明确|直接|可靠|相应|本次|现有|阳性)'; $sufficiency = '(?:足以(?:独立|直接|充分|明确){0,2}(?:确立|确认|确定|支持|证明|证实|排除|否定))?'; return preg_replace_callback('/(?(?:^|[,,。;;])\s*(?:因此|所以|故|但|仍|尚|暂){0,2}' . '(?:不(?:采用|选用)|不予(?:采用|选用))' . $list . '等(?:尚|仍)?)' . '(?没有|无)(?' . $modifier . '(?:证据|依据)的' . '(?:限定|特定|具体|针对性)?(?:治法|治则|治疗方法|治疗方案))' . '(?=$|[,,。;;!?!?]|且|但|而)/u', static fn (array $match): string => $match['prefix'] . str_repeat(' ', strlen($match['operator'])) . $match['qualification'], $text) ?? $text; } /** An explicit diagnostic target limits the absent capability to evidence. */ private static function diagnosticEvidenceCapability(string $text): string { $domain = self::diagnosticDomainPattern(); $target = '(' . $domain . '(?:[ \t]*(?:、|以及|及|与|和|或)[ \t]*' . $domain . '){0,7})'; $modifier = '(?:额外|独立|直接|明确|充分|足够|可靠|实质|进一步)'; return preg_replace_callback('/(?:对于|对)' . $target . '(?:并|亦|也|尚|仍)?(?:没有|无)' . $modifier . '{0,4}(?:证明|佐证|支持)作用' . '(?=$|[,,。;;!?!?]|且|但|而)/u', static function (array $match): string { if (!self::diagnosticNounTarget($match[1])) return $match[0]; return str_repeat(' ', strlen($match[0])); }, $text) ?? $text; } private static function diagnosticDomainPattern(): string { $named = '(?:(?:(?=\p{L})\p{Han}){1,16})?(?:证候|证型|病机|定位|诊断|辨证|推断|结论|判断|证)'; $bare = '(?:(?:阴|阳)(?:虚|亢)|(?:气|血)虚|(?:阴阳|气血|气阴)两虚)'; return '(?:' . $named . '|' . $bare . ')'; } private static function diagnosticNounTarget(string $target): bool { // A patient predicate, question or record-state clause cannot borrow // a diagnostic noun's role, even when followed by its usual suffix. return preg_match('/患者|本例|本次|当前|目前|主诉|自述|诉称|诉说|反映|诊见|查见|有无|是否|没有|未见|否认|不伴|无|存在|出现|发生|伴有|属于|属(?!性)|为|见|呈|已|需|须|应|请|若|如果|并|且|但|而|的/u', $target) !== 1; } private static function quotes(string $line): array { preg_match_all(self::QUOTE, $line, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); $entries = []; foreach ($matches as $match) { $quote = ''; foreach (array_slice($match, 1) as $capture) if ($capture[1] >= 0) { $quote = $capture[0]; break; } $start = $match[0][1]; $end = $start + strlen($match[0][0]); $ids = []; if (preg_match('/^\s*(?:((s[1-9][0-9]*(?:\s*[,,、]\s*s[1-9][0-9]*)*))|\((s[1-9][0-9]*(?:\s*[,,、]\s*s[1-9][0-9]*)*)\))/u', substr($line, $end), $citation)) { $ids = preg_split('/\s*[,,、]\s*/u', ($citation[1] ?? '') !== '' ? $citation[1] : ($citation[2] ?? '')) ?: []; $end += strlen($citation[0]); } $entries[] = ['start' => $start, 'end' => $end, 'quote' => $quote, 'ids' => array_values(array_unique($ids))]; } return $entries; } private static function recordedRole(array $index, string $role): bool { foreach ($index['sources'] as $source) { if ($source['scope'] !== 'current') continue; foreach ($source['spans'] as $span) if ($span['role'] === $role && $span['recorded']) return true; } return false; } /** Punctuation-bounded excerpts cannot drop a preceding negation/qualifier/list head. */ private static function sourceQuote(string $source, string $quote): bool { if ($quote === '' || trim($quote) !== $quote || !preg_match('/[\p{L}\p{N}]/u', $quote)) return false; $offset = 0; while (($position = strpos($source, $quote, $offset)) !== false) { $left = substr($source, 0, $position); $right = substr($source, $position + strlen($quote)); if (!preg_match('/(?:否认|未见|没有|不伴|无)[,,::]\s*$/u', $left) && ($left === '' || preg_match('/[。;;\r\n]\s*$/u', $left)) && ($right === '' || preg_match('/[。;;\r\n]$/u', $quote) || preg_match('/^\s*[。;;\r\n]/u', $right))) return true; $offset = $position + 1; } return false; } private static function dates(string $text): array { preg_match_all('/(?]\s+)/u', '', $line) ?? $line); } private static function section(string $line): ?int { $line = preg_replace('/^(?:第\s*[一二三四五六0-9]+\s*(?:节|章|部分)|[((]?[一二三四五六0-9]+[))\..、])\s*[、::]?\s*/u', '', $line) ?? $line; $line = preg_replace('/[::\s]+$/u', '', $line) ?? $line; $patterns = [1 => '/^(?:四诊(?:采集|资料|信息|记录|整理|分析|合参|情况)?|望闻问切)$/u', 2 => '/^八纲(?:四对|辨证|分析|判断|归纳)?$/u', 3 => '/^气血[、,,\/]?津液(?:及|与|和|、|\/)?脏腑(?:定位|辨证|分析|归纳)?$/u', 4 => '/^(?:(?:中医)?证名|证候|证名与证候|辨证结论|证候结论|辨证证名)(?:及辨证结论)?$/u', 5 => '/^(?:(?:对应)?治法|治则(?:与|及)?治法|治则)(?:方向|与治疗原则|与治则|及治则)?$/u', 6 => '/^(?:(?:中药)?方药(?:方向|建议|选择|思路)?|中药方向|中药)(?:(?:与|及|和)(?:复核(?:与|及)?监测|监测|复核|用药复核|安全监测|复核要点))?$/u']; foreach ($patterns as $id => $pattern) if (preg_match($pattern, $line)) return $id; return null; } private static function issue(array &$issues, string $code, string $id = 'report'): void { $issues[$code . ':' . $id] = ['code' => $code, 'source_id' => $id]; } }