505 lines
32 KiB
PHP
505 lines
32 KiB
PHP
<?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];
|
||
}
|
||
}
|