ForcePilot/backend/package/yuxi/channel/approval/parser.py

230 lines
7.9 KiB
Python
Raw Normal View History

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