199 lines
7.3 KiB
Python
199 lines
7.3 KiB
Python
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
|
||
|