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

104 lines
7.5 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;
/** 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;
}
}