ForcePilot/backend/package/yuxi/channel/approval/parser.py
Kris 79ee930957 feat(approval): 新增审批流程核心模块
实现了完整的审批功能链路,包含审批请求投递、模板渲染、回复解析、引擎管理以及原生交互式卡片支持
2026-05-21 10:22:45 +08:00

230 lines
7.9 KiB
Python
Raw 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.

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