PMFlow/backend/app/services/feishu_integration_service/mixin_reply_guard.py

199 lines
7.3 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
from .shared import *
class FeishuReplyGuardMixin:
@staticmethod
def _detect_forbidden_execute_keyword(text: Optional[str]) -> Optional[str]:
lowered = str(text or "").strip().lower()
if not lowered:
return None
forbidden = (
"curl",
"调用方式",
"接口教程",
"api 调用",
"请求示例",
"postman",
"sdk 调用",
)
for token in forbidden:
if token in lowered:
return token
return None
@staticmethod
def _detect_forbidden_qa_keyword(text: Optional[str]) -> Optional[str]:
lowered = str(text or "").strip().lower()
if not lowered:
return None
forbidden = (
"模型失败了所以没分析",
"让我直接处理",
"直接操作数据库",
"先检查数据库",
"检查数据库迁移",
"数据库迁移状态",
"alembic",
"ddl",
"alter table",
"drop table",
"create table",
"truncate table",
)
for token in forbidden:
if token in lowered:
return token
return None
@classmethod
def _contains_forbidden_execute_keywords(cls, text: Optional[str]) -> bool:
return cls._detect_forbidden_execute_keyword(text) is not None
def _guard_execute_reply_text_with_reason(
self,
text: Optional[str],
*,
trace_id: str,
) -> tuple[Optional[str], Optional[str]]:
if not text:
return text, None
hit = self._detect_forbidden_execute_keyword(text)
if not hit:
return text, None
self._emit_state_transition(
trace_id=trace_id,
state="ROUTED",
route="execute_template_guard",
reason="forbidden_keyword_detected",
keyword=hit,
)
return (
"已执行完成并入库。如需我继续动作,请直接补充目标、对象和时限。",
hit,
)
def _guard_execute_reply_text(self, text: Optional[str], *, trace_id: str) -> Optional[str]:
sanitized, _ = self._guard_execute_reply_text_with_reason(text=text, trace_id=trace_id)
return sanitized
def _guard_qa_reply_text_with_reason(
self,
text: Optional[str],
*,
trace_id: str,
capture_result: Dict[str, Any],
) -> tuple[Optional[str], Optional[str]]:
if not text:
return text, None
hit = self._detect_forbidden_qa_keyword(text)
if not hit:
return text, None
self._emit_state_transition(
trace_id=trace_id,
state="ROUTED",
route="qa_llm_guard",
reason="forbidden_keyword_detected",
keyword=hit,
)
return self._fallback_reply_text(capture_result), hit
async def _record_execute_guard_alert(
self,
*,
trace_id: str,
message_id: str,
message_content: str,
capture_result: Dict[str, Any],
guard_reason: str,
) -> None:
try:
async with AsyncSessionLocal() as db:
db.add(
AIProcessingLog(
tenant_id=int(self.tenant_id or 1),
feishu_message_id=(message_id or "")[:128] or None,
feishu_item_id=None,
raw_message=(message_content or "")[:8000] or None,
ai_raw_output=json.dumps(
{
"trace_id": trace_id,
"guard_reason": guard_reason,
"status": capture_result.get("status"),
"classification": capture_result.get("classification"),
},
ensure_ascii=False,
)[:120000],
project_score=None,
signal_confidence=None,
level="execute_guard",
routing_decision="execute_reply_guard_triggered",
routing_reason=f"执行态回复命中禁词({guard_reason}),已切换模板模式。",
reasoning_bullets=[
"执行态不允许输出调用教程类文本。",
"本次回复已被熔断为固定模板。",
f"trace_id={trace_id}",
],
processing_status="success",
error_message=None,
duration_ms=0,
)
)
recipients = (
await db.execute(
select(User.id).where(
and_(
User.tenant_id == int(self.tenant_id or 1),
User.is_active.is_(True),
)
).limit(5)
)
).scalars().all()
await NotificationService(db).create_notifications(
tenant_id=int(self.tenant_id or 1),
user_ids=[int(uid) for uid in recipients],
category="alert",
title="执行态回复熔断已触发",
content=(
f"trace_id={trace_id}\n"
f"message_id={message_id}\n"
f"原因:命中禁词 {guard_reason}\n"
"系统已自动切换为固定模板回复。"
)[:2000],
source="feishu_guard",
entity_type="feishu_message",
entity_id=None,
target_url="/ai-logs",
commit=False,
)
await db.commit()
except Exception as exc:
print(f"[Feishu] execute guard alert write failed: trace_id={trace_id}, error={exc}")
@staticmethod
def _is_duplicate_capture_result(result: Dict[str, Any]) -> bool:
if not isinstance(result, dict):
return False
status = str(result.get("status") or "").strip().lower()
if status != "ignored":
return False
reason = str(result.get("reason") or "").strip().lower()
classification = str(result.get("classification") or "").strip().lower()
return reason == "duplicate_filtered" or classification == "duplicate"
@staticmethod
def _should_reply_to_mention(*, mentioned_bot_names: List[str], message_content: Optional[str]) -> bool:
# 仅在明确 @PMFlow(而不是 @all / @其他成员)时触发自动回复
normalized_names = {str(x or "").strip().lower() for x in mentioned_bot_names if str(x or "").strip()}
if any(name in {"pmflow", "_user_1"} for name in normalized_names):
return True
# 兼容文本中的 @ 语法(不同消息类型返回格式不一致)
text = str(message_content or "").strip().lower()
if not text:
return False
if "@pmflow" in text or "@_user_1" in text:
return True
return False