Files
zyt/server/app/adminapi/service/TcmClinicalEvidenceContract.php
T
2026-10-27 14:03:35 +08:00

505 lines
32 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\adminapi\service;
/** Constrained source/role protocol. Passing does not establish clinical sufficiency. */
final class TcmClinicalEvidenceContract
{
public const VERSION = 'tcm-clinical-evidence-protocol-pulse-excluded-r36';
private const DENIAL = '/没有|未见|否认|不伴|无(?!力|法|论|须)/u';
private const DATE = '(?:19|20)[0-9]{2}(?:-[0-9]{1,2}-[0-9]{1,2}|年[0-9]{1,2}月[0-9]{1,2}日?)';
private const QUOTE = '/“([^”\r\n]*)”|「([^」\r\n]*)」|"([^"\r\n]*)"/u';
public static function validate(array $index, string $report, bool $complete = false): array
{
$issues = [];
if (($index['version'] ?? '') !== TcmClinicalEvidenceIndex::VERSION || empty($index['valid'])
|| !is_array($index['sources'] ?? null)) {
return ['valid' => 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('/(?<boundary>^|[。;;])\s*(?<frame>适用条件[::]\s*复核症状仍以'
. '(?<target>[\p{L}\p{N}、及和或的]{1,100})为主[,,]'
. '(?:(?<persist>[\p{L}\p{N}、及和或的]{1,100})持续存在[,,])?)\s*且(?<operator>无)需要优先处理的急性病因'
. '(?=$|[,,。;;!?!?]|并且|并|同时|还|又|另|且|但|而|患者|本例|自述|主诉|诉称|诉说|述及|反映|报告|诊见|查见)/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>' . $frame . ')(?<lead>' . $lead . ')(?<operator>没有|无)'
. '(?<object>' . $object . ')(?<close>时\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('/(?<boundary>^|[,,。;;])\s*' . $head . '(?<category>表|里|寒|热|虚|实|阴|阳)(?:证候|证型|证)'
. '(?:亦|也|尚|仍|均|并){0,2}(?:没有|无)' . $modifier . '\k<category>象(?:的?(?:证据|依据))?'
. '(?=$|[,,。;;!?!?]|且|但|而)/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('/(?<boundary>^|[,,。;;])\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 = '(?:((?<ids>s[1-9][0-9]*(?:[、,,]s[1-9][0-9]*)*)))?';
$text = preg_replace_callback('/(?<boundary>^|[,,。;;])\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('/(?<boundary>^|[,,。;;])\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('/(?<!有)(?:没有|未见|无)' . $adjective . '{0,8}' . $sufficiency . $target . '(?:的)?' . $adjective . '{0,8}(?:定位|辨证|诊断|分型)?(?:证据|依据)'
. '(?=$|[,,。;;!?!?]|支持|证明|证实|表明|说明|确认|确定|确立|且|但|而)/u',
static function (array $match): string {
if (!self::diagnosticNounTarget($match[1])) return $match[0];
return str_repeat(' ', strlen($match[0]));
}, $text) ?? $text;
}
/** Withholding a method can qualify its evidence, never erase its factual object. */
private static function withheldMethodEvidenceAbsence(string $text): string
{
$method = '(?:(?=\p{L})\p{Han}){1,12}';
$list = $method . '(?:[ \t]*(?:、|以及|及|与|和|或)[ \t]*' . $method . '){0,7}';
$modifier = '(?:完整|独立|充分|足够|明确|直接|可靠|相应|阳性){1,6}';
return preg_replace_callback('/(?<prefix>(?:^|[,,。;;])\s*(?:因此|所以|故|但|仍|尚|暂){0,2}'
. '(?:不(?:采用|选用)|不予(?:采用|选用))' . $list . '等(?:尚|仍)?)'
. '(?<operator>没有|无)(?<qualification>' . $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('/(?<![0-9])((?:19|20)[0-9]{2})(?:-([0-9]{1,2})-([0-9]{1,2})|年([0-9]{1,2})月([0-9]{1,2})日?)(?![0-9])/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);
}
return $dates;
}
private static function plainLine(string $line): string
{
$line = trim(str_replace(['**', '__', '`'], '', $line));
return trim(preg_replace('/^(?:#{1,6}\s*|[-*>]\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];
}
}