230 lines
7.9 KiB
Python
230 lines
7.9 KiB
Python
"""审批响应解析器 — 解析审批人回复文本中的批准/拒绝信号"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from dataclasses import dataclass, field
|
||
from typing import Any
|
||
|
||
from yuxi.channel.protocols import ApprovalDecision
|
||
|
||
APPROVE_PATTERNS = [
|
||
re.compile(r"^批准|^同意|^通过|^approve|^yes|^ok|^可以|^确认"),
|
||
re.compile(r"批准|同意|通过|approve|yes|ok", re.IGNORECASE),
|
||
]
|
||
|
||
DENY_PATTERNS = [
|
||
re.compile(r"^拒绝|^驳回|^deny|^no(?!t)\b|^不行"),
|
||
re.compile(r"拒绝|驳回|deny|\bno(?!t)\b", re.IGNORECASE),
|
||
]
|
||
|
||
# 当 "no" 后面紧跟这些词时,视为否定表达而非拒绝信号
|
||
_NO_EXCLUDE_SUFFIX = re.compile(r"\s+(?:approve|agree|ok|yes|pass|可以|批准|同意|通过)", re.IGNORECASE)
|
||
|
||
CANCEL_PATTERNS = [
|
||
re.compile(r"^取消|^撤销|^cancel|^撤回"),
|
||
re.compile(r"取消|cancel", re.IGNORECASE),
|
||
]
|
||
|
||
# 否定词模式 — 要求紧邻匹配关键词前(允许可选空白)
|
||
# 包括独立的否定词 + 中文复合否定词(不能、不会、没法、无法等)
|
||
_NEGATION_PATTERNS = [
|
||
re.compile(r"不\s*$"),
|
||
re.compile(r"没\s*$"),
|
||
re.compile(r"未\s*$"),
|
||
re.compile(r"别\s*$"),
|
||
re.compile(r"勿\s*$"),
|
||
re.compile(r"(?:不能|不会|不要|不可|不准|不想|不愿|不敢|不必|不用)\s*$"),
|
||
re.compile(r"(?:没法|没有|无法)\s*$"),
|
||
re.compile(r"(?:不可以|不应该|不可能|不允许|不准许|不值得)\s*$"),
|
||
re.compile(r"non[-\s]?$", re.IGNORECASE),
|
||
re.compile(r"not\s*$", re.IGNORECASE),
|
||
re.compile(r"no\s*$", re.IGNORECASE),
|
||
re.compile(r"never\s*$", re.IGNORECASE),
|
||
re.compile(r"don'?t\s*$", re.IGNORECASE),
|
||
re.compile(r"doesn'?t\s*$", re.IGNORECASE),
|
||
re.compile(r"didn'?t\s*$", re.IGNORECASE),
|
||
re.compile(r"won'?t\s*$", re.IGNORECASE),
|
||
re.compile(r"wouldn'?t\s*$", re.IGNORECASE),
|
||
re.compile(r"can'?t\s*$", re.IGNORECASE),
|
||
re.compile(r"couldn'?t\s*$", re.IGNORECASE),
|
||
re.compile(r"shouldn'?t\s*$", re.IGNORECASE),
|
||
]
|
||
|
||
# 转折/让步词 — 当这些词出现在批准关键词之后时,降低置信度或视为拒绝
|
||
_CONCESSION_PATTERNS = [
|
||
re.compile(r"[,,。;\n]\s*但是"),
|
||
re.compile(r"[,,。;\n]\s*但"),
|
||
re.compile(r"[,,。;\n]\s*不过"),
|
||
re.compile(r"[,,。;\n]\s*然而"),
|
||
re.compile(r"[,,。;\n]\s*只是"),
|
||
re.compile(r"[,,。;\n]\s*except"),
|
||
re.compile(r"[,,。;\n]\s*but"),
|
||
re.compile(r"[,,。;\n]\s*however"),
|
||
]
|
||
|
||
|
||
def _is_negated(text: str, match_start: int) -> bool:
|
||
"""检查匹配位置前是否紧邻否定词。
|
||
|
||
只检查匹配位置之前的有限文本片段(最多20个字符),
|
||
要求否定词紧邻关键词前(中间仅允许空白字符),
|
||
避免"不是不行"中"不行"被误判,也避免远距离否定词干扰。
|
||
同时避免否定词属于前一句子的情况。
|
||
"""
|
||
if match_start <= 0:
|
||
return False
|
||
window_start = max(0, match_start - 20)
|
||
prefix = text[window_start:match_start]
|
||
|
||
sentence_breaks = [". ", "。", "? ", "?", "! ", "!", "\n"]
|
||
for breaker in sentence_breaks:
|
||
idx = prefix.rfind(breaker)
|
||
if idx >= 0:
|
||
prefix = prefix[idx + len(breaker):]
|
||
|
||
for neg_pattern in _NEGATION_PATTERNS:
|
||
if neg_pattern.search(prefix):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _has_concession_after(text: str, match_end: int) -> bool:
|
||
"""检查匹配位置后是否出现转折/让步词。"""
|
||
if match_end >= len(text):
|
||
return False
|
||
suffix = text[match_end:]
|
||
for con_pattern in _CONCESSION_PATTERNS:
|
||
if con_pattern.search(suffix):
|
||
return True
|
||
return False
|
||
|
||
|
||
@dataclass
|
||
class ParseResult:
|
||
decision: ApprovalDecision | None
|
||
reason: str | None = None
|
||
confidence: float = 0.0
|
||
matched_pattern: str | None = None
|
||
|
||
|
||
@dataclass
|
||
class ApprovalContext:
|
||
channel_type: str
|
||
account_id: str
|
||
request_id: str
|
||
approver_id: str
|
||
reply_text: str
|
||
reply_at: float = 0.0
|
||
metadata: dict[str, Any] = field(default_factory=dict)
|
||
|
||
|
||
def parse_approval_reply(
|
||
reply_text: str,
|
||
*,
|
||
strict: bool = False,
|
||
) -> ParseResult:
|
||
"""解析审批回复文本,识别审批决定。
|
||
|
||
优先级: 批准 > 拒绝 > 取消
|
||
严格模式下仅匹配行首关键字(如「批准」「拒绝」)。
|
||
非严格模式下会检测否定词前缀,避免"不批准"被误判为批准。
|
||
"""
|
||
original = reply_text.strip()
|
||
cleaned = original.lower()
|
||
|
||
if strict:
|
||
for pattern in APPROVE_PATTERNS[:1]:
|
||
match = pattern.match(cleaned)
|
||
if match and not _is_negated(cleaned, match.start()):
|
||
if _has_concession_after(cleaned, match.end()):
|
||
return ParseResult(
|
||
decision=ApprovalDecision.DENIED,
|
||
confidence=0.75,
|
||
matched_pattern=pattern.pattern,
|
||
reason=original,
|
||
)
|
||
return ParseResult(
|
||
decision=ApprovalDecision.APPROVED,
|
||
confidence=0.95,
|
||
matched_pattern=pattern.pattern,
|
||
)
|
||
for pattern in DENY_PATTERNS[:1]:
|
||
match = pattern.match(cleaned)
|
||
if match and not _is_negated(cleaned, match.start()):
|
||
return ParseResult(
|
||
decision=ApprovalDecision.DENIED,
|
||
confidence=0.95,
|
||
matched_pattern=pattern.pattern,
|
||
reason=original,
|
||
)
|
||
for pattern in CANCEL_PATTERNS[:1]:
|
||
match = pattern.match(cleaned)
|
||
if match and not _is_negated(cleaned, match.start()):
|
||
return ParseResult(
|
||
decision=ApprovalDecision.CANCELLED,
|
||
confidence=0.95,
|
||
matched_pattern=pattern.pattern,
|
||
reason=original,
|
||
)
|
||
return ParseResult(decision=None, confidence=0.0)
|
||
|
||
for pattern in APPROVE_PATTERNS:
|
||
match = pattern.search(cleaned)
|
||
if match and not _is_negated(cleaned, match.start()):
|
||
if _has_concession_after(cleaned, match.end()):
|
||
return ParseResult(
|
||
decision=ApprovalDecision.DENIED,
|
||
confidence=0.70,
|
||
matched_pattern=pattern.pattern,
|
||
reason=original,
|
||
)
|
||
return ParseResult(
|
||
decision=ApprovalDecision.APPROVED,
|
||
confidence=0.85,
|
||
matched_pattern=pattern.pattern,
|
||
)
|
||
|
||
for pattern in DENY_PATTERNS:
|
||
match = pattern.search(cleaned)
|
||
if match and not _is_negated(cleaned, match.start()):
|
||
matched_text = match.group().lower()
|
||
if matched_text == "no" and _NO_EXCLUDE_SUFFIX.match(cleaned[match.end() :]):
|
||
continue
|
||
return ParseResult(
|
||
decision=ApprovalDecision.DENIED,
|
||
confidence=0.85,
|
||
matched_pattern=pattern.pattern,
|
||
reason=original,
|
||
)
|
||
|
||
for pattern in CANCEL_PATTERNS:
|
||
match = pattern.search(cleaned)
|
||
if match and not _is_negated(cleaned, match.start()):
|
||
return ParseResult(
|
||
decision=ApprovalDecision.CANCELLED,
|
||
confidence=0.70,
|
||
matched_pattern=pattern.pattern,
|
||
reason="审批人取消",
|
||
)
|
||
|
||
return ParseResult(decision=None, confidence=0.0)
|
||
|
||
|
||
async def parse_channel_approval_reply(
|
||
channel_type: str,
|
||
reply_text: str,
|
||
*,
|
||
strict: bool = False,
|
||
) -> ParseResult:
|
||
"""渠道感知的审批回复解析。
|
||
|
||
各渠道可自定义解析逻辑。当前统一使用文本模式匹配,
|
||
预留渠道扩展点。
|
||
"""
|
||
result = parse_approval_reply(reply_text, strict=strict)
|
||
if result.decision is not None:
|
||
return result
|
||
|
||
return result
|