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

2379 lines
159 KiB
PHP
Raw Permalink 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;
/**
* 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) ?? '';
}
}