104 lines
7.5 KiB
PHP
104 lines
7.5 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
namespace app\adminapi\service;
|
||
|
||
/** Detect explicit self-contradiction only; never selects or certifies a formula. */
|
||
final class TcmHerbalPremiseGuard
|
||
{
|
||
// Pronoun/metanoun endings are references, not concrete formula names.
|
||
private const NAMED_FORMULA = '(?:[\p{Han}]{2,16}(?:汤|散|丸|饮|煎|膏|丹)|[\p{Han}]{2,16}(?<![该此旧原主处])(?<!上述|前述)方)(?!向|证|药|案|法)';
|
||
|
||
public static function validate(string $report, array $ledger = []): array
|
||
{
|
||
$text = self::withoutVerifiedAttributedQuotes($report, $ledger);
|
||
$proposal = self::hasPostfixCurrentProposal($report);
|
||
$admission = false;
|
||
foreach (preg_split('/[。;;\r\n]+|[,,](?=(?:但|而|本次|当前|现拟))/u', $text) ?: [] as $clause) {
|
||
$clause = trim($clause);
|
||
if ($clause === '') continue;
|
||
$historical = preg_match('/^(?:历史|既往|旧方|当年|教材|古籍|文献|一般|示例|假设)/u', $clause)
|
||
&& !preg_match('/(?:本次|当前|现拟|现建议).{0,16}(?:考虑|推荐|采用|选用|拟用)/u', $clause);
|
||
if ($historical || preg_match('/^(?:若|如果|假如|一旦)|(?:并非|不是|不意味着|不表示).{0,16}(?:关键|核心)|是否[^。;]{0,24}(?:关键|核心)/u', $clause)) continue;
|
||
$deferred = preg_match('/(?:暂不(?:列方|选方|用方|考虑|采用|推荐)|不予(?:推荐|建议|采用)|不作(?:为)?(?:本次|当前)?(?:推荐|建议|主方|处方)|不列(?:具体)?(?:方名|主方|方药)|不(?:推荐|建议|考虑|选方|选用|采用)|仅作历史|早已停用|不代表本次)/u', $clause);
|
||
$named = self::NAMED_FORMULA;
|
||
if (!$deferred && (preg_match('/(?:主方(?:方向)?|候选方|方药方向|拟用方)[::]\s*[^。;\n]{0,36}' . $named . '/u', $clause)
|
||
|| preg_match('/(?:考虑|推荐|建议|采用|选用|拟用|方选)\s*(?:医师)?[^。;\n]{0,12}' . $named . '/u', $clause)
|
||
|| preg_match('/(?:拟用药味|新增药味|药味|拟用中药|拟用饮片|主药)[::]\s*[\p{Han}]{1,8}(?=[、,,。]|$)/u', $clause))) $proposal = true;
|
||
// Ordinary safety checks and uncertain differential features are
|
||
// not an admission of missing essential positive formula evidence.
|
||
$key = '(?:(?:关键|核心)(?:的)?(?:阳性)?(?:方证|辨证(?:支持)?|选方|用方)(?:证据|依据|条件)?|(?:本方|该方|本次辨证|主要辨证)[^。;\n]{0,8}(?:关键|核心)(?:支持)?(?:证据|依据|条件))';
|
||
$missing = '(?:不足|缺乏|缺失|缺少|尚缺|尚未(?:确立|证实|满足|具备)|未(?:确立|证实|满足|具备))';
|
||
if (preg_match('/' . $key . '[^。;\n]{0,16}' . $missing . '|' . $missing . '[^。;\n]{0,12}' . $key . '/u', $clause)) $admission = true;
|
||
}
|
||
$issues = $proposal && $admission ? [['code' => 'HERBAL_PREMISE_SELF_CONFLICT', 'source_id' => 'report']] : [];
|
||
return ['valid' => $issues === [], 'issues' => $issues];
|
||
}
|
||
|
||
private static function hasPostfixCurrentProposal(string $report): bool
|
||
{
|
||
// Keep quoted names as possible antecedents, but search only outside
|
||
// quotation marks for a new recommendation. Source attribution does
|
||
// not extend over a later current-use operator. Bound references to
|
||
// the same sentence or the immediately following explicit 该方/旧方.
|
||
$outside = preg_replace_callback('/“[^”]*”|「[^」]*」|『[^』]*』|"[^"]*"/su',
|
||
static fn (array $m): string => str_repeat(' ', strlen($m[0])), $report) ?? $report;
|
||
preg_match_all('/[^。;;\r\n]+/u', $outside, $sentences, PREG_OFFSET_CAPTURE);
|
||
$action = '(?:沿用|续用|启用|采用|选用|使用|(?:列为|作为)(?:本次|当前)?(?:主方|候选方))';
|
||
$operator = '/(?:(?:本次|此次)\s*(?:拟|决定)?\s*(?:沿用|续用|启用)'
|
||
. '|(?:建议|推荐|考虑|拟|决定)\s*(?:可|继续|仍)?\s*(?:' . $action . '|服用)'
|
||
. '|(?:列为|作为)(?:本次|当前)(?:主方|候选方)'
|
||
. '|(?:本次|当前|此次|现)\s*(?:予以)?推荐)/u';
|
||
$previousName = false;
|
||
foreach ($sentences[0] as [$sentence, $start]) {
|
||
$raw = substr($report, $start, strlen($sentence));
|
||
preg_match_all('/' . self::NAMED_FORMULA . '/u', $raw, $names, PREG_OFFSET_CAPTURE);
|
||
preg_match_all($operator, $sentence, $operators, PREG_OFFSET_CAPTURE);
|
||
foreach ($operators[0] as [$op, $position]) {
|
||
$before = mb_substr(substr($sentence, 0, $position), -16, null, 'UTF-8');
|
||
$after = mb_substr(substr($sentence, $position + strlen($op)), 0, 12, 'UTF-8');
|
||
if (preg_match('/(?:不|勿|未|没有|禁止|避免|暂缓|无需|不能|不宜|不应|是否|能否|可否)\s*$/u', $before)
|
||
|| preg_match('/^\s*(?:不|勿|禁止|暂不|仅作历史)/u', $after)) continue;
|
||
foreach ($names[0] as [$name, $nameStart]) {
|
||
$end = $nameStart + strlen($name);
|
||
if ($end <= $position && mb_strlen(substr($raw, $end, $position - $end), 'UTF-8') <= 64) return true;
|
||
}
|
||
if ($previousName && mb_strlen($sentence, 'UTF-8') <= 80
|
||
&& preg_match('/(?:该|此|旧|上述|前述|原)(?:旧)?方(?:剂)?/u', $sentence)) return true;
|
||
}
|
||
$previousName = preg_match('/' . self::NAMED_FORMULA . '/u', mb_substr($raw, -80, null, 'UTF-8')) === 1;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
private static function withoutVerifiedAttributedQuotes(string $report, array $ledger): string
|
||
{
|
||
// A citation is not provenance. Check every cited source and exact
|
||
// quotation, then require attribution outside it. Even a genuine old
|
||
// formula name remains a current proposal under an outside operator.
|
||
$pattern = '/(?:“([^”]*)”|「([^」]*)」|『([^』]*)』|"([^"]*)")\s*[((]\s*(s[1-9][0-9]*(?:\s*[、,,]\s*s[1-9][0-9]*)*)\s*[))]/su';
|
||
preg_match_all($pattern, $report, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE | PREG_UNMATCHED_AS_NULL);
|
||
foreach (array_reverse($matches) as $match) {
|
||
$quote = '';
|
||
for ($i = 1; $i <= 4; ++$i) if ($match[$i][0] !== null) { $quote = $match[$i][0]; break; }
|
||
if ($quote === '') continue;
|
||
preg_match_all('/s[1-9][0-9]*/', $match[5][0], $ids);
|
||
$verified = $ids[0] !== [];
|
||
foreach ($ids[0] as $id) {
|
||
$source = $ledger['sources'][$id] ?? null;
|
||
if (!is_array($source) || ($source['id'] ?? '') !== $id
|
||
|| !is_string($source['quote'] ?? null) || !str_contains($source['quote'], $quote)) $verified = false;
|
||
}
|
||
if (!$verified) continue;
|
||
$before = substr($report, 0, $match[0][1]);
|
||
preg_match('/[^。;;\r\n]*$/u', $before, $context);
|
||
$outside = $context[0] ?? '';
|
||
$currentOperator = preg_match('/(?:主方(?:方向)?|候选方|方药方向|拟用方)[::]|(?:考虑|推荐|建议|采用|选用|拟用|方选)[^。;\n]{0,12}$/u', $outside);
|
||
$attributed = preg_match('/(?:来源|原文|记录|资料|历史|既往|旧方|当年|望诊|闻诊|问诊|切诊|舌象|脉象)/u', $outside);
|
||
if ($currentOperator || !$attributed) continue;
|
||
$report = substr_replace($report, '', $match[0][1], strlen($match[0][0]));
|
||
}
|
||
return $report;
|
||
}
|
||
}
|