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
|