"""审批响应解析器 — 解析审批人回复文本中的批准/拒绝信号""" 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